engine v5.2.8
Engine v5.2.8
July 28, 2026
A patch in the Lume line.
Power lines plant their poles again
what's new
- Floating-island and sky-world builds: set
atmosphere: { ground: false }and the world stops pretending there's a ground below the horizon. Look down past an island's edge and you see real sky haze deepening toward the depths — not the flat sea-colored band. One line, per place; everything else about the sky (day/night, clouds, stars, fog) keeps working exactly as before. - Joining a world no longer risks a false "physics is slowing down" alert: the engine now waits out the normal loading blip (the first seconds after joining or entering a new area) and only reports physics slowdown to Savi when it actually persists for several seconds. Real slowdowns still get reported with the same numbers and fixes as before.
- Power lines got their poles back — a bare power line plants grounded poles with crossarms along the run again instead of leaving the wires floating in the sky. Want a cable strung between buildings with no poles? Say so:
poles: falsehangs it as one draped line. - Games with HTML UI no longer risk a dead menu (or a soft-locked player) on slower devices. The UI's boot loader is now ~13x smaller, a busy device gets time to catch up instead of being cut off at 4 seconds, and if the UI ever does fail to appear it now quietly retries as you play instead of staying gone until a page reload.
- An accidental infinite loop in a script (like
while (true)with no way out) no longer freezes the room forever. The engine stops the runaway loop after a very large budget (real game loops never come close), keeps the game running, and hands Savi a clear error naming the script and the loop so she can fix it — a teachable moment instead of a dead room. - Terrain edits can no longer eat your ponds and paths by accident. Savi now adds, tweaks, and removes rivers, ponds, paths, and flatten pads one at a time, by name — so carving a new path can never silently wipe the pond you built yesterday. If anything tries the old wipe-everything write, the engine refuses and says exactly which marks it was about to delete.
- Multiplayer effects are honest about their reach now: targeting other players with
audience: { kind: "nearby" | "place" | "all" }(or another player's id) on screenShake, vignette, sounds, and other juice never actually reached them — it silently played for the calling player only. The call still works exactly as before, but it now logs a clear note explaining that effects are self-only in multiplayer for now and how to do it right: fire the effect from each player's own trigger (onInput/onInteract/onTriggerEnter), or drop the audience option on world-anchored effects (bursts, decals, positioned sounds) — those already reach every nearby player. - Fixed a leak where objects spawned with a lifetime (fireballs, pickups, timed props) could linger forever in a room whose only player closed or refreshed the tab and never came back — the room now cleans them up right on schedule even with nobody there.
setObjectProperty("properties.light.intensity", …)now just works — keys written with the spec'sproperties.prefix resolve exactly like the bare key instead of being ignored with an "unknown property" warning.- Fog values the renderer can't honor as written now tell you at patch time: linear fog with
far <= near(which renders a hard fog wall instead of a gradient) and negative exp2 density (which renders like its absolute value) warn with the key, your value, and the allowed range — instead of silently rendering something you didn't ask for. - Fixed a bug where scripts on a player rig couldn't reach the parts they'd spawned onto it — writes like
api.setObjectProperty(api.id + "-gun", …)were silently thrown away with "entity not found" warnings even though the part was right there. Those writes land now: player-part color changes, attachments, and animations that never took effect come alive, and the warning flood that came with them stops. - Looking up an object by a short id that doesn't exist is now effectively free instead of costing a scan of every object in the world — games that check stored ids every tick no longer pay a hidden per-miss tax that grew with world size.
- Fixed a multiplayer bug that hit solo sessions hardest: switching tabs (for example to chat with Savi), refreshing the page, or a brief connection stall could permanently desync your room — you kept playing normally on your screen while the room stopped counting your progress (pickups, health, wave triggers silently dead), and only a full room restart fixed it. The room now keeps your session's authority seated across those interruptions, so everything you do keeps landing when you come back.
- Debug dumps now show whether your game's UI is actually repainting: if a menu looks stuck on screen while the game itself is fine, the dump names exactly which step stopped — including HUDs the engine refused to mount because of a broken button handler, which previously froze silently.
- Fixed a multiplayer bug where switching away from the tab and coming back could leave you in a frozen copy of the world — everything you built or spawned after returning silently vanished for everyone else, and neither reloading nor restarting the room recovered it. The engine now detects that stuck state within seconds and re-syncs you automatically; your session picks up live from the real world state, no tab close needed.
- Explosions, magic, and weather can play real animation frames now — upload one sprite sheet and it just works
- Fog and smoke hug the ground and walls instead of slicing through them, and heat shimmer over fire actually bends the world behind it
- Shell casings, hail, and debris bounce — each piece with its own weight
- Effects can orbit, spiral, and tether between moving things with exact math, and they keep up on their own
- Lights ride your effects now (fireflies light the ground they drift over)
- Marks can grow and fade on their own — a blood pool that spreads, a puddle that dries
- Objects can burn away with a glowing dissolve instead of blinking out
- A whole weapon's feel — muzzle flash, tracer, casings, impact — fits in one effect file, fired once per shot
›technical notes
- Below-horizon open sky —
atmosphere.ground: false(adjudication r-e98895c5; floating-island worlds). One declarative place-level atmosphere property:ground: falsedeclares "no ground plane" and the procedural sky becomes full-sphere atmosphere. In the sky-view LUT (sky-luts.ts), below-horizon rays march to the atmosphere's far exit instead of the ground hit, the medium continues at sea-level density below the reference radius (per-sample radial clamp), and the lit-ground bounce term is skipped — the below-horizon rows come out as physically-derived downward aerial perspective (Hillaire's own integral with the ground intersection removed: haze that saturates toward the sea-level in-scatter limit at the nadir), NOT a mirror and NOT a pinned horizon clamp. The horizon stays seamless by construction (the integrand varies continuously through the tangent ray). Plumbing: schema →resolveAtmosphereSkySource→DrawSkybox.ground→ skybox handler →SkyValues.openSky→ the Sky uniform's spare pad slot; the flag folds into the scattering change key, so a live toggle rebuilds the LUTs and the IBL (the environment capture and sky-driven ambient inherit the haze below automatically — floating islands get lit from below by it, physically right). The grounded default is untouched:openSky = 0leaves every sky-view expression the grounded path verbatim (above-horizon rows are unchanged even when open — those rays never hit ground), transmittance/multi-scatter LUTs never read the flag (the planet remains the twilight shadower and the ambient's ground), and theatmosphere-open-skyrender-harness fixture pins the open-sky signature on GPU (its thresholds discriminate both states with measured margins). - physics/step perf reports become lagging indicators (jacob's ruling, #inventors 1785193978, 07-27: "performance reports to savi should be lagging until we're 100% sure that there's an issue" — his screenshot: a single-tick physics blip at JOIN, zero moving objects, reached Savi as a client perf report and the creator as a slowdown notice; the three-consecutive-strikes trip from #10952 fired ~100ms into any breach with no join exemption). The
physics-step-budgetlane (behavior-watchdog.ts) now requires the breach to HOLD unbroken forPHYSICS_STEP_SUSTAIN_SECONDS(5s) of wall clock on top of the existing consecutive/separated strike laws — a spike of any size, even several over-budget ticks in a row, is never evidence — and the firstPHYSICS_STEP_JOIN_GRACE_SECONDS(10s) after the world boots or after any place first steps its physics (PlacePhysicsSteppedTickResource, the same stamp the transition veil'ssettledreads) are exempt entirely: no evidence accrues, so a join/place-load blip reports nothing, ever. A visibility resume re-anchors the grace (the flip already wiped the lane's state; the wake burst is the same expected-physics class as a join). A genuine population melt (probe 5703dcd7's every-tick billing) still clears both gates trivially and reports once — the mechanism, teach numbers, consent rule, DM latch, and DD breadcrumb are all unchanged. - BREAKING: powerline splines mint poles by default again (jacob's 07-27 walk of the 5.2.7 zoo: the bare 'Powerline: wood' exhibit rendered as wires floating unanchored — one run ending mid-air, the wire bundle sagging against nothing). #10870's poles-opt-in default was jacob's ruling for the string kinds (fairy lights, bunting); powerline got swept into the same default, but a power line's poles are the product, not optional supports. Bare
powerlineruns route to the grounded pole+crossarm layout (pole_N+ per-spanwire_<span>_<wire>, byte-identical to pre-#10870);poles: falseroutes a run to the #10870 draped layout (one whole-runwire_Ncable perwireCount, hung at the pole layout's wire height).poles: truestays valid and redundant. Stringlight/bannerline defaults untouched. - Authored-realm boot: the starvation class is eliminated, not tuned (2026-07 incident: 3 prod apps in 8h, one hard soft-lock — a fixed 4s hello deadline racing a 2.85MB iframe module on starved devices, with a session-permanent failure latch). Four structural changes, one per mechanism:
- The engine is out of the UI iframe.
frame/lifecycle.tsimported@/tome/compiler— the whole-engine compile rail — which dragged ObjectAPI, mantle physics, terrain streaming and frustum culling into the authored-realm bundle (1005 modules, 2,854,157 minified bytes). The module rail (require()/lib compilation, source transforms, dep watches, UI-lifecycle compile) now lives insrc/tome/script-modules.ts(engine-free;structuredCloneTomeScriptValue's snapshot-proxy cluster extracted toscript-value-clone.ts), and compiler.ts re-exports it — one implementation, zero engine-side import churn. The bundle is now 213,587 minified bytes (62 modules) — a 92.5% cut — andframe-entry.size.test.tspins a 512,000-byte CI ceiling so an engine-scale import can never creep back in.require("builtin/*"), quantize-off compilation, uiGlobals/audio injections, and the deterministic-math/numeric-runtime installs are byte-identical in-frame. - The boot deadline arms on progress, not wall clock. The iframe
loadevent splits the two failure classes the old single timer conflated: NO LOAD (asset fetch failed/blocked — the class a cache-busted retry can fix) keeps the retry ladder onREALM_BOOT_LOAD_TIMEOUT_MS; LOADED-BUT-NO-HELLO (starvation, or a pre-hello crash carried by the boot-error relay) never retries against the identical immutable module — it faults once (diagnostic) and keeps waiting throughREALM_BOOT_HELLO_EXTENDED_TIMEOUT_MS(30s), so a starved frame that catches up still boots. Hello is accepted from either pre-hello phase (module scripts execute before the document load event). - The terminal latch is deleted. A failed target re-arms on the next mount-trigger message (nonempty render / lifecycle mount — the lazy-mount trigger, both targets), giving the field-requested periodic re-retry with zero new machinery; the dead, caller-less
remount()escape hatch is removed. Boot faults latch one-per-phase-per-target-per-session, so re-armed ladders retry silently instead of spammingui.realm. - The fault lines name the class.
shell document never loaded …(network/serving) vsauthored realm boot starved (… no hello …still waiting…)(starvation/parse, with crash detail when relayed) vsno ready after retry(init handshake stall) — the next dig is a read, not an 8-hour correlation. All keep theauthored realm bootgrep prefix; the misleading always-epoch 2line is gone.
- The engine is out of the UI iframe.
- Script loop budget: the wedge-the-renderer class is contained (ledger 1328). A creator behavior script that never returns (
while (true) {}) used to block the tick forever on every surface scripts run — browser client-simulated rooms, the Bun container tier, workerd realms — and all wall-clock watchdogs live ON the tick, so none could fire (behavior-watchdog.ts named the limitation). Now every body compiled through the singlecompileFunctionFactorychoke point (tome compiler, terrain generator-runtime, voxel templates share the memo) gets AST-level back-edge instrumentation (engine/library/script-loop-budget.ts, acorn parse — real AST, never regex): each function that directly contains a loop declares a per-invocation int32 counter, each loop back-edge (while / do-while / for / for-of / for-in, labeled included) pays one increment + one predictable branch, and the full budget comparison runs every 1024th iteration. Exhaustion throws the namedScriptBudgetExceeded(script attribution + approximate loop line), which rides the ordinary throwing-handler containment into the runtime logs — the tick CONTINUES and Savi gets a teachable fault instead of a dead room. - Ceiling is generous by design (2^28 ≈ 268M iterations per invocation — a wedge-catcher, never a perf governor): the zoo's heaviest real exhibits stay under ~10^4 iterations/invocation, the heaviest legitimate engine-invited shape (full-map generator sweep ≈ 8.4M) sits ~32× under the ceiling, and a tight wedge still trips in well under a second. Per-invocation counters mean bounded per-tick loops can never accumulate toward the budget across ticks.
- Fail-open law: unparseable bodies compile byte-identical to before (containment never breaks a script that works today); loop-free bodies skip the transform entirely. Workerd startup-seal semantics untouched — instrumentation runs inside the memo-miss path before
new Function, so startup warms compile the instrumented body and a post-seal miss still refuses withCodegenStartupOnlyErrorbefore any parse. Injected fragments carry no newlines (stack line numbers survive) and identifiers are collision-checked against the body text. - Scope v1 deliberately: loop back-edges only — no recursion instrumentation (stack overflow self-contains), no async coverage (await-forever doesn't block the tick), no per-op cost accounting.
- BREAKING: terrain marks become named-target verbs — the bulk
marksclobber is unrepresentable (jacob's bless, thread 1785200291; the oak-path/pond specimen, dump 2243de5f: onepatchTerrain({ marks })call silently deleted every mark it didn't name, verified green by a single-key read-back). Three top-level ObjectAPI methods —addMark(key, mark, place?)(add-or-replace one),updateMark(key, patch, place?)(deep-merge into one; throws on an absent key with the add/update asymmetry teach),removeMark(key | key[], place?)(delete by name; idempotent on missing keys) — all riding patchTerrain's spec-write core (same place resolution, validation, row commit, no-op verdict, persist stream). No expression in this vocabulary can touch a mark it doesn't name. patchTerrain({ marks })as a same-kind operation throws the retirement teach, naming the casualties mechanically ([Tome] patchTerrain(): "marks" replaces the whole mark map — this call would delete N marks you didn't name (…). Use addMark(key, mark) / updateMark(key, patch) / removeMark(key). Rebuilding the set? removeMark the old keys first, then addMark the new ones. To wipe the map deliberately: removeMark([…]).— both key lists BOUNDED: casualties coalesce past 8 named, and a >8-key map's wipe hint self-computes viaremoveMark(Object.keys(api.getSpec("places.<id>.terrain.marks")))so it stays a teach, not an 18k-char wall) — the objects-array refusal law, extended to the sibling collection. The carve-out is the authoring create: a kind-switching patch (and a first kind over a markless base) replaces terrain wholesale, somarksthere has no prior map to clobber and keeps working.marks: nulland theaddMarks/removeMarkspatch sub-keys (both accepted shapes) are deleted with errors naming the successors.- The persisted wire goes granular: mark verbs record
addMarks: [{key, value}]/removeMarks: [keys]ops instead of echoing the merged map as a bulkmarksreplacement (the oldbuildPersistedTerrainPatchbehavior) — the durable fold now merges disjoint concurrent mark writers instead of last-map-wins, and an engine carrying the verbs can never put a same-kind bulk replacement on the wire. - Both folds enforce the same named-target law via shared @spawn/tome-schemas primitives (
terrainMarksReplacementCasualties,isTerrainMarksRemovalEcho,terrainMarksBulkReplaceRetiredMessage): kiln's durable gate throws the identical retirement message on a same-kind bulk replacement that deletes marks it didn't name; cf-kernel's spec-mutation fold skips that class (no error channel — never destructive). Pre-verb engines stay compatible through mechanical classification, not version sniffing: their persist layer echoed the full merged map for every mark op, so an add/update arrives as a superset (deletes nothing unnamed — folds), a removeMarks arrives as a pure-erasure echo (base echo minus removed keys, or null — folds), and only the clobber class (writes riding unnamed deletions) is refused. Kiln'saddMarkswire parsing narrows to the canonical{key, value}array the engine emits (the record/single-entry shapes had zero wire producers). - Teach-side swap in the same change: the always-on
<terrain>block teaches the three verbs where the bulkmarks?:field sat alone;_examplesriver/ocean rewritten toaddMark; heightmap-terrain / voxel-terrain / structures / water-and-swimming skills and the api-reference regenerate to the verbs; studio-chat'srun_scriptteach and theapi.addMarksphantom-lint entry now point ataddMark. - Cross-player effect audiences: the silent lie becomes a named teaching fault — phase one is self-only (jacob's ruling, #inventors 1785207433: "Cross-player audiences are structurally dead under relay — two independent verified blocks. Phase one is self-only or it lies."). In client-auth multiplayer every effect call executes on a CLIENT (the acting player's machine, the place host, or the relay exec host), and a cross-player audience cannot be honored from there — two independent structural blocks: (1) EMIT-SIDE, the fan-out cannot see or address the room's other players (
SessionOwnerreplicates owner-only and is server-stamped — session.ts — so the nearby/place/all loops in object-api.ts enumerate exactly {self}, and rows placed on a remote player's entity die at the client-auth upload envelope, runtime-client.tsgetClientAuthUploadView); (2) WIRE-SIDE, the room has no per-player delivery channel (relay fan-out is broadcast; the owner-replicated carriersTomeJuiceTargeted/TomePlayerJuiceStateare stripped at every peer reader — relay-peer-apply.tsstripPeerInvisibleRows/PEER_VISIBLE_REPLICATE_MODES, the law that closed the 2026-07-23 session/owner cross-wire). Previously an explicitnearby/place/all/other-playeraudience silently collapsed to self (the exact screenShake/vignette-with-nearby break the ruling names). NowresolveJuiceEventAudience/resolvePlayerStateAudienceroute every explicit audience throughteachCrossPlayerAudienceSelfOnly: in client-auth multiplayer client worlds it records a named teaching fault on the getLogs rail (mutationWarn — "audience 'nearby' can't reach other players yet — effects are self-only in multiplayer for now", with the per-space action: world-anchored effects say drop-the-option since AOI already reaches everyone; screen/state effects say call-it-from-each-player's-own-trigger). Delivery is unchanged — the self half still applies;playeraudiences that resolve to the local session player (the ambient hook pin's own shape) never teach; server worlds (kernel-tier DO rooms, where the fan-out genuinely works) and singleplayer (trivially honest) never teach. Docs narrowed in the same change: theAudience@tomeapi block (types.ts), the generator's audience-defaults teaching block (generate-tome-api-docs.ts → api-reference §Audience defaults), and game-feel.md's manager-juice gotcha now teach the self-only phase-one contract. Runtime-fault-only compat: theAudiencetype keeps its members (existing specs carry nearby/place/all; scripts are untyped at runtime) — the narrowing is contract + fault, not a type break. - The lifetime reaper of last resort now fires on a successorless-HELD seat whose sitting host is suspension-stamped (fixes the warm-room-leak regression the successorless hold (ledger 1788, #11256) reintroduced for the never-returning-solo class).
reapsExpiredLifetimeHere's server branch keyed on "place unhosted" (getPlaceHostEntry === undefined); the hold keeps the entry — same clientId, same epoch, "frozen-in-fact exactly like unhosted: nothing simulates a held place" — so a solo room whose owner F5'd or alt-tabbed away forever deferred everyapi.spawn({ lifetime })reap indefinitely: the exact ledger-#732 warm-room ghost the deadline component was built to kill. "Hosted" for reap purposes now means ACTIVELY SIMULATING: the server also reaps the paused remainder when the seat's sitting host is suspension-stamped (the replicated word for "this session simulates nothing" — the same boundary that classifies its envelope into the remainder; the drain refuses a stamped session's uploads wholesale and any resume re-bases from a reset, so the reap can never race a valid write). In multi-client rooms a stamped host is re-elected away the same reconcile tick, so the new clause effectively fires only on the held solo room. The unstamped hold flavors (silent, off-Streaming past the retention ceiling, a god-parked detach) deliberately still defer — no authority boundary has been declared for them and each has a bounded exit. The hold's own semantics are untouched (all #11256 pins green); the GHOST solo e2e now asserts BOTH truths: seat held AND server reaps at one-tick precision. properties.-prefix forgiveness innormalizePropertyKey(tome/api/properties.ts):setObjectProperty(id, "properties.light.intensity", …)— the spelling authors reach for because the spec nests object properties underproperties— now strips the prefix and resolves identically to the bare key, silently, the same posture as the existingposition → feetPositionalias. Kills theunknown property "properties.light.intensity" — call ignoredclass for Savi, wisps, mods, and humans at once (ravi's promotion-audit receipts, ×2 in staging events). One normalization site covers set/get/batch/dotted paths; a barepropertieskey (no dot) still rides the unknown-property teaching rail.- Fog numeric ranges warn instead of clamping silently (
tome/api/dead-write-warnings.ts, newfogRangeClampRulerow in the atmosphere table): apatchAtmospherefog patch whose merged state the renderer would clamp now warns in the result channel naming the key, the given value, and the allowed range — linearfar <= near(renderer clamps far tonear + 1e-4→ hard fog wall atnearinstead of a gradient; atmosphere-sync's unauthored defaultsnear ?? 100/far ?? 500participate the way they render) and exp2density < 0(the shader squares density, so a negative renders exactly like its absolute value). Warn-and-clamp, deliberately not a reject: a latest-version-per-app scan of prodgame_specs(2026-07-27) found 2 live specs carryingfar == nearlinear fog that render through the clamp today — a hard reject would fault them on their next authored patch, and patchAtmosphere's told-success ⇒ readable-back contract stays intact (the doc keeps the authored values; the renderer clamps at its own boundary). Judged only when the patch touches a range key (kind/near/far/density); non-finite values remain applyFog's coercion lane. - Regression coverage red→green in
__tests__/object-api.test.ts(prefix resolves like the bare key, composes with the position alias, barepropertiesstill teaches) and__tests__/dead-write-warnings.test.ts(both range rows, default participation, silence for in-range/untouched/non-finite, plus an e2e pinning the warn rides the green receipt on the real verb). player/-prefixed owners can now address their own spawned children (tome/api/object-api.tsresolveObjectIdFromEntity; DUST LINE lag dig, dump 01b9df / report b955324d §3A): theplayer/early return resolved exact entity ids only and preempted the local-child scope every other id form gets, so an entity whose OWN id starts withplayer/— every player rig — could never resolveapi.id + suffixback to the parts it spawned withapi.spawn(api.id + suffix, { parent: api.id }). The children existed (the dump's teach line showedhasChild: truewhile the call dropped); the writes were discarded at 497/session in DUST LINE and the flood repeatedly misdirected that game's own debugging journal. On an exact-id miss the resolver now falls through toresolveLocalChildId— local-child scope only; every other missedplayer/…id stays null, and live exact ids still win first. Red-first regression pins the rig pattern end-to-end (spawn → resolve →setObjectPropertylands) plus the exact-first and stays-null contracts.- The authored-leaf-id fallback no longer walks the world per miss (same resolver tail +
findLeafIdCandidates; §3B): any slash-free id that missed every cheap scope triggered a fullworld.query(TomeChildren)walk —endsWith+hasEntityper listed child, O(world) per miss (~65 µs on a 2,203-entity DUST LINE-shaped world; a script looping over stored-but-dead ids paid for the whole world on every dropped call). Both sites now read a leaf→ids index (tome/leaf-id-index, classified re-derive at the exec boundary) maintained by TomeChildren component events — which fire synchronously on every write path (ObjectAPI spawn/destroy/reparent, interpreter, replicated deltas) and on parent despawn. Duplicate entries are refcounts so out-of-order replicated childIds writes (new parent's list landing before the old parent's removal) can't orphan a live listing. Lookup semantics are byte-equivalent to the scan: same per-childhasEntityliveness guard, same unique-leaf match, same ambiguity refusal. Micro-bench on the 2,203-entity world: miss 65.06 µs → 0.53 µs, leaf-hit 64.65 µs → 0.56 µs (~120×). Churn tests pin spawn/destroy/respawn-elsewhere/reparent/parent-despawn/out-of-order-replication equivalence. - Sim-tick attribution in the debug dump — the instrument that ends the blind-perf-pass class (b955324d §5: DUST LINE's sim ran 11× over budget, two structural perf passes landed without curing it, and the dump carried ONE aggregate sim number — "the profile is structural, not a stopwatch"). The #631 simTiming payload now carries per-system AND per-behavior-script milliseconds over the same ~45-tick window, on both sides: the client worker's perf-rollup block and the server's input-stats capture each gain
simTiming.attribution = { ticks, entries: [{ name, kind: "system" | "script", p50Ms, p95Ms, maxMs, totalMs }] }. No new clocks on the hot path — the collector (engine/runtime/core/sim-attribution.ts) routes measurements the engine already takes:Diagnostics.runWrapped/runTimedgained anonSampletap (every system, every tick, ecs/commit and changelog/prune included), and behavior-update reports the per-entity elapsed it already measures for the watchdog through the newSimAttributionResource, keyed by the composed behavior's normalized update-bearing script refs (dust-storm.js— spec-declared objects included, which carry nobehaviorRefs; multi-script entities join their refs; non-authority multiplayer clients stay clock-free and simply ship no script rows). Client-side collection is gated to the MAIN simulation phase — exactly the window the client tick ring measures, so the two blocks cross-read. Payload bounded by construction: top 12 systems + top 8 scripts by window-total ms, each kind's remainder folded into "(other)"; tracked-name caps (128 systems / 32 scripts) route overflow into the same fold. Kiln retains the block through the existing simTiming lane (re-validated + re-bounded,session-captures.ts) and the dump summary renders "Sim Attribution — top by p50 ms per tick" beside the Sim Tick Timing rings, script rows as a sublist under the behavior-update system row they subdivide. Instrument overhead measured (scripts/bench-sim-attribution.ts, the #11220 bar): 28µs/tick at a deliberately heavy 64-systems + 300-script-samples scale = 0.085% of the 33.3ms tick budget, runWrapped tap delta unmeasurable — noise floor, so the instrument ships unsampled. - Sole-candidate place-host churn: a place is never unhosted out from under a live connection — the successorless hold (ledger 1788, dump b0298916 / incident family across ≥7 apps: in a room whose place has ONE live client — the creator, also its host — every transport/visibility boundary deleted the TomePlaceHosts entry because
electable.length === 0, then re-elected at a bumped epoch once the client recovered; every host write uploaded around the boundary died silently at the drain's stale-epoch gate on the unhosted branch (tableEpoch:null), and because uploads are deltas against the client's own last-sent state the lost rows were never re-sent — one-shot script writes (entered/health init) forked the room's copy from the client's truth permanently; F5 reproduced ~100%, room restart + fresh join was the only cure). The fix is election-side, at the unhost branch of reconcilePlace: when retention declines the sitting host for a reason its connection record survives — detached inside the 20s disconnect grace (F5/pagehide), suspension-stamped (alt-tab past the takeover grace), sustained silence, or off-Streaming past the retention ceiling — and NO other candidate is electable, the entry is HELD: same clientId, same epoch, no revoke, frozen-in-fact exactly like unhosted (nothing simulates a held place; server-write-forward only forwards to Streaming connections; the drain's suspension/ingress gates refuse a non-live host's writes). The returning client's uploads validate the moment it recovers — the resume reset carries the held table at the epoch the client already adopted, and an F5 rejoin adopts hostship on its join's OWN reset with no unhosted gap. The hold never outranks a real successor (any electable candidate takes the place through ordinary election, same epoch bump as before) and never shelters a genuine departure (connection gone/tearing down, disconnect-grace expiry, place-leave, attach-time opt-out unhost exactly as before, now with the retention-declinereasonon thenetcode.place_host.unhostedline — the solo-churn field dumps carried six drop windows and zero election-side evidence). New ops receipts:netcode.place_host.successorless_hold/successorless_hold_released(one pair per episode, with reason, heldTicks, and outcome) and asuccessorlessHoldstenure counter. This also deletes the precondition of the #11237 returning-tab wedge for the un-stamped (god-parked) sole population: the "dead" frozen table claim stays live, so writes land directly and the forced re-seat rail is left to the moved-table wedges it was built for. - UI render heartbeat rides the debug dump — "frozen authored UI + all-green instruments" stops being representable (ledger 1786, dig 72d4ee01: a creator stared at an unclosable shop menu while Savi's live bisect proved the close click ARRIVED and flipped state server-side; the dump summary carried sim-tick/renderer/network/scene-gate and NOTHING about UI render, and
run_ui_scriptcannot reach the nested cross-origin realm). The HUD pipeline stage ledger (c1762332's instrument) gains the two legs the incident class needs and then surfaces in the dump: (1) a rejected stage — theapply-render.tsinvalid-inline-handler early return, which keeps the OLD DOM painted on purpose, now counts itself via a newui.rejectedframe receipt (sig echo + htmlLen + teaching-message head; protocol spec + 120/s cap mirroringui.applied, relayeduiRejectedby the realm host, recorded on the ledger besideapplied); (2) a client-side ui.fault counter —uiLifecycleFaultarrivals count on the ledger at the worker choke point BEFORE the behavior-fault rail forward, so a fault count that moved while getLogs stayed empty names the rail's dead leg instead of reading as "no fault fired". The whole ledger (compile state, sent/deduped/applied/rejected sig-stamped stage breadcrumbs, faults) now rides the 15s perf rollup aspayload.uiRender→ kiln session-captures retains it (re-validated, bounded) → the debug-dump diagnostics carry it → the dump summary renders a per-planeUI render:line beside Scene gate, including the derived stale-paint tell (⚠ last sent ≠ last applied — the painted DOM is NOT the last render). Fault-rail verdict from the same commission: the DM/log path is intact end-to-end in code (frameui.fault→ host →uiLifecycleFault→sendClientBehaviorFault→engine.diagnostic, allowlistedbehavior-fault→ runtime log + Savi DM) — the incident session's empty getLogs is evidence the reject class never fired on that client, which the heartbeat now proves or refutes from the dump alone. - Host-migration desync: the forced re-seat rail admits Suspended senders — the unhosted-epoch silence is healed, not just counted (2026-07-27 field family, verdict 5c2a90ee: four specimens on the unhosted-table branch —
tableEntry === undefined, "the place is no longer hosted" — one 204s / 31,482-write window; partner gutterbt's Castle Clash session, dumps c870e5ce/7ec9cc8b: a tab goes inactive, the place host migrates/unhosts, the returning client keeps stamping its frozen table copy and every host write drops silently, unrecovered by room restart or reload). The wedge was a false premise at the rail's connection gate: it declined any non-Streaming sender on the claim that its uploads "never reach this drain" — false for plain Suspended since the ledger-1572 ingress admit. A session the server still classifies Suspended while its tab is actually back and simulating had NO exit: ingress admits its uploads, the stale-epoch gate drops every host write, the truth echo is inert on the unhosted branch (v1 null-truth), egress skips the connection wholesale, election cannot touch it, and the one rail built for exactly this wedge (ledger 1257) declined per-message forever —reseatsDeclinedwithprojectionState: Suspendedwas the counted-but-unhealed signature. The gate now tracks the ingress admit exactly: Streaming and Suspended-without-rebase-debt force (same floors, same cooldown, same NeedsReset directive through the production make-before-break reset rail); NeedsReset, Suspended-mid-rebase, and detached still decline (a rebase is in flight, or the directive cannot be delivered). Session-STAMPED zombies never reach this gate (theisSessionSuspendedwholesale drop runs earlier), so the admit can never fight the suspension takeover. A genuinely-hidden tab discards the snapshot until it returns — bounded by the reseat cooldown and the reset-ack hold backoff — replacing an UNBOUNDED silent write-floor. Red-first e2e pins the whole field arc: god-parked creator (the un-stamped suspension population), tab hides, place unhosts on silence, tab returns without the server ever hearing the Active, the client stamps the dead claim for a sustained window → one forced re-seat, the client rebases onto the unhosted truth, election re-hosts it at the bumped epoch, and its writes land where they silently dropped. - fx VM algebra completion: sin, cos, atan2, fract, floor, step, smoothstep, abs, mod, pow, sqrt, dot as field ops on both backends (branchless WGSL, CPU/GPU edge semantics pinned: totalized pow(|x|,y), atan2 x=0 column defined), plus channelIndex() for exact per-particle spawn ordinals and .yaw() sugar (atan2(−z, x), the ground-plane spin convention)
- bindings.frame: flipbook as a field over the sprite sink's layers (floor()ed, clamped; unset keeps the stable per-life random pick bit-for-bit); sheet("…?animated=CxR") slices one uploaded atlas into layers at pack build; fx texture packs now carry full mip chains with trilinear sampling
- pos:/vel: authored kinematics (integrator bypass; attr("p") reads last tick); entity(id) live-transform vec3 fields (≤4 links per effect, dead links freeze at last sample)
- sprite sink: soft: meters depth fade against the viewport share; blend:"distort" (color.xy → screen-space refraction offset, alpha = mix); align:"ground" honors stretch as a plain aspect factor along an authored rotation binding
- floor:"bounce" with per-particle restitution (bounce: number | spawn-time field); floor:"die" death records now carry age-at-impact on BOTH backends (was end-of-life on CPU)
- one dynamic light per population now follows the alive-particle centroid on the GPU backend too (anchor stand-in path deleted); light radius accepts a field
- B12 event rail: api.pulseFx(id, name, { at?, data? }) + spawn: { when: { event: "pulse", name } } arms — integer counters on the replicated FxEmitter, per-tick coalescing, late-join snapshot-then-deltas; world-anchored juice kinds are simulator-elected at the emit funnel (the doubled-VFX class is structurally unrepresentable; realm:"server" is a compat no-op)
- decal: [start, end] lifetime ramps on scalar fields, attachTo alias, anchor references resolve like every sibling ("player", place-scoped, child ids); part materials gain dissolve: 0..1 (perlin-erode cutaway, emissive edge tint, blended lane)
- BREAKING (vfxContractV2 games only): slash throws on invalid args and requires size in meters, defaults to a horizontal arc at the caller's yaw with a seeded sweep side; decal rotation in degrees; decal size pairs are ramps
- slash edgeBody preset product (20 names) retired to an untaught legacy lowering; the 14 particleBurst presets frozen into a lowering table; oklch color strings accepted spec-wide in fx fields and juice colors (slash's ride the v2 gate — legacy slash colors stay number-only, exactly as shipped)
- governance: slash/shockwave/decal instances register in the fx census and pressure ladder with honest cost weights (distort 2×, soft +0.5, slash 3×, shockwave 2×); new fx-bespoke-clamp diagnostic
- 27 confirmed bug fixes from the adversarially-verified VFX scout sweep, each regression-pinned — highlights: space:"local" localP force fields correct and CPU/GPU-identical at non-origin anchors; inheritVelocity works platform-wide (anchor velocity derived at the sampling seam); duration'd highlight/pushLook cleanup survives the calling entity's death; GPU rate-spawn derivation is closed-form (rate 1e9 costs O(1)); ribbon segments validated/capped; live param-patch reshapes take effect on the CPU backend (reset-on-grow, matching GPU); fx census/expected-value folds never certify non-monotone ops or fold taught idioms to 0