Place the Studio grouping map on main and record Q2-Q4 (Q5 overtaken)
The map carried only on codex/studio-grouping moves to main. The operator ruled Q2 A, Q3 A, Q4 A on 2026-09-30. Q5 is overtaken: one Studio release with variants C3. A refresh pass is due before Hands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
75501fbc06
commit
feeced8949
1 changed files with 720 additions and 0 deletions
720
docs/studio-grouping-cut.md
Normal file
720
docs/studio-grouping-cut.md
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
# Studio Record Grouping And Reference Drag-Drop Cut
|
||||
|
||||
Date: 2026-09-17 (Imagination pass)
|
||||
|
||||
**2026-09-30 (Self; the campaign was picked up by the Eureka session at the operator's request).**
|
||||
Operator rulings, each asked on its own: **Q2 A** (occupied values only), **Q3 A** (list only),
|
||||
**Q4 A** (no search in this campaign). Q1 was ruled B on 2026-09-17. **Q5 is overtaken:** the
|
||||
Studio release comes from `main` together with document-variants C3 (Studio edits the
|
||||
delta), as one `caching-unity` release. This is option A, and it takes the native-plugin drift.
|
||||
|
||||
**Context this map predates:**
|
||||
- `[CultIndex]` now means unique (operator, 2026-09-30); see `docs/document-variants-cut.md`.
|
||||
Grouping is this campaign's own attribute, and the operator named it as the missing
|
||||
DatabaseListView feature.
|
||||
- Variants C1 changed `CultInspectorModel` to take a `CultCodec` (merged at `6ffe17a`).
|
||||
- C2a (element ids everywhere) is in flight.
|
||||
|
||||
An Imagination refresh pass re-anchors every cut against current `main` before Hands. The
|
||||
map moved to `main` from `codex/studio-grouping`, which carried only this file.
|
||||
|
||||
Status: cut map, forks open. The operator rules on forks Q1-Q5 before Hands
|
||||
starts. Where a line depends on a fork, it names the fork and follows the
|
||||
recommendation. Line references are against CultLib `122c217` and Aetheria
|
||||
`515859cf` unless marked otherwise.
|
||||
|
||||
CultLib `main` moved during this pass, from `122c217` to `1845301`. The three
|
||||
new commits touch only `native/GameCult.Mesh.Quic.Native`,
|
||||
`scripts/mutate-cultmesh.mjs` and a QUIC doc. They were live uncommitted edits
|
||||
in the main checkout when the pass began. The Q5 audit extends to them
|
||||
unchanged. Another agent is actively working on `main`, so Hands works in its
|
||||
own worktree.
|
||||
|
||||
## Target
|
||||
|
||||
### Ends
|
||||
|
||||
1. **Declared record grouping.** An attribute on a document type's model
|
||||
declares that the type's records group by one or more member values. Studio's
|
||||
record list becomes a foldout tree for that type. Tree levels nest in the
|
||||
declared order. A declaration on a base type groups the records of every
|
||||
derived type listed under it.
|
||||
2. **Create in a group.** Every group node offers `Create`. The new record gets
|
||||
every grouped member on the path to that node set to that node's value, so
|
||||
it appears in that node.
|
||||
3. **Reference drag and drop.** You can drag a record row from Studio's record
|
||||
list onto any `CultRecordRef<T>` value the inspector draws (a member, a list
|
||||
element, a dictionary key or value). The drop is accepted exactly when the
|
||||
record is one the ref's picker would offer.
|
||||
|
||||
Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`,
|
||||
`Assets/Scripts/CultCache/Editor/DatabaseView.cs`, `AetheriaDatabaseView.cs`,
|
||||
`Inspectors/DatabaseLinkInspector.cs`). Declaring grouping by selector lambdas
|
||||
is rejected by operator ruling (2026-09-17): use attributes.
|
||||
|
||||
### Invariants
|
||||
|
||||
- **I1. Model owns grouping.** `CultInspectorModel` alone decides:
|
||||
- which members group a listed type, and in what order;
|
||||
- which declarations are invalid, and the notice for each;
|
||||
- the tree's partition, node identity, node order and node labels;
|
||||
- the object that create-in-group makes.
|
||||
The Studio lowers the tree and never reads the attribute, calls
|
||||
`SetValue` for a preset, or computes a label.
|
||||
- **I2. The leaves partition the candidates.** Across all leaves, the tree's
|
||||
records are exactly `RecordCandidates(CultRecordRef<listed>, records)`. Each
|
||||
record appears once, and records keep candidate order inside a leaf. Grouping
|
||||
never hides a record, including one whose grouped value is unset, dangling,
|
||||
or an undefined enum value.
|
||||
- **I3. The listed type owns the tree's shape.** Grouping comes from the type
|
||||
selected in the type pane, never from each record's runtime type. A
|
||||
`WeaponItemData` listed under `GearData` is grouped by `GearData`'s
|
||||
declaration.
|
||||
- **I4. Node identity is value identity, not the label.**
|
||||
- A `CultRecordRef` groups by key.
|
||||
- An enum groups by its underlying value.
|
||||
- Other kinds group by the invariant string of the value.
|
||||
So two factions with the same label are two nodes, and foldout state keys on
|
||||
the node id.
|
||||
- **I5. Create-in-group round-trips.** Upsert the object that
|
||||
`CreateInGroup(listed, node)` returns, then regroup: the new record lies in a
|
||||
node with the same id.
|
||||
- **I6. One candidate rule.** `IsRecordCandidate(refType, record)` is the only
|
||||
test for "this record may be this ref's value". `RecordCandidates` filters
|
||||
through it, and drop acceptance calls it. No `IsInstanceOfType` appears in
|
||||
`src/GameCult.Unity`.
|
||||
- **I7. One ref label.** The model owns the strings `None` and `Missing <key>`.
|
||||
The picker (`DrawRecordRef`) and group labels both use it.
|
||||
- **I8. Invalid declarations never break the window.** An invalid declaration
|
||||
gives a flat list plus the model's notice. It never throws in `OnGUI`.
|
||||
- **I9. A drop honours the same gates as the picker.** A drop is refused:
|
||||
- in a disabled (read-only) scope;
|
||||
- when the payload came from another store's model;
|
||||
- when the key equals the current key.
|
||||
A dictionary key drop still goes through `ReplaceKey` refusal, because
|
||||
`DrawDictionary` already routes every key change there
|
||||
(`CultCacheStudioDrawers.cs:243-251`).
|
||||
|
||||
### Non-consumers and non-goals
|
||||
|
||||
- **Sibling runtimes.** There is no port of the inspector model or the
|
||||
inspector attributes.
|
||||
- `packages/cultcache-ts/src/cult-cache-inspector.ts` is a raw
|
||||
`.cc` byte and catalog dumper (`inspectCultCacheBytes`). It has no members,
|
||||
metadata, candidates or grouping.
|
||||
- Rust and Python have neither.
|
||||
No parity work is needed.
|
||||
- **Wire and catalog.** Attributes are metadata only. They change no schema id,
|
||||
catalog entry or store byte.
|
||||
- **Runtime CultUI panel.** This cut adds none. The model's API is shaped so one
|
||||
could lower it.
|
||||
- **Not in scope:** record search (Q4), grouping in the ref picker popup (Q3),
|
||||
drop-to-append on list headers (the legacy `DatabaseLinkListInspector`), and
|
||||
the legacy "entry name `New <Type>`" on create.
|
||||
|
||||
## Body findings that shape the cut
|
||||
|
||||
Established by reading and by probes in scratch (`probe/`, `studio-compile/`):
|
||||
|
||||
- **F1. Legacy grouping by gear hardpoint read a derived property.**
|
||||
- Legacy `GearData` grouping read `HardpointType` and wrote `Hardpoint`.
|
||||
- `EquippableItemData.HardpointType` (`ItemData.cs:401-402`) is an abstract
|
||||
`[IgnoreMember]` property. `GearData.HardpointType => Hardpoint`
|
||||
(`:455`), `CargoBayData` returns `Tool` (`:464`) and `HullData` returns
|
||||
`Hull` (`:533`).
|
||||
- The stored member is `GearData.Hardpoint` (slot 23, `:453`).
|
||||
- The model's member list for `GearData` and `WeaponItemData` includes
|
||||
`Hardpoint#23@GearData`, confirmed by probe. `HardpointType` is not an
|
||||
inspector member.
|
||||
- The declaration names `Hardpoint`. The overriding types have no stored
|
||||
hardpoint, so they have nothing to group.
|
||||
- **F2. `Manufacturer` is declared on the abstract `ItemData`** (`ItemData.cs:281`),
|
||||
not on `GearData`. Probe of `GameData/Aetheria.cc` (a copy):
|
||||
|
||||
| Listed type | Records | Manufacturer set | Manufacturer unset |
|
||||
|---|---|---|---|
|
||||
| `GearData` (includes `WeaponItemData`) | 43 | 29 | 14 |
|
||||
| `HullData` | 3 | 3 | 0 |
|
||||
| `CargoBayData` (includes `DockingBayData`) | 5 | 5 | 0 |
|
||||
| `SimpleCommodityData` | 13 | 0 | 13 |
|
||||
| `CompoundCommodityData` | 51 | 0 | 51 |
|
||||
|
||||
`GearData` has 11 distinct manufacturers. A member attribute on
|
||||
`ItemData.Manufacturer` would therefore also wrap all 64 commodity records in
|
||||
one "None" level. Legacy grouped only `GearData` by manufacturer. This drives
|
||||
Q1.
|
||||
- **F3. Legacy grouping matched the exact type** (`CanGroup: typeof(T) == type`),
|
||||
and its tables listed exact types. So it never grouped `WeaponItemData`
|
||||
records. Grouping derived records under a base declaration is new behaviour,
|
||||
as the brief asks.
|
||||
- **F4. Legacy create-in-group was dead.** `DatabaseEntryGroup<T,K>.Activate`
|
||||
(`DatabaseView.cs:70-75`) throws after invoking. Legacy groups also existed
|
||||
only when occupied (`GroupBy`), and foldouts keyed on the label's hash, so
|
||||
groups with the same label collapsed together.
|
||||
- **F5. Existing metadata misses inherited attributes on overriding properties.**
|
||||
- `CultInspectorMetadata` reads `member.GetCustomAttributes(true)`
|
||||
(`CultInspectorModel.cs:36`), and `CultInspectorDrawerClaims.Resolve` reads
|
||||
it too (`:209`).
|
||||
- For a `PropertyInfo`, that overload ignores `inherit`. Probe: a base
|
||||
`[M] abstract int P` is not seen on the override.
|
||||
`Attribute.GetCustomAttributes(member, true)` does see it.
|
||||
- Fields are unaffected. This is a latent defect in every `Inherited = true`
|
||||
inspector attribute.
|
||||
- **F6. Studio `Add` creates only the listed type**
|
||||
(`CultCacheStudioWindow.cs:340-347`: `CreateElement(type, type)`), and is
|
||||
disabled when the model cannot create it (`:141`).
|
||||
- Create-in-group therefore never makes a derived type, and every grouped
|
||||
member exists on the created object.
|
||||
- "A preset value is invalid for the created derived type" cannot arise, so it
|
||||
is recorded as a default, not a fork.
|
||||
- An abstract listed type offers no `Create`.
|
||||
- **F7. Studio search filters the type pane only** (`:112-120`). No record
|
||||
search exists.
|
||||
- **F8. Studio foldouts are not persisted.** The inspector keeps foldouts in an
|
||||
in-memory dictionary (`CultCacheStudioDrawers.cs:33`). The window closes its
|
||||
store on `OnDisable` (`:48-51`), which includes a domain reload. The only
|
||||
`EditorPrefs` key is `LastPathKey` (`:13`).
|
||||
- **F9. No drawer claims `CultRecordRef<>`.** This was checked in CultLib Studio
|
||||
and in Aetheria `HEAD:Assets/Scripts/Editor/CultCacheDrawers.cs`. So every
|
||||
ref value reaches `DrawRecordRef` (`:195-221`), and the drop target added
|
||||
there covers all of them. A future claimed ref drawer would bypass it, which
|
||||
is acceptable: a claim owns its widget.
|
||||
- **F10. The Studio editor sources compile outside Unity.**
|
||||
- Probe: a netstandard2.1 / C# 9 project compiles
|
||||
`src/GameCult.Unity/Assets/Caching/Editor/*.cs` with 0 errors against:
|
||||
- `Editor/Data/Managed/UnityEngine/*.dll` from Unity 6000.3.24f1 (the
|
||||
`UnityEditor.dll` facade must be left out, because it duplicates the
|
||||
module types);
|
||||
- the tracked `GameCult.Caching*.dll` and `MessagePack*.dll`.
|
||||
- This gives a pre-tag compile gate. The last migration only had
|
||||
post-release Aetheria batchmode.
|
||||
- **F11. The Studio source row reads its click in `GUILayout.Toggle`**
|
||||
(`:184`). IMGUI button-like controls take `MouseDrag` while they are the hot
|
||||
control, so the drag start must be handled on a reserved rect *before* the
|
||||
toggle draws. This comes from Unity's IMGUI convention and was not probed
|
||||
here; the operator click-through proves it.
|
||||
|
||||
## Identity, lifecycle and authority
|
||||
|
||||
| Thing | Identity | Lifecycle | Owner (decides) | Readers | Persisted? |
|
||||
|---|---|---|---|---|---|
|
||||
| Grouping declaration | Attribute on a document class (Q1-B) or member (Q1-A), in the consumer's engine-free assembly | Compile time; inherited per `AttributeUsage` | Consumer source (Aetheria `ServerShared`) | `CultInspectorModel` only | Source code; no store bytes |
|
||||
| Resolved grouping (`CultInspectorGrouping`) | Listed `Type` | Cached per model instance, like `_shapes` (`CultInspectorModel.cs:298-299`); dies with the model at store close | `CultInspectorModel` | Studio window | No |
|
||||
| Group tree (`CultInspectorRecordGroup`) | Root per (listed type, records snapshot) | Rebuilt each `OnGUI`, as `RecordCandidates` already is (`CultCacheStudioWindow.cs:167`) | `CultInspectorModel` | Studio window | No |
|
||||
| Node id | `<listed schema name>` + `/` + per-level value identity (I4) | Stable while the values exist | `CultInspectorModel` | Studio foldout map | No |
|
||||
| Node label | Enum name, `RecordLabel`, `None`, `Missing <key>`, invariant string, `(empty)` | Recomputed with the tree | `CultInspectorModel` | Studio | No |
|
||||
| Group foldout state | Node id → bool | Window instance memory; cleared by `CloseStore`; lost on domain reload, which already closes the store (F8) | `CultCacheStudioWindow` | Same | No (default; see Defaults) |
|
||||
| Invalid-declaration notice | Listed `Type` | With the resolved grouping | `CultInspectorModel` | Studio shows a warning HelpBox above the list | No |
|
||||
| Created-in-group object | New document | Made by the model, upserted by the window through `_cache.UpsertAsync` exactly as `Add` does | Model makes it; `CultCache` admits it | Window selects the key and expands its path | Store, on `Save` |
|
||||
| Drag payload | `CultCacheStudioRecordDrag { CultInspectorModel Model; string Key }` under generic data key `"GameCult.CultCacheStudio.Record"` | One drag gesture (Unity `DragAndDrop`) | Studio record list (source) | `CultInspector.DrawRecordRef` (target) | No |
|
||||
| Drop acceptance | (ref type, record) | Per `DragUpdated` / `DragPerform` | `CultInspectorModel.IsRecordCandidate` plus the I9 gates | Studio | No |
|
||||
|
||||
No persistent state is introduced. No cell is empty.
|
||||
|
||||
## Forks
|
||||
|
||||
### Q1. Where does the grouping declaration live, and how is nesting ordered? (blocks Cuts 1, 4)
|
||||
|
||||
**Ruled B (operator, 2026-09-17):** "I like B". The same ruling reopens the item
|
||||
schema: a crafted item's manufacturer belongs to its production lot, not its
|
||||
design (see Cut 4).
|
||||
|
||||
|
||||
- **A. Member attribute** `[CultInspectorGroup(int order = 0)]` on a field or
|
||||
property.
|
||||
- Levels are ordered by `order`, then by the member's display order
|
||||
(`Metadata.Order ?? Slot`).
|
||||
- Inheritance comes free.
|
||||
- The member can't be scoped: on `ItemData.Manufacturer`, it groups every item
|
||||
type by manufacturer, and all 64 commodity records sit under one `None`
|
||||
level (F2).
|
||||
- Ordering spreads across files: `Hardpoint` on `GearData` with `order: 0`,
|
||||
`Manufacturer` on `ItemData` with `order: 1`.
|
||||
- **B. Type attribute** `[CultInspectorGroupBy(nameof(Hardpoint), nameof(Manufacturer))]`
|
||||
on a document class. `AttributeUsage(Class, Inherited = true,
|
||||
AllowMultiple = false)`.
|
||||
- The argument order is the nesting order.
|
||||
- The nearest declaration wins, so a derived type re-declares, and
|
||||
`[CultInspectorGroupBy]` with no arguments opts out.
|
||||
- Names resolve against the listed type's inspector members, so inherited
|
||||
members work.
|
||||
- `nameof` keeps it refactor-safe, and it names data rather than code.
|
||||
- It scopes `Manufacturer` to `GearData` exactly as legacy did.
|
||||
- **C. Both forms.** Twice the surface, two ordering rules.
|
||||
- **Recommendation: B.**
|
||||
- It is the only form that recovers the legacy grouping without the
|
||||
commodity regression.
|
||||
- Its nesting order sits in one place, readable.
|
||||
- It is still an attribute, not a selector.
|
||||
- It departs from the brief's "member attribute" working name; that is why
|
||||
this is asked.
|
||||
- **Depends on it:**
|
||||
- Cut 1's attribute type and its resolution tests.
|
||||
- Cut 4's annotations: under B, four class attributes; under A, four member
|
||||
attributes plus accepting F2.
|
||||
|
||||
### Q2. Which group nodes exist? (blocks Cut 1)
|
||||
|
||||
- **A. Occupied values only** (legacy parity, F4).
|
||||
- Create into an empty category means creating at the root and then editing
|
||||
the member.
|
||||
- **B. Enums show every defined value, including empty nodes**, so
|
||||
create-in-group reaches empty categories. Other kinds show occupied values
|
||||
only.
|
||||
- Refs cannot enumerate every candidate record: 11+ factions per level times
|
||||
the enum levels is too many.
|
||||
- **Recommendation: A.**
|
||||
- The trees stay proportional to the data.
|
||||
- The rule is the same for every kind.
|
||||
- B is a small later addition.
|
||||
- **Depends on it:** the tree builder and one test.
|
||||
|
||||
### Q3. Does grouping also apply to the `CultRecordRef` picker popup? (blocks Cut 2 scope)
|
||||
|
||||
- **A. List only.**
|
||||
- **B. Also the popup**, as `EditorGUILayout.Popup` submenus using `/` paths
|
||||
built from the ref target type's grouping.
|
||||
- A label containing `/` would split wrongly unless it is escaped.
|
||||
- A target type such as `EquippableItemData` has no declaration, so it still
|
||||
shows flat.
|
||||
- **Recommendation: A.**
|
||||
- With drag-drop from the grouped list, the long-popup pain is gone.
|
||||
- B can reuse the model tree later without new model surface.
|
||||
|
||||
### Q4. Record search? (blocks nothing if A)
|
||||
|
||||
- **A. None in this cut.** The Studio has no record search today (F7).
|
||||
- **B. A record search field** that filters tree leaves and auto-expands
|
||||
matching paths.
|
||||
- **Recommendation: A.** It is a separate feature, and grouping does not need it.
|
||||
|
||||
### Q5. Release base and re-pin scope (blocks Cuts 3, 4)
|
||||
|
||||
`git log a0813c6..122c217` (68 commits) touches nothing under:
|
||||
|
||||
- `src/GameCult.Caching`
|
||||
- `src/GameCult.Caching.MessagePack`
|
||||
- `src/GameCult.Unity`
|
||||
- `unity/`
|
||||
- `packages/cultmath`
|
||||
|
||||
It touches:
|
||||
|
||||
- the native QUIC bridge, rewritten to a C ABI v2 (`6a091ef`). The v1 exports
|
||||
`cultmesh_quic_open/state/poll` remain at `cultmesh_quic_native.cpp:1360-1392`.
|
||||
- OpenSSL MsQuic (`c729dff`).
|
||||
- TS CultMesh/CultNet, the QUIC native tests, and docs and CI.
|
||||
- `scripts/build-unity-package.ps1`, one message line.
|
||||
|
||||
The build script rebuilds and, with `-UpdateTemplate`, replaces
|
||||
`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`
|
||||
and `msquic.dll`. `19d8d5c` records that the committed native plugin is the
|
||||
older 40,448-byte Schannel build and that current source builds to 302,080
|
||||
bytes. So a release from main ships a new native bridge and msquic into
|
||||
Aetheria's Unity package. Aetheria calls no QUIC (grep `Quic` in `Assets/Scripts`
|
||||
hits only a KDTree file).
|
||||
|
||||
- **A. Release from main** after the feature merges, and accept the native
|
||||
plugin replacement.
|
||||
- Verify the rebuilt DLL still exports the three v1 entry points the managed
|
||||
`GameCult.Mesh.Quic.Native.dll` imports
|
||||
(`src/GameCult.Mesh.Quic.Native/*.cs:261-275`).
|
||||
- The build host needs MSVC and the pinned MsQuic digests. If the native
|
||||
build fails, stop and return to the operator.
|
||||
- **B. Release from a branch off `a0813c6`** carrying only this feature. Tag it
|
||||
there and merge back to main.
|
||||
- The tags sit on a commit whose native plugin stays old.
|
||||
- The next main release takes the drift anyway, so B only defers it.
|
||||
- **C. Release from main, but hand-restore the old native plugin** in the
|
||||
release commit.
|
||||
- The byte check then fails by construction. Rejected unless the operator
|
||||
wants it.
|
||||
- **Recommendation: A.** The drift is committed, reviewed work that only the
|
||||
release step has held back. Aetheria does not load the bridge, and B leaves
|
||||
the committed plugin already known to be stale (`19d8d5c`).
|
||||
- **Depends on it:** Cut 3's base, its verification list, and Cut 4's
|
||||
`CultLibRevision`.
|
||||
|
||||
## Defaults (recorded, not asked)
|
||||
|
||||
- **Group labels:**
|
||||
- An enum shows `Enum.GetName`. An undefined value shows its number.
|
||||
- A `CultRecordRef<T>` shows `RecordLabel` of the candidate with that key
|
||||
(the target's `[CultName]`), so for Faction that is `Name`, not the
|
||||
legacy `ShortName`.
|
||||
- An unset ref shows `None`, like the picker (`CultCacheStudioDrawers.cs:207`),
|
||||
replacing the legacy `Default`.
|
||||
- A key with no candidate shows `Missing <key>`, as the picker does.
|
||||
- A string shows its value, or `(empty)`.
|
||||
- An integer shows its invariant string.
|
||||
- A bool shows `False`/`True`.
|
||||
- **Group order within a level:**
|
||||
- Enums sort by underlying value.
|
||||
- Refs sort `None` first, then by label (`OrdinalIgnoreCase`, then key),
|
||||
with `Missing` last.
|
||||
- Strings sort `OrdinalIgnoreCase`, then ordinal.
|
||||
- Integers sort numerically; bools sort false before true.
|
||||
- **Groupable kinds:** String, Integer, Bool, Enum, RecordRef. A grouped member
|
||||
is refused with a notice and a flat list (I8) when:
|
||||
- it is of any other kind, including Float, whose bit-equality makes
|
||||
meaningless groups;
|
||||
- it is `Hidden`;
|
||||
- it is read-only (`IsReadOnly`, so a preset cannot assign it and an edit
|
||||
cannot move the record);
|
||||
- it is named but is not an inspector member of the listed type;
|
||||
- it is named twice.
|
||||
- **Global types are never grouped.** They have one record.
|
||||
- **Foldouts:**
|
||||
- Window memory, collapsed by default.
|
||||
- `CloseStore` clears them.
|
||||
- Create-in-group, `Add`, and `Duplicate` expand the path to the new
|
||||
selection, so the selection is always visible.
|
||||
- No `EditorPrefs` or `SessionState` (F8: a domain reload already closes the
|
||||
store).
|
||||
- **Create-in-group into a `Missing <key>` node** presets the dangling key, which
|
||||
keeps I5. The record joins the node it was created in.
|
||||
- **A drop of the current key is rejected** (legacy parity) so that no no-op
|
||||
commit occurs.
|
||||
- **F5 is fixed in Cut 1.** The new attribute reads through `MetadataOf`, so
|
||||
that path must honour inheritance on properties; the fix is one call site
|
||||
in each of two places.
|
||||
|
||||
## Cuts
|
||||
|
||||
### Cut 1. CultLib model: declaration, grouping tree, candidate and label authority
|
||||
|
||||
- **Repo and branch:** CultLib `claude/studio-grouping`, in a new worktree
|
||||
`F:\Projects\CultLib-studio-grouping` from `origin/main`. The main checkout
|
||||
is in active use by the QUIC bridge work; do not use or touch it or the other
|
||||
worktrees.
|
||||
- **Deletes and collapses first:**
|
||||
- `CultInspectorModel.cs:512-520` `RecordCandidates`: its inline predicate
|
||||
`target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0`
|
||||
moves into `IsRecordCandidate`, and `RecordCandidates` filters through it
|
||||
(I6).
|
||||
- `CultCacheStudioDrawers.cs:207` builds its `"Missing " + key` / `"None"`
|
||||
label inline. The label moves to the model (I7), and the Studio call site
|
||||
changes in Cut 2.
|
||||
- `CultInspectorModel.cs:36` and `:209` switch from `member.GetCustomAttributes(true)`
|
||||
to `Attribute.GetCustomAttributes(member, true)` (F5).
|
||||
- **Adds in `CultInspectorAttributes.cs`:**
|
||||
- Q1-B: `CultInspectorGroupByAttribute(params string[] members)`, with
|
||||
`Class`, `Inherited = true`, `AllowMultiple = false`, and property
|
||||
`Members`.
|
||||
- Q1-A instead: `CultInspectorGroupAttribute(int order = 0)`, with
|
||||
`Field|Property`, `Inherited = true`.
|
||||
- Place it before `CultInspectorDrawerAttribute` (`:80`), with a comment in the
|
||||
file's style stating the nesting and inheritance rules.
|
||||
- **Adds in `CultInspectorModel.cs`** (or a sibling `CultInspectorGrouping.cs`
|
||||
in the same namespace; Hands picks, and one file is preferred while it
|
||||
stays under ~800 lines):
|
||||
- `public sealed class CultInspectorGrouping`: `Type ListedType`,
|
||||
`IReadOnlyList<CultInspectorMember> Members` (outermost first), and
|
||||
`string? Notice`. When `Notice` is set, `Members` is empty.
|
||||
- `public sealed class CultInspectorRecordGroup`: `string Id`, `string Label`
|
||||
(empty at the root), `int Depth`, `IReadOnlyList<object?> Values` (root to
|
||||
here), `IReadOnlyList<CultInspectorRecordGroup> Children`,
|
||||
`IReadOnlyList<CultStoredDocument> Records` (non-empty only at leaves), and
|
||||
`int Count` (records beneath).
|
||||
- `CultInspectorModel.GroupingOf(Type listedType)`, cached like `ShapeOf`:
|
||||
- Global types, and types with no declaration, give empty members and no
|
||||
notice.
|
||||
- Q1-B: resolve names against `MembersOf(listedType)`.
|
||||
- Q1-A: take members carrying the attribute, ordered by (order, display
|
||||
order).
|
||||
- Validate the groupable kinds, hidden, read-only and duplicates (Defaults).
|
||||
- `CultInspectorModel.GroupRecords(Type listedType, IEnumerable<CultStoredDocument> records)`:
|
||||
- The input is `RecordCandidates(CultRecordRef<listedType>)`, partitioned
|
||||
level by level by value identity (I4).
|
||||
- Nodes follow the Defaults order and labels. Records stay in candidate
|
||||
order (I2).
|
||||
- With no grouping members, the root holds every record as one leaf.
|
||||
- `CultInspectorModel.CreateInGroup(Type listedType, CultInspectorRecordGroup group, out string? notice)`:
|
||||
- Call `CreateElement(listedType, listedType, out notice)`. On null, return
|
||||
null with that notice.
|
||||
- Otherwise `SetValue` each grouping member with the node's value. A ref is
|
||||
rebuilt with `CreateRecordRef(member type, key)`.
|
||||
- Refuse (`ArgumentException`) a node whose depth or id prefix does not
|
||||
belong to `listedType`'s current grouping.
|
||||
- `public bool IsRecordCandidate(Type recordRefType, CultStoredDocument record)`.
|
||||
- `public string RecordRefLabel(Type recordRefType, object? value, IEnumerable<CultStoredDocument> records)`:
|
||||
- `None` when unset.
|
||||
- Otherwise `RecordLabel` of the candidate with that key.
|
||||
- Otherwise `Missing <key>`.
|
||||
- **Keeps:** `RecordLabel` (`:313-317`), `CreateElement` (`:385-396`), and every other API unchanged.
|
||||
- **Tests** go in `tests/GameCult.Caching.Tests/CultInspectorModelTests.cs`,
|
||||
with new fixtures near `:380-459`. Fixtures:
|
||||
- an abstract base document class, and a derived `[CultDocument]` pair
|
||||
`GroupBase` / `GroupLeaf : GroupBase`;
|
||||
- an enum member, a `CultRecordRef<InspectOther>` member, a string, a float,
|
||||
a hidden int and a read-only int;
|
||||
- a type re-declaring (Q1-B) and a type opting out.
|
||||
|
||||
Each test names the mutation that must turn it red:
|
||||
|
||||
1. `GroupsNestInDeclaredOrder`. The level order equals the declaration, not
|
||||
slot order. *Mutation:* order members by slot, and the test fails.
|
||||
2. `BaseDeclarationGroupsDerivedRecords`. A `GroupLeaf` record listed under
|
||||
`GroupBase` is grouped by the base declaration (I3). *Mutation:* look up the
|
||||
attribute with `inherit: false`, or take the grouping from
|
||||
`record.Descriptor.DocumentType`.
|
||||
3. `NearestDeclarationWinsAndEmptyOptsOut` (Q1-B only). *Mutation:* merge base
|
||||
and derived name lists.
|
||||
4. `LeavesPartitionTheCandidates`. Over records with unset refs, dangling
|
||||
refs and undefined enum values, the concatenated leaves equal
|
||||
`RecordCandidates` as a multiset in order (I2). *Mutation:* skip records
|
||||
whose value is unset, or dedupe by label.
|
||||
5. `RefNodesAreKeyedByKeyAndLabelledByTheModel`. Two referenced records share
|
||||
one `[CultName]` and give two nodes. Unset gives `None`, first. A dangling
|
||||
key gives `Missing k`, last (I4, I7). *Mutation:* group by label.
|
||||
6. `EnumNodesOrderByValue`. Declare the enum out of alphabetical order.
|
||||
*Mutation:* order nodes by label.
|
||||
7. `CreateInGroupPresetsEveryLevelAndRegroupsInPlace`. Create at depth 2,
|
||||
upsert into a `CultCache` over a temp single-file store, and regroup: the
|
||||
new record's leaf id equals the node id (I5). *Mutation:* preset only the
|
||||
deepest level.
|
||||
8. `InvalidDeclarationsGiveANoticeAndAFlatTree`. Cover an unknown name, a
|
||||
float, a hidden member, a read-only member and a duplicate. Each gives a
|
||||
notice, and `GroupRecords` does not throw (I8). *Mutation:* remove any one
|
||||
validation branch.
|
||||
9. `IsRecordCandidateIsTheRecordCandidatesRule`. A subtype record is
|
||||
accepted; a sibling type and an empty key are refused; `RecordCandidates`
|
||||
equals the records filtered by it (I6). *Mutation:* use an exact-type
|
||||
comparison.
|
||||
10. `InheritedMetadataReachesOverridingProperties`. A base abstract property
|
||||
with `[CultInspectorLabel]` is seen on the override's member metadata
|
||||
(F5). *Mutation:* revert `:36`.
|
||||
- **Verification:**
|
||||
- Run `dotnet test tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj -c Release`
|
||||
in the worktree. All pass, and the count grows by the tests above.
|
||||
- For each mutation listed, apply it in the worktree, confirm its test fails,
|
||||
then revert. Record the mutation table in the landing commit message.
|
||||
- **Ledger:**
|
||||
- Model: about +150 to 190 lines.
|
||||
- Attributes: about +15 lines.
|
||||
- Tests: about +200 lines.
|
||||
- Two predicate and label duplicates deleted (one moved, one
|
||||
collapsed in Cut 2).
|
||||
|
||||
### Cut 2. CultLib Studio: lower the tree, create in group, drag source, drop target
|
||||
|
||||
- **Repo and branch:** CultLib `claude/studio-grouping` (same worktree), after
|
||||
Cut 1.
|
||||
- **Deletes first:**
|
||||
- `CultCacheStudioDrawers.cs:207`: the inline `"Missing " + key : "None"`
|
||||
becomes `Model.RecordRefLabel(type, value, Records)` for `names[0]` when
|
||||
`index == 0`.
|
||||
- `CultCacheStudioWindow.cs:180-185`: the flat `foreach` over records is
|
||||
replaced by tree drawing.
|
||||
- **Changes in `CultCacheStudioWindow.cs`:**
|
||||
- Fields (`:13-30`): add `Dictionary<string,bool> _groupFoldouts`
|
||||
(`StringComparer.Ordinal`) and `const string RecordDragKey`.
|
||||
`CloseStore` (`:318-327`) clears the foldouts.
|
||||
- `DrawRecords` (`:135-190`):
|
||||
- Take `_model.GroupingOf(_selectedType)`. When its `Notice` is set, show a
|
||||
`HelpBox` Warning above the list.
|
||||
- Draw `_model.GroupRecords(_selectedType, _records)` recursively. A node
|
||||
is a foldout labelled `Label (Count)` and indented by `Depth`.
|
||||
- An expanded node draws its children, then its leaf records, then a
|
||||
`Create` mini button. The button is enabled under the same
|
||||
`cannotAdd || IsGlobal` gate as `Add` (`:141-147`) and calls
|
||||
`CreateInGroup`.
|
||||
- The root (depth 0) is not drawn as a foldout. `Add` stays the root create.
|
||||
- Record row:
|
||||
- Reserve its rect with `GUILayoutUtility.GetRect` before
|
||||
`GUI.Toggle(rect, ...)` (F11).
|
||||
- On `MouseDrag` inside the rect with a button held, set
|
||||
`DragAndDrop.PrepareStartDrag()`, then `SetGenericData(RecordDragKey,
|
||||
new CultCacheStudioRecordDrag(_model, key))`, set `objectReferences` to
|
||||
empty, call `StartDrag(label)`, and use the event.
|
||||
- `AddInGroup(node)`: mirrors `Add` (`:340-347`) through `Run`. It upserts
|
||||
`CreateInGroup`'s object (null means throw its notice), selects the key, and
|
||||
expands the node's id prefixes. `Add` and `Duplicate` expand the new
|
||||
record's path by looking up its leaf in the next tree; Hands may do that
|
||||
on the next frame through a `_revealKey` field.
|
||||
- **Changes in `CultCacheStudioDrawers.cs`:**
|
||||
- Add `internal sealed class CultCacheStudioRecordDrag { Model; Key }`, or
|
||||
put it in the window file (Hands picks; one type, no interface).
|
||||
- `DrawRecordRef` (`:195-221`): capture the `HorizontalScope` rect.
|
||||
- On `DragUpdated` or `DragPerform` inside it, when `GUI.enabled` is true,
|
||||
the payload's `Model == Model`, the payload key is not the current key,
|
||||
the key's record is in `Records`, and `Model.IsRecordCandidate(type,
|
||||
record)` holds, set the visual mode to `Copy`. Otherwise set it to
|
||||
`Rejected` for our payload only (leave foreign drags untouched).
|
||||
- On perform: `AcceptDrag`, `next = key`, `GUI.changed = true`, and use
|
||||
the event.
|
||||
- Return through the existing `next == key ? value : CreateRecordRef`
|
||||
(`:220`).
|
||||
- **README** (`src/GameCult.Unity/Assets/Caching/README.md`, after `:36-39`):
|
||||
- Grouping: the attribute, nesting, inheritance, labels, create in group,
|
||||
and the notice.
|
||||
- Drag a record onto a reference field.
|
||||
- Under "Inspection Model", the grouping API and `IsRecordCandidate` /
|
||||
`RecordRefLabel`.
|
||||
- **Verification:**
|
||||
- Pre-tag compile gate (F10). Copy the scratch `studio.csproj` pattern:
|
||||
netstandard2.1, `LangVersion 9.0`, compiling the worktree's
|
||||
`Assets/Caching/Editor/*.cs`. Reference Unity 6000.3.24f1
|
||||
`Editor\Data\Managed\UnityEngine\*.dll` (not `UnityEditor.dll`) and the
|
||||
worktree's freshly built `src\GameCult.Caching` and
|
||||
`GameCult.Caching.MessagePack` outputs (`dotnet build -c Release`), plus
|
||||
the tracked `MessagePack*.dll`. Expect 0 errors. Keep the project in
|
||||
scratch, not the repo.
|
||||
- Negative greps in the worktree, each empty:
|
||||
- `rg -n "IsInstanceOfType" src/GameCult.Unity/Assets/Caching` (I6)
|
||||
- `rg -n "\"Missing \"|\"None\"" src/GameCult.Unity/Assets/Caching/Editor` (I7)
|
||||
- `rg -n "SetValue|GetCustomAttribute|CultInspectorGroup" src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs` (I1)
|
||||
- `rg -n "EditorPrefs" src/GameCult.Unity/Assets/Caching/Editor` shows only
|
||||
`LastPathKey`
|
||||
- Cut 1's tests still pass.
|
||||
- Operator click-through happens after Cut 4, in Aetheria against the tagged
|
||||
package, as in the migration's Cut 6 precedent. See Cut 4.
|
||||
- **Ledger:** Window about +70 to 90 lines, drawers about +25 lines, README
|
||||
about +20 lines. Two inline decisions deleted.
|
||||
|
||||
### Cut 3. CultLib release: cultlib Unity 1.0.61, Studio 1.5.0
|
||||
|
||||
Versions bumped from the original 1.0.60/1.4.0 plan: `codex/asset-guid-attribute`
|
||||
took 1.0.60/1.4.0 for its own release (replacing the asset path attribute with
|
||||
`CultInspectorAssetGuid`, then adding sub-asset key support, then a built-in
|
||||
refusal fix and a stale-value Clear button); it releases first. This branch
|
||||
must rebase onto and rebuild after `codex/asset-guid-attribute` before its own
|
||||
release: same files (`CultInspectorModel.cs`, the Studio drawers, the plugin
|
||||
DLLs/pdbs), so a plain merge reports binary conflicts on the plugin DLLs/pdbs
|
||||
rather than silently picking one side; resolve by taking either side's text
|
||||
changes as needed and then rebuilding, not by hand-merging the binaries.
|
||||
|
||||
- **Repo and branch:** CultLib `main`. Merge `claude/studio-grouping`
|
||||
(fast-forward or merge commit), then create the release commit in the same
|
||||
clean worktree. Q5-A is assumed.
|
||||
- **Files:**
|
||||
- `unity/org.gamecult.cultlib/package.json:4`: `1.0.60` → `1.0.61`.
|
||||
- `src/GameCult.Unity/Assets/Caching/package.json:4`: `1.4.0` → `1.5.0`,
|
||||
because this is new user-facing capability. `:13`: `"org.gamecult.cultlib": "1.0.61"`.
|
||||
- `unity/org.gamecult.cultlib/Runtime/Plugins/*.dll|pdb` and
|
||||
`x86_64/*.dll`, via
|
||||
`powershell -File scripts\build-unity-package.ps1 -UpdateTemplate`. The
|
||||
script derives the assembly version from the package.json and checks it
|
||||
(`:152-155` after the rebase onto `codex/asset-guid-attribute`, whose
|
||||
`0c9cb57` inserted two lines above this block for the OpenSSL notice;
|
||||
it is `:150-153` on this branch's own unrebased copy of the script).
|
||||
- **Tags on the release commit:** `cultlib-unity-v1.0.61` and
|
||||
`caching-unity-v1.5.0`. That is two tags in one push, under GitHub's
|
||||
three-tag workflow limit (migration cut map header). No `cultmath-unity` tag,
|
||||
because CultMath is unchanged.
|
||||
- **Verification:**
|
||||
- **Byte check.** Run the build script again and check that
|
||||
`git status --porcelain unity/` is empty (a reproducible build). This is
|
||||
manual, as recorded in the migration's follow-ups.
|
||||
- **Expected diff.** `git diff --stat HEAD~1 -- unity/` lists
|
||||
`GameCult.Caching.dll/.pdb` changed. Under Q5-A,
|
||||
`x86_64/gamecult_mesh_quic_native.dll` (about 302 KB) and `msquic.dll` also
|
||||
change. An unexpected changed assembly stops the release for review.
|
||||
- **API present.** A throwaway scratch script loads the tracked
|
||||
`GameCult.Caching.dll` by reflection and confirms that
|
||||
`CultInspectorGroupByAttribute`, `CultInspectorModel.GroupRecords`,
|
||||
`CreateInGroup`, `IsRecordCandidate` and `RecordRefLabel` exist.
|
||||
- **Native ABI (Q5-A).** Run `dumpbin /exports` on the tracked
|
||||
`gamecult_mesh_quic_native.dll`. It lists `cultmesh_quic_open`, `_state`,
|
||||
`_poll`, `_error` and `_close`.
|
||||
- **Tags pushed.** `git ls-remote --tags origin` lists both tags.
|
||||
- **Ledger:** Versions and binaries only.
|
||||
|
||||
### Cut 4. Aetheria: re-pin CultLib and annotate grouping
|
||||
|
||||
**Unblocked, needs re-mapping (2026-09-19).** The operator ruled that a crafted
|
||||
item's manufacturer and optional branding belong to its production lot, after
|
||||
Njordr's lot ontology, so `ItemData.Manufacturer` leaves the design. Grouping
|
||||
`GearData` by `Manufacturer` is therefore not the target. That schema ruling has
|
||||
now settled: Aetheria's provenance schema cut closed at `46efccbe` and merged to
|
||||
master at `9b85211f`, and branding is derived from the lot's `ProvenanceLedger`
|
||||
rather than stored on the design. This cut is waiting on a fresh Imagination pass
|
||||
against the landed schema, not on the ruling. Cuts 1-3 do not depend on it.
|
||||
|
||||
|
||||
- **Repo and branch:** Aetheria `codex/cultcache-cutover`, the current branch.
|
||||
Stage by path only: `Assets/Scripts/Editor/CultCacheDrawers.cs` carries an
|
||||
unrelated uncommitted edit that must stay out of the commit and untouched.
|
||||
- **Files:**
|
||||
- `Directory.Build.props:5` `CultLibRevision`: set it to the Cut 3 release
|
||||
commit's full SHA. Note that it lives in props, not targets; the check is in
|
||||
`Directory.Build.targets:2-22`.
|
||||
- `Packages/manifest.json:53`: `#caching-unity-v1.4.0` → `#caching-unity-v1.5.0`.
|
||||
`:54`: `#cultlib-unity-v1.0.60` → `#cultlib-unity-v1.0.61`. `:55`
|
||||
`cultmath-unity-v0.2.3` is unchanged. Unity rewrites
|
||||
`Packages/packages-lock.json`; commit that too.
|
||||
- `Assets/Scripts/ServerShared/ItemData.cs`, under Q1-B:
|
||||
- `SimpleCommodityData` (`:300`): `[CultInspectorGroupBy(nameof(Category))]`
|
||||
- `CompoundCommodityData` (`:330`): `[CultInspectorGroupBy(nameof(Category))]`
|
||||
- `GearData` (`:449`): `[CultInspectorGroupBy(nameof(Hardpoint), nameof(Manufacturer))]`.
|
||||
`WeaponItemData` inherits it.
|
||||
- `HullData` (`:493`): `[CultInspectorGroupBy(nameof(HullType))]`
|
||||
- Under Q1-A instead: `[CultInspectorGroup]` on `SimpleCommodityData.Category`
|
||||
(`:309`), `CompoundCommodityData.Category` (`:337`),
|
||||
`GearData.Hardpoint` (`:453`), `ItemData.Manufacturer` (`:281`, order 1)
|
||||
and `HullData.HullType` (`:503`). Commodity lists then gain the `None`
|
||||
manufacturer level (F2).
|
||||
- **Tests** (`tests/Aetheria.Shared.Tests`, new `StudioGroupingTests.cs`, xUnit):
|
||||
- `AnnotatedCatalogTypesGroupAsDeclared`:
|
||||
- Build `CultCacheMessagePack.CreateInspectorModel` over the Aetheria
|
||||
registry, as the probe did with the `[CultDocument]` types of
|
||||
`typeof(GearData).Assembly`.
|
||||
- `GroupingOf` names exactly `Category`, `Category`,
|
||||
`Hardpoint, Manufacturer` and `HullType` for the four types, each with no
|
||||
notice.
|
||||
- `GroupingOf(typeof(WeaponItemData))` equals `GearData`'s.
|
||||
- *Mutation:* remove one annotation, or reorder `GearData`'s names.
|
||||
- `NoGroupedMemberIsRefused`. Every registered document type's `GroupingOf`
|
||||
has a null notice. This guards against a future annotation on a float or
|
||||
hidden member.
|
||||
- **Verification:**
|
||||
- **Headless.** Run
|
||||
`dotnet test tests\Aetheria.Shared.Tests -p:CultLibRoot=F:\Projects\CultLib-studio-grouping`,
|
||||
with that worktree checked out clean at the release SHA. The revision check
|
||||
requires a clean root at exactly that SHA, and the main CultLib checkout is
|
||||
shared with live work. All pass.
|
||||
- **Unity batchmode compile.** With the editor closed, run
|
||||
`-batchmode -nographics -quit -projectPath F:\Projects\Aetheria -logFile <scratch>`
|
||||
on Unity 6000.3.24f1. Expect 0 compile errors, and the package cache
|
||||
resolving both new tags.
|
||||
- **Operator click-through** in Aetheria's Studio against
|
||||
`GameData/Aetheria.cc`:
|
||||
- `GearData` shows hardpoint then manufacturer. It includes the
|
||||
`WeaponItemData` records marked `<WeaponItemData>`, and a `None` node
|
||||
holds the 14 unset.
|
||||
- Commodities show categories only. `HullData` shows hull types.
|
||||
- Create in a gear node, then check that the record lands there and is
|
||||
selected.
|
||||
- Drag a faction onto a gear `Manufacturer` and see it accepted. Drag a
|
||||
gear record onto it and see it rejected.
|
||||
- Drag onto a `DemandProfile` dictionary key and see a duplicate refused
|
||||
through the notice.
|
||||
- In a read-only open, every drop is rejected.
|
||||
- Close without edits and confirm that the store bytes are unchanged.
|
||||
- **Negative grep.** `rg -n "HardpointType\)" Assets/Scripts/ServerShared/ItemData.cs`
|
||||
shows no grouping declaration names the derived property.
|
||||
- **Ledger:** 4 attribute lines (Q1-B), the pin and manifest lines, and about
|
||||
+40 test lines.
|
||||
|
||||
## Subtraction ledger (whole change)
|
||||
|
||||
- **Added:**
|
||||
- One attribute type.
|
||||
- Two model types and five model methods.
|
||||
- About 10 CultLib tests and 2 Aetheria tests.
|
||||
- About 100 Studio lines.
|
||||
- 4 annotations.
|
||||
- **Removed or collapsed:**
|
||||
- The candidate predicate duplicated at each future call site. It was
|
||||
inline once; now it is one method, reused by drop.
|
||||
- The ref label strings, duplicated between picker and grouping.
|
||||
- The latent attribute-inheritance miss on overriding properties (F5).
|
||||
- **Not added:** selectors, registries, `EditorPrefs` keys, sibling-runtime
|
||||
ports, or new packages, targets or daemons.
|
||||
- **Build footprint:**
|
||||
- `GameCult.Caching` and its tests.
|
||||
- The Unity package script, which rebuilds every tracked plugin including the
|
||||
native bridge (Q5).
|
||||
- Aetheria's headless shared build and one batchmode compile.
|
||||
- The build host is the Windows workstation, which is also the target
|
||||
platform for both the Unity editor plugin and the win-x64 native bridge.
|
||||
Loading…
Reference in a new issue