Spawn
spawn / swhat we're building

pinned

start herewhat spawn isfaqfrequently asked questionsthe betthe spawn bet

updates

engine v5.2Lume7 hoursengine v5.1Connection3 weeksengine v5.0For RealJune 4, 2026engine v4.6AtelierJune 1, 2026engine v4.5Surface TensionMay 22, 2026engine v4.4SolidMay 15, 2026engine v4.3GroovyMay 13, 2026engine v4.2ContinuumMay 9, 2026engine v4.1FoundationsMay 4, 2026engine v0.1GenesisApril 29, 2026

pinned

what spawn isstart herefrequently asked questionsfaqthe spawn betthe bet

updates

Lumeengine v5.27 hoursConnectionengine v5.13 weeksFor Realengine v5.0June 4, 2026Atelierengine v4.6June 1, 2026Surface Tensionengine v4.5May 22, 2026Solidengine v4.4May 15, 2026Groovyengine v4.3May 13, 2026Continuumengine v4.2May 9, 2026Foundationsengine v4.1May 4, 2026Genesisengine v0.1April 29, 2026
← All posts
← All posts

engine v5.2.0

Lume

July 10, 2026

The renderer, rebuilt in-house: your worlds look the same and run on a fraction of the GPU — faster loads, cooler machines, longer battery.

what's new

The big one: the rendering engine was rebuilt in-house.

  • Same look, faster loads — and custom materials keep working exactly as documented: measured against the full catalog, every published custom material and look keeps working exactly as before.
  • Games use a fraction of the GPU power they used to (typically 30–95% less), which means cooler machines, quieter fans, and much longer battery life at higher frame rates.

And everything else that lands with it:

  • Switching camera kinds (e.g. a first-person ↔ third-person toggle via setCamera) now cuts instantly to the new camera instead of gliding the view through your character. Zoom easing and camera smoothing within one camera are unchanged.
  • Painting grass and flowers feels completely different now: strokes go straight into the massive GPU grass system, so you can paint whole meadows — hundreds of thousands of blades and blooms — with instant feedback under the brush.
  • Erase works both ways: clear the flowers you painted, or thin out naturally-growing grass to carve paths and clearings.
  • Every decoration layer in your world gets its own Foliage brush, so you can hand-shape where the grass grows.
  • Edits to generator scripts now reach everyone in the room. Before, if you (or Savi) edited a script that generates scattered objects or scripted spline shapes, the world quietly split: your server kept the old shapes' fingerprints while every player's game rebuilt with the new script — so flowers, rocks, cloud-seas, even player rigs could turn permanently invisible for everyone else, surviving rejoins, with no error anywhere. Now a generator edit rebuilds those objects for the whole room the instant it applies, and each rebuilt piece remembers which script version made it, so the engine can tell "still syncing" apart from "actually broken" instead of leaving things invisible.
  • Grass is alive now! Fields sway and gust with your world's wind direction — turn the wind and the whole meadow leans with it.
  • One line gets you a full meadow: deck: "meadow" (or clover, alpine, autumn, lavender) plants tuned grass and flowers that inherit your ground colors.
  • Ground cover is way denser near you — thick carpets instead of sparse tufts — and every blade varies a little in color, so fields stop looking flat.
  • New worlds start with a living meadow instead of bare ground.
  • Buttons, doors, chests, shops, and vehicles now respond instantly for everyone — not just the player hosting the place. Pressing E used to wait a full network round trip when someone else's machine was simulating the object; now the interaction runs on your machine the moment you press, and the rest of the room converges through the normal write lanes.
  • When a sprite animation can't be generated, Savi now learns exactly why and can fix it right away — instead of everyone waiting on retries that could never succeed.
  • Point and spot lights now illuminate every material — matte (Lambert), glossy (Phong), cartoon (Toon), custom scripted materials, and water — not just standard PBR surfaces, sprites, and tilemaps. A lamp next to a toon house lights the house; lanterns reflect in lakes at night.
  • Switching a place between 3D and 2D while people are in it now updates everyone's look immediately. Before, players (and objects) kept their old-mode visuals — in a 2D world your character could turn invisible because it was still wearing its 3D avatar — until each person refreshed. Flipping back to 3D restores 3D models and profile avatars the same way. Also fixed 2D worlds silently not showing image-based (png) models placed in the world spec.
  • A lag spike no longer permanently costs you your drawn textures or scripted materials. Before, one bad stretch (a busy device, a backgrounded tab) could park a texture script or flatten your scripted materials for the whole session — things like a sprite's drawn halo or a planet's shader skin went missing until someone refreshed. Now the engine retries before parking, one stalled texture variant no longer takes down its siblings, and once the frame rate recovers, parked work comes back on its own — carefully, one piece at a time, re-parking only a piece that actually causes trouble again.
  • Walking through a portal now starts loading the destination's models, textures, and sounds immediately instead of after the server confirms the travel — and places you haven't visited yet warm quietly in the background during idle frames, current place first. Transitions hold their loading veil for less and same-dimension arrivals land with real art instead of placeholders popping in. Nothing costs the current place a frame, and nothing changes about who is allowed to travel.
  • Other players and NPCs now start moving with a visible wind-up and settle when they stop, instead of moonwalking into their walk cycle or skating to a halt — network delay reads as character weight, not lag. When someone's connection stalls, their avatar eases to rest instead of running in place. Your own character is untouched, and it stays out of the way of your own animation: actors driven by your mixer channels or an authored speedOverride are never touched, and interpolation: false disables it with the rest of motion smoothing.
  • Hits land where they look like they land: on jittery connections, damage numbers, hit flashes, and impact sounds from other players now present the same frame as the body they land on, instead of flashing up to ~130 ms ahead of it. Your own actions stay instant.
  • Behind-the-scenes: sprite loading failures now record exactly why they failed, so problems get fixed faster.
  • HUD animations driven by game state (momentum counters, timers, scale pops, fades computed in your ui.js render) update 3× more often — 30 times per second, up from 10 — so they look noticeably smoother. For perfectly smooth motion at any display refresh, keep using CSS transitions/keyframes in your UI html. New knob: api.patchEngine({ ui: { targetFps: 10 } }) (5–30) if you'd rather dial a mostly-static HUD down.
  • A second of flaky wifi no longer leaves permanent broken-image icons in your game's UI. Images that fail to load now quietly retry on their own (after 2s, then 8s), and even one that gives up comes back on the next screen change or image swap — refreshing the page is no longer the only fix. Your own onerror fallbacks on images keep working exactly as you wrote them.
  • Guards, creatures, and anything using canSee/awarenessOf now notice players the moment they come into view instead of up to a couple of tenths of a second late. Sneaking past a patrol, chases, and "the shopkeeper looks up as you walk in" all feel more alive — and the authored feel (awareness rise time, vision cones, gradual loss of sight) is untouched.
  • Lights work in 2D worlds again — lanterns pool warm light on the ground, windows glow onto the yard, a firefly carries its own drifting glow — and dark scenes sit at the same brightness they did before the renderer swap instead of reading washed-out.
  • Fixed custom materials sometimes staying stuck as flat gray/tinted after joining or refreshing a world — the engine now notices when a material didn't finish building at join and retries it automatically, including after reconnects.
  • Custom geometry that swaps textures mid-mesh (a table with oak top and weathered legs) shows its textures again, and lantern-style objects that mix glass with solid parts draw in the right order — the solid cap and base sit in front of or behind the glass like they should, instead of the glass smearing over them.
  • castShadow: false and receiveShadow: false on objects now actually work — models stop casting when told not to, and both models and primitives set to not receive shadows shade fully lit instead of darkening anyway. Worlds that turned shadows off per object for looks or performance get that back.
  • Textured surfaces no longer turn flat white when a world mixes texture sizes — the iso-town cars keep their paint and rust next to the playground's oak tables, no matter which loads first.
  • Fixed models turning entirely white when a wind-sway or other vertex-animation material script was applied — the staging-zoo pine that waved correctly but lost its bark and foliage colors. A script that only moves vertices now keeps the model's own textures, colors, and vertex paint; scripts that set their own color still win.
  • Fixed skinned characters in crowds sinking waist-deep into the ground (and rendering at the wrong size) when their script asked for an animation name the model doesn't have — they now stand on their feet in the model's rest pose, exactly like a single copy of the same character would.
  • Lights, particles, and scripts you attach to an object's children now actually run when your game boots — before, they could silently do nothing until an unrelated edit woke them up. And any script can now address a child object by the id you gave it in the spec, not just from its own parent.
  • When an asset's generation fails hard on the server, your game now stops hammering the dead asset (which was slowing everything else down, including the retry that would fix it) — Savi gets one clear message saying what happened and that regenerating the asset is the fix, and once it's regenerated it loads again on its own.
  • If your scripts spawn objects into a place nobody has entered yet (like pre-building a match arena from a lobby), you now get a clear warning telling you those objects would silently vanish — with the fix: bake them into definePlace's objects[] or create them at entry with enterPlace createIfMissing.
  • Worlds whose scripts re-set the same values on every boot no longer jam the save pipeline — the engine now recognizes "nothing actually changed" and skips those saves instead of retrying them forever, so real edits and state changes save promptly again.
  • Characters and objects with "breathing" or squash-and-stretch scale animations no longer secretly rebuild their physics every frame — worlds full of animated creatures run smoother, and idle characters stay cheap like they're supposed to.
  • When a bunch of script-generated objects appear at once (a whole block of generated buildings restocking), other players' games no longer freeze for a moment — the shapes stream in over a few frames while everything else keeps moving.
  • You can outline things in up to 8 different colors at once now (up from 4). When a world asks for more than fits, the most important outlines stay visible — your god-mode selection no longer vanishes — and Savi is told exactly which outline colors got dropped instead of them silently not appearing.

