Hardening wave 2b — roadmap 26: the overload gateway, from one poke per frame to a window that pauses instead of freezing - #209
Merged
Conversation
…indowed object list
Roadmap 26 Stage 0 (hardening audit M6, M1, M2). The receive path ran an unbounded
amount of work per incoming object; this bounds all three places it did so.
WHAT
- `pokeScene()` in sceneStore replaces all 117 `objectsGroup.update((v) => v)` call
sites across 37 files. Eighteen subscribers hang off that store and several traverse
the whole tree, so a 1,000-object handshake was ~8M node visits, synchronously, on
the receive path. One flush per microtask normally; one per ~16ms while an ingest
batch is open (`beginSceneBatch`/`endSceneBatch`, refcounted). The seam lives IN the
store because all 37 files already import from it, so it costs no import edge — which
matters when the pokers include peerHandler, flowRuntime, autosave and history.
A microtask, not rAF: it lands before paint AND still runs in a hidden tab. Batch
mode uses a timer for the same reason. `sceneRevision` is bumped on every flush, for
a subscriber that wants to cache a traversal (and for 26-A's meter).
- `createObject` is a QUEUE with an 8ms slice (`INGEST_SLICE_MS`). The dispatcher never
awaited it, so N object messages started N overlapping main-thread GLTF parses in one
task and their completion order was accidental. Objects now apply strictly in arrival
order, the drainer yields a MACROtask every 8ms (a microtask chain never returns to
the browser), and the whole drain holds a scene batch. `clearSceneLocal` drops the
queue — roadmap section 5's "the ingest queue drops on clear".
- Toasts' "Receiving objects" reconciliation was `getObjectByProperty` — a full tree
walk — TWICE per outstanding uuid on EVERY poke. One traversal into a Set, then
lookups: O(objects + outstanding) instead of O(both).
- The object list VIRTUALISES above 500 visible rows: the same `Objects.svelte` row
component in a new `flat` mode over the flattened `visibleObjectRows` — the one array
the keyboard walker, Ctrl+A and the type-ahead already read their order from — with
two spacer divs for what is off screen and a keyboard follow that moves the window.
Below the threshold the recursive tree is unchanged. The scroller is found by real
SCROLLABILITY (it is flowbite's Listgroup, whose element we do not own) and the row
height is MEASURED, never assumed, because the spacers are in pixels.
- audit M1: `sendObjects` builds its uuid list PER CALL (it was a module-level array
that `countObjects` pushed onto and only the timer emptied, so two approvals 400ms
apart cross-contaminated both joiners' `loading` lists and `count` was a running
total), and resolves its connection INSIDE the 500ms timer, bailing when it is gone —
`peer.connections[peerId]` is undefined mid-dial and closed when the joiner gave up,
and both used to throw inside a timer where nothing catches it.
- audit M2: a `loading` batch records its SENDER (local only — the message is
unchanged), and is cleared by that peer's teardown, by a parse that rejects
(`noteLoadFailed`), by a scene clear, and by a 60s stall. Nothing could clear it
before: the only writer removed a uuid when its object APPEARED, and an object that
never arrives never appears.
COUNTERFACTUALS (suite `scene-poke`, 32 checks, one page)
- Measured IN THE SAME RUN: the bare `objectsGroup.update((v) => v)` this replaced
notifies 500 times for 500 calls where `pokeScene` notifies once.
- Measured IN THE SAME RUN on the same payloads: the old ingest shape (parse + add +
bare poke, 400 objects, one task) holds the main thread for 215ms and renders 2
frames; through the queue the worst hitch is 86ms and 7 frames render.
- Broken then restored: `handleDisconnected`'s batch clear removed -> "the sender
disconnecting clears the batch" red. `VIRTUAL_MIN` raised to 5000 -> "the list draws
a WINDOW" reads 720 rows in the DOM, the spacers and the mode attribute go red too
(4 checks). Both restored, suite green again.
GATES
- svelte-check 352 errors / 47 warnings, DOWN from the 357/47 floor — typing `loading`
and `loadingcount` in appStore (they were written as arrays through a splice-in-place
and inferred `never[]`) removed 5 pre-existing errors. `check-baseline.json` ratcheted
with `--update`.
- `npm run build` green with the dev server stopped.
- Held suites at or above base: object-list-keys, objectlist-search, object-search,
object-delete, selection-extras, clear-scene, inspector, dispose all green.
`multi-select` ("member transforms replicated to B") and `undo` ("the placed position
replicates") are red — and reproduce IDENTICALLY at base 7646fc2 with src reverted,
so they are pre-existing and not this diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a desktop Statistics panel
Roadmap 26 sections 2 and 3. There was no scene-level budget anywhere — no object,
triangle, draw-call or texture ceiling — and `renderer.info` had exactly ONE reader in
the whole app: the VR stats plate. So on a desktop, where every heavy scene is built,
there was no way to see draw calls, triangles, GPU object counts or a single frame-time
number, and a diagnostics bundle carried none of them.
WHAT
- `src/lib/sceneBudget.js`, a LEAF (svelte/store, the scene store, and `inputDevice`
which is itself import-free). That matters: peerHandler counts wire traffic through it
and commandsHandler publishes its ingest backlog, and both sit inside the documented
import cycles. Anything it cannot reach REGISTERS instead (`registerMetricSource`, the
`registerDiagnosticsSection` shape).
- `BUDGETS` is the roadmap's section-2 table as DATA, so the meter, the panel and (next)
the ingest fork and the auto-stops read ONE source. `tierOf` / `worstTier` /
`budgetRows` are PURE, which is the part that has to be right and the part that needs
no browser and no GPU to test.
- TIERS WITH ACTIONS, NOT WALLS: green does nothing, amber shows, red is what 26-C and
26-G will read. Nothing here refuses anything — a budget that stops you working is a
budget people switch off. TWO PROFILES (desktop vs VR/mobile), because the same scene
is fine on one and fatal on the other, and on a headset the tab is KILLED rather than
slowed.
- `unknown` is its own answer and never darkens the meter. "Not measured" is not "bad",
and a meter that cries wolf before the first sample is one nobody reads.
- The sampler is our OWN rAF loop, ~2 samples a second: frame time measured from the
browser's own callback cadence is exactly the quantity "did the window freeze" asks
about, and it keeps this a leaf that Scene.svelte does not have to know exists. It
carries p50/p95/p99 over a 240-frame ring (FPS averages the stutter away; p95 is the
frame you feel), `PerformanceObserver('longtask')` where it exists and a row that says
"not available" where it does not, `performance.memory`, the renderer's render and
memory counters, the scene walk, and every registered source.
- WIRE TRAFFIC PER TYPE (audit H7's measurement): counted in `broadcast` and at the
dispatcher's entry. Message counts are EXACT and free; bytes are a 1-in-16
`JSON.stringify` sample scaled up and labelled "≈", so the measurement cannot become
the cost being measured.
- UI: a `Statistics` row in the burger menu (`#open-stats`) and a coloured dot in the
object-list status line, whose tooltip names what is over budget and which opens the
panel — a warning you cannot act on is a decoration. The panel is a floating window,
not a modal, because the point is to watch the numbers move while you work.
- `budgetSummary()` is registered as a diagnostics section, so a report carries the
numbers (audit H4 + section 3's last row).
COUNTERFACTUALS (suite `scene-budget`, 34 checks, one page)
- `tierOf` forced to always answer green -> 6 checks red, including the status-line dot
and its tooltip, which is the whole user-facing half.
- `registerMetricSource('ingestBacklog', …)` removed -> "a registered source reaches the
sample" red, so the seam is proven rather than assumed.
- The diagnostics section registration removed -> "the bundle carries a scene-budget
section" red.
All restored; suite green again (34/34).
NOTES
- The status-line meter's click is a DIRECT listener via an action, not `on:click`:
Controls.svelte is written in the `on:` style throughout, so an attribute handler is a
hard "mixing syntaxes" error there and the `on:` form costs a deprecation warning —
and a delegated handler inside a panel can be swallowed on its way up anyway, which is
the rule this codebase already keeps for panel chrome.
- Physics body count is in the budget table's spirit but NOT yet wired: `physics.js` can
publish it through `registerMetricSource` with no import edge, and that is left to
whoever touches that file next.
GATES
- svelte-check 352 errors / 47 warnings — exactly the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held: scene-poke (32/32), diagnostics, wire-hardening, object-list-keys,
controls-state, vr-stats, sidebar-toggle all green. `panels` is red on
"save format dropdown opens" — PRE-EXISTING and visible in the source: the JSON format
button renders behind `{#if showGltf || showJson}` and both default to false since B3
demoted JSON behind the export cog, so `getByRole('button', {name: /json/i})` has
matched nothing since that change. Not this diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e says so before it opens
Roadmap 26 section 4, Stage 2 (the ingest gate). A scene arriving over the wire
announces itself FIRST — `{type:'loading', count, uuids}` — and only then sends the
objects, so there is exactly one moment where its size is known and nothing has been
applied. Past that moment a 4,000-object scene is simply happening to you.
WHAT
- `ingestVerdict(current, incoming, profile)` in sceneBudget, PURE: the total, the
tier, the object-budget limit, and `allowed` — how many fit before the scene crosses
into red. What is ALREADY in the scene counts (2,900 here plus 500 asks). Amber warns
and does not ask; red asks — the tiers-with-actions rule 26-A set up.
- THE GATE lives in `createLoader`, the one place a `loading` announcement lands. On a
red verdict it HOLDS the ingest queue that 26-B built, so every object that arrives
after it is parked — parsed or not, with no second code path and nothing to unwind.
The 60s stall timer is disarmed while the question is open: the objects are parked,
not missing, and clearing the progress bar under an open fork would be a lie.
- THE FORK, three ways, as a sticky card mirrored from an `ingestGate` store (the
`restoreAvailable` idiom, so commandsHandler never imports the UI), with `noClose` —
an X would leave the transfer stalled with nothing left to resume it:
Load all · Load the first N · Cancel. "The first N" caps the drainer; everything past
the cap is DROPPED and counted as arrived, so the bar does not wait out the stall for
something that is never coming. Cancel drops the queue and clears the bar.
- LOCAL ONLY. Nothing is sent. The peer is not told we declined — that is a fact about
THIS device's budget and there is nothing for them to do about it. They see us with
fewer objects, which is what happened.
- THE FILE HALF: `requestLoadPayload` — the one entry point a PERSON reaches by opening
a .tpscene or pressing Load in Sessions — counts the payload (`countPayloadObjects`,
nested children included, the unit the budget is stated in) and asks before
replacing the scene. Deliberately NOT in `applySession`: travel, a peer's proposal,
an autosave restore and rejoin all go through that, and a replicated hop must never
stop at a dialog nobody is standing at (the travel-node rule). The file gets TWO ways
out, not three: "load the first N objects of this file" makes a scene nobody saved,
which the user would then re-save over their own file silently truncated. A stream is
divisible; a document is not. The file is compared against the budget ALONE, because
it replaces the scene rather than adding to it.
COUNTERFACTUALS (suite `ingest-gate`, 29 checks, one page)
- `createLoader`'s gate disabled -> 6 red: nothing parks, the scene is touched while
the question should be open, the card never appears, and the rest of the run cannot
find the fork's buttons.
- The file-open ask removed from `requestLoadPayload` -> the dialog never appears and
the run cannot answer it.
Both restored; suite green again (29/29).
GATES
- svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held green: scene-poke (32/32), scene-budget (34/34), sessions, tpscene, clear-scene,
and the two dedicated handshake suites object-sync and net-handshake — which cover
exactly the late-joiner receive path this gate sits on ("a late joiner receives EVERY
object", "every message left over an OPEN connection").
- `scene-levels` is red on nine two-peer checks and reproduces IDENTICALLY at base
7646fc2 with src reverted (same nine names, 206s vs 205s). Pre-existing, not this diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n window pauses drawing
Roadmap 26 section 4, Stages 3 and 4. Stages 0-2 stop the app freezing on the way IN;
this is what happens once a heavy scene is already here and the device cannot keep up.
The roadmap's principle holds throughout: every stop is ONCE per streak, REVERSIBLE,
and SAYS SO — an automatic action the user cannot see and cannot undo is just a
different kind of broken.
WHAT ALREADY EXISTED, AND WAS WIRED TO RATHER THAN REBUILT
- 27-C put a try/catch around the physics step (audit M7). A step that THROWS already
stops the run once. Nothing caught a step that is merely too SLOW.
- 27-D shipped the per-node script budget (audit C1) — Stage 3's second bullet. Done.
- 27-G shipped the context-loss half of Stage 4 (`ContextLostOverlay`, the canvas
listeners, the recompile sweep). The new overlay stands DOWN whenever that one is up;
two cards describing two different failures at once would be two cards arguing.
WHAT
- `src/lib/overloadGuard.js`, a LEAF (stores + sceneBudget). `createStreakWatch` is PURE:
N bad samples IN A ROW, firing exactly once per streak. Consecutive, never cumulative
— one 300ms hitch while a texture uploads is not a scene too heavy to run, and a
trigger that fired on it would stop somebody's simulation because they imported a
picture.
- STAGE 3, PHYSICS: `step()` times `stepInner`; 30 consecutive steps over 24ms (a 24ms
step on a 16.7ms frame makes every frame late before rendering starts) stop the run
ONCE with a toast that names the reason and carries Resume, so the stop is never a
dead end. The real step and the test hook share ONE stop path so they cannot drift.
- STAGE 4, THE FREEZE: sceneBudget's frame loop (26-A) feeds a streak of 10 frames over
250ms — 2.5 seconds of a window that has stopped answering — through a new
`registerFrameObserver` seam, so the budget module keeps knowing nothing about pausing.
Three things are NOT a frozen scene and never trip it: a backgrounded tab (the browser
throttles rAF to ~1Hz on purpose), the first frame after the tab returns (its delta
spans the whole absence), and a 3s grace after Resume (the first composer frame
recompiles).
- THE PAUSE IS REAL: `Outline.svelte`'s render task — the one place a frame is drawn —
returns early while `renderPaused` holds, so a device that cannot keep up does no GPU
work at all. NEVER in a headset: the XR compositor needs frames, and a paused session
is a frozen world strapped to the user's face with no overlay (DOM is invisible in VR).
- THE OVERLAY (`RenderPausedOverlay.svelte`): Save now · Reduce · Resume, saying that
nothing is lost and that autosave keeps running.
- REDUCE sets the NEWEST top-level objects aside until what is still drawn fits the
object budget — and does it with a render LAYER, NOT `visible = false`. That is the
design's load-bearing decision: autosave exports through GLTFExporter with no options,
and `onlyVisible` DEFAULTS TO TRUE, so a hidden object is silently DROPPED from the
recovery snapshot. Reducing a scene would quietly delete its newest objects from the
one copy meant to survive a crash, while the overlay promised autosave carries on. A
layer is invisible to every serializer, never replicates, and is honoured by the
camera cull and the raycaster alike: a reduced object is still in the scene, the save,
the wire and the undo stack — only not drawn or picked HERE. Original masks live in a
WeakMap, never on userData, so they cannot leak into a file. "Show them again" undoes.
- THE RESTORE PROMPT names the snapshot's object count against this device's budget
before restoring (Stage 4's last bullet) — a phone that died restoring a big scene
comes back to exactly that prompt, and the count is the reason. It reads the same
`ingestVerdict` the 26-C gate does.
COUNTERFACTUALS (suite `overload-guard`, 34 checks, one page)
- Measured IN THE SAME EXPORT: a layer-reduced object is in a default GLTFExporter
output while a `visible = false` twin is dropped — the hazard, proven rather than
asserted.
- The Outline render gate disabled -> "no frame is drawn while paused" red (684 frames
in 600ms instead of 0).
- The hidden-tab guard disabled -> "thirty 1-second frames in a HIDDEN tab never pause"
red: every tab switch would have paused the scene.
Both restored; suite green again.
GATES
- svelte-check 352 errors / 47 warnings — at the floor this branch ratcheted to.
- `npm run build` green with the dev server stopped.
- Held green: scene-poke, scene-budget, ingest-gate, ai-flow-physics,
flow-physics-collider, physics-ground-bounds.
- The slow-step stop NEVER fired in any physics suite across two full runs (no "too
slow" toast anywhere in the logs), so it cannot be behind their reds. Those are
`physics-discoverability`, `flow-physics-nodes` and `physics-kinematic` — the standing
pre-existing reds CLAUDE.md's 21-B entry already names as A/B'd against base — and
suites that died at `h.connect` ("could not press Connect" / "could not approve"),
i.e. signaling on a saturated box before any physics code ran.
- The first battery run died once inside `overload-guard` with Playwright's "Resulting
promise was garbage collected" after 3,200 real meshes on a swap-full box. The Reduce
fixture now uses empty Groups (the budget counts tree NODES, so a Group counts exactly
like a mesh) and the suite is green standalone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one The freeze trigger in d322e7a paused rendering on ANY ten consecutive frames over 250ms. A software-rendered page lives at ~2.5fps — 400ms frames, permanently — so the "Rendering paused" overlay appeared during ORDINARY non-GPU e2e suites and covered their clicks: `#render-paused intercepts pointer events` 23 times in one battery, turning physics-colliders red on a click timeout and adding one to physics-discoverability. The new suite could not see it, because it ran on a GPU page and drove the trigger directly — found by reading the held-suite logs. The same fault reaches a real user: a weak GPU drawing a scene of twelve boxes. Pausing that helps nothing. There is nothing heavy to set aside, Reduce would reduce nothing, and the person loses a window that was slow but still ANSWERING. WHAT - `sceneIsHeavy()`: objects, triangles or draw calls at amber or worse for this profile, read from sceneBudget's `sceneMetrics`. The streak only counts while it is true, so a light scene cannot build one at all. The scene-size axes only, never frame time — that would make the rule circular. An `unknown` reading (nothing sampled yet) is not heavy, so a freshly booted page can never be paused before the first sample. - This is also simply what the overlay already SAYS: "the scene is too heavy for this device". The trigger now agrees with its own copy. SUITE `overload-guard` 34 -> 36 checks - New guard: "forty 400ms frames on a LIGHT scene never pause" (+ its premise). - The freeze and hidden-tab checks now set a HEAVY reading first. The hidden-tab one would otherwise pass VACUOUSLY, since a light scene never pauses whatever the tab does. - The suite leaves a light scene behind after Reduce: 3,200 objects is heavy by definition, and on a saturated box the real frame loop is entitled to pause over it. COUNTERFACTUAL - `sceneIsHeavy` forced to always answer true -> "forty 400ms frames on a LIGHT scene never pause" and its premise go red. Restored; 36/36 green. REGRESSION CHECK (same battery that found it) - `render-paused intercepts pointer events`: 23 -> 0. - physics-colliders: red -> ALL PASS. - physics-discoverability: now runs to completion with no click timeout; its remaining FAILs are the standing pre-existing reds CLAUDE.md's 21-B entry names as A/B'd against base. Neither 26-G trigger fired anywhere in that run (0 "too slow" toasts, 0 pauses), so they cannot come from this lane. GATES: svelte-check 352/47 (the floor); `npm run build` green with the server stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 17, 2026
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.
Wave 2's second half: roadmap 26's overload gateway, four phases on top of wave 1 (#206) and wave 2a (#208), both now in
release/next.Roadmap 26 answers "what happens when the scene overloads so the window does not completely freeze". Stage 0 removes the freeze mechanism; the rest is the gateway around it.
526624f4f85a819be2f00d322e7a+ec9da2eRead
ec9da2ewithd322e7a. The first commit's freeze trigger fired on any 10 frames over 250ms, so on a software-rendered page (~2.5fps forever) its overlay covered ordinary non-GPU suites —#render-paused intercepts pointer events×23 in one battery.ec9da2egates the streak onsceneIsHeavy(). Noted322e7a's GATES paragraph is wrong on one point: it listsphysics-collidersamong suites that "died at h.connect" when it actually died on that overlay.ec9da2e's body has the corrected account (23 → 0 intercepts,physics-collidersred → ALL PASS).The headline changes
pokeScene()replaces all 117 identityobjectsGroup.update((v) => v)sites across 37 files, coalescing to one flush per microtask (or one per ~16ms while an ingest batch is open). Measured live in-suite: 500 notifications → 1, and 215ms/2 frames → 86ms/7 frames on 400 objects.createObjectis a time-sliced queue — 8ms slices, macrotask yield, strict arrival order.flat/depthmode over two spacer divs.src/lib/sceneBudget.jscarries roadmap section 2's table as data, with desktop and VR/mobile profiles, an rAF sampler at ~2/s, andregisterMetricSourcefor readings it cannot import.budgetSummary()is registered as a diagnostics section, so 27-B's bundle now carries the numbers.loadingannouncement is parked until you answer Load all / Load the first N / Cancel. Local only; nothing is sent. The file ask lives insessions.requestLoadPayload(the entry point a person reaches), deliberately not inapplySession, so travel, proposals, restore and rejoin never stop at a dialog.src/lib/overloadGuard.js: 30 physics steps over 24ms stop the run once with a Resume toast; a 10-frames-over-250ms streak pauses drawing behindRenderPausedOverlay(Save now / Reduce / Resume), which stands down under 27-G'sContextLostOverlay. Hidden tabs, the first frame back and a 3s post-Resume grace never count.Reduce uses a render layer, never
visible = false— becauseautosave'sGLTFExporter.parsepasses no options, soonlyVisibledefaults TRUE and a hidden object is silently dropped from the recovery snapshot. Proven in-suite in the same export. That is a pre-existing latent bug for any local hide, and it is called out in the handover as its own ticket.Evidence
Four new suites, all green:
scene-poke32/32 ·scene-budget34/34 ·ingest-gate29/29 ·overload-guard36/36. Every guard has a counterfactual; two of 26-B's were measured live in the same run rather than broken-and-restored.Held green: the physics set (
physics-colliders,ai-flow-physics,flow-physics-collider,physics-ground-bounds),sessions,tpscene,clear-scene,object-sync,net-handshake,diagnostics,wire-hardening,controls-state,vr-stats,sidebar-toggle, the object-list set,selection-extras,inspector,dispose.svelte-check 357 → 352 / 47. Typing
loading/loadingcountin appStore removed five pre-existing errors;check-baseline.jsonratcheted with--update.ci.ymlandrelease.ymlboth callscripts/check-ratchet.cjs, so neither needs an edit — worth confirming at review.Pre-existing reds, each A/B'd against base
7646fc2withsrc/fully revertedmulti-select"member transforms replicated to B" andundo"the placed position replicates" — reproduce identically, same check names, same run. Two-peer transform replication reads, failing on three separate runs, so reproducible rather than flaky. Worth their own ticket.scene-levels— nine two-peer checks, identical nine failures at base (206s vs 205s). The dedicated handshake suitesobject-syncandnet-handshakeare green here, so the late-joiner path itself is sound; scene-levels travels between levels before B joins.panels"save format dropdown opens" — no A/B needed, visible in source: the JSON button renders behind{#if showGltf || showJson}and both default false since B3 demoted JSON behind the export cog. The suite is stale, not the app.h.connecton a saturated box before any physics code ran, and were not re-run underPEER_CONFIG.Deliberately not done, carried into wave 3
Physics body count via
registerMetricSource('bodies', …);bufferedAmounthigh-water per conn; handshake time-to-synced, IDB write latency, autosave export ms, physics step ms and script-node ms — each needs aregisterMetricSourcecall from its own module. Also the autosave restore prompt's count-vs-budget line, which wantssaveSnapshotto record the count at save time rather than re-parsing the GLTF.🤖 Generated with Claude Code