Spawn

Make Games with Words

Explore or make your own

spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.0For Real1 dayengine v4.6Atelier4 daysengine v4.5Surface Tension2 weeksengine v4.4Solid3 weeksengine v4.3Groovy3 weeksengine v4.2Continuum3 weeksengine v4.1FoundationsMay 4, 2026engine v0.1GenesisApril 29, 2026
← All posts

engine v5.0.0

For Real

June 4, 2026

A million particles, real skies, enemies with brains — the world got real, and Savi can build all of it. The biggest engine release yet.

what's new

5.0 is the biggest engine release Spawn has ever shipped. The theme, if it has one: your world got real. The sky scatters actual sunlight and hands over to a moon at night, the ground has depth you can see and edit instantly, enemies can genuinely find you, and multiplayer runs on a new engine — with matchmaking your game controls. Nothing here needs migration: your game stays on the engine it was built with until you take the update — ask Savi, or hit the "New features" button in the project tab — and when you do, it keeps working exactly as written: much of this turns on the moment you update, and the rest is one flag or one sentence to Savi away.

Storms, explosions, magic — a million particles. Effects used to hit a hard ceiling around twenty thousand particles; now the graphics card runs them itself.

  • Ask Savi for a snowstorm you can stand inside and she builds it — over a million particles without slowing down. The nebula in the clip below is about half a million motes you can walk through.
  • Effects carry real light: a campfire genuinely warms the cave around it, an explosion flashes the walls.
  • When Savi invents a brand-new effect mid-conversation, it appears the same instant — no shader compilation, no stutter.
  • Ask her for smoke that darkens the room instead of glowing and she has the tool: a new subtract blend mode for particles and sprites — smoke shadows, ink clouds, energy drains.
  • Glow, smoke, and fog finally play fair: bright glows always draw on top of smoke, overlapping smoke puffs sort properly instead of flickering, and distant fire keeps its color in fog instead of turning gray. Effects using the old screen blend now render as additive glow, which behaves correctly on bright scenes.

Every light you place actually lights the world.

  • Streetlights, neon signs, lanterns, torches — hundreds at once (our test scene runs roughly 950) instead of only the few nearest the camera, and the lighting no longer shifts when you turn around.
  • The sky now gives your world soft ambient light when you haven't set your own, so outdoor scenes stop looking dim — blue-tinted by day, warm at sunset, dark at night.
  • Sun shadows are steadier (no more flickering while nothing moves) and reach farther into the distance, and the engine picks lighting quality to match each player's device, so big lighting setups stay smooth on phones.
  • Point a spotlight at something by saying where: light: { kind: "spot", aim: { x, y, z } } — no more yaw/pitch math to rake a spot down onto a pedestal. aim: "down" for ceiling lights, aim: "forward" for headlights and flashlights.
  • Anything you set yourself — ambient, hemisphere colors, sun values, or a deliberately pitch-black scene — stays exactly the way you made it.

Ground and walls with real depth. One flag — pbr: true — turns any texture into a real material.

  • Terrain blends like actual ground: grass grows up between cobblestones instead of cross-fading, the surface has real relief up close, and materials respond to light correctly (normals, roughness).
  • Tell Savi a wall should be brick and it's actually brick: she sets one flag — pbr: true — and on capable desktops it gets depth, self-shadowing between bricks, notched brick silhouettes along its edges, and true displaced geometry up close — with the detail handing off smoothly as you walk away. No extra setup.
  • Tiled floors and long paths stop looking like a copy-pasted grid: textures repeated across big surfaces now vary subtly tile to tile, the way real stone and brick do — automatically, with small textures and props keeping their exact authored look.
  • Resize a texture pattern the natural way: textureScale: 2 makes cobbles, bricks, or planks twice as large — it composes with the automatic real-world tiling instead of fighting it.
  • Everything is tiered for the player's device: phones and weaker laptops keep the simpler look at full speed.

Dig and build with no lag — and it's saved for good.

  • Terrain editing is instant now: break a block and it's gone, place one and you're already standing on it. Big builds land at once instead of trickling in block by block.
  • Savi got bulk tools too — big builds land as a few commands instead of hundreds: fill a region, carve a moat, stamp a whole watchtower in one go. A wall that used to take 512 separate commands is now 4, so building together keeps pace with the conversation.
  • Every edit is saved automatically, in every 5.0 world, with no setting to remember — come back tomorrow and your tunnel is still your tunnel.
  • Block worlds finally sit in their lighting: terrain casts shadows now, so a tower throws a long shadow at sunset and a canyon floor falls into shade.

The sky is real now. Every world with a procedural sky gets a physically real one, automatically, the moment it's on 5.0.

  • Sunrises, sunsets, twilight, and a sun you can look at. Stay out past dusk and the stars fade in, the milky way arcs overhead, and a moon with real phases rises — and actually lights the world, so a full-moon night is silvery-bright and a new-moon night is properly dark.
  • One number scrubs the whole day: set timeOfDay and sun position and color, ambient light, reflections, fog, stars, and the moon all move together with no scripts. 0.8 is a sunset; one cycle line is a day/night loop that runs forever.
  • Clouds live in the sky itself now: they drift with the wind, catch sunset colors, glow silver under the moon, and dim the world when it's overcast — the way an overcast day actually feels.
  • Want somewhere else entirely? Sun size, horizon tint, milky way brightness, moon size and phase are all dials now — ask Savi for "a Mars sky" or "permanent golden hour" and the atmosphere itself changes, lighting your whole world with it.

Multiplayer, rebuilt — and your game runs the door.

  • Multiplayer got a whole new engine under the hood: smoother movement, and hits on moving players land where you actually aimed — the server rewinds up to 300ms to where the target was on your screen, so if your crosshair was on them, the hit counts.
  • Rooms hold about 100 players, and when one fills, a fresh server boots automatically and the next player rolls in — your game scales sideways with zero setup. Under the hood the server writes one update per neighborhood instead of one per player; on our benchmark that cut the per-tick sync cost at 1,000 players from about 119ms to about 6ms — headroom to spare.
  • Click Play and you're in: joining a world that's already awake lands in about a second, and waking a sleeping one now overlaps with your page load instead of stacking after it.
  • Ask Savi for matchmaking and she writes it into your game: keep returning players on their old server, fill the fullest open room before starting a new one, shard by region — a few lines of routing code your game owns and decides which room each player joins. No code at all gets you sensible defaults.
  • Rooms have a real player cap: a full room turns players away at the door and routing automatically rolls them into the next room, creating a brand-new one when everything's full. Players just land somewhere playable.
  • Ask Savi for a live server browser and she builds it from real data: server lists, matchmaking queues, and per-server saves can now be listed, queried, and locked from in-game scripts.
  • Preview: Mantle, our own deterministic physics engine, built from the first line for multiplayer prediction — opt-in per world via physicsEngine: "mantle"; rapier stays the default for every game.

Enemies have brains. Ask Savi for "an enemy that patrols and chases me when it spots me" — one sentence, and the engine handles everything that used to go wrong.

  • Smart movement — enemies walk around obstacles, through doorways, and around each other instead of bumping into walls or piling up. No more zombies stuck on fountains.
  • Smooth movement — NPCs walk, break into a sprint, and turn at a natural rate instead of snapping or moonwalking.
  • They see and hear — give an NPC vision and it spots you (or loses you when you hide); throw a noise and guards come to investigate. Hide and seek is a real game now.
  • Fair fights — enemy groups can take turns attacking instead of mobbing you all at once, and archers keep their distance while staying aimed at you.
  • Tune it yourself — select an NPC in god mode and drag its vision ring, pick its speed and hearing, right in the world.

Savi sees your game now — and she's a shader artist.

  • Savi can watch your game while you play — through your camera, or an invisible one she flies to check the back of the castle without moving your view. What she reports is her view of the world, and she catches her own mistakes by looking, the way you would.
  • Ask for a force field and she writes the actual shader: glowing energy, glass, lava, holograms, dissolves — on anything in your world. Say "slower pulse" or "more green" and it updates live.
  • Ask her to preview something and she'll show you a picture before it lands in your world — even a little filmstrip of it animating, or a sweep of variations.
  • Whole-screen looks: film grain, retro pixelation, scanlines, color grades, underwater moods. Savi writes the look as a tiny script, tweaks it live, and can fade it in and out during gameplay.
  • Every new game now opens with its look already written out as an editable script — the world looks exactly as before, but the dials for color, glow, grain, and vignette are sitting right there. "Warm it up a little" is a one-number edit.
  • If a fancy shader ever makes a game run slow, the engine steps in to keep it smooth and tells Savi exactly what to fix — players never get stuck with a laggy world.
  • For big asks she fans out a whole team of background builders — splitting the work, gathering it back, sending reviewers over anything that isn't right. You watch the flames in the studio footer while it happens, and if the server blinks mid-build, the team picks up where it left off.

Characters and crowds.

  • Characters plant their feet on slopes and uneven ground instead of floating or clipping — and Savi can make them reach, grab, and look: "hand the player the sword" works with no animation files.
  • Ask Savi for a marching army or a festival crowd and the engine animates everyone sharing a model in one pass — the clip below runs 360 characters at once, each still walking at its own pace.

Making a mod is exactly as hard as making a folder.

  • Put your scripts in a folder with a small mod.json, and that folder is the mod — publishing ships every file in it, including teaching docs for Savi and slash commands for players, so there's no way to forget a piece and ship something broken.
  • Every game has a real mod catalog now: browse, search, like, comment, and install in one click — with public, unlisted, and private visibility, so you can soft-launch to friends or keep a mod to yourself.
  • Savi knows the catalog: ask her for a shop system and she checks whether another creator already built a great one before building from scratch.
  • Installed mods are starting points, not sealed boxes: ask Savi to change how one works and she edits it in place — your edits survive the author's updates, and uninstalling removes exactly what the mod brought.

Building in god mode. Editing by hand got quicker and more physical.

  • Spotlights place in two clicks: one for where the light sits, one for what it shines on — the beam follows your cursor between them.
  • Right-click anything for its dials: recolor a lantern from a palette, drag its glow radius, set a sound's volume and range, tune an effect's rate and size — all in place, no typing.
  • Click a house, get the house: selecting grabs the whole assembly instead of one plank, and clicking again drills down to the tower, the door, the doorknob.
  • Every brush erases: hold Alt and brush the extra daisies back out — the ring tints warm so you always know which mode you're in.
  • Paint with anything: turn any object — a mushroom, a crystal, a gravestone — into a scatter brush and sweep copies across the hillside.
  • Building makes sound now: placing, painting, erasing, and undoing each answer back with their own little sound.
