Soul (F9) flagged three drifted claims in Cut 3, from8c0e726: codex/asset-guid-attribute is pushed but not merged to main, so it has not "landed and released" - it releases first, this cut after. A plain merge of two branches that each rebuilt the same tracked plugin DLLs/pdbs does not "silently drop" one side; git reports binary conflicts on them, resolved by rebuilding rather than hand-merging binary blobs. And the build script's meshPackageVersion check line reference was stale for the post-rebase state:0c9cb57(on codex/asset-guid-attribute, not yet on this branch) inserted two lines above it for the OpenSSL notice, moving the check from :150-153 to :152-155 once this cut rebases onto that branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 KiB
Studio Record Grouping And Reference Drag-Drop Cut
Date: 2026-09-17 (Imagination pass)
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
- 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.
- 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. - 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.
CultInspectorModelalone 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
SetValuefor 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
WeaponItemDatalisted underGearDatais grouped byGearData's declaration. - I4. Node identity is value identity, not the label.
- A
CultRecordRefgroups 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.
- A
- 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".RecordCandidatesfilters through it, and drop acceptance calls it. NoIsInstanceOfTypeappears insrc/GameCult.Unity. - I7. One ref label. The model owns the strings
NoneandMissing <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
ReplaceKeyrefusal, becauseDrawDictionaryalready 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.tsis a raw.ccbyte 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 nameNew <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
GearDatagrouping readHardpointTypeand wroteHardpoint. EquippableItemData.HardpointType(ItemData.cs:401-402) is an abstract[IgnoreMember]property.GearData.HardpointType => Hardpoint(:455),CargoBayDatareturnsTool(:464) andHullDatareturnsHull(:533).- The stored member is
GearData.Hardpoint(slot 23,:453). - The model's member list for
GearDataandWeaponItemDataincludesHardpoint#23@GearData, confirmed by probe.HardpointTypeis not an inspector member. - The declaration names
Hardpoint. The overriding types have no stored hardpoint, so they have nothing to group.
- Legacy
-
F2.
Manufactureris declared on the abstractItemData(ItemData.cs:281), not onGearData. Probe ofGameData/Aetheria.cc(a copy):Listed type Records Manufacturer set Manufacturer unset GearData(includesWeaponItemData)43 29 14 HullData3 3 0 CargoBayData(includesDockingBayData)5 5 0 SimpleCommodityData13 0 13 CompoundCommodityData51 0 51 GearDatahas 11 distinct manufacturers. A member attribute onItemData.Manufacturerwould therefore also wrap all 64 commodity records in one "None" level. Legacy grouped onlyGearDataby manufacturer. This drives Q1. -
F3. Legacy grouping matched the exact type (
CanGroup: typeof(T) == type), and its tables listed exact types. So it never groupedWeaponItemDatarecords. 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.
CultInspectorMetadatareadsmember.GetCustomAttributes(true)(CultInspectorModel.cs:36), andCultInspectorDrawerClaims.Resolvereads it too (:209).- For a
PropertyInfo, that overload ignoresinherit. Probe: a base[M] abstract int Pis not seen on the override.Attribute.GetCustomAttributes(member, true)does see it. - Fields are unaffected. This is a latent defect in every
Inherited = trueinspector attribute.
-
F6. Studio
Addcreates 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 onOnDisable(:48-51), which includes a domain reload. The onlyEditorPrefskey isLastPathKey(:13). -
F9. No drawer claims
CultRecordRef<>. This was checked in CultLib Studio and in AetheriaHEAD:Assets/Scripts/Editor/CultCacheDrawers.cs. So every ref value reachesDrawRecordRef(: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/*.cswith 0 errors against:Editor/Data/Managed/UnityEngine/*.dllfrom Unity 6000.3.24f1 (theUnityEditor.dllfacade must be left out, because it duplicates the module types);- the tracked
GameCult.Caching*.dllandMessagePack*.dll.
- This gives a pre-tag compile gate. The last migration only had post-release Aetheria batchmode.
- Probe: a netstandard2.1 / C# 9 project compiles
-
F11. The Studio source row reads its click in
GUILayout.Toggle(:184). IMGUI button-like controls takeMouseDragwhile 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 oneNonelevel (F2). - Ordering spreads across files:
HardpointonGearDatawithorder: 0,ManufactureronItemDatawithorder: 1.
- Levels are ordered by
- 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.
nameofkeeps it refactor-safe, and it names data rather than code.- It scopes
ManufacturertoGearDataexactly 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.Popupsubmenus 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
EquippableItemDatahas no declaration, so it still shows flat.
- A label containing
- 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.Cachingsrc/GameCult.Caching.MessagePacksrc/GameCult.Unityunity/packages/cultmath
It touches:
- the native QUIC bridge, rewritten to a C ABI v2 (
6a091ef). The v1 exportscultmesh_quic_open/state/pollremain atcultmesh_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.dllimports (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.
- Verify the rebuilt DLL still exports the three v1 entry points the managed
- B. Release from a branch off
a0813c6carrying 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>showsRecordLabelof the candidate with that key (the target's[CultName]), so for Faction that isName, not the legacyShortName. - An unset ref shows
None, like the picker (CultCacheStudioDrawers.cs:207), replacing the legacyDefault. - 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.
- An enum shows
- Group order within a level:
- Enums sort by underlying value.
- Refs sort
Nonefirst, then by label (OrdinalIgnoreCase, then key), withMissinglast. - 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.
CloseStoreclears them.- Create-in-group,
Add, andDuplicateexpand the path to the new selection, so the selection is always visible. - No
EditorPrefsorSessionState(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 worktreeF:\Projects\CultLib-studio-groupingfromorigin/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-520RecordCandidates: its inline predicatetarget.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0moves intoIsRecordCandidate, andRecordCandidatesfilters through it (I6).CultCacheStudioDrawers.cs:207builds its"Missing " + key/"None"label inline. The label moves to the model (I7), and the Studio call site changes in Cut 2.CultInspectorModel.cs:36and:209switch frommember.GetCustomAttributes(true)toAttribute.GetCustomAttributes(member, true)(F5).
-
Adds in
CultInspectorAttributes.cs:- Q1-B:
CultInspectorGroupByAttribute(params string[] members), withClass,Inherited = true,AllowMultiple = false, and propertyMembers. - Q1-A instead:
CultInspectorGroupAttribute(int order = 0), withField|Property,Inherited = true. - Place it before
CultInspectorDrawerAttribute(:80), with a comment in the file's style stating the nesting and inheritance rules.
- Q1-B:
-
Adds in
CultInspectorModel.cs(or a siblingCultInspectorGrouping.csin 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), andstring? Notice. WhenNoticeis set,Membersis 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), andint Count(records beneath).CultInspectorModel.GroupingOf(Type listedType), cached likeShapeOf:- 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.
- The input is
CultInspectorModel.CreateInGroup(Type listedType, CultInspectorRecordGroup group, out string? notice):- Call
CreateElement(listedType, listedType, out notice). On null, return null with that notice. - Otherwise
SetValueeach grouping member with the node's value. A ref is rebuilt withCreateRecordRef(member type, key). - Refuse (
ArgumentException) a node whose depth or id prefix does not belong tolistedType's current grouping.
- Call
public bool IsRecordCandidate(Type recordRefType, CultStoredDocument record).public string RecordRefLabel(Type recordRefType, object? value, IEnumerable<CultStoredDocument> records):Nonewhen unset.- Otherwise
RecordLabelof 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]pairGroupBase/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:
GroupsNestInDeclaredOrder. The level order equals the declaration, not slot order. Mutation: order members by slot, and the test fails.BaseDeclarationGroupsDerivedRecords. AGroupLeafrecord listed underGroupBaseis grouped by the base declaration (I3). Mutation: look up the attribute withinherit: false, or take the grouping fromrecord.Descriptor.DocumentType.NearestDeclarationWinsAndEmptyOptsOut(Q1-B only). Mutation: merge base and derived name lists.LeavesPartitionTheCandidates. Over records with unset refs, dangling refs and undefined enum values, the concatenated leaves equalRecordCandidatesas a multiset in order (I2). Mutation: skip records whose value is unset, or dedupe by label.RefNodesAreKeyedByKeyAndLabelledByTheModel. Two referenced records share one[CultName]and give two nodes. Unset givesNone, first. A dangling key givesMissing k, last (I4, I7). Mutation: group by label.EnumNodesOrderByValue. Declare the enum out of alphabetical order. Mutation: order nodes by label.CreateInGroupPresetsEveryLevelAndRegroupsInPlace. Create at depth 2, upsert into aCultCacheover a temp single-file store, and regroup: the new record's leaf id equals the node id (I5). Mutation: preset only the deepest level.InvalidDeclarationsGiveANoticeAndAFlatTree. Cover an unknown name, a float, a hidden member, a read-only member and a duplicate. Each gives a notice, andGroupRecordsdoes not throw (I8). Mutation: remove any one validation branch.IsRecordCandidateIsTheRecordCandidatesRule. A subtype record is accepted; a sibling type and an empty key are refused;RecordCandidatesequals the records filtered by it (I6). Mutation: use an exact-type comparison.InheritedMetadataReachesOverridingProperties. A base abstract property with[CultInspectorLabel]is seen on the override's member metadata (F5). Mutation: revert:36.
- an abstract base document class, and a derived
-
Verification:
- Run
dotnet test tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj -c Releasein 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.
- Run
-
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"becomesModel.RecordRefLabel(type, value, Records)fornames[0]whenindex == 0.CultCacheStudioWindow.cs:180-185: the flatforeachover records is replaced by tree drawing.
- Changes in
CultCacheStudioWindow.cs:- Fields (
:13-30): addDictionary<string,bool> _groupFoldouts(StringComparer.Ordinal) andconst string RecordDragKey.CloseStore(:318-327) clears the foldouts. DrawRecords(:135-190):- Take
_model.GroupingOf(_selectedType). When itsNoticeis set, show aHelpBoxWarning above the list. - Draw
_model.GroupRecords(_selectedType, _records)recursively. A node is a foldout labelledLabel (Count)and indented byDepth. - An expanded node draws its children, then its leaf records, then a
Createmini button. The button is enabled under the samecannotAdd || IsGlobalgate asAdd(:141-147) and callsCreateInGroup. - The root (depth 0) is not drawn as a foldout.
Addstays the root create.
- Take
- Record row:
- Reserve its rect with
GUILayoutUtility.GetRectbeforeGUI.Toggle(rect, ...)(F11). - On
MouseDraginside the rect with a button held, setDragAndDrop.PrepareStartDrag(), thenSetGenericData(RecordDragKey, new CultCacheStudioRecordDrag(_model, key)), setobjectReferencesto empty, callStartDrag(label), and use the event.
- Reserve its rect with
AddInGroup(node): mirrorsAdd(:340-347) throughRun. It upsertsCreateInGroup's object (null means throw its notice), selects the key, and expands the node's id prefixes.AddandDuplicateexpand the new record's path by looking up its leaf in the next tree; Hands may do that on the next frame through a_revealKeyfield.
- Fields (
- 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 theHorizontalScoperect.- On
DragUpdatedorDragPerforminside it, whenGUI.enabledis true, the payload'sModel == Model, the payload key is not the current key, the key's record is inRecords, andModel.IsRecordCandidate(type, record)holds, set the visual mode toCopy. Otherwise set it toRejectedfor 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).
- On
- Add
- 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.csprojpattern: netstandard2.1,LangVersion 9.0, compiling the worktree'sAssets/Caching/Editor/*.cs. Reference Unity 6000.3.24f1Editor\Data\Managed\UnityEngine\*.dll(notUnityEditor.dll) and the worktree's freshly builtsrc\GameCult.CachingandGameCult.Caching.MessagePackoutputs (dotnet build -c Release), plus the trackedMessagePack*.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/Editorshows onlyLastPathKey
- 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.
- Pre-tag compile gate (F10). Copy the scratch
- 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. Mergeclaude/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|pdbandx86_64/*.dll, viapowershell -File scripts\build-unity-package.ps1 -UpdateTemplate. The script derives the assembly version from the package.json and checks it (:152-155after the rebase ontocodex/asset-guid-attribute, whose0c9cb57inserted two lines above this block for the OpenSSL notice; it is:150-153on this branch's own unrebased copy of the script).
- Tags on the release commit:
cultlib-unity-v1.0.61andcaching-unity-v1.5.0. That is two tags in one push, under GitHub's three-tag workflow limit (migration cut map header). Nocultmath-unitytag, 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/listsGameCult.Caching.dll/.pdbchanged. Under Q5-A,x86_64/gamecult_mesh_quic_native.dll(about 302 KB) andmsquic.dllalso change. An unexpected changed assembly stops the release for review. - API present. A throwaway scratch script loads the tracked
GameCult.Caching.dllby reflection and confirms thatCultInspectorGroupByAttribute,CultInspectorModel.GroupRecords,CreateInGroup,IsRecordCandidateandRecordRefLabelexist. - Native ABI (Q5-A). Run
dumpbin /exportson the trackedgamecult_mesh_quic_native.dll. It listscultmesh_quic_open,_state,_poll,_errorand_close. - Tags pushed.
git ls-remote --tags originlists both tags.
- Byte check. Run the build script again and check that
- Ledger: Versions and binaries only.
Cut 4. Aetheria: re-pin CultLib and annotate grouping
Blocked (2026-09-17). 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. This cut is re-mapped once that schema ruling
settles. 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.cscarries an unrelated uncommitted edit that must stay out of the commit and untouched. - Files:
Directory.Build.props:5CultLibRevision: set it to the Cut 3 release commit's full SHA. Note that it lives in props, not targets; the check is inDirectory.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.:55cultmath-unity-v0.2.3is unchanged. Unity rewritesPackages/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))].WeaponItemDatainherits it.HullData(:493):[CultInspectorGroupBy(nameof(HullType))]
- Under Q1-A instead:
[CultInspectorGroup]onSimpleCommodityData.Category(:309),CompoundCommodityData.Category(:337),GearData.Hardpoint(:453),ItemData.Manufacturer(:281, order 1) andHullData.HullType(:503). Commodity lists then gain theNonemanufacturer level (F2).
- Tests (
tests/Aetheria.Shared.Tests, newStudioGroupingTests.cs, xUnit):AnnotatedCatalogTypesGroupAsDeclared:- Build
CultCacheMessagePack.CreateInspectorModelover the Aetheria registry, as the probe did with the[CultDocument]types oftypeof(GearData).Assembly. GroupingOfnames exactlyCategory,Category,Hardpoint, ManufacturerandHullTypefor the four types, each with no notice.GroupingOf(typeof(WeaponItemData))equalsGearData's.- Mutation: remove one annotation, or reorder
GearData's names.
- Build
NoGroupedMemberIsRefused. Every registered document type'sGroupingOfhas 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:GearDatashows hardpoint then manufacturer. It includes theWeaponItemDatarecords marked<WeaponItemData>, and aNonenode holds the 14 unset.- Commodities show categories only.
HullDatashows hull types. - Create in a gear node, then check that the record lands there and is selected.
- Drag a faction onto a gear
Manufacturerand see it accepted. Drag a gear record onto it and see it rejected. - Drag onto a
DemandProfiledictionary 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.csshows no grouping declaration names the derived property.
- Headless. Run
- 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,
EditorPrefskeys, sibling-runtime ports, or new packages, targets or daemons. - Build footprint:
GameCult.Cachingand 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.