Repository navigation
0.12.0 - #21
Merged
Merged
0.12.0#21
Conversation
.gitattributes has said "* text=auto eol=lf" for a while, but it only normalizes what is added after it: three files committed before it kept their CRLF in the object store. Any edit to them shows up as a whole-file rewrite, which is how this was noticed. Content is untouched — only the line endings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
A state's transitions are evaluated in list order — the first whose conditions hold wins — and DaerD had no way to change that order. The numbers it printed made it worse: the list held only the selected edge's transitions, so a row labelled "2." was the second of that edge and could be the seventh of the state. Anyone who needed the real order had to go back to Unity's own Animator window. The list is now built from the source rather than from the edge. When every selected edge leaves the same node it is that node's complete list, in evaluation order, with drag handles — the only form where the rows a drag would move past are all on screen. A selection spanning several sources falls back to just the selected transitions, each still numbered by where it really sits. Rows read "source → destination" instead of "→ destination". With one edge that was merely redundant; with a list that can hold several destinations, and a selection that can mix sources, the source is the half that says which one this is. Entry and Any State own transition lists too, and their nodes answered with a one-line hint. They get the same list, so their priority is visible and editable at all — Any State transitions are where a VRChat controller collects most of them. Reordering writes through EdgeCommands, which already speaks in TransitionEnd and runs without a graph. Each owner needs its own array type: Entry and state-machine transitions are AnimatorTransition, states and Any State are AnimatorStateTransition, and writing one back through the wrong one empties the list instead of ordering it. Hovering a row lights its edge in the graph, which is what makes a list of "A → B" rows navigable at all. The panel clears the highlight when the pointer leaves it: rows only repaint while the pointer is over them, so the last highlight would otherwise stay lit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
Solo is a debugging aid that survives being saved: one soloed transition makes the Animator ignore every other transition leaving the same node. DaerD let you switch it on from the inspector and then showed nothing — not on the graph, not in the analyzer — so a controller could ship with most of a state's transitions silently disabled and no way to see why nothing fires. Solo gets no colour of its own, because it is only ever visible in what it disables: the lines it shuts out turn the same red an all-muted edge already does, which is the same fact arrived at from the other side. The tooltip says which of the two it is. Whether a line is shut out is a property of the source's whole transition list, so no edge can answer it alone — GraphSync works it out per source, reading the list from the model rather than from the edges present in the view. The analyzer reports it per source, with a Clear Solo fix, and only when solo actually takes something away: soloing a state's only live transition changes nothing, and a solo that is also muted keeps nothing alive, since muting beats soloing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
A condition pointing at a parameter the controller no longer declares was drawn as the first parameter in the list: Array.IndexOf returned -1 and Mathf.Max turned that into 0. Nothing was written while the row was merely drawn, but editing any other condition on the same transition saved the whole working list, so the broken condition quietly became a working one pointing somewhere else — and the analyzer's Invalid Condition complaint disappeared along with the evidence. The name is kept now, as a red entry at the end of the dropdown. Picking a real parameter is the repair; leaving it alone leaves it broken and visible. Two smaller things in the same form: "+ Add Condition" always added the first parameter in the list. A second condition on a numeric parameter is nearly always the other half of a range, so it now inherits the previous row's parameter and takes the opposite comparison — Greater then Less. Bool and Trigger have no range to build and a second condition on one can only contradict the first, so they keep the old behaviour. The Duration field changes unit with the Fixed Duration toggle directly above it, and the label never said which one was in force: the same 0.25 is a quarter of a second or a quarter of the source clip. It is labelled the way Unity's own inspector labels it, with the tooltip carrying what "(%)" really means, since the field holds a fraction and not a number out of a hundred. Exit Time and Offset are fractions too and now say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
F frames the selection and A frames everything, as they do in the Scene view, Shader Graph and the VFX graph. Both were free — the graph only read plain I / O / P — and framing was reachable only through the toolbar button or the right-click menu. F with nothing selected falls through to framing everything rather than doing nothing. Ctrl+F puts the caret in the toolbar's search box. The graph cannot reach the toolbar, so the request goes through the context, the same way framing and note editing already do. The result list answers the arrow keys. Enter used to take the first hit whatever was on screen, which made the other thirty-nine reachable only by mouse. A new query resets the highlight, so Enter always means "the best match for what I just typed" rather than whatever row number the last search had been walked down to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
…hey look like A transition whose conditions rule each other out is as dead as one with no conditions at all — every condition is ANDed, so "Wet > 0.8" beside "Wet < 0.2" is a window that never opens. It reads as a working transition and the analyzer said nothing. Same category as the existing dead transition, same Delete fix, different sentence. The check is deliberately blind to parameter types: "Greater 0 and Less 1" is impossible for an Int and perfectly ordinary for a Float, and catching the Int case is not worth ever crying wolf about the Float one. An Any State transition with Can Transition To Self left on interrupts its own destination — while the condition still holds, that state restarts from the beginning instead of playing on, and nothing on screen says why the clip never gets past its first frames. Unity's own editor creates them that way; DaerD already defaults it off for transitions it makes, so this only ever fires on work that came from elsewhere. Transitions gated on a Trigger are left alone, because a Trigger is consumed when it is read and the condition stops holding by itself. Reported once per state machine with a count — a gesture layer holds a dozen, and a row each would bury the rest of the report. Two things moved to where the analyzer can reach them without borrowing from the graph: the solo question to EdgeCommands, which already runs headless, and the rendering of a single condition to ParameterConverter, so an edge label and an analyzer message naming the same condition are the same sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
The row rectangles are only real on the repaint pass, so the widget answered "nothing is hovered" on the layout and mouse passes that precede it. Pushing that to the graph cleared the highlight and set it again for every event the panel received. The end of each frame was correct, so nothing was visible — it was just work, on the pointer's schedule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
…lection Two bugs with the same shape: something is answered while the thing being asked about is still moving. Select All picked a different set of states on every press. It walked the node query and selected each match as it went — but selecting a node brings it to the front of its parent, and reordering the visual tree while a UQuery is walking it makes the walk skip elements and revisit others. Which ones depends on the order left behind by the previous press, so no two presses agreed. It has behaved that way since Ctrl+A was added in 0.9.0. Matches are gathered first and selected afterwards now, here and in the three other places that replaced the selection the same way. Hovering an inspector row lit its edge only when that edge was not selected, because selection outranked hover in the colour order. That is exactly backwards: the question "which line is this row" gets asked when several rows are selected at once and they are all the same colour. Hover now comes first, and thickens the line as well as recolouring it — weight reads on a zoomed-out graph where a hue change on a one-pixel line does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
The menu entry was built only when exactly one state was selected, while the I / O / P keys it mirrors have always worked on all of them. Two ways to ask the same question, disagreeing about what it means. There is now one implementation, on the graph view, that takes a set of nodes; the menu and the keys both call it. The counts in the labels are what the entry would actually select, so "Incoming (0)" is greyed out and says why. The set is every selected node a transition can touch, not only states — a sub-state machine and the Entry / Any State nodes carry transitions too, and "the transitions connected to what I have selected" plainly includes those. Selecting nothing now leaves the selection alone instead of clearing it: the keys fall through rather than wiping what the user had to show them zero results. Disconnect All stays single-state. It removes every transition touching its target, and that is not a thing to widen to a selection without being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
Hovering the comparison popup and scrolling flips it — true / false on a Bool, Greater / Less on a Float, round the four comparisons on an Int. Hovering the threshold and scrolling moves it: a tenth for a Float, a hundredth with Ctrl held, and whole numbers for an Int, which cannot match a threshold of 2.5 however fine the step. Gesture values step through the gestures and stop at the ends, because one notch past would turn the row back into a number field. The wheel is consumed only over those two controls. The parameter dropdown beside them is the widest thing in the row and is deliberately left alone, so scrolling the panel still works wherever the pointer usually is. That is the trade: over a comparison or a threshold, the panel does not scroll. Shift is not available as a modifier — the window takes Shift+wheel for switching layers before any panel sees the event — which is why the finer step is on Ctrl. A wheel notch is not a GUI edit, so EndChangeCheck cannot see it; the draw call reports it back instead, and both the single and the multi-transition editors fold that into what they already do with an edit. Values are rounded after stepping, so ten notches of a tenth land on 1 rather than on 0.99999994. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
Greater / Less / Equals / NotEqual were being translated ("より大", "より小").
They are the words on the same popup in Unity's own Animator window and in
every guide written about it, so a translated pair reads as a different control
than the one being described. They go through no catalogue now, and the four
entries they had are gone from ja.po.
The wheel over a threshold moves it by one, and by a tenth with Ctrl held —
the other way round from what it shipped as an hour ago. An Int still ignores
the finer step, since a threshold of 2.5 on an Int parameter is one no value
can sit on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
…aining themselves Three things asked for after using the last batch. Arrow keys move the selection to the neighbouring node. Only from a single selected node — with several selected there is no "from" to measure against. Candidates are scored by distance along the direction plus twice the distance off to the side, so a near neighbour slightly off-axis beats a distant one dead ahead: a column of states rarely lines up to the pixel, and insisting on dead ahead would skip the state that is obviously next. Something more sideways than forwards is held back and used only when nothing is properly ahead, so the key still moves in a wide layout instead of doing nothing. The view pans just far enough to bring the new selection in, keeping the zoom — framing instead would re-zoom on every press and turn walking a state machine into a series of lurches. M marks the selection as sources and T wires: marked to selected when there are marks, otherwise the selection chained in click order, which is the two-node case that never needed marking. Joining two states was a trip through a submenu either way; it is now click, click, T. The menu entries carry the keys, and marking says how many it took, since a mark is otherwise invisible until the menu is opened again. Transition edges no longer carry a tooltip. A UIElements tooltip is placed against the element's bounding box, and a diagonal line's box is most of the graph — so "1 transition(s)" appeared somewhere unrelated to the line it was about, after every hover pause. The count was already on the edge as a badge and the analyzer reports what solo and mute are doing, so nothing is lost that was not being said better elsewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
The shortcuts were spelled out where they happened to be implemented: a long if/else chain of key codes in the graph view, three more in the inspectors, and nothing anywhere that could answer "what keys are there". Adding one meant finding the chain and hoping the key was free. DaerDShortcuts is now the only statement of a binding. Handlers ask it what a keystroke means and switch on the answer, so a key code appears once, in the table, next to the sentence describing it. Modifiers are matched exactly rather than by falling through an else, which is how Ctrl+Shift+V used to reach the behaviour paste that only meant to handle Ctrl+V. The scope is part of resolving: T wires the graph and is free for a text field in the inspector, and the table says so instead of each handler knowing which keys are none of its business. Three things fall out of having it. The settings page lists the keys by reading the same table, so the written list cannot drift from the working one. A test refuses two commands on one keystroke, and another refuses a command no key can reach. And the descriptions are checked for translations by asking the table — they are plain fields, so the scan for L.Tr literals cannot see them, and a Japanese user would have silently got English. Not in the table: keys that belong to a control rather than to a command — Escape while renaming a node, the arrows inside the search box. They mean nothing outside the widget that reads them. The M notification is a line in the corner of the graph now, not the big centred overlay ShowNotification puts up. It also stays while the marks do, which the overlay could not: a mark survives changing the selection, and a message that fades cannot answer "what is marked" a minute later, which is exactly when the question gets asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
DaerD was handling its own key events, which meant its shortcuts existed nowhere a user would look for them and could not be changed at all. The graph commands are registered with Unity's shortcut manager now: they appear in Edit > Shortcuts under DaerD, rebind like any other editor shortcut, and get their conflicts pointed out by the one system that can see every package at once. The settings page keeps only what it is good for — what the keys currently are, read live from the manager rather than remembered, and a button to the window that changes them. A second editor here could only disagree with Unity's. Ctrl+C, Ctrl+V and Ctrl+Shift+V stay ours. A registered shortcut belongs to the whole window, and those three are exactly the keys whose meaning depends on which pane has focus: Ctrl+C copies states in the graph and the selected transitions or behaviours in the inspector. Handing them to the window would pick one of those and silence the other. They are drawn greyed in the list with that reason next to them. Resolve() no longer answers for a registered command, or a stock install would run it twice — once from Unity and once from the graph's own key handler — and a rebound one would keep working on its old key. The defaults are now written twice: in the table, and in the [Shortcut] attribute, which needs a constant. Unity is the one that reconciles them, so Unity is what the tests ask: every table id is registered, nothing is registered that the table does not carry, and the two lists of defaults agree. All three pass against a real shortcut manager in the batch-mode run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
Replaces registering with Unity's shortcut manager. Every key is DaerD's again, and the settings page is where they are changed: a row per command with a switch to turn it off and a button that listens for the next keystroke. Rebinding by keystroke rather than by typing a key's name — a shortcut is the keystroke, and spelling it out is a spelling exercise. The listening row says what it is waiting for, Escape gets out, and a keystroke another command already holds is refused by name instead of quietly stealing it. Modifier presses on their own are skipped, or holding Ctrl would end the capture before the user reached the key they meant. Switching a command off leaves its key alone rather than binding it to nothing, which is the point of the switch: it is how you hand Ctrl+A back to whatever else wants it. Bindings live in EditorPrefs, one per scope and command, written in a form somebody can read and clear by hand. A binding equal to the default stores nothing, so a default that changes later reaches everyone who never touched it. Unreadable values fall back to the default instead of throwing. The live table is read from the preferences once and cached, since a key event asks it many times a second. Tests ask Defaults or a hand-built list — never the live one, which would read the preferences of whoever is running them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
Showing the source's whole list was a decision, and it cost the thing that gets done far more often. "What does this arrow do" and "in what order does this state try things" are not the same question: the first wants the two or three transitions the clicked arrow carries, together; the second wants the complete list, which is the only form where reordering means anything, because the neighbours a drag moves past have to be on screen. Answering the second always left the first scattered among rows that had nothing to do with it. The arrow's own transitions are the default now, with the whole list one click away on a switch above the list — drawn only when the two actually differ, and remembered for the session. Reordering follows the whole list, which is where it belongs. The rows also say what each transition is waiting for. An arrow carrying three of them produced three rows reading "Idle → Wave", identical but for a number, so telling them apart meant clicking each in turn — which is most of what made editing one of several awkward. The one-line summary the graph already draws on an edge now labels the row too, from one shared implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
A request used to move the ring's position: the redirect landed on the requested slot's own step and the pass carried on from there, so the steps between were left for the next lap. A flag raised at the same point of the cycle would then pin the pass to one stretch of it and starve everything outside — remotes waiting for a value that is never sent, with no way to say so. It is a detour now. One extra step sends the requested slot, then the ring resumes at the step after the one it left, which every send state writes to "base/Return" on its way past. A detour carries no routes of its own, so requests cannot chain: the worst a pass can be driven to is detour, step, detour, step — one pass in twice the time, and never more however often the flags are raised. The wizard says that number in seconds rather than asking anyone to use requests sparingly. Which steps may start one is now decided on index values instead of slots, because that is the actual rule: the detour returns to the following step, and a step repeating the index the detour just wrote is one the decoder never sees. A slot the pass already visits every other step has no such step left, and is told so — the cycle reaches it as fast as a request could. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BXDzUTpkhKmzieQqwbTDQh
A remote has no way to ask the wearer anything, so "have I received all of this yet" is a question it can only answer by watching. The setup can now generate that answer: a local Bool that turns on once this client has decoded every slot at least once, and never turns off again. The bits behind it accumulate — each Recv driver raises its slot's, nothing clears any — so the check is "has every slot arrived by now" rather than "did every slot arrive this pass". That is what spares it a window to size, a clearing pass to race against, and the second lap a per-pass reading costs a client that starts decoding halfway through one. The wearer reads it as on from the start: their own values were never anywhere else. Write Ready && !IsLocal for a remote that has finished initializing. Off by default, and nothing is synced for it: the flag and its bits are animator-local, and the cost is one Bool per slot plus the watcher's own layer — which the setup now owns beside the cycle's, regenerated and taken away with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
The flag the letter asked for beside Ready: 1 when the lap that just closed did not bring every slot, 0 when it did. Ready says whether this client ever heard everything; this says whether it is still hearing it. Measured against the lap rather than against a clock. A slot the pass sends exactly once is an event that happens exactly once per lap, so its arrival IS the boundary — no window to size, no margin to guess, and a pass stretched by a request cannot make the reading wrong. A slot anything can request is no use as one: a detour would send it a second time and call the lap over with half of it still to come. A pass with no such slot cannot carry the flag at all, and that is refused rather than dropped, with both ways out named. The per-lap bits are cleared by the watcher and not by the decoder, which is what leaves nothing to race: the judgement lands a frame after the marker, when that step still has its whole dwell to run and the next slot is hundreds of frames away. The marker is left out of its own check for the same reason from the other side. And the watcher arms itself on the index rather than through Any-State, because the index sits on the marker for that whole dwell and an Any-State route would re-judge on every frame of it. Its own bits rather than Ready's — those accumulate and these are cleared every lap, and one set cannot be both. Bools cost nothing on the wire, which is the only reason that trade is cheap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
The wizard called it a rate and the word did the damage a name can: a rate reads as an absolute, and this is a division of a fixed pass. Raising every target's ×N changes nothing (the common factor is divided out), and raising one lengthens the pass for the rest — both of which the old wording invited people to expect the opposite of. It is a weight, so it says weight now, and the line under the list says the consequence out loud instead of leaving it to be discovered in the per-row seconds. The Rate() call keeps its name and the saved setup keeps its fields: those are what existing controllers and hand-edited recipes are written against, and neither is what anyone reads. Only the words change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
The one wrong state multiplexing invents: a change spread over several parameters reaches a remote one piece at a time, so for a second or two it shows a combination the wearer never wore. A group ends that. Not by waiting — a remote has nothing to wait on and no way to ask — but by changing where the values land. The decoder writes a grouped target into a shadow instead of into the parameter, and one driver copies the whole set across once every member's flag is up. A driver's entries run in one frame, so the simultaneity is structural rather than a matter of timing. The guard is "every member has arrived", not "the last member just did". A lap that loses one commits nothing and leaves the remote on the last complete set: a whole value one pass old, rather than a fresh half of one. That is also why the flags are cleared by the commit that consumed them, in the same driver as the copies, with nothing in between to race. Targets that share a step already arrive together — one driver sends them — so a group over those is machinery for a guarantee that was already being made, and it says so. Groups are for the ones that cannot share a step: different types, or more of a type than the channels carry. Nothing synced: a shadow and a flag per member, both animator-local, and a two-state layer per group. Nothing runs on the wearer either — the decoder that raises the flags is behind the cycle's remote branch, so their own parameters are never written from a shadow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
DD StaticAnalyze answers what a controller says. Nothing yet answers what it does — which is where the answers people actually want live: whether a transition is ever taken, what a driver leaves behind, how long a value takes to reach a remote. This is the engine for that, and nothing else. A run is a real Animator on a hidden object, stepped by hand. Not a model of Mecanim but Mecanim itself, because a model of Mecanim would be wrong in exactly the places the question is about. The time belongs to the caller: frames per second, how many seconds, and how unevenly. Jitter is not decoration — a controller that only works at exactly 60 fps works nowhere, and a dwell in normalized time, a smoothing built on feedback and anything reading the frame delta all move when the frame does. It is seeded, so the same settings are the same run and a new seed is a new question. Parameter Drivers are applied here rather than left to the SDK's behaviour. That costs fidelity — they land just after the frame that entered the state, and a transition to a state's own self enters nothing this can see — and buys the three things the feature exists for: running without the SDK, telling the wearer's copy from a remote's, and being able to interfere. The product is the trace, not the window: signals by frame, laid out for the question "when did this change". A viewer reads it, and so does a test, which is what lets the part that can be wrong be checked with nothing drawn. It lives in its own folder and namespace and depends on three types from the core, one way. An asmdef in that folder is the whole of splitting it out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
An Animator debugger answers what a controller does. What people need answered is what the OTHER person sees, and that is a different question with a network in the middle of it. Two copies of the avatar now run off one clock, and the only thing crossing between them is what the wire carries. So everything a remote gets wrong has to come from the wire, which is what makes the answer worth anything: an unsynced parameter never arrives, a localOnly driver never runs there, and the IsLocal branch sends the two copies down different sides of the controller. The wire is a periodic sample rather than a stream of changes, because that is the shape the network has. Two things then fall out instead of being modelled: a change that comes and goes inside one interval never leaves the wearer, and two changes to one parameter inside one interval arrive as the second only. That is why a decoder firing on a value CHANGING can miss a step, and it is now something a run shows rather than something a warning claims. Values are rounded on the way over the way the network rounds them — a Float to 8 bits across -1..1, an Int to a byte, a Bool to a bit. Not an approximation of VRChat but VRChat; a run without it would show a remote holding a value no remote can hold. Losses are whole samples, never parts of one: the set travels together, so an index and the channels it describes can never be read half-updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
A trace nothing can read is a log file. This is the window over it: names down the left, the value at the cursor beside each one, and the run itself across the right — a waveform rather than a table, because what a run is asked is nearly always about WHEN, and a table turns every one of those into counting. Play and pause are the viewer's, not the engine's. A run is computed whole before anything is drawn, so playing is a cursor walking a finished result at wall-clock speed. There is nothing left running to pause, and nothing to desynchronize by scrubbing back. Wheel zooms about the pointer so what is being looked at stays under it, and each line keeps its vertical range over the whole run rather than over what is on screen — a line that rescaled as it scrolled would make two moments impossible to compare by eye. States draw as named bands, the way a waveform viewer draws a bus, because a state is a span and not a moment. The module keeps its own palette. The rule the two palettes share is the one that matters — a colour is written down once, under a name — and the second one exists so this folder stays liftable into its own assembly, which a palette it did not own would be the last thing preventing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
Four things, and the first two are one thing. The window was drawing one rectangle per visible frame per row — sixty rows and six hundred frames is thirty-six thousand of them, twice a frame, including for rows scrolled out of sight. It now draws one rectangle per RUN of frames that share a pixel row, skips rows that are not on screen, and does none of it during the layout pass. Most rows in most runs are flat for most of their length, so most rows are now one rectangle. LIVE keeps the clients open and steps them on the editor's own update, and the input panel writes straight into them: change a value and the next frame has it. A controller is understood by pushing on it and watching, and no amount of writing the pushes down in advance replaces having done that once. History is a window rather than everything — a session left open all afternoon keeps the last few seconds and forgets the rest, while the clock goes on counting so a moment does not change its name when the window slides past it. Written-down pokes now have an editor too, which is what a run has instead of hands and what makes an experiment repeatable, which hands are not. And the remote view, as a number: a row per parameter saying how long the other person has been looking at a different value. For a multiplexed target that is the age of their copy, for a synced one it is the sawtooth of the wire's own cadence, and for something that never travels it is a line that only goes up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
Parameters were in the window twice: once as a panel of editable fields and once as rows of the waveform. Two lists of the same thing, and the one you pushed on was not the one you were looking at. The row's value cell is now the control. The row already says whose value it is and already shows what it did, so poking it needs nothing else — and the wearer/remote picker goes away too, because the row is the answer. The rest is the same complaint from the other side: a real avatar declares hundreds of parameters and a run touches a dozen. Rows are grouped under their scope and can be folded, and by default a signal that never moved is not shown. The header still counts the quiet ones, so "not shown" stays distinguishable from "not there" — which is the thing a hidden row must never be confused with. Whether a signal moved is kept as it is written rather than worked out per repaint: hundreds of rows, twice a frame, and a live run would have to answer it again every frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
Running the cycle instead of reading it, which DD DynamicAnalyze can now do, found three things in one afternoon. Two are bugs in code that every structural test passed. The Stale watcher's else branch and a group's way back out of its commit were transitions with neither a condition nor an exit time, and Mecanim never takes one. So Stale judged the first lap and then sat in Judge forever, and a group committed once and then never again — both invisible to any test that reads the layer, because the layer is shaped exactly right. The Ready watcher put the wearer on their own side with a condition on Entry. An entry transition is resolved once, at a moment nothing controls, and a run shows it never taken at all: the wearer sat in Watch waiting for values that were already theirs. It is a route out of the default state now, which is the one place a layer is guaranteed to begin. The cycle's own entry transition is left alone — the send drivers are localOnly, so a remote that stays in the ring does nothing either way, which is why nobody ever noticed. The third is not a bug but a limit worth writing down: a client that has just arrived decodes the index it finds — zero, because nothing has reached it — as that slot arriving. So a group's first commit can carry a value nobody sent. After one full pass the guarantee holds; before it, Ready is how you know. The simulator gained one thing on the way: each client now runs its own copy of the controller, because IsLocal has to be true before the animator initializes and the two clients need different answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
An AnimationClip is a set of values over time, which is what a run is. So that is what a run saves as: it opens in the Animation window, it diffs, it has a meta file and an undo, and none of that had to be invented. What a clip cannot say — whose values these are, whether a number is a bool or a state's index, and what that index is called — rides along as a sub-asset, so a saved run is still one file and still comes back whole. A clip from somewhere else still loads; it just loads as unscoped floats, which is enough to look at. And it reads the other way, which is the half worth having. A saved run loads as timed inputs: what one experiment recorded becomes what the next one is told to do, against a different wire, a different frame rate, a different seed. Being able to write a stimulus by hand and not to capture one would have been a strange place to stop. The times need care in that direction. A sample is taken after its frame ran, so a value that first reads differently at frame k was caused by something already true when k began — pokes go back to the start of the frame, and a quarter of one earlier again so that repeating a run lands them on the same frame however the arithmetic rounds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
Everything DaerD knows about the Parameter Driver is reached by name — the serialized fields it reads and writes, the numbering of the change types, the callback that decides when a driver happens — because it carries no reference to the SDK and has to work without one. A name is a fine way to talk to something and a terrible way to remember it: the SDK renames a field or adds one, and everything still compiles, still runs, and quietly does less. So the beliefs are now read off the shipped type and asserted, and skipped when the SDK is absent, which is also when none of them could be wrong. Reading it settled the timing question outright: the driver overrides OnStateEnter and nothing else, so a driver is a thing that happens on the way INTO a state. The simulator's note is narrowed to the divergence that actually remains — a driver's write is not visible to another layer inside the same frame, so a chain across layers takes a frame per link here and one frame in total on a headset. The check that pays for itself is the one pointing the other way. A field DaerD does not carry is a field the exporter drops, and asking the SDK what it has turned up preventRepeats — the option that stops a Random driver rolling the value it just rolled. Someone's controller went through the exporter and came out without it. It is carried now, end to end, and the test that found it fails if the SDK grows another. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
A simulator that is wrong is a nuisance. A simulator that is silently wrong is worse than none, because every answer it gives is then worth less than it looks — and the answers look exactly the same either way. So the places this run is known to part company with a headset are now read off the controller in front of it and stated above the result, named rather than counted: which layers choose where to begin with a condition on Entry and will therefore start in their default state; which drivers sit in a state that can be entered from itself and will fire once instead of every time; which parameters are driven in one layer and read by another, where a headset closes the loop inside a frame and this takes one per link; and which behaviours are not run at all — with the layer weights told apart from the rest, because that is the only one of them that can change a number this records. Each of those was already true and already written down in a doc comment nobody reads while looking at a waveform. Saying it about THIS controller, where the answer is read, is the difference between a stated assumption and a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCuhRLxfPecoKWuSBv2YJw
…ar it moved Two questions were being answered by eye on the plot. "Did that setting change anything" meant saving the run in hand to a file and opening it again as a ghost, which left a folder of .anim files nobody wanted to keep; and "when did this row change, and by how much" meant nudging the cursor a frame at a time and reading two numbers off two cells. Compare With This Run puts the trace in hand under the next one. It works because of how a batch run already behaves: Run REPLACES the trace rather than appending to it, so a reference kept before the next Run goes on being the run it was taken from. Nothing is copied and nothing is written. The item is disabled in a live session, which is the one mood where that is not true — there the same object is appended to for as long as the session lasts, and the ghost would be the run itself, drawn under itself. The viewer can pick a row out now: a click on the name column selects, a second click on it clears, headers are not selectable. The selection is drawing state and is held as scope and name rather than as the Signal, which is the interesting part. A held Signal would either vanish at the next Run — the one moment a reader most wants the row they were watching to still be there — or, worse, go on answering out of the trace that has been replaced, printing a number from the previous experiment beside the right one. Scope and name are what make a row the same row across two runs, which is what the ghost is matched by for the same reason. What that buys: ] and [ walk the cursor to the selected row's next and previous change, and with a mark down the toolbar says how far that row's value moved between the two cursors, beside how long it took. The pair is what a duration is usually being measured for — how fast did this ramp, how much did the wire round off — and neither half was readable without counting pixels. Bounded to one row deliberately. The alternative considered was jumping to the next change anywhere in the run, which needs no selection at all; it was dropped because a real avatar's trace has hundreds of rows and the next change anywhere is nearly always a lag row twitching, so the key would walk a reader through noise. A row picked out is a question asked, and these keys answer that question. The difference is Float and Int only. A Bool or a Trigger differing by one is what "it changed" already says, and subtracting two state indices is arithmetic on names — 3 minus 1 is not two of anything. It is signed and reads cursor minus mark, because which way it went is half of what is being asked. Keys are spelled in WaveformInput beside the ones already there rather than added to DaerDShortcuts, for the reason that file's own note gives: the table lives in the core and this module is meant to lift out into an assembly of its own. Both brackets were free over this plot — F, A, the arrows, Home and End are the whole of what it answers to. The arithmetic behind them (NextChange, ValueDelta, ValueDeltaText) sits on the view where a test reaches it without an event, which is the structure the gesture rework already established. Not covered: the click that selects is taken off the raw event rather than through a control, because a label here takes no control id on purpose, so selecting while a value cell is being typed into leaves that cell focused and the bracket keys go to the text field until it is let go. The selection tint is drawn under the name and value columns only — a wash over the waveform would change what every ink on that row reads as. Compatibility: viewer and window only, the engine is untouched, and no serialized field is added — the selection is not remembered across a domain reload, which is the same promise the cursor and the mark make. Saved runs, opened clips and ghosts loaded from disk behave exactly as before. Six tests: the ghost taken from the run in hand still being that run after the next one, the selection surviving a re-run as the new trace's row, the jump landing on both edges of a stepped signal and stopping at the ends, the jump doing nothing without a selection or with one this run has not got, and the difference over Float, Int, Bool and State rows. The suite runs 1039/1039 with the VRChat SDK installed; it was not run without, because nothing here touches a behaviour, a driver or the engine. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
A deterministic clock, a seeded wire and a stimulus written down in advance exist so that two runs of the same settings can be laid side by side. The settings lived only in the window's fields, so the moment a run was saved its question was thrown away: the .anim held an answer nobody could re-ask, compare against one changed setting, or hand to anybody. Opening yesterday's result told you what happened and never what had been asked. The manifest that already rides along as a sub-asset now carries the settings too — clock, wire (interval, latency, loss, rounding, seed, who joined when, what travels) and the timed inputs — and Open Run puts them back into the form. Silently, without a dialog: "the settings of the run I just opened are in the form" is what a reader expects rather than what surprises them, and a confirmation nobody would ever answer No to is a confirmation that trains people to dismiss the ones that matter. A clip that carries none leaves the form untouched, which is what every clip saved so far does. The block is flat by value and carries a version int rather than being a serialized SimSettings. A file format that is the engine's object graph moves whenever the engine does, and this one has to be readable in the .anim's YAML by a person. Version 0 is the whole compatibility story: Unity gives a missing block its defaults rather than a null, so a run saved before this change reads as version 0 and means "no settings", which is a different answer from settings of zero. A version this build does not know is read for the fields it does have names for — refusing would leave the reader holding settings that are nobody's. Two things are deliberately derived rather than stored. Whether the wire had a seed of its own is worked out from the seed differing from the clock's, because the number is what reproduces the run and the tick box is a way of typing it; what that loses is a number typed into the box and then unticked, which changed nothing. How many remotes there were comes from the join list for the same reason. The alternative — saving the window's form — would have made every field the panel grows a change to the file format. What is saved is the settings that PRODUCED the trace, not the fields as they stand, which is why a live session now keeps them too: the panel goes on being edited after a run, and settings written beside a result they did not produce would be a file that lies in exactly the way this change exists to prevent. Not promised: that a clip's settings still describe the controller they were run against. Nothing here notices a controller edited afterwards, and nothing can. The findings follow from it. RunFindings takes settings because half of what it knows — a change that died inside one sync period, a value the wire could not carry, an input that never leaves the wearer — cannot be read off a trace alone; an opened run used to get the trace-only half and now gets all of it when the clip says what the wire was. EditMode: 1048/1048 with the VRChat SDK installed, and 1035 passing with 6 skipped without it — TraceClip sits next to the engine, so both. Existing clips, existing tests and the Animation window see no change: the curves, the bindings and the signal list are byte for byte what they were. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…ent's script The window has two moods and no way from one to the other. Live is where a controller is understood — press it, watch it, press it again — and batch is where an understanding is checked, repeatably and against a wire. An afternoon of pressing things taught somebody exactly which three presses mattered, and then the run that would prove it had to be typed back into Timed inputs from memory, at times nobody wrote down. A live poke now writes itself into that list, at the second the session was at. The wiring point is the value cell's write callback, which is the only place in this window a person edits a value directly: what a driver wrote, what the wire carried and what a layer blended are all values the run WORKED OUT, and taking those down would produce a stimulus that replays the run's own output back into it. So the rule is not "record what changed" but "record what was asked", and the callback is the one place those two are already told apart. A layer's weight is the exception, and it stays out. A timed input is "at this second, set this parameter to this", and a weight is not a parameter — Stimulus says why at length, and the short of it is that a timed weight is a Layer Control by another name. Since the weight row is written through the same callback, silence there is a decision rather than an oversight: a list that quietly carried one would replay into a different run than the one it was taken from. Live remains the only place a weight moves at all. Two writes at one moment collapse into one input, the later value winning. A float cell fires on every repaint while it is being dragged, so a paused session would take a hundred inputs down for one drag — and all of them would land on the same frame anyway, where only the last can be seen, so nothing about the replayed run changes. That is the only thing ever dropped. Considered and rejected: capping the list and discarding the oldest past N. A hand is the experiment, and an experiment that silently forgets what was done to it is the worst surprise this window could hold; a long list says how long it is in its header instead, which is also why the header now counts — the list grows while it is off screen, because Timed inputs is a batch mood's panel. Restart does not clear it either. The minus button is still the only thing that removes an input, which is the property that makes the rest of this safe. The rule sits in a static told a list, a session and a row rather than inside OnGUI, because an EditorWindow cannot be put under test and the rule can. Scope is normalised to the panel's spelling on the way in — the wearer is the empty scope in a stimulus and "Local" on a row, and one list spelling it both ways is one the reader cannot sort. EditMode: 1048/1048 with the VRChat SDK installed. Not run without it for this commit's sake — window and list only, nothing here touches a behaviour, a driver or the engine — though the wave it belongs to was run both ways. Saved window layouts keep their timed inputs: the list's element type is unchanged and only gains entries nobody typed. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…ered The module is meant to stay liftable — an .asmdef away from being a simulator somebody can take without the rest of DaerD, or a DaerD somebody can take without a simulator. That promise is about the EDGE between the two, and an edge is the one thing nobody can see while writing a line of code: a using that resolves, a call that compiles, and the promise is gone without a test going red. It was being kept by memory, and memory had already drifted. ADR 0017 says the module reaches three core types (VrcParameterDriver, ControllerIR, L); the scan finds six, plus three extension methods off a seventh class — seven core things in all, every one of them added for a reason at the time and none of them noticed as a widening. The list below is what the module actually depends on, so the drift stops here whatever the ADR eventually says; correcting the ADR is a human's call and is deliberately not done in this commit. Two checks, and they are not the same check. Nothing outside the module may name it at all, comments included — the core is what stays behind, and a sentence about a module that has been lifted out is the next reader's wild goose chase. The module may name the core, because it plainly must, but only through an allowlist that carries a reason per entry: adding a line there is the moment to ask whether this is still liftable, which is the whole value of having to add one. Names the module does not use are not pre-approved — an allowance nobody is exercising is a hole nobody is watching. Over source text rather than over compiled types, deliberately. Both halves live in one assembly today, so a reflection test would be asking the compiler about a boundary the compiler does not have; text is what the boundary is made of until there are two .asmdefs, and on that day this test becomes redundant rather than wrong. A name counts as a dependency in the three shapes one has to take to be worth anything — dereferenced, constructed, or called as an extension method — and comments and string literals are blanked first, because RunFindings names two core classes in prose precisely to say it does NOT use them. What it cannot see, chosen after measuring rather than assumed. A core type used only as a declaration and never touched is invisible: telling that from a local's name needs a C# parser. And a core name the module also gives to something of its own drops out, which today is six real collisions — Entry, Find, Frame, Handle, Note, Row — every one of which would be a false alarm, and a guard whose alarms are usually wrong is a guard people learn to silence. Both tests carry the self-checks that idiom needs (source counts, core-type counts, and a synthetic dependency built from a name picked out of what was just collected, so the reader is proved able to see one). Verified by planting a violation of each kind: both fail with the offending file and line, and the scan reports nothing on the tree as it stands. EditMode: 1048/1048 with the VRChat SDK installed and 1035 passing with 6 skipped without it — the wave these tests guard was run both ways. Tests only, so no behaviour changes either way. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…ally uses DD DynamicAnalyze carried every built-in the same way: one Sync.Broadcast list, written across every frame, uncompressed. That is the fastest of VRChat's channels applied to all of them, and it told two lies about the avatars people actually bring here. A gesture crossed on the frame the wearer's hand moved, where a headset spends up to a tenth of a second on it and then interpolates — so a run reassured you about exactly the timing a gesture layer is most likely to be wrong about. And AvatarVersion was filed as never leaving the client at all, so a controller that branches on the avatar version was shown branching only on the wearer's copy. The official channel list disagrees with both. Source: https://creators.vrchat.com/avatars/animator-parameters/ Sync is now named after the channel rather than after "does it cross": Ik, Playable, Speech, with PerViewer and Local as they were. Naming it that way is the fix and not decoration — the channels differ in each of the three things a run exists to answer, which is when a value leaves, whether it can be lost on the way, and what the other person's copy does with it in between, and one "it broadcasts" answer covers all three with the flattering half of the truth. The table follows the documented split, which deliberately does not follow the categories: the two gesture indices ride with the pose while the two weights beside them ride with the expression sample. IK keeps its own fixed 0.1 s schedule — VRChat's number, so a const with the source beside it and no field for anybody to turn — and rides the wire's existing delivery queue and latency, because it is the same network. A Float does not snap on arrival: the update sets where the value is heading, and the receiving copy walks to it over one interval, which is what stops ten updates a second looking like a staircase in the parameters most likely to be driving a blend tree. The playable built-ins ride the wearer's expression sample itself — same cadence, same latency, gone with the same lost sample — because that is what "the same channel as an expression parameter" means; they are not quantized, since the eight bits over -1..1 buy a place in the avatar's parameter budget and a built-in was never in that budget. Speech is unchanged. Sending, landing and interpolating live in Simulation as statics the live session calls, exactly as the sample queue already did, so the session keeps a schedule variable and nothing else of its own. Not modelled, and each for a reason. Loss on the IK stream: a dropped tick of a stream that repeats every 0.1 s is overwritten by the next one, so it would buy a tenth of a second of delay plus the impression that a run can tell you how often loss happens — the cost is that a run cannot show IK packet loss at all, and should not be asked to. A guessed interpolation curve: the real one is not published, so this is a straight line that says so in its doc rather than an ease that would put a shape this module invented into somebody's answer about their avatar; mid-interpolation, a frame here and a frame on a headset are not promised to agree. A cadence of its own for the playable built-ins: the documented "0.1 to 1 seconds, depending on parameter changes" is the wearer's own sync cadence, so the honest model is the interval already on the wire, and the bracket cannot be explored apart from it. A trace row per IK tick: the tick is already visible as the other person's values moving, and a second thing ticking on the wire's row would make a typed cadence unreadable. VrcParameters .Broadcasts() is deleted rather than reinterpreted — the question it asked now splits three ways, and it had no callers left to split. AdjustedScaleFactor is the one guess in the table: the official channel list does not name it, so it is filed with the rest of the scale family and the doc says as much rather than pretending. What this costs a reader: a lag row on a locomotion Float no longer returns to zero while the wearer is moving, because the other copy is now genuinely a tenth of a second and an interpolation behind. That is the model being honest, not a regression, and the recorder's existing tolerance already treats a settled value as caught up. Speech is still instant, which is an approximation stated in its doc — a headset's remote viseme is as late as the voice, and a run with no audio has nothing to be late by. Compatibility: no field was added to SimSettings or SyncWire, so every saved experiment, ghost and recipe replays from the same settings, and generated controllers and the recipe API are untouched. A run whose controller reads no built-in is unchanged frame for frame — the expression sample's contents, its cadence and the order the wire's dice are drawn in are all as they were, which is what the no-latency wedge run keeps proving. A single-client run is unchanged for the same reason it always was: there is nobody to carry anything to. A controller that DOES read a built-in now produces a different trace than it did, which is the point of the commit. The one user-visible string that moved is the playspace note, which claimed the two copies read the same number here; they now read the wearer's number carried across, and ja.po follows. The BuiltIns_* tests are rewritten to the new truth rather than relaxed: what was "a built-in arrives within a frame" is now "it arrives on the next tick of a 10 Hz channel and not before", the pair that must never cross drops AvatarVersion for PreviewMode, and the session test that asked whether a value got there now compares the live engine against the batch one frame for frame, which is what a channel with a schedule and an interpolation needs. Added around them: IK interpolation climbing rather than snapping, a playable built-in arriving in the same delivery as an expression parameter without being rounded like one and missing when that sample misses, the viseme still frame for frame, AvatarVersion arriving at all, and a single-client run left alone. EditMode: 1053/1053 with the VRChat SDK installed, and 1040 passing with 6 skipped without it — both ways, because this is engine and the built-in names are resolved against whichever driver type is present. Net +5 tests over the 1048 / 1035+6 this branch stood at. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…assumed
The analyzer names three places where a run and a headset are said to part
company — a conditional Entry transition, a state re-entered from itself, a
driver's write crossing to another layer — and all three are beliefs formed by
stepping an Animator by hand in the Editor. ADR 0008 records the doubt in its
own weakest_premise: nobody had ever asked Play mode. A probe that answers it
is worth more than another round of reasoning about it, and worth more still if
the answer stays in the suite.
Each measurement is written once, as a static method full of assertions, and
called from a [UnityTest] that enters Play mode and a [Test] that does not.
The pair passing is the finding. A number that held in only one mode would have
to be written down twice, and two copies of a measurement drift without anyone
noticing which one was measured.
Play mode is reached with `yield return new EnterPlayMode()` from the ordinary
EditMode runner, which turns out to work under batchmode -nographics, so the
suite stays one suite. Entering reloads the domain every time, so nothing is
built before the yield: controllers, stepping, reading and asserting all happen
inside Play mode and no state has to survive the boundary.
What came back: every one of the five measurements is identical in both modes.
A conditional Entry at the top of a layer is never taken — with the parameter
declaring true, with it raised before the first step, with no Rebind, after ten
steps, and off a saved .controller — while the same condition on a sub state
machine's Entry decides every visit, including a second visit with a different
answer. A state entered from itself is entered again, by its own route or from
Any, at both blend lengths. A write made from OnStateEnter is read one step
later — in either direction across layers, and also by the layer that wrote it,
which puts the delay after the frame's transition evaluation rather than at the
layer boundary. An exitTime of exactly zero never fires at all, and a chain that
does fire walks one link per step. A zero-length transition cannot be seen from
outside: by the end of the step it is already over.
The premise under all of it was wrong, which is the most useful thing here.
StateMachineBehaviour callbacks fire on a hand-stepped Animator outside Play
mode too, enabled or not. The rig runs Parameter Drivers inert — that
measurement stands — but not because Mecanim never calls them: the SDK driver
raises an OnApplySettings delegate whose only subscribers live in the VRChat
client and the emulator, and the test stub implements no callback at all. ADR
0017's three reasons for applying drivers by hand (works without the SDK, tells
the wearer from a remote, can be interrupted) are untouched by this; only the
fourth explanation, the one nobody decided on, was false.
Not taken: EnterPlayModeOptions.DisableDomainReload, which would have let state
cross the boundary and saved a reload per test. It is a ProjectSettings change,
so it would alter the environment of every other test in the run to make this
one cheaper — the wrong trade for a measurement. The cost is a domain reload per
Play mode test and nothing else. Also not taken: a second run under
-testPlatform PlayMode with a runtime asmdef, which the plan allowed as a
fallback. It was not needed, and it would have put these numbers somewhere
run-tests.sh does not look. And not taken: writing the numbers down in the notes
only. The point of pinning them is that a Unity upgrade should break this file
rather than quietly move the ground the analyzer stands on.
The scope is Unity, not VRChat. No client and no emulator is in the room, so
the cross-layer result says Mecanim takes a step in both directions and says
nothing about whether the client's subscriber writes back inside the frame.
That question stays open on purpose. Nothing here corrects the analyzer's
wording, the harness agreement doc, or ADR 0017 — measuring and re-arguing a
design in one commit is how a measurement stops being readable as one.
Compatible with everything: no engine, window or existing test is touched, and
no saved run, generated controller or recipe API is involved. The probe uses no
VRChat SDK type, so it measures the same thing with the SDK installed and
without.
Ran the full EditMode suite with the SDK: 1067 of 1067, which is the 1053 from
wave 7 plus the 14 added here. With com.vrchat.avatars removed, the probe alone:
14 of 14. Nothing was unstable across the seven Play mode round trips mixed into
the run, so [Category("PlayModeProbe")] marks these without excluding them from
the default run.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…cond later An exit time of 0 was written in two places meaning "there is nothing to wait for here", and it does not mean that. Measured on a hand-stepped Animator: exit time 0 fires exactly where exit time 1 fires, at the loop boundary — a state carrying a 0.5 s clip holds the layer for 0.5 s, and a motion-less one, whose normalized unit is a second, holds it for 1.017 s (61 frames at 60 fps). The two routes written that way are the else of the Stale watcher's judgement and the way back out of a group's commit, so both stood for about a second of every lap in a state whose whole job was the driver that ran on the way in. No test caught it: the shape was the one that had been asked for, and the values still arrived, only later than anybody had written down. The judgement's else now waits a millisecond instead, written in seconds and divided by the state's motion the way the cycle's own dwell is, so it stays a millisecond with the Empty clip on the states and without it. A millisecond is under a frame at any frame rate, so the verdict is reached on the first frame the watcher is evaluated at all. That also makes the else an else for the first time: the conditioned "this slot is missing" routes above it become eligible on that same frame, and Mecanim takes the first transition in list order. Until now they were the only eligible routes for a second, so the ordering the layer depends on was never exercised by anything. Not "no exit time plus a condition that is always true", which is the usual way round this. An always-true condition needs a parameter that can spell one; the Int index can (greater than -1) and the bit encoding has nothing of the sort, so it would mean generating a parameter for the trick's sake — and a condition that asks nothing is one more thing for whoever opens the generated layer to work out. The commit's way out is NOT made prompt, which is the half of this that was planned and then measured instead of assumed. Coming straight back makes a group tear MORE, not less. With the commit prompt the guard closes the moment the last member arrives, so every commit copies each member's first decode after the previous commit — and a change the wearer made between two members' sends is copied out half-old. Sweeping the moment of a change from 3.0 s to 4.2 s in tenths, on a three-slot 0.3 s cycle with a two-member group: four of the thirteen tore with the dwell in place, nine without it. So the dwell stays, and is spelled as the exit time of 1 it always was, under its own name and with its own reason, so the next person to tidy it away reads why first. That leaves the group's real hole in the open rather than closed: its members travel in different steps of the cycle, so no dwell makes a change atomic, and the "never half of a change" promise the runtime suite states is kept by the moments that suite happens to pick — on the build before this commit as much as on the one after it. Closing it is a change to the guard or to the schedule, and it is not made here. What is done is that the odds are now measured in a test instead of being a property nobody had looked at. Compatibility: controllers already generated keep the old shape until they are regenerated, and go on behaving exactly as they did. A regenerate — wizard or recipe — writes the new judgement route, and rewrites the commit route from an exit time of 0 to the 1 Mecanim already read it as, which is the same transition. No saved setup, generated parameter or recipe API moves, and nothing outside the generated Stale layer changes what it does. Tests: the whole suite twice, because this is the core builder and it sits next to the drivers — with the VRChat SDK installed 1072/1072, and with it removed 1065 run, 1059 passed and 6 skipped. Five are new: one reading the two routes' exit times off the built layer, and four in a suite of their own that runs them — the judgement's dwell and the tie its list order breaks, the same with a 0.5 s Empty clip on the states, the commit's loop, and the tear that loop does not close. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…rom itself A run served a state's Parameter Drivers when the layer's state hash changed, and a transition from a state to itself does not change it — so a state re-entered from itself, or re-entered by an any-state route with canTransitionToSelf, drove its parameters once for the whole run however many times it was actually entered. The note in SimNotes read that as something the harness could not reproduce. It is not: measured on a hand-stepped Animator, in play mode and in the editor alike, Mecanim calls OnStateEnter a second time for both routes and at a blend of 0.25 s as well as 0. Mecanim was reporting the re-entry all along and this was not looking at it, which makes the missing drives a bug rather than a limit. What it looks at now is the layer aiming at the state it is already in: while a transition blends, the "next" state is the destination, and a destination equal to the state last entered is a transition to itself. That is true of every frame of the blend, so the drive is taken on the rising edge — a per-layer flag saying the blend in progress has had its destination served, cleared when the layer is seen settled. The same flag is set when an ordinary transition is served, which is what keeps an ordinary blend at one drive: before this, "the hash has not moved" was doing that job, and the new branch would have driven a quarter-second blend fifteen times without it. The alternative was to stop reading entries off Mecanim and let Mecanim report them, by hanging a behaviour of our own on every state of the controller each client runs. That would catch the one case this still cannot (below). It was not taken because the controller a client runs is an Instantiate of the user's, and the copy shares the state machines it was made from — that sharing is why a client costs a header instead of a whole controller — so writing behaviours onto its states writes them onto somebody's saved asset. Doing it safely means a deep copy of every layer per client per run, which is a cost paid by every run to recover one case, and it would move the drivers out of the frame order the whole module is built on: Mecanim runs, then the entries it made are served, in a place where a wire can be put in between. Also not taken: counting a self transition that interrupts a blend into the same state as a second entry. A restarted blend and a running one are the same transition under the same hash, so telling them apart means watching the transition's normalized time for a step backwards and calling a guess an entry. Two drives for one entry would be the run inventing a write nobody made, so consecutive self transitions are counted as one, and the flag's doc says so. The scope of what this fixes: a self transition with a blend. One of duration 0 is still invisible, and not because nothing looks — measured, a transition of no length is finished inside the step it begins on, so the state is the state it was, aiming nowhere, and no frame of any run carries evidence that anything happened. There is nothing there to read. That limit is now a test rather than a sentence, held by a trigger whose press is checked to have been consumed so the case cannot pass by not happening. SimNotes still tells a reader that a re-entered state's drivers fire once instead of every time, which after this is only true of the duration-0 case. Narrowing that wording, and the rest of the wording the play-mode measurements overturned, is the next commit's work and is left whole here rather than half done. Compatibility: nothing generated, saved or exported changes. Every any-state route DaerD builds sets canTransitionToSelf false, so no controller this repository writes runs differently in an analysis than it did. Saved traces are recordings and are untouched; a rerun of a controller that has a blended self transition on a driven state now shows the drives it always made on a headset. The two harnesses part where they parted before — AnimatorRig runs no driver at all, so this changes how many entries the simulation serves, not the list of things the two disagree about. Tests: the whole suite twice, because this is the engine's driver path — with the VRChat SDK installed 1076/1076, and with it removed 1069 run, 1063 passed and 6 skipped. Four are new, all against SimClient with an Add driver counting its own entries: a state's own transition to itself at a 0.25 s blend (twice, and with the via row naming "A → A" while it blends), the same through an any-state route with canTransitionToSelf, the duration-0 limit above, and an ordinary blend between two states that must still drive exactly once. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…d from several A group promised that a remote never sees half a change, and it delivered the half of that which is about the far side: the members are held aside as they arrive and copied over by one driver, so nobody is ever caught mid-copy. What it never had was the other half. The members leave in DIFFERENT steps of the cycle, so a change the wearer made between two of those steps was already half on the wire, and no guard on the receiving end can reassemble two halves that were never a pair. 4d7a618 measured it rather than assuming it: sweeping the moment of a change from 3.0 s to 4.2 s in tenths, on a three-slot 0.3 s cycle with a two-member group and a wire that dropped nothing at all, four of the thirteen moments reached the far side torn. The runtime suite's "never half of a change" had held for as long as it had because the moments it picks are whole ones. The fix is a latch on the SENDING side, and a window on the receiving side that follows from it. Each group is given a latch point: the first step of the pass that carries any of its members. That step's driver copies every member of the group into a latch parameter before it touches a channel, and from then until the ring comes round to it again every step sends its members out of those latches rather than out of the parameters. A driver's entries run in order and in one frame, so the reading is one moment's — structural, not a matter of timing — and the values one lap puts on the wire cannot be from two moments. The clean sweep is 0 of 13 where it was 4. The receiving side needs the matching half because the wire loses things. The decoder state for the latch point's own slot puts every one of that group's arrival flags down before it writes anything, so the flags a commit fires on all stand for decodes taken since that arrival — one latch's worth. Without it a lap that lost a member would leave its flag standing and the next lap's member would complete the guard against it, which is two readings again by another route. Not a generation number travelling with the values, which is the version of this that closes the hole completely. It needs bits on the wire, and the wire is the one thing a group is not allowed to spend: a group costs no synced bits today, and saving bits is why a cycle exists at all. A counter narrow enough to afford is ambiguous under the loss it exists to survive, and a wide one is several bits off the budget the multiplexing was bought to stretch, on every avatar that uses a group whether or not its wire ever drops anything. Not a receiving-side fix alone, which was the shape the guard was reached for first. Waiting for a settled set cannot help: by the time the values are in the shadows the two halves are already two halves, and every version of "wait longer" only chooses which torn pair to show. Nor a step of its own for the latch — that costs the pass a place, so every other slot comes round less often, to send nothing. What is left open, and left open deliberately: a lap that loses the arrival which would have opened the window leaves the previous lap's flags standing, and a member of the new lap can complete a commit against them. Both values are latched readings; they are two laps' readings rather than half of one change. Measured over eight loss seeds and the same thirteen moments with a quarter of the samples dropped, 32 of the 104 runs tore before this and 7 after, all seven of that shape and in one seed. The freshness cost is the other half of the trade and is bounded by a pass: a change made just after a group's reading waits for the ring to come round to it. A request for a grouped member is answered with the current latch for the same reason — a detour is a step out of turn, and a fresh reading sent there would be the tear this closes, at a moment nothing scheduled. The commit's dwell is NOT touched here, though it is now standing in front of a hole that has mostly gone. That is a separate measurement and it is made in its own commit, so that the 0 of 13 is established against a build that differs from its predecessor in one thing. Compatibility: controllers already generated keep the old shape until they are regenerated, and go on behaving exactly as they did. A regenerate — wizard or recipe — creates one more animator-local parameter per grouped member, "Latch/" beside the "Hold/" and "Held/" it already had. Nothing new is synced, nothing is added to the parameter store, and the wizard's cost line now counts the group's local parameters from the list that is generated rather than from a factor written beside it. Setups without groups build byte for byte what they did. No saved setup, recipe API or generated state name moves. Tests: the whole suite twice, because this is the core builder and it sits on the drivers — with the VRChat SDK installed 1082/1082, and with it removed 1075 run, 1069 passed and 6 skipped. Six are new. Three read the build: that a group's latches are created on the controller and reach neither the store nor a second copy on regenerate, that the latch point is the first member's step on both sides at once, and that a detour sends the latch rather than a reading of its own. Three run it, in the suite that owns the sweeps: the clean sweep at 0 of 13, the same sweep with a request held down all run at 0 of 13, and the lossy sweep at 7 of 104 against the 32 the build without the latch measured. The wedge 4d7a618 left — that a change at 3.4 s is shown half-old — is inverted rather than deleted, so the moment that used to be the counterexample is the one now named as whole. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…pletes The way out of a group's commit waited a whole loop of the state's own motion — about a second, motion-less — and did so deliberately. 4d7a618 had planned to make it prompt and measured instead: coming straight back made the group tear MORE, at nine of thirteen change moments against four. The reason was that the tear was on the sending side. The guard closed the moment the last member arrived, so every commit copied out each member's first decode since the previous one, and a change the wearer made between two members' sends went out half-old. The dwell was not fixing that; it was blunting it, by throwing away the decodes that would have caught the group mid-change and letting a later, settled set through instead. 9065a70 removed the thing the dwell was blunting. A lap now carries one reading of the whole group, so there is no worse half of a lap left to wait out. Measured on this build with the commit prompt: 0 of the 13 clean change moments tear, the same as with the dwell, and under a wire dropping a quarter of its samples 0 of 104 runs tear either way. The wait is buying nothing it was kept for, so it goes, and what it was hiding comes back: a whole set reaches the far side on the frame it completes rather than up to a second later. Commit occupancy measured at 1 frame, against the 61 a loop of a motion-less state is. The transition is now the same Immediate the drift verdict's else takes — a millisecond, written in seconds and divided by the state's motion, so it stays a millisecond with the Empty clip on the generated states and without it. AfterALoop goes with it: it had one caller and its whole doc was the argument for the wait, which is now wrong. The argument is not lost — it is the last two paragraphs of Immediate, because the next person to look at that route will be looking at it for the same reason, and "somebody tried this and measured it worse" is the first thing they need. Not left alone as a margin against the hole that is still open, which is the tempting reading now that the tear has a known residue. The residue is a lap that loses its own opening arrival, and a dwell does not help with it: the flags it completes the guard against are already standing when the commit ends, so the wait only delays the same torn pair. Measured at 70% loss, where the residue actually shows: 6 of 104 runs with the commit prompt, which is the same shape and the same handful the dwell build produced. What this costs: a group that commits every lap now enters and leaves its Commit state every lap rather than once a second. That is one more state change per lap per group on every client, which is what a state change costs and no more — the driver ran exactly as often before, since the guard was what gated it. Compatibility: controllers already generated keep the old shape until they are regenerated, and go on behaving exactly as they did — a second later per commit, which is what they do today. A regenerate — wizard or recipe — rewrites the commit's way out from an exit time of 1 to the millisecond. No saved setup, generated parameter, recipe API or state name moves, and a setup without groups builds what it built. Tests: the whole suite twice, because this is the core builder and it sits on the drivers — with the VRChat SDK installed 1082/1082, and with it removed 1075 run, 1069 passed and 6 skipped. No test is added; three change sides. The commit's dwell, pinned at 61 frames, is now pinned at one, with a second assertion that the group commits at least once a lap rather than once a second. The builder's reading of the two unconditioned routes now expects the same millisecond on both. And the lossy sweep is rewritten around the loss it takes to reach the residue: 25% and 50% of samples dropped tear nothing (the build before the latch tore 32 and 36 of those 104 runs), 70% tears 6 against 29, and 80% was measured at 19 against 33 and is written down rather than pinned, because three sweeps is already most of what this suite costs to run. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
The list of what a run does not promise was written before any of it was measured. Now that it has been — on a hand-stepped Animator, in play mode and in the editor alike (PlayModeProbeTests) — two of its five notes turn out to describe Mecanim's own behaviour rather than a shortcoming of the simulation, and a third is far narrower than it said. A caveat about faithful behaviour costs more than silence: it teaches whoever reads the panel that the list is guesswork, and the notes that are true are then worth less than they look. What is withdrawn, and what overturned it: - "Layers that choose where to begin with a condition on Entry start in their default state here, whatever the condition says." Mecanim starts them there too — measured four ways, in both modes, in-memory and off disk. And the route is not dead: pass through Exit and the same condition is read again, every time, answering differently as the parameter changes (the new M1d). So a controller that splits the wearer from a remote at Entry does run down the default side, on a headset as well as here. The note is gone; the trap itself belongs to static analysis, which can see a shape a run cannot. - "A driver's write reaches the other layer on the next frame here, and inside the same one on a headset." The second half was an inference from the SDK driver applying its entries from OnStateEnter, and it does not survive: no transition reads a write made from OnStateEnter inside the step that made it — not layer 0 to 1, not 1 to 0, not even on the layer that wrote it. Mecanim raises the callbacks after the frame's transitions are decided, which is where this serves drivers from, so the frame per link is Mecanim's and not the simulation's. Whether the VRChat client adds something of its own on the subscriber side is unmeasured and would need the emulator or a headset; it is no longer asserted either way. - "This run cannot see a state re-entered, so those drivers fire once instead of every time." True until 0575114 fixed the blended case; what is left is a self transition of duration 0, which is over inside the step it starts on and leaves no frame carrying evidence of anything. The wording says that now, and so does the detection: the note names only states whose route back to themselves has no length. Rewording without renarrowing was the other option and was not taken — it would have kept naming states whose drivers do fire, which is the same disservice in a smaller font. - Probe, "an exit time of exactly zero never fires". It fires; the window was four steps and the link waits for the end of a lap. exitTime 0 is exitTime 1: a 0.5 s clip leaves in half a second, a 0.25 s one in a quarter, and a state with no motion after the second Mecanim gives it. The test watches eighty steps now and pins each answer to its lap within one step, across four clip lengths and both values. This is what AsyncSyncApplier's Immediate was costing before it was changed to a millisecond. - HarnessAgreementTests, "Mecanim stepped by hand outside play mode never calls a StateMachineBehaviour". It calls it, in either mode, enabled or not. The rig still runs every driver inert, so the measurement it guards is unchanged — only the reason was wrong. The real one: the SDK's driver answers its callback by raising an event, and the only subscribers are the VRChat client and the emulator. - The reading that a state's first OnStateEnter never arrives. A layer's default state is entered on the first Update and not at Rebind, which is what SimClient assumes when it serves the initial state's drivers on frame one. The zero that suggested otherwise came from a chain whose first link fires immediately: a state left inside the step it is entered on is never announced, and that is the whole of the exception. Both halves are one test. Deliberately not added: a note for the blend-interrupting self transition that the run counts once instead of twice. Saying it means naming every driven state with a blended self route — the width this note just shed — to warn about a case needing two presses inside one blend, and the run's choice there is to miss a press rather than invent a write. SimClient's doc carries it instead. Also not added: a dead-link check for exitTime 0, which wave 8's write-up asked for on the strength of the reading now withdrawn — the link is not dead, it is slow, and a static check that called it dead would be the same error with a longer reach. What a reader loses: the panel no longer volunteers that a conditional Entry decides only from the second pass, or that a cross-layer driver chain costs a frame a link. Both are still true and both are now properties of the controller rather than of the run, so they belong to whoever reads the graph, not to whoever reads a trace. Nothing is claimed here about the VRChat client's own frame ordering. Compatibility: nothing generated, saved or exported changes, and no recipe API moves. The notes panel shows fewer lines for the same controller, and a controller with only a blended self transition now shows none where it showed one. ja.po loses the two retired msgids and carries the reworded one. ADR 0008's weakest premise and ADR 0017's addendum still record the old explanations; those are a human's to change and are untouched here. Tests: the whole suite twice, because SimNotes reads behaviours off states and that path differs with the SDK present — with the VRChat SDK installed 1087/1087, and with it removed 1080 run, 1074 passed and 6 skipped. Six probes are new (three measurements, each run once in play mode and once out of it): Entry read again through Exit, the exit time as a lap, and the default state's entry. The Notes_* tests go from three to two: the retired pair is now a single test asserting that neither shape is named, and the self-transition one gained a blended control that must stay silent. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
DD DynamicAnalyze could only answer about a controller it ran itself. The question
people ask first — "what did it do when I put it on" — was out of reach, because the
answer lives inside somebody else's Play mode.
WHERE THE VALUES ARE. Both editor tools an avatar is worn with drive it through a
PlayableGraph: an AnimatorControllerPlayable per VRC playable layer under a layer
mixer, with the Animator component as the graph's OUTPUT and no controller on it.
Animator.GetFloat against that answers for whatever Unity feels like or for nothing.
So the recorder reads the graph, through Unity's own Playable API, and names no
tool's types anywhere: Utility.GetAllGraphs() for the graphs, an animation output's
target for which avatar, and a walk down to the controller playables inside.
The rejected alternative was reflection over one tool's own parameter objects. It
reads more directly and it was dropped: private API that changes shape between
releases, and the second tool would need a second implementation of the same idea
that could then disagree with the first. What the graph route gives up is anything
only a tool knows — which of VRChat's playable layers this is, whether the avatar is
a clone, what the tool calls itself. None of that is in a trace.
MEASURED BEFORE IT WAS WRITTEN, and now pinned in DynamicAnalyzeRecTests because
each one is load-bearing and none of it is documented:
- a graph built by hand a moment ago IS handed out by GetAllGraphs, which is the
only reason a graph nobody registered can be found at all;
- an AnimatorControllerPlayable created over an AnimatorOverrideController reports
the BASE controller's layer names and the base's fullPathHash for every state — so
the tables built from the asset in the window's field name the states of the avatar
on screen even though the running object is a wrapper;
- an Animator with an ordinary controller has a graph of its own the moment the
controller is assigned, so plain playback goes through the same road and gets state
rows too. The Animator-reading side of PlaySource is therefore a safety net (a graph
with no controller playable in it), not the plain-playback path the plan expected;
- a destroyed PlayableGraph goes on being handed out AND goes on answering every
question. Hence two consequences written into the code: matching takes the LAST
candidate rather than the first, because graphs come out in creation order and the
newest is the live one; and liveness is asked of the Animator, not of the graph.
The cost is stated where it is paid — a tool that drops its graph and leaves the
avatar standing is one this goes on recording stale values from.
WHAT IT PROMISES AND WHAT IT DOES NOT. A Trigger is a press and this is a poll: one
look per editor update, so a press raised and consumed between two looks is invisible.
It stays a Trigger row rather than becoming a Bool, and the frames nobody looked at
are counted (Missed) so a quiet recording can be told from an unwatched one. The
mixer input weight — how much of a whole VRC playable layer is being blended in — is
not recorded; the weight row means the controller's own layer weight, the same
quantity a simulated run records, and a row that meant sometimes one and sometimes
the other would be worse than no row. Sampling is keyed on Time.frameCount, so a
second call inside one frame is refused rather than written down as a frame of zero
seconds. Nothing is trimmed: a live session keeps a window because it is meant to be
left running, a recording is meant to be kept.
Layers are matched as a multiset of names and then aligned BY name, so a playable
that agrees on which layers exist while disagreeing on their order is read correctly
rather than plausibly. Reading one layer's state under another's labels is the single
failure a recording must not have — rows that are wrong rather than absent.
SHAPE. The trace is a trace: rows named exactly as a run names them, under scope
"Play". That is what lets a recording be saved as a clip, laid under a run as a ghost
and read by the findings. "Play" is deliberately not one of Simulation.IsClient's
scopes — a client is a copy this module made and its cells may be typed into, and
nothing here can reach into somebody else's Play mode to set a parameter, so an
editable recorded row would offer a control that writes nowhere. RunFindings' two
trace-only findings now read any avatar's rows (Simulation.IsAvatar) rather than only
a client's: "a state nobody entered" is the same finding whether the avatar was
simulated or worn.
SimClient's walk over the controller — the state and transition hash tables, and the
drivers found on the same pass — moves out to StateTables so the recorder labels a
state exactly the way the simulator does. Two copies of that walk would be two answers
to "what is that state called", and the worth of a recorded row is that the simulated
row beside it is labelled the same. SimClient's behaviour is unchanged: it now holds
the tables instead of five arrays, and its 104 existing tests are the wedge that says
so.
COMPATIBILITY. Nothing recorded before exists, so nothing is migrated. Saved runs,
saved layouts, recipe API and generated controllers are untouched; TraceRecorder's
KindOf only changed visibility. No window is involved yet — this commit is the engine
and the tests over it.
Tests: full suite twice, being engine-adjacent. With the VRChat SDK 1101/1101, without
it 1094 of which 1088 pass and 6 skip (baseline 1087 and 1074+6; 14 new here). One of
the 14 enters Play mode and is tagged Category("PlayModeProbe") with the rest of the
tests that do.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
Run computes an experiment, Live keeps one open, and neither could answer the question people actually arrive with: what did the avatar do when I put it on. Rec is that — it watches an avatar somebody is wearing in Play mode and writes down what happened, then hands the result to everything the window already does with a trace. MOOD, AS TWO BOOLEANS. An editor layout is serialized fields by name, and every layout ever saved from this window carries _live and nothing else. An enum would have been tidier and would have had to give the old false a number — pick wrong and every saved window opens in a mood nobody left it in. So _live keeps its name and meaning and _rec is added beside it, with the invariant that at most one is up and SwitchTo as the only thing that writes either. A layout that predates this reads _rec as its default and opens exactly where it was; the test that says so asserts the field NAMES, because names are the whole of what a layout is. What that costs is a pair that could in principle disagree — bought back by there being one writer, and by "Batch" being a question asked of the pair rather than a third flag to keep in step. WHAT THE MOOD OFFERS AND WHAT IT REFUSES. An Animator field, a list of every Animator some graph is currently driving (no component is looked for by name, so an avatar worn by a tool nobody has heard of is in the list too, ticked if it runs this controller), Record/Stop while Play mode is running, and an arm toggle. The arm toggle is serialized because it has to survive entering Play mode to do its job, and it WAITS rather than firing once: the graph does not exist at the instant Play begins, and the first second of a session is gone by the time the window can be clicked. It declines to start on an avatar that is not running this controller, which a person pressing the button is not second-guessed about — they get whatever the chosen Animator can give, down to the component read directly, and the state line says which it was. Refused: the clock, the wire, the timed inputs, the value cells. The frames are the avatar's own and the other person is a real one or is nobody, so the settings panel is replaced rather than disabled. A cell cannot be typed into because scope "Play" is not a client — nothing here can reach into somebody else's Play mode and set a parameter, and a field that wrote nowhere would be worse than no field. SimNotes is hidden too: its list is what a SIMULATION of this controller cannot promise, and a recording simulates nothing. STOPPED IS THE INTERESTING STATE. While a recording runs the transport is disabled except Follow — the newest frame moves under the cursor, so scrubbing is not a thing — and the findings stay quiet, because a trace growing under the reader would make every finding that begins with "never" a claim about however much had arrived. The moment it stops, it is an ordinary trace: scrub it, save it as a clip, lay it under a run as a ghost, Compare With This Run, and read what a trace can say on its own. Leaving Play mode stops it automatically, from ExitingPlayMode rather than after: the scene is about to go and a recorder still looking would be reading a destroyed Animator. THE ASYMMETRY THIS IS BUILT ON, since it is the reason any of it is worth doing: entering Play mode reloads the domain and takes the recorder with it, and LEAVING does not. So a recording made inside Play mode is still there when Play mode is over, which is where a recording is of any use — and an unsaved one does not survive the NEXT entry. The state line says that in a sentence rather than a dialog: it is true of every recording, and a modal that is true every time is a modal nobody reads. Seventeen new strings, translated both ways in ja.po. The mood toggle is "Rec" and the button is "Record"/"Stop" rather than the plan's single "Rec ●", because the two sit next to each other and one word doing both jobs reads as a toggle that does nothing. COMPATIBILITY. Saved layouts open where they were closed (above). Saved runs, the clip format, the recipe API and generated controllers are untouched — a recording saves through the same TraceClip.Save as a run, carrying no settings, which is honest: there was no experiment. Nothing about Run or Live changed except that "Compare With This Run" now also declines while a recording is being written. Not done here, deliberately: recognising WHICH tool is driving the avatar, or which of VRChat's playable layers a graph's other playables are (that needs the tools' own types, and this mood is worth having without them); and turning a recording back into timed inputs, which already works through a saved clip and Load As Timed Inputs. Tests: full suite twice, since the mood reaches the recorder and the findings. With the VRChat SDK 1102/1102, without it 1095 of which 1089 pass and 6 skip (baseline 1087 and 1074+6; 15 new across both commits of this feature). The window itself is checked only where it makes a promise a test can hold it to — the layout's field names — the way the rest of DD DynamicAnalyze is tested through its trace rather than its pixels. Recording a real avatar under GestureManager or Av3Emulator is not automated and stays a thing to try by hand. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… you are wearing Rec finds what to record by asking Unity which Animators a PlayableGraph is driving. That is tool-blind on purpose and stays so. What it cannot do is tell one driven Animator from another, and a real session is full of them: under Av3Emulator the scene holds the avatar, its mirror copy, its shadow copy and one more per other person in the instance, all running the same controller, all identical from outside a tool. Offering those as five equal entries called "Avatar" and defaulting to whichever graph Unity handed out first is picking at random among them. WHAT THE TOOLS ARE ASKED, AND WHAT THEY ARE NOT. GestureManager keeps a public static dictionary of the avatars it controls, keyed by the avatar GameObject; Av3Emulator puts a LyumaAv3Runtime on the avatar carrying IsLocal, IsMirrorClone, IsShadowClone, AvatarSyncSource and NonLocalClones. Between them that answers the only question this needs — who is worn, who is a copy, and whose copy — and none of it exists in a graph. Nothing about how values are READ changed: PlayRecorder still goes through the Playable API and names no tool, so an avatar worn by something nobody has heard of is still a candidate and still records the same rows. The tools are asked which avatar is MEANT, never what it is doing. BY TYPE, BEHIND A DEFINE. versionDefines DAERD_GM and DAERD_AV3E against the two package names, with a reference to each tool's RUNTIME asmdef and to neither editor asmdef — the editor assemblies carry the VRC SDK dependency and their own SDK guards, and nothing here needs them. This is ADR 0033's bargain with the VRChat SDK applied to these two: reached by type so that a field renamed upstream is a compile error here rather than a feature that silently stops working on somebody else's machine. The rejected alternative was reflection by name, which was genuinely cheaper: no asmdef references, no unresolved ones to explain, and a tool whose package id changed would go on being found by type name. It was dropped because what it costs is paid where it hurts most — a tool's public surface is somebody else's and moves between releases, so the compiler is exactly the reader wanted, and the deeper use these defines exist for (writing a parameter back through GestureManager's own objects) is not something to attempt through strings. WHAT #if COSTS AND WHERE IT IS PAID. All of it inside PlayTools. Every entry point exists in every project and answers "nobody has this avatar" when the packages are absent, so no conditional reaches the window or the recorder, and a project with neither tool — most of them — behaves exactly as it did. An unresolved asmdef reference in that project is harmless and is now proved so on every commit: the suite run without the SDK is run without the tools too, and it compiles and passes. THE ORDERING, which is the part that could have gone wrong quietly. Running this controller still wins over anything a tool says: arming refuses an avatar that is not running it, so a preference strong enough to override that would be a preference for never starting a recording. Underneath that — where the old code took whichever candidate came first — GestureManager's avatar wins, then Av3Emulator's local non-clone, then first as before. Mirror and shadow copies are dropped from the candidate list entirely, because they run the same controller and would be offered under a name that looked right; non-local clones stay in it, labelled, since recording one on purpose is a reasonable thing to want. The filter is applied over the tool-blind list rather than inside it, so what Driven() measures is still true. TESTS, AND WHAT THEY CANNOT REACH. Twelve new, all of which exist in both projects: three assert the tool-free behaviour, nine assert with the tools and are skipped by name without them, so the total does not move between runs and a test that vanished cannot be mistaken for one that passed. The typed ones set the tools' own state by hand — an entry in the static dictionary, a runtime component with its flags — which is also the only way to get a mirror clone or a third copy on demand; measured first, LyumaAv3Runtime has neither ExecuteAlways nor ExecuteInEditMode, so adding one outside Play mode starts nothing. One test asserts each define is on exactly when the matching assembly is loaded, which is the failure nothing else would catch: a package renamed upstream leaves the define off, every #if block disappears, and everything still passes. Recording a real avatar under either tool remains a thing to try by hand. COMPATIBILITY. Saved layouts, saved clips, the recipe API and generated controllers are untouched. A project without the packages gets the behaviour it had. The candidate list's tooltip changed and is translated both ways. Not done here, deliberately: recording the non-local clones alongside the wearer (the next commit), and writing anything back through a tool's own parameter objects — the defines are what make that possible and it is a later wave's. Tests: full suite twice, since the change reaches the recorder. With the SDK and both tools installed 1114/1114 (baseline 1102). With neither the SDK nor the tools — the tools must be moved out of Packages/ before the SDK is removed, or they fail to compile and nothing runs, which CLAUDE.md now says — 1107 of which 1092 pass and 15 skip (baseline 1095, 1089 + 6). Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…he one you wear A recording was of one avatar. Under Av3Emulator the interesting thing in the scene is the difference between two of them: the avatar the wearer is driving, and the copy being driven by whatever actually crossed the wire. That difference is the subject of this whole module — Run models it, Live lets you push on it — and Rec could see only the near half of it. ONE TRACE, A SCOPE EACH. The copies go into the same SignalTrace as the wearer, under "Play Remote", "Play Remote 2" and so on, spelt by the same rule as a run's remotes so that a reader who has learnt one naming has learnt the other. The rejected alternative was the obvious one: record each avatar separately and lay the results over each other afterwards. It cannot answer the question. Two recordings have different frame numbers and different starting instants, and every comparison would rest on arithmetic nobody should have to trust. One trace is one clock and one frame index, so the rows sit under each other in the viewer, save into one clip, and are read by findings that already know what a scope is. Not the existing "Remote" scope, which was the other tempting shortcut. A Remote is a copy this module MADE, and what comes with being one is that a reader may type into its cells; a recorded copy is somebody else's Play mode and there is nothing to type into. A trace that mixed the two under one name could not have told them apart either. IsAvatar covers the recorded copies — a finding about states nobody entered is worth exactly as much about a copy, and that comparison is what the second scope was taken for — and IsClient still does not. EACH COPY IS MATCHED ON ITS OWN, against the same controller, through the same three-way look the wearer gets. A copy is a separate graph and could be running something else; one that does gets its parameters and no state rows, like any unmatched avatar, rather than borrowing the wearer's labels and producing rows that are wrong instead of absent. WHO IS IN IT IS DECIDED ONCE, when the recording starts. Av3Emulator makes a clone on a tick box, so the set really does change mid-session, and following it was considered and dropped: a scope added partway through would have a row with fewer samples than the trace has frames, and every reader of a trace — viewer, findings, clip writer, ghost comparison — is written on there being no such row. What that costs is stated in the doc and in the tooltip: a clone made after the button was pressed is caught by pressing it again. The other half of the same problem is a copy that LEAVES, which happens the moment somebody unticks it, and there the row holds its last value: the frame has to be filled with something and holding is the only filling that cannot be read as an event. The panel says how many avatars are being read, so a flat tail is a leaving rather than a mystery. The wearer leaving still ends the recording, because there is nothing left that the run was about. THE TOGGLE is "Record Clones", on by default, and it is on for a layout saved before it existed too. What that changes for such a window is a second scope in a recording taken off an avatar that has copies — which is what somebody who went to the trouble of making those copies was after. It is drawn whether or not Av3Emulator is installed: without it the list of copies is empty, so the toggle is a toggle over nothing rather than a control that appears and disappears, and no #if reaches the window. Three new strings, translated both ways. COMPATIBILITY. A recording of one avatar is exactly the recording it was: one watched source, scope "Play", the same rows in the same order, and PlayRecorder.On keeps its two-argument form for every caller and test that had one. Saved clips, saved layouts, the recipe API and generated controllers are untouched. Nothing about Run or Live changed; Simulation gained a scope name and a predicate and lost nothing. Tests: full suite twice, since this reaches the recorder and the findings' idea of what an avatar is. With the SDK and both tools installed 1118/1118; with neither the SDK nor the tools 1111 of which 1095 pass and 16 skip. Four new: three that hand the copies in directly, so they run in both projects and pin the naming, the holding and the two halves of liveness; and one that goes the whole way from Av3Emulator's own fields through to two scopes in a trace, skipped where the tool is absent. What none of them reach is a real session — the graphs are built by hand, so "a clone runs the same controller and therefore matches it" is proved of the shape and remains a thing to confirm on an avatar somebody is actually wearing. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…what it made A project that assembles its avatar out of parts never hands DaerD the thing VRChat runs. The FX in the window's field is one input among several: the controller the avatar wears is merged out of it and other people's layers, and half the parameter names in it were rewritten on the way. Two things follow, and both are failures of this module on exactly the avatars that are hardest to reason about. Rec matches a graph against the controller in the field, finds no match, and loses its state, transition and layer-weight rows. And the bridge between the names somebody typed and the names on the wire cannot be rebuilt afterwards — Wag$1f4c2b is recognisable as a rename and not traceable back to what it renamed. STAND IN THE BUILD. NDMF lets a plugin put a pass in the sequence, and a build is the one moment all of this is known, so DD registers a plugin and copies out what it sees: the avatar's playable layers with their finished controllers, the expression parameters the build ended with, the rename table, and the parameter names standing at the end of each phase. Nothing is asked of the user — no component, no tick box — and no step is added: entering Play mode already runs a build, which is when a person is going to record anyway. The rejected alternative was reconstruction: walk the scene for the components that declare renames and reimplement their rules. It is a second implementation of somebody else's algorithm, wrong the release after it is written, and blind to any plugin other than the one that was reimplemented. What was given up by not doing it is the only thing it could have offered — an answer for an avatar that was never built this session. There is none; the panel says so rather than guessing. NO COMPONENT IN v1, which is a decision and not an omission. Per-avatar settings would want one, and DaerD is an Editor-only assembly: a MonoBehaviour of ours left on an avatar uploads as a missing script. Having one properly means a runtime assembly and a pass that strips it again, and that is a design to write when there is a setting worth putting on it. Until then the capture is unconditional and costs a few dictionaries. THE PUBLIC API AND NOTHING ELSE. The rename table comes from NDMF's ParameterInfo, which every renaming plugin feeds, rather than from Modular Avatar's own mapping — that one is internal to MA's assembly, so reaching it would mean either referencing MA or reflecting into a private field that changes without telling us. The passes are ordered after MA by its published qualified name, so this file names no MA type at all, and measured: with MA taken out of the project the constraint resolves to nothing, the passes still run and the capture still lands. The cost of the public API is that it answers for renames NDMF was told about and is silent about a plugin that rewrites names without declaring them. WHERE THE PASSES SIT, all three of them measured rather than assumed. The controllers are captured at the end of Transforming and again at the end of Optimizing, which is where they are finished and then where they are finished being shrunk. The rename table could not be taken there: MA destroys the components that DECLARE the renames as the last act of the pass that applies them, so the API answers with an empty table by the time the controllers exist. It is therefore taken at the end of Resolving, which the spec for this wave did not anticipate and which is what makes the table exist at all — the mapping is a pure function of the component and its path, so asking early gives the same answer as asking late. Asking it also walks the whole avatar rather than only the root, because the remappings-at-an-object call answers for that object's own components and would come back empty for the ordinary case: a gimmick prefab three levels down. WHAT THE CAPTURE DOES NOT PROMISE. Not to be last — another plugin may put a pass after these, and what it then does is not in the capture; NDMF has no "after everything" hook and faking one out of constraints against plugins nobody has installed would be a promise that cannot be kept. The phase each capture came from is recorded with it. And not that the built controllers survive: measured, they are temporary assets and NDMF deletes the folder they live in the moment Play mode ends. Cloning them was considered and dropped — a merged FX is thousands of objects, and the deep copy would be paid on every Play entry to keep something no reader needs afterwards. Everything that has to outlive the session is copied out as text at capture time instead. Also measured, because the registry's shape depends on it: entering Play mode reloads the domain and the build runs after it, in the Awake of the scene that comes back, so a plain static is filled rather than emptied and nothing needs SessionState. Leaving Play mode does not reload, so what was captured is still there when the recording is read. Entries are keyed on the object and fall back to the avatar's name once that object is gone, which is what lets an answer survive Play mode; two avatars sharing a name share an entry, and the last built wins. Eight are kept. TWO DEFINES, not one. DAERD_NDMF is NDMF's package and DAERD_VRC is the VRChat avatars SDK, and neither implies the other: NDMF builds for platforms that are not VRChat, while everything captured here is VRChat's shape — playable layers, expression parameters — and the API that answers about renames lives in NDMF's own VRChat-only assembly. The capture is compiled when both are present and is a set of methods answering "no build has been seen" when either is not, so no #if reaches the recorder or the window. COMPATIBILITY. Nothing reads the registry yet; this commit only fills it. No saved data, saved layout, clip, recipe API or generated controller is touched, and an avatar built without DaerD installed is built identically with it — the passes only read. Tests: full suite twice, since a plugin registered in somebody else's build reaches further than its own file. With the SDK and all four tools installed 1128/1128; with neither the SDK nor the tools 1121 of which 1097 pass and 24 skip. Ten new, of which two run in every project — the whole no-build behaviour, and the defines checked against what is actually loaded — and eight need a build and skip by name where there is none. The suite was also run unchanged after NDMF and MA were added to the test project and came back 1118/1118, so the two packages bring no drift of their own; and once more with MA alone taken out, where the four tests that do not need it pass and the six that do skip. The test project's evacuation order gained two packages and CLAUDE.md says so. What none of it reaches is a real avatar: the rig's descriptor is built in code, so "the merged FX is what the avatar wears" is proved of the shape and remains a thing to confirm on somebody's own project. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…troller you dropped in
The registry filled by the previous commit had no readers. Rec still matched a graph
against the controller in the window's field, which on an avatar somebody assembles is
an input to what is playing rather than what is playing: layers merged in from
elsewhere, parameters renamed. The match fails, and a recording of the avatar this
module cares most about comes back with its parameters and nothing else — no states, no
transitions, no layer weights, and every parameter under the name in the editor rather
than the name on the wire.
THE BUILT CONTROLLERS ARE TRIED FIRST. Where a build of this avatar was watched, its
playable layers are matched against the graph before the field's controller is, by the
same layer-name multiset rule as everything else; the first that fits names the rows.
Where no build was watched — most projects, most of the time — this is line for line
what it was. Trying the field's controller first was the other order and is worse for
the only avatars where the two differ: both can match a graph, and the one that is
actually playing is the built one, so preferring the field's would name a state after
the layer that happens to share its name in a controller that is not running.
WHAT A MATCH IS AND IS NOT TAKEN TO PROVE. That the graph is running this controller.
Not that the controller descends from the one in the field — nothing in a built
controller records what it was built out of, and claiming a provenance we cannot see
would be the kind of quiet wrongness a recording must never have. What is leaned on
instead is narrow and checkable: the registry says this avatar was built, and the avatar
in front of the recorder is that avatar. The state line says which of the two the rows
came from, naming the playable layer ("FX"), so a reader comparing a recording against
the asset open beside it is never left to guess.
ARMING HAD TO LEARN THE SAME THING. "Record On Play" refuses to start on an avatar that
is not running this controller, which is what stops it firing on the half-built scene of
the first few frames. On an assembled avatar that condition is never true, so arming
would have waited forever on exactly the case this pair of commits is for. It now asks
PlayRecorder.Runs, which answers for the field's controller or for what a build made,
and the candidate list's tick asks the same question so the two cannot disagree.
Picking a target keeps the field's controller ahead of a built one: an avatar running
the very controller in the window wins outright, a built one is next, and a tool's
opinion breaks the tie inside each group. The scene that decides the order is the one
people really have open — the gimmick being edited, playing on its own, beside the
assembled avatar it belongs to — and there the plain one is what somebody pointed the
window at.
FROM THE BUILD, beside From The Store. What travels is the one setting that decides
every difference a two-client run shows, and on an assembled avatar the store and the
build disagree: the build's list has names the store never had, and has the store's own
names under the spelling the wire uses. Filling from the wrong one produces a run whose
every wire-crossing is about a parameter that does not exist. Both buttons stay, because
they answer different questions — the store is what somebody wrote down and is the right
answer for a controller that belongs to no avatar yet. The new one is drawn whether or
not anything was ever built and is disabled when nothing was, rather than appearing and
disappearing; and it leaves the list alone rather than emptying it when there is nothing
to fill from.
WHAT IS DELIBERATELY NOT HERE. The rename table is captured and published and nothing
reads it: row labels showing "built ← source", and following a name the other way when
a run is extracted, are the next step and are not worth half-doing against a table whose
only reader would be a label. The recording still holds the built controller only for as
long as the build's leftovers live, which is until Play mode ends — that costs nothing,
because a trace bakes its state labels when it is declared, so the rows keep their names
long after the controller they were read out of is gone.
COMPATIBILITY. A recording on a project with no build framework is identical: same
match, same rows, same names, and PlayRecorder keeps every entry point it had. Saved
clips, saved layouts, the recipe API and generated controllers are untouched; the new
state line only appears when the rows came out of a build. Three new strings, translated
both ways.
Tests: full suite twice, since this reaches the recorder that every mood's rows come
out of. With the SDK and all four tools installed 1132/1132; with neither the SDK nor
the tools 1125 of which 1098 pass and 27 skip. Four new: three that build an avatar, run
its own output through a graph and check the rows, the tick and the order of preference,
skipped where there is no build framework; and one that asserts the unchanged case
directly, so the promise that nothing moved for a project without one is checked in the
project that has none. What none of them reach is a real session — the graph is built by
hand rather than by a tool wearing the avatar — so "the thing GestureManager plays is the
thing the build produced" is proved of the shape and remains to be confirmed on an
avatar somebody is actually wearing.
Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…an edit A recording could be looked at and could drive the next run, and that was the whole of it: the run it drove was the recording flat, every value it contained replayed at the second it changed. What a person wants from a recording is not to watch it again. It is to ask what would have happened if they had pressed one thing differently — and that question needs the record and the question it answered told apart. THE FRAME THIS SITS IN, because the rest of the wave only makes sense inside it. A trace is history and history is not editable: the numbers in it are what happened, and changing one produces a file that says something untrue. What IS editable is the INPUT FACE — the part of the recording that was done to the avatar rather than by it — and a deterministic engine will answer an edited question with a different past. So this module is not a Playmode viewer with a scrub bar, and deliberately never becomes one: it is an experiment somebody edits, and time travel is not one of the things on offer. That makes two loops, and they are not the same loop. The INNER one is fast and wrong about the world: extract, edit, run the simulator, lay the answer against the recording as a ghost. The OUTER one is slow and real: play the edited inputs back into an avatar somebody is actually wearing and record it again. The inner loop is what this commit makes possible; the outer one is what the third of this series is for. The reason both are wanted is the world track below. TRACKS. A stimulus was one flat list, which is the right shape for a stimulus somebody typed and the wrong shape for one taken off a recording, where three unrelated things arrive at once: what the wearer pressed, what their body and voice were doing, and what the world touched them with. "Run this again without the gestures" then meant deleting rows and typing them back, which is not an experiment anybody performs twice. A track is those things kept apart and switched off whole. Muting is part of the experiment and travels with a saved run, because "this run is the recording without its gesture track" is a question and an answer that cannot say which tracks were in it is one nobody can re-ask. Solo is not in the model at all — it is "mute the others while I look at this one", a fact about somebody standing in front of the window, and storing it would be storing a glance. The engine does not know any of this exists. It takes the merge — active tracks, ordered by time, ties broken by track order and then by the order rows were written down — exactly as it took the flat list before. A track is how an experiment is EDITED; giving the run's own loop a second concept to be right about buys nothing the merge does not. The merge sort changed shape while it was there: the old stable insertion sort cost the square of the entry count, which was invisible for a dozen hand-typed pokes and is not for a recording's worth of them. WHAT THE EXTRACTION LEAVES OUT, and why leaving it in would be worse than losing it. Two kinds of value in a recording moved because the avatar's own logic moved them: what a VRC Parameter Driver wrote, and what an animation wrote onto the Animator. The simulator computes both again from the same controller, so replaying them applies each twice — and the second application is the one that would look like a bug in the controller. They are reported by name rather than dropped in silence, because a parameter that moved all through a recording and is in none of the tracks looks exactly like a tool that lost it. Asked of the driver through ReadSpec and of animation through AapWriteScan rather than reimplemented: a second scan of the same controller is one that can disagree with the analyzer about it. AapWriteScan is a new name on the module's allowed list, and the list is meant to shrink — the reason it is worth growing for is that the alternative was that second scan. What travels — the names that make the menu track — is the build's own list first, then the wire's, then the store's. Each is a better answer than the next about the recording in hand and a worse one about anything else: a recording of an assembled avatar speaks the names its build produced, and a menu parameter renamed by the build would otherwise be filed under the world with the contacts. CONSIDERED AND NOT DONE. A hidden identity per track, so a renamed track could still be taken from the recording again, was the obvious way to keep re-import working after a rename. It was dropped: a name a person edits and a name a machine matches on would then be two fields that a rename makes disagree, and the disagreement is invisible. The name is the identity, so renaming a track is how somebody says it is theirs now — and what that costs is stated rather than hidden, because re-import then has nothing to match. Empty tracks are dropped after an extraction rather than kept as evidence that the recording had no menu activity. The panel is a list of things to edit and a track with nothing in it is not one; what the recording did not contain is the extraction note's job. COMPATIBILITY. The manifest is version 2 and the flat list stays in the format, read and never written, meaning "the inputs of a run saved before there were tracks" — a v1 clip opens as one track under the name the panel used to have at the top of it, and a v0 clip still says nothing rather than saying defaults. A window laid out before this adopts its old pokes the same way, once, on the way in; Unity would otherwise keep them in the layout and simply stop drawing them. Not attempted, deliberately: a v2 clip is unreadable as an experiment by an older build. Writing the merge into the flat list as well would have let one read a subset — and then save it, flattening the tracks and destroying the muted ones without saying so. Losing the inputs outright is the better of the two failures. Nothing about a run's RESULT changes: the same settings produce the same trace, because a stimulus of one track merges to the list it always was. Run in both configurations, whole: with the SDK and all four tools 1142/1142, with neither 1135 of which 1108 pass and 27 skip. Ten of those are new here — the merge's ordering and muting, the three-way split, the two exclusion sets, the manifest round trip, and the two wedges for the formats this replaces. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…en again The tracks existed and there was no way to use them: the panel drew every row of every one of them in a flat column, and the two things a track is FOR — leaving one out, and taking one down from the recording again without losing the rest — had no control at all. This is the panel that makes the model usable, and the one sentence the model made it possible to say honestly. THE SENTENCE FIRST, because it is why the rest is shaped this way. The world track is frozen input: it replays what a contact, a physbone or somebody else's hand did that day, and the simulator does not model any of them. Edit the menu track and, in a headset, the contact would have fired somewhere else or not at all — here it fires exactly where it did, and the run comes back looking like a clean answer to a question that was only half asked. So the warning is said the moment the inputs stop being the recording, names the two ways out (mute the track, or play the inputs into a real avatar and record what the world does about it), and disappears when either is taken. It is said to a single-client run too, which moved every warning on the panel down to its foot. The others are each about a value reaching somebody else and are rightly silent when nobody else is in the run; this one is about what the run does not recompute, which needs no second person to be wrong — and a warning that only appeared with a remote in the settings would be one nobody could find. Knowing that the inputs have been edited is not something a stimulus can say about itself: it holds what it is, not what it used to be. The window keeps a signature of the tracks as they came off the recording and compares. Not a copy of the tracks — that is a second list in the layout to answer a question worth one bit — and not string.GetHashCode, which is not promised to be the same number after a runtime update, in a value that is written into a window layout and read back after a domain reload. The world track is deliberately outside the signature: muting or re-taking it is the reader ACTING on the warning, and a signature that moved when they did would take the warning away at the moment they were reading it. MUTE AND SOLO sit side by side and are not the same kind of thing, which is the one thing about them worth explaining. Mute travels with the experiment because "the recording without its gestures" is a question a saved answer has to be able to state. Solo is "mute the others while I look at this one" — a fact about somebody standing in front of the window — so it lives in a field that does not survive a domain reload, applies on the way out to the engine, and leaves the real muting underneath exactly as it was. A saved clip written while a track was soloed says the others were muted, because for that run they were. RENAME is delayed and refuses a name another track already has: two tracks of one name merge when the run is built, and a merge nobody asked for is a worse surprise than a rename that did not happen. What renaming costs is the re-take button, which matches by name — that is the price of the name being the identity, decided in the previous commit and now visible: the button is disabled on anything that is not one of the three an extraction owns. Also here and not in the specification: a track can be deleted whole. Muting leaves it in the file, which is right for an experiment and wrong for a track somebody has finished with — and without this, a track emptied row by row stays in the list for ever. The tooltip says which of the two a reader is doing. Considered and not done: making solo exclusive across windows, or persisting it "just until the editor closes". Both make it a setting, and the moment it is a setting somebody will be looking at a run that is quietly missing three tracks with nothing on screen saying why. No format change, no engine change: a clip saved by the previous commit reads identically, and a run of the same tracks produces the same trace. What a saved run gains is nothing; what it loses is nothing. Run whole in both configurations: with the SDK and all four tools 1146/1146, with neither 1139 of which 1112 pass and 27 skip. Four are new — the warning appearing, going away with the track, reaching a single-client run, and the signature moving for every edit except the world's. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… are wearing The inner loop was complete and honest about its one blind spot: the simulator does not model the world, so a run whose menu track has been edited replays the contacts and physbones exactly as they were recorded and the panel says so. The warning names a way out that did not exist yet. This is it — the outer loop: send the edited inputs into an avatar somebody is really wearing, record what comes back, and lay the two answers side by side. WHY THROUGH GESTUREMANAGER'S OWN SET. The value could be written straight onto the AnimatorControllerPlayable the graph is running, which is fewer moving parts and is the wrong thing. The tool holds state ABOUT that value — the radial menu showing where the toggle sits, its OSC module, whatever is subscribed to it changing — and a write that goes round the tool leaves every one of those describing an avatar that is no longer doing what they say. A menu on screen disagreeing with the avatar under it is exactly the confusion somebody running an experiment cannot afford. Its Set is the point the tool publishes for this and is what a person clicking the radial goes through. That needs types from GestureManager's EDITOR assembly (the module and its parameter live there, not in the runtime one this module already referenced), so the .asmdef gains that reference and the code sits behind DAERD_GM and DAERD_VRC together — the tool's own file is compiled only with the SDK, so the GM define alone would be a promise this cannot keep. Reflection by name was the alternative and stays rejected for the reason it always was, with more force here: this is a call taking a type of theirs as an argument. Av3Emulator is not written to, deliberately. It is the recording side's tool — it is what produces the other people's copies — and one injection point is enough to find out whether the outer loop is worth having. What that leaves undone is stated rather than implied: an experiment cannot yet be pressed into the remote copies, and the wire between them stays a thing only the simulator has. WHAT IS A CLOCK AND WHAT IS A TOOL. Nothing in PlayInputs writes anything: it holds the entries in the order a run would see them and hands back the ones whose second has arrived. So the part that can be wrong about time — an entry sent twice, one skipped because two updates fell in one frame, a playback that never says it is finished — is a pure object a test drives at any rate it likes, and the part that needs a tool installed is one call. It is told the TIME rather than the elapsed slice, so a stall sends what came due during it, late and in order, instead of losing it; and a time that went backwards sends nothing, because there is no rewinding an avatar and a playback that quietly started again would press things nobody asked for. WHAT IT DOES NOT PROMISE, said where it is claimed. The editor updates when it likes and the game draws when it likes, so an input written for 1.500 s lands on the first update at or after it and reaches whichever frame is next. The simulator lands its inputs on an exact frame and this cannot. That is the honest difference between the fast loop and the real one, and it is why the comparison afterwards is between two RECORDINGS rather than a claim that the same experiment ran twice. WHAT IS NOT SENT. The extraction's exclusions again, at the door: a value a driver or an animation writes is one the avatar computes every frame, and pressing it from outside fights the thing computing it rather than reproducing the run. An extraction has already dropped those, so this catches the ones somebody typed, and the panel says how many and which. An input aimed at somebody else's copy is not sent either — there is one avatar in the editor, and a remote's values are what a wire does to them. The world track is not sent by default, and that one is a choice rather than an arithmetic fact: those values came from contacts and physbones, and in Play mode the real components are running and writing the same parameters. Two authors on one value, and which wins is a race. It is offered behind a toggle with the reason in its tooltip, because a scene with none of those components in it, and a deliberate look at what the recorded world does to an edited avatar, are both real things to want. The controller asked about what it computes for itself is the one in the field, not the one the avatar's build produced. On an assembled avatar those have different names, so a driver-written parameter can slip through the filter under its built name and be pressed. Not fixed here: the rename table is captured and following it in both directions is a wave of its own, and the failure it leaves is one write losing a race with a driver rather than a wrong answer being reported as right. TESTED, and where the test stops. The scheduling is covered whole and without either package — ordering, lateness, going backwards, the world toggle, muted tracks, the derived set, the empty playback. The typed side is pinned for the case a test can really make: GestureManager holding an avatar whose entry is not a VRChat 3 module takes no input and does not throw. A real module is built by the tool out of a live descriptor inside Play mode; that the radial really moves when an input arrives is a headset-and-hands check, written down as one rather than faked with a mock of somebody else's class. Run whole in both configurations: with the SDK and all four tools 1153/1153, with neither 1146 of which 1118 pass and 28 skip — one more skip than before, which is that GestureManager test declining to run rather than vanishing. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… gimmick that is only a prefab Two things were wrong with the MA half of the parameter store, and they have the same root: it did not know MA existed. It reached the MA Parameters component by matching a type NAME and reading it through SerializedObject, which is the bargain DaerD keeps with the VRChat SDK (ADR 0009) and is the wrong bargain here. The SDK's is paid for a reason — DaerD has to compile, run and be tested in a project with no SDK, so the SDK cannot be a reference. MA integration has no such half: it means nothing at all in a project without MA. So the code is behind a versionDefine (`DAERD_MA`, declared in both .asmdefs against the package name `nadena.dev.modular-avatar`) and gone where MA is, which is where the feature was already worthless. What that buys is the compiler: `nameOrPrefix`, `syncType`, `localOnly`, `saved`, `hasExplicitDefaultValue` are now fields of somebody else's public API rather than strings, and the day one of them is renamed upstream is a build error here instead of a row that silently stops being read on a user's machine after an update. This releases the hold ADR 0033 has been under since 0.11.0 — GM, Av3Emulator and NDMF took the same shape in waves 12 and 14a, so MA is the last of the four (the ADR's own status is updated separately). And it looked only in the SCENE. A gimmick spends most of its life as a prefab that is in no scene at all, so Detect answered "nothing" about exactly the controllers the MA backend exists for. `DetectInPrefabs` asks the project instead: every prefab whose MA Merge Animator names this controller, and the MA Parameters beside it. WHY IT IS A SECOND BUTTON AND NOT A FALLBACK INSIDE Detect. Sweeping the project is not free and ADR 0028 is about exactly this: a sweep is stopped at its source, and here the source is a person pressing a button. Measured on this test project: a cold sweep costs 6.7 ms over 8 prefabs and 23.0 ms over 208 — about 0.08 ms per prefab of pure filtering, which a real project's few thousand prefabs turn into a fifth of a second nobody asked for, once per repaint, while a mouse moves over the panel. Two things keep it off the ordinary path. Prefabs are filtered by `AssetDatabase.GetDependencies(path, false)`, which answers out of the import database's table and opens nothing: with 208 prefabs in the project, 207 of them carrying MA Parameters of their own, exactly one was loaded. And the answer is remembered per controller GUID, so a second press costs 0.006 ms instead of 23. Direct dependencies only, deliberately. A prefab reaches a controller through a component field, which is a direct reference; asking recursively would put every prefab that merely contains a prefab that contains a material through the expensive stage. The cost is that a prefab which only NESTS the one carrying the Merge Animator is not offered — but the nested prefab is in the sweep on its own account, and it is the component a person would edit anyway. WRITING INTO A PREFAB, measured rather than assumed. A component found this way lives inside a prefab asset, and a dirty asset nobody saves is a change that looks saved until the next domain reload takes it. Editing the loaded prefab asset's component in place and calling `AssetDatabase.SaveAssetIfDirty` is enough — the round trip through `PrefabUtility.LoadPrefabContents` / `SavePrefabAsset` was tried and dropped, because it hands back a different object graph than the store was given and would make every write go and find its own component again. A test asserts the added, renamed and removed rows are in the prefab FILE, not merely in memory. WHAT WAS GIVEN UP. The SerializedObject path also worked against any component that happened to have MA's field names, which is what let the tests drive it with a stand-in of their own; that stand-in is deleted and the tests drive the real component, skipping themselves by name where MA is absent. A project without MA sees no change: it has no MA components, TryWrap does not recognise one, and both Detect entry points answer "nothing" — which is what they answered before. `ParameterStore.FindComponent` went with the name matching and has no callers left. COMPATIBILITY. The store slot saved in GraphFrameData is an object reference and is untouched; a slot holding an MA component keeps working, and a slot pointing into a prefab asset is a cross-asset reference Unity already serialises. Read / Add / Remove / Edit / WriteAll behave identically — the existing tests are the wedge and none of them changed except in how they build the component. One bit does differ and is a fix rather than a regression: a row added by Add no longer inherits the previous row's private "override animator defaults" flag, which `InsertArrayElementAtIndex` used to clone because the reset list could not name an internal field. Generated controllers, recipes and the analyzer are not involved. Tests: full runs in both project shapes. With the SDK and all four tools, 1159/1159. With neither the SDK nor any of the four, 1152 of which 1112 pass and 40 skip — twelve more skips than before, being the seven MA store tests that now need the real component and the five new prefab-sweep tests. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…will call a parameter A Modular Avatar row can be declared INTERNAL, and an internal parameter is renamed on the way into the avatar so two copies of one gimmick do not fight over a name. The store went on saying "Hat"; what travels is "Hat$-8842". Every answer DaerD gave about such a parameter was therefore wrong in the one way that looks right — DD DynamicAnalyze's From The Store filled a wire with names the built avatar does not have, which simulates perfectly and matches nothing, and the "≠ store" mark compared a build's names against editor names and disagreed with itself. From The Build already spoke built names because it reads what the build made; the two ways of filling one list disagreed about one avatar. WHERE THE ANSWER COMES FROM. NDMF, through `ParameterInfo.ForUI`, which is the query MA's own inspector uses and answers outside a build. Re-deriving it here from `internalParameter` and `remapTo` was the alternative: it is a second implementation of somebody else's rule that agrees with it until the release where it does not, and — worse — it is blind to a rename applied by a plugin DaerD has never heard of, which is most of the reason to ask the framework rather than the component. Asked at the game object rather than at the component, because NDMF's component overload reaches for `transform.parent` without checking it exists and that is a null reference on exactly the shape this is most often asked about: a gimmick prefab whose root is not inside an avatar. A QUERY BESIDE Read RATHER THAN A FIELD ON THE ENTRY, which was the other design on the table. `VrcExpressionParameters.Entry` is the shared shape: the VRC asset backend reads and writes it, the sync window diffs it, `WriteAll` copies it field by field, and recipes hand it around. A field that only one backend could ever fill would have to be decided about at every one of those places — what the VRC backend puts there, whether a diff counts it, whether a copy carries it — for a fact none of them has any use for. `EffectiveNames()` is virtual with an empty default, so every other store pays one empty dictionary and changes in no other way. What that costs: a caller holding an Entry alone cannot ask; it has to have the store too. Both callers do. THE SCOPE OF THE FIX, stated. Renames are only visible where NDMF is installed; without it the map is empty and DaerD shows the written name, which is what it did before this commit. A project with MA and no NDMF cannot exist, so that hole is theoretical — the real one is a build step that renames parameters without telling NDMF, and about that gimmick DaerD will still be confidently wrong. Two places use the answer and no more: the run's synced list and the parameter rows. The analyzer, the sync window and the async-sync builder still speak written names, which is correct for them — they edit the store, and the store's names are what a person types. The row's mark is drawn only where the two names differ and is sized to its text, so a controller with no internal rows looks exactly as it did rather than growing a permanently blank column. COMPATIBILITY. Nothing is stored, so no saved data changes shape and no existing controller is touched. A run set up before this commit keeps whatever list was typed into it — the fill buttons write the list, they do not correct it on load — so an old run with editor names is only changed by pressing From The Store again. Generated controllers and recipes are not involved. Tests: 1163/1163 with the SDK and all four tools; 1156 with neither the SDK nor any of the four, of which 1114 pass and 42 skip (the two renaming tests need MA and NDMF and skip by name; the two that do not need either assert the empty default and the no-store answer in both project shapes). Ran with the SDK present and absent because this reaches the engine's synced list, not only a window. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…atar The capture already held every piece of this and showed none of it. Standing in an NDMF build gave DD the rename table, a parameter set per phase and the built expression parameters, and all a reader ever saw of it was a button that filled the synced list. So on exactly the avatars this bridge exists for — the ones assembled out of parts — a recording came back full of rows named things nobody typed, and the person reading it had no way to learn from the window that a name had been rewritten at all. A row for a parameter under an unfamiliar spelling reads as a parameter that has gone missing, which is the wrong bug to go looking for. Rec now carries a foldout in the place the other two moods keep SimNotes: what was renamed (source on the left, what the avatar wears on the right), what each phase added, what each phase took away, and how many parameters travel in the end. Same frame, same bullet lines, same "count stays visible when it is folded" as the notes and the findings, because it answers the same shape of question and is read at the same moment — before the rows are trusted. The reading is a pure function on BuildCapture over one captured entry (Changed), with the window doing nothing but drawing what it returns. That is what makes the cases worth pinning testable at all: a parameter that goes away again, a phase no pass reached, two rename tables that must not be poured together are none of them things a build of a test rig can be made to produce without writing somebody else's plugin. The end-to-end against a real Modular Avatar build then checks that a build fills the entry with the shape those assume. The grain is a build phase and not a plugin, and that is a limit rather than a simplification. The capture passes stand at the END of Resolving, Transforming and Optimizing, so what can be compared is the avatar before a phase against the avatar after it, with every plugin that ran inside summed together — a line says "Transforming added these", never "this plugin added these". Attributing a name to its author would need a capture point between every pair of passes, and NDMF orders passes without offering a hook between other people's. The alternative considered and dropped was guessing the author from the shape of the name (MA's `name$hash`, other conventions): that is a table of other people's private spellings, wrong on their next release, and it would have made the module know about plugins it deliberately does not reference. Two more things it does not promise. The rename lines and the added lines overlap on purpose — a renamed parameter appears in both, and subtracting one from the other would hide a merged-in parameter that is also renamed, which is the ordinary case. And the phase names are only as honest as the passes are last: another plugin may run after ours in the same phase, which the capture has always said and this does not improve on. The phase names were three string literals at the capture points and are now named constants, because a difference between two sets is a before-and-after only if something says which is which, and the dictionary holding them has an order that is not the build's. Compatibility: nothing outside the module changes. The panel draws no frame at all when no build of the recorded avatar was watched, so a project without NDMF sees exactly what it saw. The window gains one serialized fold flag that defaults to open, so a saved layout opens the way it did; no recipe API, saved run, clip or controller is touched. Six new UI strings, translated in ja.po. Tests: the full EditMode suite in both project shapes. With the VRChat SDK and all four tools installed, 1167/1167. With neither the SDK nor GestureManager, Av3Emulator, NDMF or Modular Avatar, 1160 collected — 1117 passed and 43 skipped by name, one more skip than before because the end-to-end needs a build framework. Four tests are new: three over hand-written captures that run in both shapes, and the Modular Avatar build. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
…t a clip A saved run was an AnimationClip, and a clip writes a key on every curve at every frame whether or not the value moved. That is the wrong shape for the thing being saved: a recording is a few hundred rows of which a handful ever change, sampled thousands of times. A 403-row, 2400-frame run of a controller the size of a real FX layer came to 378 MiB of YAML and took twenty seconds to write, which is not a file anybody keeps, and the honest way to say the same thing is the moments a value became something else and nothing at all in between. The format is a .ddrun: five magic bytes and a little-endian version left UNCOMPRESSED, then everything else through gzip — a JSON block holding the same manifest fields the sub-asset carried, the frame count with a float32 time and a float32 step for every frame, then per row a count and its change points as (varint frame delta, float32). The header is outside the compression so a reader can say "not mine" and "mine, from a later build" without first trusting the bytes enough to inflate them. Times AND steps are both written rather than one reconstructed from the other: a jittered run has no frame rate to divide by, and a live trace that has been trimmed starts at the second it starts at, so the first frame's time is not its length. Being redundant about two nearly constant float arrays is what gzip is for. f072608 chose a clip because a clip IS a set of values over time and because Unity already had a curve editor, a diff, a meta file and a Ctrl+Z for one. That reasoning was right about what a run is and wrong about what it costs. What is genuinely lost is two things. Reading a run in the Animation window: kept, as an Export As AnimationClip… that goes through the same ToClip path, wired up in the commit after this one. And Unity-native diff of two runs: given up, because a recording is not a thing anybody edits — it is an immutable history — and "how does this run differ from that one" is a comparison, which the ghost already answers row by row and in the reader's own units. Losing per-key undo on a run is not a loss for the same reason. The manifest's SHAPE is reused rather than reinvented, so there is one definition of what a saved experiment looks like and one set of compatibility rules for it; what leaves the format is the ScriptableObject and the sub-asset, and with them the trap that a sub-asset added to an asset which has not been written through stays invisible until the asset is imported again. TraceManifest and the conversions to and from SimSettings therefore moved to being shared by both formats, but the TYPE stays declared in TraceClip.cs on purpose: every .anim already on disk names that script file by GUID in its sub-asset, and moving the class would orphan the settings of every run anybody has saved. What this does not promise. Round-trip equality is of float VALUES: a negative zero comes back as a positive one, which nothing that reaches a trace can tell apart. SignalTrace.Recorded is not written — it counts the frames a live session has dropped, which is a session's bookkeeping and not a file's. A version number above the one this build knows is REFUSED with the number in the message, the opposite of how an unknown settings block is treated (read for the fields there are names for): an unknown field is survivable, an unknown layout is bytes that would be misread as numbers. And no ScriptedImporter in v1 — a .ddrun under Assets/ imports as a DefaultAsset, which shows, moves and deletes fine; an importer buys a typed ObjectField nobody has asked for yet and is a promise that every run in every project re-imports through it forever. Measured on the same run, both formats, same machine. Toy scale, 7 rows of which 3 ever moved over 120 frames: 409,291 B as .anim against 906 B as .ddrun, 452x. Real FX scale, 403 rows of which 135 ever moved over 2400 frames: 395,910,523 B against 10,972 B, about 36,000x. The gap is the point rather than a headline — the .anim grows with rows times frames while the .ddrun grows with changes, so it widens with every still row and every extra second. Writing went from 19.6 s to 17 ms and reading from 953 ms to 40 ms at that size. Compatibility: nothing writes .ddrun yet. Save Run still writes a clip until the next commit, every saved .anim still reads exactly as it did, and TraceClip's own tests are untouched to say so. No recipe API, controller or generated asset is affected. Four new UI strings — all of them things a reader is told when a file cannot be opened — are translated in ja.po. Tests: the full EditMode suite in both project shapes. With the VRChat SDK and all four tools installed, 1173/1173. With neither the SDK nor GestureManager, Av3Emulator, NDMF or Modular Avatar, 1166 collected — 1123 passed and 43 skipped by name, the same 43 as before. Six are new, all over the format: an exact round trip of a jittered two-remote run with a Trigger and two layers' label tables, compared frame by frame rather than sampled; the experiment and its muted tracks; a run saved with no settings coming back saying so rather than saying 60 fps; a trimmed live trace whose first time is not its first step; the refusal of an unknown version, of a file that is not a run and of one that is not there; and that a still row costs one change point rather than six hundred. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… exports clips The format the commit before this one built is now what Save Run writes. Three things had to move with it, and one of them is the interesting one. Opening takes either format, everywhere a run is opened — Open Run, Compare With, and Load As Timed Inputs both whole and per track. One file panel filter listing both extensions rather than two menu items, because "open the run I saved" is one intention and which container a file happens to be in is this window's problem rather than the reader's. Branching is by extension: reading the first bytes of a clip to learn it is a clip would be a disk read to find out what its name already said. A .ddrun that will not open now says why — a version from a later build, a file that is not a run, a truncated one — because those three look identical from the outside of a file panel and a picker that silently did nothing would leave the reader to guess. Export As AnimationClip… is the answer to the half of f072608 that was right. A clip is genuinely better at one thing: the Animation window will draw it. So the whole ToClip path is kept and put on the menu below the separator, where it reads as what it is — a copy in somebody else's format rather than where the run now lives. The export still carries the manifest, because a clip whose rows are called "Local/GestureLeft" and whose state rows are bare integers is unreadable without one, and an export that could not be read back is a worse thing to hand somebody than one that can. The extraction moved to reading a SignalTrace instead of an AnimationClip, and that is the part worth explaining. InputSurface.Of and ToStimulus took a clip only because a clip was the only shape a saved run came in; with two containers, a reader that started from either one would have been written twice and would have disagreed with itself once — the pokes it produces are the input to the next run, so a disagreement there is two experiments that are not comparable and no way to see it. The core now takes what a run IS, and the clip doors are one line each on top of it. CanDrive and Changes went with it out of TraceClip, which knows about clips and had no business owning the arithmetic that turns a recording into inputs; one test that called TraceClip.ToStimulus calls InputSurface.ToStimulus instead, and nothing else moved. What was considered and dropped: keeping a second extraction entry point per format, and keeping ToStimulus on TraceClip as the "real" one. Both are the same mistake in different places — two readers of one thing, which is exactly what this codebase already pays for elsewhere in the analyzer. Save also imports the file it just wrote. Nothing IMPORTS a .ddrun in the sense of giving it a type — there is no ScriptedImporter and v1 deliberately has none — but the Project window does not show a file it has not been told about, and a saved run nobody can see reads as a save that did not happen. Trade-offs and what this does not do. The toolbar dropdown is still labelled Clip, which is now only half true; renaming it is a UI change this change does not need and would cost the reader their muscle memory for no gain today. A run saved from outside the project still cannot be opened as a clip — a .anim's manifest is a sub-asset and only the AssetDatabase can reach it — which was true before and is not true of a .ddrun, since that one is read straight off disk. Nothing converts old runs: a .anim stays a .anim, opens as one, and becomes a .ddrun only if somebody opens it and saves it again. A silent migration would rewrite files people did not ask to have rewritten. Compatibility: every .anim already saved opens exactly as it did, with its scopes, kinds, state names and settings — pinned by the tests that were already there plus a wedge that reads one back beside the same run in the new format. The window's serialized layout is unchanged, so a saved window opens as it was. No recipe API, controller or generated asset is touched. Five UI strings are new and one changed wording (the Rec panel no longer tells you to save a recording "as a clip"); all are translated in ja.po. Tests: the full EditMode suite in both project shapes. With the VRChat SDK and all four tools installed, 1178/1178. With neither the SDK nor GestureManager, Av3Emulator, NDMF or Modular Avatar, 1171 collected — 1128 passed and 43 skipped by name, the same 43 as before. Five are new: both formats told apart by name with the older reader pinned unchanged; one recording split into inputs through both doors and asserted poke for poke to be the same; an export written from a run that came out of a .ddrun and read back through the clip path with its experiment intact; the findings speaking about the wire off a reopened .ddrun; and a ghost loaded from one lying under the run in hand on a shared scale. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
… slot's Recording a real avatar under a tool wrote down the movement parameters and nothing else. Everything the person pressed — every gesture, every toggle — was absent from the trace, and the states came off the walk cycle. Two faults in the recorder's graph handling, both invisible to every test here, because the rig those tests build was one avatar with one controller and the whole of the problem is what happens when there is more than one. An avatar worn by a tool is a layer mixer with one AnimatorControllerPlayable per VRC playable slot, and Mecanim gives each playable the parameter list of its OWN controller. So the locomotion slot declares the movement parameters and no gestures; the slot somebody is editing declares the gestures and none of the movement. Reading the matched playable alone recorded one slice. Parameters are therefore merged: a row per distinct name across every valid controller playable of that avatar, read from the matched playable where it declares the name and otherwise from the first playable that does, in mixer input order. Fixed order rather than clever, so two recordings of one avatar read the same row off the same place. Clones get the same merge, per avatar, because the merge happens where an avatar is taken on rather than once. The second fault was upstream of that. Fitting tried the captured controllers in the descriptor's slot order and took the first that matched, and on a real avatar every slot matches a playable of its own — so the first is Base, a locomotion controller nobody was pointing at. It now picks on the only evidence there is: the layer names the field's controller and each candidate have in common as a multiset, most in common winning, a tie with something in common going to the FX slot. A tie at nothing in common is left in the descriptor's order, deliberately: there is no evidence about which slot was meant, and a project this used to decide one way should not be quietly decided another for a guess. What the merge assumes is that same-named copies in different slots agree. They are separate values in Mecanim and nothing makes them equal — the tools this is for write a parameter to every playable that has it, which is why an avatar's gesture means the same thing in FX as in Action, but a graph built by hand could diverge. Where it does, the row is one of them, and which one is the rule above rather than an accident. Splitting the row per slot was the alternative and was dropped: it would double the row count of every real recording to say the same number twice, and it would stop a recording lining up under a simulated run, which names rows by parameter and has no slots at all. Worth naming too is what was NOT bought back: state, via, transition and weight rows still come from the matched playable alone. Layers of different slots can share a name and mean different states, so merging those would be wrong rather than missing; recording every slot's layers wants a scope each, and that is a separate design. Compatibility. An avatar with one controller playable — no tool, plain playback, the hand-built graph, every recording most projects will ever take — is bit for bit what it was: the merge over a pool of one adds no row and changes no reading, and the Animator fallback does not go through the pool at all. Where no build was watched the captured candidate list is empty, so the new matching order never runs. Saved runs, the .ddrun format, the clip export and the recipe API are untouched. What does change is a recording taken with several playables present: it now has more rows than it had, which is the fix. Tests: the full EditMode suite in both project shapes. With the VRChat SDK and all four tools installed, 1180/1180. With neither the SDK nor GestureManager, Av3Emulator, NDMF or Modular Avatar, 1173 collected — 1129 passed and 44 skipped by name, one more skip than before because the new build-path test needs a build framework. Two tests are new and both were checked to fail without this change, on the symptom rather than on a detail: the hand-built rig grown to two playables — one declaring only a movement parameter, one the gestures and the field's layer name — which pins the merged rows, the read origin of a name both declare, and the state rows still coming from one playable; and a built avatar with the locomotion slot deliberately AHEAD of FX, which pins the slot the recording is named from. One existing assertion was inverted with them: the two-playable case used to assert that the other playable's parameters were absent, which is the behaviour being fixed. Session: 824a7695-f5bf-4233-9faf-e8532224f9c5
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
daerd | 1803b33 | Aug 16 2026, 03:11 AM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.