Known issues (being polished on staging):

  • In dense scenes with many overlapping transparent sprites, front/back ordering can still occasionally pop wrong — the specific reported cases are fixed, and deeper ordering work continues.
  • Water detail is temporarily simplified: ripple normals, crest foam, and caustic sparkle use placeholder patterns until the real textures are wired into the new renderer.
  • A few very advanced material scripts that reach past the documented vocabulary pause safely with a message naming what's missing instead of rendering — that vocabulary is being filled in now. Everything written from the Custom Materials and Looks docs works unchanged.
›technical notes

THE RENDERER SWAP (the 5.2.0 headline):

  • Replaced the vendored three.js r185 fork with an in-house WebGPU renderer (lume) and WGSL shader system (shade): one GPU layer talking GPUDevice directly, one frame graph per frame, storage-buffer-instanced indirect draws, async pipeline builds that never block the frame.
  • shade compiles the taught TSL-compatible node vocabulary to WGSL synchronously and deterministically on the CPU; shader build errors surface at authoring time with ref attribution instead of being swallowed.
  • The sim→renderer wire protocol, handler structure, and material-key contract are unchanged; the three-era guard industry (error interceptor, wgsl-dominance, sampler-decl culprit, MSAA resolve probe, invalid-attribute guard) died with its cause.
  • Deleted the fork build machinery (vendor/three, build:three, fork patch tests) and the three / @types/three / three-mesh-bvh dependencies.

