Skip to content

0.13.0 - #22

Merged
YozoraKurage merged 42 commits into
mainfrom
0.13.0
Aug 18, 2026
Merged

YozoraKurage merged 42 commits into
mainfrom
0.13.0

Conversation

@YozoraKurage

Copy link
Copy Markdown
Owner

No description provided.

YozoraKurage and others added 30 commits August 16, 2026 22:49
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
YozoraKurage and others added 12 commits August 18, 2026 17:12
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
@YozoraKurage YozoraKurage added the minor up to 0.x.0 label Aug 18, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
daerd 765af12 Aug 18 2026, 12:29 PM

@YozoraKurage
YozoraKurage merged commit 89e3ecd into main Aug 18, 2026
1 check failed
@YozoraKurage
YozoraKurage deleted the 0.13.0 branch August 18, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor up to 0.x.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant