engine v5.2.34
Engine v5.2.34
August 22, 2026
A patch in the Lume line.
Six cures: deleted objects can never pop back after the save confirms, players' script-driven saves stop dying silently in relay rooms, tweaking one knob on a custom material no longer wipes the material, group placed pieces without anything moving (keepWorld), roads can vary width along the route, and old room-host images can't silently wedge new engine sessions.
what's new
- Tweaking one knob on a custom material no longer breaks the whole material. Changing a single parameter on a script-built material (a glow's gain, a beam's density) used to silently throw away the script and every other parameter — the object snapped back to a plain solid-colored default, and it looked like your material was never there. Now a single-parameter tweak changes exactly that parameter and leaves the rest of the material alone.
- Group objects that are already placed without anything moving:
attachTo(rootId, { keepWorld: true })adopts a piece under a parent while keeping it exactly where it stands — build a brazier or chandelier from placed pieces, parent them to one root, and move the whole thing as one object. - null
- Saves and other database calls made by players' own behaviors were silently dying in relay-hosted rooms — on some games, all of them ("GameDB door forward has no lane on this wire"). Fixed: those calls now ride the relay's database lane and wait out reconnection blips instead of being dropped.
- Roads can vary their width along the route. Give a road's
widtha list — one number per point, like[2, 8, 2]— and it narrows and widens smoothly between them: straights that open up, corners that pinch, a plaza that necks down into an alley, all on one spline. Edge lines, sidewalks, hedges, and lamp posts follow the changing edge, and driving/collision matches what you see. A single number still means one width for the whole road. - Deleted something and watched it pop back a few seconds later? Fixed. Your delete always saved — a laggy sync lane could briefly re-load the world from just before it and bring the object back. The engine now remembers what your last save contains and never rolls behind it.
›technical notes
- Dotted writes into a scripted material merge instead of gutting it:
setObjectProperty(id, "material.params.X", v)now preserves the script ref and every sibling param (r-3fb1c3c8, ravi's dig 4f5f3ff8, dump e3b929c6; 5.2.34 rider). The dotted machinery already merged correctly — buildDottedPropertyMerge deep-sets into the property reader's flat echo, script included — butnormalizeMaterialSpeconly recognized the AUTHORED scripted spelling ({kind:"scripted", script:"scripts/x.js"}); the shape the engine itself emits — the reader's echo and the persisted mirror,{key:"Scripted", script:{ref, params}}— fell to the standard branch, wherescriptandparamsare interpreted flat keys the override collector drops. So every dotted material write on a scripted material handed the writer a value it silently gutted to{key, params:{X}}: script ref gone, all sibling params gone, and a script-lesskey:"Scripted"renders as the Std/PBR default. That is exactly the mergeproperties.ts's flat-echo comment promises the echo exists to serve ("Readers feed dot-path writers … dotted setObjectProperty merges"), and the same lossless round-trip contractmaterial-utils.tspins for standard materials (ledger #173). It hit Savi three times in one session across spellings (material.params.*twice,material.script.params.*) — dump e3b929c6 carries fiveshaft-n-*prisms persisted as{"key":"Scripted","params":{"gain":1.5}}after the v116 receipts show the full 8-param scripted material: jacob's solid violet slabs. Two changes, one mechanism: (1)normalizeMaterialSpecround-trips both scripted spellings — object-shapedscript.refaccepted, top-levelparamsfolded over script-carried params (the top level is always the newer write, since dot-path writers deep-set the spec spelling there) — which also repairs the tween-evaluator and god-mode gesture-commit lanes that re-apply the flat read-back through the same normalizer; (2) the dotted grammar canonicalizesmaterial.params.*→material.script.params.*when the current material carries an object script (themodel.materials→partsmove), so the merge lands beside its siblings, the mirrored spec stays in the one persisted shape, andgetProperty("material.params.X")answers what the write landed (read/write parity, both halves sharecanonicalScriptedMaterialSubPath). Whole-material assignment is untouched — replace stays its meaning. Red-first: the three dotted spellings, the v116→v119 persisted-spec fixture (full material before, dotted write, full material after minus only the addressed leaf), the whole-replace pin, and the mirrored-shape reload path (applySpecre-applying{key:"Scripted", script:{ref, params}}), all red on the old normalizer. Not touched here: a gutted legacy{key:"Scripted", params}with no ref anywhere still folds key-only (no ref to recover), and the b2 half of the dig (reversed smoothstep edges emitted verbatim to WGSL) stays open. - The merge contract is now pinned for EVERY dotted subtree, not just material (jacob, in-thread 1787363264: "that foot gun is insane. remove it for all cases not just this one"). The general contract: a dotted
setObjectProperty("<parent>.<leaf>", v)changes ONLY the addressed leaf of the parent's echo — every sibling the reader echoes survives — while whole-subtree assignment stays a replace. New census suite (dotted-write-merge-census.test.ts) probes 17 families generically (read echo → dotted-write one leaf → read again → after must equal before with only that leaf deep-set): feetPosition, scale, primitive, material (echo-space), text, sprite, sign, light, audio, particles, fx, physics, visibleRange, jiggle, layout, npc, mixer — plus the families pinned in their own suites (rotation euler leaves, model parts/materials, animated3DCharacter part materials). Census verdict:buildDottedPropertyMergemerges by construction — clone the reader's echo, deep-set the leaf, hand the whole parent to the writer — so the replace-instead-of-merge class only exists where a writer's normalizer refuses the reader's echo and drops what it doesn't recognize; the scripted material was the one live instance in the registry, and the census pins each family against that asymmetry returning. Named bound on the census: attachment (writes ride the parent verb), scatter (template arrays — dotted indices are not part of the taught grammar), ik (channel values carry no sibling records), spline (writer runs the preview pipeline a bare test world doesn't host). setParent/attachTogrow akeepWorld: trueoption (r-0752ba7b, jacob's brazier dig 3acd7d3f §2) — the world-preserving adopt, three.jsattach()semantics. Unspecified position/rotation/scale now default to the local values that keep the child's current world pose under the new parent (rotation/scale invert the parent frame up front; the position leg converts throughresolveWorldFeetPositionWriteafter the link lands, so pivot folds and socket/part anchors peel exactly as the hierarchy solve composes them). The snap default is unchanged — the equipment idiom and the specparentwrite lane both rely on it. Frames with no honest conversion (bone attachments, camera subtrees, ~zero parent scale axes) refuse loudly before any mutation.- The private-transport port's admission bar fails OPEN on an absent generation stamp — the port-protocol skew class dies structurally (dig d75f7599, 2557's second lane). The worker's
PrivateClientTransportgate (row 2515, 37f6a0c080) was fail-closed on ANY mismatch —if (message.generation !== this.dialGeneration) return;— but the headless room-host shell BAKES the bridge into the container image and DOWNLOADS the worker per room pin, so the two halves of a protocol designed as one bundle can ship from different builds. The pinned b5f063f48e-era image's pre-stamp bridge posted{type:"open"}with no generation; 5.2.31+ workers read that asundefined !== 1and silently dropped every open/message/messageBatch/close —opennever flipped, the hello could never leave, the 4408 close never reached the reconnect ladder, and the summons parked 180s to exit 6. 24 apps wedged for 4 days; no engine pin could fix it (every post-2515 worker carried the bar, and the stale bridge couldn't answer it). The cure per the dig's fix-shape:generation !== undefined && generation !== this.dialGeneration(staleGeneration) — an absent stamp proves the far side predates stamping and is delivered; a mismatched stamp is still a superseded dial's late flush and still drops. The 2515 protection stands whole: in browsers both halves ride one bundle so absence never occurs, and the stale flushes the stamp exists to kill only ever arrive STAMPED from a post-2515 bridge. Red-first: unstamped open/message/messageBatch/close from a hand-posted legacy bridge reach the client (red on the fail-closed bar), a mismatched stamp still drops, a matching stamp passes (room-transport-bridge.test.ts). The protocol doc now names the reader-leniency law: every wire-shape change owes the question "what does the OTHER era's half do with this message?". Ships with the trigger half of the same PR: container-build.yml's auto-advance path filter (and container-image-drift.yml's lineage twin, lockstep) now watches the baked↔downloaded port-protocol seam roots, so a protocol change recooks the image instead of drifting under the pin. - Player-behavior api.sql on relay-hosted singleplayer rooms no longer dies at the relay egress' dead-lane drop — the forward gate is tri-state honest (ledger 2438 P1, medivhus's Cardhouse; field kin: the Grind95 save write lost in a host-flap window, 08-19). The forwarded GameDB door's eligibility gate (
tome/api/game-db-forward.tsisGameDbForwardEligible) read the 2-state live wire sniff (clientWireMode !== "relay"), and the sniff's "undetermined" hold — the first-frame window AND every between-sockets redial (rejoin, suspend/overflow re-bootstrap, host-election flap) — stayed forward-ELIGIBLE by documented design. Every behavior api.sql issued in a hold enqueued aSqlRequestthe egress deferred, and the welcome proving relay dropped the whole backlog typed at the dead-lane drop (game-db-forward.tsrejectDroppedForwardedGameDbCall— "GameDB door forward has no lane on this wire"). On Cardhouse (singleplayer pin, relay-hosted dev room-1) the window read permanently open across three engine versions: 100% of player-behavior SQL dropped while the DB sat provisioned and healthy (migrate_applied=1134), so the creator's all-truth-in-SQL game ran stateless for players. The cure per the row's own prescription: the gate now readsclientRelayWireTruth(the pin-04da3c48 pattern netcode's travel-authority forks already use) — the live sniff when determinate, else the welcome-edge stamp (ClientRelayWireModeResource). A stamped-relay world in the hold falls through to the relay lane, whose posture is composable there (the relay identity survives socket bounces) and whose pre-send park (ledger 2381) holds the call replay-safe until the session opens — hold what can be held. Fresh worlds whose wire never proved relay (stamp absent) and kernel-stamped reconnects keep today's path exactly; the egress dead-lane drop stays as defense-in-depth and now fires only on a fresh world's pre-first-welcome window or a resolution regression. The security boundary is unmoved: relay wires still carry no control-channel door, and the diverted calls ride the relay player lane's existing digest-and-verified-identity argument (or the room_host lane on a credentialed seat). Red-first ingame-db-forward.test.ts: the between-sockets fixture (stamp relay, sniff undetermined) resolves the relay lane, parks, and SENDS when the wire opens — nothing touches the dead control lane; the kernel-stamped hold keeps the forward lane. - Road
widtharrays are honored per-point — the schema's existing promise, kept instead of averaged (ravi's vocabulary datum from the #13374 teaching-child; 5.2.34 rider).SplineSpec.widthhas always typednumber | number[], butresolveRoadVisualWidth(spline.ts:1142) silently averaged an array to one scalar — Savi writeswidth: [2, 8, 2], gets a uniform road, no error (the #13371 build was fooled exactly this way; ravi's fix-forward taught around it in world-composition.md, now reverted). The road surface now lofts a per-frame half-width:width[i]ridespoints[i](clamped to the last entry, likerolls) and interpolates linearly by arc distance between them, through the samebuildPerPointDistanceSamplesparameterization the rolls channel uses — one mechanism, two per-point channels. Roadside features track the varying edge (edge lines, sidewalks, hedges, and lamp posts all take their lateral offset fromwidthAt(distance)), and the collider is definitionally the same mesh (custom-primitive physics reuses the surface'sBespokeGeometry), so collision follows the taper — the lie can't move. Scalar and uniform-array roads are byte-stable: a cross-revision dump of a full-roadside scalar road (16 generated entities, 8 geometries, curving 3D centerline) diffed byte-identical against master. Invalid array entries fall back to the average of the valid ones; an all-invalid array keepsDEFAULT_ROAD_WIDTH— the behavior arrays always had. Red-first pins in spline-road-per-point-width.test.ts:[2, 8, 2]narrow-wide-narrow at the authored points plus an interpolated midpoint, scalar ≡ uniform array, edge lines hugging the varying edge, and the collider-is-the-mesh agreement. - A confirmed spec save is now a head claim — the stale-refold resurrection class dies (ledger 2599 P1, cal's god-mode resurrections: thread 1540752985330819262, dig 1580caec). On relay rooms, the spec-save confirm (
kiln:spec-save-resultok) splices the batch out of pending custody (relay-spec-save.tsonResult) — the instant the fold-rebase overlay stops protecting the write — but DISCARDEDresult.version: nothing recorded "my write is folded into every durable version ≥ N". A client on the F3 heartbeat-beacon fallback lane (relay-join.tsonHeartbeatPointer — which re-states the RELAY's checkpoint, one version behind the client's own just-confirmed write by construction, and cal's ONLY spec lane: 31/31 SPEC frames missed on the ring) would then fetch and fold the PRE-write doc, and applySpec's spawn pass re-materializes any spec row with no live entity (interpreter.ts:!world.hasEntity → spawn). Tree deleted at v21943; the beacon folded v21942; tree back ~3s later — on tape twice in the ECS capsule (two#delete:clickdestroys of the same id, 3s apart, zero inputs between). The cure per the row: the ok verdict's saved version now feeds the SAME spec-claim door SPEC frames and the F3 beacon share (RelaySpecSaveScheduler.onConfirmedSavedVersion→ netcode → transportrelayNoteSpecSaveConfirmed→ relayJoin hooks →RelayJoinCoordinator.onSpecSaveConfirmed→armSpecClaim), so the client never folds a doc older than its own confirmed write: an older beacon can never displace the claim, an in-flight older fetch discards on resolve (the existing supersede check), and once the confirmed version folds the adopted head covers everything below it forever. Claim lifetime is untouched — room-lineage scope, survives socket bounces, dies at resetForNewRoom — and at-or-below-head confirms are covered no-ops, so the ordinary lane (own SPEC frame already folded the post-write doc) fetches nothing. Red-first against a real tome world (real fold-rebase, real applySpec spawn pass): the tape (delete confirms at vN, the beacon names vN−1 — the entity must not respawn), the crossed sub-ordering (pre-write fetch in flight when the confirm lands — resolve discarded), folds above the claim passing untouched, and resetForNewRoom clearing the claim (relay-spec-confirm-refold.test.ts; scheduler-side verdict pins inrelay-spec-save.test.ts).