Everything merged since 5.1.13 rides this version:

  • Remote actors ride an adaptive playout delay: the interpolation buffer sizes itself to each peer's measured arrival jitter (floor at the previous fixed 1-tick offset), so unstable connections get smooth remote motion instead of stutter, and stable ones keep today's latency. Anticipation and juice samplers resolve their history offsets through the same playout clock — one timing authority for every stacked presentation effect.
  • The magic-cdn async probe now classifies a 5xx that carries the cook-kill tombstone signature (Retry-After header once kiln ships it; the stable "process-killed mid-cook" body fingerprint as fallback) as a terminal server verdict: the verdict joins the per-URL tombstone map (the ledger-1023 quota mechanism, generalized), so re-asks answer locally with zero network until the window lifts, and every retry ladder parks on the window instead of burning backoff — the sim AssetService (previously 5s→60s forever; dig 628c4096 measured 40k+ requests against one dead sprite in 50 minutes across real player sessions), the lume texture ladder (previously a 15s cooldown forever), and the renderer model ladder (attempts frozen, mirroring its quota park). Parked assets surface once with the tombstone's teaching detail: models on the model-load-failed rail immediately (a parked-at-attempt-1 entry would never reach the attempts-5 diagnostic), textures on the texture-load-failed rail (the ledger-895 code, allowlisted but emitter-less since the lume port). Window expiry re-probes exactly once — an admin regenerate or tombstone clear resumes normal loading automatically, and ordinary transient 5xx keeps the existing ladders byte-identical.
  • The renderer camera smoother is now regime-keyed on the sim-declared camera kind (rendererCamera.kind, undefined for authored/non-interactive cameras): a kind swap CUTS every smoothing layer — the target timeline ring restarts at the new pose and renderPos/renderCenter/renderRot/currentOrbitDist/fpBlend snap — so the new camera renders at its own pose next frame (ledger 1057). Previously the smoother treated two different rigs as one continuous target: a setCamera replace between built-in first-person and third-person (distance 5.5) pose-interpolated the straight line between the head pose and the orbit pose — currentOrbitDist lerped 0 ↔ 5.5 while the snapshot ring blended the swap tick pair — a deterministic ~0.3–0.6s glide through the top of the player avatar, both directions, custom-script toggles included. The sim already cut (kind transitions clear camera state and write the new pose in one tick) and the renderer orientation already snapped to the seed yaw/pitch on kind change; the smoother was the only layer gliding. Same-kind parameter changes are untouched: orbit-distance easing (zoom, spring-arm follow-through, occlusion recovery), zoomTo sim-authority windows, place-transition tick rebasing, spawn framing, and FOV blending (god-mode enter/exit relies on it) all keep today's behavior — a same-kind full replace is indistinguishable from parameter drift on the wire and in the sim (stable compiled-behavior identity keeps camera state), so it keeps smoothing by design. Pinned in camera-smoother.test.ts: kind swap = at the new pose next frame both directions; same-kind distance change still eases.
  • setCamera/getCamera docs now state the pitch conventions: initialYaw/initialPitch are radians while getCamera() returns degrees, and the pitch sign flips between kinds (third-person positive = camera above looking down; first-person positive = looking up) — carrying getCamera().pitch into initialPitch across a kind swap needs a convert-and-negate. Doc-only: unifying the convention would reinterpret stored initialPitch values in shipped specs.
  • Behavior scripts animate entity scale every tick (breathing, walk squash) with values that never repeat, and full-precision scale fed collider signatures — so every write was a signature change → collider destroy+recreate every tick → colliderSetEpoch churn that disabled the character-controller rest-skip for every character in the place (ledger 1159, #8713). Scale components in collider signatures now quantize through quantizeColliderScale (sign + exponent + top 5 mantissa bits of the f32, round-to-nearest — adjacent buckets ~1.6–3.1% apart), applied in both physics engines (rapier buildColliderSignature, mantle runtime). The bucket moves only when scale moves materially; between rebuilds the realized collider can lag the render scale by at most one bucket (~3.2% worst case, within the existing render-vs-physics tolerance class — feet offsets keep tracking full-precision scale), and a rebuild still realizes the collider at the exact current scale. Pure f32 bit ops, so client and server always agree on when a collider rebuilds. Only scale is bucketed, and only in signatures.
  • God-mode foliage re-target: scatter brushes whose content is pure sprite cards (grass/flowers — no model, no physics, no behavior) on heightmap terrain now paint a decor:<layerId> density field read by the GPU decoration sampler (300k-instance budget) instead of minting a 500-entity scatter bed. The brush mints a paintedOnly decoration layer once into terrain.decorations (deterministic content-hash id); strokes persist as compact field data in place.fields and replicate like every painted edit. Model/behavior templates keep the scatter-bed path unchanged.
  • New DecorationLayer.paintedOnly flag: zero baseline growth — instances appear only where the layer's painted decor:<layerId> field is above zero (material bindings ignored for it).
  • Decoration sampler: painted density rides the existing tile-map storage buffer (camera-follow grid per painted layer). Positive paint force-binds candidates on any material and lifts cluster/noise thinning (bounded by the LOD ring fade); negative paint thins below the authored baseline toward bald at -1. Unpainted layers take the exact previous path.
  • Per-layer "Foliage" brushes synthesized from the place's authored decoration layers (like the Materials shelf) — paint grows a layer past its natural coverage, erase thins it below baseline.
  • New client sync lane: tome/decoration-density-sync publishes composed decor:* field snapshots to the renderer via the terrain/decoration-density component; paint→visible lands within the frame that applies the dab's tick.
  • Generator-script content joins the object-diff contract (ledger 1069 strand 2, the class killer). The diff signature carried script content only for an object's OWN primitive.kind:"scripted" (interpreter buildObjectDiffSignature), and scatterChanged was value-equality on the scatter spec — so editing a scatter-template or spline generator never re-minted the server-side generated children: the server kept DrawMesh pointers minted from the OLD source while every client derived with the CURRENT source, a deterministic non-converging mismatch (stable wrong sigs across attempts/sessions/clients, e.g. island-hero-flowers/scatter/0 deriving bf2c835280 vs expected 3a5ee21f15), invisible entities that ride the report budget into a park and re-mint identically on re-join. Now collectObjectGeneratorScriptRefs (own scripted primitive + own spline script + every scripted primitive/spline in scatter templates, incl. multi-part template assemblies, via scatter.ts collectScatterGeneratorScriptRefs) feeds the diff signature, defDependsOnChangedScripts, AND the scatter-change hint, so a generator edit tears down + re-mints the bed's children synchronously inside the same applySpec (instant spec→ECS, no queue). Spec-spline products already re-lofted through expansion buffer inequality; that parity is now pinned by test.
  • Mint-from-the-recipe invariant (part 2): all scripted-primitive mint sites (interpreter applyAppearanceProps + live setProperty, now one shared mintScriptedPrimitiveRecipe) derive the pointer signature from the recipe as it will exist AFTER sanitize + wire round-trip, and re-store that wire-faithful value (#8231's stored-value-IS-the-wire-value discipline applied to derive inputs). sanitizeWireSafeStateBag deliberately passes JSON-representable values that JSON itself still rewrites ([undefined] → [null], class instances → their JSON projection, -0 → 0) — deriving from the pre-wire value minted signatures no client could ever reproduce (the Koi player-rig sub-case's named candidate). A mint-time self-check warns LOUD (bespoke-geometry-mint-self-check, greppable + additive-only) naming the first property-access-divergent param when storage/wire changed the recipe. Scripted-spline expansion mints with the same discipline (loft + children() run against the round-tripped inputs; recipes store them verbatim).
  • Recipes carry the mint-era script content hash (scriptHash, recipe-internal on TomeScriptedPrimitive/TomeScriptedSpline component values — no new spec fields), and the existing derive-mismatch warns (bespoke-geometry-derive-mismatch, scripted-spline-derive-mismatch, names unchanged) now distinguish script-content skew ("spec lags/leads the mint; should converge when the spec syncs and the server re-mints") from true divergence under identical content (nondeterministic script or non-stored derive inputs; unhealable) — with scriptContentSkew + both hashes in the payload.
  • Non-goal honored: the renderer's signature strictness is untouched — wrong-sig geometry still never renders. Pinned in generator-edit-remint.test.ts: generator edit → scatter children re-mint end-to-end with remote-client derive joining the new pointers; unrelated script edits leave beds untouched (no respawn storm); spline generator edit parity incl. recipe hash re-stamp and remote derive; player-rig-shaped state-bag params (Infinity/Date/Map/[undefined]/-0) through the real sanitize path derive equal on a fresh client; mint self-check loud on the constructed mismatch and silent on wire-faithful recipes; warn-text skew/true-divergence split.
  • Decoration wind sway is a directional gust field driven by the global sky wind (atmosphere.windDirection/windSpeed → sky-state → new Frame.wind uniform lane @ 368; frame struct 368 → 384 bytes): traveling noise advected along the wind, mean directional lean, per-instance-phased lateral shimmer. The three-era directionless value-noise sway body is deleted.
  • useTerrainColor is implemented (was a documented no-op since the lume port): the placement sampler blend-weights a terrain material tint palette (new 64×vec4 uniform block read from the atlas tint rows) and packs the ground tint RGB888 into the previously-unread per-instance .w output lane; tinted draw variants decode it — textured sprites keep their luminance pattern (three-era pattern constants), grass cards get a root→tip lift. No new sampler storage buffer (stays at the 8-buffer floor); zero extra bytes per instance.
  • New settings.alive regime: items without authored wind sway gently by kind default (grass 0.06 / sprite 0.05), items without explicit useTerrainColor tint. Explicit per-item values always win. Absent = pre-alive interpretation.
  • Deck presets: decorations.deck: "meadow" | "clover" | "alpine" | "autumn" | "lavender" (or { name, on, density }) expands into composed layers at the render boundary (decoration-decks.ts / decorations-sanitize.ts); composes with hand-authored layers; only verified CDN sprites + color-authored cards.
  • Near-field density: per-item draw distance (maxDistance per item; ground-cover cards default 80m), item counts ask over the item's own placement disc, LOD ring splits clip + re-normalize per item, cull exp far follows the item distance. Minted meadow deck lands ≥10/6/4 instances/m² near-field on desktop/tablet/phone within the 200k/100k/50k scatter ceilings (pinned by decoration-decks.test.ts acceptance math).
  • Per-instance colorJitter (hue rotation + value swing hashed from instance XZ, zero buffer traffic; defaults subtle on cards, off on primitives). Grass-kind tipColor now actually renders (base→tip gradient in the item uniform — was silently ignored since the lume port).
  • New world mints (starter-3d) ship decorations: { deck: { name: "meadow", on: ["grass"] }, settings: { alive: true } }.
  • The batched skinned path's missing-clip hold now points instances at a baked REST-POSE palette row (pose-bake appends one row after every clip) instead of the three-era anim.w < 0 → identity skinMat sentinel. The sentinel's semantics didn't survive the lume port: three's vertex math sandwiched the palette in bindMatrix/postMatrix constants, so identity collapsed to rigLocalMatrix — the mesh in feet-anchored model space. Lume's palettes are already model-space and the sandwich is gone, so identity skinning rendered raw quantized bind space — wrong size, centered pivot, waist-deep in the ground. The WGSL identity branch is deleted (no writer remains); batched and clone paths now hold the same rest pose across the horde↔character demotion seam.
  • A server tick replicating a batch of scripted-primitive spawns (specimen: a food-streamer restock landing 8 tier-7/8 building generators in one tick) derived every recipe synchronously in one client netIngest pass — seeded RNG + noise + full geometry construction in a single frame, the felt hitch (ledger 1159, #8716). The sweep now only discovers work; builds drain through a FIFO capped at 2 per pass. Entities exist in ECS the instant the delta applies — only BespokeGeometry realization spreads, the same async-arrival shape the bespoke pipeline already handles (geometry-wait treats an unresolved pointer as a wait before reporting). Drain re-reads current world state: a recipe mutated while queued builds once with the final value; a despawn while queued drops cleanly; dead entries spend no budget. Up-to-budget spawns still derive the same pass they land, and the queue order is deterministic and cross-client identical.
  • Interactions run at the INTERACTOR (docs/interactor-context-interactions.md): onInteract on an entity the interactor doesn't simulate no longer round-trips to the target's simulator — the interactor's client runs the hook itself, the same tick as the press, and every write routes through the existing effect lanes (own-state immediate; spawns re-rooted into the interactor's envelope, born at 0 ms; cross-writer writes as guarded intents sourced at the interactor; emit/music/noise/purchase/enterPlace/persistence on the validated Command rails under an interaction grant registered by the new rail.interactClaim). The hook's continuations (timers, api.on subscriptions, job callbacks) carry the execution context and run where the hook ran. The interact.fwd/interact.verdict forwarding machinery, the routed-pending table, and the interaction sweep are deleted — the owner/host can never double-run a hook by construction. rail.interact survives narrowed to server-simulated targets (no PlaceMembership). One scoped authority change: rail.transferControl accepts a grant-backed SELF-claim on unowned remainder targets (the vehicle-mount hook now runs on the mounting player's machine), under strict deny tiers — never someone else's player, never another client's envelope, never a second controller.
  • resolveClientAuthTargetSimulator (the server's who-simulates-this-entity answer, consulted by intent verdict routing, rail.interact classification, and every control-transfer simulator check) now consults the sim-lease table, completing the resolution chain owned → leased → hosted → unhosted on the server side. Before this, a sim-leased loose dynamic resolved to its place host — mis-routing control-switch release legs and intent ratification to a machine that no longer simulates the entity.
  • The scripted-material join watchdog now verifies BUILT-state instead of delivery: parked refs join the sweep predicate (join-window build-failure parks get one bounded unpark-and-refresh; an all-parked sweep logs loudly instead of disarming satisfied), built-state matching requires a stamp matching the anchor's current intent ref with no scripted-intent fallback in the subtree (fixes chunk-sibling masking), and the watchdog re-arms on library delivery (reconnect/place-resync/recovery snapshots each get their own sweep window — previously one sweep per renderer session, permanently disarmed after). Park entries now store their park reason.
  • Two lume port drops in the lit-2D lanes (sprites/shader.ts, tilemaps/shader.ts), one fix. (1) Froxel lights restored — three's SpriteLightingModel received every clustered point/spot light through the froxel loop calling direct() per light; lume's port shaded lit 2D art against sun + ambientMean only, so every authored point/spot light contributed exactly nothing in 2D places (Farm Dusk's 34 lanterns/windows/fireflies rendered as unlit sprites on a flat field). The shared froxel machinery (cluster lookup, distance/cone attenuation, atlas shadow taps, per-light sample) is now factored out of the pbr-only clusteredEvaluateWgsl into one chunk both consumers include: pbr folds the identical per-light sample through Cook-Torrance, the new clusteredWrapEvaluateWgsl folds it through wrap-Lambert (lighting_wrapClusteredIrradiance, carried on LightingModule.wrapEvaluateDefs) — extra-directional prefix + froxel list, same clustered mechanism as 3D, no bespoke 2D light path, local shadow-atlas reception included (three parity). (2) BRDF_Lambert 1/π restored — three folded every 2D light term (sun, clustered, ambient) through albedo/π; lume's 2D lanes dropped the factor while the 3D lanes kept it (shading.ts), measuring ~2.5× hot linear luminance on 2D-top vs 5.1.13. The whole light sum now scales by 1/π; emissive (three's finish()) and the cooking wash stay unscaled. Unlit sprites/tilemaps are untouched — lit: false stays byte-identical, zero-cost-when-unused holds.
  • The lume primitives store collapsed every bespoke mesh into ONE vertex-PBR lane slot, dropping the three-era planBespokeDrawGroups split — ctx.albedo() texture runs never bound a texture (the whole mesh sampled layer 0 and rendered its packed color: the Two-Texture Table white regression), entity-level map on bespoke meshes was equally dead, and material: { transparent: true } dragged a mixed mesh's opaque faces into the alpha pass where lume has no per-group draw order (the pesky-lantern cap/base compositing over/under its glass). planBespokeGeometrySlots (features/bespoke-geometry) now ports the ledger-956 plan — a face is translucent when any corner's vertex alpha < 1; each face belongs to its covering texture run or the −1 base; slots are canonically ordered so identical content plans identically — and buildBespokeSlotGeometry compacts one slot into standalone buffer data with whole-mesh-resolved UVs/normals (slot texture mapping and smoothing match the old single-geometry groups). The store allocates one lane slot per material slot ({ kind: "lane", parts } representation; matrix/visibility/outline/bounds fan out over parts), derives per-slot overrides via the three-era materialForTexture rule (run texture becomes the slot's map, authored color recolors it as tint), and resolves per-slot texture state through the texture arrays with the same held-layer refcounts + arrival subscriptions primitives use. Alpha semantics: opacity < 1 or overlay still blends every slot; transparent: true forces whole-mesh transparency only when the geometry carries no alpha split of its own — a mixed body's opaque hull keeps depth writes and shadow casting (the ordering fix), and an all-translucent body blends with no entity override at all. The orphaned buildTexturedBespokeBufferGeometry/groups half-port (consumer died in the lume cutover) is deleted.
  • draw/shadow intent now reaches actual render behavior on lume, all three legs of jure's report. Models: the shadow handler routes intent into the models store (held per owner entity, so spawn-frame ordering and model re-adds keep it); intent partitions static/horde batch identity via a signature shadow segment, castShadow:false gates batch/character/scripted caster draws (and the character caster-sphere feed), receiveShadow:false splits the color pipeline. Primitives: the lane key's receiveShadow flag finally reaches the shader — the clustered lighting evaluate takes a compile-time receiveShadow gate that skips the sun-cascade AND local-light atlas taps (lane pipelines split on it; depth-only caster pipelines normalize it away), oversized direct-texture pools key on intent, and intent arriving before the primitive record (the shadow handler collects first on spawn frames) is held for record creation instead of dropped.
  • The shared per-channel primitive texture arrays lock a first-source template (512²×full-chain on desktop), and #8493 routed bespoke slot textures through them with no fallback — in any world mixing KTX2 cook dims (the staging zoo: playground oaks 512×512×10, iso-town car panels 64×64×7), whichever shape loaded second was refused (template-mismatch) into the masked white layer 0 and rendered flat (ledger 1091). Routing is now a single load-order-independent predicate (PrimitiveTextureArrays.routing()): off-profile dims always route to the existing per-texture direct-bind pools (oversized.ts, extended with a vertexPbr lane layout so bespoke slots can ride it — a bespoke representation's parts now mix shared-lane and pool slots, with matrix/visibility/outline/bounds/release handling both); profile-dim sources join the shared array unless the locked template disagrees on format/mipCount, in which case they pool too — the one first-admitted-wins residue moves WHICH path a texture binds, never whether it renders, so the same world renders identically regardless of pen residency order. Primitives' dims-only oversized threshold folds into the same predicate (a profile-dim format conflict previously white-placeholdered there too). The alternative — shape-bucketed arrays keyed by (channel, format, dims, mipCount) — keeps off-profile textures batched but forces per-bucket lane splits through the one-array-view-per-channel group-2 bind contract plus texture resolution before lane selection in both reconcile paths; pool count per world stays small (distinct off-profile texture bindings, 2 in the zoo), and this change doesn't foreclose bucketing later if that grows.
  • Magic CDN fetch errors now carry the server's error-body detail: ensureMagicCdnAssetReady reads the JSON detail/error from 4xx and 5xx probe answers into MagicCdnFetchError.detail (previously only fingerprinted cook-kill 502s kept their detail), and the sprite atlas metadata fetchers (fetchRangeClassified, fetchSpriteAtlasMetaFromKtx2Classified, fetchSpriteAtlasMetaFromPngClassified) classify failures with a failureDetail field.
  • The sprite-atlas-missing engine diagnostic branches on the failure class: a terminal 4xx (the CDN's verdict that this exact name can never generate, e.g. the over-long-filename reject — auth-window 401/403 excluded, since the hydration lane runs unauthenticated) now reports "FAILED to generate (HTTP status: detail) — fix the named cause or regenerate under a different name" instead of "hasn't finished generating — do not change the asset URL", which taught the opposite of the fix on deterministic failures (grok-imagine incident, 2026-07-09). The same verdict parks atlas hydration for the session instead of re-fetching the doomed URL every 30s.
  • All lit material paths now participate in clustered lights (ledger 1096, jacob's ruling: "all materials including custom ones need to participate in clustered lights") — the materials half of lume known-issues item 15, completing what #8491 shipped for the 2D lanes. Lambert/Phong/Toon meshes previously shaded sun + flat ambient only (materials/shading.ts cheap snippets); water shaded Cook-Torrance sun + flat ambient only (water/water-wgsl.ts). Every path now folds the froxel point/spot list AND the extra-directional prefix through the same shared per-light sample the PBR path uses (lighting_sampleClusteredLight — attenuation, cone falloff, atlas shadow taps can never drift per model). Folds per material model: Lambert rides the existing wrap chunk at wrap 0 (plain N·L — lighting_wrapClusteredIrradiance(N, P, fragCoord, 0.0)); Phong adds its Blinn specular per light next to the Lambert diffuse (clusteredPhongEvaluateWgsl); Toon bands each light's angular term through the material's band function before the sum (clusteredToonEvaluateWgsl — three r185 ToonLightingModel parity; banding the summed composite instead would leak sun radiance onto sun-averted faces through the band's ceil rounding); water folds Cook-Torrance per light (metal 0) via the evaluate library's lighting_clusteredLight. Sun + ambient terms are byte-unchanged in every path — the clustered term is purely additive, so scenes with no local lights render as before. The PBR path is byte-identical (verified by generated-WGSL diff of clusteredEvaluateWgsl + fully assembled pbr/physical modules against master — the #8491 regression gate); the wrap chunk consumed by sprites/tilemaps is byte-identical too.
  • Scripted materials (material: { kind: "scripted" } — TSL MeshLambertNodeMaterial/MeshPhongNodeMaterial/MeshToonNodeMaterial) get the fix through the same assembleMaterialShader snippets; the look-system isolation contract is untouched (assemble throws still park the ref and fall back to the Std/PBR lane; pipeline build failures still park with attribution — a scripted material's lighting hookup can never break the main render). Matcap stays lighting-independent by design (three parity: lighting is baked into the sphere texture — it takes no sun either), and cheap models against the v1 sun-ambient module (capture booth) degrade to sun + ambient exactly as before.
  • New WGSL-resolution audit test: every assembled model's shade_/lighting_/lume_ calls must resolve and each fn must be defined exactly once — WGSL validates all declarations before dead-code elimination, so a chunk referencing a fn its module never includes is a pipeline compile failure (on the scripted lane: a parked material). This audit is what forced the phong/toon folds into separate self-contained chunks.
  • Named split-out (NOT in this PR): water's permanent placeholder normal/foam/caustic texture binds (water/water-store.ts TODO(lume-water-textures)). Receipt in-tree: the placeholders are exact three-era parity (master's createWaterNodeMaterial was only ever constructed without texture options; the unused setWaterTextures seam had zero production callers and was deleted). Wiring the real maps is feature work in the renderer's asset layer — lease water/normals + water/crest-foam and composite the 31-frame caustics sequence into one 2d-array lease — not a bounded change in this pass.
  • A live place-mode flip (updatePlace 3d ↔ 2d-side/2d-top) now re-runs the appearance contract for already-spawned entities in the flipped place (ledger 1083). Appearance dispatch is mode-dependent — the #509 sprite-wins contract and the png.glb→sprite implicit conversion read the place mode — but the flip previously triggered terrain dirtiness only: object defs and the player template are unchanged by a pure flip, so neither diff path re-dressed anything, and a spawned player kept its pre-flip runtime-written 3D avatar (invisible under the 2D camera) until rejoin/refresh (Sandsong QA dump ad87396e, triage 99703695). Now applySpec collects modeFlippedPlaceIds and (a) re-applies applyAppearanceProps from the current spec defs for the flipped place's objects, contained per object, and (b) ORs the flip into the player template-diff trigger, so players re-dress through the existing #509 machinery (profile-avatar resolution stays in one place, both realms decide from the same replicated inputs, and the Draw* writes ride normal component replication — no bespoke re-dispatch lane). Reverse flips (2D→3D) restore the model/avatar path through the same pass. First apply stays exempt, mirroring the player block (a fresh client join carries replicated live state a def re-apply would destroy).
  • Fixed a dead arm the re-dress exposed in applyAppearanceProps: the implicit model→sprite conversion for 2D places wrote DrawSprite directly, then the function's own trailing sprite pass (next.sprite unset → else-remove) stripped it in the same call — spec-authored png.glb objects spawned in 2D places rendered nothing on this path (the live setProperty lane has its own conversion and was unaffected). The implicit sprite now rides next.sprite through the single trailing write lane.
  • Known residual, named not fixed: runtime-spawned scatter children keep their pre-flip dress until scatter refresh (terrain updates refresh them; a pure mode flip does not), and physics topology (e.g. 2D collider needs) is not re-derived on flip — the pinned gap was visual re-dispatch.
  • Pinned in player-sprite-2d-avatar.test.ts (ledger #509's suite): live 3d→2d-top flip sheds a runtime-written avatar model for the authored sprite on the same entity with no rejoin; 2d→3d restores the profile avatar (animated3DCharacter: true) and a template model; object re-dispatch on flip (png.glb→sprite conversion, runtime model write shed against an authored sprite); and the 2D spawn-path implicit sprite conversion.
  • Runtime spawn({ place }) targeting a provably non-resident place in a multiplayer world now emits a teaching warning at the API boundary (ledger 1077): the spawn succeeds locally on the executing client but the server's create-row gate rejects entities that are neither sender-owned nor unowned members of the sender's hosted place, so the object ghosts untick'd and every follow-up write dies unknown_target. The check uses the server's PlaceResidencyResource and, on the executing client, the replicated TomePlaceHosts table (own hosted place, any live host, or a sender-owned envelope spawn all stay silent). One warning per (place, burst) — never per spawn — on the mutation-warn rail, plus one engine-error DM per place per room life (server: DM notifier; client: the behavior-fault wire onto the engine.diagnostic rail). Singleplayer is untouched — the pattern genuinely works there. This is the soak form; promotion to a throw (enterPlace's nonexistent-place precedent) is the planned second step.
  • Two receipted persist-path defects (ledger 1157, #8711). (1) Boot-deterministic onSpawn re-assertions (patchState/replaceState of values already in the spec) minted 364–1604-mutation batches that were 100% content no-ops, wedging rooms against kiln's 60s apply wall indefinitely. A state write that changes nothing against the live state is now dropped at the applier — guarded by the durable-mirror veto: suppression happens ONLY when the write is also a no-op against the object's GameSpecResource row (the durable-intent mirror, which state writes never advance), so a live-only write followed by an identical withPersistence commit still records. At boot, hydration makes mirror == live, so the wedge-killing property stands: boot re-assertion batches collapse to zero. Per-tick debug-level count instead of per-mutation spam; the veto can only force recording, never suppress more (A→B→A still records both). (2) The #8202 reconcile ladder's bound didn't survive host recycles — teardownActiveSimulationHost reset persistReconcileRounds while pendingMutationQueue survived, restarting the ladder on the same stuck batch forever (~18 gateway-timeout kiln invocations per ladder, indefinitely). The round count is now keyed on the batch's content identity and rides with the queue across recycles; a genuinely new batch composition honestly starts a fresh ladder. The terminal drop's loud rails stay exactly as #8202 built them.
  • Enfeul's "only 4 different colors" report turned up two real bugs beside the cap (ledger 1161, #8721). Truncation direction was INVERTED — assignOutlineSlots sorted priority ascending and kept the FIRST maxSlots, so god-mode selection outlines (priority 4/5/7, meant to read above gameplay highlights) were exactly what vanished under gameplay-highlight pressure; the LOW end is dropped now, with ascending order preserved among survivors for composite stacking. maxOutlineSlotsForTier was dead code in lume — no caller passed maxOutlineSlots, so phones got the desktop cap; the renderer session tier is now wired through frame inputs to the post chain (desktop 8 / non-desktop 2, the pre-lume tiering). And over-cap drops were silent by construction (highlight() has no error path; state reads back as set) — a new outline-slots-exhausted diagnostic names the dropped styles' colors and the cap (listing at most 6 colors so the consent clause survives the 500-char DM truncation), once per distinct overflow set per session, riding the engine.diagnostic rail into getLogs. The cap raise 4→8 is safe: slots are lazy pay-per-active (inactive groups share a 1×1 zero view) and the composite already scales by slot count.
  • Scripted-texture parking is now per-REF with retry-before-park and probation recovery (ledger 1067, half A). The 50ms bake budget is wall-clock, so scheduler starvation one-striked trivially cheap draws (a 64×64 one-gradient draw measured 288–3201ms on a busy main thread) into a park that only a source-hash change could lift — and the park keyed on the script PATH, so one faulted ?param ghost variant starved the live variant's whole parametric family. Now: a budget fault marks the ref suspect (quiet rejection; the asset service's existing retry ladder re-asks) and the park lands only on TEXTURE_BUDGET_FAULTS_TO_PARK = 2 consecutive budget faults; parks key on the full script+params ref identity; a budget park re-probes one bake per probation window (TEXTURE_PARK_PROBATION_MS = 30s, doubling per failed probe to a 5min cap — bounded, never a hot loop) and a clean probe un-parks in place, so the consumer heals without a refresh. Retry-before-park was chosen over CPU-time measurement because performance.now() around the draw is the same wall clock (no isolatable per-thread CPU clock exists in workers); a retry is evidence the budget can trust. Compile/runtime faults stay one-strike and edit-only — they are deterministic, so probing them wastes bakes. Pinned in scripted-texture.test.ts: suspect-then-park fault counts, clean-bake streak reset, ?hue=1 park leaves ?hue=2 live, probation unpark/re-park with window doubling, deterministic parks never probe.
  • Scripted-material frame-budget parks now stage back on after sustained recovery (ledger 1067, half B). recovered used to be a no-op — park state is renderer-module memory, so one transient stall (thermal dip, background tab) flattened every scripted material to Std/PBR for the rest of the session; refresh healed it. Now driveScriptedMaterialRecovery (wired into the renderer's guard tick) re-enables parked frame-budget materials ONE per sustained-healthy interval (UNPARK_STAGE_INTERVAL_MS = 15s under the guard's own 28ms degraded line, guard state ok) — never all at once, which would spike the GPU into a thundering-herd re-park. The newest re-enable stays on probation for one full interval; a GPU fallback while it holds re-parks THAT ref individually (reason probe-reoffense, edit-only from then on) and leaves the proven survivors live. Park reasons are now explicit (parkScriptedMaterials(refs, reason)): frame-budget parks are recovery-eligible, build-failure parks and probe re-offenders stay edit-only, and the parked look never auto-unparks (script-caused, not device-caused). The fallback diagnostic's body now describes the automatic staged recovery instead of naming a script edit as the only path back (consent rule and its truncation-safe position unchanged). Pinned in frame-budget-report.test.ts: one-per-interval staging with action reports, unhealthy-sample stretch reset, individual re-park + quarantine, organic fallback still full-parks, build-failure parks and the look stay parked.
  • No new spec surface or config knobs — all constants above are internal and documented at their definition; no new diagnostic codes (unpark/re-park reports ride the existing frame-budget-fallback rail with their own dedupe keys).
  • Place travel now warms the far side ahead of the server round trip (docs/place-travel-prewarm.md), through the existing preload-hint lane end to end — no new pipeline, queue, priority, or spec vocabulary. Two triggers: (1) issue-time — the moment enterPlace's client branch forwards rail.enterPlace for a locally-controlled traveler, the destination place's asset refs (objects/terrain/atmosphere refs, script literals, sprite variants; createIfMissing warms from the inline def, unioned with the base def for instanced travel onto an existing place) queue at explicit priority, the same lane api.preloadAsset uses, so fetches overlap the RTT and the veil beat instead of waiting behind them; (2) steady-state — the quiet-window preload sweep additionally emits the UNFILTERED spec snapshot's refs at idle priority after the kept place's (the applied spec is place-filtered, so other places' assets were invisible to the sweep and stone-cold at travel time). All downstream discipline is inherited, not re-implemented: warms never outrank a real load, idle guesses need boot-drained + idle-now frames, residency lives under the asset services' keep-alive byte budgets. Zero sim trace (the hint lane's determinism contract); a refused travel leaves cached bytes and nothing else; travel spam converges on the queue's ref dedupe + priority upgrade; capsule replay worlds never prewarm (the sweep's replay exclusion, mirrored — speculative warms the capsule can't serve would trip the replay's offline contract as phantom misses). Travel authority untouched — the validated server rail remains the only door. Pinned in place-travel-prewarm.test.ts (issue-time trigger + gates), client-auth-place-travel-prewarm-e2e.test.ts (overlap timeline, refused-travel abandonment, double-travel convergence over real netcode), and asset-preload-sweep.test.ts (warm-ahead leg).
  • A renderer-side boot watchdog now covers the silent never-drew lane (ledger 1163, resx's sky plate + trunk): any standalone scripted-material entity that is authored visible and in view but has produced zero main-pass draws by the post-boot sweep gets ONE getLogs-visible diagnostic naming the entity, its lane, and the stage it is wedged at (record missing / geometry empty / pipeline compiling or parked / textures unresolved) — the lume successor of the three-era renderer-object-draw-stalled rail, on the join watchdog's timing. Mechanism hardening in the same lane: a parked (permanently failed) pipeline no longer counts as compile-in-flight on the scripted primitive/model draw paths, so one bad material can't hold the frame-idle bootDrained latch — and every watchdog gated on it — hostage for the session.
  • Remote actors now carry anticipation instead of leaking the playout delay as netcode (docs/remote-anticipation-masking.md). The problem: a remote a3dc actor's locomotion weights (draw/mixer, derived on its simulator) apply at data-ARRIVAL time while the drawn body runs 1–2+ ticks behind on the playout buffer — so starts read as walk-cycle-then-slide, stops as plant-and-skate, and feed stalls as running in place. The mask re-times the envelope to the drawn body: a client system (tome/remote-actor-anticipation, the draw/presentation-delay idiom) stamps a generic draw/anticipation policy on a3dc actors this realm does NOT simulate (no speedOverride), mirroring their resolved walk/run thresholds; the renderer's transform sampler re-derives the blend from max(displayedSpeed, headSpeed) — the drawn pose's own XZ derivative (releases follow the body: stops are honest) vs the newest snapshot-pair slope faded by freshness and arrival age (onsets lead: wind-up begins at arrival, up to the full playout lead before the body launches; stalls ease to rest instead of a treadmill). Weights ride a pooled synthetic draw/locomotion-weights op (change-gated — a resting actor costs nothing) into the models store, which steers ONLY the frozen a3dc locomotion layers' target weights and re-applies the override after every wire mixer reconcile; REMOVE restores the wire weights. Rotation additionally samples up to 2 ticks ahead of position on the same ring (clamped to newest — no pose is ever invented), so the body turns into the move first. Presentation-plane only: no sim writes, no wire changes, plane: "client" / replicate: "never" policy; locally simulated actors and everything in singleplayer never carry the policy and their op streams are pinned byte-identical.
  • The a3dc speed→weights blend (thresholds, hysteresis, smoothstep bands, weight floor) moved from the locomotion feature into draw-animated-character.ts (blendLocomotionWeights + exported constants) so the simulator-side shim and the renderer-side mask speak one vocabulary — the feature's behavior is unchanged, pinned by its existing single-writer suite.
  • Remote-sourced event juice schedules on the playout clock (docs/remote-juice-playout-clock.md, stacks on the adaptive remote-view playout). Fires that arrived over the wire (StickyEventAdd.ingested — captured at emit time while a replicated ingest window is open, beginIngest(tick, { replicated: true }) at the two wire seams: replication-delta event rows and forwarded server-context writes) hold at the juice drain until the renderer-published displayedTick reaches the fire's timeline coordinate (drain tick − release lead, lead = floor depth + 1), so a hit flash and the pose it was emitted against present on the same timeline at every buffer depth — at the deepest adaptive buffer the flash no longer lands ~130 ms ahead of the body it hits. The renderer publishes displayedTick once per frame over a new seqlock SAB feedback lane (playout-feedback.ts, same render→sim family as cameraAngles/pointerRay/modelBonePoses); the drain reads it once per sim tick. Local fires never enter the hold (own-action juice stays 0 ms), dedup/sequencing runs at drain with unchanged keys and counters, audio and visual kinds release from the same held entry, and every hold is bounded — floor-depth clocks release at drain (zero deferral, today's timing), a stalled or vanished clock degrades to arrival-time dispatch after MAX_PLAYOUT_DELAY_TICKS, and a local timeline rebase voids the stamps. Zero sim impact: the hold delays handleJuiceEvent dispatch only; change log, replication, upload, and determinism are untouched.
  • The lume scripted-material lanes (static models, skinned characters, bespoke primitives) now pass the surface's own color inputs into the shared record cache as ScriptedSurfaceDefaults: when the built ShadeMaterial has no colorNode, the record synthesizes the glTF albedo chain — baseColorFactor × baseColorTexture(uv) × COLOR_0 vertex colors (bespoke meshes contribute their vertex-color leg) — exactly what the plain lanes hard-code in WGSL. Scripts that author colorNode own their appearance unchanged; an explicit overrides.color replaces the factor (copy-source-appearance precedence). Model lanes now group scripted primitives per (attribute signature × glTF material) so trunk/foliage materials never share a record, the surface identity rides the record key (an asset-generation id keeps reloads off stale texture views), and async base-color decodes flip the record's texture handle so bind groups rebind off the 1×1 placeholder.
  • Two holes in the spec-child pipeline, found live in the zoo's Farm Dusk pen (walk 07-08 — scarecrow rim + 24-firefly swarm dead in sim while the lamps worked). (1) compileSpecSteps iterated RAW place objects, so a children: [...] entry carrying behavior never registered in compiled.objects under its expanded {parent}/{child} id — and spawnObject stores only { specId } (no behaviorRefs), so resolveCompiledBehavior's lazy path had nothing to compile from. Child behaviors silently never ran on a fresh apply (no fault, no log); they only came alive if a LATER apply's rebind sweep happened to recompile them, which is why the rim worked in long-lived rooms and died on every fresh boot. The compiler now expands children through the same expandChildObjects the interpreter spawns with (extracted to tome/spec-children.ts — one id convention, both consumers), so child behaviors compile, enter the module entry-point graph, and run from tick one. (2) resolveObjectIdFromEntity gains a final fallback: a leaf id that missed every scope (self-child, mod, place, raw) resolves to the unique parented entity whose id ends with /{leafId} — the id the creator actually authored in the spec. Ambiguity (two parents both naming twin, child-N fallbacks) stays null: resolution never guesses. This unblocks the controller pattern — one script driving many objects' child lights by their authored ids (setObjectProperty('fd-fly-3-light', …)) — which previously warned entity not found and dropped every write.
  • The 2D-meta hydration lane (sprite-metadata-hydration) and windowed range fetches (fetchRangeWindowClassified) now classify failures with the same failureDetail field as the atlas fetchers — the server error body's teaching detail rides into the give-up warning instead of a bare HTTP status (completes the grok-imagine detail relay across every sprite fetch lane).
  • The tome UI planes (game HUD + creator rail) sample at 30Hz by default instead of 10Hz (DEFAULT_TARGET_FPS, worker-controller.ts) — the new default matches the default simulation rate, so JS-computed animation values in HUD html step at the rate state actually changes instead of visibly snapping (a 0.4s momentum pop rendered as ~4 discrete size jumps over a 120Hz world; dig 8a9adfa6). The FNV render-signature dedup keeps idle traffic flat: unchanged UI state posts nothing at any cadence (now pinned by test), so the added idle cost is renderFn eval + signature per sample — measured ~3.6µs on a 2KB HUD and ~38µs on a 16KB HUD, ≤ ~1.2ms/s of worker time at 30Hz. Animating UIs post up to 3× more messages, each bounded by the existing morphdom apply.
  • New creator-facing knob engine.ui.targetFps (default 30, clamped 5..30 — the ceiling matches the default sim rate, past which samples can only rebuild identical state). The controller re-reads it every sample, so api.patchEngine({ ui: { targetFps: 15 } }) reschedules live with no remount. The zod schema stays deliberately lenient (plain optional number; the engine clamps at read) so the kiln persistence gate never rejects a value the engine runs, and patchEngine's ignored-key warn rail knows the key via the compile-pinned parity lists. Deliberately NO interpolation machinery: CSS transitions/keyframes inside the rendered html remain the smoothing lane for sub-sample motion (they run on the browser clock at full display refresh).
  • Game-UI mount-key recheck rebased from 10 to 30 resolver invocations, preserving the designed ~1s staleness bound and unchanged per-second recheck cost at the new sample rate.
  • Tome UI plane <img> loads now have an error path (ledger 1087): the UI plane was the one asset consumer in the engine with no retry — renderer textures poll, audio parks and re-arms, the cdn session retries forever, but a UI image whose fetch died mid-flight stayed a broken icon for the whole session, because the morphdom pass reuses nodes whose src is unchanged (the browser never refetches a reused node) and a page refresh was the only remount (dreamcatcher's Petling Grove bestiary, dump 1ec69fd1 — two connection blips killed four in-flight variant fetches). dom-host.ts now installs one delegated capture-phase error/load listener pair on the render container (error/load don't bubble; capture still walks the ancestor chain — zero per-node listeners): a failed load gets a bounded backoff refetch (2s then 8s, plain same-url src re-set — no cache-buster, the failure mode is a transient client blip against a healthy CDN), then a terminal data-img-failed mark. Retry state rides data-retry-count on the node, carried fromEl→toEl across morphs only while src is unchanged (same shape as preservePersistingVideoSrc) so the isEqualNode fast path is preserved; a src change copies nothing — morphdom strips the marks and the new source loads and retries from a clean slate, which is also how a failed-marked image revives. A successful load clears the ledger so a later blip gets the full schedule again; dispose cancels pending retry timers.
  • Excluded from the generic retry, pinned by test: data:/blob: sources (game onerror-bootstrap imgs — retrying re-runs creator code), images carrying an authored onerror (that attribute IS the creator's failure path; the refire machinery owns re-arming those), and anything under mcdn-icon's data-icon-failed freeze (the letter-fallback chip is the icon system's own live-DOM fallback).
  • warmPriorityUiAssets' warm set stops being add-only poison: the warm Image gets an onerror that evicts the url, and a live <img> error evicts it too, so the next render re-warms instead of trusting a fetch that never landed.
  • computeCanSee (npc/perception.ts) deferred its occlusion ray to the per-pair (tick + id-hash) stagger whenever ANY NpcSense entry existed — including a stale one whose checkTick froze while the target was out of range/cone (or the behavior wasn't asking). That quantized the approach edge — "player walks into vision range", "sight line opens" — by up to a full re-ray interval (~170 ms worst / ~83 ms average at 30 Hz); for friend-lobby RTTs, larger than every network leg of the world-reacts-to-you loop combined (honest leg-by-leg budget: docs/world-reactions-offrail.md). Now only a FRESH entry (ray-confirmed within one interval, i.e. the pair is under continuous surveillance and the stagger is actively refreshing it) defers; a stale entry re-rays immediately, exactly like a missing entry (first contact already behaved this way). Steady-state ray rate is unchanged by construction — the immediate ray makes the entry fresh, so a pair still costs at most one ray per interval — and the decision stays a pure function of replicated state + tick (deterministic across simulators, host migrations, and replays). Simulator-local: zero protocol change, zero new vocabulary, zero presentation impact. Pinned in perception.test.ts: off-phase re-entry rays immediately, off-phase sight-line-reopen rays immediately, continuous surveillance keeps exactly one ray per interval.
  • docs/world-reactions-offrail.md documents why relocating crossing detection to the approaching player's client (and the "senses lease" variant) is rejected: a P-published crossing event rides the same socket, server sequencing, and fan-out as the pose upload it derives from and cannot arrive at the simulator earlier; the cone gate would read RTT-stale sensor facing instead of the simulator's 0 ms-fresh one; and NpcSense is a single-writer component on the observer, so per-target foreign writers would LWW-clobber each other. Also records the discovered adjacent gap (non-host envelope onNoise listeners are structurally deaf — noise.fwd only reaches the place host) as a named follow-up, not touched here.

KNOWN OPEN AT MINT (5.2.0 staging draft — named honestly for the zoo walk and the promote packet):

  • shade vocabulary gaps: scripts reaching for node-vocabulary symbols shade doesn't yet cover park with a diagnostic naming the missing symbol (never a broken frame); scripts within the documented Custom Materials / Looks vocabulary are unaffected. Jure is filling the gaps now (2026-07-10, his call: not a mint blocker).
  • Sprite ordering (named open at the lume merge): ordering fixes have landed since — mixed opaque/translucent bespoke meshes draw in the right order (this version) and transparency-sort receipts are pinned executable in the staging zoo (#8588) — but whole-batch-granularity ordering edge cases in dense transparent-sprite scenes remain a polish lane.
  • PBR completeness (named open at the lume merge): clustered lights now reach every material path (this version); still open — water binds placeholder normal/foam/caustic textures (water-store.ts TODO(lume-water-textures); wiring the real maps is named feature work in the renderer's asset layer).
  • The master→lume parity catalog (docs/lume-master-parity.md) carries the remaining port ledger, notably: view_live_scene burst filmstrip still lacks the look+bloom composite leg (Savi captures under-report active grading), the lume texture-leg retry ladder still runs pre-overhaul semantics (terminal tombstone parks landed this version; quota-park/jitter/dead-4xx parity pending), ghost-mesh stall sentinel pending (text-material prewarm landed — lume/text/text.ts:510, test-pinned).

PROMOTE GATE — auto-update exposure (for the promote packet; NOT resolved by this mint): games with engine_auto_update = true inherit the lume renderer sight-unseen the moment 5.2.0 promotes (tucker's exposure flag, 2026-07-03: "a lot of people auto update"). Before the draft:false flip, the promote packet must carry the auto-update-cohort story: staging soak breadth across device tiers (zoo cert walk + real staging playtime on this SHA), the staged-promote / pause-auto-update decision for this rung, and the skill/teaching-skew story in BOTH directions (games pinned back to 5.1.x resurrect era-stamped teaching; auto-updated games must get current-era skills — the skills-versioned-per-app model). The flip to draft:false is the release step and stays human-gated.

›migration notes

THE RENDERER SWAP — same material and look vocabulary, new engine underneath. Scripted materials, looks, and texture scripts compile unchanged: require("builtin/tsl"), require("builtin/three"), and require("builtin/postfx") keep the exact documented surface — inputs, constructors, math, noise, oscillators, Fn/If/select/.assign/.toVar, material node slots, ctx.params live tweaks. No spec edits needed for scripts written within the taught vocabulary.

Scripts that reached past the taught vocabulary (deep three/tsl internals outside the documented surface) now park with a diagnostic naming the missing symbol — rewrite them within the documented vocabulary from the Custom Materials / Looks skills.

The select() branch-hazard law is repealed: select(cond, a, b) and shared .toVar() reads inside branches compile soundly now. Branchless mix/step/smoothstep remains a taste preference for GPU uniformity, not a correctness rule.

No action required — existing specs render with their authored wind/color values (absent wind still means still; absent useTerrainColor still means authored colors). Two engine-tuning changes affect existing looks: (1) ground-cover cards (grass/sprite items with mean height ≤ 1.5m) now draw to 80m instead of the settings maxDistance, concentrating the same instance budget near the camera — author maxDistance: 200 on an item to restore the old thin 200m dusting; (2) grass/sprite items get a subtle per-instance hue/value jitter by default — author colorJitter: { hue: 0, value: 0 } to pin an exact flat color. Wind sway on items that already authored wind.force is now directional (bends along atmosphere.wind) instead of directionless shimmer.

Existing games keep working, but four onInteract behaviors are script-visible when the interactor is not the target's simulator:

  • Closure-state locality. Script-level let variables in a target's behavior module used to accumulate at the target's simulator across every player's presses; the hook now runs on each interactor's machine, so module closures read that machine's (usually default) value per press. Counters, cooldowns, and press-tracking belong in api.state (replicated, durable) — the shape the skills already teach.
  • Event-juice audience. Juice a relocated hook attaches to the non-simulated target with an audience wider than the triggering player renders only on the interactor's screen (the same semantics a player's own script has always had when juicing a foreign entity). The default onInteract audience — the triggering player — is unaffected, and spawnFx is unaffected (its entity rides the re-rooted spawn lane and replicates to everyone).
  • Storage namespace. Storage calls in a relocated hook now ride the INTERACTOR's connection: per-player saves keyed user/<the pressing player>/… now land correctly (they used to be rejected — the hook ran on the host's connection, which couldn't touch the visitor's namespace). The flip side: a hook that saved shared/world data under whatever machine happened to simulate it was writing the HOST's namespace; that data stays where it was written and new writes land under each interactor. Shared world data belongs in cron / lifecycle-hook jobs (server-context), as the jobs-and-storage skill teaches.
  • Contested rewards into own state. When two players interact within one network round trip, the guarded target field (the test-and-set idiom) and every taint-deferred effect (spawns, emits, jobs) resolve to exactly one winner. Own-entity writes are immediate and final by design, so a losing hook that had already written a reward directly into the interacting player's own state keeps it. Author reward grants as kill/claim-gated spawns or fan-back events (the taught convention) — those void with the losing premise.

No code changes needed — canSee/awarenessOf keep their exact signatures and semantics. One timing tightening is script-observable: a canSee call now confirms sight on the tick a target enters range (or a sight line reopens) instead of up to one re-ray interval (~0.17 s at 30 Hz) later, so awareness meters start rising slightly sooner. Games that (implicitly) leaned on that engine-added notice lag get faster guards; tune the authored knobs (npc.visionRange, visionFov, or the awareness threshold your script reacts at) if a specific encounter now feels too sharp.