engine v5.2.32
Engine v5.2.32
August 20, 2026
A patch in the Lume line.
Ten cures in one: meshes reworked behind a hidden tab reappear, wire-spawned objects land for every joiner, custom-cursor games click again on desktop, multi-area games stop losing areas, shared UI library edits hot-reload, phantom version churn ends, momentary server hiccups stop reading as dead models, static-model shadows return on iPhone/iPad, big worlds stream in smoother, and creators can cap the frame rate.
what's new
- Meshes reworked while your tab was hidden now reappear when you come back — big generated meshes edited behind a hidden tab could return solid but invisible until each one was nudged by hand; the engine now spends no retry budget while nothing is being drawn, re-requests everything parked the moment the tab returns, and tells Savi how many meshes are undelivered on a player's screen if any stay stuck.
- Objects spawned over the wire show up for everyone, every time. Before, an object created through certain save paths could land in the game's saved data but never appear in the live room — invisible to you and every joiner until someone else happened to edit the world. Now the room builds it within a fraction of a second, no outside edit needed.
- Fixed: custom cursor sprites no longer swallow mouse clicks. Games that replace the native cursor with an image (
draggable=false,pointer-events:none) work again on desktop — clicks pass through to your buttons and your world exactly like they did on 5.2.21. - A model that hits a momentary server hiccup no longer reads as dead. The placeholder used to flip to "Couldn't spawn" — and Savi's eye to "a receipted death" — off one failed read, even when the asset was fully imported and serving; creators were told to regenerate or rename assets that were fine. Now the engine keeps probing through transient trouble, tells you exactly what it measured ("N reads have failed so far") and only declares an asset dead when the server itself confirmed it.
- Shadows from static models come back on iPhone and iPad. On some Apple devices, props and buildings imported as unskinned 3D models silently stopped casting shadows (characters still did). They cast again.
- Multi-area games stop losing areas: walking back into an area that had emptied out (a prologue street, a lobby, an arena) now reliably brings back everything you built there. Before, a busy room could bring the area back as an empty box until the room restarted.
- Cap your game's frame rate —
engine.graphics.maxFpscaps how fast the renderer draws (great for heat, battery, and steady pacing on phones), and players can set their own lower cap in settings. Your game logic runs exactly the same; only the drawing rate changes. - Big worlds load in smoother. While a large game streams in, the renderer used to spend a chunk of every frame re-sorting the scene; that cost is ~10× smaller now, so the first seconds of a heavy world hitch less.
- Editing a shared library file your game's UI uses now reliably updates the screen: before, if the room's update lane hiccuped, a lib-file edit could leave the old UI showing forever — while the diagnostics insisted everything was fine — until the tab reloaded.
›technical notes
- Geometry-wait parks re-arm on renderer-pause exit (dig 5b3d86d5 — derrf's hidden-tab rework). Three moves on the #713/#983 wait tracker, deleting the "creator returns to invisible walls until someone hand-nudges each one" class: (1) the report clock FREEZES while the renderer is paused (
BespokeGeometryWaitTracker.pause()/resume()on the visibility edges + the boot-hidden path) — throttled-flow browsers keep delivering ~1Hz hidden rAF ticks through the mesh handler's collect (which runs above the present gate), so the 10-attempt/~3-minute report budget burned into frames nobody drew while the sim worker was itself throttled too slow to heal anything; hiding pauses the clock, never rewinds it (the frame-budget guard's law). (2) Renderer-pause EXIT (tab return) is a re-arm edge: one unpark-all sweep gives every parked wait — budget-spent and sim-verdict alike — a fresh report budget, because a hidden window is exactly when reworks land unobserved (a plausibly-changed cause, the tracker's own doctrine); a wrongly re-armed unhealable wait re-parks within ~one report cycle (ecs-sync re-classifies per report and re-sends the verdict at-least-once). (3) The parked population gets a getLogs face: a park episode raises one aggregatedbespoke-geometry-parkeddiagnostic ("N generated mesh(es) are parked undelivered on this player's screen", full roster in data) — previously the budget-exhaustion park was console-only on the affected player's machine, reachable solely by per-entityidentify_objectinterrogation, which is why it read as "the stale copy is served from spawn's side". - An accepted god-wire spawn's live half is completed at the fold seam (dig 2431 — the #12074 mirror; 7 field specimens plus the multiwriter net's catch). The container room folds an accepted forwarded SpecMutations batch doc-only —
foldSpecMutationsIntoWorldadvances and persists the doc, deliberately running no applySpec, because the contract assumes the sender's simulating world uploads the created entities via StateDeltas. A thin wire client (anything that writes the wire without a simulating world — the harness gods, and any thin sender) never does, and every applySpec trigger downstream is foreign-gated (kiln skips echo-poking the author room, the self-echo stamp guard skips consecutive self-persists, and every doc-level compare is structurally blind to world↔doc divergence since the fold already advanced the doc) — so a doc-present/world-missing object stayed invisible to every joiner until the next foreign-authored write or a room recycle, unboundedly when neither came. MirroringreapForwardedDestroyTargets("the seam that authorized the destroy finishes it", row 2005/#12074): after fold+persist, every accepted spawn still missing a live entity after the same-flush grace (SPEC_MUTATION_SOURCE_GRACE_TICKS— a real sender's create row lands at preUpdate, before the −150 ingest, so real clients no-op) is BUILT by the server via a scheduled real applySpec of the folded head, whose reconcile spawns every doc-present/world-missing row — the exact cascade a foreign write used to deliver by accident. Idempotent both directions: entity exists → the hold resolves silently; server builds first → a late create row lands in the existing create-collision class (owner-idempotent update / host reparent). The invariant restored: a version the room stamps as self-authored is fully represented in its world. draggable="false"no longer reads as an interactive surface (ledger 2527 — Idle Homestead's dead desktop clicks on 5.2.30/.31). INTERACTIVE_SELECTORS matched bare[draggable]by attribute presence, so the ubiquitous anti-drag idiom on sprite/cursor images — an element declaring itself LESS interactive — entered the pointer-events grant walk. Since row 2437's whole-body 500ms rescue cadence (tap-actions INTERACTIVE_GRANT_SCAN_INTERVAL_MS) that walk reaches body-mounted elements, andmakeElementInteractivestomped the game's mouse-tracking cursor sprite (<img draggable=false style="pointer-events:none">on document.body) topointer-events:auto— an interceptor glued under the pointer hotspot by construction, eating EVERY mouse press in the game (authored buttons and world taps both) while HUD, state, frames, and the control grant all stayed healthy. The selector is now[draggable="true"]— the only drag-positive form of the enumerated attribute, and already the engine's own vocabulary (frame/styles.ts exempts exactlyimg[draggable="true"]from -webkit-user-drag:none). Drag-interactive elements keep their grant; anti-drag sprites keep their authored pointer-events. The dead-link dim inherits the same narrowing (cursor sprites no longer dim as "controls").- A model's death certificate must be earned (ledger 2395, dig abb254af — iz's wardrobe imported clean in 21s and was SERVING while the session read it terminally dead). The model lane minted terminal verdicts off ONE read-side transient: a single 502-without-Retry-After or a lone dead 4xx flipped the placard to "dead" (
modelLoadVerdict) and put the asset in the eye's failed bucket, whose caption dressed it as "a receipted death, not a guess" — the confession instrument overclaiming (ledger 2306's verdict-honesty family, inverted face). The retry ladder was already the N-spaced-re-probes machinery; the defect was verdicts minted at attempt 1. Now "dead" requires earned evidence: the server's own fingerprinted terminal cook-kill tombstone (terminalParked), or a dead 4xx that answered EVERY spaced re-probe across the give-up window (deadParked— 10 dead answers over ~5 minutes). Anything short of that stays "waiting": the ladder keeps probing and a serving asset heals it on the next read. The unready atom names its evidence class (failedFact: "server-verdict" | "read-failure", plusfailedAttempts— the measured N), and the scene-view note tells only what was measured: read-failure atoms get "could not be READ from the asset host — N reads have failed so far: a measured fetch failure, NOT the server's verdict on the asset … do NOT regenerate or rename", while the receipted-death/rename teaching is reserved for server-verdict atoms. Statuses are unchanged (no settle-gate shifts); the 2306 temporary split (retryInMs) is untouched. The attempt-5 retry-ladder diagnostic rides the same honesty (codex review): at that point no verdict can be earned, so it now teaches a measured read failure — loading placeholder, engine keeps retrying, "do NOT regenerate or rename it off this alone" — instead of claiming a "Couldn't spawn" placard and prescribing regeneration against the scene note's own words. When the read-failure bucket holds more than one asset, each name carries its own measured failed-read count (codex review: one shared maximum was a false sentence for every asset below it). - Caster/mask model WGSL compiles on the gpu's latched survivor arm (dig row 2560 — filing d5efc458, app d8a0fb4a on 5.2.31).
getModelShadowPipelinekeyedmodels/shadow/static/depth16unorm/spulland declared the storage arm's layouts, but built the WGSL with the builder's DEFAULT vertex arm — which consumes@location(6) lumeSurvivorAttr: u32with no attribute behind it. The GPU refuses the pipeline (WebKit: "vertex descriptor creation failed !matchesFormat(attribute(6)…)"), three attempts park it, and every static-model caster draw on a convicted device silently skips — shadows from unskinned GLB models missing while everything else renders. Only static-kind model content on the storage arm hits it (convicted WebKit/AGX devices with indirect-first-instance granted);getModelMaskPipelinecarried the identical drift (no production caller today; fixed for consistency). Same commit fixes the draw-side twins that invalidated the encoder instead (rigid-child caster binding +identitySurvivorBuffergaining STORAGE usage). Red-first class pin:survivor-arm.test.ts— for every pipeline the models family registers (3 kinds × color/caster/mask, both arms), every@locationthe compiled WGSL consumes must be provided by the descriptor's vertex layouts with a matching format class. - No-op spec patches never enter the persist pipe (ledger 2551). Every patch-class spec verb already computed a no-op verdict (
specDocChanged/ the effective-view compare) and used it to firespecPatchNoOpWarning— but calledrecordMutationunconditionally, so a value-identicalpatchTerrain(the specimen: a behavior re-patching terrain to its current value, ~1 row/24s for 8 hours, app 3ad02cb1) rode relay-spec-save into a kiln version mint whose only diff was the__appliedSpecSaveRowIdsring: version history churned for hours and Savi's eye tools went EYE-STALE seconds after any read. The verdict now gates the recorder at every site whose verdict baseline IS the durable fold's baseline —applyTerrainSpecWrite(patchTerrain + the material/mark/structure verbs), patchPlayer, patchCamera, patchInputs, patchGodMode, patchUi, patchEngine, patchRouting, updateBuyable, updateJob, updatePlace (including the keyed-map objects branch, which now skips value-identical row upserts exactly like the whole-list branch always did) — each counting through the ledger-1157noteSuppressedNoopScriptMutationreceipt. The warn still fires: the no-op is told, it just isn't persisted. patchAtmosphere stays deliberately UNGATED: its no-op verdict is judged on the session-merged view (base + overrides) while the durable fold applies the patch onto the head's base alone, so an override pre-populated by a behavior would make a tracked bake-in read as a no-op and starve spec.atmosphere forever; behaviors never reach its recorder (no behaviorPersistSourceId), so no tick-loop churn reopens, and the kiln route floors its value-identical mints on the correct baseline. The kiln half (relay spec-save route: mutations mode gets the no-op refusal mirroring doc mode'sidenticalToHead— an all-no-op batch answersapplied: 0with no ring append and no mint, while one real change in a batch of no-ops still mints once) ships with the next web promote, giving older engine bundles the same floor server-side. - The applied-generation record captures its residency/instance keys at expansion time — place re-entry survives interleaved applies (ledger 2145 leg-1 cure defeat, dump a812611d — Alliance of Darkness's black-box prologue). #12599's cure broke the delta-0 binding at unload (place-cleanup drops TomeAppliedGenerationResource), but any later apply re-records it — and the record's top-level
residencyKey/instancePlacesKeywere recomputed at RECORD-WRITE time while every other capture (the derive-gate inputs, the record's ownexpansionfield) reads expansion time. A hook running inside that apply —onSpawn → api.enterPlace, the prologue session-script idiom routing every joiner — marks the unloaded place resident AFTER expansion skipped it, so the completed apply stamped a key claiming a place it never expanded: the parkedplaceResidentre-apply (the re-entry's only materialization trigger) then hit the delta-0 short-circuit and the place came back permanently empty — residency marked,api.spawnlanding without a not-resident warn, authored skeleton/state defaults/onSpawn all absent. The record now storesexpansionResidencyKey/expansionInstancePlacesKey: mid-apply residency drift reads as a delta-0 veto, the drain full-passes, and the reconcile's missing-entity spawn materializes exactly what expansion skipped. A re-entry that accepts a traveler materializes the place. Pinned red-first in place-reentry-interleaved-apply.test.ts (the mid-apply defeat shape plus the unload→interleave→re-enter orderings). - Render frame-rate cap primitive (creator ask r-8691b70e — Sangre y Vitral on an Adreno 619 at ~82ms/frame).
engine.graphics.maxFps(integer 5..240, live on patch,nullclears) is a ceiling the renderer paces presents to: a deadline pacer (renderer/frame-rate-cap.ts) gates the frame body exactly like the governor's 120 Hz half-rate pacing — steady spacing by schedule-anchored deadlines, stall re-anchor instead of catch-up bursts, sim tick untouched (determinism untouched). It min-composes with the player's own explicit cap (new resolved player-settings laneperf.maxFps, sanitizer-clamped 5..240; boot seed on the renderer init's playerPerf snapshot, live viaset-player-max-fps— players may always go lower, never higher), and the quality governor's judged refresh target folds the same cap (setFrameRateCap, themaxRefreshTargetHzmin): a 30-capped session is budgeted against 33.3ms — quality sheds only when the device can't make the CAPPED rate, up-probes stay live, and the capped cadence can never read as the environmental-throttle signature. Spec rail is the renderResolution grammar end-to-end: zod contract in tome-schemas, spec-sync →draw/frame-rate-capclient-plane singleton → renderer handler; the in-game FPS-slider menu itself stays the creator's script — the primitive is the whole engine surface. - The synthetic-transform ordered rebuild buckets by depth (#13264 — the streaming-load sort term).
getOrderedRecordsre-sorted ALL records wheneverorderDirty, withrecordDepthwalking the parent chain inside the sort comparator — O(N log N × depth) per streaming frame (504ms of an 11.2s load capture vs 39ms settled, renderer worker). Depth is bounded byMAX_PARENT_DEPTH, so the rebuild now buckets records by one O(depth) walk each — no comparator at all — and the ingest touched-sort memoizes depth into a scratch map before sorting. Ordering is unchanged: stable within a depth by map iteration order, parents still before children. Bench (scripts/bench-synthetic-order-sort.ts, 10k records in chains 3-5 deep, 120Hz frames): dirty-sort term 5.68 → 0.51 ms/frame; the settled path is untouched (early return unchanged). - The HUD stale-compile heal and its receipt key on the whole require() closure, not the entry source alone (ledger 2426 face 3 — the lib-dep hot-reload miss). Row 2186's heal re-keys the game HUD's compile slot on the live doc's
ui.rendersource and recompiles when the serving graph'suiSourcelags it — but a save to ascripts/lib/*.jsmodule the entry requires moves NO byte of the entry, so under a stalled apply lane the guard read converged, the compile receipt pinnedsinceMsat mount asserting health, and the HUD deduped the old panel forever (Apartment 6B 26d489f5: three saves toscripts/lib/ui-directory.jsunder an unchanged ui.js, 52k+ dedups on one frozen sig, compile ok).CompiledSpecnow stampsuiDependencySignature(the tracked dep closure's per-source signature, from the same dependency sets the compile already records — a Map hit, never a re-walk), the controller compares it besideuiSourceand heals through the same content-keyed cache, and the receipt'ssourceHashfolds the dep signature in sosinceMsre-stamps on lib edits too — the receipt can no longer assert health while a dep-shaped edit serves stale. Graphs minted before the field existed read as "unknown — never heal on deps alone", so no healthy mount confesses a stall that never was. Pinned red-first in worker-controller.test.ts (the lib-dep stall shape plus the pre-field no-spurious-heal control).