diff --git a/docs/studio-grouping-cut.md b/docs/studio-grouping-cut.md new file mode 100644 index 00000000..9ec84ea2 --- /dev/null +++ b/docs/studio-grouping-cut.md @@ -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` 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, 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 `. + 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 `" 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.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 | `` + `/` + per-level value identity (I4) | Stable while the values exist | `CultInspectorModel` | Studio foldout map | No | +| Node label | Enum name, `RecordLabel`, `None`, `Missing `, 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` 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 `, 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 ` 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 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 Values` (root to + here), `IReadOnlyList Children`, + `IReadOnlyList 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 records)`: + - The input is `RecordCandidates(CultRecordRef)`, 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 records)`: + - `None` when unset. + - Otherwise `RecordLabel` of the candidate with that key. + - Otherwise `Missing `. +- **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` 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 _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 ` + 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 ``, 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.