engine v5.2.20
Engine v5.2.20
August 7, 2026
A patch in the Lume line.
what's new
- Cleaned up engine diagnostic noise that every game session was quietly emitting — no visible change in your game, slightly less background chatter from the engine.
- While a model is still being made, its in-world stand-in now says so: "Making cat hero… takes a few minutes" instead of an endless "Loading…" — players watching a long cook know something real is coming and roughly how long, instead of reading the world as broken.
- Fixed frame pacing on some machines (many Macs included) where the engine misread the screen's refresh rate as faster than it really is and paced the game against a budget the display never had — those machines now run at their display's honest rhythm.
- When a save partially fails, Savi now gets told exactly which pieces failed and why — so she fixes the real problem instead of re-running things to figure out what broke.
- A typo'd shape name in a spawn now fails immediately with the list of real shapes (and a "did you mean"), instead of silently spawning nothing and breaking the next save.
- Notes and archived files in your scripts folder no longer break saving: a README or a
.bakbackup sitting in scripts/ used to make the game refuse every save read after an update — it looked like your character got completely reset. Those files are ignored now and your real scripts run exactly as before. - Box-bodied cars and characters no longer catch on invisible seams in flat terrain — no more phantom stops at low speed or bumpy hops at high speed on ground that looks perfectly smooth.
- Reading a nested property path now just works: getProperty("primitive.width") answers what you wrote there. And bad values tell you what they wanted (a scale of "big" explains itself) instead of quietly shrinking your object to normal size.
- Spline verbs are honest now: a wrong option (like a misspelled space or a bad offset) tells you exactly what it wanted instead of quietly doing something else. And offsets written as [x, y, z] arrays just work.
- Fixed a bug where accidentally putting a function inside your saved state (even deep inside an object) could silently undo everything else your script just did. Now the function is simply skipped, everything else saves, and the log tells you exactly which key was skipped.
- Deleting a state key that has a dot in its name now works:
deleteState([["dotted.key"]])reaches exactly the key you wrote, and if you spell it the old way the log tells you the working spelling. And handing the state verbs something that isn't a state object gets a clear "call ignored" instead of quietly wiping your state. - Coming back to your world after a disconnect no longer bounces you or your friends with "room full": a player who just dropped doesn't count as still occupying their seat while the game waits for them, so one-seat worlds stop turning away the very player they were made for.
- Terrain reads stopped inventing answers: asking for the ground at a broken coordinate (NaN from a divide-by-zero, say) now tells you exactly that in getLogs instead of quietly answering "the ground is at 0".
- When a bounds read comes back empty, the log now tells you why — a typo'd id, an object with nothing visible to measure, or a model that hasn't finished loading — instead of leaving you to guess which one you hit.
›technical notes
- lume alloc census logs only where armed (#12335, jure's catch in #w-rendering): wave 3 wired
tickAllocCensusinto the SHARED lume frame loop un-gated, so every prod client dumped a census line every 300 frames into Datadog. Now an arm-gate, armed exactly by the jsc-shell entry (the census's one consumer — the consumer declares itself, no env sniffing); every other bundle including prod web is a no-op. W4-PHASE-CENSUS never leaked (its ticker arms off the shell's__jsc*globals, verified null in browsers). - The in-world placard gains the cook register (r-426fb323, dig af2514aa, #12206): ModelLoadVerdict grows
'cooking'— the asset service's ownlastHttpStatus 202receipt (written only by the still-generating lane) walks through to the placard, which then names WHAT is being made and the honest time class ("Making cat hero…" / "takes a few minutes" — no fabricated ETA). Ledger 1675's register law holds: generation language only on surfaces that can prove a generation job from the server's own answer; unproven non-failed copy stays "Loading X…", dead checks unchanged. Two-line labels ride the existing lume text lane (layoutGlyphs newline split; anchor [0.5,0] grows the block upward off the hologram). Copy pins updated; MID_COOK fixtures carry the verdict the real service answers mid-cook. - Quality governor refresh detection rebuilt (#12346, jure's 120.1Hz-is-not-165Hz thread): the p10 floor estimator raced the fast tail of rAF DELIVERY jitter — worker rAF timestamps are callback delivery times, not vsync times, so late-then-on-time delivery manufactures intervals shorter than the display tick, and every misjudgment landed in the budget-tightening direction. Replaced with a mode-share + median estimator over the same 240-interval ring: the judged point must hold ≥25% of the window inside its band (fastest-first), between-points promotion reads the MEDIAN rounded down onto the vocabulary, and adoption residency is asymmetric by direction — down stays ~1s + the load-gate, UP takes ×3 sustained residency (this clock can fake fast, and a wrong fast judgment manufactures overload). All five field streams pinned as fixture tests (clean/jitter/double-fire/mac/rig); no presented-frame clock exists in the worker to confirm against — stated on the detector.
- Persist verdicts name their failures (dig 9741709b, #12344): every per-mutation error entry carries
target(the mutation's own id/path/place/behavior-target/starter-id — one generic probe, no per-kind switch) on all three error lanes (throw, row gate, batch gate) and in the operator reject log; the studio-chat verdict formatter reads[#i kind(target)] path: why, mutation-first, every failing mutation named when ≤10 failed and first 10 + explicit(+K more failed mutations)beyond. Savi's deliberate bisection re-runs — re-running a batch purely to extract WHICH mutation the old "N of M failed" verdict withheld — lose their reason to exist. api.spawnrefuses an unknownprimitive.kindhonest-early: the curated primitive validator throws at call time with the persist gate's own vocabulary (derived from PrimitiveSpecSchema, plus a did-you-mean) instead of letting the bad kind detonate the whole persist batch later. setProperty lanes untouched.- Worker-layer fix recorded here because creators experience it as engine behavior (rides kiln deploys, not this kernel tarball; ledger 2068, dig 095031f8): a non-JS file in
scripts/(README.md,archived.js.bak— Savi's own documented practice writes these) failed acorn's parse in kiln's published-SQL manifest extractor, marked the whole manifest PARTIAL, and the cf-storage door then refused the game's entire player SQL lane — save reads at join refused, presenting as a full character reset at the update boundary. The extractor now consults the file's NAME at the parse-failure/size seam only (isJavaScriptShapedPath, kiln core/game-db/published-sql-manifest.ts):.js/.mjs/.cjsand extensionless entries that cannot be read still fail the lane closed (they are scripts the engine may run), while non-JS-named files are skipped. Skipping never admits a statement — the manifest is an allowlist, so anything unextracted refuses per-statement at the door. A non-JS-named file that PARSES as JavaScript still extracts under normal posture rules, because the engine's module rail resolves exact-named keys verbatim. - Mantle box-character wedge/pop on flat heightfield internal edges cured (ledger 2080, tiger's nightline; conviction from the #12391 A/B harness):
- Mechanism: the character controller's contact collection (
move-shape.ts collectContacts) ran GJK/EPA against each heightfield/trimesh triangle as a bare 3-point hull (queries/primitives.ts) with no active-edge information — the narrowphase's FixNormal law (static-trimesh.ts) never reached the CC path. A box parked at the padded gap near the next cell's shared edge got a closest-pair normal of box-bottom-edge vs triangle-edge — near-horizontal, belowmaxSlopeCos— a manufactured wall. At 12 m/s the plane solver blocked all forward motion forever (0.091 delivery, 133-tick wedge ~0.02 m short of integer cell boundaries); at 51 m/s the same plane converted forward velocity into upward slide (+0.42 m single-tick pops, grounded flap). Capsules escaped geometrically: the bottom sphere keeps seam normals inside the walkable cone. - Fix:
collectConvexContactnow applies the narrowphase's active-edge FixNormal law to per-triangle static contacts — when the contact's barycentric feature region has only inactive edges, the triangle face normal replaces the manufactured one and the separation is projected (gap ×= dot); within 1° the snap is unconditional (the same float-fuzz kill that keeps flat-ground normals bit-exact). Edge activity comes from the enumeration that produced the triangle (queries/primitives.tscurrently-visited-triangle context): cooked masks for trimesh, the on-the-fly neighbor-normal rule for heightfields (static-heightfield.ts heightfieldTriangleActiveMask). - The #12391 divergence pins regenerated toward the rapier column per the suite's own header rule: mantle @12 delivery 0.091 → 1.000 (streak 133 → 0), mantle @51 delivery 0.928 → 0.99999 (maxRise 0.421 → 0.000, flap 12 → 0, airborne 30 → 0). The race run now crosses the two-chunk seam at x=0 with min tick delivery 0.999998.
- Determinism goldens regenerated (
runtime-browser-scenario.ts): characters/narrowphase/solver/world sections moved (+ nightly chain.queries);finalCharacterXmoved by ~4e-5 m; every other section hash and every physical metric stayed bit-identical. V8/JSC receipts verified identical.
- Mechanism: the character controller's contact collection (
- get/setProperty asymmetries cured (ledger 1947, tucker's ObjectAPI audit batch 1):
- Dotted read parity:
readDottedProperty(properties.ts) is the read half of the dotted machinery — same path grammar asbuildDottedPropertyMerge(split at first dot, samemodel.materials→partscanonicalization) wired intogetPropertyandgetObjectProperty. The write half existed for years; the read half kept answering undefined and scripts "fixed" working code. - Non-string keys: one teach rail (
warnNonStringPropertyKey) across getProperty/setProperty/getObjectProperty/setObjectProperty — wasnull.startsWith/key.startsWith is not a functionraw TypeErrors. - The null promise honored, and truthfully scoped:
setProperty("scale", null)clears to neutral 1 (was a TypeError atvalue.xin the writer, contradicting the boundary's own "Pass null to clear" log); the undefined-value log now says "most properties accept null to clear" instead of promising it universally.
- Dotted read parity:
- Silent numeric coercion cured at the property boundary (ledger 1941):
CURATED_UPFRONT_PROPERTY_KEYS— curated validators now run up front on live writes for audited keys (rollout gate, spawn() has always run them all):scalefirst, whose garbage writes used to be rescued to{1,1,1}by quantize + component sanitize with zero log traffic (live-probed: an authored scale-2 box silently RESET to 1). The scale validator refuses non-finite numbers and partial/non-finite{x,y,z}with verdicts naming the value. A blanket gate was deliberately not shipped: writers likevisible: Boolean(value)are lenient on purpose — widening is one line per key after a leniency audit.
- Silent numeric coercion cured at the state choke point (ledger 1941, the 1943 rail's numeric kin):
quantizeStateValue/quantizeTrackedStateWriteValueturn non-finite numbers intonull(JSON's own semantics) at any depth and teach through the json-safety rail with the path (kind: "nonfinite");isSimplePatchroutes non-finite to the slow path (top and nested level — the same fast-path leak shape as the 1943 symbol kin); the tracked-proxysettrap applies the same law at the proxy door.
- Two fixture-stock
test.failspins (dot-path-round-trip, nan-state-coercion) flipped to unexpected-pass and were promoted to real assertions per that file's protocol. One old test pinned the silent 0 (getState direct writes normalize non-finite numbers) — flipped to pin the null + row citation. - Docs ride same-PR (ravi r-4f375dc0): types.ts docstrings (getProperty/getObjectProperty dot-path parity, patchState/replaceState non-finite law), skills/api-reference.md, regenerated TomeAPI.md + tome-api-prompt.ts.
- Spline family contract cures (ledger 1946, tucker's ObjectAPI audit batch 1 — the validation half; the mesh-rebind-on-spline-move half is a separate engine dig):
placeOnSplinereadsoffsetas{x, y, z}OR[x, y, z]— the tuple used to fail the Vec3 guard and be skipped silently while the call still answeredtrue(the boolean is the caller's verification; success now means effect). A malformed offset refuses the whole call:false+ a verdict naming the accepted shapes, no partial placement.projectToSplineaccepts[x, y, z]points; a malformed point answers a verdict-carrying warning instead of a bare null indistinguishable from "no spline".options.spaceis validated acrosssampleSpline/placeOnSpline: anything but"normalized"/"distance"refuses (null/false + a verdict naming the valid values) —space: "furlongs"withat: 21.71used to clamp silently to the spline's END undersuccess: true.- Non-finite
at(NaN/Infinity) refuses with a verdict instead of a bare null.
- Two fixture-stock
test.failspins (spline-offset-silent-skip, spline-space-fallback) flipped to unexpected-pass and were promoted to real assertions per that file's protocol. - Not new in this change but pinned by its tests: non-string spline object ids (
getSplineLength(5)) teach-and-refuse via the ledger-2018 id-resolver guard instead of throwingQ.includes is not a function. - State writes filter unstorable leaves (functions, symbols) at EVERY depth, not just the top level (ledger 1943, tucker's ObjectAPI audit batch 1). Before:
patchState({ deep: { bad: () => 2 } })slipped the top-level-only filter, the function survived into the stored bag, and the whole script's mutation batch rolled back at the serialization seam — one nested function nuked every other mutation in the run_script. Now the drop costs exactly its own key (array slots become null, JSON's own semantics), the rest of the write lands, and a getLogs warning names the dropped path (state values must be JSON-safe: function at "deep.bad" was dropped — the rest of the write landed…). Applies to patchState/replaceState/setState, setObjectState/patchObjectState, spawn state, and direct tracked-proxy assignment (api.getState().bad = fnis refused with the same teach instead of storing). Symbol values also used to sail the simple-patch fast path straight into the stored bag; they filter identically now. - The old top-level-only warn ("Functions cannot be stored in state and were filtered out") is replaced by the per-path JSON-safety teach rail (same rail as Map/Set/Date normalization: runtime log → getLogs and run_script's returned logs, cooldown-deduped per script per key).
- One path grammar across the state verbs (ledger 1942, tucker's ObjectAPI audit batch 1):
normalizeDeleteStatePaths(the one place dots ever split) now also accepts segment arrays — literal key chains that map 1:1 onto the wire/mutation format (paths: string[][]), so the API-level grammar converges with the recorded-mutation grammar and keys containing dots are reachable. patchState keys are LITERAL (unchanged — now said out loud in the docstring).teachDeleteStatePathObstacleclassifies no-op delete paths against the live bag: literal-dot shadow (teaches the segment-form spelling verbatim), array obstacle (arrays are leaves per the merge law — replace wholesale), primitive obstacle. Missing paths stay silent (documented idempotence). Wired intoapplyDeleteStateToEntitybehind a changed-nothing guard so single-path deletes that land pay nothing.
- One door for the map-taking verbs (ledger 1944):
resolveStateMapArgvalidates the state/patch argument (tracked-state proxies unwrap first) across replaceState/setState/patchState/setObjectState/replaceObjectState/patchObjectState; refusals teach with the verb's own name and the value. Kills three lies at once: number → silent state CLEAR, string → index-keyed bag, no-args → rawSymbol(tome.trackedStateRawTarget)TypeError leak.replaceObjectStateimplemented assetObjectStateunder replaceState's name (shared bodyapplyObjectStateReplace; each alias keeps its own name in the teach rails; read-only prototype covers it). The verb family now reads replace·set·patch·delete on self and cross-entity alike.
- Docs ride same-PR: types.ts docstrings (patchState literal-keys law, deleteState/deleteObjectState grammar + segment form, replaceState argument law, replaceObjectState), regenerated api-reference.md + TomeAPI.md.
- 17 new pins in
state-verb-grammar.test.ts(bare-world, fixture-stock pattern). - A suspension-stamped dead-socket ghost no longer holds a capacity seat at the admission door (ledger 2072, dig bd369775). On ws_close a Ready connection stays grace-held (phase Ready,
detachedAtMsset) for the 20s disconnect grace, and in client-auth worlds the session is suspension-stamped intoTomeSuspendedSessionson detach (takeover grace 0) — the engine's own declaration that the owner is gone and the envelope is host-simulated place remainder. The door kept counting that ghost againstspec.routing.maxPlayersanyway, so on a maxPlayers:1 world every disconnect made the room unjoinable by any other clientId for the whole grace window (LFO playtest: 11 room-full rejects in one window, savi's router minting a fresh container per bounce — ≥6 concurrent containers of one app, two testers structurally unable to share a world). The capacity count now skips a connection that is BOTH detached and suspension-stamped. The reservation survives where the occupant is plausibly present: an unstamped detach ghost (no avatar, or a non-client-auth world) and a hidden-tab suspension on a live socket still count. The ghost's own owner was never door-blocked (same-clientId reattach resumes past the count — behavior unchanged, now pinned by test). Deliberate residue: a stamped owner who returns after a newcomer took the seat resumes into a transiently over-cap room, bounded by the disconnect grace — favored over turning live players away for a ghost. - The room-full reject is a structured log event (
netcode.connection.rejected_room_fullwith roomId/clientId/connectionId/maxPlayers, via logger.warn) instead of a console.warn whose fields object was dropped by log ingestion — the dig's named receipt gap: eleven rejects, none attributable to a client. - Terrain/water point reads refuse non-finite coordinates (ledger 1945, tucker's ObjectAPI audit batch 1; the 1941 silent-coercion class, terrain specimen):
getTerrainHeight,getWaterLevelAt,getTerrainNormal,getTerrainMaterialall answer null for NaN/Infinity x/z through one loud-once teach rail (teachTerrainProbeNonFinite) naming the verb and the coordinate. NaN used to flow into the sampler, the generator-fault rail clamped the non-finite result to 0 — blaming the CREATOR's generator for the CALLER's coordinate — andgetTerrainHeightanswered a silent 0 against its own "(never a silent 0)" docstring. The siblinggetWaterLevelAtalready had the finiteness guard; the cure is symmetry. getTerrainNormal(ObjectAPI + CameraAPI) returnsVec3 | null: null for no-terrain and non-finite coordinates — the same null semantics asgetTerrainHeight. The old{x:0, y:1, z:0}phantom-flat answer read as plausible data over places with no ground at all.- One fixture-stock
test.failspin (terrain-height-nan-silent-zero) flipped to unexpected-pass and was promoted to a real assertion per that file's protocol. - Docstrings + regenerated api-reference/TomeAPI teach the shipped null semantics (ravi r-4f375dc0 row 2, same-PR law).
- getWorldBoundsBox null polysemy cured by naming, not by return-shape change (ledger 1948, tucker's ObjectAPI audit batch 1; kin of the 1931 enumeration family): null used to mean five things — missing object, no geometry, light-only,
"", null-arg — while the docstring reserved it for still-loading and the promised warning fired for exactly that one cause (the rail was gated onreadPendingModelId). The return type staysWorldBoundsBox | null(every existingif (box)guard keeps working; the platform's own register for this class — voxel probes, terrain reads — is null + a named verdict, not a discriminated wrapper).teachWorldBoundsNull— the r-0223b62a loading rail generalized into a classifier, loud-once per (entity, cause, script): "loading" keeps the original text; "no-geometry" names what null means (nothing to measure, never zero size) and, for spline owners (resolveObjectSplineValue), names the generated sibling id prefix that carries the rendered surface.teachBoundsMissingTarget— the missing-object cause teaches at the API boundary with the caller's spelling (""included), mutation-warn throttled per id.- Non-string ids (
getWorldBoundsBox(5)/(null)) were already cured by the ledger-2018 id-resolver type guard — pinned here so the Q.includes crash can't regress.
- Docstring (ravi r-4f375dc0 row 3 — the doc shape this cure decides): "null always means 'no answer', never 'zero size' — and every null names its reason in a getLogs warning", enumerating the reasons. Regenerated api-reference.md + TomeAPI.md.
- Remainder, named honestly: owner-answered spline bounds (the spline object itself answering its surface's box) is the same generated-SIBLING architecture dig as the 1946 mesh-rebind remainder — the loft mesh is
${owner.id}__spline__*with no parent link, so no subtree union can see it. The verdict's redirect is the working call today; the row stays open for that dig.