Repository navigation
0.13.0 - #22
Merged
Merged
0.13.0#22
Conversation
DD DynamicAnalyze measured what exitTime = 0 actually does: it is not "leave at once" but the same instruction as exitTime = 1 — leave at the end of a lap of the source state's clip, or after the one second Mecanim gives a state with no motion of its own. The measurement is pinned in PlayModeProbeTests (AnExitTimeOfZeroWaitsForTheEndOfALap, which asserts the two exit times are the same instruction). DaerD shipped that misreading itself in 0.12.0 — async sync's immediate hop waited a full lap every time — and a hand-written controller makes the same mistake with nothing on screen to say so. Filed under a kind of its own rather than DeadTransition. That box means "this never fires"; these do fire, only late, and sharing the box would make the category wrong in both directions. The two cannot collide anyway: IsDeadTransition requires !hasExitTime. Whether anything else gates the transition decides what is said. With conditions the intent is legible — somebody wrote 0 to mean "the moment this holds" — so it is a Warning carrying a one-click repair that clears hasExitTime and leaves the conditions to decide when it fires. Without conditions it is a repeating fall-through, which is a real shape and not necessarily a mistake, so it is Info with no repair offered: clearing exit time there would leave a transition with neither conditions nor exit time and merely trade this row for a dead-transition one. Choosing between "you meant the loop" and "you meant immediately" needs an intent the asset does not record, so the machine does not choose. Not reported, deliberately: Any State transitions. What exit time counts against for a transition with no source state of its own was never measured, and the analyzer does not guess — the exclusion lives in the method's doc comment because nothing in the code shows it. Entry, Exit and state-machine transitions carry no exit time at all and fall out on their own. Nothing here evaluates conditions either, so an exitTime-0 transition whose conditions never hold is still reported: the claim is about the wait, not about the transition being live. Compatibility: only new rows in the analyzer list. No saved data, generated asset or recipe API changes, and no existing issue changes its wording, severity or fix. DaerD's own generators stay clear of the check — async sync writes either a positive normalised exit time or hasExitTime = false, so its output never matches. This is the first of three ports of DynamicAnalyze findings into StaticAnalyze, so the scope of the series: what stays dynamic-only, and why. A driver on a self-transition of zero duration (SimNotes) is a limit of the simulator, not a fault in the controller — the real client re-enters the state — and a static warning about it would be a false claim. A driver reaching another layer on the next step is Unity working correctly, so a warning would fire on every avatar ever built. Quantisation, interpolation, packet loss, group splits and the rest of RunFindings are only knowable by running the thing, which is what dynamic analysis is for. The one genuine candidate left out is a default state's driver not firing when the first Update already leaves the state: stating it statically needs conditions evaluated at their declared defaults, which collides with the rule that the analyzer never evaluates conditions. Tested with 1185 EditMode tests (VRChat SDK plus GestureManager, Av3Emulator, NDMF and Modular Avatar), all green; 5 of them are new for this check. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqyBBrcK6HmR6aJJURUU8J
Second port of a DD DynamicAnalyze measurement into StaticAnalyze. The probes established that a layer does not consult its root Entry conditions when it starts — it begins at the default state whatever they say — and that they are read again on every arrival back at Entry, which only happens by passing the layer's Exit. A sub state machine's Entry is read on every visit and is not part of this. The consequence is a controller that reads one way and runs another: an author writes "start here when P holds", the layer starts somewhere else, and the branch is drawn on the graph forever without being evaluated once. ca0bcd7 named this as a candidate for static analysis and deliberately left it there; this is that pickup. The mechanism is one question added to ControllerReachability: does the walk ever pass the root's Exit. It is the same walk that answers ReachableStates, now shared rather than copied, so both answers come from one interpretation of Unity's entry rules and cannot drift apart. ReachableStates is unchanged, bit for bit — its existing tests were the check on that. Only layers that cannot reach Exit are reported. Warning on every conditional root Entry was the obvious alternative and was rejected: a layer that loops back through Exit reads its branches on every lap afterwards, so warning there would be firing at an ordinary idiom for the sake of the first lap, and the row would appear on controllers that are entirely correct. What that costs is real — the first lap ignores the conditions in those layers too, and DaerD says nothing about it. Direction of error, chosen once and stated in the doc comment: reaching Exit suppresses the finding, so anywhere the walk is too generous the cost is a warning not raised. The unmeasured shape behind that is an Exit bubbling up out of a sub machine with nothing drawn out of it — the walk treats it as returning to the root's Entry, and nobody has measured whether the root's conditions are re-read on that route. If they are not, this check stays quiet about a layer it could have reported. It will not do the opposite and call a live branch dead. In the same spirit conditions are never evaluated, so a route to Exit that nothing could ever satisfy still counts as reaching Exit. Reported once per layer with the count, the shape AnyStateRetrigger established: the branches share one cause and a row each would repeat one sentence. No fix — the repair is either giving the layer a way back to Entry or moving the choice into the first state's own transitions, and only the author knows which. Rows overlapping with UnreachableState are left alone rather than deduplicated: "this state cannot be entered" and "this branch is not read" are different claims about different objects, and one does not imply the other. Compatibility: new rows only. Synced layers are skipped, as in the unreachable check, so a mirror layer does not repeat its source's finding. No saved data, generated output or recipe API is touched. Tested with 1195 EditMode tests (VRChat SDK plus GestureManager, Av3Emulator, NDMF and Modular Avatar), all green; 10 are new — 6 on the analyzer's rows and 4 on the Exit question itself. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqyBBrcK6HmR6aJJURUU8J
Last of the three DD DynamicAnalyze ports. VrcParameters already carries VRChat's official type for every built-in, built from the platform's own parameter documentation, and until now the analyzer never compared a controller against it. The mismatch is worth a row precisely because it does not break: the platform converts into whatever type the controller declared. The Av3Emulator sources in the test project show the shape of it — the write path picks SetBool, SetFloat or SetInteger by the destination parameter's real type, so a Float GestureLeft is fed 0..7 as floats and a Bool one collapses to "not zero". So the layer runs, wrongly, and the only visible symptom is that conditions have to be written against the converted number: Equals does not exist on a Float, so an Int built-in declared Float needs Greater/Less pairs instead of the eight equalities the author expects. Reported as Info in the existing VRC Parameters category, in the same voice as the store-versus-controller mismatch check that already lives there — converted is not broken, and it is occasionally deliberate. A new kind was considered and dropped: the box is already named for exactly this subject. The two checks are not deduplicated against each other either, because they compare different things (an expression parameter asset versus the controller; VRChat's table versus the controller) and a controller with a store can legitimately show both. It runs before the store checks and does not need a store to exist, which is what makes it useful on gimmick controllers that belong to no avatar. Trigger is reported rather than exempted. Nothing in the official table is a Trigger, so a built-in declared Trigger disagrees by construction; a value the platform keeps writing is far enough from a flag consumed on read that silence would be the wrong answer. Names differing only in case are not reported: VRChat matches exactly, so "gestureleft" is a parameter of the avatar's own and calling it a typo is a different feature than this one. No fix offered. Retyping a parameter invalidates the conditions already written against it — the Int equalities above become invalid the moment the type is Float — and choosing which of them to rewrite is not something the analyzer can do. Scope of the claim: that the value arrives converted, not what any particular rounding does at the boundary. The evidence is the official type table plus a trusted reimplementation of the runtime, not the closed client, so the message says "converted" and leaves the arithmetic alone. Mechanically, the official type is now asked for through VrcParameters (TryOfficialType, over a UnityType translation the parameters panel's private copy gave way to), so the Add menu and the analyzer cannot come to disagree about what the official type is. The panel's behaviour is unchanged. Compatibility: new Info rows in a category the UI already draws; no saved data, generated output or recipe API is affected. Tested with 1200 EditMode tests (VRChat SDK plus GestureManager, Av3Emulator, NDMF and Modular Avatar), all green; 5 of them are new for this check. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqyBBrcK6HmR6aJJURUU8J
…ted remote The distillery skill's invariant changed upstream from "raw logs never leave this machine" to "raw logs never leave the owner's control": a pre-push guard now admits exactly the remotes a human listed in allowed_remotes, requires git-crypt encryption at rest for sessions/, and rejects everything else including plaintext history. This repository's log repo was re-keyed accordingly (fresh history, encrypted blobs), so the CLAUDE.md sentence describing .distillery as strictly local stopped being true. The remote's identity stays out of this file on purpose — the no user-specific-data rule covers infrastructure hostnames too; the URL lives in .distillery/config.yaml, which never enters this repository. No code, tests or generated output are affected. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqyBBrcK6HmR6aJJURUU8J
…ill sync logs distillery's log repository is encrypted at rest and its pre-push guard rejects any push carrying plaintext sessions/ blobs. Both halves of that need the git-crypt binary on PATH: the unlock after a fresh clone, and every later sync, because without the binary git's smudge filter leaves ciphertext in the working tree and sync stops rather than overwrite it. This image had no git-crypt, and the container gives node no passwordless sudo, so the only in-container remedy was unpacking a .deb into the home directory — which the next rebuild discards. Putting it in the image is what makes a rebuild a non-event for the log repo. Taken from the distro apt list rather than a pinned upstream build: 22.04 carries 0.6.0, and that exact package unlocked this project's existing log repository with the key exported from the other device, so the format the hub already uses is readable by it. A pinned upstream build was considered and dropped — it would add a keyring and a version to bump by hand, to buy compatibility we have no evidence of needing. The cost is one more package in the base layer for a tool only distillery uses. Nothing else in the container references it, and a checkout without .distillery never invokes it. No effect on the package, the tests, or generated output. Running containers are unaffected; the binary appears at the next rebuild, and until then a hand-unpacked copy of the same package stands in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wj7bAQvRKvhVTTNSgv6vpi Session: c51ae792-90c5-4710-bd79-29ff9d298a0d
…window Using DaerD as an analysis tool means reading a controller as C#, and until now the only way in was the export window. Anything scripted had to reach a static class the assembly keeps internal, which in practice meant writing a throwaway test inside Yozolab.DaerD.Tests — the one assembly InternalsVisibleTo names — and driving Unity through the test runner. That is a workaround, not an interface, and it answered nothing about whether the emitted C# was faithful. RecipeExport is the way in now, at three depths, because callers want different things. ToSource returns the text and touches nothing: reading a controller is a read-only act, so it must not create folders, queue assets or trigger a domain reload. ToDirectory writes the two halves anywhere on disk, including outside the project, so an analysis leaves nothing behind in Assets. ToProject is the full export and is what the window calls. Verify answers the question an automated reader actually has — it compares the controller against the IR of the builder the exporter drove while recording, so it shows that the code about to be read rebuilds the controller exactly, and it needs no compiler to say so. Only that facade and its four data classes are public. Making RecipeExporter public instead was the cheaper move and was dropped: its Result carries the replayed ControllerBuilder, and its GadgetPlan / AsyncSyncPlan are shapes the IR work keeps changing. Publishing them would freeze internals that have already moved more than once; the facade costs one mapping loop per export and keeps that freedom. The window was rewired through the facade rather than left alone. Two copies of "write the generated half, keep the hand half, add the asmdef, queue the asset" drift, and the copy that drifts unnoticed is the scripted one — nobody watches it. What stayed in the form is the one decision that needs a human: rewriting a pre-split single-file recipe replaces a file the author owns, so the dialog licenses it and the facade refuses by default (Options.migrateSingleFile, -daerdMigrate). An unattended export that reshapes a hand-written file is the failure mode worth being rude about. RecipeExportCli.Run is the -executeMethod entry; where -daerdOut points decides which export runs, and omitting it writes beside each controller. It exits the editor only in batch mode, so calling it from a running editor reports through the console instead of closing it. Scope of the tests: the 12 new ones cover what both writing routes share — the class name, the layer subset, the two halves, the re-export that leaves the hand half alone, and the command line's misuse rules. ToProject itself is not among them; it queues an asset that only exists after the next compile, so that path stays the window's and was checked by hand (project export, /Editor leaf, asmdef, both halves) rather than in CI. No behaviour change for anyone exporting through the window: same files, same names, same asset, same dialog. Recipes exported before this commit are untouched, and nothing about the generated code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wj7bAQvRKvhVTTNSgv6vpi Session: c51ae792-90c5-4710-bd79-29ff9d298a0d
Everything the package does was under a single "主な機能" bullet list, 266 lines of it, with three items running past fifty lines each (DynamicAnalyze, the async sync flags, the recipe API). Nothing above the fold said what the tool is for, and there was no install section at all — the one question a first-time reader arrives with had no answer in the file. The content was good; the shape made it unreadable. It is now seven sections behind a table of contents, with the deep material folded into <details>: read the headings for the shape of the tool, open a fold only when that feature is the one you came for. Enumerable things became tables — the analyzer's categories, the gadgets' frame costs, the copy/paste matrix, the default shortcuts — because a table is where a reader looks for "is mine in here", and prose is not. Claims were checked against the code rather than carried over, and three were wrong or short: - the analyzer was described with 8 categories; IssueKind declares 20. Exit Time 0, Dead Entry Branch, Terminal States, Duplicate Name, Empty Layer, Layer Weight, Missing Behaviour, Duplicate Condition, Clip Bindings, VRC Parameters and both AAP checks were missing. - the gadget latencies read as if the linear smoothing answered in one frame; AapGadgets.Latency returns 2 for it, 3 for the ranged reciprocal and 4 for the ranged divide and for Power, and Buffer is capped at 8. - I / O / P were listed as bare keys with no meaning, and F2, Ctrl+F2, Ctrl+D and Ctrl+Shift+V were absent though they ship as defaults. Three features had no entry: the Animation window integration (Select Sync and Preview), the blend tree Hierarchy panel, and the Home screen. Two more — parameter type conversion and cascade rename — appeared only in the one-line intro despite being the headline refactorings. Nothing substantive was dropped. The rewrite kept every claim the old file made and was checked by grepping the distinctive phrases (the reciprocal's 240 limit, the Hold/ shadow parameters, Back To Cycle Order, FramesBehind, the ghost overlay) back out of the new one. The tradeoff is the folds: text inside <details> is invisible to the browser's find-in-page until it is opened, so a reader searching the page for a keyword can miss it. The summaries carry the keywords for that reason. The alternative — leaving the detail inline — is what made the file unreadable in the first place. Left alone on the author's instruction: everything from "# Vibe Coding" down. Still duplicated: the docs branch carries a VitePress site with a page per feature, and this file now says much of the same thing twice. Linking the published site and shrinking the README to a landing page is the next step; it needs the site's public URL, which is not in the repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wj7bAQvRKvhVTTNSgv6vpi Session: c51ae792-90c5-4710-bd79-29ff9d298a0d
Select Sync ships ON, but only the checkbox was. BuildToolbar applied the default by assigning `_selectSyncToggle.value = true` while the toolbar was still detached — it is added to rootVisualElement only after BuildToolbar returns — and UIElements' BaseField<T> checks `panel != null` before dispatching a ChangeEvent, falling through to SetValueWithoutNotify when there is none. The registered callback never ran, AnimationWindowSync was never enabled, and clicking a State pushed nothing into the Animation window. Turning the switch off and back on repaired it for that session, because by then the toolbar had a panel and the event was dispatched — which is how the bug was reported: "it only works after I toggle it". The default is now applied in two explicit halves: SetValueWithoutNotify(true) for what the toolbar draws, and an explicit SetEnabled(_selectSyncToggle.value) in CreateGUI for the subsystem behind it. Doing that after _animationSync.Start() rather than inside BuildToolbar also means the first Sync() the enable triggers already has its subscriptions in place. Moving the assignment to just after rootVisualElement.Add(...) and leaving the callback to do the work was the smaller diff and was dropped. It works, but it keeps the initial state riding on event dispatch, so it stays correct only while the toggle happens to be attached at that moment — the exact condition that broke here, and one nothing in the file would restate to whoever reorders CreateGUI next. What it costs is a second place holding "default ON": the drawn value and the SetEnabled call have to agree, which is why the call reads the toggle instead of repeating `true`. The default itself did not change, nor the labels, nor what a click does. The Preview toggle is untouched — it ships OFF, so it never hit this, though it would have the day its default changed. Neither toggle is serialized, so existing projects, saved controllers and generated output are unaffected. The test asserts the visible consequence — opening the DaerD window opens the Animation window, which happens only inside SetEnabled — because the enabled flag is private state with no other observable. It fails against the old code with "the toggle reads ON but the sync was never enabled". It has to suppress log assertions: showing a window that hosts a GraphView logs a device error under -nographics, which does not affect the UI tree it inspects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCjxzSsy8nGaziQ52zuo7Z Session: e8cac289-2606-43f0-8e78-7929f8ee7ec4
…t of Rec Undo.undoRedoPerformed is global, so DaerDWindow.OnUndoRedo runs for undos that have nothing to do with the controller — including the Ctrl+Z that takes back a key the user just recorded. It asks the graph to rebuild; the finished rebuild raises GraphRebuilt; both Select Sync and Preview are subscribed to it. Select Sync then wrote the selected State's clip into the Animation window again. That write was assumed to be a no-op when the clip was already there. It is not. AnimationWindowState.activeAnimationClip has no equality guard: it assigns and calls OnSelectionChanged, which reaches AnimationWindowControl.OnSelectionChanged, which rewinds the playhead to frame 0 and calls StopPreview — and StopPreview calls StopRecording. So Ctrl+Z on one keyframe ended recording and moved the time cursor, and the user had to click Rec again to carry on. Both subscribers are idempotent now. Select Sync reads the window's current clip and returns when it already matches. Preview skips its off/on re-toggle when it is already previewing the same clip, and only when previewing could actually be read — an unreadable window re-toggles as before rather than silently doing nothing, since a stale pose is the worse failure of the two. Unsubscribing the two from GraphRebuilt was the obvious alternative and was dropped. A rebuild is also how a genuine change arrives — an undo that restores a State's motion, the wizard applying onto the layer, a recipe regenerating it — and dropping the subscription would leave the Animation window showing the old clip in exactly those cases, which is the capability the subscription was added for. Guarding at the write keeps it and costs one property read per rebuild. This was reachable before the Select Sync default was repaired, but only for users who had toggled the switch themselves; with the default now taking effect it would have been everyone's first Ctrl+Z. The two fixes are separate bugs and neither depends on the other, but they ship together for that reason. Scope: selecting a State whose clip differs still ends recording, because the Animation window genuinely has to open another clip — only the redundant write is gone. DaerD still rebuilds the whole graph on every global undo; that is untouched. Nothing about generated output, saved data or the recipe API changes. Two tests. One pins Unity's behaviour by writing back the clip the window already has and asserting recording stops: it is the reason the guard exists, and if a Unity upgrade ever makes that write harmless it will say so and the guard can go. The other drives a real Animation window into Rec, raises GraphRebuilt, and asserts recording survives; against the old code it fails with "enabling Select Sync dropped the window out of Rec mode". Both reach Unity's recording state over reflection and suppress log assertions, since the window logs a device error under -nographics. The Preview half has no automated test — its re-toggle runs behind two chained delayCalls, which EditMode cannot pump — so it rests on the same reading of Unity's source and is unverified in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCjxzSsy8nGaziQ52zuo7Z Session: e8cac289-2606-43f0-8e78-7929f8ee7ec4
… shows it The prefab link's model, its project sweep, and the tab badge that reads it. DaerD has only ever known a gimmick controller by itself: the .controller is the document, and the prefab that merges it into an avatar sits somewhere else in the project with no way back. Every question the object-gadget family is going to ask — where does this toggle's target live, which MA Parameters declares this parameter — starts by naming that prefab, and working it out fresh each time means walking every prefab in the project, which is not a thing to do while somebody moves a mouse (ADR 0028). So the answer is written down once, by hand. What is saved is a PIN rather than the link: the MA Merge Animator's own animator reference IS the link, and the pin only says which prefab and which merge to call this controller's home. It lives in GraphFrameData beside the parameter store, which is the shape the whole prefab side was designed around — measured with a headless probe, a sub-asset embedded in a prefab survives every re-save from a machine that HAS DaerD and is deleted by the first save from a machine that does not. So nothing of DaerD's is put into a prefab at all; the controller holds both ends of the link. The merge is held as a plain UnityEngine.Object and not as MA's type, the same bargain parameterStore makes. A field whose SHAPE depends on an installed package is a field that empties itself the day somebody opens the controller without that package — not unreadable until they reinstall, gone from the file on the next save. Untyped, the reference is inert and intact there, and the state reads as Unverifiable instead of pretending to know. References and not paths, also measured: a reference into a prefab survives the object being renamed, reparented and the prefab saved over itself, and the current path can be derived from it whenever it is wanted. A saved path string is wrong after the first rename. That is why deriving is the rule for everything this family will point at, and why the regression test renames and reparents a merge rather than trusting the note the probe left. Nothing normalizes the record. A reference that stops resolving is a sentence to say out loud — the prefab may be on a branch that is not checked out, MA may be uninstalled — so only Set and Clear write those two fields, and Clear tells an unset field from an unresolvable one by reference identity, since Unity's == reports both as null and skipping the write there would leave a dead pin the UI can see and nothing can clear. The two-stage walk over the project's prefabs — dependency table first, prefab opened only where the table already names the controller — moves out of the parameter store's sweep into a lazy enumerator the two share. Lazy is load-bearing rather than tidy: the store's sweep still stops at the first match, and the prefabs after it are still never opened. The link sweep deliberately does not stop, because its whole purpose is to show a person both prefabs when two of them merge the same controller. Alternatives dropped. A record inside the prefab was measured to work and thrown out anyway: it is deleted by one save on a machine without DaerD, and until then it shows an end user a Missing script in an asset they bought. Deriving the link from a sweep with no pin at all is less to save and less to keep honest, but it cannot answer which of two prefabs the person meant and it puts a project-wide walk on the repaint path. Saving the merge's path as a string would have given up nothing except correctness after a rename — what it would have bought is a line a human can read in a diff, which a fileID is not, and that is the price paid here. The badge is drawn only for a healthy pin. A mark that also appeared for a pin pointing at a deleted prefab would be stating the opposite of the truth in the one place with no room to explain itself, so the broken states are left to the home screen. PrefabLinkChanged exists because the strip is otherwise only rebuilt when a tab opens, closes or is switched; nothing raises it yet, and the first caller arrives with the home screen's Prefab section. Scope. Nothing here checks that the pinned merge is actually inside the pinned prefab, and a merge that lives only in a scene cannot be pinned at all — the sweep is over prefab assets, which is what a gimmick is for most of its life. Diverged is reported and never repaired. No prefab is written to, opened for writing or changed in any way: the sweep only loads. Nothing about generated output, saved gadget records or the recipe API moves. Compatibility: controllers saved before this read back with an empty pin, which is None, which draws nothing and asks nothing. The parameter store's own sweep answers exactly as before — the shared walk is the same two stages it already had — and its load counter now counts both sweeps, which is what the claim it backs was ever about. Tests: 1228 pass with the SDK and all four tools installed, against 1215 before; 13 new in PrefabLinkTests. Ten of them need a Merge Animator to exist and skip themselves by name where Modular Avatar is absent. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The pin landed with nowhere to set it from. This is the screen that sets it, the sentences it says when the link has gone stale, and the one thing that follows automatically from linking. The card sits under Controller rather than as a fourth slot inside it, because the two are different kinds of thing. The slots up there are assets a person hands DaerD and that is the end of it; this is a claim about the project that has to keep being checked, can go stale with nobody touching the controller, and when it does needs a sentence rather than an empty field. Every broken state is drawn as a named sentence: which prefab the merge went missing from, what the merge points at instead. None of them offers to repair itself — a merge re-pointed at another controller is somebody's edit, and which of the two is now the mistake is not something DaerD can know (the analyzer's rule, applied here). Scan is a button and only a button (ADR 0028). Its three answers — nothing, one, several — are decided by the model, in a plan object, rather than by whichever caller counted the list. That is not tidiness: linking touches two saved fields at once, and the confirmation dialog has to describe the same decision the write is about to make. One object answers both, and a test can read it without a dialog on screen. The second field is the parameter store slot, and it is filled ONLY when it is empty. A gimmick prefab already carries the MA Parameters that declares what the controller uses, so linking the prefab and then hunting for that component by hand is a step with one possible answer. But a slot somebody filled themselves is an answer, and replacing it while they pressed a button labelled "link a prefab" would be DaerD editing something it was not asked about — so a differing store is offered as a button and the dialog says which of the two cases this is before anything is written. Rejected. Editing the store's rows here was dropped as out of scope for this wave: the Parameters panel is already that surface, it is in the left column and visible from this screen, and it knows about effective names, the budget and the sync diff — a second list would be a second implementation of all three. Which is also why there is no "jump to the Parameters panel" button, though the plan asked for one: the panel is never not on screen, so there is nowhere to jump. What that costs is discoverability — somebody reading the summary line has to know the panel beside them is the place to edit it — so the summary's tooltip says so. Auto-adopting the prefab's store on link, or scanning when the screen opens, were both dropped for the same reason as always: a project-wide walk is not a thing to do while somebody moves a mouse, and an association DaerD picked by itself is the one nobody can find later to correct. The candidate menu replaces slashes in its labels. GenericMenu reads one as "start a submenu", and both halves of what identifies a candidate are paths — the prefab's, and the merge's inside it — so the picker would otherwise be a folder tree of one-item branches. Scope. A missing prefab cannot be named, and that is a real cost of the design rather than an oversight: naming it would mean having saved its path, which is exactly what dies on the first rename. Nothing checks that the pinned merge is inside the pinned prefab. Clearing the object field asks the Unlink question rather than silently dropping the link. No prefab is written to by anything in this commit — MA Parameters is only ever read, and adding one comes next. Compatibility: nothing new is saved. The pin and the parameter store slot are the two fields that already existed, and a controller with no pin draws the card with "not linked" in it. Generated output, gadget records and the recipe API are untouched. Tests: 1234 pass with the SDK and all four tools installed, against 1228 before; 6 new — the three scan answers, and the store slot being filled when empty, left alone when full, and left empty when the prefab declares nothing. Five of the six skip themselves by name where Modular Avatar is absent. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The first thing DaerD writes into somebody's prefab, and the shape the rest of the prefab-side work will follow. A gimmick prefab that merges a controller but declares nothing has no store to point the parameter slot at, and the fix by hand needs a person to already know Modular Avatar's lookup rule: it reads a merge's parameters from the merge's own object or the nearest parent that has them. Put the component beside the merge and it declares for that merge alone; put it on the root and every Merge Animator in the prefab sees it, including ones added later and ones several objects down. So the root is where this puts it, and the dialog says why — the rule is the reason the button can exist at all, not an implementation detail. Refusals are decided before anything is opened, not discovered halfway through: a prefab inside a package the package manager owns (a registry version, a git dependency, a tarball — the write is thrown away by the next resolve, and a change that vanishes without saying so is worse than one that never happened), and a prefab open in prefab mode with unsaved edits, where writing the file underneath the stage means one of the two sets of changes is about to lose. Embedded and Local packages are writable and refused by nobody: a VPM project keeps its dependencies as real folders, and a gimmick being developed in one is a normal thing to be editing. The verdict is a function over facts — path, package source, whether a dirty stage has this asset open — rather than over the project, which is what lets the registry-package case be tested at all. There is no way to install an immutable package on demand inside a test, and that is precisely the case nobody would otherwise notice was wrong. The write itself is load the contents, change them, save once. Saving an asset cannot be undone, so the sentence naming what is about to be added IS the undo, and a half-applied change is not a state this can end in. Adding the component straight onto the loaded prefab asset was not an option: Unity does not allow structural edits there, which is the same reason the design settled on this shape before any of it was written. MEASURED, and the reason this commit does not carry a second write path: editing the VALUES of a component that lives inside a prefab asset already reaches the file through the ordinary parameter store, which marks the component dirty and saves the asset it belongs to. The spec allowed for that not being true and for values having to be routed through a prefab-contents round trip as well; a test adds a declaration, writes rows through the store, reimports the prefab from disk and reads them back, and it passes as it stands. So there are two ways into a prefab and they stay different in kind — structure through PrefabWriter, values through the store — exactly as they already are for a component in a scene. Alternatives dropped: adding the component beside the merge (loses every other merge in the prefab and asks the same question again next time); adding it as part of linking (a button labelled "link a prefab" that writes a file is the thing this whole design is trying not to be); building an undo for the write (there is no undo for an asset save to build on, so it would have to be a copy of the file, which is a backup system nobody asked DaerD for). Scope. Only the prefab stage that is open right now is asked about, so drilling into a nested prefab sees the innermost one — stated rather than worked around, because a wrong answer there is a refusal and not a loss. Prefab variants get no special handling: the component is added to whatever asset was linked. Nothing here removes a component, and the sweep behind it still only ever loads. Compatibility: a prefab gains exactly one component and nothing else moves — the test asserts the objects are where they were, the merge still merges what it merged, and the pin that pointed INTO the prefab is still healthy afterwards (fileIDs survive the contents round trip). Controllers, saved gadget records and the recipe API are untouched. Prefabs nobody presses the button on are not opened. Out of P1 entirely, and named here so the boundary is on the record: object gadgets, the toggle UI, MA Menu, recipe changes, inline editing of store rows, pathMode=Absolute, and pinning a Merge Animator that lives only in a scene. Tests: 1240 pass with the SDK and all four tools installed, against 1234 before; 6 new — the refusal table, the fact-gathering half of it, the write leaving the prefab otherwise alone, a second press having nothing to do, and the value round trip through the file. Three of the six need Modular Avatar and skip themselves by name where it is absent. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…enerated The object gadget family starts here: generated things whose subject is an OBJECT in the gimmick prefab a controller is pinned to, rather than a parameter. One kind so far, the toggle, and it is the toggle that motivated the shape. WHAT WAS WRONG. The old ToggleBuilder took hierarchy paths a person had typed against a scene, built clips and a layer, and recorded nothing. So there was no Edit, no Regenerate and no Delete, the home screen had nothing to list, and the wizard had been unreachable from any menu for two releases (ADR 0036). Recording the request as it stood would not have fixed it either: a scene path is not something a gimmick prefab can be rebuilt from, and it dies silently the first time somebody renames an object. THE MECHANISM. A record in GraphFrameData (ADR 0013's holder), keyed by the parameter it drives, holding its targets as REFERENCES into the pinned prefab. Paths are derived from the target and the merge every time the gadget is applied (ADR 0044): measured in a headless probe, such a reference survives renaming, reparenting and re-saving, and a saved path does not. Ownership on the animator side is the record's own references — the layer it added or the tree it hung, the clips it generated — and sweeping removes exactly those (ADR 0045). The driving parameter is the one thing removed by name, and only when createdParameter says this gadget is what put it there, so two gadgets sharing a switch keep working when one goes. Clips are sub-assets of the .controller, with the flush at the end of the batch (ADR 0011) — without the SaveAssets + ImportAsset a new clip is invisible to the Project window and to anything reading the imported artifact. Validation refuses by name and never guesses: an unpinned controller, each broken pin state as itself, a merge set to Absolute paths (design: only Relative is supported, because with Absolute the prefab alone cannot say where its objects end up), a target outside the merge's subtree, a component type this project does not have, a parameter of the wrong type for the wiring, and a second gadget on a parameter that already belongs to one. Declaring the parameter goes through the existing ParameterStore so NDMF's effective names stay one code path's business; a gadget asked to declare with no store set is NOTED rather than refused, since it works perfectly well and simply reaches no avatar. The row is added synced (a toggle nobody else can see is not a toggle, the same call the async sync setups make) and unsaved (whether a gimmick comes back on at the next login is a preference, and the Parameters panel has that column). WHAT WAS DROPPED AND WHY. ToggleBuilder's public face — Request, Validate, Apply and SaveClipBesideController — is gone, along with the window that drove it, and ToggleBuilder is now the generation core: derived paths in, clips and wiring out, nothing owned or saved. Keeping the old face beside the family was the obvious cheaper move and was rejected: two entry points into one generator drift apart about what a toggle is, and the old one is the one that cannot be recorded. Two capabilities went with it. Toggles can no longer be built against a scene hierarchy at all — a controller must be pinned to a prefab first, which also means the feature does nothing in a project without Modular Avatar. And clips are no longer written as .anim files beside the controller; they are sub-assets, which is where every other generator's output lives and what lets a sweep own them, but it is a change of file layout for anyone who was relying on the old one. Nothing that ToggleBuilder built in the past is adopted: those toggles have no record, and inventing one would mean guessing which objects a path meant. WHAT THIS DOES NOT PROMISE. pathMode=Absolute is refused, not approximated. The `placed` list is reserved and empty — no kind places anything inside a prefab yet, and the machinery to apply and sweep that arrives with the first kind that needs one. The `written` ledger is kept from this commit on, including for clips DaerD owns outright, but nothing consumes it yet: user-supplied clips and the difference-application it exists for are the next commit's (ADR 0046). There is no wizard and no home screen entry in this commit — the model is complete and reachable only from tests until the UI lands. COMPATIBILITY. objectGadgets is a new list on the holder, so controllers saved before it deserialize with an empty one and are untouched; nothing reads or rewrites the other lists. Generated controllers are unaffected — no existing layer, clip or parameter changes shape. Recipes do not mention object gadgets yet, so no exported script changes. The one breaking change is to DaerD's own API surface: ToggleBuilder.Request no longer exists, and the three tests that drove toggles through it now drive the core directly. TESTS. 1260 pass with the SDK and all four tools installed (1240 before). The 20 new ones write a real prefab and a real controller to disk, because the two claims worth most cannot be made in memory: that a target survives being renamed inside the prefab and the curve follows it, and that the generated clips end up inside the .controller where the Project window can list them. All 20 skip by name where Modular Avatar is absent, which is honest — without it a controller cannot be pinned and a toggle has nothing to be relative to. ToggleBuilder's own 20 tests stayed 20: the validation cases moved to the model, and rows, regeneration handles and unresolvable bindings took their place. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The model landed in the previous commit with no way in but a test. This is the way in: a wizard that picks its targets out of the linked gimmick prefab, and a card on the home screen that lists what a controller carries. THE WIZARD. ObjectGadgetWindow replaces the toggle wizard that was deleted with the old builder. The difference that matters is where a target comes from: the old one had a "Path Root" slot and a text field per row, so a person typed paths against whatever scene happened to be open. Here the pinned merge is the frame of reference and the picker offers exactly the objects under it, as a GenericMenu that mirrors the hierarchy — the derived path already contains '/', and GenericMenu reading that as "open a submenu" is for once exactly what is wanted. (The prefab link's candidate menu replaces its slashes for the opposite reason: there the '/' belongs to an asset path that has to be read as one string.) What is stored is the reference; the path is derived again on every generate. Rows hold the saved binding records themselves rather than a set of checkboxes, and that is the one design decision in the window worth stating: a binding it draws no button for — a PhysBone in a project without the SDK, a component a later version of DaerD writes — travels through an edit untouched instead of being dropped by a form that only knew about its own boxes. The same rule covers a target whose object has gone missing: it comes back as an empty row to be removed or replaced, never silently forgotten. A pin that is not healthy leaves the window with nothing to offer, so it says which state the link is in and stops, rather than showing controls that would refuse one by one on being pressed. THE CARD. "Object Gadgets" sits beside "DBT Gadgets" and not inside it. They are two families: these regenerate against a prefab and mean nothing without a healthy pin, while a DBT gadget is arithmetic over parameters and cares about no prefab at all — one card would need a heading that is true of half its rows. Each row is the record read back (name, kind, wiring, how many objects) with Edit and Delete, and Delete names what goes with it: the layer or the tree, the generated clips, and the parameter only when the gadget is what created it. That sentence is built from the record's own references — the same ones the sweep removes — so the dialog cannot promise something the sweep leaves behind, or stay silent about something it takes. WHAT WAS NOT DONE, AND WHY. There is no Delete inside the wizard, unlike the DBT gadget window: that window is also where its gadgets are listed, and this one is opened on a subject the card already picked, so the card is the one place a gadget is deleted from. There is no Select button on the row either, though its sibling card has one — the row already carries three notes, and the gadget's layer is named after the gadget in the layer list beside it. The wizard also offers no name for a new DBT host layer (it is "DBT", uniquified): the record holds a machine reference rather than a name, so a name field would be a fourth input that nothing keeps. Only the record round trip is tested — a config in through LoadConfig, a config out through BuildConfig — which is the path a Regenerate runs through and where a dropped field becomes a rebuilt layer that animates one thing less. The drawing needs an IMGUI event loop and is not tested, the same line AsyncSyncFormTests draws. Bind, LoadConfig and BuildConfig are internal for that; they are the window's own path, not a door cut for the test. COMPATIBILITY. Nothing saved changes shape: the card and the wizard read and write the objectGadgets records the previous commit defined. ja.po loses the fifteen strings that belonged to the deleted wizard (its path root, its scene drops, its selection button) and gains the family's; the ones both wizards say — the component chips, the blendshape rows, the parameter notes — are kept as they were, so the translations they already had are not re-orphaned. The stale comment in the parameters panel about Object Toggle "recording nothing, so there is nothing for a management surface to list" is gone: it does now, and the surface is on the home screen. TESTS. 1268 pass with the SDK and all four tools (1260 before). The 8 new ones need no prefab and no Modular Avatar — moving a record through a form is the same work wherever the targets live — and cover the round trip, the binding the form has no button for, a target whose object is gone, the host layer coming back as the same machine, and what the delete preview enumerates (what it lists, not how it is worded: the wording is translated, the list is a claim about behaviour). Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
Object gadgets minted both of their clips as sub-assets of the controller, which is the right default and the wrong only option: a gimmick author who already has a hand-animated ON pose had no way to point a toggle at it, and copying those curves into DaerD's clip means maintaining them twice. A record's two clip slots now take an asset. When one holds a clip DaerD did not make, the generate writes THIS gadget's rows into it and leaves every other curve alone, and the ledger the record already kept becomes load-bearing: regenerating erases exactly the rows the last generate booked, then writes the ones it needs now. That is what a rename needs — the path derived today is not the path that was written, so a clip cleaned by "what the gadget animates" would keep the stale row forever, holding an object nobody can find at a value nobody set. ToggleBuilder.Write is the loop BuildClip already ran, split out rather than copied so the two kinds of clip cannot drift apart about what a toggle is; FindCurveType is FindComponentType plus GameObject, because the m_IsActive rows are most of the ledger and GameObject is not a Component. A row already in a supplied clip that no ledger claims stops the generate by name, with the clip and the row in the sentence. Overwriting it and warning was considered and dropped: an overwritten curve leaves nothing behind to say what was there, so the person who lost it cannot tell what to restore. Sharing one clip between two gadgets stays legal — each owns the rows it wrote, and only the same row claimed twice is a conflict — because that is how one clip becomes the ON pose of a whole outfit. One clip in both slots is refused for the opposite reason: the two sides key the same rows with different values, so it would hold whichever was written last. Trade-offs. DaerD never deletes a supplied file, so removing a gadget can leave an empty clip behind — an empty asset is recoverable and a deleted one is not. Ownership is the record's flag alone: a clip that stops being pointed at loses its rows on the way out (that is the switch working), and a clip DaerD generated is never offered back in the slot, or it would become a clip nothing sweeps. Curves are matched as (path, type name, property), so an object-reference curve at the same address is invisible to the check, and a booked row whose component type this project no longer has is left in place rather than guessed at. Undo covers the writes with RegisterCompleteObjectUndo per clip, which is all Unity offers for an asset edit; the prefab is not touched at all. Compatibility: saved records are unaffected — an empty slot means "generate one", which is what every record written so far says, and their clips stay DaerD's sub-assets. The delete dialog now names supplied clips apart from generated ones, since what happens to them is different. Tests: 1276/1276 with the VRChat SDK and all four tools installed; the object gadget files skip themselves where Modular Avatar is absent. Eight new: rows written beside a user's own, no stale row after a rename, the named refusal, one clip in both slots, two gadgets sharing a clip and sweeping only their own rows, the switch in both directions, and the form's round trip of a supplied slot. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…of writing it
The object gadget family had no recipe API at all, which left the one family
whose subject is a prefab outside the one mechanism DaerD has for describing a
controller as text. Two shapes were rejected on the way here. A gimmick recipe
that BUILDS the prefab — places objects, adds components — would be an
implicit write path into an asset a person opens, edits and ships, and the
prefab side of this feature is explicitly an explicit, batched, refusable
operation from the editor. A separate manifest format would split "how this
controller is reproduced" across two file formats that drift.
What is here instead: c.Objects() rebuilds the controller side — layers,
trees, clips, parameters, records — and reads the prefab as a statement to be
checked. An unhealthy pin builds nothing and says which state the link is in.
A target the prefab does not have stops the run with EVERY missing target
listed, not the first: re-pin a controller at another copy of a gimmick, run
the recipe, and what comes back is the port's to-do list. That list is a
by-product of refusing to build half a gimmick, which matters because a toggle
wired to an object it never found is a switch that silently does nothing and
looks exactly like a working one.
Targets are paths where the record holds a reference. Source code cannot carry
a reference, so a path is what a call takes, it is resolved inside the pinned
prefab at run time, and what the record ends up holding is the reference that
lookup found — after which a rename moves the gadget as usual (ADR 0044). What
was given up is that a rename now breaks the recipe TEXT, by name, on the next
run; the alternative was a recipe that silently animates a different object.
User clips are named by asset path for the same reason, rather than through
the serialized asset fields every other clip in an export uses: a field would
have to be assigned by hand on the recipe asset before the code mentioning it
could run at all.
Declaring is opt-in here and on by default in the wizard. A gimmick's
parameter store is usually an MA Parameters INSIDE the pinned prefab, so a
recipe that declared by default would write to somebody's prefab as a side
effect of generating a controller; .Declare() makes that a line of code that
says so. Nothing else in the step touches the prefab.
The exporter writes a layer back as calls only when every state or tree child
in it has a record to stand for it AND every target still resolves — otherwise
the raw states come out, with a named warning. A layer holding both a DBT
gadget and an object gadget therefore exports raw from both planners' point of
view, which is two true sentences rather than one call that drops the other
half.
Limits, named: a toggle deleted from a recipe is not swept from the
controller, because a post step that removed "everything I did not declare"
would delete gadgets made in the wizard on the same controller — delete those
from the home screen. Tree-wired toggles create a layer called "DBT" when
there is none, so a recipe that also calls c.Gadgets("DBT") has two post steps
claiming one layer name; give one of them another name. Verify and Compare
still cannot see post steps, and now say object gadgets out loud alongside the
other two.
Object gadget records deliberately stay out of RemapMachineReferences, checked
against the real behaviour rather than assumed: a Layer-wired gadget IS its
layer, so re-pointing its record at a machine a recipe just built in its place
would make the next regenerate sweep that recipe's layer. Losing the record —
pruned on the next read — costs one gadget to rebuild, which is the cheaper
mistake. A test pins the decision so it does not read as an omission.
Compatibility: saved records, existing recipes and exported code are
unaffected; a controller with no object gadgets exports exactly as before. A
controller that HAS them and is exported without Modular Avatar installed
falls back to raw states with the pin named, which is the honest answer where
the pin cannot be read at all.
Tests: 1288/1288 with the VRChat SDK and all four tools installed. Twelve new
— building against the pin, regenerating in place, tree toggles sharing one
layer across runs, the unpinned stop, every missing target listed at once, a
clip resolved by asset path and a missing one stopping the run, declaring as
opt-in, the export's calls and what it leaves undeclared, the replay onto
another copy of the gimmick, the raw-state fallback, and the remap decision.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… belongs to The store column showed a parameter's synced and saved flags and said nothing about the third field that decides what the row costs. Changing a Float row to a Bool — the ordinary move when an avatar is over budget, since VRChat converts on the way in and eight bits become one — meant leaving DaerD for the asset or the MA inspector, then coming back to see whether the meter moved. The type is a popup beside S and D now, and the bit meter above is read from the store on the same repaint, so the saving shows up as it is made. The write goes through ParameterStore.SetValueType, which lands in each backend's own field (the VRC asset's valueType, MA's syncType) — the one route, so nothing learns a second way to say what a row is. A row whose type differs from the animator parameter gets a "≠" and a sentence, not a warning. The difference is invisible everywhere else and consequential either way: it is a documented saving as often as it is a slip, so the mark speaks in the analyzer's voice about the same fact (parameter mismatching) rather than telling anyone off. Colouring it as a problem was considered and dropped for that reason. An MA "NotSynced" row gets no popup at all. It declares no type, so the control would have to invent one to have a value to show, and writing that back would declare a parameter nobody asked for and charge the avatar bits for it — the same rule SetSynced already keeps by refusing to switch such a row on. SetValueType refuses it at the model too, so the rule does not live only in the drawing. localOnly is untouched by a retype: a declared-but-local row keeps both its type and its locality. Trade-offs and scope: "+" still adds a row with the animator's own type, which is the right guess for a parameter somebody deliberately picked. Nothing migrates or re-checks existing rows, and no default value is converted with the type — a Float row at 0.35 retyped to Bool keeps 0.35 in the store, which is what the asset would have kept too. Triggers have no store type and are unaffected. Compatibility: no saved data changes shape; this only edits a field both backends already had. Tests: 1291/1291 with the VRChat SDK and all four tools installed. Three new: the retype and the budget following it on the VRC backend, the same call reaching MA's syncType while leaving localOnly alone and refusing a NotSynced row, and the mark's rule — typed rows only, and nothing to disagree with for a Trigger. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…a reimport ComplexControllerTests.AHandBuiltDbtLayer_SurvivesTheGadgetStepRebuildingItsOwn failed roughly one full-suite run in five and always passed alone. The gadget step saves and reimports the controller, and a reimport occasionally hands back fresh managed wrappers for the same serialized objects; Assert.AreSame across that boundary was comparing wrapper identity, which is not the claim the test makes. The claim is "kept, not swept and rebuilt", and the identity that survives a reload is the local fileID — a rebuilt state gets a new one, so the detection the test exists for is intact. The hand objects are saved once before the fileIDs are read (an in-memory object has none), which also removes the test's dependence on the gadget step being the first thing to save them. Not taken: retrying the AreSame assert (hides the mechanism instead of naming it), and pinning the wrapper behaviour itself (Unity does not promise it, so a test asserting it is a test of the weather). The wrapper-identity check is the one capability given up: a hypothetical bug that rebuilt a state while reusing its fileID would now pass — no current code path can do that, since sweeping destroys the object and Unity assigns fresh fileIDs to new sub-assets. The flake predates this branch (the test is unchanged since 0.11.0); nothing about the recipe or gadget code moves. Tests: 1291 pass with the SDK and all four tools, including this fixture alone and a full run either side of the fix. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…hans
Until now the only way to be rid of a saved sync setup was to delete its main
layer by hand and let the record be pruned on the next read. That answers for
the cycle's own layer and for nothing else: the Ready and Stale watchers and
one commit layer per group stayed behind, still evaluated every frame, and the
generated parameters stayed with them — the synced ones among them going on
costing bits on a setup nobody was using any more. The Home screen's Async Sync
rows now carry a Delete beside Select, mirroring the DBT and object gadget rows.
What a setup OWNS is the whole of this change. The layers are the four the
record holds by reference (the cycle's own, both watchers, one per group), and
a null one is a layer somebody already deleted and is skipped in silence —
reference and never name, so a renamed layer is still this setup's and a layer
that merely answers to the generated name is not. The parameters are the ones
the five enumerators EnsureParameters itself calls name, asked over the request
AsyncSyncBuilder.FromConfig rebuilds from the saved setup. That reconstruction
is the one the per-state sync request and the layer panel already regenerate
through, rather than a second reading of the saved data, so a delete cannot
describe a different setup than a regenerate would.
IsLocal is outside those five deliberately and stays: it is the one name async
sync shares with every other network-aware layer on the avatar, and the direct
sync builder writes it too. The multiplexed targets stay for the plainer reason
that they are the user's own parameters and the reason the setup existed. Both
are pinned by tests, because they are the two mistakes that would be silent and
expensive to make.
Two things are left alone on purpose. The Empty clip is shared between setups
and may be one the user designated — the same line the builder already draws
when it refuses to replace a designated clip. The declaration rows in the
parameter store are not written either: an orphaned row is already surfaced by
the Sync window's own remove list, and reaching into an asset outside the
controller on the strength of a name is not something a delete should do.
Not taken: sweeping every parameter under "base/". That would also catch the
Seen and Fresh bits of a setup whose slots have drifted, which is exactly what
enumeration can miss — but it would just as readily eat a parameter the user
happens to have named under that prefix. The namespace is a convention the
builder keeps, not a claim it can prove, and removing more than the build
created is the worse of the two failures.
The sync requests aimed at the setup go through SyncRequestBuilder.Remove
rather than straight at the record. The record is only the authoring half; the
runtime half is a Parameter Driver raising "base/Req/target" flags this delete
is about to remove, and a driver left setting a parameter that no longer exists
is precisely the orphan this commit is closing. Requests aimed at another base
name are not this delete's business and are left standing.
Scope of the guarantee: Seen and Fresh are named after SLOTS, and slots are
derived from the targets and channel counts as they are NOW. A setup edited
underneath itself — a multiplexed parameter deleted by hand, channels widened
without regenerating — batches differently than it was built, and the bits of
the old slots are left behind; regenerating before deleting puts the two back
in step. Nothing here validates the setup first, on purpose: a delete has to
work on a setup that no longer builds.
Compatibility: no saved shape changed and existing data deserializes exactly as
before. GraphFrameData gains an explicit RemoveAsyncSync beside SaveAsyncSync —
matched by record identity first and by layer second, so it can be called while
the layer it is keyed by is still there. Pruning is unchanged and still answers
its own question ("somebody deleted this layer"), which is not the same question
as "this setup is gone". No recipe API moved, nothing regenerates differently,
and a controller with no asset to hold the record has no row to press Delete on.
Tests: 1298 EditMode tests pass with the VRChat SDK and all four embedded tools
installed, 7 of them new — every kind of layer and every generated parameter
going; IsLocal, the targets and the Empty clip staying; a second setup on the
same controller untouched; a hand-deleted watcher not stopping the fold; the
sync requests of one base name swept and the others left; and the delete
dialog's sentence held against the two lists the sweep itself uses. Not run
without the SDK: no driver or behaviour code changed, and the new tests skip
drivers throughout, so there is no type the two configurations could disagree
about.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… is regenerated An avatar's Direct blend tree layer is shared property: DBT gadgets, tree-wired object toggles and hand-built children all hang off one root tree. The export asked about the layer, not about the children — "does EVERY child have a call to stand for it?" — so one unaccounted child sent the whole layer to raw states. That is where the loss came from, and it is a chain of four steps that are each defensible on their own. Raw states go into the recipe's DECLARATION. The next Generate rebuilds a declared layer from scratch. The saved records point at the state machine and the tree that were just destroyed. They are pruned on the next read. What the toggles do survives as anonymous states; that DaerD made them does not, and nothing looks broken while it happens. Each planner now claims only the children it can write back, and the layer is declared as a raw tree holding what nobody claimed. Three shapes come out of one rule: nothing claimed is the old raw layer unchanged; everything claimed is calls alone, as before; a mixed layer is BOTH, the remainder declared and the calls adding their own children back on the next Generate. The two post steps had to learn to share the layer for that to hold — the DBT gadget step no longer takes away a layer the recipe declares or one holding object gadget trees, and sweeps its own children out of it one saved record at a time instead. And a tree-wired toggle joins a layer standing under the recipe's name when neither the run nor a record hands it one, which is what a port to a second gimmick is; `c.Objects()` takes that name, mirroring `c.Gadgets()`. Not taken: re-pointing the object gadget records at the rebuilt machine, the way async-sync setups and DBT gadgets already follow theirs. It would have saved the records without touching the exporter, and it fails exactly where it matters — the layer is rebuilt AROUND a tree-wired gadget, so the record would survive pointing at a tree that is gone while a copy of that tree stands in the layer, and editing the gadget would add a second child beside the copy. Losing the record is the cheaper mistake, and the fix is to stop the layer being declared with the gadget inside it at all. Trade-offs. A split layer loses its child ORDER: the declared remainder is built first, then each post step's own children. Direct children sum, so this only matters between two children writing one parameter, and such a chain lives inside a single planner's claim where the order is kept. A record that does still get destroyed loses its generated clips as orphaned sub-assets of the controller, which is what already happened on the raw path and is not addressed here. Layer mode stays all-or-nothing — a Bool toggle IS its layer and has no children to claim. The Reshaped check survives, asked only about a gadget's own machinery. Two Direct layers of wizard-made toggles still export as calls that gather them into the first: one recipe builds one shared layer, and the call carries one name. Compatibility: saved records, prefabs and generated controllers are untouched — this changes what an export writes and where two post steps put their children. Recipes exported before it still generate; the layers they declare raw stay raw, and re-exporting is what turns them into calls. `Objects()` gains an optional layer name and keeps its old meaning without one. Tests: SDK + all tools, 1303/1303 (baseline 1298, five added). The mixed layer exports as call + remainder, regenerates with the record alive and the hand child intact, AAP gadgets and toggles round-trip into one layer and regenerate in place there, and a shared layer under another name is named on the call. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…f dropping them quietly Claiming a shared blend tree layer child by child keeps a recipe's own gadgets out of the declaration, but the raw fallback is still there for what cannot be written down as a call — an unhealthy pin, targets gone from the prefab, a layer somebody reshaped by hand. Generating such a recipe rebuilds the machinery those saved records were built into, and the records are pruned. The controller goes on doing exactly what it did, which is the whole problem: nothing surfaces, and the only evidence is a gadget that quietly stopped being listed on the home screen. ControllerRecipe.Generate now lists the object and DBT gadget records living in the machines its declared layers are about to replace, and after the run reports, by name and grouped by layer, the ones that are no longer there. The message says what survived (the states) and what to do (re-export, or rebuild from the home screen). Before-and-after rather than a check up front, because up front is not knowable: the post steps have not run, and they are what re-register everything a call rebuilds. A guard placed in ControllerIRBuilder.Rebuild — the other candidate, and where the layer rebuild actually happens — would have cried wolf on every mixed layer, warning about records the Gadgets/Objects calls two lines later put back. Reading the records again afterwards costs one pass over two small lists and can only be wrong in the direction of silence. Scope of the claim. It only looks at layers the recipe DECLARES: a record swept because a gadget was taken out of a Gadgets() chain is a deliberate removal and says nothing. It reports loss, not cause, so a record destroyed by a failing post step reads the same as one destroyed by the declaration — both are losses, and naming them is the point. It cannot give the gadget back; that needs a re-export of the layer, which the sentence asks for. Compatibility: warnings only. Saved data, generated controllers and the recipe API are untouched, and a recipe that loses nothing is as quiet as before. Tests: SDK + all tools, 1305/1305 (two added). A tree-wired toggle whose target left the prefab falls back to raw, and generating that declaration names both the layer and the gadget; a mixed layer whose call rebuilds the toggle stays silent. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…efab it comes from The store slot appeared three times on one screen: a row in the home screen's Controller card, the same row again above the parameters panel, and a read-only summary of the same association in the Prefab Link card. Four buttons hung off it — Detect, In Prefabs, Add All, Sync — and the report from using it was that the block was unreadable. Two of those four name the subject rather than the action, and one of the two does the opposite of what it says. The assignment surface is now the Prefab Link card and nowhere else, because the pin is what answers the question: for a gimmick the store is the MA Parameters above the linked merge nearly every time, and "Use The Prefab's MA Parameters" is one button away only while the slot and the link sit together. The parameters panel keeps the half that is about the list on screen — the synced-bit budget and the two bulk actions — and a controller with no store gets a sentence naming where the slot went instead of a second copy of it. Both bulk buttons are renamed for what they do. "Add All" declares rows that are deliberately neither synced nor saved, which is not what either word led anyone to expect; it is "Declare Missing", and its tooltip states that default and how it differs from the per-row "+". "Sync" synced nothing — it opened a comparison window — so it is "Sync List". WHAT WAS DROPPED. "In Prefabs" swept every prefab in the project for a merge of this controller and silently took the MA Parameters above it. It is gone, and with it ParameterStore.DetectInPrefabs, MaStore's sweep and the per-controller answer cache behind it. The argument that put it there still holds — a gimmick spends most of its life as a prefab in no scene at all, so the scene search answers "nothing" about exactly the controllers it was for — but Scan in this same card now answers that better: it walks the same prefabs through the same PrefabAssetSweep, and it comes back with the prefab NAMED, offers a choice when several match, and fills the slot from a link somebody confirmed. What is genuinely lost is the one-press path for somebody who wants the store without the link: they now go through the link dialog, or drop the component in the slot by hand. Lost with it is that sweep's memory of "answers, including none" — the link keeps a cache of its own, so nothing is swept twice; it is simply no longer cached for a question nobody asks. TRADE-OFFS. Detect still searches the SCENE only and still only on a press (ADR 0028) — nothing here starts a sweep on its own, and Detect's tooltip now names Scan as the button for the prefab-only case rather than leaving that gap silent. The slot is drawn whether or not the pin is healthy, so a VRC avatar controller that will never have a prefab link still has somewhere to put its Expression Parameters asset; only the linked-store half below it is withheld when the pin cannot be read. Nothing keeps the slot and the link in agreement: a store somebody assigned by hand stays assigned, exactly as before. COMPATIBILITY. No saved data changes shape or meaning. The association is the same GraphFrameData field it has always been, so a controller whose store was filled in by the old button keeps it. The postprocessor that dropped both project sweeps' memories now drops the link's alone and has moved next to it as PrefabLinkImportWatcher; the only visible difference is that an import no longer clears a cache that no longer exists. TESTS. 1302 of 1302 with the VRChat SDK, Modular Avatar, NDMF, Gesture Manager and Av3Emulator installed (1305 before). ParameterStorePrefabTests loses the four tests that drove DetectInPrefabs — what they pinned about caching and about the dependency table keeping unrelated prefabs unopened belongs to the shared walk, which PrefabLinkTests already asks the same two questions of — and keeps the one claim that outlives the button: a write through a store inside a prefab asset reaches the FILE, not just a dirty asset that dies at the next domain reload. A second test there puts the old scene-vs-project question to the surviving route, ParameterStore.StoreFor, which is what the link uses. PrefabLinkTests reads its load count off PrefabAssetSweep directly now that ParameterStore no longer forwards it. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…ot and editor are gone DaerD carried a second editor for somebody else's asset: an Expressions Menu slot in the home screen's Controller card, a Menu tool card beside the analyzer and the clip index, and VrcMenuWindow behind it — breadcrumb navigation through submenus, a control list with reorder, and an inspector for the selected control. It is removed by the user decision of 2026-08-18, and the decision is about SCOPE rather than about that implementation: an avatar's menu tree belongs to MA Menu, and a second editor for it is a second place the same tree gets edited from, with none of the merge semantics that make the other one the real answer. The home-screen work had already put this window on hold rather than inlining it (its root menu is serialized on the window, among other reasons); ending it is what that hold was waiting to hear. WHAT WAS LOST. Editing a menu from inside DaerD, and with it the one thing that editor did which the alternatives do not: it checked each control's parameter against the controller AND against the controller's parameter store, so a puppet bound to a Bool row, or a control naming a parameter nothing declares, was visible while looking at the menu. Nothing replaces that — the analyzer reads controllers and stores, not menus — and it is not coming back as a report, because reading a menu means being handed one, which is the association this same commit stops asking for. CODE AND ALL, NOT JUST THE ENTRY POINT. The other option was ADR 0036's: leave the window and take away the button. It was refused here because a window nobody can open is still a window that has to compile, still owns the 15 UI strings the catalogue check keeps demanding a translation for, and is never exercised — and an unexercised path is an unverified one. The strings left ja.po in the same commit that took their call sites away, which is what makes LocalizationCoverage's two directions meaningful as the proof that the removal was complete. WHAT STAYED, AND WHY. GraphFrameData.expressionsMenu is untouched: saved data is not rewritten to suit a UI change (ADR 0016). Its setter is gone — nothing assigns the association any more — and the getter is not, because two readers that were never menu editing still answer better with it than without: the async-sync wizard's warning that a target is driven by a puppet control (a technique that visibly stutters if recommended wrongly), and carrying a parameter rename through to the controls that name it, which without this would leave somebody's menu pointing at a name nothing answers to. Both now only reach controllers that were given a menu while the slot existed; a controller saved after this gets neither, which is the honest consequence of no longer asking. VrcMenuAccess itself is kept whole rather than carved up: its reading half is what those two callers use, and deciding whether DaerD keeps a menu ACCESSOR at all is a separate question from whether it ships a menu EDITOR. COMPATIBILITY. No saved field changes shape or value. A controller whose menu was assigned before today keeps the reference, and nothing in DaerD writes to that field again. No generated output, recipe API or existing controller is touched — the removed code only ever wrote to the menu asset the user pointed it at. TESTS. 1302 of 1302 with the VRChat SDK, Modular Avatar, NDMF, Gesture Manager and Av3Emulator installed — the same count as before, since nothing here had a test of its own. VrcMenuAccessTests still drives the accessor directly and still passes, and LocalizationCoverage passing in both directions is the statement that the 20 msgids removed from ja.po are exactly the ones whose call sites left with the window. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…hose behalf
Two of the marks existed already and neither covered the question being asked.
SYNC appeared on an async sync setup's cycle layer and on none of the Ready,
Stale or group layers it also generates; C# appeared on recipe-owned layers.
Everything else DaerD builds — a Layer-wired object gadget, and every Direct
blend tree layer that gadgets are hung in — looked hand-made in the list, which
is exactly where somebody decides whether editing a layer is safe.
Every layer with a claim on it now carries Unity's d_CustomTool glyph, and the
tooltip says what the claim is: the cycle / Ready watcher / Stale watcher /
group commit layer of a named setup, a named object gadget, a named recipe.
Shared hosts get the icon too — being under DaerD's management is the thing
worth knowing about them — but they are described by COUNT rather than by name
("hosts 3 DBT gadgets in its root Direct blend tree"), because a host is meant
to accumulate gadgets and a tooltip that lists them grows without bound. The
names are in the home screen's list, which is where they can be acted on.
NO NEW LEDGER. Every claim is already written down in GraphFrameData — a setup
names its four kinds of layer, a gadget names the layer it lives in, a recipe
names what it owns — so LayerOwners is those references turned round, layer
first instead of record first, and nothing else. A second list of "layers DaerD
owns" would be a second thing to keep true while a gadget is deleted or a
recipe regenerates, and the first of the two to be wrong. The rejected
alternative was marking layers as they are built, which is the same list under
another name and does not survive a controller edited on another machine.
CACHED, ON THE INVALIDATIONS THAT ALREADY EXIST (ADR 0028). The list asks about
every row on every repaint and answering means walking four saved lists, so the
map is built once per controller and dropped twice over: the panel drops it on
the structural notifications it already repaints on, and GraphFrameData's
ForgetHolders drops it for everything that reaches the saved records without
passing through the window — an import, a recipe's Generate, a test. It rides
that call rather than watching for imports itself, because it is built out of
what Find hands back: anything that can make one stale makes both stale, and a
watcher of its own would be a second opinion about when.
WHAT THIS DOES NOT DO. The icon says a layer is generated, not that editing it
will be overwritten on any particular action — those are different for a cycle
layer (rebuilt wholesale by the wizard), a shared host (only the gadget's own
child is rebuilt) and a recipe layer (destroyed and recreated), and one glyph
cannot carry that difference. It is the tooltip's job and it says so per claim.
The two existing badges are unchanged in meaning: SYNC still means the cycle
layer specifically, so that the four layers of one setup are not all labelled
with a word that only describes one of them, and C# still names its recipe.
Both now read the same map instead of collecting their own per repaint.
d_CustomTool is the dark-skin variant, which DaerD is entitled to assume
(ADR 0034); the icon lookups moved into DaerDIcons so the assumption is written
down once, next to where DaerDColors keeps the same kind of vocabulary. The
layer settings gear moved there with it — asset type icons deliberately did
not, since those are Unity's own and mean the same thing everywhere.
COMPATIBILITY. Nothing is written. No saved field, generated layer or recipe
API changes; a controller built by an older DaerD gets its icons from the
records it already carries, and one whose records were pruned gets none, which
is the same thing the home screen's lists already say about it.
TESTS. 1305 of 1305 with the VRChat SDK, Modular Avatar, NDMF, Gesture Manager
and Av3Emulator installed (1302 before; three new). They pin the turn-round
being complete — one layer per record kind, including both halves of a shared
host and a layer nobody claims — the tooltip naming owners while counting
hosted gadgets, and the cache: asking twice builds once, a record written
behind the map's back stays invisible until something says otherwise, and
ForgetHolders is that something.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…oggle "Add Toggle" named the only kind that exists instead of the family the card holds, and the family is the part that is about to change: an object gadget is "pick objects inside the linked prefab and do something to them", and the kinds coming are placements of other prefabs and the like, not more ways to toggle. A label naming today's single kind would have to be renamed on the day a second one lands, and until then it tells the reader that toggling is all this list can ever hold. It reads "+ Add Gadget" now, which is the shape the DBT gadgets card above it already uses: the card's heading says which family, the button says add one of them. The two buttons therefore share a caption, and that is the pattern rather than a collision — they sit under headings that differ, and their tooltips say what each family is. Behaviour is unchanged: the button still opens the toggle editor directly. When a second kind arrives this becomes a choice of kind, and the label will already be right for it. The tooltip carries what the label stopped saying — that the one kind so far is a toggle — so nothing is hidden by the more general word. The window it opens was already called Object Gadget, so no title, dialog or kind name needed touching; "Toggle" remains correct wherever it names the KIND rather than the family. COMPATIBILITY. Nothing but two UI strings. No saved record, generated layer or recipe API is involved, and existing object gadgets are untouched. TESTS. 1305 of 1305 with the VRChat SDK, Modular Avatar, NDMF, Gesture Manager and Av3Emulator installed — unchanged. LocalizationCoverage is what covers a label change in both directions: the new caption reuses the entry the DBT card already had, the old one left ja.po with its call site, and the reworded tooltip is translated. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…xtures Every test run paid ~2.5 minutes of editor startup before a single test ran, and the discipline of a full suite before every commit multiplied that by every commit of every wave. The fix is a resident batchmode editor: test-daemon.sh installs a receiver into the test project (never into DaerD itself — the receiver is volume-side only), run-tests.sh hands requests to it when it is alive and falls back to the cold path when it is not. A filtered fixture run drops from ~2.5 minutes to ~3 seconds. The receiver is a file state machine so a domain reload cannot lose a request: it claims request.json as running.json BEFORE calling Refresh, and the next domain's InitializeOnLoad picks the claim up. Liveness is judged by PID alone — the first design also required a fresh heartbeat and was measured wrong: a synchronous refresh or a long test frame stalls the update loop, the beat goes stale, and the client declared a busy daemon dead, fell back to cold, and collided with the resident editor's project lock. Busy is not dead. For the same reason a timeout no longer kills the daemon; it says so and stops. What was measured and NOT won: a full suite through the daemon costs the same ~3 minutes as cold. Two causes were run down — editor-loop throttling for an unfocused editor (fixed with InteractionMode, which is what makes fixture runs fast) and mid-run domain reloads from export tests writing .cs files (fenced with LockReloadAssemblies, matching -runTests semantics) — and what remains is the per-test asset creation and import the newer tests do by design (~800 native refreshes a run). That cost is the tests', not the runner's. So the commit gate changes with the tool (user decision, 2026-08-18): fixtures matching the change before each commit, the full suite once at the end of a series. CLAUDE.md carries both the daemon and the rule; the broken '*Frame*' filter example it still showed is replaced by the full-name form that works. Compatibility: without the daemon nothing changes — run-tests.sh behaves as before. The daemon holds the project open, so cold runs and other editors are locked out while it lives; test-daemon.sh stop releases it. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
Editor/Model had grown to 53 files and ~18.5k lines in a single flat folder, so "where does this belong" had no answer and "what already exists for this" took reading the whole listing to ask. The generation engine, the analyzers, the SDK/prefab boundary and the graph editing commands were neighbours by alphabet only. The files move into six folders by concern: Engine (what generates layers), Analyze (what reads a controller and reports), IR (the recipe round-trip contract), Persist (what becomes a saved asset), Bridge (the boundary with the VRC SDK, prefabs and the running scene) and Edit (the in-place editing commands). Nothing else happens here. File contents are byte-identical, git reports 106 renames at 100% similarity, and the only additions are the six folder .meta files Unity requires. Sub-namespaces follow in the next commit, so that this one can be read as a move and nothing else (ADR 0025). Rejected: doing the move and the sub-namespaces together. To a person it is one mechanical change, but it destroys the only proof of behaviour-preservation available for a change this wide - a rename-only diff. Also rejected: dividing by kind (Builders/, Scans/, Clipboards/) instead of by concern, which files AsyncSyncBuilder next to LayerClipboard and is the flat folder again with extra steps. Trade-offs: the six names are a judgement, not a derivation, and the wave buys nothing at runtime. Edit is a sixth wing beyond the five that were sketched - the clipboards, duplicators, renamers, packers and layout are neither generation nor analysis, and folding them into Engine would have made Engine mean "everything that writes". LayerOwners went to Analyze because it computes and caches an answer about a controller and generates nothing, even though what it reads is DaerD's own saved records. The layer and blend-tree templates went to Persist because what they are is a saved asset, which also keeps them under the rule that saved types do not change namespace. Nothing is renamed, deduplicated or merged: files that overlapped still overlap. Compatibility: none affected. Each .meta moved with its .cs via git mv, so every GUID is unchanged and no asset reference or serialized script pointer moves. Saved GraphFrameData, generated controllers and exported recipes are untouched - this commit changes no namespace, type or field name. Tests: EditMode 1305/1305 with the VRC SDK and all four tools present, the same count as before the move. Worth knowing: a bulk move leaves the resident test daemon unable to answer - the first request after it never returned, and a restart was needed before the run above. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… one you reached for The previous commit put the Model layer in six folders, but every type still sat in the flat Yozolab.DaerD namespace, so a panel could reach the async sync builder, a clipboard and the prefab bridge without saying so anywhere. Folders that the compiler does not know about are a filing convention, not a boundary. Five of the six wings become sub-namespaces - Yozolab.DaerD.Engine, .Analyze, .IR, .Bridge, .Edit - and the consumers grew the usings that follow. C# does not let a parent namespace see into a child, so the imports are the point: LayersPanel now opens with Analyze, Bridge, Edit and Engine, and that line is the honest statement of what a layer row depends on. Nothing else moved: same files, same types, same members, same visibility. Persist stays in Yozolab.DaerD. GraphFrameData, the layer template and the blend-tree template are what a user's project has on disk, and a serialized ScriptableObject is bound through its script GUID, so in theory the namespace is free to change. Saved data is not where theory gets tested (design.md 4), and the wing exists precisely to hold the types that decline the rename. Rejected: an assembly per wing. asmdefs would make the boundary enforceable instead of advisory, but that is ADR 0025's DON'T-TOUCH item 7 (Authoring stays in one assembly), it multiplies the compile units a Unity import has to walk, and the wave is supposed to move things, not change how the package is built. What is lost by staying advisory: nothing stops a future edit from adding a using and reaching across a wing - the namespaces name the crossing, they do not refuse it. Also rejected: renaming types to shed the prefixes their flat neighbourhood forced on them (ControllerIR.* inside IR, AsyncSync* inside Engine). It reads better and it is a different commit; a rename touches the recipe surface and the export golden strings, which this one must not. Trade-offs: three alias and using-static directives had to be requalified by hand (Yozolab.DaerD.AsyncSyncBuilder -> Yozolab.DaerD.Engine.AsyncSyncBuilder) because file-scope aliases resolve against the global namespace, not the enclosing one. Session and EditorPrefs key strings that happen to spell a type name - "Yozolab.DaerD.TransitionClipboard" and friends - are left exactly as they are: they are storage keys, and changing one silently empties a clipboard that a user is mid-way through using. Compatibility: saved data unaffected. No serialized type changed namespace, no field, type or member name changed, and no .meta or GUID moved. Recipes are unaffected in this commit - ControllerRecipe, ControllerBuilder and the whole Authoring surface stay in Yozolab.DaerD.Authoring, so an exported recipe and its hand-edited half compile exactly as before. Tests: EditMode 1305/1305 with the VRC SDK and all four tools present, unchanged from the count before the wave. Also verified out of band by replaying Unity's own compile response files through Roslyn, which catches every CS error in one pass without booting an editor. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The package exported 119 public type declarations while promising 27. The rest were public out of habit — nested records inside internal helpers, the window, the controller IR, the generation engine's request and plan types — and being public was doing no work: a recipe compiles in the user's own assembly, so a public type nested in an internal one is unreachable there anyway, and an internal one is reachable from every corner of DaerD regardless. So "public" said nothing about what DaerD promises, which is the one thing the word is supposed to say. 92 declarations become internal. What stays public is the recipe surface: the 23 top-level types in Yozolab.DaerD.Authoring plus the four records nested in RecipeExport, listed by name in Editor/AssemblyInfo.cs. That list is now the package's whole answer to "what may I hold on to", and ADR 0016's promise not to change the Recipe API has something exact to cover. The list is enforced twice, because a comment naming an API surface is the first thing to go stale. RecipeCompileTests exports a real controller and compiles both halves DaerD writes — the machine half and the hand half — with Unity's own Roslyn, referencing the built DLL as an outsider rather than the sources, which is what makes internals invisible to it. Beside it, a census test compares the assembly's exported types against the list in AssemblyInfo and fails on any drift in either direction. The probe assembly is deliberately not named Yozolab.DaerD.Tests: that name is in InternalsVisibleTo, and using it would have hidden the exact failure the test exists to catch. Rejected: leaving nested public types alone because they are already unreachable. True, and it keeps "public" meaningless — grep it and you get 119 answers, 92 of them wrong. Also rejected: demoting the Authoring types not named by generated code (RecipeExportCli has no caller inside the package). It is reached by name from outside through -executeMethod, and a batch job that stops finding its entry point is a worse failure than an over-wide surface. Also rejected: splitting the recipe API into its own assembly, which would make the boundary structural instead of declarative — ADR 0025's DON'T-TOUCH item 7, and a build-shape change this wave is not allowed to make. What is lost by staying in one assembly: nothing prevents a future edit from typing public and widening the promise silently — which is precisely why the census test exists. Trade-offs: this changes nothing a user can observe, costs one subprocess compile per test run (~seconds), and needs Unity's Roslyn on disk. Where it is absent the compile test says so and skips rather than passing quietly, so the guarantee is only as strong as the machine it ran on. The census test needs nothing and always runs. Compatibility: this is a deliberate break, approved 2026-08-18. A hand-edited half that reached past Yozolab.DaerD.Authoring — into the IR, the gadget builders, GraphFrameData — stops compiling, with a plain "inaccessible due to its protection level" naming the type. That is the intended shape of the break: it is loud, it points at the line, and it happens before anything runs. Halves that use only the documented API are untouched, which is every half the exporter has ever written, since the generated code names Authoring types and nothing else. Saved data, generated controllers, .meta files and GUIDs are all unaffected — no type moved, was renamed, or changed namespace here. Tests: EditMode 1307/1307 with the VRC SDK and all four tools present (1305 before this commit, plus the two added here; no skips). Compilation without the SDK and without any of the four tools was checked by replaying Unity's own response files with the DAERD_* and VRC_SDK_VRCSDK3 defines and the tool references stripped — both assemblies build clean, so the guarded regions survived the wave. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…g for nobody The first request after a source edit hung: Refresh ran, Tundra compiled, and isCompiling stayed true forever, because a batch editor with no focus events never reached the safe point that applies compiled assemblies. The receiver's own gate then kept it from ever starting the run, while the heartbeat — written before that gate — kept telling the client everything was fine. Every edit cost a restart. Now a claimed request waiting on isCompiling for ten seconds asks for the reload itself (EditorUtility.RequestScriptReload); the claim survives the reload as a file and the next domain runs it. Measured: a post-edit fixture request completes in ~11s where it previously timed out at 15 minutes. Not taken: restarting the daemon per edit (works, ~20s, but turns the daemon into a slower cold run with extra steps) and shortening the client timeout (cures the wait, not the hang). The ten-second grace is deliberate slack so an ordinary compile is never interrupted by an extra reload request. Compatibility: none — receiver-side only, installed fresh on every daemon start. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The menu editor left with U2, and VrcMenuAccess kept carrying the authoring surface it existed to serve — add, remove, move, a setter per field, submenu asset creation, the descriptor lookup — with no product caller and five tests as its only life support. An unexercised path is an unverified path, so the authoring half is deleted (user decision, 2026-08-18), the same reasoning that removed the MaStore reflection fallback. What stays is what is actually reached for: Is and Read (the async-sync puppet warning reads controls), and RenameParameterReferences (a parameter rename must not silently orphan menu references to the old name). The rename cascade writes menus through SetParameter and SetSubParameter, so those two and EditControl remain — private, as the cascade's own machinery rather than an API anyone else may build on. The fixture rebuilds its menus by filling the stand-in's serialized fields directly instead of driving the deleted API, and shrinks to the two claims that still have a subject: Read hands back what the live features ask, and the cascade traverses submenus cycle-safe. What was given up with the four deleted tests: coverage of add-control defaults, the clone-clearing behaviour and the control cap — all properties of code that no longer exists. Compatibility: none for saved data or assets. Any hand-written tool that reached into these internals loses them at compile time, which is the same line R2 drew for the whole assembly. Tests: fixture 2/2 (daemon); the full suite gates the series end per the current rule. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… updates one
Exporting a controller to a C# recipe decided the recipe's folder and class name
from scratch every time — the controller's current folder, plus a class name
derived from its name — and nothing ever looked for a recipe that already
existed. Move the controller, edit the folder by hand, or keep two controllers
with the same name, and the next export writes a SECOND pair of files under the
same class name with a second .asset beside them. Land both in one assembly and
CS0101 freezes compilation; land them in two and the duplicate class quietly
coexists, with Generate running whichever copy the loaded assembly happens to
hold. Both read the same way from the user's chair: "Generate produces the same
old content every time."
So the controller now holds the answer. GraphFrameData gains `recipes`, a list
of Object references beside parameterStore and the prefab pin — additive, and
Object-typed for the reason those are: Persist is saved data and has no business
depending on Authoring one layer up. LinkedRecipes prunes dead entries on read
(codeOwned's rule) and LinkRecipe is idempotent, because three separate moments
record the same link:
1. the export queue, at the instant the .asset exists and both halves of the
link are in hand;
2. Generate, which adopts itself — the migration path for every recipe asset
made before links existed, and the one moment guaranteed to happen to a
recipe somebody is actually using;
3. opening the export form, which sweeps the project for recipe assets whose
targetController is this controller and takes up the unlinked ones. It
matches on the recipe's own target field rather than on a path: a stray
recipe is stray precisely because its folder stopped agreeing with the
controller's, and the field is the half of it that never lied.
The sweep is one FindAssets plus a load per recipe asset — not per asset in the
project — and it runs on an explicit user action and nowhere else (ADR 0028).
Nothing here searches on a repaint or on load.
Not taken: a "duplicate class name detected" dialog at export time. It was the
obvious fix for the symptom, and the link makes it structurally unnecessary — a
re-export of a linked controller updates the recipe it already has, so there is
no second pair left to detect. Also not taken: interpreting the recipe with
Roslyn instead of compiling it, which would remove the compile wait altogether
but buys a new guarantee to keep (that the interpreted run behaves exactly like
the compiled one) in exchange for saving only the domain reload's fixed cost.
Compiling stays, because compiling is what makes an exported recipe ordinary C#
somebody can reshape by hand.
What this does not do: it records where recipes ARE, not which one is right. A
link whose recipe now names another controller is kept rather than repaired —
which of the two is the mistake is not something DaerD can know — and nothing
here deletes, moves or merges a duplicate pair that is already on disk. The
sweep makes those visible and leaves them where they are. There is no unlink
action yet.
Compatibility: GraphFrameData saved before this field deserializes it as null,
which reads as empty, so existing controllers load unchanged and gain their link
the first time any of the three moments fires. Existing recipe assets need no
migration for the same reason. The public surface is untouched — RecipeLinks is
internal, and so is RecipeExportQueue.Process, which stops being private only so
the queue's one step can be driven without a domain reload; the reload is what
makes the export path untestable end to end, and it is not what the recording
point is about.
Tests: RecipeLinkTests 5/5 (daemon) — idempotent linking, a deleted recipe
pruned on read, the queue's recording point, Generate's self-adoption, and the
sweep taking up a stray while leaving another controller's recipe alone. The
full suite gates the series end per the current rule.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…g it A recipe's Build runs out of a LOADED assembly. Editing the .cs changes a file; what runs changes only after a compile and a domain reload, and in between there is a window where Generate rebuilds the controller from the previous version — silently, with a clean result and no warnings. A compile error widens that window indefinitely, because Unity keeps the last assembly that built, so the recipe goes on generating last week's controller for as long as the error stands. Every symptom of it reads as "Generate produces the same old content every time", and nothing on screen says why. RecipeFreshness is the guard, split the way PrefabWriter is: Judge takes timestamps and flags and returns a verdict, Check collects the editor's facts for it. The split is the whole reason the states are testable — a compile in flight, a failed compile and a .cs saved one second ago are exactly the conditions nobody can reproduce on demand. The facts are isCompiling/isUpdating, scriptCompilationFailed, the mtimes of the two halves (the generated one only when the recipe still declares one) and the mtime of the assembly holding the class. Two seconds of slack sit between them because a save and the compile it triggers routinely land in the same second, and a guard that refuses the very run it was meant to make safe is a guard that gets switched off. Generate, Verify and Compare all ask before touching anything. Verify stopping too is the deliberate half: holding a controller against last week's assembly produces a confident list of differences that are not there, which is worse than a refusal because it reads like a finding. THE SCOPE OF THE GUARANTEE. This is best-effort and it fails open. Every missing fact — an assembly with no file on disk, a script Unity will not name, a timestamp that throws — reads as Fresh and the run goes ahead. What it promises is that the frequent silent freezes get a sentence; it does not promise that the code you are reading is the code that ran. A hand half edited inside a still-warm assembly, a recipe compiled elsewhere and copied in, and any change that leaves the .cs mtime alone are all invisible to it. Nothing here inspects what the code says, only when it was written. One deliberate ordering: a failed compile outranks a compile in flight, so a recompile started right after fixing an error is announced as the error until it lands. Both refuse the run, so what is at stake is only which sentence is shown, and the error is the one that needs a person — a recompile finishes on its own. Not taken: interpreting the recipe with Roslyn rather than compiling it, which removes the wait entirely and with it this whole class. It buys a new guarantee to keep — that the interpreted run behaves exactly like the compiled one — in exchange for saving only the domain reload's fixed cost, and it gives up the thing that makes an exported recipe worth having: it is ordinary C# that a person or an AI can reshape, and it is the compiler that says so. Also not taken: blocking the buttons in the UI instead of guarding the methods. The methods are the public API; a batch export or somebody's editor script reaches them without passing a window, and a guarantee that only holds when clicked is not one. Compatibility: no saved data, generated controller or recipe asset is affected, and the recipe API is unchanged — RecipeFreshness is internal, and the three entry points keep their signatures and return the refusal as an ordinary warning line, which every existing caller already renders. A recipe in an editor whose assemblies are current behaves exactly as before. Tests: RecipeFreshnessTests 9/9 (daemon) — each state, both slack boundaries, the flags deciding without any timestamps, fail-open in both directions, and that every refusal has a sentence while Fresh has none. The wiring is deliberately not mocked: Generate, Verify and Compare all ask first, so a guard that wrongly refused a real recipe would take the recipe suite down with it. Re-ran that suite for exactly that reason — AuthoringTests 28/28, RecipeExporterTests 24/24, RecipeExportApiTests 12/12, ComplexControllerTests 13/13, ObjectRecipeTests 11/11, SharedDbtLayerTests 7/7, RecipeLinkTests 5/5, LocalizationCoverageTests 4/4. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…at exists With the link recorded, two surfaces can finally stop guessing. THE HOME CARD. The C# Recipes card listed the recipes that own layers here and offered Ping and Select — enough to find a recipe, nothing about whether running it would do what the .cs says. It now lists the LINKED recipes, which is a different set in the case that matters: a recipe exported and not yet generated owns no layer and was invisible, and it is exactly the one somebody is about to press Generate on. Each row carries its state — wrong target, compile error, compiling, .cs is newer, up to date — and Generate and Verify, disabled with the reason spelled out on the row rather than refusing after the click. The state comes from RecipeFreshness, the same verdict the methods themselves use, because a button and the method behind it disagreeing about "runnable" is how you get a button that offers a run and a method that then refuses it. Results are kept until the next action, the way the recipe inspector keeps them. Not a second card, which is what the plan asked for. There was already one called C# Recipes, and the linked set is a superset of the code-owning set the moment anything generates (Generate records both). Two cards would have been the same question asked in two vocabularies on one screen — the thing the parameter store slot was consolidated out of three places for. So the existing card grew the link as its source, keeps any code-owning recipe that is not linked yet as a trailing row, and hides itself outright when there is nothing to list: unlike the gadget cards it has no "+ New" of its own, so an empty one would be a heading with nothing to offer. THE EXPORT DEFAULT. The form always described a NEW recipe — class name from the controller's name, folder from where the controller sits — which is what wrote the second copy. With a link present it now opens a target popup listing the linked recipes (class name and asset path, because two recipes can share a class name and that is the mess being untangled) plus "New recipe…", defaulting to an update. In update mode the class name, namespace and folder are shown as they are and cannot be edited: that pair of files IS the recipe, and renaming it from here would produce a third one. Renaming stays possible through "New recipe…", which is the honest description of what it does. PickDefault is a decision function with tests on it rather than a shape the popup happens to have, because the default is what decides the outcome for anybody who does not read the popup. One link wins outright; among several, the first that owns a ticked layer wins, since the ticks are the only evidence on screen about which recipe is meant; with no intersection it falls back to the FIRST LINK and never to "new" — proposing a second recipe is the accident, so the default never proposes it while any link exists. Not taken: keeping "new" as the default and merely warning about the existing recipe. A warning that has to be read to prevent a duplicate is the same design that let this happen, one dialog later. Also not taken: letting an update edit the class name and moving the files to match. Moving a .cs, its generated half, its .meta and the asset's script reference in one step is a refactor with several failure modes, and none of them are worth carrying to save picking "New recipe…". What this does not do: nothing here removes or merges a duplicate pair that is already on disk, and a recipe whose script cannot be named (a class that no longer exists) is refused for update with a sentence rather than repaired. The IMGUI of the card and the form is checked by hand, as the other cards are. Compatibility: a controller with no link sees no popup and no card, and the form behaves exactly as before — same defaults, same fields, same export. The empty state "No recipe-owned layers." is gone with the card that showed it. No saved data changes, and the recipe API is untouched: PickDefault and ScriptLocation are internal on the internal RecipeLinks. Tests: RecipePickDefaultTests 5/5 (daemon) — no links, one link winning whatever is ticked, the ticked-layer intersection, the fall-back to the first link rather than to new, and a link that owns nothing being skipped instead of throwing. Re-ran LocalizationCoverageTests 4/4 for the new strings and their translations, plus RecipeCompileTests 2/2 (the public-surface census), RecipeLinkTests 5/5, RecipeFreshnessTests 9/9, AuthoringTests 28/28 and RecipeExportApiTests 12/12. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65
…ee times
The layer list grew a generated-layer icon when the owner map arrived, and its
tooltip already names every claim on the row: which async sync setup, which
recipe, how many gadgets hang in a shared host. The SYNC and C# text badges
beside it had been saying two of those in another way since before the icon
existed, so a recipe-owned layer carried a picture and a word that meant the
same thing, in a row whose scarce resource is width for the layer's own name.
Both badges go. The DBT badge stays, and the distinction is the point: SYNC and
C# were claims — statements about who owns the layer, which is exactly what the
icon and its tooltip are for — while DBT says what the layer IS. Every state a
Direct blend tree is a fact about a layer somebody built by hand as readily as
one DaerD generated, and no owner mark covers it.
What is lost, plainly: the two words themselves, readable at a glance without
hovering, and with them the consequence each tooltip spelled out — that
regenerating from the wizard, or pressing Generate on the recipe asset, rebuilds
this layer's states. What replaces the first is the icon, which is present on
every generated layer including the ones that never had a badge. What replaces
the second is weaker at the row and stronger away from it: LayerOwners.Describe
names the setup and the recipe outright ("The cycle of the async sync setup
'X'.", "Generated by the recipe 'Y'."), which was checked before deleting
anything, and the home screen's recipe card now carries the state and the
actions that used to be implied by a four-character badge. The async sync
panel's own header still says the layer is generated machinery rebuilt in place,
and the recipe sentence survives there verbatim — that call site is why its
translation stays in the catalogue while the SYNC one leaves with its only
caller.
Not taken: adding the "hand edits are replaced" consequence to Describe as a
closing line. It is a real loss and that would be the place to make it good, but
the tooltip's shape is asserted line-for-line by a test that says one line per
claim, and rewriting that contract is a change to what the map promises rather
than the badge cleanup this is.
Compatibility: display only. No saved data, no generated output, no API. The two
dead entries in the localization allowlist go with the literals they exempted,
and GraphFrameData's doc comment stops pointing at a badge that no longer
exists.
Tests: LocalizationCoverageTests 4/4 and LayerOwnersTests 3/3 (daemon). The
localization run is the one that matters here — it caught that the recipe
tooltip has a second call site in the async sync panel, so its msgid stays.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…oring Delegated implementation agents were spending their first budget re-deriving the repository's shape — which wing owns what, which way dependencies point, where a feature's entry type lives — and the cost grew with the codebase until it dominated small tasks. The knowledge is stable since the six-wing split, so it is cheaper written once than rediscovered per agent. ARCHITECTURE.md is a map, not documentation: wings, dependency direction, the frozen public surface, and one line per notable type. It deliberately repeats nothing that a file's own doc comment says. The working rule is written into it: exploratory grep is a last resort, and having needed one is a defect in the map to fix, not a habit to keep. Trade-off: a map can rot. It lists types by name, so a rename leaves a stale entry until someone trips over it — accepted, because the reader is an agent that fails loudly on a missing name and the fix is one line. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
Triaging a bug report or deciding whether a saved GraphFrameData needs a migration currently means asking the reporter what DD version they were on, if they even remember. The data itself carried no answer. GraphFrameData gains one additive field, savedByVersion, stamped every time DD writes through it: empty/absent reads as "0.13 or earlier", "X.Y.Z" as a release build, "X.Y.Z+dev" as a development build made after that release. WHERE THE STAMP GOES. Every write accessor in GraphFrameData reaches its data through GetOrCreate (or already holds what a prior call to it returned) before mutating and calling EditorUtility.SetDirty — that pipeline, not the twenty scattered SetDirty call sites, is the one place a stamp has to live to cover every write without repeating itself. WHERE THE VERSION COMES FROM. A new internal Bridge.SavedByVersion resolves PackageManager.PackageInfo.FindForAssembly(this assembly) once and caches it, rather than asking on every save. The dev/release split does NOT key off PackageSource.Registry: a VPM/VCC install copies the package straight into Packages/, which Unity reports as Embedded — indistinguishable by source alone from a package under active development in place. A release archive never carries a .git folder (a worktree's is a file rather than a directory, but either counts), so that presence is the actual "this copy can drift from its tag" signal. Local and Git sources are folded into the same answer for the same reason: neither is a resolved, addressed copy either. The pure half (Format(version, source, hasDotGit)) follows the Check-then-Judge split PrefabWriter set for PackageInfo-reading code, so the decision is testable without installing a package under a controlled source. WHAT THIS DOES NOT COVER. Removal/clear paths that mutate an existing holder through Find rather than GetOrCreate (RemoveGadget, ClearPrefabLink and its siblings) do not re-stamp — they were never a "GetOrCreate → SetDirty" call, and stretching the stamp to them would mean stamping at every one of those twenty SetDirty sites instead of the single chosen one. A holder that is only ever cleared, never (re)created afterward, can carry a stamp from whenever it was last created rather than from its last edit; accepted, because the question this field answers is "which DD build can this data's shape be attributed to", not "when was this row last touched". Compatibility: additive only. Saved controllers with no savedByVersion deserialize the field as null/empty, which the doc comment defines as the pre-0.13 answer — no migration, no behavior change for existing data. No public API: SavedByVersion is internal, reached only from inside Persist. Tests: SavedByVersionTests 7/7 (Format's fail-open-on-empty-version case and every source/.git combination, plus one real GetOrCreate write asserted down to the +dev suffix, since this project resolves DaerD as a Local package). Also reran GraphFrameDataTests 6/6, GraphFrameDataCacheTests 6/6, FrameNoteClipboardTests 6/6, RecipeLinkTests 5/5, PrefabLinkTests 25/25 and RecipeCompileTests 2/2 (the public-surface census) since GetOrCreate is the join point every one of them writes through. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
…pdating 8ef871a taught the daemon to request a script reload when a claimed request sat behind isCompiling for 10 seconds, because a batch editor does not always start the reload on its own. The gate it sits in, though, is isCompiling OR isUpdating — and an editor wedged on the isUpdating side matched neither the nudge's condition nor anything else, so it waited forever with a healthy heartbeat. Two independent runs hit exactly that: first request after a source edit, imports done, log silent for minutes, fixed by a manual restart. The nudge now keys on "a claimed request has been stuck in this gate for 10 seconds", whichever flag is holding it. RequestScriptReload is queued by Unity until it is safe, so asking during an import is at worst a no-op — the same fail-open posture as the rest of the daemon. Not taken: a separate import-specific nudge (another Refresh call). The stuck state was never diagnosed to the flag level from outside, and Refresh from inside the gate risks re-entering the very import that is stuck; one reload request that Unity itself schedules is the conservative move. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
The feature text still described the deprecated standalone object toggle, the menu editor, the SYNC / C# text badges, and generator entry points that moved to the home screen — all of which the 0.13 series replaced. A README that contradicts the running tool is worse than a thin one, so this brings every stale claim in line with what ships: - object toggle -> object gadget (one kind so far), built against the linked prefab from the home screen; reference-stored targets, own-rows user clips - a Prefab Link section: controller-side link, zero bytes on the prefab, explicit refused-first writes, the parameter store slot living in that card - recipe links, the home recipe card and the freshness guard; layer ownership is an icon now, not per-machinery text badges (DBT stays — it marks structure, not ownership) - menu editing named as out of scope, with the rename cascade into menus kept Wording follows the shipped UI labels (Scan & Link, Detect, + Add Gadget) so a reader can find the buttons the text names. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
A controller headed for distribution, or one whose DaerD records should simply start over, had no way to shed them: the holder is a hidden sub-asset, and the only ways to touch it were per-feature deletes. The home screen's Controller card now carries a Discard button that destroys the holder wholesale — frames and notes, gadget and sync records, the prefab and recipe links, the store and Empty clip assignments — while the controller itself keeps every layer, parameter, motion and generated clip and plays exactly as before. What it loses is management: no regeneration, no ownership marks, no records to delete from. Not undoable, by the nature of the thing rather than by choice: removing a sub-asset is an asset save, so the dialog says so and the asset is saved on the spot. The button only renders when a holder exists, so the card carries no dead destructive control. The cache writes the answer through as a real null and drops the layer-owner map with it — the same discipline GetOrCreate applies in the other direction. In-memory controllers are out of scope: their holders are never cached or read back (GetOrCreate says why), so there is nothing there to discard. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
Discarding (819dd70) answered "this controller is going out to somebody else" by throwing DaerD's records away. That is the right answer only when they were worth nothing; the usual case is a controller the author keeps working on and wants to hand over clean. So the holder can now be MOVED out of the .controller into a sidecar asset of its own — the controller file loses every trace of DaerD, and frames, gadget and sync records, links and assignments all keep working here, because every accessor in Persist reaches its data through Find and Find does not care which file answered. The link runs one way, from the sidecar to its controller (a new additive `owner` field), and never the other. A pointer on the controller's side would be a byte of DaerD in the file this whole operation exists to leave clean — which is the alternative this rejects, and it was the cheap one: it would have made the lookup a dereference. What replaces it is three tries, cheapest first: the sub-assets, then the path the controller's own path derives (`Assets/Chara/FX.controller` -> `Assets/zz_DaerD/Chara/FX.asset`), then a project sweep for a holder claiming this controller. Only controllers with no data at all reach the sweep, and Find writes what comes back through to its table, so it is one query per answer rather than one per repaint. The mirrored folder is a BIRTHPLACE, not a tracker. Move the controller and nothing moves the sidecar; the derived path stops naming it and the sweep is what recovers it. That is deliberate — a sidecar the user filed somewhere of their own is theirs to keep there — and it is the tradeoff: a project with no sidecars pays a type query per controller whose answer was invalidated, and a sidecar deleted or left out of a checkout reads as "this controller has no DaerD data", which is what it now honestly is. What this does not do: create sidecars on its own (new data is embedded, as before), guarantee anything for controllers outside Assets/ (Detach states its refusal instead of failing later in the asset pipeline), or offer an undo — taking a sub-asset out is an asset save, Discard's reason. Embed is a copy rather than a move, since a main asset cannot be lifted out of its own file; the references it holds are copied as references, so this is invisible except to code holding the old instance, and none does across the call. Discard grew the sidecar case: the holder IS the file there, so it deletes the file and the folders the mirror made for it. Existing controllers are untouched — an embedded holder leaves `owner` null, which is what data saved before the field existed deserializes to anyway, and detaching is only ever a user action. A controller detached and then opened where DaerD is absent is simply a controller with no sub-assets of ours. The folder-chain creator moved from RecipeExportQueue to Utils/AssetFolders rather than being called across the layer boundary: Persist knows nothing of Authoring (this file says so about ControllerRecipe a few hundred lines up), and a second copy of the AssetDatabase ceremony would be a second chance to get it wrong. Coercing typed input into a project path stayed behind, with the code that takes input. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
…d back The Controller card could only discard (819dd70), which answers "hand this controller to somebody" by destroying the records. Detach answers it by moving them instead: the card now offers Detach beside Discard, and once the data is detached it shows the file it went to, with a Ping, and offers Embed to bring it back. Discard stays where it was, as the answer for records that are worth nothing rather than worth keeping elsewhere. Both new actions confirm first, for Discard's reason — taking a sub-asset out is an asset save, so none of the three can be undone, and each dialog says so. The Detach dialog names the file it is about to write and says to keep it, because the failure mode of this feature is a controller whose sidecar was never committed: it reads, correctly and unhelpfully, as a controller DaerD has never seen. The Discard dialog keeps its wording and gains one sentence when the data is detached, so "the file goes too" is read before the click rather than noticed afterwards in the Project window. Detach is not offered at all when the controller has nowhere to keep a sidecar (one inside a package, say) rather than offered and refused — the alternative would make a fact about the controller into something you find out by pressing a button. The refusal string Persist returns is still logged if the state changes under us between the draw and the click. The path row is a label rather than an object field: the file is not something to reassign by dropping another one on it (the link lives in the file, not here), and Ping is the one thing worth doing with it from this card. README gains three lines beside the home screen: detach for distribution, discard to forget, the controller itself untouched either way. Every new string goes through L.Tr with a ja.po entry beside the Discard ones. Session: df1dd21f-4842-4ebf-a0f8-0c88e127cb65 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcqoQ7eTjFWUJYo5ST8QYW
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
daerd | 765af12 | Aug 18 2026, 12:29 PM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.