›technical notes
  • New games (default-game-spec.ts) now ship scripts/look-default.js wired on via atmosphere.look on the default place. The script is a pass-through (look(ctx) → ctx.scene) that surfaces the engine's bloom defaults as live bloomStrength / bloomRadius / bloomThreshold params and carries the grade/vignette/grain dials as commented one-liners, so the rendered output is the engine baseline (bloom 0.15/0.6/1, neutral grade, no vignette) until a creator edits it.

  • The look pass's intermediate "finished frame" target is now HalfFloatType (was UnsignedByte), matching the scene pass's precision so a passthrough look no longer 8-bit-requantizes the tone-mapped frame before the sRGB encode — dark regions and bloom falloff stay smooth. A look-active frame is now indistinguishable from no-look apart from the look's own grade.

  • A base look's bloomStrength / bloomRadius / bloomThreshold params now seed the resolved bloom in the look resolver (clamped to the engine's bloom ranges), so they drive the engine bloom uniforms live with no recompile. Absent params, the resolved bloom stays exactly NEUTRAL (0.15/0.6/1); games that set no look are unaffected.

  • Displaced (subdivided) primitive buckets are re-enabled (PRIMITIVE_SURFACE_DISPLACEMENT_ENABLED = true). The per-column banding that forced the switch off is fixed at the root: the vertex-stage height mip now derives from the face's real UV span (baked per vertex from the geometry's own uv attribute into the displacement-direction .w) instead of assuming unit face UVs, so kind "box"/"wall" faces sample the correct texel density.

  • Displaced relief eases to exactly zero by the coarse bucket's enter distance (18→25 m, measured per instance with the same camera-to-translation metric the bucket pass uses), so instances parked in the 25–29 m hysteresis band render flat and the migration back to the POM lane never pops.

  • The per-instance relief amplitude is defined once in meters: the displaced lanes displace by it directly, and the POM march converts it to tile units with the same native/workspace tiles-per-meter blend its march UV uses — POM depth and displaced geometry stay one number, which is what the displaced→POM handoff parity rests on.

  • Gate: scripts/verify-primitive-pixels cases C6 (column-banding) and C7 (displaced-vs-POM handoff parity at 24/26/29 m) pass on a real WebGPU device; the switch may only be flipped in a change where both pass.

  • Known limit carried honestly: the Nyquist-correct vertex mip is a low-pass — fine texel-scale relief mostly disappears into the 8/16-segment grids on large walls, and the visible displaced relief is the broad height structure. Raising segment counts or amplitude is a named product follow-up, not a reason to sample the wrong mip.

  • Voxel terrain rebuilt end-to-end: grid substrate, binary mesher, same-tick edit fast-lane, region/structure edit API, durable per-region persistence, binary transport, GPU-resident terrain (#6622)

  • Networking stack replaced: new netcode transport, server-side lag compensation (history rewind for player-attributed hits), AOI bucketing + shared encode (#6674)

  • ECS rewritten on sparse-set columns: one write path, one change history, O(changes) drains (#6630); contract in ecs/contract.md

  • Humanoid IK: analytic two-bone solver, automatic foot grounding, updateIK ObjectAPI surface (#6667)

  • FX runtime: field-based effects substrate (.fx.js) + FxVM GPU compute interpreter — 1M+ particles with zero per-effect shader compilation (#6594, #6628)

  • Instanced GPU-skinned crowds: 8+ identical characters auto-batch; complex mixers demote to clone path (#6631)

  • Spawning-bubble lifecycle derived from authoritative state; cosmetic mispredicts no longer trigger resims (#6673)

  • Camera stairstepping root-cause fix: nominal-keyed smoothing alpha (#6650)

  • Cold-start TTI backbone: <1s warm path, overlapped container boot (#6670)

  • Mods: install/edit/private mods + Savi catalog skill (#6645, #6656)

  • Savi: view_live_scene (player viewport / free camera / framed object) (#6657); every authorable surface contained + reported on failure (#6672); run_weave deterministic orchestration (#6668)

  • Terrain client-build: event-driven chunk dirtiness, idle re-hash 2.5–3.4ms → ~0.1ms (#6637)

  • LightSpec (spot + directional) gains aim?: "down" | "forward" | PositionSpec. "down" = rotation·(0,−1,0) (identity points straight down, tilting tilts the beam), "forward" = rotation·(0,0,−1) for BOTH kinds (previously directional rotated +Z while spot rotated −Z), PositionSpec = beam points at the resolved world position (resolved once at apply time, terrain-y and tile forms supported).

  • aim omitted preserves the legacy heuristic exactly: straight down until the entity has any rotation, then per-kind local axis (spot −Z, directional +Z). Documented at LightAim in components/draw.ts.

  • God-mode light editor visuals (beam cone, sun arrow) follow the explicit aim, including PositionSpec aims localized through the anchor's rotation.

  • builtin/primitives spotLight accepts aim in opts.

  • RotationSpec docs now state loudly that lookAt defaults to include: ["yaw"] (swivels, never tilts) and point light-aiming use cases at aim instead.

  • Lighting v2: clustered forward+ lighting (ClusteredLighting / ClusteredLightsNode) — froxel grid + per-frame light data texture replaces the nearest-N point/spot selection (LIGHT_LIMITS); ambient/hemisphere/directional stay uniform-batched; shadow-casting/IES/node lights stay on three's per-light path. Renderer init switch lightingMode ("clustered" | "dynamic" | "default"), per-browser override localStorage["spawn.lightingMode.v1"].

  • Lighting quality tiers (low / medium / high / ultra): one LightingQuality config owns the froxel grid + light capacity, sun cascade count/size/max distance, and local shadow map size; the tier is guessed from the device at renderer init, per-browser override localStorage["spawn.lightingTier.v1"].

  • Sun shadows: cascaded shadow maps (SunCascadeShadow wrapping CSMShadowNode for manual matrixWorld updates) replace the single 4096² follow-camera fit on perspective cameras; orthographic (2D) places keep the single-map fit.

  • Sky-driven ambient: places that author neither ambient nor hemisphere get ambient derived from the sky instead of the flat defaults; explicit values and all-lights-off darkness setups are honored unchanged.

  • The legacy lighting path ("dynamic", 8/8/8/4 uniform-array batching) is unchanged and selectable per browser via spawn.lightingMode.v1.

  • Unified the sprite/particle blend vocabulary in engine/materials/sprite-blend-mode.ts; particle batches and the sprite paths now share one resolver, blend-state table, and color-output shaping.

  • Added "subtract" (ReverseSubtractEquation, SrcAlpha/One color, destination alpha untouched) to SpriteBlendMode and the particle BlendMode.

  • "screen" removed from the forward vocabulary; the literal stays accepted in the type unions and resolves to "add" at material-creation time (the OneMinusDstColor screen term darkens over >1 HDR destinations).

  • "alpha" now composites premultiplied internally: the node renderer premultiplies the material output after fog and blends One/OneMinusSrcAlpha. Visually equivalent for well-formed straight-alpha content, correct at filtered edges, and fog no longer needs the straight-alpha factor trick.

  • Fog only applies to "alpha" content. Additive/subtractive light no longer fades toward the fog color (which injected/carved fog-colored light), and multiply content no longer fog-tints the scene instead of fading to its white no-op.

  • Particle batch render order is blend-aware: alpha/multiply batches draw at order 100, add/subtract at 101, so glow no longer disappears behind smoke spawned later by accident.

  • Each particle batch now writes its packed-instance centroid into its matrixWorld/bounding sphere every frame, so three's transparent sort sees an honest per-batch depth instead of every batch sharing the identity origin.

  • Alpha and multiply particle batches get a per-frame CPU back-to-front sort of their instances against the camera; add/subtract batches skip it (order-independent).

  • Removed the dead material.toneMapped = false on particle batch materials (the WebGPU node renderer never reads it; tone mapping/grading happen in the post pipeline).

  • Repeat-textured batched primitives get default anti-tiling: a per-tile stochastic offset jitter (triangular-grid 3-tap blend, offset-only — no rotation/mirror, so brick courses can't ghost) plus low-frequency macro luminance variation (±6%), both ramping in with the effective repeat (smoothstep 2→4 on the dominant axis) and seeded per instance so identical neighbors decorrelate.

  • Channel coherence: one jitter resolve per fragment is shared by the albedo and normal samples (3-tap) and the POM march base UV (dominant-cell offset), so relief and color always agree on the tile variant. All taps use explicit gradients of the un-jittered UV — mip selection stays continuous across cell borders.

  • Identity contract: at strength 0 the whole feature collapses to exactly the plain sample (weights, offsets, and the macro term all converge by construction) — pinned by pixel case C9 (strength-0 render vs a compile-level off-baseline, bit-identical) alongside a detrended autocorrelation assert that the tile-period repetition actually breaks (0.82 → −0.05 on the staged brick wall) and a variance bound so the jitter can't shred textures.

  • Named follow-ups (recorded, not silently dropped): the standalone-mesh anti-tiled sampler and an antiTiling: false creator override (today a material carrying that override is simply batch-ineligible, which routes it to the unjittered standalone path — correct escape-hatch semantics until the standalone sampler lands); whether strongly embossed Patina normal sets want the jitter on their NRO maps is a taste question for the unify-configs pass.

  • Scripted TSL materials: material: { kind: "scripted", script, params } — material(ctx) builds a node material with require("builtin/tsl") / require("builtin/three"); source ships to the renderer worker via the MaterialScripts library component with a hash-keyed compile cache; ctx.param() values are live uniforms (no recompile on tweak); failures fall back to Std/PBR with scripted-material-* diagnostics; scripted materials are batch-ineligible. New custom-materials skill.

  • preview_object tool: isolated render of any object or material inside the live session, returned inline with a perf line (vs Std/PBR baseline). Supports whole hierarchies, frames filmstrips, params / paramsByFrame sweeps, studio / checkerboard / OKLCH-color backgrounds (auto-checkerboard for transparent materials), and a burned-in caption. Renders at the frame loop's safe point; failures return { ok: false } and never disturb the session.

  • The Look system: atmosphere.look = { script, params } (vibe pattern) — look(ctx) composes require("builtin/postfx") passes (grade, bloom, vignette, grain, film, chromaticAberration, dotScreen, scanlines, dof, pixelate, tint) and raw TSL; rendered as an isolated pass over the finished frame so a failing look can never break the main render (falls back to the un-graded frame + diagnostic); pushLook / clearLook runtime layers with fades; juice effect() / slowMo() / vignette() are now sugar over layers and finally render; patchAtmosphere({ look: null }) resets to baseline; the never-wired look-stack / DrawLook / renderlist plumbing was deleted. New looks skill. Known gap: the lut pass awaits Data3DTexture support in the asset service.

  • Frame-budget guard (default on): sustained low frame rate warns Savi via getLogs + DM naming the active scripted materials/look and pointing at preview_object; sustained worse parks the Savi-authored GPU work (materials → Std/PBR fallback, look → neutral baseline) and editing the script re-enables it. If the slowness isn't Savi-authored work, it only warns.

  • Renderer error containment: shader build-time failures and errors three swallows internally (THREE.TSL: channel, e.g. runaway node recursion) are captured, attributed to the offending material/look, parked, and reported (scripted-material-build-failed, scripted-look-build-failed, renderer-tsl-error, renderer-frame-loop-error); an exception can no longer silently stop the frame loop.

  • Sky v2: Hillaire-style LUT atmosphere (transmittance / multi-scatter / sky-view compute LUTs, rgba16f storage textures) drives the rayleigh/realistic skies under the new renderer switch skyMode ("physical" | "legacy", per-browser override localStorage["spawn.skyMode.v1"]). One persistent uniform-driven background node — day/night cycles and patchAtmosphere are uniform writes, never node rebuilds.

  • Night sky: baked milky-way equirect + analytic flux-conserving stars + procedural moon with phase shading; celestial layer fades in through twilight and is skipped entirely while the sun is up.

  • Cloud layer: wind-driven FBM coverage field lit as a participating medium (Beer–Lambert, powder, dual-lobe HG), rendered in the sky and in the IBL capture; skipped when disabled.

  • Sky-driven IBL: small cube capture of the sky (sun disc off) → in-place PMREM scene.environmentNode, throttled by sun-angle delta per tier. Gating matches lighting v2: authored ambient/hemisphere or the darkness convention force it to 0, and it is suppressed while the zero-authored-light fallback lights are engaged.

  • atmosphere-sync: timeOfDay/cycle drive the sun orbit, derived sun color/intensity, overcast dimming, moon handover, and sky-model fog color; patching timeOfDay during a running cycle scrubs the cycle.

  • Sky quality knobs (LUT sizes, capture size/throttle, night bake size) ride the existing LightingQualityConfig tiers.

  • Post chain: static analytic Bayer ordered dither applied after tone mapping (kills 8-bit banding in dark skies; worker-safe, no texture loads).

  • Magic CDN packed Patina variants: ?transform=albedo-height (basecolor.rgb + height.a, sRGB KTX2) and ?transform=nro (normal.xy + roughness.b, linear KTX2), composed on demand from a bundle's stored siblings and cached like every other derived asset.

  • Terrain height-aware PBR blending: heightmap terrain materials with pbr: true resolve packed albedo+height and NRO arrays in the layer atlas; splat transitions are sharpened by the materials' packed heights (depth-windowed soft-max, distance-faded), and the same weights drive per-layer detail normals, roughness, and metalness through one struct-shared TSL resolve. Non-PBR libraries compile the exact pre-existing shader.

  • Terrain relief: LOD 0 vertices add the blended material height (fading out by 40 m) on top of the heightfield displacement, and a near-field parallax march on the dominant layer adds texel-scale depth within ~18 m; shadows follow via positionNode; sim-side colliders are untouched. Both ease out on steep slopes, where textured layers instead blend toward a slope-facing side projection so texels stop stretching down cliff faces.

  • Primitive surface detail: height-mapped primitive materials get a per-lane gated POM march (8 steps default tier / 16 + self-shadow on high), a box-local silhouette cutout on the high tier (edges erode to the height surface), and near, large boxes on the high tier move to subdivided displacement lanes (real geometry + shadows). Per-instance height layer/scale ride the existing instance storage; lanes without the flag and the mobile tier compile none of it.

  • Quality tiers: high = desktop browsers on discrete or Apple-silicon GPUs; default = other desktops (8-step POM only); off = mobile/tablet.

  • pbr: true on a primitive/mesh material now derives the Patina height map alongside normal/roughness/metalness, and materials carrying the full Patina override set stay batch-eligible (per-texel roughness/metalness maps currently apply on the standalone mesh path; batched lanes use the scalar values — packing NRO onto the lanes is the recorded follow-up).

  • New textureScale on standard material specs (meters each texture tile covers, default 1): divides the automatic real-world tiling everywhere it's produced — batched primitive lanes (initial write, dynamic instance-scale rescale, and the oversized pool) and the standalone mesh/lease paths. An explicit mapRepeat still wins verbatim; textureScale is ignored alongside it and under clamp wrap. Stays batch-eligible.

  • The generated prompt teaches textureScale as the density knob and steers away from repeat (whose box-family semantics are repeats-per-meter, not the absolute counts Savi's three.js prior expects).

  • Bone-attached child entities (attachment: { bone }) are now a horde demotion signal, parallel to explicit IK channels. The horde skinned batch (#6631) replays baked GPU clip palettes and never registers a ModelBonePoseProvider, so once 8+ copies of the same model were in the scene, an attachment target could silently land on the batch — writeModelBoneTransform failed forever and the attached child parked at the world origin. New client-plane draw/bone-pose-required marker (maintained by tome/hierarchy-render-solve from the attachment hierarchy) forwards to the renderer, gates horde admission in createSkinnedRepresentation, and demotes an already-batched visual to the per-character clone via demoteHordeVisualToClone.

  • Renderer plumbing: draw/bone-pose-required op routed through the model handler to setModelBonePoseRequired / applyModelVisualBonePoseRequired, mirroring draw/ik.

  • applyVoxelTerrainEditToChunkEditsState is now O(1) amortized per single-cell edit instead of rebuilding the chunk's whole edits array (filter + per-entry bbox-key strings + full re-copy) on every apply — the quadratic sim-tick cost that killed sustained block-ticking voxel sessions (/zoo water + grass automata: sim ticks 13.9→40.5 ms, worker heap +4.5 MB/s). The apply path keeps a cell-keyed index (WeakMap) beside each edits array it allocates and mutates that array in place: same-cell edits replace their predecessor, new cells append, a cell whose command predates a later region command is moved to the end (preserving "later commands win" sampling). Retained state stays bounded by touched cells.

  • Ownership rule: only arrays allocated by the apply path are mutated. Foreign arrays — replication decode, prediction-rollback restores (which write oplog values into the world by reference), deserialize, region appends — are compact-copied once before first mutation, so prediction/oplog history can never be corrupted through a shared reference.

  • New getAppliedVoxelEditDelta(value): the single command a state value introduced. All TerrainChunkEdits set-hook consumers (block-tick neighbor enqueue, material-lookup overlay, voxel edit fast lane, edit-latency probe) read it before falling back to array diffs — required for correctness with in-place mutation (previous/value share one array) and removes the remaining O(edit count)-per-edit hook work (getNewVoxelEdits id-set build, fast-lane append-shape walk, probe seen-set rebuild).

  • Terrain chunk job inputs snapshot the edits array at creation (jobs/io.ts), so an in-flight job keeps describing exactly what its inputsHash hashed even if more edits land before it runs.

  • F3 prediction overlay: [pred] srv= (and Δ) now read the live server-tick source. The reads went through the interp clock's lastServerTick, whose feeders (onFullsync/notifyServerSnap) were deleted in the netcode merge (#6674) — the clock stayed at its initial 0, so srv=0 and Δ grew forever. Both consumers (updateAndPublishDebugOverlay, catchUpWorkerTickDrift) now read the newest server-oplog tick (base tick after a projection reset), which recordAuthoritativeProjection feeds on every StateDelta.

  • Worker tick catchup now sees real server ticks even before any input acks land (previously masked by Math.max with ack ticks).

  • The horde skinning bake (bakeHordeSkinnedModel) now closes clip palettes into a loop cycle for frame-1 exports — clips whose tracks are keyed from t=1/30 (Blender/Mixamo style) with nothing at t=0. Three clamps every interpolant to its first key inside the [0, firstKey) dead zone regardless of loop mode (WrapAroundEnding only affects cubic tracks), so the old [0, duration] sweep baked frame 0 as a duplicate of the first-key pose and never recorded the wrap bridge: palette loops froze for a frame and snapped at the seam. Dead-zone frames are now baked as the bridge from the wrap pose (end of clip) back to the first-key pose via per-node TRS lerp/slerp — frames[0] ≈ frames[frameCount−1], closing the cycle. Clips keyed from t=0 bake byte-identically to before.

  • Known parity nuance: loop: "once" channels on the horde path show the bridge pose for the first ≤1 bake frame (≤1/30s) instead of the clamped first-key pose; sub-frame and intentionally not special-cased.

  • The idle DRAW/TRI oscillation (e.g. 15M ↔ 25M triangles on a perfectly still village scene) was a gauge artifact, not remesh/LOD churn: renderer.info resets once per frame and accumulates the main pass plus every shadow pass that re-rendered inside the same render(), and the sun CSM far cascades stagger round-robin (cascade i every i+1 frames), so the per-frame pass composition — and therefore the raw totals — oscillates with the camera static. A new shadow-pass frame gauge (extensions/lighting/shadow-pass-gauge.ts) is fed by SunCascadeShadow.scheduleCascadeShadowRenders (each marked cascade) and ShadowAtlas._renderQueuedFaces (each atlas face render), consumed once per frame by the renderer frame loop, and carried on RendererInfoStats.shadowPasses / RendererStatsSample.shadowPasses.

  • Inspector attribution: the Performance tab metric grid gains a "Shadow passes" cell next to Draw/Triangles, plus a footnote stating that Draw + Triangles include every shadow pass rendered that frame and that GPU-culled indirect draws (terrain tile pools, decorations, voxel buckets) report triangles at full CPU-side instance capacity — GPU cull survivors never reach renderer.info, so the totals are an upper bound, not real drawn geometry. The stats-gl header gains a SHDW panel beside DRAW/TRI so the oscillation visibly correlates with shadow-pass count.

  • Telemetry-only — no rendering behavior change. The real GPU load behind the felt ~50fps (all-cascade collision frames in the stagger, per-frame decoration cluster-extent recompute) is tracked separately under the renderer perf work.

  • IK foot grounding no longer demodulates per-tick transform noise into leg motion. Every gate in the foot-grounding state machine was keyed to the instantaneous wrapper Y, so mm–cm frame-alternating noise (netcode correction blending, physics rest jitter) flipped the platform-mismatch verdict, sawtoothed the step-fade weight, and reproduced 1:1 in planted feet — visible as one or both legs shaking on world Y while the surface decided which gate sat near its threshold. World-keyed measures (terrain sample vs the body, plant anchors, the capsule contact plane, the stretch-release hips estimate) now compare against state.referenceY, an exp-smoothed (15/s), lag-clamped (5cm·scale) wrapper Y that snaps on teleport; clip-keyed measures (animated lift above the body) deliberately stay on the raw wrapper Y, where the noise cancels exactly.

  • The platform-mismatch gate carries per-foot hysteresis (±0.05·scale around FOOT_PLATFORM_MISMATCH) — one terrain↔plane verdict per surface, never one per frame.

  • The vertical-stability clock that gates the capsule-plane fallback runs on the reference height's derivative instead of raw dy/dt. At high frame rates, ±2mm alternating noise used to read as ~1 m/s and permanently starve the fallback (feet never grounded on physics surfaces); real motion still trips the clock within a frame via the lag clamp, and a jump apex still cannot engage it.

  • A locked anchor tracks ground-height changes at a bounded speed (FOOT_ANCHOR_TRACK_SPEED, 1.5 m/s·scale) instead of copying the sample verbatim — a one-frame sample step (LOD swap, terrain↔plane branch change) ramps a planted foot instead of teleporting it.

  • Regression tests drive frame-alternating wrapper noise at each gate (mismatch boundary, step-fade edge, plane fallback at 240fps) and a one-frame ground step under a planted anchor.

  • Analytic stars (sky-node.ts) now carry a wide low-amplitude gaussian halo (bright layer, 6× sigma at 0.06 amplitude — the moon's pattern). Stars previously had no analytic glow term and relied entirely on engine bloom, which they almost never reached: at threshold 1.0 / strength 0.15, a 1-2 px sub-threshold core contributes zero, and even an over-threshold peak dilutes to nothing through the bloom mip chain. Bright-layer radiance also raised 1.3 → 2.0 so the top-percentile stars clear the bloom threshold and pick up real bloom on top of the halo.

  • The per-pixel starlight grain over the milky-way band is now footprint-aware: it fades back to the smooth bake as the pixel footprint approaches the finest noise octave's feature size (560/rad ≈ 0.1°, ~1 canvas px at game FOV — at/below Nyquist it could only alias into per-pixel speckle). The factor is mean-1.0, so authored band brightness is unchanged.

  • Moonlight star washout softened 45% → 25%: the default night always has the moon up (moonDirection defaults to −sunDirection), so the heavier cut dimmed every star on every default night.

  • Client spec-sync (spec-sync.ts) no longer consumes a TomeSpec revision before applying it. Previously syncState.revision was recorded before the applySpec try/catch, so a throw was swallowed and that revision skipped forever — on a fresh join (whose snapshot carries exactly one revision) this left the client with no terrain/atmosphere while server-replicated objects rendered fine, until the server happened to bump the spec again. A failed apply now retries the same revision on a 60-tick backoff and self-heals.

  • Server spec-update system now preserves dbVersion when rebuilding the TomeSpec value (mirrors object-api). Dropping it disarmed room-runtime's stale-echo guard (existingVersion: null in logs), letting a late replace:true room.update reconcile a live world down to an older DB spec.

  • Kernel-server fetches to kiln /magic/* (glb-bounds prefetch, server model warm, collider hull extraction) now authenticate with the variant integration API key + x-variant-id; kiln's magic-cdn auth accepts that pair and scopes serving/generation to the variant's app. Previously these fetches were unauthenticated and 401'd on any asset still mid-generation, starving bounds metadata (mis-grounded placement), animated-model warming, and the bounds-driven spec-revision bumps during every build window. Headers attach only on absolute /magic/* URLs when the SDK env is present — browser fetch paths are untouched and the key cannot leak to third-party hosts.

  • createVoxelMarkSampler no longer allocates string-keyed per-voxel Maps (${x}:${y}:${z} key plus two fresh closures per cache miss). Per-stage material memoization is now a flat palette-interned Uint16Array indexed by extent-local voxel position (extent = chunk bounds + margin, full vertical range), allocated lazily per stage and dropped with the job. On mark-heavy chunks the old caches drove jobs-worker isolates to 531MB-2.05GB (measured) and OOMed the renderer process.

  • Surface resolution is memoized per (column, stage). The old cache key included the polyline-interpolated approxY (${x}:${z}:${round(approxY*1000)}), so near-miss values re-scanned entire vertical columns through the recursive stage stack — the O((2H)^k) blowup for k stacked path marks. Each stage is only queried by the path mark directly above it, whose approxY is a pure function of the column, so the column alone is a sound key. The per-mark cross-job surfaceCache (unbounded growth for the lifetime of a definition revision, shared across chunk stage stacks) is deleted; the per-job memo replaces it.

  • Mark application contexts are built once per stage instead of per cache miss; re-entrancy only descends to strictly lower stages, so per-stage mutable contexts cannot clash. Dead samplePreviousMaterial / sampleBaseMaterialId context fields removed.

  • Hard per-job work budget: extent cells x (stages + 1) x 2 — a generous ceiling no correctly-memoized job can reach. A chunk that exceeds it logs one structured [terrain/voxel/marks] warning and degrades to base terrain sampling (no marks) for the rest of the job instead of pinning a jobs-worker isolate forever. Jobs terminate. Extents too large for flat caches (cells x stages > 2^26) sample uncached under the same budget.

  • Tests: pathological chunk (12 overlapping full-height path marks) completes inside the budget with marks applied; memoized chunk sampling matches the uncached sampler voxel-for-voxel on a mixed-marks case; budget exhaustion warns exactly once and falls back to valid base sampling. NPC-AI V1 — engine agents with pathfinding, steering, perception, and locomotion (design: docs/npc-ai-design.md).

  • New properties.npc (world-model params only: speeds, turn rate, vision, hearing, step capabilities, radius, clip overrides). Presence makes the object an agent: no rigid body (kinematic trigger hurtbox by default — kills the per-NPC character-controller cost), terrain-snapped, engine-driven locomotion.

  • New ObjectAPI verbs: moveTo(target, { speed, range, face, avoid, exact }) → { arrived, blocked, unreachable, distance }, flee, wander, face, canSee, nearest, makeNoise + onNoise behavior hook, seconds(n), and the low-level surface findPath, isReachable, navAt, steer({ interest, danger }), groundAhead/wallAhead/gapAhead.

  • Lifetime rule: movement intents decay unless re-asserted each tick (stop-by-not-calling, hot-swap idempotent); face/steer decay independently.

  • Nav substrate: lazy walkable-column grid per place (heightmap heightFromDefinition, voxel column surface, 3d-rooms parsed tiles incl. walk-through doorways, static primitive colliders rasterized — wedges as ramps), chunk islands with union-find (O(1) unreachable), invalidated by terrain-edit version + static-prim signature. Deterministic integer A* (verified bit-identical across JSC/V8), expansion-quota budgeted per tick, entityId-ordered, memoized via the replicated tome/nav-agent component (completedAtTick keeps resim replays observation-stable).

  • Movement: 16-slot context-steering resolver (interest/danger maps; ~50–70ns/agent — no LOD needed) + octree separation + collide-and-slide mover with step-up/max-drop; stuck watchdog (1.2s no-progress → one auto-repath → blocked).

  • Locomotion felt layer: rate-limited facing + idle/walk/run mixer channel with EMA/hysteresis/anti-slide playback scaling (absorbs the deprecated Animated3DCharacterLocomotionFeature machinery), writes DrawMixer without stomping script-authored channels.

  • Perception: canSee (distance² → wide-near/narrow-far cone → single chest-point ray over voxel/rooms/prim geometry — no Rapier dependency), 1.3× lose-sight hysteresis, deterministically staggered re-rays cached in tome/npc-sense. makeNoise → server-only onNoise dispatch (physics-dispatch precedent), scaled per listener by npc.hearing.

  • Script-space game conventions (BUILTIN_MODULES, forkable): builtin/combat (health/damage convention with attacker attribution via hurt events + default juice), builtin/claims (TTL-decay token grants + runCoordinator for a realm:"server" coordinator script — attack tokens/roles/jobs in one keyed surface), builtin/barks (anti-spammed chat-bubble barks).

  • God-mode chips: npc RULES entry + vision-range drag-ring (falloffRangeHandle pattern) + speed/turn/hearing preset menus; npc added to UNDO_ENVELOPE_KEYS.

  • New npc skill (≤3K authored tokens) carrying the FULL verb teaching + the canonical zombie example; the always-on prompt keeps only properties.npc as a breadcrumb and seconds() (platform-wide) — the npc surface is net NEGATIVE inline (~−440 real tokens vs pre-npc baseline). New zoo brains section folded into the Playground zone (x +90 lane): chase-around-obstacles, kiting archer, 12-agent separation, leash, noise/investigation, 60-agent perf pen.

  • New components: tome/npc-agent-cfg, tome/npc-intent, tome/nav-agent, tome/npc-motion (replicate aoi, mismatch-included), tome/npc-sense (replicate aoi, mismatch-EXCLUDED — per-side stagger stamps), and tome/npc-assert-beat (replicate never — the lifetime-rule heartbeat is side-local; absolute behavior-clock ticks never enter replicated state, so per-tick stamps can neither trigger rollbacks nor cost egress; decay grace 2 ticks absorbs the known ±1 server/client behavior-clock skew). Group claim grants live in coordinator entity state.

  • New systems: tome/npc-nav-solver (both, 105), tome/npc-move-resolve (both, 110), tome/npc-locomotion (both, 10001), tome/npc-noise-dispatch (server, 116).

  • New routing spec section — routing: { script, maxPlayers } names a script exporting pickRoom(ctx) that picks the room a joining player lands in (return a roomId string; a name that doesn't exist yet creates that room). maxPlayers is the per-room cap enforced at connection accept; full rooms reject the join and the client re-picks. pickRoom is its own hook kind: once per join, off-tick, async-allowed, non-deterministic-allowed, no ObjectAPI/world access; null/throw falls back to the player's last room or the default room. Compiled via compileRoomRoutingHook; ctx types live in src/tome/room-routing.ts.

  • New builtin/room-routing module for routing scripts: firstOpenRoom(ctx) (last room → most-full open room → fresh room), mostFullWithSpace(ctx), nextRoomName(rooms, exclude?), roomFor(key) (stable key → room name).

  • Storage jobs gain cross-room data access for server lists, queues, and per-server saves: storage:list (prefix), storage:query (where/sort/limit), and storage:lock / storage:unlock — server-side spec code only, same calling convention as storage:get.

  • updateObject (interpreter.ts) re-applied AUTHORED feetPosition/rotation/yaw/scale over LIVE state whenever def-level propertiesChanged was true — any unrelated property edit (visible, material, model, behavior re-save) snapped the entity back to its authored transform. Behavior-swung doors closed on every Savi save; runtime-moved objects teleported home on any def touch. Each transform re-apply is now gated on that key's own diff (prevDef vs def via specValuesEqual); a missing prevDef (snapshot restore, spline re-loft children) keeps the old re-apply-authored behavior.

  • rebindExplicitParentObjects (interpreter.ts) ran on EVERY applySpec and EVERY player join (spawnPlayer), re-attaching every explicitly-parented (parent: field) object and resetting its entire local transform to the def values — {0,0,0} local when the def carries no feetPosition (attachEntityToResolvedParent). A village whose houses were laid out at runtime (setLocalPosition and friends, which don't record into the spec) stacked at the parent origin on the next unrelated edit or join. Rebind now skips children already attached to their resolved parent unless the applySpec diff shows the authored parent/feetPosition/rotation/scale/pivot/attachment actually changed; the player-join path (no prev defs) only attaches not-yet-attached children (its actual job: binding parent: "player" objects to the fresh player entity).

  • terrain/client-build no longer re-evaluates its entire unsubmitted dirty backlog every tick. At connect, extended desktop streaming profiles mark 2,500-4,289 resident chunks dirty at once; Phase 2 then re-ran edit aggregation + the composed-field version walk + inputs hashing for every one of them on every tick while the 64-slot pending budget drained at ~64 chunks/tick — 324 ms mean (494 ms max) sim ticks for ~12-16 s, sim at 2-3 Hz (traced: getComposedFieldChunkVersionsInBounds 5.9 s + chunkKeysInBounds 1.2 s of a 14.2 s capture). Phase 2 now orders dirty chunks by streaming priority (player-proximal first) BEFORE any hashing, evaluates at most 2× free pending capacity per tick, and skips entirely when the pending queue is full. Per-tick Phase-2 cost is O(free slots), never O(backlog).

  • Dirty marks now carry a monotonic per-side stamp (terrain-dirty.ts), and the client build system memoizes each evaluated inputs hash keyed on that stamp: a dirty chunk waiting for pending capacity is hashed exactly once per mark, never once per tick. Any observable input event (edits, field writes, wantedLod retarget, definition install) re-marks with a fresh stamp and voids the memo, so in-flight and queued builds keep hashing exactly what they describe. The "idle loaded place performs zero hash computations" invariant is unchanged and still pinned by tests.

  • getComposedFieldChunkVersionsInBounds iterates min(rect cells, painted chunks) instead of probing every rect cell, and the field-chunk index now stores pre-parsed refs (kx, kz, entity id) so the walk does zero per-cell key parsing or entity-id string building. Output is byte-identical to the dense rect walk (same entries, same rect-scan order — it feeds the terrain inputsHash) on both iteration strategies, pinned by parity-oracle tests; the existing heightmap build golden fixtures pin output/outputsHash stability end-to-end.

  • Physics stable-entity invalidation for a rebuilding chunk moved from "every tick the chunk sits dirty-mismatched" to submit time — once per actual rebuild.

  • Server build system unchanged: it already selects by priority and hashes inside its pending-capped submit loop, so it never had the O(backlog × ticks) shape.

  • tome/fx-compile no longer re-derives every fx entity's compile key — entry source hash plus tracked import-closure hash — on every frame and every resim replay tick just to compare it against the stored key. On an fx-heavy game (DD5 #69, 363 leaked combat fx) that pure key-recomputation measured 4.44 s self-time in a 21.5 s capture (60% of the sim worker, ~205 ms/s) with zero actual compiles the whole window, and 1.65 s of it sat under the tick fn so resim multiplied it. The key is now memoized per entity on what actually invalidates it: the spec.scripts record by identity (every script edit replaces the record — ObjectAPI script writes spread a new one, client applySpec installs a structuredClone; nothing mutates it in place), the FxEmitter.script ref, FxEmitter.version, and a new compiler cache epoch. Steady-state frames do an O(1) identity/version check per fx entity and zero source hashing; the hash work runs only on the frame a script, import, or fx definition actually changes.

  • New getBehaviorCacheEpoch() in tome/compiler.ts: a monotonic counter bumped by clearBehaviorCache/invalidateBehaviorCache. Those resets clear the dependency-tracking cache behind getTrackedDependencySignature, which changes compile keys without the spec's scripts record moving — the epoch folds those resets into the memo's validity check so a cache reset still triggers the same one-shot recompile pass it always did.

  • All previous recompile triggers are pinned by tests with an fnv1a32 call-count probe (fx-compile-memo.test.ts): entry script edit recompiles exactly once, lib/import edit recompiles, FxEmitter.version bump recompiles only the bumped entity, broken scripts fault once and stop hashing while broken, content-identical spec replacement re-keys once but compiles nothing, a resim window replays with zero hashing, and a compiler cache reset recompiles like before. All eight tests fail on the parent commit at the zero-hash assertions (parent: 36 hashes/frame for 12 entities) while every behavior assertion passes on both — semantics unchanged, cost removed.

  • Ported LightAimSchema ("down" | "forward" | PositionSpec, mirroring LightAimSpec in cf-kernel/src/tome/types.ts) into packages/tome-schemas and added the optional aim field to SpotLightSchema and DirectionalLightSchema. The engine shipped aim in #6653 but the Zod schemas never learned it, so any GameSpecSchema parse round-trip silently stripped the field (Zod v3 drops unknown keys from parse output) and validate_spec accepted malformed aim values. Runtime defaults untouched: aim omitted still means the legacy rotation-presence behavior (down until the object has any rotation), resolved in the renderer.

  • Regression tests in apps/cf-kernel/src/tome/__tests__/spec-schema-light-aim.test.ts: aim survives spec parse round-trips (root objects + places), every engine-accepted aim shape validates, no-aim specs stay legacy, malformed aim is rejected.

  • patchAtmosphere / patchTerrain accept every type-distinguishable (patch, place?) call shape: (patch), (patch, "main"), (patch, { place: "main" }), and string-first ("main", patch) (the string is the place, the object is the patch). Normalized by normalizePatchPlaceArgs in object-api.ts.

  • Caller argument-shape errors on spec-mutation methods now THROW a teaching error instead of mutationWarn + silent early return (run_script still reported {ok:true} while nothing changed — the debug-day 5.0 "washed terrain" / "spec adjustments not saving" P0). Converted sites: patchAtmosphere/patchTerrain (malformed patch/place, no-supported-fields), patchPlayer (Zod root-shape rejection, previously warn+notifyDmOnce), definePlace/updatePlace/deletePlace (invalid : place ids), setDefaultPlace (empty id), enterPlace (missing options, invalid place ids).

  • State/environment guards (entity missing, place doesn't exist, physics runtime absent, client/server gating) keep mutationWarn — and the warn → run_script result-logs round trip now has regression coverage in script-dispatch.test.ts.

  • @spawn/tome-schemas: added the missing { type: "grid", cellSize?, radiusCells?, axes? } AOI variant to engine.networking.aoi, matching AOIConfig in types.ts. The engine's default spec ({ type: "grid", cellSize: 128, radiusCells: 4 }) failed its own schema, so every validate_spec carried permanent "engine.networking.aoi: Invalid input" noise.

  • api.job() and api.triggerPurchase() now mint their request ids from a side-local, non-replicated counter (TomeRequestIdCounterResource, mirroring the lifecycle lane's lifecycle-${n} precedent) instead of this.uniqueId() on the player's replicated tome/id-seq. The job/purchase registries are server-only and the ids never need cross-machine minting equality, but jobs commonly fire from timer/lifecycle contexts whose fire ticks are structurally skewed across client and server — so every storage heartbeat (runInSeconds loops calling storage:set/storage:list, the most common Savi storage idiom) was a guaranteed recurring tome/id-seq mispredict on the player (the Δp50=2 / Δmax=3 / 1.4% pattern from the 5.0 debug-day report).

  • The boundary is now named in code (above nextOwnerIdSeq in object-api.ts): replicated seq (TomeIdSeq) = ids that cross the wire or name shared entities (spawn/joint/structure — unchanged); side-local counter = request/correlation ids one side mints and the other never re-mints.

  • The counter is a registered predicted resource, so discarded predicted timelines re-mint the same request ids on replay; it resets with the world in resetTomeWorld.

  • New Session Lab grind scenario wrapper-embed/id-seq-heartbeat-grind.json: the distilled heartbeat repro (3s leaderboard set+list, 15s autosave), asserting zero post-warmup present/unknown mismatches.

  • Fixed the analytic star halo truncating at its grid-cell border (sky-node.ts starLayer). The wave-1 halo (sigma = 6× core, ≈ 0.27 cell at the bright layer's 0.0015 rad sigma floor) reaches past the per-cell evaluation boundary, so the brightest stars showed a hard square clip edge where the halo met the cell border. The footprint now fades to zero across the outer quarter-cell (smoothstep on cell-local border distance — branchless, no select), so brightness lands at exactly zero where evaluation hands off to the neighbor cell. Neighbor-cell sampling was rejected: the grid is 3D over the view direction, so honest cross-border halos need an 8–27 cell neighborhood per sky pixel per layer (also paid by the IBL capture) for a 6%-amplitude tail.

  • Visual-constant sanity notes: star jitter is ±0.15 cell, so every peak sits ≥ 0.35 cell from any border — the fade band (0 → 0.25 cell) never touches a core or inner halo, only the outer tail of the few stars bright enough for their halo to clear the night floor (halo at the border carried up to ~0.4 of its peak ≈ 0.1 radiance against a ~0.01–0.1 floor — the visible edge). Bright layer: fade band ≈ 8 mrad ≈ 8 px at game FOV — a smooth multi-pixel rolloff, ~0.9 halo-sigma from a centered star. Faint layer (halo 0): fade only matters in the pixel-clamped-sigma regime (low res / wide FOV), where it stops cores from drawing dashed cell borders; at the 0.0011 rad sigma floor the core is at e^-6 by the fade band, unchanged. Applied to the whole footprint (core + halo) — one multiply, and the invariant is uniform: per-cell star evaluation reaches zero by its cell border.

  • Added sky-node-wgsl-dominance.test.ts: transpiles the production sky background node through the real WGSLNodeBuilder and asserts no execution path reads a node var before assignment (the select/toVar miscompile detector, now covering the sky shader).

  • Time-sliced terrain chunk batch builds (features/terrain/jobs/chunk-build.ts): terrain/chunk-batch-build no longer builds its 8-chunk batch as one synchronous task. Heightmap/rooms batches build one chunk at a time and yield the jobs worker's macrotask queue whenever the running slice exceeds TERRAIN_CHUNK_BATCH_SLICE_BUDGET_MS (8 ms), so an expensive custom heightAt/materialAt script (~2.9M noise evals per batch in the reported game) produces ~one-chunk slices instead of 200-250 ms worker stalls. Cancellation is checked between slices (a canceled job stops burning the worker). Voxel batches keep the fused cross-chunk batch mesher. Outputs are bit-identical — slicing only moves task boundaries.

  • Walk-speed streaming lookahead (features/terrain/streaming.ts): LOOKAHEAD_MIN_SPEED 8 → 4 m/s. The default walk speed is 6 m/s, so walking players previously got zero lookahead and every 32 m chunk-boundary crossing started a cold 15-30 chunk build burst exactly when the new ring became visible — the "fps drop every 5-10 s while moving" cadence (32 m / 6 m/s ≈ 5.3 s). Walkers now pre-request the ring 1.5 s (9 m) ahead of the crossing, on both client and server streaming.

  • Renderer per-frame chunk install cap (renderer/handlers/terrain.ts): height-chunk visual installs (pool slot binds + height/blend texture-layer writes) are capped at HEIGHT_CHUNK_INSTALLS_PER_FRAME = 4 per collect. Pending installs persist across frames (FIFO) instead of landing as one ingest train — a batch arrival no longer stacks multi-ms of adapter work plus that frame's node-material builds onto a single rAF. Despawns and component removals still apply immediately; only visual installs ride the queue. Place-render-config re-keys ride the same cap, so a material-config edit updates resident chunks progressively over a few hundred ms instead of one heavy frame.

Named follow-up (deliberately not shipped here): a batched noise sampling API for custom terrain generators (fill a typed array per chunk per noise field instead of per-sample option-object calls) to kill the ~120k allocations/chunk feeding job-worker scavenge storms and the render-worker major GC. The seam is the single heightmap pipeline resolution point (TerrainConfigLike in chunk-build.ts + terrainNoiseRuntime in program/noise.ts); it needs a Savi-facing vocabulary decision, not just plumbing.

  • F3 perf tab: the systems list (text overlay + advanced panel) now leads with the already-collected recent-1s window (recentMsTotal as ms/1s, recentMsMax as worst run, recentRuns) and sorts by it; the session-cumulative average stays as a secondary column. Previously rows showed msTotal/runs — a session average that diluted burst systems (e.g. terrain/client-build at 36-207ms/run while walking) to invisible while the unattributed "Frame Work" gauge climbed.

  • New frame-work attribution: "Frame Work (1s)" (rolling 1s window of sim-worker CPU, same clock basis as system recents) is broken into named sub-rows — ingest [netIngest], sim (main), resim, input, interpolation, render egress (ecs-sync), renderPrep (rest), messages (decode/apply) — plus an honest other remainder (Frame Work minus attributed, clamped at zero) covering GC, scheduler overhead, and egress posts. computeFrameWorkBreakdown in engine/client/perf-debug.ts is the pure attribution function; the worker tracks the 1s frame-work and onmessage-busy windows in runtime-worker.ts.

  • Restored the singleplayer mode deleted wholesale by the #6674 netcode merge, rewired onto the new room protocol instead of resurrecting the old host.ts ctrl channel:

    • glue(features, "singleplayer") is back: server-only systems run client-side (preUpdate→netIngest, postUpdate→interpolation, replication-phase dropped, client wins name collisions).
    • Mode resolution flows from the spec again: restored NetworkingModeTransitionResource + spec-sync syncNetworkingMode; the runtime worker's transition handler reconfigures the live client via client.reconfigure(glue(features, side)).
    • Prediction is a real mode gate now: singleplayer sets ClientPredictionEnabledResource=false (mismatch comparator, oplog capture and resim are all gated behind it — zero mismatch ticks, zero resims, structurally). Local input apply and client physics stepping treat the singleplayer client as the authority instead of early-returning when prediction is off.
    • State deltas are dropped wholesale on singleplayer clients (applyWireMessage gate) AND the room projection is suspended (setProjectionActive(false), the same protocol used for hidden tabs) so the server stops streaming them at all. The socket stays for control traffic.
    • The spec rides the new Control opcode: ControlToClient gains tome.spec.push, tome/spec-sync-server pushes revisions through the restored TomeSpecPushNotifierResource into every connection's control outbox, and the worker's applyWorkerSpecPush applies them (accepts when the world is singleplayer or the pushed spec declares it).
    • Singleplayer ↔ multiplayer transitions work live: entering suspends the projection, sweeps stale remote players and clears prediction state; leaving resumes the projection, which makes the server answer with a resetProjection snapshot that rebases the world (the new stack's fullsync).
    • Restored the three authority remaps (getWorldMode in object-api/query-utils/interpreter: singleplayer client → "server" for mutation guards), the physics hasAuthoritativeSpec short-circuit, ensurePlayerPhysicsComponents on the local player, the per-tick stale-remote-player sweep, and the networking.mode bullet in the patchEngine docs.
    • Server-rails jobs ride the control channel too: with server systems running client-side, builtin tome jobs (storage:*, llm:*, sparks:claim-verify) were landing in the client job pool, where storage adapters and credentials don't exist — saves went to IndexedDB instead of the room's cloud documents and llm calls threw on stub envs. withSingleplayerJobForwarding wraps the client JobsHandle so builtin tome/run-job submissions on a singleplayer client send RoomClientOpcode.JobRequest (ref-correlated) instead; tome/job-forward-server executes them on the room's normal job pool and replies with a job.response control message. The forwarded token settles through the normal JobsHandle surface, so both api.job() callbacks (tome/job-response) and direct system submitters (terrain-edit persistence) work unchanged, with a client-side timeout so a lost reply fails loudly instead of hanging the behavior. Custom spec jobs stay local; multiplayer clients never forward (per-submission gate on the spec's networking mode), and the server rejects forwarded jobs from non-singleplayer rooms.
  • Terrain decorations honor their spec contract again — the renderer-deslop GPU consumer (decorations.ts) had silently dropped typed, skill-taught fields while Savi kept writing them into every spec:

    • sink is back: card geometry seats into the terrain by sink × height (sprite default 0.15 per the documented contract; negative still floats butterflies/fireflies). Primitives gained the same seating (sink added to DecorationItemPrimitive, default 0.1 of shape height) — fixes decorations hovering above hills where the placement sample diverges from the rendered mesh on slopes; composes with the content-rect anchor so the content's bottom row is cropped by the ground ("crop with slight sink").
    • height/width are per-instance ranges again: a world-stable hash2D(instanceXZ) positionNode (same recipe as the primitive variation path) sizes each card in [min, max] instead of collapsing everything to the midpoint — restores the top half of every authored size distribution (the "decorations are much smaller than before" report). Sprite width falls back to the authored height range, not a [0.3, 0.5] constant. Sprite randomRotation is honored (per-instance yaw in the same node). Shared cull bounding sphere inflated by the max variation factor.
  • No loading placeholder: sprite decoration meshes stay visible = false until their texture binds AND the alpha-content rect resolves (the measurement always resolves — failures report null) with the UV anchor applied. Cache hits reveal synchronously; the never-ready case stays hidden instead of drawing bare white crosses that take over the landscape at density.

  • New decorations-card.test.ts pins the layout/sink/variation contract and the texture-gated reveal flow (sync, late-bind, null-rect, no-provider).

  • Lifted the hard DPR-1 renderer cap: MAX_RENDERER_PIXEL_RATIO is now 2 (the absolute ceiling the host reports), and the device's quality tier owns how much of the reported DPR it renders at via the new LightingQualityConfig.maxPixelRatio knob — desktop tiers (high/ultra) take 2, mobile-class tiers (low/medium) stay at 1. The renderer worker applies the tier clamp at init and on resize; the host (getDefaultRendererPixelRatio) just reports window.devicePixelRatio capped at the ceiling. Mobile and tablet devices land on medium/low through guessLightingQualityTier, so phones keep rendering at CSS resolution.

  • Perf note — approved trade: HiDPI desktops (Retina Macs, 4K displays at DPR 2) now fill 4× the pixels of the old DPR-1 path (2× per axis), which roughly doubles fill-bound GPU frame cost on those displays. This was approved as a deliberate quality-for-fill trade on desktop-class GPUs (debug-day 5.0 task #53); low-end desktops are protected by the tier ladder (software rasterizers land on low → DPR 1).

  • History: this lift first shipped inside the sky v2 PR (6b9e7418d, dcde71bea) and was reverted in review (4ab67228e) as an unmeasured rider — "the lift can return as its own measured PR". This is that PR, re-landing the reviewed tier-knob design with the approved values (the earlier attempt's high-tier 1.5 becomes 2 per the approval: DPR 2 on all desktop tiers).

  • Sun cascade scheduling (SunCascadeShadow.ts): the force-all-cascades-on-any-sun-motion epsilon (1e-9 squared chord — below one texel of every cascade) is replaced by two thresholds derived from the actual cascade texel geometry in fitCascadeDepthRange. Sub-texel moves (below min(texelWorld / shadowCameraFar) across cascades) are ignored and accumulate; smooth motion (an animated timeOfDay cycle) rides the existing round-robin stagger, so every cascade refreshes within its period instead of all 4 ultra 4096² cascades re-rendering every frame; only a single-frame jump past ~8 texels on the most sensitive staggered cascade (a discrete timeOfDay change) forces a same-frame full refresh. On an ultra rig with an animated sun this drops sun shadow passes from 4.0/frame to the stagger's ~2.1/frame average.

  • Sky day/night + cloud gates (sky-node.ts): the night stack (moonlit wash, moon disc maria/craters, milky way, analytic stars, starlight grain — ~13 noise octaves plus the star hash chains per pixel) ran branchlessly at noon multiplied by zero; it is now behind a uniform-flow If (nightVisibility > 0), and the cloud layer (~8 octaves) behind If (cloudsEnabled > 0.5). Every expression crossing a branch boundary is materialized as a top-level toVar (the select/toVar r0.184 lowering hazard); a new WGSL structure suite pins that all entry-body noise calls are if-gated and the dominance analyzer stays green. Pixel output is unchanged — the gated terms were exactly zero whenever the gates are closed.

  • Cloud FBM octaves now come from the lighting tier (sky-quality.ts cloudBaseOctaves/cloudDetailOctaves): low 3+2, medium 4+2, high/ultra keep the original 5+3.

  • GPU timestamp timing is inspector-gated (debug/gpu-timing.ts, renderer.ts, renderer-host/browser-init.ts): the renderer worker's StatsProfiler was created with trackGPU: true unconditionally, so three attached timestampWrites to every render/compute pass (per-pass Metal counter-sample buffer allocations + validation in the GPU process) for every player with the HUD closed. The worker now keeps backend.trackTimestamp off by default and flips it via a set-gpu-timing message wired to the renderer inspector's stats-HUD embed signal; while active the frame loop resolves the timestamp queries so the HUD's GPU/CPT numbers are live, and disabling zeroes the last resolved durations. Consequence: the 15s perf-telemetry rollup carries gpuFrameMs only while an inspector is open, and the frame-budget guard sees gpuMs: 0 (its documented timing-unavailable state) unless someone is measuring.

  • engine-reference/ is now version-matched to the room's kernel. The version catalog (scripts/publish-versions.ts) carries a per-semver docs.engineSource URL; kiln's /api/studio-chat/engine-source accepts ?engineVersion= and resolves it through the catalog, reading the artifact private-first from the spawn-engine bucket (the public bucket stopped receiving engine-source artifacts on 2026-05-08 — every read through the public manifest chain served the May 8 snapshot since).

  • Skew fails loudly end to end: a pinned engine version that doesn't resolve in the catalog is a 404 naming the version — never a silent serve of some other build's source — and Savi's grep/view of engine-reference/ reports "engine-reference/ is unavailable" with the reason instead of "No matches". A missing reference must never read as "the engine has no such API"; that misread is how the debug-day day/night session went sideways.

  • cf-studio-chat caches engine source per engine version (small LRU, the decompressed maps are ~25 MB each) instead of one global map shared by every app on the DO.

  • Atmosphere read-after-write (debug-day 5.0 task #76, C1). patchAtmosphere writes the override layer + replicated components, never GameSpecResource, so a same-script getSpec("places.*.atmosphere") read-back returned the OLD atmosphere and produced false "my patch was rejected" spirals (patchTerrain, by contrast, updates the spec resource synchronously). getSpec now reads the atmosphere portion through PlaceAtmosphereMergedResource (specWithLiveAtmosphere in object-api.ts) — authored base + runtime patches + live cycle hour — so getSpec and getAtmosphere() agree and a read immediately after a patch reflects the patch. The chosen read model: getSpec is the LIVE spec (the persisted spec converges to the same atmosphere via the recorded patchAtmosphere mutation; the live view additionally carries runtime cycle-anchor state and the live timeOfDay hour). Non-atmosphere reads are unchanged. getAtmosphere(place?) also gained an optional place argument so a patch targeted at another place can be read back symmetrically.

  • physics.body enum validation (C3). The spawn validator only checked body presence and the property writer passed any string straight through to the physics layer as bodyType — a typo'd body spawned visibly fine with no collider. PROPERTY_VALIDATORS.physics now validates the body against the accepted enum ("none" | "static" | "dynamic" | "kinematic" | "character" | "vehicle") with a did-you-mean map for the names models keep guessing ("fixed" → "static" — the RAPIER name — plus case fixes and rigid/kinematicPositionBased variants), and propertySetters.physics throws the same teaching error on update paths (setProperty/setObjectProperty/batch) instead of silently removing physics. Spec application at load keeps its own non-throwing path — existing persisted specs with bad bodies still load.

  • spawn parent-in-properties auto-hoist (B1). The teaching throw (#6563) provably does not stop wisp retry loops — lanes burned multiple versions regenerating the same mistake. Per the accept-obvious-call-shapes philosophy, spawn() now hoists parent out of properties to the top level with a mutationWarn and spawns what was meant (recursing into inline children, since placement validation runs on the whole tree before child recursion). A conflicting top-level parent wins; a non-string nested parent is dropped with a warning. Other misplaced spec fields (behavior, tags, …) keep the teaching throw.

  • NaN/∞ scrub on both sides of the bloom seam (post-processing.ts scrubFiniteHdr): the scene texture feeding the bloom threshold/downsample and the bloom composite back in are clamped branchless (min/max, no select/toVar — the r0.184 lowering hazard) to finite non-negative with a HalfFloat ceiling (65504). Previously the only floor sat AFTER bloom, so one NaN pixel passed the threshold high-pass intact, the mip pyramid smeared it across every mip, and the bilinear-upsampled widest mip washed the entire frame flat (the white-world session: app 6bf82e5c, dumps 868a9cc9/5c0dac3a).

  • Velocity-aligned sprite orientation (fx-gpu render.ts + CPU particles.ts): the screen-projection angle guard is now branchless — mix onto the screen-x axis via step(1e-5, length) BEFORE the atan, so atan2(0, 0) (indeterminate in WGSL, NaN on some drivers) is never computed for stalled/floor-stuck particles. The old select() only discarded the value after both operands were evaluated.

  • Fog color derivation when density is authored without a color (atmosphere-sync.ts deriveSkyFogColor, was deriveProceduralFogColor): gradient/color skies hand over their authored horizon/sky tint; procedural skies anchor to an authored horizonTint when present and otherwise use a neutral dark-leaning haze (the old default was a near-white daytime ramp — a white-world generator at typical densities with affectsSkybox: true). The no-sky fallback drops 0xa0a0a0 → 0x4d525a. Explicitly authored fog colors are untouched.

  • The inspection-render note (scene-view-capture.ts) now states that engine bloom and the look pass are absent from camera/frame captures, so a washed player viewport vs. a clean inspection render reads as a post-chain difference instead of two contradictory truths.

  • Camera smoother regime gear-shifts now blend instead of snapping: CameraSmoothEntry gains intervalStepTarget (the hysteresis-quantized 1x/2x/3x regime from smoothingIntervalStep), and tick() eases the effective intervalStep toward it with a 30ms half-life (~100ms perceived transition). Previously a 1x→2x promotion hard-doubled the smoothing half-life in a single frame (per-frame convergence ~40%→23% at 120Hz), felt as the camera abruptly going sluggish.

  • Mouse-driven orbit center and distance converge ~2x faster (half-life factor 0.25 vs 0.5) in the orbit-with-mouse branch only. Yaw/pitch was already renderer-direct, but the orbit center/distance smoothed at the sim-follow alpha, so the rendered position trailed an instant rotation by 2-3 frames. Sim-authoritative (zoomTo), first-person settle, non-interactive/cinematic, and FOV smoothing are untouched, as are the EMA weight and hysteresis thresholds.

  • Regression tests in apps/cf-kernel/src/engine/camera/__tests__/camera-smoother.test.ts: a cadence degradation passes through intermediate alphas over multiple frames and still settles into the new regime; orbit center/distance first-frame convergence is the fast alpha (~0.86), not the follow alpha (~0.63).

  • Stranded-sleep class killed (rapier): any authoritative position write that MOVES a dynamic body now clears PhysicsBodyState.status on the wire (clearReplicatedSleepStatus) instead of re-asserting stale sleep. Sites: syncPhysicsBodyToComponents (the external-transform funnel: teleports, terrain-anchor re-hoists, hierarchy-solve), syncFeetPositionPhysics (both the live-body path and the disposed-body respawn window, which now also reconciles BodyPosition with the authored feet write), and body recreation (initializeBodyState only honors born-asleep when the feet/center pose is coherent; an incoherent pair is born awake at the authored feet position with the status bit flipped). The write sites are mode-both/deterministic, so both sides derive the same wake — parity-safe by construction. Previously a slept car teleported by a terrain edit was woken then immediately re-slept from the stale replicated bit: a dynamic vehicle asleep in mid-air, permanently.

  • Rapier wheel control lanes (parity-war law): setWheelEngineForce/Brake/Steering now route through setRapierVehicleControlLane — the control lands in replicated PhysicsVehicleConfig.wheelStates (engineForce/brake/steering), is applied to the live controller for same-tick effect, and is re-seeded into recreated controllers (seedVehicleControlLanes at creation, including resetVehicleControllerState during resim). The writeback publishes the held lanes from the controller so they round-trip exactly (f32). Wake gate mirrors mantle's: wake on lane change or while nonzero engineForce is held, clearing the replicated status bit (throttle on a sleeping chassis used to be silently dropped). Tuning setters (setWheelFrictionSlip/suspension knobs) similarly land in PhysicsVehicleConfig.wheels[i] instead of a live-controller poke that the next sync clobbered.

  • Vehicle suspension self-exclusion: vc.updateVehicle now receives a filterPredicate excluding the vehicle's own entity tree (chassis + Tome descendants, entity-keyed so static children surviving destroy/recreate churn stay excluded) plus EXCLUDE_SENSORS. Wheel rays no longer ground the car on its own floor/panel colliders or on trigger zones.

  • Primitive child physics default: p.box/p.cyl/etc. defaulted every child to physics: { body: "static" }; children of a dynamic/vehicle-bodied parent (resolved up through pass-through pivots) now default to NO physics body. The static default stays for world-building under static/non-physics parents; explicit physics opts always win. The vehicles/turrets skills' taught physics: false workaround is deleted (the canonical car was one omitted token away from ~40 static panels nailed inside the chassis).

  • Vehicles join collider streaming: awake vehicles (PhysicsVehicleConfig, non-static, status !== 1) are terrain-collider anchors regardless of player AOI range, with the same velocity lookahead players get (driven vehicles already streamed as the session's control target; sleeping vehicles don't integrate, so they need no collider until a now-replicated wake path fires). Bounded by nature: vehicles are a handful of hand-authored objects per game, unlike generic dynamic bodies, which keep the existing AOI-range bound.

  • Vehicle chunk-rescue (loud): terrain/chunk-rescue extends to vehicles — below the ECS terrain surface, the chassis is snapped above it with downward velocity clamped. Containment only: no forged grounded/contact state, and repeated fires emit the missing-collider diagnostic ([terrain/chunk-rescue] warn with side/entity/place/chunk), matching the honesty contract of the CC rescue fix.

  • Tests: engine/physics/__tests__/stranded-sleep-wake.test.ts (re-anchor wake, no-move keeps sleep, born-awake/born-asleep recreate gate, sleeping-chassis throttle wake, two-world lane seeding, suspension self-exclusion), tome/__tests__/terrain-anchor-sleep-clear.test.ts (the float repro through the real setProperty("feetPosition") anchor path + respawn window), tome/__tests__/primitive-child-physics-default.test.ts, engine/features/terrain/__tests__/vehicle-streaming-anchors.test.ts, engine/features/terrain/__tests__/vehicle-chunk-rescue.test.ts.

  • Client terrain-collider readiness is now content-realized (debug day 5.0 task #88 — the client mirror of the collider-parity family). The client readiness gate (client-collider-state.ts) used to equate "the physics runtime has ≥1 collider handle for the chunk entity" with "the chunk's geometry is current": a handle whose shape predated the latest install — mantle's deliberate keep-stale-over-hole window with a stranded replacement payload, a torn config/payload pair after an authoritative adopt moved the slim PhysicsBodyConfig under the local payload, any stale install — passed the LOD0 anchor gate, physics ran against wrong ground, and the predicted CC free-fell 1–3 gravity ticks per snapshot at coordinates where the server CC stood grounded (the dump-#88 duty cycle: grounded srv=true/cli=false every frame, deltas exactly k·(−g·dt), ~700 ms/s resim, permanent within episodes). Readiness now requires the runtime to realize the recorded install's CONTENT identity: rapier compares the handle's collider signature against terrainChunk:<payload signature>; mantle handles record terrainContentSignature at collider creation and the refresh compares it against the install. Outputs now carry the realized payload signature (setClientTerrainColliderOutput({ ..., contentSignature })).

  • Stale installs re-request their build instead of stranding. An output-compatible chunk that fails content realization for 3 consecutive refreshes loses readiness, emits a deduped [terrain/client-collider-stale] diagnostic (place/entity/chunk/lod/inputsHash/expected-vs-realized content), and enqueues a rebuild re-request. The client build system drains these before Phase 2, voids its installed-hash short-circuit for the entity (the "this content is current" claim is exactly what stranded the chunk), and re-marks it dirty — the rebuild rides the normal budgeted Phase-2 scan; no new scan loop.

  • Airborne-vs-height parity probe (terrain/client-collider-parity-probe, client-only, every 5 ticks on locally controlled CC entities, skipped during resim replays): when the predicted CC reports airborne+descending for 3 consecutive samples while the client's own height sample says ground is at the feet (−0.1 m..+0.3 m band — physically impossible when the collision world matches the sampled terrain; this dump's exact signature, previously silent because the 0.1 m chunk-rescue threshold is never reached before a correction re-adopts), it emits a deduped [terrain/client-collider-parity] diagnostic naming chunk/entity/hashes plus an explicit chunk-vs-primitive verdict (chunk collider bookkeeping claims-current ⇒ primitive-collider suspect; stale-or-missing ⇒ terrain-chunk suspect — the missing telemetry that settles the 60/40 question per episode), and force-rebuilds the chunk's collider through the same re-request drain.

  • Mismatch classifier base-contamination guard (finding B, display/accounting only): OplogBuffer now tracks which base rows were patched in place by a rollback correction (setBaseComponent/replaceBaseEntityComponents mark; rebase promotion, compaction folds, and full reseeds clear). The skew search never credits a match against a correction-adopted base sample — the adopted value IS the server's value, so matching it is a server-to-server compare. A genuine never-converging parity bug against a static server now books [drift] instead of vanishing into [skew] while resim burns 700 ms/s. Client-provenance base samples (initial baseline, compacted/promoted client writes) still credit skew — the canonical client-leads lag shape is unchanged, and the drift-must-page guard stays green.

  • patchAtmosphere client/server parity (object-api.ts): a predicted client call did its read-modify-write against the side-local PlaceAtmosphereOverridesResource map — which only contains patches that side ran this session, never overrides that predate the client's join — and then rebuilt the replicated TomePlaceAtmosphereOverrides component from that subset with no authority guard. Every predicted patch (a mode: "both" behavior dialing one look param) stripped the room's override residue client-side for ~1 RTT until the server's authoritative component clobbered it back: the 5.0 Post-Processing Lab "renders correctly, then incorrectly shortly after" flip, and a guaranteed parity violation in any room whose override layer predates the client's join. The client now re-baselines its side-local map on the replicated component (the same priority atmosphere-sync uses when merging) before the merge, so both sides compute the identical next-override map for the same patch and the authoritative echo confirms the prediction instead of clobbering it. During resim the component is restored to the authoritative baseline before inputs replay, so replays re-derive the same convergent map. Composes with the window-3 semantics: explicit-null tombstones ride over the replicated baseline, cycle anchors survive untouched patches, and a server-side clearAtmosphereSessionOverrides (revert's replace: true) supersedes the client's stale session map on its next patch instead of being resurrected.

  • Server terrain collider presence parity (PhysicsEngineFeature): the per-place step gate on the server now counts terrain colliders that verifiably exist in the rapier runtime (claim + realized handle after the static sync), not raw ECS TerrainChunkCollider components. The ECS count lies exactly when it matters — claims survive a physics-world corruption rebuild and payload staleness — so the old gate kept stepping character controllers on a world with no ground (pure ballistic CC + chunk-rescue = the standing-still mispredict storm, naruto dump 3f17d056). Claims the runtime cannot realize for 2 consecutive ticks are re-requested via requestServerTerrainChunkColliderRebuild (artifacts cleared so the inputsHash short-circuit can't dismiss the retry as spurious, dirty mark set) with a rate-limited [physics/terrain-parity] warn. Claims without a body config (air chunks, 2D places) count as-is. Mantle places keep the ECS count (no WASM corruption class there).

  • terrain/chunk-rescue is loud and honest (terrain-systems-shared.ts): the rescue no longer forges grounded: true — with the taught isGrounded()/gravity script pattern the forged flag reset script gravity every rescue and manufactured a stable 6-tick server limit cycle (4-of-6 mispredicted ticks, ~100 ms/s resim, forever, while standing still). It now clamps position and downward velocity only; grounded belongs exclusively to the character controller's real contact test. Repeated fires for the same entity emit a [terrain/chunk-rescue] warn with side, entity, place, chunk coord, and fire count — the missing-collider discriminator every dump of this family needs (it previously fired 5×/s for minutes in total silence).

  • Prediction resync hard-adopt (runtime-client.ts): when reconciliation is impossible — mismatch older than MAX_RESIM_TICKS (cap_exceeded) or repeated missing authoritative state — the client now hard-adopts the server's newest authoritative snapshot using the join/place-transition machinery: rollback-only resimulation (authoritative apply + full physics resync + oplog reseed), input history dropped, mismatch tracker cleared, prediction baseline recaptured wholesale at the next commit. Previously markPredictionResyncUnavailable was a debug log and nothing else, so a >45-tick-behind server meant corrections were never applied: server teleports, stall recoveries, and wedged-server corrections all read as "nothing happened" (dump c548601c, "Savi can't move me"). 1 s cooldown between adopts prevents rubber-band thrash on permanently-behind servers.

  • Mismatch display canonicalizes codec-dropped boolean flags (prediction-debug.ts): decoded-absent flag keys vs local booleans render as false, never undef — ceiling srv=undef cli=true was repeatedly misread as a client-only component leak. Decoded-absent vs explicit false no longer emits a spurious detail line. Codec round-trip shape for all flag combinations is pinned by test.

  • NPC gait is now a truthful motion signal. writeAgentMotion derives speedEma/gait from the entity's actual per-tick position delta (a lastX/lastZ anchor recorded in NpcMotion, riding the wire with the rest of the component) instead of the locally commanded step alone. On the client a remote NPC moves by replication while its side-local NpcAssertBeat is stale — the intent correctly decays to idle (THE LIFETIME RULE is untouched), but the old local-step derivation read planarSpeed 0 and starved the gait to "idle" while the NPC visibly walked. Observed deltas beyond DISCONTINUITY_SPEED_FACTOR (4×) of the agent's command ceiling fall back to the local step, so teleports and place moves never read as a sprint.

  • Removed the skinned renderer's silent first-clip substitution — on both animation paths. A channel naming a missing clip used to play the model's first clip (per-character mixer: first usable clip under a cloned name; horde batch: substring scan then baked.clips[0]) — the engine picking an animation on its own. Now both paths follow the same law: exact match, else a genuine name variant via clip-name-resolve.ts (shared/prefixed name tokens like "Walking"/"Armature|Walk" for "Walk", or the locomotion families idle/walk/run e.g. "Sprint" for "Run"), else hold pose (clone path: no layer is created; horde path: the bind-pose anim-param sentinel). Batch and clone agreeing here keeps the batch<->clone demotion seam from popping between "first clip playing" and "held pose". The model-clip-not-found diagnostic fires on both paths and reports the resolution outcome (resolved in data, "holding pose" vs "playing the matching clip" in the message) so Savi sees exactly what happened, plus the real clip list as before; the horde path dedupes it per batch, not per instance.

  • The model-spec auto-play contract (_default with no clip name plays the model's first usable clip) is unchanged on both paths — but it now belongs to whole-body layers only: a masked channel with an empty clip name is a no-op again (no layer, no diagnostic), as it was before variant resolution. Auto-playing an arbitrary clip on a body-part mask would be the engine deciding an animation. Masked channels naming a real-but-missing clip get the same variant resolution as unmasked ones.

  • Tests: replicated-movement-with-stale-beat must read gait "walk" (the follow-NPC repro), teleport-discontinuity stays idle, variant resolver matrix, hold-pose-instead-of-first-clip on the clone path and the horde path (anim-param sentinel + per-batch diagnostic dedup), variant-resolution reporting, the horde empty-name auto-play contract, and the masked empty-name guard.

  • Self-reaping one-shot fx (debug day #69 — 363 leaked combat fx ≈ 96 ms/frame of CPU particle tick in the render adapter). Both particle backends already implement the wind-down rule, but only renderer-side: the authoritative object lived forever. New deriveFxProgramCompletionSeconds (tome/fx-utils.ts) mirrors that predicate statically — a finite upper bound on when an effect is provably finished (burst-without-every, closed windows, death-coupling chains; null for unbounded rates, repeating bursts, persistent/lifetime-less populations, coupling cycles). The new server system tome/fx-reap despawns finished one-shots through the same cascade-destroy path stopFx uses. Eligibility is strict: runtime-spawned (TomeSpawnedBy), no behavior, no spec entry. Pause holds the clock; resume restarts it (matching backend effect-time semantics); version bumps re-derive without resetting.

  • Runaway caps with graceful cull. Sim-side: FX_RUNTIME_OBJECT_CAP = 128 live runtime-spawned fx objects per room; on breach the oldest eligible effects are culled first and ONE deduped diagnostic (60 s window) lands on the runtime-log + DM rail. Renderer-side: CPU_MAX_FX_POPULATIONS = 256 total live fx-native CPU populations (the per-population fixed tick is what melted the frame — ~60 µs each); the CPU backend sheds the oldest fx effects on breach (legacy emitters/bursts exempt) and reports once through the new fx-population-cap engine diagnostic (allowlisted; counts ride in data so the content-signature dedupe collapses repeats).

  • Durable behavior parking (the "stuck erroring orb"). Behaviors already fault-and-park on the first thrown error, but every applySpec cleared all faults — a permanently broken script resurrected and re-errored after every edit anywhere in the game. New strike ledger (TomeBehaviorRepeatFaultsResource): BEHAVIOR_PARK_THRESHOLD = 3 consecutive identical errors parks the entity until one of its own scripts (or their tracked lib dependents, via the set invalidateBehaviorCache now returns) actually changes. A different error resets the count; game reset clears all parks; a runtime-log entry tells Savi how to unpark.

  • God-mode entry no longer falls back to the world origin when the player's WorldFeetPosition hasn't resolved yet (toggling right after join). resolveGodSpawnPosition now resolves the place spawn point through the same path player spawn uses (resolvePlayerSpawnPosition, extracted from configurePlayerEntity in tome/lifecycle.ts), so the god entity — and the creator's camera — start where the player would spawn instead of at a marker cube at 0,0,0.

  • The applySpec player reconciliation is now god-mode-aware: re-applying a changed player appearance had rewritten/removed DrawVisibility and DrawInterpolation on the creator's player, popping the hidden body back into the world mid-session. The reconciliation now folds the spec's own visibility/interpolation into the TomeGodMode exit-restore slots and reasserts the session overrides (hide + interpolation-disable), so leaving god mode lands on the NEW spec appearance. The fold only runs when the update actually carries player appearance props — an update that drops them never touched the overrides, and folding there would capture the session's own hide as the restore value, leaving the creator invisible after exit. Runs identically on server and client (applySpec is shared), so no replication divergence.

  • applyMenuPick stamps god:menu-pick entries with the input frame's tick when the payload carries no capturedAtTick (the DOM drawer never sends one). The previous 0 default made expireStaleMenuPick (added in the object-qualified-picks commit) reap every pick the same tick it landed once the session was past the 2-second TTL — every drawer option (brush Radius/Strength/Mode, object Tint/Glow/Material) was a dead click. Same pattern applyToolbarPick already documents; the frame tick is identical on both sides, so the TTL now measures real age.

  • GPU fx populations without an authored maxParticles no longer get the CPU-era flat 3000 clamp (DEFAULT_MAX_PARTICLES in fx-gpu/backend.ts). They now get an arena-aware default: a 1/16 share of the shared particle arena (65,536 desktop / 16,384 mobile), bounded to a quarter of the arena's remaining free slots so defaulted populations degrade geometrically under pressure instead of hitting an exhaustion cliff, with the old 3000 as the floor. Oversized segments are near-free on this path — the fixed passes dispatch at arena capacity and threads early-out past each segment's live range — so the real ceilings stay overdraw and the 256-population cap.

  • A defaulted population's GPU-side spawn cap (population-table MaxParticles) and persistent-count clamp now equal its segment capacity; only an authored maxParticles soft-clamps below the allocation. Authored values are honored as written, subject only to the existing arena-tight halving clamp every request gets.

  • FxGpuArena gains freeParticleCapacity() (segment allocator free total) to size the default.

  • The CPU/ribbon backend keeps its modest flat 3000 default (cpu-backend.ts) — every alive particle costs CPU time there.

  • isComplexMixer (the horde-batch admission/demotion gate in renderer/three/models.ts) now treats mixer channels below a contribution epsilon (MIXER_CONTRIBUTION_EPSILON = 0.01) as inactive — mask and all. Previously any channel with weight > 0 counted as active, so the near-zero residual weights that locomotion blends (smoothstep/EMA weight ramps) settle into — idle: 0.001 next to walk: 0.999 — read as "two weighted channels" and permanently demoted every crowd member to the per-character clone path. A 190-enemy horde rendered as 190 skinned clones instead of one instanced batch. Channels at or above the epsilon behave exactly as before: a second contributing channel or a contributing masked channel still demotes.

  • Foot-grounding plant release is now plant-aware (foot-grounding.ts). A STRETCH release — the anchor left leg reach because the BODY moved (a script-commanded speed/gait snap like sprint 6→12 m/s in one tick), not because the clip swung the foot — hands the foot back to the clip: the release fade targets the ANIMATED foot (riding the body) instead of pinning the dead world anchor. Previously the IK target held the world-fixed anchor for the whole ~0.13s weight fade (FOOT_WEIGHT_RAMP_PER_SECOND 8, unchanged) while the body receded up to ~1.5m, so solveTwoBone parked the leg at its soft reach cap — a full-extension leg snap on every sprint press AND release. SWING release (toe-off) deliberately keeps the pinned anchor: hold-the-ground continuity through the fade is what makes toe-off look planted. The asymmetry is the fix and is latched per release (FootState.releaseToAnimated); a swing release upgrades to a stretch mid-fade if the body outruns the anchor during the ramp (anchorBeyondReach, the old stretch math split out of shouldReleaseLock). Since the solve toward the animated ankle is an identity over the clip pose, the residual fade is invisible — no pop at hand-back.

  • Pole direction is rate-limited (solver.ts): the remembered knee/elbow plane turns toward the fresh pose-derived plane at most POLE_MAX_TURN_PER_SECOND = 2π rad/s (~360°/s; visual constant — the knee plane sweeps over ~2–8 frames instead of snapping). This extends the window-1 hysteresis, which only protects near-straight limbs (POLE_TRUST_MAX_SIN 0.2 ≈ 11.5° of bend): a phase-unsynced gait crossfade produces genuinely-bent intermediate poses — full trust — whose bend plane tips laterally within one frame and whipped the knee sideways (the consistent "leftward" component: the tip's sign is fixed by the avatar's clip pair). Both the remembered and candidate poles lie in the plane ⊥ the target direction, so the clamp is an exact rotation about the target axis (Rodrigues). solveTwoBone gains a dt parameter (frame seconds) to feed the limit; both callers in applyIKPass pass the pass dt. Genuine clip re-aims (typically well under 90°) settle in under 0.25s; the worst-case caveat is a fast character spin (>360°/s yaw) briefly lagging the knee plane, bounded and continuous.

  • Tests (fail-on-parent): stretch-release fade tracks the animated foot with the commanded target never beyond 0.97× leg reach (vs parent pinning at the anchor and engaging the reach cap); a stretch-initiated release (slow foot, body accelerating away) follows the animated foot from its first frame; a swing release with the body planted keeps the anchor through the whole fade (the asymmetry guard); a synthetic one-frame 90° bend-plane tip moves the pole ≤ 2π·dt and keeps the knee on the remembered side, then settles onto the new plane over held frames. The window-1 noise-filter foot-grounding suite stays green; the existing "re-aimed bend plane" solver test now documents rate-limited follow.

  • Lag compensation now rewinds to the pose the shooter was actually shown. The server rewound to the newest applied tick (integer), but the renderer displayed an EMA chase trailing that tick by ~0.5–1.5 ticks — so the rewound capsule sat ~1 tick ahead of the displayed silhouette along its motion (trailing-edge clips missed, slight leads hit). Three coupled changes close the gap end to end:

    • Presentation is now reconstructible. The renderer's per-entity EMA chase (synthetic-transform-delta) is replaced by fixed-delay two-snapshot interpolation on the authoritative tick timeline: poses are buffered per delta tick and the displayed pose is pose(newestTick − 1 + alpha) (alpha = elapsed/tickInterval, clamped), blended with the exact same lerp/shortest-arc-nlerp the lag-compensation history uses. Teleport-threshold snapping is preserved. The arrival-cadence regime smoothing (intervalStep) is gone from entity transforms — bunched tick arrivals now keep their intermediate snapshots and replay on the timeline instead of being smeared.
    • The stamp says what was displayed. Input frames stamp remoteViewTick = newestAppliedTick − 1 + alpha (fractional; computeRemoteViewTick), with the ack tracker recording the arrival time of the newest acked tick as the clock base. Falls back to the integer newest-applied tick when no arrival time is known.
    • The server honors the fraction. clampRewindTick no longer truncates — fractional rewind ticks clamp in float space and history.sampleAt blends the bracketing records (it already could; it just never received a fraction). Applies to raycasts, sphere sweeps, and the instigated-sensor overlap path alike.
  • Residual display-vs-rewind error is now ≤1 render frame of sim→renderer pacing (~16 ms) plus flick-window camera smoothing, down from ~1 tick of target motion (0.1–0.27 m at 3–8 m/s, worse under degraded cadence).

  • Look pass rebuilds on script source edits (look-pass.ts): lookPassTopologyKey now folds the script's source hash into the topology key (script:${ref}@${hash}). An in-place edit (same ref, new source) is a topology change → present() rebuilds the compiled graph and re-arms pipelineFailed. Previously the key was hash-blind, so the pass presented the first compiled version of a look forever — every subsequent str_replace_editor edit compiled into the cache and was thrown away (debug-day 5.0 P1, app 6bf82e5c). Param tweaks (vocab uniforms and lookScript.params) keep the no-rebuild uniform fast path.

  • Full-reset applies clear session atmosphere overrides (interpreter.ts, room-runtime.ts, reset.ts): new clearAtmosphereSessionOverrides(world) drops the patchAtmosphere override layer — legacy + per-place resources AND the replicated TomeAtmosphereOverrides/TomePlaceAtmosphereOverrides components (cycle anchors ride this layer and reset with it). applyTomeSpec calls it when replace: true (the revert_to_version path), and resetTomeWorld now uses it too (previously it only cleared the legacy layer, leaking per-place overrides). Previously applySpec re-merged the kept override layer over every new spec, so a revert left the whole session's overrides (timeOfDay, sun, sky hybrids, look) alive forever — surviving page reloads by construction because they live in the room's ECS.

  • Explicit null scrubs nested atmosphere keys (atmosphere-utils.ts): mergeAtmosphere / mergeAtmosphereOverrides now recurse into nested plain objects, so patchAtmosphere({ sky: { turbidity: null } }) deletes the key from the effective atmosphere (tombstone kept in the override layer, scrubbed in the merge). Previously nested nulls survived the shallow spread as literal null values — nothing nested could ever be removed, which is what trapped the 5.0 hybrid-sky residue.

  • Silent render failures reach Savi (renderer-backend.ts, look-pass.ts, engine-diagnostics.ts): backend GPU onError (pipeline/validation/OOM) now routes through reportEngineDiagnostic as renderer-gpu-error (deduped per distinct api+type+message; console.warn keeps firing per occurrence), and the look pass's conservative pipelineFailed drop reports look-pipeline-failed naming the look ref (once per flip — the flag gates storms). Both codes added to the server allowlist → getLogs + one DM. Previously both paths were console-only, which is why a session with a visibly broken look had clean getLogs.

  • Dynamic-spawn behaviors now compile on demand from the replicated ref (src/tome/compiled-behavior.ts, debug day 5.0 #78). Dynamic string-spawns (objectApi.spawn with a behavior field) only compiled on the side that executed the spawn call; an entity materialized through replication ingest (join snapshot, AOI stream-in, predicted spawn racing its confirmation) carried TomeBehaviorRef.behaviorRefs but no compiled.objects entry, so its hooks silently never ran on that side. Because TomeSpawnedBy is replicated and player entity ids are session-stable, such entities still classified client-predicted — compared every tick but never simulated. The golf game's hole-arrow (t += dt bob/spin) showed the signature: a constant Δp50 = 2-tick state.t mismatch at 99.3% present rate that 17 corrections/s re-seeded but could never converge, burning 408 ms/s of resim forever. resolveCompiledBehavior lazily compiles from the spec scripts (negative-cached per CompiledSpec; onSpawn deliberately not re-run — state arrives via replication) and is now the single lookup used by behavior-update, input-applier, interaction-dispatch, NPC noise listeners, object-api, and the god-mode editor passes.

  • Per-tick prediction delta capture is never entity-filtered (recordPredictionHistory in projection-history.ts, debug day 5.0 #79). The client oplog's deltas were scoped to the prediction envelope, but predicted behaviors write outside their envelope — the golf cannonball script-detects hits and patches static targets' broken state. Those rows never reached the oplog, so buildRollbackScope never restored them and every resimulation replayed against a future-contaminated world: the replayed ball skipped targets its abandoned timeline had already broken (if (t.state.broken) continue), missing hits the server made — server-only _hitLog entries, forked kill/score chains, diverged id-seq spawn counts (8 vs 4) and owned-entities childIds, camera warps. Full snapshots stay envelope-filtered (memory), and the mismatch compare filter is untouched (no new compare noise, AOI-scatter hazard still solved). The capture policy lives in one shared helper used by the client runtime and the resim replay loop.

  • Tests, both verified fail-on-parent by reverting each fix in isolation: join-offset-behavior-state.test.ts (two-world join fixture; control spec-object scenario isolates the lazy compile) and predicted-cross-write-rollback.test.ts (scripted ball + smashable target + one rollback forced across the hit tick ⇒ identical hit chains, target state, FX spawn counts both sides).

  • River marks now resolve their authored points into a centripetal Catmull-Rom spline centerline (sampleCentripetalSpline in program/path.ts, resolved once per definition revision in resolveTerrainMarkEntries). Centripetal parameterization (alpha 0.5) interpolates exactly through every authored point and cannot cusp or self-intersect between them; sampling is curvature-adaptive (3 m straights → 0.75 m in tight bends, 1024-point budget per river with graceful spacing degradation). Every consumer measures against the same centerline: the height carve, bank/bed material blends, mark bounds, scatter exclusion (tome/scatter.ts, with a bounds pre-reject so the dense polyline never runs for far samples), liquid queries, and the water-surface geometry (marks-liquid.ts rides the centerline instead of re-resampling the raw polyline). The carve's water-profile arc-length projection already ran against the dense profile polyline, so per-sample cost shape is unchanged.

  • Ocean carve rewritten from "push already-underwater terrain down by a depth-keyed offset" (which produced walls at the mark boundary, no shore, and 1 m-deep "oceans" over low terrain) to a coastline profile keyed on distance from the authored boundary: a slope-limited beach band outside (terrain eases to a sand berm 0.35 m above the waterline, band widens up to 48 m on tall coasts so bluffs ease down instead of cliffing), a wadeable shelf (2.2 m over 14 m), then a smootherstep drop at a ~35° continental slope to the full authored depth, floor relief preserved at 50% of the base terrain's below-sea variation. The drop width clamps to the basin's interior reach so small bays still hit their authored depth. Terrain rising ≥14 m above the water past the coast band survives as islands; the island gate is keyed to profile progress so the blend's derivative vanishes exactly where the profile's does (monotonic shore→depth, no ripple at band junctions). Raising is gated on water-mark overlap so river mouths are never dammed; ocean influence reach shrank from 250 m to the 48 m beach band (bounds tighten accordingly).

  • floorMaterial now paints a sand apron across the beach band outside the waterline (18 m reach, fading out by 5 m above the water) instead of cutting off hard at the boundary/water level — island rims get sandy rings for free.

  • All mark height/material functions remain pure and deterministic (no RNG, no clock); server and client sample identically, pinned by a new determinism test across both resolve regimes. Heightmap build golden fixtures re-captured for the two ocean-mark cases (intended output change); all other fixtures byte-identical.

  • Made the room.full re-pick loop unconditional on the client (bug: https://github.com/earth-kiln/main/pull/6677#issuecomment-4627648366). The retry handler used to exist only when the pre-join routing context resolved (index.ts wired onRoomFull solely off resolveRoutedJoinSetup); the server enforces door admission whenever the live spec has spec.routing, so any client whose routing-context fetch failed or was answered "routing disabled" (stale kiln spec cache, 401, RPC race, legacy SDK host) connected with no retry handler, got rejected at the cap, and dead-ended on "This room is full. Reload to try another room." — on every reload, since the boot condition repeats. New createRoomFullRetryHandler (room-routing-bootstrap.ts) is wired into every connectRoom: it keeps the compiled pickRoom hook driving re-picks when the routed join exists, and otherwise lazily builds a fallback RoutedJoin from the rejection's own rooms snapshot with the builtin firstOpenRoom picker (strictly-under-cap open predicate, rejected-room exclusion, fresh nextRoomName mint).

  • Bounded the loop: MAX_TOTAL_ROOM_FULL_REJECTIONS = 5 rejections per page load (the hook is consulted for the first 3, then fresh-name rollovers); the reload copy is now reserved for genuine failure — budget exhausted or a connection-URL fetch error.

  • Verified-correct pieces left untouched: firstOpenRoom's open predicate (playerCount < maxPlayers), the fresh-room spill, applyAdmissionRejection, server-side admission + room.full snapshot, and patchRouting.

  • Tests: createRoomFullRetryHandler suite in room-routing-bootstrap.test.ts (no-pre-join-context spill to room-2, empty-snapshot mint, Tucker's verbatim routing.js with a stale 1/2 count, consecutive-rejection exclusion, retry-budget exhaustion copy, connection-URL failure) plus explicit maxPlayers-2 second/third-player cases in room-routing.test.ts.

  • run_script now exposes getLogs on api as well as the bare global (script-dispatch.ts) — both spellings are the same function. Savi's property-enumeration of api previously found no log channel and concluded none existed (debug-day 5.0 Painterly Vale session).

  • custom-materials skill body now carries the water redirect that previously lived only in its frontmatter description: water waves = liquid.waves/turbulence/crestFoam on the mark, or material: { kind: "water" } — instead of overlaying a scripted displacement plane on builtin water.

  • custom-materials skill documents the vertex-displacement contract: positionNode does not recompute normals, and the plane primitive bakes its flat orientation into geometry (positionLocal.y is 0), so displacement should be driven from positionLocal.x/.z and paired with a normalNode when the motion should shade.

  • voxel-terrain skill gains an Interactive Editing example, "Highlight Your Cursor": raycastVoxel returns integer voxel cells (not object ids), so setVoxelMark(id, hit.voxel, opts) / setVoxelMark(id, null) is the block-world counterpart to highlight(objectId).

  • Lighting prompt example (_examples/behaviors.ts → tome-api-prompt) now names the containment consequence of shadows: a shadowless light passes through walls and tints geometry outside; interior lights want shadow: { enabled: true }.

  • applySpawnProperties (the api.spawn dynamic-spawn path in src/tome/api/object-api.ts) now applies castShadow/receiveShadow via the existing propertySetters. The per-object shadow surface was already end-to-end everywhere else — ObjectProperties type, ObjectPropertiesSchema Zod, interpreter applyAppearanceProps (spec objects), setProperty/setObjectProperty, DrawShadow ECS component (aoi-replicated, renderer-forwarded), renderer shadow handler + standalone/lane meshes — but runtime spawns silently dropped the flags, so a behavior-script api.spawn({ properties: { castShadow: false } }) kept casting while every readback claimed it didn't (Tucker's geometry lightning bolts, debug-day 5.0).

  • Defaults unchanged: flags omitted → no DrawShadow component → cast and receive both on, and flipping both flags back to default still drops the component.

  • Tests: spawn-path cast/receive application + readback, default-unchanged, live flip via setObjectProperty (object-api.test.ts); strict Zod round-trip (schema-docs.test.ts). Renderer-level mesh.castShadow coverage already existed (engine/renderer/__tests__/primitive.test.ts).

  • requestSpecUpdate (object-api) now coalesces with any spec update already pending in TomeSpecUpdateResource instead of clobbering it: the EARLIEST pending baselineSpec is preserved. Previously a multi-mutation burst (one run_script doing patchPlayer + removeBehavior + addBehavior + camera swaps) left a pending {spec: S_N, baselineSpec: S_(N-1)}, so specUpdateSystem diffed only the final mutation. Live-entity reconciliation gates in applySpec (player appearance, physics, behavior onSpawn) saw no change for every earlier mutation — runtime Draw state from before the burst (e.g. the old avatar's DrawModel) survived on the server until the player refreshed, while clients diffed the full spec against their own baseline. This is not avatar-specific: any same-tick patch + behavior-swap burst lost live reconciliation for all but the final delta.

  • When the pending update carries no baseline but the new request does, the new baseline is adopted (pending?.baselineSpec ?? baselineSpec) — covers a queued re-apply (e.g. placeResident) followed by an object-api mutation that already advanced GameSpecResource.

  • Regression tests in spec-update-coalesce.test.ts: baseline coalescing unit tests plus a live-player burst test (avatar model → primitive swap + behavior swap in one tick) asserting the player ends with DrawPrimitive and no DrawModel after specUpdateSystem.

  • Sun cascade shadows now install the fork's radius-aware Vogel-disk PCF (PCFShadowFilter: 5 IGN-rotated taps × hardware 4-tap compare) as each cascade's filterNode. The renderer renders with PCFSoftShadowMap, whose filter is a fixed 3×3 kernel that ignores shadow.radius entirely — the per-cascade PCF radius SunCascadeShadow.fitCascadeDepthRange computes (one world-space penumbra target over the cascade's texel size) had no GPU consumer and governed nothing on screen. With the override, radius is read per render as a uniform and the penumbra targeting is real. Local-light shadows (shadow atlas) stay on the renderer default.

  • Radius bounds re-sized for the now-live consumer: cap 2.5 → 3.5 texels (high tier's near cascade, ~0.05 m texels, needs ~3 texels for the 0.15 m target penumbra — the old cap would truncate it), floor 0.4 → 1.0 texels (Vogel support ≈ 2·radius+1 texels; 1.0 keeps coarse/far cascades at least as soft as the 3×3 PCFSoft kernel they previously rendered with, so the filter swap can't sharpen the far-cascade striping genre).

  • This is aimed at the grazing-sun diagonal-band report (cascade texel-grid aliasing): the per-pixel IGN rotation dithers the grid pattern into noise instead of bands. The bug itself stays open until the repro is re-tasted on stage — the softness/dither tradeoff at grazing angles is taste-gated.

  • Root cause of the default wet sheen: the Patina NRO roughness channel is ML-derived from the albedo alone (fal-ai/patina) and trends glossy for ground textures, sitting at or below TERRAIN_ROUGHNESS_FLOOR (a min-clamp wet-plastic guard, 0.55) over wide areas — so the floor became the effective roughness and terrain rendered a flat 0.55 semi-gloss, which reads shiny/wet at grazing sun angles. A min-clamp can only permit gloss, never remove it, so the floor itself was not the de-shine lever.

  • New per-material roughnessIntensity knob (0-2) on heightmap terrain materials, plumbed exactly like normalStrength: spec type + Zod schema → interpreter signature/config → MaterialDef → buildMaterialPack colorBy → readTextureColorBy (clamped 0-2, in the layering signature) → render config component → layer atlas params. The atlas params texture grew a PBR2 row (TERRAIN_LAYER_PARAM_ROWS 13 → 14, [roughnessIntensity, unused, unused, unused]), fetched only on tiers that sample the NRO array. The shader multiplies the NRO roughness sample (top and biplanar side projections) by the intensity and saturates — rescaling preserves the roughness map's spatial structure instead of flattening it to a scalar.

  • The actual default de-shine: buildMaterialPack defaults Patina-derived NRO layers to roughnessIntensity 1.4 (PATINA_DEFAULT_ROUGHNESS_INTENSITY) when the spec doesn't set one — existing games read matte with no spec changes. With the ×1.4 lift, blended samples ≥ ~0.39 land at or above the old 0.55 guard.

  • TERRAIN_ROUGHNESS_FLOOR lowered 0.55 → 0.4, justified only by the default bump above: in the default state the bump keeps typical blended roughness past the old guard, and the lower floor exists as headroom so an explicit roughnessIntensity < 1 ("polish") can actually reach below 0.55. Stated tradeoff: derived samples whose blended value lands under ~0.39 can now render down to 0.4 instead of riding a flat 0.55 — localized glossy spots the derivation actually authored, while the terrain-wide average moves matte-ward.

  • Reaches Savi automatically through patchTerrain material helpers (strict schema now accepts the key); documented in the heightmap-terrain skill and the generated tome-api prompt.

  • TerrainChunkEdits now rides the prediction rollback envelope. The client oplog capture filter (isPredictionProjectionEntity, a superset of isClientPredictedEntity) includes terrain chunk entities, so buildRollbackScope picks up locally-applied voxel edits and applyAuthoritativeState restores server truth at the mismatch tick before resim replay. Replayed clicks raycast the tick-exact world and re-append the IDENTICAL edit (same editId/revision) — previously each resim pass re-read the abandoned timeline's block and extruded a fresh voxel toward the camera (one click → many voxels, plus the ghost flash when the authoritative row landed). Chunks stay direct-snap ingested and outside mismatch comparison; only edited chunks enter the oplog.

  • Mixed-ownership rollback semantics for chunk entities: client streaming tags chunks ClientEntity, which used to make the selective-rollback skip rule preserve predicted edits the server had never confirmed. Chunk entities now have their server-absent replicated components removed (entity + client-plane bookkeeping survive; never despawned/respawned), and their oplog base rows are cleared when server truth has nothing.

  • setTerrainChunkEditsState / clearTerrainChunkEditsState (and the replace path's removals) record recordPendingLocalEdit, so authoritative rows older than an in-flight local edit defer instead of snap-wiping it (same overlay the field store uses). Resimulation replays and server worlds no-op the record.

  • Material-lookup overlay: bulk edit transitions (rollback restore, authoritative replace, clear) backfill abandoned cells with the GENERATOR's truth instead of deleting their overrides — the stale impersonated build is the next read fallback and would otherwise keep answering the abandoned value until the convergence rebuild. Budgeted at 4096 cells per transition (larger region replaces keep the old delete-and-wait behavior).

  • TerrainChunkEdits gained a content-identity equals gate (revision + timestamp + editId sequence): identical-content restores/direct-snap rows (fresh clones from the oplog/wire) no longer count as changes that tear down the resident fast-lane grid and queue redundant rebuilds — the resim-storm churn amplifier.

  • water-material.ts: foam is now composited inside the lit PBR surface — colorNode = mix(waterColor, foamColor, foamVisible) plus the existing roughness lift — replacing the post-lighting material.outputNode mix toward raw foamColor. The outputNode path ran after setupLighting() and after fog (setupOutput), so foam was an unlit, unfogged constant (the "river disproportionately bright at sunset" and "objects behind water render fully white" debug-day reports).

  • Shore foam no longer saturates into a solid fill: shoreThreshold is floored at 0.18 so foamTexture always modulates the shape (a threshold ≤ 0 at full contact made shoreFoamShape ≡ 1), the contact fade is squared before the fill mask so contact foam concentrates at the waterline instead of covering everything shallower than contactFoamWidth (meters on gentle pond/river beds), and foamVisible ramps over 0.015→0.35 instead of binarizing at 0.12 so computed foam intensity survives to the blend.

  • Test harness: voxel-bucket-wgsl-test-utils.buildWgsl now wires backend.renderer (normally done by Backend.init) so viewport depth/shared-texture materials (water) can codegen headless; new water-material-wgsl-dominance.test.ts pins the water fragment/vertex WGSL against the select()/toVar branch-trap class.

  • Editing a behavior script re-ran onSpawn but never reaped timers armed by the previous script version. TomeTimerEntry callbacks are raw closures over the dead compiled module with no script identity, and the only reapers were owner-destroy (clearTimersForEntity, destroy cascade only) and owner-gone-at-dispatch — so Savi's self-rescheduling runInSeconds loop idiom became an immortal zombie chain on every edit, re-arming under fresh ids forever (debug-day dumps 8f33e24f/868a9cc9: three concurrent storm loops, tube-bolt lightning from code present in no spec script). The hot-reload boundaries already cleared event subscriptions (clearEntityEventSubscriptions, added for exactly this stacking class) but missed timers. clearTimersForEntity is now exported and called at both re-run sites — updateObject's behaviorChanged branch and rerunOnSpawn — right next to the event-subscription clear.

  • Hazard handled: owner-scoped reaping would also kill duration cleanups the ENGINE armed on the edited entity (effect/pushLook/highlight duration-clears, spawn lifetime destroys). Those four sites now arm through armEngineCleanupTimer, which tags the entry engineOwned at creation; script-edit reaping passes keepEngineOwned so a mid-duration edit can't strand a look layer, highlight, or immortal lifetime-spawn. Destroy still reaps everything. Long-term these cleanups should become expiry-as-data on their resources (the shape effect() already half-has with expiresAt) instead of timers.

  • New introspection: api.getTimers(ownerId?) lists active timers ({ id, ownerId, dueInTicks, dueInSeconds, engineOwned }, soonest first, world-wide by default) — Savi found this bug by pure inference because nothing could enumerate armed timers.

  • Tests (timer-zombie.test.ts): edit-while-armed kills the old chain on both hot-reload paths (spec object + dynamic spawn), revert/spec-replace and destroy reaping regression-guarded, engine-armed highlight cleanup survives an edit and still clears, getTimers shape.

  • Converging-write classification in the prediction comparator (mismatch-classifier.ts): every mismatch row is classified at compare time as push (server-only write the client structurally cannot predict — e.g. a server-realm game-manager patching a predicted player's tome/state; provenance = no client write to that component path in the buffered oplog window), skew (authoritative value equals the client's predicted value at T±k, k ≤ 3 — late-frame salvage fire shift), or drift (genuine determinism divergence). Classification is display + accounting only — zero behavior change to corrections/resim; the rollback path still applies authoritative state exactly as before, and it runs only on ticks that mismatched (already the slow path).

  • The headline mispredict rate now counts ONLY drift. [window-15s mispredict] reads drift=N/total(%) push=N skew=N decayed=N absent=N; the [mismatch] FOUND line carries class=, per-field rows carry [push]/[skew]/[drift] tags, and the [mismatch-rec] digest header splits mismatchedTicks by class with per-field class tags.

  • Guard rail (tested): a seeded determinism bug in a both-sides behavior still produces drift-classified rows and a nonzero headline rate; gm-pattern pushes and 1-tick salvage skews never page the headline.

  • api.raycast now registers NPC hurtboxes by default. The properties.npc default hurtbox is a kinematic trigger capsule (a Rapier/mantle sensor), and tome raycasts passed EXCLUDE_SENSORS to the engine unless includeSensors: true — so every taught bullet pattern (hitscan in combat.md, the raycast-stepped projectile in projectiles.md) sailed straight through NPCs and burst on the wall behind them. The engine cast now always includes sensors, and runRapierRaycast/runMantleRaycast post-filter: a sensor hit registers only when the entity carries NpcAgentCfg (an NPC hurtbox); every other sensor stays ray-invisible.

  • Plain trigger zones keep today's semantics with correct pass-through: a ray entering a non-NPC sensor doesn't stop there — the single-hit path falls back to the all-hits cast and returns the nearest visible hit behind it (zone in front of an NPC or wall resolves to the NPC/wall, not null).

  • includeSensors: true is unchanged as the raw escape hatch: no filtering, nearest sensor wins.

  • The filter reads only replicated components (PhysicsBodyConfig.sensor, NpcAgentCfg), so client and server raycasts stay deterministic; lag-compensated hit merging is unaffected (compensated player hits are solid and merge after the sensor filter). Engine-level castPhysicsRay/castMantleRay defaults are untouched — only the tome api.raycast path changed.

  • queryWorld picks tag-first candidate selection for selective tag queries with a radius (debug day #92, Tucker's elevator server profile: EntityTable.indexOf 19.9% self, queryWorld 25.5% total). Radius queries used to always walk the octree and tag-filter per candidate — query({ radius, tags: ["player"] }) (the findPlayer/inTalkWindow behavior-script staple, called per scripted entity per tick) paid 2 string-keyed world.gets for every in-radius entity to keep a handful of matches. When the tag index bounds the candidate set to ≤ ¼ of the place's octree population (cheap set-size peek, no materialization), the query now filters by tag first and radius-checks the few candidates. Dense-tag sweeps (e.g. hundreds of letter entities) keep the spatial path. Result-identical by the same invariants radius-undefined queries already rely on: the tag index is authoritative for tag membership, and the octree mirrors WorldFeetPosition synchronously (spatial hooks fire on write) so radius predicates agree; both paths id-sort. Elevator-shaped bench (1200 entities, 120 queries/tick mix): 104.0 → 48.2µs/query, 2.16x; selective player queries individually collapse ~50x.

  • SpatialOctree.queryRadiusNode iterates node entries with Map.forEach instead of for (const [k, v] of map) — the destructuring form allocated an iterator + tuple per entry per node per query (the 6.6%-self iterator next slice in the same profile, 976/1286 edges from queryRadiusNode). Same insertion order, same results. SpatialOctree.size + getSpatialPopulation() expose the indexed-entity count for the path choice.

  • matchesAllTags skips the every() closure for the dominant single-required-tag case.

  • New pins in query-fast-path.test.ts: randomized brute-force-reference equivalence across both paths (tags/anyTags/multi-tag/untagged/no-radius, with entities moving between rounds), stale-octree-candidate semantics preserved exactly, and a perf-shape pin (selective tag query in a 400-entity world does < 20 component gets; the spatial path's ~800 fails on the previous code). scripts/bench-query-hot-path.ts reproduces the profile shape on demand.

  • Measured and rejected: per-candidate resolveEntityIndex + getByIndex in the query loops benched 0.75–0.93x (the saved string Map.get ≈ the added call overhead once string hashes are cached) — the win is cutting candidates, not cheapening per-candidate reads. Server CPU wins from the 101-players-in-one-AOI profile (42.84s bun CPU profile, server ~94% busy — https://github.com/earth-kiln/main/pull/6677#issuecomment-4627421301). All four changes are behavior-identical and pinned by fail-on-parent tests; combined ~10–13% of server CPU at that load.

  • Terrain desired-chunk dedupe (terrain/streaming.ts, ~6% of profile): computeDesiredChunks walked the full LOD spiral once per player — 101 co-located players did 101× identical work, and every duplicate center's candidates lose all upsert tie-breaks anyway (same lod/distance/priority, strictly higher order). Player chunk coords are now deduped (first occurrence preserved) before the spiral walk; the chunk budget still scales with the real player count. The stationary-streaming cache key for non-voxel generators is now the ordered-unique occupied cells + player count instead of the per-player position list, so co-located movement inside the same cells (and pure vertical movement — surface coords resolve to [cx, 0, cz]) no longer forces a full recompute. Voxel keeps the per-position key (its Y resolution carries per-player hysteresis state).

  • PacketWriter capacity cache (room-wire-codec.ts, ~2.4% profile self in the ArrayBuffer.byteLength getter): every wire write funnels through ensure(), which read this.buffer.byteLength (a native getter) per write. Capacity is now a cached number field. Bytes on the wire are unchanged.

  • registry.list() memoization (ecs/registry.ts, ~1.9%): replication entity classification (entityHadVisibleComponentsAtTick / entityHasVisibleComponentsNow) walks the component list per entity per drain, and list() ran Array.from per call. Now memoized and invalidated on register(), matching sortedByIdCache.

  • Object-map patch journal single-entry fast path (replication/object-map-patches.ts, ~1.5%): mergedPatchFor cloned the journal patch once per (component, entity, receiver-class) even when the drain window held exactly one entry — the steady-state case. Journal entries are immutable after record() and all consumers (the wire writers) only read, so the single-entry window now returns the stored patch directly; multi-entry windows still merge onto a fresh clone.

  • Publish pipeline: v4/skills/<semver>/skills.json now mirrors the SHA-keyed bundle the catalog names for that semver (ETag compare, refresh on mismatch) instead of being write-once. Same-semver re-publishes (catalog SHA bumps) now reach Savi's per-version instructions; previously 5.0.0 kept its pre-rooms draft skills forever.

  • buildScriptedMaterial now runs a build-time finiteness walk over the returned material's node slots (scripted-material.ts): a NaN/Infinity baked into a const or uniform — the classic cause is JS arithmetic on a TSL node (scale * 2.3 instead of .mul(2.3)), which coerces the node to NaN and compiles into a WGSL literal the GPU rejects — reports one scripted-material-runtime-error diagnostic naming the slot with a teaching message and returns null, so the entity takes the existing Std/PBR fallback instead of shipping an invalid pipeline.

  • Scripted materials are now named Material:Scripted(<ref>) (material-key.ts). three labels GPU pipelines and shader modules with the material name, and WebGPU device errors quote those labels — so device-timeline failures carry the script ref.

  • createRendererBackend gained an onDeviceError hook on its initialize options; the render worker (renderer.ts) uses it to extract a scripted-material ref from uncaptured GPU errors (invalid pipeline at Queue.Submit, async WGSL parse failures) and route it through the existing build-failure path: park to Std/PBR, one attributed diagnostic, auto-unpark on the next script edit. Previously these errors were a bare per-submit console.warn that never reached Savi.

  • One skill line in custom-materials.md: ctx.param() returns a node — scale it with .mul(2.3), never JS *.

  • New src/tome/server-behind-monitor.ts: the room runtime feeds each simulation pump frame's measured tick work (getServerRuntimeTelemetry → lastTickWorkMs / lastSteps) into observeServerTickHealth. The gate is wall-clock coverage — when ticks over budget×1.5 have covered ≥80% of the trailing 5s window's wall time (and at least 4 over ticks, so one anomalous monster tick can't impersonate an episode), the monitor enters a "behind" episode: one warn entry in the runtime log (getLogs(), code server-behind) and one DM to Savi over the same deduped rail ObjectAPI's notifyDmOnce uses (TomeDmNotifierResource + TomeDmNotifiedKeysResource, key server-behind#<episode>). Coverage rather than frame counting because the production pump is a blocking setInterval whose frames coalesce under exactly this load (8-step frames every 8×tickMs) — the detector stays reachable at any pump cadence and tick rate. The message carries the measured slow-tick ms, the budget, and the tick rate. The episode ends only when the window recovers (over-work coverage ≤25%), and DMs are additionally spaced by a 60s re-arm cooldown — a flapping server can never mint more than one hidden Savi turn per minute.

  • input-config.ts resolveKeyBinding now resolves every KeyboardEvent.code in the engine Key constant (Enter, Escape, Backspace, F1-F12, punctuation, Meta, nav keys — ~35 codes that KeyCodeSchema blessed but the resolver silently dropped). Tab stays unbindable (reserved by the Spawn chrome for the overlay toggle).

  • The action keys path now normalizes tokens the same way the axis path always did — interact: { keys: ["Enter"] } (the taught example in types.ts) previously produced ZERO bindings while validating clean and persisting; the key was dead at runtime with no diagnostic. normalizeToken is collapsed into resolveKeyBinding, so actions, axes, and modifier keys all resolve identically; case-insensitive shortcuts ("ENTER", "Shift") now resolve too.

  • Code-shaped tokens outside the Key constant (e.g. NumpadComma) still pass through verbatim — raw capture records ev.code strings the constant doesn't enumerate.

  • input-config.test.ts iterates every KeyCodeSchema member through both the actions and axis paths asserting a binding is produced, so the schema and resolver can never diverge again.

  • Interpolation now speaks one authored shape: { teleportThreshold } | null (the spec/schema/prompt contract). writeDrawInterpolation normalizes any authored value into the kind-discriminated DrawInterpolation component, so the component invariantly stores the internal form and readers no longer depend on the tolerant dual-shape parser for new writes. Engine call sites passing the internal { kind } shape keep working.

  • getProperty('interpolation') echoes the authored shape back ({ teleportThreshold } when enabled, null when disabled) instead of leaking the internal component — spawn(... getProperty echo ...) used to fail PROPERTY_VALIDATORS.interpolation on the round trip.

  • Fixes a latent crash: setProperty('interpolation', null) (the documented disable) wrote null into the component verbatim; a second write then read .kind off null and threw. null now normalizes to { kind: "disabled" }.

  • The zoo's interpolation exhibits (the only authored content writing the internal { kind } shape) are migrated to the taught shape, clearing the third of three permanent validate_spec errors on the zoo.

  • VoxelMaterialDefSchema learns tags (mirrors VoxelMaterialDef.tags in types.ts). The strict patchTerrain validation no longer rejects the taught ladder/climbable pattern (addMaterials: { oak_ladder: { tags: ['climbable'] } }) with unrecognized_keys — previously the material was silently dropped and queryVoxels({ tag }) found nothing forever.

  • ParticlesSpecSchema.blend enum gains "subtract", matching the engine's particle BlendMode vocabulary (alpha/add/multiply/subtract, screen = legacy alias for add). Subtract-blend particles rendered live but were rejected by the kiln in-area persistence gate and erred in validate_spec.

  • LayoutSpecSchema rewritten from the pre-4.3 legacy shape (required maxExtents, x/z only, removed fit still described) to the engine's LayoutSpec: minExtents/maxExtents both optional, all three axes. The taught layout: { minExtents: { y: 2 } } no longer fails persistence with "maxExtents Required". Legacy fit specs still parse (non-strict) and the engine still honors them at runtime.

  • Wall-hole schema drops the bottomY-required refine: the engine defaults the sill to 0 (bottomY ?? y ?? 0 in renderer geometry and primitive colliders), so holes: [{ x, w, h }] is valid authored form.

  • SpriteSpecSchema learns the 10 engine sprite fields it omitted: playing, speed, loop, time, pixelsPerUnit, anchor, tint, opacity, blend (5-value SpriteBlendMode), layerMask. These now appear in Savi's generated <object-properties> reference and survive schema round-trips instead of being stripped.

  • New parity suite spec-schema-engine-parity.test.ts validates the zoo spec and DEFAULT_GAME_SPEC against GameSpecSchema so authored engine content can never drift schema-invalid again, plus per-surface regression tests for each gap above.

  • Third-person camera occlusion gains an asymmetric envelope (stepOcclusionEnvelope in tome/systems/camera-behavior.ts): pull-in stays tick-exact (the camera never looks through or sits inside a wall), pull-out now waits for 4 consecutive clear ticks (~130ms) and then eases back out at 2/s — matching the built-in spring arm's pushOut taste. Previously the sphere-cast clamp was applied raw every tick in BOTH directions, so any occlusion hit (walls, props, terrain crests at grazing angles) warped the camera in and instantly back out, oscillating at tick rate. While no cap is active the cast value passes through untouched, so zoom and spring-arm feel are unchanged; behavior-authored state.collisionDist still bypasses the envelope entirely.

  • The renderer's first-person collapse is now a blend, not a threshold (camera-smoother.ts): the rotation source crossfades from the orbit lookAt to the raw pointer orientation via a smoothstep of orbit distance across [0.35, 0.65] with a 50ms time ease. The old hard switch at dist > 0.5 sat exactly on the occlusion clamp floor (0.5), so a hovering hit distance flipped the rotation source every frame — a ~1.2 rad snap whenever lookOffsetY != heightOffset (every custom orbit camera).

  • Tests: camera-archetypes.test.ts drives a real Rapier wall through an occlusion sequence (tick-exact pull-in, no instant restore on a single clear tick, pinned under tick-alternating occlusion, monotonic eased recovery, tick-exact re-occlusion); camera-smoother.test.ts bounds the per-frame rotation step across a collapse to the 0.5m floor and under a hit distance hovering at the old threshold. All three fail on the parent commit.

  • FX particle decks no longer participate in scene fog (debug day 5.0 #26, Savi's storm-cloud report). applySpriteBlendMode enabled fog for "alpha" content only, so past the fog falloff the alpha deck's fragment mixed the per-particle tint into the haze color — the population's color binding read as "ignored" (near-white/pale-blue puffs no matter what was bound, even full red) while the SAME population's additive decks stayed vivid because add/subtract/multiply never fogged. The tint itself was never dropped: the binding rides the attribute/storage lane and applySaturationWeightedRecolorTint compiles correctly on every deck (verified end-to-end through the real CPU backend, the FxVM encoder/arena upload, and the backend's real WGSLNodeBuilder — the select/toVar miscompile suspected during debug-day triage is NOT present in this path).

  • Mechanism: applySpriteBlendMode(material, blend, options?) gains a fog opt-out (sprite-blend-mode.ts). Both fx deck creators pass { fog: false } — the CPU batches (renderer/three/particles.ts createBatch, which also covers trail batches) and the GPU batches (renderer/three/fx-gpu/render.ts createBatch). Sprite SURFACES (single sprites, sprite batches, cutout foliage) keep the existing vocabulary: alpha surfaces still fade into the haze like the meshes around them.

  • Premultiplied compositing is untouched: alpha decks still output rgb·a after the tint (One/OneMinusSrcAlpha factors), so there is no fringing change against bloom. Zero-recompile law upheld — fog is set once at deck-material creation, and alpha decks no longer pick up a pipeline-cache-key dependency on scene fog presence (toggling scene fog used to rebuild their pipelines).

  • Regression detector: renderer/__tests__/fx-tint-alpha-deck-wgsl.test.ts transpiles the real CPU and GPU deck materials through the backend's WGSLNodeBuilder with a production-parity builder.fogNode wired, pins tint-feeds-output + premultiply-present + no-fog-stage, runs the WGSL dominance analyzer over both stages (the r0.184 select/toVar hazard detector), and drives the real CPU fx pipeline to assert the bound color reaches the per-instance attribute.

  • Taste tradeoff, named: distant alpha-blend fx (e.g. ground smoke far away) no longer fade into the haze. They already sat next to additive embers that never fogged, so effect-internal coherence wins; if per-effect fog participation is ever wanted, the right shape is a per-sink knob, not a blend-keyed default.

  • Parented assemblies no longer tear apart in the drawn frame (DD5 P1, dump 53efcbae — a parented car's parts "slowly come apart then snap back"). Root cause: the renderer's presentation smoothing (synthetic-transform-delta) ran an independent timeline per entity — own snapshot ring, own arrival clock, own teleport threshold. A driven vehicle carries the control-target DrawInterpolation (teleportThreshold 20) while its runtime-spawned children keep the implicit default (threshold 2), so any burst-delivery correction ≥ 2 m (4g stalls at race speed) snapped every child to the corrected pose while the parent slewed on its own clock — the assembly visibly came apart on every correction and reconverged, repeatedly. Reproduced end-to-end (real server netcode + client runtime + prediction + render channel + smoothing) at 0.694 m of drawn separation.

  • Fix: hierarchy-consistent interpolation. tome/parent now rides the render channel (forwardToRenderer: "always", registered in createRendererRegistry), and parented records store their snapshot ring parent-relative, composing every displayed frame against the parent's displayed pose (child = parentDisplayed ⊗ relDisplayed). The child rides the parent's drawn timeline — corrections, folds, and teleport decisions included — while its own clock only animates parent-relative articulation (wheel spin, wing pitch). Multi-level chains compose in depth order; bone-attached children keep their world records (the renderer attachment system owns them); broken chains (parent despawn, spawn races) demote to world space and self-heal back to rel when the parent's timeline resolves.

  • Lag-compensation note: root entities (remote players — the actual rewind targets) are byte-identical to before; parented children now display on the parent's clock, so a child's drawn pose is no longer guaranteed to equal its own sampleAt(remoteViewTick) reconstruction (it equals parent.sampleAt ⊗ rel instead — rigid-correct).

  • New fail-on-parent suite engine/runtime/__tests__/hierarchy-drawn-pose.test.ts: full-stack harness (real server netcode + input acks + client prediction/resim + ecs-sync + render-channel + smoothing) asserting the child's displayed world pose stays rigid to the drawn parent across interpolated frames AND through per-tick corrections under 4g stall/burst delivery. The driving case fails on the parent commit at 0.694 m; anti-vacuity assertions pin that corrections actually flowed (acks > 10, displayed parent tracked server motion).

  • The night-sky bake (skyNightBake — the milky way band/wisps/dust equirect, 2048×1024 at the high tier) is now reconstructed with a 4-tap B-spline bicubic in sky-node.ts (sampleNightBakeBicubic, weights mirrored from three's textureBicubic at a single explicit level) instead of one hardware bilinear tap. Root cause of the "night sky looks like a pixelated 1080p texture" report (debug-day 5.0 #52): once desktop tiers started rendering at native DPR (debug-day #53), one bake texel spanned ~5–7 physical pixels on HiDPI desktops, and bilinear magnification of the bake's near-Nyquist fractal content showed the texel grid across the whole dome — while the per-pixel clouds, analytic stars, and starlight grain stayed sharp around it, which is exactly the reported contrast. Night-only symptom because the bake is the only textured layer in the night composite and only contributes when the sun is below the horizon; the day sky is the smooth sky-view LUT + per-pixel sun/clouds.

  • Cost: zero memory delta at every quality tier (bake sizes unchanged), one textureDimensions + three extra bilinear taps + ~30 ALU per sky-dome pixel, only inside the uniform night gate (which already runs ~13 noise octaves per pixel). The equirect longitude wrap still rides the sampler's RepeatWrapping, and all taps stay at explicit level 0 (no derivative seam), branchless — no select/toVar exposure.

  • WGSL codegen pin added to sky-node-wgsl-structure.test.ts: the night bake must take exactly 4 if-gated taps in both the background and IBL-capture fragments (fails on a single-tap bilinear build); the existing noise-gating and dominance-analyzer pins cover the new expression tree.

  • nightTextureSize doc updated: past Nyquist the knob no longer buys apparent resolution — reconstruction owns that now; bump the width only for genuinely finer authored content.

  • Stretched sprite quads (align: "velocity" / "segment" / "axis") now degenerate gracefully when their direction goes end-on to the camera. The long-axis law is unchanged where it matters — size + |direction| · stretch meters with the WORLD-space magnitude, so the painted streak length never changes as the camera orbits — but the parent renderer kept that full length even when the direction's screen projection vanished, leaving the quad's angle ill-conditioned: full-length streaks whipping around the screen for particles moving at/away from the camera, axis-aligned rain viewed from above painting full-length horizontal streaks, a path segment viewed down its axis drawing a full quad perpendicular to the bolt, and a hard snap to screen-x at the atan guard threshold. The stretch term now eases to zero (branchless smoothstep over the projected fraction — sin of the direction's angle to the view axis — below 0.25 ≈ 14.5°), so an end-on streak renders as the round size × size billboard, smoothly, with no popping. The basis math is collapsed into one shared builder (engine/materials/stretched-sprite-basis.ts) consumed by both the CPU particle batches and the GPU fx batches, with WGSL codegen pins + a numeric mirror of the law in fx-velocity-align-wgsl-dominance.test.ts.

  • Root cause of "authored terrain normal maps read too weak" (#6677 thread, bug #59): the per-layer chain (NRO sample × normalStrength → candidate weight blend → whiteout combine onto the heightfield normal) is full-strength at default everywhere except the detail-normal distance fade — mix(flat, normalTs, 1 - smoothstep(30, 60, viewDist)) zeroed the detail normal by 60 m camera distance, so authored normals only ever lit a bubble at the player's feet. Tangent basis on the grid substrate (T=+X, B=+Z, N=+Y) verified correct.

  • Fade widened to 60–140 m and eased to a 0.35 floor instead of flat (TERRAIN_DETAIL_NORMAL_FAR_FLOOR): macro normal response survives at range; the NRO mip chain plus the 0.4 roughness floor already handle the single-pixel specular sparkle the old fade-to-zero guarded against.

  • A PBR-library textured layer with no authored NRO (never authored, not yet streamed, or the can't-join fallback) now auto-derives its tangent-space detail normal from the signal already in the albedo array: packed material height (alpha) when albedoAlphaIsHeight, else albedo luma. Two forward-difference taps with explicit gradients (WGSL-legal in the weight-gated branches), slope scaled to 0.05 m apparent relief × the existing normalStrength knob — no new spec surface; normalStrength: 0 opts a layer out. Mip minification collapses the differences at range, so the derived normal self-fades. Tier gating preserved: LOD 0 all candidates, LOD 1/mobile dominant candidate only, LOD 2 and zero-PBR libraries unchanged at zero cost (+2 albedo grad taps per candidate only inside the no-NRO branch).

  • Named taste checks: far-floor 0.35 vs specular shimmer at range (drop the floor before narrowing the band if it shows), luma-derived bump on legacy layers inside PBR libraries is a deliberate look change, and the open suspicion that the NRO G-channel is inverted vs the MCDN bake convention still needs one in-scene look.

  • Root cause of the debug-day "contour rings on terrain at grazing angles" (#6677 thread, bug #58): the macro albedo variation (fractalNoise2 at 5/m, second octave 10/m, ±7% luminance) is a per-pixel value-noise lattice sampled with no band-limiting — at grazing view angles the screen footprint sweeps through the lattice Nyquist as a function of distance and prints world-anchored concentric ring moiré (0.1–0.2 m pitch), decaying to per-pixel static at the horizon. The parallax march was a secondary contributor inside its 18 m bubble: every pixel entered the ray at exactly rayHeight=1, offset=0, quantizing intersections into shared stepSize bands.

  • fractalNoise2Banded replaces fractalNoise2 (deleted — no other consumer): each octave fades to its mean (0.5) over a 0.25→0.75 lattice-units-per-pixel footprint band. Mean-preserving — far/grazing pixels converge to the same average shading the unfiltered noise dithered around. terrain.ts computes the footprint (max |dFdx|,|dFdy| of worldPos.xz) once in uniform control flow and feeds the LOD0 fractal, the LOD1 single-octave macro, and the legacy-library layer dither (6/m lattice, same aliasing class). ALU-only, no new texture taps, all tiers.

  • The parallax march entry is jittered per pixel (interleaved gradient noise on screenCoordinate, no texture tap): residual step quantization decorrelates into sub-band noise the existing 2 binary refinement taps converge away. Step counts and tiering unchanged (8→16 adaptive, desktop LOD 0 only).

  • Rider fix found by the new WGSL dominance gate: the biplanar side-axis pick used select(), whose r0.184 if/else lowering was emitted inside candidate 0's side-projection branch — candidates 1/2 read zero-initialized component vars (wrong side-uv gradients → wrong mip/aniso on steep faces) whenever candidate 0 skipped its branch. Now branchless (mix on a 0/1 float). The terrain heightmap material now runs the statement-dominance analyzer in tests (terrain-relief-shading.test.ts), same gate as water/voxel/fx.

  • New path() emission source (engine/fx/path.ts + FxSourceShape kind "path"): a script-supplied polyline — or list of polylines — in meters from the anchor. The engine has no opinion about the shape: lightning channels, crack webs, vines, and laser graphs are all just points the script computed. Each spawned particle is one SEGMENT (a consecutive point pair) — positioned at the segment midpoint and carrying seg (the segment vector), along (0..1 toward its polyline's tip), and channel (the index of its polyline) as spawn attributes, set before init runs. Emission walks the segments in point order and restarts at the first segment on every burst volley, so a volley of exactly segment-count particles draws the whole path once, and populations sharing the same points trace the same shape (a hot core inside its soft halo). The points are read live from the def: a fx.params patch re-runs effect(ctx) and the swapped def drops the cached segments, so the next volley traces the new path — that's the re-aim/re-roll hook.

  • New sprite align: "segment": the quad's long axis follows the particle's per-particle seg vector (long-axis length = size + |seg| · stretch, so stretch: 1 spans the segment exactly). Rides the existing per-particle-direction render path (ParticleGroupAlign 2) with the snapshot writer sourcing the direction from seg instead of the velocity — zero renderer changes. Validation requires seg to be defined (path source or init/inherit).

  • GPU routing: path sources and segment-aligned sprites are CPU-backend populations (explicit ineligibility reasons, like ribbons); segment counts are tiny and lifetimes short. Effects remain visual-only per client. One-shot path draws (and windowed re-volleys) keep deriveFxProgramCompletionSeconds finite, so they self-reap through the existing wind-down/fx-reap machinery.

  • fx skill + examples: a forked-lightning worked example that computes its own midpoint-displaced channel + forks in ~15 lines of script (the creative constants — kink, fork count, fork run — live in the script where they can be taste-tuned) and feeds path(channels) to a white-hot core + tinted halo with the flash light; the tesla-fence example re-scoped to straight beams. Chain lightning is computing more paths.

  • api.getIK() no longer reads DrawIK. DrawIK is render-plane state (realm-local, asset-arrival-dependent — ik-targets' own invariant is "no gameplay reads DrawIK"), but the public API fed it straight into mode-both behavior code: a behavior writing ik.target/ik.reachable into replicated state produced different values per side depending on whether/when each realm loaded the model — a permanent replicated-state mismatch resim cannot reproduce. getIK now resolves from IKIntent + replicated transforms at the shared TomeTick basis via resolveIKTargetPoint, the single resolution implementation shared with the DrawIK derivation (which layers the bounds-center/bind-pose-bone anchor on top as a render-only refinement). The bind-pose estimateIKReachable is deleted; reachable is constant-true at the sim plane.

  • view_live_scene captures now carry an unready-scene-assets annotation in the existing note channel (renderer worker → scene-view.ts → tool caption, no chat changes). Savi kept looking at a wall whose Magic CDN texture was still generating, reading the placeholder as broken work, and fighting it.

    • Truth source is RendererAssetService.getUnreadySceneAssets() — subscriber-scoped (every scene consumer holds a subscribeOnce subscription until its asset loads, the same liveness criterion the retry loop uses), so the note only names assets something rendered is waiting on right now.
    • Classification matches the pipeline's real signals: a /cdn/ asset that hasn't loaded is "still generating" (the Magic CDN generates on first fetch and the service retries CDN assets forever); a terminal generation failure — the 502 tombstone, now captured per retry entry off three's FileLoader HttpError — reads as FAILED, never as generating. Non-CDN assets read loading/failed.
    • Elapsed time rides a new textureFirstRequestedAtMs first-request clock (mirror of the model one): "texture-wall-stone-mossy.png ~42s", stable across retries.
  • Applied to every successful capture source (viewport, camera, frame) in captureSceneViewFrame; wording lives in the pure scene-view-asset-note.ts builder, pinned by tests alongside the service classification.

  • api.pushLook now returns the same deterministic layer id on server and client (minted from the replicated TomeIdSeq, the uniqueId lane) instead of "" on the server and look/N from a module-level counter on the client. Behaviors run mode-both and store the return value in replicated state (state.lookId = api.pushLook('noir')), so the side-asymmetric value guaranteed a permanent replicated-state mismatch on that entity; the unrolled counter also meant a resim replay minted a different id than the timeline whose cleanup timer captured the old one — at fire time it cleared a layer that no longer existed and the real layer leaked (stuck screen effect). The look layer itself stays client-only presentation; pushLookLayer now requires an explicit id (the counter fallback is gone — every caller passes a stable or deterministic id).

  • Author-time misattachment affordance (debug-day ledger #115): authoring a parent link (spawn({ parent }), attachTo/setParent, setProperty("parent")) now runs a geometric sanity check (tome/api/attachment-sanity.ts) and warns through the mutation-warn rail (getLogs) when the link can't plausibly be an attachment. Two checks, signal only — no auto-correction, no spec mutation, no per-tick cost:

    • Escaped child: the child's AABB is fully disjoint from the parent's in the parent-local frame, with a closest-approach gap longer than one full parent-length (2m floor). The classic shape is a world coordinate passed as a parent-relative offset; the warning names both objects, the authored offset, the gap, and suggests "sibling at that position, or a smaller local offset". Skipped for explicit-pivot children (orbit rigs) and bone/socket attachments.
    • Oversized child: the child's bounding volume is ≥25x the parent's (≈3x per linear axis) AND the child is ≥1.5x bigger on every axis. The per-axis gate keeps the legitimate big-child-on-thin-spine archetype (canopy on trunk, sail on mast) quiet. The warning names both objects, both sizes, and the ratio.
  • Both checks compare unscaled local raw bounds (readLocalRawBounds, newly exported from tome/api/world-bounds.ts) in the parent-local frame where the parent's own scale cancels; the child's authored local scale is applied. Either side without derivable drawable bounds (anchor/manager objects, models whose bounds haven't landed) is skipped silently — false positives teach Savi to ignore the channel, missed warnings cost nothing.

  • Spec-load (applySpec/rebindExplicitParentObjects) does not pass through the checks, so existing persisted content never re-warns on room boot.

  • Per-script behavior fault granularity (debug-day #100): fault/park state is keyed (entity, script) end-to-end — one throwing script no longer kills its siblings' hooks. The dispatch loop continues past a faulted entry, timer/event/job callbacks carry their arming script's ref (withActiveScriptRef), park logs name the script in getLogs, and editing the parked script unparks exactly that scope. Sweep-E rollback semantics preserved (side-local deterministic re-marking).

  • Singleplayer rails are now mode-agnostic end-to-end: notifyDm/notifyDmOnce forward from the client authority over the existing Command envelope (tome.dm.notify, server-side once-key dedup in the shared resource set, 20/60s rate window, 2000-char cap, every drop surfaced) (#120); spec mutations forward over a new RoomClientOpcode.SpecMutations to the durable sink while the divergent server-mirror echo is discarded — one persistence mechanism, multiplayer-equivalent (#121).

  • Terrain-edit persistence hardened in two layers: the one-strike permanent kill switch is gone — failures retry with exponential backoff (1s→60s at 30Hz) for the room's life, the write queue survives failures, degradation/recovery surface via getLogs + deduped DM (#122); and a failed boot load is no longer treated as "no durable data" — saves are blocked until a verified base loads, so a transient storage error at boot can never overwrite a previous session's edits (#123).

  • Push-aware delivery (#118): a mismatch tick whose every diverging row classifies push (client provably never wrote the leaf), with no presence rows, no engine-mirrored components, and no buffered writes touching them, is adopted directly instead of booking a full rollback+replay — provably the same end state at O(components) cost. Comparator untouched; F3 shows adopt=N/s. The moving-NPC-near-idle-player resim storm drops to zero.

  • Room engine identity (#119, kernel half): rooms report the engine identity their container booted with in register/heartbeat metadata, so the registry can pin every resolution path (player iframe, Savi fan-out, exec, prewarm) to one DO for the room's whole life — mid-session engine publishes no longer bifurcate auto-update apps. Explicit engine switches force-drain live rooms via cooldown-gated container stop so "switch now" means now (#124, cf-edge/kiln side).

  • Voxel overlap hooks (onOverlapEnter/onOverlapExit) now fire on mantle places via engine-neutral runtime discovery (engine/physics/trigger-overlap.ts) — this was the named gate on the mantle default flip. Mantle trimesh trigger shapes run as their convex enclosure per mantle's primitives-first contract.

  • Renderer hot-path cleanups: op-driven material-compile queue scoped to entity anchors (zero-recompile contract preserved; whole-scene per-frame traverse on animated draw ops eliminated), decoration rebuild guard re-keyed on revisions/identity (per-frame JSON.stringify deleted), look-active drawing-buffer-size Vector2 hoisted to a module scratch.

  • Savi truthfulness rails: saveDocAndNotify retries the room poke once and reports per-room live-update failures in the tool result instead of claiming plain success; a failed draft-storage read is a first-class error that can never silently clobber the published file; model-collider GLB load failures surface via the diagnostics rail with a wire-replicated placeholder status instead of degrading silently to a unit box.

  • Schema parity: AudioSpec carries the full engine AudioIntent surface (refDistance, rolloffFactor, bus, layerMask, priority, vibe paused); PlaceDefSchema carries physicsEngine and brush-painted fields — both were one mutation away from the kiln gate rejecting valid specs.

  • Hygiene: chat v2 debug gizmo auto-refreshes while a turn is active; lag-compensation recording idles on singleplayer worlds; raw NUL bytes removed from physics-dispatch source (with a source-byte hygiene test); dead getEnvVarsForVariant and leftover gt3 debug logging deleted; selection/hover outline post pass dominance-gated.

  • Vehicle prediction parity (debug-day #131): api.getVehicleSpeed() now reads PhysicsVehicleConfig.vehicleSpeed — published by the wheel-state writeback, replicated, rollback-restored, f32-quantized — instead of rapier's controller-internal speed, which resim recreation zeroed (every correction re-injected engineForce/steering/downforce divergence: the driving-car vertical-sawtooth storm). And fresh physics bodies apply their full body/collider config before their first step — they previously stepped tick one at collider-density mass with zero damping (~29× forces), kicking vehicles at a different tick per side when a client materialized one mid-drive.

  • Authored custom cameras keep their framing (debug-day #127): the renderer-authoritative "orbit camera" path now requires the camera behavior to actually read the look axes (input.axes.lookX/lookY, latched per compiled behavior) before it may take over — shape-matching on pointerLock + yaw/pitch state alone hijacked fully-authored cameras (shot-follow, cinematic rigs) and discarded every pose they wrote. Genuinely mouse-driven cameras keep the display-rate path unchanged.

  • Targeted juice reaches multiplayer (debug-day #128): queueEventAdd fires now mirror into the change log at commit, so server-fired screenFlash (player/nearby/place audiences), purchase prompts, and server-side particle bursts ride the wire — they previously fed only the world-local drain queue that nothing server-side consumes (singleplayer masked it because the client is the authority). Event rows for predicted entities bypass the ingest refusal and dedup on the established tick:kind:source#seq key; unresolvable player-audience targets warn instead of dropping silently.

  • Server event queues are bounded (debug-day #129): undrained event fires expire at commit on the component's own ttlTicks, stamped on the local commit clock (safe across prediction lead and resim replays) — server worlds leaked one row per fire for the room's life. A doubling high-water warning surfaces pathological fire rates.

  • destroy() cascades through the persisted spec (debug-day #126): destroying a parent retires every authored descendant row (children-first) with its own recorded mutation + spec-mirror removal — previously the live cascade reaped child entities while their persisted rows survived as orphans pointing at a dead parent, and orphans reload as ROOTS at world origin with parent-local rotation read as world rotation (the "car parts pinned at 0,0,0" corruption, and the long-unreproduced 4.6 rotation-persistence report). applySpec now warns into getLogs when a row's parent can't resolve, so already-corrupted documents self-identify and Savi can sweep them.

  • ObjectAPI.destroy() now cascades through the persisted spec: children persisted as their own parent-linked spec rows (the flat shape spawn() persists under withPersistence / run_script persist) leave the spec — and the recorded mutation stream — together with their destroyed parent. Previously the live cascade reaped the entities (destroyEntityWithHook recursion never re-enters destroy()), but only the target's row was mirrored/recorded out, so child rows survived as dangling-parent orphans. Every later load re-instantiated them with an unresolvable parent; hierarchy-solve skips children whose parent entity is missing, so their authored LOCAL feetPosition rendered as world coordinates — parts pinned at the world origin forever (DD5 ledger #126, app bf76ad30 "Tucker Circuit", dump 12ae2d30's test-veh/tc-* rows). The cascade covers grandchildren and rows whose live entity is already gone; rows are mirrored children-first so an undo replay respawns the parent before its children.

  • applySpec surfaces dangling parent links: after the full reconcile + rebind pass, any spec row whose parent is still unresolvable records a runtime warn (getLogs-visible to Savi, tome.reconcile.parent_missing server-side) naming the row, the missing parent, and the consequence. Rendering is unchanged (the parent may legitimately be authored later) — the signal is the fix, per graceful-degradation-surfaces-signal.

  • New pinning suite singleplayer-assembly-adoption.test.ts: full-stack server-mirror + client harness (real netcode pipe, real tome feature via glue, real rapier vehicle, real renderer smoothing) proving a behavior-built parented assembly stays rigid in sim AND drawn poses through the singleplayer join-snapshot flip and through a reload against a long-diverged mirror — the rest of ledger #126's surface, pinned healthy.

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

For Realengine v5.01 dayAtelierengine v4.64 daysSurface Tensionengine v4.52 weeksSolidengine v4.43 weeksGroovyengine v4.33 weeksContinuumengine v4.23 weeksFoundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts