engine v5.2.2
Engine v5.2.2
July 14, 2026
A patch in the Lume line.
what's new
- Fixed sprites and objects that had stopped moving randomly lurching a few pixels out and back in quiet scenes (most visible on parked critters in 2D games). Resting entities now hold their position exactly until they actually move again.
- Savi can now see everything playing in your world — the soundtrack, every looping sound effect (with the handle to stop it), and every object with a sound attached — in one read,
api.audio.playing(). Great for hunting down that one rogue sound. - If your game music points at a file that can't load, Savi and you now get told ("no client could load … Is the asset loadable?") and the game stops pretending it's playing — before, it reported a silent track as playing forever.
- Asking to stop music when nothing is playing now says so instead of silently doing nothing.
- Savi can now check the water level at any spot in your world. Before, asking the wrong way silently answered "no water" — even over a healthy ocean — which could make working water features look broken and send fixes chasing a problem that wasn't there.
- Fixed worlds sometimes loading with a washed-out white/grey sky (fog and sky settings silently not applied) that stuck for the whole session until someone touched the atmosphere. The engine now re-asserts the sky on its own once the world finishes loading, and logs when a boot-time loss was healed.
- Objects and effects no longer flash as dark solid shapes while their textures are still generating or loading — a decal, particle, or textured shape simply stays invisible until its art is ready, then appears correctly. If a texture fails to load, the object stays hidden instead of rendering as a wrong-looking solid block.
- Fixed a failure where every custom material in a world could go grey/plain mid-session and only a page refresh brought them back. A renderer error is now contained to the one thing that failed — the rest of the scene keeps updating — and material rebuilds retry on their own.
- Fixed players arriving in 2D scenes stuck facing the wrong way — rotation carried over from the previous area (or snapped toward the teleport jump) used to stick to the player sprite and its nameplate. Entering a 2D place now lands with clean facing, while rotations your scripts set on purpose still work.
- Fixed lights and shadows sometimes jumping or looking wrong for a while after the renderer recovers from a GPU error. Shadow maps are re-rendered after a recovery instead of reusing stale copies.
- Authored animation clips now play in multiplayer worlds. Clips you define as motion code (
export function swim(t)played withupdateChannel) used to work only in singleplayer — in a multiplayer room the character froze in rest pose while the console warned the clip wasn't found. The machine that runs the character now shares the sampled animation with the server and every player, so the same script animates everywhere, immediately for the player who triggered it and within a round trip for everyone else.
›technical notes
- The client atmosphere apply is at-least-once, engine-owned (ledger 1297: session-permanent washed white/grey sky when ONE row was swallowed during the fresh-room boot window — deterministic for affected boots, value-independent, healed by any live patchAtmosphere). Every stage of the apply is change-gated (
lastHash/lastAtmosphereReflatches, thelastLookReflook-library ensure,plainValueEqualson the skybox row, the ECS structural-shallow equals gate), so a boot-window loss made every later pass read the atmosphere as already-applied. Now, once per render-channel epoch, when world-residency first reads true — the renderer is provably up and quiet —tome/systems/atmosphere-sync.tsresets its apply latches and force-re-emits every live atmosphere row (sun/ambient/hemisphere lights, fog, skybox, cloud/star sprites, transforms, and the LookScripts library) viamarkComponentUpdated, the engine's value-identical force-emit — the honest version of the creator-space ~1e-7 fog-density-jitter bandaid, never a value perturbation. The epoch signal rides the existing sim-side renderer seam (tome/world-residency.ts), bumped by the runtime worker on every render-channel attach; steady state pays two module reads and one compare per pass, the re-assert never repeats inside an epoch, and a renderer-worker swap (new channel epoch) re-asserts once more. 2D and 3D ride the same system and channel; no new spec surface. - Boot-window sky losses are LOUD now: an UPDATE for
draw/skyboxordraw/fogarriving while the lume store holds no active entity means the session's first apply (the ADD) never landed — the renderer handlers reportatmosphere-apply-lostthrough the existing engine-diagnostic rail (allowlisted, log-only: the arriving row already healed the sky), once per component per renderer session. The code also ridesSERVER_CONSOLE_BREADCRUMB_CODES, so the accepted report prints one server-console line per room per deduped condition — the kernel container's stdout is what observability ingests — making the class visible to BOTH getLogs and DD (fleet-level heal counts validate the fix in prod). - Script-authored animation clips (#7986) now deliver in MULTIPLAYER rooms (ledger 899 — the frozen 5.1.11 zoo swimmer). The mechanism: in client-auth multiplayer the server simulates nothing — a hosted NPC's
updateChannel(fn)play executes on the PLACE HOST (a client), andcanMintAuthoredClipscorrectly forbids clients from writing the spec. The host resolved the identity string only; the identity fanned out through its DrawMixer upload while the sampled tracks existed nowhere, so every renderer warnedclip "…#swim" not foundforever. The played function is typically a closure over the simulator's own rig reads (getBones()names/amplitudes — the taught rig-adaptive pattern), so no other machine can re-derive it from script source; the sampled ENTRY has to travel. Fix, all on existing rails: the simulating client samples locally (its own renderer plays the clip immediately, 0-RTT), stages the entry as a newAuthoredClipOfferscomponent on the playing entity, and the offer rides its normal StateDeltas upload; the server re-validates the untrusted entry from scratch (structure, caps, contentHash recomputed — never trusted) and mints it through the existing pending→fold path (single spec writer intact, mint-velocity guard included); TomeSpec replication fans the entry to every client; the simulator sweeps the offer once the replicated spec carries it at the same hash (ratification ack — offers never linger in snapshots). Malformed offers reject loudly once (runtime-log warn, memoized) and never reach the spec. Singleplayer and server-side plays are untouched (direct mint, no offers). Pinned end-to-end through the production client-auth harness: real host election, real upload validation, real fan-out, real sweep — the delivery assert fails on master. Offers respect the room wire's per-component-value budget at stage time (MAX_STATE_DELTA_VALUE_BYTES, 64KiB — enforced decode-side only, where a breach rejects the simulator's ENTIRE delta message for the tick, delivering nothing and warning nowhere client-side): a legal-by-float-caps clip that would serialize past the budget (a full rig at defaults gets there) is REFUSED with a creator-visible runtime-log warn naming the clip, the size, the limit, and the cure (shorter duration / lower fps / fewer tracks); the refusal is memoized per content so a re-resolving channel never re-offers or re-warns per tick, and the simulator's own renderer keeps its local play. Known residual gaps, stated honestly: raw-tracks plays ({ name, tracks }) from a client simulator deliver through the same offers path, but the offer path as a whole only runs where a play call executes — places nobody hosts stay paused (unchanged semantics). And the offer IDENTITY is unbound to the offering simulator in client-auth: a modified client that simulates any entity can overwrite an existingscriptRef#fnentry with valid-shaped junk of its own, or squat identities toward the 48-clip cap — bounded by the per-value wire budget × the clip cap and by full server-side re-validation, and a legit re-sample is indistinguishable from a hostile overwrite, so client-auth cannot close it airtight; a per-client offer budget and a purge story for squatted entries are fast-follow material. api.getWaterLevelAt(x, z, place?)— the world point query for liquid height (#9142, ledger 1315): wraps the engine-internalgetLiquidHeightAtPointover resolved marks (revision-cached per place) plus the voxel liquid sampler (topmost surface in the column), and works from ANY calling context — exec entities included. Fills the instrument gap that produced false "liquid system dead" verdicts:getLiquidLeveltakes no arguments (it's entity-bound and only answers for entities the liquid detector tracks via onLiquidEnter/onLiquidExit), so probing open water withgetLiquidLevel(x, z)from run_script's untracked exec entity silently swallowed the args and answered null in every game.getLiquidLevelcalled WITH arguments now records a getLogs-visible warn (throttled per entity) pointing atgetWaterLevelAt. Docs ride types.ts JSDoc → api-reference §Terrain/§Movement via the generator; zero always-on prompt tokens.- Music load-failure honesty (music-apex design D2.1):
music.playof a plausible-but-unloadable ref used to be a silent forever-lie — clients failed the load with console-only errors whilemusic.now()reported the dead layer as playing for the rest of the session (loop defaults true; the duration never mirrors). Clients now report a trust-gated load-failed verdict on the existingengine.soundDurationwire (authority-referenced clips only, same per-connection rate budget, first-verdict-wins; fired only on TERMINAL failure — 3 real attempts or an unresolvable ref, never for Magic-CDN still-generating/pre-session parks). On verdict the authority marks the layerfailedin replicated state, writes a creator-visible runtime log (music layer "default": no client could load "<ref>". Is the asset loadable?), and every read excludes it (music.now(), the census, client playback); the next verb write prunes it. A freshmusic.playof the same clip re-arms the verdict (fresh chance after an asset fix; failing clients re-offer on a 30s per-clip cooldown, so a still-broken clip is re-told). Deliberately verdict-driven, never TTL-driven: a bare timeout would false-kill honest music in empty rooms and during long Magic-CDN generation. A known duration always beats a verdict (the clip provably loaded somewhere) — order-independently: a trusted duration arriving AFTER an accepted verdict clears it, revives the failed layers with an epoch bump (clients rebuild the voice mid-clip), and tells the creator the earlier warn is void. - Zero-match
music.stopis TOLD (design D2.2): stopping when nothing is playing — or naming a layer that isn't live — now writes a mutationWarn teaching line into the runtime log (music.stop: nothing was playing (music.now() => null) — no-op/no live layer "x"), once per (entity, kind) with value-free dedupe keys. The miss path still writes no replicated state. - The audio census (design D3):
api.audio.playing(): AudioCensus— one read, any realm, returning{ music, loops, emitters, shifts }.music===music.now();loopsenumerates liveplaySound(loop:true)voices as{ soundId, ref, entityId?, bus }— previously enumerable by NO read anywhere (the returned soundId was the only handle and the start event expired after 180 ticks);emitterslists standingaudio:components as{ entityId, ref, bus, spatial }(vibes report their script path; paused vibes excluded; gain-0 emitters included deliberately — the hidden-rig hunt is the point);shiftslists the standing legacy per-listener musicShift/ambience channels as{ entityId, music: {clip,volume}|null, ambience: {clip,volume}|null }(TomePlayerJuiceState, one entry per listener with an audible channel — the second-largest audio population in prod, previously the census's one blind spot). Census = authority truth (what the server told clients to play); client-local hatch audio deliberately excluded. Backed by a new replicated loop-voice registry (TomeSoundLoopsontome/spec,replicate:"always"/clientAuthWrite:"never"— the TomeMusicState chassis), written at the verb sites on authority worlds and by a server-side juice-event sweep for client-auth-authored loops; capped at 128 entries (oldest evicts, ops-logged). Standing loops are now snapshot-visible to late joiners (their enumerability no longer depends on catching a 180-tick event window). - run_script declaration collisions with injected globals teach (design D1.6):
const music = …used to die with a stockIdentifier 'music' has already been declared/Cannot declare a const variable twiceand teach nothing (the Waystead specimen — Savi renamed her variable and never learned the namespace existed). The exec error now appends what the global is:'music' is the engine music API (music.play/stop/now — audio skill); rename your variable.Original error text preserved; user-vs-user redeclarations untouched. music.ducknow carries the same settled-layer prune every other music verb write performs (the documented prune-on-verb-write contract; previously duck writes skipped it).- Pending/failed DECLARED textures render nothing — never an untextured solid (the QA "Paws of Fury" P1: a decal plane with a still-cooking alpha-mask map drew its whole plane as a 0.85-alpha dark slab, and blend:"alpha" dust/petal/KO sprites drew unmasked dark quads while art streamed). Two sites, same law:
- Primitives (batched lanes, primitive entities + bespoke mesh slots): a slot whose declared albedo (
map) has no resident canonical asset carriesPrimitiveTextureState.albedoPendingand is not presentable — excluded from cull-bounds occupancy (radius −1), draw windows, shadow survivors, picks, and outline masks — instead of resolving to the masked layer 0 and drawing solid instance color. Residency is the gate, not placement fidelity: budget/profile flat-floor degradations of RESIDENT textures keep drawing exactly as before, layer-0 "no texture declared" solid-color semantics are byte-identical, and pending normal/height maps still stream in without hiding the instance (shading detail, not coverage). Zero per-frame cost: the bit is written at reconcile time and flows through the existing visibility rails. - FxVM sprite batches (GPU indirect + CPU snapshot paths):
collectDrawsskips a batch until its declared texture is applied (single-texture lease resident, or composited pack ready), instead of drawing with the opaque-white 1×1 fallback that the fragment shader's curve-alpha × tex.a multiply turns into solid quads. Skip-draw rather than a transparent fallback texture: no fragment cost, no blend-mode edge cases. The white fallback and pack placeholder remain as bind-group filler only and are never rendered; a texture that never arrives (silent magic-cdn failure) now renders nothing forever instead of a slab forever.
- Primitives (batched lanes, primitive entities + bespoke mesh slots): a slot whose declared albedo (
- Crossing into a 2D-mode place now resets the player's rotation to identity unless the place's authored spawn rotation actually applied (#9125, the farm-dusk stuck-rotation walk find, 07-13). A player's 3D heading yaw is meaningless in 2D and nothing in 2D ever rewrites it (facing is flipX/clip/texture-swap vocabulary), so whatever rotation the traveler carried at switch time parked on the entity forever — replicated to every client and displayed, since the 2D quad pin passes entity twist through (sprite and parented nameplate spin together). The reset gates on an actual place change and on the spawn rotation having been WRITTEN, not merely authored — same-place respawns keep script-set yaw, and an authored-but-unresolvable rotation can't suppress the reset. Non-players keep their rotation (authored yaw on 2D objects — top-down cars, ships — is the twist pass-through's whole point).
- The animated-character locomotion/facing feature forgets its previous-position state when an entity's place changes: a place transfer teleports the entity, and a prev-pos spanning the teleport read the jump as phantom velocity — the facing system wrote the jump direction as yaw on the first post-transfer tick, and locomotion read the same jump as a speed spike.
- A throwing renderer handler is now contained to its own lane (#9128, ledger 1307 — izkimar's dump 6fb847c7: whole-scene Std/PBR material mute mid-session that neither a script edit nor the join watchdog could wake, only a tab refresh healed). Frame-prep ops are drained destructively from the SAB ring, so one handler throw used to drop the whole frame's drained ops for EVERY handler — never re-applied, silently desyncing the renderer's stores from the sim for the rest of the session, deduped to a single frame-loop error. Each handler's collect now runs in its own try/catch: a failing handler degrades its own lane for one frame, every other handler keeps applying, the frame still renders, and the failure reports once per (handler, message) on the existing frame-loop diagnostic rail.
- The scripted-material refresh retries instead of stranding: the material-scripts handler swapped the library BEFORE the refresh walk, so an aborted walk left library="current" with the scene still muted — identical re-deliveries (edit heals, join-watchdog re-requests) diffed to zero changed refs and never rebuilt anything. Changed refs now queue before the walk and drain on every collect: a throwing refresh retries next frame (idempotent re-refresh), bounded at 8 attempts, then one loud named gave-up diagnostic instead of a silent walk per frame forever.
- Reverted 5.2.0's adaptive remote-view playout (#8339, squash 4f96889a; dig 8151b070, jacob's Waystead field report). The depth estimator read scheduled server quietness as feed lateness — the server skips empty StateDeltas, so a quiet scene flaps the overdue gauge constantly — and while overdue, tickStaleRecord un-converged every held transform ring and dead-reckoned it along its newest snapshot-pair velocity (up to REMOTE_STREAM_EXTRAPOLATION_TICKS = 2.5 ticks). A hop-and-stop critter parks with full pre-stop velocity in that pair, so every flap lurched the parked sprite 5–10px along its last hop vector, then eased it back when the feed caught up. Remote-view presentation returns to exact pre-#8339 semantics: fixed 1-tick playout delay, clock extrapolation bounded to 0.25 tick for rings current to the feed head, and the resting-entity law — a ring stale versus the head holds its newest pose and never extrapolates. The burst-move-then-rest scenario is pinned in synthetic-transform-delta.test.ts as the acceptance criterion for any adaptive-playout re-land (tucker's redesign).
- GPU validation-burst recovery now distrusts cached shadow depth (#9124, the zoo lights walk — ledger-1303 residue present since the 5.2.0 lume cut, not a 5.2.1 regression). The frame is one submit, so each validation-invalid encoder in a burst discarded shadow-atlas and sun-cascade render passes the scheduler had already recorded as rendered — after recovery, lights sampled depth from before the burst (or from a prior slot owner) until the ordinary refresh aged every face out, which read as lights jumping or shadows going wrong for a stretch. Recovery now calls
ShadowAtlasScheduler.invalidateCachedFaces()and forces the next sun-cascade renders — pure CPU flag writes, hoisted above the disposals so a dispose throw can never skip them. Slots, fades, and assignments stay put; every cached face re-renders through the ordinary per-frame shadow budget.