engine v5.0.8
Engine v5.0.8
June 8, 2026
A patch in the For Real line.
what's new
- Shadows now fit every graphics card the same way: machines with tighter limits (many Macs) keep full shadow quality at every tier instead of dropping to a single sun shadow. Secondary sun shadows in big worlds render slightly smaller on low and medium quality.
- Your posted game is now truly pinned to what you published. Switching engine versions, editing with Savi, or saving work-in-progress no longer touches anyone playing your posted game — players keep running exactly the version you shipped until you hit publish again.
- The automatic graphics governor now actually engages in heavy scenes: worlds that stream assets or compile effects continuously used to keep it switched off, so weak devices stayed at 14fps with full effects. It also now notices devices stuck around 25–35fps, not just total collapses.
- Distant mountains stay on the horizon — big foggy worlds keep their silhouettes without the loading cost
- Content Savi adds appears all at once again — no more piece-by-piece assembly
- When an NPC's model can't animate at all (the asset has no skeleton or animation clips baked in), Savi now gets told exactly that — instead of your enemies sliding around in a T-pose with no clue anywhere about why.
- Switching your world to nighttime no longer drops the frame rate the way it used to. The moon, milky way, and stars look exactly the same — the sky just stopped doing invisible work for every pixel, every frame.
- Fixed a scary "Unable to reconnect" banner that could appear over a perfectly healthy game and never go away. If your connection is fine, the engine no longer cries wolf.
- Traveling between areas rebuilds terrain fresh each time — simpler and more predictable.
- Reverting to a previous version now fully resets the live world to that version — runtime leftovers from removed scripts no longer survive the revert. Objects a script had moved snap back to their saved positions, and script-written state is cleared to what the version saved. Players are never teleported or reset by a revert.
- Attaching multi-part objects to a character's hands (or any bone) no longer makes the parts fly apart or swirl around before snapping into place — they ride the wearer from the first frame, in solo and multiplayer games alike.
- Fixed a crash that could freeze the game (with an endless "Unable to reconnect" message) for everyone in the room when terrain decorations were written with missing or misshapen fields. Invalid decoration patches now fail immediately with a precise error, and clients skip any broken decoration entries instead of crashing.
- Fixed visual effects from one place leaking into other places. Effects spawned by an object that removes itself (like an explosion left behind by a grenade) used to land in your world's default place and pile up there forever — swapping places could greet you with effects that were never spawned there. Effects now always live and die with the place they were created in.
- Fixed objects from a place you just left occasionally staying visible in the place you arrived in. A network hiccup around the moment of travel could resurrect the objects nearest you as ghosts that rendered in every place and never went away until a full reload — most visible as a cluster of exhibits from the previous zone hanging in the air. Ghosts can no longer be created; leaving a place now reliably takes its objects with it.
- Fixed a bug where ponds or lakes with foam or caustics could turn the whole world black (except the water itself) after turning water refraction off. If your world went dark with no errors, this was likely it — it renders correctly now.
- Savi's changes now show up live for everyone in the room, every time — no more "I had to refresh to see it" moments.
- Games that drive movement or aiming from scripts (setAxis) no longer freeze players mid-stride after a reload
- Fixed a multiplayer bug where a brief server or network stall could leave a player rubber-banding for minutes — snapping back to an old position (and rolled-back health) about once a second. Recovery is now a single correction.
- Games that drive movement or aiming from scripts (setAxis) no longer freeze players mid-stride after a reload
- Savi now knows that bullets fired by your character should be spawned right where you pressed fire — in multiplayer they leave your muzzle instantly instead of trailing behind you while you move.
- Fixed stripey/banded shadows that could appear on big flat surfaces when the engine steps lighting quality down to keep your game smooth — shadows now stay clean at every quality level.
›technical notes
- Pinned-publish live rooms (ledger #161, Rocket Romp): a LIVE room for a posted game now resolves BOTH its spec and its engine from the PINNED publish row — never the latest dev row. Kiln's iframe-context resolver consults the publish pin for new live-mode rooms (
getPublishedEngineVersionForApp: game_publishes → game_specs.engine_version at the published version; slug rooms pin their own publish row), covering the play pages, the internal iframe-context route, prewarm, init-kernel, and edge routing through the one chokepoint. Active live rooms keep their registry-recorded identity (#119) unchanged. - Engine switches are dev-room events: the explicit-switch force-drain rails (engine-version PATCH route, Savi's manage_engine_version via the studio-chat drain-rooms route) are now mode-scoped to
['dev']. Pre-#161, a 4.6→5.0 switch mid-editing force-drained live rooms — effectively publishing work-in-progress and restarting everyone playing the posted game. - Publish is the ONE rail that moves live rooms: when the freshly pinned publish row carries an engine that PROVABLY differs from a live room's recorded identity, the publish flow drains that room onto the published engine (before the spec fanout, so reconnecting players boot engine + published spec together). Spec-only publishes keep the seamless in-place fanout; unknown-identity rooms are never restarted on a guess.
- Auto-update (Jacob's ruling, verbatim: "it should only update on publish full stop. auto update is for the non /play (the dev) world"): engine auto-update flows to the creator's dev world only; players see the new engine when the creator next publishes. Spec reverts continue to carry engine_version by design — a revert reaches live, like everything else, only at publish.
- Governor grace wedge (ledger #304): the budget rail demanded 10 contiguous wall-seconds of over-trip samples outside grace, and any single graced sample (collect backlog, JIT load in flight, first-sight shader compile — each re-arms a 3s grace) erased all accumulated overload evidence. Heavy scenes re-arm those graces chronically, so the exact sessions the governor exists for (sustained 14fps in a content-heavy world) never stepped at all — the drip-feed machinery disabled the safety net precisely when it was needed. The budget-rail axes (and the 60s boot-escalation floor window) now ACCUMULATE span-capped steady-state overload evidence: grace pauses accumulation (graced samples still never count — the #188 law holds), a genuinely calm steady-state sample still resets it, and a parked tab can't fake coverage (1s span cap, the guard's chronicMaxSampleSpanMs idiom). The mobile wall rail keeps its #6710-pinned contiguous behavior byte-identical.
- GPU-bound interval blind band (ledger #304): the guard's presented-interval sensor (#199) was gated on the 40ms fallback budget, so a GPU-bound session between the degraded line (28ms) and the fallback line — e.g. a misdetected-iGPU desktop at 34fps — read as its tiny CPU time forever: invisible to chronic accounting AND to the budget rail whose documented trip line is 28ms. The sensor's floor is now the degraded line, with the wall rail's environmental-throttle signature (clean 2× vsync cadence, tight jitter, near-idle CPU — computed from the governor's per-frame EMAs and passed into each guard sample) holding the floor at the fallback budget so Low Power Mode and idle 30Hz displays still never read as degradation. Above the fallback budget nothing changes.
- Governor telemetry drop (ledger #304): the sim worker's perf-rollup merge rebuilt the governor block field-by-field and dropped the three #193 effects-axis fields (
effectsRung,effectsTransitions,bootSteps) —spawn.kernel.client.renderer.governor.effects_rung/effects_transition/boot_stepsnever reached Datadog from ANY session, so budget-rail engagement was unprovable in prod and the tuning data the #193 changeset promised never arrived. The fields now ride the rollup like the resolution/geometry ones. - Horizon silhouette ring (ledger #300): the #149/#261 extended-band streaming clamp bounded chunk streaming at the fog-saturation distance on the claim that fogged terrain is invisible — but fog-saturated terrain is fog-COLORED, not invisible: the skybox is never fog-extinguished, so fully-fogged mountains still read as silhouettes where they break the skyline. On clamped profiles, terrain visibly went missing (or popped) at the horizon. The clamp (and its perf win — it killed a 4,225-chunk boot storm) stays exactly as it was; a cheap far representation now carries the silhouette.
- The ring: one client-local entity per heightmap place (
terrain/horizon/<placeId>, newterrain/horizon-meshcomponent) carrying a 384×12 polar grid of REAL terrain heights spanning the annulus from just inside the clamped band edge (3 chunks of overlap) out to the radius the unclamped extended bands would have streamed. Heights are sampled in a jobs worker (terrain/horizon-build) through the exact chunk-build height pipeline — generatorheightAt+ marks + height fields + edits, via the now-sharedcreateHeightmapHeightSampler(chunk builds verified byte-identical by the golden fixtures) — so authored mountains shape the skyline honestly. Total budget: 4,608 height samples ≈ a quarter of ONE LOD0 chunk's height pass, ~0.01% of the builds the clamp saves; zero chunk entities, zero colliders, zero decorations. - Renderer: one double-sided
MeshBasicNodeMaterialannulus mesh per place whosecolorNodeIS the retained scene-fog color uniform (three/fog.ts) — fog mixes fog toward fog, so every fragment lands at exactly the color fully-fogged real terrain rendered, at any saturation, tracking fog-color changes as uniform writes with no per-frame sync. Outer skirt drops to the place's vertical floor so elevated cameras never see under the rim. Never raycast-bound, never in the shadow pass, exempt from the fog-equivalence object cull by construction (it's a plain mesh, not a model/decoration). - Lifecycle honesty: the ring exists exactly when the clamp actually cut bands (linear fog saturating inside the band edge, extended profiles, heightmap generator) and disappears when fog lifts or goes exp2 — exp2/no-fog/camera-far cases never cut, so pre-clamp parity needs nothing restored there. Staleness is input-keyed: definition revision/content, annulus-intersecting edits (buried-under-streamed-terrain edits are filtered out), composed height-field chunk versions, and a 2-chunk anchor recenter lattice all fold into the build key; rebuilds coalesce through a 1 s throttle and never stack more than one in-flight job. Standard profile (mobile/server) and the server streaming path are untouched.
- The overturned premise's second consumer dies with it (architecture-audit scope addition): the fog-equivalence cull on model/decoration draws (geometry-budget.ts
fogEquivalenceCullDistance+ the models LOD-pass fog cut + the decorations cull-groupmaxDistancefog write) proved the fragment was fog-COLORED, not that skipping the draw was invisible — a fogged tower breaking the terrain skyline visibly vanished, exactly like the mountains. A sound test needs per-direction skyline knowledge no cheap per-object predicate has, so the cull is REMOVED, not gated (the ruling's escape clause):FOG_CULL_TRANSMITTANCE,FogReading,fogSaturationViewZ,perspectiveScreenCornerFactor(the geometry-budget copy),fogEquivalenceCullDistance,decorationFogCullUniformValue,readSceneFogReading, and the retained-fogrevisionstaleness key are all gone. Distance-ladder shedding (cullDistanceScale, "too small to read") is unchanged, and the fog-aware capacity win lives where it's coverage-preserving: the terrain clamp + this ring. The old cull test is inverted and pinned: fully-fogged models keep drawing. - Content instantiation lands in ONE renderer frame again (ledger #299): deleted the #164 collect budget (chunked collect, carry queue, backlog bookkeeping). Its 758ms storm is fixed at the roots instead — sync pipeline compiles (#263's creation-time async-warm covers every instantiation-path material class), a redundant per-material-op
packModelBatches(scene, null)in the same-signature recolor branch (O(n²/2) slot packs), and getEntityObject's miss-path name scan over scene.children (O(scene) per creating lookup). A 1200-entity single-frame storm's JS collect cost dropped ~2,870ms → ~13ms warm (600 entities: ~717ms → ~6.5ms) — quadratic to linear. - Consumers of the budget's backlog signal updated honestly: the hitch detector's
collect_backlogcause is gone (compile/terrain/load rails remain), the frame-budget guard's #188 load grace reads real in-flight asset loads only, the asset warmer's idle gate reads compile starts + real loads, and the chronic CPU report no longer names an instantiation backlog. - Ledger #287 ("mantle STILL slides down hills") investigation: the mantle character controller's slope hold is proven to NOT slide on real heightmap shapes at three levels — CC unit (heightfield tri-mesh ramps 10–44°, zoo-like bumpy octave hills, chunk seams between adjacent heightfield colliders, walk/jump/release entries into the hold, real player motor + DEFAULT_CONTROLLER_CONFIG), full
physics/steppipeline (realbuildTerrainHeightfieldBodyConfigterrain-chunk payloads, replicated config threading incl. the codec-bit-7minSlopeSlideAngledefault lane, both engines), and the real stack (Session Lab zoo terrain highlands, 40° hill: mantle feet bit-frozen over ~37 s of idle while raw rapier creeps ~4 mm/s downhill and never stops). On every measured scenario mantle's hold is equal to or strictly tighter than rapier's. - New
slope-hold-heightfieldsuite pins the real-heightmap shape the #246 box-slope suite missed: triangle-face contacts, internal-edge crossings, chunk seams, bumpy hills, landing/standing/release entries, the real motor shape (groundedresets vy, thenvy += g·dt— not a constant pressed-down displacement), plus a raw-rapier parity block documenting rapier's own idle creep and cross-walk drift on the identical mesh. Aslope-idle-terrain-chunkintegration test runs both engines throughphysics/stepon production terrain-chunk colliders so config-threading breaks can't pass-while-broken. - The one downhill motion mantle does have on tri-meshes — internal-edge contact normals slightly deflecting cross-slope WALKS downhill (≤0.19 m per 12 m walked at 35°, zero at idle) — is bounded by the suite below raw rapier's same-scenario drift (−0.21 m). No engine math changed in this commit.
- New Session Lab scenarios
hill-slide-mantle/hill-slide-rapierare the reusable real-stack A/B harness: zoo spec,updatePlaceengine flip before place entry, highlands steep-spot scan, idle + walk feet-position sampling via room_exec. - Moving T-pose NPCs now report why (ledger #307): a model with no skeleton and no animation clips renders through the static batch path, where mixer channels are silently ignored — the sculpt stays frozen in its authored pose while the NPC mover drives the entity around (QA's draugr "actively chasing me, stuck in a T-pose"). The skinned path's
model-clip-not-founddiagnostic never fires for these models because no mixer root exists, so Savi reasoned from the silence that the clips were fine and burned a session-long debugging loop on correct channel code. A named mixer channel landing on a static representation (at model attach or on a later mixer write) now emits amodel-not-animatableengine diagnostic — one report per entity+model, re-armed on model swap — naming the entity, the model, and the requested clip, and pointing at the asset: the bake produced no rig (?animations=...only keeps clips that actually exist), so the fix is rebaking with animations or swapping to a rigged model. ReachesgetLogs()and the one-time DM like every client diagnostic; static scenery with no channels stays silent. - Night-sky per-pixel cost gates (Ledger #301). Switching a game to nighttime opens the sky node's uniform night gate, and every sky pixel started paying the whole celestial stack every frame — most of it provably wasted: the moon's two fractal mottling stacks (~7 octaves of 3D perlin) ran for every night pixel and were multiplied by the disc mask's exact 0 outside the tiny disc, and the two 27-cell star lattices ran the full site/shell/gaussian/tint/twinkle math for every cell and multiplied it by the one-hash existence step's exact 0 (most cells are empty at any authored density). The moon disc shading is now gated to the disc mask, and the star lattices gate per-cell work on the existence hash plus the exact-zero shell/footprint window — every skipped term was a multiply-by-zero, so the rendered night is byte-identical; empty cells now cost one hash instead of six plus two gaussians. Cuts the night sky's marginal per-pixel cost roughly 3–4× (background node and IBL capture both). WGSL structure pinned by
sky-night-cost-gates.test.tsvia named gate vars (moonDiscMask,starCellLiveBright/starCellLiveFaint); the dominance suite keeps proving the new branches trap no shared vars. - Post-ready runtime-worker errors no longer raise the reconnect banner (ledger #305): an uncaught exception in the client runtime worker mid-session was broadcast as a loading-state
error, which kiln renders as its infinite-duration "Unable to reconnect" toast — QA's session carried a scary dead-end banner ("Uncaught TypeError: Cannot read properties of undefined (reading 'reduce')") over a perfectly healthy, fully playable game for the entire session, because nothing ever posts a recovering state after a one-shot exception. A worker ErrorEvent is not a death certificate (the worker's event loop survives uncaught exceptions) and is not connection state. The host's runtime-workererror/messageerrorhandlers now route throughbroadcastRuntimeWorkerError(loading-state-broadcast.ts): pre-ready it walls the boot exactly as before (a sim worker that dies during boot is a real boot failure); post-ready it posts nothing — the error stays visible through its console.error, which client-error-forwarding (#268) mirrors to the parent logger and the #284 recent-errors ring, and connection truth stays with the transport's own disconnected/ready posts. This mirrors the rendererReady gate the renderer-worker host has had since #206. - Removed the client place-travel terrain retention that ledger #261 added (deleted by ruling, ledger #302): the keep-last-left-heightmap-place mechanism in client streaming (
CLIENT_PLACE_TRAVEL_STATE/retainWhenEmpty/retainLastLeftPlace), the build system's place-switch installed-output keep + inputs-hash re-validation path, the entity-keyed output delete it required (deleteClientActiveOutputForEntity), and both retention test files. Leaving a place evicts its terrain again after the normal keep-alive grace, and re-entry rebuilds — that cost is accepted; back-and-forth crossings are uncommon and the build path is fast. The default-place never-evict rule and the server's default-place-only rule are unchanged, and #261's honest-aspect extended-band view clamp (the real place-entry boot fix) stays. - Replace apply resets live state (ledger #296):
revert_to_version/ kiln revert re-feed live rooms withreplace: true, but the room only reset ONE live-state class (the debug-day W3 atmosphere-override clear) before running a plain diff-apply — authored transforms whose authored value didn't change across the revert were skipped (updateObject's prevDef gate), and runtime-onlyTomeStatekeys were structurally undeletable (mergeRuntimeStateWithSpecDefaultsstarts from{...current}). Runtime-moved objects andpatchStatekeys survived every revert while the spec rail stayed byte-perfect — the creator saw a revert that "didn't work" (enfeul / Muffled Static, dump eddd7a4a). - The fix plumbs
replacethrough toapplySpec(ApplySpecOptions): a replace apply gives EVERY spec object the snapshot-restore treatment — authored transforms re-applied over live values,TomeStatereset to a clone of the def's state (runtime keys drop), and the session atmosphere-override clear now lives inside the replace branch instead of as a room-runtime special case. Players keep their session state (the player merge is deliberately untouched) — a revert never teleports or wipes connected players. Normal (non-replace) live-edit semantics are byte-identical, pinned by tests. - Both execution modes run the same branch: the flag rides the upserted
TomeSpecValue(replication to multiplayer clients) and thetome.spec.pushcontrol message (singleplayer authority — without this, a revert in singleplayer reset nothing, since the client world owns the live state there). spec-sync only honors the flag on an observed revision transition: fresh joins land on post-restore replicated state and must not replay the reset locally. - Tool honesty (cf-studio-chat, same train):
revert_to_version's diff line no longer says "(no changes)" when the spec already matched the target — it now states the live world was still reset to this version, because that's exactly the case where the user reverted to fix runtime leftovers. - Bone-attach seeding (ledger #308): attaching a multi-part rig to an avatar bone (
setParent/attachTowithattachment,spawn({ parent })+properties.attachment, or setting theattachmentproperty on an existing child) left the subtree'sWorld*as garbage — the root at local-as-world (~world origin) and the parts it carried at their pre-attach world coords — because the #160 attach-time compose explicitly skipped bone-attached subtrees and no per-tick solve on either side ever composes them. Every renderer frame until the bone-pose feedback first resolves (a cross-realm SAB round trip at minimum; model load / horde demotion windows in practice) drew the rig exploded across the world.composeAttachedWorldTransformsnow seeds bone edges exactly like plain edges (parent-entity ⊗ local — the best baseline both sides can agree on), and all three attach mouths route through it; the renderer's attachment solve takes over from the seed the moment feedback resolves. - Client projection corruption for bone subtrees (ledger #308, the multiplayer lane):
localTransformProjectionSystemre-derivedLocal*for bone-attached children by dividing theirWorld*by the parent'sWorld*— but the derivation invariant doesn't hold for them (World = bone ⊗ local, notparent ⊗ local), and the server-frameWorld*rows it divides are frozen at the attach-time values forever. Every recompose overwrote the authored hand-relative offsets withinv(parentWorld) ⊗ stale— an error that changes with every parent move/turn, so the rig parts swirled in giant arcs while the player moved ("disintegrating into a tornado") and only re-converged when a replicatedLocal*row happened to land last. The projection now skips bone-attached subtrees entirely: theirLocal*is authored truth (replicated rows + script writes), excluded from prediction compare like all hierarchy-child transforms. - Render worker no longer dies on malformed terrain decoration configs (ledger #312, dump 2979b175).
patchTerraincommits merged terrain to the room's live spec immediately while kiln's persist gate rejects the mutation seconds later — in that window every connected client rendered the raw value. A pebbles item authored as{ kind: "primitive", primitive: {...}, material: {...} }(noshape/color) produced the whole #312 crash family:.reduceof undefined (layer withoutitems),.lengthof undefined (hashString(item.shape)with shape missing), and.hasAttributeof undefined (unknown shape fell throughcreatePrimitiveGeometry's switch intofinalizeGeometry(undefined)). On 5.0.7 each throw latched kiln's "Unable to reconnect" banner (the #305 lane). Three gates now: (1)patchTerrainvalidates the MERGEDdecorationssubtree againstDecorationDefSchemabefore touching the live spec and fails the call with the same issues the persist gate would report — Savi sees the error at the call site instead of a poisoned room; untouched legacy-dirty decorations don't block unrelated terrain patches. (2) The renderer sanitizes the replicatedterrain/decorationscomponent at its boundary (sanitizeTerrainDecorationsValue): structurally broken layers/items are dropped with a warn naming the path, valid ones render. (3)supportedPrimitiveFromactually rejects unknown draw/primitive kinds (it was an identity function with a null-handling call site), andcreatePrimitiveGeometrynames the kind in a descriptive error instead of propagating undefined geometry. - Touch access to the keyboard-only debug panels (ledger #324). F3 overlay sizes to the viewport on coarse pointers —
width: min(50vw, 680px)reads at ~190px on a phone, no panel at all. New?debugboot fallback rides the same kiln page-url → iframe forwarding rail as?stats: truthy values open the renderer inspector at renderer-ready,?debug=f3/netcodestart the F3 overlay open (URL intent beats the persisted closed state). The F2 inspector header on coarse pointers documents both openers (kiln's hold-Home gesture + the url param). Thekiln:render-inspector/kiln:debug-overlayparent-message toggles the gesture rides already shipped (#6742). - Runtime spawns (
spawn/spawnFx) issued after the spawner entity was destroyed (destroy-self-then-spawn — the grenade pattern) now inherit the spawner's last known place instead of falling back to the spec's default place. The fallback parked looping fx objects in the default place's bucket: invisible where they were spawned, permanently rendering for anyone who traveled to the default place, never reaped (looping programs have no completion bound) and never unloaded (the default place never unloads). The ObjectAPI instance captures its entity's place at creation and refreshes it on every live spawn-place read (ledger #309, the visual twin of #256's place-less audio proxies). - AOI juice events whose source entity died between emit and process (emit-burst-then-destroy-self) now scope their proxy entities to the receiving player's current place — the same ownership rule #256 gave targeted/broadcast deliveries. Looping particle proxies from a dead source previously spawned place-less ("global"), rendered in every place, and survived every place-travel sweep.
- The exhibit leg (ledger #309 reopened): the client's room-delta ingest no longer materializes entities from UPDATE rows. Update rows carry only changed components — never PlaceMembership — so spawning an unknown entity from one minted a place-less husk that the renderer's place filter treats as a GLOBAL: it rendered in every place and survived every travel sweep. A stray update row for a departed-place entity (a duplicated/late/replayed frame around a travel — the class observed alongside the container-restart and RPC-retry storms in the reported session) resurrected exactly the entities with rows in flight at the travel moment: the couple dozen exhibits the zoo's player script was re-labeling near the player. Entity lifecycle is now owned by create rows, reset blobs, and delete rows; update rows (including deferred pending-local-edit stashes flushing after a despawn) for unknown entities are dropped. Two deliberate exceptions keep materializing: terrain chunk rows (#285 client-owned lifecycle — edit baselines arriving before streaming are never lost) and event rows (a die-fast source's fire has no create row; its transient carrier holds no draw output and the juice layer place-scopes the proxies spawned from it). The spec-authored exhibits themselves always had honest residency — the ghosts were ingest-minted husks wearing their components.
- Regression pins: render-plane fx teardown on place travel (ecs-sync → render channel → particles handler → backend), spawn-place residency for live/attached/post-destroy spawns, dead-source juice proxy ownership + sweep, stray update/event rows after travel (no husk in the world, nothing re-enters the render stream, steady-state updates unaffected), and a full-pipeline zoo-shaped travel harness (real server netcode + behaviors + spec updates + place cleanup against the real client runtime with prediction, real place-filtered spec-sync, and renderer ecs-sync — join, dwell with exhibit-label churn, travel, settle) asserting zero departed-place entities in the client world or render stream.
- Foam/caustics water with refraction turned off blacked every opaque pixel in the frame — terrain, player, sky — while the water itself (and its foam) rendered normally, with a completely clean console (ledger #332, the "Still Water" black world on 5.0.7). The water material's depth pipeline (shore foam / caustics / depth tint) samples
viewportDepthTexture, whoseViewportTextureNode.updateBeforeinterrupts the scene pass mid-frame with acopyFramebufferToTextureexactly like the refraction backdrop's color grab. But the #253 viewport-share registration — the post chain's signal to STORE the scene pass's MSAA color instead of taking the #228 transient discard — was gated onrefractionEnabledalone. With refraction off and any depth feature on, the depth grab still ended the pass (storeOpdiscardthrew away every sample drawn so far) and resumed on zeroed memory: everything drawn before the water resolved black, everything after (the water, drawn in the transparent bucket) rendered lit and normal, and no validation error fired because the sequence is API-legal. The trigger content was verified live in the game's spec: both terrain pond marks carryliquid: { refraction: 0, shoreFoam: 0.55, caustics: 0.25, ... }(Savi zeroed refraction mid-session) — shore foam and caustics keep the depth pipeline on while refraction off drops the registration, exactly the gap. The black silhouette player in the report is the depth test: depth stores through the break, so water pixels behind the player never draw and the zeroed player region reads as a silhouette against the bright foam. - Fix: the factory registers the material as a viewport-share consumer whenever
depthPipelineEnabled(which folds refraction), not just when refraction is on — the registry's own contract ("every material whose node graph can trigger the grab registers") now matches the graph. Pinned red→green by the depth-pipeline registration cases inwater-material.test.tsand an end-to-endmsaa-store-discard.test.tscase that drives the REAL fork backend through a depth-destination mid-pass grab with the REAL foam-only water material deciding the flag: stored at the break, loaded on the resume, discard restored once the pond is disposed. - Fixed intermittent loss of live spec updates to running rooms (ledger #326). The room's stale-echo guard stamped the live TomeSpec with the version of its own persist, but that persist's content is built by kiln on the DB's latest spec — which can include a Savi save the room never applied. The stamp then made the room skip Savi's poke (and its own echo) as stale, leaving the live world and every connected client on the old spec until the next foreign save or a refresh. Stamping is now consecutive-only: a persist that lands past an unapplied foreign version leaves the stamp alone so the poke applies.
- Room spec-mutation persists are now serialized per room, coalesced, and retried with backoff. Overlapping persists raced kiln's version slot, 500'd, and silently dropped the losing batch's mutations (observed in prod as
applyMutations failedstorms). - A replace poke at the room's current DB version now applies instead of being skipped as a stale echo — this is the resync kiln sends after rejecting a mutation batch, which previously could never take effect.
- Perf ACTION reports carry the creator-consent rule JIT (ledger #297, after Savi deleted a creator's effect off a frame-budget park report). Every diagnostic that describes an automatic engine action on creator content now embeds
PERF_ACTION_CONSENT_RULE(rendererdiagnostics.ts— the ruling's three elements in one line: ask the creator first, prefer the smallest reversible change, never delete): the frame-budget fallback/park report (frame-budget-report.ts fallbackMessage), the renderer fx budget-cull report (particles.ts FX_POPULATION_CAP_MESSAGE), and the server-side fx cap-cull log (fx-reap.ts, both latch shapes — its pointer DM carried the #235 consent frame but the log body Savi pulls with getLogs did not). The park report's "Edit the script(s) to re-enable" imperative is gone — un-parking is described as the creator-consented path. The rule sits ahead of the variable-length park list so the rail's 500-char message cap can never truncate it away. Coverage is pinned by a classification test over the server's diagnostic-code allowlist (engine-diagnostics.test.ts): every code must either carry the rule in its minted body or sit in a justified exempt bucket — error-class diagnostics (compile/runtime/build failures, broken assets: the fix-iterate loop, not actions on working content), device-local adaptive-quality steps (#193, no creator content touched), and informational perf warnings (no action taken; their DM rides the #235 consent-framed pointer). The #235 consent frame on the hourly perf pointer DMs is unchanged. - Fixed programmatic input axes vanishing from input frames when their value is exactly 0: the frame encoder skipped zero-valued axes and the input Proxy then fabricated a 0 for the absent key, so script-side
?? fallbackpatterns were dead code and aim-coupled controllers (e.g. setAxis-driven movement) halted mid-stride after a reload. Programmatic axes now ride every frame once granted, including at 0. - Fixed the prediction cap-adopt rubber-band storm (ledger #333): when a hard baseline adopt finds the server's newest snapshot more than MAX_RESIM_TICKS behind the local clock (a server wall-clock stall — worker blocked by a long synchronous spec apply/exec, GC, host CPU contention — whose missed time the backlog clamp discards), the client now rebases its local tick onto the server timeline with the same RTT-based lead a join picks. Previously the adopt rewrote state but left the clock stranded in the server's future: every input frame landed too_far in the server's buffer (the server simulated an idle player), every fresh correction was over the resim cap, and the adopt re-fired each cooldown window (~1/s) until the 0.8x input-flow throttle ground the surplus lead away at roughly 4s of slewing per 1s of stall — felt as "keeps restarting, snaps me back to full health and a different position."
- Hard baseline adopts are now counted in resimulation stats (
totalBaselineAdopts/lastSecondBaselineAdopts) and surfaced in the debug-dump misprediction block — adopt storms were previously invisible in dumps. - Projectiles skill now teaches WHY spawning from the shooter's
onInputis multiplayer-safe (predicted locally,uniqueId()mints the same id on both machines from the replicated per-owner seq, resim reconciles, unconfirmed spawns are despawned) and names the anti-pattern: a server-only manager reading player positions births projectiles ~RTT behind a moving shooter's muzzle. Mirrored as a combat-skill best practice. The cross-realm reconciliation contract is pinned bypredicted-projectile-spawn.test.ts(same-id mint with zero coordination, one welded bullet through rollback+replay, server-unconfirmed predicted spawns cleaned). - Resident directional light slots (compile-census row 19 + the fallback-swap storm): shadow-casting directionals are unbatched and hashed into every lit material's lighting cache key (
light.id+castShadowinClusteredLightsNode.customCacheKey, identically in the legacyDynamicLightsNode), so adding/removing one — including the zero-light fallback sun being disposed when Savi adds the FIRST light to an empty world — rebuilt every lit material synchronously (the trace-verified 235–540 ms #317 mechanism).resident-light-slots.tsgeneralizes the #317 idiom to the light layer: a pool of DirectionalLight identities (1 sun + 1–2 plain by tier) mints at the primary-scene seam insyncSceneLighting,castShadowfrozen true, and never leaves the scene. Authored shadow directionals lease a slot (the entity's visual IS the slot light; values/transforms flow through the unchanged machinery); release idles it — intensity 0,shadow.autoUpdate/needsUpdatefalse (the fork'sShadowNode.updateBeforegate ⇒ zero shadow passes),shadow.intensity0, map shrunk to 4×4 — so light churn never changes the identity set the hash sees. The sun slot has two flavors: csm (aSunCascadeShadowrig wraps the resident light for its lifetime; newsetIdleparks scheduling and the per-frame fit) and plain (legacy follow-camera fit); camera/mode gate flips re-lease between resident flavors instead of dispose-and-recreate. The plain flavor needs no camera and mints eagerly with the pool, sofitToView: falsesuns and perspective→ortho camera flips never mint mid-session (cost: one extra always-resident shadow chain on csm scenes). Named residual: the csm flavor's rig needs a perspective camera, so a scene that BOOTS without one (2D/ortho start) and gains cascaded sun shadows mid-session pays exactly one whole-scene rebuild at that mint — structurally deferred, at most once per scene. The fallback sun is now a lease on the sun slot (ensureFallbackLightsdrives slot values; sky-ambient/IBL suppression keys on fallback ACTIVE).shadow.enabledflips migrate the visual between the batched uniform-array container and a slot —castShadownever flips on a hashed light. Over-K shadow directionals demote to the batched container (castShadow=false: lit, unshadowed,lightResidencyDebugInfocounts them) and the highest-scoring demotee promotes when a slot frees; each demotion also reports once per entity on the engine diagnostic rail (new allowlisted codelight-shadow-slots-exhausted— runtime log entry Savi reads viagetLogs()+ one-time DM, #297 ACTION classification with the consent rule inline), because a shadow-requesting light quietly rendering shadowless is undiagnosable from the canvas. Demotion is an assignment state, not an object lifecycle: the promotion gate resolves through the lease's own availability predicate (findIdleCompatibleSlot) over the same request the promote would make, so a demoted visual keeps its batched object across syncs until a compatible slot actually frees — a looser gate ("any sun flavor idle") admitted demoted csm-wanting suns whose lease then failed on the specific flavor, disposing/re-minting their object every lighting sync; the N-sync steady-state pin (zero identity changes, zero shadow disposes, diagnostic once per entity, exactly one swap when the ranking actually changes) holds the class. Light selection pins resident lights visible (leaving the light list would change the hash), batched directionals compete for the remaining cap; authoredlayerMaskfollows the same law — slot-backed lights keepvisible=true+ full THREE layers for the lease's lifetime and express a layer-hide as intensity 0 + parked shadow passes (the idle idiom), where it previously visibility-flipped the hashed identity (one whole-scene rebuild on hide, another on show). Leased map sizes clamp per role (mapSize is unhashed, so every clamp is a binding refresh, never a pipeline): csm sun cascades budget PER CASCADE at the tier'ssun.mapSize; the single-map (non-cascade) sun — 2D/ortho cameras,fitToView: false, legacy lighting, low tier — budgets at twice the per-cascade size ceilinged at the 4096 default (low/medium 2048, high/ultra/legacy 4096), since one map covering the whole shadow range needs more texels than one cascade of several; plain slots cap at 2048. Flagged taste calls (render-output changes vs the unclamped 4096 default): plain slots at 2048, and the single-map sun at 2048 on low/medium. Storm pins inlights-static-pipelines.test.tshold the key bit-identical across add/remove churn, the fallback swap (both directions), shadow flips, over-K demotion/promotion, selection oversubscription, authored layerMask hide/show on resident lights (csm rig park included), the both-sun-flavors boot +fitToView: falseplain-flavor re-lease, and the csm fallback→authored→parked arc — plus the demotion diagnostic emission, the single-map sun budget, and the IES/projector residency contract (texture OBJECT identity rides the hash; content updates don't) for the deferred resident-spot surface. - Steady-state GPU validation sentinel (ledger #365, extends #249): the renderer worker now opens a short run of per-frame validation error scopes on a fixed cadence (6 frames every 5s), not only inside the post-recovery probe window. A validation storm that begins muzzled — the boot storm already burned Dawn's per-device uncaptured-error budget, so a mid-session pipeline break delivers zero uncaptured errors — previously never tripped the #187 burst detector and left the canvas flat grey for the rest of the session with an empty error rail (dump d7730358). Sentinel captures re-enter the existing classifier, so a silent storm now earns recovery #1 and from there the #249 escalation: probe window → recovery #2 → exhaustion → the player's reload wall, with diagnostics on Savi's getLogs/DM rail at every step.
- Render-worker silence watchdog (ledger #365): the renderer host now stamps liveness on every worker message (perf samples ride at ~1Hz whenever frames flow) and judges sustained silence — 15s with the page visible, post-ready — as a dead/wedged worker. A browser-killed worker fires no "error" event, no device.lost, and no validation errors; before this it froze the canvas forever with zero player affordance and zero telemetry. The trip is latched once and raises the same three surfaces device loss does: a page-context console.error (rides the #268 iframe→parent forwarding into observability), a
renderer-worker-silentengine diagnostic (new allowlisted code), and a loading-state error post (kiln's reload pill). Hidden/occluded time never counts — the worker's loop parks legitimately without compositor begin-frames. - Directional shadow bank (
extensions/lighting/directional-shadow-bank.ts) — the resident-light-slots changeset's named follow-up, built: every shadow-casting directional (sun CSM cascades + plain sun flavor + all k_plain resident slots) now renders into ONE shared depth array + ONE transmitted color array instead of a per-light depth+color pair each. Texture bindings dedupe by texture UUID (TextureNode.getUniformHash→value.uuid), so the whole directional shadow system costs a flat 2 sampled textures in every lit shader — K-marginal 0, cascade-marginal 0 (ultra == high == medium). High-tier worst-case lit stack: 23 → 13 honest (low: 11); a 16-grant Mac (Safari-class grants, spec defaults — the #249 field-death class) now runs FULL tier shadow rows instead of folding to medium. Mechanism is the fork's own TileShadowNode addon pattern, zero fork changes: per participant the stockShadowNodeis kept and the INSTANCE is patched —depthLayer(array-aware PCF filters + transmitted tail; bakes into WGSL as a const, so layers are mint-frozen),setupRenderTargetreturning the shared pair,renderShadowtargeting the layer viasetRenderTarget(rt, layer)(the PointShadowNode cube-face precedent) with the stocksetSizeDROPPED — the bank is construction-frozen per session (perf-program law 3); stray participant mapSizes are pinned back to the bank edge with a once-per-session warning. Built at the resident-pool mint seam before the first material build (shader structure changes only at session boot); cascade layers are reserved up front even before the csm flavor mints, so a mid-session first-perspective-camera csm mint never grows the array. VRAM rides the atlas #228 placeholder-boot idiom (textures boot at 4², grow to the tier edge on the first directional shadow render, sticky). Layer layout per tier (clustered): high 6 @2048, ultra 7 @4096, medium 4 @1024, low 2 @1024. - Idle/wake on resident slots is now flags-only: the 4×4 idle shadow-map shrink and
resizeShadowRenderTargetdied (a bank layer cannot resize — the flat array IS the K-marginal-0 cost); idle slots (and parked csm cascades) park their shadow matrix on a constant-UV projection so the shadow taps baked into every lit material stay on one cache-hot texel of the full-size layer. Lease/demotion/promotion,castShadow-frozen identities, K-fixed-per-session, and every lighting-cache-key/identity-churn pin are byte-identical — the bank touches textures, never the hash (light.id+castShadow; same texture UUIDs for the arrays across all participants). - DECLARED VERDICT DEVIATION (needs ratification, not discovery): the shadow-cost verdict's build-order #1 ruled the budget-K backstop "stays regardless — the fold does not replace it"; this build DELETES it instead. The pool's grant-derived K budget (
planResidentSlotBudget,setResidentLightSampledTextureGrant, the min-sun spend order, thelight-shadow-slots-budget-constraineddiagnostic,rendererMaxSampledTexturesPerShaderStage) collapsed and died: bank-backed slots are binding-free, so grant-folding K post-bank saves exactly zero bindings — the backstop became a structural no-op, not a vestigial floor, and keeping it would be dead-but-armed code. Law-5 parity holds: pre-bank low-tier demand was 11 and post-bank low is also 11, so hostile grants below 11 land in the identical place before and after (pinned by the "hostile grant below even low's rows still degrades" test).sampledTextureDemand(quality.ts) rewritten honest:8 material+IBL reserve + 1 cluster data + (atlas ? 2 : 0) + (shadow pipeline ? 2 bank : 0)— it now INCLUDES the pool (the omission was the #249 mechanism) and fixes the old comment that misattributed the atlas's 2 to transmitted doubling (the atlas pair is depth + slot-record DataTexture; atlas lights never had transmitted color).applyAdapterShadowCap's donor walk survives as the hostile-limit fallback only (sheds the atlas pair for grants in [11, 13); below 11 degrades to low's rows rather than dying — law 5 floor unchanged). - Demand-model scope (honest residual, pre-existing — not a bank regression): the flat-13 accounting covers the DIRECTIONAL system; shadow-casting IES/projector/custom-color spots keep three's per-light path (
ClusteredLightsNodeisSpecialSpotLight) and bind an uncounted depth + transmitted pair each, so two of those on a 16-grant rebuild the #249 validation-burst class outside the cap. Named follow-up: count authored per-light spot shadows into the demand at theworstCaseLitStackSampledTexturesseam, or demote their shadows on constrained grants. - Teardown disarm on bank participants: the fork's stock teardown disposes through both target aliases (
ShadowNode._reset→this.shadowMap.dispose(), reached fromdispose()and the shadow-type-change branch ofsetup();LightShadow.dispose→this.map.dispose()) — for an adopted node both alias the SHARED bank target, so any single participant's teardown would have destroyed every directional shadow in the session. Unreachable today (slots are session-lifetime, lights.ts only tears down non-bank rigs), but unguarded;adoptShadowNodeIntoBanknow also patches_resetandshadow.disposeto detach the bank alias before the stock body runs (participant-owned resources still tear down stock). - Flagged taste rulings (per the shadow-cost verdict — defaults shipped, do not re-decide silently):
- Plain-sun map budget halves. The single-map (non-cascade) sun rides the bank at the tier's
sun.mapSizeedge — high 4096→2048, medium 2048→1024 (the previous ×2-ceilinged-at-4096 budget died with the per-slot targets). 2D/ortho sessions (where the plain sun IS the sun) feel it most; the alternative (plain sun off-bank, +2 bindings → 15 total) was ruled out as the default. - Ultra bank = 7 layers @4096, ~flat VRAM. ≈896MB depth+color allocated once grown (vs ~512MB idle / ~704MB full-lease before) — accepted for binding-flatness on discrete GPUs; placeholder boot covers shadow-light scenes.
- Transmitted color array KEPT (tinted/transparent-caster shadows preserved through the bank). Colored shadows through the shared color array need a zoo parity taste pass; dropping the color array (13→12 bindings, −~448MB ultra) is the contingent follow-up ruling and needs a fork one-liner.
- Plain-sun map budget halves. The single-map (non-cascade) sun rides the bank at the tier's
- Shadow bias now derives from shadow-texel world size at WALKED lighting tiers (staging w30 field report: banding/acne on desktop — the one-ladder rebuild made lighting-tier-down rungs holdable by any device, so desktop sessions now run map sizes/cascade counts whose bias constants were tuned once for each tier's DESKTOP-era defaults and never re-derived; the shadow-bank verdict named "acne retune" as the known bill for map-size changes). The law everywhere:
bias = baseBias × (texelWorldSize / referenceTexelWorldSize), where the reference is the desktop tier-default configuration each constant was tuned against — scale exactly 1.0 there (the HIGH-tier desktop look is byte-identical, pinned by tests; at ULTRA — the default tier for discrete-GPU/Apple desktops — the single-map sun and plain slots run the bank's 4096 edge against the flat 2048 reference, resolving scale 0.5: authored bias/normalBias and the 0.08 normal-bias cap HALVE, an acne-direction look change with no test pin — OPEN RULING: floor the sun-flavor scale at 1, or accept as the high-tier retune in the staging taste pass), proportionally more bias at walked/clamped configurations instead of acne. Per context:- Sun cascades (
SunCascadeShadow.fitCascadeDepthRange) — already the law; untouched. Per-cascade bias/normalBias re-derive from each cascade's actual texel geometry, so walked configs (medium's 2×1024/100 m on a desktop) land in-regime by construction. New tests pin the derivation itself across the high/ultra references, the medium walked rung, half-size maps (~2× texel → exactly 2× bias), and cascade-count reduction (the near cascade coarsens and its bias follows; the far cascade's box is maxFar-dominated and matches across counts). - Resident plain slots + the single-map (non-cascade) follow-camera sun (
lights.ts directionalShadowBias). The auto depth term was already texel-proportional, but the absolute pieces — the normal-bias clamp bounds (0.02/0.08) and any authoredbias/normalBias— were frozen at the desktop-era reference clamp (2048 for EVERY non-csm slot, single-map sun included —referenceResidentShadowMapSizereturns a flat 2048; the csm flavor rides the live edge by construction). A walked tier shrinks the live edge under the same world frame (low/medium edge 1024 — texels ×2 vs the 2048 reference with the cap frozen at 0.08, under half the needed normal offset on large frames → the field-reported striping); the absolute pieces now scale by the live-clamp/reference-clamp texel ratio, so the resolved pair at any walked config is exactly the reference pair × the texel ratio. The scale follows the CLAMP, not the tier: an authored map under every cap resolves identically everywhere. The csm sun flavor is excluded by construction (scale 1) — its bias never comes from the source shadow; the rig re-derives per cascade. - Local-light shadow atlas (
ShadowAtlas._captureFaceState→resolveAtlasFaceBias, consumed byClusteredLightDataNodeslotParams). The depth bias was a pure constant (−0.0005 in [0, 1] depth units — range cancels against texel-world growth, leaving cell texel COUNT as the one untracked dimension) and the normal-bias clamp bounds were absolute. Both now scale by the configuredmaxSlotSize's ratio to the desktop-era reference (1024 — TIER_PRESETS high/ultra since lighting v2 #6602): a walked atlas (medium's 512, previously phone-only, now a desktop rung) halves every cell and gets 2× bias instead of half the tuned slack. The coverage-bucket cell shrink was tuned-in behavior at reference and is deliberately NOT in the scale — desktop-default output is byte-identical including contended atlases. - Tests: reference-config invariance (scale 1.0 → existing values byte-identical, literal pins), walked-tier scaling (half map → exactly 2× bias, including the cap-engaged and authored-value cases), cascade-count reduction scaling, resident-slot clamped-size scaling, and the pure atlas resolver law (
light.test.ts,sun-cascade-shadow.test.ts,shadow-atlas.test.ts).
- Sun cascades (
- Deliberately out of scope: PCF radius (penumbra targeting, not bias — unchanged at every tier) and the engine-owned fallback sun's zero default bias (0 × scale = 0; pre-existing, not part of the tuned constant family).
- TASTE PASS REQUIRED (Jacob, staging): the walked-tier look changes (banding/acne → clean, slightly more peter-panning headroom at walked rungs); the desktop-default look is pinned unchanged. Repro surface: a desktop session holding
lighting-tier-downrungs (or a forced low/medium tier) over large flat receivers at grazing sun angles + shadowed local lights.