@driftengine/core 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.json CHANGED
@@ -1,4 +1,20 @@
1
1
  [
2
+ {
3
+ "version": "4.1.1",
4
+ "date": "2026-09-20",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "Two adapters, so the inspector and the network panel have something to read. An entity world becomes inspectable and a lockstep session becomes a readout, both typed by shape rather than by import.",
9
+ "text": "**A panel needs its world in the shape the panel reads, and two of those shapes are the same in every game that has the thing behind them.** `createToolsOverlay` put the panels on screen in 4.1.0 and left every consumer to write the same two bridges, which is the drift this repository opens by describing, one layer in.\n\n**`entitiesInspectable(world, types)` turns an entity world into an `InspectableWorld`.** The inspector addresses a component by name, because a name is what a row carries; a world addresses it by the type object, because that is what indexes its stores. Bridging them is seven forwarding methods and a lookup. **A name with no type behind it does nothing rather than guessing**, so a row built against a stale schema cannot write a field into a component the world does not have and report success, which is a silent loss of an edit somebody just made and watched apply.\n\n**`createSessionRecorder`, `observeSession` and `sessionReadout` turn a lockstep session into a `NetworkReadout`.** The watching is the half worth writing once: `session.desync` is **latched rather than an event**, so it keeps answering with the same disagreement on every frame until the next one arrives, and a recorder that appended what it read would turn one divergence into sixty a second and bury the tick somebody is looking for under copies of itself. `snapshotBytes` is the caller's, because `RewindLoop` is generic over the state it snapshots and cannot know the size of one; a caller passing zero is saying it has not measured.\n\n**Both are typed structurally rather than imported**, the arrangement `@driftengine/drft` already has with the DTEX types `@driftengine/texture` satisfies. So this package gains no dependency, the release is the same ninety five places it was, and a consumer whose state is not an entity world at all can still satisfy the shape.\n\n**5,391 bytes gzipped, from 5,105, and 286 of that is these two.** Named in the size fixture in the same commit, which is the whole point of the paragraph the last release added to that file."
10
+ },
11
+ {
12
+ "kind": "fixed",
13
+ "short": "The city scene fits the device it is on. A phone binds 128 MiB and the published reach asked for 145, so every handheld got a blank frame and a refusal in a console nobody had open.",
14
+ "text": "**Reported from a Galaxy S23 Ultra, on the published site: \"A city at dusk\" was a blank frame.** The scene asked for **152,314,560 bytes** as one storage binding and the device binds **134,217,728**. That is 128 MiB, the WebGPU default, and it is what most handhelds offer against the several gigabytes a desktop adapter does.\n\n**Nothing was misconfigured.** `select.ts` already asks the adapter for its ceiling on `maxStorageBufferBindingSize` rather than taking the default, so there was nothing left to raise: the city was simply larger than the part could hold. The refusal was correct, it named the number, and it reached a console rather than a person.\n\n**The scene sizes itself to the device now.** `reachForCeiling` steps down a ladder of reaches until the vertex buffer fits inside four fifths of what the adapter reports, leaving room for the pipeline's other storage bindings. An S23 Ultra gets a reach of 450 metres at 71% of its limit; a desktop adapter keeps the published 900. A smaller reach asked for with `?reach=` is a decision and is never raised to fill the room available.\n\n**The budget could not have fixed this.** The scene ignored the one it was handed, and honouring it would not have helped: a demos page passes `full` to every device and only a `?budget=` in the address says otherwise, so a phone never asked for less. Sizing against the limit the device reports needs no coordination with whoever mounts the scene.\n\n**`VERTEX_FLOATS` is exported for it.** A `StreamCapacity` is counts and what a device refuses is bytes, so whether a capacity fits is `vertices * VERTEX_FLOATS * 4` against the binding ceiling. Without it a consumer either hard-codes the stride or finds out by being refused, which is what this scene did on every phone."
15
+ }
16
+ ]
17
+ },
2
18
  {
3
19
  "version": "4.1.0",
4
20
  "date": "2026-09-20",
@@ -17,17 +33,17 @@
17
33
  {
18
34
  "kind": "changed",
19
35
  "short": "A video becomes a scene, the engine runs without a browser, and the editor opens. Five new packages, a GPU driven pipeline, worlds past float precision, and 769 public symbols added, none removed.",
20
- "text": "**This is a major version for what it adds, not for what it breaks.** Five packages are new — `capture`, `native-host`, `nav`, `texture` and `tools` — alongside an `editor` application workspace, bringing the tree to twenty-three packages. Across every package barrel, **769 public symbols were added and none were removed**, so a game built against 3.63.0 compiles against this unchanged.\n\n**What is in it, in one paragraph.** A GPU-driven pipeline behind one option, and a frame graph under the renderer. Temporal reconstruction, and indirect light that falls back through a bounded world-space field rather than guessing off the edge of the screen. Worlds larger than single precision can address, streamed by where the camera is about to look, with terrain that decides its own detail and a navigation mesh across it. Textures as compiled programs over a latent rather than images, with residency predicted instead of reacted to. An editor that opens, scrubs the simulation backwards, and lets a scene be edited while it runs. The engine on a native window with no browser in the process. And a video of a room becoming a scene with colliders, materials and proposed entities, on the player's own device.\n\n**What a consumer owes the upgrade is the bump itself.** A release moves `version` in twenty-five manifests, the sixty-nine internal `@driftengine/*` ranges that pin the packages to each other, and `package-lock.json` — ninety-five places. That count was ninety-three in this repository's own notes until it was counted rather than read, immediately before this release, and answered two more.\n\nNothing here moves the `driftscript` pin, which is a published package on a version line this repository does not own."
36
+ "text": "**This is a major version for what it adds, not for what it breaks.** Five packages are new \u2014 `capture`, `native-host`, `nav`, `texture` and `tools` \u2014 alongside an `editor` application workspace, bringing the tree to twenty-three packages. Across every package barrel, **769 public symbols were added and none were removed**, so a game built against 3.63.0 compiles against this unchanged.\n\n**What is in it, in one paragraph.** A GPU-driven pipeline behind one option, and a frame graph under the renderer. Temporal reconstruction, and indirect light that falls back through a bounded world-space field rather than guessing off the edge of the screen. Worlds larger than single precision can address, streamed by where the camera is about to look, with terrain that decides its own detail and a navigation mesh across it. Textures as compiled programs over a latent rather than images, with residency predicted instead of reacted to. An editor that opens, scrubs the simulation backwards, and lets a scene be edited while it runs. The engine on a native window with no browser in the process. And a video of a room becoming a scene with colliders, materials and proposed entities, on the player's own device.\n\n**What a consumer owes the upgrade is the bump itself.** A release moves `version` in twenty-five manifests, the sixty-nine internal `@driftengine/*` ranges that pin the packages to each other, and `package-lock.json` \u2014 ninety-five places. That count was ninety-three in this repository's own notes until it was counted rather than read, immediately before this release, and answered two more.\n\nNothing here moves the `driftscript` pin, which is a published package on a version line this repository does not own."
21
37
  },
22
38
  {
23
39
  "kind": "added",
24
40
  "short": "The editor opens. npm run editor gives a menu bar, a viewport, docked panels, picking, a gizmo, undo and a command palette, on the browser and on a native window.",
25
- "text": "**`npm run editor` and it is there.** A menu bar, a viewport, docked panel slots, selection by picking, delete, undo, redo, select-all and a command palette. Every edit is a `Command` on a stack, so a panel cannot touch the world except through something the stack can put back.\n\n**`app.ts` is the general shell and `shell.ts` is the product**, which is a deviation from the design and the right one: assembling a product into the reusable shell would make it depend on a selection model. The dock is a binary tree of splits with one panel per region, and `panels/docked.ts` is the single seam that closes a `Panel<W, V>`'s generics and owns the title strip.\n\n**The gizmo is the editor's arithmetic rather than core's, and the reason is a conditioning number.** Core translates by the closest point between the ray and the axis line, which is right and refuses only within `1e-6` of parallel; between that cutoff and a comfortable angle the answer divides by the squared sine, so **two degrees off the axis multiplies a pixel of mouse noise by 820**. A drag here is computed against a plane containing the axis, chosen per drag as the one whose normal most faces the camera: the two candidates are orthogonal, so the conditioning is never worse than **√2** where a fixed choice reaches a billion four times a turn. An axis pointing at the camera has no good plane at all, so the drag is refused rather than invented. Commands carry absolute positions rather than deltas, which is what makes the merge contract's trap harmless: applying an absorbed command twice writes the same position twice, where a delta would move the object twice as far as the pointer and read as a sensitivity bug.\n\n**What it does not do is in `editor/README.md` rather than left to be found**: there are no tabs and no splitter dragging, and nothing paints the gizmo's arms."
41
+ "text": "**`npm run editor` and it is there.** A menu bar, a viewport, docked panel slots, selection by picking, delete, undo, redo, select-all and a command palette. Every edit is a `Command` on a stack, so a panel cannot touch the world except through something the stack can put back.\n\n**`app.ts` is the general shell and `shell.ts` is the product**, which is a deviation from the design and the right one: assembling a product into the reusable shell would make it depend on a selection model. The dock is a binary tree of splits with one panel per region, and `panels/docked.ts` is the single seam that closes a `Panel<W, V>`'s generics and owns the title strip.\n\n**The gizmo is the editor's arithmetic rather than core's, and the reason is a conditioning number.** Core translates by the closest point between the ray and the axis line, which is right and refuses only within `1e-6` of parallel; between that cutoff and a comfortable angle the answer divides by the squared sine, so **two degrees off the axis multiplies a pixel of mouse noise by 820**. A drag here is computed against a plane containing the axis, chosen per drag as the one whose normal most faces the camera: the two candidates are orthogonal, so the conditioning is never worse than **\u221a2** where a fixed choice reaches a billion four times a turn. An axis pointing at the camera has no good plane at all, so the drag is refused rather than invented. Commands carry absolute positions rather than deltas, which is what makes the merge contract's trap harmless: applying an absorbed command twice writes the same position twice, where a delta would move the object twice as far as the pointer and read as a sensitivity bug.\n\n**What it does not do is in `editor/README.md` rather than left to be found**: there are no tabs and no splitter dragging, and nothing paints the gizmo's arms."
26
42
  },
27
43
  {
28
44
  "kind": "added",
29
45
  "short": "Play in editor, and a scrubber that runs the simulation backwards. Scrub to any recorded frame and play forward again, frame-for-frame identical to a run that never scrubbed.",
30
- "text": "**Press play, press stop, and the scene is exactly as it was** — the oldest defect an editor has. `editor/src/pie/session.ts` captures the world before the first step and restores it on stop, checked by a digest of the whole world rather than a spot check of a few fields. Advancing and stepping are different verbs: a loop calls `advancePie` every frame and it does nothing while paused, and a person presses step and gets exactly that many frames and a session still paused. The world arrives through `Snapshotter`, the engine's own seam, so nothing here knows what a world is.\n\n**Scrub the simulation backwards to any recorded frame, and forward again unchanged.** A keyframe every _n_ frames plus the input log between reconstructs any frame exactly, because the simulation is deterministic. The assertion that matters is the second one: scrub back, play forward, and compare against a run that never scrubbed, frame for frame. **A scrubber that perturbs the simulation is worse than none**, because it produces a version of the bug that is not the bug. A frame the ring has dropped is reported as gone and never as the nearest one.\n\n**Change something at frame four hundred and the rest happens differently.** `editDuringPlay.ts` records the edit and re-simulates from its frame rather than applying it on the spot, which is one path instead of two and the only arrangement under which scrubbing away and back keeps the change. The edit is the same `Command` an inspector emits at rest, so it is undoable and there is no second way to write the world. Editor edits stay out of the input log, because an editor command is not something a peer sends.\n\n**And when two runs stop agreeing, the frame and then the component.** `divergence.ts` binary-searches the per-frame fingerprints and `@driftengine/tools` compares the per-component hashes at that frame. Three things are reported as themselves rather than as a number meaning something else: agreement is not frame zero, no overlap is not agreement, and a whole-world difference no recorded component accounts for is said plainly.\n\n**What this cost is the shortest honest summary of the determinism work.** A timeline, a slider, and the rule that an edit during play goes into the log. Snapshots, rewind, input logs, deterministic replay and fingerprints all shipped already, for the netcode."
46
+ "text": "**Press play, press stop, and the scene is exactly as it was** \u2014 the oldest defect an editor has. `editor/src/pie/session.ts` captures the world before the first step and restores it on stop, checked by a digest of the whole world rather than a spot check of a few fields. Advancing and stepping are different verbs: a loop calls `advancePie` every frame and it does nothing while paused, and a person presses step and gets exactly that many frames and a session still paused. The world arrives through `Snapshotter`, the engine's own seam, so nothing here knows what a world is.\n\n**Scrub the simulation backwards to any recorded frame, and forward again unchanged.** A keyframe every _n_ frames plus the input log between reconstructs any frame exactly, because the simulation is deterministic. The assertion that matters is the second one: scrub back, play forward, and compare against a run that never scrubbed, frame for frame. **A scrubber that perturbs the simulation is worse than none**, because it produces a version of the bug that is not the bug. A frame the ring has dropped is reported as gone and never as the nearest one.\n\n**Change something at frame four hundred and the rest happens differently.** `editDuringPlay.ts` records the edit and re-simulates from its frame rather than applying it on the spot, which is one path instead of two and the only arrangement under which scrubbing away and back keeps the change. The edit is the same `Command` an inspector emits at rest, so it is undoable and there is no second way to write the world. Editor edits stay out of the input log, because an editor command is not something a peer sends.\n\n**And when two runs stop agreeing, the frame and then the component.** `divergence.ts` binary-searches the per-frame fingerprints and `@driftengine/tools` compares the per-component hashes at that frame. Three things are reported as themselves rather than as a number meaning something else: agreement is not frame zero, no overlap is not agreement, and a whole-world difference no recorded component accounts for is said plainly.\n\n**What this cost is the shortest honest summary of the determinism work.** A timeline, a slider, and the rule that an edit during play goes into the log. Snapshots, rewind, input logs, deterministic replay and fingerprints all shipped already, for the netcode."
31
47
  },
32
48
  {
33
49
  "kind": "added",
@@ -37,57 +53,57 @@
37
53
  {
38
54
  "kind": "added",
39
55
  "short": "A node graph canvas over three vocabularies: materials compile to a DTEX decode program, particles to the pool options the engine already takes, and behaviour to DriftScript source.",
40
- "text": "**One editor over three node vocabularies**, in `editor/src/graph/`. Nodes and links with a cycle refused by name and by node; every edit a command, including a node drag, which merges into one undo entry; a canvas that pans and zooms about the pointer.\n\n**The hit test is handed no view at all.** `buildGraphGeometry` turns graph space into screen space once, into arrays, and both the draw and the hit test read those arrays — because the commonest defect in a graph editor is a hit test that disagrees with the draw about the pan or the zoom, and the only way to prevent it for good is to leave no second place that could apply a transform. Automatic arrangement reads the graph and never the current positions, so tidying is not path-dependent.\n\n**A material graph's compile target is a `DTEX` decode program, not a shader** — the fact a reader most needs and least expects. A texture was already defined as a small interpreted decode program, so a material graph is a visual editor for exactly that structure: a graph-authored material and a baked one are indistinguishable downstream, and the graph editor is a debugger for baked materials as a side effect. A node the vocabulary cannot express grows the decode vocabulary, which costs bytes linearly because an operation is data.\n\n**A particle graph compiles to the `ParticlePoolOptions` the engine already takes**, plus the emission the pool has never owned, so a compiled graph is two records rather than a third representation to keep in step. Randomness is a seed and never the global generator. A curve is two numbers, because two is what `ParticlePool` interpolates between, so a preview agrees with the frame. Capacity is derived from rate × life rather than typed in.\n\n**A behaviour graph emits DriftScript source, and DriftScript's own compiler is what judges it.** The graph gets no semantics, no effect analysis and no hot reload of its own, which is what makes a graph and hand-written code the same artefact and lets a project mix them. A graph **can** write a component it never declared, and the real compiler refuses it with DS0288 in the words a person hand-writing that file would get.\n\n**Each vocabulary ships one honest refusal.** `separate` is in the material palette and deliberately does not compile, because splitting a colour into channels is the first node somebody reaches for that this genuinely cannot do; `turbulence` is the particle vocabulary's."
56
+ "text": "**One editor over three node vocabularies**, in `editor/src/graph/`. Nodes and links with a cycle refused by name and by node; every edit a command, including a node drag, which merges into one undo entry; a canvas that pans and zooms about the pointer.\n\n**The hit test is handed no view at all.** `buildGraphGeometry` turns graph space into screen space once, into arrays, and both the draw and the hit test read those arrays \u2014 because the commonest defect in a graph editor is a hit test that disagrees with the draw about the pan or the zoom, and the only way to prevent it for good is to leave no second place that could apply a transform. Automatic arrangement reads the graph and never the current positions, so tidying is not path-dependent.\n\n**A material graph's compile target is a `DTEX` decode program, not a shader** \u2014 the fact a reader most needs and least expects. A texture was already defined as a small interpreted decode program, so a material graph is a visual editor for exactly that structure: a graph-authored material and a baked one are indistinguishable downstream, and the graph editor is a debugger for baked materials as a side effect. A node the vocabulary cannot express grows the decode vocabulary, which costs bytes linearly because an operation is data.\n\n**A particle graph compiles to the `ParticlePoolOptions` the engine already takes**, plus the emission the pool has never owned, so a compiled graph is two records rather than a third representation to keep in step. Randomness is a seed and never the global generator. A curve is two numbers, because two is what `ParticlePool` interpolates between, so a preview agrees with the frame. Capacity is derived from rate \u00d7 life rather than typed in.\n\n**A behaviour graph emits DriftScript source, and DriftScript's own compiler is what judges it.** The graph gets no semantics, no effect analysis and no hot reload of its own, which is what makes a graph and hand-written code the same artefact and lets a project mix them. A graph **can** write a component it never declared, and the real compiler refuses it with DS0288 in the words a person hand-writing that file would get.\n\n**Each vocabulary ships one honest refusal.** `separate` is in the material palette and deliberately does not compile, because splitting a colour into channels is the first node somebody reaches for that this genuinely cannot do; `turbulence` is the particle vocabulary's."
41
57
  },
42
58
  {
43
59
  "kind": "added",
44
60
  "short": "A script editor in the editor, backed by the DriftScript language server rather than a second implementation of the language. It works with no client attached at all.",
45
- "text": "**DriftScript is edited against the language server that already exists, not a second language implementation.** `editor/src/panels/script.ts` is a text view, a diagnostic gutter and a completion popup; every rule about what the language means stays with the compiler, which is the same line the behaviour-graph editor holds and for the same reason.\n\n**It works with no client at all** — a headless build has none and a test has none, so it is a text view before it is anything else.\n\n**The server's view of the buffer lags it by a message**, so a diagnostic arrives describing text already deleted every time somebody types quickly. Ranges are clamped, and one entirely past the end collapses to an empty mark at the end rather than disappearing, because the server has something to say and dropping it loses the only sign that it did. **Nothing awaits the server**: a change notification goes out and the buffer is already updated, and a completion reply arriving after a dismissal is dropped rather than reopening a popup over text somebody went back to typing."
61
+ "text": "**DriftScript is edited against the language server that already exists, not a second language implementation.** `editor/src/panels/script.ts` is a text view, a diagnostic gutter and a completion popup; every rule about what the language means stays with the compiler, which is the same line the behaviour-graph editor holds and for the same reason.\n\n**It works with no client at all** \u2014 a headless build has none and a test has none, so it is a text view before it is anything else.\n\n**The server's view of the buffer lags it by a message**, so a diagnostic arrives describing text already deleted every time somebody types quickly. Ranges are clamped, and one entirely past the end collapses to an empty mark at the end rather than disappearing, because the server has something to say and dropping it loses the only sign that it did. **Nothing awaits the server**: a change notification goes out and the buffer is already updated, and a completion reply arriving after a dismissal is dropped rather than reopening a popup over text somebody went back to typing."
46
62
  },
47
63
  {
48
64
  "kind": "added",
49
65
  "short": "@driftengine/capture: a video becomes a scene you can walk around in, on the player device and with no service. Depth and poses, a fused surface, colliders, a navmesh and proposed entities.",
50
- "text": "**A clip becomes a surface, on the device, with no service.** `@driftengine/capture` takes a `FrameSource` — a video element in a browser, ffmpeg on the native host — chooses frames, runs Depth Anything 3 Small for poses, depth, intrinsics and a confidence together, fuses the views into a truncated signed-distance volume, marches it and decimates the result. Then `collisionMesh` makes it something a `CharacterController` stands on, a navigation mesh bakes from the same surface, `delight` separates what the surface reflects from the light it was photographed under, and `segmentGeometry` and `proposeEntities` cut it into regions and say what each might be.\n\n**The same stages run in both hosts with nothing in the package changed between them.** Measured 2026-09-20 on an RX 9070 XT over six frames of a 1,401-frame 1080p handheld clip: **7.2 s in the browser and 9.05 s on the native host**, of which 3.6 s and 5.5 s are loading the weights. A room came back as 33,060 triangles over 1.08 × 0.70 × 1.08 m, with one navigation polygon and eight entity proposals, in a 1.09 MB file.\n\n**Its maturity is stated rather than implied, stage by stage, because a reconstruction that looks finished and is not costs a consumer more than one that says so.** Geometry reconstructs what a clip saw from where the clip saw it and nothing more. Colliders hold where the capture has a surface, and the capture does not have one everywhere: dropping a controller from a metre above over a three-by-three grid, **three of nine landed on it and six fell through**, which is the reconstruction being patchy rather than the colliders failing. Delighting recovers colour and says how much it trusts it, and on ordinary footage that is not much — a mean confidence of **0.276** on a wooden table and **0.249** on a room. It cannot remove a cast shadow, because a shadowed point is shadowed from every camera and agreement is not evidence of albedo. Entity proposals arrive unlabelled and undecided: a person accepts or rejects each one in the editor, and the file records decisions rather than guesses.\n\n**Three limits ship as limits.** **Six frames is the ceiling on this device** and ten is refused with an invalid pipeline layout. **The metric scale is not verified**: no capture in this record contained an object of known size, so the metres are the model's claim rather than a measurement. And delighting is the most expensive stage by thirty times — **44 s over 16,670 triangles and 179 s over 33,060** — because it ray-casts each vertex against every triangle with no acceleration structure. Colour is per vertex, so its detail ceiling is the mesh rather than the footage.\n\n**Each model definition is held to the upstream's own code on a seeded miniature**, rather than to a picture that looks about right: Depth Anything 3 agrees to **3 parts per million**, Depth Anything V2 to 7.8e-7, MobileSAM to 3.1e-6, SAM 2.1 to 7.4e-6, and OWLv2 to 4.1e-6 with its box prior held bit for bit. An unread weight is refused, so a branch this engine sets aside is set aside by name.\n\n**The package floor is 66,373 gzipped bytes**, built up a stage at a time so the bill is legible: the largest single step is the Gaussian fit at 5,336, and the assembly step's 7,683 is `@driftengine/drft`'s whole writer rather than this package's code.\n\n**No weights are distributed with this engine.** `tools/capture-weights/` fetches each model at the revision its manifest pins, verifies it and converts it; a game that ships one ships it under that model's own licence, and `CREDITS.md` and `NOTICE` carry the attribution. The runtime never fetches."
66
+ "text": "**A clip becomes a surface, on the device, with no service.** `@driftengine/capture` takes a `FrameSource` \u2014 a video element in a browser, ffmpeg on the native host \u2014 chooses frames, runs Depth Anything 3 Small for poses, depth, intrinsics and a confidence together, fuses the views into a truncated signed-distance volume, marches it and decimates the result. Then `collisionMesh` makes it something a `CharacterController` stands on, a navigation mesh bakes from the same surface, `delight` separates what the surface reflects from the light it was photographed under, and `segmentGeometry` and `proposeEntities` cut it into regions and say what each might be.\n\n**The same stages run in both hosts with nothing in the package changed between them.** Measured 2026-09-20 on an RX 9070 XT over six frames of a 1,401-frame 1080p handheld clip: **7.2 s in the browser and 9.05 s on the native host**, of which 3.6 s and 5.5 s are loading the weights. A room came back as 33,060 triangles over 1.08 \u00d7 0.70 \u00d7 1.08 m, with one navigation polygon and eight entity proposals, in a 1.09 MB file.\n\n**Its maturity is stated rather than implied, stage by stage, because a reconstruction that looks finished and is not costs a consumer more than one that says so.** Geometry reconstructs what a clip saw from where the clip saw it and nothing more. Colliders hold where the capture has a surface, and the capture does not have one everywhere: dropping a controller from a metre above over a three-by-three grid, **three of nine landed on it and six fell through**, which is the reconstruction being patchy rather than the colliders failing. Delighting recovers colour and says how much it trusts it, and on ordinary footage that is not much \u2014 a mean confidence of **0.276** on a wooden table and **0.249** on a room. It cannot remove a cast shadow, because a shadowed point is shadowed from every camera and agreement is not evidence of albedo. Entity proposals arrive unlabelled and undecided: a person accepts or rejects each one in the editor, and the file records decisions rather than guesses.\n\n**Three limits ship as limits.** **Six frames is the ceiling on this device** and ten is refused with an invalid pipeline layout. **The metric scale is not verified**: no capture in this record contained an object of known size, so the metres are the model's claim rather than a measurement. And delighting is the most expensive stage by thirty times \u2014 **44 s over 16,670 triangles and 179 s over 33,060** \u2014 because it ray-casts each vertex against every triangle with no acceleration structure. Colour is per vertex, so its detail ceiling is the mesh rather than the footage.\n\n**Each model definition is held to the upstream's own code on a seeded miniature**, rather than to a picture that looks about right: Depth Anything 3 agrees to **3 parts per million**, Depth Anything V2 to 7.8e-7, MobileSAM to 3.1e-6, SAM 2.1 to 7.4e-6, and OWLv2 to 4.1e-6 with its box prior held bit for bit. An unread weight is refused, so a branch this engine sets aside is set aside by name.\n\n**The package floor is 66,373 gzipped bytes**, built up a stage at a time so the bill is legible: the largest single step is the Gaussian fit at 5,336, and the assembly step's 7,683 is `@driftengine/drft`'s whole writer rather than this package's code.\n\n**No weights are distributed with this engine.** `tools/capture-weights/` fetches each model at the revision its manifest pins, verifies it and converts it; a game that ships one ships it under that model's own licence, and `CREDITS.md` and `NOTICE` carry the attribution. The runtime never fetches."
51
67
  },
52
68
  {
53
69
  "kind": "added",
54
70
  "short": "@driftengine/native-host runs the engine on Node, Dawn and SDL with no browser and no engine code changed, and draws the published scenes pixel-identically to Chrome.",
55
- "text": "**The engine on a native window and a native WebGPU device, with no browser between them.** `@driftengine/native-host` supplies the page a game expects around its canvas — a window, a GPU, events, gamepads, audio — and **no engine code changed to make it work**. A game's whole contract is one export: the manifest's `native.entry` names a module exporting `mount(canvas)`.\n\n**All nine published scenes come back 0 of 776,960 pixels against Chrome**, checked by `npm run native:gate`. Measured on an RX 9070 XT: **730 ms median from spawn to first frame** — 275 ms to reach the game's `mount`, 355 to build a WebGPU renderer and 85 to its first frame — in a **61.3 MB** archive. Of that 355 ms the adapter and the device are 30; the rest is pipelines the binding has no cache for, which Chrome builds warm in 75 ms, and that gap is recorded as an absent capability rather than smoothed over.\n\n**This is what the platform rule was written for.** Nothing under `src/` may call a platform API directly when a consumer might want a different one, and until a second host existed that rule was held by review. Every platform touch this host had to answer was a capability the caller supplies or a page global, never a call buried in the engine. What each host can do that the other cannot is recorded both ways in `docs/CAPABILITIES.md` §2a, the host's side guarded by a `present` block so a capability cannot quietly go.\n\n**Four defects in it were found by a person at the machine and by no gate**, which is worth publishing because the shape recurs. A first key press was reported as an auto-repeat: the code read `(event.repeat ?? 0) !== 0`, which is right for the number the platform's types declare and inverted for the boolean it actually gives, so held keys were discarded while tapped keys still worked. **Two automated checks confirmed it working**, because a replayed key carries the number `0`, which coerces correctly. Two platform handles were closed twice, each ending the process. A keyboard's power-key endpoint enumerated as a gamepad and took the first pad slot; a device with no axes and no hats is not offered as one now.\n\n**And the editor on this host painted its interface after `endFrame`**, which survives on WebGL2 and vanishes on WebGPU: 0 pixels above black after, 1,747,114 before, with one line moved. It was invisible to every capture, because a readback reads the canvas texture and a texture that is never presented still reads back perfectly.\n\nWindows and macOS are not built yet; the Linux target is."
71
+ "text": "**The engine on a native window and a native WebGPU device, with no browser between them.** `@driftengine/native-host` supplies the page a game expects around its canvas \u2014 a window, a GPU, events, gamepads, audio \u2014 and **no engine code changed to make it work**. A game's whole contract is one export: the manifest's `native.entry` names a module exporting `mount(canvas)`.\n\n**All nine published scenes come back 0 of 776,960 pixels against Chrome**, checked by `npm run native:gate`. Measured on an RX 9070 XT: **730 ms median from spawn to first frame** \u2014 275 ms to reach the game's `mount`, 355 to build a WebGPU renderer and 85 to its first frame \u2014 in a **61.3 MB** archive. Of that 355 ms the adapter and the device are 30; the rest is pipelines the binding has no cache for, which Chrome builds warm in 75 ms, and that gap is recorded as an absent capability rather than smoothed over.\n\n**This is what the platform rule was written for.** Nothing under `src/` may call a platform API directly when a consumer might want a different one, and until a second host existed that rule was held by review. Every platform touch this host had to answer was a capability the caller supplies or a page global, never a call buried in the engine. What each host can do that the other cannot is recorded both ways in `docs/CAPABILITIES.md` \u00a72a, the host's side guarded by a `present` block so a capability cannot quietly go.\n\n**Four defects in it were found by a person at the machine and by no gate**, which is worth publishing because the shape recurs. A first key press was reported as an auto-repeat: the code read `(event.repeat ?? 0) !== 0`, which is right for the number the platform's types declare and inverted for the boolean it actually gives, so held keys were discarded while tapped keys still worked. **Two automated checks confirmed it working**, because a replayed key carries the number `0`, which coerces correctly. Two platform handles were closed twice, each ending the process. A keyboard's power-key endpoint enumerated as a gamepad and took the first pad slot; a device with no axes and no hats is not offered as one now.\n\n**And the editor on this host painted its interface after `endFrame`**, which survives on WebGL2 and vanishes on WebGPU: 0 pixels above black after, 1,747,114 before, with one line moved. It was invisible to every capture, because a readback reads the canvas texture and a texture that is never presented still reads back perfectly.\n\nWindows and macOS are not built yet; the Linux target is."
56
72
  },
57
73
  {
58
74
  "kind": "added",
59
75
  "short": "@driftengine/texture and the DTEX chunk: a texture as a compiled decode program over a latent rather than an image, streaming by tile with a content hash so identical tiles share a payload.",
60
- "text": "**A material in the container as a decode program over a latent, rather than as pictures.** `@driftengine/texture` is the interpreter and `drft/src/dtex.ts` is the chunk: the latent grid, the channel specs, the graph's nodes, a small network's shape and weights, and a tile table with a **content hash** per tile so two identical tiles share one payload.\n\n**The chunk carries no semantics.** `@driftengine/drft` has no dependencies and does not know what an operation means, so the types are plain arrays that `@driftengine/texture`'s satisfy structurally — the arrangement `Terrain` already has with `Heightfield` across the physics boundary. **It validates containment, not meaning**: a tile whose bytes run past the payload and a result register no node writes are refused, because those hand a reader arbitrary memory or an uninitialised value. Those guards are unreachable through the writer, so the tests corrupt a written chunk to reach them.\n\n**No per-chunk version**, which is where it departs from its design: the file's version covers the format and a second one is a second thing to keep in step. It is additive, so an older reader skips it by its length and loses only the material it could not have decoded.\n\n**And a texture cannot move the simulation, asserted rather than left true by construction.** A run sampling an animated `DTEX` every tick fingerprints identically to a run sampling nothing, and the same simulation run twice at different wall-clock times fingerprints identically, because `t` is the caller's, from the simulation's clock. A run that reads a wall clock instead is asserted to **diverge**, without which the first two pass for a decoder that reads a clock nobody noticed."
76
+ "text": "**A material in the container as a decode program over a latent, rather than as pictures.** `@driftengine/texture` is the interpreter and `drft/src/dtex.ts` is the chunk: the latent grid, the channel specs, the graph's nodes, a small network's shape and weights, and a tile table with a **content hash** per tile so two identical tiles share one payload.\n\n**The chunk carries no semantics.** `@driftengine/drft` has no dependencies and does not know what an operation means, so the types are plain arrays that `@driftengine/texture`'s satisfy structurally \u2014 the arrangement `Terrain` already has with `Heightfield` across the physics boundary. **It validates containment, not meaning**: a tile whose bytes run past the payload and a result register no node writes are refused, because those hand a reader arbitrary memory or an uninitialised value. Those guards are unreachable through the writer, so the tests corrupt a written chunk to reach them.\n\n**No per-chunk version**, which is where it departs from its design: the file's version covers the format and a second one is a second thing to keep in step. It is additive, so an older reader skips it by its length and loses only the material it could not have decoded.\n\n**And a texture cannot move the simulation, asserted rather than left true by construction.** A run sampling an animated `DTEX` every tick fingerprints identically to a run sampling nothing, and the same simulation run twice at different wall-clock times fingerprints identically, because `t` is the caller's, from the simulation's clock. A run that reads a wall clock instead is asserted to **diverge**, without which the first two pass for a decoder that reads a clock nobody noticed."
61
77
  },
62
78
  {
63
79
  "kind": "added",
64
80
  "short": "Texture streaming that decides residency from where the camera will be, not where it has been. Measured on a scripted flight: 296 late samples predicted against 1,294 reactive.",
65
- "text": "**Residency is decided by looking at where the camera _will_ be.** The state of the art is reactive by construction — the most widely deployed implementation's own documentation says streaming is reactive by nature, because nothing can know a tile is needed until a frame has already needed it. That is true of an engine that cannot run its simulation forward and put it back. This one does it every frame, for the netcode.\n\n`predictViews` saves, advances _n_ deterministic steps with no rendering, samples which tiles those views want, and restores. **No feedback buffer and no readback**, so it works for passes that could never write feedback. What those views want is `tilesForView`'s answer: each instance inside a predicted view names the level its nearest point asks for, the finer one within an eighth of a level of a boundary, and every coarser one a sampler falls back to, most important first — so the answer at any budget is a prefix of the answer with none.\n\n**Measured on a scripted flight down a corridor of real latents, 452 tiles through a 256-page cache and never more than 102 in a frame: 296 late samples predicted against 1,294 reactive**, and the 296 are exactly the three frames before the first fetch could possibly have landed.\n\n**Mispredicting degrades rather than fails, and that is asserted rather than claimed.** A camera reversing every three frames is what prediction cannot help with, and the cost is a wasted fetch and a late tile, which is what a reactive engine does on every frame. Measured on that flight: **102 late samples predicted against 755 reactive**. The floor of this mechanism is everyone else's ceiling.\n\n**A tile that will not arrive stops being waited for.** A fetch resolving with nothing leaves the tile requested and its in-flight slot occupied forever if nobody handles it, and a handful of those stops streaming entirely with no error anywhere. A missing tile is remembered as missing, a rejection is treated identically, bytes larger than a page are refused rather than truncated, and a fetch landing after its tile was evicted is dropped.\n\n**And the decode can move from per-sample to residency time**, which is the fallback the design named before the risk could arrive: decode once when a tile becomes resident and sample it as an ordinary texture after. That loses procedural and per-sample parameterisation for the affected materials and keeps the compression, the joint channels and the streaming."
81
+ "text": "**Residency is decided by looking at where the camera _will_ be.** The state of the art is reactive by construction \u2014 the most widely deployed implementation's own documentation says streaming is reactive by nature, because nothing can know a tile is needed until a frame has already needed it. That is true of an engine that cannot run its simulation forward and put it back. This one does it every frame, for the netcode.\n\n`predictViews` saves, advances _n_ deterministic steps with no rendering, samples which tiles those views want, and restores. **No feedback buffer and no readback**, so it works for passes that could never write feedback. What those views want is `tilesForView`'s answer: each instance inside a predicted view names the level its nearest point asks for, the finer one within an eighth of a level of a boundary, and every coarser one a sampler falls back to, most important first \u2014 so the answer at any budget is a prefix of the answer with none.\n\n**Measured on a scripted flight down a corridor of real latents, 452 tiles through a 256-page cache and never more than 102 in a frame: 296 late samples predicted against 1,294 reactive**, and the 296 are exactly the three frames before the first fetch could possibly have landed.\n\n**Mispredicting degrades rather than fails, and that is asserted rather than claimed.** A camera reversing every three frames is what prediction cannot help with, and the cost is a wasted fetch and a late tile, which is what a reactive engine does on every frame. Measured on that flight: **102 late samples predicted against 755 reactive**. The floor of this mechanism is everyone else's ceiling.\n\n**A tile that will not arrive stops being waited for.** A fetch resolving with nothing leaves the tile requested and its in-flight slot occupied forever if nobody handles it, and a handful of those stops streaming entirely with no error anywhere. A missing tile is remembered as missing, a rejection is treated identically, bytes larger than a page are refused rather than truncated, and a fetch landing after its tile was evicted is dropped.\n\n**And the decode can move from per-sample to residency time**, which is the fallback the design named before the risk could arrive: decode once when a tile becomes resident and sample it as an ordinary texture after. That loses procedural and per-sample parameterisation for the affected materials and keeps the compression, the joint channels and the streaming."
66
82
  },
67
83
  {
68
84
  "kind": "added",
69
85
  "short": "Runtime-writable textures that survive replay. A scorch mark goes into the input log, so a recording plays back the same burnt wall instead of a clean one.",
70
- "text": "**A scorch mark is in the input log, so a replay burns the same wall.** Runtime-mutable textures normally break replay: the marks are not part of the simulation and nothing records them, so a recording plays back clean walls and nothing fails at the time.\n\n`overlay/sparse.ts` allocates only the tiles a mark touched and tracks written per texel **and** per channel; `overlay/journal.ts` records every write beside the input. **Rolling back is rebuilding from the journal**, because a write is not invertible — two marks on one texel leave no record of what was underneath. The journal is truncated on rollback, or a replay draws both what happened and what was undone."
86
+ "text": "**A scorch mark is in the input log, so a replay burns the same wall.** Runtime-mutable textures normally break replay: the marks are not part of the simulation and nothing records them, so a recording plays back clean walls and nothing fails at the time.\n\n`overlay/sparse.ts` allocates only the tiles a mark touched and tracks written per texel **and** per channel; `overlay/journal.ts` records every write beside the input. **Rolling back is rebuilding from the journal**, because a write is not invertible \u2014 two marks on one texel leave no record of what was underneath. The journal is truncated on rollback, or a replay draws both what happened and what was undone."
71
87
  },
72
88
  {
73
89
  "kind": "added",
74
90
  "short": "@driftengine/nav, 8,401 gzipped bytes: geometry becomes walkable convex polygons and a straight line across them. It reverses a refusal that had named its own trigger.",
75
- "text": "**`@driftengine/nav` ships, at 8,401 bytes gzipped, and it reverses a refusal that named its own trigger.** `core/src/nav/navGraph.ts` states that it is a graph rather than a mesh, that this is a decision rather than a first step, and that a mesh is the row that is still open if a world's walkable space is genuinely a region. A streamed open world is that world.\n\nGeometry becomes a walkable voxel field — two layers under an overhang, which is why it is voxels and not a heightfield — then a watershed partition, contours checked for self-intersection rather than assumed simple, and convex polygons whose adjacency is symmetric. A one-directional build makes paths work in one direction only and nothing says so.\n\n**One A\\* in the repository**: the query builds a `NavGraph` and hands it to core's existing `NavSearch`, then funnels, so open ground is a straight line and not a walk along polygon centres. The graph is not replaced; it stays right for roads, corridors and docking lanes.\n\n**Two limitations ship with a test pinning each.** A region with a free-standing pillar loses the inner loop, so an agent walks through the pillar; and the funnel gives the shortest path through the corridor A\\* chose, 10.75 against an ideal 9.81 on one wall.\n\n**Steering is still refused**, which is the line the reversal did not cross: a path is a function of geometry and an engine can own one, but how a character moves along it is a game's feel."
91
+ "text": "**`@driftengine/nav` ships, at 8,401 bytes gzipped, and it reverses a refusal that named its own trigger.** `core/src/nav/navGraph.ts` states that it is a graph rather than a mesh, that this is a decision rather than a first step, and that a mesh is the row that is still open if a world's walkable space is genuinely a region. A streamed open world is that world.\n\nGeometry becomes a walkable voxel field \u2014 two layers under an overhang, which is why it is voxels and not a heightfield \u2014 then a watershed partition, contours checked for self-intersection rather than assumed simple, and convex polygons whose adjacency is symmetric. A one-directional build makes paths work in one direction only and nothing says so.\n\n**One A\\* in the repository**: the query builds a `NavGraph` and hands it to core's existing `NavSearch`, then funnels, so open ground is a straight line and not a walk along polygon centres. The graph is not replaced; it stays right for roads, corridors and docking lanes.\n\n**Two limitations ship with a test pinning each.** A region with a free-standing pillar loses the inner loop, so an agent walks through the pillar; and the funnel gives the shortest path through the corridor A\\* chose, 10.75 against an ideal 9.81 on one wall.\n\n**Steering is still refused**, which is the line the reversal did not cross: a path is a function of geometry and an engine can own one, but how a character moves along it is a game's feel."
76
92
  },
77
93
  {
78
94
  "kind": "added",
79
95
  "short": "A world bigger than a float. Simulation coordinates stay absolute and double; only rendering rebases, in whole cells, so a replay fingerprint cannot depend on where the camera stood.",
80
- "text": "**The simulation never rebases; rendering does.** `core/src/world/` holds a sparse cell grid, a render origin that follows the camera in whole cells, and the rule that keeps them apart: simulation coordinates stay absolute and double, because a rebase would change floating-point results and therefore the replay fingerprint — only in sessions that crossed an origin boundary, which is a divergence nobody reproduces on demand.\n\n**The origin moves in whole cells**, since a continuously moving one re-quantises every vertex every frame and the world shimmers: measured over a 220-tick walk at the far end of the world, it moves **75 times rather than 220**. A round trip through render space costs an ulp of a number under five hundred and **does not grow with distance**, where the same coordinate put into single precision is wrong by **exactly one metre** at 2²⁴–2²⁵, because single precision counts in twos there.\n\n**Cells stream by where the camera will look, not only by where it will be.** `cellsInFrustum` walks the box round a predicted view's corners in double precision and keeps a cell unless it lies wholly beyond one plane: 33,554 km out, it tells a sliver a tenth of a metre inside the view from one a tenth outside, which planes held in single precision keep both of.\n\n**Freezing is a simulation decision and unloading is a memory one**, and `world/freeze.ts` keeps them apart on purpose. A cell outside the simulated radius freezes whether or not its contents are resident, and only a frozen cell may be unloaded, so streaming can change where the bytes live and never what the simulation computes. The alternative — unloading causing freezing — makes the simulation depend on how much memory a machine had, which is a divergence that appears on one player's computer and nowhere else. **A frozen cell still contributes to the fingerprint**, from a digest taken when it froze, because a run that agrees only once everything has thawed is a run that disagreed for a while. What it costs is stated rather than discovered: an agent in an unloaded cell does not walk anywhere, does not age and does not finish what it was doing."
96
+ "text": "**The simulation never rebases; rendering does.** `core/src/world/` holds a sparse cell grid, a render origin that follows the camera in whole cells, and the rule that keeps them apart: simulation coordinates stay absolute and double, because a rebase would change floating-point results and therefore the replay fingerprint \u2014 only in sessions that crossed an origin boundary, which is a divergence nobody reproduces on demand.\n\n**The origin moves in whole cells**, since a continuously moving one re-quantises every vertex every frame and the world shimmers: measured over a 220-tick walk at the far end of the world, it moves **75 times rather than 220**. A round trip through render space costs an ulp of a number under five hundred and **does not grow with distance**, where the same coordinate put into single precision is wrong by **exactly one metre** at 2\u00b2\u2074\u20132\u00b2\u2075, because single precision counts in twos there.\n\n**Cells stream by where the camera will look, not only by where it will be.** `cellsInFrustum` walks the box round a predicted view's corners in double precision and keeps a cell unless it lies wholly beyond one plane: 33,554 km out, it tells a sliver a tenth of a metre inside the view from one a tenth outside, which planes held in single precision keep both of.\n\n**Freezing is a simulation decision and unloading is a memory one**, and `world/freeze.ts` keeps them apart on purpose. A cell outside the simulated radius freezes whether or not its contents are resident, and only a frozen cell may be unloaded, so streaming can change where the bytes live and never what the simulation computes. The alternative \u2014 unloading causing freezing \u2014 makes the simulation depend on how much memory a machine had, which is a divergence that appears on one player's computer and nowhere else. **A frozen cell still contributes to the fingerprint**, from a digest taken when it froze, because a run that agrees only once everything has thawed is a run that disagreed for a while. What it costs is stated rather than discovered: an agent in an unloaded cell does not walk anywhere, does not age and does not finish what it was doing."
81
97
  },
82
98
  {
83
99
  "kind": "added",
84
100
  "short": "Terrain decides its own detail per patch in one pass, and a heightfield is stored as a DTEX layer. Collision reads the decoded heights, so the renderer and the query cannot disagree.",
85
- "text": "**Which squares of the field to draw, at what detail, as one decision per patch.** `terrain/src/clipmap.ts` takes a camera and a patch index and answers, reading the frame and nothing else — which is what lets it move into a compute pass beside the cluster cut rather than be rewritten there.\n\nEach level's block snaps to an **even** patch index, which is the whole of why the levels nest: an even multiple of a level's patch span is a multiple of the next level's, so a finer block's edge always falls on a coarser patch boundary. **The hole a finer level leaves is not fixed at the centre**, which looks wrong and is right: levels re-centre at different moments, so culling by coverage rather than by a central hole is what removes the need for the L-shaped stitching strip the usual implementation carries. Asserted by counting: **65,536 field cells drawn, exactly, at every one of thirty-three camera positions across a coarse patch** — an area equal to the footprint with no cell counted twice is no overlap and no hole, together.\n\n**A heightfield is a `DTEX` layer, and collision reads the decoded numbers rather than the source ones.** Terrain inherits the format's residency, streaming and determinism instead of growing an image path of its own. **The design had the requirement backwards**: it asked that decoded heights match the source within the format's tolerance, which they do and which is not enough — a renderer reading the layer while a query reads the source differ by exactly that tolerance, everywhere, permanently, and a character floating by a fraction of a millimetre on every surface in the world is a defect nobody attributes. So `terrainFromHeightLayer` builds the `Terrain` from the decoded samples and everything reads that one object.\n\nSplat weights decode renormalised, since four weights rounded to eight bits sum to between 0.994 and 1.006, and a shader that trusts the sum shades a whole hillside wrong, smoothly enough to read as lighting."
101
+ "text": "**Which squares of the field to draw, at what detail, as one decision per patch.** `terrain/src/clipmap.ts` takes a camera and a patch index and answers, reading the frame and nothing else \u2014 which is what lets it move into a compute pass beside the cluster cut rather than be rewritten there.\n\nEach level's block snaps to an **even** patch index, which is the whole of why the levels nest: an even multiple of a level's patch span is a multiple of the next level's, so a finer block's edge always falls on a coarser patch boundary. **The hole a finer level leaves is not fixed at the centre**, which looks wrong and is right: levels re-centre at different moments, so culling by coverage rather than by a central hole is what removes the need for the L-shaped stitching strip the usual implementation carries. Asserted by counting: **65,536 field cells drawn, exactly, at every one of thirty-three camera positions across a coarse patch** \u2014 an area equal to the footprint with no cell counted twice is no overlap and no hole, together.\n\n**A heightfield is a `DTEX` layer, and collision reads the decoded numbers rather than the source ones.** Terrain inherits the format's residency, streaming and determinism instead of growing an image path of its own. **The design had the requirement backwards**: it asked that decoded heights match the source within the format's tolerance, which they do and which is not enough \u2014 a renderer reading the layer while a query reads the source differ by exactly that tolerance, everywhere, permanently, and a character floating by a fraction of a millimetre on every surface in the world is a defect nobody attributes. So `terrainFromHeightLayer` builds the `Terrain` from the decoded samples and everything reads that one object.\n\nSplat weights decode renormalised, since four weights rounded to eight bits sum to between 0.994 and 1.006, and a shader that trusts the sum shades a whole hillside wrong, smoothly enough to read as lighting."
86
102
  },
87
103
  {
88
104
  "kind": "added",
89
105
  "short": "Hierarchical detail, baked offline: a distant block of a city is one coarse mesh and a distant tree is its own picture. A proxy that would not be cheaper is refused rather than shipped.",
90
- "text": "**A distant block of a city is one coarse mesh, and a distant tree is its own picture.** `assets/src/bake/hlod.ts` builds both offline. The proxy is not a new mesh merger — `coarseLevel.ts` already emits the boundary of an occupancy grid rather than decimating a surface, which is what stops a merge growing triangles through a car's bodywork — so what is new is the grouping and the level schedule.\n\n**A proxy can be refused, and that is the honest half.** A grid-based merge cannot promise fewer triangles than its input, because its output scales with surface area rather than with triangle count, so the half-budget is checked and a group that is already cheap gets `null` instead of something larger than what it replaced.\n\n**The impostor's hard part is its edges.** Colour is dilated past the silhouette and every tile carries a two-texel gutter, because a transparent texel left at the clear colour is what makes a distant tree glow at its atlas seams. Measured on a tree at eight directions a side: **1.0000 silhouette agreement on a baked direction and 0.9083 between two**, since the blend carries no parallax correction and the fix costs the square of the direction count."
106
+ "text": "**A distant block of a city is one coarse mesh, and a distant tree is its own picture.** `assets/src/bake/hlod.ts` builds both offline. The proxy is not a new mesh merger \u2014 `coarseLevel.ts` already emits the boundary of an occupancy grid rather than decimating a surface, which is what stops a merge growing triangles through a car's bodywork \u2014 so what is new is the grouping and the level schedule.\n\n**A proxy can be refused, and that is the honest half.** A grid-based merge cannot promise fewer triangles than its input, because its output scales with surface area rather than with triangle count, so the half-budget is checked and a group that is already cheap gets `null` instead of something larger than what it replaced.\n\n**The impostor's hard part is its edges.** Colour is dilated past the silhouette and every tile carries a two-texel gutter, because a transparent texel left at the clear colour is what makes a distant tree glow at its atlas seams. Measured on a tree at eight directions a side: **1.0000 silhouette agreement on a baked direction and 0.9083 between two**, since the blend carries no parallax correction and the fix costs the square of the direction count."
91
107
  },
92
108
  {
93
109
  "kind": "added",
@@ -97,27 +113,27 @@
97
113
  {
98
114
  "kind": "added",
99
115
  "short": "A GPU-driven pipeline behind createRenderer({ pipeline: gpu-driven }), opt-in and WebGPU only. It refuses on a backend without indirect draws rather than falling back quietly.",
100
- "text": "**The GPU-driven pipeline is opt-in, WebGPU only, and refuses rather than falls back.** `createRenderer({ pipeline: 'gpu-driven' })` on a backend without indirect draws throws with the reason in words, checked against the backend that will _draw_ rather than the one that was asked for — three of the paths into that decision arrive having already fallen back from WebGPU.\n\nInstance culling, a level cut, a depth reduction, cluster culling, a near-plane refusal, compaction into one draw, a visibility buffer, material binning, surface reconstruction, the lit expression, the shadow lookup, the gradient a compute invocation has to assemble for itself, the octahedral probe mapping, the DriftTexture decode interpreter and the surface frame a textured pixel is read with, and the network evaluator in single and half precision.\n\n**Every pass is written twice** — once in TypeScript as the reference and once in WGSL — and `scripts/gpu-parity.mjs` runs twenty checks requiring the two to agree on a real device. **Until 2026-09-17 that was one short**: the instance cull was checked on the device and never dispatched by the pass, so every cluster of a mesh wholly outside the view was cut and tested one by one. It is a stage of the frame now, and the eighteen scenes are 0 of 921,600 pixels against the build before it.\n\n**Its scene streams, and keeps no copy of what it uploads.** The pass first built its scene in a method whose constructor said the geometry does not move — and a voxel world streams chunks as a player walks and remeshes one on every block they break. Vertices and indices now go through a `GeometrySink` straight into the device's buffers when a mesh is placed, because holding them as well doubled what a world costs and, at the voxel sandbox's radius 32, the copy was an `ArrayBuffer` Chrome will not allocate. **There is one path and not two**: the static case is a scene filled once and never emptied, asserted to pack byte for byte the way the old builder did, because two paths would mean the draft rigs exercise the path no published scene uses, which is how a control stops being a control.\n\n**Timings are a range rather than a number, because the device clock is quantised.** Four captures of each rig land on multiples of about 0.065 ms and nothing between them, so a single frame's figure is a bucket and quoting one is quoting the low end of a distribution."
116
+ "text": "**The GPU-driven pipeline is opt-in, WebGPU only, and refuses rather than falls back.** `createRenderer({ pipeline: 'gpu-driven' })` on a backend without indirect draws throws with the reason in words, checked against the backend that will _draw_ rather than the one that was asked for \u2014 three of the paths into that decision arrive having already fallen back from WebGPU.\n\nInstance culling, a level cut, a depth reduction, cluster culling, a near-plane refusal, compaction into one draw, a visibility buffer, material binning, surface reconstruction, the lit expression, the shadow lookup, the gradient a compute invocation has to assemble for itself, the octahedral probe mapping, the DriftTexture decode interpreter and the surface frame a textured pixel is read with, and the network evaluator in single and half precision.\n\n**Every pass is written twice** \u2014 once in TypeScript as the reference and once in WGSL \u2014 and `scripts/gpu-parity.mjs` runs twenty checks requiring the two to agree on a real device. **Until 2026-09-17 that was one short**: the instance cull was checked on the device and never dispatched by the pass, so every cluster of a mesh wholly outside the view was cut and tested one by one. It is a stage of the frame now, and the eighteen scenes are 0 of 921,600 pixels against the build before it.\n\n**Its scene streams, and keeps no copy of what it uploads.** The pass first built its scene in a method whose constructor said the geometry does not move \u2014 and a voxel world streams chunks as a player walks and remeshes one on every block they break. Vertices and indices now go through a `GeometrySink` straight into the device's buffers when a mesh is placed, because holding them as well doubled what a world costs and, at the voxel sandbox's radius 32, the copy was an `ArrayBuffer` Chrome will not allocate. **There is one path and not two**: the static case is a scene filled once and never emptied, asserted to pack byte for byte the way the old builder did, because two paths would mean the draft rigs exercise the path no published scene uses, which is how a control stops being a control.\n\n**Timings are a range rather than a number, because the device clock is quantised.** Four captures of each rig land on multiples of about 0.065 ms and nothing between them, so a single frame's figure is a bucket and quoting one is quoting the low end of a distribution."
101
117
  },
102
118
  {
103
119
  "kind": "added",
104
120
  "short": "Indirect light as three levels that fall back to each other and never off the end: the screen, a world-space distance field, and a probe volume. CPU-side, and no scene draws differently.",
105
- "text": "**Indirect light, as three levels that fall back to each other and never off the end.** `ROADMAP.md` refused screen-space global illumination because its error is unbounded: a ray that leaves the frame has no answer, and every technique that ships one anyway invents one from whatever happened to be on screen. **This does not reverse that refusal.** The screen is an accelerator with a world-space distance field behind it, whose error is bounded by its own resolution, and a probe volume behind _that_, which is never wrong and only ever coarse.\n\n`traceIndirect` takes a ray and a `GiResources` and answers from the best level that can; `GI_SOURCE_SCREEN`, `GI_SOURCE_FIELD` and `GI_SOURCE_PROBES` say which one did, so a caller can see the fallback rather than infer it. A scene that wants indirect light and declared no grid is given one that fits it.\n\n**It is CPU-side, and reached by nobody who does not ask.** There is no WGSL, no pass and no renderer option yet, so **no scene renders differently for these existing** — measured rather than asserted: the eight published scenes are 0 of 921,600 pixels against the build before them, and `core-only` is byte-identical with these exported and without them. It is exported anyway, because a capability with no route out of its package is one the first consumer finds by failing a boundary test."
121
+ "text": "**Indirect light, as three levels that fall back to each other and never off the end.** `ROADMAP.md` refused screen-space global illumination because its error is unbounded: a ray that leaves the frame has no answer, and every technique that ships one anyway invents one from whatever happened to be on screen. **This does not reverse that refusal.** The screen is an accelerator with a world-space distance field behind it, whose error is bounded by its own resolution, and a probe volume behind _that_, which is never wrong and only ever coarse.\n\n`traceIndirect` takes a ray and a `GiResources` and answers from the best level that can; `GI_SOURCE_SCREEN`, `GI_SOURCE_FIELD` and `GI_SOURCE_PROBES` say which one did, so a caller can see the fallback rather than infer it. A scene that wants indirect light and declared no grid is given one that fits it.\n\n**It is CPU-side, and reached by nobody who does not ask.** There is no WGSL, no pass and no renderer option yet, so **no scene renders differently for these existing** \u2014 measured rather than asserted: the eight published scenes are 0 of 921,600 pixels against the build before them, and `core-only` is byte-identical with these exported and without them. It is exported anyway, because a capability with no route out of its package is one the first consumer finds by failing a boundary test."
106
122
  },
107
123
  {
108
124
  "kind": "added",
109
125
  "short": "A frame graph under the renderer: a recording arena, a resource table, a scheduler and a replay, none of which import a backend, so the scheduling is proven by assertion rather than a picture.",
110
- "text": "**The frame's pure half.** `core/src/render/frame/` holds the recording arena, the resource table, the scheduler and the replay, and **imports neither backend** — which is what lets the scheduling be proven by assertion rather than by a picture.\n\nA node declares what it touches, by bit where a mask has bits and by identifier when it has run out of them, and a transient resource knows when it is born and when nothing needs it again. `FrameResource` and `PassTimings` are the public surface; the rest stays under `render/`, because nothing outside it may reach in yet and saying so is cheaper than discovering a consumer who did."
126
+ "text": "**The frame's pure half.** `core/src/render/frame/` holds the recording arena, the resource table, the scheduler and the replay, and **imports neither backend** \u2014 which is what lets the scheduling be proven by assertion rather than by a picture.\n\nA node declares what it touches, by bit where a mask has bits and by identifier when it has run out of them, and a transient resource knows when it is born and when nothing needs it again. `FrameResource` and `PassTimings` are the public surface; the rest stays under `render/`, because nothing outside it may reach in yet and saying so is cheaper than discovering a consumer who did."
111
127
  },
112
128
  {
113
129
  "kind": "added",
114
130
  "short": "A compute shader can be tested. gpuCompute.mjs runs WGSL on a real device and reads the buffers back, from a loopback server, because navigator.gpu is not exposed on about:blank.",
115
- "text": "**`packages/core/scripts/gpuCompute.mjs` runs WGSL on a real device and reads the buffers back**, which is what lets the GPU-driven pipeline's arithmetic have a test at all. Before it, the WGSL half had nothing but a generator check that confirms a committed file matches its source — which says nothing about whether the arithmetic is right.\n\n**It serves its own page from a loopback server**, because `navigator.gpu` is not exposed on `about:blank` and `http://localhost` is a secure context. There is no dev server to start first.\n\n`scripts/gpu-parity.mjs` runs the passes over generated input and requires agreement: **256 spheres with 149 culled, 256 clusters with 58 selected, and a 37×21 depth pyramid reduced to 18×10** — an odd size on purpose, because the extra row and column are what a reduction gets wrong. Both answers must occur in each case, or the agreement is between two constants. **The check was audited by breaking the WGSL five ways** — the reversed-Z minimum, the conservative direction of the cull, the second half of the cut rule, the plane normalisation and the odd-row fold — and it caught all five."
131
+ "text": "**`packages/core/scripts/gpuCompute.mjs` runs WGSL on a real device and reads the buffers back**, which is what lets the GPU-driven pipeline's arithmetic have a test at all. Before it, the WGSL half had nothing but a generator check that confirms a committed file matches its source \u2014 which says nothing about whether the arithmetic is right.\n\n**It serves its own page from a loopback server**, because `navigator.gpu` is not exposed on `about:blank` and `http://localhost` is a secure context. There is no dev server to start first.\n\n`scripts/gpu-parity.mjs` runs the passes over generated input and requires agreement: **256 spheres with 149 culled, 256 clusters with 58 selected, and a 37\u00d721 depth pyramid reduced to 18\u00d710** \u2014 an odd size on purpose, because the extra row and column are what a reduction gets wrong. Both answers must occur in each case, or the agreement is between two constants. **The check was audited by breaking the WGSL five ways** \u2014 the reversed-Z minimum, the conservative direction of the cull, the second half of the cut rule, the plane normalisation and the odd-row fold \u2014 and it caught all five."
116
132
  },
117
133
  {
118
134
  "kind": "note",
119
135
  "short": "What 4.0.0 does not do, stated as deliberately as what it does: no texture atlas or metric anchor on a capture, no learned reconstruction tier, and a proof suite deferred out of this release.",
120
- "text": "**This project treats an absence as a deliverable**, and `docs/CAPABILITIES.md` §2 names a sentinel symbol per absent row — the symbol that would exist if the work had landed — so the suite goes red on the day one arrives rather than the documentation going quietly stale.\n\n**DriftCapture's three.** There is no texture atlas: colour is one value per vertex, so the detail ceiling is the mesh rather than the footage. There is no delighting prior, so a grey object on a grey floor has no chromaticity to tell shading from paint. And there is no metric anchor: no clip in this record contained an object of known size, so the scale is the model's claim rather than a measurement.\n\n**Reconstruction ships its analytic tier only.** The learned tier and frame generation inside the frame are not here.\n\n**Screen-space global illumination stays refused in writing**, for the reason given when it was first refused: its error is unbounded. What ships instead falls back through a bounded world-space field to a probe volume.\n\n**A proof suite was planned for this release and deferred out of it**, deliberately, so that what is in 4.0.0 is what has been run rather than what was scheduled.\n\n**Two things in this release have been used by one person and no more**: the editor, which nobody who did not write it has opened, and the native host on Windows and macOS, which is not built at all. The Linux target is."
136
+ "text": "**This project treats an absence as a deliverable**, and `docs/CAPABILITIES.md` \u00a72 names a sentinel symbol per absent row \u2014 the symbol that would exist if the work had landed \u2014 so the suite goes red on the day one arrives rather than the documentation going quietly stale.\n\n**DriftCapture's three.** There is no texture atlas: colour is one value per vertex, so the detail ceiling is the mesh rather than the footage. There is no delighting prior, so a grey object on a grey floor has no chromaticity to tell shading from paint. And there is no metric anchor: no clip in this record contained an object of known size, so the scale is the model's claim rather than a measurement.\n\n**Reconstruction ships its analytic tier only.** The learned tier and frame generation inside the frame are not here.\n\n**Screen-space global illumination stays refused in writing**, for the reason given when it was first refused: its error is unbounded. What ships instead falls back through a bounded world-space field to a probe volume.\n\n**A proof suite was planned for this release and deferred out of it**, deliberately, so that what is in 4.0.0 is what has been run rather than what was scheduled.\n\n**Two things in this release have been used by one person and no more**: the editor, which nobody who did not write it has opened, and the native host on Windows and macOS, which is not built at all. The Linux target is."
121
137
  }
122
138
  ]
123
139
  },
@@ -128,7 +144,7 @@
128
144
  {
129
145
  "kind": "added",
130
146
  "short": "A sprite sheet can carry a mip chain. Type baked into an atlas and drawn small was losing strokes, so a t read as an l and the same letter read differently in different words.",
131
- "text": "**`mipmap` on `SpriteTextureOptions`, false by default.** A sprite sheet is usually pixel art, where a chain is memory nothing samples and a blur nobody asked for, so nothing changes for a sheet that does not ask. A glyph page is the sheet where that default is wrong, and the comment on `filter` said as much without offering a way out: it assumed a sheet that is not pixel art \"is normally drawn near its authored size, where the two filters differ by very little\".\n\nA consumer bakes one page per weight at 96 px and draws body copy at 11 to 13, which is about a **7x** minification. `linear` reads four texels out of a footprint covering dozens, so a `t` crossbar two texels tall lands on roughly a quarter of a pixel and survives or not depending on where the sample falls. A player reported it as `Step-In Uppercut` reading `Slep-In Uppercul` — and because the sample point moves with the glyph's position, the same letter survived in one word and not the next, which made the line look unevenly spaced as well as misread.\n\nNeither workaround was worth having: baking nearer the drawn size blurs the headings, because one atlas cannot serve the 8x range between body copy and a display size, and a second atlas for small text doubles the pages and the uploads and moves a sampling decision into the game where it has to be re-tuned whenever a size changes.\n\n**Measured rather than asserted.** `demo/dev/glyphMip.ts` draws one glyph twelve times at deliberately different subpixel offsets, through a plain sampler and through a chain, because the reported symptom is not missing ink but *inconsistent* ink — a build that samples well by luck passes an ink threshold and fails this. The copies' ink has a standard deviation of **0.0130** plain and **0.0020** mipmapped: **6.6x less** disagreement, and the two backends land on the same number to four decimal places. `scripts/glyph-mip-check.mjs` is that measurement as a gate, and it reddens with four failures when the chain is disabled.\n\nWith `filter: 'linear'` this is trilinear; with `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is about magnification, which is the split `SurfaceTexture` already makes. Padding cells against bleed stays the caller's problem, since how much depends on how far down the sheet is ever sampled.\n\n**WebGPU has no `generateMipmap`**, so `generateMipChain` and `mipLevelCount` are public now: the per-level blit that `SurfaceTexture` has always carried is a free function rather than a private method, and `ui2d` calls it. Writing the blit a second time there would have been a second shader to keep in step, and the copy that quietly disagrees about the colour space is the one that ships. It is the first runtime import `ui2d` takes from the engine and it was measured before it was taken: bundled alone, **11,777 against 11,163 gzipped bytes**, so 614 for the blit and its shader."
147
+ "text": "**`mipmap` on `SpriteTextureOptions`, false by default.** A sprite sheet is usually pixel art, where a chain is memory nothing samples and a blur nobody asked for, so nothing changes for a sheet that does not ask. A glyph page is the sheet where that default is wrong, and the comment on `filter` said as much without offering a way out: it assumed a sheet that is not pixel art \"is normally drawn near its authored size, where the two filters differ by very little\".\n\nA consumer bakes one page per weight at 96 px and draws body copy at 11 to 13, which is about a **7x** minification. `linear` reads four texels out of a footprint covering dozens, so a `t` crossbar two texels tall lands on roughly a quarter of a pixel and survives or not depending on where the sample falls. A player reported it as `Step-In Uppercut` reading `Slep-In Uppercul` \u2014 and because the sample point moves with the glyph's position, the same letter survived in one word and not the next, which made the line look unevenly spaced as well as misread.\n\nNeither workaround was worth having: baking nearer the drawn size blurs the headings, because one atlas cannot serve the 8x range between body copy and a display size, and a second atlas for small text doubles the pages and the uploads and moves a sampling decision into the game where it has to be re-tuned whenever a size changes.\n\n**Measured rather than asserted.** `demo/dev/glyphMip.ts` draws one glyph twelve times at deliberately different subpixel offsets, through a plain sampler and through a chain, because the reported symptom is not missing ink but *inconsistent* ink \u2014 a build that samples well by luck passes an ink threshold and fails this. The copies' ink has a standard deviation of **0.0130** plain and **0.0020** mipmapped: **6.6x less** disagreement, and the two backends land on the same number to four decimal places. `scripts/glyph-mip-check.mjs` is that measurement as a gate, and it reddens with four failures when the chain is disabled.\n\nWith `filter: 'linear'` this is trilinear; with `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is about magnification, which is the split `SurfaceTexture` already makes. Padding cells against bleed stays the caller's problem, since how much depends on how far down the sheet is ever sampled.\n\n**WebGPU has no `generateMipmap`**, so `generateMipChain` and `mipLevelCount` are public now: the per-level blit that `SurfaceTexture` has always carried is a free function rather than a private method, and `ui2d` calls it. Writing the blit a second time there would have been a second shader to keep in step, and the copy that quietly disagrees about the colour space is the one that ships. It is the first runtime import `ui2d` takes from the engine and it was measured before it was taken: bundled alone, **11,777 against 11,163 gzipped bytes**, so 614 for the blit and its shader."
132
148
  }
133
149
  ]
134
150
  },
@@ -139,7 +155,7 @@
139
155
  {
140
156
  "kind": "added",
141
157
  "short": "An Android build can ask for the permissions it needs. Without this an APK could not open a socket at all, and there was no way for a game to say otherwise short of vendoring three directories.",
142
- "text": "**The Android manifest template declares one activity and no permissions, and that default is right**: a permission is visible in a store listing, and a packager that granted the network to every game it ever built would be asking on behalf of games that never use it. The difficulty was that a consumer had no way to depart from it. The template is copied wholesale, none of the eight `-Pdrift*` properties Gradle takes reaches the manifest, there is no `manifestPlaceholders` and no source-set overlay, and `--resources` moves the desktop shell and the iOS spec along with the Gradle project — so changing one line of one manifest meant vendoring all three.\n\nSo an online game shipped an APK whose process is not permitted to open a socket. Every connection failed, the game reported that it could not connect, and the relay logged nothing at all because no packet left the phone: the same silence at both ends, indistinguishable from a wrong address. Measured on the built artifact, `aapt2 dump permissions` printed the package name and nothing else.\n\n```json\n\"android\": { \"permissions\": [\"INTERNET\"], \"cleartextTraffic\": false }\n```\n\n`\"INTERNET\"` and `\"android.permission.INTERNET\"` are both accepted; a name that would break out of the XML attribute it is written into is refused at parse time. The elements are written into the copied Gradle project before Gradle runs, which is where the icon is already written, so nothing in the engine's own tree is touched by a consumer's build. `doctor` prints what was asked for beside the signing mode, and says `none — the APK cannot open a socket` when nothing was.\n\n**`cleartextTraffic` is the second half, and it is the one only the engine could answer.** The game is served from `https://appassets.androidplatform.net`, which is a secure context, and a WebView defaults to `MIXED_CONTENT_NEVER_ALLOW` — stricter than a browser tab, where the same insecure WebSocket connects with a deprecation warning rather than being refused, measured on Chrome 151 against a LAN address. Setting it true writes `android:usesCleartextTraffic=\"true\"` **and** is what `MainActivity` reads back through `NetworkSecurityPolicy` to allow mixed content in the WebView. One switch drives both, so the platform and the renderer cannot disagree about it, and a network security config a consumer adds later is picked up without the Java learning about it. A relay on a LAN cannot hold a certificate, which is the case it exists for; a public relay should be `wss://` and this should stay false.\n\nVerified on real APKs with `aapt2`: unchanged, the dump is the package name alone; asking, it carries both permissions and the cleartext flag. Held by twenty-nine cases, and seven mutations each redden at least one."
158
+ "text": "**The Android manifest template declares one activity and no permissions, and that default is right**: a permission is visible in a store listing, and a packager that granted the network to every game it ever built would be asking on behalf of games that never use it. The difficulty was that a consumer had no way to depart from it. The template is copied wholesale, none of the eight `-Pdrift*` properties Gradle takes reaches the manifest, there is no `manifestPlaceholders` and no source-set overlay, and `--resources` moves the desktop shell and the iOS spec along with the Gradle project \u2014 so changing one line of one manifest meant vendoring all three.\n\nSo an online game shipped an APK whose process is not permitted to open a socket. Every connection failed, the game reported that it could not connect, and the relay logged nothing at all because no packet left the phone: the same silence at both ends, indistinguishable from a wrong address. Measured on the built artifact, `aapt2 dump permissions` printed the package name and nothing else.\n\n```json\n\"android\": { \"permissions\": [\"INTERNET\"], \"cleartextTraffic\": false }\n```\n\n`\"INTERNET\"` and `\"android.permission.INTERNET\"` are both accepted; a name that would break out of the XML attribute it is written into is refused at parse time. The elements are written into the copied Gradle project before Gradle runs, which is where the icon is already written, so nothing in the engine's own tree is touched by a consumer's build. `doctor` prints what was asked for beside the signing mode, and says `none \u2014 the APK cannot open a socket` when nothing was.\n\n**`cleartextTraffic` is the second half, and it is the one only the engine could answer.** The game is served from `https://appassets.androidplatform.net`, which is a secure context, and a WebView defaults to `MIXED_CONTENT_NEVER_ALLOW` \u2014 stricter than a browser tab, where the same insecure WebSocket connects with a deprecation warning rather than being refused, measured on Chrome 151 against a LAN address. Setting it true writes `android:usesCleartextTraffic=\"true\"` **and** is what `MainActivity` reads back through `NetworkSecurityPolicy` to allow mixed content in the WebView. One switch drives both, so the platform and the renderer cannot disagree about it, and a network security config a consumer adds later is picked up without the Java learning about it. A relay on a LAN cannot hold a certificate, which is the case it exists for; a public relay should be `wss://` and this should stay false.\n\nVerified on real APKs with `aapt2`: unchanged, the dump is the package name alone; asking, it carries both permissions and the cleartext flag. Held by twenty-nine cases, and seven mutations each redden at least one."
143
159
  }
144
160
  ]
145
161
  },
@@ -183,7 +199,7 @@
183
199
  {
184
200
  "kind": "fixed",
185
201
  "short": "Three packages shipped without files a consumer needs; 3.61.0 should not be used.",
186
- "text": "**`files` dropped four directories and two data files on the way to the registry.** `@driftengine/package` shipped without `bin/`, `assets/`, `android/` and `ios/` — so the `drift-package` command its own `bin` field declares was not in the tarball and the package could not run at all. `@driftengine/script` shipped without `capabilities.json`, which the DriftScript Vite plugin resolves through `@driftengine/script/capabilities.json`, so a consumer compiling `.drs` failed at config load. `@driftengine/core` now carries `CHANGELOG.json`, which a consumer imports to render engine release notes.\\n\\nNo engine code changed. The manifests did: the `files` array was rewritten when these packages were prepared for publication and it lost entries that had been there all along. Use 3.61.1."
202
+ "text": "**`files` dropped four directories and two data files on the way to the registry.** `@driftengine/package` shipped without `bin/`, `assets/`, `android/` and `ios/` \u2014 so the `drift-package` command its own `bin` field declares was not in the tarball and the package could not run at all. `@driftengine/script` shipped without `capabilities.json`, which the DriftScript Vite plugin resolves through `@driftengine/script/capabilities.json`, so a consumer compiling `.drs` failed at config load. `@driftengine/core` now carries `CHANGELOG.json`, which a consumer imports to render engine release notes.\\n\\nNo engine code changed. The manifests did: the `files` array was rewritten when these packages were prepared for publication and it lost entries that had been there all along. Use 3.61.1."
187
203
  }
188
204
  ]
189
205
  },
@@ -194,7 +210,7 @@
194
210
  {
195
211
  "kind": "fixed",
196
212
  "short": "The lit shader is built to fit the fragment uniform grid the device reports, so a phone that could not link it at all now draws, with point shadows still compiled in.",
197
- "text": "**A shader that will not link is not a slower frame, it is no frame at all.** The lit fragment stage declares twenty uniform arrays sized by the point-light budget and fourteen by the area-light one, and GLSL ES gives an array a whole row of the uniform grid per *element* whatever its base type — so `uniform float uLightRadius[16]` costs the same sixteen rows a `vec3` array of sixteen does. At the full budget that is **440 rows**. An Adreno 740 offers **256**, and WebGL2 guarantees any conforming implementation only **224**.\n\nOn that part the program did not link, the constructor threw, and an application that awaited `createRenderer` got no renderer, no reason and no frame — the page's background colour for as long as a player was willing to look at it, with the boot badge covering the first second of it. The only lever there was is switching point shadows off, and it is not enough to be a fix: it pays a whole feature on every part under 440, and the lit path without point shadows is still 248, so it could not reach a conforming device at all.\n\nThe budget is a build-time size now. The renderer counts the shader it is about to compile — from the source, not from a constant somebody measured once — against what the device reports, and builds at the largest budget that fits: **8 lights and 2 rectangles is 252 rows with point shadows compiled in**, and 4 and 1 is 158, which is inside what every conforming device guarantees. A refused link steps down a rung and tries again rather than propagating, and a shader that will not link at any budget throws a sentence carrying the device's own numbers instead of a bare driver string.\n\n`maxLights` and `maxAreaLights` are the ceiling for a consumer who wants to choose, and `renderer.shadedLights` reports what was resolved. Size a `PointLightBuffer` from that number: the selection evicts the weakest when the buffer is full, so a wider buffer leaves an arbitrary subset lit rather than the nearest ones. A wider upload is otherwise harmless, measured on ANGLE. WebGPU is unaffected and reports the full budget: its lit block is about 7 KB against a guaranteed 64 KB binding size, and there is no per-stage uniform-vector ceiling to be short of."
213
+ "text": "**A shader that will not link is not a slower frame, it is no frame at all.** The lit fragment stage declares twenty uniform arrays sized by the point-light budget and fourteen by the area-light one, and GLSL ES gives an array a whole row of the uniform grid per *element* whatever its base type \u2014 so `uniform float uLightRadius[16]` costs the same sixteen rows a `vec3` array of sixteen does. At the full budget that is **440 rows**. An Adreno 740 offers **256**, and WebGL2 guarantees any conforming implementation only **224**.\n\nOn that part the program did not link, the constructor threw, and an application that awaited `createRenderer` got no renderer, no reason and no frame \u2014 the page's background colour for as long as a player was willing to look at it, with the boot badge covering the first second of it. The only lever there was is switching point shadows off, and it is not enough to be a fix: it pays a whole feature on every part under 440, and the lit path without point shadows is still 248, so it could not reach a conforming device at all.\n\nThe budget is a build-time size now. The renderer counts the shader it is about to compile \u2014 from the source, not from a constant somebody measured once \u2014 against what the device reports, and builds at the largest budget that fits: **8 lights and 2 rectangles is 252 rows with point shadows compiled in**, and 4 and 1 is 158, which is inside what every conforming device guarantees. A refused link steps down a rung and tries again rather than propagating, and a shader that will not link at any budget throws a sentence carrying the device's own numbers instead of a bare driver string.\n\n`maxLights` and `maxAreaLights` are the ceiling for a consumer who wants to choose, and `renderer.shadedLights` reports what was resolved. Size a `PointLightBuffer` from that number: the selection evicts the weakest when the buffer is full, so a wider buffer leaves an arbitrary subset lit rather than the nearest ones. A wider upload is otherwise harmless, measured on ANGLE. WebGPU is unaffected and reports the full budget: its lit block is about 7 KB against a guaranteed 64 KB binding size, and there is no per-stage uniform-vector ceiling to be short of."
198
214
  }
199
215
  ]
200
216
  },
@@ -205,7 +221,7 @@
205
221
  {
206
222
  "kind": "fixed",
207
223
  "short": "A setting passed as undefined no longer deletes the default it was meant to fall back to. It turned a camera arm to NaN on its first step, and nothing in the type system could see it.",
208
- "text": "**`{ ...DEFAULTS, ...given }` is the obvious spelling and it destroys the default it was written to preserve.** A spread copies every own key, including one whose value is explicitly `undefined` — so a caller writing `{ retractLambda: settings.lambda }`, where that value may be absent, does not fall back. It overwrites the default with nothing, and what comes out of the arithmetic downstream is `NaN`.\n\nMeasured on the boom: a timing of `{ retractLambda: undefined }` takes the arm's fraction from 0.96 to `NaN` on the first step, which is a camera that never recovers. The same shape was in the resolution governor's limits and in a prefab's component overrides, where it writes `undefined` into a field that holds a number.\n\n**Nothing in this repository could see it.** The flag that separates “absent” from “present and undefined” is `exactOptionalPropertyTypes`, and this tree does not run it — a consumer that does reported the diagnostics, forty of them across this source, and every one reads as harmless because the receiver almost always uses `??`. The three that merge with a spread are the ones that are not.\n\nThe merge is a function now rather than a spread, and it keeps a falsy value that somebody chose: zero, an empty string and `false` are values, not absence. One implementation, for the reason the boom's own header gives about the arm it smooths — a third caller must not be able to get this wrong again.\n\nHeld by seven cases. Two fail when the function goes back to a spread, one when it treats falsy as absent, and one when the prefab override does."
224
+ "text": "**`{ ...DEFAULTS, ...given }` is the obvious spelling and it destroys the default it was written to preserve.** A spread copies every own key, including one whose value is explicitly `undefined` \u2014 so a caller writing `{ retractLambda: settings.lambda }`, where that value may be absent, does not fall back. It overwrites the default with nothing, and what comes out of the arithmetic downstream is `NaN`.\n\nMeasured on the boom: a timing of `{ retractLambda: undefined }` takes the arm's fraction from 0.96 to `NaN` on the first step, which is a camera that never recovers. The same shape was in the resolution governor's limits and in a prefab's component overrides, where it writes `undefined` into a field that holds a number.\n\n**Nothing in this repository could see it.** The flag that separates \u201cabsent\u201d from \u201cpresent and undefined\u201d is `exactOptionalPropertyTypes`, and this tree does not run it \u2014 a consumer that does reported the diagnostics, forty of them across this source, and every one reads as harmless because the receiver almost always uses `??`. The three that merge with a spread are the ones that are not.\n\nThe merge is a function now rather than a spread, and it keeps a falsy value that somebody chose: zero, an empty string and `false` are values, not absence. One implementation, for the reason the boom's own header gives about the arm it smooths \u2014 a third caller must not be able to get this wrong again.\n\nHeld by seven cases. Two fail when the function goes back to a spread, one when it treats falsy as absent, and one when the prefab override does."
209
225
  }
210
226
  ]
211
227
  },
@@ -216,7 +232,7 @@
216
232
  {
217
233
  "kind": "fixed",
218
234
  "short": "A lockstep session no longer halts when a packet repeats a tick the world already played. Sessions carrying more redundancy than their rewind window is deep were ending on links that lost nothing.",
219
- "text": "**Three windows move independently and a session lives inside all of them.** Redundancy says how far back every packet reaches, the rewind loop's depth says how far back a correction can reach, and the session forgets inputs below the oldest tick that correction window covers, every tick. The guard that skips a repeated input asked the log whether the tick was already confirmed, and the log can only answer for the window it still holds. So on any session whose redundancy reaches further back than its rewind depth, the oldest word of a packet names a tick retention has just dropped, the answer comes back “never seen”, and a link that lost nothing ends the match.\n\nReported from outside on the eleventh frame of every match, with no loss, no jitter and 60 ms of one-way delay. Reproduced here on the same tick the report names.\n\nThe judgement now rests on what this world has applied rather than on what the log can still say. A tick at or below that mark had every participant's real input and was stepped with it, so a second copy cannot change the world and cannot be evidence of a divergence. Above it nothing changes, halt included, and there the halt is right: an input this peer never had and can no longer apply really is fatal.\n\n**That mark is kept rather than asked for, because the obvious version of this fix has a hole in it.** Reading the session's live `confirmed` looks equivalent and is not: when a peer goes quiet for longer than the rewind window is deep, retention walks past the last tick everybody had an input for and the live figure falls to nothing, while the world built from those inputs is still the world. The peer's first packet back then carries exactly the repeat this fix is about. Held by three cases over a hand-driven transport, one of which fails against the live figure and passes against the kept one.\n\nNothing about the numbers has to be arranged: redundancy is chosen for the link and the rewind depth for the game, and the README now says so instead of leaving a consumer to find it by having a session halt on a perfect connection."
235
+ "text": "**Three windows move independently and a session lives inside all of them.** Redundancy says how far back every packet reaches, the rewind loop's depth says how far back a correction can reach, and the session forgets inputs below the oldest tick that correction window covers, every tick. The guard that skips a repeated input asked the log whether the tick was already confirmed, and the log can only answer for the window it still holds. So on any session whose redundancy reaches further back than its rewind depth, the oldest word of a packet names a tick retention has just dropped, the answer comes back \u201cnever seen\u201d, and a link that lost nothing ends the match.\n\nReported from outside on the eleventh frame of every match, with no loss, no jitter and 60 ms of one-way delay. Reproduced here on the same tick the report names.\n\nThe judgement now rests on what this world has applied rather than on what the log can still say. A tick at or below that mark had every participant's real input and was stepped with it, so a second copy cannot change the world and cannot be evidence of a divergence. Above it nothing changes, halt included, and there the halt is right: an input this peer never had and can no longer apply really is fatal.\n\n**That mark is kept rather than asked for, because the obvious version of this fix has a hole in it.** Reading the session's live `confirmed` looks equivalent and is not: when a peer goes quiet for longer than the rewind window is deep, retention walks past the last tick everybody had an input for and the live figure falls to nothing, while the world built from those inputs is still the world. The peer's first packet back then carries exactly the repeat this fix is about. Held by three cases over a hand-driven transport, one of which fails against the live figure and passes against the kept one.\n\nNothing about the numbers has to be arranged: redundancy is chosen for the link and the rewind depth for the game, and the README now says so instead of leaving a consumer to find it by having a session halt on a perfect connection."
220
236
  }
221
237
  ]
222
238
  },
@@ -243,7 +259,7 @@
243
259
  {
244
260
  "kind": "added",
245
261
  "short": "The third-person camera can be told which way its subject faces. Until now the only way to frame a shot from behind was to guess an axis the rig never reported.",
246
- "text": "**The boom's up became a parameter, then its aim followed that up, and the forward was still carried.** Carrying is the right default and stays the default: it is what makes a body walking from a floor onto a wall onto a ceiling continuous, and deriving forward from a fixed reference would put a pole wherever up lined up with that reference — the arm swinging through a half turn as a subject crossed it, on a rig whose whole job is that the view does not snap.\n\nWhat carrying cannot say is \"behind *this*\". A yaw of pi puts the eye opposite the carried forward, which on the first frame of a run is world Z re-projected: an axis the caller never chose and could not read. Reported from outside as a subject spawning face-on to a wall and the camera sitting beside it, which reads as a shot framed at random — and as a caller unable to work out how far to roll for the support surface to sit at the bottom of the picture, because that answer depends on where the arm is and the arm's azimuth was measured from an axis nothing reported.\n\n`setBoomForward(x, y, z)` orthogonalises against the current up exactly as the carried one is, and every later `setBoomUp` re-orthogonalises it the same way — a heading is a direction in the world and the frame it lands in is the surface's. **A caller that never calls it carries its forward exactly as before.**\n\nA zero-length vector, or one parallel to the up, keeps the frame it had rather than falling back to a world axis. That asymmetry with `setBoomUp` is deliberate: that method must produce some forward because the up is what changed, and here the forward is the argument — answering with a direction the caller did not ask for is the failure this exists to fix.\n\nThe frame's forward is readable now for the same reason: both setters orthogonalise, so what the rig holds is rarely the vector it was handed, and a caller computing a roll needs the one the arm is actually built from."
262
+ "text": "**The boom's up became a parameter, then its aim followed that up, and the forward was still carried.** Carrying is the right default and stays the default: it is what makes a body walking from a floor onto a wall onto a ceiling continuous, and deriving forward from a fixed reference would put a pole wherever up lined up with that reference \u2014 the arm swinging through a half turn as a subject crossed it, on a rig whose whole job is that the view does not snap.\n\nWhat carrying cannot say is \"behind *this*\". A yaw of pi puts the eye opposite the carried forward, which on the first frame of a run is world Z re-projected: an axis the caller never chose and could not read. Reported from outside as a subject spawning face-on to a wall and the camera sitting beside it, which reads as a shot framed at random \u2014 and as a caller unable to work out how far to roll for the support surface to sit at the bottom of the picture, because that answer depends on where the arm is and the arm's azimuth was measured from an axis nothing reported.\n\n`setBoomForward(x, y, z)` orthogonalises against the current up exactly as the carried one is, and every later `setBoomUp` re-orthogonalises it the same way \u2014 a heading is a direction in the world and the frame it lands in is the surface's. **A caller that never calls it carries its forward exactly as before.**\n\nA zero-length vector, or one parallel to the up, keeps the frame it had rather than falling back to a world axis. That asymmetry with `setBoomUp` is deliberate: that method must produce some forward because the up is what changed, and here the forward is the argument \u2014 answering with a direction the caller did not ask for is the failure this exists to fix.\n\nThe frame's forward is readable now for the same reason: both setters orthogonalise, so what the rig holds is rarely the vector it was handed, and a caller computing a roll needs the one the arm is actually built from."
247
263
  }
248
264
  ]
249
265
  },
@@ -254,17 +270,17 @@
254
270
  {
255
271
  "kind": "fixed",
256
272
  "short": "Two-bone IK now solves a rig whose hierarchy carries a scale. It used to return the right length in the wrong direction and report success.",
257
- "text": "**A rotation read out of a scaled matrix is a different rotation, not the same one at a different length.** `quat.fromMat3` recovers a rotation from the matrix trace by way of `sqrt(trace + 1)`; scaling the matrix scales the trace while that `+ 1` stays put, so normalising the result afterwards only fixes the length of something already pointing the wrong way. Measured against the maths library alone, a known rotation scaled and read back: 13.98 degrees out at scale 0.5, 61.59 at 0.007132, 22.89 at 140.2. Nothing but exactly 1 is close, and neither direction is safe.\n\nThe solver's two steps differed in one way. The bend transforms an *axis* through the parent frame and normalises it, so a uniform scale divides out and it was always correct. The aim read quaternions straight out of that frame. Split that way the failure says exactly where it is: reported from outside as a chain coming out the right length **to a part in ten million** and pointing **108.8 degrees wrong**, on an animal 46 mm long whose model is authored in metres.\n\nThe parent frame's columns are normalised now, at the one place both steps read it. A hierarchy at unit scale divides by 1 and gets precisely what it got before, which is why no test here saw this: a rig authored at the scale it is played at has determinant 1, where the recovery is exact.\n\nThe consumer that reported it was running twenty-four passes of its own cyclic descent per limb instead, measured at 4.41 ms a tick against 2.45 ms for the closed form — 44% of its body step spent grinding out an answer a triangle already has."
273
+ "text": "**A rotation read out of a scaled matrix is a different rotation, not the same one at a different length.** `quat.fromMat3` recovers a rotation from the matrix trace by way of `sqrt(trace + 1)`; scaling the matrix scales the trace while that `+ 1` stays put, so normalising the result afterwards only fixes the length of something already pointing the wrong way. Measured against the maths library alone, a known rotation scaled and read back: 13.98 degrees out at scale 0.5, 61.59 at 0.007132, 22.89 at 140.2. Nothing but exactly 1 is close, and neither direction is safe.\n\nThe solver's two steps differed in one way. The bend transforms an *axis* through the parent frame and normalises it, so a uniform scale divides out and it was always correct. The aim read quaternions straight out of that frame. Split that way the failure says exactly where it is: reported from outside as a chain coming out the right length **to a part in ten million** and pointing **108.8 degrees wrong**, on an animal 46 mm long whose model is authored in metres.\n\nThe parent frame's columns are normalised now, at the one place both steps read it. A hierarchy at unit scale divides by 1 and gets precisely what it got before, which is why no test here saw this: a rig authored at the scale it is played at has determinant 1, where the recovery is exact.\n\nThe consumer that reported it was running twenty-four passes of its own cyclic descent per limb instead, measured at 4.41 ms a tick against 2.45 ms for the closed form \u2014 44% of its body step spent grinding out an answer a triangle already has."
258
274
  },
259
275
  {
260
276
  "kind": "added",
261
277
  "short": "A character controller can be steered in any direction, not only in the world's horizontal plane. `ControllerInput` takes an optional third component.",
262
- "text": "**Two components span the tangent plane only while up is world Y.** Giving the controller an arbitrary up left its input two-dimensional in a fixed world plane, so a body that could stand on a wall could not be steered along one: on a wall whose normal is +Z, up the wall is +Y and no pair of the old two describes it — the input plane collapses onto a line.\n\nThe reporting consumer had given up on the feel layer entirely to get that axis back, writing the velocity components directly each tick with zero acceleration and deceleration so the controller would leave them alone. That keeps the sweep, the step-over and the contact resolution, and throws away the acceleration curves, the air control and the speed clamp — and every consumer with a non-vertical up would have done the same.\n\nOptional, so no caller changes and nothing that implements the interface breaks. Absent means zero, and zero is arithmetically what a caller got before this existed: the clamp, the projection and the approach are the same three lines with a third component in them."
278
+ "text": "**Two components span the tangent plane only while up is world Y.** Giving the controller an arbitrary up left its input two-dimensional in a fixed world plane, so a body that could stand on a wall could not be steered along one: on a wall whose normal is +Z, up the wall is +Y and no pair of the old two describes it \u2014 the input plane collapses onto a line.\n\nThe reporting consumer had given up on the feel layer entirely to get that axis back, writing the velocity components directly each tick with zero acceleration and deceleration so the controller would leave them alone. That keeps the sweep, the step-over and the contact resolution, and throws away the acceleration curves, the air control and the speed clamp \u2014 and every consumer with a non-vertical up would have done the same.\n\nOptional, so no caller changes and nothing that implements the interface breaks. Absent means zero, and zero is arithmetically what a caller got before this existed: the clamp, the projection and the approach are the same three lines with a third component in them."
263
279
  },
264
280
  {
265
281
  "kind": "fixed",
266
282
  "short": "The third-person camera aims in the same frame its arm is built in. On any surface that was not a floor it put the eye in the right place and looked somewhere else.",
267
- "text": "**A half-migration rather than a defect in either half.** The arm moved into the boom's basis when that basis was added; the aim stayed on the world axes, and the two agree only while up is world Y. With up at +X an arm built at yaw 0 lies along the frame's forward while a camera at yaw 0 looks down world −Z — a right angle apart. What it rendered was the scene beside the subject, with the subject nowhere in it and no error anywhere.\n\nThe aim is derived from the arm now, so the signature is unchanged and nothing outside has to learn the convention. The decomposition is the analytic inverse of what the camera builds from its three angles: the arm fixes yaw and pitch, and the roll is the angle from the basis those two would build onto the up the arm was built against, with the caller's damped roll added on top.\n\n**A rig that never sets a boom up reads back the three numbers it was handed**, to within 3e-17 radians on yaw and pitch and 8e-13 on roll — a hundred-millionth of a pixel across a 1080-line frame. Every existing consumer is in that case.\n\nThe consumer that reported it was recovering the arm from where the rig had put the eye and inverting the rig's own boom formula from outside it, against a sign convention nothing stated — and got the sign backwards first, which renders as a plausible picture of the wrong thing."
283
+ "text": "**A half-migration rather than a defect in either half.** The arm moved into the boom's basis when that basis was added; the aim stayed on the world axes, and the two agree only while up is world Y. With up at +X an arm built at yaw 0 lies along the frame's forward while a camera at yaw 0 looks down world \u2212Z \u2014 a right angle apart. What it rendered was the scene beside the subject, with the subject nowhere in it and no error anywhere.\n\nThe aim is derived from the arm now, so the signature is unchanged and nothing outside has to learn the convention. The decomposition is the analytic inverse of what the camera builds from its three angles: the arm fixes yaw and pitch, and the roll is the angle from the basis those two would build onto the up the arm was built against, with the caller's damped roll added on top.\n\n**A rig that never sets a boom up reads back the three numbers it was handed**, to within 3e-17 radians on yaw and pitch and 8e-13 on roll \u2014 a hundred-millionth of a pixel across a 1080-line frame. Every existing consumer is in that case.\n\nThe consumer that reported it was recovering the arm from where the rig had put the eye and inverting the rig's own boom formula from outside it, against a sign convention nothing stated \u2014 and got the sign backwards first, which renders as a plausible picture of the wrong thing."
268
284
  }
269
285
  ]
270
286
  },
@@ -275,12 +291,12 @@
275
291
  {
276
292
  "kind": "fixed",
277
293
  "short": "Two-bone IK puts a limb on its target in one call. It used to land short whenever the pole hint sat off the plane the limb was bending in, and reach the target only on a second call.",
278
- "text": "**A closed-form solver has nothing to converge**, so a chain that reached its target on the second call and not the first was the symptom of an angle applied about the wrong axis.\n\nThe mid joint is bent by the difference between the interior angle it has and the one the triangle wants, and that produces the angle asked for only when the rotation happens *in* the plane the angle is measured in. The axis used was the bone crossed with the pole hint, which is perpendicular to that plane **exactly when the pole lies in it** — so the wrong axis was right by accident for every pole that happened to sit in the limb's own plane, and wrong for every one that did not. A knee's pole points where a character faces; the plane its leg is bending in is wherever the animation left it, and the two coincide by luck.\n\nOff the plane, the bend turned the bone out of the triangle instead, the chain came out short, and the aim step then placed the tip on the right ray at the wrong distance. Reported from outside against a crouching leg. Held now by a fixed rig with a 0.46 m thigh, a 0.44 m shin and a pole off the plane: **0.028 short of its target before, under a micrometre after**.\n\nThe axis is the chain's own plane normal now. The pole still chooses the bend for a chain that is straight, which is the one case with no plane and the only case it was ever needed for."
294
+ "text": "**A closed-form solver has nothing to converge**, so a chain that reached its target on the second call and not the first was the symptom of an angle applied about the wrong axis.\n\nThe mid joint is bent by the difference between the interior angle it has and the one the triangle wants, and that produces the angle asked for only when the rotation happens *in* the plane the angle is measured in. The axis used was the bone crossed with the pole hint, which is perpendicular to that plane **exactly when the pole lies in it** \u2014 so the wrong axis was right by accident for every pole that happened to sit in the limb's own plane, and wrong for every one that did not. A knee's pole points where a character faces; the plane its leg is bending in is wherever the animation left it, and the two coincide by luck.\n\nOff the plane, the bend turned the bone out of the triangle instead, the chain came out short, and the aim step then placed the tip on the right ray at the wrong distance. Reported from outside against a crouching leg. Held now by a fixed rig with a 0.46 m thigh, a 0.44 m shin and a pole off the plane: **0.028 short of its target before, under a micrometre after**.\n\nThe axis is the chain's own plane normal now. The pole still chooses the bend for a chain that is straight, which is the one case with no plane and the only case it was ever needed for."
279
295
  },
280
296
  {
281
297
  "kind": "changed",
282
298
  "short": "A fixed step now says it may run more than once for the same tick, and what that means for anything it plays, sends or spawns.",
283
- "text": "**Rewind owns state, and an effect is not state.** Correcting a tick restores the world and steps forward again, so a step runs once when it is first predicted and again after every correction reaching back past it — and everything it emitted the first time is emitted again. Snapshots put a world back; they cannot un-play a sound.\n\nReported from outside as a doubled impact on a corrected frame. Nothing in the rewind surface warned about it, which is the gap: replaying a tick is the whole mechanism, and a step that is not re-runnable cannot be part of one.\n\nThe engine does not deduplicate, and the note says why: knowing what counts as the same emission means knowing what an event is, which is a game's vocabulary and not an engine's. What it does now is name the hazard, point at the two answers — keep effects out of the fixed step, or gate them on a small table of what a tick already emitted — and name the watermark such a table is cleared behind.\n\nAlso documented: fixed-rate capture paces to whatever rate is asked for. Every sentence about it named 60, which reads as a limit and is not one; capturing at 60 and discarding every other frame pays a full render per discarded frame for a rate that was available."
299
+ "text": "**Rewind owns state, and an effect is not state.** Correcting a tick restores the world and steps forward again, so a step runs once when it is first predicted and again after every correction reaching back past it \u2014 and everything it emitted the first time is emitted again. Snapshots put a world back; they cannot un-play a sound.\n\nReported from outside as a doubled impact on a corrected frame. Nothing in the rewind surface warned about it, which is the gap: replaying a tick is the whole mechanism, and a step that is not re-runnable cannot be part of one.\n\nThe engine does not deduplicate, and the note says why: knowing what counts as the same emission means knowing what an event is, which is a game's vocabulary and not an engine's. What it does now is name the hazard, point at the two answers \u2014 keep effects out of the fixed step, or gate them on a small table of what a tick already emitted \u2014 and name the watermark such a table is cleared behind.\n\nAlso documented: fixed-rate capture paces to whatever rate is asked for. Every sentence about it named 60, which reads as a limit and is not one; capturing at 60 and discarding every other frame pays a full render per discarded frame for a rate that was available."
284
300
  }
285
301
  ]
286
302
  },
@@ -291,7 +307,7 @@
291
307
  {
292
308
  "kind": "fixed",
293
309
  "short": "A world asked for more physics workers than a machine can afford now says so. It used to report the number it settled on as the number you asked for, with nothing to explain the difference.",
294
- "text": "**`PhysicsWorld` keeps one core back for the thread that steps the world**, so a four-worker request on a two-core machine grants one. That is right. What was wrong is that `parallelism` then reported `requested: 1, running: 1, reason: ''` — every field agreeing with every other, and none of them saying three quarters of the request had been dropped.\n\nIts own type documents `requested` as \"workers the consumer asked for\" and `reason` as \"empty when `running` matches `requested`\", and the code met neither. This is the silent clamp that whole surface exists to prevent: its note says to read it *rather than assuming the `workers` option took*, and on the most common shortfall there is it had nothing to say.\n\n`requested` is now what was asked for, and a clamped pool carries a sentence naming both numbers and the machine's own core count. A pool that could not start at all keeps its own better sentence.\n\n**Found by continuous integration rather than by a local run**, which is the other half of it. A build character has two cores and the machine this was written on has twenty-four, so the clamp never bit here — and the tests that asserted the documented meaning had been red on every push since the pool shipped. They pin the core count now, so they measure the pool rather than the character they happen to be on, and two new cases pin it low to hold the clamp itself."
310
+ "text": "**`PhysicsWorld` keeps one core back for the thread that steps the world**, so a four-worker request on a two-core machine grants one. That is right. What was wrong is that `parallelism` then reported `requested: 1, running: 1, reason: ''` \u2014 every field agreeing with every other, and none of them saying three quarters of the request had been dropped.\n\nIts own type documents `requested` as \"workers the consumer asked for\" and `reason` as \"empty when `running` matches `requested`\", and the code met neither. This is the silent clamp that whole surface exists to prevent: its note says to read it *rather than assuming the `workers` option took*, and on the most common shortfall there is it had nothing to say.\n\n`requested` is now what was asked for, and a clamped pool carries a sentence naming both numbers and the machine's own core count. A pool that could not start at all keeps its own better sentence.\n\n**Found by continuous integration rather than by a local run**, which is the other half of it. A build character has two cores and the machine this was written on has twenty-four, so the clamp never bit here \u2014 and the tests that asserted the documented meaning had been red on every push since the pool shipped. They pin the core count now, so they measure the pool rather than the character they happen to be on, and two new cases pin it low to hold the clamp itself."
295
311
  }
296
312
  ]
297
313
  },
@@ -302,12 +318,12 @@
302
318
  {
303
319
  "kind": "added",
304
320
  "short": "A global participating medium: `setGlobalMedium` fills the frustum with air the sun can light, and `globalMediumSteps` decides what that may cost. Off by default, and off costs nothing.",
305
- "text": "**Volumetrics were a body inside a hull and nothing else.** `drawLightVolume` marches a cone you place, so a shaft through a window works and fog filling a room does not; `atmosphere.ts` fades a *surface* toward a colour by distance, which dims a wall behind a doorway and cannot put the doorway's shape on the floor. Distance fog asks how much of a surface survives the journey. This asks what the journey adds, which is every point along it the sun can see.\n\n`setGlobalMedium(density, albedo, anisotropy, maxDistance)` is the per-frame dial and `globalMediumSteps` the construction-time ceiling — the split `bloom` and `setBloom` already draw. **At `density: 0` no target is allocated, no program is compiled and no composite is drawn**, so a game that never asks for weather renders the frame it rendered before this existed. `globalMediumSteps` is 0 by default and a part `isWeakGpuFamily` names is clamped to 16, because a march is the only ceiling in `RenderQuality` that is a per-pixel loop.\n\nOne GLSL source for both backends. The march writes scattered light in rgb and transmittance in alpha, so the composite is `(ONE, SRC_ALPHA)` — the transfer equation for a segment written as a blend state, which means the pass never samples the colour it is modifying. Half resolution behind a depth-aware upsample, because a homogeneous medium is smooth everywhere except at a silhouette and that is the one place the upsample is careful.\n\n**Samples are spread as distance squared along the ray**, and evenly spaced ones were measured before they were changed: a 200 m ray at 32 steps puts six metres between samples and the shadow dither then moves each by three, which photographed as coarse salt and pepper over the brightest part of the frame. Everything a march has to resolve is near.\n\n`scripts/medium-check.mjs` measures six claims on both backends, each written against the deletion that breaks it, and the six deletions were run. Extinction is checked against what `exp(-sigma d)` predicts rather than against a threshold: **1.41 measured against 1.41 predicted** on both backends."
321
+ "text": "**Volumetrics were a body inside a hull and nothing else.** `drawLightVolume` marches a cone you place, so a shaft through a window works and fog filling a room does not; `atmosphere.ts` fades a *surface* toward a colour by distance, which dims a wall behind a doorway and cannot put the doorway's shape on the floor. Distance fog asks how much of a surface survives the journey. This asks what the journey adds, which is every point along it the sun can see.\n\n`setGlobalMedium(density, albedo, anisotropy, maxDistance)` is the per-frame dial and `globalMediumSteps` the construction-time ceiling \u2014 the split `bloom` and `setBloom` already draw. **At `density: 0` no target is allocated, no program is compiled and no composite is drawn**, so a game that never asks for weather renders the frame it rendered before this existed. `globalMediumSteps` is 0 by default and a part `isWeakGpuFamily` names is clamped to 16, because a march is the only ceiling in `RenderQuality` that is a per-pixel loop.\n\nOne GLSL source for both backends. The march writes scattered light in rgb and transmittance in alpha, so the composite is `(ONE, SRC_ALPHA)` \u2014 the transfer equation for a segment written as a blend state, which means the pass never samples the colour it is modifying. Half resolution behind a depth-aware upsample, because a homogeneous medium is smooth everywhere except at a silhouette and that is the one place the upsample is careful.\n\n**Samples are spread as distance squared along the ray**, and evenly spaced ones were measured before they were changed: a 200 m ray at 32 steps puts six metres between samples and the shadow dither then moves each by three, which photographed as coarse salt and pepper over the brightest part of the frame. Everything a march has to resolve is near.\n\n`scripts/medium-check.mjs` measures six claims on both backends, each written against the deletion that breaks it, and the six deletions were run. Extinction is checked against what `exp(-sigma d)` predicts rather than against a threshold: **1.41 measured against 1.41 predicted** on both backends."
306
322
  },
307
323
  {
308
324
  "kind": "added",
309
325
  "short": "`readSplatSog` reads the `.sog` container newer capture tools emit: a ZIP of lossless WebP images and a manifest.",
310
- "text": "**Two formats share the name, and the distinction is the first thing to know.** The self-organising-Gaussians paper is a *technique* for sorting Gaussians onto a grid so image compression can carry them; its released scenes decompress to `.ply` and it documents no on-disk structure. What capture tools write is the PlayCanvas container, version 2, and that is what this reads.\n\n**The WebP decoder is a parameter rather than a dependency.** Node has none and this package will not vendor a thousand lines of VP8L to pretend otherwise, so the contract is declared and `browserWebpDecoder` is the ordinary implementation — the arrangement `KeyValueStore` and `BrowserStore` already have. `unbundleSog` reads the ZIP through its central directory, which is the only place the sizes are: `splat-transform` writes streaming, so every local header carries a size of zero.\n\nChecked against a bundle `@playcanvas/splat-transform` produced from sixty-four Gaussians chosen in this repository and decoded by Pillow, so nothing in the loop checks itself. Every field lands inside the quantiser's own resolution: position 1.1e-4 m over a four-metre cloud, opacity within one eight-bit step."
326
+ "text": "**Two formats share the name, and the distinction is the first thing to know.** The self-organising-Gaussians paper is a *technique* for sorting Gaussians onto a grid so image compression can carry them; its released scenes decompress to `.ply` and it documents no on-disk structure. What capture tools write is the PlayCanvas container, version 2, and that is what this reads.\n\n**The WebP decoder is a parameter rather than a dependency.** Node has none and this package will not vendor a thousand lines of VP8L to pretend otherwise, so the contract is declared and `browserWebpDecoder` is the ordinary implementation \u2014 the arrangement `KeyValueStore` and `BrowserStore` already have. `unbundleSog` reads the ZIP through its central directory, which is the only place the sizes are: `splat-transform` writes streaming, so every local header carries a size of zero.\n\nChecked against a bundle `@playcanvas/splat-transform` produced from sixty-four Gaussians chosen in this repository and decoded by Pillow, so nothing in the loop checks itself. Every field lands inside the quantiser's own resolution: position 1.1e-4 m over a four-metre cloud, opacity within one eight-bit step."
311
327
  },
312
328
  {
313
329
  "kind": "added",
@@ -322,7 +338,7 @@
322
338
  {
323
339
  "kind": "changed",
324
340
  "short": "The LTC fitter fits against polygon integrals now, which is what it is judged on. The shipped term's 71.8% becomes 8.8% on the placement the bound is set on.",
325
- "text": "Nothing in the engine reads this yet and the row is still open, but four attempts had concluded the fit was not worth having and that conclusion is wrong.\n\n**The objective was the blocker.** The search fitted a transformed cosine to the *lobe* over a hemisphere and was judged on how well it integrates a *rectangle*, so it chose matrices that won the first and lost the second — with the analytic answer offered as a seed at every entry and rejected on score. Fitting against the polygon integrals directly is affordable because a reference does not depend on the candidate: the brute force runs once per entry, and every evaluation afterwards is six closed-form form factors.\n\n**And the comparison was wrong.** A relative error against a reference of 0.000204 is not a bound, and the analytic term's headline 85.7% is exactly that cell — where the rectangle covers two hundredths of one per cent of the lobe. Every figure is now reported twice, over the grid and over the cells carrying at least a twentieth of it.\n\nWhat is left is the storage: read back through a degree-5 polynomial the good table is worse than doing nothing, and degrees 6 to 10 are worse still. These are data that can only be shipped, which is what the row said at the start, and a 32x32 table of five values is 562 gzipped bytes at 8 bits.\n\n**Two tooling fixes came with it and are recorded here rather than as their own entry**, because neither changes anything a consumer can observe. Writing the fitted table out read `fits.m00` where the fits are keyed `k00`, so that flag threw for as long as it existed and nobody had run it. And a fitted table can now be read back instead of refitted, so a polynomial degree is a sweep of seconds rather than a four-minute refit each — which is why the third attempt's conclusion that a degree-5 surface cannot carry these terms had never been tested against a degree-7 one."
341
+ "text": "Nothing in the engine reads this yet and the row is still open, but four attempts had concluded the fit was not worth having and that conclusion is wrong.\n\n**The objective was the blocker.** The search fitted a transformed cosine to the *lobe* over a hemisphere and was judged on how well it integrates a *rectangle*, so it chose matrices that won the first and lost the second \u2014 with the analytic answer offered as a seed at every entry and rejected on score. Fitting against the polygon integrals directly is affordable because a reference does not depend on the candidate: the brute force runs once per entry, and every evaluation afterwards is six closed-form form factors.\n\n**And the comparison was wrong.** A relative error against a reference of 0.000204 is not a bound, and the analytic term's headline 85.7% is exactly that cell \u2014 where the rectangle covers two hundredths of one per cent of the lobe. Every figure is now reported twice, over the grid and over the cells carrying at least a twentieth of it.\n\nWhat is left is the storage: read back through a degree-5 polynomial the good table is worse than doing nothing, and degrees 6 to 10 are worse still. These are data that can only be shipped, which is what the row said at the start, and a 32x32 table of five values is 562 gzipped bytes at 8 bits.\n\n**Two tooling fixes came with it and are recorded here rather than as their own entry**, because neither changes anything a consumer can observe. Writing the fitted table out read `fits.m00` where the fits are keyed `k00`, so that flag threw for as long as it existed and nobody had run it. And a fitted table can now be read back instead of refitted, so a polynomial degree is a sweep of seconds rather than a four-minute refit each \u2014 which is why the third attempt's conclusion that a degree-5 surface cannot carry these terms had never been tested against a degree-7 one."
326
342
  }
327
343
  ]
328
344
  },
@@ -354,7 +370,7 @@
354
370
  {
355
371
  "kind": "changed",
356
372
  "short": "README's gap list is rewritten. Every roadmap track is complete, so what is left is two unscheduled rendering ideas and two things written but never run.",
357
- "text": "The section listed a worker pool that shipped in 3.54.0, a fitted LTC table that came off the list on 2026-09-04 after four attempts, and two empty headings where WebXR used to be.\n\nWhat it says now is what is true. Two rendering rows are genuinely absent and neither is scheduled: screen-space GI, and the fitted LTC table, which is priced with the measurement that stopped it — the analytic matrix scores 19.5% against the fitted table's 75.2% on the placement the feature exists for, because the search fits a lobe over a hemisphere while the term integrates a polygon.\n\nAnd two things are written and have never been run: WebXR against a headset, and an iOS build on a Mac. Those are named in prose rather than listed as gaps, because the section holds gaps only and the code exists. Neither carries a sentinel, since a sentinel guards a symbol that would exist if the work had landed and both symbols are already here."
373
+ "text": "The section listed a worker pool that shipped in 3.54.0, a fitted LTC table that came off the list on 2026-09-04 after four attempts, and two empty headings where WebXR used to be.\n\nWhat it says now is what is true. Two rendering rows are genuinely absent and neither is scheduled: screen-space GI, and the fitted LTC table, which is priced with the measurement that stopped it \u2014 the analytic matrix scores 19.5% against the fitted table's 75.2% on the placement the feature exists for, because the search fits a lobe over a hemisphere while the term integrates a polygon.\n\nAnd two things are written and have never been run: WebXR against a headset, and an iOS build on a Mac. Those are named in prose rather than listed as gaps, because the section holds gaps only and the code exists. Neither carries a sentinel, since a sentinel guards a symbol that would exist if the work had landed and both symbols are already here."
358
374
  }
359
375
  ]
360
376
  },
@@ -402,22 +418,22 @@
402
418
  {
403
419
  "kind": "added",
404
420
  "short": "`navigationBridge` gives an agent a `navigate@1` tool whose guard runs the search, so a destination stranded between a model proposing it and the agent acting on it is discarded.",
405
- "text": "**The first tool in this package whose `admits` says something.** A buffered intent is a proposal authored against one snapshot and executed later — the README calls it \"a proposal, never a decision\" — and until now the strongest guard a consumer could write was \"the entity still exists\". A bridge that dropped, a door that closed or a region that streamed out makes a destination unreachable in between, and navigation is the first subsystem here that can say so.\n\n**A factory over a three-function adapter**, because the engine owns the graph, the A* and the follower while the consumer owns what an agent is. `tools/registry.ts` says a guard is \"the consumer's sentence, written against the consumer's world\", and that stays true: `graphOf`, `positionOf` and `pathOf` are theirs and everything above them is the bridge's.\n\n**It costs three searches for an intent that is accepted and applied**, and the third was a correction found by wiring the demo rather than by thinking: `take` guards the buffered intent, `applyCommand` guards it again on the way in — which `apply.ts` argues for at length, because those are two different moments and a snapshot is never authority — and `execute` runs a third. Caching a route between any two of them would key it on nothing stable, since the world changing between them is the entire reason the guard exists.\n\n`reachableBy` asks the same question without acting, which is what a policy scoring a destination needs and what `route` could not give it."
421
+ "text": "**The first tool in this package whose `admits` says something.** A buffered intent is a proposal authored against one snapshot and executed later \u2014 the README calls it \"a proposal, never a decision\" \u2014 and until now the strongest guard a consumer could write was \"the entity still exists\". A bridge that dropped, a door that closed or a region that streamed out makes a destination unreachable in between, and navigation is the first subsystem here that can say so.\n\n**A factory over a three-function adapter**, because the engine owns the graph, the A* and the follower while the consumer owns what an agent is. `tools/registry.ts` says a guard is \"the consumer's sentence, written against the consumer's world\", and that stays true: `graphOf`, `positionOf` and `pathOf` are theirs and everything above them is the bridge's.\n\n**It costs three searches for an intent that is accepted and applied**, and the third was a correction found by wiring the demo rather than by thinking: `take` guards the buffered intent, `applyCommand` guards it again on the way in \u2014 which `apply.ts` argues for at length, because those are two different moments and a snapshot is never authority \u2014 and `execute` runs a third. Caching a route between any two of them would key it on nothing stable, since the world changing between them is the entire reason the guard exists.\n\n`reachableBy` asks the same question without acting, which is what a policy scoring a destination needs and what `route` could not give it."
406
422
  },
407
423
  {
408
424
  "kind": "added",
409
425
  "short": "`AuthoritativeAgent` takes an agent's model decisions on one peer and replays them everywhere else. A participant never reaches a provider, and a rewind issues nothing.",
410
- "text": "**A model is not a function of the simulation**: it is slow, variable and metered, so two peers running the same agent loop over the same world do not agree. A decision is taken on the authority and reaches a participant as a fact it does not re-take.\n\n**The design got smaller by reading what was already here.** Its spec proposed replicating a `u16` intent id on Track J's input path, since `inputLog.ts` is fixed-width per participant per tick and refuses variable-length payloads in writing. But `CommandLog` already records what a model decided and when it crossed, and `ReplaySession` already reads that log, recomputes floor intents and **calls no provider, ever**. So a participant is a replay whose log arrives over a network instead of from a recording, and a rewind is a replay of the same log: one mechanism, two reasons for reaching it, and no second implementation.\n\nWhat is left is the part that is genuinely about networking. Which side decides — asked every tick through a function, because authority can move and a wrapper that cached the answer would keep deciding after it stopped being allowed to. Getting decisions onto the wire, read back out of the log so what crosses is exactly what a replay would see. And **reporting a decision that lands for a tick already run**, without acting on it: performing a rewind from inside an agent would be an AI package deciding when a whole simulation goes backwards.\n\nThe rollback rule is Track J's own argument applied here. `network.write` stays outside `DETERMINISTIC_EFFECTS` because of the rewind loop rather than the send, since a `@deterministic` function is precisely the kind that gets re-run. An inference call is not idempotent either, and unlike a send it is billed."
426
+ "text": "**A model is not a function of the simulation**: it is slow, variable and metered, so two peers running the same agent loop over the same world do not agree. A decision is taken on the authority and reaches a participant as a fact it does not re-take.\n\n**The design got smaller by reading what was already here.** Its spec proposed replicating a `u16` intent id on Track J's input path, since `inputLog.ts` is fixed-width per participant per tick and refuses variable-length payloads in writing. But `CommandLog` already records what a model decided and when it crossed, and `ReplaySession` already reads that log, recomputes floor intents and **calls no provider, ever**. So a participant is a replay whose log arrives over a network instead of from a recording, and a rewind is a replay of the same log: one mechanism, two reasons for reaching it, and no second implementation.\n\nWhat is left is the part that is genuinely about networking. Which side decides \u2014 asked every tick through a function, because authority can move and a wrapper that cached the answer would keep deciding after it stopped being allowed to. Getting decisions onto the wire, read back out of the log so what crosses is exactly what a replay would see. And **reporting a decision that lands for a tick already run**, without acting on it: performing a rewind from inside an agent would be an AI package deciding when a whole simulation goes backwards.\n\nThe rollback rule is Track J's own argument applied here. `network.write` stays outside `DETERMINISTIC_EFFECTS` because of the rewind loop rather than the send, since a `@deterministic` function is precisely the kind that gets re-run. An inference call is not idempotent either, and unlike a send it is billed."
411
427
  },
412
428
  {
413
429
  "kind": "added",
414
430
  "short": "Four `drift/ai` capabilities reach both bridges from a script: `reachable`, `navigate`, `path` and `deciding`. No language release, and none duplicates `drift/navigation`.",
415
- "text": "`drift/ai` was already specified at DriftScript 1.13.0 and already bound with five capabilities. The language specifies modules and the host describes capabilities, so this is a binding and a regenerated snapshot.\n\n**`drift/navigation` binds twelve and every one takes a `NavPath` or a `NavGraph`**, so what a script could not do was aim any of them at an `Agent`, which is a session keyed by an id the consumer registered rather than an entity. `path` is the join: one capability makes all twelve work on an agent instead of any being bound a second time. It is not a second `navigation.path`, which takes an ECS entity — same output, different door.\n\n**Every effect is inherited from a decision already made.** `reachable` is `navigation.read` and is the guard as a question, which matters because `route` computes *and writes* and a script scoring a destination had to route and undo. `navigate` is `navigation.write` and deterministic on the argument 1.12.0 made when it admitted that effect: a route is a function of the graph and two endpoints, and ties break on the node index. `deciding` is `network.read` on the narrowing `drift/network.authority` already uses, that a role cannot vary with packet timing.\n\n**`ai.navigate` runs on every peer and that does not contradict `deciding`.** The authority rule is about *model*-authored intents; a script's own command is a function of the simulation, so every peer computes the same route and it replicates itself by being re-run.\n\n**Nothing hands a script the tools or arguments a model chose.** `intentId` is bound and safe because the floor put it there and a replay recomputes it; a capability returning the model's action would let a `@deterministic` system branch on a provider's answer and take the other branch on replay. A host test asserts that absence."
431
+ "text": "`drift/ai` was already specified at DriftScript 1.13.0 and already bound with five capabilities. The language specifies modules and the host describes capabilities, so this is a binding and a regenerated snapshot.\n\n**`drift/navigation` binds twelve and every one takes a `NavPath` or a `NavGraph`**, so what a script could not do was aim any of them at an `Agent`, which is a session keyed by an id the consumer registered rather than an entity. `path` is the join: one capability makes all twelve work on an agent instead of any being bound a second time. It is not a second `navigation.path`, which takes an ECS entity \u2014 same output, different door.\n\n**Every effect is inherited from a decision already made.** `reachable` is `navigation.read` and is the guard as a question, which matters because `route` computes *and writes* and a script scoring a destination had to route and undo. `navigate` is `navigation.write` and deterministic on the argument 1.12.0 made when it admitted that effect: a route is a function of the graph and two endpoints, and ties break on the node index. `deciding` is `network.read` on the narrowing `drift/network.authority` already uses, that a role cannot vary with packet timing.\n\n**`ai.navigate` runs on every peer and that does not contradict `deciding`.** The authority rule is about *model*-authored intents; a script's own command is a function of the simulation, so every peer computes the same route and it replicates itself by being re-run.\n\n**Nothing hands a script the tools or arguments a model chose.** `intentId` is bound and safe because the floor put it there and a replay recomputes it; a capability returning the model's action would let a `@deterministic` system branch on a provider's answer and take the other branch on replay. A host test asserts that absence."
416
432
  },
417
433
  {
418
434
  "kind": "changed",
419
435
  "short": "Both bridges were refused in writing for reasons that stopped being true on 2026-09-03. The sentinels stayed armed and correct while the prose above them went stale.",
420
- "text": "`CAPABILITIES.md` said there was no navigation bridge because \"nothing pathfinds\" and no network authority because \"Track J is not built\". Navigation shipped on 2026-08-28 and Track J on 2026-09-03. The sentinels underneath were still armed and still correct, so the mechanism worked and the document around it did not — the same failure `drift/render` carried for five releases.\n\nThe exports are named `navigationBridge`, `AI_NETWORK_AUTHORITY` and `replicateDecision` **deliberately, to match the sentinel patterns**. A bridge named around them would have landed the capability and left both guards quiet, which is worse than stale prose: the guard built to catch exactly this would have been stepped around rather than removed. Both sentinels are deleted in the commit that rewrites the row.\n\n`npm run check:ai-bridges` is the exit test: twelve assertions over a page running an authority, a participant, a graph cut mid-run and a rewind. The first two establish that decisions were taken and moved the world, because every other claim is satisfied by a run in which no agent asked for anything.\n\n**One claim is written and has not been run**: the same page against a real remote provider, which needs a proxy URL rather than a key since this package holds no credential. The check prints `NOT RUN` for it rather than skipping quietly, and no document says it passed."
436
+ "text": "`CAPABILITIES.md` said there was no navigation bridge because \"nothing pathfinds\" and no network authority because \"Track J is not built\". Navigation shipped on 2026-08-28 and Track J on 2026-09-03. The sentinels underneath were still armed and still correct, so the mechanism worked and the document around it did not \u2014 the same failure `drift/render` carried for five releases.\n\nThe exports are named `navigationBridge`, `AI_NETWORK_AUTHORITY` and `replicateDecision` **deliberately, to match the sentinel patterns**. A bridge named around them would have landed the capability and left both guards quiet, which is worse than stale prose: the guard built to catch exactly this would have been stepped around rather than removed. Both sentinels are deleted in the commit that rewrites the row.\n\n`npm run check:ai-bridges` is the exit test: twelve assertions over a page running an authority, a participant, a graph cut mid-run and a rewind. The first two establish that decisions were taken and moved the world, because every other claim is satisfied by a run in which no agent asked for anything.\n\n**One claim is written and has not been run**: the same page against a real remote provider, which needs a proxy URL rather than a key since this package holds no credential. The check prints `NOT RUN` for it rather than skipping quietly, and no document says it passed."
421
437
  }
422
438
  ]
423
439
  },
@@ -428,7 +444,7 @@
428
444
  {
429
445
  "kind": "added",
430
446
  "short": "Islands solve on worker threads when a consumer asks: `new PhysicsWorld({ workers: 4, pool: createIslandPool })`. Measured 1.39x at eight workers, and serial with a reason wherever it cannot start.",
431
- "text": "**A worker pool behind the island executor's seam, which closes Track B.** No two islands share a body, so solving them in any order or all at once gives the same bits; the seam has been there since islands landed and `ShuffledExecutor` has been asserting that property since. What kept a pool out was never the engine.\n\nIslands are claimed from an `Atomics.add` cursor, which balances load and costs nothing in determinism precisely because the islands are disjoint. Workers park on `Atomics.wait`. The caller claims from the same cursor and then joins, blocking where it may and spinning where it may not, decided by trying `Atomics.wait` and catching rather than by sniffing for `window` — so what is left to wait for is the imbalance and not the solve.\n\n**`pool` is passed in rather than imported by `PhysicsWorld`, for a measured reason.** A world that reached `createIslandPool` on its own put 2,629 bytes gzipped into every consumer's bundle, including everyone who never asks for a worker. Naming it at the call site costs one import line and keeps the package's promise that you pay only for what you import.\n\n**Read `parallelism` instead of assuming the option took.** Every path that does not start a pool sets a whole sentence saying why: the page is not cross-origin isolated, the bundler could not build the worker entry, no pool was passed. `running: 0` with correct serial physics is the design working. A world with a pool must be `dispose`d, which is a new method, because its workers hold the staging buffer and every body's state with it.\n\n**Measured on a 24-core desktop, 256 islands of ten boxes**: 1.05x, 1.22x, 1.26x and 1.39x at one, two, four and eight workers. `executor.ts` predicted 2x to 4x from Amdahl and the solve share; the staging copy accounts for 1.7% of the gap and nothing has measured the rest, so nothing claims a cause. Two bounds hold and both belong to the scene: Amdahl's, and `total island cost / largest island cost`, which is why a hundred jointed boxes gain nothing at any worker count.\n\n**No `drift/*` binding, as a decision.** A pool needs headers on the consumer's document, so whether one can exist is settled before any script runs. A capability like `physics.workers(4)` would work on an isolated page and silently do nothing everywhere else, which is the blind surface the 3.53.0 audit removed fifty-seven of."
447
+ "text": "**A worker pool behind the island executor's seam, which closes Track B.** No two islands share a body, so solving them in any order or all at once gives the same bits; the seam has been there since islands landed and `ShuffledExecutor` has been asserting that property since. What kept a pool out was never the engine.\n\nIslands are claimed from an `Atomics.add` cursor, which balances load and costs nothing in determinism precisely because the islands are disjoint. Workers park on `Atomics.wait`. The caller claims from the same cursor and then joins, blocking where it may and spinning where it may not, decided by trying `Atomics.wait` and catching rather than by sniffing for `window` \u2014 so what is left to wait for is the imbalance and not the solve.\n\n**`pool` is passed in rather than imported by `PhysicsWorld`, for a measured reason.** A world that reached `createIslandPool` on its own put 2,629 bytes gzipped into every consumer's bundle, including everyone who never asks for a worker. Naming it at the call site costs one import line and keeps the package's promise that you pay only for what you import.\n\n**Read `parallelism` instead of assuming the option took.** Every path that does not start a pool sets a whole sentence saying why: the page is not cross-origin isolated, the bundler could not build the worker entry, no pool was passed. `running: 0` with correct serial physics is the design working. A world with a pool must be `dispose`d, which is a new method, because its workers hold the staging buffer and every body's state with it.\n\n**Measured on a 24-core desktop, 256 islands of ten boxes**: 1.05x, 1.22x, 1.26x and 1.39x at one, two, four and eight workers. `executor.ts` predicted 2x to 4x from Amdahl and the solve share; the staging copy accounts for 1.7% of the gap and nothing has measured the rest, so nothing claims a cause. Two bounds hold and both belong to the scene: Amdahl's, and `total island cost / largest island cost`, which is why a hundred jointed boxes gain nothing at any worker count.\n\n**No `drift/*` binding, as a decision.** A pool needs headers on the consumer's document, so whether one can exist is settled before any script runs. A capability like `physics.workers(4)` would work on an isolated page and silently do nothing everywhere else, which is the blind surface the 3.53.0 audit removed fifty-seven of."
432
448
  },
433
449
  {
434
450
  "kind": "changed",
@@ -443,7 +459,7 @@
443
459
  {
444
460
  "kind": "fixed",
445
461
  "short": "`npm run test:scripts` ran none of the package tests more than one directory deep. Eleven already-written, already-passing tests had never run under it.",
446
- "text": "The script passed `packages/*/src/**/*.test.mjs` unquoted, and POSIX `sh` expands `**` as `*`, so only files exactly one directory below `src` were ever reached. `packages/ai/src/package.test.mjs` and `packages/package/src/cli.test.mjs` had never run under this command. Quoted, Node expands the pattern itself: 197 tests to 214, all green.\n\nAlso re-measured `physics-only`, which was 2.7% above its floor on `main` before this branch and had never tripped the 3% gate — the failure the floors file's own header describes, hit for the second time on the same entry."
462
+ "text": "The script passed `packages/*/src/**/*.test.mjs` unquoted, and POSIX `sh` expands `**` as `*`, so only files exactly one directory below `src` were ever reached. `packages/ai/src/package.test.mjs` and `packages/package/src/cli.test.mjs` had never run under this command. Quoted, Node expands the pattern itself: 197 tests to 214, all green.\n\nAlso re-measured `physics-only`, which was 2.7% above its floor on `main` before this branch and had never tripped the 3% gate \u2014 the failure the floors file's own header describes, hit for the second time on the same entry."
447
463
  }
448
464
  ]
449
465
  },
@@ -454,12 +470,12 @@
454
470
  {
455
471
  "kind": "added",
456
472
  "short": "A script drives six per-frame presentation dials through `drift/render`: bloom, exposure, motion blur, speed blur, focus and the frame veil. The linker had refused the module by name since 1.0.",
457
- "text": "**`drift/render` is bound, and it had been free to bind for five releases.** The linker's refusal named Track D; Track D completed on 2026-09-03 with refraction, and nothing re-read the mapping when it closed. The capability map still named the track while `host.test.ts` had already been corrected to say the surface waited on a row of its own.\n\n**It binds the dials and none of the profile.** `RenderQuality` has forty-eight fields and no script can reach one, on the ceiling-and-dial split the engine already argues for depth of field: a ceiling says how far a defocused point may spread and therefore what the pass may cost, and the dial says where this frame's lens is focused and how much of that ceiling it takes. A profile is chosen once from what a device can afford and is clamped against what the adapter reports, so a script writing to one would be overruling a decision made about hardware it cannot see, and two machines running the same script would draw different pictures for a reason the script did not choose.\n\n**Nothing reads a dial back, and that is the same argument.** Each is clamped against the ceiling — `setBloom` does nothing at all when the profile's `bloom` is 0, because the chain is never built — so a read would answer a fact about the machine wearing the costume of a fact about the frame. The language wrote this once already, for `network.read` and a confirmed-input watermark.\n\n**The effect is `scene.write` and it needed no language release.** That effect is outside `DETERMINISTIC_EFFECTS` because a node is what draws and moving one is a change to the view; the renderers' own comments call these a renderer parameter rather than a scene one, describing how the finished image is presented and not anything in the world. So a `@deterministic` system may not dim the screen, which is the property rather than a limitation.\n\n`speedBlur` is the one capability whose name is not the engine's own: the method is `setSpeedRush`, and a rush is a thing that happens in somebody's game rather than a description of an image operation."
473
+ "text": "**`drift/render` is bound, and it had been free to bind for five releases.** The linker's refusal named Track D; Track D completed on 2026-09-03 with refraction, and nothing re-read the mapping when it closed. The capability map still named the track while `host.test.ts` had already been corrected to say the surface waited on a row of its own.\n\n**It binds the dials and none of the profile.** `RenderQuality` has forty-eight fields and no script can reach one, on the ceiling-and-dial split the engine already argues for depth of field: a ceiling says how far a defocused point may spread and therefore what the pass may cost, and the dial says where this frame's lens is focused and how much of that ceiling it takes. A profile is chosen once from what a device can afford and is clamped against what the adapter reports, so a script writing to one would be overruling a decision made about hardware it cannot see, and two machines running the same script would draw different pictures for a reason the script did not choose.\n\n**Nothing reads a dial back, and that is the same argument.** Each is clamped against the ceiling \u2014 `setBloom` does nothing at all when the profile's `bloom` is 0, because the chain is never built \u2014 so a read would answer a fact about the machine wearing the costume of a fact about the frame. The language wrote this once already, for `network.read` and a confirmed-input watermark.\n\n**The effect is `scene.write` and it needed no language release.** That effect is outside `DETERMINISTIC_EFFECTS` because a node is what draws and moving one is a change to the view; the renderers' own comments call these a renderer parameter rather than a scene one, describing how the finished image is presented and not anything in the world. So a `@deterministic` system may not dim the screen, which is the property rather than a limitation.\n\n`speedBlur` is the one capability whose name is not the engine's own: the method is `setSpeedRush`, and a rush is a thing that happens in somebody's game rather than a description of an image operation."
458
474
  },
459
475
  {
460
476
  "kind": "changed",
461
477
  "short": "All twenty-nine checks were audited by deleting the term each measures. Fifty-seven claims across eighteen scripts could not see their own subject removed.",
462
- "text": "**A green check is not evidence that it can go red.** Twice on 2026-09-04 one was measuring nothing: an area-light capture would have passed with the whole specular term deleted, and the claim written to replace it passed with the term multiplied by 0.02. This is the generalisation, run against every script that asserts.\n\n**Two classes of blind claim came out of it, neither visible from reading the source.** A *parity* claim — \"the backends agree\" — passes when both agree on nothing, since a difference of zero is within any tolerance; three scripts wrote that case out explicitly, giving a spread of zero for two blank frames. A shared regression is the one failure a cross-backend claim is uniquely able to catch, and it was the one it could not see. A *shape* claim — \"glows evenly\", \"rises without stepping back\", \"the frame is identical pixel for pixel\" — passes when the shape is absent, because nothing is perfectly even and nothing steps back if nothing steps.\n\n**Seven scripts already had the guard written down and inert.** One says above four claims that if the two cones were drawn identically then every comparison further down would pass while measuring nothing, and then compares anyway. One says its cross-backend pair agree on a count of nothing trivially. One carries the sentence that it is the scene that is order-independent rather than an empty frame, over a claim nothing rested on — and seven of its nine claims survived, which is the exact failure another script's header records as having shipped once. In every case the repair is to make the premise a value and let the claims rest on it.\n\nNine scripts were already sound, and their claims caught the mutation on the term they name. One asserts nothing and is renamed rather than repaired.\n\n**`scripts/claimAudit.sh` runs the chain and `scripts/claimCensus.mjs` counts the shapes.** The harness restarts the dev server for all three runs, which is what makes the rest possible rather than a precaution: a vite server that has been up across an edit serves a stale transform, and the identical mutation read twelve-of-twelve green on one and five-of-twelve on a fresh one. Three other ways a run can be meaningless are recorded in its header, each found by making the mistake: a mutation aimed at a term the check does not measure, a mutation that replaces the output rather than removing it, and a regeneration that leaves one backend whole.\n\n**One script needed a different repair from every other.** The ORM check's roughness claim compared a column against itself, and the comment three lines above it already said why that cannot work: the column still shows a smooth gradient, because a lit sphere always does. It reads the difference from the same column drawn without a map now, which is the quantity the map is responsible for — a swing of 130.9 with the map in, and exactly zero without it, because the two frames are the same frame. Its rough-metal claim passed because a non-metal is not black either, and now requires the map to have changed that cell first. Neither was missing a premise; both were reading the wrong quantity."
478
+ "text": "**A green check is not evidence that it can go red.** Twice on 2026-09-04 one was measuring nothing: an area-light capture would have passed with the whole specular term deleted, and the claim written to replace it passed with the term multiplied by 0.02. This is the generalisation, run against every script that asserts.\n\n**Two classes of blind claim came out of it, neither visible from reading the source.** A *parity* claim \u2014 \"the backends agree\" \u2014 passes when both agree on nothing, since a difference of zero is within any tolerance; three scripts wrote that case out explicitly, giving a spread of zero for two blank frames. A shared regression is the one failure a cross-backend claim is uniquely able to catch, and it was the one it could not see. A *shape* claim \u2014 \"glows evenly\", \"rises without stepping back\", \"the frame is identical pixel for pixel\" \u2014 passes when the shape is absent, because nothing is perfectly even and nothing steps back if nothing steps.\n\n**Seven scripts already had the guard written down and inert.** One says above four claims that if the two cones were drawn identically then every comparison further down would pass while measuring nothing, and then compares anyway. One says its cross-backend pair agree on a count of nothing trivially. One carries the sentence that it is the scene that is order-independent rather than an empty frame, over a claim nothing rested on \u2014 and seven of its nine claims survived, which is the exact failure another script's header records as having shipped once. In every case the repair is to make the premise a value and let the claims rest on it.\n\nNine scripts were already sound, and their claims caught the mutation on the term they name. One asserts nothing and is renamed rather than repaired.\n\n**`scripts/claimAudit.sh` runs the chain and `scripts/claimCensus.mjs` counts the shapes.** The harness restarts the dev server for all three runs, which is what makes the rest possible rather than a precaution: a vite server that has been up across an edit serves a stale transform, and the identical mutation read twelve-of-twelve green on one and five-of-twelve on a fresh one. Three other ways a run can be meaningless are recorded in its header, each found by making the mistake: a mutation aimed at a term the check does not measure, a mutation that replaces the output rather than removing it, and a regeneration that leaves one backend whole.\n\n**One script needed a different repair from every other.** The ORM check's roughness claim compared a column against itself, and the comment three lines above it already said why that cannot work: the column still shows a smooth gradient, because a lit sphere always does. It reads the difference from the same column drawn without a map now, which is the quantity the map is responsible for \u2014 a swing of 130.9 with the map in, and exactly zero without it, because the two frames are the same frame. Its rough-metal claim passed because a non-metal is not black either, and now requires the map to have changed that cell first. Neither was missing a premise; both were reading the wrong quantity."
463
479
  },
464
480
  {
465
481
  "kind": "changed",
@@ -486,7 +502,7 @@
486
502
  {
487
503
  "kind": "fixed",
488
504
  "short": "Drawing a recorded scene into a mirror or a probe binds each material once for the run of draws that share it, so a busy frame stops losing geometry to the material budget.",
489
- "text": "**A caster sink's material is optional, and omitting it meant *no material* rather than *unchanged*.** So a run of entries sharing one material had no shorter spelling and `drawSceneCasters` bound one per entry.\n\nOn WebGPU that is a cliff rather than a slope. A bind dirties the material slot, so a bind per entry spends a slot **per draw** out of the 1,024 a frame holds — and past the ring the draws asking for the rest are skipped rather than mispainted, which is a stretch of the world simply not drawn. A consumer measured **82 material binds a model**, because it binds per surface and draws two to four meshes under each, and a reflection replaying the frame shares the ring with the pass that recorded it: eight models on screen took the two together over the ceiling. On WebGL2 the same bind is a `useProgram` and a whole material write for every flat program.\n\nBoth sinks hold the material they bound and skip the bind when the next entry asks for the same one. **Reference identity rather than a deep compare**: a caster list holds the material objects its draws bound, so a run from one surface is literally one object, and two structurally equal materials from different surfaces cost what they cost today.\n\n**The standing material is forgotten at the start of every `drawSceneCasters` call**, because what the pass around it last set is not the sink's to assume — a mirror draws its own water between replays. Carrying it across would paint the next replay with whatever the frame set in between, which is a wrong picture rather than a slow one, and both backends have a test that fails on exactly that.\n\nThe consumer that reported it had written a deduping sink of its own over the public verbs to get this, and can delete it."
505
+ "text": "**A caster sink's material is optional, and omitting it meant *no material* rather than *unchanged*.** So a run of entries sharing one material had no shorter spelling and `drawSceneCasters` bound one per entry.\n\nOn WebGPU that is a cliff rather than a slope. A bind dirties the material slot, so a bind per entry spends a slot **per draw** out of the 1,024 a frame holds \u2014 and past the ring the draws asking for the rest are skipped rather than mispainted, which is a stretch of the world simply not drawn. A consumer measured **82 material binds a model**, because it binds per surface and draws two to four meshes under each, and a reflection replaying the frame shares the ring with the pass that recorded it: eight models on screen took the two together over the ceiling. On WebGL2 the same bind is a `useProgram` and a whole material write for every flat program.\n\nBoth sinks hold the material they bound and skip the bind when the next entry asks for the same one. **Reference identity rather than a deep compare**: a caster list holds the material objects its draws bound, so a run from one surface is literally one object, and two structurally equal materials from different surfaces cost what they cost today.\n\n**The standing material is forgotten at the start of every `drawSceneCasters` call**, because what the pass around it last set is not the sink's to assume \u2014 a mirror draws its own water between replays. Carrying it across would paint the next replay with whatever the frame set in between, which is a wrong picture rather than a slow one, and both backends have a test that fails on exactly that.\n\nThe consumer that reported it had written a deduping sink of its own over the public verbs to get this, and can delete it."
490
506
  }
491
507
  ]
492
508
  },
@@ -497,12 +513,12 @@
497
513
  {
498
514
  "kind": "fixed",
499
515
  "short": "3.51.0 closed every package. Adding exports stops every undeclared path resolving, so a consumer reaching a deep path lost it; the packages that were open are open again.",
500
- "text": "**`exports` does not only route requests, it closes a package**, and 3.51.0's own design called the change *\"additive for anybody who is not Node\"*. That was wrong in a direction nothing in this repository can see: a path not declared in `exports` stops resolving, the workspace symlink keeps resolving it locally, and only a consumer's build fails.\n\nA consumer broke on three paths it had always used — `core/package.json`, `core/CHANGELOG.json`, and a module under `core/src/` — with `TS2307` and a failed `vite build`.\n\nEvery package that declared no `exports` before 3.51.0 now carries `\"./*\": \"./*\"`, which is exactly what the absence of the field used to give. `chemistry` and `script` declared `exports` already and were closed already, so they keep the surface they had. `scripts/packages.test.mjs` asserts it, because the failure is invisible from here.\n\n`scripts/cleanroom.mjs` skips pattern keys now: it was treating `\"./*\"` as an entry point and reporting fifteen failures for specifiers nobody can import."
516
+ "text": "**`exports` does not only route requests, it closes a package**, and 3.51.0's own design called the change *\"additive for anybody who is not Node\"*. That was wrong in a direction nothing in this repository can see: a path not declared in `exports` stops resolving, the workspace symlink keeps resolving it locally, and only a consumer's build fails.\n\nA consumer broke on three paths it had always used \u2014 `core/package.json`, `core/CHANGELOG.json`, and a module under `core/src/` \u2014 with `TS2307` and a failed `vite build`.\n\nEvery package that declared no `exports` before 3.51.0 now carries `\"./*\": \"./*\"`, which is exactly what the absence of the field used to give. `chemistry` and `script` declared `exports` already and were closed already, so they keep the surface they had. `scripts/packages.test.mjs` asserts it, because the failure is invisible from here.\n\n`scripts/cleanroom.mjs` skips pattern keys now: it was treating `\"./*\"` as an entry point and reporting fifteen failures for specifiers nobody can import."
501
517
  },
502
518
  {
503
519
  "kind": "changed",
504
520
  "short": "A project that typechecks against the engine's own source needs allowImportingTsExtensions enabled in its tsconfig. It is legal beside noEmit, which a typecheck-only config already sets.",
505
- "text": "**Porting note, and 3.51.0 should have carried it.** Every relative import inside these packages names `.ts` since 3.51.0. A consumer that resolves `@driftengine/*` to `src/index.ts` — which is what `main` and the `default` condition give a bundler — has the compiler follow into that source, and `tsc` refuses a `.ts` specifier unless the flag is set: `TS5097`.\n\nAdd it to the tsconfig that typechecks:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true } }\n```\n\nIt is legal beside `noEmit` or `emitDeclarationOnly`, which a typecheck-only config already sets. One consumer here needed it in three configs and a fourth already had it, which is why 3.51.0's own consumer check passed on one repository and not the other.\n\nNothing about the runtime changes: a bundler resolves and strips the same source it always did.\n\n**A compile that *emits* from this source needs one flag more**, and this note did not say so until a second consumer path found it: `allowImportingTsExtensions` is legal beside `noEmit`, and legal without it only when `rewriteRelativeImportExtensions` is set too. A build script running `tsc` over a single module of this source to produce JavaScript is not a typecheck and cannot set `noEmit`, so it needs both:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true, \"rewriteRelativeImportExtensions\": true } }\n```\n\nThat is the same pair `npm run build` uses, and it rewrites each specifier as it emits. The failure without it is the same `TS5097`, from a command nobody thinks of as a typecheck."
521
+ "text": "**Porting note, and 3.51.0 should have carried it.** Every relative import inside these packages names `.ts` since 3.51.0. A consumer that resolves `@driftengine/*` to `src/index.ts` \u2014 which is what `main` and the `default` condition give a bundler \u2014 has the compiler follow into that source, and `tsc` refuses a `.ts` specifier unless the flag is set: `TS5097`.\n\nAdd it to the tsconfig that typechecks:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true } }\n```\n\nIt is legal beside `noEmit` or `emitDeclarationOnly`, which a typecheck-only config already sets. One consumer here needed it in three configs and a fourth already had it, which is why 3.51.0's own consumer check passed on one repository and not the other.\n\nNothing about the runtime changes: a bundler resolves and strips the same source it always did.\n\n**A compile that *emits* from this source needs one flag more**, and this note did not say so until a second consumer path found it: `allowImportingTsExtensions` is legal beside `noEmit`, and legal without it only when `rewriteRelativeImportExtensions` is set too. A build script running `tsc` over a single module of this source to produce JavaScript is not a typecheck and cannot set `noEmit`, so it needs both:\n\n```jsonc\n{ \"compilerOptions\": { \"allowImportingTsExtensions\": true, \"rewriteRelativeImportExtensions\": true } }\n```\n\nThat is the same pair `npm run build` uses, and it rewrites each specifier as it emits. The failure without it is the same `TS5097`, from a command nobody thinks of as a typecheck."
506
522
  }
507
523
  ]
508
524
  },
@@ -513,12 +529,12 @@
513
529
  {
514
530
  "kind": "added",
515
531
  "short": "These packages load from Node without a bundler. npm run build emits JavaScript, exports sends Node to it, and a bundler still reads src so an edit stays live in a consumer.",
516
- "text": "**Node could not load this engine at all, and the reason was never the specifiers.** The packages ship TypeScript, `main` points at `src/index.ts`, and a bundler resolves it — but Node refuses to strip types for any file under `node_modules`, categorically: `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, fired on the package entry before a single relative import is reached. So adding extensions would have fixed nothing on its own, and a consumer whose importer or measurement script needed a package from Node ran esbuild first and maintained a resolver mapping every package to its barrel. One consumer had five scripts over a shared resolver whose own comment recorded that the package list had drifted out of step with their bundler config for long enough that nobody could say when.\n\n**`npm run build` emits `dist` with `tsc` and `rewriteRelativeImportExtensions`** — a type strip with no code generation anywhere in it, so the shipped JavaScript is the source with its types removed, line for line. Nothing is bundled, minified or reordered, and no source map ships because there is nothing a map could explain that the file does not.\n\n**`exports` carries both readers at once, and that is measured rather than assumed.** `node` resolves `dist`, `default` resolves `src`: Node takes the build, a browser bundler takes the source. So an edit in this repository is still live in a consumer the moment it is saved, which is the property that made the package split affordable in the first place.\n\n**A private `drift-source` condition keeps this repository reading its own source.** Packages import each other by name, and vitest, `tsx` and `tsc` all run in Node — so without it the suite would test a `dist` nobody had built. Measured before it existed: a test importing a sibling failed with *\"Failed to resolve entry for package @driftengine/entities\"*. It is first in every conditions object and needs `customConditions` in the root tsconfig, which is one decision written in two files.\n\nEvery relative import inside the packages now names `.ts`, which finishes a migration rather than starting one: `chemistry`, `ai` and `entities` already did and nothing else did. Nothing is published — these packages stay private, and what changed is that a `file:` consumer can load them from Node."
532
+ "text": "**Node could not load this engine at all, and the reason was never the specifiers.** The packages ship TypeScript, `main` points at `src/index.ts`, and a bundler resolves it \u2014 but Node refuses to strip types for any file under `node_modules`, categorically: `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, fired on the package entry before a single relative import is reached. So adding extensions would have fixed nothing on its own, and a consumer whose importer or measurement script needed a package from Node ran esbuild first and maintained a resolver mapping every package to its barrel. One consumer had five scripts over a shared resolver whose own comment recorded that the package list had drifted out of step with their bundler config for long enough that nobody could say when.\n\n**`npm run build` emits `dist` with `tsc` and `rewriteRelativeImportExtensions`** \u2014 a type strip with no code generation anywhere in it, so the shipped JavaScript is the source with its types removed, line for line. Nothing is bundled, minified or reordered, and no source map ships because there is nothing a map could explain that the file does not.\n\n**`exports` carries both readers at once, and that is measured rather than assumed.** `node` resolves `dist`, `default` resolves `src`: Node takes the build, a browser bundler takes the source. So an edit in this repository is still live in a consumer the moment it is saved, which is the property that made the package split affordable in the first place.\n\n**A private `drift-source` condition keeps this repository reading its own source.** Packages import each other by name, and vitest, `tsx` and `tsc` all run in Node \u2014 so without it the suite would test a `dist` nobody had built. Measured before it existed: a test importing a sibling failed with *\"Failed to resolve entry for package @driftengine/entities\"*. It is first in every conditions object and needs `customConditions` in the root tsconfig, which is one decision written in two files.\n\nEvery relative import inside the packages now names `.ts`, which finishes a migration rather than starting one: `chemistry`, `ai` and `entities` already did and nothing else did. Nothing is published \u2014 these packages stay private, and what changed is that a `file:` consumer can load them from Node."
517
533
  },
518
534
  {
519
535
  "kind": "fixed",
520
536
  "short": "Every package tarball shipped zero built files, because dist is in .gitignore and npm honours it. Nothing inside the workspace could have seen it.",
521
- "text": "**The build worked, the tests passed, and the tarball contained no JavaScript.** `dist/` is in `.gitignore`, npm honours a `.gitignore` when a package declares no `files` and no `.npmignore`, and every package declared neither. `npm pack` on `@driftengine/drft` produced 42 source files and zero built ones.\n\nThis is the defect `npm run cleanroom` exists for, and it found it on its first successful run. Nothing inside the workspace can see this class of fault: a dependency resolves through a symlink whose real path has no `node_modules` segment, so neither Node's refusal to strip types nor a missing `dist` ever fires locally. The clean room packs every package, extracts it into a real `node_modules` outside the checkout, and imports **25 entry points under plain `node`** — the only arrangement in which the answer means anything.\n\nEach package declares `files` now. `driftscript` learned the same lesson by publishing, where five of seven consumption paths failed while all 892 of its tests passed."
537
+ "text": "**The build worked, the tests passed, and the tarball contained no JavaScript.** `dist/` is in `.gitignore`, npm honours a `.gitignore` when a package declares no `files` and no `.npmignore`, and every package declared neither. `npm pack` on `@driftengine/drft` produced 42 source files and zero built ones.\n\nThis is the defect `npm run cleanroom` exists for, and it found it on its first successful run. Nothing inside the workspace can see this class of fault: a dependency resolves through a symlink whose real path has no `node_modules` segment, so neither Node's refusal to strip types nor a missing `dist` ever fires locally. The clean room packs every package, extracts it into a real `node_modules` outside the checkout, and imports **25 entry points under plain `node`** \u2014 the only arrangement in which the answer means anything.\n\nEach package declares `files` now. `driftscript` learned the same lesson by publishing, where five of seven consumption paths failed while all 892 of its tests passed."
522
538
  }
523
539
  ]
524
540
  },
@@ -529,12 +545,12 @@
529
545
  {
530
546
  "kind": "added",
531
547
  "short": "Convex decomposition, offline: decomposeConvex turns a triangle mesh into hulls, BodyDesc.shapes makes them one rigid body, and COLL carries them out of a bake.",
532
- "text": "**A concave thing could not move.** `addBody` refused a triangle mesh on anything but a static body and told the caller to use convex decomposition into hulls, *\"which the runtime already accepts\"* — and `BodySet.shape` held one shape per body, so a decomposed body had nowhere to go. The sentence was true of the kinematic sweep, which has taken `Body.parts` all along, and false of the dynamics it was written in. This row is the three pieces that make it true.\n\n**`decomposeConvex` is offline and works on a grid rather than on the triangles.** A collision mesh is a triangle soup: routinely not watertight, routinely self-intersecting, routinely missing a face nobody would see. Every method that reasons over the source has to decide what the inside of such a thing is and gets it wrong differently for each mesh; a flood fill from outside answers it once, the same way, for any input, at a cost set by the resolution rather than by the triangle count. It claims maximal boxes of solid cells, merges them, and returns at most 32 hulls of at most 50 points — inside `hullShape`'s cap of 64.\n\n**The merge is measured two ways, and that is the design.** Reaching the part budget takes thousands of merges, ordered by how empty a merged bounding box would be, which is six integer compares. Going below it takes a handful, and each asks the real question: how much of the merged hull is not solid. A box cannot tell a convex solid from a cavity, and three attempts to make it were each wrong differently — a fixed threshold left a convex prism as eight parts, because a cylinder's box is 21.5% empty however it is cut; scoring the change against the parents' sum gave negative costs that always pass and collapsed a cup to one hull at 403% bloat; anchoring against the emptier parent let each merge license the next and reached 57%. A real hull costs 1.09 ms at fifty points, and it is affordable at that count because a region is always a union of boxes, so its support set is exact from its seeds' corners.\n\n**Greedy merging cannot finish a slanted convex solid either.** A tetrahedron came back as sixteen parts at 20.3% bloat: every individual merge across a voxel staircase leaves a notch, while merging all of them gives the tetrahedron back exactly. The whole solid is tested for convexity once, before anything is merged, for the cost of one hull — and a crate, a wedge, a rock and a wheel are all that case.\n\n**`BodyDesc.shapes` is where the hulls go.** A part carries no offset and no rotation, because a `ConvexShape` is already a point cloud in the body's frame. Mass is `combineMassProperties`: volumes added, the centre volume-weighted, each tensor moved by the parallel axis theorem. Contacts are one manifold per touching part, in a set sized to `MAX_BODY_PARTS` so a compound against a single convex shape can never overflow, with the part pair folded into every feature id. A compound against a static mesh shares the budget between the parts that find candidates, never below one each.\n\n**`COLL` is defined at container 1.12.** The FourCC was claimed in v1 and had no payload, no reader and no writer for the whole of it, because nobody had decided whether a baked hull is triangle soup or a set of convex hulls. It is a set of convex hulls, carried as points, since `@driftengine/drft` depends on nothing. `drft bake --collider hull[:N] | box | none` writes it, and prefers a vehicle folder's `collider.kn5` over the model's own geometry.\n\nMeasured by `npm run check:decompose`: a convex prism returns one part, an L-beam two at zero bloat, a cup 12 to 14 with its cavity open, a staircase 4 to 6. A torus is the limit at 27.0% summed and 16.2% occupied, and the budget is not what binds it. `drft-only` rises 4,847 to 5,146 bytes; `physics-only` holds."
548
+ "text": "**A concave thing could not move.** `addBody` refused a triangle mesh on anything but a static body and told the caller to use convex decomposition into hulls, *\"which the runtime already accepts\"* \u2014 and `BodySet.shape` held one shape per body, so a decomposed body had nowhere to go. The sentence was true of the kinematic sweep, which has taken `Body.parts` all along, and false of the dynamics it was written in. This row is the three pieces that make it true.\n\n**`decomposeConvex` is offline and works on a grid rather than on the triangles.** A collision mesh is a triangle soup: routinely not watertight, routinely self-intersecting, routinely missing a face nobody would see. Every method that reasons over the source has to decide what the inside of such a thing is and gets it wrong differently for each mesh; a flood fill from outside answers it once, the same way, for any input, at a cost set by the resolution rather than by the triangle count. It claims maximal boxes of solid cells, merges them, and returns at most 32 hulls of at most 50 points \u2014 inside `hullShape`'s cap of 64.\n\n**The merge is measured two ways, and that is the design.** Reaching the part budget takes thousands of merges, ordered by how empty a merged bounding box would be, which is six integer compares. Going below it takes a handful, and each asks the real question: how much of the merged hull is not solid. A box cannot tell a convex solid from a cavity, and three attempts to make it were each wrong differently \u2014 a fixed threshold left a convex prism as eight parts, because a cylinder's box is 21.5% empty however it is cut; scoring the change against the parents' sum gave negative costs that always pass and collapsed a cup to one hull at 403% bloat; anchoring against the emptier parent let each merge license the next and reached 57%. A real hull costs 1.09 ms at fifty points, and it is affordable at that count because a region is always a union of boxes, so its support set is exact from its seeds' corners.\n\n**Greedy merging cannot finish a slanted convex solid either.** A tetrahedron came back as sixteen parts at 20.3% bloat: every individual merge across a voxel staircase leaves a notch, while merging all of them gives the tetrahedron back exactly. The whole solid is tested for convexity once, before anything is merged, for the cost of one hull \u2014 and a crate, a wedge, a rock and a wheel are all that case.\n\n**`BodyDesc.shapes` is where the hulls go.** A part carries no offset and no rotation, because a `ConvexShape` is already a point cloud in the body's frame. Mass is `combineMassProperties`: volumes added, the centre volume-weighted, each tensor moved by the parallel axis theorem. Contacts are one manifold per touching part, in a set sized to `MAX_BODY_PARTS` so a compound against a single convex shape can never overflow, with the part pair folded into every feature id. A compound against a static mesh shares the budget between the parts that find candidates, never below one each.\n\n**`COLL` is defined at container 1.12.** The FourCC was claimed in v1 and had no payload, no reader and no writer for the whole of it, because nobody had decided whether a baked hull is triangle soup or a set of convex hulls. It is a set of convex hulls, carried as points, since `@driftengine/drft` depends on nothing. `drft bake --collider hull[:N] | box | none` writes it, and prefers a vehicle folder's `collider.kn5` over the model's own geometry.\n\nMeasured by `npm run check:decompose`: a convex prism returns one part, an L-beam two at zero bloat, a cup 12 to 14 with its cavity open, a staircase 4 to 6. A torus is the limit at 27.0% summed and 16.2% occupied, and the budget is not what binds it. `drft-only` rises 4,847 to 5,146 bytes; `physics-only` holds."
533
549
  },
534
550
  {
535
551
  "kind": "fixed",
536
552
  "short": "The voxel fill walked out through a model's own base at four resolutions of eight, because a face lying exactly on a cell boundary was marked into neither of the two cells it touches.",
537
- "text": "**A closed prism of volume 3.106 filled to 3.57, 0.56, 0.47, 3.37, 3.33, 3.31, 0.20 and 0.17** at resolutions 32 through 96 — correct at four of them and the bare shell at the other four, with nothing in the shape to say which.\n\nA face lying exactly in a cell boundary plane is tangent to the cell boxes on both sides, so the plane test compares two quantities that are equal in exact arithmetic and differ by a rounding step in this one. When it fell the wrong way *neither* neighbour was marked, the flood fill walked straight through the gap, and the whole interior came back empty. The fans that close a prism are two such faces, and so is every floor, wall, lid and cap in every model.\n\nThe cell box is grown by one part in a million. That is the safe direction to be wrong in: this marking is conservative by design, so a cell marked that need not be costs a little accuracy at the surface and a cell missed that should be marked is a hole.\n\n**It was visible only because the check sweeps resolutions.** A leak deflates the source volume and the hulls together, so every quality bound held at any single resolution while the fill was empty."
553
+ "text": "**A closed prism of volume 3.106 filled to 3.57, 0.56, 0.47, 3.37, 3.33, 3.31, 0.20 and 0.17** at resolutions 32 through 96 \u2014 correct at four of them and the bare shell at the other four, with nothing in the shape to say which.\n\nA face lying exactly in a cell boundary plane is tangent to the cell boxes on both sides, so the plane test compares two quantities that are equal in exact arithmetic and differ by a rounding step in this one. When it fell the wrong way *neither* neighbour was marked, the flood fill walked straight through the gap, and the whole interior came back empty. The fans that close a prism are two such faces, and so is every floor, wall, lid and cap in every model.\n\nThe cell box is grown by one part in a million. That is the safe direction to be wrong in: this marking is conservative by design, so a cell marked that need not be costs a little accuracy at the surface and a cell missed that should be marked is a hole.\n\n**It was visible only because the check sweeps resolutions.** A leak deflates the source volume and the hulls together, so every quality bound held at any single resolution while the fill was empty."
538
554
  }
539
555
  ]
540
556
  },
@@ -545,12 +561,12 @@
545
561
  {
546
562
  "kind": "added",
547
563
  "short": "Environment probes stand on a grid. ProbeGrid and nearestProbes place them; the probe binding became an octahedral array carrying reflection and ambient, so a grid costs no texture unit.",
548
- "text": "**One environment used to light everything equally, and now a surface takes the light of where it stands.** `ProbeGrid` places probes on a lattice and `nearestProbes` returns the eight around a point with trilinear weights. A single baked probe is a grid of one, so there is one path through the array, the convolution and the shader instead of a grid path and a legacy path that drift.\n\n**It costs no texture unit, and that is the whole design.** Fifteen of WebGL2's guaranteed sixteen are spent and one is left, which is fewer than a grid of cubes could ever want — GLSL ES cannot index a sampler array with a non-constant expression, so a grid of cubes needs a `samplerCube` each and a branch chain over them. The probe's binding became a `TEXTURE_2D_ARRAY` of octahedral maps instead, one layer a probe, which is the answer twelve point-shadow cubemaps already reached for. Levels 0 to 4 hold the GGX chain and level 5 holds a cosine convolution, so one sampler carries the reflection *and* the diffuse ambient, and unit 15 is still free.\n\n**That retired the spherical-harmonic path outright.** `irradianceSh.ts` and the WebGPU readback are gone, with the synchronous `readPixels` in the bake, the nine coefficients, and the second gate uniform that existed only because one backend's ambient landed a frame or two after its reflection. The diffuse term is a fetch now. The risk in that fold is a single factor — irradiance is the integral of L cos, the cosine density is cos/pi, and the lit pass wants radiance, so the convolution is a plain mean and a stray pi would be a room 3.14 times too bright with every direction still in proportion. It is pinned against the closed form.\n\n**A filtered octahedral map needs a gutter, which the shipped one never did.** The point-shadow array is one storage level with `NEAREST`, so no tap crosses the map's border; a prefiltered chain is `LINEAR` and does. The border is a fold rather than an edge, and a gutter texel filled by that rule matches the texel it is folded against to 3.3e-16, against 4.6e-2 for a border without one.\n\n**A grid of one reproduces the probe this engine already had**, which was the row's own stop condition, written before the work because a probe grid changes the pixels of every published scene. Measured on one GPU against the previous release through `ibl-check.mjs`: within about one level of 255 on every figure, with the no-probe control identical.\n\nProbes cost layers rather than units, capped at 64 — 683 KB a layer, 43.7 MB for the lot, against the point-shadow array's 92.3 MB. `ProbeBakeOptions.irradiance` keeps its meaning and narrows from per bake to per grid. **21,495 gzipped bytes on `core-only`, 3.33%**, of which the generated WGSL is 16,502."
564
+ "text": "**One environment used to light everything equally, and now a surface takes the light of where it stands.** `ProbeGrid` places probes on a lattice and `nearestProbes` returns the eight around a point with trilinear weights. A single baked probe is a grid of one, so there is one path through the array, the convolution and the shader instead of a grid path and a legacy path that drift.\n\n**It costs no texture unit, and that is the whole design.** Fifteen of WebGL2's guaranteed sixteen are spent and one is left, which is fewer than a grid of cubes could ever want \u2014 GLSL ES cannot index a sampler array with a non-constant expression, so a grid of cubes needs a `samplerCube` each and a branch chain over them. The probe's binding became a `TEXTURE_2D_ARRAY` of octahedral maps instead, one layer a probe, which is the answer twelve point-shadow cubemaps already reached for. Levels 0 to 4 hold the GGX chain and level 5 holds a cosine convolution, so one sampler carries the reflection *and* the diffuse ambient, and unit 15 is still free.\n\n**That retired the spherical-harmonic path outright.** `irradianceSh.ts` and the WebGPU readback are gone, with the synchronous `readPixels` in the bake, the nine coefficients, and the second gate uniform that existed only because one backend's ambient landed a frame or two after its reflection. The diffuse term is a fetch now. The risk in that fold is a single factor \u2014 irradiance is the integral of L cos, the cosine density is cos/pi, and the lit pass wants radiance, so the convolution is a plain mean and a stray pi would be a room 3.14 times too bright with every direction still in proportion. It is pinned against the closed form.\n\n**A filtered octahedral map needs a gutter, which the shipped one never did.** The point-shadow array is one storage level with `NEAREST`, so no tap crosses the map's border; a prefiltered chain is `LINEAR` and does. The border is a fold rather than an edge, and a gutter texel filled by that rule matches the texel it is folded against to 3.3e-16, against 4.6e-2 for a border without one.\n\n**A grid of one reproduces the probe this engine already had**, which was the row's own stop condition, written before the work because a probe grid changes the pixels of every published scene. Measured on one GPU against the previous release through `ibl-check.mjs`: within about one level of 255 on every figure, with the no-probe control identical.\n\nProbes cost layers rather than units, capped at 64 \u2014 683 KB a layer, 43.7 MB for the lot, against the point-shadow array's 92.3 MB. `ProbeBakeOptions.irradiance` keeps its meaning and narrows from per bake to per grid. **21,495 gzipped bytes on `core-only`, 3.33%**, of which the generated WGSL is 16,502."
549
565
  },
550
566
  {
551
567
  "kind": "fixed",
552
568
  "short": "The octahedral shader chunk had been compiled only into permutations with point shadows since it was written, because an earlier chunk opens a conditional a later one closes.",
553
- "text": "**Its own comment said it was outside every `#if` and it was not.** `LOBES_GLSL` opens a conditional that `DIRECTIONALSHADOW_GLSL` closes, so every chunk between them is inside it — and `octEncode` and `octDecode` sat there, compiled into the eight permutations with point shadows and absent from the other eight.\n\nNothing noticed for as long as it has existed, because the only caller was `pointShadow`, which is inside the same arm. A probe grid calls the octahedral helpers from an arm that has nothing to do with shadows, so glslang refused four permutations with *\"no matching overloaded function found\"* for a function whose definition was in the source.\n\nFound by counting `#if` against `#endif` per chunk rather than by reading them, which is the only way this class of fault is visible at all."
569
+ "text": "**Its own comment said it was outside every `#if` and it was not.** `LOBES_GLSL` opens a conditional that `DIRECTIONALSHADOW_GLSL` closes, so every chunk between them is inside it \u2014 and `octEncode` and `octDecode` sat there, compiled into the eight permutations with point shadows and absent from the other eight.\n\nNothing noticed for as long as it has existed, because the only caller was `pointShadow`, which is inside the same arm. A probe grid calls the octahedral helpers from an arm that has nothing to do with shadows, so glslang refused four permutations with *\"no matching overloaded function found\"* for a function whose definition was in the source.\n\nFound by counting `#if` against `#endif` per chunk rather than by reading them, which is the only way this class of fault is visible at all."
554
570
  }
555
571
  ]
556
572
  },
@@ -561,7 +577,7 @@
561
577
  {
562
578
  "kind": "fixed",
563
579
  "short": "SceneCasterMaterial and SceneCasters are exported from the barrel, so a consumer implementing ShadowCasterSink can name the material its methods are obliged to accept.",
564
- "text": "**One export, and it was missing for two releases.** 3.46.0 widened `ShadowCasterSink.mesh` and `.skinnedMesh` to take a `SceneCasterMaterial` so a caster enumeration could feed a colour pass, declared that type in `render/shadowCasters.ts`, exported it *from that module*, and did not add it to `src/index.ts` — where `ShadowCasterSink` and `ShadowCasters` beside it both were. The barrel is the entire public surface, which `scripts/docs-api.mjs` states in as many words, so a consumer implementing the interface could accept the material and had no way to name it.\n\nThe report that found it did what this repository's rules prescribe rather than reasoning about it: wrote the import, ran the typechecker, and quoted what came back — *Module `\"@driftengine/core\"` has no exported member `SceneCasterMaterial`*. Their workaround was `Parameters<ShadowCasterSink['mesh']>[2]`, which is exact by construction and cannot drift from whatever this engine widens the type to; it cost them a line and a paragraph explaining why it was not an import.\n\n**`SceneCasters` goes with it, for the same reason rather than a different one.** That alias exists to carry the contract `drawSceneCasters` documents itself by pointing at, and a doc comment referring a reader to a name the barrel does not publish is a reference into a private module. The report asked for one export and was right to; leaving the second would have been the next entry.\n\n**Interfaces implemented outwards are the case where a missing export is a defect rather than an omission.** This one's own comments say so twice: `instanced` is optional because consumers' sinks and test doubles satisfy the type, and the material added in 3.46.0 is optional for the same reason. An interface a consumer is expected to implement is only as usable as the names it obliges them to write.\n\nGuarded now rather than noticed again. `render/shadowCasters.test.ts` implements a sink using only names the barrel publishes, and the assertion is that the file compiles — a type that is not exported is a `tsc` error and nothing at all at run time, so `npm run typecheck` is the instrument. It reproduces the reported message on the exact line the report quoted when either export is taken back out."
580
+ "text": "**One export, and it was missing for two releases.** 3.46.0 widened `ShadowCasterSink.mesh` and `.skinnedMesh` to take a `SceneCasterMaterial` so a caster enumeration could feed a colour pass, declared that type in `render/shadowCasters.ts`, exported it *from that module*, and did not add it to `src/index.ts` \u2014 where `ShadowCasterSink` and `ShadowCasters` beside it both were. The barrel is the entire public surface, which `scripts/docs-api.mjs` states in as many words, so a consumer implementing the interface could accept the material and had no way to name it.\n\nThe report that found it did what this repository's rules prescribe rather than reasoning about it: wrote the import, ran the typechecker, and quoted what came back \u2014 *Module `\"@driftengine/core\"` has no exported member `SceneCasterMaterial`*. Their workaround was `Parameters<ShadowCasterSink['mesh']>[2]`, which is exact by construction and cannot drift from whatever this engine widens the type to; it cost them a line and a paragraph explaining why it was not an import.\n\n**`SceneCasters` goes with it, for the same reason rather than a different one.** That alias exists to carry the contract `drawSceneCasters` documents itself by pointing at, and a doc comment referring a reader to a name the barrel does not publish is a reference into a private module. The report asked for one export and was right to; leaving the second would have been the next entry.\n\n**Interfaces implemented outwards are the case where a missing export is a defect rather than an omission.** This one's own comments say so twice: `instanced` is optional because consumers' sinks and test doubles satisfy the type, and the material added in 3.46.0 is optional for the same reason. An interface a consumer is expected to implement is only as usable as the names it obliges them to write.\n\nGuarded now rather than noticed again. `render/shadowCasters.test.ts` implements a sink using only names the barrel publishes, and the assertion is that the file compiles \u2014 a type that is not exported is a `tsc` error and nothing at all at run time, so `npm run typecheck` is the instrument. It reproduces the reported message on the exact line the report quoted when either export is taken back out."
565
581
  }
566
582
  ]
567
583
  },
@@ -577,12 +593,12 @@
577
593
  {
578
594
  "kind": "fixed",
579
595
  "short": "The determinism gate scanned one package while three documents called it engine-wide. Widened to thirteen roots, it found twenty unexempted calls and two defects.",
580
- "text": "**`scripts/determinism.mjs` was written for Track B and walked `packages/physics/src` and nothing else.** Physics complied visibly and chemistry adopted the same discipline by hand, with a reason written at each site. Nothing outside those two was ever checked, while `CAPABILITIES.md`, `AGENTS.md` and the physics README all described the determinism contract as a property of the engine.\n\n**Two of the twenty were defects rather than untidiness.** `packages/script/src/bindings/terrain.ts` returned a slope angle from `Math.acos` to a script, and `drift/terrain` ships under `physics.read`, which is inside the language's `DETERMINISTIC_EFFECTS` — so a `@deterministic` system asking a hillside how steep it was received a number ECMAScript does not specify, and the annotation promising a reproducible replay was false for anything that branched on it. And `packages/terrain/src/heightfield.ts` computed a surface normal with `Math.hypot`, breaking a rule physics documents at five separate sites, in a query a consumer may call every tick.\n\n**The scanner could not see the exponentiation operator**, which is `Math.pow` wearing punctuation, and `joints.ts` used it for a squared length inside the collision kernel the gate exists to protect. It catches `**` now, and strips string literals as well as comments, which the old stripper documented as a known limitation and which widening the scope to `packages/script` made false twice over.\n\n**`math/exact.ts` supplies four transcendentals built from arithmetic and `sqrt` alone** — within one ulp of the platform's and identical on any conforming engine. Three fdlibm coefficients were transcribed a repeated digit short; the accuracy check found them at nineteen ulps *inside* [-π, π], where no range reduction is involved, and golden bit patterns now pin them."
596
+ "text": "**`scripts/determinism.mjs` was written for Track B and walked `packages/physics/src` and nothing else.** Physics complied visibly and chemistry adopted the same discipline by hand, with a reason written at each site. Nothing outside those two was ever checked, while `CAPABILITIES.md`, `AGENTS.md` and the physics README all described the determinism contract as a property of the engine.\n\n**Two of the twenty were defects rather than untidiness.** `packages/script/src/bindings/terrain.ts` returned a slope angle from `Math.acos` to a script, and `drift/terrain` ships under `physics.read`, which is inside the language's `DETERMINISTIC_EFFECTS` \u2014 so a `@deterministic` system asking a hillside how steep it was received a number ECMAScript does not specify, and the annotation promising a reproducible replay was false for anything that branched on it. And `packages/terrain/src/heightfield.ts` computed a surface normal with `Math.hypot`, breaking a rule physics documents at five separate sites, in a query a consumer may call every tick.\n\n**The scanner could not see the exponentiation operator**, which is `Math.pow` wearing punctuation, and `joints.ts` used it for a squared length inside the collision kernel the gate exists to protect. It catches `**` now, and strips string literals as well as comments, which the old stripper documented as a known limitation and which widening the scope to `packages/script` made false twice over.\n\n**`math/exact.ts` supplies four transcendentals built from arithmetic and `sqrt` alone** \u2014 within one ulp of the platform's and identical on any conforming engine. Three fdlibm coefficients were transcribed a repeated digit short; the accuracy check found them at nineteen ulps *inside* [-\u03c0, \u03c0], where no range reduction is involved, and golden bit patterns now pin them."
581
597
  },
582
598
  {
583
599
  "kind": "changed",
584
600
  "short": "startLoop numbers its ticks and hands the number to simulate, mulberry32 gains a savable position, and every size floor was re-measured after all twenty-two were found stale.",
585
- "text": "**The loop knew the fixed-step count and kept it**, so anything needing one — a recording, a replay, anything networked — kept a second counter beside `simulate` and trusted the two to stay in step. `simulate(dt, tick)` takes it as an additive second argument and `LoopOptions.startTick` is where the count begins, for a participant joining a session in progress. A pause holds the tick, which is the property the dropped accumulator already had, read from the other side.\n\n**`savableMulberry32` exposes the position `mulberry32` closes over**, as one integer, so it stores beside a tick in a snapshot. Its sequence is asserted draw-for-draw against `mulberry32` over six seeds, because `AGENTS.md` makes that sequence a contract about every stored ghost and replay.\n\n**Every size floor was stale and none tripped the 3% gate.** The drift ranged from 3 bytes to 3,194, and seven package READMEs plus seven root-table rows were quoting numbers that read low under a sentence claiming to be a fact about the build. `@driftengine/terrain` said 0.8 KB and measures 1.4 — a figure `ROADMAP.md` has had right since the day terrain shipped. The drift was attributed by A/B rather than apportioned: `core-only` was unchanged by this track, `core-and-audio` is byte-identical raw with and without `math/exact.ts` exported from core's barrel, and `exactAcos` accounts for 435 of `core-and-script`'s 3,194."
601
+ "text": "**The loop knew the fixed-step count and kept it**, so anything needing one \u2014 a recording, a replay, anything networked \u2014 kept a second counter beside `simulate` and trusted the two to stay in step. `simulate(dt, tick)` takes it as an additive second argument and `LoopOptions.startTick` is where the count begins, for a participant joining a session in progress. A pause holds the tick, which is the property the dropped accumulator already had, read from the other side.\n\n**`savableMulberry32` exposes the position `mulberry32` closes over**, as one integer, so it stores beside a tick in a snapshot. Its sequence is asserted draw-for-draw against `mulberry32` over six seeds, because `AGENTS.md` makes that sequence a contract about every stored ghost and replay.\n\n**Every size floor was stale and none tripped the 3% gate.** The drift ranged from 3 bytes to 3,194, and seven package READMEs plus seven root-table rows were quoting numbers that read low under a sentence claiming to be a fact about the build. `@driftengine/terrain` said 0.8 KB and measures 1.4 \u2014 a figure `ROADMAP.md` has had right since the day terrain shipped. The drift was attributed by A/B rather than apportioned: `core-only` was unchanged by this track, `core-and-audio` is byte-identical raw with and without `math/exact.ts` exported from core's barrel, and `exactAcos` accounts for 435 of `core-and-script`'s 3,194."
586
602
  }
587
603
  ]
588
604
  },
@@ -593,12 +609,12 @@
593
609
  {
594
610
  "kind": "added",
595
611
  "short": "CharacterController takes its up as a vector, settable per tick and defaulting to world up, so a body can stand on a wall or a ceiling. Gravity keeps a direction of its own.",
596
- "text": "**The controller could not represent a body standing on a wall. Not badly, at all: there was no orientation to give it.** `velY` was the only representation of vertical velocity, `gravity` was a scalar applied to it, `slopeCos` was measured against world Y, `stepHeight` was a height only because up was fixed, and `GROUNDED`, `AIRBORNE` and `SLIDING` were decided by a downward probe. Nothing in the file was wrong. All of it assumed one axis.\n\n**What that cost a consumer is a reimplementation rather than a workaround.** Roughly nine hundred lines duplicating the sweep, the step-over, the slope test and the ground probe this file already has, and not sharing its contact code, so the two would drift. It is not one game's problem: it is every wall-crawler, every arbitrary-gravity level, and every walker on the inside of a rotating station.\n\n**`setUp` takes the direction, per tick, defaulting to world up so every existing consumer is unchanged.** Velocity stays a world vector and the frame decides how it is read: the speed along up is what `velY` used to be, exposed as `speedAlongUp`, and the rest stays tangent to it. Steering happens in the tangent plane, with the input read as a world direction flattened onto the surface, which at world up removes nothing and is exactly the arithmetic it replaced. `slopeCos`, `stepHeight`, the step-over and both ground probes measure in that frame. The three states are unchanged in meaning.\n\n**Gravity keeps a direction of its own, because a support normal and a fall direction are different questions.** It follows up until `setGravityDirection` is called, which is the default that leaves existing behaviour alone; a body clinging to a wall stands in the wall's frame and may still fall the way the world falls the moment it lets go.\n\n**The capsule is rotated to stand along up, and that is the one part that cannot be left out.** The pose quaternion was identity, which is right while up is world Y and wrong the instant it is not: a body on a wall whose capsule is still world-vertical is swept against the wall by its side rather than its foot. Its check is the measurement — with the rotation it rests 0.95 m clear of the face, which is `halfHeight + radius` exactly as on a floor, and without it 0.35 m, which is a radius.\n\n**What a `GroundProbe` cannot do is stated rather than approximated.** A probe answers a height for an (x, z), so it has already chosen which axis is vertical and there is no reading of it in a tilted frame. `standOnSurface` is skipped outside world up, and a body on a wall stands on a collider. Approximating it would hoist a body along the wrong axis, which is worse than not hoisting it.\n\nWhat stays out: adhesion, grip, contamination, pads and claws, and anything that knows what an animal is. Those are the caller's, and they are what the engine's own boundary rule refuses — a parameter that means something in only one game does not belong here."
612
+ "text": "**The controller could not represent a body standing on a wall. Not badly, at all: there was no orientation to give it.** `velY` was the only representation of vertical velocity, `gravity` was a scalar applied to it, `slopeCos` was measured against world Y, `stepHeight` was a height only because up was fixed, and `GROUNDED`, `AIRBORNE` and `SLIDING` were decided by a downward probe. Nothing in the file was wrong. All of it assumed one axis.\n\n**What that cost a consumer is a reimplementation rather than a workaround.** Roughly nine hundred lines duplicating the sweep, the step-over, the slope test and the ground probe this file already has, and not sharing its contact code, so the two would drift. It is not one game's problem: it is every wall-crawler, every arbitrary-gravity level, and every walker on the inside of a rotating station.\n\n**`setUp` takes the direction, per tick, defaulting to world up so every existing consumer is unchanged.** Velocity stays a world vector and the frame decides how it is read: the speed along up is what `velY` used to be, exposed as `speedAlongUp`, and the rest stays tangent to it. Steering happens in the tangent plane, with the input read as a world direction flattened onto the surface, which at world up removes nothing and is exactly the arithmetic it replaced. `slopeCos`, `stepHeight`, the step-over and both ground probes measure in that frame. The three states are unchanged in meaning.\n\n**Gravity keeps a direction of its own, because a support normal and a fall direction are different questions.** It follows up until `setGravityDirection` is called, which is the default that leaves existing behaviour alone; a body clinging to a wall stands in the wall's frame and may still fall the way the world falls the moment it lets go.\n\n**The capsule is rotated to stand along up, and that is the one part that cannot be left out.** The pose quaternion was identity, which is right while up is world Y and wrong the instant it is not: a body on a wall whose capsule is still world-vertical is swept against the wall by its side rather than its foot. Its check is the measurement \u2014 with the rotation it rests 0.95 m clear of the face, which is `halfHeight + radius` exactly as on a floor, and without it 0.35 m, which is a radius.\n\n**What a `GroundProbe` cannot do is stated rather than approximated.** A probe answers a height for an (x, z), so it has already chosen which axis is vertical and there is no reading of it in a tilted frame. `standOnSurface` is skipped outside world up, and a body on a wall stands on a collider. Approximating it would hoist a body along the wrong axis, which is worse than not hoisting it.\n\nWhat stays out: adhesion, grip, contamination, pads and claws, and anything that knows what an animal is. Those are the caller's, and they are what the engine's own boundary rule refuses \u2014 a parameter that means something in only one game does not belong here."
597
613
  },
598
614
  {
599
615
  "kind": "added",
600
616
  "short": "ThirdPersonCamera builds its arm in a basis rather than swinging it about world Y, so a rig can orbit a banked surface. Yaw and pitch become angles inside the frame.",
601
- "text": "**The rig could already look banked and could not orbit a banked surface.** The arm was a spherical boom about world Y — `-sin(yaw) cos(pitch)`, `-sin(pitch)`, `cos(yaw) cos(pitch)` — so a subject on a ceiling got an arm that still swung about world up and the rig placed the eye through the ceiling. `groundClear` then cut the arm where the eye would go under the deck, which is the same assumption a second time.\n\n**The rig had already anticipated this case and stopped one step short of it.** `targetRoll`'s own comment reads that this rig knows nothing about banked surfaces, only that a camera can be tilted, and that roll is damped here so a subject snapping between surfaces cannot snap the view. Roll was carried; the arm was not.\n\n**`setBoomUp` takes the direction and the rest of the frame follows it**, defaulting to world axes so a rig that never asks gets the arm it always got. Yaw and pitch keep their meaning and become angles inside the frame, and `Boom`, the collision sweep, the damping and the field-of-view handling are untouched. That matters more than the arm: a consumer computing its own arm keeps the damping and loses the sweep, which is the hard part and the part that lets a camera enter under-table and baseboard space without clipping.\n\n**Forward is carried from the frame it had rather than derived from a world axis.** Deriving it from a fixed reference is one line shorter and puts a pole wherever up lines up with that reference, so the arm would swing through a half turn as a subject crossed it — on a rig whose whole purpose is that the view does not snap. Carrying it makes a body walking from a floor onto a wall and onto a ceiling continuous, and the world-axis fallbacks are reached only by a teleport, which a continuous rotation cannot produce.\n\n**`groundClear` is skipped outside world up rather than generalised.** It reads a surface that answers a height for an (x, z), so \"under the deck\" and \"behind the support plane\" are the same sentence only while up is world up. In any other frame the arm is left to the collision sweep, which is frame-agnostic and is the instrument that actually keeps the eye out of geometry.\n\nIts check is a mirror, because that is the only assertion that cannot pass by accident: the pitch that lifts the eye 2.40 m above the subject in the default frame lowers it 2.40 m below in an inverted one, and the two agree to within a centimetre. Building the arm on world Y instead gives 2.40 m in both, which is the eye through the ceiling."
617
+ "text": "**The rig could already look banked and could not orbit a banked surface.** The arm was a spherical boom about world Y \u2014 `-sin(yaw) cos(pitch)`, `-sin(pitch)`, `cos(yaw) cos(pitch)` \u2014 so a subject on a ceiling got an arm that still swung about world up and the rig placed the eye through the ceiling. `groundClear` then cut the arm where the eye would go under the deck, which is the same assumption a second time.\n\n**The rig had already anticipated this case and stopped one step short of it.** `targetRoll`'s own comment reads that this rig knows nothing about banked surfaces, only that a camera can be tilted, and that roll is damped here so a subject snapping between surfaces cannot snap the view. Roll was carried; the arm was not.\n\n**`setBoomUp` takes the direction and the rest of the frame follows it**, defaulting to world axes so a rig that never asks gets the arm it always got. Yaw and pitch keep their meaning and become angles inside the frame, and `Boom`, the collision sweep, the damping and the field-of-view handling are untouched. That matters more than the arm: a consumer computing its own arm keeps the damping and loses the sweep, which is the hard part and the part that lets a camera enter under-table and baseboard space without clipping.\n\n**Forward is carried from the frame it had rather than derived from a world axis.** Deriving it from a fixed reference is one line shorter and puts a pole wherever up lines up with that reference, so the arm would swing through a half turn as a subject crossed it \u2014 on a rig whose whole purpose is that the view does not snap. Carrying it makes a body walking from a floor onto a wall and onto a ceiling continuous, and the world-axis fallbacks are reached only by a teleport, which a continuous rotation cannot produce.\n\n**`groundClear` is skipped outside world up rather than generalised.** It reads a surface that answers a height for an (x, z), so \"under the deck\" and \"behind the support plane\" are the same sentence only while up is world up. In any other frame the arm is left to the collision sweep, which is frame-agnostic and is the instrument that actually keeps the eye out of geometry.\n\nIts check is a mirror, because that is the only assertion that cannot pass by accident: the pitch that lifts the eye 2.40 m above the subject in the default frame lowers it 2.40 m below in an inverted one, and the two agree to within a centimetre. Building the arm on world Y instead gives 2.40 m in both, which is the eye through the ceiling."
602
618
  }
603
619
  ]
604
620
  },
@@ -609,17 +625,17 @@
609
625
  {
610
626
  "kind": "fixed",
611
627
  "short": "A WebGPU material change costs one bind group the first time that combination is seen and nothing after. The cache was keyed on the albedo while every group in it held four maps.",
612
- "text": "**Every material change rebuilt the flat bind group and threw the whole cache away.** `setMaterial` called `rebuildFlatBindGroup` once per changed map, up to three times in one call, and each of those cleared the cache; the albedo lookup on the next line then missed the cache it had just emptied and built a fourth. So a material carrying a different map from the one before it cost up to **four `createBindGroup` calls**, and the cache that exists to make the second frame free was destroyed on every one of them.\n\n**The mismatch was stated in the file and was the reason for the clearing.** `albedoBindGroups` was *keyed* by albedo while every group it held also carried the normal, ORM and emissive views current when it was built. Keyed on one thing and holding four, it could only be correct by being thrown away whenever any of the other three moved.\n\n**What it cost was measured from outside, against a consumer's own models.** The materials each car merges to were read out of the baked files and this backend's state machine replayed over them in draw order: 476 `createBindGroup` calls a pass across seven models whose distinct signatures number 247. That game draws its vehicles twice, once for the frame and once for the water's mirror, so about **950 a frame** in steady state where the correct number after the first frame is nought. It scales with the number of distinct *models* on screen rather than with the number of cars, because identical cars share their batches, which is exactly how it was reported from playing: the frame rate falling off when a second kind of car came into view.\n\n**The key was the defect, not the caching.** The cache is keyed on all four maps now and `setMaterial` clears nothing. Nested maps rather than one map under a composite key, because a key built per lookup is an allocation on a path that runs per material change. The four invalidations that remain move something *every* group holds — the shadow cubemaps, the probe fence, the cookie atlas and the IES rows — and none of them is a frame path.\n\n**And the count is on the frame budget now**, as `bind groups`, which is the one number a consumer could not get at and the reason this took a measurement of the *models* to find rather than a reading of the frame. No ceiling, because the honest figure is nought: every other line counts something rationed and this counts something cached, so any standing figure is a cache missing. WebGL2 declares the same line and never asks it, because it builds none."
628
+ "text": "**Every material change rebuilt the flat bind group and threw the whole cache away.** `setMaterial` called `rebuildFlatBindGroup` once per changed map, up to three times in one call, and each of those cleared the cache; the albedo lookup on the next line then missed the cache it had just emptied and built a fourth. So a material carrying a different map from the one before it cost up to **four `createBindGroup` calls**, and the cache that exists to make the second frame free was destroyed on every one of them.\n\n**The mismatch was stated in the file and was the reason for the clearing.** `albedoBindGroups` was *keyed* by albedo while every group it held also carried the normal, ORM and emissive views current when it was built. Keyed on one thing and holding four, it could only be correct by being thrown away whenever any of the other three moved.\n\n**What it cost was measured from outside, against a consumer's own models.** The materials each car merges to were read out of the baked files and this backend's state machine replayed over them in draw order: 476 `createBindGroup` calls a pass across seven models whose distinct signatures number 247. That game draws its vehicles twice, once for the frame and once for the water's mirror, so about **950 a frame** in steady state where the correct number after the first frame is nought. It scales with the number of distinct *models* on screen rather than with the number of cars, because identical cars share their batches, which is exactly how it was reported from playing: the frame rate falling off when a second kind of car came into view.\n\n**The key was the defect, not the caching.** The cache is keyed on all four maps now and `setMaterial` clears nothing. Nested maps rather than one map under a composite key, because a key built per lookup is an allocation on a path that runs per material change. The four invalidations that remain move something *every* group holds \u2014 the shadow cubemaps, the probe fence, the cookie atlas and the IES rows \u2014 and none of them is a frame path.\n\n**And the count is on the frame budget now**, as `bind groups`, which is the one number a consumer could not get at and the reason this took a measurement of the *models* to find rather than a reading of the frame. No ceiling, because the honest figure is nought: every other line counts something rationed and this counts something cached, so any standing figure is a cache missing. WebGL2 declares the same line and never asks it, because it builds none."
613
629
  },
614
630
  {
615
631
  "kind": "fixed",
616
632
  "short": "damp holds its target still, so a chase camera settled a different distance behind at every frame rate and juddered when frames were uneven. dampTracking is exact for a target that is moving.",
617
- "text": "**`damp(a, b, lambda, dt)` is `lerp(a, b, 1 - exp(-lambda dt))`, which is the right answer for a target standing still and a zero-order hold for one that is not.** It assumes `b` is constant across the interval. A third-person camera's target is never constant: it is a vehicle. `ThirdPersonCamera` smoothed its position with it, so every consumer's chase camera had this.\n\n**Two costs fall out of it, and the error is a function of `dt`, which is what makes it a frame-rate dependence rather than an offset somebody could tune out.** The settled gap works out to `v dt (1-k)/k` for `k = 1 - exp(-lambda dt)`, which *shrinks as the frame time grows*: at lambda 14 and 25 m/s that is 1.586 m at 60 Hz against 1.376 m at 28 Hz, so a machine that drops rate silently re-frames the shot. And under jitter the gap oscillates rather than settling, which at 90 km/h several times a second is what a consumer reported as the car snapping about inside the picture when the frame rate dipped.\n\n**The subject itself was never the problem, and that is how the camera was identified rather than assumed.** Driven through the engine's own interpolation harness at 25 m/s, the car holds its world speed to within a twentieth of a per cent at 60 Hz, at 28 Hz, and through jitter of plus or minus 20 ms; the camera's own speed varies by 5.38% under the last of those. The fixed step and the interpolation were doing exactly what they are for.\n\n**`dampTracking(a, b, velocity, lambda, dt)` solves the same equation for a target arriving at `b` having travelled at `v`.** Substituting `e = a - b` gives `e′ = -lambda e - v`, whose solution is `e(t) = (e0 + v/lambda) exp(-lambda t) - v/lambda` — so the settled gap is `v/lambda` at every rate and there is nothing left for jitter to move. Zero velocity returns `damp` exactly, and a lambda of zero returns `a` rather than dividing by it, which is also the limit of the arithmetic.\n\n**It is `ThirdPersonCamera`'s default rather than an option**, because a camera arm following something that stands still is the rare case. The velocity is differenced from the target rather than asked for, so no consumer signature moved: every caller already hands the target over each frame, and the difference is exact for a target moving smoothly. **Roll and the field of view keep the hold**, deliberately: both follow targets that step, and one frame of `delta/dt` across a step is a spike this would then aim `v/lambda` beyond."
633
+ "text": "**`damp(a, b, lambda, dt)` is `lerp(a, b, 1 - exp(-lambda dt))`, which is the right answer for a target standing still and a zero-order hold for one that is not.** It assumes `b` is constant across the interval. A third-person camera's target is never constant: it is a vehicle. `ThirdPersonCamera` smoothed its position with it, so every consumer's chase camera had this.\n\n**Two costs fall out of it, and the error is a function of `dt`, which is what makes it a frame-rate dependence rather than an offset somebody could tune out.** The settled gap works out to `v dt (1-k)/k` for `k = 1 - exp(-lambda dt)`, which *shrinks as the frame time grows*: at lambda 14 and 25 m/s that is 1.586 m at 60 Hz against 1.376 m at 28 Hz, so a machine that drops rate silently re-frames the shot. And under jitter the gap oscillates rather than settling, which at 90 km/h several times a second is what a consumer reported as the car snapping about inside the picture when the frame rate dipped.\n\n**The subject itself was never the problem, and that is how the camera was identified rather than assumed.** Driven through the engine's own interpolation harness at 25 m/s, the car holds its world speed to within a twentieth of a per cent at 60 Hz, at 28 Hz, and through jitter of plus or minus 20 ms; the camera's own speed varies by 5.38% under the last of those. The fixed step and the interpolation were doing exactly what they are for.\n\n**`dampTracking(a, b, velocity, lambda, dt)` solves the same equation for a target arriving at `b` having travelled at `v`.** Substituting `e = a - b` gives `e\u2032 = -lambda e - v`, whose solution is `e(t) = (e0 + v/lambda) exp(-lambda t) - v/lambda` \u2014 so the settled gap is `v/lambda` at every rate and there is nothing left for jitter to move. Zero velocity returns `damp` exactly, and a lambda of zero returns `a` rather than dividing by it, which is also the limit of the arithmetic.\n\n**It is `ThirdPersonCamera`'s default rather than an option**, because a camera arm following something that stands still is the rare case. The velocity is differenced from the target rather than asked for, so no consumer signature moved: every caller already hands the target over each frame, and the difference is exact for a target moving smoothly. **Roll and the field of view keep the hold**, deliberately: both follow targets that step, and one frame of `delta/dt` across a step is a spike this would then aim `v/lambda` beyond."
618
634
  },
619
635
  {
620
636
  "kind": "added",
621
637
  "short": "A caster enumeration may carry each draw's material, and drawSceneCasters replays one into the open colour pass, so a second viewpoint costs a replay rather than the whole draw path again.",
622
- "text": "**The only draw-replay the engine had carried no material, so a second pass meant running the whole draw again.** `ShadowCasterSink` is exactly the mechanism — a handle, a matrix and a skinning palette — and carrying only that is right for a depth pass, where a material cannot change a depth. `beginPlanarReflection` hands back a `Camera` and nothing else, so a consumer wanting the same scene from a second viewpoint had one option, which was to call its own whole draw path a second time.\n\n**What that costs was measured in a consumer**, whose `sim` phase — the people, the vehicles, the props and the smoke — is 2.3 to 9.4 ms and is the largest single row in its frame meter. The water's mirror pays all of it again, and it is charged to a `mirror` row rather than hidden precisely because it is the most expensive thing in the frame.\n\n**The part worth recording is what was tried first.** That mirror was drawn by replaying the caster list, because the list already existed and was already recorded. It came back with **every car unpainted**, because the list carries no material. So the game re-enters its own draw with a mirrored camera, which is correct and costs the phase twice.\n\n**The material rides the sink now**, optional for the reason `instanced` already gives at length: this interface is implemented outwards, so a required parameter added in a minor stops every consumer's sink and every test double compiling. A sink that ignores it behaves exactly as it did, and a depth pass ignores it correctly. `drawSceneCasters` is the colour counterpart of `drawShadowCasters` on both backends, so one closure answers what is in this frame for the shadow cascade, every cubemap face, and a mirror or a probe face.\n\n**Scatter is declined rather than drawn, and the absence is meant to be in the picture.** A scatter batch’s colour draw needs the camera and the environment of the pass it is going into, and this enumeration records what a thing *is* rather than what the pass around it looks like. Handing the colour path a stale camera would put the grass of one pass into another, which is the plausible-picture failure the two-backends rule exists to forbid. So a replayed pass has no scatter in it and a caller wanting grass in a mirror draws it with the camera it already has."
638
+ "text": "**The only draw-replay the engine had carried no material, so a second pass meant running the whole draw again.** `ShadowCasterSink` is exactly the mechanism \u2014 a handle, a matrix and a skinning palette \u2014 and carrying only that is right for a depth pass, where a material cannot change a depth. `beginPlanarReflection` hands back a `Camera` and nothing else, so a consumer wanting the same scene from a second viewpoint had one option, which was to call its own whole draw path a second time.\n\n**What that costs was measured in a consumer**, whose `sim` phase \u2014 the people, the vehicles, the props and the smoke \u2014 is 2.3 to 9.4 ms and is the largest single row in its frame meter. The water's mirror pays all of it again, and it is charged to a `mirror` row rather than hidden precisely because it is the most expensive thing in the frame.\n\n**The part worth recording is what was tried first.** That mirror was drawn by replaying the caster list, because the list already existed and was already recorded. It came back with **every car unpainted**, because the list carries no material. So the game re-enters its own draw with a mirrored camera, which is correct and costs the phase twice.\n\n**The material rides the sink now**, optional for the reason `instanced` already gives at length: this interface is implemented outwards, so a required parameter added in a minor stops every consumer's sink and every test double compiling. A sink that ignores it behaves exactly as it did, and a depth pass ignores it correctly. `drawSceneCasters` is the colour counterpart of `drawShadowCasters` on both backends, so one closure answers what is in this frame for the shadow cascade, every cubemap face, and a mirror or a probe face.\n\n**Scatter is declined rather than drawn, and the absence is meant to be in the picture.** A scatter batch\u2019s colour draw needs the camera and the environment of the pass it is going into, and this enumeration records what a thing *is* rather than what the pass around it looks like. Handing the colour path a stale camera would put the grass of one pass into another, which is the plausible-picture failure the two-backends rule exists to forbid. So a replayed pass has no scatter in it and a caller wanting grass in a mirror draws it with the camera it already has."
623
639
  }
624
640
  ]
625
641
  },
@@ -630,17 +646,17 @@
630
646
  {
631
647
  "kind": "added",
632
648
  "short": "Track K is complete: an inspector, a scene tree and play-in-editor, as @driftengine/editor at 10.3 KB standalone. The stop condition held for an hour and then somebody asked for the rest.",
633
- "text": "**`ROADMAP.md`'s risk table has said since it was written that if the editor threatened the\nprogramme the answer was to stop at debug draw and gizmos and defer the rest.** The gizmo shipped in\n3.44.0, the condition held, and the census recorded the deferral. Then the rest was asked for, which\nis the only reason a stop condition is ever lifted, and this is it.\n\n**The trigger never fired.** The track did not exceed its budget. What the estimate was wrong about\nis the size of the thing: an editor is a multi-year speciality when it is a *product* — a window, a\ndocking layout, a property drawer per type, an undo stack, an asset browser — and this is the model\nunderneath one. A tree flatten, a schema walk, a snapshot round trip and a `UiNode` builder.\n\n**A package, not part of core, and the reason is a boundary worth keeping.** It imports\n`@driftengine/entities`, which imports no other engine package deliberately; having core reach it to\nserve an editor would invert that for every game that never opens one. **10.3 KB gzipped standalone**,\nand standalone is a measurement rather than a convenience: `Gizmo` is the only value it imports from\ncore, so a bundle of it is 31.8 KB raw against core's 2.79 MB, with `createRenderer` and every shader\nstring absent from the output.\n\n**`SceneTree` is a flattened, collapsible, named view**, and the names live in the tree rather than on\n`SceneNode`. A string on every node would be paid by every game, in every serialised scene and every\nnode allocation, to serve an editor most of them never open. Reparenting *answers* rather than\nthrows, and the refusal itself is `attachChild`'s — checked rather than re-implemented, because it\nalready rejects a self-parent and any ancestor.\n\n**`Inspector` is reflection and needs no code per component.** `ComponentType.schema.fields` already\ncarries `{ id, name, type }` and `World.read`/`write` take a field by name, so it inspects a component\na `.drs` file declared and this package has never heard of. **The field's kind comes from the declared\ntype, because a read cannot answer it**: `bool` arrives as 0 or 1, an enum discriminant as an integer,\nan `Entity` as a number — every one of them is a number by the time you have it, and only the\ndeclaration says whether to draw a checkbox, a stepper or a menu. An option is spelled `option:f32`,\na prefix rather than the `?` suffix that is the obvious guess, because a field type is a key a\nmigration compares for equality.\n\n**A transform is written through `setPosition` and `markMoved`, never into the array.** Writing\n`node.position` directly leaves `worldMatrix` describing where the node used to be — no error, a\nplausible matrix, geometry in the wrong place until something else happens to move it.\n\n**Play-in-editor's hard part was not pause.** `LoopHooks.shouldSimulate` already existed so a paused\ngame keeps rendering, which is exactly what an editor wants; `step()` is a one-shot flag that hook\nconsumes, so a step is one tick rather than a duration that advances a variable number of them.\n\n**It was that `deserializeWorld` creates entities rather than replacing them.** A load appends, so a\nstop that only loads leaves the world holding both what was authored and what play produced — and the\nduplication grows every time somebody presses stop. **Measured at 4 live entities against 2** with the\nclear removed. So `stop()` clears first, walking the same `store.dense` per type that\n`serializeWorld` walks to decide what is live.\n\n**And every entity handle changes across a stop**, because the restored entities are new ones. A\nselection held as a handle is stale the moment play ends — pointing at nothing, or at whatever reused\nthe slot, which is worse because it looks like it worked. A selection is re-found by its *index in the\nsnapshot*, the only identity that survives, and an entity that play created has no index and the\nselection is emptied rather than moved to a stranger.\n\n**Panels are `@driftengine/ui2d` nodes and not pixels**, which is the shape `DebugLines` has against\n`drawLines`, one level up. Rows are rebuilt in place and carry a name of `row:<index>`, so the node\n`routeUiPointer` hands back parses straight to the row. **Nothing marks the selected row and `UiNode`\ngrows no field for it**: `tree.rowOf(tree.selected)` is the index, and how a selected row looks is a\ndecision about a consumer's palette — the same trade this package made when it kept node names out of\n`SceneNode`.\n\n**Its check found a hole in itself.** Every assertion was verified by breaking what it covers, and\none of those runs changed the page rather than confirming it: replacing `setPosition` with a raw array\nwrite left the check green, because the edit was applied during setup, before any world matrix had\nbeen computed, so the first `updateWorld` picked up a raw write as readily as a proper one. The edit\nnow lands after a frame has drawn and the same mutation fails at 669 pixels against 669.\n`inspector.test.ts` had it covered all along, because it calls `updateWorld` before writing — which\nis why a check with a control and a unit test with a precondition are not the same instrument.\n\nMeasured on both backends, identical to the pixel: three rows at 11,016 px and two at 7,344 when a\nbranch is collapsed; a click selecting row 2 and lighting 3,672 px where the control lights none; an\ninspector edit moving the rightmost cube from 669 to 849; and a stop restoring 2 live entities and a\nvalue of 10 where play left 2 and 999."
649
+ "text": "**`ROADMAP.md`'s risk table has said since it was written that if the editor threatened the\nprogramme the answer was to stop at debug draw and gizmos and defer the rest.** The gizmo shipped in\n3.44.0, the condition held, and the census recorded the deferral. Then the rest was asked for, which\nis the only reason a stop condition is ever lifted, and this is it.\n\n**The trigger never fired.** The track did not exceed its budget. What the estimate was wrong about\nis the size of the thing: an editor is a multi-year speciality when it is a *product* \u2014 a window, a\ndocking layout, a property drawer per type, an undo stack, an asset browser \u2014 and this is the model\nunderneath one. A tree flatten, a schema walk, a snapshot round trip and a `UiNode` builder.\n\n**A package, not part of core, and the reason is a boundary worth keeping.** It imports\n`@driftengine/entities`, which imports no other engine package deliberately; having core reach it to\nserve an editor would invert that for every game that never opens one. **10.3 KB gzipped standalone**,\nand standalone is a measurement rather than a convenience: `Gizmo` is the only value it imports from\ncore, so a bundle of it is 31.8 KB raw against core's 2.79 MB, with `createRenderer` and every shader\nstring absent from the output.\n\n**`SceneTree` is a flattened, collapsible, named view**, and the names live in the tree rather than on\n`SceneNode`. A string on every node would be paid by every game, in every serialised scene and every\nnode allocation, to serve an editor most of them never open. Reparenting *answers* rather than\nthrows, and the refusal itself is `attachChild`'s \u2014 checked rather than re-implemented, because it\nalready rejects a self-parent and any ancestor.\n\n**`Inspector` is reflection and needs no code per component.** `ComponentType.schema.fields` already\ncarries `{ id, name, type }` and `World.read`/`write` take a field by name, so it inspects a component\na `.drs` file declared and this package has never heard of. **The field's kind comes from the declared\ntype, because a read cannot answer it**: `bool` arrives as 0 or 1, an enum discriminant as an integer,\nan `Entity` as a number \u2014 every one of them is a number by the time you have it, and only the\ndeclaration says whether to draw a checkbox, a stepper or a menu. An option is spelled `option:f32`,\na prefix rather than the `?` suffix that is the obvious guess, because a field type is a key a\nmigration compares for equality.\n\n**A transform is written through `setPosition` and `markMoved`, never into the array.** Writing\n`node.position` directly leaves `worldMatrix` describing where the node used to be \u2014 no error, a\nplausible matrix, geometry in the wrong place until something else happens to move it.\n\n**Play-in-editor's hard part was not pause.** `LoopHooks.shouldSimulate` already existed so a paused\ngame keeps rendering, which is exactly what an editor wants; `step()` is a one-shot flag that hook\nconsumes, so a step is one tick rather than a duration that advances a variable number of them.\n\n**It was that `deserializeWorld` creates entities rather than replacing them.** A load appends, so a\nstop that only loads leaves the world holding both what was authored and what play produced \u2014 and the\nduplication grows every time somebody presses stop. **Measured at 4 live entities against 2** with the\nclear removed. So `stop()` clears first, walking the same `store.dense` per type that\n`serializeWorld` walks to decide what is live.\n\n**And every entity handle changes across a stop**, because the restored entities are new ones. A\nselection held as a handle is stale the moment play ends \u2014 pointing at nothing, or at whatever reused\nthe slot, which is worse because it looks like it worked. A selection is re-found by its *index in the\nsnapshot*, the only identity that survives, and an entity that play created has no index and the\nselection is emptied rather than moved to a stranger.\n\n**Panels are `@driftengine/ui2d` nodes and not pixels**, which is the shape `DebugLines` has against\n`drawLines`, one level up. Rows are rebuilt in place and carry a name of `row:<index>`, so the node\n`routeUiPointer` hands back parses straight to the row. **Nothing marks the selected row and `UiNode`\ngrows no field for it**: `tree.rowOf(tree.selected)` is the index, and how a selected row looks is a\ndecision about a consumer's palette \u2014 the same trade this package made when it kept node names out of\n`SceneNode`.\n\n**Its check found a hole in itself.** Every assertion was verified by breaking what it covers, and\none of those runs changed the page rather than confirming it: replacing `setPosition` with a raw array\nwrite left the check green, because the edit was applied during setup, before any world matrix had\nbeen computed, so the first `updateWorld` picked up a raw write as readily as a proper one. The edit\nnow lands after a frame has drawn and the same mutation fails at 669 pixels against 669.\n`inspector.test.ts` had it covered all along, because it calls `updateWorld` before writing \u2014 which\nis why a check with a control and a unit test with a precondition are not the same instrument.\n\nMeasured on both backends, identical to the pixel: three rows at 11,016 px and two at 7,344 when a\nbranch is collapsed; a click selecting row 2 and lighting 3,672 px where the control lights none; an\ninspector edit moving the rightmost cube from 669 to 849; and a stop restoring 2 live entities and a\nvalue of 10 where play left 2 and 999."
634
650
  },
635
651
  {
636
652
  "kind": "added",
637
653
  "short": "drift/editor grows to forty-three capabilities over two opaque types: twenty for the tree, the inspector and the play state, beside the twenty-three that already drove the gizmo.",
638
- "text": "**A second opaque type.** `Gizmo` was the transform tool; `Editor` is the session — what is\nselected, the tree of what exists, and whether the world is playing. Both reach a script through\n`uses`, the way a `Terrain` and a `NavGraph` do, so the module needs nothing from the host and stays\nregistered unconditionally.\n\n**A name is unique across the module and not per type**, which two opaque types make easy to forget: a\nmodule is one namespace and one implementation map, so `mode` cannot mean the gizmo's mode to a\n`Gizmo` and the session's to an `Editor`. It is `gizmoMode` and `mode`, and TypeScript caught the\ncollision as a duplicate object key rather than one of them silently winning at run time.\n\n**Every row and field reader answers a defined value for an index that does not exist**, because the\nlanguage has no optional to answer with and a frame loop is the worst place to learn that — the same\nargument `drift/ui` makes for addressing nodes by name.\n\n**And nothing here edits a field by name**, which is the same shape as the gizmo not picking: an\ninspector row is addressed by its index in a list the host built this frame, and a script wanting to\nwrite `Health.current` on a selection would be doing entity work `drift/ecs` already does properly,\nwith a component handle and a checked field."
654
+ "text": "**A second opaque type.** `Gizmo` was the transform tool; `Editor` is the session \u2014 what is\nselected, the tree of what exists, and whether the world is playing. Both reach a script through\n`uses`, the way a `Terrain` and a `NavGraph` do, so the module needs nothing from the host and stays\nregistered unconditionally.\n\n**A name is unique across the module and not per type**, which two opaque types make easy to forget: a\nmodule is one namespace and one implementation map, so `mode` cannot mean the gizmo's mode to a\n`Gizmo` and the session's to an `Editor`. It is `gizmoMode` and `mode`, and TypeScript caught the\ncollision as a duplicate object key rather than one of them silently winning at run time.\n\n**Every row and field reader answers a defined value for an index that does not exist**, because the\nlanguage has no optional to answer with and a frame loop is the worst place to learn that \u2014 the same\nargument `drift/ui` makes for addressing nodes by name.\n\n**And nothing here edits a field by name**, which is the same shape as the gizmo not picking: an\ninspector row is addressed by its index in a list the host built this frame, and a script wanting to\nwrite `Health.current` on a selection would be doing entity work `drift/ecs` already does properly,\nwith a component handle and a checked field."
639
655
  },
640
656
  {
641
657
  "kind": "changed",
642
658
  "short": "A version bump is forty-eight places, not forty-three. The sixteenth package moved it, and four of the five new places are ranges rather than manifests.",
643
- "text": "**The seventh time this number has moved, and the third time a peer dependency rather than a package\nwas the reason.** `@driftengine/editor` is one manifest and *four* ranges: three peers of its own —\ncore, entities and ui2d — and `@driftengine/script`'s peer on it. So a release is now **seventeen\nmanifests, thirty ranges and the lockfile: forty-eight places.**\n\nCounted with the snippet in `AGENTS.md` rather than read off the previous sentence, which is the only\nreason it is right — that paragraph has been wrong six times while saying so. The floor in\n`scripts/version.test.mjs` is raised in the same commit, because a floor passes at the true number and\nat every stale one below it."
659
+ "text": "**The seventh time this number has moved, and the third time a peer dependency rather than a package\nwas the reason.** `@driftengine/editor` is one manifest and *four* ranges: three peers of its own \u2014\ncore, entities and ui2d \u2014 and `@driftengine/script`'s peer on it. So a release is now **seventeen\nmanifests, thirty ranges and the lockfile: forty-eight places.**\n\nCounted with the snippet in `AGENTS.md` rather than read off the previous sentence, which is the only\nreason it is right \u2014 that paragraph has been wrong six times while saying so. The floor in\n`scripts/version.test.mjs` is raised in the same commit, because a floor passes at the true number and\nat every stale one below it."
644
660
  }
645
661
  ]
646
662
  },
@@ -651,22 +667,22 @@
651
667
  {
652
668
  "kind": "added",
653
669
  "short": "A transform gizmo, which stops Track K where its own risk table said to stop: translate, rotate and scale, thirteen handles, 4.53 KB over core and nothing to a game that never imports one.",
654
- "text": "**Track K stops here, and that is a different thing from running out.** `ROADMAP.md`'s risk table has\nsaid since it was written that if the editor threatens the programme the answer is to *\"stop at debug\ndraw and gizmos, which are independently valuable, and defer the rest\"*. Debug draw shipped on\n2026-08-28. This is the other half, and it is the first row in this repository closed by a stop\ncondition written in advance rather than by an estimate running out.\n\n**`Gizmo` is a generator and not a pass**, which is the argument `DebugLines` makes and which holds\nhere with more force: it answers what a ray is over and what a drag does to a transform, fills five\nline buffers, and `drawLines` — on both backends since the polyline batch landed — draws them. Five\nand not one because `drawLines` takes one colour a call: three axes, one neutral, and one for\nwhatever the pointer is on.\n\n**Translate, rotate and scale; thirteen handles; world or local — except scale, which is always\nlocal.** A non-uniform scale along a world axis is not representable in a translation-rotation-scale\ntransform, so a gizmo offering it would shear the object and call it scaling.\n\n**Every drag answers from its anchor rather than accumulating**, so fifty frames of dragging back and\nforth return the target to where it started, exactly. **And every degenerate ray keeps the previous\nvalue rather than producing one**: a ray parallel to the axis it is dragging, a ray edge-on to a\nring, a grab at a ring's own centre. Each of those has a closest point that is either undefined or\nenormous, and a caller that took the number would teleport whatever it was holding at the moment the\npointer crossed the plane. `rayPlane`, `rayClosestOnLine` and `raySphere` are new in `math/intersect`\nand each refuses its own degenerate case at the source.\n\n**The basis is frozen at the grab.** Recomputing it from a rotation the drag is writing feeds the\nanswer into its own input, and what that looks like is a local-space ring accelerating away from the\npointer. A pair of tests says so the only way that can be said: the same drag on the same pixels in\neach space, against a target already turned a quarter, where the two answers differ. Against an\nunrotated target they would be identical, which is why that test starts turned.\n\n**The angle unwrap is real and the orientation cannot show it.** `atan2` answers in the half-open\nturn, so a drag crossing the seam reads as a jump of nearly a full one; the total is unwrapped and\naccumulated. But `setAxisAngle` is periodic in a full turn and a quaternion double-covers the\nrotations, so an accumulated angle wrong by a turn produces the same pose negated, which is the same\nrotation. What the unwrap buys is the *number* — a readout that says 270 rather than -90, and a\ncaller that wants to snap or clamp — so `dragAngle` exposes it and the tests assert it there.\n\n**4.53 KB gzipped over core, and nothing at all to a consumer that never imports one**, which is\nmeasured rather than assumed: `core-only` bundles byte-identical against this branch and against the\ncommit before it, and the bundle contains no gizmo symbol. It imports `createRenderer` and a bundler\nshakes the rest of the barrel out, which is what makes an editor tool affordable to ship in core.\n\n**Its check has a control on every claim.** A white quad that shrinks by four as the camera pulls\nback while the gizmo holds its size to within four pixels; a pointer on the background that lights\nnothing; the same drag begun off the handle that moves nothing and does not start. A test that\nmeasured only the gizmo across two distances would pass a build that ignores distance, and one that\ndraws nothing at all.\n\n**What it does not do is pick**, and that is the shape rather than a gap: a pick needs a ray, a ray\nneeds a camera and a pointer, and both belong to the application. `registerPickable` settled the same\nquestion for meshes and this follows it — the engine says what, and enter, leave, capture and the\ndifference between a click and a drag stay with the consumer that already has that logic."
670
+ "text": "**Track K stops here, and that is a different thing from running out.** `ROADMAP.md`'s risk table has\nsaid since it was written that if the editor threatens the programme the answer is to *\"stop at debug\ndraw and gizmos, which are independently valuable, and defer the rest\"*. Debug draw shipped on\n2026-08-28. This is the other half, and it is the first row in this repository closed by a stop\ncondition written in advance rather than by an estimate running out.\n\n**`Gizmo` is a generator and not a pass**, which is the argument `DebugLines` makes and which holds\nhere with more force: it answers what a ray is over and what a drag does to a transform, fills five\nline buffers, and `drawLines` \u2014 on both backends since the polyline batch landed \u2014 draws them. Five\nand not one because `drawLines` takes one colour a call: three axes, one neutral, and one for\nwhatever the pointer is on.\n\n**Translate, rotate and scale; thirteen handles; world or local \u2014 except scale, which is always\nlocal.** A non-uniform scale along a world axis is not representable in a translation-rotation-scale\ntransform, so a gizmo offering it would shear the object and call it scaling.\n\n**Every drag answers from its anchor rather than accumulating**, so fifty frames of dragging back and\nforth return the target to where it started, exactly. **And every degenerate ray keeps the previous\nvalue rather than producing one**: a ray parallel to the axis it is dragging, a ray edge-on to a\nring, a grab at a ring's own centre. Each of those has a closest point that is either undefined or\nenormous, and a caller that took the number would teleport whatever it was holding at the moment the\npointer crossed the plane. `rayPlane`, `rayClosestOnLine` and `raySphere` are new in `math/intersect`\nand each refuses its own degenerate case at the source.\n\n**The basis is frozen at the grab.** Recomputing it from a rotation the drag is writing feeds the\nanswer into its own input, and what that looks like is a local-space ring accelerating away from the\npointer. A pair of tests says so the only way that can be said: the same drag on the same pixels in\neach space, against a target already turned a quarter, where the two answers differ. Against an\nunrotated target they would be identical, which is why that test starts turned.\n\n**The angle unwrap is real and the orientation cannot show it.** `atan2` answers in the half-open\nturn, so a drag crossing the seam reads as a jump of nearly a full one; the total is unwrapped and\naccumulated. But `setAxisAngle` is periodic in a full turn and a quaternion double-covers the\nrotations, so an accumulated angle wrong by a turn produces the same pose negated, which is the same\nrotation. What the unwrap buys is the *number* \u2014 a readout that says 270 rather than -90, and a\ncaller that wants to snap or clamp \u2014 so `dragAngle` exposes it and the tests assert it there.\n\n**4.53 KB gzipped over core, and nothing at all to a consumer that never imports one**, which is\nmeasured rather than assumed: `core-only` bundles byte-identical against this branch and against the\ncommit before it, and the bundle contains no gizmo symbol. It imports `createRenderer` and a bundler\nshakes the rest of the barrel out, which is what makes an editor tool affordable to ship in core.\n\n**Its check has a control on every claim.** A white quad that shrinks by four as the camera pulls\nback while the gizmo holds its size to within four pixels; a pointer on the background that lights\nnothing; the same drag begun off the handle that moves nothing and does not start. A test that\nmeasured only the gizmo across two distances would pass a build that ignores distance, and one that\ndraws nothing at all.\n\n**What it does not do is pick**, and that is the shape rather than a gap: a pick needs a ray, a ray\nneeds a camera and a pointer, and both belong to the application. `registerPickable` settled the same\nquestion for meshes and this follows it \u2014 the engine says what, and enter, leave, capture and the\ndifference between a click and a drag stay with the consumer that already has that logic."
655
671
  },
656
672
  {
657
673
  "kind": "added",
658
674
  "short": "drift/editor is bound, under the editor effect the language had already named. Twenty-two capabilities: what the pointer is over, what the drag has done, and which kind of tool it is.",
659
- "text": "**`drift/editor` was one of six specified surfaces nothing implemented, and it is five now.** A script\ndrives the tool: which of the three modes it is, which axes the handles point along, what the pointer\nis over by name, how far a rotation has turned, and the transform the drag has produced so it can be\nwritten onto an entity.\n\n**`editor` is the effect, and unusually the language had already named it.** Every other track that\narrived at this point found the vocabulary short: terrain ships under `physics.read` and says so in\nits binding, and navigation waited on a DriftScript release. `Effect` has carried `editor` since it\nwas written, outside the deterministic set, which is exactly right — a gizmo holds a person's pointer\npart way through moving something, so a `@deterministic` system reading one would replay differently.\nIt is the effect `@editor` was designed to pair with.\n\n**Three capabilities where an argument would have done.** `translateMode`, `rotateMode` and\n`scaleMode` rather than one taking a mode. The language has no enum, so the argument would be a\nstring or a number, and an unrecognised one leaves two options: change nothing, which is the silent\nno-op `host.ts` rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the\ninvalid call impossible to write, and the checker refuses it instead of a runtime branch. The\n*readers* answer strings, because a reader is total and has no invalid value to report.\n\n**And nothing here picks**, for the reason the gizmo itself does not: the host runs the pointer."
675
+ "text": "**`drift/editor` was one of six specified surfaces nothing implemented, and it is five now.** A script\ndrives the tool: which of the three modes it is, which axes the handles point along, what the pointer\nis over by name, how far a rotation has turned, and the transform the drag has produced so it can be\nwritten onto an entity.\n\n**`editor` is the effect, and unusually the language had already named it.** Every other track that\narrived at this point found the vocabulary short: terrain ships under `physics.read` and says so in\nits binding, and navigation waited on a DriftScript release. `Effect` has carried `editor` since it\nwas written, outside the deterministic set, which is exactly right \u2014 a gizmo holds a person's pointer\npart way through moving something, so a `@deterministic` system reading one would replay differently.\nIt is the effect `@editor` was designed to pair with.\n\n**Three capabilities where an argument would have done.** `translateMode`, `rotateMode` and\n`scaleMode` rather than one taking a mode. The language has no enum, so the argument would be a\nstring or a number, and an unrecognised one leaves two options: change nothing, which is the silent\nno-op `host.ts` rules out, or throw inside a frame, which `AGENTS.md` rules out. Three names make the\ninvalid call impossible to write, and the checker refuses it instead of a runtime branch. The\n*readers* answer strings, because a reader is total and has no invalid value to report.\n\n**And nothing here picks**, for the reason the gizmo itself does not: the host runs the pointer."
660
676
  },
661
677
  {
662
678
  "kind": "changed",
663
679
  "short": "PickableSet is on the barrel, the class and not only its types. Picking shipped in 3.19.0 and a consumer could describe a set without being able to build one.",
664
- "text": "**Every renderer has held a `PickableSet` since picking shipped**, and `registerPickable`,\n`updatePickable`, `unregisterPickable` and `pickAt` have all been on `RendererApi` — so picking\nagainst what a renderer draws has been reachable the whole time. What was not exported is the class,\nso a consumer wanting a *second* set, over gizmo handles or over anything a renderer never draws, had\n`PickHit` and `PickableSource` to describe one and no way to construct it.\n\n**Two documents said picking did not ship at all.** The register of unpriced work had the gizmo row\nas *\"picking, a handle to drag, a transform gizmo\"*, and `docs/CAPABILITIES.md` said \"no handle to\ndrag, no picking, no gizmo\" in one row while listing \"ray picking\" as shipped two rows above it — a\nmap contradicting itself for eighteen days. Both are corrected.\n\n**What that cost was the ranking rather than the estimate.** A row priced at three pieces of work\nsits differently from one priced at two, and this one was passed over twice. The check is one grep,\nand it is the same check the `drift/math` row needed and did not get: that one was filed as a missing\nlanguage module when `std/math` had thirteen functions the whole time."
680
+ "text": "**Every renderer has held a `PickableSet` since picking shipped**, and `registerPickable`,\n`updatePickable`, `unregisterPickable` and `pickAt` have all been on `RendererApi` \u2014 so picking\nagainst what a renderer draws has been reachable the whole time. What was not exported is the class,\nso a consumer wanting a *second* set, over gizmo handles or over anything a renderer never draws, had\n`PickHit` and `PickableSource` to describe one and no way to construct it.\n\n**Two documents said picking did not ship at all.** The register of unpriced work had the gizmo row\nas *\"picking, a handle to drag, a transform gizmo\"*, and `docs/CAPABILITIES.md` said \"no handle to\ndrag, no picking, no gizmo\" in one row while listing \"ray picking\" as shipped two rows above it \u2014 a\nmap contradicting itself for eighteen days. Both are corrected.\n\n**What that cost was the ranking rather than the estimate.** A row priced at three pieces of work\nsits differently from one priced at two, and this one was passed over twice. The check is one grep,\nand it is the same check the `drift/math` row needed and did not get: that one was filed as a missing\nlanguage module when `std/math` had thirteen functions the whole time."
665
681
  },
666
682
  {
667
683
  "kind": "fixed",
668
684
  "short": "The core size floor was 505 bytes stale, so every KB-over-core figure in nine READMEs and the root table read 0.5 KB high. Measured against main rather than derived from the floor.",
669
- "text": "**The floors drifted again, nine days after refraction found them 15.4 KB behind.** This time it was\n505 bytes, which is 0.078% and which the 3% tolerance passes without a word — the failure mode\n`scripts/size-floors.mjs` warns about in its own header: **a floor passes at the true number and at\nevery stale one below it.**\n\n**The difference is that it was looked for rather than stumbled on.** A new capability re-measures the\nfloors whether or not the gate asks, and the A/B that followed is what separates a correction from a\nguess: `core-only` against this branch is 645,522 bytes and against the commit before it 645,523. One\nbyte apart, in the direction that says the new code costs a consumer of core nothing — confirmed\ndirectly by the bundle, which contains no gizmo symbol.\n\n**So every `KB gzipped over core` figure read half a kilobyte high**, in eight package READMEs and\nagain in the root table, each under a sentence claiming to be a fact about the build. `IMPROVEMENTS.md`\nhad carried the sweep as its own commit for a while; it lands here because this is the commit that\nmoved the floor."
685
+ "text": "**The floors drifted again, nine days after refraction found them 15.4 KB behind.** This time it was\n505 bytes, which is 0.078% and which the 3% tolerance passes without a word \u2014 the failure mode\n`scripts/size-floors.mjs` warns about in its own header: **a floor passes at the true number and at\nevery stale one below it.**\n\n**The difference is that it was looked for rather than stumbled on.** A new capability re-measures the\nfloors whether or not the gate asks, and the A/B that followed is what separates a correction from a\nguess: `core-only` against this branch is 645,522 bytes and against the commit before it 645,523. One\nbyte apart, in the direction that says the new code costs a consumer of core nothing \u2014 confirmed\ndirectly by the bundle, which contains no gizmo symbol.\n\n**So every `KB gzipped over core` figure read half a kilobyte high**, in eight package READMEs and\nagain in the root table, each under a sentence claiming to be a fact about the build. `IMPROVEMENTS.md`\nhad carried the sweep as its own commit for a while; it lands here because this is the commit that\nmoved the floor."
670
686
  }
671
687
  ]
672
688
  },
@@ -677,22 +693,22 @@
677
693
  {
678
694
  "kind": "added",
679
695
  "short": "Refraction, which completes Track D: a colour snapshot sampled along the surface normal, absorbed by Beer-Lambert over a path length that grows at grazing angles. 9.05 KB over core.",
680
- "text": "**Track D is complete.** Glass bends and tints what is behind it: a copy of the frame's colour,\nlatched at the first refracting draw, sampled at an offset along the surface normal, and absorbed by\nBeer-Lambert over a path length that grows at grazing angles.\n\n**9.05 KB gzipped over core, against the 19.8 KB it had been priced at.** That estimate came off the\nbranch-in-`flatFrag` figure Track D's earlier rows measured, and it was right about where the cost\nlives and wrong about how much: a uniform and a branch that reads one sampler is not the size of the\nbranch the figure came from. The fragment stage stays at sixteen permutations and the vertex stage at\nfive. A frame that refracts nothing takes no copy, breaks no pass and binds an empty texel.\n\n**The snapshot is latched rather than declared**, because there is no translucent-set boundary to\nhang it on: `drawTranslucentMesh` draws immediately and the queue exists so order-independent\ntransparency can replay the set. So the first refracting draw takes the copy and the rest of the\nframe reuses it. Glass does not refract other glass, which is right; opaque geometry drawn after the\nfirst pane is missing from what that pane shows, which is the trap `snapshotDepth` already documents.\n\n**Neither backend can read what it is writing, and they answer differently.** WebGL2 blits, which\nresolves a multisampled source and therefore works in exactly the configuration where\n`colorAttachment` returns null. WebGPU ends the pass, copies the resolved `post.sceneColor` and\nreopens with `loadOp: 'load'` — a fourth instance of a boundary this backend already runs for the\nmirror, the light volume's depth snapshot and text.\n\n**The absorption is stated as what survives one metre** rather than as a coefficient: `pow(tint, d)`\nis `exp(-absorption * d)`, so clear glass is `(1, 1, 1)` with no special case and no `log(0)` at the\nAPI. **The path length divides by `dot(N, V)`**, which is the whole difference between this and a\ntint — a slab seen edge-on is more glass than the same slab face-on, so a glass edge goes green while\nits middle stays clear. Thickness rides the per-vertex channel's `.w` lane, which 3.42.0 declared\nreserved; it cost a varying and no attribute, where a fifteenth attribute would have spent one of the\ntwo vertex locations left.\n\n**Texture unit 14 of the two that were free.** One remains, and the next sampler has to fold into an\nexisting binding the way the point-shadow cubemaps became one array, rather than take the last one.\n\n**Its check found five defects before it went green, and only two were in the shader.** The scene was\nwrong twice: a flat pane facing the camera has a normal of (0,0,1) and correctly displaces nothing,\nand a rippled one displaces differently at every x, which smears rather than moves. The shader used\none scalar as both the offset and the blend, so a plausible 0.025 offset also meant a 2.5% blend and\nglass drew as flat paint. **Three were on WebGPU and every one silent**: `post.sceneColor` had no\n`COPY_SRC`; the pass is opened lazily, so there was none to end at the moment a refracting draw\nasked; and the flat bind group is built at construction and cached, so it held the one-texel white\nstand-in for the snapshot forever and every pane came out pure white — 255,255,255 against WebGL2's\n108.9, with the non-refracting variants agreeing to a tenth on both.\n\nMeasured on both backends, agreeing to the digit: the pattern behind a pane shifts 11 px at one\nstrength and 23 at double; a tinted pane reads 9.7,88.4,18.6 against a clear 108.9; an edge-on pane\nabsorbs to 32.03 where face-on holds 38.55; the thickness lane separates 85.21 from 39.44.\n\n**What it gives up**: refraction replaces the surface's own shading rather than mixing with it, so\nglass carries no specular of its own. By that line the shaded colour has the lighting, the fog and\nthe tone curve folded into it and there is no highlight left to keep. A caller wanting a glint draws\na second, non-refracting pass over the same geometry.\n\n**Also**: the size floors were 15.4 KB behind reality and are swept. About 6.1 KB of that is 3.42.0's\nper-vertex channel, which stayed inside the 3% tolerance and so moved no floor and raised no gate."
696
+ "text": "**Track D is complete.** Glass bends and tints what is behind it: a copy of the frame's colour,\nlatched at the first refracting draw, sampled at an offset along the surface normal, and absorbed by\nBeer-Lambert over a path length that grows at grazing angles.\n\n**9.05 KB gzipped over core, against the 19.8 KB it had been priced at.** That estimate came off the\nbranch-in-`flatFrag` figure Track D's earlier rows measured, and it was right about where the cost\nlives and wrong about how much: a uniform and a branch that reads one sampler is not the size of the\nbranch the figure came from. The fragment stage stays at sixteen permutations and the vertex stage at\nfive. A frame that refracts nothing takes no copy, breaks no pass and binds an empty texel.\n\n**The snapshot is latched rather than declared**, because there is no translucent-set boundary to\nhang it on: `drawTranslucentMesh` draws immediately and the queue exists so order-independent\ntransparency can replay the set. So the first refracting draw takes the copy and the rest of the\nframe reuses it. Glass does not refract other glass, which is right; opaque geometry drawn after the\nfirst pane is missing from what that pane shows, which is the trap `snapshotDepth` already documents.\n\n**Neither backend can read what it is writing, and they answer differently.** WebGL2 blits, which\nresolves a multisampled source and therefore works in exactly the configuration where\n`colorAttachment` returns null. WebGPU ends the pass, copies the resolved `post.sceneColor` and\nreopens with `loadOp: 'load'` \u2014 a fourth instance of a boundary this backend already runs for the\nmirror, the light volume's depth snapshot and text.\n\n**The absorption is stated as what survives one metre** rather than as a coefficient: `pow(tint, d)`\nis `exp(-absorption * d)`, so clear glass is `(1, 1, 1)` with no special case and no `log(0)` at the\nAPI. **The path length divides by `dot(N, V)`**, which is the whole difference between this and a\ntint \u2014 a slab seen edge-on is more glass than the same slab face-on, so a glass edge goes green while\nits middle stays clear. Thickness rides the per-vertex channel's `.w` lane, which 3.42.0 declared\nreserved; it cost a varying and no attribute, where a fifteenth attribute would have spent one of the\ntwo vertex locations left.\n\n**Texture unit 14 of the two that were free.** One remains, and the next sampler has to fold into an\nexisting binding the way the point-shadow cubemaps became one array, rather than take the last one.\n\n**Its check found five defects before it went green, and only two were in the shader.** The scene was\nwrong twice: a flat pane facing the camera has a normal of (0,0,1) and correctly displaces nothing,\nand a rippled one displaces differently at every x, which smears rather than moves. The shader used\none scalar as both the offset and the blend, so a plausible 0.025 offset also meant a 2.5% blend and\nglass drew as flat paint. **Three were on WebGPU and every one silent**: `post.sceneColor` had no\n`COPY_SRC`; the pass is opened lazily, so there was none to end at the moment a refracting draw\nasked; and the flat bind group is built at construction and cached, so it held the one-texel white\nstand-in for the snapshot forever and every pane came out pure white \u2014 255,255,255 against WebGL2's\n108.9, with the non-refracting variants agreeing to a tenth on both.\n\nMeasured on both backends, agreeing to the digit: the pattern behind a pane shifts 11 px at one\nstrength and 23 at double; a tinted pane reads 9.7,88.4,18.6 against a clear 108.9; an edge-on pane\nabsorbs to 32.03 where face-on holds 38.55; the thickness lane separates 85.21 from 39.44.\n\n**What it gives up**: refraction replaces the surface's own shading rather than mixing with it, so\nglass carries no specular of its own. By that line the shaded colour has the lighting, the fog and\nthe tone curve folded into it and there is no highlight left to keep. A caller wanting a glint draws\na second, non-refracting pass over the same geometry.\n\n**Also**: the size floors were 15.4 KB behind reality and are swept. About 6.1 KB of that is 3.42.0's\nper-vertex channel, which stayed inside the 3% tolerance and so moved no floor and raised no gate."
681
697
  },
682
698
  {
683
699
  "kind": "changed",
684
700
  "short": "A celestial site can name its own clock: longitudeDeg and utcOffsetHours make the timestamp a plain UTC instant, so the host machine's time zone stops choosing the hour and the moon phase.",
685
- "text": "**There was no way to tell `celestialStateAt` which hour to put the sun at.** It read the hour off\nthe timestamp with `Date#getHours` — the *host machine's* wall clock — so one argument and one\ninstant, with `dayOfYear` pinned, put solar noon at 12:00 under `TZ=UTC`, 10:00 in Rome, 16:00 in New\nYork and 03:00 in Tokyo. The only way to name an hour was to build a timestamp whose host offset\ncancelled the one being added.\n\n**That trick steadies the sun and cannot reach the moon.** `moonPhase` comes from the timestamp's\nabsolute value rather than from its hour, so a caller handing this function a different instant on\nevery machine — which is exactly what the workaround does — got a different phase on every machine.\n\n**`CelestialSite.longitudeDeg` and `utcOffsetHours` are the way to say it.** Naming either makes the\ntimestamp a plain UTC instant and the engine derives the site's own hour from it: the offset naming\nits calendar day, the meridian placing its sun, up to half an hour off the centre of its zone. That\nlast correction is the one this module's header had always warned about and handed back to the\ncaller, and it is the engine's now. A longitude alone implies the offset of its own meridian, so a\nsite giving one and not the other still describes a consistent clock.\n\n**A site naming neither, and a caller passing no site, keeps `getHours` exactly.** That is the model\nevery world here is tuned against, so the defect is *kept* in that form rather than quietly removed,\nand a test asserts the four numbers above so nobody makes it host-independent and moves every shadow\nin every consumer at once.\n\n**The equation of time stays the caller's**, which is not an oversight: it is a property of the\nplanet's orbit rather than of the place, and a world with an obliquity of its own has an equation of\ntime of its own.\n\nFound by a player in Italy watching the sun set before six."
701
+ "text": "**There was no way to tell `celestialStateAt` which hour to put the sun at.** It read the hour off\nthe timestamp with `Date#getHours` \u2014 the *host machine's* wall clock \u2014 so one argument and one\ninstant, with `dayOfYear` pinned, put solar noon at 12:00 under `TZ=UTC`, 10:00 in Rome, 16:00 in New\nYork and 03:00 in Tokyo. The only way to name an hour was to build a timestamp whose host offset\ncancelled the one being added.\n\n**That trick steadies the sun and cannot reach the moon.** `moonPhase` comes from the timestamp's\nabsolute value rather than from its hour, so a caller handing this function a different instant on\nevery machine \u2014 which is exactly what the workaround does \u2014 got a different phase on every machine.\n\n**`CelestialSite.longitudeDeg` and `utcOffsetHours` are the way to say it.** Naming either makes the\ntimestamp a plain UTC instant and the engine derives the site's own hour from it: the offset naming\nits calendar day, the meridian placing its sun, up to half an hour off the centre of its zone. That\nlast correction is the one this module's header had always warned about and handed back to the\ncaller, and it is the engine's now. A longitude alone implies the offset of its own meridian, so a\nsite giving one and not the other still describes a consistent clock.\n\n**A site naming neither, and a caller passing no site, keeps `getHours` exactly.** That is the model\nevery world here is tuned against, so the defect is *kept* in that form rather than quietly removed,\nand a test asserts the four numbers above so nobody makes it host-independent and moves every shadow\nin every consumer at once.\n\n**The equation of time stays the caller's**, which is not an oversight: it is a property of the\nplanet's orbit rather than of the place, and a world with an obliquity of its own has an equation of\ntime of its own.\n\nFound by a player in Italy watching the sun set before six."
686
702
  },
687
703
  {
688
704
  "kind": "added",
689
705
  "short": "The last four engine rows from an in-repo consumer's gap report: nearest filtering, a stick a caller need not supply, an additive stroke, and a component field that can hold an enum.",
690
- "text": "**`demo/voxelSandbox/GAPS.md` has no open engine rows left.** Four closed here, on top of the three\n3.42.0 answered with the per-vertex channel. What remains in that file is the port's own work.\n\n**A surface texture can be asked for nearest magnification.** `filter` on `SurfaceTextureOptions`,\ndefaulting to `linear`. Linear magnification turned an authored pixel-art tile into a smear as the\ncamera approached, and the consumer's workarounds — composing the atlas with `imageSmoothingEnabled`\noff, keeping tiles at twice the resolution they wanted — could not reach it, because the smoothing\nhappens in the sampler after everything a caller controls. Minification still blends between mip\nlevels: the option is about magnification, and taking the nearest level too would trade a smear for\na visible pop as the camera pulls back. WebGPU refuses `maxAnisotropy` above 1 unless every filter is\n`linear`, so a nearest sampler gets 1 there and an ignored request on WebGL2 — that file had no test\nat all, and a refused sampler is a bind group that never builds and a frame that draws nothing.\n\n**`TouchControls` takes its stick nodes as options, or not at all.** They were positional and read as\ndependencies, so a consumer forbidden the page concluded the class needed two `HTMLElement`s and\nbuilt two `div`s it never appended anywhere. They are an output channel: only ever assigned `hidden`\nand `style`, never measured, with every touch coming from `input.target`. `new TouchControls(input)`\nis now a working scheme with no visible stick, and the positional form keeps working. Which form was\ncalled is decided by arity rather than by inspecting the second argument — `instanceof HTMLElement`\nneeds a DOM constructor this package is checked without, and duck-typing on `nodeType` reads a\nstand-in element as an options object, which the file's own harness supplies.\n\n**A stroke can say it is light.** `drawLines` takes a blend, so a glowing outline is one additive\npass rather than two alpha-blended ones at different softness faking a halo — which tinted toward the\nglow colour against a bright surface instead of adding to it. It is a blend and not a bolt mode,\nwhich `AGENTS.md` forbids: what separates a line from a bolt is the *path*, a bolt jitters because it\nis lightning, and an additive line still follows the path it was given, keeps its clean edge and is\nstill fogged. Blending is pipeline state on WebGPU, so the additive arm is a second pipeline under\nits own cache key.\n\n**A component field can hold a fieldless enum**, which is where a state machine's state lives. The\ndiscriminant takes an integer column, because that is what it always was. This was read as a language\ngap by two documents and is not one: DriftScript has `enum`, and `match` over one refuses to compile\nuntil every variant is handled — its own corpus advertises exactly that against a Lua version which\nwould silently do nothing for a state nobody wrote a branch for. What refused was this engine's\ncomponent store. So the corpus keeps its `Alertness` in a `data` record, which is not what a system\niterates, and the consumer shipped `mood: i32` with three numbers written out in a comment.\n\n`Int32Array` and not a byte, and the width is the one decision the report did not make: a byte would\nhold every enum anybody is likely to write, and `defineComponent` is handed `enum:Mood` — a name,\nwith the variant list belonging to the module that declared it. It cannot count them, and guessing\ntoo small fails silently, so it spends three bytes it will usually not need."
706
+ "text": "**`demo/voxelSandbox/GAPS.md` has no open engine rows left.** Four closed here, on top of the three\n3.42.0 answered with the per-vertex channel. What remains in that file is the port's own work.\n\n**A surface texture can be asked for nearest magnification.** `filter` on `SurfaceTextureOptions`,\ndefaulting to `linear`. Linear magnification turned an authored pixel-art tile into a smear as the\ncamera approached, and the consumer's workarounds \u2014 composing the atlas with `imageSmoothingEnabled`\noff, keeping tiles at twice the resolution they wanted \u2014 could not reach it, because the smoothing\nhappens in the sampler after everything a caller controls. Minification still blends between mip\nlevels: the option is about magnification, and taking the nearest level too would trade a smear for\na visible pop as the camera pulls back. WebGPU refuses `maxAnisotropy` above 1 unless every filter is\n`linear`, so a nearest sampler gets 1 there and an ignored request on WebGL2 \u2014 that file had no test\nat all, and a refused sampler is a bind group that never builds and a frame that draws nothing.\n\n**`TouchControls` takes its stick nodes as options, or not at all.** They were positional and read as\ndependencies, so a consumer forbidden the page concluded the class needed two `HTMLElement`s and\nbuilt two `div`s it never appended anywhere. They are an output channel: only ever assigned `hidden`\nand `style`, never measured, with every touch coming from `input.target`. `new TouchControls(input)`\nis now a working scheme with no visible stick, and the positional form keeps working. Which form was\ncalled is decided by arity rather than by inspecting the second argument \u2014 `instanceof HTMLElement`\nneeds a DOM constructor this package is checked without, and duck-typing on `nodeType` reads a\nstand-in element as an options object, which the file's own harness supplies.\n\n**A stroke can say it is light.** `drawLines` takes a blend, so a glowing outline is one additive\npass rather than two alpha-blended ones at different softness faking a halo \u2014 which tinted toward the\nglow colour against a bright surface instead of adding to it. It is a blend and not a bolt mode,\nwhich `AGENTS.md` forbids: what separates a line from a bolt is the *path*, a bolt jitters because it\nis lightning, and an additive line still follows the path it was given, keeps its clean edge and is\nstill fogged. Blending is pipeline state on WebGPU, so the additive arm is a second pipeline under\nits own cache key.\n\n**A component field can hold a fieldless enum**, which is where a state machine's state lives. The\ndiscriminant takes an integer column, because that is what it always was. This was read as a language\ngap by two documents and is not one: DriftScript has `enum`, and `match` over one refuses to compile\nuntil every variant is handled \u2014 its own corpus advertises exactly that against a Lua version which\nwould silently do nothing for a state nobody wrote a branch for. What refused was this engine's\ncomponent store. So the corpus keeps its `Alertness` in a `data` record, which is not what a system\niterates, and the consumer shipped `mood: i32` with three numbers written out in a comment.\n\n`Int32Array` and not a byte, and the width is the one decision the report did not make: a byte would\nhold every enum anybody is likely to write, and `defineComponent` is handed `enum:Mood` \u2014 a name,\nwith the variant list belonging to the module that declared it. It cannot count them, and guessing\ntoo small fails silently, so it spends three bytes it will usually not need."
691
707
  },
692
708
  {
693
709
  "kind": "fixed",
694
710
  "short": "The per-vertex channel's fourth lane defaulted to 0 while carrying thickness, so a refracting mesh with no channel array absorbed nothing. A multiplier's identity is one.",
695
- "text": "**A defect introduced in this release, found before it shipped, and worth recording because of how it\nhid.** 3.42.0 declared the channel's fourth lane *reserved and unread* and gave it an absent value of\n0, which was right for a lane nothing reads. Refraction then made it the thickness multiplier and\nleft the 0 in place.\n\nThe consequence is not subtle: the path length is `thickness * lane / dot(N, V)`, so a lane of zero\nmakes it zero, `pow(tint, 0)` is one, and **a pane naming a tint and a thickness bent the scene behind\nit and took no colour out of it at all**. Every lane in that tuple is a multiplier or an amount, and\nthe identity for a multiplier is one.\n\n**It survived the check because every subject the check draws supplies a channel array**, so the\nabsent value was never the thing being measured — the page had eight variants and all eight carried\none. There is a ninth now that carries none, and against the old default it reads the clear pane's\ncolour exactly: 108.9 grey where it should read 9.7,88.4,18.6.\n\nThat is the second defect this lane has produced in two releases. The other was a duplicate attribute\nlocation on every instanced WebGPU pipeline, found by a consumer bisecting a scrambled frame. Both\nhave the same shape: a value that was correct for a lane nobody reads, left standing when something\nstarted reading it."
711
+ "text": "**A defect introduced in this release, found before it shipped, and worth recording because of how it\nhid.** 3.42.0 declared the channel's fourth lane *reserved and unread* and gave it an absent value of\n0, which was right for a lane nothing reads. Refraction then made it the thickness multiplier and\nleft the 0 in place.\n\nThe consequence is not subtle: the path length is `thickness * lane / dot(N, V)`, so a lane of zero\nmakes it zero, `pow(tint, 0)` is one, and **a pane naming a tint and a thickness bent the scene behind\nit and took no colour out of it at all**. Every lane in that tuple is a multiplier or an amount, and\nthe identity for a multiplier is one.\n\n**It survived the check because every subject the check draws supplies a channel array**, so the\nabsent value was never the thing being measured \u2014 the page had eight variants and all eight carried\none. There is a ninth now that carries none, and against the old default it reads the clear pane's\ncolour exactly: 108.9 grey where it should read 9.7,88.4,18.6.\n\nThat is the second defect this lane has produced in two releases. The other was a duplicate attribute\nlocation on every instanced WebGPU pipeline, found by a consumer bisecting a scrambled frame. Both\nhave the same shape: a value that was correct for a lane nobody reads, left standing when something\nstarted reading it."
696
712
  }
697
713
  ]
698
714
  },
@@ -714,7 +730,7 @@
714
730
  {
715
731
  "kind": "added",
716
732
  "short": "Track F: the 2D layer as `@driftengine/ui2d`. Batched sprites, sheets, tilemaps culled to the view, and a retained interface tree with layout, focus and input routing. `drift/2d` and `drift/ui` bound.",
717
- "text": "**Track F ships, as `@driftengine/ui2d`, and it draws through `registerPass` rather than through\na verb on the renderer.** That is what lets it be a package at all — `pass.ts` in core names this\npackage by name as one of the three the contributed-pass seam exists for — and it means a\nconsumer that never draws a sprite carries no sampler, no pipeline and no generated WGSL for one.\n**8.7 KB gzipped on top of core**, which places it on Track D's own price table: a uniform and a\nbranch in `flatFrag` cost 19.8 KB across sixteen fragment permutations, a shader of its own cost\n4 KB for drawn decals and 7.5 KB for screen-space reflection, and a package of pure arithmetic\ncost 1.4 KB for terrain. The alternative a consumer actually asked for — `drawPanelTexture` beside\n`fillPanel`, filed in `demo/voxelSandbox/GAPS.md` — is the 19.8 KB row.\n\n**Two coordinate systems, and they count y opposite ways.** `screenToNdc` is CSS pixels from the\ntop-left, the convention `InsetRect` and `fillPanel` already use; `worldToNdc` is a 2D world\nthrough a `Camera2D`, where y counts up, because a caller placing a platform above a floor should\nnot have to subtract. One shared convention would make every caller wrong half the time.\n\n**No depth is written and none is tested: the order sprites were submitted in is the layering**,\nwhich is the decision gate 1.2 already took about pass ordering. So the batch keeps *runs* rather\nthan grouping by texture — consecutive sprites on one slot are one draw, a slot change starts a\nrun, and going back to the first slot starts a third rather than rejoining, because rejoining\nwould reorder the picture. `AGENTS.md` asks for exactly that shape: the number to hold down is\nmaterial changes, not draws. The batch does not grow; past its capacity `drawSprite` counts into\n`dropped` rather than allocating under a frame.\n\n**`drawTilemap` costs the view rather than the map.** The visible span is arithmetic on four\nnumbers, measured at **25 tiles drawn out of a million-cell map**.\n\n**The retained tree is the other half and it touches no GPU at all.** `layoutUiTree` is two passes\nover an object graph — measure bottom-up, place top-down — and no third, because a `fit` size that\ndepended on the space it was given would need a rule for when to stop. Three size cases (`fit`,\n`grow`, a number) rather than percentages or flex weights, since a percentage of a `fit` parent is\na cycle. It allocates nothing: every number lands in a `UiRect` the node already owns, which is\nwhy a node is an object with fields rather than a description that gets resolved into one.\n\n**`uiHitTest` searches last-drawn first, so the hit test agrees with the picture**, and a node that\nis not `interactive` does not block what is behind it — a backdrop that swallowed clicks makes\nevery button under a plate dead, which no screenshot shows. `routeUiPointer` activates on a press\nand a release **on the same node**, so somebody who pressed the wrong button can slide off it.\nFocus order is tree order rather than a declared index, and a node hidden since it was focused\ndoes not take the keyboard with it. The pass fills a **white slot of its own**, from four bytes\nrather than a canvas, so a solid rectangle needs no sheet.\n\n**Two checks on the card, on both backends, and one of them found a real defect on its first\nrun.** `scripts/sprite-check.mjs` is 22 assertions over sheets that are two texels by two with\nfour different colours, so a sprite's four quadrants say which corner is which — an assertion a\nmirror fails and a bounding box cannot, since a mirror preserves every count and every box. The\ntwo sheets arrive as a `<canvas>` and an `ImageBitmap`, because WebGL2 ignores\n`UNPACK_FLIP_Y_WEBGL` for one and honours it for the other. A sprite lands on its CSS rectangle to\n**0.00 px** on every edge and the two backends agree byte for byte in both submission orders.\n`scripts/ui-check.mjs` is 17 more: the page publishes the boxes the layout computed *and* the\nbounding boxes of the colours they were drawn in, and asserts the two agree — a bar sized by its\nown contents lands at 530, 660, 220 by 60 to **0.0 px**, byte-identical in all three pointer\nstates, with the pointer visible as a colour rather than read back from the router.\n\n**The defect: a contributed pass inherits whatever cull state the last scene draw left on**, and a\nscreen-space quad's winding is not the scene's — so WebGL2 drew *nothing at all* while WebGPU,\nwhose pipeline states its own cull mode, was pixel-perfect. No error on either side, no validation\nmessage, no warning. Every backend difference this seam has is that shape: one API carries state\nbetween draws and the other does not.\n\n**`drift/ui` is bound** — eighteen capabilities over a tree addressed by the names its nodes were\nbuilt with, because the language has no optional to answer a missing node with inside a frame\nloop. Reading a laid-out box is `scene.read` and inside the fixed step; `hovered` and `pressed`\nare `input.read` and outside it, so a `@deterministic` system cannot ask where the pointer is —\nthey are fields on the tree and read like any other, so `scene.read` would type-check and would\nbe a lie about what they carry.\n\n**`drift/2d` is bound with it, and getting there took a language release and one day.** A\nmodule's namespace was the last segment of its path -- `split('/').pop()`, in three places in\nthe compiler, with no alias -- so a call would have been `2d.sprite(...)`, and `2d` is a number\nfollowed by an identifier the lexer refuses before the checker sees it. Five spellings were\ncompiled against the real registry and every one failed: the bare name, the namespace, `import\nas`, `import *`, a backquoted segment. `ui.layout` and `terrain.heightAt` compiled in the same\nharness, so the failure was the name and nothing else.\n\n**So the binding was written, wired, found uncallable and withdrawn rather than shipped.**\nRegistering it would have traded `DS0301`, which says nothing here implements it and is\nactionable, for `DS0003` inside a script author's own call -- a capability nobody can spell is\nthe silent no-op this repository forbids, wearing a feature's clothes. It was recorded with a\nsentinel and filed against the language, and **DriftScript 1.11.0 answered it at the import**:\n`import { sprite } from \"drift/2d\" as sprites`, with `DS0139` refusing a namespace that cannot\nbe written and saying the line to write. The fix is at the import rather than at the derivation,\nbecause a rule that turned a bad segment into a good identifier would be the language picking a\nname on the author's behalf.\n\nThe engine was already on 1.11.0 by the time this landed, so what moves here is the binding and\nthe sentinel with it. The round trip took one day. Twelve capabilities over three opaque types -- a batch, a sheet and a tilemap -- with a\ndraw as `scene.write` and a read of a sheet or a cell as `scene.read`, so a `@deterministic`\nsystem may ask what is in a cell and may not draw it."
733
+ "text": "**Track F ships, as `@driftengine/ui2d`, and it draws through `registerPass` rather than through\na verb on the renderer.** That is what lets it be a package at all \u2014 `pass.ts` in core names this\npackage by name as one of the three the contributed-pass seam exists for \u2014 and it means a\nconsumer that never draws a sprite carries no sampler, no pipeline and no generated WGSL for one.\n**8.7 KB gzipped on top of core**, which places it on Track D's own price table: a uniform and a\nbranch in `flatFrag` cost 19.8 KB across sixteen fragment permutations, a shader of its own cost\n4 KB for drawn decals and 7.5 KB for screen-space reflection, and a package of pure arithmetic\ncost 1.4 KB for terrain. The alternative a consumer actually asked for \u2014 `drawPanelTexture` beside\n`fillPanel`, filed in `demo/voxelSandbox/GAPS.md` \u2014 is the 19.8 KB row.\n\n**Two coordinate systems, and they count y opposite ways.** `screenToNdc` is CSS pixels from the\ntop-left, the convention `InsetRect` and `fillPanel` already use; `worldToNdc` is a 2D world\nthrough a `Camera2D`, where y counts up, because a caller placing a platform above a floor should\nnot have to subtract. One shared convention would make every caller wrong half the time.\n\n**No depth is written and none is tested: the order sprites were submitted in is the layering**,\nwhich is the decision gate 1.2 already took about pass ordering. So the batch keeps *runs* rather\nthan grouping by texture \u2014 consecutive sprites on one slot are one draw, a slot change starts a\nrun, and going back to the first slot starts a third rather than rejoining, because rejoining\nwould reorder the picture. `AGENTS.md` asks for exactly that shape: the number to hold down is\nmaterial changes, not draws. The batch does not grow; past its capacity `drawSprite` counts into\n`dropped` rather than allocating under a frame.\n\n**`drawTilemap` costs the view rather than the map.** The visible span is arithmetic on four\nnumbers, measured at **25 tiles drawn out of a million-cell map**.\n\n**The retained tree is the other half and it touches no GPU at all.** `layoutUiTree` is two passes\nover an object graph \u2014 measure bottom-up, place top-down \u2014 and no third, because a `fit` size that\ndepended on the space it was given would need a rule for when to stop. Three size cases (`fit`,\n`grow`, a number) rather than percentages or flex weights, since a percentage of a `fit` parent is\na cycle. It allocates nothing: every number lands in a `UiRect` the node already owns, which is\nwhy a node is an object with fields rather than a description that gets resolved into one.\n\n**`uiHitTest` searches last-drawn first, so the hit test agrees with the picture**, and a node that\nis not `interactive` does not block what is behind it \u2014 a backdrop that swallowed clicks makes\nevery button under a plate dead, which no screenshot shows. `routeUiPointer` activates on a press\nand a release **on the same node**, so somebody who pressed the wrong button can slide off it.\nFocus order is tree order rather than a declared index, and a node hidden since it was focused\ndoes not take the keyboard with it. The pass fills a **white slot of its own**, from four bytes\nrather than a canvas, so a solid rectangle needs no sheet.\n\n**Two checks on the card, on both backends, and one of them found a real defect on its first\nrun.** `scripts/sprite-check.mjs` is 22 assertions over sheets that are two texels by two with\nfour different colours, so a sprite's four quadrants say which corner is which \u2014 an assertion a\nmirror fails and a bounding box cannot, since a mirror preserves every count and every box. The\ntwo sheets arrive as a `<canvas>` and an `ImageBitmap`, because WebGL2 ignores\n`UNPACK_FLIP_Y_WEBGL` for one and honours it for the other. A sprite lands on its CSS rectangle to\n**0.00 px** on every edge and the two backends agree byte for byte in both submission orders.\n`scripts/ui-check.mjs` is 17 more: the page publishes the boxes the layout computed *and* the\nbounding boxes of the colours they were drawn in, and asserts the two agree \u2014 a bar sized by its\nown contents lands at 530, 660, 220 by 60 to **0.0 px**, byte-identical in all three pointer\nstates, with the pointer visible as a colour rather than read back from the router.\n\n**The defect: a contributed pass inherits whatever cull state the last scene draw left on**, and a\nscreen-space quad's winding is not the scene's \u2014 so WebGL2 drew *nothing at all* while WebGPU,\nwhose pipeline states its own cull mode, was pixel-perfect. No error on either side, no validation\nmessage, no warning. Every backend difference this seam has is that shape: one API carries state\nbetween draws and the other does not.\n\n**`drift/ui` is bound** \u2014 eighteen capabilities over a tree addressed by the names its nodes were\nbuilt with, because the language has no optional to answer a missing node with inside a frame\nloop. Reading a laid-out box is `scene.read` and inside the fixed step; `hovered` and `pressed`\nare `input.read` and outside it, so a `@deterministic` system cannot ask where the pointer is \u2014\nthey are fields on the tree and read like any other, so `scene.read` would type-check and would\nbe a lie about what they carry.\n\n**`drift/2d` is bound with it, and getting there took a language release and one day.** A\nmodule's namespace was the last segment of its path -- `split('/').pop()`, in three places in\nthe compiler, with no alias -- so a call would have been `2d.sprite(...)`, and `2d` is a number\nfollowed by an identifier the lexer refuses before the checker sees it. Five spellings were\ncompiled against the real registry and every one failed: the bare name, the namespace, `import\nas`, `import *`, a backquoted segment. `ui.layout` and `terrain.heightAt` compiled in the same\nharness, so the failure was the name and nothing else.\n\n**So the binding was written, wired, found uncallable and withdrawn rather than shipped.**\nRegistering it would have traded `DS0301`, which says nothing here implements it and is\nactionable, for `DS0003` inside a script author's own call -- a capability nobody can spell is\nthe silent no-op this repository forbids, wearing a feature's clothes. It was recorded with a\nsentinel and filed against the language, and **DriftScript 1.11.0 answered it at the import**:\n`import { sprite } from \"drift/2d\" as sprites`, with `DS0139` refusing a namespace that cannot\nbe written and saying the line to write. The fix is at the import rather than at the derivation,\nbecause a rule that turned a bad segment into a good identifier would be the language picking a\nname on the author's behalf.\n\nThe engine was already on 1.11.0 by the time this landed, so what moves here is the binding and\nthe sentinel with it. The round trip took one day. Twelve capabilities over three opaque types -- a batch, a sheet and a tilemap -- with a\ndraw as `scene.write` and a read of a sheet or a cell as `scene.read`, so a `@deterministic`\nsystem may ask what is in a cell and may not draw it."
718
734
  }
719
735
  ]
720
736
  },
@@ -725,12 +741,12 @@
725
741
  {
726
742
  "kind": "added",
727
743
  "short": "`.fbx` returns skins and clips, and indexed geometry: a bought character arrives with its skeleton, its weights and its take instead of as a bag of triangles five times too large.",
728
- "text": "**`.fbx` returns a rig, which is the one bar that makes a character format useful.** `readModel` documented `skins` and `clips` as \"where the format carries them. glTF does; nothing else here does\", and FBX is how characters are *sold* — it is the only thing Mixamo exports — so the one format a bought character arrives in was the one a rig could not be read from. Measured on a Renderpeople figure: through this reader, a bag of triangles with no skeleton, no weights and no clip; the same file converted to glTF first, 88 joints and a 64-track take. A clip can be authored afterwards and a skin cannot be recovered from a mesh, which is why the skin is the half that mattered.\n\n**The bind pose is two matrices and not one, and that is the trap a hand-made fixture cannot catch.** A cluster carries `TransformLink`, where the bone was when the mesh was bound, and `Transform`, where the *mesh* was; the engine's `inverseBind` takes a vertex from model space into the joint's bind space, so it is the inverse of the first times the second. `TransformLink` alone is right exactly while the mesh sits at the origin with no rotation — which is what every hand-made rig does and no bought one does. The first test written here binds the mesh at the origin and cannot tell the two formulas apart; it is kept beside the one that binds it three metres off, and perturbing the reader turns the second red and leaves the first green.\n\n**A bone names its cluster and its parent bone, and only one of those is hierarchy.** The transform walk took the first `OO` parent it found, which is whichever the exporter wrote first, and a cluster is not a model — so the walk to the world stopped there and silently returned identity, dropping every ancestor above that bone. A prop parented to a hand drew as though the hand were a root: right relative to the hand and in the wrong place in the scene, which reads as a rigging fault rather than as a connection read in the wrong order.\n\n**And the geometry stopped arriving five times over.** The reader wrote `indices[i] = i` across every corner it had fanned, so a bought character came back at 64,518 vertices where its own file describes 12,216. The baker welds, so a baked asset never showed it and anything calling `readModel` directly carried all of it. Deduplicating on the layer *offsets* is the cheap answer and shares almost nothing, because an exporter writing `ByPolygonVertex` `Direct` gives every corner its own offset into an array of repeated values: measured on a rigged car, **1,958,002 vertices from 1,958,016 corners — fourteen shared out of two million**. Keyed on the values instead, bucketed by control point so the candidates for any corner are the handful already emitted for it, the same car is **388,362**, and the three FBX files in this repository come down by 4.2 to 5.0 times. The read is also **three and a half times faster**, 827 ms against 2,952 ms, because the allocations went with the duplicates.\n\n**Weights are normalised on the way in and four is the shader's number**, both said once per mesh rather than once per vertex; a vertex held by more than four joints keeps its four *largest* and is renormalised, because dropping by order discards whichever the exporter happened to write last and on a shoulder that is as likely to be the dominant influence as not.\n\n**A rotation is the chain and not the curve.** FBX composes a joint's local rotation as `PreRotation * R * inverse(PostRotation)`, and both outer terms are fixed per joint and written on the model rather than in the curve — so a reader taking the curve alone plays a take in which every joint carrying one is turned by a constant amount. Twenty-nine of the measured character's eighty-eight carry one. Every key still interpolates and the clip still runs, which is why it reads as a bad export rather than as an unread field. Both are composed; a non-default `RotationOrder` is the one part of the chain still refused by name.\n\n**A binary FBX separates a name from its class with `\\u0000\\u0001`, not with `::`.** The ASCII form's separator is the one a reader tends to be written against, because it is the one the specification's examples show. Every joint of the measured character therefore came back named `rp_nathan_animated_003_walking_spine_01\\u0000\\u0001Model`, and the take `Take 001\\u0000\\u0001AnimStack`. Nothing fails on a name: `Joint.name` is what retargeting matches on, so a rig whose every joint carries a class suffix matches nothing, one subsystem away from the reader that wrote it. Both of these were found by running the reader over the file it was written for, after the synthetic tests were green — which is the argument for real bytes in one line.\n\n**What is refused by name rather than approximated**: a non-default `RotationOrder`, the second and later animation layers of a stack, morph channels driven by curves, and the `Take` records of pre-7000 files. Materials and textures are unchanged.\n\n**The test files are written rather than checked in.** The assets that would prove this on real bytes are bought characters: tens of megabytes, licensed, and not committable here. `fbxHarness.ts` writes the binary records instead, in the shape Autodesk's exporters emit them, which also answers the questions a test wants to ask — a cluster whose weights do not sum to one, a bone whose only connection is to its cluster, a curve with one key — rather than only the ones one file happens to contain. **Measured on the character it was written for**, a 24.6 MB Renderpeople figure at version 7300: 12,216 vertices and 21,506 triangles, which is the same vertex count that file gives through a glTF conversion; 88 joints, named and sorted parents-first with none naming a parent after it; a 176-track take of 2.267 seconds; every joint index in range, no vertex left unweighted, and weights summing to one within 4.5e-8. Baked to `.drft` 1.11 and read back it is the same asset, and the engine's own `Skeleton` accepts the rig and resolves an **identity palette at the bind pose to 5.5e-5** across the whole 88-joint chain, which is float32 accumulation over 88 matrix inversions rather than an error."
744
+ "text": "**`.fbx` returns a rig, which is the one bar that makes a character format useful.** `readModel` documented `skins` and `clips` as \"where the format carries them. glTF does; nothing else here does\", and FBX is how characters are *sold* \u2014 it is the only thing Mixamo exports \u2014 so the one format a bought character arrives in was the one a rig could not be read from. Measured on a Renderpeople figure: through this reader, a bag of triangles with no skeleton, no weights and no clip; the same file converted to glTF first, 88 joints and a 64-track take. A clip can be authored afterwards and a skin cannot be recovered from a mesh, which is why the skin is the half that mattered.\n\n**The bind pose is two matrices and not one, and that is the trap a hand-made fixture cannot catch.** A cluster carries `TransformLink`, where the bone was when the mesh was bound, and `Transform`, where the *mesh* was; the engine's `inverseBind` takes a vertex from model space into the joint's bind space, so it is the inverse of the first times the second. `TransformLink` alone is right exactly while the mesh sits at the origin with no rotation \u2014 which is what every hand-made rig does and no bought one does. The first test written here binds the mesh at the origin and cannot tell the two formulas apart; it is kept beside the one that binds it three metres off, and perturbing the reader turns the second red and leaves the first green.\n\n**A bone names its cluster and its parent bone, and only one of those is hierarchy.** The transform walk took the first `OO` parent it found, which is whichever the exporter wrote first, and a cluster is not a model \u2014 so the walk to the world stopped there and silently returned identity, dropping every ancestor above that bone. A prop parented to a hand drew as though the hand were a root: right relative to the hand and in the wrong place in the scene, which reads as a rigging fault rather than as a connection read in the wrong order.\n\n**And the geometry stopped arriving five times over.** The reader wrote `indices[i] = i` across every corner it had fanned, so a bought character came back at 64,518 vertices where its own file describes 12,216. The baker welds, so a baked asset never showed it and anything calling `readModel` directly carried all of it. Deduplicating on the layer *offsets* is the cheap answer and shares almost nothing, because an exporter writing `ByPolygonVertex` `Direct` gives every corner its own offset into an array of repeated values: measured on a rigged car, **1,958,002 vertices from 1,958,016 corners \u2014 fourteen shared out of two million**. Keyed on the values instead, bucketed by control point so the candidates for any corner are the handful already emitted for it, the same car is **388,362**, and the three FBX files in this repository come down by 4.2 to 5.0 times. The read is also **three and a half times faster**, 827 ms against 2,952 ms, because the allocations went with the duplicates.\n\n**Weights are normalised on the way in and four is the shader's number**, both said once per mesh rather than once per vertex; a vertex held by more than four joints keeps its four *largest* and is renormalised, because dropping by order discards whichever the exporter happened to write last and on a shoulder that is as likely to be the dominant influence as not.\n\n**A rotation is the chain and not the curve.** FBX composes a joint's local rotation as `PreRotation * R * inverse(PostRotation)`, and both outer terms are fixed per joint and written on the model rather than in the curve \u2014 so a reader taking the curve alone plays a take in which every joint carrying one is turned by a constant amount. Twenty-nine of the measured character's eighty-eight carry one. Every key still interpolates and the clip still runs, which is why it reads as a bad export rather than as an unread field. Both are composed; a non-default `RotationOrder` is the one part of the chain still refused by name.\n\n**A binary FBX separates a name from its class with `\\u0000\\u0001`, not with `::`.** The ASCII form's separator is the one a reader tends to be written against, because it is the one the specification's examples show. Every joint of the measured character therefore came back named `rp_nathan_animated_003_walking_spine_01\\u0000\\u0001Model`, and the take `Take 001\\u0000\\u0001AnimStack`. Nothing fails on a name: `Joint.name` is what retargeting matches on, so a rig whose every joint carries a class suffix matches nothing, one subsystem away from the reader that wrote it. Both of these were found by running the reader over the file it was written for, after the synthetic tests were green \u2014 which is the argument for real bytes in one line.\n\n**What is refused by name rather than approximated**: a non-default `RotationOrder`, the second and later animation layers of a stack, morph channels driven by curves, and the `Take` records of pre-7000 files. Materials and textures are unchanged.\n\n**The test files are written rather than checked in.** The assets that would prove this on real bytes are bought characters: tens of megabytes, licensed, and not committable here. `fbxHarness.ts` writes the binary records instead, in the shape Autodesk's exporters emit them, which also answers the questions a test wants to ask \u2014 a cluster whose weights do not sum to one, a bone whose only connection is to its cluster, a curve with one key \u2014 rather than only the ones one file happens to contain. **Measured on the character it was written for**, a 24.6 MB Renderpeople figure at version 7300: 12,216 vertices and 21,506 triangles, which is the same vertex count that file gives through a glTF conversion; 88 joints, named and sorted parents-first with none naming a parent after it; a 176-track take of 2.267 seconds; every joint index in range, no vertex left unweighted, and weights summing to one within 4.5e-8. Baked to `.drft` 1.11 and read back it is the same asset, and the engine's own `Skeleton` accepts the rig and resolves an **identity palette at the bind pose to 5.5e-5** across the whole 88-joint chain, which is float32 accumulation over 88 matrix inversions rather than an error."
729
745
  },
730
746
  {
731
747
  "kind": "fixed",
732
- "short": "The FBX reader's «no geometry found» refusal now says which of the three shapes of nothing it met, instead of ending the trail where it starts.",
733
- "text": "**«fbx: no geometry found» is three different faults and said only one thing.** Two rig-specific exports of one bought character both failed with that line and the trail ended there: the message could not say whether the file carries no `Geometry` records at all, carries records that are not polygon meshes — a blend-shape target, a patch, a subdivision cage — or carries ones that produced no polygons. Each is a different next step. The refusal now counts what it met and names which of the three it is, which is the difference between a report somebody can act on and one they can only forward.\n\n**And the two exports it was met on are not a reader fault.** `_ue4.fbx` and `_u3d.fbx`, which ship beside a bought character for two particular engines, carry 95 and 88 `LimbNode`s with their curves and **no `Geometry` records at all**: they are animation-only files and the refusal was correct the whole time. It simply could not say so, and «no geometry found» reads as a reader that cannot find something rather than as a file that does not contain one. The message now names that case specifically, and says the mesh is in the file next to them."
748
+ "short": "The FBX reader's \u00abno geometry found\u00bb refusal now says which of the three shapes of nothing it met, instead of ending the trail where it starts.",
749
+ "text": "**\u00abfbx: no geometry found\u00bb is three different faults and said only one thing.** Two rig-specific exports of one bought character both failed with that line and the trail ended there: the message could not say whether the file carries no `Geometry` records at all, carries records that are not polygon meshes \u2014 a blend-shape target, a patch, a subdivision cage \u2014 or carries ones that produced no polygons. Each is a different next step. The refusal now counts what it met and names which of the three it is, which is the difference between a report somebody can act on and one they can only forward.\n\n**And the two exports it was met on are not a reader fault.** `_ue4.fbx` and `_u3d.fbx`, which ship beside a bought character for two particular engines, carry 95 and 88 `LimbNode`s with their curves and **no `Geometry` records at all**: they are animation-only files and the refusal was correct the whole time. It simply could not say so, and \u00abno geometry found\u00bb reads as a reader that cannot find something rather than as a file that does not contain one. The message now names that case specifically, and says the mesh is in the file next to them."
734
750
  }
735
751
  ]
736
752
  },
@@ -741,17 +757,17 @@
741
757
  {
742
758
  "kind": "fixed",
743
759
  "short": "WebGL2 ran order-independent transparency under multisampling, which WebGPU refuses: its two passes tested against a depth texture the frame was never drawn into. Both backends now refuse, once.",
744
- "text": "**Order-independent transparency excluded multisampling on one backend of two.** WebGPU decided the frame's `oitActive` as `orderIndependent && screenEffects && samples === 1` and said why; WebGL2 decided it as `orderIndependent && sceneTarget !== null` and never asked the sample count. One boolean expression against another, found by reading them rather than by a capture.\n\n**What the missing guard cost.** Both order-independent passes attach `SceneTarget.depthAttachment()` and depth-test against it without writing, and above one sample the frame is not drawn into that texture: `ensureSize` attaches `msaaDepth`, a renderbuffer, and the sampleable copy is written only by the depth blit at the end of `resolve` — which itself runs only when ambient occlusion, motion blur, depth of field or the temporal resolve asked for depth. So a pane was rejected against the previous frame's depth, and with those four effects off, against a texture the frame had never written. The method immediately below it, `colorAttachment()`, guards the colour half of exactly this and gives exactly this reasoning; `sampleCount` was already public two accessors above, so the guard needed no new API.\n\n**Either behaviour is defensible and the disagreement is the defect.** 3.34.0's own note says it in as many words: two backends compositing different pictures is what the parity rule exists to prevent. A consumer profile carrying `orderIndependent: true` beside `sceneSamples: 4` — which is what a graphics screen's antialiasing switch produces — got weighted blending on one backend and sorted blending on the other, with nothing said on either.\n\n**Both backends now refuse it, once per renderer, in one string.** `OIT_MULTISAMPLE_REFUSAL` lives in `orderIndependent.ts`, the backend-neutral module the capability already had, rather than as a literal in each renderer: the decal and reflection refusals beside it are duplicated literals whose halves have to be diffed by eye, and this engine has already paid for that arrangement once — `compositeWantsDepth` was two copies of one expression, depth of field was added to one, and the effect ran against a discarded attachment. The WebGPU comment had claimed since the effect shipped that it \"says so once\", and for two releases nothing did.\n\n**Pinned by a test on each backend, neither meaningful alone.** WebGL2 gained a `renderer.test.ts` — the first test in this repository to construct that renderer for anything but its budget — which submits one translucent pane and counts `drawElements`: one means sorted and blended immediately, none means held for the two passes at the end of the frame. Multisampled must be one and single-sampled must be none, and before the fix the multisampled profile was none. The harness can now declare an extension present, because without `EXT_color_buffer_float` the effect refuses one step earlier and both profiles look alike for a reason neither test is about."
760
+ "text": "**Order-independent transparency excluded multisampling on one backend of two.** WebGPU decided the frame's `oitActive` as `orderIndependent && screenEffects && samples === 1` and said why; WebGL2 decided it as `orderIndependent && sceneTarget !== null` and never asked the sample count. One boolean expression against another, found by reading them rather than by a capture.\n\n**What the missing guard cost.** Both order-independent passes attach `SceneTarget.depthAttachment()` and depth-test against it without writing, and above one sample the frame is not drawn into that texture: `ensureSize` attaches `msaaDepth`, a renderbuffer, and the sampleable copy is written only by the depth blit at the end of `resolve` \u2014 which itself runs only when ambient occlusion, motion blur, depth of field or the temporal resolve asked for depth. So a pane was rejected against the previous frame's depth, and with those four effects off, against a texture the frame had never written. The method immediately below it, `colorAttachment()`, guards the colour half of exactly this and gives exactly this reasoning; `sampleCount` was already public two accessors above, so the guard needed no new API.\n\n**Either behaviour is defensible and the disagreement is the defect.** 3.34.0's own note says it in as many words: two backends compositing different pictures is what the parity rule exists to prevent. A consumer profile carrying `orderIndependent: true` beside `sceneSamples: 4` \u2014 which is what a graphics screen's antialiasing switch produces \u2014 got weighted blending on one backend and sorted blending on the other, with nothing said on either.\n\n**Both backends now refuse it, once per renderer, in one string.** `OIT_MULTISAMPLE_REFUSAL` lives in `orderIndependent.ts`, the backend-neutral module the capability already had, rather than as a literal in each renderer: the decal and reflection refusals beside it are duplicated literals whose halves have to be diffed by eye, and this engine has already paid for that arrangement once \u2014 `compositeWantsDepth` was two copies of one expression, depth of field was added to one, and the effect ran against a discarded attachment. The WebGPU comment had claimed since the effect shipped that it \"says so once\", and for two releases nothing did.\n\n**Pinned by a test on each backend, neither meaningful alone.** WebGL2 gained a `renderer.test.ts` \u2014 the first test in this repository to construct that renderer for anything but its budget \u2014 which submits one translucent pane and counts `drawElements`: one means sorted and blended immediately, none means held for the two passes at the end of the frame. Multisampled must be one and single-sampled must be none, and before the fix the multisampled profile was none. The harness can now declare an extension present, because without `EXT_color_buffer_float` the effect refuses one step earlier and both profiles look alike for a reason neither test is about."
745
761
  },
746
762
  {
747
763
  "kind": "added",
748
764
  "short": "`.drft` 1.11 carries a skin's per-vertex half: `ATTR_JOINTS` and `ATTR_WEIGHTS`, the pair the format validated for four minor versions and wrote neither of.",
749
- "text": "**`.drft` validated a skinned mesh and wrote half of it.** `MeshData` has declared `joints` and `weights` since skinning shipped and `validateMeshData` checks both — four floats a vertex, and neither present without the other — while `buildMesh` listed the optional arrays it had bits for and there were no bits for these. The `SKIN` chunk beside them was correct the whole time, so a file carried a skeleton, its bind pose and its clips, pointing at vertices that recorded no influence on any of them. The two halves of a skin disagreed about whether the format supported skinning.\n\n**The write succeeded in silence, which is what made it survive four minor versions.** A baker handed `writeDrft` a correctly-validated skinned mesh, got no error and no warning, and produced a file nothing could skin — and `Mesh.isSkinned` reads `data.joints`, so what came back drew in bind pose for ever. A consumer wrote its own binary container for a single human body rather than use this: about seventy lines across a baker and a loader, and with them everything `.drft` would have brought — the shard splitting a static host wants over 25 MiB, the coarse-first ordering that puts an outline on screen in the first few kilobytes, `DrftLoader`'s fitting, and the streaming reader.\n\n**Every other layer was already right**, which is why this is two bits and not a subsystem: the glTF reader reads `JOINTS_0` and `WEIGHTS_0` and remaps them onto its parents-first joint order, the weld's attribute table carries both at four floats each, and both backends upload them at attributes 11 and 12. `ATTR_JOINTS` (`0x80`) and `ATTR_WEIGHTS` (`0x100`) are the next two free bits, appended last in the frozen order, so a 1.10 reader meets the seven arrays it knows in the seven places it expects them, stops before these, and draws the asset in bind pose — which is what a reader that has never heard of skinning should produce. Two bits rather than one because the frozen order is a property of arrays and these are two; they stay both-or-neither, refused on the way out and again on the way in.\n\n**The test that should have caught it was right and its fixture was not.** `drft.test.ts` enumerates the keys of the mesh it builds and reports every loss at once — the method `relief` bought after a named-attribute list missed it — and the mesh it built carried no influences, so there was nothing to enumerate. It carries them now, which is what turns the enumeration back into a guarantee. A `v1-11` compatibility fixture is checked in beside the others, and §4.4's practice of one fixture per released minor is repaired for this version; 1.8 and 1.10 are still missing theirs, and forging them from a later writer would look like evidence and be none."
765
+ "text": "**`.drft` validated a skinned mesh and wrote half of it.** `MeshData` has declared `joints` and `weights` since skinning shipped and `validateMeshData` checks both \u2014 four floats a vertex, and neither present without the other \u2014 while `buildMesh` listed the optional arrays it had bits for and there were no bits for these. The `SKIN` chunk beside them was correct the whole time, so a file carried a skeleton, its bind pose and its clips, pointing at vertices that recorded no influence on any of them. The two halves of a skin disagreed about whether the format supported skinning.\n\n**The write succeeded in silence, which is what made it survive four minor versions.** A baker handed `writeDrft` a correctly-validated skinned mesh, got no error and no warning, and produced a file nothing could skin \u2014 and `Mesh.isSkinned` reads `data.joints`, so what came back drew in bind pose for ever. A consumer wrote its own binary container for a single human body rather than use this: about seventy lines across a baker and a loader, and with them everything `.drft` would have brought \u2014 the shard splitting a static host wants over 25 MiB, the coarse-first ordering that puts an outline on screen in the first few kilobytes, `DrftLoader`'s fitting, and the streaming reader.\n\n**Every other layer was already right**, which is why this is two bits and not a subsystem: the glTF reader reads `JOINTS_0` and `WEIGHTS_0` and remaps them onto its parents-first joint order, the weld's attribute table carries both at four floats each, and both backends upload them at attributes 11 and 12. `ATTR_JOINTS` (`0x80`) and `ATTR_WEIGHTS` (`0x100`) are the next two free bits, appended last in the frozen order, so a 1.10 reader meets the seven arrays it knows in the seven places it expects them, stops before these, and draws the asset in bind pose \u2014 which is what a reader that has never heard of skinning should produce. Two bits rather than one because the frozen order is a property of arrays and these are two; they stay both-or-neither, refused on the way out and again on the way in.\n\n**The test that should have caught it was right and its fixture was not.** `drft.test.ts` enumerates the keys of the mesh it builds and reports every loss at once \u2014 the method `relief` bought after a named-attribute list missed it \u2014 and the mesh it built carried no influences, so there was nothing to enumerate. It carries them now, which is what turns the enumeration back into a guarantee. A `v1-11` compatibility fixture is checked in beside the others, and \u00a74.4's practice of one fixture per released minor is repaired for this version; 1.8 and 1.10 are still missing theirs, and forging them from a later writer would look like evidence and be none."
750
766
  },
751
767
  {
752
768
  "kind": "note",
753
769
  "short": "A release is forty places, not thirty-six. The count and its floors were left behind by 3.38.0's new package, for the sixth time.",
754
- "text": "**A version bump is forty places, not thirty-six, and the number had been stale for a whole release.** 3.38.0 shipped `@driftengine/terrain` — one manifest and *three* internal ranges — and raised neither the paragraph in `AGENTS.md` nor the floors in `scripts/version.test.mjs`, so an entire version ran at fifteen manifests and twenty-four ranges while both said fourteen and twenty-one and everything stayed green. That is the sixth time this figure has drifted, and it is exactly the failure the rule predicts: **a floor passes at the true number and at every stale one below it.** Found by running the counting snippet rather than by reading the sentence above it, which is what that snippet is there for."
770
+ "text": "**A version bump is forty places, not thirty-six, and the number had been stale for a whole release.** 3.38.0 shipped `@driftengine/terrain` \u2014 one manifest and *three* internal ranges \u2014 and raised neither the paragraph in `AGENTS.md` nor the floors in `scripts/version.test.mjs`, so an entire version ran at fifteen manifests and twenty-four ranges while both said fourteen and twenty-one and everything stayed green. That is the sixth time this figure has drifted, and it is exactly the failure the rule predicts: **a floor passes at the true number and at every stale one below it.** Found by running the counting snippet rather than by reading the sentence above it, which is what that snippet is there for."
755
771
  }
756
772
  ]
757
773
  },
@@ -762,7 +778,7 @@
762
778
  {
763
779
  "kind": "added",
764
780
  "short": "Heightfield terrain: a query that answers the surface that is drawn, patches that meet without a hole, blended materials, a collider with no triangles in it, and `drift/terrain` bound.",
765
- "text": "**`@driftengine/terrain` imports no renderer.** A `Terrain` is a heightfield and `heightfieldPatch` hands back `MeshData`, which goes to `createMesh` like any other geometry — so terrain is drawn by the pass that already exists. That is why it is a package rather than part of core: a world with no terrain pays nothing, and one with terrain pays **1.4 KB gzipped** for arithmetic.\n\n**The query answers the surface that is drawn, and that is the whole design.** A heightfield is stored as a lattice and drawn as triangles, and a bilinear patch through four corners is not the pair of triangles that spans them — they agree only at the corners and along their shared diagonal. Interpolating bilinearly because it is the obvious thing puts a character above the ground over half of every cell and below it over the other half, by up to a quarter of that cell's height range, everywhere, for ever, with no screenshot that shows it and no test that fails. So `heightAt` reads the triangle, `heightfieldPatch` emits that same triangulation, and a test asserts every vertex of a patch against the query.\n\n`normalAt` is the other way round on purpose: a central difference over the samples, continuous across every cell boundary. A face normal jumps at every edge, which makes terrain faceted and makes two patches shade differently along the edge they share — a crack that is not there.\n\n**The seam between two levels of detail is matched rather than hidden.** Where a patch drawn at full detail meets one that skips every fourth sample, the fine edge has vertices the coarse edge does not, and those sit on the field while the coarse edge cuts the chord beneath them. The gap is a hole through to the sky. The usual remedy is a skirt — a vertical curtain dropped from every patch edge, the wrong colour, lit the wrong way, and paid for along every boundary in the world for ever. `neighbours` moves the fine edge's odd vertices onto the coarse edge's own straight segment instead, so the two are one polyline and there is nothing to fill.\n\n**Measured on an AMD card at 1280x720, both backends.** An unmatched seam lets 15,401 pixels of the world through — counted per column below that column's own horizon, so sky is never mistaken for a hole — and a matched one lets through **exactly zero**. The two backends agree on both figures and byte for byte on both frames, the geometry being built once on the CPU and handed to each.\n\n**What matching costs, stated because it is real**: along a matched edge the drawn surface is the coarse chord rather than the field, so the query and the picture differ there by up to the sag of one coarse cell — the same error the coarse patch carries over its whole area, arriving one cell early.\n\n**Several materials blend across one field, as vertex colour.** `TerrainMaterials` takes a weight map and `heightfieldPatch` bakes the blend into the vertices — the idiomatic answer here rather than a compromise, this engine's colour *being* vertex data. A splat map read by a shader would mean a texture bound on every terrain draw and a branch in `flatFrag`, which Track D measured at 19.8 KB gzipped generated into sixteen fragment permutations; this cost **144 bytes** and nothing at runtime. What it gives up is sharpness: the blend is only as fine as the mesh, so a consumer wanting a road edge draws that patch at full detail — the dial the geometry already has. The weights are read *bilinearly*, which is the opposite of how a height is read and for the opposite reason: a weight is not drawn, so there is no triangulation to agree with, and reading it as triangles would put a crease along every cell diagonal for nothing.\n\n**It collides, through the mesh the picture is made of.** `meshShape` takes exactly the two arrays a patch hands back, so the geometry drawn is the geometry collided with and nothing is authored twice — asserted rather than claimed: a ray cast against that static body lands where `heightAt` says the ground is, at forty points off the lattice.\n\n**That assertion found a real defect in the first half of this release.** The mesh was split along one diagonal while the query read the other, so the two surfaces disagreed by up to a centimetre everywhere except along the anti-diagonal. Nothing caught it: every vertex lies on *both* triangulations, so per-vertex assertions passed either way — and every test field was of the form `f(x) + g(z)`, which is **separable**, and for a separable field both diagonals give the identical surface. The fields carry a cross term now, and a test reads the mesh's own triangle *between* the vertices.\n\n**`drift/terrain` is bound**, which is one fewer module the linker refuses: `heightAt`, three normal components, `slopeAt`, `covers` and the two extents, all deterministic reads, so a `@deterministic` system may ask where the ground is. They are declared under `physics.read`, and that is a description rather than a borrowing — the terrain *is* the collision surface, which this package's tests assert — but the reason is that **the language names no `terrain.*` effect at all**. `drift/behavior` was given its two ahead of any provider precisely so a track would not need a language release first; terrain was missed. `docs/IMPROVEMENTS.md` carries the two lines that would fix it.\n\n**And it collides as a heightfield, not only as a mesh.** `heightfieldShape` in `@driftengine/physics` indexes the cell under a body straight from its x and z and carries the heights it was handed and no geometry at all — no vertex buffer, no index buffer, no tree. At a 129-square field a mesh collider's positions and indices alone are more than five times the bytes of the heights, before the tree over them is counted. The shape itself cost **949 bytes** in the physics package.\n\n**It is not a second narrow phase, and that is the decision the row turns on.** `meshContact.ts` still owns every contact rule — many manifolds rather than one, one-sided triangles, and the interior-edge filter that stops a box stumbling on a flat seam — and a field changes only *where triangles come from*: an index range instead of a tree query, four samples instead of an index buffer. A second copy of that filter would have been the most expensive duplication in the package.\n\n**The proof is differential rather than descriptive.** Both colliders are built over one field and a box is walked across it, and the normals, counts and separations must be *identical* at every pose; splitting the field's cells the other way turns that red. The flat-floor case is asserted directly as well, because a heightfield collider that reproduced the mesh path's own bug would pass a comparison against it.\n\n**The triangulation rule now exists in two packages, and is guarded rather than trusted.** `@driftengine/physics` imports no other engine package, so it cannot see a `Terrain` — but a `Terrain` *is* a `Heightfield` structurally, which is how `heightfieldShape(terrain)` type-checks with no import in either direction. Sixty rays are cast at both colliders and at the query, and all three must agree. That is the arrangement `glDepthFunc` has with `DEPTH_COMPARE`, for the same reason: a boundary that forbids sharing the code does not forbid sharing a test."
781
+ "text": "**`@driftengine/terrain` imports no renderer.** A `Terrain` is a heightfield and `heightfieldPatch` hands back `MeshData`, which goes to `createMesh` like any other geometry \u2014 so terrain is drawn by the pass that already exists. That is why it is a package rather than part of core: a world with no terrain pays nothing, and one with terrain pays **1.4 KB gzipped** for arithmetic.\n\n**The query answers the surface that is drawn, and that is the whole design.** A heightfield is stored as a lattice and drawn as triangles, and a bilinear patch through four corners is not the pair of triangles that spans them \u2014 they agree only at the corners and along their shared diagonal. Interpolating bilinearly because it is the obvious thing puts a character above the ground over half of every cell and below it over the other half, by up to a quarter of that cell's height range, everywhere, for ever, with no screenshot that shows it and no test that fails. So `heightAt` reads the triangle, `heightfieldPatch` emits that same triangulation, and a test asserts every vertex of a patch against the query.\n\n`normalAt` is the other way round on purpose: a central difference over the samples, continuous across every cell boundary. A face normal jumps at every edge, which makes terrain faceted and makes two patches shade differently along the edge they share \u2014 a crack that is not there.\n\n**The seam between two levels of detail is matched rather than hidden.** Where a patch drawn at full detail meets one that skips every fourth sample, the fine edge has vertices the coarse edge does not, and those sit on the field while the coarse edge cuts the chord beneath them. The gap is a hole through to the sky. The usual remedy is a skirt \u2014 a vertical curtain dropped from every patch edge, the wrong colour, lit the wrong way, and paid for along every boundary in the world for ever. `neighbours` moves the fine edge's odd vertices onto the coarse edge's own straight segment instead, so the two are one polyline and there is nothing to fill.\n\n**Measured on an AMD card at 1280x720, both backends.** An unmatched seam lets 15,401 pixels of the world through \u2014 counted per column below that column's own horizon, so sky is never mistaken for a hole \u2014 and a matched one lets through **exactly zero**. The two backends agree on both figures and byte for byte on both frames, the geometry being built once on the CPU and handed to each.\n\n**What matching costs, stated because it is real**: along a matched edge the drawn surface is the coarse chord rather than the field, so the query and the picture differ there by up to the sag of one coarse cell \u2014 the same error the coarse patch carries over its whole area, arriving one cell early.\n\n**Several materials blend across one field, as vertex colour.** `TerrainMaterials` takes a weight map and `heightfieldPatch` bakes the blend into the vertices \u2014 the idiomatic answer here rather than a compromise, this engine's colour *being* vertex data. A splat map read by a shader would mean a texture bound on every terrain draw and a branch in `flatFrag`, which Track D measured at 19.8 KB gzipped generated into sixteen fragment permutations; this cost **144 bytes** and nothing at runtime. What it gives up is sharpness: the blend is only as fine as the mesh, so a consumer wanting a road edge draws that patch at full detail \u2014 the dial the geometry already has. The weights are read *bilinearly*, which is the opposite of how a height is read and for the opposite reason: a weight is not drawn, so there is no triangulation to agree with, and reading it as triangles would put a crease along every cell diagonal for nothing.\n\n**It collides, through the mesh the picture is made of.** `meshShape` takes exactly the two arrays a patch hands back, so the geometry drawn is the geometry collided with and nothing is authored twice \u2014 asserted rather than claimed: a ray cast against that static body lands where `heightAt` says the ground is, at forty points off the lattice.\n\n**That assertion found a real defect in the first half of this release.** The mesh was split along one diagonal while the query read the other, so the two surfaces disagreed by up to a centimetre everywhere except along the anti-diagonal. Nothing caught it: every vertex lies on *both* triangulations, so per-vertex assertions passed either way \u2014 and every test field was of the form `f(x) + g(z)`, which is **separable**, and for a separable field both diagonals give the identical surface. The fields carry a cross term now, and a test reads the mesh's own triangle *between* the vertices.\n\n**`drift/terrain` is bound**, which is one fewer module the linker refuses: `heightAt`, three normal components, `slopeAt`, `covers` and the two extents, all deterministic reads, so a `@deterministic` system may ask where the ground is. They are declared under `physics.read`, and that is a description rather than a borrowing \u2014 the terrain *is* the collision surface, which this package's tests assert \u2014 but the reason is that **the language names no `terrain.*` effect at all**. `drift/behavior` was given its two ahead of any provider precisely so a track would not need a language release first; terrain was missed. `docs/IMPROVEMENTS.md` carries the two lines that would fix it.\n\n**And it collides as a heightfield, not only as a mesh.** `heightfieldShape` in `@driftengine/physics` indexes the cell under a body straight from its x and z and carries the heights it was handed and no geometry at all \u2014 no vertex buffer, no index buffer, no tree. At a 129-square field a mesh collider's positions and indices alone are more than five times the bytes of the heights, before the tree over them is counted. The shape itself cost **949 bytes** in the physics package.\n\n**It is not a second narrow phase, and that is the decision the row turns on.** `meshContact.ts` still owns every contact rule \u2014 many manifolds rather than one, one-sided triangles, and the interior-edge filter that stops a box stumbling on a flat seam \u2014 and a field changes only *where triangles come from*: an index range instead of a tree query, four samples instead of an index buffer. A second copy of that filter would have been the most expensive duplication in the package.\n\n**The proof is differential rather than descriptive.** Both colliders are built over one field and a box is walked across it, and the normals, counts and separations must be *identical* at every pose; splitting the field's cells the other way turns that red. The flat-floor case is asserted directly as well, because a heightfield collider that reproduced the mesh path's own bug would pass a comparison against it.\n\n**The triangulation rule now exists in two packages, and is guarded rather than trusted.** `@driftengine/physics` imports no other engine package, so it cannot see a `Terrain` \u2014 but a `Terrain` *is* a `Heightfield` structurally, which is how `heightfieldShape(terrain)` type-checks with no import in either direction. Sixty rays are cast at both colliders and at the query, and all three must agree. That is the arrangement `glDepthFunc` has with `DEPTH_COMPARE`, for the same reason: a boundary that forbids sharing the code does not forbid sharing a test."
766
782
  }
767
783
  ]
768
784
  },
@@ -773,7 +789,7 @@
773
789
  {
774
790
  "kind": "note",
775
791
  "short": "Refraction and screen-space GI go on the census: one scheduled with its design and its price, one refused in writing, and both with sentinels so the documents cannot go quietly wrong.",
776
- "text": "**Track D's list of what it does not do had two entries that nothing could close.** Refraction and screen-space global illumination were named as absent in `ROADMAP.md` and `CAPABILITIES.md` prose and had no sentinel and no README bullet — and a sentinel is what makes a gap closeable here: `docs.test.mjs` asserts that each named symbol is still missing, so the suite goes red on the day one arrives, which is the day the document needs editing. A gap living only in prose goes quietly wrong instead, which is how this documentation set once fell a whole major version behind.\n\n**Refraction is scheduled and priced.** It cannot be a region pass like the drawn decal and the screen-space reflection that shipped beside it: those apply to whatever the depth buffer holds inside a box, and a refracting surface is translucent and therefore drawn after that. Offsetting the sample of a region containing glass reads the glass itself. What it needs is a snapshot of the colour taken before the translucent set, bound into `flatFrag` and sampled at an offset along the surface normal — a new sampler in the shader every draw already uses, which is the one place in this engine where a capability is priced by the permutation count rather than by its own size. `docs/IMPROVEMENTS.md` carries the design and the numbers.\n\n**Screen-space global illumination is refused in writing**, on the same terms DS-7 and AI-7 were. It is the one screen-space effect whose error is not bounded by a fade: a reflection that runs out of screen fades to nothing, and indirect light that runs out of screen changes the brightness of the room — so a viewer sees a scene that dims when they turn their head. The honest version wants temporal accumulation, a disocclusion test and a denoiser, which is three subsystems for one effect. It carries a sentinel too, so building it fails the documentation rather than passing unnoticed."
792
+ "text": "**Track D's list of what it does not do had two entries that nothing could close.** Refraction and screen-space global illumination were named as absent in `ROADMAP.md` and `CAPABILITIES.md` prose and had no sentinel and no README bullet \u2014 and a sentinel is what makes a gap closeable here: `docs.test.mjs` asserts that each named symbol is still missing, so the suite goes red on the day one arrives, which is the day the document needs editing. A gap living only in prose goes quietly wrong instead, which is how this documentation set once fell a whole major version behind.\n\n**Refraction is scheduled and priced.** It cannot be a region pass like the drawn decal and the screen-space reflection that shipped beside it: those apply to whatever the depth buffer holds inside a box, and a refracting surface is translucent and therefore drawn after that. Offsetting the sample of a region containing glass reads the glass itself. What it needs is a snapshot of the colour taken before the translucent set, bound into `flatFrag` and sampled at an offset along the surface normal \u2014 a new sampler in the shader every draw already uses, which is the one place in this engine where a capability is priced by the permutation count rather than by its own size. `docs/IMPROVEMENTS.md` carries the design and the numbers.\n\n**Screen-space global illumination is refused in writing**, on the same terms DS-7 and AI-7 were. It is the one screen-space effect whose error is not bounded by a fade: a reflection that runs out of screen fades to nothing, and indirect light that runs out of screen changes the brightness of the room \u2014 so a viewer sees a scene that dims when they turn their head. The honest version wants temporal accumulation, a disocclusion test and a denoiser, which is three subsystems for one effect. It carries a sentinel too, so building it fails the documentation rather than passing unnoticed."
777
793
  }
778
794
  ]
779
795
  },
@@ -784,7 +800,7 @@
784
800
  {
785
801
  "kind": "added",
786
802
  "short": "Screen-space reflection on both backends: a reflected ray marched against the frame's own depth, in a region a caller declares, for what a planar mirror cannot reach.",
787
- "text": "**A planar reflection re-renders the world from a mirrored camera for one horizontal plane at one height.** That is exactly right for a lake and gives nothing for a floor that undulates, a bonnet or a tilted pane — and it costs a second pass over the scene.\n\n`ReflectiveSurface` and `renderer.drawReflection` march a reflected ray against the depth the frame has already drawn, sampling the finished picture where it lands. One scissored fullscreen pass per surface, the surface followed per pixel whatever shape it is, and exact where an object meets the floor — the part of a reflection a viewer reads first.\n\n**Reflective is a region rather than a material, and that is a fact about a forward renderer.** There is no G-buffer, so nothing on screen records which surface was polished; recording it would be a second colour attachment written by every draw path in the engine, which is the price the temporal row already declined for a velocity buffer. A caller declares a box and a facing rule instead — the shape `DecalProjector` already has.\n\n**Measured on an AMD card at 1280x720, both backends.** Two identical blocks stand on one floor and a surface covers the half under one of them: 17,022 reflected pixels on WebGL2 and 17,008 on WebGPU under the declared half, and **0** under the other. The control is in the scene rather than in the check — the second block differs from the first in nothing but the region beneath it. Raising the blocks a metre moves their reflections *down* the screen, 524.4 to 615.9, which a tint or a decal would not do; building the trace with the other backend's Y constant leaves every per-backend assertion green and turns that one red, the reflection moving up instead.\n\n**The march is quadratic rather than evenly spaced**, and the difference is the contact seam. Evenly spaced, a ten-metre reach in 24 steps leaves a dark band along every contact and 11,826 reflected pixels; spacing the samples by the square of their index gives 13,890 with the band closed — a first step of centimetres where it matters and a last one of nearly a metre out where a reflection is faint and about to leave the frame.\n\n**It can only reflect what is on screen**, which is the honest limit of every screen-space method. A ray that leaves the frame, points back toward the eye, or runs its whole reach fades out rather than answering with the wrong pixel. A hit is a *crossing* and never a comparison — the ray must be in front of the scene at one sample and behind it at the next, which is what keeps a grazing ray off its own surface — and a crossing further behind than `thicknessM` is refused, without which a ray sliding onto something in the foreground pastes it into the reflection.\n\n**7.5 KB gzipped on every core entry point.** It needs `screenEffects` and excludes multisampling, refusing in the same words on both backends: a multisampled attachment is not a texture until it has been resolved. Eight surfaces a frame is the cap."
803
+ "text": "**A planar reflection re-renders the world from a mirrored camera for one horizontal plane at one height.** That is exactly right for a lake and gives nothing for a floor that undulates, a bonnet or a tilted pane \u2014 and it costs a second pass over the scene.\n\n`ReflectiveSurface` and `renderer.drawReflection` march a reflected ray against the depth the frame has already drawn, sampling the finished picture where it lands. One scissored fullscreen pass per surface, the surface followed per pixel whatever shape it is, and exact where an object meets the floor \u2014 the part of a reflection a viewer reads first.\n\n**Reflective is a region rather than a material, and that is a fact about a forward renderer.** There is no G-buffer, so nothing on screen records which surface was polished; recording it would be a second colour attachment written by every draw path in the engine, which is the price the temporal row already declined for a velocity buffer. A caller declares a box and a facing rule instead \u2014 the shape `DecalProjector` already has.\n\n**Measured on an AMD card at 1280x720, both backends.** Two identical blocks stand on one floor and a surface covers the half under one of them: 17,022 reflected pixels on WebGL2 and 17,008 on WebGPU under the declared half, and **0** under the other. The control is in the scene rather than in the check \u2014 the second block differs from the first in nothing but the region beneath it. Raising the blocks a metre moves their reflections *down* the screen, 524.4 to 615.9, which a tint or a decal would not do; building the trace with the other backend's Y constant leaves every per-backend assertion green and turns that one red, the reflection moving up instead.\n\n**The march is quadratic rather than evenly spaced**, and the difference is the contact seam. Evenly spaced, a ten-metre reach in 24 steps leaves a dark band along every contact and 11,826 reflected pixels; spacing the samples by the square of their index gives 13,890 with the band closed \u2014 a first step of centimetres where it matters and a last one of nearly a metre out where a reflection is faint and about to leave the frame.\n\n**It can only reflect what is on screen**, which is the honest limit of every screen-space method. A ray that leaves the frame, points back toward the eye, or runs its whole reach fades out rather than answering with the wrong pixel. A hit is a *crossing* and never a comparison \u2014 the ray must be in front of the scene at one sample and behind it at the next, which is what keeps a grazing ray off its own surface \u2014 and a crossing further behind than `thicknessM` is refused, without which a ray sliding onto something in the foreground pastes it into the reflection.\n\n**7.5 KB gzipped on every core entry point.** It needs `screenEffects` and excludes multisampling, refusing in the same words on both backends: a multisampled attachment is not a texture until it has been resolved. Eight surfaces a frame is the cap."
788
804
  }
789
805
  ]
790
806
  },
@@ -795,7 +811,7 @@
795
811
  {
796
812
  "kind": "added",
797
813
  "short": "Drawn decals on both backends: a projector evaluated against the frame's own depth, so a mark follows a surface that deforms or streams in after it was placed.",
798
- "text": "**`projectDecal` clips a mark once, against the mesh as it stood.** That is exact, lit as the surface is lit and free every frame after the one that built it, and it is the wrong shape for a surface that then changes: a mark on something that deforms rides the old shape, and a mark on something that streams in later cannot be made at all, because there was nothing to clip.\n\n`DecalProjector` and `renderer.drawDecal` keep the projector alive instead. The pass reads the depth the frame has already drawn, turns each pixel back into the world point it stands for, and marks it where that point is inside the box — so cloth, a heightfield being rewritten, a skinned mesh or an instanced crowd is marked on the frame it is drawn.\n\n**It multiplies rather than covers, and that is the design rather than a shortcut.** A forward renderer has no G-buffer, so by the time this runs the pixel is already lit and there is nowhere to write an albedo. Painting a flat colour over it would light a scorch mark by nothing at all and make it glow in an unlit corner; multiplying takes the receiver's lighting exactly, and on a diffuse surface it is identical to having marked the albedo before the light was applied. The cost is that a mark can darken and tint and can never brighten.\n\n**Measured on an AMD card at 1280x720, both backends.** A floor is marked and then deformed under the mark through `updateMesh`: a decal clipped from the flat floor goes from 94,736 px to **0**, and the projected one from 77,522 to 79,914. The first half is the control — without it the second is a statement about a scene that never changed. The two backends agree to a tenth of a pixel on both the covered area and the centre of the mark, having reconstructed it through their own framebuffer conventions; building one with the other's constant moves that centre by eleven pixels, which is what `scripts/decal-check.mjs` is set against.\n\n**4 KB gzipped on every core entry point**, against the 19.8 KB order-independent transparency cost in the release before it. The difference is the whole lesson: that one added a uniform and a branch to `flatFrag`, which is generated into sixteen fragment permutations and five vertex ones, and this is a shader of its own, generated once.\n\n**What it does not do.** The mark is a procedural ellipse with a soft edge and a facing fade rather than a texture, since a per-decal image is a bind group per decal on WebGPU. It needs `screenEffects`, having no depth to read without the off-screen target, and refuses in the same words on both backends — WebGPU never opens a composite for that profile, so its marks were being dropped in silence while WebGL2 explained itself, which is what the first run of the check caught. It knows only what the depth buffer knows, so anything drawn without writing depth — a beam, a particle — is in front of the mark and is darkened with it. Thirty-two marks a frame is the cap, above which it says so once and draws the first thirty-two."
814
+ "text": "**`projectDecal` clips a mark once, against the mesh as it stood.** That is exact, lit as the surface is lit and free every frame after the one that built it, and it is the wrong shape for a surface that then changes: a mark on something that deforms rides the old shape, and a mark on something that streams in later cannot be made at all, because there was nothing to clip.\n\n`DecalProjector` and `renderer.drawDecal` keep the projector alive instead. The pass reads the depth the frame has already drawn, turns each pixel back into the world point it stands for, and marks it where that point is inside the box \u2014 so cloth, a heightfield being rewritten, a skinned mesh or an instanced crowd is marked on the frame it is drawn.\n\n**It multiplies rather than covers, and that is the design rather than a shortcut.** A forward renderer has no G-buffer, so by the time this runs the pixel is already lit and there is nowhere to write an albedo. Painting a flat colour over it would light a scorch mark by nothing at all and make it glow in an unlit corner; multiplying takes the receiver's lighting exactly, and on a diffuse surface it is identical to having marked the albedo before the light was applied. The cost is that a mark can darken and tint and can never brighten.\n\n**Measured on an AMD card at 1280x720, both backends.** A floor is marked and then deformed under the mark through `updateMesh`: a decal clipped from the flat floor goes from 94,736 px to **0**, and the projected one from 77,522 to 79,914. The first half is the control \u2014 without it the second is a statement about a scene that never changed. The two backends agree to a tenth of a pixel on both the covered area and the centre of the mark, having reconstructed it through their own framebuffer conventions; building one with the other's constant moves that centre by eleven pixels, which is what `scripts/decal-check.mjs` is set against.\n\n**4 KB gzipped on every core entry point**, against the 19.8 KB order-independent transparency cost in the release before it. The difference is the whole lesson: that one added a uniform and a branch to `flatFrag`, which is generated into sixteen fragment permutations and five vertex ones, and this is a shader of its own, generated once.\n\n**What it does not do.** The mark is a procedural ellipse with a soft edge and a facing fade rather than a texture, since a per-decal image is a bind group per decal on WebGPU. It needs `screenEffects`, having no depth to read without the off-screen target, and refuses in the same words on both backends \u2014 WebGPU never opens a composite for that profile, so its marks were being dropped in silence while WebGL2 explained itself, which is what the first run of the check caught. It knows only what the depth buffer knows, so anything drawn without writing depth \u2014 a beam, a particle \u2014 is in front of the mark and is darkened with it. Thirty-two marks a frame is the cap, above which it says so once and draws the first thirty-two."
799
815
  }
800
816
  ]
801
817
  },
@@ -806,7 +822,7 @@
806
822
  {
807
823
  "kind": "added",
808
824
  "short": "Order-independent transparency on both backends, off by default: two panes of glass stop depending on which was submitted first, including where they intersect and no sort can help.",
809
- "text": "**Sorted alpha blending is order-dependent by construction** — `over` does not commute — so a translucent set is drawn back to front and anything the sort cannot separate wins arbitrarily. Where two panes *intersect* there is no back-to-front order at all: each is in front of the other over part of the screen, so sorting harder cannot help and the usual remedy is splitting the geometry to work around the renderer.\n\n`renderQuality.orderIndependent` replaces the ordering with a weighted sum and a running transmittance. Both commute, so the frame stops depending on submission order. It is **exact for a single layer**, where there is no ordering to approximate, and an approximation for several, near layers counting for more than far ones.\n\n**Measured on an AMD card at 1280x720, both backends.** Two intersecting panes drawn in both orders give digests `2a644bb9` and `bb4cb7b1` with the effect off, and `f8913765` both times with it on — byte-identical, and the same digest on WebGL2 and WebGPU, which reach it by different mechanics. Switching the effect off turns that assertion red on both, which is how `scripts/oit-check.mjs` was shown to have teeth.\n\n**Two passes over the translucent set rather than one with two outputs.** Writing both buffers from one pass needs a second fragment output and therefore another `flatFrag` permutation — about 247 KB gzipped, paid by every consumer whether or not they enable this. The weighting is a uniform branch instead, which cost `flat.wgsl.ts` 18 KB, and the geometry is submitted twice on the frames that asked for the effect.\n\nNeeds a float colour buffer: the accumulation is a sum whose weight reaches three thousand, so eight bits of it is white after one bright layer. Without `EXT_color_buffer_float` it refuses and says so once. Multisampling is excluded."
825
+ "text": "**Sorted alpha blending is order-dependent by construction** \u2014 `over` does not commute \u2014 so a translucent set is drawn back to front and anything the sort cannot separate wins arbitrarily. Where two panes *intersect* there is no back-to-front order at all: each is in front of the other over part of the screen, so sorting harder cannot help and the usual remedy is splitting the geometry to work around the renderer.\n\n`renderQuality.orderIndependent` replaces the ordering with a weighted sum and a running transmittance. Both commute, so the frame stops depending on submission order. It is **exact for a single layer**, where there is no ordering to approximate, and an approximation for several, near layers counting for more than far ones.\n\n**Measured on an AMD card at 1280x720, both backends.** Two intersecting panes drawn in both orders give digests `2a644bb9` and `bb4cb7b1` with the effect off, and `f8913765` both times with it on \u2014 byte-identical, and the same digest on WebGL2 and WebGPU, which reach it by different mechanics. Switching the effect off turns that assertion red on both, which is how `scripts/oit-check.mjs` was shown to have teeth.\n\n**Two passes over the translucent set rather than one with two outputs.** Writing both buffers from one pass needs a second fragment output and therefore another `flatFrag` permutation \u2014 about 247 KB gzipped, paid by every consumer whether or not they enable this. The weighting is a uniform branch instead, which cost `flat.wgsl.ts` 18 KB, and the geometry is submitted twice on the frames that asked for the effect.\n\nNeeds a float colour buffer: the accumulation is a sum whose weight reaches three thousand, so eight bits of it is white after one bright layer. Without `EXT_color_buffer_float` it refuses and says so once. Multisampling is excluded."
810
826
  }
811
827
  ]
812
828
  },
@@ -817,12 +833,12 @@
817
833
  {
818
834
  "kind": "added",
819
835
  "short": "Temporal antialiasing on both backends, off by default: the projection is jittered a fraction of a pixel each frame and blended into where the last was, so an edge stops crawling as the camera moves.",
820
- "text": "**An edge past a certain angle is two colours and nothing between**, and which one a pixel takes flips as the edge crosses its centre — the crawl seen on every slow pan. `renderQuality.temporalAa` jitters the projection a fraction of a pixel each frame and blends the result into where the last frame was, so eight sub-pixel samples land either side of the edge and put it where it actually is. What MSAA buys inside a frame this buys across them, for one texture and one fullscreen pass.\n\n**The jitter goes on the matrix that draws and never on the one that reprojects.** The history holds a resolved picture standing for the *unjittered* scene, so reprojecting through a jittered matrix would look every sample up half a pixel from where it is and cancel the accumulation. Shadow maps take their own matrices and are untouched, which is correct: jittering a shadow map moves the shadow rather than the sample.\n\n**Measured on an AMD card at 1280x720, both backends.** An unresolved edge has *zero* pixels between the two colours it separates; a resolved one has 1,078 on WebGL2 and 1,101 on WebGPU, and the frame's mean brightness moves by 0.007% — antialiased and not displaced, which is what the centred jitter sequence buys. Turning the accumulation off with the jitter left on returns both backends to zero, which is how `scripts/temporal-aa-check.mjs` was shown to have teeth.\n\n**Off by default, and that is not timidity**: jittering the projection moves every pixel of every frame by construction, so defaulting it on would move every published capture and every consumer's reference images with them.\n\n**The motion is the camera's.** That is exact for a static world under a moving camera and wrong for a moving object under a still one. The neighbourhood clip rejects the ghost where the background differs and cannot where it does not; a velocity buffer is what would fix it, and it means a second colour attachment on the scene pass with every draw path writing into it. `demo/dev/taa.html` drives it, `?taa=0` against `?taa=1`."
836
+ "text": "**An edge past a certain angle is two colours and nothing between**, and which one a pixel takes flips as the edge crosses its centre \u2014 the crawl seen on every slow pan. `renderQuality.temporalAa` jitters the projection a fraction of a pixel each frame and blends the result into where the last frame was, so eight sub-pixel samples land either side of the edge and put it where it actually is. What MSAA buys inside a frame this buys across them, for one texture and one fullscreen pass.\n\n**The jitter goes on the matrix that draws and never on the one that reprojects.** The history holds a resolved picture standing for the *unjittered* scene, so reprojecting through a jittered matrix would look every sample up half a pixel from where it is and cancel the accumulation. Shadow maps take their own matrices and are untouched, which is correct: jittering a shadow map moves the shadow rather than the sample.\n\n**Measured on an AMD card at 1280x720, both backends.** An unresolved edge has *zero* pixels between the two colours it separates; a resolved one has 1,078 on WebGL2 and 1,101 on WebGPU, and the frame's mean brightness moves by 0.007% \u2014 antialiased and not displaced, which is what the centred jitter sequence buys. Turning the accumulation off with the jitter left on returns both backends to zero, which is how `scripts/temporal-aa-check.mjs` was shown to have teeth.\n\n**Off by default, and that is not timidity**: jittering the projection moves every pixel of every frame by construction, so defaulting it on would move every published capture and every consumer's reference images with them.\n\n**The motion is the camera's.** That is exact for a static world under a moving camera and wrong for a moving object under a still one. The neighbourhood clip rejects the ghost where the background differs and cannot where it does not; a velocity buffer is what would fix it, and it means a second colour attachment on the scene pass with every draw path writing into it. `demo/dev/taa.html` drives it, `?taa=0` against `?taa=1`."
821
837
  },
822
838
  {
823
839
  "kind": "fixed",
824
840
  "short": "The previous frame's view-projection is kept whenever an effect reprojects, not only while camera motion blur is on, so one running alone no longer reads a matrix nothing has written.",
825
- "text": "The reprojection matrix was built and `previousViewProj` written only while `cameraMotionBlur` was above zero. Any other effect reprojecting through it — the temporal resolve is the first — would have read a matrix nothing had ever written, reprojecting every pixel through an identity and sampling the history at the wrong place for the life of the renderer. Both backends now keep it whenever anything asks."
841
+ "text": "The reprojection matrix was built and `previousViewProj` written only while `cameraMotionBlur` was above zero. Any other effect reprojecting through it \u2014 the temporal resolve is the first \u2014 would have read a matrix nothing had ever written, reprojecting every pixel through an identity and sampling the history at the wrong place for the life of the renderer. Both backends now keep it whenever anything asks."
826
842
  }
827
843
  ]
828
844
  },
@@ -833,12 +849,12 @@
833
849
  {
834
850
  "kind": "fixed",
835
851
  "short": "glTF's archived specular-glossiness model imports. Such a file bound none of its textures and painted flat; its diffuse maps bind and the specular/glossiness pair solves into a metalness.",
836
- "text": "**`KHR_materials_pbrSpecularGlossiness` was not read at all**, and a material using it carries **no `pbrMetallicRoughness` object** — its colour map is `diffuseTexture`. The reader therefore found no base colour map, returned `albedo: -1` for every surface, and the file's images were decoded, embedded in the bake, and sampled by nothing. Measured on a 1995 Fiat Punto GT: **20 textures loaded, 0 bound**, 144 of 144 meshes reported as carrying no albedo map, and the whole car painted in one flat fallback colour — no badge, no grille, no glass tint, no tyre tread, on a model whose largest single texture is a 10 MB body map. It is Khronos' first PBR material model, archived in 2020, and still what an asset store serves for anything exported from a specular workflow.\n\n**The specular colour and the glossiness are solved for jointly rather than renamed.** They are not a metalness and a roughness under other names: a dielectric's reflectance is fixed at 4% and its colour lives in its diffuse, while a metal has no diffuse at all and its colour lives in its specular, so a given pair is produced by exactly one metalness between those ends. That is the extension's own Appendix B, conversion and base-colour derivation both, and the derivation is what makes chrome — diffuse black, colour entirely in the specular — arrive as chrome rather than as a black mirror.\n\n**A `specularGlossinessTexture` is the half that cannot be carried, and it is left absent rather than guessed.** It packs the specular colour in RGB and the glossiness in A; glTF's ORM packing wants roughness in G and metallic in B, so producing one means decoding and re-encoding every image to move a channel — a baker's job and not a reader's. Binding it unconverted would be worse than binding nothing, because the renderer would read a specular colour's green as a roughness. Such a surface draws as a dielectric at the engine's default roughness, which is the rule a metallic-roughness material with an ORM map already follows, **and the reader names every material it applies to** — once per material rather than once per primitive, which on that car is four warnings instead of a hundred and forty-four.\n\n**A file carrying both blocks is read as the core one.** The extension's text says the opposite, and that rule is written for a renderer that supports it completely; an exporter that wrote both did the conversion with the texels in hand and put a real `metallicRoughnessTexture` in the core block, which is a better answer than this conversion can compute.\n\n**And it reaches a `.glb`.** The workaround this replaces rewrote the `.gltf` JSON before the reader saw it, so a `.glb` — whose JSON is a length-prefixed chunk inside the file — got nothing from it. The choice is made on the parsed document, which is what both spellings become, so there is no second path to keep in step."
852
+ "text": "**`KHR_materials_pbrSpecularGlossiness` was not read at all**, and a material using it carries **no `pbrMetallicRoughness` object** \u2014 its colour map is `diffuseTexture`. The reader therefore found no base colour map, returned `albedo: -1` for every surface, and the file's images were decoded, embedded in the bake, and sampled by nothing. Measured on a 1995 Fiat Punto GT: **20 textures loaded, 0 bound**, 144 of 144 meshes reported as carrying no albedo map, and the whole car painted in one flat fallback colour \u2014 no badge, no grille, no glass tint, no tyre tread, on a model whose largest single texture is a 10 MB body map. It is Khronos' first PBR material model, archived in 2020, and still what an asset store serves for anything exported from a specular workflow.\n\n**The specular colour and the glossiness are solved for jointly rather than renamed.** They are not a metalness and a roughness under other names: a dielectric's reflectance is fixed at 4% and its colour lives in its diffuse, while a metal has no diffuse at all and its colour lives in its specular, so a given pair is produced by exactly one metalness between those ends. That is the extension's own Appendix B, conversion and base-colour derivation both, and the derivation is what makes chrome \u2014 diffuse black, colour entirely in the specular \u2014 arrive as chrome rather than as a black mirror.\n\n**A `specularGlossinessTexture` is the half that cannot be carried, and it is left absent rather than guessed.** It packs the specular colour in RGB and the glossiness in A; glTF's ORM packing wants roughness in G and metallic in B, so producing one means decoding and re-encoding every image to move a channel \u2014 a baker's job and not a reader's. Binding it unconverted would be worse than binding nothing, because the renderer would read a specular colour's green as a roughness. Such a surface draws as a dielectric at the engine's default roughness, which is the rule a metallic-roughness material with an ORM map already follows, **and the reader names every material it applies to** \u2014 once per material rather than once per primitive, which on that car is four warnings instead of a hundred and forty-four.\n\n**A file carrying both blocks is read as the core one.** The extension's text says the opposite, and that rule is written for a renderer that supports it completely; an exporter that wrote both did the conversion with the texels in hand and put a real `metallicRoughnessTexture` in the core block, which is a better answer than this conversion can compute.\n\n**And it reaches a `.glb`.** The workaround this replaces rewrote the `.gltf` JSON before the reader saw it, so a `.glb` \u2014 whose JSON is a length-prefixed chunk inside the file \u2014 got nothing from it. The choice is made on the parsed document, which is what both spellings become, so there is no second path to keep in step."
837
853
  },
838
854
  {
839
855
  "kind": "fixed",
840
856
  "short": "The baker leaves out images no material samples and renumbers the ordinals that stay. A bake carried its source's whole texture list before: 20 textures on a car that reached none of them.",
841
- "text": "**Every image the source declared was embedded, whether or not anything could sample it.** `MATL` carries four texture ordinals and they index `TEXS`, so compacting that list without rewriting them repaints the model — which is exactly why `readModel` leaves a reference that resolved to nothing in place, and why `embedTextures` writes a 1x1 placeholder instead of dropping an entry. Each of those was right on its own, neither could do the whole job, and so nothing did.\n\nIt is one function now and it does both halves together: the entries nothing reaches go, the ordinals that stay are renumbered, and every nested level does the same for itself because a level is a whole asset. **A dropped image is never opened**, so a block-compressed texture nothing samples costs no decode and no PNG re-encode — the stage that turned 3.6 MB of DDS into 11.6 MB of RGBA on a shipped car.\n\n**Measured at a whole model on the case that prompted it**: a car written in glTF's archived specular-glossiness model embedded 20 textures and reached none of them, the largest a 10 MB body map. That half is closed by the reader understanding the extension; what stays unreachable is the specular-glossiness map itself, whose channels no rearrangement fits into an ORM one, and that is what this leaves out.\n\n**The baker says what it left out, and what the model carried for it**, because an image silently missing from a bake is not a saving: `1 image no material samples, not embedded: body_sg (8 KB the model carried)`.\n\n**The cost is stated rather than hidden.** An image no material samples is no longer in the asset, so `TextureSet.get` throws for its name the way it throws for any name the file does not have. A consumer that wants an unsampled image out of a container needs a way to say so; nothing does today, and the alternative is every asset carrying its source's whole texture folder. `HANDBOOK` §3 had promised this behaviour — *only images a material actually reaches are embedded* — for longer than it had been true."
857
+ "text": "**Every image the source declared was embedded, whether or not anything could sample it.** `MATL` carries four texture ordinals and they index `TEXS`, so compacting that list without rewriting them repaints the model \u2014 which is exactly why `readModel` leaves a reference that resolved to nothing in place, and why `embedTextures` writes a 1x1 placeholder instead of dropping an entry. Each of those was right on its own, neither could do the whole job, and so nothing did.\n\nIt is one function now and it does both halves together: the entries nothing reaches go, the ordinals that stay are renumbered, and every nested level does the same for itself because a level is a whole asset. **A dropped image is never opened**, so a block-compressed texture nothing samples costs no decode and no PNG re-encode \u2014 the stage that turned 3.6 MB of DDS into 11.6 MB of RGBA on a shipped car.\n\n**Measured at a whole model on the case that prompted it**: a car written in glTF's archived specular-glossiness model embedded 20 textures and reached none of them, the largest a 10 MB body map. That half is closed by the reader understanding the extension; what stays unreachable is the specular-glossiness map itself, whose channels no rearrangement fits into an ORM one, and that is what this leaves out.\n\n**The baker says what it left out, and what the model carried for it**, because an image silently missing from a bake is not a saving: `1 image no material samples, not embedded: body_sg (8 KB the model carried)`.\n\n**The cost is stated rather than hidden.** An image no material samples is no longer in the asset, so `TextureSet.get` throws for its name the way it throws for any name the file does not have. A consumer that wants an unsampled image out of a container needs a way to say so; nothing does today, and the alternative is every asset carrying its source's whole texture folder. `HANDBOOK` \u00a73 had promised this behaviour \u2014 *only images a material actually reaches are embedded* \u2014 for longer than it had been true."
842
858
  }
843
859
  ]
844
860
  },
@@ -849,22 +865,22 @@
849
865
  {
850
866
  "kind": "fixed",
851
867
  "short": "The WebGPU draw and material rings grow to fit the scene. A frame that ran out loses geometry once and is whole from the next, instead of for as long as the scene stays that size.",
852
- "text": "**Past `MAX_DRAWS_PER_FRAME` or `MAX_MATERIALS_PER_FRAME` the backend skipped the work, and went on skipping it every frame.** A consumer whose world simply got bigger lost a stretch of it permanently — reported from a game as ground that appears and disappears as the camera moves. The ceiling was sized for a phone's memory, so the number could not be raised far enough to be safe; what was wrong was that it was fixed at all.\n\nBoth rings now grow at the start of the frame after one that ran out, to the next power of two above what that frame *asked for*. **Measured on a real WebGPU device against a scene of 1,200 material changes: the first frame reports `materials 1200/1024 dropped 176` and the third reports nothing dropped**, drawing all 1,200.\n\n**At the start of a frame and nowhere else**, which keeps `UniformRing.allocate`'s refusal to grow intact for the reason it gives: growing where a draw runs out would stall the frame to make room. At `beginFrame` everything outstanding has been submitted and nothing has been written into the staging that discarding it would lose.\n\n**Growing replaces the `GPUBuffer`, so every bind group holding it is rebuilt** — the cached albedo groups, the skinned twins and the blank one a pass starts from. The blank is rebuilt with the material maps forced to null rather than with whatever the last frame left set, or a group that means *no maps at all* would quietly carry one.\n\n**Bounded at 64 MiB of staging a ring**, past which it refuses and goes back to skipping and reporting: a runaway frame should degrade rather than crash a phone. For the flat vertex ring that is about forty-three thousand draws.\n\nThe other eleven ceilings are unchanged. They are feature counts — sixteen bodies of water, eight flocks — rather than measures of how much world is in frame, and a consumer reaching one has a question a bigger ring does not answer. They report through `frameBudget` like everything else."
868
+ "text": "**Past `MAX_DRAWS_PER_FRAME` or `MAX_MATERIALS_PER_FRAME` the backend skipped the work, and went on skipping it every frame.** A consumer whose world simply got bigger lost a stretch of it permanently \u2014 reported from a game as ground that appears and disappears as the camera moves. The ceiling was sized for a phone's memory, so the number could not be raised far enough to be safe; what was wrong was that it was fixed at all.\n\nBoth rings now grow at the start of the frame after one that ran out, to the next power of two above what that frame *asked for*. **Measured on a real WebGPU device against a scene of 1,200 material changes: the first frame reports `materials 1200/1024 dropped 176` and the third reports nothing dropped**, drawing all 1,200.\n\n**At the start of a frame and nowhere else**, which keeps `UniformRing.allocate`'s refusal to grow intact for the reason it gives: growing where a draw runs out would stall the frame to make room. At `beginFrame` everything outstanding has been submitted and nothing has been written into the staging that discarding it would lose.\n\n**Growing replaces the `GPUBuffer`, so every bind group holding it is rebuilt** \u2014 the cached albedo groups, the skinned twins and the blank one a pass starts from. The blank is rebuilt with the material maps forced to null rather than with whatever the last frame left set, or a group that means *no maps at all* would quietly carry one.\n\n**Bounded at 64 MiB of staging a ring**, past which it refuses and goes back to skipping and reporting: a runaway frame should degrade rather than crash a phone. For the flat vertex ring that is about forty-three thousand draws.\n\nThe other eleven ceilings are unchanged. They are feature counts \u2014 sixteen bodies of water, eight flocks \u2014 rather than measures of how much world is in frame, and a consumer reaching one has a question a bigger ring does not answer. They report through `frameBudget` like everything else."
853
869
  },
854
870
  {
855
871
  "kind": "added",
856
872
  "short": "renderer.frameBudget reports what a frame asked for against every ceiling the backend imposes, so a check can fail on it instead of a console warning nobody reads.",
857
- "text": "**A per-frame ceiling firing was unobservable, and that cost a consumer weeks.** The WebGPU backend holds thirteen of them — draws, material changes, shadow draws, water bodies, light volumes, three kinds of effect batch, overlays — and past any one it skips the work and writes a single `console.warn` for the lifetime of the renderer. That cannot be asserted on, cannot be read from a headless check, and is gone by the time anybody looks. One consumer's open report on ground that appeared and disappeared as the camera moved still listed, as the experiment nobody had run, *ask the reporter for the console*; it had written a draw counter and a per-material field of its own to guess at a worst case the renderer already knew exactly.\n\n`renderer.frameBudget` is that number. Each line carries what was **asked for** — not what fit, which is the distinction that makes it actionable: a ring naturally reports 1,024 of 1,024 and says a frame is exactly full, where 1,267 of 1,024 says how much to cut. `frameBudget.dropped` is one boolean a consumer's own suite can fail on, and it stays correct when a ceiling is added that the check was written before.\n\n**Counted in `UniformRing` rather than at the thirteen call sites**, because the ring is the only place that knows both halves. It also means the count matches what each ceiling actually governs: `materialSlotForDraw` reuses an open slot without allocating, so the material line counts material *changes* and not draws.\n\n**WebGL2 reports the same lines with a `null` ceiling, which is the point rather than a gap.** It imposes no per-frame limit on anything, so a scene over a WebGPU ceiling draws in full there and loses geometry here, with nothing failing on either side — and most development happens on WebGL2, because it is the fallback that runs everywhere. A consumer can now compare its own count against the ceiling the other backend publishes, before a player finds it."
873
+ "text": "**A per-frame ceiling firing was unobservable, and that cost a consumer weeks.** The WebGPU backend holds thirteen of them \u2014 draws, material changes, shadow draws, water bodies, light volumes, three kinds of effect batch, overlays \u2014 and past any one it skips the work and writes a single `console.warn` for the lifetime of the renderer. That cannot be asserted on, cannot be read from a headless check, and is gone by the time anybody looks. One consumer's open report on ground that appeared and disappeared as the camera moved still listed, as the experiment nobody had run, *ask the reporter for the console*; it had written a draw counter and a per-material field of its own to guess at a worst case the renderer already knew exactly.\n\n`renderer.frameBudget` is that number. Each line carries what was **asked for** \u2014 not what fit, which is the distinction that makes it actionable: a ring naturally reports 1,024 of 1,024 and says a frame is exactly full, where 1,267 of 1,024 says how much to cut. `frameBudget.dropped` is one boolean a consumer's own suite can fail on, and it stays correct when a ceiling is added that the check was written before.\n\n**Counted in `UniformRing` rather than at the thirteen call sites**, because the ring is the only place that knows both halves. It also means the count matches what each ceiling actually governs: `materialSlotForDraw` reuses an open slot without allocating, so the material line counts material *changes* and not draws.\n\n**WebGL2 reports the same lines with a `null` ceiling, which is the point rather than a gap.** It imposes no per-frame limit on anything, so a scene over a WebGPU ceiling draws in full there and loses geometry here, with nothing failing on either side \u2014 and most development happens on WebGL2, because it is the fallback that runs everywhere. A consumer can now compare its own count against the ceiling the other backend publishes, before a player finds it."
858
874
  },
859
875
  {
860
876
  "kind": "fixed",
861
877
  "short": "The WebGPU material-ceiling warning said draws would reuse the last material. They are skipped, and always have been. The doc comment that raised the ring from 256 said the same wrong thing.",
862
- "text": "**`WebGPU: more than 1024 materials in a frame; the rest reuse the last` was wrong in the commit that wrote it.** `materialSlotForDraw` returns null and the caller skips the draw; nothing has ever been drawn with another material's numbers. Both halves went in together, so this is not a message left behind by a later change — it has never described the code.\n\n**It matters because the two failures send you to different places.** Wrong texture is a material problem; missing geometry is a culling or budget problem. The consumer that reported this went looking at culling, which is where its weeks went, while the console line it was reading described a symptom the renderer cannot produce.\n\n**And the wrong failure was the stated argument for the ring's size.** The comment at `MAX_MATERIALS_PER_FRAME` justified raising it from 256 to 1,024 with *reads as a stretch of the world losing its texture and has been reported twice from a consumer*. The number may still be right; the reasoning recorded beside it was not. Both now say what the code does, and a test pins the behaviour rather than the wording so they cannot part again."
878
+ "text": "**`WebGPU: more than 1024 materials in a frame; the rest reuse the last` was wrong in the commit that wrote it.** `materialSlotForDraw` returns null and the caller skips the draw; nothing has ever been drawn with another material's numbers. Both halves went in together, so this is not a message left behind by a later change \u2014 it has never described the code.\n\n**It matters because the two failures send you to different places.** Wrong texture is a material problem; missing geometry is a culling or budget problem. The consumer that reported this went looking at culling, which is where its weeks went, while the console line it was reading described a symptom the renderer cannot produce.\n\n**And the wrong failure was the stated argument for the ring's size.** The comment at `MAX_MATERIALS_PER_FRAME` justified raising it from 256 to 1,024 with *reads as a stretch of the world losing its texture and has been reported twice from a consumer*. The number may still be right; the reasoning recorded beside it was not. Both now say what the code does, and a test pins the behaviour rather than the wording so they cannot part again."
863
879
  },
864
880
  {
865
881
  "kind": "fixed",
866
882
  "short": "Two WebGPU tests named for the exhausted material ring stopped reaching it when that ring grew, and kept passing. They drive to the ceiling the renderer reports now.",
867
- "text": "**`restores uLightingEnabled/uFogEnabled even when the material ring is exhausted` and its sibling both wrote `const materialRingCapacity = 256` and issued 257 draws.** When the ring became 1,024 they went on passing without ever reaching the null return they are named for — measured before the fix at 257 slots taken of 1,024. The restore they assert happens on the ordinary path too, which is why nothing failed.\n\nThey read the ceiling off `frameBudget` now, so a future raise cannot unhook them again. That is the same defect one layer down: the ceiling was unobservable from outside the renderer, and the tests could not see it either."
883
+ "text": "**`restores uLightingEnabled/uFogEnabled even when the material ring is exhausted` and its sibling both wrote `const materialRingCapacity = 256` and issued 257 draws.** When the ring became 1,024 they went on passing without ever reaching the null return they are named for \u2014 measured before the fix at 257 slots taken of 1,024. The restore they assert happens on the ordinary path too, which is why nothing failed.\n\nThey read the ceiling off `frameBudget` now, so a future raise cannot unhook them again. That is the same defect one layer down: the ceiling was unobservable from outside the renderer, and the tests could not see it either."
868
884
  }
869
885
  ]
870
886
  },
@@ -875,7 +891,7 @@
875
891
  {
876
892
  "kind": "added",
877
893
  "short": "Spring secondary motion in @driftengine/animation: hair, a coat, an antenna or a chain, sampled as a pure function of the time you ask about, so it scrubs backwards.",
878
- "text": "**`sampleSpring` and `sampleSpringChain` answer *where is it at time t*, for any `t`, in any order.** Nothing accumulates between calls, nothing reads a clock, and the same `t` gives the same three floats whether the caller reached it by playing forwards or by dragging a playhead back — which is `sampleClip`'s contract and the reason this sits beside it rather than in `@driftengine/physics`.\n\n**A ragdoll is the obvious tool for this and is the wrong one**, for a reason the consumer who asked wrote down before we did: a ragdoll integrates state, so a preview reading an audio clock and an export reading a frame index disagree, and two exports of one project differ. **What makes a pure version possible is that a damped spring forgets.** The anchor's influence decays as `e^(-ζω(t-τ))`, so evaluating at `t` is: start at rest a settle time earlier, march forward, return. `springSettleSec` is that lookback, derived rather than dialled.\n\n**Each substep is solved in closed form, and that is a measurement rather than a preference.** Stepping the equation with semi-implicit Euler missed a four-thousand-step reference march by 0.058 at thirty-two substeps per period, 0.029 at sixty-four and 0.0075 at two hundred and fifty-six — first order, and it needed about a thousand substeps a period to hold a hundredth, three thousand per joint per frame. Subtracting the moving anchor's own trail leaves an unforced oscillator, whose solution over a fixed step is a 2x2 built once per link: the error is second order, **4.0e-4 at thirty-two substeps**, and the cost is ninety-odd steps of four multiplies.\n\n**A chain is not a list of springs, and `springChainSettleSec` is where that shows.** Each link hangs off where the one above it actually *is*, so a cascade holds a disturbance longer than any of its links: the composite carries a polynomial in `t` beside the exponential and the lookback grows with depth. Sampling links independently would also cost `steps^depth` anchor calls, so the whole chain marches on one grid — `steps × depth`, same answer.\n\n**`maxOffsetM` is a limit and not a stiffness change**, applied to the finished position rather than inside the march, because clamping a position the spring never reached back into its own velocity makes a different spring. **Damping of exactly 0 is refused by name**: an undamped spring rings forever, so no lookback is long enough and there is no honest answer to give."
894
+ "text": "**`sampleSpring` and `sampleSpringChain` answer *where is it at time t*, for any `t`, in any order.** Nothing accumulates between calls, nothing reads a clock, and the same `t` gives the same three floats whether the caller reached it by playing forwards or by dragging a playhead back \u2014 which is `sampleClip`'s contract and the reason this sits beside it rather than in `@driftengine/physics`.\n\n**A ragdoll is the obvious tool for this and is the wrong one**, for a reason the consumer who asked wrote down before we did: a ragdoll integrates state, so a preview reading an audio clock and an export reading a frame index disagree, and two exports of one project differ. **What makes a pure version possible is that a damped spring forgets.** The anchor's influence decays as `e^(-\u03b6\u03c9(t-\u03c4))`, so evaluating at `t` is: start at rest a settle time earlier, march forward, return. `springSettleSec` is that lookback, derived rather than dialled.\n\n**Each substep is solved in closed form, and that is a measurement rather than a preference.** Stepping the equation with semi-implicit Euler missed a four-thousand-step reference march by 0.058 at thirty-two substeps per period, 0.029 at sixty-four and 0.0075 at two hundred and fifty-six \u2014 first order, and it needed about a thousand substeps a period to hold a hundredth, three thousand per joint per frame. Subtracting the moving anchor's own trail leaves an unforced oscillator, whose solution over a fixed step is a 2x2 built once per link: the error is second order, **4.0e-4 at thirty-two substeps**, and the cost is ninety-odd steps of four multiplies.\n\n**A chain is not a list of springs, and `springChainSettleSec` is where that shows.** Each link hangs off where the one above it actually *is*, so a cascade holds a disturbance longer than any of its links: the composite carries a polynomial in `t` beside the exponential and the lookback grows with depth. Sampling links independently would also cost `steps^depth` anchor calls, so the whole chain marches on one grid \u2014 `steps \u00d7 depth`, same answer.\n\n**`maxOffsetM` is a limit and not a stiffness change**, applied to the finished position rather than inside the march, because clamping a position the spring never reached back into its own velocity makes a different spring. **Damping of exactly 0 is refused by name**: an undamped spring rings forever, so no lookback is long enough and there is no honest answer to give."
879
895
  }
880
896
  ]
881
897
  },
@@ -886,22 +902,22 @@
886
902
  {
887
903
  "kind": "added",
888
904
  "short": "The DDS reader takes a DX10 header and BC2 (DXT3). A consumer carrying a header rewrite of its own because this reader would not take the file can delete it.",
889
- "text": "**A `DX10` header names the format in a 20-byte extension rather than in the four characters at byte 84, and the blocks behind it are the same blocks this reader already decoded.** `DXGI_FORMAT_BC3_UNORM_SRGB` is bit for bit the BC3 announced as `DXT5` — sRGB is a statement about how the values are read, not about how they are stored. The consumer who reported it counted the bytes: 5,592,580 against 5,592,560 for the classic spelling of the same 2048² chain, which is 148 + 5,592,432 against 128 + 5,592,432.\n\n**BC2 (DXT3) decodes too**: eight bytes of explicit four-bit alpha, one nibble a texel, scaled by replication so `0xF` arrives as 255 and not 240.\n\n**Measured on the six vehicle bundles it was reported from: 31 surfaces of 766 that returned a 1x1 white pixel now decode** — 19 at BC3 behind a `DX10` header, one at BC1, and 11 DXT3. The nineteen are one car's paint, its wheels, its lamps, its plate and seven interior maps, and they cost that consumer a *lighting* bug rather than a missing texture: white in an ORM map is roughness 1 **and** metallic 1, and a fully metallic surface in the flat shader has no diffuse and no sun term. The bodywork stopped being paint and became a blurred mirror of the field the car was parked in.\n\n**What is still refused, and on purpose:** a cube map, a texture array or a volume behind that header, because a classic header cannot state any of them and accepting one would hand the caller six faces claiming to be a single surface. Also BC4, signed BC5, BC6H and BC7, none of which this reader decodes.\n\n**If you carry a header rewrite of your own, it can go.** `blockDdsFromDx10` in `confine`'s `scripts/dds.mjs` exists only because this reader would not take the file."
905
+ "text": "**A `DX10` header names the format in a 20-byte extension rather than in the four characters at byte 84, and the blocks behind it are the same blocks this reader already decoded.** `DXGI_FORMAT_BC3_UNORM_SRGB` is bit for bit the BC3 announced as `DXT5` \u2014 sRGB is a statement about how the values are read, not about how they are stored. The consumer who reported it counted the bytes: 5,592,580 against 5,592,560 for the classic spelling of the same 2048\u00b2 chain, which is 148 + 5,592,432 against 128 + 5,592,432.\n\n**BC2 (DXT3) decodes too**: eight bytes of explicit four-bit alpha, one nibble a texel, scaled by replication so `0xF` arrives as 255 and not 240.\n\n**Measured on the six vehicle bundles it was reported from: 31 surfaces of 766 that returned a 1x1 white pixel now decode** \u2014 19 at BC3 behind a `DX10` header, one at BC1, and 11 DXT3. The nineteen are one car's paint, its wheels, its lamps, its plate and seven interior maps, and they cost that consumer a *lighting* bug rather than a missing texture: white in an ORM map is roughness 1 **and** metallic 1, and a fully metallic surface in the flat shader has no diffuse and no sun term. The bodywork stopped being paint and became a blurred mirror of the field the car was parked in.\n\n**What is still refused, and on purpose:** a cube map, a texture array or a volume behind that header, because a classic header cannot state any of them and accepting one would hand the caller six faces claiming to be a single surface. Also BC4, signed BC5, BC6H and BC7, none of which this reader decodes.\n\n**If you carry a header rewrite of your own, it can go.** `blockDdsFromDx10` in `confine`'s `scripts/dds.mjs` exists only because this reader would not take the file."
890
906
  },
891
907
  {
892
908
  "kind": "added",
893
909
  "short": "An uncompressed DDS decodes: 32- and 24-bit RGB in any channel order, and 8- and 16-bit luminance, with or without alpha.",
894
- "text": "**A pixel format that describes channels rather than naming a compression**, which this reader refused for as long as it has existed. There is no block to decode, so it is a bit count, four masks and a copy.\n\n**One mask walk and no per-format branch**, because the masks *are* the format — a reader with a case per layout is a reader with a case missing. The layouts one consumer's six vehicle bundles carry, surveyed rather than guessed: 32-bit BGRA and RGBA, 24-bit BGR, and 8- and 16-bit luminance with and without alpha.\n\n**Three things this gets right that are quiet when they are wrong**, each with its own test. A narrow channel is scaled by *replication*, so five bits of `0x1F` is 255 and not 248 — otherwise a white surface comes back very slightly grey on every texel. A 24-bit surface has no alpha channel and comes back opaque rather than empty. And a declared row pitch is honoured, because a decoder that assumes `width * bytes` walks diagonally through a padded surface and returns the right picture progressively more sheared down the frame.\n\n**Measured across those bundles: all 766 DDS surfaces now decode and none is refused**, against 477 and 289 before this release."
910
+ "text": "**A pixel format that describes channels rather than naming a compression**, which this reader refused for as long as it has existed. There is no block to decode, so it is a bit count, four masks and a copy.\n\n**One mask walk and no per-format branch**, because the masks *are* the format \u2014 a reader with a case per layout is a reader with a case missing. The layouts one consumer's six vehicle bundles carry, surveyed rather than guessed: 32-bit BGRA and RGBA, 24-bit BGR, and 8- and 16-bit luminance with and without alpha.\n\n**Three things this gets right that are quiet when they are wrong**, each with its own test. A narrow channel is scaled by *replication*, so five bits of `0x1F` is 255 and not 248 \u2014 otherwise a white surface comes back very slightly grey on every texel. A 24-bit surface has no alpha channel and comes back opaque rather than empty. And a declared row pitch is honoured, because a decoder that assumes `width * bytes` walks diagonally through a padded surface and returns the right picture progressively more sheared down the frame.\n\n**Measured across those bundles: all 766 DDS surfaces now decode and none is refused**, against 477 and 289 before this release."
895
911
  },
896
912
  {
897
913
  "kind": "fixed",
898
914
  "short": "A BC3 block whose colour endpoints are ordered low-to-high no longer decodes its fourth index to transparent black. BC2 and BC3 never take BC1's punch-through mode.",
899
- "text": "**`decodeColourBlock` was reached unconditionally, so BC3 inherited BC1's three-colour mode.** BC2 and BC3 carry alpha in their own half of the block, which leaves no index over to mean transparent: their colour half is *always* the four-colour opaque mode, whatever the order of the endpoints. A BC3 block written with `c0 <= c1` therefore decoded its fourth index to transparent black instead of to the interpolant two thirds of the way between them.\n\nInvisible in every test this reader had, because they all order their endpoints the other way, and uncommon in real data because an encoder with no reason to write them low usually does not. Found by reading BC2's specification while adding it — it says the same thing about the same bytes — and it has a test that fails without the fix."
915
+ "text": "**`decodeColourBlock` was reached unconditionally, so BC3 inherited BC1's three-colour mode.** BC2 and BC3 carry alpha in their own half of the block, which leaves no index over to mean transparent: their colour half is *always* the four-colour opaque mode, whatever the order of the endpoints. A BC3 block written with `c0 <= c1` therefore decoded its fourth index to transparent black instead of to the interpolant two thirds of the way between them.\n\nInvisible in every test this reader had, because they all order their endpoints the other way, and uncommon in real data because an encoder with no reason to write them low usually does not. Found by reading BC2's specification while adding it \u2014 it says the same thing about the same bytes \u2014 and it has a test that fails without the fix."
900
916
  },
901
917
  {
902
918
  "kind": "changed",
903
919
  "short": "A .kn5 material's txMaps is no longer bound as a glTF ORM map. Every Assetto Corsa car's paint was rendering fully matte; it now keeps the roughness its own ksSpecularEXP states.",
904
- "text": "**glTF's ORM is occlusion in R, roughness in G and metallic in B. AC's `txMaps` is not that packing** — two independent references give it as specular intensity in R, reflection and specular *sharpness* in G, and reflection intensity in B. Sharpness is the inverse of roughness, so a G pinned at 255, which is what the paint map of every bundle measured holds, is a file asking for the sharpest reflection available and was being read as the bluntest. Measured, G against each material's own roughness: the Hyundai i20 N 255 against 0.180, the Fiat Panda 255 against 0.140, the Giulia GTAm 255 against 0.196.\n\n**The half of that which needed no reference** is that `roughness` is derived from `ksSpecularEXP` two lines above the binding and is correct, and the shader *replaces* it with the map's G rather than scaling it. The reader computed the right number and then discarded it for a channel it had already described as unestablished.\n\nNot binding it is not a claim about what `txMaps` means; it is declining to assert a packing that is not there. The reader now says so once per model rather than reading the texture past in silence.\n\n**If you have tuned anything around AC paint coming out wrong, re-tune it.** Every imported AC car's paint changes: it keeps the roughness its own material states instead of 1.0. `docs/IMPROVEMENTS.md` carries the measurements and what would justify reading the map properly — a shader reference for `ksPerPixelMultiMap` settling the curve between AC's sharpness and this engine's roughness."
920
+ "text": "**glTF's ORM is occlusion in R, roughness in G and metallic in B. AC's `txMaps` is not that packing** \u2014 two independent references give it as specular intensity in R, reflection and specular *sharpness* in G, and reflection intensity in B. Sharpness is the inverse of roughness, so a G pinned at 255, which is what the paint map of every bundle measured holds, is a file asking for the sharpest reflection available and was being read as the bluntest. Measured, G against each material's own roughness: the Hyundai i20 N 255 against 0.180, the Fiat Panda 255 against 0.140, the Giulia GTAm 255 against 0.196.\n\n**The half of that which needed no reference** is that `roughness` is derived from `ksSpecularEXP` two lines above the binding and is correct, and the shader *replaces* it with the map's G rather than scaling it. The reader computed the right number and then discarded it for a channel it had already described as unestablished.\n\nNot binding it is not a claim about what `txMaps` means; it is declining to assert a packing that is not there. The reader now says so once per model rather than reading the texture past in silence.\n\n**If you have tuned anything around AC paint coming out wrong, re-tune it.** Every imported AC car's paint changes: it keeps the roughness its own material states instead of 1.0. `docs/IMPROVEMENTS.md` carries the measurements and what would justify reading the map properly \u2014 a shader reference for `ksPerPixelMultiMap` settling the curve between AC's sharpness and this engine's roughness."
905
921
  }
906
922
  ]
907
923
  },
@@ -912,7 +928,7 @@
912
928
  {
913
929
  "kind": "fixed",
914
930
  "short": "Bolts, flocks, wind streaks and caustics each hold a uniform slot per batch on WebGPU, so a frame drawing two of any of them no longer gives both the second one's colour, width, time and placement.",
915
- "text": "**Four passes wrote their per-draw uniforms into one shared buffer and then recorded a draw.** `queue.write*` calls are ordered on the queue timeline and the frame's encoder is submitted after every one of them, so the *last* write reached every draw: two bolt pools, two flocks, two wind-street lattices or two caustic batches in one frame all came out wearing the second one's numbers. Each of these takes a handle per batch, so drawing several is what the API is for — and one of each is correct, which is why it never showed. Water had the same defect and lost three bodies of four.\n\nEach now takes a slot from a `UniformRing` and binds it by dynamic offset, which is the fix water took on 2026-08-28. A batch past the ceiling of eight is **told once and skipped**, because a batch that cannot be addressed must not be drawn with another batch's numbers.\n\n**Measured on `demo/dev/batches.html`**, which draws two of each tinted red and blue, with WebGL2 as the control since it sets uniforms and draws in one stream. The first batch's signature in the frame with both, before and after: bolts **0 then 4,492**, caustics **0 then 21,088**, the flock **0 then 1,699**, wind streaks **0 then 1,003**. `npm run check:batches` is the instrument.\n\n**The flock said more than the tint**: both flocks drew at the same centre, so the shared slot was carrying placement too — the blue count stayed at exactly one flock's instead of doubling.\n\n**A scene drawing one batch each is unchanged.** `storm-sea` on WebGPU — a flock, arcs and rain, one batch each — before against after: **0 of 750,080 pixels** differ across the frame the harness compares. Both captures were taken against a dev server started for the purpose: one left running across the edits serves a mixed module graph, and a first attempt at this measurement was taken from one that had a pass compiled with dynamic offsets against a renderer compiled without them, which drew a black frame and looked exactly like a regression."
931
+ "text": "**Four passes wrote their per-draw uniforms into one shared buffer and then recorded a draw.** `queue.write*` calls are ordered on the queue timeline and the frame's encoder is submitted after every one of them, so the *last* write reached every draw: two bolt pools, two flocks, two wind-street lattices or two caustic batches in one frame all came out wearing the second one's numbers. Each of these takes a handle per batch, so drawing several is what the API is for \u2014 and one of each is correct, which is why it never showed. Water had the same defect and lost three bodies of four.\n\nEach now takes a slot from a `UniformRing` and binds it by dynamic offset, which is the fix water took on 2026-08-28. A batch past the ceiling of eight is **told once and skipped**, because a batch that cannot be addressed must not be drawn with another batch's numbers.\n\n**Measured on `demo/dev/batches.html`**, which draws two of each tinted red and blue, with WebGL2 as the control since it sets uniforms and draws in one stream. The first batch's signature in the frame with both, before and after: bolts **0 then 4,492**, caustics **0 then 21,088**, the flock **0 then 1,699**, wind streaks **0 then 1,003**. `npm run check:batches` is the instrument.\n\n**The flock said more than the tint**: both flocks drew at the same centre, so the shared slot was carrying placement too \u2014 the blue count stayed at exactly one flock's instead of doubling.\n\n**A scene drawing one batch each is unchanged.** `storm-sea` on WebGPU \u2014 a flock, arcs and rain, one batch each \u2014 before against after: **0 of 750,080 pixels** differ across the frame the harness compares. Both captures were taken against a dev server started for the purpose: one left running across the edits serves a mixed module graph, and a first attempt at this measurement was taken from one that had a pass compiled with dynamic offsets against a renderer compiled without them, which drew a black frame and looked exactly like a regression."
916
932
  }
917
933
  ]
918
934
  },
@@ -923,22 +939,22 @@
923
939
  {
924
940
  "kind": "added",
925
941
  "short": "drawKeyOf and resolveDrawGrouping expose the loader's own draw-call grouping, so a consumer counting the draws a bake will cost calls the function the loader calls.",
926
- "text": "**`DrftLoader` merges parts whose material would set the same GPU state, and until now the key that decided it was built inline.** A consumer's baker reports how many draws each baked part will cost, and to do that it rebuilt that key from `DrftMaterial` by hand — its own comment said \"`DrftLoader.uploadOne`'s own key, field for field\", which is exactly what it was.\n\nIt went one field short the day `cutout` joined the key in 3.26.0, and a short key does not throw: it merges two surfaces the loader keeps apart and under-counts. `drawKeyOf(material, override?)` returns the key and `resolveDrawGrouping` returns the ten fields behind it, both from `@driftengine/assets`. `uploadOne` calls them, so the inline key is gone rather than duplicated and the two cannot drift.\n\nThe test asserts one case per field rather than looping the ten names, because the defect being closed is a field going missing and a loop cannot fail for an eleventh that nobody added to it."
942
+ "text": "**`DrftLoader` merges parts whose material would set the same GPU state, and until now the key that decided it was built inline.** A consumer's baker reports how many draws each baked part will cost, and to do that it rebuilt that key from `DrftMaterial` by hand \u2014 its own comment said \"`DrftLoader.uploadOne`'s own key, field for field\", which is exactly what it was.\n\nIt went one field short the day `cutout` joined the key in 3.26.0, and a short key does not throw: it merges two surfaces the loader keeps apart and under-counts. `drawKeyOf(material, override?)` returns the key and `resolveDrawGrouping` returns the ten fields behind it, both from `@driftengine/assets`. `uploadOne` calls them, so the inline key is gone rather than duplicated and the two cannot drift.\n\nThe test asserts one case per field rather than looping the ten names, because the defect being closed is a field going missing and a loop cannot fail for an eleventh that nobody added to it."
927
943
  },
928
944
  {
929
945
  "kind": "changed",
930
946
  "short": "The baker writes a decoded DDS back as PNG instead of embedding it uncompressed, which had taken 21 textures of one shipped car from 3.6 MB of source to 11.6 MB of RGBA.",
931
- "text": "**A block-compressed texture is decoded so a bought model's maps survive the bake, and the decoded surface was then embedded as `CODEC_RAW`** — on the argument that a bake runs offline and never in a frame. That is true of the *time* and not of the file, which a browser downloads. Measured on a shipped car's level of detail B: 21 DDS textures, 3.6 MB in the source, 11.6 MB of RGBA in a 22.8 MB container, 3.3x. A consumer wrote the missing encoder themselves and reported it as the difference between a model that needs sharding to clear a static host's per-file limit and one that does not.\n\n`encodePng` now sits beside the decoder that was already there: `node:zlib`, one filter chosen per row by the sum of absolute residuals, about a hundred lines, Node-side and offline, so nothing a game links grows. Both ends are lossless, so the pixels are the source's.\n\n**The repository's own PNG reader is the wrong oracle for half of it, so a browser was used for that half.** `decodePng` never reads a CRC, so an encoder writing zeros there would round trip through it perfectly and be refused by every browser at `createImageBitmap`. Loaded into headless Chrome, drawn and read back: 0 of 12,288 samples differ.\n\n**The baker had no test at all**, which is how this stood for three minor versions. It has one now, and it reads `TEXS` by the layout `FORMAT.md` states rather than with this repository's reader, since a reader and a writer sharing a mistake agree with each other."
947
+ "text": "**A block-compressed texture is decoded so a bought model's maps survive the bake, and the decoded surface was then embedded as `CODEC_RAW`** \u2014 on the argument that a bake runs offline and never in a frame. That is true of the *time* and not of the file, which a browser downloads. Measured on a shipped car's level of detail B: 21 DDS textures, 3.6 MB in the source, 11.6 MB of RGBA in a 22.8 MB container, 3.3x. A consumer wrote the missing encoder themselves and reported it as the difference between a model that needs sharding to clear a static host's per-file limit and one that does not.\n\n`encodePng` now sits beside the decoder that was already there: `node:zlib`, one filter chosen per row by the sum of absolute residuals, about a hundred lines, Node-side and offline, so nothing a game links grows. Both ends are lossless, so the pixels are the source's.\n\n**The repository's own PNG reader is the wrong oracle for half of it, so a browser was used for that half.** `decodePng` never reads a CRC, so an encoder writing zeros there would round trip through it perfectly and be refused by every browser at `createImageBitmap`. Loaded into headless Chrome, drawn and read back: 0 of 12,288 samples differ.\n\n**The baker had no test at all**, which is how this stood for three minor versions. It has one now, and it reads `TEXS` by the layout `FORMAT.md` states rather than with this repository's reader, since a reader and a writer sharing a mistake agree with each other."
932
948
  },
933
949
  {
934
950
  "kind": "changed",
935
951
  "short": "readModel and gltfToMeshes take deriveTangents, and the baker declines it so the frame is derived after the weld instead of before it. The default is unchanged.",
936
- "text": "**`generateTangents` accumulates a frame per index, so a vertex shared by several triangles comes out smooth — which is what it was written to do.** Run on a mesh as a great many files store one, a corner soup with one vertex per triangle corner, every corner is its own index and gets exactly one triangle's frame. No two corners at a point agree, and `weldMesh` cannot merge them, because two corners alike in position, normal and UV but opposite in the bitangent's sign are a mirrored UV shell and merging those lights one side of a model inside out. Nothing can tell the two cases apart from the data.\n\n`readModel` and `gltfToMeshes` take `deriveTangents`, **on by default**, and `deriveTangentsFor` is exported beside `weldMesh`. The baker passes false and derives afterwards, on merged topology — which is a smaller mesh and a *better* frame, since sharing is exactly what the derivation averages over. A consumer reading a model straight into a renderer never welds and keeps today's behaviour, so this is an option arriving and not public behaviour leaving.\n\n**Two figures in this repository disagreed about what it costs, and both describe a real case.** The frame is normalised, so two triangles differ only where the *direction* u increases in differs. A separable unwrap — u from x, v from y — gives every triangle on a flat surface the same frame: seven vertices of 728,168 on a 253-mesh CAD export. A rotational unwrap turns the direction with position and costs almost the whole weld: 9,600 corners to 9,482 vertices against 1,681, with six different frames at one point at worst. End to end through the baker, a normal-mapped soup bakes to 169 vertices against 830."
952
+ "text": "**`generateTangents` accumulates a frame per index, so a vertex shared by several triangles comes out smooth \u2014 which is what it was written to do.** Run on a mesh as a great many files store one, a corner soup with one vertex per triangle corner, every corner is its own index and gets exactly one triangle's frame. No two corners at a point agree, and `weldMesh` cannot merge them, because two corners alike in position, normal and UV but opposite in the bitangent's sign are a mirrored UV shell and merging those lights one side of a model inside out. Nothing can tell the two cases apart from the data.\n\n`readModel` and `gltfToMeshes` take `deriveTangents`, **on by default**, and `deriveTangentsFor` is exported beside `weldMesh`. The baker passes false and derives afterwards, on merged topology \u2014 which is a smaller mesh and a *better* frame, since sharing is exactly what the derivation averages over. A consumer reading a model straight into a renderer never welds and keeps today's behaviour, so this is an option arriving and not public behaviour leaving.\n\n**Two figures in this repository disagreed about what it costs, and both describe a real case.** The frame is normalised, so two triangles differ only where the *direction* u increases in differs. A separable unwrap \u2014 u from x, v from y \u2014 gives every triangle on a flat surface the same frame: seven vertices of 728,168 on a 253-mesh CAD export. A rotational unwrap turns the direction with position and costs almost the whole weld: 9,600 corners to 9,482 vertices against 1,681, with six different frames at one point at worst. End to end through the baker, a normal-mapped soup bakes to 169 vertices against 830."
937
953
  },
938
954
  {
939
955
  "kind": "note",
940
956
  "short": "Two light volumes in one frame are the sum of each drawn alone, to 0 of 844,800 pixels on both backends. No engine code changed; nothing had ever drawn a second cone.",
941
- "text": "**A claim in a comment became a measurement.** Four WebGPU passes are on record handing every draw in a frame the *last* draw's uniforms — `drawBolts`, `drawFlock`, `drawWindStreaks` and `drawCaustics` each write one shared buffer with `queue.writeBuffer` while the frame's encoder is submitted afterwards. Water had exactly this and lost three bodies of four. `drawLightVolume` is documented as taking its uniforms from a ring, and nothing had ever checked, because one cone is the arrangement in which a shared slot draws the right picture.\n\n`demo/dev/volume.html` gained `?cones=`, `?only=`, `?phase=` and `?warm=`, and `npm run check:cones` reads the answers on both backends. **Two volumes in one frame are the sum of each drawn alone, 0 of 844,800 pixels, nothing clamped.** **A pose swept into over twelve frames is the pose drawn cold, 0 of 844,800**, in haze. So a consumer sweeping a beam through a scene can draw as many as the frame will take.\n\nThe check was watched failing at 21,292 pixels with the last draw's strength and dust forced onto both draws, so it can see the defect it exists for. No engine code changed for this entry, which is the point of it being a note: the row is closed by evidence rather than by a fix."
957
+ "text": "**A claim in a comment became a measurement.** Four WebGPU passes are on record handing every draw in a frame the *last* draw's uniforms \u2014 `drawBolts`, `drawFlock`, `drawWindStreaks` and `drawCaustics` each write one shared buffer with `queue.writeBuffer` while the frame's encoder is submitted afterwards. Water had exactly this and lost three bodies of four. `drawLightVolume` is documented as taking its uniforms from a ring, and nothing had ever checked, because one cone is the arrangement in which a shared slot draws the right picture.\n\n`demo/dev/volume.html` gained `?cones=`, `?only=`, `?phase=` and `?warm=`, and `npm run check:cones` reads the answers on both backends. **Two volumes in one frame are the sum of each drawn alone, 0 of 844,800 pixels, nothing clamped.** **A pose swept into over twelve frames is the pose drawn cold, 0 of 844,800**, in haze. So a consumer sweeping a beam through a scene can draw as many as the frame will take.\n\nThe check was watched failing at 21,292 pixels with the last draw's strength and dust forced onto both draws, so it can see the defect it exists for. No engine code changed for this entry, which is the point of it being a note: the row is closed by evidence rather than by a fix."
942
958
  }
943
959
  ]
944
960
  },
@@ -949,7 +965,7 @@
949
965
  {
950
966
  "kind": "fixed",
951
967
  "short": "ShadowCasterSink.instanced is optional, so a consumer implementing that interface still compiles. 3.29.0 added it as required and broke every existing implementation.",
952
- "text": "**3.29.0 added `instanced` to `ShadowCasterSink` as a required member, which is a breaking change and should not have shipped in a minor.**\n\nThat interface is implemented *outwards*: a consumer writes a sink, and its tests write doubles of one. Adding a member every existing implementation lacks stops all of them compiling — found immediately in a consumer, where three test doubles went red against a feature that game does not use and has no batches for.\n\nIt is optional in the type now and unchanged in spirit: the renderer always supplies it, so `sink.instanced?.(batch, data)` never actually skips, and a consumer written before instanced draws existed has nothing to declare through it.\n\n**The lesson is one this repository already writes down.** Because consumers bundle this source directly, a change to the public barrel is live in every consumer the moment it is saved, and it has to be verified against a real one before it is committed. A typecheck here cannot see a consumer’s own implementations of an interface it exports."
968
+ "text": "**3.29.0 added `instanced` to `ShadowCasterSink` as a required member, which is a breaking change and should not have shipped in a minor.**\n\nThat interface is implemented *outwards*: a consumer writes a sink, and its tests write doubles of one. Adding a member every existing implementation lacks stops all of them compiling \u2014 found immediately in a consumer, where three test doubles went red against a feature that game does not use and has no batches for.\n\nIt is optional in the type now and unchanged in spirit: the renderer always supplies it, so `sink.instanced?.(batch, data)` never actually skips, and a consumer written before instanced draws existed has nothing to declare through it.\n\n**The lesson is one this repository already writes down.** Because consumers bundle this source directly, a change to the public barrel is live in every consumer the moment it is saved, and it has to be verified against a real one before it is committed. A typecheck here cannot see a consumer\u2019s own implementations of an interface it exports."
953
969
  }
954
970
  ]
955
971
  },
@@ -960,17 +976,17 @@
960
976
  {
961
977
  "kind": "added",
962
978
  "short": "A mesh already on the device can be drawn many times over with a placement and a colour per instance, in one draw call and one material slot, on both backends and casting one shadow each.",
963
- "text": "**Thirty copies of one model are one draw call — and, the half that matters more, one slot of the frame’s material ring.** `createInstanced` attaches per-instance placement and colour to a mesh already uploaded; `uploadInstanced` pushes the live prefix; `drawInstanced` and `drawTranslucentInstanced` submit the batch; `disposeInstanced` releases it. `MeshInstances` carries a column-major matrix and an RGB tint per instance, and `createMeshInstances` allocates both arrays once so a frame that re-uploads a moving batch allocates nothing.\n\n**Distinct from `createScatter`, and not merged with it.** A scatter owns its own base geometry and carries a uniform scale, a yaw and a wind response, because it describes a plant. This attaches to a mesh the consumer already uploaded — the geometry an instanced draw repeats is usually a loaded model, and a second copy of it is the cost this exists to avoid — and carries a full transform, because a vehicle pitches and rolls on its suspension and a yaw cannot say so.\n\n**Shadows came with it.** `ShadowCasterSink` grows an `instanced` verb and the depth stage a matching variant. Without it a batch casts one shadow, from whichever matrix was last bound, with the other twenty-nine missing — which reads as a lighting fault rather than as a pass nobody wrote.\n\n**Three refusals, each loud rather than a silent degrade.** A skinned mesh cannot be instanced: eleven of WebGL2’s guaranteed sixteen attribute locations are the base mesh and the five that remain are exactly the matrix and the tint, so an instanced pipeline reclaims locations 11 and 12 from the joint indices and weights — free for an unskinned mesh, which carries both as constants, and impossible for a skinned one, which interleaves them. A morphed mesh cannot either, and that one *compiles*, which makes it worse: a morph weight is per draw, so every instance would wear one expression between them. And a mesh may have one batch, because WebGL2 binds the attributes to the mesh’s own vertex array.\n\n**What would make the layout wrong** is a twelfth base attribute, which cannot coexist with instancing at all.\n\nDriven on hardware on both backends by `demo/instancing.ts`: two ranks of thirty blocks, same geometry and material, thirty draws against one, each block its own colour, both casting into one shadow pass through their own verb. Swapping which rank is instanced — changing nothing else — produced a frame identical in **0 of 704,000 pixels**."
979
+ "text": "**Thirty copies of one model are one draw call \u2014 and, the half that matters more, one slot of the frame\u2019s material ring.** `createInstanced` attaches per-instance placement and colour to a mesh already uploaded; `uploadInstanced` pushes the live prefix; `drawInstanced` and `drawTranslucentInstanced` submit the batch; `disposeInstanced` releases it. `MeshInstances` carries a column-major matrix and an RGB tint per instance, and `createMeshInstances` allocates both arrays once so a frame that re-uploads a moving batch allocates nothing.\n\n**Distinct from `createScatter`, and not merged with it.** A scatter owns its own base geometry and carries a uniform scale, a yaw and a wind response, because it describes a plant. This attaches to a mesh the consumer already uploaded \u2014 the geometry an instanced draw repeats is usually a loaded model, and a second copy of it is the cost this exists to avoid \u2014 and carries a full transform, because a vehicle pitches and rolls on its suspension and a yaw cannot say so.\n\n**Shadows came with it.** `ShadowCasterSink` grows an `instanced` verb and the depth stage a matching variant. Without it a batch casts one shadow, from whichever matrix was last bound, with the other twenty-nine missing \u2014 which reads as a lighting fault rather than as a pass nobody wrote.\n\n**Three refusals, each loud rather than a silent degrade.** A skinned mesh cannot be instanced: eleven of WebGL2\u2019s guaranteed sixteen attribute locations are the base mesh and the five that remain are exactly the matrix and the tint, so an instanced pipeline reclaims locations 11 and 12 from the joint indices and weights \u2014 free for an unskinned mesh, which carries both as constants, and impossible for a skinned one, which interleaves them. A morphed mesh cannot either, and that one *compiles*, which makes it worse: a morph weight is per draw, so every instance would wear one expression between them. And a mesh may have one batch, because WebGL2 binds the attributes to the mesh\u2019s own vertex array.\n\n**What would make the layout wrong** is a twelfth base attribute, which cannot coexist with instancing at all.\n\nDriven on hardware on both backends by `demo/instancing.ts`: two ranks of thirty blocks, same geometry and material, thirty draws against one, each block its own colour, both casting into one shadow pass through their own verb. Swapping which rank is instanced \u2014 changing nothing else \u2014 produced a frame identical in **0 of 704,000 pixels**."
964
980
  },
965
981
  {
966
982
  "kind": "changed",
967
- "short": "The engine’s stated draw-call budget of under 100 is replaced by measured bands. It had been wrong for as long as anyone was using the engine.",
968
- "text": "`AGENTS.md` had said `< 100 draw calls` since before the first production consumer existed. That consumer measures **744 draws in its worst world frame** and has since its world existed — so the budget was not a target anybody held to, it was a number a reader would either design around needlessly or, correctly, ignore.\n\nReplaced by bands: roughly **1,000–2,000** for a high-refresh target, **2,000–10,000** for ordinary 60 fps, and far past that where the draws are instanced. `MAX_DRAWS_PER_FRAME` is 4,096 and is a ring size rather than an opinion, sized so a consumer inside those bands never meets it.\n\n**And the number to hold down is material changes rather than draws**, because that is the ring whose overflow is silent: past it every subsequent draw reuses the last material set. `drawInstanced` is the answer where the same mesh repeats."
983
+ "short": "The engine\u2019s stated draw-call budget of under 100 is replaced by measured bands. It had been wrong for as long as anyone was using the engine.",
984
+ "text": "`AGENTS.md` had said `< 100 draw calls` since before the first production consumer existed. That consumer measures **744 draws in its worst world frame** and has since its world existed \u2014 so the budget was not a target anybody held to, it was a number a reader would either design around needlessly or, correctly, ignore.\n\nReplaced by bands: roughly **1,000\u20132,000** for a high-refresh target, **2,000\u201310,000** for ordinary 60 fps, and far past that where the draws are instanced. `MAX_DRAWS_PER_FRAME` is 4,096 and is a ring size rather than an opinion, sized so a consumer inside those bands never meets it.\n\n**And the number to hold down is material changes rather than draws**, because that is the ring whose overflow is silent: past it every subsequent draw reuses the last material set. `drawInstanced` is the answer where the same mesh repeats."
969
985
  },
970
986
  {
971
987
  "kind": "added",
972
988
  "short": "A blended draw can carry a per-draw tint, so a model faded in with a runtime colour keeps that colour while it fades instead of arriving grey.",
973
- "text": "`TranslucentMeshOptions.tint` is the per-draw colour multiplier `drawMesh` has always taken as its fourth argument, now available to the blended path as well.\n\n**The two paths were not interchangeable for a caller that tints, and the gap only shows during a transition.** A consumer fading a coloured model in makes every one of its surfaces translucent for the length of the fade, so every surface went through `drawTranslucentMesh` — which wrote no tint — and the model arrived in its own vertex colour before snapping to the runtime one as the fade completed. Reported from a game whose cars arrive that way.\n\nIn the options rather than as a fifth positional parameter, because it is one of the same set of per-draw decisions the rest of that interface holds. Scoped and handed back on both backends, so a tinted draw cannot leak its colour onto the next one."
989
+ "text": "`TranslucentMeshOptions.tint` is the per-draw colour multiplier `drawMesh` has always taken as its fourth argument, now available to the blended path as well.\n\n**The two paths were not interchangeable for a caller that tints, and the gap only shows during a transition.** A consumer fading a coloured model in makes every one of its surfaces translucent for the length of the fade, so every surface went through `drawTranslucentMesh` \u2014 which wrote no tint \u2014 and the model arrived in its own vertex colour before snapping to the runtime one as the fade completed. Reported from a game whose cars arrive that way.\n\nIn the options rather than as a fifth positional parameter, because it is one of the same set of per-draw decisions the rest of that interface holds. Scoped and handed back on both backends, so a tinted draw cannot leak its colour onto the next one."
974
990
  }
975
991
  ]
976
992
  },
@@ -981,12 +997,12 @@
981
997
  {
982
998
  "kind": "changed",
983
999
  "short": "The per-frame draw and material ceilings go to 4096 and 1024. Neither was a hardware limit, and raising a ring costs allocation once rather than bandwidth every frame.",
984
- "text": "**The two per-frame ceilings on the WebGPU backend were ring capacities sized against this repository's own demo scenes, and nobody had asked what raising them costs.** `MAX_DRAWS_PER_FRAME` goes from 1024 to **4096** and `MAX_MATERIALS_PER_FRAME` from 256 to **1024**.\n\nThe measurement is the whole argument. `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two hundred whatever the capacity says — **a bigger ring costs no per-frame bandwidth at all.** What it costs is one-time allocation, and only for the parts that cannot grow: the command pool and the node arena already reallocate on demand, so of the five things the draw ceiling sizes, three were never ceilings.\n\nA draw slot is **1,540 bytes** and a material slot **7,168 to 13,312**, depending on which fragment permutation a profile builds. Both raised is **10 to 14 MB** once, on a backend where a single loaded car body can be 36 MB.\n\nThe material ring is the one worth paying for, and for how it fails rather than how it performs: past it, every subsequent draw **silently reuses the last material set**, which reads as a stretch of the world losing its texture rather than as an error. It had been reported twice from consumers. A consumer rationing its own draws to stay under 256 materials can stop.\n\n**What would make this wrong** is a scene switching material every draw. A thousand material changes is a thousand uniform copies, and that is a batching problem no ring size answers."
1000
+ "text": "**The two per-frame ceilings on the WebGPU backend were ring capacities sized against this repository's own demo scenes, and nobody had asked what raising them costs.** `MAX_DRAWS_PER_FRAME` goes from 1024 to **4096** and `MAX_MATERIALS_PER_FRAME` from 256 to **1024**.\n\nThe measurement is the whole argument. `UniformRing.flush` uploads `used * slotSize`, so a frame that draws two hundred pays for two hundred whatever the capacity says \u2014 **a bigger ring costs no per-frame bandwidth at all.** What it costs is one-time allocation, and only for the parts that cannot grow: the command pool and the node arena already reallocate on demand, so of the five things the draw ceiling sizes, three were never ceilings.\n\nA draw slot is **1,540 bytes** and a material slot **7,168 to 13,312**, depending on which fragment permutation a profile builds. Both raised is **10 to 14 MB** once, on a backend where a single loaded car body can be 36 MB.\n\nThe material ring is the one worth paying for, and for how it fails rather than how it performs: past it, every subsequent draw **silently reuses the last material set**, which reads as a stretch of the world losing its texture rather than as an error. It had been reported twice from consumers. A consumer rationing its own draws to stay under 256 materials can stop.\n\n**What would make this wrong** is a scene switching material every draw. A thousand material changes is a thousand uniform copies, and that is a batching problem no ring size answers."
985
1001
  },
986
1002
  {
987
1003
  "kind": "fixed",
988
1004
  "short": "A frame recording more graph nodes than the replay scratch held dropped the tail of its passes silently. The scratch now grows with the arena, as two comments already claimed it did.",
989
- "text": "**A frame heavy in graph nodes replayed only the first 1024 of them, and said nothing.**\n\nThe frame graph's node arena reallocates at twice its capacity whenever a frame records more nodes than it holds. The scratch array the replay reads through did not: it was allocated once at `MAX_DRAWS_PER_FRAME` and never grown, and `keptNodes` stops at the end of the array it is given. So the tail of that frame's passes was replayed by nobody — no warning, no error, and a plausible picture missing whatever those passes drew.\n\nBoth `keptNodes` and the renderer's own field carried comments saying the array was *sized with the arena* and that this therefore could not happen. Neither was true, and the comments are why it survived: they read as a settled invariant rather than as an assumption.\n\nIt was reachable well below the draw ceiling, because a node is recorded **per verb** rather than per draw — overlays, scatter, plumes and text each record one. The bound in `keptNodes` stays, because it is what stops a write past the end of the array; what changed is that the caller now honours the contract that bound documents."
1005
+ "text": "**A frame heavy in graph nodes replayed only the first 1024 of them, and said nothing.**\n\nThe frame graph's node arena reallocates at twice its capacity whenever a frame records more nodes than it holds. The scratch array the replay reads through did not: it was allocated once at `MAX_DRAWS_PER_FRAME` and never grown, and `keptNodes` stops at the end of the array it is given. So the tail of that frame's passes was replayed by nobody \u2014 no warning, no error, and a plausible picture missing whatever those passes drew.\n\nBoth `keptNodes` and the renderer's own field carried comments saying the array was *sized with the arena* and that this therefore could not happen. Neither was true, and the comments are why it survived: they read as a settled invariant rather than as an assumption.\n\nIt was reachable well below the draw ceiling, because a node is recorded **per verb** rather than per draw \u2014 overlays, scatter, plumes and text each record one. The bound in `keptNodes` stays, because it is what stops a write past the end of the array; what changed is that the caller now honours the contract that bound documents."
990
1006
  }
991
1007
  ]
992
1008
  },
@@ -997,12 +1013,12 @@
997
1013
  {
998
1014
  "kind": "added",
999
1015
  "short": "A blended draw can decline to write depth, so a set of translucent surfaces blends through itself instead of the first one drawn hiding the rest.",
1000
- "text": "**A blended draw writes depth, which is right for one surface and wrong for a set of them.** That has been the rule since translucency landed and the reason is good: the sky is drawn last over everything the world left untouched, so a sign hung in the air that declined to write depth is painted straight over. What it costs is that depth written by a blended surface *rejects* the blended surfaces behind it — so a model whose interior is blended draws the first of each overlapping pair and discards the rest, and which one is first is whatever order the caller submitted in.\n\n**Reported by a consumer importing vehicles**, on a car whose interior is 96 blended surfaces sitting inside its shell: it drew as interpenetrating shards with its dashboard showing through its bonnet, and every check that model had was green. A quarter of that file's materials declare a blend, and the format they come from states *whether* a surface blends and never by how much, so there is nothing in the file to draw a threshold at.\n\n**`{ depthWrite: false }` is the tool the docstring had been describing without providing.** It leaves the depth buffer alone, so a set of blended surfaces blends through itself. The cost is stated where it is taken and is why this is an option and not the default: nothing in the depth buffer means nothing to sort by, so the caller owns the order, and a surface that writes no depth cannot stand against the sky. `demo/dev/blended.html` shows all three states — the defect, the half fix, and both together — because this is a failure a photograph shows and no number does."
1016
+ "text": "**A blended draw writes depth, which is right for one surface and wrong for a set of them.** That has been the rule since translucency landed and the reason is good: the sky is drawn last over everything the world left untouched, so a sign hung in the air that declined to write depth is painted straight over. What it costs is that depth written by a blended surface *rejects* the blended surfaces behind it \u2014 so a model whose interior is blended draws the first of each overlapping pair and discards the rest, and which one is first is whatever order the caller submitted in.\n\n**Reported by a consumer importing vehicles**, on a car whose interior is 96 blended surfaces sitting inside its shell: it drew as interpenetrating shards with its dashboard showing through its bonnet, and every check that model had was green. A quarter of that file's materials declare a blend, and the format they come from states *whether* a surface blends and never by how much, so there is nothing in the file to draw a threshold at.\n\n**`{ depthWrite: false }` is the tool the docstring had been describing without providing.** It leaves the depth buffer alone, so a set of blended surfaces blends through itself. The cost is stated where it is taken and is why this is an option and not the default: nothing in the depth buffer means nothing to sort by, so the caller owns the order, and a surface that writes no depth cannot stand against the sky. `demo/dev/blended.html` shows all three states \u2014 the defect, the half fix, and both together \u2014 because this is a failure a photograph shows and no number does."
1001
1017
  },
1002
1018
  {
1003
1019
  "kind": "added",
1004
1020
  "short": "A blended draw can declare a depth layer, so a decal coplanar with what it decorates stops fighting it. On WebGPU the layer had been accepted and ignored outright.",
1005
- "text": "**A decal lying on the surface it decorates is coplanar with it, and coplanar surfaces are a coin toss taken per pixel**: their interpolated depths are equal in exact arithmetic, so which one survives is decided by which way the rounding fell. `drawMesh` has had `depthLayer` since that turned up in six places in one session. A blended draw had no way to say it, so the same marking over the same slab was ordered when it was opaque and a hatch of fighting pixels when it blended — which is most of what an imported interior's decal layer is.\n\n**And on WebGPU the parameter was accepted and dropped.** `drawMesh` there took a `depthLayer` and ignored it for as long as that backend has existed: a consumer declaring which of two fused surfaces should win got the answer on one backend and a coin toss on the other, with nothing failing and nothing recorded. It reaches a pipeline now, where the offset is baked in as a depth bias, and both backends read one function for what a layer is worth.\n\n**The slope term went from zero to matching the constant, and a measurement is what changed it.** The argument for zero is that a declared overlay is coplanar from every angle, so a constant nudge should be the whole of it. What that misses is that the offset is applied in depth, and how much depth a pixel spans grows without bound as a surface turns edge-on. Measured on a blended quad coplanar with its panel, counting the decal pixels that survive: WebGL2 clears it at layer 1 either way, and **WebGPU keeps 1,178 of 1,237 at layer 1 with no slope** and needs layer 2 to reach 1,237. With the slope both backends read 1,237 at layer 1. The sign is the constant's sign and not its opposite — given the opposite the two terms fight, the slope wins at a grazing angle, and the overlay disappears behind what it decorates.\n\n**Gated rather than argued: every published scene is 0 of 921,600 pixels on both backends** against the release before it."
1021
+ "text": "**A decal lying on the surface it decorates is coplanar with it, and coplanar surfaces are a coin toss taken per pixel**: their interpolated depths are equal in exact arithmetic, so which one survives is decided by which way the rounding fell. `drawMesh` has had `depthLayer` since that turned up in six places in one session. A blended draw had no way to say it, so the same marking over the same slab was ordered when it was opaque and a hatch of fighting pixels when it blended \u2014 which is most of what an imported interior's decal layer is.\n\n**And on WebGPU the parameter was accepted and dropped.** `drawMesh` there took a `depthLayer` and ignored it for as long as that backend has existed: a consumer declaring which of two fused surfaces should win got the answer on one backend and a coin toss on the other, with nothing failing and nothing recorded. It reaches a pipeline now, where the offset is baked in as a depth bias, and both backends read one function for what a layer is worth.\n\n**The slope term went from zero to matching the constant, and a measurement is what changed it.** The argument for zero is that a declared overlay is coplanar from every angle, so a constant nudge should be the whole of it. What that misses is that the offset is applied in depth, and how much depth a pixel spans grows without bound as a surface turns edge-on. Measured on a blended quad coplanar with its panel, counting the decal pixels that survive: WebGL2 clears it at layer 1 either way, and **WebGPU keeps 1,178 of 1,237 at layer 1 with no slope** and needs layer 2 to reach 1,237. With the slope both backends read 1,237 at layer 1. The sign is the constant's sign and not its opposite \u2014 given the opposite the two terms fight, the slope wins at a grazing angle, and the overlay disappears behind what it decorates.\n\n**Gated rather than argued: every published scene is 0 of 921,600 pixels on both backends** against the release before it."
1006
1022
  }
1007
1023
  ]
1008
1024
  },
@@ -1013,17 +1029,17 @@
1013
1029
  {
1014
1030
  "kind": "fixed",
1015
1031
  "short": "A weld silently dropped a mesh's rig, tangents, relief and morph targets, and merged corners that differed only in them. A skinned model came back unskinned, drawing at its bind pose.",
1016
- "text": "**The weld knew about five of the format's optional attributes and there are ten.** `MeshData` gained a tangent frame and relief on 21 August, then a rig and morph deltas on the 25th; `weldMesh` was written on the 21st and was told about none of them. Both halves of it were lists written out by hand — the key that decides whether two corners are the same vertex, and the copy that writes the survivors — so for five releases a merged mesh came back **without** its joints, its weights, its tangents, its relief and its morph targets, and with corners merged across the bones they were weighted to.\n\n**Nothing raised, which is why it lasted.** An unskinned mesh is a valid mesh and it draws at its bind pose. A mesh with no tangent frame is a valid mesh and the lit pass falls back to screen-space derivatives. The only symptom is a model that has stopped moving, in a bake whose every printed number is correct.\n\n**It surfaced through a report that had the measurement right and the cause wrong, and both halves are worth recording.** A consumer re-baked an unchanged model against a newer engine and found it 8.3 MB larger than the copy committed two days before, which read as a change between the two versions. The two versions produce identical bytes from identical input: what had moved was the *source file*. The earlier bake came from an export storing one vertex per triangle corner, where the weld merged 126 meshes of 253 and destroyed the tangent frame on every one of them; the later came from an already-shared export of the same model, where it merged three. The 8.3 MB was this defect stopping, not a regression starting.\n\n**One table drives both halves now, and it is checked against `MeshData` at compile time**, so the day an eleventh attribute is added to the format this file stops compiling until the table lists it. A table that can be forgotten is what was already forgotten twice.\n\nWhat it costs is stated where it happens: a file carrying a tangent frame *derived* per corner welds less, because a corner soup gets one triangle's frame per corner and no two corners agree. The answer to that is not a looser key, which would merge a mirrored UV shell and light one side of a model inside out; it is the entry below, which stops deriving a frame nothing samples."
1032
+ "text": "**The weld knew about five of the format's optional attributes and there are ten.** `MeshData` gained a tangent frame and relief on 21 August, then a rig and morph deltas on the 25th; `weldMesh` was written on the 21st and was told about none of them. Both halves of it were lists written out by hand \u2014 the key that decides whether two corners are the same vertex, and the copy that writes the survivors \u2014 so for five releases a merged mesh came back **without** its joints, its weights, its tangents, its relief and its morph targets, and with corners merged across the bones they were weighted to.\n\n**Nothing raised, which is why it lasted.** An unskinned mesh is a valid mesh and it draws at its bind pose. A mesh with no tangent frame is a valid mesh and the lit pass falls back to screen-space derivatives. The only symptom is a model that has stopped moving, in a bake whose every printed number is correct.\n\n**It surfaced through a report that had the measurement right and the cause wrong, and both halves are worth recording.** A consumer re-baked an unchanged model against a newer engine and found it 8.3 MB larger than the copy committed two days before, which read as a change between the two versions. The two versions produce identical bytes from identical input: what had moved was the *source file*. The earlier bake came from an export storing one vertex per triangle corner, where the weld merged 126 meshes of 253 and destroyed the tangent frame on every one of them; the later came from an already-shared export of the same model, where it merged three. The 8.3 MB was this defect stopping, not a regression starting.\n\n**One table drives both halves now, and it is checked against `MeshData` at compile time**, so the day an eleventh attribute is added to the format this file stops compiling until the table lists it. A table that can be forgotten is what was already forgotten twice.\n\nWhat it costs is stated where it happens: a file carrying a tangent frame *derived* per corner welds less, because a corner soup gets one triangle's frame per corner and no two corners agree. The answer to that is not a looser key, which would merge a mirrored UV shell and light one side of a model inside out; it is the entry below, which stops deriving a frame nothing samples."
1017
1033
  },
1018
1034
  {
1019
1035
  "kind": "changed",
1020
1036
  "short": "An importer derives a tangent frame only where a material declares a normal map. Deriving one for every mesh with a texture coordinate cost 11 MB of a 62.7 MB bake that nothing could sample.",
1021
- "text": "**A tangent frame exists to sample a normal map, and this engine reads one in a single place** — inside the lit pass's `if (uNormalStrength > 0.0)`. It was derived for every imported primitive carrying a texture coordinate, whatever its material declared, which is four floats a vertex that no draw can ever fetch.\n\n**Measured on a 253-mesh CAD vehicle export whose eight materials declare no normal map at all: a bake of 62.7 MB becomes 51.7 MB**, and the difference is exactly 689,033 vertices times 16 bytes, so the whole of it is that one buffer. It is not a frame cost: the buffer uploads once and the vertex stride is the same either way. It is bytes on the wire, and on a model already large enough to shard it is a whole extra request.\n\n**A `TANGENT` the file supplies is still read**, whatever the material says. That is authored data and the file is entitled to be believed; only *inventing* a frame is gated. A consumer attaching a normal map to a material the source did not have one on calls `generateTangents` itself, which is exported for it.\n\n**What would make this wrong** is a second reader of the frame that needs no map: anisotropic specular, hair, cloth. There is none today, and the day one lands it wants a frame derived after the weld, not this one back."
1037
+ "text": "**A tangent frame exists to sample a normal map, and this engine reads one in a single place** \u2014 inside the lit pass's `if (uNormalStrength > 0.0)`. It was derived for every imported primitive carrying a texture coordinate, whatever its material declared, which is four floats a vertex that no draw can ever fetch.\n\n**Measured on a 253-mesh CAD vehicle export whose eight materials declare no normal map at all: a bake of 62.7 MB becomes 51.7 MB**, and the difference is exactly 689,033 vertices times 16 bytes, so the whole of it is that one buffer. It is not a frame cost: the buffer uploads once and the vertex stride is the same either way. It is bytes on the wire, and on a model already large enough to shard it is a whole extra request.\n\n**A `TANGENT` the file supplies is still read**, whatever the material says. That is authored data and the file is entitled to be believed; only *inventing* a frame is gated. A consumer attaching a normal map to a material the source did not have one on calls `generateTangents` itself, which is exported for it.\n\n**What would make this wrong** is a second reader of the frame that needs no map: anisotropic specular, hair, cloth. There is none today, and the day one lands it wants a frame derived after the weld, not this one back."
1022
1038
  },
1023
1039
  {
1024
1040
  "kind": "fixed",
1025
1041
  "short": "A relief array that is zero everywhere is no longer stored. Absent already means a perfectly smooth surface.",
1026
- "text": "`dropDefaultAttributes` drops an all-zero `relief` array now, alongside the specular, roughness, grain and emissive-colour arrays it already dropped. Relief landed on `MeshData` on 21 August and this function was not told, so a producer filling it with zeroes spent four bytes a vertex saying \"no relief\" — which is the one thing an absent attribute already says. Same omission as the weld's above, same day, one line."
1042
+ "text": "`dropDefaultAttributes` drops an all-zero `relief` array now, alongside the specular, roughness, grain and emissive-colour arrays it already dropped. Relief landed on `MeshData` on 21 August and this function was not told, so a producer filling it with zeroes spent four bytes a vertex saying \"no relief\" \u2014 which is the one thing an absent attribute already says. Same omission as the weld's above, same day, one line."
1027
1043
  }
1028
1044
  ]
1029
1045
  },
@@ -1034,27 +1050,27 @@
1034
1050
  {
1035
1051
  "kind": "fixed",
1036
1052
  "short": "A bought car model imported mirrored. The reader believed its format's declared handedness and turned every left-hand-drive vehicle into a right-hand-drive one.",
1037
- "text": "**A car came in as its own mirror image, and nothing a bake measures could see it.** The tier 2 vehicle reader declared its format left-handed — the tool that writes it targets a left-handed graphics API, which is a good reason and not evidence — and mirrored every model it read. A mirrored car has the same bounds, the same triangle count and the same consistently wound triangles as an unmirrored one, so the import passed every check it had while producing a car whose steering wheel was on the wrong side.\n\n**It was found by eye, by a consumer, on the second file this reader had ever met**: a rear badge reading backwards, a number plate written backwards, and the driver sitting on the wrong side of the cabin.\n\n**What settles it is a picture rather than an argument.** The badge mesh, rasterised straight out of the file with its numbers read as right-handed and the viewer standing behind the car, spells the model's name forwards; through the conversion the reader was applying, it spells it backwards. Every other asymmetry in the file agrees: up is `+y`, the front bumper and headlights are at `+z` and the tail lamps at `-z`, and the steering wheel, the driver's seat, the door named for the left side, the left mirror and the driver's eye position in the model's own configuration data are all at `+x` — which is the car's left in a right-handed frame with that up and that forward. Read exactly as stored, the file is a left-hand-drive car.\n\nSo the reader reports the frame it measured and mirrors nothing. `convertHandedness` stays in `orient.ts` for a format that genuinely is left-handed, and none of the formats read here is one."
1053
+ "text": "**A car came in as its own mirror image, and nothing a bake measures could see it.** The tier 2 vehicle reader declared its format left-handed \u2014 the tool that writes it targets a left-handed graphics API, which is a good reason and not evidence \u2014 and mirrored every model it read. A mirrored car has the same bounds, the same triangle count and the same consistently wound triangles as an unmirrored one, so the import passed every check it had while producing a car whose steering wheel was on the wrong side.\n\n**It was found by eye, by a consumer, on the second file this reader had ever met**: a rear badge reading backwards, a number plate written backwards, and the driver sitting on the wrong side of the cabin.\n\n**What settles it is a picture rather than an argument.** The badge mesh, rasterised straight out of the file with its numbers read as right-handed and the viewer standing behind the car, spells the model's name forwards; through the conversion the reader was applying, it spells it backwards. Every other asymmetry in the file agrees: up is `+y`, the front bumper and headlights are at `+z` and the tail lamps at `-z`, and the steering wheel, the driver's seat, the door named for the left side, the left mirror and the driver's eye position in the model's own configuration data are all at `+x` \u2014 which is the car's left in a right-handed frame with that up and that forward. Read exactly as stored, the file is a left-hand-drive car.\n\nSo the reader reports the frame it measured and mirrors nothing. `convertHandedness` stays in `orient.ts` for a format that genuinely is left-handed, and none of the formats read here is one."
1038
1054
  },
1039
1055
  {
1040
1056
  "kind": "fixed",
1041
1057
  "short": "A mirrored asset's hierarchy is mirrored too. Only a node's translation was, so a graph with rotations placed parts a metre from their own geometry.",
1042
- "text": "**A mirror has a hierarchy half and it did not exist.** The one place that mirrored an asset negated each node's translation X inline and left its rotation alone, so a graph carrying rotations desynchronised from the geometry it places — measured on a real car at 1.385 m apart and on the other side of the model. Both halves stay individually well formed, which is the worst shape this can take: nothing downstream can detect it, and a consumer's only recourse is to stop using the graph, which is what happened.\n\n`mirrorNodes` is the pair to `convertHandedness`, exactly as `orientNodes` is the pair to `orientMeshes`, and it is exported beside it. The rotation is conjugated rather than negated — reflecting a rotation gives `M R M`, which as a quaternion is `(x, -y, -z, w)` — and **every node is conjugated, not only the roots**, which is the one place this differs from an up-axis turn: a rotation applied to a root already carries its descendants, while a reflection is a conjugation and conjugation distributes over the product, so leaving a child alone drops the mirror out of the composition."
1058
+ "text": "**A mirror has a hierarchy half and it did not exist.** The one place that mirrored an asset negated each node's translation X inline and left its rotation alone, so a graph carrying rotations desynchronised from the geometry it places \u2014 measured on a real car at 1.385 m apart and on the other side of the model. Both halves stay individually well formed, which is the worst shape this can take: nothing downstream can detect it, and a consumer's only recourse is to stop using the graph, which is what happened.\n\n`mirrorNodes` is the pair to `convertHandedness`, exactly as `orientNodes` is the pair to `orientMeshes`, and it is exported beside it. The rotation is conjugated rather than negated \u2014 reflecting a rotation gives `M R M`, which as a quaternion is `(x, -y, -z, w)` \u2014 and **every node is conjugated, not only the roots**, which is the one place this differs from an up-axis turn: a rotation applied to a root already carries its descendants, while a reflection is a conjugation and conjugation distributes over the product, so leaving a child alone drops the mirror out of the composition."
1043
1059
  },
1044
1060
  {
1045
1061
  "kind": "fixed",
1046
1062
  "short": "Nine surfaces of a hundred and two arrived invisible on an imported car. An alpha test was being read as an opacity, so every material that asked for no test came out at zero.",
1047
- "text": "**That format's alpha-test reference is the value below which a fragment is discarded, and it was being read as the surface's opacity.** It is 0 on any transparent-shader material that wants no alpha test at all, so those materials came out fully transparent and drew as nothing. Measured on a shipped car: **nine meshes of a hundred and two** — four tyres, four rims and the side windows — and seventeen of a hundred and seventy-two at full detail. It was reported twice from playing, first as wheels with no tyres and then as a photograph of bare rims.\n\n**The file states what blends, and a list of shader names was answering instead.** Two bytes the material walk skipped as \"the flags\" are a blend mode and an alpha-test flag, and they are read now: across a car, its three levels of detail and its collider they take only the values the format defines, and they agree with each node's own transparency flag on 171 of 172 meshes. The shader-name list they replace was wrong about that car — all four of its tyres wear a shader the list called transparent, while the material itself says opaque and the node agrees. A name is a guess where a field is a fact, and the list survives only for the warning it can genuinely answer: a shader this reader has not mapped.\n\nSo a blended surface is one the file says is blended, its per-fragment alpha comes from its texture where it always did, and the alpha test goes to the field that means it."
1063
+ "text": "**That format's alpha-test reference is the value below which a fragment is discarded, and it was being read as the surface's opacity.** It is 0 on any transparent-shader material that wants no alpha test at all, so those materials came out fully transparent and drew as nothing. Measured on a shipped car: **nine meshes of a hundred and two** \u2014 four tyres, four rims and the side windows \u2014 and seventeen of a hundred and seventy-two at full detail. It was reported twice from playing, first as wheels with no tyres and then as a photograph of bare rims.\n\n**The file states what blends, and a list of shader names was answering instead.** Two bytes the material walk skipped as \"the flags\" are a blend mode and an alpha-test flag, and they are read now: across a car, its three levels of detail and its collider they take only the values the format defines, and they agree with each node's own transparency flag on 171 of 172 meshes. The shader-name list they replace was wrong about that car \u2014 all four of its tyres wear a shader the list called transparent, while the material itself says opaque and the node agrees. A name is a guess where a field is a fact, and the list survives only for the warning it can genuinely answer: a shader this reader has not mapped.\n\nSo a blended surface is one the file says is blended, its per-fragment alpha comes from its texture where it always did, and the alpha test goes to the field that means it."
1048
1064
  },
1049
1065
  {
1050
1066
  "kind": "added",
1051
1067
  "short": "Container 1.10 carries a material's cutout, so a masked surface keeps the holes its image gives it instead of arriving as a solid rectangle.",
1052
- "text": "**A cutout is a test and an opacity is not, and the container had only the second.** `SurfaceMaterial.cutout` has been in the renderer since decals landed, defined as the alpha below which a fragment is discarded, and nothing could reach it: `MATL` had no such field and a part carried no such number, so a surface whose *shape* lives in its alpha channel — a grille, a vent, foliage, a fence — arrived as the rectangle its geometry actually is.\n\n**glTF states it exactly and it was being dropped.** `alphaMode: 'MASK'` with `alphaCutoff`, which the specification defaults to 0.5, is neither of the two modes this reader handled: read as an opacity it would be wrong in both directions, and ignored it fills in the mask. It becomes a cutout now, and so does a vehicle format's alpha-test reference, on the materials that say they are tested.\n\n**Additive behind the stride `MATL` already declares.** The entry grows from 72 bytes to 76 and an older file's shorter entry opens with the field defaulted to 0 — discard nothing, which is what every file written before it meant. `DrftPart` carries it beside `opacity`, and the loader groups by it, because merging a masked surface into a solid one either punches holes in the solid or fills in the mask depending on which material wins."
1068
+ "text": "**A cutout is a test and an opacity is not, and the container had only the second.** `SurfaceMaterial.cutout` has been in the renderer since decals landed, defined as the alpha below which a fragment is discarded, and nothing could reach it: `MATL` had no such field and a part carried no such number, so a surface whose *shape* lives in its alpha channel \u2014 a grille, a vent, foliage, a fence \u2014 arrived as the rectangle its geometry actually is.\n\n**glTF states it exactly and it was being dropped.** `alphaMode: 'MASK'` with `alphaCutoff`, which the specification defaults to 0.5, is neither of the two modes this reader handled: read as an opacity it would be wrong in both directions, and ignored it fills in the mask. It becomes a cutout now, and so does a vehicle format's alpha-test reference, on the materials that say they are tested.\n\n**Additive behind the stride `MATL` already declares.** The entry grows from 72 bytes to 76 and an older file's shorter entry opens with the field defaulted to 0 \u2014 discard nothing, which is what every file written before it meant. `DrftPart` carries it beside `opacity`, and the loader groups by it, because merging a masked surface into a solid one either punches holes in the solid or fills in the mask depending on which material wins."
1053
1069
  },
1054
1070
  {
1055
1071
  "kind": "fixed",
1056
1072
  "short": "localiseNodes is exported from @driftengine/assets, which the handbook has told people to import for two releases. Every import the docs print is now checked against the barrel.",
1057
- "text": "**The handbook printed an import that did not compile.** `import { localiseNodes } from '@driftengine/assets'` is what the vehicle chapter tells a consumer to write, and the barrel did not export it — so the one consumer who followed the handbook reached the module by sub-path, which is the exception this repository reserves for one library, and nothing anywhere said the documented form was wrong.\n\nIt is exported, with `Localised` beside it. **And the class of defect is closed rather than the instance**: `scripts/docs.test.mjs` now reads every `import { … } from '@driftengine/…'` in the documentation and asserts the named package's barrel exports each binding. It found this one and nothing else. The porting guide is exempt by name, for the one reason that a porting guide's job is to print the import that stopped working beside the one that replaced it."
1073
+ "text": "**The handbook printed an import that did not compile.** `import { localiseNodes } from '@driftengine/assets'` is what the vehicle chapter tells a consumer to write, and the barrel did not export it \u2014 so the one consumer who followed the handbook reached the module by sub-path, which is the exception this repository reserves for one library, and nothing anywhere said the documented form was wrong.\n\nIt is exported, with `Localised` beside it. **And the class of defect is closed rather than the instance**: `scripts/docs.test.mjs` now reads every `import { \u2026 } from '@driftengine/\u2026'` in the documentation and asserts the named package's barrel exports each binding. It found this one and nothing else. The porting guide is exempt by name, for the one reason that a porting guide's job is to print the import that stopped working beside the one that replaced it."
1058
1074
  },
1059
1075
  {
1060
1076
  "kind": "added",
@@ -1123,12 +1139,12 @@
1123
1139
  {
1124
1140
  "kind": "added",
1125
1141
  "short": "Reversed-Z depth with a float buffer, on both backends. Precision becomes roughly uniform with distance, so a near plane can come in and decals stop needing offsets nobody chose.",
1126
- "text": "**Depth runs the other way, and `depthConvention.ts` is the one place that says so.** A conventional buffer spends its precision hyperbolically, about `z² / (near · 2^bits)` at distance `z`, so the near plane is the only control there is. A consumer measured what that costs them: a street plate's letters held 7.5 mm proud of a 6 mm plate, a sign 15 mm proud of its board, and a near plane settled at 0.25 for a first-person camera that wanted 0.05, because dropping it to 0.15 made every decal in the world blink. None of those offsets is a thickness anybody chose.\n\n**What reversing buys.** A float's exponent gives its resolution where the values are small, and putting *far* at zero makes the hyperbolic loss and the floating-point gain very nearly cancel, so precision becomes roughly uniform with distance.\n\n**On both backends, gated at zero tolerance across every published scene on a real GPU.** Each backend against its own conventional output: WebGL2's worst frame 7,348 pixels of 921,600 and WebGPU's 11,832, most a few hundred or none, all of it seams resolving the other way. The two backends against each other come to 443,035 pixels on their worst scene reversed against 443,039 conventional, so the conversion costs four pixels of the ordinary disagreement between two independent implementations of an animated scene, in the direction of agreeing slightly better.\n\n**A game reads what the context granted, never the engine's wish.** WebGL2 needs `EXT_clip_control` and a context without it keeps the conventional sense, so `reversedDepth` on the created renderer is the fact to key a near plane on.\n\n**This is the cure for a near-coincident seam and deliberately not for a coincident one.** Two exactly coplanar surfaces have equal depth in exact arithmetic and no format separates equal numbers, which is what a polygon offset is for. What this fixes is the other case: surfaces a fraction of a millimetre apart, which a conventional buffer cannot tell apart past about forty metres and this one can.\n\n**Shadows keep the conventional sense.** The directional cascades are orthographic, where depth is already linear and a float buffer gains nothing, so reversing them would mean flipping every comparison in the shadow path for no precision at all."
1142
+ "text": "**Depth runs the other way, and `depthConvention.ts` is the one place that says so.** A conventional buffer spends its precision hyperbolically, about `z\u00b2 / (near \u00b7 2^bits)` at distance `z`, so the near plane is the only control there is. A consumer measured what that costs them: a street plate's letters held 7.5 mm proud of a 6 mm plate, a sign 15 mm proud of its board, and a near plane settled at 0.25 for a first-person camera that wanted 0.05, because dropping it to 0.15 made every decal in the world blink. None of those offsets is a thickness anybody chose.\n\n**What reversing buys.** A float's exponent gives its resolution where the values are small, and putting *far* at zero makes the hyperbolic loss and the floating-point gain very nearly cancel, so precision becomes roughly uniform with distance.\n\n**On both backends, gated at zero tolerance across every published scene on a real GPU.** Each backend against its own conventional output: WebGL2's worst frame 7,348 pixels of 921,600 and WebGPU's 11,832, most a few hundred or none, all of it seams resolving the other way. The two backends against each other come to 443,035 pixels on their worst scene reversed against 443,039 conventional, so the conversion costs four pixels of the ordinary disagreement between two independent implementations of an animated scene, in the direction of agreeing slightly better.\n\n**A game reads what the context granted, never the engine's wish.** WebGL2 needs `EXT_clip_control` and a context without it keeps the conventional sense, so `reversedDepth` on the created renderer is the fact to key a near plane on.\n\n**This is the cure for a near-coincident seam and deliberately not for a coincident one.** Two exactly coplanar surfaces have equal depth in exact arithmetic and no format separates equal numbers, which is what a polygon offset is for. What this fixes is the other case: surfaces a fraction of a millimetre apart, which a conventional buffer cannot tell apart past about forty metres and this one can.\n\n**Shadows keep the conventional sense.** The directional cascades are orthographic, where depth is already linear and a float buffer gains nothing, so reversing them would mean flipping every comparison in the shadow path for no precision at all."
1127
1143
  },
1128
1144
  {
1129
1145
  "kind": "fixed",
1130
1146
  "short": "Two depth defects found by converting: the sky wrote the near plane once reversed, covering the world, and the camera blur recovered clip z with the wrong inverse.",
1131
- "text": "**Two defects the conversion exposed, both fixed and both invisible until depth was reversed.** The sky is a full-screen triangle written straight into clip space at `z = 1.0`, which is the far plane conventionally and the *near* plane once reversed — so it painted over the entire world, measured at **743,669 changed pixels of 921,600** in one frame. It now writes `glslFarDepth()`. And `rush.ts` recovered clip z from the depth texture as `depth * 2 - 1`, whose reversed inverse is `1 - 2 * depth`; four shaders carried that expression by hand and now share `glslSceneDepthToNdc`, with `DEPTH_CLIP_CORRECTION` negating in step so the matrix and the shader cannot drift. The renderer's own reprojection test is what caught it."
1147
+ "text": "**Two defects the conversion exposed, both fixed and both invisible until depth was reversed.** The sky is a full-screen triangle written straight into clip space at `z = 1.0`, which is the far plane conventionally and the *near* plane once reversed \u2014 so it painted over the entire world, measured at **743,669 changed pixels of 921,600** in one frame. It now writes `glslFarDepth()`. And `rush.ts` recovered clip z from the depth texture as `depth * 2 - 1`, whose reversed inverse is `1 - 2 * depth`; four shaders carried that expression by hand and now share `glslSceneDepthToNdc`, with `DEPTH_CLIP_CORRECTION` negating in step so the matrix and the shader cannot drift. The renderer's own reprojection test is what caught it."
1132
1148
  },
1133
1149
  {
1134
1150
  "kind": "added",
@@ -1138,22 +1154,22 @@
1138
1154
  {
1139
1155
  "kind": "added",
1140
1156
  "short": "A game can ask which way depth runs. The renderer reports what the context granted, and the constants a near plane and a decal offset depend on are exported.",
1141
- "text": "**A consumer can ask which way depth runs.** `CreatedRenderer.reversedDepth` reports what the context *granted*, reported the way `rendererName` is and read off the renderer in the same one place, because on WebGL2 it depends on `EXT_clip_control` and is a property of the object that got built rather than of anything upstream. `REVERSED_DEPTH`, `DEPTH_CLEAR`, `DEPTH_COMPARE`, `DEPTH_COMPARE_EQUAL`, `DEPTH_FORMAT`, `DEPTH_OFFSET_SIGN` and `conventionalDepth` are exported for what the engine asks; the two agree everywhere except a WebGL2 context without that extension.\n\n**Reported from outside as the half that was missing.** The depth seam shipped whole and none of it was reachable through the barrel, so a game had no way to know whether it could put its near plane at 0.05 or had to leave it at 0.25, and no way to size the offset that keeps a decal off its surface — it had to choose for the worse case and gained nothing from the better one. A conventional buffer resolves about `z² / (near · 2^bits)`, which is what makes the near plane the only control there is."
1157
+ "text": "**A consumer can ask which way depth runs.** `CreatedRenderer.reversedDepth` reports what the context *granted*, reported the way `rendererName` is and read off the renderer in the same one place, because on WebGL2 it depends on `EXT_clip_control` and is a property of the object that got built rather than of anything upstream. `REVERSED_DEPTH`, `DEPTH_CLEAR`, `DEPTH_COMPARE`, `DEPTH_COMPARE_EQUAL`, `DEPTH_FORMAT`, `DEPTH_OFFSET_SIGN` and `conventionalDepth` are exported for what the engine asks; the two agree everywhere except a WebGL2 context without that extension.\n\n**Reported from outside as the half that was missing.** The depth seam shipped whole and none of it was reachable through the barrel, so a game had no way to know whether it could put its near plane at 0.05 or had to leave it at 0.25, and no way to size the offset that keeps a decal off its surface \u2014 it had to choose for the worse case and gained nothing from the better one. A conventional buffer resolves about `z\u00b2 / (near \u00b7 2^bits)`, which is what makes the near plane the only control there is."
1142
1158
  },
1143
1159
  {
1144
1160
  "kind": "changed",
1145
1161
  "short": "`MeshBuilder.build()` is a slice instead of ten conversions: 41.9 ms to 1.6 ms for 110,628 triangles. A streamer's batch cap no longer has to be a number tuned to one laptop.",
1146
- "text": "**`MeshBuilder.build()` went from 41.9 ms to 1.6 ms for 110,628 triangles**, measured on this machine. It accumulated into ten plain `number[]` and turned each into a typed array in one uninterruptible call at the end — two and a half frames at 16.7 ms, which is what made a consumer streaming a world cap a batch at a triangle count tuned to one laptop and overshoot it by whatever the last object happened to be.\n\nThe builder now writes into growable typed arrays as geometry arrives, so the end is one memcpy rather than ten walks over boxed doubles. And the five \"did anything differ from the default\" questions `build` asked — specular, emissive colour, roughness, grain, relief — were each a scan of every vertex, about 1.5 million predicate calls for a mesh that size; a write knows whether it matters, so the list now answers in a field. `npm run bench:mesh` reports both halves.\n\n**`MeshBuilder.triangleCount` and `vertexCountSoFar`** answer how big a builder has become without building it, which is the other half of the same report: a caller batching by size can ask instead of tallying every geometry verb's triangle count on its own side."
1162
+ "text": "**`MeshBuilder.build()` went from 41.9 ms to 1.6 ms for 110,628 triangles**, measured on this machine. It accumulated into ten plain `number[]` and turned each into a typed array in one uninterruptible call at the end \u2014 two and a half frames at 16.7 ms, which is what made a consumer streaming a world cap a batch at a triangle count tuned to one laptop and overshoot it by whatever the last object happened to be.\n\nThe builder now writes into growable typed arrays as geometry arrives, so the end is one memcpy rather than ten walks over boxed doubles. And the five \"did anything differ from the default\" questions `build` asked \u2014 specular, emissive colour, roughness, grain, relief \u2014 were each a scan of every vertex, about 1.5 million predicate calls for a mesh that size; a write knows whether it matters, so the list now answers in a field. `npm run bench:mesh` reports both halves.\n\n**`MeshBuilder.triangleCount` and `vertexCountSoFar`** answer how big a builder has become without building it, which is the other half of the same report: a caller batching by size can ask instead of tallying every geometry verb's triangle count on its own side."
1147
1163
  },
1148
1164
  {
1149
1165
  "kind": "changed",
1150
1166
  "short": "World text is one box per horizontal run of lit cells rather than one per cell: 1.52x fewer triangles, and pixel-identical on both backends across every published scene.",
1151
- "text": "**`buildTextMesh` merges horizontal runs of lit cells into one box each.** It put a box — twelve triangles — at every lit cell of a five-by-seven bitmap font, so a run of three adjacent cells was three boxes with four interior faces between them that nothing can ever see. Measured against a representative corpus of street names and house numbers: **13,541 lit cells become 8,895 boxes, 1.52x**, which matches the 1.53x a consumer measured against their own 1,168 strings — and their world is 1,195,296 triangles of lettering out of 2,662,324, forty-five per cent.\n\n**Horizontal only, and that is a measurement rather than a simplification.** A greedy rectangle merge over two dimensions gives exactly the same number on that corpus: in a glyph five cells wide there is nothing vertical left to win, so the second pass would be code that never pays.\n\n**Invisible, and gated rather than argued.** A run and the box that spans it have the same outer surface, so every published scene is **pixel-identical on both backends at zero tolerance**. A test asserts the stronger form: the merged geometry covers exactly the extent one box per cell would, which is what catches a centre or half-extent off by half a cell. `forEachRun` is exported beside `forEachCell` for anything else that walks a glyph."
1167
+ "text": "**`buildTextMesh` merges horizontal runs of lit cells into one box each.** It put a box \u2014 twelve triangles \u2014 at every lit cell of a five-by-seven bitmap font, so a run of three adjacent cells was three boxes with four interior faces between them that nothing can ever see. Measured against a representative corpus of street names and house numbers: **13,541 lit cells become 8,895 boxes, 1.52x**, which matches the 1.53x a consumer measured against their own 1,168 strings \u2014 and their world is 1,195,296 triangles of lettering out of 2,662,324, forty-five per cent.\n\n**Horizontal only, and that is a measurement rather than a simplification.** A greedy rectangle merge over two dimensions gives exactly the same number on that corpus: in a glyph five cells wide there is nothing vertical left to win, so the second pass would be code that never pays.\n\n**Invisible, and gated rather than argued.** A run and the box that spans it have the same outer surface, so every published scene is **pixel-identical on both backends at zero tolerance**. A test asserts the stronger form: the merged geometry covers exactly the extent one box per cell would, which is what catches a centre or half-extent off by half a cell. `forEachRun` is exported beside `forEachCell` for anything else that walks a glyph."
1152
1168
  },
1153
1169
  {
1154
1170
  "kind": "fixed",
1155
1171
  "short": "Ambient occlusion's two far-plane guards asked whether depth had reached 1.0, which is the near plane once depth is reversed.",
1156
- "text": "**Ambient occlusion skipped the background by asking whether depth had reached 1.0**, which is the far plane conventionally and the *near* plane once depth is reversed — so under a reversed buffer it would have computed occlusion over the sky. Both guards now go through `glslIsFarDepth`, which moves the sense and the bound together.\n\n**It is a latent fix, not the one that unblocks the switch, and measuring that is what it bought.** Flipping the guards moved the shot gate by *zero pixels*, and the scene that darkens worst runs no occlusion at all — so ambient occlusion is eliminated as the cause of the two scenes that still differ when reversed depth is turned on, by experiment rather than by argument. That is the third candidate eliminated; the constant's docstring carries all of them and what the next attempt should try instead.\n\n**`REVERSED_DEPTH` remains `false`**, and every published scene is pixel-identical on both backends at zero tolerance with it off."
1172
+ "text": "**Ambient occlusion skipped the background by asking whether depth had reached 1.0**, which is the far plane conventionally and the *near* plane once depth is reversed \u2014 so under a reversed buffer it would have computed occlusion over the sky. Both guards now go through `glslIsFarDepth`, which moves the sense and the bound together.\n\n**It is a latent fix, not the one that unblocks the switch, and measuring that is what it bought.** Flipping the guards moved the shot gate by *zero pixels*, and the scene that darkens worst runs no occlusion at all \u2014 so ambient occlusion is eliminated as the cause of the two scenes that still differ when reversed depth is turned on, by experiment rather than by argument. That is the third candidate eliminated; the constant's docstring carries all of them and what the next attempt should try instead.\n\n**`REVERSED_DEPTH` remains `false`**, and every published scene is pixel-identical on both backends at zero tolerance with it off."
1157
1173
  },
1158
1174
  {
1159
1175
  "kind": "fixed",
@@ -1174,7 +1190,7 @@
1174
1190
  {
1175
1191
  "kind": "added",
1176
1192
  "short": "`heightSurface` takes a list of height fields as well as one, so a road can pass under a road. Each layer says `NaN` where it is not there and the asker's own height picks the floor.",
1177
- "text": "**`heightSurface` takes a list of height fields, so a road can pass under a road.** One function of `x` and `z` has exactly one height per column and no arrangement of it holds both a flyover's deck and the carriageway beneath it; a list does, and which floor a query gets is decided by the height every caller already passes. Each layer answers a non-finite number where it is not there, so a deck is a floor exactly over its span and nothing at all beside it — that contract was previously stated only in a private comment inside the file, which is no use to somebody deciding how to model a crossing. Order in the list carries no meaning: the rule is nearness to the asker with a tie broken toward the floor below, which is the rule `CompositeSurface` and `RibbonSurface` already use between their own passes, and layers compose through the first of those rather than through a second copy of it.\n\n**Reported by a consumer with 276 km of road and six flyovers**, who had built the deck into a single field, where the only way through a crossing is over it. What they were missing was reachable — two surfaces and a `CompositeSurface` answered it, and the comment on `heightSurface.sample` said so — but it was the wrong shape of answer to a question about a height field, and it read as \"a height field cannot do this\". Measured against a real `CharacterController` standing under a span: one merged field leaves the body falling at -40.33 m, and two layers leave it grounded at 0.00 m with the deck 5.5 m overhead."
1193
+ "text": "**`heightSurface` takes a list of height fields, so a road can pass under a road.** One function of `x` and `z` has exactly one height per column and no arrangement of it holds both a flyover's deck and the carriageway beneath it; a list does, and which floor a query gets is decided by the height every caller already passes. Each layer answers a non-finite number where it is not there, so a deck is a floor exactly over its span and nothing at all beside it \u2014 that contract was previously stated only in a private comment inside the file, which is no use to somebody deciding how to model a crossing. Order in the list carries no meaning: the rule is nearness to the asker with a tie broken toward the floor below, which is the rule `CompositeSurface` and `RibbonSurface` already use between their own passes, and layers compose through the first of those rather than through a second copy of it.\n\n**Reported by a consumer with 276 km of road and six flyovers**, who had built the deck into a single field, where the only way through a crossing is over it. What they were missing was reachable \u2014 two surfaces and a `CompositeSurface` answered it, and the comment on `heightSurface.sample` said so \u2014 but it was the wrong shape of answer to a question about a height field, and it read as \"a height field cannot do this\". Measured against a real `CharacterController` standing under a span: one merged field leaves the body falling at -40.33 m, and two layers leave it grounded at 0.00 m with the deck 5.5 m overhead."
1178
1194
  },
1179
1195
  {
1180
1196
  "kind": "changed",
@@ -1184,7 +1200,7 @@
1184
1200
  {
1185
1201
  "kind": "added",
1186
1202
  "short": "`colliderSurface` makes a `ColliderSet` answer as ground, so a solid holds a body up. A slab whose lid is at 5.5 m used to let a character fall to -84 m, exactly as no slab does.",
1187
- "text": "**`colliderSurface(set, { minY, maxY })` makes a `ColliderSet` answer as a `GroundSurface`, so a solid holds a body up.** A collider was solid to walk *into* and not to stand *on*: the kinematic sweep resolves a body against a set laterally, and `CharacterController` senses ground through `PhysicsWorld` bodies and through a `GroundSurface`, which a collider set was neither — so nothing ever asked it what was under your feet. Measured: a character dropped over a slab whose lid is at 5.5 m fell to **-84 m in three seconds**, which is exactly what it does with no slab at all; through this it stands at 5.50 m and reports grounded.\n\n**A sloped hull supports at its slope, not at its bounding box.** The lid is solved against the solid's own face planes — for a convex solid the vertical line at a column leaves through the lowest upward-facing plane and enters through the highest downward-facing one — so a ramp holds a body along its face and hands back a normal that tilts with it. Answering the broad-phase box instead would stand a body on thin air over the low half of every ramp, which is the same mistake the segment query records having made.\n\n**It is a `GroundSurface` and not a new seam, which is the point.** Everything that stands on ground already takes one, so the character controller, the raycast vehicle, the camera boom and the rain field all read it with no change, and it composes with terrain through `CompositeSurface` or a list of height fields. `minY` and `maxY` are required rather than defaulted: the spatial hash walks every cell of the column, so an unbounded one is half a million cells and a frozen frame, and a consumer knows how tall their world is where the engine does not.\n\nRequested beside the layered ground above, by the same consumer, as the general answer of which a flyover is one case: a deck becomes a slab rather than a height, and the same set that stops a body walking into it now stops it falling through."
1203
+ "text": "**`colliderSurface(set, { minY, maxY })` makes a `ColliderSet` answer as a `GroundSurface`, so a solid holds a body up.** A collider was solid to walk *into* and not to stand *on*: the kinematic sweep resolves a body against a set laterally, and `CharacterController` senses ground through `PhysicsWorld` bodies and through a `GroundSurface`, which a collider set was neither \u2014 so nothing ever asked it what was under your feet. Measured: a character dropped over a slab whose lid is at 5.5 m fell to **-84 m in three seconds**, which is exactly what it does with no slab at all; through this it stands at 5.50 m and reports grounded.\n\n**A sloped hull supports at its slope, not at its bounding box.** The lid is solved against the solid's own face planes \u2014 for a convex solid the vertical line at a column leaves through the lowest upward-facing plane and enters through the highest downward-facing one \u2014 so a ramp holds a body along its face and hands back a normal that tilts with it. Answering the broad-phase box instead would stand a body on thin air over the low half of every ramp, which is the same mistake the segment query records having made.\n\n**It is a `GroundSurface` and not a new seam, which is the point.** Everything that stands on ground already takes one, so the character controller, the raycast vehicle, the camera boom and the rain field all read it with no change, and it composes with terrain through `CompositeSurface` or a list of height fields. `minY` and `maxY` are required rather than defaulted: the spatial hash walks every cell of the column, so an unbounded one is half a million cells and a frozen frame, and a consumer knows how tall their world is where the engine does not.\n\nRequested beside the layered ground above, by the same consumer, as the general answer of which a flyover is one case: a deck becomes a slab rather than a height, and the same set that stops a body walking into it now stops it falling through."
1188
1204
  }
1189
1205
  ]
1190
1206
  },
@@ -1194,17 +1210,17 @@
1194
1210
  "entries": [
1195
1211
  {
1196
1212
  "kind": "added",
1197
- "text": "**A `ColliderSet` can change after it is built, so a world whose solid geometry streams no longer has to build another one.** `add(boxes)` returns a handle, `remove(group)` drops it, and the uniform grid is updated for that group and for no other collider in the set. Reported by a consumer moving from 2.4 km of survey sheets to a 9.3 x 4.9 km world off OpenStreetMap, who measured the two things today's API left them: rebuilding at every region crossing costs about **2.1 microseconds a box and is linear** — 16.9 ms at their 6,076 colliders and **41.2 ms at the 15,000 they are moving to**, two and a half frames, every few seconds while somebody is driving; and the alternative they shipped instead, one set per region with the nine around the player queried, measured **1.19 microseconds a query against 0.16 for a single set, a 7.5x multiplier on every collision query in the game** plus a seam where an index means nothing without the set that answered it. Measured here by `npm run check:streaming`, driving the real sweep across region boundaries at five frame rates: **a crossing costs 0.090 ms** and **queries on the streamed set run at 1.25 to 1.31x a freshly built one's**. The workaround can be deleted and an index goes back to being an index. `absorb(other)` folds one set into another by copying packed bounds with a single `Float32Array.set` and offsetting its buckets, with nothing rehashed; a churned source's holes come across as holes, because the constant offset is only valid while every source slot occupies a destination slot and copying them as live would report the bytes of colliders that were deleted. `bytes()` answers what a set costs in four numbers, which is the question that decides a region size and which a consumer counting its own bytes could not ask.",
1213
+ "text": "**A `ColliderSet` can change after it is built, so a world whose solid geometry streams no longer has to build another one.** `add(boxes)` returns a handle, `remove(group)` drops it, and the uniform grid is updated for that group and for no other collider in the set. Reported by a consumer moving from 2.4 km of survey sheets to a 9.3 x 4.9 km world off OpenStreetMap, who measured the two things today's API left them: rebuilding at every region crossing costs about **2.1 microseconds a box and is linear** \u2014 16.9 ms at their 6,076 colliders and **41.2 ms at the 15,000 they are moving to**, two and a half frames, every few seconds while somebody is driving; and the alternative they shipped instead, one set per region with the nine around the player queried, measured **1.19 microseconds a query against 0.16 for a single set, a 7.5x multiplier on every collision query in the game** plus a seam where an index means nothing without the set that answered it. Measured here by `npm run check:streaming`, driving the real sweep across region boundaries at five frame rates: **a crossing costs 0.090 ms** and **queries on the streamed set run at 1.25 to 1.31x a freshly built one's**. The workaround can be deleted and an index goes back to being an index. `absorb(other)` folds one set into another by copying packed bounds with a single `Float32Array.set` and offsetting its buckets, with nothing rehashed; a churned source's holes come across as holes, because the constant offset is only valid while every source slot occupies a destination slot and copying them as live would report the bytes of colliders that were deleted. `bytes()` answers what a set costs in four numbers, which is the question that decides a region size and which a consumer counting its own bytes could not ask.",
1198
1214
  "short": "A `ColliderSet` takes and drops groups after it is built. A region crossing costs 0.090 ms against about 41 ms to rebuild, and the one-set-per-region workaround's 7.5x on every query goes away."
1199
1215
  },
1200
1216
  {
1201
1217
  "kind": "changed",
1202
- "text": "**`ColliderSet.count` is the live count and `capacity` is the slot count, with `liveAt(index)` between them.** They are equal on a set that has never had `remove` called on it, which is every set built before this release, so nothing that walks `data` today changes. After a removal the live slots are sparse within `capacity`, and a walk of `0..count` reads the wrong ones — walk `0..capacity` and skip `!liveAt(i)`. A reference to `data` is now valid only until the next `add`, which may reallocate; `bounds(index, out)` survives both that and a change of packing. **The colliders a set is constructed with belong to a reserved group `remove` refuses**, so a set meant to stream is constructed empty. `physics-only` grows 40,438 to 42,057 bytes gzipped, 4.0%, and none of it is tree-shakeable: they are methods on a class every consumer of the package constructs.",
1218
+ "text": "**`ColliderSet.count` is the live count and `capacity` is the slot count, with `liveAt(index)` between them.** They are equal on a set that has never had `remove` called on it, which is every set built before this release, so nothing that walks `data` today changes. After a removal the live slots are sparse within `capacity`, and a walk of `0..count` reads the wrong ones \u2014 walk `0..capacity` and skip `!liveAt(i)`. A reference to `data` is now valid only until the next `add`, which may reallocate; `bounds(index, out)` survives both that and a change of packing. **The colliders a set is constructed with belong to a reserved group `remove` refuses**, so a set meant to stream is constructed empty. `physics-only` grows 40,438 to 42,057 bytes gzipped, 4.0%, and none of it is tree-shakeable: they are methods on a class every consumer of the package constructs.",
1203
1219
  "short": "`count` is the live count and `capacity` the slot count, equal until the first `remove`; after one, walk `0..capacity` and skip `!liveAt(i)`. A set meant to stream is constructed empty."
1204
1220
  },
1205
1221
  {
1206
1222
  "kind": "changed",
1207
- "text": "**`fingerprintColliders` reads liveness, and every hash it has ever produced is unchanged.** It hashed the whole of `data`, so spare capacity or a hole would have hashed slots that are not geometry and desynced a replay in the quietest way there is. It walks live slots in index order now, which for a constructor-built set is byte-for-byte the stream it always was — including the empty slots a sparse input array leaves, which stay live with zero bounds precisely so the string does not move. Two golden hashes pin it. **On a set that has been mutated it answers a narrower question**: \"the same set with the same history\", not \"the same world\". Replay is unaffected, since the same inputs drive the same path and so the same slots; what it costs is comparing two sessions that reached the same world by different routes.",
1223
+ "text": "**`fingerprintColliders` reads liveness, and every hash it has ever produced is unchanged.** It hashed the whole of `data`, so spare capacity or a hole would have hashed slots that are not geometry and desynced a replay in the quietest way there is. It walks live slots in index order now, which for a constructor-built set is byte-for-byte the stream it always was \u2014 including the empty slots a sparse input array leaves, which stay live with zero bounds precisely so the string does not move. Two golden hashes pin it. **On a set that has been mutated it answers a narrower question**: \"the same set with the same history\", not \"the same world\". Replay is unaffected, since the same inputs drive the same path and so the same slots; what it costs is comparing two sessions that reached the same world by different routes.",
1208
1224
  "short": "`fingerprintColliders` walks live slots and every hash it ever produced is unchanged. On a mutated set it answers \"the same set with the same history\", not \"the same world\"."
1209
1225
  },
1210
1226
  {
@@ -1214,38 +1230,38 @@
1214
1230
  },
1215
1231
  {
1216
1232
  "kind": "added",
1217
- "text": "**`drift-package` takes `--out=<dir>`, so a build can stage outside the project.** The output was `join(cwd, 'out', target)` in four places and nothing moved it. That is not tidiness: Node's `fs.cp` refuses to copy a thing into itself and decides it by comparing `dev` and `ino` up the destination's ancestors, and a consumer on a Windows VM with the project on a drive mapped to a Linux folder measured `drift.package.json` and `out/` both answering `dev=66313 ino=-112686486700016` — one identity for a file and a directory — so the first staging copy of every build failed with a sentence about the one thing that was not happening. The layout under the root is unchanged, so `--out=C:\\drift-out` gives `C:\\drift-out\\win-x64\\stage`. An absolute path is used as it stands; a relative one resolves against wherever the command was run, which is not necessarily `--project=`. The fourth call site is `steam`, not the build path, so a flag threaded through `build` alone would have gone on writing depot scripts beside an output directory that had moved.",
1233
+ "text": "**`drift-package` takes `--out=<dir>`, so a build can stage outside the project.** The output was `join(cwd, 'out', target)` in four places and nothing moved it. That is not tidiness: Node's `fs.cp` refuses to copy a thing into itself and decides it by comparing `dev` and `ino` up the destination's ancestors, and a consumer on a Windows VM with the project on a drive mapped to a Linux folder measured `drift.package.json` and `out/` both answering `dev=66313 ino=-112686486700016` \u2014 one identity for a file and a directory \u2014 so the first staging copy of every build failed with a sentence about the one thing that was not happening. The layout under the root is unchanged, so `--out=C:\\drift-out` gives `C:\\drift-out\\win-x64\\stage`. An absolute path is used as it stands; a relative one resolves against wherever the command was run, which is not necessarily `--project=`. The fourth call site is `steam`, not the build path, so a flag threaded through `build` alone would have gone on writing depot scripts beside an output directory that had moved.",
1218
1234
  "short": "`drift-package` takes `--out=<dir>`, for a project on a filesystem that answers one `dev` and `ino` for both a file and a directory, making every staging copy refuse itself."
1219
1235
  },
1220
1236
  {
1221
1237
  "kind": "fixed",
1222
1238
  "short": "`drift-package` launched `tsx` through `npx`, which on Windows is `npx.cmd` and does not launch at all: it exited 1 printing nothing, because the reason sits in `result.error`.",
1223
- "text": "**`drift-package` could not start on Windows, and said nothing about it.** Its executable ran `spawnSync('npx', ['tsx', …])`. Over there `npx` is `npx.cmd`, and a `.cmd` is not something `CreateProcess` knows how to launch: `spawnSync` fails with `ENOENT`, leaves `status` at `null`, and a bare `status ?? 1` exits 1 **with nothing printed at all**, because the reason is in `result.error` and nothing read it. A consumer lost half a day to it and wrote a 209-line launcher of their own, which is the real measure of the defect. It now launches `tsx` with the Node already running — no shell, no `.cmd`, no npx resolution — reading `tsx`'s own declared `bin` so a version that moves its entry point cannot break this silently, and it reports `result.error` and `result.signal` separately from an exit code. **`tsx` is now a declared dependency of `@driftengine/package`**, which it always was in fact: its executable cannot run without it, and twelve workspace scripts were resolving it from the npx cache over the network."
1239
+ "text": "**`drift-package` could not start on Windows, and said nothing about it.** Its executable ran `spawnSync('npx', ['tsx', \u2026])`. Over there `npx` is `npx.cmd`, and a `.cmd` is not something `CreateProcess` knows how to launch: `spawnSync` fails with `ENOENT`, leaves `status` at `null`, and a bare `status ?? 1` exits 1 **with nothing printed at all**, because the reason is in `result.error` and nothing read it. A consumer lost half a day to it and wrote a 209-line launcher of their own, which is the real measure of the defect. It now launches `tsx` with the Node already running \u2014 no shell, no `.cmd`, no npx resolution \u2014 reading `tsx`'s own declared `bin` so a version that moves its entry point cannot break this silently, and it reports `result.error` and `result.signal` separately from an exit code. **`tsx` is now a declared dependency of `@driftengine/package`**, which it always was in fact: its executable cannot run without it, and twelve workspace scripts were resolving it from the npx cache over the network."
1224
1240
  },
1225
1241
  {
1226
1242
  "kind": "fixed",
1227
1243
  "short": "A desktop build proceeded with no Electron binary, because the version is read from a manifest that survives a postinstall which never finished, and failed several minutes later.",
1228
- "text": "**A desktop build proceeded with no Electron runtime and failed several minutes later saying something else.** `installedElectronVersion()` resolves `electron/package.json` and reads its `version`; that file is in the npm tarball and the binary is not, since a postinstall downloads it into `dist` and writes `path.txt` beside it. So the version always resolved, the build always proceeded, and electron-builder found nothing to package. **Measured in this repository before the fix**: a version of `43.4.1` against no `dist`, with `npm config get ignore-scripts` answering `false` — so this is not only npm refusing to run scripts, which is the case a consumer reported, but the plainer fact that a download can fail to finish for a proxy, a slow link, a sandbox or a CI image and nothing downstream is told. `build` now checks for both `dist` and `path.txt` and **fetches the runtime rather than refusing**: a consumer's own wrapper printed the command and stopped, and they watched somebody read that and re-run the script unchanged three times. If the fetch itself fails, the throw names `npm install-scripts approve electron`."
1244
+ "text": "**A desktop build proceeded with no Electron runtime and failed several minutes later saying something else.** `installedElectronVersion()` resolves `electron/package.json` and reads its `version`; that file is in the npm tarball and the binary is not, since a postinstall downloads it into `dist` and writes `path.txt` beside it. So the version always resolved, the build always proceeded, and electron-builder found nothing to package. **Measured in this repository before the fix**: a version of `43.4.1` against no `dist`, with `npm config get ignore-scripts` answering `false` \u2014 so this is not only npm refusing to run scripts, which is the case a consumer reported, but the plainer fact that a download can fail to finish for a proxy, a slow link, a sandbox or a CI image and nothing downstream is told. `build` now checks for both `dist` and `path.txt` and **fetches the runtime rather than refusing**: a consumer's own wrapper printed the command and stopped, and they watched somebody read that and re-run the script unchanged three times. If the fetch itself fails, the throw names `npm install-scripts approve electron`."
1229
1245
  },
1230
1246
  {
1231
1247
  "kind": "added",
1232
1248
  "short": "`drift-package init` writes the two files a project cannot avoid owning: a Windows entry point and a launcher for a vendored engine. Everything else moved into the packager.",
1233
- "text": "**`drift-package init` writes the two files a project has to own, and nothing else.** A Windows build needs a `.ps1` because something must start Node on that machine and that something cannot be a Node program; a project needs a launcher because a vendored engine resolves through no `node_modules`. The generated PowerShell routes every native command through a step that checks `$LASTEXITCODE` — `$ErrorActionPreference = 'Stop'` governs cmdlets and not `npm`, and PowerShell 5.1, which is what a fresh Windows machine runs, has no `$PSNativeCommandUseErrorActionPreference`, so an unchecked failure arrives as a complaint about a missing output directory with the real error scrolled off. It relocates a build off a mapped or network drive **and copies the artifacts back**, which is the half that is easy to miss: without it the build succeeds, says so, lists its files, and `out/` in the checkout is empty. Both files refuse to be overwritten without `--force`. The shipped `build-windows.ps1` is deleted rather than repaired, because two scripts that do the same thing drift and only one of them is the one anybody runs."
1249
+ "text": "**`drift-package init` writes the two files a project has to own, and nothing else.** A Windows build needs a `.ps1` because something must start Node on that machine and that something cannot be a Node program; a project needs a launcher because a vendored engine resolves through no `node_modules`. The generated PowerShell routes every native command through a step that checks `$LASTEXITCODE` \u2014 `$ErrorActionPreference = 'Stop'` governs cmdlets and not `npm`, and PowerShell 5.1, which is what a fresh Windows machine runs, has no `$PSNativeCommandUseErrorActionPreference`, so an unchecked failure arrives as a complaint about a missing output directory with the real error scrolled off. It relocates a build off a mapped or network drive **and copies the artifacts back**, which is the half that is easy to miss: without it the build succeeds, says so, lists its files, and `out/` in the checkout is empty. Both files refuse to be overwritten without `--force`. The shipped `build-windows.ps1` is deleted rather than repaired, because two scripts that do the same thing drift and only one of them is the one anybody runs."
1234
1250
  },
1235
1251
  {
1236
1252
  "kind": "added",
1237
1253
  "short": "`doctor` refuses a filesystem that gives a file and a directory one identity, which is what makes `fs.cp` reject the first staging copy ten minutes into a build.",
1238
- "text": "**`doctor` refuses a filesystem that cannot tell a file from a directory.** `fs.cp` will not copy a thing into itself and decides that by comparing `dev` and `ino` up the destination's ancestors, so a network share that invents a file index makes it reject a copy that is perfectly legitimate — and what comes out is a sentence about copying a file into a subdirectory of itself, ten minutes into a build, about the thing furthest from the cause. It is checked before anything is read or downloaded, with `bigint: true` because a Windows file index is 64 bits and loses precision as a double exactly in the high half where those values live, which would make the check wrong in both directions on the one platform it exists for. **The remedy it names is `--out=<dir>` first** and a local copy second, which is what that flag bought: until it existed the only answer was to clone the whole project."
1254
+ "text": "**`doctor` refuses a filesystem that cannot tell a file from a directory.** `fs.cp` will not copy a thing into itself and decides that by comparing `dev` and `ino` up the destination's ancestors, so a network share that invents a file index makes it reject a copy that is perfectly legitimate \u2014 and what comes out is a sentence about copying a file into a subdirectory of itself, ten minutes into a build, about the thing furthest from the cause. It is checked before anything is read or downloaded, with `bigint: true` because a Windows file index is 64 bits and loses precision as a double exactly in the high half where those values live, which would make the check wrong in both directions on the one platform it exists for. **The remedy it names is `--out=<dir>` first** and a local copy second, which is what that flag bought: until it existed the only answer was to clone the whole project."
1239
1255
  },
1240
1256
  {
1241
1257
  "kind": "fixed",
1242
1258
  "short": "`ELECTRON_RUN_AS_NODE` was removed for `run` alone, leaving `build` inheriting it. Every subcommand is protected now.",
1243
- "text": "**`ELECTRON_RUN_AS_NODE` is removed for every subcommand, not just `run`.** Any terminal that is itself an Electron application — an editor's built-in one is the ordinary case — exports it to everything it spawns, and inherited it turns Electron into plain Node: the build dies on `Cannot find module 'electron'` under a Node banner, which looks nothing like its cause. It was deleted inside `run`'s own environment and nowhere else, which left `build` inheriting it — and `build` is the one that spends twenty minutes before anything can go wrong."
1259
+ "text": "**`ELECTRON_RUN_AS_NODE` is removed for every subcommand, not just `run`.** Any terminal that is itself an Electron application \u2014 an editor's built-in one is the ordinary case \u2014 exports it to everything it spawns, and inherited it turns Electron into plain Node: the build dies on `Cannot find module 'electron'` under a Node banner, which looks nothing like its cause. It was deleted inside `run`'s own environment and nowhere else, which left `build` inheriting it \u2014 and `build` is the one that spends twenty minutes before anything can go wrong."
1244
1260
  },
1245
1261
  {
1246
1262
  "kind": "fixed",
1247
1263
  "short": "The packager's README said the generated Android keystore is kept beside the artifact. It is in `~/.cache`, and it is the application's permanent identity.",
1248
- "text": "**The packager's README said the generated Android keystore is \"kept beside the artifact\". It is not**: it is in `~/.cache/driftengine/toolchain/keystores/<app id>.jks`, which the source has always said and the page did not. That matters because of what the file is — a device refuses an upgrade signed with a different key, reading it as a different application wearing the same name, and the only way out on the phone is to uninstall, which throws away the player's save. The page now carries the real path and the three things that follow: back it up, because `~/.cache` is a directory whose entire purpose is being safe to delete; a second build machine mints a second identity **silently**, so a game built in two places needs an owned key; and `apksigner verify --print-certs` before handing a build to somebody who already has one installed, because the phone's refusal says nothing about signing."
1264
+ "text": "**The packager's README said the generated Android keystore is \"kept beside the artifact\". It is not**: it is in `~/.cache/driftengine/toolchain/keystores/<app id>.jks`, which the source has always said and the page did not. That matters because of what the file is \u2014 a device refuses an upgrade signed with a different key, reading it as a different application wearing the same name, and the only way out on the phone is to uninstall, which throws away the player's save. The page now carries the real path and the three things that follow: back it up, because `~/.cache` is a directory whose entire purpose is being safe to delete; a second build machine mints a second identity **silently**, so a game built in two places needs an owned key; and `apksigner verify --print-certs` before handing a build to somebody who already has one installed, because the phone's refusal says nothing about signing."
1249
1265
  }
1250
1266
  ]
1251
1267
  },
@@ -1255,12 +1271,12 @@
1255
1271
  "entries": [
1256
1272
  {
1257
1273
  "kind": "fixed",
1258
- "text": "**The scene target's resolve gives face culling back, so the world is no longer drawn double-sided on WebGL2.** `SceneTarget.resolve` switches off depth, blend and culling to draw its one triangle over the canvas, and put back only the first two. It runs at `endFrame`, so from the *second frame onwards* every `drawMesh` in every scene ran with `CULL_FACE` disabled — against a renderer that enables culling once at setup and documents it as on for the renderer's life because the world is solid. The two backends therefore disagreed about a rule only one of them was enforcing: WebGPU sets `cullMode: 'back'` in pipeline state, which no pass can leak. It is invisible wherever geometry is wound correctly, which is every published scene here, and total in a world whose geometry is not — a voxel demo wound backwards looked perfect on WebGL2 and drew no ground at all on WebGPU, so the divergence read as a WebGPU defect for the length of that port and was this line. **Measured across the harness at `--delta=16`:** ten of thirteen scenes are pixel-identical either way. `showroom` moves 255 pixels of 504,000 and moves *closer* to WebGPU. `night street` moves 2,282 because its planar reflection sets `cullFace(FRONT)`, which had been doing nothing while culling was off — the wet road now carries the window reflections it should always have had. `sceneTarget.test.ts` asserts the restore rather than the disable, so a pass that borrows the state again has to give it back.",
1274
+ "text": "**The scene target's resolve gives face culling back, so the world is no longer drawn double-sided on WebGL2.** `SceneTarget.resolve` switches off depth, blend and culling to draw its one triangle over the canvas, and put back only the first two. It runs at `endFrame`, so from the *second frame onwards* every `drawMesh` in every scene ran with `CULL_FACE` disabled \u2014 against a renderer that enables culling once at setup and documents it as on for the renderer's life because the world is solid. The two backends therefore disagreed about a rule only one of them was enforcing: WebGPU sets `cullMode: 'back'` in pipeline state, which no pass can leak. It is invisible wherever geometry is wound correctly, which is every published scene here, and total in a world whose geometry is not \u2014 a voxel demo wound backwards looked perfect on WebGL2 and drew no ground at all on WebGPU, so the divergence read as a WebGPU defect for the length of that port and was this line. **Measured across the harness at `--delta=16`:** ten of thirteen scenes are pixel-identical either way. `showroom` moves 255 pixels of 504,000 and moves *closer* to WebGPU. `night street` moves 2,282 because its planar reflection sets `cullFace(FRONT)`, which had been doing nothing while culling was off \u2014 the wet road now carries the window reflections it should always have had. `sceneTarget.test.ts` asserts the restore rather than the disable, so a pass that borrows the state again has to give it back.",
1259
1275
  "short": "`SceneTarget.resolve` left `CULL_FACE` disabled, so from the second frame on every scene was drawn double-sided on WebGL2 while WebGPU culled as documented."
1260
1276
  },
1261
1277
  {
1262
1278
  "kind": "fixed",
1263
- "text": "**The WebGPU mesh pass uploads the camera medium, which it never did.** `bindMeshPass` set `uFogEnabled` and never wrote the fog and underwater fields behind it, so the mesh pass read whatever the uniform block happened to hold. Every other pass in that backend resolves and writes the medium — plumes, film, lines, water — and WebGL2's `writeMeshPassState` calls `bindAtmosphere` for exactly this reason, with a comment beside it saying an unwritten uniform is zero. `uFogNear` and `uFogFar` at zero collapse the linear span to 1e-4, so the ramp saturates at any distance past a tenth of a millimetre: the failure is total rather than subtle, every surface at full fog colour or black where the ramp inverts. It stayed hidden because it only shows on a scene that asks for `fogMode: 'linear'`, and none of the published ones do.",
1279
+ "text": "**The WebGPU mesh pass uploads the camera medium, which it never did.** `bindMeshPass` set `uFogEnabled` and never wrote the fog and underwater fields behind it, so the mesh pass read whatever the uniform block happened to hold. Every other pass in that backend resolves and writes the medium \u2014 plumes, film, lines, water \u2014 and WebGL2's `writeMeshPassState` calls `bindAtmosphere` for exactly this reason, with a comment beside it saying an unwritten uniform is zero. `uFogNear` and `uFogFar` at zero collapse the linear span to 1e-4, so the ramp saturates at any distance past a tenth of a millimetre: the failure is total rather than subtle, every surface at full fog colour or black where the ramp inverts. It stayed hidden because it only shows on a scene that asks for `fogMode: 'linear'`, and none of the published ones do.",
1264
1280
  "short": "WebGPU's `bindMeshPass` set `uFogEnabled` without ever uploading the fog and underwater fields behind it, which saturates a linear ramp at any distance."
1265
1281
  }
1266
1282
  ]
@@ -1271,22 +1287,22 @@
1271
1287
  "entries": [
1272
1288
  {
1273
1289
  "kind": "added",
1274
- "text": "**A rectangular area light casts a shadow, and its penumbra is the shape of the emitter.** An `AreaLightSource` used to illuminate without occluding, which `AGENTS.md` calls a bug rather than a limitation: a lit panel over a table lit the floor through the table. The gap was recorded as needing a different projection, on the reasoning that the shadow pool is octahedral and centred on a point and a rectangle is not one. A rectangle does have a point — its centre — and what the emitter's extent is actually needed for is the *filter* rather than the bake. So the bake is a point light's bake from the rectangle's middle, reusing `PointShadowMap`, `PointShadowImage`, the staleness rule, the bake ordering and the arrival ramp unchanged on both backends; and `areaShadow()` projects the rectangle's two axes across the light ray and opens **an ellipse the size of the emitter's outline** instead of a disk the size of a radius. That is the difference a single radius cannot express: nine bulbs on a cable throw nine hard shadows that pile up darker where they cross, and one wide emitter throws one shadow, soft along its length and tight across it. **Measured on `demo/dev/pointshadow.html?area=1`, identically on both backends:** a shadow edge **0.178 m** wide along a three-metre strip against **0.044 m** across it, and turning the strip a quarter turn moves the softening to the other axis — 0.300 m against 0.067 m. A still caster's ground reads 4.5 against 10.7 at its mirror through the emitter and a moving caster's 2.1 against 13.7, so the static layer and the live one both arrive. The two backends differ in **122 pixels of 1,689,600** past a delta of 8, and the two published point-lit scenes are **0 of 921,600** against the release before this one. **Off unless a rectangle asks for it:** `castsShadow` defaults **false** here where it defaults true on a point light, because every scene that already declared a rectangle was authored against a light that passed through stone, and turning it on costs two octahedral layers — 8.39 MB — and a six-face bake. `shadowRange` is required alongside it and is deliberately not defaulted: a rectangle has no radius to take a far plane from, and guessing one puts the shadow's edge in a straight line partway across the floor. Both refusals are warned by name, once. **It rides `RenderQuality.pointShadows`**, and that is a texture-unit wall rather than a preference — the occlusion is read out of the same array texture a point light's is, WebGL2 guarantees sixteen units and the lit pass binds sixteen, so there is no seventeenth sampler to declare. **What it approximates** is visibility over the emitter: one map from the centre is right in the middle of the rectangle and increasingly wrong toward its edges. Measured on the case the improvement ledger named before the feature existed — a panel a metre across, half a metre above its caster — the shadow reads 0.231 of its control against 0.094 for the same panel 1.9 m up, so it softens by more than half rather than hardening or vanishing.",
1290
+ "text": "**A rectangular area light casts a shadow, and its penumbra is the shape of the emitter.** An `AreaLightSource` used to illuminate without occluding, which `AGENTS.md` calls a bug rather than a limitation: a lit panel over a table lit the floor through the table. The gap was recorded as needing a different projection, on the reasoning that the shadow pool is octahedral and centred on a point and a rectangle is not one. A rectangle does have a point \u2014 its centre \u2014 and what the emitter's extent is actually needed for is the *filter* rather than the bake. So the bake is a point light's bake from the rectangle's middle, reusing `PointShadowMap`, `PointShadowImage`, the staleness rule, the bake ordering and the arrival ramp unchanged on both backends; and `areaShadow()` projects the rectangle's two axes across the light ray and opens **an ellipse the size of the emitter's outline** instead of a disk the size of a radius. That is the difference a single radius cannot express: nine bulbs on a cable throw nine hard shadows that pile up darker where they cross, and one wide emitter throws one shadow, soft along its length and tight across it. **Measured on `demo/dev/pointshadow.html?area=1`, identically on both backends:** a shadow edge **0.178 m** wide along a three-metre strip against **0.044 m** across it, and turning the strip a quarter turn moves the softening to the other axis \u2014 0.300 m against 0.067 m. A still caster's ground reads 4.5 against 10.7 at its mirror through the emitter and a moving caster's 2.1 against 13.7, so the static layer and the live one both arrive. The two backends differ in **122 pixels of 1,689,600** past a delta of 8, and the two published point-lit scenes are **0 of 921,600** against the release before this one. **Off unless a rectangle asks for it:** `castsShadow` defaults **false** here where it defaults true on a point light, because every scene that already declared a rectangle was authored against a light that passed through stone, and turning it on costs two octahedral layers \u2014 8.39 MB \u2014 and a six-face bake. `shadowRange` is required alongside it and is deliberately not defaulted: a rectangle has no radius to take a far plane from, and guessing one puts the shadow's edge in a straight line partway across the floor. Both refusals are warned by name, once. **It rides `RenderQuality.pointShadows`**, and that is a texture-unit wall rather than a preference \u2014 the occlusion is read out of the same array texture a point light's is, WebGL2 guarantees sixteen units and the lit pass binds sixteen, so there is no seventeenth sampler to declare. **What it approximates** is visibility over the emitter: one map from the centre is right in the middle of the rectangle and increasingly wrong toward its edges. Measured on the case the improvement ledger named before the feature existed \u2014 a panel a metre across, half a metre above its caster \u2014 the shadow reads 0.231 of its control against 0.094 for the same panel 1.9 m up, so it softens by more than half rather than hardening or vanishing.",
1275
1291
  "short": "A rectangular area light casts a shadow whose penumbra is the emitter's shape: a strip blurs along its length and stays tight across it. Off unless a rectangle asks."
1276
1292
  },
1277
1293
  {
1278
1294
  "kind": "added",
1279
- "text": "**The packager can be told where its own `assets/`, `android/` and `ios/` are, and it checks that what it found is really them.** Four call sites climbed out of `src/` on their own — `../assets` twice, `../android`, `../ios` — with no option, flag or variable that moved any of them, and the first runs on every desktop build. That is fine in a checkout of this repository and a wall for a consumer that vendors engine source rather than installing it: a vendor copies `packages/<name>/src/**` to `<name>/**`, and the flattening drops exactly the level those paths climbed to. `resourceRoot`, `resourceDir` and `resourceFile` in `@driftengine/package` answer all four from one place, `--resources=<dir>` sets it on `doctor`, `run` and `build`, and a relative path resolves against where the command was typed rather than against the module. **Making it configurable made its absence quiet, and that is fixed in the same release.** A vendored engine puts every package in one directory and one of them is `@driftengine/assets`, so the default root in that layout *is* a directory called `assets` — an existence check passes on a package's TypeScript source, and staging then produces an artifact that launches with no splash screen and no error. Each directory is now identified by a file only it has: `splash.html`, which `main/splash.ts` loads; `project.yml`, which the iOS host rewrites; `settings.gradle.kts`, without which Gradle will not open the project. Every marker is a file the packager itself reads, so one cannot rot while the check keeps passing. The refusal names the coincidence rather than the rule, because to somebody hitting it the path and the name both look right.",
1295
+ "text": "**The packager can be told where its own `assets/`, `android/` and `ios/` are, and it checks that what it found is really them.** Four call sites climbed out of `src/` on their own \u2014 `../assets` twice, `../android`, `../ios` \u2014 with no option, flag or variable that moved any of them, and the first runs on every desktop build. That is fine in a checkout of this repository and a wall for a consumer that vendors engine source rather than installing it: a vendor copies `packages/<name>/src/**` to `<name>/**`, and the flattening drops exactly the level those paths climbed to. `resourceRoot`, `resourceDir` and `resourceFile` in `@driftengine/package` answer all four from one place, `--resources=<dir>` sets it on `doctor`, `run` and `build`, and a relative path resolves against where the command was typed rather than against the module. **Making it configurable made its absence quiet, and that is fixed in the same release.** A vendored engine puts every package in one directory and one of them is `@driftengine/assets`, so the default root in that layout *is* a directory called `assets` \u2014 an existence check passes on a package's TypeScript source, and staging then produces an artifact that launches with no splash screen and no error. Each directory is now identified by a file only it has: `splash.html`, which `main/splash.ts` loads; `project.yml`, which the iOS host rewrites; `settings.gradle.kts`, without which Gradle will not open the project. Every marker is a file the packager itself reads, so one cannot rot while the check keeps passing. The refusal names the coincidence rather than the rule, because to somebody hitting it the path and the name both look right.",
1280
1296
  "short": "The packager takes `--resources=<dir>` for its own assets, android and ios directories, and refuses one that is correctly named but is somebody else's package."
1281
1297
  },
1282
1298
  {
1283
1299
  "kind": "fixed",
1284
- "text": "**Asking whether a window can be resized no longer resizes it.** `DisplayControl` offered `setSize` and no way to ask whether it would do anything, so a consumer wanting to know had to call it and watch — which on a full-screen window is a visible change to the thing being asked about. `canSetSize()` answers without performing one, following `pad.canRumble`'s precedent: a capability query belongs beside the capability. `BrowserDisplay` answers false, and the native path forwards to one decider in `main/ipc.ts` that both the query and the resize now read, so the two cannot disagree about when a resize is refused. The Android and iOS hosts answer it too.",
1300
+ "text": "**Asking whether a window can be resized no longer resizes it.** `DisplayControl` offered `setSize` and no way to ask whether it would do anything, so a consumer wanting to know had to call it and watch \u2014 which on a full-screen window is a visible change to the thing being asked about. `canSetSize()` answers without performing one, following `pad.canRumble`'s precedent: a capability query belongs beside the capability. `BrowserDisplay` answers false, and the native path forwards to one decider in `main/ipc.ts` that both the query and the resize now read, so the two cannot disagree about when a resize is refused. The Android and iOS hosts answer it too.",
1285
1301
  "short": "`DisplayControl.canSetSize()` answers whether a resize would work without performing one, which on a full-screen window used to be a visible change."
1286
1302
  },
1287
1303
  {
1288
1304
  "kind": "changed",
1289
- "text": "**Every `core-*` bundle floor moves, and `@driftengine/core` is 584.1 KB gzipped against 553.0.** Two things arrived at once and the split is on record in `scripts/size-floors.mjs`. **7,343 bytes of it was drift already inside the gate's 3% tolerance** — the floor said 566,250 and the build before this release measured 573,593, so the gate stayed green while the number in the file stopped being the measured one. The other **24,549 bytes, 4.3%, is the area-light shadow**, and nine tenths of that is generated WGSL rather than the decision: a shader ships twice in this engine, authored in GLSL and transpiled, and `areaShadow()` is compiled into the eight fragment permutations that carry point shadows. Gzip recovers most of it — 114,278 raw bytes of near-identical copies is what it is best at. Each package README now quotes what its own fixture measures rather than what it measured when its floor was last written, which is why the per-package deltas moved by up to 3.6 KB even though this change touches only core.",
1305
+ "text": "**Every `core-*` bundle floor moves, and `@driftengine/core` is 584.1 KB gzipped against 553.0.** Two things arrived at once and the split is on record in `scripts/size-floors.mjs`. **7,343 bytes of it was drift already inside the gate's 3% tolerance** \u2014 the floor said 566,250 and the build before this release measured 573,593, so the gate stayed green while the number in the file stopped being the measured one. The other **24,549 bytes, 4.3%, is the area-light shadow**, and nine tenths of that is generated WGSL rather than the decision: a shader ships twice in this engine, authored in GLSL and transpiled, and `areaShadow()` is compiled into the eight fragment permutations that carry point shadows. Gzip recovers most of it \u2014 114,278 raw bytes of near-identical copies is what it is best at. Each package README now quotes what its own fixture measures rather than what it measured when its floor was last written, which is why the per-package deltas moved by up to 3.6 KB even though this change touches only core.",
1290
1306
  "short": "`@driftengine/core` is 584.1 KB gzipped, up from 553.0: 24,549 bytes of area-light shadow, mostly generated WGSL, and 7,343 of drift the 3% tolerance had been hiding."
1291
1307
  }
1292
1308
  ]
@@ -1297,17 +1313,17 @@
1297
1313
  "entries": [
1298
1314
  {
1299
1315
  "kind": "fixed",
1300
- "text": "**A system that throws no longer takes the rest of the tick with it.** The throw left `runSchedule`, so every system scheduled after a failing one never ran — once per tick, for as long as the game was up. Reported from outside, and the way it was found is the argument for containing it: a system queried a component it had not declared, which is refused by design; the system that stopped was three places later and was the one stepping a vehicle; the symptom was *a car moving in jerks*, and the only evidence was a browser console line no headless check reads. Each system's failure is caught now, reported **once** naming the system and the reason, and the schedule carries on. Nothing is disabled — a declaration error throws again next tick and is caught again, which costs a throw a tick and keeps a transient failure recoverable — and `runSchedule` no longer throws, which is what `AGENTS.md` has required of anything the frame loop calls since it was written. What it gives up is the stack reaching a debugger's uncaught-exception break; what would change that is a reporter this takes as a parameter rather than a rule chosen here for everybody.",
1316
+ "text": "**A system that throws no longer takes the rest of the tick with it.** The throw left `runSchedule`, so every system scheduled after a failing one never ran \u2014 once per tick, for as long as the game was up. Reported from outside, and the way it was found is the argument for containing it: a system queried a component it had not declared, which is refused by design; the system that stopped was three places later and was the one stepping a vehicle; the symptom was *a car moving in jerks*, and the only evidence was a browser console line no headless check reads. Each system's failure is caught now, reported **once** naming the system and the reason, and the schedule carries on. Nothing is disabled \u2014 a declaration error throws again next tick and is caught again, which costs a throw a tick and keeps a transient failure recoverable \u2014 and `runSchedule` no longer throws, which is what `AGENTS.md` has required of anything the frame loop calls since it was written. What it gives up is the stack reaching a debugger's uncaught-exception break; what would change that is a reporter this takes as a parameter rather than a rule chosen here for everybody.",
1301
1317
  "short": "A system that throws is caught, reported once by name, and the systems scheduled after it still run."
1302
1318
  },
1303
1319
  {
1304
1320
  "kind": "changed",
1305
- "text": "**A ragdoll's joint limits are per joint.** `RagdollOptions.swingCos` and `twistSin` were one pair applied to every joint a rig has, and a knee is a hinge with no twist where a shoulder is nearly a ball — so a consumer chose which of the two to get wrong. Reported from outside, where the choice was loose (a 78° cone and 74° of twist at every joint, with a comment saying tight limits look like a wooden puppet) and what it produced was reported from play as *a scrambled ball of things, limbs torso head mixed together*: at 78° nothing stops a knee or an elbow folding a limb through the torso, so a body that lands rolls itself into a knot. Either option now takes an array as well as a number, **indexed the way `parents` is**, so the entry at the knee limits the knee. The default stands in past the array's end and wherever an entry is not a finite number, because the other reading of a missing entry is zero and zero is a 90° cone at exactly the joint nobody thought about. What this does **not** buy is a real hinge: a narrow cone with no twist still bends a little in every direction, and the axis a revolute joint would need is rig data the builder is never handed — a parent array and world matrices do not say which way a knee folds.",
1321
+ "text": "**A ragdoll's joint limits are per joint.** `RagdollOptions.swingCos` and `twistSin` were one pair applied to every joint a rig has, and a knee is a hinge with no twist where a shoulder is nearly a ball \u2014 so a consumer chose which of the two to get wrong. Reported from outside, where the choice was loose (a 78\u00b0 cone and 74\u00b0 of twist at every joint, with a comment saying tight limits look like a wooden puppet) and what it produced was reported from play as *a scrambled ball of things, limbs torso head mixed together*: at 78\u00b0 nothing stops a knee or an elbow folding a limb through the torso, so a body that lands rolls itself into a knot. Either option now takes an array as well as a number, **indexed the way `parents` is**, so the entry at the knee limits the knee. The default stands in past the array's end and wherever an entry is not a finite number, because the other reading of a missing entry is zero and zero is a 90\u00b0 cone at exactly the joint nobody thought about. What this does **not** buy is a real hinge: a narrow cone with no twist still bends a little in every direction, and the axis a revolute joint would need is rig data the builder is never handed \u2014 a parent array and world matrices do not say which way a knee folds.",
1306
1322
  "short": "`RagdollOptions` takes a limit per joint, so a knee can be tight while a shoulder stays loose."
1307
1323
  },
1308
1324
  {
1309
1325
  "kind": "changed",
1310
- "text": "**Takes DriftScript 1.9.0, where a component a query narrows by is a component it reads.** The language's access analysis walked a loop's body and never its own `query<…>`, so a component appearing in a query and nowhere else was invisible to it — while this engine's runtime refuses a query unless every component in it is declared, because a schedule derived from declarations is wrong the moment a system touches more than it says. The two tools gave opposite instructions about the same line: `DS0291` called the declaration the runtime demanded unused, and a module written on that advice compiled clean and threw once a tick. `DS0288` refuses the omission now, at compile time. A consumer that compiles `.drs` moves its own pin in the same step, which is the fourth copy of this number and the rule `AGENTS.md` records.",
1326
+ "text": "**Takes DriftScript 1.9.0, where a component a query narrows by is a component it reads.** The language's access analysis walked a loop's body and never its own `query<\u2026>`, so a component appearing in a query and nowhere else was invisible to it \u2014 while this engine's runtime refuses a query unless every component in it is declared, because a schedule derived from declarations is wrong the moment a system touches more than it says. The two tools gave opposite instructions about the same line: `DS0291` called the declaration the runtime demanded unused, and a module written on that advice compiled clean and threw once a tick. `DS0288` refuses the omission now, at compile time. A consumer that compiles `.drs` moves its own pin in the same step, which is the fourth copy of this number and the rule `AGENTS.md` records.",
1311
1327
  "short": "Takes DriftScript 1.9.0: a component named in a query is one the compiler now counts as read."
1312
1328
  }
1313
1329
  ]
@@ -1318,12 +1334,12 @@
1318
1334
  "entries": [
1319
1335
  {
1320
1336
  "kind": "added",
1321
- "text": "**A node of a blend tree may be sampled on a clock of its own.** `BlendTree.evaluate` walked every node with one `timeSec`, so a set whose members must be sampled on *different* clocks could not be written as one tree — and that is the canonical locomotion blend space rather than an exotic case. A stride has to advance with **distance travelled** or the foot slides while the body passes over it, which is the fact `rootMotion` exists for; an idle has to advance with **time**, because somebody standing still is still breathing. On one clock one of the two is wrong: share the distance and the idle freezes whenever nobody moves, share the time and the walk skates. A `clip` node now names the parameter its own time comes from, and a node that names none takes the time `evaluate` was given, so every tree written before this is unchanged. The value is a time on the clip's own axis in seconds, and what advances it is the caller's business — a stride in metres is divided by the metres a cycle covers and multiplied by the cycle's duration, because an engine that took metres here would be deciding what a character is doing. A name is a blend weight or a clock and never both, refused at construction: one number doing both jobs is a rig answering the wrong dial. Reported from outside, where the workaround was two clocks kept by hand and a `blendPoses` per overlay, which gives up the declared graph, the scratch poses claimed at construction, and a state machine's crossfades on top.",
1337
+ "text": "**A node of a blend tree may be sampled on a clock of its own.** `BlendTree.evaluate` walked every node with one `timeSec`, so a set whose members must be sampled on *different* clocks could not be written as one tree \u2014 and that is the canonical locomotion blend space rather than an exotic case. A stride has to advance with **distance travelled** or the foot slides while the body passes over it, which is the fact `rootMotion` exists for; an idle has to advance with **time**, because somebody standing still is still breathing. On one clock one of the two is wrong: share the distance and the idle freezes whenever nobody moves, share the time and the walk skates. A `clip` node now names the parameter its own time comes from, and a node that names none takes the time `evaluate` was given, so every tree written before this is unchanged. The value is a time on the clip's own axis in seconds, and what advances it is the caller's business \u2014 a stride in metres is divided by the metres a cycle covers and multiplied by the cycle's duration, because an engine that took metres here would be deciding what a character is doing. A name is a blend weight or a clock and never both, refused at construction: one number doing both jobs is a rig answering the wrong dial. Reported from outside, where the workaround was two clocks kept by hand and a `blendPoses` per overlay, which gives up the declared graph, the scratch poses claimed at construction, and a state machine's crossfades on top.",
1322
1338
  "short": "A `clip` node in a blend tree can name its own clock, so one set holds a time-driven idle and a distance-driven gait."
1323
1339
  },
1324
1340
  {
1325
1341
  "kind": "changed",
1326
- "text": "**`drift/animation` drives a blend tree and no longer offers to build one.** `blendTree` took a `Skeleton` and handed it to the engine constructor's *root node* parameter behind an `as never`; called, it threw `Cannot read properties of undefined (reading 'length')`, because a tree asked a skeleton for its children. Nothing had ever run it — the two tests over the capability asserted that the compiler required its third argument, which it did — so a capability nobody could use had been described, typed and documented. Building a graph needs a node value the language has no shape for, so the broken capability is gone rather than repaired in place: a compile-time refusal is earlier than a runtime one. `blendSet` and `blendAt` replace it and are the whole script-side surface of a tree: a host builds one in TypeScript, where the skeleton and the clips already are, and hands it to a system through `uses`, which is the seam DriftScript 1.8.1 opened. `blendSet` is also how a script reaches a node's clock.",
1342
+ "text": "**`drift/animation` drives a blend tree and no longer offers to build one.** `blendTree` took a `Skeleton` and handed it to the engine constructor's *root node* parameter behind an `as never`; called, it threw `Cannot read properties of undefined (reading 'length')`, because a tree asked a skeleton for its children. Nothing had ever run it \u2014 the two tests over the capability asserted that the compiler required its third argument, which it did \u2014 so a capability nobody could use had been described, typed and documented. Building a graph needs a node value the language has no shape for, so the broken capability is gone rather than repaired in place: a compile-time refusal is earlier than a runtime one. `blendSet` and `blendAt` replace it and are the whole script-side surface of a tree: a host builds one in TypeScript, where the skeleton and the clips already are, and hands it to a system through `uses`, which is the seam DriftScript 1.8.1 opened. `blendSet` is also how a script reaches a node's clock.",
1327
1343
  "short": "`drift/animation` gains `blendSet` and `blendAt` for a tree a host hands it, and drops a `blendTree` that could not work."
1328
1344
  }
1329
1345
  ]
@@ -1334,27 +1350,27 @@
1334
1350
  "entries": [
1335
1351
  {
1336
1352
  "kind": "changed",
1337
- "text": "**A body of water is bounded by a rotated rectangle, not a square.** `WaterBody.bounds` was a centre and one half-extent, so the only shape a bounded body could take was a square — and the square that contains a 3 by 21 metre channel floods eighteen metres of ground either side of it. `halfX` and `halfZ` are the body's own axes now, and `forwardX` and `forwardZ` say which way those axes point: a direction and not an angle, matching `MeshBuilder.addOrientedBox`, because a consumer bounding a ditch, a gutter or a canal arrives already holding the direction its run takes. It is normalised on the way in, and a direction of no length is read as unturned instead of putting the sheet at NaN. `halfM` stays as the shorthand for a square and a per-axis extent overrides it on that axis alone, resolved in one place so two spellings cannot disagree. The extents and both rim fades are measured per axis through `resolveWater` and both backends, so a channel reaches its own bank instead of fading along its whole length; the unbounded ocean passes one number twice and is unchanged. What this cannot do is bend, so a canal that turns is one body per straight run, and the corner is where two sheets overlap. Reported from outside, where the workaround was a single unbounded body at the ditches' level with the world's whole ground raised above it.",
1353
+ "text": "**A body of water is bounded by a rotated rectangle, not a square.** `WaterBody.bounds` was a centre and one half-extent, so the only shape a bounded body could take was a square \u2014 and the square that contains a 3 by 21 metre channel floods eighteen metres of ground either side of it. `halfX` and `halfZ` are the body's own axes now, and `forwardX` and `forwardZ` say which way those axes point: a direction and not an angle, matching `MeshBuilder.addOrientedBox`, because a consumer bounding a ditch, a gutter or a canal arrives already holding the direction its run takes. It is normalised on the way in, and a direction of no length is read as unturned instead of putting the sheet at NaN. `halfM` stays as the shorthand for a square and a per-axis extent overrides it on that axis alone, resolved in one place so two spellings cannot disagree. The extents and both rim fades are measured per axis through `resolveWater` and both backends, so a channel reaches its own bank instead of fading along its whole length; the unbounded ocean passes one number twice and is unchanged. What this cannot do is bend, so a canal that turns is one body per straight run, and the corner is where two sheets overlap. Reported from outside, where the workaround was a single unbounded body at the ditches' level with the world's whole ground raised above it.",
1338
1354
  "short": "`WaterBody.bounds` takes per-axis extents and a direction, so a channel is water and the fields beside it are not."
1339
1355
  },
1340
1356
  {
1341
1357
  "kind": "fixed",
1342
- "text": "**A frame may draw more than one body of water on WebGPU.** Every body's uniforms lived in one buffer rewritten before each draw, and a `queue.write*` call does not interleave with the draws it sits between: writes are ordered on the queue timeline and the frame's encoder is submitted after all of them. So a ditch at its own level, a basin in a courtyard and the sea behind them all drew with whichever body was written last — its extents, its level, its colours, its direction. Measured on the new instrument at **387,837 pixels of a 921,600-pixel frame**: three of four bodies gone, and the sea with them. Each body takes a slot of a `UniformRing` now and binds it by dynamic offset, uploaded once a frame. WebGL2 never had this, because it sets uniforms and draws in one stream. Invisible for as long as nothing drew two, which is how the joint palette's copy of the same defect survived eight releases; `demo/dev/water.html` is the page that draws four.",
1358
+ "text": "**A frame may draw more than one body of water on WebGPU.** Every body's uniforms lived in one buffer rewritten before each draw, and a `queue.write*` call does not interleave with the draws it sits between: writes are ordered on the queue timeline and the frame's encoder is submitted after all of them. So a ditch at its own level, a basin in a courtyard and the sea behind them all drew with whichever body was written last \u2014 its extents, its level, its colours, its direction. Measured on the new instrument at **387,837 pixels of a 921,600-pixel frame**: three of four bodies gone, and the sea with them. Each body takes a slot of a `UniformRing` now and binds it by dynamic offset, uploaded once a frame. WebGL2 never had this, because it sets uniforms and draws in one stream. Invisible for as long as nothing drew two, which is how the joint palette's copy of the same defect survived eight releases; `demo/dev/water.html` is the page that draws four.",
1343
1359
  "short": "Several bodies of water in one frame no longer all draw with the last body's extents, level and colours on WebGPU."
1344
1360
  },
1345
1361
  {
1346
1362
  "kind": "changed",
1347
- "text": "**A rain field's fall speed and shutter time move while it is running.** `RainFieldOptions.speedMps` and `streakSec` were read once into private readonly fields, so a weather state that changes under a player could not change the rain. `RainField.speedMps` and `RainField.streakSec` are plain settable fields: nothing is sized by either and nothing caches a value derived from one, so a write between updates costs an assignment. Hail is the case that shows it — about 14 m/s against rain's 9, which is 0.233 m of fall a tick instead of 0.15 and a 0.7 m streak instead of 0.45, because the streak is the distance a drop covers while the shutter is open. Reported from outside by a consumer whose hailstorm had to be expressed as a wider, denser rain and read as heavy rain. `demo/dev/lines.html?rainspeed=` writes the value every frame, which is what asserts it is read live.",
1363
+ "text": "**A rain field's fall speed and shutter time move while it is running.** `RainFieldOptions.speedMps` and `streakSec` were read once into private readonly fields, so a weather state that changes under a player could not change the rain. `RainField.speedMps` and `RainField.streakSec` are plain settable fields: nothing is sized by either and nothing caches a value derived from one, so a write between updates costs an assignment. Hail is the case that shows it \u2014 about 14 m/s against rain's 9, which is 0.233 m of fall a tick instead of 0.15 and a 0.7 m streak instead of 0.45, because the streak is the distance a drop covers while the shutter is open. Reported from outside by a consumer whose hailstorm had to be expressed as a wider, denser rain and read as heavy rain. `demo/dev/lines.html?rainspeed=` writes the value every frame, which is what asserts it is read live.",
1348
1364
  "short": "`RainField.speedMps` and `streakSec` are settable, so drizzle, rain and hail differ in how fast they fall."
1349
1365
  },
1350
1366
  {
1351
1367
  "kind": "fixed",
1352
- "text": "**A ragdoll's root accessors answer the bone head on all three axes.** `rootX`, `rootY` and `rootZ` are documented as the first bone's parent end, so a caller can move a character's node to where the physics put it, and they returned the body's *centre* — half a bone away, which for a 0.92 m root bone is 0.46 m of a character standing in the air. Two of them read `bodies.posY` and `bodies.posZ` plainly; the third reached the same centre through a helper named `boneHeadX`, so the name answered the question and the arithmetic never did. A capsule is built about its bone's middle and turned to put its own +y along the bone, so the head is the centre half a bone back along that axis, and the first bone's half length is taken in the build loop from the length the body was placed from. Reported from outside, where the symptom was a body drawn half a metre above the physics holding it — which reads exactly like a pose that never arrived, and cost the reporter two weeks of looking for one.",
1368
+ "text": "**A ragdoll's root accessors answer the bone head on all three axes.** `rootX`, `rootY` and `rootZ` are documented as the first bone's parent end, so a caller can move a character's node to where the physics put it, and they returned the body's *centre* \u2014 half a bone away, which for a 0.92 m root bone is 0.46 m of a character standing in the air. Two of them read `bodies.posY` and `bodies.posZ` plainly; the third reached the same centre through a helper named `boneHeadX`, so the name answered the question and the arithmetic never did. A capsule is built about its bone's middle and turned to put its own +y along the bone, so the head is the centre half a bone back along that axis, and the first bone's half length is taken in the build loop from the length the body was placed from. Reported from outside, where the symptom was a body drawn half a metre above the physics holding it \u2014 which reads exactly like a pose that never arrived, and cost the reporter two weeks of looking for one.",
1353
1369
  "short": "`Ragdoll.rootY` and `rootZ` return the bone head like `rootX` claims to, instead of the body's centre."
1354
1370
  },
1355
1371
  {
1356
1372
  "kind": "added",
1357
- "text": "**`demo/dev/water.html`, the instrument for a body of water.** Four bodies in one frame — the unbounded sea, a square basin, a channel three metres across and forty-two long, and the same channel laid along a direction forty degrees off the axis. It exists because every shape `bounds` can take was untestable from inside: the two published scenes that draw a bounded body draw a square each, and nothing anywhere drew two bodies at once. It reads no clock, so two runs photograph the same pixels, and the two backends differ on it by 56 pixels.",
1373
+ "text": "**`demo/dev/water.html`, the instrument for a body of water.** Four bodies in one frame \u2014 the unbounded sea, a square basin, a channel three metres across and forty-two long, and the same channel laid along a direction forty degrees off the axis. It exists because every shape `bounds` can take was untestable from inside: the two published scenes that draw a bounded body draw a square each, and nothing anywhere drew two bodies at once. It reads no clock, so two runs photograph the same pixels, and the two backends differ on it by 56 pixels.",
1358
1374
  "short": "`demo/dev/water.html` draws four bodies of water in one frame, one of them turned, on either backend."
1359
1375
  }
1360
1376
  ]
@@ -1390,12 +1406,12 @@
1390
1406
  },
1391
1407
  {
1392
1408
  "kind": "added",
1393
- "text": "**Debug draw, as the generator the line batch was missing.** `drawLines` has been on both backends since the polyline batch landed, with a world-space width and a pixel floor. What was missing was anything that turns what you want to see into segments, so a consumer who wanted a normals key wrote the geometry themselves or, far more often, did not. `DebugLines` fills a `LineSegments` with a mesh's vertex normals and with every collider a `PhysicsWorld` holds, dispatched on what a shape *is*: a sphere is three great circles, a capsule a ball at each end, a cylinder two flat caps and a sharp rim, a polytope its face loops with each edge kept once, a concave mesh its triangle edges. It allocates nothing after construction and **never throws** — a segment past capacity is counted in `dropped`, because a drawer that threw would take the frame down at the moment somebody was trying to see what was wrong. Its absence was measured outside: one consumer wrote geometry with downward normals four separate times, including every building in their world lit inside out, and found each by wondering why tarmac was black at midday.",
1409
+ "text": "**Debug draw, as the generator the line batch was missing.** `drawLines` has been on both backends since the polyline batch landed, with a world-space width and a pixel floor. What was missing was anything that turns what you want to see into segments, so a consumer who wanted a normals key wrote the geometry themselves or, far more often, did not. `DebugLines` fills a `LineSegments` with a mesh's vertex normals and with every collider a `PhysicsWorld` holds, dispatched on what a shape *is*: a sphere is three great circles, a capsule a ball at each end, a cylinder two flat caps and a sharp rim, a polytope its face loops with each edge kept once, a concave mesh its triangle edges. It allocates nothing after construction and **never throws** \u2014 a segment past capacity is counted in `dropped`, because a drawer that threw would take the frame down at the moment somebody was trying to see what was wrong. Its absence was measured outside: one consumer wrote geometry with downward normals four separate times, including every building in their world lit inside out, and found each by wondering why tarmac was black at midday.",
1394
1410
  "short": "`DebugLines` draws a mesh's normals and a world's colliders through the existing line batch."
1395
1411
  },
1396
1412
  {
1397
1413
  "kind": "added",
1398
- "text": "**Quads that face where you meant, and a box that lies along a line.** `addQuad` takes its normal from `(b − a) × (d − a)`, which is not the order a person walks around a rectangle, so the obvious winding for a horizontal quad lights it from below. That is documented, and documentation is not the fix: it cost five scenes in one application and then four bugs in one afternoon in another, one of which was every building in a village lit inside out. `addGroundQuad` takes the corners in any order and faces up; `addWallQuad` faces away from a point you name, which is a thing a caller knows without looking anything up. Both reverse corners instead of negating a normal, so the triangle winding still agrees with the face. `addOrientedBox` is the other half: `addBox` is world-axis only, so a hedge along a property line came out as a row of slabs each facing a different way. Its six faces go through `addWallQuad` against its own centre, which makes the outward normals one argument instead of six windings.",
1414
+ "text": "**Quads that face where you meant, and a box that lies along a line.** `addQuad` takes its normal from `(b \u2212 a) \u00d7 (d \u2212 a)`, which is not the order a person walks around a rectangle, so the obvious winding for a horizontal quad lights it from below. That is documented, and documentation is not the fix: it cost five scenes in one application and then four bugs in one afternoon in another, one of which was every building in a village lit inside out. `addGroundQuad` takes the corners in any order and faces up; `addWallQuad` faces away from a point you name, which is a thing a caller knows without looking anything up. Both reverse corners instead of negating a normal, so the triangle winding still agrees with the face. `addOrientedBox` is the other half: `addBox` is world-axis only, so a hedge along a property line came out as a row of slabs each facing a different way. Its six faces go through `addWallQuad` against its own centre, which makes the outward normals one argument instead of six windings.",
1399
1415
  "short": "`addGroundQuad`, `addWallQuad` and `addOrientedBox` take the trap out of `addQuad`."
1400
1416
  },
1401
1417
  {
@@ -1415,7 +1431,7 @@
1415
1431
  },
1416
1432
  {
1417
1433
  "kind": "added",
1418
- "text": "**`ActionMap.axis(action, 'x' | 'y')`, and `rawAxisX`/`rawAxisY` in `drift/input`.** `vector` treats an action's two axes as one direction and shortens a keyboard diagonal to the rim, which is right on foot and wrong at a wheel: throttle and steering are two controls, and dividing both by √2 means a driver holding forward and left can never reach full lock while accelerating. **A consumer lost two weeks to it.** The report was that the car did not turn and was slow, the whole vehicle model was rewritten looking for it, and the cause was `Math.hypot` three call frames away doing exactly what it was written to do; they shipped four duplicate digital actions bound to the same keys to get around it. `axisX` and `axisY` are unchanged, because correcting them in place would make every scripted character quietly faster on a diagonal.",
1434
+ "text": "**`ActionMap.axis(action, 'x' | 'y')`, and `rawAxisX`/`rawAxisY` in `drift/input`.** `vector` treats an action's two axes as one direction and shortens a keyboard diagonal to the rim, which is right on foot and wrong at a wheel: throttle and steering are two controls, and dividing both by \u221a2 means a driver holding forward and left can never reach full lock while accelerating. **A consumer lost two weeks to it.** The report was that the car did not turn and was slow, the whole vehicle model was rewritten looking for it, and the cause was `Math.hypot` three call frames away doing exactly what it was written to do; they shipped four duplicate digital actions bound to the same keys to get around it. `axisX` and `axisY` are unchanged, because correcting them in place would make every scripted character quietly faster on a diagonal.",
1419
1435
  "short": "`ActionMap.axis` reads one axis without normalising it against the other."
1420
1436
  },
1421
1437
  {
@@ -1431,27 +1447,27 @@
1431
1447
  "entries": [
1432
1448
  {
1433
1449
  "kind": "fixed",
1434
- "text": "**A rolling body's contact anchor turned with it, which sank a rolling sphere 28 millimetres into the floor and braked it — and every physics result moves now that it does not.** The solver stores each contact anchor in its body's own frame and turns it forward through the substeps, which is exact for a face or a corner and wrong for anything round: the point where a rolling ball meets the floor stays at the bottom however far the ball has spun, and an anchor that turns anyway climbs its side and reports a gap that is not there. The solver then let that gap close, so the body sank until the penetration made up the difference. Measured before the fix: a sphere of radius 1 rolling at 6 rad/s sat 28 mm into the ground and stayed there, and in the solver's own baseline scene a sphere of radius 0.4 rested at 0.3916. The drift also acted as a rolling resistance nobody had asked for — the same ball now leaves a floor it used to stop on. A contact on a curved surface is held in world frame instead, where a constant offset from the centre is the right answer for the whole tick; `Manifold.curvedA` and `curvedB` carry the decision, and a cylinder sets them per feature because its cap is flat and its side is not. The sphere rests at exactly 0.4000. **What this costs a consumer**: any stored fingerprint from `fingerprintBodies`, and any replay compared against one, will diverge and must be re-taken. Nothing in the public surface changed shape, so code compiles and runs unchanged. It was found while building the cylinder primitive and was never about cylinders.",
1450
+ "text": "**A rolling body's contact anchor turned with it, which sank a rolling sphere 28 millimetres into the floor and braked it \u2014 and every physics result moves now that it does not.** The solver stores each contact anchor in its body's own frame and turns it forward through the substeps, which is exact for a face or a corner and wrong for anything round: the point where a rolling ball meets the floor stays at the bottom however far the ball has spun, and an anchor that turns anyway climbs its side and reports a gap that is not there. The solver then let that gap close, so the body sank until the penetration made up the difference. Measured before the fix: a sphere of radius 1 rolling at 6 rad/s sat 28 mm into the ground and stayed there, and in the solver's own baseline scene a sphere of radius 0.4 rested at 0.3916. The drift also acted as a rolling resistance nobody had asked for \u2014 the same ball now leaves a floor it used to stop on. A contact on a curved surface is held in world frame instead, where a constant offset from the centre is the right answer for the whole tick; `Manifold.curvedA` and `curvedB` carry the decision, and a cylinder sets them per feature because its cap is flat and its side is not. The sphere rests at exactly 0.4000. **What this costs a consumer**: any stored fingerprint from `fingerprintBodies`, and any replay compared against one, will diverge and must be re-taken. Nothing in the public surface changed shape, so code compiles and runs unchanged. It was found while building the cylinder primitive and was never about cylinders.",
1435
1451
  "short": "A rolling body's contact anchor turned with it and sank a sphere 28 mm into the floor. Stored physics fingerprints and replays must be re-taken."
1436
1452
  },
1437
1453
  {
1438
1454
  "kind": "added",
1439
- "text": "**A cylinder primitive, `cylinderShape(radius, halfHeight)`, and it is a second kind of rounding rather than a second kind of shape.** `ConvexShape.radius` grows a point cloud by a ball, which is what makes a sphere and a capsule and is exactly why it could never express a cylinder: a uniform radius rounds the rim along with the side, and the rim is what a wheel rolls on. `sideRadius` grows the cloud by a **disc** perpendicular to the segment through its first two points instead — two flat caps, a curved side, a sharp rim between them. The workaround it replaces had a floor this repository had already measured: an n-gon prism through `hullShape`, capped at thirty-two sides because a hull takes 64 points, where a thirty-two-sided wheel of radius 1 rolling at 6 m/s bobs 16 mm that no side count can reduce. The same wheel as a cylinder bobs **under 2 mm**, inside the solver's own five-millimetre slop, and `prismRoll.test.ts` measures both side by side. A curved side enumerates no separating axis, so contacts build their axis list against the *other* shape — its faces, its edges crossed with the cylinder's axis, and two directions per vertex — and answer every one exactly from the cylinder's support; the touching feature is then a cap disc, a side line or a rim point, each clipped differently. Mass properties are the two textbook moments in closed form, bounds grow by the disc's extent down each axis rather than by the radius on all three, a ray is a quadratic and two discs rather than the march a capsule needs, and cloth pushes a particle out through the same four-region closest point.",
1455
+ "text": "**A cylinder primitive, `cylinderShape(radius, halfHeight)`, and it is a second kind of rounding rather than a second kind of shape.** `ConvexShape.radius` grows a point cloud by a ball, which is what makes a sphere and a capsule and is exactly why it could never express a cylinder: a uniform radius rounds the rim along with the side, and the rim is what a wheel rolls on. `sideRadius` grows the cloud by a **disc** perpendicular to the segment through its first two points instead \u2014 two flat caps, a curved side, a sharp rim between them. The workaround it replaces had a floor this repository had already measured: an n-gon prism through `hullShape`, capped at thirty-two sides because a hull takes 64 points, where a thirty-two-sided wheel of radius 1 rolling at 6 m/s bobs 16 mm that no side count can reduce. The same wheel as a cylinder bobs **under 2 mm**, inside the solver's own five-millimetre slop, and `prismRoll.test.ts` measures both side by side. A curved side enumerates no separating axis, so contacts build their axis list against the *other* shape \u2014 its faces, its edges crossed with the cylinder's axis, and two directions per vertex \u2014 and answer every one exactly from the cylinder's support; the touching feature is then a cap disc, a side line or a rim point, each clipped differently. Mass properties are the two textbook moments in closed form, bounds grow by the disc's extent down each axis rather than by the radius on all three, a ray is a quadratic and two discs rather than the march a capsule needs, and cloth pushes a particle out through the same four-region closest point.",
1440
1456
  "short": "`cylinderShape` ships. A wheel that bobbed 16 mm as a thirty-two-sided prism bobs under 2 mm as a cylinder."
1441
1457
  },
1442
1458
  {
1443
1459
  "kind": "added",
1444
- "text": "**Static concave mesh colliders, `meshShape(positions, indices)`.** A level is a mesh and everything in it is a hull. The shape carries the triangles, their planes, an edge classification and a `DynamicTree` over them — the tree this package already had, built once and never moved, so no second acceleration structure had to be written or tested. **Static only, and the limit is enforced rather than documented**: `addBody` refuses a mesh on anything but a static body and `colliderFromShape` refuses one outright, because a moving concave mesh needs its tree refitted every tick and a mass tensor a triangle soup does not have, and because a mesh handed to the kinematic sweep would arrive as its own bounding box and turn a hollow level into a solid brick. A moving concave body wants convex decomposition into hulls, which the runtime already accepts. Every triangle goes through the same narrow phase everything else does, as a three-point polytope written into one scratch shape — so a box, a sphere, a capsule, a cylinder and a hull are all exact against a mesh on the day it lands and none of them was written twice. A mesh pair produces **several** contact planes rather than one, because a body in the corner of a mesh room has two or three and reducing them to one picks a wall and lets the body through the others. Contacts are one-sided, because a level mesh is a surface and a two-sided triangle fires anything that has sunk below it back out the wrong way; rays are two-sided, because a sight line that saw through a wall from behind is a worse answer than one that did not. **The interior-edge filter is what decides whether a mesh floor feels like a floor**: without it a box sliding across two coplanar triangles catches on their seam, which is a character stumbling on a flat floor once a metre. Measured — a box slid at 6 m/s across sixteen quads keeps **80% of its speed with the filter and 1% without it**.",
1460
+ "text": "**Static concave mesh colliders, `meshShape(positions, indices)`.** A level is a mesh and everything in it is a hull. The shape carries the triangles, their planes, an edge classification and a `DynamicTree` over them \u2014 the tree this package already had, built once and never moved, so no second acceleration structure had to be written or tested. **Static only, and the limit is enforced rather than documented**: `addBody` refuses a mesh on anything but a static body and `colliderFromShape` refuses one outright, because a moving concave mesh needs its tree refitted every tick and a mass tensor a triangle soup does not have, and because a mesh handed to the kinematic sweep would arrive as its own bounding box and turn a hollow level into a solid brick. A moving concave body wants convex decomposition into hulls, which the runtime already accepts. Every triangle goes through the same narrow phase everything else does, as a three-point polytope written into one scratch shape \u2014 so a box, a sphere, a capsule, a cylinder and a hull are all exact against a mesh on the day it lands and none of them was written twice. A mesh pair produces **several** contact planes rather than one, because a body in the corner of a mesh room has two or three and reducing them to one picks a wall and lets the body through the others. Contacts are one-sided, because a level mesh is a surface and a two-sided triangle fires anything that has sunk below it back out the wrong way; rays are two-sided, because a sight line that saw through a wall from behind is a worse answer than one that did not. **The interior-edge filter is what decides whether a mesh floor feels like a floor**: without it a box sliding across two coplanar triangles catches on their seam, which is a character stumbling on a flat floor once a metre. Measured \u2014 a box slid at 6 m/s across sixteen quads keeps **80% of its speed with the filter and 1% without it**.",
1445
1461
  "short": "`meshShape` ships: a static triangle mesh with an interior-edge filter, worth 80% of a sliding box's speed against 1%."
1446
1462
  },
1447
1463
  {
1448
1464
  "kind": "added",
1449
- "text": "**Cloth pushes what it lands on, and stops passing through itself.** Both are off by default, so no existing world moves until it asks. `coupling` is a scale from 0 to 1: at anything above zero, a particle pushed out of a body hands the body back the momentum it lost, at the contact point, so a sheet landing on one end of a plank tips it — which an impulse through the centre of mass could not. It is an **impulse exchange rather than a merged island**, and the refusal it replaces was right about the price and wrong about the only way to pay it: putting particles into the rigid solver's constraint graph would merge islands, and merged islands are what the parallel executor is made of, so that stays refused. The exchange is explicit, so the body answers on the next tick and a very heavy sheet on a very light body rings; turning `coupling` down is the answer. `particleMass` is in kilograms and is deliberately separate from `invMass`, which is a relative weight the compliance is tuned against. `selfDistance` is in metres and turns on self-collision: a spatial hash rebuilt each tick with one bucket a particle, a 3×3×3 cell scan, and a symmetric push apart split by inverse mass. **Particles a link joins are exempt**, which is not an optimisation — a stretch link's rest length *is* the spacing, so a self-collision distance anywhere near it would have every neighbour fighting its own link and the sheet would inflate rather than drape. Measured on a sheet squeezed until it folds: with it off the closest unlinked pair comes to 58 mm inside a sheet whose own spacing is 100, and with it on the closest pair is at or above what was asked for at 60, 80 and 90.",
1465
+ "text": "**Cloth pushes what it lands on, and stops passing through itself.** Both are off by default, so no existing world moves until it asks. `coupling` is a scale from 0 to 1: at anything above zero, a particle pushed out of a body hands the body back the momentum it lost, at the contact point, so a sheet landing on one end of a plank tips it \u2014 which an impulse through the centre of mass could not. It is an **impulse exchange rather than a merged island**, and the refusal it replaces was right about the price and wrong about the only way to pay it: putting particles into the rigid solver's constraint graph would merge islands, and merged islands are what the parallel executor is made of, so that stays refused. The exchange is explicit, so the body answers on the next tick and a very heavy sheet on a very light body rings; turning `coupling` down is the answer. `particleMass` is in kilograms and is deliberately separate from `invMass`, which is a relative weight the compliance is tuned against. `selfDistance` is in metres and turns on self-collision: a spatial hash rebuilt each tick with one bucket a particle, a 3\u00d73\u00d73 cell scan, and a symmetric push apart split by inverse mass. **Particles a link joins are exempt**, which is not an optimisation \u2014 a stretch link's rest length *is* the spacing, so a self-collision distance anywhere near it would have every neighbour fighting its own link and the sheet would inflate rather than drape. Measured on a sheet squeezed until it folds: with it off the closest unlinked pair comes to 58 mm inside a sheet whose own spacing is 100, and with it on the closest pair is at or above what was asked for at 60, 80 and 90.",
1450
1466
  "short": "Cloth gains two-way coupling and self-collision, both off by default."
1451
1467
  },
1452
1468
  {
1453
1469
  "kind": "added",
1454
- "text": "**Depth of field.** `depthOfField` is the construction-time ceiling — how far a defocused point may spread, as a fraction of the frame's height — and `setDepthOfField(distance, range, scale)` is the per-frame dial: where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling it takes. Both distances are in **metres**, because a camera focuses on a subject rather than on a value of `gl_FragCoord.z`. A circle of confusion from the depth buffer and an eight-tap disc, spliced into the composite rather than given a pass of its own: that pass already holds the resolved scene, its depth and a place in the chain where the picture is finished but not graded, so a second fullscreen pass would be a second read and a second write of the frame to add eight taps to the pixels that are out of focus. Off costs one comparison on a uniform. The ramp is linear rather than physical — sharp inside `range` of the plane, full blur one more `range` beyond it — because a real circle of confusion needs an aperture, a focal length and a sensor size, three numbers a game camera does not have and would have to invent. A tap contributes fully when it is behind the pixel it is blurring into and only as far as it is itself defocused when it is in front, which removes the halo a sharp subject wears against a blurred background; **spreading a blurred foreground over a sharp background is something a gather cannot do**, and that is said rather than approximated. **The pair matters**: `depthOfField` alone leaves the focus at its default of zero metres, which is behind the camera, so every pixel comes back at full blur.",
1470
+ "text": "**Depth of field.** `depthOfField` is the construction-time ceiling \u2014 how far a defocused point may spread, as a fraction of the frame's height \u2014 and `setDepthOfField(distance, range, scale)` is the per-frame dial: where this frame's lens is focused, how deep the sharp zone is, and how much of the ceiling it takes. Both distances are in **metres**, because a camera focuses on a subject rather than on a value of `gl_FragCoord.z`. A circle of confusion from the depth buffer and an eight-tap disc, spliced into the composite rather than given a pass of its own: that pass already holds the resolved scene, its depth and a place in the chain where the picture is finished but not graded, so a second fullscreen pass would be a second read and a second write of the frame to add eight taps to the pixels that are out of focus. Off costs one comparison on a uniform. The ramp is linear rather than physical \u2014 sharp inside `range` of the plane, full blur one more `range` beyond it \u2014 because a real circle of confusion needs an aperture, a focal length and a sensor size, three numbers a game camera does not have and would have to invent. A tap contributes fully when it is behind the pixel it is blurring into and only as far as it is itself defocused when it is in front, which removes the halo a sharp subject wears against a blurred background; **spreading a blurred foreground over a sharp background is something a gather cannot do**, and that is said rather than approximated. **The pair matters**: `depthOfField` alone leaves the focus at its default of zero metres, which is behind the camera, so every pixel comes back at full blur.",
1455
1471
  "short": "Depth of field: a construction-time ceiling and a per-frame focus distance, both in metres. Set the plane where you set the camera."
1456
1472
  },
1457
1473
  {
@@ -1461,27 +1477,27 @@
1461
1477
  },
1462
1478
  {
1463
1479
  "kind": "added",
1464
- "text": "**A contributed pass may bring its own attachments.** `PassDefinition.prepare` is a sibling of `draw` rather than a widening of it: `draw` is handed an open `GPURenderPassEncoder` and a render pass cannot be nested, so the WebGPU arm of `PrepareContext` carries a `GPUCommandEncoder` instead. It runs at one fixed point in `beginFrame` — after the frame's encoder exists and before anything opens the frame's render pass — for every pass that declares it, in registration order. That is an order and not a dependency graph: gate 1.2's withdrawal of dependency ordering stands, and a pass that needs another's output registers after it. On WebGL2 the contract is a **restore** rather than an ordering: `prepare` is called with the default framebuffer bound and must leave it bound, which is the promise `draw` already makes about program, vertex array, blend, depth and viewport, one step stronger because a framebuffer left bound takes the whole frame rather than one draw. `createPassAttachment` builds the target from the `PassDevice` the pass was handed at `init` — colour and optional depth, `rgba8` or `rgba16float`, released by the pass's own `dispose` — and it is single-sampled on purpose, because a multisampled one needs a resolve target beside it and a decision nobody has asked for. It costs nothing for a pass that does not declare it: both renderers count the passes that do and skip the step at zero. What a pass writes into the *frame* stays refused, for the reason `reads` already gives.",
1480
+ "text": "**A contributed pass may bring its own attachments.** `PassDefinition.prepare` is a sibling of `draw` rather than a widening of it: `draw` is handed an open `GPURenderPassEncoder` and a render pass cannot be nested, so the WebGPU arm of `PrepareContext` carries a `GPUCommandEncoder` instead. It runs at one fixed point in `beginFrame` \u2014 after the frame's encoder exists and before anything opens the frame's render pass \u2014 for every pass that declares it, in registration order. That is an order and not a dependency graph: gate 1.2's withdrawal of dependency ordering stands, and a pass that needs another's output registers after it. On WebGL2 the contract is a **restore** rather than an ordering: `prepare` is called with the default framebuffer bound and must leave it bound, which is the promise `draw` already makes about program, vertex array, blend, depth and viewport, one step stronger because a framebuffer left bound takes the whole frame rather than one draw. `createPassAttachment` builds the target from the `PassDevice` the pass was handed at `init` \u2014 colour and optional depth, `rgba8` or `rgba16float`, released by the pass's own `dispose` \u2014 and it is single-sampled on purpose, because a multisampled one needs a resolve target beside it and a decision nobody has asked for. It costs nothing for a pass that does not declare it: both renderers count the passes that do and skip the step at zero. What a pass writes into the *frame* stays refused, for the reason `reads` already gives.",
1465
1481
  "short": "A contributed pass can own a target: fill it in `prepare` before the frame's pass opens, sample it in `draw`."
1466
1482
  },
1467
1483
  {
1468
1484
  "kind": "added",
1469
- "text": "**View-dependent colour for Gaussian splats, at degree 1.** `readSplatPly` keeps the l=1 spherical-harmonic band — transposed out of the file's channel-major order, which is the reordering a reimplementation gets wrong and which produces a capture whose sheen is real, wrong and in the wrong channel — and `packSplats` writes it as one extra `RGBA32UI` texel a splat: nine 8-bit coefficients and a per-splat scale in the fourth word. Bytes rather than halves because nine halves is eighteen bytes and would not fit a texel; a per-splat scale rather than a capture-wide one because it costs the sixteenth byte and gives every splat the whole range for its own coefficients. The direction is taken in the **capture's own space**, because the coefficients were trained in that frame and a world-space direction leaves the sheen behind when the capture turns. **Degrees 2 and 3 are declined with the arithmetic on record**: a splat's record is read six times a frame, so at the 400,000-splat mobile budget degree 1 is 115 MB a frame against 77, degree 2 is 192 and degree 3 is 307 — on a device already carrying 389 MB a frame of attachment traffic. Opt-in by construction: a capture without harmonics is eight words a splat and two texels exactly as before, and the shader's gate is a uniform, so it performs no third fetch. The record width travels through the `SPLT` block's own header, which the container format had already designed for this.",
1485
+ "text": "**View-dependent colour for Gaussian splats, at degree 1.** `readSplatPly` keeps the l=1 spherical-harmonic band \u2014 transposed out of the file's channel-major order, which is the reordering a reimplementation gets wrong and which produces a capture whose sheen is real, wrong and in the wrong channel \u2014 and `packSplats` writes it as one extra `RGBA32UI` texel a splat: nine 8-bit coefficients and a per-splat scale in the fourth word. Bytes rather than halves because nine halves is eighteen bytes and would not fit a texel; a per-splat scale rather than a capture-wide one because it costs the sixteenth byte and gives every splat the whole range for its own coefficients. The direction is taken in the **capture's own space**, because the coefficients were trained in that frame and a world-space direction leaves the sheen behind when the capture turns. **Degrees 2 and 3 are declined with the arithmetic on record**: a splat's record is read six times a frame, so at the 400,000-splat mobile budget degree 1 is 115 MB a frame against 77, degree 2 is 192 and degree 3 is 307 \u2014 on a device already carrying 389 MB a frame of attachment traffic. Opt-in by construction: a capture without harmonics is eight words a splat and two texels exactly as before, and the shader's gate is a uniform, so it performs no third fetch. The record width travels through the `SPLT` block's own header, which the container format had already designed for this.",
1470
1486
  "short": "Gaussian splats gain view-dependent colour at degree 1, one extra texel a splat. A capture without harmonics is unchanged."
1471
1487
  },
1472
1488
  {
1473
1489
  "kind": "added",
1474
- "text": "**A latitude and a season for the celestial clock.** `celestialStateAt` takes an optional `CelestialSite`, and with one the sun's arc follows the place and the time of year rather than swinging through a fixed arc peaking at 66 degrees wherever the world was meant to be. A declination from the day of the year, an hour angle from the clock, and the two spherical formulas over them and the latitude — so the poles get their midnight sun and their polar night, and `obliquityDeg` is a parameter because a stylised world may want no seasons at all or sharper ones. **The moon then stands where its phase says**, taking the phase as its elongation: a new moon shares the sun's place in the sky and a full one is half a turn from it. The model without a site pinned the moon exactly opposite the sun, which contradicted the phase reported beside it — a crescent that rose at midnight. **A caller that names no site gets the same numbers bit for bit**, asserted against hand-derived literals rather than against a second call: every world authored against this engine is tuned to that arc, and a model that silently moved the sun would move every shadow in every consumer at once. What it still gives up: the clock's hours are taken as local *solar* time, so a shadow measured against an almanac is out by the equation of time and by a place's offset from its time zone's centre; and the moon's own five-degree orbital inclination is ignored, worth up to five degrees of its altitude. Reported by a consumer who had worked the gap out for themselves and written a note beside their workaround.",
1490
+ "text": "**A latitude and a season for the celestial clock.** `celestialStateAt` takes an optional `CelestialSite`, and with one the sun's arc follows the place and the time of year rather than swinging through a fixed arc peaking at 66 degrees wherever the world was meant to be. A declination from the day of the year, an hour angle from the clock, and the two spherical formulas over them and the latitude \u2014 so the poles get their midnight sun and their polar night, and `obliquityDeg` is a parameter because a stylised world may want no seasons at all or sharper ones. **The moon then stands where its phase says**, taking the phase as its elongation: a new moon shares the sun's place in the sky and a full one is half a turn from it. The model without a site pinned the moon exactly opposite the sun, which contradicted the phase reported beside it \u2014 a crescent that rose at midnight. **A caller that names no site gets the same numbers bit for bit**, asserted against hand-derived literals rather than against a second call: every world authored against this engine is tuned to that arc, and a model that silently moved the sun would move every shadow in every consumer at once. What it still gives up: the clock's hours are taken as local *solar* time, so a shadow measured against an almanac is out by the equation of time and by a place's offset from its time zone's centre; and the moon's own five-degree orbital inclination is ignored, worth up to five degrees of its altitude. Reported by a consumer who had worked the gap out for themselves and written a note beside their workaround.",
1475
1491
  "short": "The sky clock takes a latitude and a season. A caller that names no site is unchanged."
1476
1492
  },
1477
1493
  {
1478
1494
  "kind": "changed",
1479
- "text": "**`@driftengine/physics` grows from 31.0 KB gzipped to 39.5**, +27%, and it is three capabilities rather than a creep. The cylinder is 5.5 KB of it and is a *second shape representation* rather than a fourth constructor: a curved side enumerates no separating axis, so its contact module builds an axis list against the other shape and none of it shares an instruction with the polytope path. The mesh collider and the two cloth rows are the remaining 3.2 KB between them, and the mesh's own shape is the reuse the design was built around — one triangle written into a scratch `ConvexShape` and handed to `collideShapes`, so the separating-axis test, the clipping, the round paths and the cylinder's own module are all exact against a mesh without a byte of new narrow phase. What would make it wrong is a consumer importing the package for a character controller and paying for a mesh collider they never build; the answer there is a second entry point rather than a smaller narrow phase.",
1495
+ "text": "**`@driftengine/physics` grows from 31.0 KB gzipped to 39.5**, +27%, and it is three capabilities rather than a creep. The cylinder is 5.5 KB of it and is a *second shape representation* rather than a fourth constructor: a curved side enumerates no separating axis, so its contact module builds an axis list against the other shape and none of it shares an instruction with the polytope path. The mesh collider and the two cloth rows are the remaining 3.2 KB between them, and the mesh's own shape is the reuse the design was built around \u2014 one triangle written into a scratch `ConvexShape` and handed to `collideShapes`, so the separating-axis test, the clipping, the round paths and the cylinder's own module are all exact against a mesh without a byte of new narrow phase. What would make it wrong is a consumer importing the package for a character controller and paying for a mesh collider they never build; the answer there is a second entry point rather than a smaller narrow phase.",
1480
1496
  "short": "`@driftengine/physics` grows to 39.5 KB gzipped, for the cylinder, the mesh collider and the two cloth rows."
1481
1497
  },
1482
1498
  {
1483
1499
  "kind": "changed",
1484
- "text": "**The language pin moves to `driftscript` 1.5.0**, which gives `std/math` and `std/time` a float width they do not fix. The language grew a `float` type meaning *`f32` or `f64`, the same one throughout the call* — the width taken from the first argument that has one — so `math.clamp(v, 0, 1)` now works whether `v` is single or double precision, where before it was single only and nothing converted between the two. `f32.nearest(v)` and `f64.nearest(v)` are the conversions that were missing; a float previously had none at all, which `DS0232` said in so many words. **Nothing in `drift/*` changed shape**: every capability this engine declares is written `f32` and 1.5.0 accepts that unchanged, so a `.drs` file that compiled before compiles now, to the same JavaScript. Sixteen signatures moved in `packages/script/capabilities.json` and all sixteen are the language's own standard library, which is the language server learning the new widths. **The bindings go on rounding to single, and that is now a decision rather than an accident**: 1.5.0 moved `std/math`'s rounding out of its implementations to the call site, because a library that rounded every result would destroy the precision of a double-precision call — and the language's own note to hosts is to implement a `float` capability in double without rounding, while rounding stays right for a signature left written `f32`. Every one of ours is. What would make that wrong is widening one of them to `float` and leaving the rounding in place, which is written beside the helper that does it. The upgrade was found by one red assertion in `host.test.ts`, which is the pin's seam working: the language grew a type name and the test that lists them went red on install rather than a script author meeting it.\n\n**If you compile `.drs` files, move your own `driftscript` pin to 1.5.0 in the same step.** This engine describes itself to the language, so your compiler reads a registry this engine filled; an older compiler is then handed capabilities it has no types for and answers `DS0237`, “`float` is not a type this host registered”, on every file. Nothing warns you, because npm installs both copies happily and because a `.drs` file is compiled by your bundler and not by `tsc` — a typecheck and a full test suite pass over it, and the build is where it lands. This cost two consumers of ours an afternoon between them.",
1500
+ "text": "**The language pin moves to `driftscript` 1.5.0**, which gives `std/math` and `std/time` a float width they do not fix. The language grew a `float` type meaning *`f32` or `f64`, the same one throughout the call* \u2014 the width taken from the first argument that has one \u2014 so `math.clamp(v, 0, 1)` now works whether `v` is single or double precision, where before it was single only and nothing converted between the two. `f32.nearest(v)` and `f64.nearest(v)` are the conversions that were missing; a float previously had none at all, which `DS0232` said in so many words. **Nothing in `drift/*` changed shape**: every capability this engine declares is written `f32` and 1.5.0 accepts that unchanged, so a `.drs` file that compiled before compiles now, to the same JavaScript. Sixteen signatures moved in `packages/script/capabilities.json` and all sixteen are the language's own standard library, which is the language server learning the new widths. **The bindings go on rounding to single, and that is now a decision rather than an accident**: 1.5.0 moved `std/math`'s rounding out of its implementations to the call site, because a library that rounded every result would destroy the precision of a double-precision call \u2014 and the language's own note to hosts is to implement a `float` capability in double without rounding, while rounding stays right for a signature left written `f32`. Every one of ours is. What would make that wrong is widening one of them to `float` and leaving the rounding in place, which is written beside the helper that does it. The upgrade was found by one red assertion in `host.test.ts`, which is the pin's seam working: the language grew a type name and the test that lists them went red on install rather than a script author meeting it.\n\n**If you compile `.drs` files, move your own `driftscript` pin to 1.5.0 in the same step.** This engine describes itself to the language, so your compiler reads a registry this engine filled; an older compiler is then handed capabilities it has no types for and answers `DS0237`, \u201c`float` is not a type this host registered\u201d, on every file. Nothing warns you, because npm installs both copies happily and because a `.drs` file is compiled by your bundler and not by `tsc` \u2014 a typecheck and a full test suite pass over it, and the build is where it lands. This cost two consumers of ours an afternoon between them.",
1485
1501
  "short": "The language pin moves to driftscript 1.5.0: `std/math` works at either float width. No `drift/*` capability changed shape."
1486
1502
  }
1487
1503
  ]
@@ -1563,7 +1579,7 @@
1563
1579
  },
1564
1580
  {
1565
1581
  "kind": "fixed",
1566
- "text": "**The atmosphere walked its chunks in the order they were allocated, and that put a race into a deterministic simulation.** Every full walk of the gas field went along the chunk list, which is history rather than geometry, and `sweep` accumulates each face into both sides' deltas with `+=`. Floating-point addition is not associative, so which chunk was visited first decided the order every contribution landed in. Measured: two logs eight metres apart, spawned in the other order, sixty ticks, **1,202 cells differing** — a last bit, fed into rate laws that vary by twenty orders of magnitude across the temperature range of interest. Chunks are walked in key order now, which is a pure function of where a chunk is, and the sweep goes in rounds so a chunk created mid-sweep is still swept in the same pass. **A chemistry run recorded before 3.13.0 may not replay bit-exactly**, which is the cost of the fix and is worth it.",
1582
+ "text": "**The atmosphere walked its chunks in the order they were allocated, and that put a race into a deterministic simulation.** Every full walk of the gas field went along the chunk list, which is history rather than geometry, and `sweep` accumulates each face into both sides' deltas with `+=`. Floating-point addition is not associative, so which chunk was visited first decided the order every contribution landed in. Measured: two logs eight metres apart, spawned in the other order, sixty ticks, **1,202 cells differing** \u2014 a last bit, fed into rate laws that vary by twenty orders of magnitude across the temperature range of interest. Chunks are walked in key order now, which is a pure function of where a chunk is, and the sweep goes in rounds so a chunk created mid-sweep is still swept in the same pass. **A chemistry run recorded before 3.13.0 may not replay bit-exactly**, which is the cost of the fix and is worth it.",
1567
1583
  "short": "The gas field walked its chunks in allocation order, so two runs could diverge. It walks in key order now."
1568
1584
  },
1569
1585
  {
@@ -1579,47 +1595,47 @@
1579
1595
  "entries": [
1580
1596
  {
1581
1597
  "kind": "added",
1582
- "text": "**Track P is complete: chemistry, in `@driftengine/chemistry`.** Matter that is made of something, and behaves accordingly. Fifteen elements and sixty-eight species whose molar masses are derived from their formulas rather than stored beside them; substances whose composition, conductivity, porosity and ignition criteria are real published numbers; parcels whose state is **enthalpy rather than temperature**, so a phase change is a gated reaction and the plateau emerges from the solver; a depth stack of equal-mass shells, so a twig catches and a log does not; a reaction network checked against the element matrix at registration and costed by Hess's law, with rates tabulated so `exp` never runs on a tick; one sparse atmosphere carrying oxygen in and smoke away; radiation with the 1/r² that makes distance mean something; and ignition as five criteria rather than a flag. **18.1 KB gzipped, importing no other engine package** — nothing in core depends on it, so a game with no fire in it pays nothing.",
1598
+ "text": "**Track P is complete: chemistry, in `@driftengine/chemistry`.** Matter that is made of something, and behaves accordingly. Fifteen elements and sixty-eight species whose molar masses are derived from their formulas rather than stored beside them; substances whose composition, conductivity, porosity and ignition criteria are real published numbers; parcels whose state is **enthalpy rather than temperature**, so a phase change is a gated reaction and the plateau emerges from the solver; a depth stack of equal-mass shells, so a twig catches and a log does not; a reaction network checked against the element matrix at registration and costed by Hess's law, with rates tabulated so `exp` never runs on a tick; one sparse atmosphere carrying oxygen in and smoke away; radiation with the 1/r\u00b2 that makes distance mean something; and ignition as five criteria rather than a flag. **18.1 KB gzipped, importing no other engine package** \u2014 nothing in core depends on it, so a game with no fire in it pays nothing.",
1583
1599
  "short": "Chemistry: substances, enthalpy, reactions, an atmosphere and fire, in a package that imports nothing."
1584
1600
  },
1585
1601
  {
1586
1602
  "kind": "added",
1587
- "text": "**Thirty-nine substances across seven optional entry points**, because a substance is data and forty of them is a payload: `organic`, `food`, `fuel`, `polymer`, `mineral`, `metal`, `biological`, each 628 to 1,165 bytes, so a consumer who wants a campfire imports one. Every number is sourced or back-derived from a measurement, and every derived quantity is checked against a **second, independent** one: calcination comes out at +179.2 kJ/mol against a published +178.3, magnesium at 24.75 MJ/kg, aerobic respiration at −2,802.7 against a quoted 2,803. Some of what falls out of that: a candle is wax melting, then boiling, then burning, with the word appearing nowhere; a steak cannot brown while it is wet, because Maillard needs 140 °C and a wet surface is pinned at 100; PVC self-extinguishes in air and PMMA does not, on one limiting oxygen index each; and a hay bale self-heats out of the mesophilic band and is picked up by the thermophiles above it.",
1603
+ "text": "**Thirty-nine substances across seven optional entry points**, because a substance is data and forty of them is a payload: `organic`, `food`, `fuel`, `polymer`, `mineral`, `metal`, `biological`, each 628 to 1,165 bytes, so a consumer who wants a campfire imports one. Every number is sourced or back-derived from a measurement, and every derived quantity is checked against a **second, independent** one: calcination comes out at +179.2 kJ/mol against a published +178.3, magnesium at 24.75 MJ/kg, aerobic respiration at \u22122,802.7 against a quoted 2,803. Some of what falls out of that: a candle is wax melting, then boiling, then burning, with the word appearing nowhere; a steak cannot brown while it is wet, because Maillard needs 140 \u00b0C and a wet surface is pinned at 100; PVC self-extinguishes in air and PMMA does not, on one limiting oxygen index each; and a hay bale self-heats out of the mesophilic band and is picked up by the thermophiles above it.",
1588
1604
  "short": "Seven substance libraries, every number sourced, and behaviour nobody wrote a rule for."
1589
1605
  },
1590
1606
  {
1591
1607
  "kind": "added",
1592
- "text": "**`drift/chemistry`, the thirteenth `drift/*` module**: fifty-one capabilities over a read surface, a write surface and an event buffer of twenty-one kinds, three opaque types, and `chemistry.write` **inside the determinism boundary** — the second effect ever admitted there, because a parcel's enthalpy is a simulated quantity integrated on the fixed step. Eleven new unit suffixes came with it, `degC K kg g mol J kJ MJ W Pa kPa`, and `degC` is the only unit in the language that is an **offset** rather than a scale: the checker refuses it as an operand of `+` or `-` and says to write the difference in `K`, because the refused spelling compiles, runs, and produces a number 273.15 too large. `@substance` and `@reaction` are annotations on a `data` record rather than language forms.",
1608
+ "text": "**`drift/chemistry`, the thirteenth `drift/*` module**: fifty-one capabilities over a read surface, a write surface and an event buffer of twenty-one kinds, three opaque types, and `chemistry.write` **inside the determinism boundary** \u2014 the second effect ever admitted there, because a parcel's enthalpy is a simulated quantity integrated on the fixed step. Eleven new unit suffixes came with it, `degC K kg g mol J kJ MJ W Pa kPa`, and `degC` is the only unit in the language that is an **offset** rather than a scale: the checker refuses it as an operand of `+` or `-` and says to write the difference in `K`, because the refused spelling compiles, runs, and produces a number 273.15 too large. `@substance` and `@reaction` are annotations on a `data` record rather than language forms.",
1593
1609
  "short": "`drift/chemistry` with fifty-one capabilities, eleven unit suffixes, and a refusal for the one that is an offset scale."
1594
1610
  },
1595
1611
  {
1596
1612
  "kind": "added",
1597
- "text": "**A presentation seam that names no core type.** Six mappings, each a physical quantity rather than an art direction: smoke colour from the soot-to-aerosol ratio, flame height from Heskestad's `L ≈ 0.235·Q^(2/5) − 1.02·D`, glow from Planck's law at the surface temperature, albedo lerped by char fraction, roughness from the free surface water, and a scale from the cube root of what is left of the volume — plus three audio scalars, and the package never touches `@driftengine/audio`. Every target is described structurally by what it must have, so core's `ParticleInstances` satisfies it exactly and neither package knows the other exists. **Emissive in this engine is gated on `nightFactor`**, so embers written here look right at dusk and dead at noon; the call says so.",
1613
+ "text": "**A presentation seam that names no core type.** Six mappings, each a physical quantity rather than an art direction: smoke colour from the soot-to-aerosol ratio, flame height from Heskestad's `L \u2248 0.235\u00b7Q^(2/5) \u2212 1.02\u00b7D`, glow from Planck's law at the surface temperature, albedo lerped by char fraction, roughness from the free surface water, and a scale from the cube root of what is left of the volume \u2014 plus three audio scalars, and the package never touches `@driftengine/audio`. Every target is described structurally by what it must have, so core's `ParticleInstances` satisfies it exactly and neither package knows the other exists. **Emissive in this engine is gated on `nightFactor`**, so embers written here look right at dusk and dead at noon; the call says so.",
1598
1614
  "short": "Six mappings from physical quantities to pixels, through arrays this package never names the type of."
1599
1615
  },
1600
1616
  {
1601
1617
  "kind": "added",
1602
- "text": "**A `SUBS` chunk at container 1.8**, pairing material ordinals with substance ids, written by the baker from a glTF material's `extras.substance`. An artist labels the oak in Blender and the log burns like oak, with no code in between. Additive, so a 1.7 reader opens a 1.8 file as the geometry it also holds. Matching is **exact and unmatched materials are reported, never guessed** — a `pine_bark_02` silently matching `pine` is a material with the wrong ignition temperature that nobody would ever notice.",
1618
+ "text": "**A `SUBS` chunk at container 1.8**, pairing material ordinals with substance ids, written by the baker from a glTF material's `extras.substance`. An artist labels the oak in Blender and the log burns like oak, with no code in between. Additive, so a 1.7 reader opens a 1.8 file as the geometry it also holds. Matching is **exact and unmatched materials are reported, never guessed** \u2014 a `pine_bark_02` silently matching `pine` is a material with the wrong ignition temperature that nobody would ever notice.",
1603
1619
  "short": "`SUBS` at 1.8: an artist labels a material and it burns like that material."
1604
1620
  },
1605
1621
  {
1606
1622
  "kind": "added",
1607
- "text": "**Four level-of-detail tiers, and every one conserves exactly.** Shells are equal-mass by construction, so folding four into one is a proportional redistribution and mass, every element and enthalpy come through a tier change unchanged — which is what makes one safe to apply to a burning object mid-burn. What a tier costs is measured rather than claimed: over five hundred seconds of fire, `Near` is exact, `Far` drifts 21% and `Distant` 43%, and the coarser tiers char **more**, because a lumped parcel has no cold core to hide behind. **A cold stone sleeps**, waking on heat, water, mass, a contact or a fire coming within range, through a coarse scan spread round-robin over four ticks. And `fingerprintChemistry` is sixteen hexadecimal digits over compositions, enthalpies, the field's elements and the substance registry, order-insensitive over parcels because a handle *is* spawn order.",
1623
+ "text": "**Four level-of-detail tiers, and every one conserves exactly.** Shells are equal-mass by construction, so folding four into one is a proportional redistribution and mass, every element and enthalpy come through a tier change unchanged \u2014 which is what makes one safe to apply to a burning object mid-burn. What a tier costs is measured rather than claimed: over five hundred seconds of fire, `Near` is exact, `Far` drifts 21% and `Distant` 43%, and the coarser tiers char **more**, because a lumped parcel has no cold core to hide behind. **A cold stone sleeps**, waking on heat, water, mass, a contact or a fire coming within range, through a coarse scan spread round-robin over four ticks. And `fingerprintChemistry` is sixteen hexadecimal digits over compositions, enthalpies, the field's elements and the substance registry, order-insensitive over parcels because a handle *is* spawn order.",
1608
1624
  "short": "Tiers that conserve exactly, sleeping that costs a bounded scan, and a fingerprint that makes determinism an assertion."
1609
1625
  },
1610
1626
  {
1611
1627
  "kind": "fixed",
1612
- "text": "**A gas cell reported a different temperature depending on whether some other object had happened to realise its chunk first.** The unrealised path returned the authored ambient and the realised one returned it derived, and the two were 8e-5 K apart rather than an ulp — because the ambient enthalpy was built with a heat capacity at the ambient temperature and read back with one at the datum. That put an order dependence into convection and from there into whether a log ignites on tick N or N+1. Found by `fingerprintChemistry` on its first run, which is what it is for, and fixed at the source: the field is a constant `cp` at 298.15 and the ambient is now built with the same array a cell temperature divides by.",
1628
+ "text": "**A gas cell reported a different temperature depending on whether some other object had happened to realise its chunk first.** The unrealised path returned the authored ambient and the realised one returned it derived, and the two were 8e-5 K apart rather than an ulp \u2014 because the ambient enthalpy was built with a heat capacity at the ambient temperature and read back with one at the datum. That put an order dependence into convection and from there into whether a log ignites on tick N or N+1. Found by `fingerprintChemistry` on its first run, which is what it is for, and fixed at the source: the field is a constant `cp` at 298.15 and the ambient is now built with the same array a cell temperature divides by.",
1613
1629
  "short": "A cell's temperature depended on whether its chunk had been realised yet."
1614
1630
  },
1615
1631
  {
1616
1632
  "kind": "fixed",
1617
- "text": "**A thin, highly conductive object beside a fire rang to 4,000 K and back to 100 with its energy conserved the whole way.** Inward conduction capped its sub-steps and the note above the cap said a ring was the right failure, because nothing is lost. It is not: an ignition criterion reads a surface temperature and a glow is drawn from one, so an oscillation that loses no energy is still a fire starting in the wrong place. Each shell now carries its own `dh/dT` and a pair may not exchange more heat than would equalise it — unconditionally stable, exactly conserving, and identical wherever the step was resolved anyway. The radiative and convective terms are bounded the same way, against the source and the gas. Found by putting a copper coin on a hearth in `demo/dev/chemistry.html`.",
1633
+ "text": "**A thin, highly conductive object beside a fire rang to 4,000 K and back to 100 with its energy conserved the whole way.** Inward conduction capped its sub-steps and the note above the cap said a ring was the right failure, because nothing is lost. It is not: an ignition criterion reads a surface temperature and a glow is drawn from one, so an oscillation that loses no energy is still a fire starting in the wrong place. Each shell now carries its own `dh/dT` and a pair may not exchange more heat than would equalise it \u2014 unconditionally stable, exactly conserving, and identical wherever the step was resolved anyway. The radiative and convective terms are bounded the same way, against the source and the gas. Found by putting a copper coin on a hearth in `demo/dev/chemistry.html`.",
1618
1634
  "short": "Conduction let a thin, conductive object oscillate rather than settle; a pair is now clamped where it would be equal."
1619
1635
  },
1620
1636
  {
1621
1637
  "kind": "fixed",
1622
- "text": "**Five more `Math.hypot` calls arrived under a promise of machine-independent results**, in the new swept step and stall escape — the same class of bug 3.11.0 fixed ten of in the collision kernel, which is why the gate that names them exists. ECMAScript pins `sqrt` to IEEE-754 and leaves `hypot` to the implementation, so a stored replay can diverge between two browsers. All five are exactly-rounded arithmetic now. **A consumer holding byte-exact replays or ghosts recorded before 3.12.0 should re-record them**: the results move in the last bits.",
1638
+ "text": "**Five more `Math.hypot` calls arrived under a promise of machine-independent results**, in the new swept step and stall escape \u2014 the same class of bug 3.11.0 fixed ten of in the collision kernel, which is why the gate that names them exists. ECMAScript pins `sqrt` to IEEE-754 and leaves `hypot` to the implementation, so a stored replay can diverge between two browsers. All five are exactly-rounded arithmetic now. **A consumer holding byte-exact replays or ghosts recorded before 3.12.0 should re-record them**: the results move in the last bits.",
1623
1639
  "short": "Five calls the specification does not pin down arrived in the new character physics."
1624
1640
  }
1625
1641
  ]
@@ -1677,12 +1693,12 @@
1677
1693
  "entries": [
1678
1694
  {
1679
1695
  "kind": "added",
1680
- "text": "**Drift AI, in `@driftengine/ai`, and an agent loop that does not wait.** Live inference takes fifty milliseconds to five seconds or never, and an agent that waits for it stands visibly still — so there are two layers. A deterministic policy floor runs inside the simulation on the fixed clock, allocating nothing per tick and knowing nothing about providers, which is why an agent with none configured still behaves. On top of it, a one-slot buffer holds the next model-authored intent while the current one executes. The current slot is never empty: a provider that is slow, absent, or over budget costs quality, never motion. Exactly one request is in flight per agent, enforced by the shape of a four-state machine rather than by a counter — there is no edge on which a second could be issued. The continuation goes out when the current intent's remaining extent falls to the provider's **measured p90 latency**, so a fast provider is asked late against fresher context and a slow one early because it has to be; the tuning constant that would be wrong on every device does not exist. A buffered intent is a proposal rather than a decision, so guards the **tools** declared — never ones the model wrote — run again when it drains, and a failing guard discards it rather than deferring: a plan whose world is gone is a plan for a different world. Preemption clears the intent, the buffer and the request together and the floor covers on the same tick, with the thrown-away request reported as `preemptedRequests`, because a number nobody reports is a number nobody tunes. Backpressure is `MessageQueue`. Accepted commands and preemptions are recorded beside `TickTrace`, so a run replays exactly with no provider called. Ships a deterministic test provider with programmable latency, a remote adapter that never sees a credential, and a local adapter accepted by running it. Bound to DriftScript as `drift/ai`, with `@aiTool` and `@aiContext` generating schemas from signatures the checker already validated.",
1696
+ "text": "**Drift AI, in `@driftengine/ai`, and an agent loop that does not wait.** Live inference takes fifty milliseconds to five seconds or never, and an agent that waits for it stands visibly still \u2014 so there are two layers. A deterministic policy floor runs inside the simulation on the fixed clock, allocating nothing per tick and knowing nothing about providers, which is why an agent with none configured still behaves. On top of it, a one-slot buffer holds the next model-authored intent while the current one executes. The current slot is never empty: a provider that is slow, absent, or over budget costs quality, never motion. Exactly one request is in flight per agent, enforced by the shape of a four-state machine rather than by a counter \u2014 there is no edge on which a second could be issued. The continuation goes out when the current intent's remaining extent falls to the provider's **measured p90 latency**, so a fast provider is asked late against fresher context and a slow one early because it has to be; the tuning constant that would be wrong on every device does not exist. A buffered intent is a proposal rather than a decision, so guards the **tools** declared \u2014 never ones the model wrote \u2014 run again when it drains, and a failing guard discards it rather than deferring: a plan whose world is gone is a plan for a different world. Preemption clears the intent, the buffer and the request together and the floor covers on the same tick, with the thrown-away request reported as `preemptedRequests`, because a number nobody reports is a number nobody tunes. Backpressure is `MessageQueue`. Accepted commands and preemptions are recorded beside `TickTrace`, so a run replays exactly with no provider called. Ships a deterministic test provider with programmable latency, a remote adapter that never sees a credential, and a local adapter accepted by running it. Bound to DriftScript as `drift/ai`, with `@aiTool` and `@aiContext` generating schemas from signatures the checker already validated.",
1681
1697
  "short": "Agents that are never idle: a deterministic floor underneath, and the next decision already in flight."
1682
1698
  },
1683
1699
  {
1684
1700
  "kind": "added",
1685
- "text": "**DriftScript annotations take arguments.** `@aiTool(description: \"…\")` and `@aiContext(description: \"…\")` carry the sentence that tells a model when to reach for something, and a tool without one is refused. String literals only, because an annotation is an assertion the compiler checks and an expression there would make it a computation the compiler has to run.",
1701
+ "text": "**DriftScript annotations take arguments.** `@aiTool(description: \"\u2026\")` and `@aiContext(description: \"\u2026\")` carry the sentence that tells a model when to reach for something, and a tool without one is refused. String literals only, because an annotation is an assertion the compiler checks and an expression there would make it a computation the compiler has to run.",
1686
1702
  "short": "DriftScript annotations can carry arguments."
1687
1703
  },
1688
1704
  {
@@ -1698,27 +1714,27 @@
1698
1714
  "entries": [
1699
1715
  {
1700
1716
  "kind": "added",
1701
- "text": "**Entities are language forms in DriftScript, not library calls.** `component` declares a component's fields and the host builds a store from them, so a save survives a rename — field ids carry the `.drs` file that declared them. `component X from host { … }` goes the other way and asserts a shape the host already registered, refused at bind naming the field and both types. `entity` names a set of required components and turns its own `var` fields into a component. `system` declares a body the schedule runs, at a rate that becomes a stride over fixed steps — `update at 1Hz` is sixty, and a rate that does not divide the fixed step exactly is refused naming the arithmetic, because rounding it is how a replay stops matching a recording. `prefab` is a description a host instantiates, so its values must be constants. And `for e in query<Position, Velocity>().without<Frozen>()` is the language's first loop over anything: it opens a pooled cursor and hoists a live view, so reading a field is a property load and an array index rather than a host call. Measured at **30.4 ns per entity against 28.7 for the same loop written by hand and 388 for a call per field** — twenty thousand entities, two components, one implementation per process.",
1717
+ "text": "**Entities are language forms in DriftScript, not library calls.** `component` declares a component's fields and the host builds a store from them, so a save survives a rename \u2014 field ids carry the `.drs` file that declared them. `component X from host { \u2026 }` goes the other way and asserts a shape the host already registered, refused at bind naming the field and both types. `entity` names a set of required components and turns its own `var` fields into a component. `system` declares a body the schedule runs, at a rate that becomes a stride over fixed steps \u2014 `update at 1Hz` is sixty, and a rate that does not divide the fixed step exactly is refused naming the arithmetic, because rounding it is how a replay stops matching a recording. `prefab` is a description a host instantiates, so its values must be constants. And `for e in query<Position, Velocity>().without<Frozen>()` is the language's first loop over anything: it opens a pooled cursor and hoists a live view, so reading a field is a property load and an array index rather than a host call. Measured at **30.4 ns per entity against 28.7 for the same loop written by hand and 388 for a call per field** \u2014 twenty thousand entities, two components, one implementation per process.",
1702
1718
  "short": "DriftScript gains `component`, `entity`, `system`, `query` and `prefab` as language forms."
1703
1719
  },
1704
1720
  {
1705
1721
  "kind": "added",
1706
- "text": "**A system's `reads` and `writes` are inferred and the declaration is checked against them.** The compiler propagates component access through the call graph the way it already propagates effects, so a write made inside a helper is a write the system declares — and a declaration that omits one is an error naming the system and the component, rather than the engine refusing the write three frames into a session. A declaration *wider* than the body is a warning instead, because widening deliberately is sometimes what an author means, and a system that declares neither clause is fully described by the inference. A query loop may not `await`: its cursor comes from a pool and a suspension would hold one across a frame where the entity model already says the result is invalid.",
1722
+ "text": "**A system's `reads` and `writes` are inferred and the declaration is checked against them.** The compiler propagates component access through the call graph the way it already propagates effects, so a write made inside a helper is a write the system declares \u2014 and a declaration that omits one is an error naming the system and the component, rather than the engine refusing the write three frames into a session. A declaration *wider* than the body is a warning instead, because widening deliberately is sometimes what an author means, and a system that declares neither clause is fully described by the inference. A query loop may not `await`: its cursor comes from a pool and a suspension would hold one across a frame where the entity model already says the result is invalid.",
1707
1723
  "short": "A system's reads and writes are inferred, and an undeclared write is a compile error."
1708
1724
  },
1709
1725
  {
1710
1726
  "kind": "added",
1711
- "text": "**`@editor(…)` is checked against the field it annotates, and `mode` finally does something.** A range on a field holding text describes nothing; a range in metres on a field defaulting to seconds moves the value by the wrong amount and nothing at runtime can tell, because units are erased by then; an `assetType` on a number has nothing to pick into it. None of that needs an editor to exist, which is why it lands before one does. The metadata rides the schema a module already emits and a production build strips it — the first thing `CompileOptions.mode` has ever been read for, and deliberately a difference in payload rather than in behaviour.",
1727
+ "text": "**`@editor(\u2026)` is checked against the field it annotates, and `mode` finally does something.** A range on a field holding text describes nothing; a range in metres on a field defaulting to seconds moves the value by the wrong amount and nothing at runtime can tell, because units are erased by then; an `assetType` on a number has nothing to pick into it. None of that needs an editor to exist, which is why it lands before one does. The metadata rides the schema a module already emits and a production build strips it \u2014 the first thing `CompileOptions.mode` has ever been read for, and deliberately a difference in payload rather than in behaviour.",
1712
1728
  "short": "`@editor` metadata is checked against its field and stripped from production builds."
1713
1729
  },
1714
1730
  {
1715
1731
  "kind": "fixed",
1716
- "text": "**A hot-patched module kept running against capability namespaces that were `undefined`.** `patchModule` re-ran a module's `__runtime` hook and never its `__bind` hook, so the first capability call after any edit threw — for as long as `__bind` has existed. Nothing caught it because the only module anything ever patched imports no capability at all. The runtime remembers which host a module was bound to and binds the new version to it.",
1732
+ "text": "**A hot-patched module kept running against capability namespaces that were `undefined`.** `patchModule` re-ran a module's `__runtime` hook and never its `__bind` hook, so the first capability call after any edit threw \u2014 for as long as `__bind` has existed. Nothing caught it because the only module anything ever patched imports no capability at all. The runtime remembers which host a module was bound to and binds the new version to it.",
1717
1733
  "short": "A hot-patched module keeps its host bindings instead of losing them on every edit."
1718
1734
  },
1719
1735
  {
1720
1736
  "kind": "fixed",
1721
- "text": "**Effect inference silently skipped three kinds of statement.** The walk that decides what `@pure` and `@deterministic` mean switched over seven statement kinds with no default, so a call inside one it did not name was invisible — `emit Ping { at: time.wallClock() }` and `spawn worker(time.wallClock())` both read the wall clock inside a function annotated as deterministic, and nothing reported anything. And an option's migration key named no inner type, so `f32?` and `String?` hashed identically: a module changing one to the other produced the same interface hash and its dependents did not recompile.",
1737
+ "text": "**Effect inference silently skipped three kinds of statement.** The walk that decides what `@pure` and `@deterministic` mean switched over seven statement kinds with no default, so a call inside one it did not name was invisible \u2014 `emit Ping { at: time.wallClock() }` and `spawn worker(time.wallClock())` both read the wall clock inside a function annotated as deterministic, and nothing reported anything. And an option's migration key named no inner type, so `f32?` and `String?` hashed identically: a module changing one to the other produced the same interface hash and its dependents did not recompile.",
1722
1738
  "short": "Effect inference now walks every statement kind, and an option names its inner type."
1723
1739
  }
1724
1740
  ]
@@ -1729,17 +1745,17 @@
1729
1745
  "entries": [
1730
1746
  {
1731
1747
  "kind": "added",
1732
- "text": "**Entities, in `@driftengine/entities`, and it imports no other engine package.** An entity is one number carrying a 26-bit index and a 27-bit generation, so a handle to something destroyed is refused rather than silently addressing whatever took its slot — the conventional packed 32-bit handle splits 24 and 8, which makes a slot's 257th reuse hand back a handle identical to its first. Components are stored as a sparse set per type with a typed-array column per numeric field, so adding or removing one is constant time and moves nothing else. Queries walk the smallest set and allocate nothing, per call as well as per step. A system **declares what it reads and writes and is held to it**; the schedule orders by declaration with explicit `after` edges topologically sorted, and reports which adjacent systems could run together without running them that way. `everyTicks` strides the fixed step and is never a rate in seconds. Prefabs are values with no link back to their instances, and a scene addresses every value by a stable field id — so a scene saved before a component gained a field loads with the field at its default, and one whose field changed type is refused naming it rather than coerced. That migration is DriftScript's, unchanged: one description of a component, one thing that reconciles two versions of it. The package declares **no component types at all** — a consumer writes the ones its world needs — and costs 632 bytes gzipped with no engine at all.",
1748
+ "text": "**Entities, in `@driftengine/entities`, and it imports no other engine package.** An entity is one number carrying a 26-bit index and a 27-bit generation, so a handle to something destroyed is refused rather than silently addressing whatever took its slot \u2014 the conventional packed 32-bit handle splits 24 and 8, which makes a slot's 257th reuse hand back a handle identical to its first. Components are stored as a sparse set per type with a typed-array column per numeric field, so adding or removing one is constant time and moves nothing else. Queries walk the smallest set and allocate nothing, per call as well as per step. A system **declares what it reads and writes and is held to it**; the schedule orders by declaration with explicit `after` edges topologically sorted, and reports which adjacent systems could run together without running them that way. `everyTicks` strides the fixed step and is never a rate in seconds. Prefabs are values with no link back to their instances, and a scene addresses every value by a stable field id \u2014 so a scene saved before a component gained a field loads with the field at its default, and one whose field changed type is refused naming it rather than coerced. That migration is DriftScript's, unchanged: one description of a component, one thing that reconciles two versions of it. The package declares **no component types at all** \u2014 a consumer writes the ones its world needs \u2014 and costs 632 bytes gzipped with no engine at all.",
1733
1749
  "short": "Entities, components, queries, systems, prefabs and scenes, in a package that imports no other."
1734
1750
  },
1735
1751
  {
1736
1752
  "kind": "added",
1737
- "text": "**DriftScript reaches entities and prefabs.** `drift/ecs` and `drift/prefab` are bound, and two of the four surfaces the linker refused by name are gone with them. A component is addressed by a string the host resolves, and a name nothing registered is refused listing what the world has rather than reading zeroes and writing into nothing. There is no query capability yet — iterating one needs a language form that does not exist — so a script walks a component by `count` and `at`, counting downwards because removing swaps the last entity into a position already passed. `drift/navigation` and `drift/network` are still refused in writing.",
1753
+ "text": "**DriftScript reaches entities and prefabs.** `drift/ecs` and `drift/prefab` are bound, and two of the four surfaces the linker refused by name are gone with them. A component is addressed by a string the host resolves, and a name nothing registered is refused listing what the world has rather than reading zeroes and writing into nothing. There is no query capability yet \u2014 iterating one needs a language form that does not exist \u2014 so a script walks a component by `count` and `at`, counting downwards because removing swaps the last entity into a position already passed. `drift/navigation` and `drift/network` are still refused in writing.",
1738
1754
  "short": "Scripts can create entities, read and write components, and spawn prefabs."
1739
1755
  },
1740
1756
  {
1741
1757
  "kind": "changed",
1742
- "text": "**`ecs.write` is inside the determinism boundary.** Entity state is the simulation, so a `@deterministic` function may write it — a movement system writing a position is the canonical deterministic operation, and a rule refusing it would refuse the thing the annotation exists to describe. `scene.write` stays outside for a reason that does not apply to entities: a scene node is what draws. The boundary had been written down twice, in the registry and in the effects checker, and adding the effect to one left the other refusing every script that used it; the checker imports it now.",
1758
+ "text": "**`ecs.write` is inside the determinism boundary.** Entity state is the simulation, so a `@deterministic` function may write it \u2014 a movement system writing a position is the canonical deterministic operation, and a rule refusing it would refuse the thing the annotation exists to describe. `scene.write` stays outside for a reason that does not apply to entities: a scene node is what draws. The boundary had been written down twice, in the registry and in the effects checker, and adding the effect to one left the other refusing every script that used it; the checker imports it now.",
1743
1759
  "short": "A `@deterministic` function may write entity state, which is the simulation."
1744
1760
  }
1745
1761
  ]
@@ -1750,7 +1766,7 @@
1750
1766
  "entries": [
1751
1767
  {
1752
1768
  "kind": "added",
1753
- "text": "**Characters that deform: `@driftengine/animation` is the whole of skeletal animation, and a rig now survives an import, a bake and a stream.** A skeleton resolves a pose into a skinning palette; clips are sampled as a pure function of a time you supply; poses blend, layer additively, and run through blend trees and a state machine with crossfaded transitions; a two-bone IK solver puts a hand on a target; morph targets deform the bind pose; and retargeting plays one skeleton's clip on another by joint name. Both backends draw it, pixel for pixel. glTF's skins, animations and morph targets are read, `bake` writes them, and `.drft` carries them as `SKIN`, `ANIM` and `MORP` — alongside `NODE`, which the format specified in v1 and nothing had ever written. Nothing in the sampling path reads a clock, so an animated character replays bit-identically from an intent stream.",
1769
+ "text": "**Characters that deform: `@driftengine/animation` is the whole of skeletal animation, and a rig now survives an import, a bake and a stream.** A skeleton resolves a pose into a skinning palette; clips are sampled as a pure function of a time you supply; poses blend, layer additively, and run through blend trees and a state machine with crossfaded transitions; a two-bone IK solver puts a hand on a target; morph targets deform the bind pose; and retargeting plays one skeleton's clip on another by joint name. Both backends draw it, pixel for pixel. glTF's skins, animations and morph targets are read, `bake` writes them, and `.drft` carries them as `SKIN`, `ANIM` and `MORP` \u2014 alongside `NODE`, which the format specified in v1 and nothing had ever written. Nothing in the sampling path reads a clock, so an animated character replays bit-identically from an intent stream.",
1754
1770
  "short": "Skeletal animation ships: skinning on both backends, clips, blending, blend trees, a state machine, two-bone IK, morph targets and retargeting, imported from glTF and carried by `.drft`."
1755
1771
  },
1756
1772
  {
@@ -1760,17 +1776,17 @@
1760
1776
  },
1761
1777
  {
1762
1778
  "kind": "added",
1763
- "text": "**Container format 1.7.** `SKIN` carries a skeleton, `ANIM` a clip, `MORP` a mesh's morph deltas, and `NODE` the asset's own hierarchy — the last of which the format has specified since v1 with no writer and no reader until rigid TRS animation needed one. All four are optional, so a file written today opens in every reader this engine has shipped, as the geometry it also holds.",
1779
+ "text": "**Container format 1.7.** `SKIN` carries a skeleton, `ANIM` a clip, `MORP` a mesh's morph deltas, and `NODE` the asset's own hierarchy \u2014 the last of which the format has specified since v1 with no writer and no reader until rigid TRS animation needed one. All four are optional, so a file written today opens in every reader this engine has shipped, as the geometry it also holds.",
1764
1780
  "short": "Container 1.7: `SKIN`, `ANIM` and `MORP` join `NODE`, all optional, so older readers open the file as the geometry it also holds."
1765
1781
  },
1766
1782
  {
1767
1783
  "kind": "fixed",
1768
- "text": "**`validateMeshData` never checked the tangent attribute.** Four floats a vertex, the widest optional attribute in the format, and the only one a short buffer could reach a driver through — for three releases. The hazard is the one that function's own documentation describes: a driver may read zeroes and may equally drop the draw, with no GL error either way, so a mesh renders perfectly for whoever built it and vanishes on hardware they do not own.",
1784
+ "text": "**`validateMeshData` never checked the tangent attribute.** Four floats a vertex, the widest optional attribute in the format, and the only one a short buffer could reach a driver through \u2014 for three releases. The hazard is the one that function's own documentation describes: a driver may read zeroes and may equally drop the draw, with no GL error either way, so a mesh renders perfectly for whoever built it and vanishes on hardware they do not own.",
1769
1785
  "short": "`validateMeshData` now checks the tangent attribute, which was unguarded for three releases."
1770
1786
  },
1771
1787
  {
1772
1788
  "kind": "fixed",
1773
- "text": "**A blend tree or a state machine keeps the bind pose a rotation-only clip preserves.** Sampling a clip leaves a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever the pose held — but blending interpolates every channel, so a tree whose scratch poses started at zero translation collapsed every joint onto its parent's origin the moment it blended anything. Both now take an optional bind pose. Supply it whenever your clips drive rotation alone, which is most of them.",
1789
+ "text": "**A blend tree or a state machine keeps the bind pose a rotation-only clip preserves.** Sampling a clip leaves a channel no track mentions exactly as it found it, so a rotation-only clip preserves whatever the pose held \u2014 but blending interpolates every channel, so a tree whose scratch poses started at zero translation collapsed every joint onto its parent's origin the moment it blended anything. Both now take an optional bind pose. Supply it whenever your clips drive rotation alone, which is most of them.",
1774
1790
  "short": "Blend trees and state machines take a bind pose, so a rotation-only clip no longer loses a skeleton's joint offsets when it blends."
1775
1791
  }
1776
1792
  ]
@@ -1781,12 +1797,12 @@
1781
1797
  "entries": [
1782
1798
  {
1783
1799
  "kind": "added",
1784
- "text": "**Captured places: `@driftengine/splats` draws Gaussian splat captures, and `.drft` streams them coarse first.** A capture is a real place photographed and optimised into a few hundred thousand elliptical blobs, and the whole of it now works: `.ply` and `.splat` readers, the packing, a counting sort in a worker, and a pass that composes *into* your scene rather than instead of one. A wall in front of a capture occludes it and a beam drawn after it passes through, because depth is tested and never written. It reaches the frame through `registerPass`, so a project that never imports the package carries none of it — about twelve kilobytes gzipped over core, asserted by the size gate rather than claimed. The pass owns **no vertex buffer and no vertex array on either backend**: six vertices a splat come off the vertex id and everything else is an integer texture fetch, so the whole per-backend surface is a pipeline, two textures and a uniform block. **Sorting is where the work is**, because splats blend back to front and `over` is not commutative, so the order *is* the picture and it changes whenever the view turns. `SplatSorter` runs the sort in a worker built from a `Blob` — no bundler configuration, no second file — holds at most one in flight and drops rather than queues, and re-sorts only once the view has turned about two and a half degrees, so a still camera sorts zero times a second. Where a strict content policy refuses a `Blob` worker it falls back to the main thread and says so once. **`splatBudget` caps what a weak device is asked to draw, and it selects by screen-space size rather than by truncating the order** — the order runs far to near, so a prefix of it keeps the backdrop and throws away everything nearest the camera, which is the largest thing on screen. `defaultSplatBudget` reads the renderer's own name and lowers itself for the parts this engine has measured struggling; both numbers are reasoned estimates rather than handset measurements, and a consumer holding a real one should pass it. Each batch has its own transform, so two captures compose in one scene, and a capture out of frame is skipped **before** its sort rather than after — a discarded draw call is cheap and a linear pass over a million splats is not. **And `.drft` 1.5 carries a capture, in blocks laid out so any prefix of the file is the whole place.** The container writes splats interleaved across the whole capture — sorted by Morton code, then walked bit-reversed — so the first block that lands is a complete sparse version of it rather than a finished corner. Measured on the engine's own page: 662 splats out of 24 KB of a 332 KB file, drawing the whole room, with the rest densifying it. The chunk is optional, so an older reader skips it in silence, and a file may now carry a capture and no geometry at all. What is still absent, and worth knowing: colour comes from the spherical-harmonic DC term alone, so a capture's speculars read painted on rather than shifting as you move.",
1800
+ "text": "**Captured places: `@driftengine/splats` draws Gaussian splat captures, and `.drft` streams them coarse first.** A capture is a real place photographed and optimised into a few hundred thousand elliptical blobs, and the whole of it now works: `.ply` and `.splat` readers, the packing, a counting sort in a worker, and a pass that composes *into* your scene rather than instead of one. A wall in front of a capture occludes it and a beam drawn after it passes through, because depth is tested and never written. It reaches the frame through `registerPass`, so a project that never imports the package carries none of it \u2014 about twelve kilobytes gzipped over core, asserted by the size gate rather than claimed. The pass owns **no vertex buffer and no vertex array on either backend**: six vertices a splat come off the vertex id and everything else is an integer texture fetch, so the whole per-backend surface is a pipeline, two textures and a uniform block. **Sorting is where the work is**, because splats blend back to front and `over` is not commutative, so the order *is* the picture and it changes whenever the view turns. `SplatSorter` runs the sort in a worker built from a `Blob` \u2014 no bundler configuration, no second file \u2014 holds at most one in flight and drops rather than queues, and re-sorts only once the view has turned about two and a half degrees, so a still camera sorts zero times a second. Where a strict content policy refuses a `Blob` worker it falls back to the main thread and says so once. **`splatBudget` caps what a weak device is asked to draw, and it selects by screen-space size rather than by truncating the order** \u2014 the order runs far to near, so a prefix of it keeps the backdrop and throws away everything nearest the camera, which is the largest thing on screen. `defaultSplatBudget` reads the renderer's own name and lowers itself for the parts this engine has measured struggling; both numbers are reasoned estimates rather than handset measurements, and a consumer holding a real one should pass it. Each batch has its own transform, so two captures compose in one scene, and a capture out of frame is skipped **before** its sort rather than after \u2014 a discarded draw call is cheap and a linear pass over a million splats is not. **And `.drft` 1.5 carries a capture, in blocks laid out so any prefix of the file is the whole place.** The container writes splats interleaved across the whole capture \u2014 sorted by Morton code, then walked bit-reversed \u2014 so the first block that lands is a complete sparse version of it rather than a finished corner. Measured on the engine's own page: 662 splats out of 24 KB of a 332 KB file, drawing the whole room, with the rest densifying it. The chunk is optional, so an older reader skips it in silence, and a file may now carry a capture and no geometry at all. What is still absent, and worth knowing: colour comes from the spherical-harmonic DC term alone, so a capture's speculars read painted on rather than shifting as you move.",
1785
1801
  "short": "Gaussian splat captures draw into your scene on both backends, sorted off the frame in a worker and budgeted by screen-space size. `.drft` streams them coarse first."
1786
1802
  },
1787
1803
  {
1788
1804
  "kind": "fixed",
1789
- "text": "**A streaming capture drew up to a thousand splats short on WebGL2, at full density and never earlier.** Splat blocks are pushed into the data texture a run of whole rows at a time, and a capture whose count is not a multiple of a row ends in a partial one — so the last block's source array was shorter than the rectangle it was handed to. WebGPU had a pad for that and WebGL2 did not: `texSubImage2D` raises `INVALID_OPERATION` and **skips the upload**, so the final row kept the zeroes it was allocated with and the capture drew with its tail missing. Invisible to every test, because the arithmetic is correct for every block but the last and the missing splats are scattered across the whole capture rather than gathered anywhere a reader would look. Found by the thing that finds this class: the two backends are a control for each other, and the same page came back 59,585 pixels of 870,400 apart at full density and one pixel apart at one block. The row-covering guard is now one implementation both halves call, and the figure is zero.",
1805
+ "text": "**A streaming capture drew up to a thousand splats short on WebGL2, at full density and never earlier.** Splat blocks are pushed into the data texture a run of whole rows at a time, and a capture whose count is not a multiple of a row ends in a partial one \u2014 so the last block's source array was shorter than the rectangle it was handed to. WebGPU had a pad for that and WebGL2 did not: `texSubImage2D` raises `INVALID_OPERATION` and **skips the upload**, so the final row kept the zeroes it was allocated with and the capture drew with its tail missing. Invisible to every test, because the arithmetic is correct for every block but the last and the missing splats are scattered across the whole capture rather than gathered anywhere a reader would look. Found by the thing that finds this class: the two backends are a control for each other, and the same page came back 59,585 pixels of 870,400 apart at full density and one pixel apart at one block. The row-covering guard is now one implementation both halves call, and the figure is zero.",
1790
1806
  "short": "A streaming splat capture lost up to a thousand splats on WebGL2 at full density: the last partial row of an upload was skipped there and padded on WebGPU."
1791
1807
  }
1792
1808
  ]
@@ -1797,7 +1813,7 @@
1797
1813
  "entries": [
1798
1814
  {
1799
1815
  "kind": "fixed",
1800
- "text": "**Metals read as metal again, and the GGX prefilter is now something a scene opts into.** The prefilter shipped in 3.2.0 wired straight into the lit pass, which changed the picture of every consumer that had a reflection probe — the one thing a render-quality option exists to prevent, since a feature's default has to reproduce today's picture. A box-filtered level is a smaller picture of the room; a prefiltered level is the room convolved with the lobe for the roughness that level stands for, so at the same material roughness it is far blurrier. That is the physically correct answer and it is not what existing content was authored for: it was reported as metals going opaque. Two things moved together and neither works alone. **The level**: `envLod` floored at a texel footprint, which is right for a box chain where a coarser level costs detail and nothing else, and against a lobe chain says *this surface is rough* — measured on a 512 probe with panels swinging 0.03 radians a pixel, an enforced roughness of **0.37 on every metal** whatever its material map said. **The weight**: the split sum is the correct share of a *prefiltered* environment and over a box chain asserts an integral that was never performed, flattening the grazing-to-head-on contrast that reads as gloss from about **15 to 1 down to 5 to 1** at roughness 0.5. `RenderQuality.environmentPrefilter` switches both halves together and is **off by default**, so the arithmetic is exactly what the engine carried before the lighting track. With it on, the footprint becomes a roughness combined in quadrature rather than a floor on a level.",
1816
+ "text": "**Metals read as metal again, and the GGX prefilter is now something a scene opts into.** The prefilter shipped in 3.2.0 wired straight into the lit pass, which changed the picture of every consumer that had a reflection probe \u2014 the one thing a render-quality option exists to prevent, since a feature's default has to reproduce today's picture. A box-filtered level is a smaller picture of the room; a prefiltered level is the room convolved with the lobe for the roughness that level stands for, so at the same material roughness it is far blurrier. That is the physically correct answer and it is not what existing content was authored for: it was reported as metals going opaque. Two things moved together and neither works alone. **The level**: `envLod` floored at a texel footprint, which is right for a box chain where a coarser level costs detail and nothing else, and against a lobe chain says *this surface is rough* \u2014 measured on a 512 probe with panels swinging 0.03 radians a pixel, an enforced roughness of **0.37 on every metal** whatever its material map said. **The weight**: the split sum is the correct share of a *prefiltered* environment and over a box chain asserts an integral that was never performed, flattening the grazing-to-head-on contrast that reads as gloss from about **15 to 1 down to 5 to 1** at roughness 0.5. `RenderQuality.environmentPrefilter` switches both halves together and is **off by default**, so the arithmetic is exactly what the engine carried before the lighting track. With it on, the footprint becomes a roughness combined in quadrature rather than a floor on a level.",
1801
1817
  "short": "Metals read as metal again: the GGX prefilter is opt-in through RenderQuality.environmentPrefilter and off by default, so an existing scene renders as it did before 3.2.0."
1802
1818
  },
1803
1819
  {
@@ -1813,17 +1829,17 @@
1813
1829
  "entries": [
1814
1830
  {
1815
1831
  "kind": "fixed",
1816
- "text": "**A perfect reflector is no longer asked to absorb, and metals stop reading like paint.** `envBrdfApprox`, the split sum's analytic second half, integrates a *single* scattering event: a ray that strikes one microfacet, bounces off a second and leaves is energy the surface returned and the fit never counted. Its total comes to `1 - 0.55 x roughness`, and for a fully metallic texel — where the Fresnel base is 1 — that total **is** the whole reflection weight, so the shading asserted that a perfect mirror absorbs over half the light once it is roughened. Roughness scatters light; it does not absorb it. Measured against the weight this replaced: **22% of the reflection gone at roughness 0.4, 33% at 0.6, 44% at 0.8 and 55% at 1.0**, which is a metal losing its room and reading flat and opaque. The repair is multiple-scattering compensation, and at a Fresnel base of 1 it returns **exactly 1.0 at every roughness and every view angle** — so a metal gets back precisely the weight it had before the split sum landed, and a dielectric gains the small amount it was always owed. Every published scene is **0 of 921,600 pixels** against the build before it, on both backends.",
1832
+ "text": "**A perfect reflector is no longer asked to absorb, and metals stop reading like paint.** `envBrdfApprox`, the split sum's analytic second half, integrates a *single* scattering event: a ray that strikes one microfacet, bounces off a second and leaves is energy the surface returned and the fit never counted. Its total comes to `1 - 0.55 x roughness`, and for a fully metallic texel \u2014 where the Fresnel base is 1 \u2014 that total **is** the whole reflection weight, so the shading asserted that a perfect mirror absorbs over half the light once it is roughened. Roughness scatters light; it does not absorb it. Measured against the weight this replaced: **22% of the reflection gone at roughness 0.4, 33% at 0.6, 44% at 0.8 and 55% at 1.0**, which is a metal losing its room and reading flat and opaque. The repair is multiple-scattering compensation, and at a Fresnel base of 1 it returns **exactly 1.0 at every roughness and every view angle** \u2014 so a metal gets back precisely the weight it had before the split sum landed, and a dielectric gains the small amount it was always owed. Every published scene is **0 of 921,600 pixels** against the build before it, on both backends.",
1817
1833
  "short": "Metals reflect their surroundings fully again: the environment BRDF was losing up to 55% of a metal's reflection at high roughness by ignoring multiple scattering."
1818
1834
  },
1819
1835
  {
1820
1836
  "kind": "fixed",
1821
- "text": "**A light is offered a shadow map for whatever might cast, not for wherever the camera is.** `selectPointLights` took one reference point and used it for two decisions: ordering and culling the *shaded* list, which is a question about the camera, and ordering and culling the *shadow pool*, which is a question about whatever might cast. A third-person camera sits behind its subject, so a fire the subject was standing next to got **no map at all** whenever the camera was further from it than the fire's radius — and the scene stayed perfectly lit, which is why nothing distinguished it from a bake that never ran. It now takes a casting reference point beside the shading one, and the guard and the pool's rank both read it; reading two would fill the pool nearest-to-camera and then evict the very lamp the subject stands under. **The default is the shading point and the arithmetic is unchanged**, so a caller that says nothing selects bit-identically.",
1837
+ "text": "**A light is offered a shadow map for whatever might cast, not for wherever the camera is.** `selectPointLights` took one reference point and used it for two decisions: ordering and culling the *shaded* list, which is a question about the camera, and ordering and culling the *shadow pool*, which is a question about whatever might cast. A third-person camera sits behind its subject, so a fire the subject was standing next to got **no map at all** whenever the camera was further from it than the fire's radius \u2014 and the scene stayed perfectly lit, which is why nothing distinguished it from a bake that never ran. It now takes a casting reference point beside the shading one, and the guard and the pool's rank both read it; reading two would fill the pool nearest-to-camera and then evict the very lamp the subject stands under. **The default is the shading point and the arithmetic is unchanged**, so a caller that says nothing selects bit-identically.",
1822
1838
  "short": "A point light is offered a shadow map for where a caster stands rather than where the camera is, so a third-person subject keeps the shadow of the light beside it."
1823
1839
  },
1824
1840
  {
1825
1841
  "kind": "added",
1826
- "text": "**A registered pass is told the frame's depth format.** `PassDevice` carried the colour format and the sample count because a render pipeline cannot be built without them and a package has no other way to learn what the frame is. The same is true of the depth format, and it was missing — so a contributed pass that wants to be occluded by the world had to guess it. It rides along now, on the same argument.",
1842
+ "text": "**A registered pass is told the frame's depth format.** `PassDevice` carried the colour format and the sample count because a render pipeline cannot be built without them and a package has no other way to learn what the frame is. The same is true of the depth format, and it was missing \u2014 so a contributed pass that wants to be occluded by the world had to guess it. It rides along now, on the same argument.",
1827
1843
  "short": "PassDevice carries the frame's depth format, so a pass contributed by a package can depth-test against the world instead of guessing the attachment."
1828
1844
  }
1829
1845
  ]
@@ -1834,12 +1850,12 @@
1834
1850
  "entries": [
1835
1851
  {
1836
1852
  "kind": "fixed",
1837
- "text": "**A moving caster is shadowed by a point light again — and had never been.** `PointShadowSystem.advance` walked its own pool and never reached the live pair, which belongs to `LivePointShadowSet`, so that pair's arrival ramp never moved. The resolved weight is the crossfade weight times the image's presence, and the product was zero on every frame this engine has ever rendered: the shader multiplied a correct occlusion by nothing. Every other part of the path reported itself healthy, which is why it survived — the octahedral layer held the caster at 189 texels of 16,384 in one position and 57 in another, `hasBaked` was true, the layer index and far plane were published correctly, and the crossfade weight was 1. Only presence was 0. A moving caster now measures 0.06 shadowed where it measured 1.00, at every position, identically on both backends. **No published scene could have caught this**, because every point-lit demo passes no moving casters, which is why `demo/dev/pointshadow.html` now exists.",
1853
+ "text": "**A moving caster is shadowed by a point light again \u2014 and had never been.** `PointShadowSystem.advance` walked its own pool and never reached the live pair, which belongs to `LivePointShadowSet`, so that pair's arrival ramp never moved. The resolved weight is the crossfade weight times the image's presence, and the product was zero on every frame this engine has ever rendered: the shader multiplied a correct occlusion by nothing. Every other part of the path reported itself healthy, which is why it survived \u2014 the octahedral layer held the caster at 189 texels of 16,384 in one position and 57 in another, `hasBaked` was true, the layer index and far plane were published correctly, and the crossfade weight was 1. Only presence was 0. A moving caster now measures 0.06 shadowed where it measured 1.00, at every position, identically on both backends. **No published scene could have caught this**, because every point-lit demo passes no moving casters, which is why `demo/dev/pointshadow.html` now exists.",
1838
1854
  "short": "A moving caster is shadowed by a point light again, and had never been: the live shadow pair's arrival ramp was never advanced, so its weight was always zero."
1839
1855
  },
1840
1856
  {
1841
1857
  "kind": "fixed",
1842
- "text": "**Calling `updatePointShadows` before `prepareStaticPointShadows` now says so.** That second call builds the octahedral array and is a separate one a consumer makes once; forgetting it was completely silent — the update ran, lights were selected, the bake budget was spent every frame, and no light cast anything anywhere. The picture is indistinguishable from a world whose lamps do not cast. Both backends now warn once, naming the call to make, and return rather than baking into nothing.",
1858
+ "text": "**Calling `updatePointShadows` before `prepareStaticPointShadows` now says so.** That second call builds the octahedral array and is a separate one a consumer makes once; forgetting it was completely silent \u2014 the update ran, lights were selected, the bake budget was spent every frame, and no light cast anything anywhere. The picture is indistinguishable from a world whose lamps do not cast. Both backends now warn once, naming the call to make, and return rather than baking into nothing.",
1843
1859
  "short": "Calling updatePointShadows before prepareStaticPointShadows warns instead of silently baking into nothing and casting no shadows at all."
1844
1860
  },
1845
1861
  {
@@ -1849,27 +1865,27 @@
1849
1865
  },
1850
1866
  {
1851
1867
  "kind": "added",
1852
- "text": "**An environment can be loaded rather than only photographed.** `readRadianceHdr` reads Radiance RGBE — both scanline encodings, the mantissa at the centre of its bucket rather than the bottom, a zero exponent as exactly black, and every refusal naming what it found — and `renderer.setEnvironmentImage` takes the equirectangular image, resamples it onto six faces at the probe's own size, and runs the same convolution and the same projection a bake runs. Downstream of the cube it **is** a bake, so a loaded sky is not a second lighting path that can be wrong on its own. Opt-in exactly as SDF text is: a consumer that never loads an environment fetches nothing, ships no decoder, and the engine carries no image of its own.",
1868
+ "text": "**An environment can be loaded rather than only photographed.** `readRadianceHdr` reads Radiance RGBE \u2014 both scanline encodings, the mantissa at the centre of its bucket rather than the bottom, a zero exponent as exactly black, and every refusal naming what it found \u2014 and `renderer.setEnvironmentImage` takes the equirectangular image, resamples it onto six faces at the probe's own size, and runs the same convolution and the same projection a bake runs. Downstream of the cube it **is** a bake, so a loaded sky is not a second lighting path that can be wrong on its own. Opt-in exactly as SDF text is: a consumer that never loads an environment fetches nothing, ships no decoder, and the engine carries no image of its own.",
1853
1869
  "short": "An environment can be loaded from a Radiance .hdr rather than only photographed from the scene, joining the same prefilter and projection a bake uses."
1854
1870
  },
1855
1871
  {
1856
1872
  "kind": "added",
1857
- "text": "**Spot lights, as a point light with a direction rather than a second kind of light.** A spot rides the same sixteen-wide record, the same selection, the same froxel table and the same octahedral shadow layer. A light that declares no cone carries cosines of −1 and −2, so the cone term returns its upper bound outright for every direction on the sphere and a point light's arithmetic is unchanged — every published scene is pixel-identical against the build before it. The inner edge is clamped so it can never pass the outer, and the direction is normalised rather than trusted, because a cone compares against a cosine and an un-normalised axis gives a cone of the wrong width. A spot's shadow takes an octahedral layer covering the whole sphere, so a 45 degree cone uses about 15% of it, roughly 0.38 of the linear resolution — comfortable for a lamp, coarse for a narrow stage spotlight.",
1873
+ "text": "**Spot lights, as a point light with a direction rather than a second kind of light.** A spot rides the same sixteen-wide record, the same selection, the same froxel table and the same octahedral shadow layer. A light that declares no cone carries cosines of \u22121 and \u22122, so the cone term returns its upper bound outright for every direction on the sphere and a point light's arithmetic is unchanged \u2014 every published scene is pixel-identical against the build before it. The inner edge is clamped so it can never pass the outer, and the direction is normalised rather than trusted, because a cone compares against a cosine and an un-normalised axis gives a cone of the wrong width. A spot's shadow takes an octahedral layer covering the whole sphere, so a 45 degree cone uses about 15% of it, roughly 0.38 of the linear resolution \u2014 comfortable for a lamp, coarse for a narrow stage spotlight.",
1858
1874
  "short": "Spot lights, built as a point light with a direction and a cone that collapses exactly, so every existing scene renders unchanged."
1859
1875
  },
1860
1876
  {
1861
1877
  "kind": "added",
1862
- "text": "**IES photometry, so a light throws what a real fixture throws.** `readIesProfile` reads IESNA LM-63 whitespace-delimited across line breaks, because every published file wraps its numeric lists and a line-oriented reader fails on all of them, and both scaling factors are applied because a file means their product. `packIesAtlas` puts any number of profiles into one texture, a row each, and every row spans the same 0 to 180 degree arc whatever the file measured — which is what lets a light carry only a row index. It applies to a point light as readily as to a spot, since a bare bulb has a measured distribution too. Asymmetric fixtures are flattened to their first horizontal plane, which is stated rather than hidden.",
1878
+ "text": "**IES photometry, so a light throws what a real fixture throws.** `readIesProfile` reads IESNA LM-63 whitespace-delimited across line breaks, because every published file wraps its numeric lists and a line-oriented reader fails on all of them, and both scaling factors are applied because a file means their product. `packIesAtlas` puts any number of profiles into one texture, a row each, and every row spans the same 0 to 180 degree arc whatever the file measured \u2014 which is what lets a light carry only a row index. It applies to a point light as readily as to a spot, since a bare bulb has a measured distribution too. Asymmetric fixtures are flattened to their first horizontal plane, which is stated rather than hidden.",
1863
1879
  "short": "IES photometric profiles: a light is shaped by a real fixture's measured distribution, packed one row per profile into a single texture."
1864
1880
  },
1865
1881
  {
1866
1882
  "kind": "added",
1867
- "text": "**Rectangular area lights, with what is exact and what is approximate stated rather than blurred.** The diffuse half is the closed-form cosine-weighted integral over the polygon, which is what a linearly transformed cosine reduces to at the identity — so that half is not approximated at all. The specular is a representative point on the rectangle handed to the existing sphere lobe, and it diverges from a fitted LTC at grazing angles where the real lobe stretches along the view. **No fitted tables and no texture unit**, so this is not LTC and is not described as it. The sign of the form factor is the sidedness, exact per fragment rather than a facing test about the rectangle's centre: a one-sided emitter takes its positive part, a two-sided one its magnitude. Four at once. **They cast no shadow**, knowingly: the shadow pool is octahedral and centred on a point and a rectangle is not one, which is recorded as a gap rather than left to be discovered.",
1883
+ "text": "**Rectangular area lights, with what is exact and what is approximate stated rather than blurred.** The diffuse half is the closed-form cosine-weighted integral over the polygon, which is what a linearly transformed cosine reduces to at the identity \u2014 so that half is not approximated at all. The specular is a representative point on the rectangle handed to the existing sphere lobe, and it diverges from a fitted LTC at grazing angles where the real lobe stretches along the view. **No fitted tables and no texture unit**, so this is not LTC and is not described as it. The sign of the form factor is the sidedness, exact per fragment rather than a facing test about the rectangle's centre: a one-sided emitter takes its positive part, a two-sided one its magnitude. Four at once. **They cast no shadow**, knowingly: the shadow pool is octahedral and centred on a point and a rectangle is not one, which is recorded as a gap rather than left to be discovered.",
1868
1884
  "short": "Rectangular area lights: the exact analytic polygon integral for the diffuse half and a representative point for the specular. They cast no shadow yet."
1869
1885
  },
1870
1886
  {
1871
1887
  "kind": "changed",
1872
- "text": "**Track C is complete**, and two of its items were built differently from their design, each reversed on a measurement. Area lights are a branch on a count rather than a shader permutation, because the term costs 44,984 gzipped bytes compiled in against the 196,910 that doubling the permutation corpus costs — which every consumer pays whether or not they enable anything. That is the same reversal clustered lighting made. The three additions this release also measured what shared lit-pass code costs: 6,215 gzipped bytes for thirty lines, 10,517 for fifty and 44,984 for ninety, tracking generated operands rather than authored lines — so a term full of inverse cosines is far dearer than one full of branches. `scripts/size-gate.test.mjs` carries the table.",
1888
+ "text": "**Track C is complete**, and two of its items were built differently from their design, each reversed on a measurement. Area lights are a branch on a count rather than a shader permutation, because the term costs 44,984 gzipped bytes compiled in against the 196,910 that doubling the permutation corpus costs \u2014 which every consumer pays whether or not they enable anything. That is the same reversal clustered lighting made. The three additions this release also measured what shared lit-pass code costs: 6,215 gzipped bytes for thirty lines, 10,517 for fifty and 44,984 for ninety, tracking generated operands rather than authored lines \u2014 so a term full of inverse cosines is far dearer than one full of branches. `scripts/size-gate.test.mjs` carries the table.",
1873
1889
  "short": "Track C is complete. Area lights are a runtime branch rather than a permutation, reversed on a size measurement as clustered lighting was."
1874
1890
  }
1875
1891
  ]
@@ -1880,7 +1896,7 @@
1880
1896
  "entries": [
1881
1897
  {
1882
1898
  "kind": "changed",
1883
- "text": "**The WebGPU frame graph is on by default.** Every drawing verb is recorded rather than executed, and the schedule opens the frame's pass and the mirror's, derives their load and store operations, and drops passes nothing reads. Turning it on changes **nothing a consumer can see**: all seven published scenes come back 0 of 921,600 pixels changed against `?graph=0`, whole frame, with no device errors on either path, measured on an AMD RX 9070 XT. `?graph=0` turns it off in one reload, because a switch whose default has moved has to stay bisectable in both directions. WebGL2 has no verb-level graph and is untouched. **Two defects had kept it off, and both were the same shape — a decision taken at one moment and used at another, which only recording can separate.** A draw took the pipeline of whichever pass happened to be open rather than of the pass it would land in, which for a recorded draw are separated by a whole flush. And a reflection probe bake never replayed what its own callback recorded, so **not one draw reached the cube**: six with the graph off and zero with it on, meaning the probe stored its clear colour and a mirror reflected an empty room, with nothing anywhere reporting it. Both are fixed, both are held by tests that fail on the unfixed renderer for the right reason, and the fix for the first had its own stated assumption falsified by the second within the hour — a bake legitimately wants the scene's pipelines after the frame is presented, which the first fix had assumed nothing did.",
1899
+ "text": "**The WebGPU frame graph is on by default.** Every drawing verb is recorded rather than executed, and the schedule opens the frame's pass and the mirror's, derives their load and store operations, and drops passes nothing reads. Turning it on changes **nothing a consumer can see**: all seven published scenes come back 0 of 921,600 pixels changed against `?graph=0`, whole frame, with no device errors on either path, measured on an AMD RX 9070 XT. `?graph=0` turns it off in one reload, because a switch whose default has moved has to stay bisectable in both directions. WebGL2 has no verb-level graph and is untouched. **Two defects had kept it off, and both were the same shape \u2014 a decision taken at one moment and used at another, which only recording can separate.** A draw took the pipeline of whichever pass happened to be open rather than of the pass it would land in, which for a recorded draw are separated by a whole flush. And a reflection probe bake never replayed what its own callback recorded, so **not one draw reached the cube**: six with the graph off and zero with it on, meaning the probe stored its clear colour and a mirror reflected an empty room, with nothing anywhere reporting it. Both are fixed, both are held by tests that fail on the unfixed renderer for the right reason, and the fix for the first had its own stated assumption falsified by the second within the hour \u2014 a bake legitimately wants the scene's pipelines after the frame is presented, which the first fix had assumed nothing did.",
1884
1900
  "short": "The WebGPU frame graph is on by default. Seven published scenes are pixel-identical against it turned off, and two defects that only recording could expose are fixed."
1885
1901
  }
1886
1902
  ]
@@ -1891,17 +1907,17 @@
1891
1907
  "entries": [
1892
1908
  {
1893
1909
  "kind": "changed",
1894
- "text": "**The mix is a tree, and `AudioGraph` no longer holds it.** Buses nest to any depth, each one a fader into an insert chain into a send tap into an output, and a send is taken after the fader so turning a bus down turns down what it sends. Solo silences siblings and leaves ancestors and descendants alone, because anything else silences a soloed bus through its own parent; mute survives a solo it is part of, since the two answer different questions and a solo that cleared somebody's mute would have overruled a decision it was never asked about. A bus created while somebody is soloing arrives already gated, because the gate is a property of the tree rather than an event that happened once. Snapshots capture levels, mutes and sends and deliberately not insert parameters — the master cutoff is written every frame from a game's own state, and a snapshot that captured it would fight that writer with the last write winning. **Every mixing method on `AudioGraph` is removed rather than left forwarding**, because a shim that works forever is a second answer to every question the console already answers and the two drift the first time one grows a clamp; `PORTING.md` maps all thirteen one for one, and every site is a compiler error naming the member. The transport is unchanged. **The rewrite is asserted rather than claimed**: an offline render of the old mix was frozen before a single source file moved, and the tree came back bit-for-bit identical to it on both channels — which is the audio equivalent of the zero-pixel rule the renderer is gated on, and it is now a standing gate at `scripts/audio-baseline.mjs`.",
1910
+ "text": "**The mix is a tree, and `AudioGraph` no longer holds it.** Buses nest to any depth, each one a fader into an insert chain into a send tap into an output, and a send is taken after the fader so turning a bus down turns down what it sends. Solo silences siblings and leaves ancestors and descendants alone, because anything else silences a soloed bus through its own parent; mute survives a solo it is part of, since the two answer different questions and a solo that cleared somebody's mute would have overruled a decision it was never asked about. A bus created while somebody is soloing arrives already gated, because the gate is a property of the tree rather than an event that happened once. Snapshots capture levels, mutes and sends and deliberately not insert parameters \u2014 the master cutoff is written every frame from a game's own state, and a snapshot that captured it would fight that writer with the last write winning. **Every mixing method on `AudioGraph` is removed rather than left forwarding**, because a shim that works forever is a second answer to every question the console already answers and the two drift the first time one grows a clamp; `PORTING.md` maps all thirteen one for one, and every site is a compiler error naming the member. The transport is unchanged. **The rewrite is asserted rather than claimed**: an offline render of the old mix was frozen before a single source file moved, and the tree came back bit-for-bit identical to it on both channels \u2014 which is the audio equivalent of the zero-pixel rule the renderer is gated on, and it is now a standing gate at `scripts/audio-baseline.mjs`.",
1895
1911
  "short": "The mix is a tree of buses with inserts, solo, mute and snapshots. AudioGraph keeps the transport and its mixing methods are removed; PORTING.md maps every one."
1896
1912
  },
1897
1913
  {
1898
1914
  "kind": "added",
1899
- "text": "**Sound placed in the world: an HRTF listener, occlusion, reverb zones and doppler.** A listener carries position, facing and a velocity derived from successive positions rather than taken, with `warp` for a teleport so nothing derives twelve kilometres a second from a camera cut. Sources are panned with a head model instead of a stereo balance, which is what puts a sound *behind* somebody at all: measured on a real browser, a source three metres to the right is 4.15 times louder in the right channel and arrives 36 samples ahead of the left, 0.75 ms of interaural delay, and the mirrored source returns the two channels' figures swapped exactly. **Occlusion is a curve the engine owns over a number the game owns** — a game answers how blocked a path is through a probe closed over its own colliders, and the engine decides what that sounds like: a geometric sweep from 20 kHz to 500 Hz, twelve decibels down, smoothed in time so one rate means one thing at any frame rate. A fully occluded source is quieter and duller and never silent, because a sound that vanishes behind a wall teaches a player that walls delete things. **A reverb zone is a bus**, which is the payoff for the tree: a room is not a special case in the mixer, so it can be soloed and muted like anything else. At most two sound at once, the space being left and the space being entered. **Doppler is computed rather than configured**, because the specification removed it from `PannerNode`: measured, a tone closing at 60 m/s comes back at 1246 Hz against 1212 derived and receding at 852 against 851. It is off per source unless asked for, since on a looping bed a listener walking past makes the bed's pitch wander. The whole spatial layer is standalone functions rather than methods, so a bundler drops it for an application that never places a sound: the mix tree costs a core bundle 1,441 bytes gzipped and the spatial layer another 1,479 that only its users pay.",
1915
+ "text": "**Sound placed in the world: an HRTF listener, occlusion, reverb zones and doppler.** A listener carries position, facing and a velocity derived from successive positions rather than taken, with `warp` for a teleport so nothing derives twelve kilometres a second from a camera cut. Sources are panned with a head model instead of a stereo balance, which is what puts a sound *behind* somebody at all: measured on a real browser, a source three metres to the right is 4.15 times louder in the right channel and arrives 36 samples ahead of the left, 0.75 ms of interaural delay, and the mirrored source returns the two channels' figures swapped exactly. **Occlusion is a curve the engine owns over a number the game owns** \u2014 a game answers how blocked a path is through a probe closed over its own colliders, and the engine decides what that sounds like: a geometric sweep from 20 kHz to 500 Hz, twelve decibels down, smoothed in time so one rate means one thing at any frame rate. A fully occluded source is quieter and duller and never silent, because a sound that vanishes behind a wall teaches a player that walls delete things. **A reverb zone is a bus**, which is the payoff for the tree: a room is not a special case in the mixer, so it can be soloed and muted like anything else. At most two sound at once, the space being left and the space being entered. **Doppler is computed rather than configured**, because the specification removed it from `PannerNode`: measured, a tone closing at 60 m/s comes back at 1246 Hz against 1212 derived and receding at 852 against 851. It is off per source unless asked for, since on a looping bed a listener walking past makes the bed's pitch wander. The whole spatial layer is standalone functions rather than methods, so a bundler drops it for an application that never places a sound: the mix tree costs a core bundle 1,441 bytes gzipped and the spatial layer another 1,479 that only its users pay.",
1900
1916
  "short": "An HRTF listener, occlusion, reverb zones and doppler. Measured on a real browser rather than asserted, and the whole layer costs nothing to an app that never places a sound."
1901
1917
  },
1902
1918
  {
1903
1919
  "kind": "fixed",
1904
- "text": "**A music fade landed at the start of an exported clip instead of where it was asked for.** `AudioGraph.fadeMusic` scheduled on `context.currentTime` rather than on the graph's own clock. Live the two agree, so nothing was ever wrong in the game; offline they do not, because an `OfflineAudioContext` holds `currentTime` at zero for the whole time a timeline is being described — so in a rendered clip every fade landed at instant zero however late `at()` said to put it, and a clip that should have ducked only at its end had been rendering ducked throughout. Measured on this repository's own reference render at the moment of the change: RMS 0.129 against 0.344, with every quarter of the render moved and the last one moved least. The replacement, `MixBus.duck`, schedules on the mix's clock like everything else and takes a multiplier over the fader rather than an absolute level, so coming back out of a fade is `duck(1, seconds)` and the level to return to stops being something the caller has to have remembered across a settings change.",
1920
+ "text": "**A music fade landed at the start of an exported clip instead of where it was asked for.** `AudioGraph.fadeMusic` scheduled on `context.currentTime` rather than on the graph's own clock. Live the two agree, so nothing was ever wrong in the game; offline they do not, because an `OfflineAudioContext` holds `currentTime` at zero for the whole time a timeline is being described \u2014 so in a rendered clip every fade landed at instant zero however late `at()` said to put it, and a clip that should have ducked only at its end had been rendering ducked throughout. Measured on this repository's own reference render at the moment of the change: RMS 0.129 against 0.344, with every quarter of the render moved and the last one moved least. The replacement, `MixBus.duck`, schedules on the mix's clock like everything else and takes a multiplier over the fader rather than an absolute level, so coming back out of a fade is `duck(1, seconds)` and the level to return to stops being something the caller has to have remembered across a settings change.",
1905
1921
  "short": "A music fade landed at instant zero in an exported clip instead of where it was scheduled. Live playback was never affected; rendered clips were, and had been all along."
1906
1922
  }
1907
1923
  ]
@@ -1912,7 +1928,7 @@
1912
1928
  "entries": [
1913
1929
  {
1914
1930
  "kind": "added",
1915
- "text": "**Action maps and rebinding, which complete Track H.** A game asks whether *jump* happened rather than which device said so, so the device branch stops being written by hand at every site that reads input and widened by every device added after. An action name is the consumer's own string and never an engine enumeration, because a game's verbs are a game's business. Digital actions answer the same three verbs a pad does, `down`, `pressed` and `consumePress`, so there is one shape to learn rather than two, and `consumePress` claims through the device itself rather than keeping a second register: two claim registers over one physical press is how a press gets acted on twice. Analog actions answer a direction instead of a state, and take **the larger of stick and keys rather than the sum** — a player holding a key while pushing the stick the same way is asking to go that way once, not at twice the speed, which is the bug a naive merge ships with. A keyboard diagonal is normalised so it is not faster than a straight line, and key directions follow the stick's own convention, where up is negative, rather than the engine silently disagreeing with the API it wraps. `rebind` **reports the actions it displaced rather than deciding about them**, since stealing a binding silently, warning, or refusing is a product decision; a game that wants uniqueness enforced reads the return and rebinds back. Persistence is **a diff from the defaults, not a snapshot**: a snapshot freezes the map at the version that saved it, so a game that later adds an action, or changes a default nobody had touched, would find every returning player still on the old set with no way to tell a deliberate choice from a stale record. It goes through `KeyValueStore`, so bindings can live in a browser, a native shell, on a server or nowhere, and an unreadable record is the defaults reported once rather than a broken game. Rumble is the only input capability still absent, and it was never in the track.",
1931
+ "text": "**Action maps and rebinding, which complete Track H.** A game asks whether *jump* happened rather than which device said so, so the device branch stops being written by hand at every site that reads input and widened by every device added after. An action name is the consumer's own string and never an engine enumeration, because a game's verbs are a game's business. Digital actions answer the same three verbs a pad does, `down`, `pressed` and `consumePress`, so there is one shape to learn rather than two, and `consumePress` claims through the device itself rather than keeping a second register: two claim registers over one physical press is how a press gets acted on twice. Analog actions answer a direction instead of a state, and take **the larger of stick and keys rather than the sum** \u2014 a player holding a key while pushing the stick the same way is asking to go that way once, not at twice the speed, which is the bug a naive merge ships with. A keyboard diagonal is normalised so it is not faster than a straight line, and key directions follow the stick's own convention, where up is negative, rather than the engine silently disagreeing with the API it wraps. `rebind` **reports the actions it displaced rather than deciding about them**, since stealing a binding silently, warning, or refusing is a product decision; a game that wants uniqueness enforced reads the return and rebinds back. Persistence is **a diff from the defaults, not a snapshot**: a snapshot freezes the map at the version that saved it, so a game that later adds an action, or changes a default nobody had touched, would find every returning player still on the old set with no way to tell a deliberate choice from a stale record. It goes through `KeyValueStore`, so bindings can live in a browser, a native shell, on a server or nowhere, and an unreadable record is the defaults reported once rather than a broken game. Rumble is the only input capability still absent, and it was never in the track.",
1916
1932
  "short": "Action maps and rebinding complete Track H: one name answers for any device, and stored bindings are a diff from the defaults so a later release can still add an action."
1917
1933
  },
1918
1934
  {
@@ -1949,12 +1965,12 @@
1949
1965
  "entries": [
1950
1966
  {
1951
1967
  "kind": "added",
1952
- "text": "**Two primitives for racing a recording against a live run.** `TickTrace` keeps what a fixed-step simulation produced, tick by tick, as a flat float array plus one byte of flags: an intent stream has no keyframes, so reaching tick 4,000 of a replay means running the four thousand before it, and doing that once up front turns a replay from a simulation into a lookup. Its `tickAtOrBefore` is the method worth reading — given a channel that only rises it answers which tick crossed a value, which is how two runs of one course are compared *at the same point on the course* rather than at the same moment. Comparing by time drifts the instant somebody takes a wider line. It returns the **last** tick of a run of equal values, because a channel stalls whenever the thing it measures stops, and answering the head of a plateau would report the other party ahead by the whole length of the stall. `fingerprintColliders` is the other half: a stable, order-independent hash of the static geometry a recording ran against, so a build that generates a world differently can refuse a stale replay instead of drawing a body moving through empty space. It reads the raw bytes of the bounds array rather than any decimal rendering, because a float's text is a platform question and its bit pattern is not, and it applies **no tolerance at all** — whether half a millimetre matters is the caller's judgement, and a hash that rounded would take that judgement from every caller at once.",
1968
+ "text": "**Two primitives for racing a recording against a live run.** `TickTrace` keeps what a fixed-step simulation produced, tick by tick, as a flat float array plus one byte of flags: an intent stream has no keyframes, so reaching tick 4,000 of a replay means running the four thousand before it, and doing that once up front turns a replay from a simulation into a lookup. Its `tickAtOrBefore` is the method worth reading \u2014 given a channel that only rises it answers which tick crossed a value, which is how two runs of one course are compared *at the same point on the course* rather than at the same moment. Comparing by time drifts the instant somebody takes a wider line. It returns the **last** tick of a run of equal values, because a channel stalls whenever the thing it measures stops, and answering the head of a plateau would report the other party ahead by the whole length of the stall. `fingerprintColliders` is the other half: a stable, order-independent hash of the static geometry a recording ran against, so a build that generates a world differently can refuse a stale replay instead of drawing a body moving through empty space. It reads the raw bytes of the bounds array rather than any decimal rendering, because a float's text is a platform question and its bit pattern is not, and it applies **no tolerance at all** \u2014 whether half a millimetre matters is the caller's judgement, and a hash that rounded would take that judgement from every caller at once.",
1953
1969
  "short": "TickTrace keeps what a fixed-step simulation produced so a replay becomes a lookup, and fingerprintColliders says which geometry it ran against."
1954
1970
  },
1955
1971
  {
1956
1972
  "kind": "changed",
1957
- "text": "**The capability map's Platform row now names what backs its claim.** It has said “replay-safe simulation path” since the map was written, with no primitive behind the words. Worth recording that no sentinel forced the correction: the sentinel list has no replay row, so the only guard that went red was the test-file count — and fixing a count while leaving the prose stale would have been the worst of both, a green build over a map that lies. Track J is untouched and `ROADMAP.md` now says so where somebody would look: these are the *offline* half, comparing a recorded run against a live one with no transport anywhere, and Track J is the live half. They share the determinism contract and nothing else.",
1973
+ "text": "**The capability map's Platform row now names what backs its claim.** It has said \u201creplay-safe simulation path\u201d since the map was written, with no primitive behind the words. Worth recording that no sentinel forced the correction: the sentinel list has no replay row, so the only guard that went red was the test-file count \u2014 and fixing a count while leaving the prose stale would have been the worst of both, a green build over a map that lies. Track J is untouched and `ROADMAP.md` now says so where somebody would look: these are the *offline* half, comparing a recorded run against a live one with no transport anywhere, and Track J is the live half. They share the determinism contract and nothing else.",
1958
1974
  "short": "The Platform row names TickTrace and fingerprintColliders, and the roadmap says why this is not Track J."
1959
1975
  }
1960
1976
  ]
@@ -1965,7 +1981,7 @@
1965
1981
  "entries": [
1966
1982
  {
1967
1983
  "kind": "added",
1968
- "text": "Every plan the packaging work set out with is finished. **Steam** moved into the process that can hold it: `steamworks.js` is a native Node module and the renderer runs sandboxed with no Node, so the first arrangement could never have run. The SDK now lives in the main process and crosses the same audited bridge as every other capability — a game gets cloud saves by naming an app id, with `host.store` unchanged and the main process deciding whether the bytes land in a file or in the cloud, and achievements arrive as `host.services`, which is null in a browser, null where no app is named, and null when the copy was launched outside Steam. The packager copies the SDK out of the game's own `node_modules` and unpacks it from the asar archive, because a `.node` binary inside one fails to load. `drift-package steam` writes the two files `steamcmd` uploads with, nothing set live. **`?report=1`** draws what a device says about itself over the running game — renderer string, WebGPU, secure context, platform, WebView build — on every platform, because a pinned desktop Chromium cannot see the class of failure the mobile tier exists to catch.",
1984
+ "text": "Every plan the packaging work set out with is finished. **Steam** moved into the process that can hold it: `steamworks.js` is a native Node module and the renderer runs sandboxed with no Node, so the first arrangement could never have run. The SDK now lives in the main process and crosses the same audited bridge as every other capability \u2014 a game gets cloud saves by naming an app id, with `host.store` unchanged and the main process deciding whether the bytes land in a file or in the cloud, and achievements arrive as `host.services`, which is null in a browser, null where no app is named, and null when the copy was launched outside Steam. The packager copies the SDK out of the game's own `node_modules` and unpacks it from the asar archive, because a `.node` binary inside one fails to load. `drift-package steam` writes the two files `steamcmd` uploads with, nothing set live. **`?report=1`** draws what a device says about itself over the running game \u2014 renderer string, WebGPU, secure context, platform, WebView build \u2014 on every platform, because a pinned desktop Chromium cannot see the class of failure the mobile tier exists to catch.",
1969
1985
  "short": "Steam runs in the main process where it can, cloud saves need no game change, and ?report=1 draws what a device says about itself over the running game."
1970
1986
  },
1971
1987
  {
@@ -1975,7 +1991,7 @@
1975
1991
  },
1976
1992
  {
1977
1993
  "kind": "fixed",
1978
- "text": "**The iOS origin is `drift://localhost/`, and the host is what makes it a secure context.** This was the open question that gated the whole tier, and it is answered from the specification rather than from a device: a custom scheme is not trustworthy to WebKit on its own, but W3C's *is origin potentially trustworthy* returns trustworthy for a host inside `127.0.0.0/8` or of `localhost` **without reference to the scheme** — which is what Capacitor has shipped as `capacitor://localhost` for years, its own documentation recommending the hostname stay `localhost` because it allows Web APIs that would otherwise require a secure context. So `drift://localhost/` is one and `drift://app/` is not, and the two differ by six characters. The familiar write-up about `_registerURLSchemeAsSecure` answers a different question — an https page fetching a custom-scheme *subresource*, not a document loaded from one — and its private selector is not needed here. There is no loopback server, which is what this was otherwise going to cost: a listening socket inside a game.",
1994
+ "text": "**The iOS origin is `drift://localhost/`, and the host is what makes it a secure context.** This was the open question that gated the whole tier, and it is answered from the specification rather than from a device: a custom scheme is not trustworthy to WebKit on its own, but W3C's *is origin potentially trustworthy* returns trustworthy for a host inside `127.0.0.0/8` or of `localhost` **without reference to the scheme** \u2014 which is what Capacitor has shipped as `capacitor://localhost` for years, its own documentation recommending the hostname stay `localhost` because it allows Web APIs that would otherwise require a secure context. So `drift://localhost/` is one and `drift://app/` is not, and the two differ by six characters. The familiar write-up about `_registerURLSchemeAsSecure` answers a different question \u2014 an https page fetching a custom-scheme *subresource*, not a document loaded from one \u2014 and its private selector is not needed here. There is no loopback server, which is what this was otherwise going to cost: a listening socket inside a game.",
1979
1995
  "short": "The iOS host serves from drift://localhost, which is a secure context because of the host; no loopback server and no private API are needed."
1980
1996
  }
1981
1997
  ]
@@ -1986,17 +2002,17 @@
1986
2002
  "entries": [
1987
2003
  {
1988
2004
  "kind": "added",
1989
- "text": "The packager reaches every platform it set out to, and an exit is something a game decides. **Android** ships a signed APK measured on a device: the game is served from the asset root an https origin gives it, the back gesture goes to the game before it closes anything — a registered quit handler, or the Escape key a pause menu is bound to, and only then two presses with a line on screen between them — and a download the WebView would have dropped is caught, read back from the page and written to Downloads through MediaStore with no permission asked for. **iOS** is written: one view controller holding a WKWebView, a scheme handler with the same containment rule the desktop shell has, and everything the engine reads synchronously injected as a literal before the page's first script, because that platform has no synchronous return from native code at all. It has not been compiled on a Mac, and the question that gates it — whether WebKit calls a custom scheme a secure context — ships beside it as a probe page that answers it in one look. **Steam** binds to the two seams that already fitted: cloud saves behind `KeyValueStore`, achievements and presence behind `PlatformServices`, `steamworks.js` an optional peer dependency imported through a variable so a game that never ships there never downloads a store SDK, and `drift-package steam` writes the depot scripts with nothing set live. `bootstrap` now fetches XcodeGen too, and `--project` points the whole command at another directory so a game keeps its manifest and none of the build tooling.",
2005
+ "text": "The packager reaches every platform it set out to, and an exit is something a game decides. **Android** ships a signed APK measured on a device: the game is served from the asset root an https origin gives it, the back gesture goes to the game before it closes anything \u2014 a registered quit handler, or the Escape key a pause menu is bound to, and only then two presses with a line on screen between them \u2014 and a download the WebView would have dropped is caught, read back from the page and written to Downloads through MediaStore with no permission asked for. **iOS** is written: one view controller holding a WKWebView, a scheme handler with the same containment rule the desktop shell has, and everything the engine reads synchronously injected as a literal before the page's first script, because that platform has no synchronous return from native code at all. It has not been compiled on a Mac, and the question that gates it \u2014 whether WebKit calls a custom scheme a secure context \u2014 ships beside it as a probe page that answers it in one look. **Steam** binds to the two seams that already fitted: cloud saves behind `KeyValueStore`, achievements and presence behind `PlatformServices`, `steamworks.js` an optional peer dependency imported through a variable so a game that never ships there never downloads a store SDK, and `drift-package steam` writes the depot scripts with nothing set live. `bootstrap` now fetches XcodeGen too, and `--project` points the whole command at another directory so a game keeps its manifest and none of the build tooling.",
1990
2006
  "short": "Android ships a measured APK, iOS is written, and Steam binds to the seams that already fitted; one command still fetches every toolchain."
1991
2007
  },
1992
2008
  {
1993
2009
  "kind": "changed",
1994
- "text": "`Lifecycle` gains `canQuit`, which is what an exit button should be drawn from. A browser cannot close its own tab and an iOS application does not exit itself, so a menu that always shows Exit has a button that does nothing on two of the platforms a game ships to — and a game that instead branches on whether it is in a shell has hard-coded an assumption that stops being true. It is `false` in a tab and on iOS, `true` in a desktop shell and on Android.",
2010
+ "text": "`Lifecycle` gains `canQuit`, which is what an exit button should be drawn from. A browser cannot close its own tab and an iOS application does not exit itself, so a menu that always shows Exit has a button that does nothing on two of the platforms a game ships to \u2014 and a game that instead branches on whether it is in a shell has hard-coded an assumption that stops being true. It is `false` in a tab and on iOS, `true` in a desktop shell and on Android.",
1995
2011
  "short": "Lifecycle.canQuit says whether an exit button belongs on screen, instead of a game guessing from the platform it thinks it is on."
1996
2012
  },
1997
2013
  {
1998
2014
  "kind": "fixed",
1999
- "text": "**WebGPU on Linux no longer costs clip export, and the refusal that said it did is gone.** The packager used to fail any Linux build asking for both, on a measurement that was true of the flags it was passing: `--use-angle=vulkan --enable-features=Vulkan` yields WebGPU and encodes canvas frames as all-zero YUV under Wayland — a green clip — with nothing reporting it, since `VideoEncoder.error` never fires and the muxer finalises a file of the expected size. `ForceEnableWebGpuInterop` composites WebGPU through GL rather than moving ANGLE onto Vulkan, and both survive: measured in a packaged build on an AMD RX 9070 XT, the adapter reports `amd rdna-4`, a WebGPU draw reads back 255,0,255,255 and a vp8 round trip comes back 255,0,254 rather than green. **And an Android build is served from the root its own paths point at**: a web build references `/assets/index-abc.js` absolutely, so served from a subdirectory every stylesheet and script resolved to nothing — the APK installed, launched, and showed unstyled text with no error anywhere. Found on a phone; the build now refuses a bundle whose entry page points at files that will not be there.",
2015
+ "text": "**WebGPU on Linux no longer costs clip export, and the refusal that said it did is gone.** The packager used to fail any Linux build asking for both, on a measurement that was true of the flags it was passing: `--use-angle=vulkan --enable-features=Vulkan` yields WebGPU and encodes canvas frames as all-zero YUV under Wayland \u2014 a green clip \u2014 with nothing reporting it, since `VideoEncoder.error` never fires and the muxer finalises a file of the expected size. `ForceEnableWebGpuInterop` composites WebGPU through GL rather than moving ANGLE onto Vulkan, and both survive: measured in a packaged build on an AMD RX 9070 XT, the adapter reports `amd rdna-4`, a WebGPU draw reads back 255,0,255,255 and a vp8 round trip comes back 255,0,254 rather than green. **And an Android build is served from the root its own paths point at**: a web build references `/assets/index-abc.js` absolutely, so served from a subdirectory every stylesheet and script resolved to nothing \u2014 the APK installed, launched, and showed unstyled text with no error anywhere. Found on a phone; the build now refuses a bundle whose entry page points at files that will not be there.",
2000
2016
  "short": "WebGPU and clip export both work on Linux with the right flag, and an Android build no longer loads its HTML and none of its code."
2001
2017
  }
2002
2018
  ]
@@ -2007,17 +2023,17 @@
2007
2023
  "entries": [
2008
2024
  {
2009
2025
  "kind": "added",
2010
- "text": "A game is packaged into an installable application, and the first two platforms work end to end. `@driftengine/package` is a seventh workspace package and a `drift-package` command: `bootstrap` fetches whatever a target needs, `doctor` says whether a project and a machine are ready and how a build would be signed, `run` starts the real shell from source, `build` produces artifacts, and `verify` greps the built bundle for a string from your diff, because an artifact that was never rebuilt looks exactly like one that was. **Desktop** is a bundled Chromium: the game is served over a `drift://` origin rather than `file://` so modules, workers and `fetch` work at all; one audited bridge crosses `contextIsolation`; there is no menu bar on Windows or Linux and a minimal one on macOS where Cmd+Q lives; the browser's own key bindings are refused in a shipped build; developer tools do not exist in one; and an external link opens in the person's own browser, with only http, https and mailto allowed to leave at all. **Android** is an APK around the system WebView, served from an https origin so it is a secure context, with the same bridge injected before the game's own scripts through a document-start script. Signing has three declared modes and every build prints which one it used: none, ad-hoc — which macOS needs, because Apple Silicon's loader refuses an unsigned binary outright — and real, from the environment. **iOS is specified and not built, and Steam is not integrated yet.** Measured on a development machine: an AppImage of a real game runs, WebGPU draws through it, saves persist, and a signed APK of the same build installs with developer mode on.",
2026
+ "text": "A game is packaged into an installable application, and the first two platforms work end to end. `@driftengine/package` is a seventh workspace package and a `drift-package` command: `bootstrap` fetches whatever a target needs, `doctor` says whether a project and a machine are ready and how a build would be signed, `run` starts the real shell from source, `build` produces artifacts, and `verify` greps the built bundle for a string from your diff, because an artifact that was never rebuilt looks exactly like one that was. **Desktop** is a bundled Chromium: the game is served over a `drift://` origin rather than `file://` so modules, workers and `fetch` work at all; one audited bridge crosses `contextIsolation`; there is no menu bar on Windows or Linux and a minimal one on macOS where Cmd+Q lives; the browser's own key bindings are refused in a shipped build; developer tools do not exist in one; and an external link opens in the person's own browser, with only http, https and mailto allowed to leave at all. **Android** is an APK around the system WebView, served from an https origin so it is a secure context, with the same bridge injected before the game's own scripts through a document-start script. Signing has three declared modes and every build prints which one it used: none, ad-hoc \u2014 which macOS needs, because Apple Silicon's loader refuses an unsigned binary outright \u2014 and real, from the environment. **iOS is specified and not built, and Steam is not integrated yet.** Measured on a development machine: an AppImage of a real game runs, WebGPU draws through it, saves persist, and a signed APK of the same build installs with developer mode on.",
2011
2027
  "short": "A game becomes an installable Linux, Windows, macOS or Android application through the new drift-package command, which also fetches its own toolchain."
2012
2028
  },
2013
2029
  {
2014
2030
  "kind": "changed",
2015
- "text": "The host seam is what a video settings screen is built on. `DisplayControl` gains `mode`, `setMode`, `size`, `setSize` and `displays`, so a game's own menu can offer window mode, a resolution and a monitor list and get real answers — a shell reports its displays with their refresh rates, and a browser answers `false` from the calls it genuinely cannot perform rather than doing nothing quietly, which is what lets a settings screen grey a control instead of offering one that does not work. **Two window modes and not three**: Chromium never takes an exclusive fullscreen, so a separate borderless setting would be the same thing under another name, and a game that wants fewer pixels changes `RenderQuality.resolutionScale` instead. `ScreenPresentation` is new beside it and is what a phone has instead of a window — orientation and its lock, safe-area insets, and the wake lock — each of which can be refused, and iOS refuses an orientation lock outright. Measured while packaging: `displayFrequency` is 0 on a Wayland session, so `refreshHz` is null there, which is the honest answer rather than a plausible 60 something would divide by.",
2031
+ "text": "The host seam is what a video settings screen is built on. `DisplayControl` gains `mode`, `setMode`, `size`, `setSize` and `displays`, so a game's own menu can offer window mode, a resolution and a monitor list and get real answers \u2014 a shell reports its displays with their refresh rates, and a browser answers `false` from the calls it genuinely cannot perform rather than doing nothing quietly, which is what lets a settings screen grey a control instead of offering one that does not work. **Two window modes and not three**: Chromium never takes an exclusive fullscreen, so a separate borderless setting would be the same thing under another name, and a game that wants fewer pixels changes `RenderQuality.resolutionScale` instead. `ScreenPresentation` is new beside it and is what a phone has instead of a window \u2014 orientation and its lock, safe-area insets, and the wake lock \u2014 each of which can be refused, and iOS refuses an orientation lock outright. Measured while packaging: `displayFrequency` is 0 on a Wayland session, so `refreshHz` is null there, which is the honest answer rather than a plausible 60 something would divide by.",
2016
2032
  "short": "Window mode, resolution, displays and refresh rate are now capabilities a game's own settings menu drives, with orientation and safe areas for phones."
2017
2033
  },
2018
2034
  {
2019
2035
  "kind": "fixed",
2020
- "text": "An inset that hangs off the edge of the canvas no longer throws on WebGPU. `setScissorRect` is `[EnforceRange] unsigned long` and a viewport must lie inside the attachment at both ends, so a negative origin threw — inside the frame, which lost every draw recorded after the inset, on every frame of the animation. An interface panel sliding in from the left edge is exactly that for a few frames. WebGL2 takes the same numbers without complaint, which is why no gate had ever shown it: it was found by running a real game inside the new desktop shell, whose forwarded console printed the error. The rectangle is now clamped to the attachment, and one entirely outside draws nothing rather than clamping to a zero-width rectangle, which is itself invalid. The residual difference is recorded in the parity ledger: for the frames where a panel is half off the edge, WebGL2 slides it and WebGPU pins it to the edge.",
2036
+ "text": "An inset that hangs off the edge of the canvas no longer throws on WebGPU. `setScissorRect` is `[EnforceRange] unsigned long` and a viewport must lie inside the attachment at both ends, so a negative origin threw \u2014 inside the frame, which lost every draw recorded after the inset, on every frame of the animation. An interface panel sliding in from the left edge is exactly that for a few frames. WebGL2 takes the same numbers without complaint, which is why no gate had ever shown it: it was found by running a real game inside the new desktop shell, whose forwarded console printed the error. The rectangle is now clamped to the attachment, and one entirely outside draws nothing rather than clamping to a zero-width rectangle, which is itself invalid. The residual difference is recorded in the parity ledger: for the frames where a panel is half off the edge, WebGL2 slides it and WebGPU pins it to the edge.",
2021
2037
  "short": "A partly off-screen inset threw on WebGPU and lost the rest of the frame; it is now clamped, and one entirely off screen draws nothing."
2022
2038
  }
2023
2039
  ]
@@ -2044,7 +2060,7 @@
2044
2060
  "entries": [
2045
2061
  {
2046
2062
  "kind": "changed",
2047
- "text": "WebGPU is the backend `createRenderer` picks, and WebGL2 is the fallback beneath it. It was the other way round, and the reason had expired: `preferWebGpu` defaulted to `false` with a comment deferring the flip to a numbered task in an implementation plan, while the engine's own demo page, the game and the studio each passed `true` explicitly. So the conservative default was overridden by every consumer that exists and true for none of them — a default outliving its reason, which is only visible to somebody reading the option and the call sites at the same time. **Re-measured before it was touched**, because the parity table on record was nine days and three fixes old: seven scenes, one unchanged build, both backends, at the delta threshold the ledger reads, on an AMD Radeon RX 9070 XT. Four scenes are inside the 0.5% gate and three are above it for the reason already attributed to them — high-contrast edge density, with neither backend the better one — and nothing regressed: `collapse` and `gilded-chamber` improved by about a third. **Nothing changes for a consumer already asking for WebGPU**, which is all of them here. What changes is the consumer who asks for nothing, and it now gets an adapter request in a boot that had none; `preferWebGpu: false` declines it and restores the old boot exactly. The fallback is unchanged and still silent, on the same three counts, and `?backend=webgl2` still forces it in every build. The console warning for a WebGPU request that was not honoured now fires only when a caller asked *explicitly*, because warning on the default would put a line in front of every player whose browser has no WebGPU at all — which is most of why the fallback exists.",
2063
+ "text": "WebGPU is the backend `createRenderer` picks, and WebGL2 is the fallback beneath it. It was the other way round, and the reason had expired: `preferWebGpu` defaulted to `false` with a comment deferring the flip to a numbered task in an implementation plan, while the engine's own demo page, the game and the studio each passed `true` explicitly. So the conservative default was overridden by every consumer that exists and true for none of them \u2014 a default outliving its reason, which is only visible to somebody reading the option and the call sites at the same time. **Re-measured before it was touched**, because the parity table on record was nine days and three fixes old: seven scenes, one unchanged build, both backends, at the delta threshold the ledger reads, on an AMD Radeon RX 9070 XT. Four scenes are inside the 0.5% gate and three are above it for the reason already attributed to them \u2014 high-contrast edge density, with neither backend the better one \u2014 and nothing regressed: `collapse` and `gilded-chamber` improved by about a third. **Nothing changes for a consumer already asking for WebGPU**, which is all of them here. What changes is the consumer who asks for nothing, and it now gets an adapter request in a boot that had none; `preferWebGpu: false` declines it and restores the old boot exactly. The fallback is unchanged and still silent, on the same three counts, and `?backend=webgl2` still forces it in every build. The console warning for a WebGPU request that was not honoured now fires only when a caller asked *explicitly*, because warning on the default would put a line in front of every player whose browser has no WebGPU at all \u2014 which is most of why the fallback exists.",
2048
2064
  "short": "WebGPU is now the default backend and WebGL2 the fallback; a consumer that wants the old boot passes preferWebGpu: false."
2049
2065
  }
2050
2066
  ]
@@ -2066,22 +2082,22 @@
2066
2082
  "entries": [
2067
2083
  {
2068
2084
  "kind": "fixed",
2069
- "text": "Standing on something means standing over it. `contactSupport` asks whether a body's own column is over the thing holding it up, and it used the obstacle's *bounding box* as a stand-in for its footprint — exact for an axis-aligned collider and enormously wrong for a diagonal one. A ribbon's deck is tiled with wedges whose top face is the drawn triangle, so this is not an exotic case, it is every deck in every world: a slab lying across the world axes has a box several times its own area, and every column inside that box was called standing. What it produced is a walkable ledge in every cut in a deck — a body landing at the mouth of a gap catches the last wedge with the edge of its shoulder, its centre out over the hole, and is told it is standing, so nothing slides it off and nothing lets it fall. For a convex volume the column hits it exactly when the point is inside the shape's shadow on the ground plane, which the faces with a horizontal normal bound; faces looking up or down bound nothing there and are skipped, which is what keeps an ordinary lid from being cut down to a sliver. The same call reports which side the column fell off, so a consumer refusing support has a direction to act on. **`faceNormals` holds one entry per direction rather than per face**, and reading it as though each entry bounded one side left the far end and one flank of every deck unbounded — the first cut of this fix did exactly that and the ledge survived it. Both ends of each normal are bounded now.",
2085
+ "text": "Standing on something means standing over it. `contactSupport` asks whether a body's own column is over the thing holding it up, and it used the obstacle's *bounding box* as a stand-in for its footprint \u2014 exact for an axis-aligned collider and enormously wrong for a diagonal one. A ribbon's deck is tiled with wedges whose top face is the drawn triangle, so this is not an exotic case, it is every deck in every world: a slab lying across the world axes has a box several times its own area, and every column inside that box was called standing. What it produced is a walkable ledge in every cut in a deck \u2014 a body landing at the mouth of a gap catches the last wedge with the edge of its shoulder, its centre out over the hole, and is told it is standing, so nothing slides it off and nothing lets it fall. For a convex volume the column hits it exactly when the point is inside the shape's shadow on the ground plane, which the faces with a horizontal normal bound; faces looking up or down bound nothing there and are skipped, which is what keeps an ordinary lid from being cut down to a sliver. The same call reports which side the column fell off, so a consumer refusing support has a direction to act on. **`faceNormals` holds one entry per direction rather than per face**, and reading it as though each entry bounded one side left the far end and one flank of every deck unbounded \u2014 the first cut of this fix did exactly that and the ledge survived it. Both ends of each normal are bounded now.",
2070
2086
  "short": "A body is only standing on something when it is over the thing itself, rather than anywhere inside its bounding box."
2071
2087
  },
2072
2088
  {
2073
2089
  "kind": "fixed",
2074
- "text": "A body already inside a solid may leave it, and may not travel through it. The sweep granted such a body its whole requested move, unconditionally: the comment called it letting a body out rather than trapping it, and the code let it go anywhere. A character whose body had ended up inside a column could walk straight on through and out the far side, because every move it asked for was granted in full — and the same grant is why such a body reads as perfectly healthy, since a move nothing clamps leaves the support flag at the value it starts with, so the body is told it is standing and every backstop a consumer has waits for a refusal that never comes. The overlap runs from entry to exit along the direction of travel, so the body leaves in one going forward and the other going back; travelling toward the nearer of the two is coming out and toward the further one is going through, and only the first is granted, whole, so nothing is ever sealed in. **Horizontal travel only**, because the rule reads as its own opposite on a falling body: a body overlapping the flank of a tall wall has its shortest exit upward, so descending would count as the long way round and the fall would be refused — a body pinched between two walls then hangs in clear air, which is the failure this exists to end rather than one to introduce.",
2090
+ "text": "A body already inside a solid may leave it, and may not travel through it. The sweep granted such a body its whole requested move, unconditionally: the comment called it letting a body out rather than trapping it, and the code let it go anywhere. A character whose body had ended up inside a column could walk straight on through and out the far side, because every move it asked for was granted in full \u2014 and the same grant is why such a body reads as perfectly healthy, since a move nothing clamps leaves the support flag at the value it starts with, so the body is told it is standing and every backstop a consumer has waits for a refusal that never comes. The overlap runs from entry to exit along the direction of travel, so the body leaves in one going forward and the other going back; travelling toward the nearer of the two is coming out and toward the further one is going through, and only the first is granted, whole, so nothing is ever sealed in. **Horizontal travel only**, because the rule reads as its own opposite on a falling body: a body overlapping the flank of a tall wall has its shortest exit upward, so descending would count as the long way round and the fall would be refused \u2014 a body pinched between two walls then hangs in clear air, which is the failure this exists to end rather than one to introduce.",
2075
2091
  "short": "A body inside a solid can come out of it and can no longer walk through it."
2076
2092
  },
2077
2093
  {
2078
2094
  "kind": "added",
2079
- "text": "`ejectFromSolid` pushes a body out of geometry it has ended up inside, which is the one thing a swept move cannot do: both of its clamps test a face an overlapping body is already past, so nothing constrains it. Gated on the body's *centre* rather than on overlap, because overlap is ordinary — a deck's hull sits a give below the surface a character rides — while a centre inside is burial. It resolves every overlap with one push rather than one per collider, since a body between two columns has a shortest exit from each that points into the other, and it arms only where a collider holds more than a quarter of the body's width on both horizontal axes, because ejecting on a graze fights gravity. Hull colliders are skipped: a hull's box is its broad phase and says nothing about the convex volume inside it.",
2095
+ "text": "`ejectFromSolid` pushes a body out of geometry it has ended up inside, which is the one thing a swept move cannot do: both of its clamps test a face an overlapping body is already past, so nothing constrains it. Gated on the body's *centre* rather than on overlap, because overlap is ordinary \u2014 a deck's hull sits a give below the surface a character rides \u2014 while a centre inside is burial. It resolves every overlap with one push rather than one per collider, since a body between two columns has a shortest exit from each that points into the other, and it arms only where a collider holds more than a quarter of the body's width on both horizontal axes, because ejecting on a graze fights gravity. Hull colliders are skipped: a hull's box is its broad phase and says nothing about the convex volume inside it.",
2080
2096
  "short": "A body that has ended up inside solid geometry can be pushed back out of it."
2081
2097
  },
2082
2098
  {
2083
2099
  "kind": "fixed",
2084
- "text": "A downward contact reports the way *off* a rim it refused. Support is correctly withheld from a foot's corner on a sliver of a slab — the body's own column has to be over the thing holding it — but the contact normal on a downward clamp points straight up, because up genuinely is the way out of that clamp, so a consumer trying to slide off a perch finds no horizontal component to slide along. Nothing else can work it out either: which sideways ends the perch is a fact about the obstacle's footprint and only the sweep sees that. Without it a body could be held in clear air indefinitely, measured on a courtyard pillar as five seconds of held input and zero metres of descent.",
2100
+ "text": "A downward contact reports the way *off* a rim it refused. Support is correctly withheld from a foot's corner on a sliver of a slab \u2014 the body's own column has to be over the thing holding it \u2014 but the contact normal on a downward clamp points straight up, because up genuinely is the way out of that clamp, so a consumer trying to slide off a perch finds no horizontal component to slide along. Nothing else can work it out either: which sideways ends the perch is a fact about the obstacle's footprint and only the sweep sees that. Without it a body could be held in clear air indefinitely, measured on a courtyard pillar as five seconds of held input and zero metres of descent.",
2085
2101
  "short": "A contact that refuses to be called ground now says which way is off it."
2086
2102
  }
2087
2103
  ]
@@ -2092,7 +2108,7 @@
2092
2108
  "entries": [
2093
2109
  {
2094
2110
  "kind": "fixed",
2095
- "text": "Contact shading stops speckling and dotting along the edges it exists to draw. Three separate faults met on those pixels. The surface normal came from a hardware derivative, which is a difference across a 2x2 quad — and every silhouette in a frame puts two surfaces in one quad, so the difference is a secant from the near surface to the far one and the normal it yields is roughly perpendicular to the real one: occlusion saturates and the pixel clamps to black. A one-sided difference is substituted there, and only there, gated on a genuine discontinuity rather than on a preference, because taking the nearer side everywhere instead selects whichever half of a quantised flat surface happens to read zero and paints a horizontal band along every contour of constant depth. Second, the blur that makes the estimate usable ran over one period of the rotation tile, and its depth weight is designed to fall to zero across an edge — so a pixel beside a rail could keep a single tap, which is one rotation out of sixteen at the tile's own four-pixel period, drawn as a dotted line following the edge. It runs over two periods now, so a pixel hard against a silhouette still has four consecutive taps on its own side, which is every rotation exactly once. That halves what is left of the grain everywhere else as well.",
2111
+ "text": "Contact shading stops speckling and dotting along the edges it exists to draw. Three separate faults met on those pixels. The surface normal came from a hardware derivative, which is a difference across a 2x2 quad \u2014 and every silhouette in a frame puts two surfaces in one quad, so the difference is a secant from the near surface to the far one and the normal it yields is roughly perpendicular to the real one: occlusion saturates and the pixel clamps to black. A one-sided difference is substituted there, and only there, gated on a genuine discontinuity rather than on a preference, because taking the nearer side everywhere instead selects whichever half of a quantised flat surface happens to read zero and paints a horizontal band along every contour of constant depth. Second, the blur that makes the estimate usable ran over one period of the rotation tile, and its depth weight is designed to fall to zero across an edge \u2014 so a pixel beside a rail could keep a single tap, which is one rotation out of sixteen at the tile's own four-pixel period, drawn as a dotted line following the edge. It runs over two periods now, so a pixel hard against a silhouette still has four consecutive taps on its own side, which is every rotation exactly once. That halves what is left of the grain everywhere else as well.",
2096
2112
  "short": "Contact shading no longer leaves black speckle and a dotted fringe along every edge it draws."
2097
2113
  },
2098
2114
  {
@@ -2102,12 +2118,12 @@
2102
2118
  },
2103
2119
  {
2104
2120
  "kind": "fixed",
2105
- "text": "A lamp no longer casts a shadow with nothing making it. Three causes, and the loudest is WebGPU's alone: the depth pass's scatter batches wrote their uniforms into one shared buffer immediately before each draw, and a point light's bake records six cube faces on a single encoder with a single submit — so every batch in all six faces drew under the last face's light matrix, and five sixths of a field of ground cover went into the octahedral map at directions nothing occupies. Those batches take a ring slot each now, which is the fix the visible scatter pass has always had. Second, a bake spread across frames restarted whenever any of the light's parameters moved, and a flame that wanders to look alive moves every frame: with a budget of two faces the bake rendered the same two for ever while the other four kept an older origin, permanently, because the map stayed marked complete from the last bake that did finish. A bake in flight now owns the parameters it started with and finishes on them. Third, the live pair that holds moving casters published a map that had never been baked, and kept its image when a slot changed hands, where the static pool has forgotten and withheld one since the day that rule was written.",
2121
+ "text": "A lamp no longer casts a shadow with nothing making it. Three causes, and the loudest is WebGPU's alone: the depth pass's scatter batches wrote their uniforms into one shared buffer immediately before each draw, and a point light's bake records six cube faces on a single encoder with a single submit \u2014 so every batch in all six faces drew under the last face's light matrix, and five sixths of a field of ground cover went into the octahedral map at directions nothing occupies. Those batches take a ring slot each now, which is the fix the visible scatter pass has always had. Second, a bake spread across frames restarted whenever any of the light's parameters moved, and a flame that wanders to look alive moves every frame: with a budget of two faces the bake rendered the same two for ever while the other four kept an older origin, permanently, because the map stayed marked complete from the last bake that did finish. A bake in flight now owns the parameters it started with and finishes on them. Third, the live pair that holds moving casters published a map that had never been baked, and kept its image when a slot changed hands, where the static pool has forgotten and withheld one since the day that rule was written.",
2106
2122
  "short": "Three separate faults could each leave a lamp lighting a shadow with no caster under it, and the loudest was ground cover recorded through the wrong face of the light."
2107
2123
  },
2108
2124
  {
2109
2125
  "kind": "fixed",
2110
- "text": "Straight oblique bands stop appearing near shadows at a low sun. The directional filter follows the receiving plane's own depth gradient so that a grazing surface does not stripe itself, and that gradient is the solution of a 2x2 system whose determinant goes to zero exactly where the plane collapses toward a line in light space — a large deck under a low sun. The guard against that compared the determinant to a fixed 1e-8, where the quantity's own scale under an ordinary camera is around 1e-6: one percent of what it was guarding, so it never fired and the ratio blew up long before it. The guard is relative now, and the compensation it feeds is bounded as well, at nine texels' worth of depth — above every slope the receiver fade still draws a shadow on, and below the ones no bias can represent. Both halves matter: an unbounded gradient multiplied by a bounded offset is still unbounded, and what it drew was the shadow map's own texel grid, turned by the sun rather than by the camera, wherever the map held an occluder at all.",
2126
+ "text": "Straight oblique bands stop appearing near shadows at a low sun. The directional filter follows the receiving plane's own depth gradient so that a grazing surface does not stripe itself, and that gradient is the solution of a 2x2 system whose determinant goes to zero exactly where the plane collapses toward a line in light space \u2014 a large deck under a low sun. The guard against that compared the determinant to a fixed 1e-8, where the quantity's own scale under an ordinary camera is around 1e-6: one percent of what it was guarding, so it never fired and the ratio blew up long before it. The guard is relative now, and the compensation it feeds is bounded as well, at nine texels' worth of depth \u2014 above every slope the receiver fade still draws a shadow on, and below the ones no bias can represent. Both halves matter: an unbounded gradient multiplied by a bounded offset is still unbounded, and what it drew was the shadow map's own texel grid, turned by the sun rather than by the camera, wherever the map held an occluder at all.",
2111
2127
  "short": "A low sun no longer draws straight diagonal stripes across the surfaces near a shadow."
2112
2128
  },
2113
2129
  {
@@ -2123,7 +2139,7 @@
2123
2139
  "entries": [
2124
2140
  {
2125
2141
  "kind": "added",
2126
- "text": "Irradiance from spherical harmonics, on both backends. A baked reflection probe is projected onto nine coefficients and the lit pass evaluates them against the surface normal with no texture fetch at all — nine multiply-adds over a bare polynomial, with every constant folded in at bake time. What it replaces was a fetch three levels down a box-filtered cube, lifted by a `max` so a partial probe could not darken what stood in it, and gated by an option almost nobody set: a sample of a room rather than an integral over it. `setEnvironmentAmbient` is retired with it and is now a no-op that says so once; nothing has to change in a consumer, and it goes properly at the next major. Each backend reads its own cube its own way and hands the same six faces to the same projection — one synchronously inside the bake, the other through a sampling pass and a buffer copy that resolve a frame or two later, which a gate of its own makes a defined state rather than a difference in the picture. Measured on a grid of spheres between one emissive wall and one nearly black one: 25 of 25 lit from the bright side at a mean margin of 13.3 of 255 against a control of 1.0, identical on both backends, and zero pixels of movement on every published scene when the old term was deleted.",
2142
+ "text": "Irradiance from spherical harmonics, on both backends. A baked reflection probe is projected onto nine coefficients and the lit pass evaluates them against the surface normal with no texture fetch at all \u2014 nine multiply-adds over a bare polynomial, with every constant folded in at bake time. What it replaces was a fetch three levels down a box-filtered cube, lifted by a `max` so a partial probe could not darken what stood in it, and gated by an option almost nobody set: a sample of a room rather than an integral over it. `setEnvironmentAmbient` is retired with it and is now a no-op that says so once; nothing has to change in a consumer, and it goes properly at the next major. Each backend reads its own cube its own way and hands the same six faces to the same projection \u2014 one synchronously inside the bake, the other through a sampling pass and a buffer copy that resolve a frame or two later, which a gate of its own makes a defined state rather than a difference in the picture. Measured on a grid of spheres between one emissive wall and one nearly black one: 25 of 25 lit from the bright side at a mean margin of 13.3 of 255 against a control of 1.0, identical on both backends, and zero pixels of movement on every published scene when the old term was deleted.",
2127
2143
  "short": "A room now lights what stands in it through real irradiance rather than a blurred sample of itself, and the dial that stood in for it is retired."
2128
2144
  },
2129
2145
  {
@@ -2144,7 +2160,7 @@
2144
2160
  "entries": [
2145
2161
  {
2146
2162
  "kind": "fixed",
2147
- "text": "WebGPU draws up to thirty-two volumes of light in a frame where it drew sixteen. The cap is the capacity of the uniform rings a volume's blocks come out of, and the other backend has no equivalent, because it uploads a volume's uniforms and draws it there and then — so past the cap a shaft was skipped on one backend and drawn on the other, which the console said and nothing else did. Sixteen turned out to sit inside the range worlds are actually built in rather than above it: across twenty-six worlds of an application using this engine, six ask for a volume at all, the largest asks for twenty-four, and the next two ask for fifteen. That largest world had been losing eight of its beams on every frame anybody photographed, and the picture said so once it was looked at — a lamp visibly lit with no shaft under it. Against the other backend it went from 34,827 differing pixels at mean delta 20.8 to 21,407 at 3.5. The cost is 12 KiB of uniform buffer, and the cap stays a cap: a scene asking for hundreds of shafts is still told rather than quietly allocated for.",
2163
+ "text": "WebGPU draws up to thirty-two volumes of light in a frame where it drew sixteen. The cap is the capacity of the uniform rings a volume's blocks come out of, and the other backend has no equivalent, because it uploads a volume's uniforms and draws it there and then \u2014 so past the cap a shaft was skipped on one backend and drawn on the other, which the console said and nothing else did. Sixteen turned out to sit inside the range worlds are actually built in rather than above it: across twenty-six worlds of an application using this engine, six ask for a volume at all, the largest asks for twenty-four, and the next two ask for fifteen. That largest world had been losing eight of its beams on every frame anybody photographed, and the picture said so once it was looked at \u2014 a lamp visibly lit with no shaft under it. Against the other backend it went from 34,827 differing pixels at mean delta 20.8 to 21,407 at 3.5. The cost is 12 KiB of uniform buffer, and the cap stays a cap: a scene asking for hundreds of shafts is still told rather than quietly allocated for.",
2148
2164
  "short": "A frame can carry thirty-two shafts of light on WebGPU rather than sixteen, which is what one world was quietly losing eight of."
2149
2165
  }
2150
2166
  ]
@@ -2155,12 +2171,12 @@
2155
2171
  "entries": [
2156
2172
  {
2157
2173
  "kind": "fixed",
2158
- "text": "The WebGPU shadow pass records the surfaces the light can actually see. Which faces a pipeline culls is decided in framebuffer coordinates, whose Y points down where clip space's points up, so a triangle wound counter-clockwise in clip space reaches the rasteriser clockwise — and every pass that gets presented is projected through a correction that negates Y and turns that back over. The directional shadow map is sampled rather than shown, so its correction deliberately leaves Y alone, and its pipeline was therefore discarding exactly the faces the pass exists to record: anything the light could see through went into the map as its far side, or not at all. It takes hollow geometry to show, which is why it survived a version and a half: a closed box stores the same nearest depth whichever faces are culled, and a vault, an arcade or a colonnade does not. Read back texel for texel against WebGL2 on a cathedral interior, 805,117 texels of 2,095,575 held depths more than two metres apart and 110,097 recorded nothing where the other backend recorded a surface; both are zero now, and the two maps agree to a mean of five centimetres. That world's frame went from 230,855 differing pixels at mean delta 30.6 to 144,482 at 5.5, and four of the engine's own scenes moved toward WebGL2 with none moving away.",
2174
+ "text": "The WebGPU shadow pass records the surfaces the light can actually see. Which faces a pipeline culls is decided in framebuffer coordinates, whose Y points down where clip space's points up, so a triangle wound counter-clockwise in clip space reaches the rasteriser clockwise \u2014 and every pass that gets presented is projected through a correction that negates Y and turns that back over. The directional shadow map is sampled rather than shown, so its correction deliberately leaves Y alone, and its pipeline was therefore discarding exactly the faces the pass exists to record: anything the light could see through went into the map as its far side, or not at all. It takes hollow geometry to show, which is why it survived a version and a half: a closed box stores the same nearest depth whichever faces are culled, and a vault, an arcade or a colonnade does not. Read back texel for texel against WebGL2 on a cathedral interior, 805,117 texels of 2,095,575 held depths more than two metres apart and 110,097 recorded nothing where the other backend recorded a surface; both are zero now, and the two maps agree to a mean of five centimetres. That world's frame went from 230,855 differing pixels at mean delta 30.6 to 144,482 at 5.5, and four of the engine's own scenes moved toward WebGL2 with none moving away.",
2159
2175
  "short": "Shadows on WebGPU stop letting light through anything hollow. The depth pass was culling the faces it exists to record, so a vault or an arcade cast almost nothing."
2160
2176
  },
2161
2177
  {
2162
2178
  "kind": "fixed",
2163
- "text": "A pointer-lock request carries the options it was built with. `askForPointerLock` composes `{ unadjustedMovement: true }` and handed it to a callback that took no parameters, so the option was dropped on the way out and every browser was asked for a plain lock. The feature had been inert since the commit that added it, with three passing tests above it that each supplied their own callback and so proved only that the helper offers the option rather than that anything receives it. For a player on a Chromium browser a camera now genuinely gets the device's own counts and loses the operating system's pointer acceleration curve, which is the whole reason the option is asked for. **Firefox is unchanged and this does not fix it**: 154 rejects the option outright, the fallback then asks plainly, and what arrives is the compositor's accelerated travel in whole screen pixels. That is measured, recorded at the helper's own definition, and visible in unrelated games from other engines in the same browser — the motion is discarded before any browser sees it, so no engine can reach it.",
2179
+ "text": "A pointer-lock request carries the options it was built with. `askForPointerLock` composes `{ unadjustedMovement: true }` and handed it to a callback that took no parameters, so the option was dropped on the way out and every browser was asked for a plain lock. The feature had been inert since the commit that added it, with three passing tests above it that each supplied their own callback and so proved only that the helper offers the option rather than that anything receives it. For a player on a Chromium browser a camera now genuinely gets the device's own counts and loses the operating system's pointer acceleration curve, which is the whole reason the option is asked for. **Firefox is unchanged and this does not fix it**: 154 rejects the option outright, the fallback then asks plainly, and what arrives is the compositor's accelerated travel in whole screen pixels. That is measured, recorded at the helper's own definition, and visible in unrelated games from other engines in the same browser \u2014 the motion is discarded before any browser sees it, so no engine can reach it.",
2164
2180
  "short": "Pointer lock really asks for unadjusted movement now, so a camera on Chromium is no longer filtered through the system pointer curve. Firefox is unchanged."
2165
2181
  }
2166
2182
  ]
@@ -2171,7 +2187,7 @@
2171
2187
  "entries": [
2172
2188
  {
2173
2189
  "kind": "fixed",
2174
- "text": "`createPointLightBuffer` takes a capacity, so clustered lighting can be reached through the helper every consumer already uses. Clustering shipped in 2.2.0 able to carry 320 lights, and this buffer was sized from the shader's uniform arrays at sixteen — so the froxel table would take as many as a caller could offer, and the one function anybody offers them through could not offer more than the fixed budget. A consumer that built the arrays itself could always fill it, which is how the engine's own demo page lights forty lamps, but no consumer does that. The default is `MAX_POINT_LIGHTS` exactly, so nothing that does not ask for a wider buffer changes. Found by trying to adopt clustering in an application rather than by reading the engine.",
2190
+ "text": "`createPointLightBuffer` takes a capacity, so clustered lighting can be reached through the helper every consumer already uses. Clustering shipped in 2.2.0 able to carry 320 lights, and this buffer was sized from the shader's uniform arrays at sixteen \u2014 so the froxel table would take as many as a caller could offer, and the one function anybody offers them through could not offer more than the fixed budget. A consumer that built the arrays itself could always fill it, which is how the engine's own demo page lights forty lamps, but no consumer does that. The default is `MAX_POINT_LIGHTS` exactly, so nothing that does not ask for a wider buffer changes. Found by trying to adopt clustering in an application rather than by reading the engine.",
2175
2191
  "short": "The light buffer can be as wide as clustered lighting can carry, which the helper every consumer uses could not do before."
2176
2192
  }
2177
2193
  ]
@@ -2270,12 +2286,12 @@
2270
2286
  "entries": [
2271
2287
  {
2272
2288
  "kind": "changed",
2273
- "text": "The engine is five packages rather than one, and core's barrel no longer re-exports four of them. `@driftengine/drft` is the container format and declares no runtime dependency at all, so a `.drft` file can be read by something that never draws; `@driftengine/assets` is the model readers and the streaming loader; `@driftengine/audio` is the graph, the synthesis and the rhythm analysis; `@driftengine/media` is clip encoding, and it takes `mp4-muxer` with it, which drops core's runtime dependencies to `gl-matrix` and the format package. The break is the feature rather than a cost of it: a barrel that re-exports audio means importing core pulls audio in, and the split would buy nothing. `PORTING.md` lists every symbol that moved and where it went, and every one of them is a compiler error naming the symbol, so the migration is a list rather than a search. What it is worth is measured rather than claimed, gzipped, by a gate that fails when a floor drifts more than three per cent: the format package alone is 2.7 KB, the audio graph adds 4.9 KB to a core bundle and the model readers 9.3 KB. Five packages and not the thirteen the design named, because the import graph was measured first and allows exactly these four extractions today: the rest of the tree is mutually entangled and stays in core. `MeshData` and `validateMeshData` moved into the format package, which is what lets it stand alone — every import the format code took from the renderer was one of those two — but core re-exports both, so an application taking them from the renderer is unaffected",
2289
+ "text": "The engine is five packages rather than one, and core's barrel no longer re-exports four of them. `@driftengine/drft` is the container format and declares no runtime dependency at all, so a `.drft` file can be read by something that never draws; `@driftengine/assets` is the model readers and the streaming loader; `@driftengine/audio` is the graph, the synthesis and the rhythm analysis; `@driftengine/media` is clip encoding, and it takes `mp4-muxer` with it, which drops core's runtime dependencies to `gl-matrix` and the format package. The break is the feature rather than a cost of it: a barrel that re-exports audio means importing core pulls audio in, and the split would buy nothing. `PORTING.md` lists every symbol that moved and where it went, and every one of them is a compiler error naming the symbol, so the migration is a list rather than a search. What it is worth is measured rather than claimed, gzipped, by a gate that fails when a floor drifts more than three per cent: the format package alone is 2.7 KB, the audio graph adds 4.9 KB to a core bundle and the model readers 9.3 KB. Five packages and not the thirteen the design named, because the import graph was measured first and allows exactly these four extractions today: the rest of the tree is mutually entangled and stays in core. `MeshData` and `validateMeshData` moved into the format package, which is what lets it stand alone \u2014 every import the format code took from the renderer was one of those two \u2014 but core re-exports both, so an application taking them from the renderer is unaffected",
2274
2290
  "short": "The engine is five packages, and you pay only for the ones you import. Every package publishes its own measured size."
2275
2291
  },
2276
2292
  {
2277
2293
  "kind": "changed",
2278
- "text": "`CHANGELOG.json` moved to `packages/core/CHANGELOG.json`. By package specifier — `@driftengine/core/CHANGELOG.json` — nothing changed; a consumer reading it by file path has one line to fix",
2294
+ "text": "`CHANGELOG.json` moved to `packages/core/CHANGELOG.json`. By package specifier \u2014 `@driftengine/core/CHANGELOG.json` \u2014 nothing changed; a consumer reading it by file path has one line to fix",
2279
2295
  "short": "The changelog lives inside the core package, unchanged by specifier and moved by path."
2280
2296
  }
2281
2297
  ]
@@ -2286,7 +2302,7 @@
2286
2302
  "entries": [
2287
2303
  {
2288
2304
  "kind": "fixed",
2289
- "text": "WebGPU compiles its pipelines before the first frame instead of inside it. `createRenderPipeline` returns before the driver has compiled anything: it defers the shader to the first draw that needs it. So a consumer that built its meshes and then rendered paid every compile inside one frame, with the whole GPU queue stalled behind it. Measured on a Galaxy S23 Ultra: a single frame of 5.2 seconds, seven submits in a row each reporting the same 5.2 seconds and all draining together, with no allocation, no long task and an idle main thread for the duration. Nothing on the JavaScript side could see it, which is why it survived so long — `submit` returns immediately, so every timer wrapped around it read single digits, and the pipeline count and build time both looked trivial because creating a pipeline object is trivial. `PipelineCache` gained `getAsync`, built on `createRenderPipelineAsync`, and `ready()`, which resolves when everything asked for so far has genuinely compiled; `RendererApi.ready()` exposes it on both backends and resolves immediately on WebGL2, which links at creation and has nothing outstanding. A consumer awaits it once before its first frame. Correctness does not depend on doing so: a draw that arrives before its pipeline is ready builds it synchronously, exactly as before, so waiting is a performance choice rather than a requirement. The async path falls back to the synchronous one where `createRenderPipelineAsync` is missing",
2305
+ "text": "WebGPU compiles its pipelines before the first frame instead of inside it. `createRenderPipeline` returns before the driver has compiled anything: it defers the shader to the first draw that needs it. So a consumer that built its meshes and then rendered paid every compile inside one frame, with the whole GPU queue stalled behind it. Measured on a Galaxy S23 Ultra: a single frame of 5.2 seconds, seven submits in a row each reporting the same 5.2 seconds and all draining together, with no allocation, no long task and an idle main thread for the duration. Nothing on the JavaScript side could see it, which is why it survived so long \u2014 `submit` returns immediately, so every timer wrapped around it read single digits, and the pipeline count and build time both looked trivial because creating a pipeline object is trivial. `PipelineCache` gained `getAsync`, built on `createRenderPipelineAsync`, and `ready()`, which resolves when everything asked for so far has genuinely compiled; `RendererApi.ready()` exposes it on both backends and resolves immediately on WebGL2, which links at creation and has nothing outstanding. A consumer awaits it once before its first frame. Correctness does not depend on doing so: a draw that arrives before its pipeline is ready builds it synchronously, exactly as before, so waiting is a performance choice rather than a requirement. The async path falls back to the synchronous one where `createRenderPipelineAsync` is missing",
2290
2306
  "short": "WebGPU compiles pipelines before the first frame rather than inside it, where a phone spent 5.2 seconds with its GPU queue stalled and nothing on any CPU timer to show for it."
2291
2307
  },
2292
2308
  {
@@ -2306,7 +2322,7 @@
2306
2322
  },
2307
2323
  {
2308
2324
  "kind": "changed",
2309
- "text": "`SurfaceTexture`'s `RENDER_ATTACHMENT` usage is documented as what `copyExternalImageToTexture` requires rather than as something the mip chain needs. It said the latter, which is true and is not the reason, and reading it that way leads directly to gating the flag on `levels > 1` — which Dawn rejects outright for every unmipped texture. Stated properly so the next reader does not have to break a build to find out",
2325
+ "text": "`SurfaceTexture`'s `RENDER_ATTACHMENT` usage is documented as what `copyExternalImageToTexture` requires rather than as something the mip chain needs. It said the latter, which is true and is not the reason, and reading it that way leads directly to gating the flag on `levels > 1` \u2014 which Dawn rejects outright for every unmipped texture. Stated properly so the next reader does not have to break a build to find out",
2310
2326
  "short": "The note on a surface texture's usage flags names what actually requires them, which is the upload rather than the mip chain."
2311
2327
  }
2312
2328
  ]
@@ -2425,7 +2441,7 @@
2425
2441
  },
2426
2442
  {
2427
2443
  "kind": "added",
2428
- "text": "`Renderer.presentedFrames`, a monotonic count of frames a renderer has actually presented, requested by a consumer (`ENGINE-NOTES.md` §1) so an offline export can tell a new picture from a repeat rather than composite a frame nobody drew, at the wrong timestamp, as a copy of the one before it. It increments where a frame is genuinely shown rather than where a method happens to be called: WebGPU counts at `swapView`'s `getCurrentTexture` acquisition, and WebGL2, which has no swap chain to acquire, counts at the one point `endFrame` is guaranteed to reach only when the frame it opened is what the browser is about to show. A frame that dies between `beginFrame` and `endFrame`, or a surface lost mid-frame, does not advance it. `ExportTarget` and `FrameRecorder` consume it internally through an optional callback and a pure `isDuplicateFrame` comparison that is unit-testable without a DOM; a caller that never wires the callback sees no change in behaviour at all",
2444
+ "text": "`Renderer.presentedFrames`, a monotonic count of frames a renderer has actually presented, requested by a consumer (`ENGINE-NOTES.md` \u00a71) so an offline export can tell a new picture from a repeat rather than composite a frame nobody drew, at the wrong timestamp, as a copy of the one before it. It increments where a frame is genuinely shown rather than where a method happens to be called: WebGPU counts at `swapView`'s `getCurrentTexture` acquisition, and WebGL2, which has no swap chain to acquire, counts at the one point `endFrame` is guaranteed to reach only when the frame it opened is what the browser is about to show. A frame that dies between `beginFrame` and `endFrame`, or a surface lost mid-frame, does not advance it. `ExportTarget` and `FrameRecorder` consume it internally through an optional callback and a pure `isDuplicateFrame` comparison that is unit-testable without a DOM; a caller that never wires the callback sees no change in behaviour at all",
2429
2445
  "short": "`presentedFrames` counts frames actually presented, so an export can spot a repeat."
2430
2446
  },
2431
2447
  {
@@ -2452,7 +2468,7 @@
2452
2468
  "entries": [
2453
2469
  {
2454
2470
  "kind": "fixed",
2455
- "text": "Every shader that draws geometry compiles on iOS, which none of them did. WGSL requires a sixteen-byte element stride for an array in the uniform address space, and the toolchain emitted `array<f32, 10>` and `array<i32, 10>`, which stride by four. Dawn accepts that: all sixteen `flat` permutations compile with the validation scope clean and not one warning, measured on a real adapter. So it rendered on every desktop browser and on no iPhone, where WebKit enforces the rule and answered `arrays in the uniform address space must have a stride multiple of 16 bytes, but has a stride of 4 bytes`. `flat` is the mesh shader and no permutation of it survived, so every geometry pipeline in the frame was invalid, `setPipeline` invalidated the pass and the command buffer was dropped whole. The sky went with it. What a person saw was a completely black view on all three consumers, in both iOS browsers, that no graphics setting recovered, while the engine's own caption read `webgpu · 60 fps · 13 draws` over it, because from the API's point of view nothing had failed. `water` and `particle` carried the same arrays. The cause is that WGSL removed `@stride`, so naga cannot express SPIR-V's `ArrayStride 16` and drops it, and `scripts/wgsl/layout.mjs` had then been changed to pack the CPU side to match, on a measurement of Dawn that was sound and one browser wide. The generator now widens a narrow uniform array to four components before it reaches a block and reads it back through a component, so naga emits a conformant stride by construction and `blockLayout` computes the same sixteen without being told. The two sides cannot disagree again, because only one of them chooses. The GLSL is untouched, so the WebGL2 path keeps its scalar arrays rather than paying four times the fragment uniform vectors on the devices with the fewest. It shipped in 1.0.0 and nobody had opened the page on a phone",
2471
+ "text": "Every shader that draws geometry compiles on iOS, which none of them did. WGSL requires a sixteen-byte element stride for an array in the uniform address space, and the toolchain emitted `array<f32, 10>` and `array<i32, 10>`, which stride by four. Dawn accepts that: all sixteen `flat` permutations compile with the validation scope clean and not one warning, measured on a real adapter. So it rendered on every desktop browser and on no iPhone, where WebKit enforces the rule and answered `arrays in the uniform address space must have a stride multiple of 16 bytes, but has a stride of 4 bytes`. `flat` is the mesh shader and no permutation of it survived, so every geometry pipeline in the frame was invalid, `setPipeline` invalidated the pass and the command buffer was dropped whole. The sky went with it. What a person saw was a completely black view on all three consumers, in both iOS browsers, that no graphics setting recovered, while the engine's own caption read `webgpu \u00b7 60 fps \u00b7 13 draws` over it, because from the API's point of view nothing had failed. `water` and `particle` carried the same arrays. The cause is that WGSL removed `@stride`, so naga cannot express SPIR-V's `ArrayStride 16` and drops it, and `scripts/wgsl/layout.mjs` had then been changed to pack the CPU side to match, on a measurement of Dawn that was sound and one browser wide. The generator now widens a narrow uniform array to four components before it reaches a block and reads it back through a component, so naga emits a conformant stride by construction and `blockLayout` computes the same sixteen without being told. The two sides cannot disagree again, because only one of them chooses. The GLSL is untouched, so the WebGL2 path keeps its scalar arrays rather than paying four times the fragment uniform vectors on the devices with the fewest. It shipped in 1.0.0 and nobody had opened the page on a phone",
2456
2472
  "short": "iOS renders again: uniform arrays stride by sixteen, as WGSL requires."
2457
2473
  }
2458
2474
  ]
@@ -2463,17 +2479,17 @@
2463
2479
  "entries": [
2464
2480
  {
2465
2481
  "kind": "added",
2466
- "text": "The WebGPU backend can say what a frame cost. `gpuTimer` there was a stub reporting `available: false` forever, so every GPU figure this project has ever quoted came from WebGL2 — the backend a phone does not use — and the consumer's own rule that a GPU verdict needs `gpuSamples > 0` could never be satisfied on the one that ships. `GpuTimestamps` implements the same `FrameTimer` interface `GpuTimer` does, so a consumer already calling beginFrame/begin/end/endFrame/poll starts getting numbers with nothing changed on its side. The one shape difference is forced by the API: WebGL2 brackets an arbitrary range of commands with TIME_ELAPSED_EXT and WebGPU cannot, so a timestamp pair rides each render pass descriptor and the readback sums the pass deltas per slot — which is why every per-frame pass now carries one, a pass without it being GPU time the total would silently omit. The feature is requested only where the adapter offers it, because a `requiredFeatures` naming something an adapter lacks rejects the device outright rather than clamping. Held against WebGL2 on the gilded chamber at 900x700 dpr 2, same machine: 6.72-7.04 ms against 6.15-6.51. Two unrelated timing mechanisms agreeing within 10% is what says the number is real",
2482
+ "text": "The WebGPU backend can say what a frame cost. `gpuTimer` there was a stub reporting `available: false` forever, so every GPU figure this project has ever quoted came from WebGL2 \u2014 the backend a phone does not use \u2014 and the consumer's own rule that a GPU verdict needs `gpuSamples > 0` could never be satisfied on the one that ships. `GpuTimestamps` implements the same `FrameTimer` interface `GpuTimer` does, so a consumer already calling beginFrame/begin/end/endFrame/poll starts getting numbers with nothing changed on its side. The one shape difference is forced by the API: WebGL2 brackets an arbitrary range of commands with TIME_ELAPSED_EXT and WebGPU cannot, so a timestamp pair rides each render pass descriptor and the readback sums the pass deltas per slot \u2014 which is why every per-frame pass now carries one, a pass without it being GPU time the total would silently omit. The feature is requested only where the adapter offers it, because a `requiredFeatures` naming something an adapter lacks rejects the device outright rather than clamping. Held against WebGL2 on the gilded chamber at 900x700 dpr 2, same machine: 6.72-7.04 ms against 6.15-6.51. Two unrelated timing mechanisms agreeing within 10% is what says the number is real",
2467
2483
  "short": "The WebGPU backend measures its own frames, through timestamp queries."
2468
2484
  },
2469
2485
  {
2470
2486
  "kind": "fixed",
2471
- "text": "A resolved multisample attachment is discarded rather than written back to memory, on both backends. Not one `storeOp: 'discard'` existed in the WebGPU backend and no `invalidateFramebuffer` existed anywhere, so every multisampled colour target was resolved and then also written out in full — and `post.sceneColorMsaa`, `reflection.colorMsaa` and `probe.colorMsaa` are created with RENDER_ATTACHMENT and no TEXTURE_BINDING, so no shader can read one. `reflection.depth` and `overlay.depth` were stored on the same terms and are sampled by nothing. Measured on the consumer at a phone viewport (824x1830, four samples): 388.7 MB of attachment traffic a frame, of which five multisample stores at 23 MB each bought nothing. A desktop GPU absorbs that and a tile-based mobile GPU cannot, which is why it survived a release. **Only a terminal pass may discard**, and that cannot be settled by looking at the picture: the frame's own attachment is reopened with `loadOp: 'load'` for the mirror, the light volume and text, and after a discard those loads read undefined contents. With every attachment discarding, all seven scenes still came back inside the shot harness's own 130-pixel readout noise on this desktop part — which is the trap rather than permission, because an immediate-mode GPU has nowhere else to put the samples and a tiler really drops them. So the reopens are what hold the frame's attachment at `store`, and removing them is the next thing worth doing",
2487
+ "text": "A resolved multisample attachment is discarded rather than written back to memory, on both backends. Not one `storeOp: 'discard'` existed in the WebGPU backend and no `invalidateFramebuffer` existed anywhere, so every multisampled colour target was resolved and then also written out in full \u2014 and `post.sceneColorMsaa`, `reflection.colorMsaa` and `probe.colorMsaa` are created with RENDER_ATTACHMENT and no TEXTURE_BINDING, so no shader can read one. `reflection.depth` and `overlay.depth` were stored on the same terms and are sampled by nothing. Measured on the consumer at a phone viewport (824x1830, four samples): 388.7 MB of attachment traffic a frame, of which five multisample stores at 23 MB each bought nothing. A desktop GPU absorbs that and a tile-based mobile GPU cannot, which is why it survived a release. **Only a terminal pass may discard**, and that cannot be settled by looking at the picture: the frame's own attachment is reopened with `loadOp: 'load'` for the mirror, the light volume and text, and after a discard those loads read undefined contents. With every attachment discarding, all seven scenes still came back inside the shot harness's own 130-pixel readout noise on this desktop part \u2014 which is the trap rather than permission, because an immediate-mode GPU has nowhere else to put the samples and a tiler really drops them. So the reopens are what hold the frame's attachment at `store`, and removing them is the next thing worth doing",
2472
2488
  "short": "Multisample attachments are discarded after resolve instead of written to memory."
2473
2489
  },
2474
2490
  {
2475
2491
  "kind": "fixed",
2476
- "text": "The live point-shadow cubemaps are budgeted like every other bake. `pointShadowFacesPerFrame` was spent entirely on the static maps, and the loop over the live maps underneath it then asked for a whole cubemap, per map, every frame, with no budget and no staleness test — measured as `pointShadow.face` six times a frame in the consumer and in three of the engine's own seven demos, in frames that were otherwise nine to fourteen passes. There are two live maps and the second wakes during an ownership handoff, which is exactly what running past a row of lamps is, so a courtyard paid twelve full passes over the dynamic casters every frame on top of everything else it draws. Unlike a static map there is nothing to test — the caster moved, that is what makes it live — so what was missing is a ceiling, and the faces now round-robin under one: a mover's shadow finishes over two or three frames instead of inside one. Measured on the night court at two faces a frame, `pointShadow.cube` fell from six a frame to two and the frame from twelve passes to eight. Both backends, because the scheduler is shared",
2492
+ "text": "The live point-shadow cubemaps are budgeted like every other bake. `pointShadowFacesPerFrame` was spent entirely on the static maps, and the loop over the live maps underneath it then asked for a whole cubemap, per map, every frame, with no budget and no staleness test \u2014 measured as `pointShadow.face` six times a frame in the consumer and in three of the engine's own seven demos, in frames that were otherwise nine to fourteen passes. There are two live maps and the second wakes during an ownership handoff, which is exactly what running past a row of lamps is, so a courtyard paid twelve full passes over the dynamic casters every frame on top of everything else it draws. Unlike a static map there is nothing to test \u2014 the caster moved, that is what makes it live \u2014 so what was missing is a ceiling, and the faces now round-robin under one: a mover's shadow finishes over two or three frames instead of inside one. Measured on the night court at two faces a frame, `pointShadow.cube` fell from six a frame to two and the frame from twelve passes to eight. Both backends, because the scheduler is shared",
2477
2493
  "short": "Live point-shadow cubemaps no longer re-bake in full every frame."
2478
2494
  },
2479
2495
  {
@@ -2483,52 +2499,52 @@
2483
2499
  },
2484
2500
  {
2485
2501
  "kind": "fixed",
2486
- "text": "The frame's own attachment is opened once, instead of being closed and read back for every mirror. `beginFrame` opened the render pass eagerly, so a scene drawing a planar reflection had to end it, submit, and reopen it with `loadOp: 'load'` — a full read of the attachment back into tile memory and a full write at the end of the resumed pass, once per mirror, and the consumer has two of them. `ensurePass` opens it on the first draw that wants it instead, which for such a scene is after the last mirror, so it is opened once and cleared once. `frameNeedsClear` keeps the distinction that matters: a scene that had already drawn before opening a mirror still gets a `load`, and one that opened the mirror first gets its single `clear` late. Measured on the gilded chamber at 900x700 dpr 2 with four samples: attachment traffic from 436.5 MB a frame to 281.0, a 36% cut, with `load:post.sceneColorMsaa` and `load:flat.depth` each halved and one fewer multisample store. An empty frame still clears, because `endFrame` opens the pass rather than returning early on a null one — otherwise a frame nobody drew into would present whatever the swap chain last held. Gated against a same-build floor of 125 pixels: worst scene 132, and the two mirror-and-volume scenes at zero",
2502
+ "text": "The frame's own attachment is opened once, instead of being closed and read back for every mirror. `beginFrame` opened the render pass eagerly, so a scene drawing a planar reflection had to end it, submit, and reopen it with `loadOp: 'load'` \u2014 a full read of the attachment back into tile memory and a full write at the end of the resumed pass, once per mirror, and the consumer has two of them. `ensurePass` opens it on the first draw that wants it instead, which for such a scene is after the last mirror, so it is opened once and cleared once. `frameNeedsClear` keeps the distinction that matters: a scene that had already drawn before opening a mirror still gets a `load`, and one that opened the mirror first gets its single `clear` late. Measured on the gilded chamber at 900x700 dpr 2 with four samples: attachment traffic from 436.5 MB a frame to 281.0, a 36% cut, with `load:post.sceneColorMsaa` and `load:flat.depth` each halved and one fewer multisample store. An empty frame still clears, because `endFrame` opens the pass rather than returning early on a null one \u2014 otherwise a frame nobody drew into would present whatever the swap chain last held. Gated against a same-build floor of 125 pixels: worst scene 132, and the two mirror-and-volume scenes at zero",
2487
2503
  "short": "The frame's attachment is opened once rather than reopened per mirror."
2488
2504
  },
2489
2505
  {
2490
2506
  "kind": "added",
2491
- "text": "`RenderQuality.discardResolvedAttachments` and `RenderQuality.deferFramePass`, both true by default and both there to be turned off from a phone. They gate the two bandwidth changes in this engine that **cannot be verified anywhere but on a tile-based GPU**: throwing away a multisampled attachment once it has resolved, and opening the frame's pass on the first draw rather than in `beginFrame`. On an immediate-mode desktop part a discard costs nothing and changes nothing, because the samples have nowhere else to be, so a screenshot gate cannot tell a correct one from a wrong one — and the failure either could cause is a whole frame that does not present rather than a pixel that is slightly off. A device that flashes black is bisected with `?discard=0` and then `?defer=0`, one reload each, and whichever stops it names the cause. Held on the gilded chamber at four samples: 281.0 MB a frame with both on, 358.8 with the discards off, 436.5 with the deferral off — the last being exactly the figure from before either landed, so the switch restores the old behaviour rather than approximating it. The same switch reaches WebGL2, where the discard is `invalidateFramebuffer` after the resolve blit",
2507
+ "text": "`RenderQuality.discardResolvedAttachments` and `RenderQuality.deferFramePass`, both true by default and both there to be turned off from a phone. They gate the two bandwidth changes in this engine that **cannot be verified anywhere but on a tile-based GPU**: throwing away a multisampled attachment once it has resolved, and opening the frame's pass on the first draw rather than in `beginFrame`. On an immediate-mode desktop part a discard costs nothing and changes nothing, because the samples have nowhere else to be, so a screenshot gate cannot tell a correct one from a wrong one \u2014 and the failure either could cause is a whole frame that does not present rather than a pixel that is slightly off. A device that flashes black is bisected with `?discard=0` and then `?defer=0`, one reload each, and whichever stops it names the cause. Held on the gilded chamber at four samples: 281.0 MB a frame with both on, 358.8 with the discards off, 436.5 with the deferral off \u2014 the last being exactly the figure from before either landed, so the switch restores the old behaviour rather than approximating it. The same switch reaches WebGL2, where the discard is `invalidateFramebuffer` after the resolve blit",
2492
2508
  "short": "Two switches to turn off the tiler-only bandwidth changes from the address bar."
2493
2509
  },
2494
2510
  {
2495
2511
  "kind": "fixed",
2496
- "text": "The capability clamp fires on WebGPU. It could not before: `WebGPURenderer.rendererName` was the literal string `WebGPU`, so `isWeakGpuFamily` had nothing to match, `capabilityClamped` was a hardcoded `false`, and a consumer passing `capabilityClamp: true` got silence — on the backend that had replaced the one where that clamp rescued an Adreno 619 from 156 ms a frame. `selectBackend` now builds a name from `adapter.info` and hands it back on `BackendChoice.rendererName`, and the renderer applies the same two pixel terms `renderer.ts` applies, from the same table",
2512
+ "text": "The capability clamp fires on WebGPU. It could not before: `WebGPURenderer.rendererName` was the literal string `WebGPU`, so `isWeakGpuFamily` had nothing to match, `capabilityClamped` was a hardcoded `false`, and a consumer passing `capabilityClamp: true` got silence \u2014 on the backend that had replaced the one where that clamp rescued an Adreno 619 from 156 ms a frame. `selectBackend` now builds a name from `adapter.info` and hands it back on `BackendChoice.rendererName`, and the renderer applies the same two pixel terms `renderer.ts` applies, from the same table",
2497
2513
  "short": "The capability clamp works on WebGPU, instead of reporting that it never fired."
2498
2514
  },
2499
2515
  {
2500
2516
  "kind": "changed",
2501
- "text": "`isWeakGpuFamily` recognises WebGPU's architecture buckets as well as WebGL2's part numbers, and the difference between them is worth knowing. `UNMASKED_RENDERER_WEBGL` gives `ANGLE (Qualcomm, Adreno (TM) 619, OpenGL ES 3.2)`; `GPUAdapterInfo` deliberately does not, reporting a vendor and an architecture bucket to reduce fingerprinting entropy — measured here, an RX 9070 XT says `vendor: \"amd\", architecture: \"rdna-4\"` and nothing else at all. So `adreno-5xx`, `adreno-6xx` and `mali-g[35]x` are matched, and **`adreno-7xx` deliberately is not**: that bucket holds both the 710 measured at 112 ms a frame and the 740 in a current flagship, and softening every flagship to catch one mid-range part is the wrong trade when the governor corrects a wrong guess from measurement anyway. The bucket spellings are inferred from the specification rather than read off a device this project owns, which is why the consumer now prints the part and whether it was clamped in its boot log: one message from a real phone settles it",
2517
+ "text": "`isWeakGpuFamily` recognises WebGPU's architecture buckets as well as WebGL2's part numbers, and the difference between them is worth knowing. `UNMASKED_RENDERER_WEBGL` gives `ANGLE (Qualcomm, Adreno (TM) 619, OpenGL ES 3.2)`; `GPUAdapterInfo` deliberately does not, reporting a vendor and an architecture bucket to reduce fingerprinting entropy \u2014 measured here, an RX 9070 XT says `vendor: \"amd\", architecture: \"rdna-4\"` and nothing else at all. So `adreno-5xx`, `adreno-6xx` and `mali-g[35]x` are matched, and **`adreno-7xx` deliberately is not**: that bucket holds both the 710 measured at 112 ms a frame and the 740 in a current flagship, and softening every flagship to catch one mid-range part is the wrong trade when the governor corrects a wrong guess from measurement anyway. The bucket spellings are inferred from the specification rather than read off a device this project owns, which is why the consumer now prints the part and whether it was clamped in its boot log: one message from a real phone settles it",
2502
2518
  "short": "The weak-GPU table understands WebGPU's coarse architecture buckets."
2503
2519
  },
2504
2520
  {
2505
2521
  "kind": "fixed",
2506
- "text": "A frame that fails no longer blanks the screen. `getCurrentTexture` is an acquisition rather than a read — whatever is in the texture it returns is what the browser presents at the end of the task, drawn into or not — and `beginFrame` took it unconditionally. So every path that acquired and then failed to submit presented an untouched texture: a lost surface, a null encoder, a composite target that had gone, or an exception in the consumer's own frame code between `beginFrame` and `endFrame`. That is a black frame, indistinguishable from a rendering fault while being nothing of the kind, and it was reported from a phone as intermittent flashing. The swap chain is taken on first use instead, which under the ordinary profile is the composite at the very end of the frame — so a frame that dies before then never takes it, the browser has nothing new to present, and the last good frame stays on screen. A dropped frame reads as a dropped frame. Sizing comes from the canvas, which cannot disagree because `configure` sizes the swap chain from it",
2522
+ "text": "A frame that fails no longer blanks the screen. `getCurrentTexture` is an acquisition rather than a read \u2014 whatever is in the texture it returns is what the browser presents at the end of the task, drawn into or not \u2014 and `beginFrame` took it unconditionally. So every path that acquired and then failed to submit presented an untouched texture: a lost surface, a null encoder, a composite target that had gone, or an exception in the consumer's own frame code between `beginFrame` and `endFrame`. That is a black frame, indistinguishable from a rendering fault while being nothing of the kind, and it was reported from a phone as intermittent flashing. The swap chain is taken on first use instead, which under the ordinary profile is the composite at the very end of the frame \u2014 so a frame that dies before then never takes it, the browser has nothing new to present, and the last good frame stays on screen. A dropped frame reads as a dropped frame. Sizing comes from the canvas, which cannot disagree because `configure` sizes the swap chain from it",
2507
2523
  "short": "A frame that fails leaves the last good one on screen instead of presenting black."
2508
2524
  },
2509
2525
  {
2510
2526
  "kind": "added",
2511
- "text": "`demoQualityFor` and `readDemoDeviceHints`, so a demo host can scale a scene to the device without taking anything out of it. Every scene mounted at `full` on every device, and `full` is the engine's defaults plus `sceneSamples: 4` — four times the colour and depth bandwidth of a frame that was already the constraint on a handset, reported from a Galaxy S23 Ultra at 15 to 20 fps. **The obvious answer was the `lean` budget and it is the wrong one for a page with no settings screen**: `lean` switches off directional shadows, water reflections and `screenEffects`, and with the composite gone so are bloom, occlusion, motion blur and the speed rush. A game can afford that because a player can put it back; a demos page would simply be showing a phone reader an engine with several of its passes missing. So nothing is switched off. What a handheld gets is dials — density 1.5, a 1.6 megapixel ceiling, one sample rather than four, half-scale reflections, 1024px directional maps at one depth layer, 256px cube faces, four filter taps — and every pass a desktop reader sees it still draws. Measured on the gilded chamber at a phone viewport: 8.09 MP and 809.6 MB a frame becomes 1.60 MP and 49.7 MB, a sixteenfold cut, with the reflection, the light volume, the shadows and the composite all still in the frame. Desktop is untouched, held at 96 pixels against a 101 pixel floor. The device is recognised by its panel and its input rather than by the GPU-name table, which is exactly what missed this one",
2527
+ "text": "`demoQualityFor` and `readDemoDeviceHints`, so a demo host can scale a scene to the device without taking anything out of it. Every scene mounted at `full` on every device, and `full` is the engine's defaults plus `sceneSamples: 4` \u2014 four times the colour and depth bandwidth of a frame that was already the constraint on a handset, reported from a Galaxy S23 Ultra at 15 to 20 fps. **The obvious answer was the `lean` budget and it is the wrong one for a page with no settings screen**: `lean` switches off directional shadows, water reflections and `screenEffects`, and with the composite gone so are bloom, occlusion, motion blur and the speed rush. A game can afford that because a player can put it back; a demos page would simply be showing a phone reader an engine with several of its passes missing. So nothing is switched off. What a handheld gets is dials \u2014 density 1.5, a 1.6 megapixel ceiling, one sample rather than four, half-scale reflections, 1024px directional maps at one depth layer, 256px cube faces, four filter taps \u2014 and every pass a desktop reader sees it still draws. Measured on the gilded chamber at a phone viewport: 8.09 MP and 809.6 MB a frame becomes 1.60 MP and 49.7 MB, a sixteenfold cut, with the reflection, the light volume, the shadows and the composite all still in the frame. Desktop is untouched, held at 96 pixels against a 101 pixel floor. The device is recognised by its panel and its input rather than by the GPU-name table, which is exactly what missed this one",
2512
2528
  "short": "A demo host can scale a scene to the device without removing any of its passes."
2513
2529
  },
2514
2530
  {
2515
2531
  "kind": "changed",
2516
- "text": "`discardResolvedAttachments` and `deferFramePass` now default to **false**, and the swap chain is acquired eagerly again unless `deferFramePass` asks otherwise. All three consumers came back completely black on an iPhone 16 Pro under Chrome — which on iOS is WKWebView, so WebKit's WebGPU implementation on an Apple tile-based GPU. These two are precisely the changes whose correctness an immediate-mode desktop GPU cannot express: a discarded multisample attachment costs nothing and changes nothing where the samples have nowhere else to be, and taking the swap chain late is indistinguishable from taking it early until a browser disagrees about when a frame is presented. Both passed every gate this repository has, which is the point rather than the excuse — it is the failure mode both were documented as carrying. A black screen outweighs any bandwidth figure, so they are off until a real device says otherwise. Everything else from this release is device-independent and unchanged: the live-cubemap budget, the capability clamp, the timestamp queries and the WebGL2 `invalidateFramebuffer` (which is gated on multisampling and so never ran on a phone). Both switches are two-way from the address bar in all three consumers — `?discard=1`, `?defer=1` — so whichever brings the black back names the cause",
2532
+ "text": "`discardResolvedAttachments` and `deferFramePass` now default to **false**, and the swap chain is acquired eagerly again unless `deferFramePass` asks otherwise. All three consumers came back completely black on an iPhone 16 Pro under Chrome \u2014 which on iOS is WKWebView, so WebKit's WebGPU implementation on an Apple tile-based GPU. These two are precisely the changes whose correctness an immediate-mode desktop GPU cannot express: a discarded multisample attachment costs nothing and changes nothing where the samples have nowhere else to be, and taking the swap chain late is indistinguishable from taking it early until a browser disagrees about when a frame is presented. Both passed every gate this repository has, which is the point rather than the excuse \u2014 it is the failure mode both were documented as carrying. A black screen outweighs any bandwidth figure, so they are off until a real device says otherwise. Everything else from this release is device-independent and unchanged: the live-cubemap budget, the capability clamp, the timestamp queries and the WebGL2 `invalidateFramebuffer` (which is gated on multisampling and so never ran on a phone). Both switches are two-way from the address bar in all three consumers \u2014 `?discard=1`, `?defer=1` \u2014 so whichever brings the black back names the cause",
2517
2533
  "short": "The two tiler-only changes default off after an iOS black screen; both bisectable."
2518
2534
  },
2519
2535
  {
2520
2536
  "kind": "fixed",
2521
- "text": "A lost GPU device is no longer silent. **This is the defect behind a black screen that survived reloads and setting changes on iOS**, and it is worse than whatever caused the loss: every entry point on the WebGPU renderer guards on `surface.lost` and returns, so a host's frame loop went on calling forty of them at full rate and produced a black canvas with nothing thrown, nothing logged and nothing to catch. A consumer not already listening to `onContextLost` had no way to tell that apart from a rendering bug — and two of the three here were not listening. `beginFrame` now says so once, naming the recovery, and `DemoHandle.lost` lets a demo host see it without reaching past the handle. WebGPU cannot restore a device, only replace one: `createRenderer` on the same canvas acquires a fresh adapter and device and reconfigures the context, so it has always been the recovery — nothing was calling it",
2537
+ "text": "A lost GPU device is no longer silent. **This is the defect behind a black screen that survived reloads and setting changes on iOS**, and it is worse than whatever caused the loss: every entry point on the WebGPU renderer guards on `surface.lost` and returns, so a host's frame loop went on calling forty of them at full rate and produced a black canvas with nothing thrown, nothing logged and nothing to catch. A consumer not already listening to `onContextLost` had no way to tell that apart from a rendering bug \u2014 and two of the three here were not listening. `beginFrame` now says so once, naming the recovery, and `DemoHandle.lost` lets a demo host see it without reaching past the handle. WebGPU cannot restore a device, only replace one: `createRenderer` on the same canvas acquires a fresh adapter and device and reconfigures the context, so it has always been the recovery \u2014 nothing was calling it",
2522
2538
  "short": "A lost GPU device says so instead of drawing a silent black frame."
2523
2539
  },
2524
2540
  {
2525
2541
  "kind": "fixed",
2526
- "text": "Nothing may end the planar reflection's pass while the mirror is being drawn. `takeVolumeDepth` and `openTextPass` both end whatever pass is current and reopen on the *composite* target, and neither checked whether the current pass was the mirror's. Called during a reflection they therefore ended it, threw away what the mirror had drawn, and sent everything drawn afterwards into the frame instead — an empty mirror and a frame with the mirror's geometry in it. **On an immediate-mode GPU the first half is invisible**, because a discarded attachment has nowhere else to be and keeps its contents anyway; on a tile-based GPU it is exactly what `storeOp: 'discard'` promises, so the fault appears on a phone and nowhere else. `drawFilm` already guarded this boundary with `!reflectionPassActive` and the asymmetry is what let it survive. A volume inside the mirror now draws unclamped, which is right on its own terms — `resolvedDepthView` holds the frame's depth, and clamping a mirrored beam against it would stop it at geometry that is not in front of it",
2542
+ "text": "Nothing may end the planar reflection's pass while the mirror is being drawn. `takeVolumeDepth` and `openTextPass` both end whatever pass is current and reopen on the *composite* target, and neither checked whether the current pass was the mirror's. Called during a reflection they therefore ended it, threw away what the mirror had drawn, and sent everything drawn afterwards into the frame instead \u2014 an empty mirror and a frame with the mirror's geometry in it. **On an immediate-mode GPU the first half is invisible**, because a discarded attachment has nowhere else to be and keeps its contents anyway; on a tile-based GPU it is exactly what `storeOp: 'discard'` promises, so the fault appears on a phone and nowhere else. `drawFilm` already guarded this boundary with `!reflectionPassActive` and the asymmetry is what let it survive. A volume inside the mirror now draws unclamped, which is right on its own terms \u2014 `resolvedDepthView` holds the frame's depth, and clamping a mirrored beam against it would stop it at geometry that is not in front of it",
2527
2543
  "short": "The mirror's pass can no longer be ended by a light volume or by text."
2528
2544
  },
2529
2545
  {
2530
2546
  "kind": "changed",
2531
- "text": "GPU timing is opt-in through `RenderQuality.gpuTiming`, and off by default. **A diagnostic must never be able to break the thing it observes, and this one could.** Enabling it attaches a `timestampWrites` block to *every render pass in the frame* — twelve of them — so an implementation that disagrees about any part of it invalidates the whole command buffer, and an invalid command buffer draws nothing at all. It shipped on by default, unconditional and ungated, having only ever run against one WebGPU implementation. The `timestamp-query` feature is now requested only when a consumer asks for the timer, because a requested feature is a feature the device carries. Turn it on while measuring: `?gputiming=1` in the harness and in both consuming apps",
2547
+ "text": "GPU timing is opt-in through `RenderQuality.gpuTiming`, and off by default. **A diagnostic must never be able to break the thing it observes, and this one could.** Enabling it attaches a `timestampWrites` block to *every render pass in the frame* \u2014 twelve of them \u2014 so an implementation that disagrees about any part of it invalidates the whole command buffer, and an invalid command buffer draws nothing at all. It shipped on by default, unconditional and ungated, having only ever run against one WebGPU implementation. The `timestamp-query` feature is now requested only when a consumer asks for the timer, because a requested feature is a feature the device carries. Turn it on while measuring: `?gputiming=1` in the harness and in both consuming apps",
2532
2548
  "short": "GPU timing is off by default; a profiler must not be able to blank a frame."
2533
2549
  }
2534
2550
  ]
@@ -2586,12 +2602,12 @@
2586
2602
  },
2587
2603
  {
2588
2604
  "kind": "fixed",
2589
- "text": "Textures uploaded from a canvas are no longer flipped vertically, so a canvas and an ImageBitmap finally arrive the same way up. The upload set the pixel store flip for every source, and WebGL ignores that flag for an ImageBitmap because a bitmap carries its own orientation — so the two source types the same method accepts arrived opposite to each other, and had done for as long as the method existed. Nothing caught it because every canvas-sourced texture here was symmetric under a vertical flip: eroded noise, a radial halo, a sleeve blurred down to twenty-four pixels and back. It was found by painting lettering, where a title and an artist came out upside down beside a sleeve that did not. Bitmaps are deliberately the ones left alone, since making them match instead would have turned over every painted surface on every loaded model. If you paint to a canvas and upload it, it is now the right way up, and any code mirroring its own canvas to cancel this has to stop.",
2605
+ "text": "Textures uploaded from a canvas are no longer flipped vertically, so a canvas and an ImageBitmap finally arrive the same way up. The upload set the pixel store flip for every source, and WebGL ignores that flag for an ImageBitmap because a bitmap carries its own orientation \u2014 so the two source types the same method accepts arrived opposite to each other, and had done for as long as the method existed. Nothing caught it because every canvas-sourced texture here was symmetric under a vertical flip: eroded noise, a radial halo, a sleeve blurred down to twenty-four pixels and back. It was found by painting lettering, where a title and an artist came out upside down beside a sleeve that did not. Bitmaps are deliberately the ones left alone, since making them match instead would have turned over every painted surface on every loaded model. If you paint to a canvas and upload it, it is now the right way up, and any code mirroring its own canvas to cancel this has to stop.",
2590
2606
  "short": "A canvas-sourced texture is no longer uploaded upside down, so canvases and bitmaps agree. Anything cancelling the old flip must stop."
2591
2607
  },
2592
2608
  {
2593
2609
  "kind": "fixed",
2594
- "text": "A planar reflection is no longer dropped in full on WebGPU when the scene draws a wet film. The film samples the mirror, and a scene that draws itself twice draws the film into the mirror as well; the pass already knew not to read a half-drawn reflection and switched the lookup off, but its bind group went on holding the texture, and WebGPU validates the binding rather than the read. The result was not a wrong reflection, it was no reflection: the whole command buffer was invalidated at the end of the pass, every frame, with nothing to show for it but a console line. The binding moves now as well as the flag. The same shape of fault was fixed for the environment probe before this release, and the rule is the same both times — turning a uniform off is not enough on its own, because a driver may fetch a sampler's descriptor before it evaluates the arithmetic that discards the result.",
2610
+ "text": "A planar reflection is no longer dropped in full on WebGPU when the scene draws a wet film. The film samples the mirror, and a scene that draws itself twice draws the film into the mirror as well; the pass already knew not to read a half-drawn reflection and switched the lookup off, but its bind group went on holding the texture, and WebGPU validates the binding rather than the read. The result was not a wrong reflection, it was no reflection: the whole command buffer was invalidated at the end of the pass, every frame, with nothing to show for it but a console line. The binding moves now as well as the flag. The same shape of fault was fixed for the environment probe before this release, and the rule is the same both times \u2014 turning a uniform off is not enough on its own, because a driver may fetch a sampler's descriptor before it evaluates the arithmetic that discards the result.",
2595
2611
  "short": "A wet film no longer costs the whole planar reflection on WebGPU, where holding a texture counts even when nothing reads it."
2596
2612
  },
2597
2613
  {
@@ -2692,7 +2708,7 @@
2692
2708
  {
2693
2709
  "kind": "fixed",
2694
2710
  "text": "Bloom now decides how bright a colour is from its largest channel rather than from its luminance, which is what makes a coloured light bloom at all. Luminance weights green heavily and blue barely, so a saturated source counted as dim however far past white it really was: a pure blue driven to 1.4, which is off the top of what a screen can show in blue and is unmistakably a light rather than a surface, measured 0.101 by that reckoning and would have had to reach nearly fourteen before it crossed a threshold of one. This was found from outside, on two worlds built entirely out of coloured light, both of which bloomed by not one pixel with the effect switched on and the threshold meaning exactly what it says. The largest channel makes the threshold mean what a reader already takes it to mean, which is past white. It can only ever bloom more and never less, and the two measures agree exactly on any grey, so a scene lit in white is untouched by this and a scene lit in colour is repaired by it. The ratio is applied to all three channels rather than to each on its own, so a halo keeps the colour of whatever cast it instead of drifting toward the strongest channel.",
2695
- "short": "Bloom decides brightness from a colour’s largest channel rather than its luminance, so a saturated light blooms instead of counting as dim."
2711
+ "short": "Bloom decides brightness from a colour\u2019s largest channel rather than its luminance, so a saturated light blooms instead of counting as dim."
2696
2712
  }
2697
2713
  ]
2698
2714
  },
@@ -3126,7 +3142,7 @@
3126
3142
  "entries": [
3127
3143
  {
3128
3144
  "kind": "fixed",
3129
- "text": "A mesh whose optional attributes did not cover every vertex is now rejected when it is built, rather than drawn differently on different hardware. Each shape method filled those arrays by hand and they had drifted apart: some wrote a value twice per vertex, one wrote it five times, and merging another mesh did not carry roughness across at all. A short attribute buffer is not harmless — the standard lets a driver read past the end as zeroes or refuse the draw outright, and both are correct — so a scene could be flawless on one machine and simply absent on another, with no error raised anywhere. A courtyard lit by lanterns arrived on a phone as a fire burning in an empty void for exactly this reason.",
3145
+ "text": "A mesh whose optional attributes did not cover every vertex is now rejected when it is built, rather than drawn differently on different hardware. Each shape method filled those arrays by hand and they had drifted apart: some wrote a value twice per vertex, one wrote it five times, and merging another mesh did not carry roughness across at all. A short attribute buffer is not harmless \u2014 the standard lets a driver read past the end as zeroes or refuse the draw outright, and both are correct \u2014 so a scene could be flawless on one machine and simply absent on another, with no error raised anywhere. A courtyard lit by lanterns arrived on a phone as a fire burning in an empty void for exactly this reason.",
3130
3146
  "short": "Meshes with incomplete attributes are caught when built, instead of vanishing on some GPUs."
3131
3147
  },
3132
3148
  {
@@ -3141,7 +3157,7 @@
3141
3157
  },
3142
3158
  {
3143
3159
  "kind": "fixed",
3144
- "text": "Point-light shadows are no longer switched off on hardware that can afford them. The budget fits a sixteen-unit device exactly, and requiring one unit spare disabled the feature on every GPU reporting that figure — which is all of Apple's.",
3160
+ "text": "Point-light shadows are no longer switched off on hardware that can afford them. The budget fits a sixteen-unit device exactly, and requiring one unit spare disabled the feature on every GPU reporting that figure \u2014 which is all of Apple's.",
3145
3161
  "short": "Point-light shadows stay on where the texture units genuinely fit."
3146
3162
  },
3147
3163
  {
@@ -3162,7 +3178,7 @@
3162
3178
  },
3163
3179
  {
3164
3180
  "kind": "changed",
3165
- "text": "Comments and documentation throughout the engine have been rewritten to stand on their own. They were dense with the history of one game — its proper nouns, the names of the people who filed each report, and the words they used at the time — which reads as a private notebook rather than as an engine's reference, and would be the wrong thing to hand to anyone else. The reasoning has been kept in full, because it is the reason these files are worth reading; only the parts that meant something to one project have been replaced by the general thing they were an instance of.",
3181
+ "text": "Comments and documentation throughout the engine have been rewritten to stand on their own. They were dense with the history of one game \u2014 its proper nouns, the names of the people who filed each report, and the words they used at the time \u2014 which reads as a private notebook rather than as an engine's reference, and would be the wrong thing to hand to anyone else. The reasoning has been kept in full, because it is the reason these files are worth reading; only the parts that meant something to one project have been replaced by the general thing they were an instance of.",
3166
3182
  "short": "Comments and documentation are now written for any reader, not just one project."
3167
3183
  }
3168
3184
  ]
@@ -3173,7 +3189,7 @@
3173
3189
  "entries": [
3174
3190
  {
3175
3191
  "kind": "added",
3176
- "text": "Surfaces can take their colour from a picture. Until now everything was coloured a corner at a time, which is right for a cliff face and hopeless for wallpaper, floor tile, a photograph or a hand print on a wall — so a game whose whole look is patterned surfaces had no way to say so. A picture can also cut its own silhouette out, so a shape drawn on a clear background arrives as that shape and not as a rectangle around it, and it can be replaced after the fact, which is how a portrait that is still downloading gets swapped in without rebuilding anything.",
3192
+ "text": "Surfaces can take their colour from a picture. Until now everything was coloured a corner at a time, which is right for a cliff face and hopeless for wallpaper, floor tile, a photograph or a hand print on a wall \u2014 so a game whose whole look is patterned surfaces had no way to say so. A picture can also cut its own silhouette out, so a shape drawn on a clear background arrives as that shape and not as a rectangle around it, and it can be replaced after the fact, which is how a portrait that is still downloading gets swapped in without rebuilding anything.",
3177
3193
  "short": "Surfaces can take their colour from a picture, cut their own silhouette from it, and swap it later."
3178
3194
  },
3179
3195
  {
@@ -3188,17 +3204,17 @@
3188
3204
  },
3189
3205
  {
3190
3206
  "kind": "added",
3191
- "text": "A surface can glow in a colour it is not made of. Glow used to be a single dial that brightened whatever the surface already was, so a warm strip set into a pale panel came out pale and bright rather than warm — right about the brightness, wrong about the light. It can also be turned up and down for a single draw, which is what lets a doorway breathe or a ceiling pulse with music instead of sitting at one fixed brightness.",
3207
+ "text": "A surface can glow in a colour it is not made of. Glow used to be a single dial that brightened whatever the surface already was, so a warm strip set into a pale panel came out pale and bright rather than warm \u2014 right about the brightness, wrong about the light. It can also be turned up and down for a single draw, which is what lets a doorway breathe or a ceiling pulse with music instead of sitting at one fixed brightness.",
3192
3208
  "short": "A surface can glow in a colour it is not made of, and a single draw can pulse its glow."
3193
3209
  },
3194
3210
  {
3195
3211
  "kind": "added",
3196
- "text": "Polished floors catch the lamps above them. A highlight answered only to the sun, so a room lit entirely by its own fixtures had none at all and a waxed floor under a strip light looked like matte board. Surfaces also carry how rough they are, which decides the *width* of a highlight — the difference between a lamp landing as a dot and smearing down the length of a corridor.",
3212
+ "text": "Polished floors catch the lamps above them. A highlight answered only to the sun, so a room lit entirely by its own fixtures had none at all and a waxed floor under a strip light looked like matte board. Surfaces also carry how rough they are, which decides the *width* of a highlight \u2014 the difference between a lamp landing as a dot and smearing down the length of a corridor.",
3197
3213
  "short": "Polished floors catch the lamps above them, and how rough a surface is decides how wide the highlight is."
3198
3214
  },
3199
3215
  {
3200
3216
  "kind": "added",
3201
- "text": "Water is lit by the lamps around it, not only by the sky. The surface answered to a single distant source, which quietly assumes water lives outdoors — a flooded corridor, a cistern, an indoor pool and a harbour at night are all water lit entirely by lamps, and under those it had no shading and none of the glitter that is the strongest single sign that a surface is water rather than a coloured plane. A body of water can also be told how disturbed it is, rather than taking it from the weather, so water in a sealed room is not calm because there is no wind in the room.",
3217
+ "text": "Water is lit by the lamps around it, not only by the sky. The surface answered to a single distant source, which quietly assumes water lives outdoors \u2014 a flooded corridor, a cistern, an indoor pool and a harbour at night are all water lit entirely by lamps, and under those it had no shading and none of the glitter that is the strongest single sign that a surface is water rather than a coloured plane. A body of water can also be told how disturbed it is, rather than taking it from the weather, so water in a sealed room is not calm because there is no wind in the room.",
3202
3218
  "short": "Water is lit by the lamps around it, and can be as disturbed as it likes with no wind to explain it."
3203
3219
  },
3204
3220
  {
@@ -3208,13 +3224,13 @@
3208
3224
  },
3209
3225
  {
3210
3226
  "kind": "added",
3211
- "text": "Creatures can be built out of capsules and spheres rather than boxes. Everything was a box, so anything alive read as furniture — a fair description of what it looked like. Flat panels are cheaper to build too: a single face can be added on its own instead of a whole six-sided block, which is what a wall, a floor or a poster actually is.",
3227
+ "text": "Creatures can be built out of capsules and spheres rather than boxes. Everything was a box, so anything alive read as furniture \u2014 a fair description of what it looked like. Flat panels are cheaper to build too: a single face can be added on its own instead of a whole six-sided block, which is what a wall, a floor or a poster actually is.",
3212
3228
  "short": "Creatures can be built from capsules and spheres, and a flat panel no longer has to be a whole box."
3213
3229
  },
3214
3230
  {
3215
3231
  "kind": "added",
3216
3232
  "text": "Anything flat can be made to face the viewer. A sign, a figure, a face hanging in the dark: two ways, one that turns only on the spot so a standing thing stays upright when you look down at it, and one that faces you on every axis for a spark or a puff of smoke. Picking the wrong one is the classic version of this mistake and both are now available to pick from.",
3217
- "short": "Anything flat can face the viewer — upright for a standing figure, fully for a spark."
3233
+ "short": "Anything flat can face the viewer \u2014 upright for a standing figure, fully for a spark."
3218
3234
  },
3219
3235
  {
3220
3236
  "kind": "added",
@@ -3223,12 +3239,12 @@
3223
3239
  },
3224
3240
  {
3225
3241
  "kind": "fixed",
3226
- "text": "A room lit only by its own lamps is no longer nearly black. Half of the soft fill light — the half that reaches anything facing downward — was being left out entirely, and the film-like curve a scene asked for was reaching one part of the picture and not the rest. Both were silent: nothing failed, the room was simply dark, and no amount of turning the lights up could reach what was not being asked for.",
3242
+ "text": "A room lit only by its own lamps is no longer nearly black. Half of the soft fill light \u2014 the half that reaches anything facing downward \u2014 was being left out entirely, and the film-like curve a scene asked for was reaching one part of the picture and not the rest. Both were silent: nothing failed, the room was simply dark, and no amount of turning the lights up could reach what was not being asked for.",
3227
3243
  "short": "A room lit only by its own lamps is no longer nearly black, from two settings that were never applied."
3228
3244
  },
3229
3245
  {
3230
3246
  "kind": "fixed",
3231
- "text": "A world that never tints anything is no longer black. The tint every surface is multiplied by started at nothing rather than at white, so a game that never set one had every surface multiplied to zero — a first run that renders a black screen and looks like a much deeper problem than it is.",
3247
+ "text": "A world that never tints anything is no longer black. The tint every surface is multiplied by started at nothing rather than at white, so a game that never set one had every surface multiplied to zero \u2014 a first run that renders a black screen and looks like a much deeper problem than it is.",
3232
3248
  "short": "A world that never tints anything is no longer rendered black."
3233
3249
  },
3234
3250
  {
@@ -3238,7 +3254,7 @@
3238
3254
  },
3239
3255
  {
3240
3256
  "kind": "fixed",
3241
- "text": "A renderer whose drawing surface has been taken away now stops instead of failing. A browser can reclaim it at any time — most often when a machine has too many pages open — and everything that touched it afterwards raised its own error, so one loss became a flood of them with the cause buried somewhere near the top. It stops drawing, it answers honestly when asked whether it still has one, and letting go of something after the surface is gone is no longer treated as a mistake.",
3257
+ "text": "A renderer whose drawing surface has been taken away now stops instead of failing. A browser can reclaim it at any time \u2014 most often when a machine has too many pages open \u2014 and everything that touched it afterwards raised its own error, so one loss became a flood of them with the cause buried somewhere near the top. It stops drawing, it answers honestly when asked whether it still has one, and letting go of something after the surface is gone is no longer treated as a mistake.",
3242
3258
  "short": "A renderer whose drawing surface was taken away stops cleanly, instead of raising an error per call."
3243
3259
  },
3244
3260
  {
@@ -3248,7 +3264,7 @@
3248
3264
  },
3249
3265
  {
3250
3266
  "kind": "fixed",
3251
- "text": "A highlight's width and its strength stopped interfering with each other. Roughness is meant to say how wide a highlight is and nothing else, and it was quietly setting how bright one was too — so a surface that had asked for a soft sheen before roughness existed came out as a blown white speck instead, fifty times too bright and less than half as wide. Anything that was tuned against the old behaviour keeps its look by saying how strong it wants its highlight, which is the setting that was always meant to carry it.",
3267
+ "text": "A highlight's width and its strength stopped interfering with each other. Roughness is meant to say how wide a highlight is and nothing else, and it was quietly setting how bright one was too \u2014 so a surface that had asked for a soft sheen before roughness existed came out as a blown white speck instead, fifty times too bright and less than half as wide. Anything that was tuned against the old behaviour keeps its look by saying how strong it wants its highlight, which is the setting that was always meant to carry it.",
3252
3268
  "short": "A highlight's width and its strength stopped interfering with each other."
3253
3269
  }
3254
3270
  ]
@@ -3291,7 +3307,7 @@
3291
3307
  "entries": [
3292
3308
  {
3293
3309
  "kind": "added",
3294
- "text": "Surfaces can carry a specular highlight. Until now everything in a world was lit the same way — a flat colour with the sun's angle on it and nothing else — which is honest for stone and wrong for anything that is meant to catch the light. Geometry can now ask for a highlight per vertex, so a cut face flashes as it turns and a plain wall next to it does not. It is optional and off by default, which matters more than it sounds: a mesh that does not ask for one produces exactly the data it produced before, uploads nothing extra, and costs a single comparison per pixel.",
3310
+ "text": "Surfaces can carry a specular highlight. Until now everything in a world was lit the same way \u2014 a flat colour with the sun's angle on it and nothing else \u2014 which is honest for stone and wrong for anything that is meant to catch the light. Geometry can now ask for a highlight per vertex, so a cut face flashes as it turns and a plain wall next to it does not. It is optional and off by default, which matters more than it sounds: a mesh that does not ask for one produces exactly the data it produced before, uploads nothing extra, and costs a single comparison per pixel.",
3295
3311
  "short": "Surfaces can ask for a specular highlight, so a cut face flashes as it turns while plain ones do not."
3296
3312
  },
3297
3313
  {
@@ -3301,7 +3317,7 @@
3301
3317
  },
3302
3318
  {
3303
3319
  "kind": "added",
3304
- "text": "A shader for arcane auras, and a colour for effects to take. The volumetric effect system could draw fire and smoke — things that rise — and had no way to draw something that surrounds an object instead. The new one works outward from its centre in rings that turn against each other, which reads as contained energy rather than as a flame. Effects can also be tinted now, so one shader serves every colour instead of one shader per colour.",
3320
+ "text": "A shader for arcane auras, and a colour for effects to take. The volumetric effect system could draw fire and smoke \u2014 things that rise \u2014 and had no way to draw something that surrounds an object instead. The new one works outward from its centre in rings that turn against each other, which reads as contained energy rather than as a flame. Effects can also be tinted now, so one shader serves every colour instead of one shader per colour.",
3305
3321
  "short": "A shader for arcane auras that surround an object rather than rising off it, and a tint for effects."
3306
3322
  },
3307
3323
  {
@@ -3322,7 +3338,7 @@
3322
3338
  },
3323
3339
  {
3324
3340
  "kind": "added",
3325
- "text": "`ResolutionGovernor` holds a frame budget by moving the drawing-buffer scale, and nothing else. Pure policy with no GL, no DOM and no clock, because every device it exists for is one no test machine has: frame times go in, a new scale comes out, and `null` comes out on the frames where nothing moved so an ordinary frame costs no GL or DOM work. **It is deliberately hard to trigger**, because the failure mode that matters is degrading a machine that was fine. A window is bad only when half its sixty frames ran over 40 ms, and the scale moves only after two bad windows in a row with no good one between them — so a garbage collection, a shader compile, a world build or another application grabbing the GPU cannot move it, while an Adreno 619 at 156.66 ms a frame has a share of 1.0 and clears two windows immediately. An earlier version took the worst frame in the window instead, which would have dropped a 120 fps machine a step on one hitch: the worst frame is the right rule for diagnosing a device and the wrong one for a control loop, which has to ignore what it cannot act on. Raising is slower still and waits behind four clean windows, because an oscillating resolution is more visible than a slightly low one. Unplayable frames are not evidence at all: a menu frame and a hidden-tab frame are not verdicts about the machine, and that false positive reached a player once already",
3341
+ "text": "`ResolutionGovernor` holds a frame budget by moving the drawing-buffer scale, and nothing else. Pure policy with no GL, no DOM and no clock, because every device it exists for is one no test machine has: frame times go in, a new scale comes out, and `null` comes out on the frames where nothing moved so an ordinary frame costs no GL or DOM work. **It is deliberately hard to trigger**, because the failure mode that matters is degrading a machine that was fine. A window is bad only when half its sixty frames ran over 40 ms, and the scale moves only after two bad windows in a row with no good one between them \u2014 so a garbage collection, a shader compile, a world build or another application grabbing the GPU cannot move it, while an Adreno 619 at 156.66 ms a frame has a share of 1.0 and clears two windows immediately. An earlier version took the worst frame in the window instead, which would have dropped a 120 fps machine a step on one hitch: the worst frame is the right rule for diagnosing a device and the wrong one for a control loop, which has to ignore what it cannot act on. Raising is slower still and waits behind four clean windows, because an oscillating resolution is more visible than a slightly low one. Unplayable frames are not evidence at all: a menu frame and a hidden-tab frame are not verdicts about the machine, and that false positive reached a player once already",
3326
3342
  "short": "`ResolutionGovernor` holds a frame budget by moving the drawing-buffer scale. Deliberately hard to trigger."
3327
3343
  },
3328
3344
  {
@@ -3332,7 +3348,7 @@
3332
3348
  },
3333
3349
  {
3334
3350
  "kind": "changed",
3335
- "text": "A GPU family known to struggle now opens at pixel terms it can hold, clamped at construction to a density of 1 and a reflection scale of 0.5. An Adreno 619 was opening at a density of 2.625 with a full-size reflection target and holding 156.66 ms a frame, because the consumer's default quality is a constant for every device. Only the pixel terms move: the shadow and water switches are what a preset means, and drawing a different scene than a settings screen describes is worse than drawing the right one at fewer pixels, while both clamped terms are ones a player can already reach themselves. It is a hint and never authority — the unknown renderer string is deliberately not treated as weak, because a device database is wrong about every GPU nobody has tested, and the governor corrects a wrong guess from measurement in both directions. `RenderQualityOptions.capabilityClamp: false` turns it off entirely, and a consumer passes that to mean \"this person chose these numbers themselves\" — a player who deliberately asks a weak part for the good profile is entitled to it and to the frame rate that comes with it, because a setting that silently does not apply is worse than a slow game",
3351
+ "text": "A GPU family known to struggle now opens at pixel terms it can hold, clamped at construction to a density of 1 and a reflection scale of 0.5. An Adreno 619 was opening at a density of 2.625 with a full-size reflection target and holding 156.66 ms a frame, because the consumer's default quality is a constant for every device. Only the pixel terms move: the shadow and water switches are what a preset means, and drawing a different scene than a settings screen describes is worse than drawing the right one at fewer pixels, while both clamped terms are ones a player can already reach themselves. It is a hint and never authority \u2014 the unknown renderer string is deliberately not treated as weak, because a device database is wrong about every GPU nobody has tested, and the governor corrects a wrong guess from measurement in both directions. `RenderQualityOptions.capabilityClamp: false` turns it off entirely, and a consumer passes that to mean \"this person chose these numbers themselves\" \u2014 a player who deliberately asks a weak part for the good profile is entitled to it and to the frame rate that comes with it, because a setting that silently does not apply is worse than a slow game",
3336
3352
  "short": "A GPU family known to struggle opens at pixel terms it can hold. A hint, never authority."
3337
3353
  }
3338
3354
  ],
@@ -3346,7 +3362,7 @@
3346
3362
  "entries": [
3347
3363
  {
3348
3364
  "kind": "fixed",
3349
- "text": "A `texture()` call reached through a branch the compiler could not prove uniform was being flattened, costing 13 ms a frame. `flat.ts`'s point-light loop samples its shadow cubemaps inside a ten-way `if (i == uPointShadowIndex[k])` chain, which is the shape GLSL ES 3.00 forces without a dynamically-indexable sampler array, and `texture()`'s implicit derivatives are only well-defined under uniform control flow — so a compiler unwilling to trust that branch is free to compute every arm and discard nine. The tell was a system getting slower when it was switched off: `?shadows=0` moved the unattributed part of the frame from 17.50 ms to 28.50 ms, reproduced three times, and removing work cannot cost 11 ms. Both calls are now `textureLod(..., 0.0)`, which is output-identical by construction rather than by eye, because those cubemaps carry one storage level and `NEAREST` on both filters, so no mip exists for the two to disagree about. Measured on one rig with a pinned sky: 17.50 ms to 4.50 ms, wall clock 17.00 to 8.50, which is the measured vsync interval",
3365
+ "text": "A `texture()` call reached through a branch the compiler could not prove uniform was being flattened, costing 13 ms a frame. `flat.ts`'s point-light loop samples its shadow cubemaps inside a ten-way `if (i == uPointShadowIndex[k])` chain, which is the shape GLSL ES 3.00 forces without a dynamically-indexable sampler array, and `texture()`'s implicit derivatives are only well-defined under uniform control flow \u2014 so a compiler unwilling to trust that branch is free to compute every arm and discard nine. The tell was a system getting slower when it was switched off: `?shadows=0` moved the unattributed part of the frame from 17.50 ms to 28.50 ms, reproduced three times, and removing work cannot cost 11 ms. Both calls are now `textureLod(..., 0.0)`, which is output-identical by construction rather than by eye, because those cubemaps carry one storage level and `NEAREST` on both filters, so no mip exists for the two to disagree about. Measured on one rig with a pinned sky: 17.50 ms to 4.50 ms, wall clock 17.00 to 8.50, which is the measured vsync interval",
3350
3366
  "short": "A `texture()` call in a branch the compiler could not prove uniform was being flattened, costing 13 ms a frame."
3351
3367
  }
3352
3368
  ],
@@ -3361,8 +3377,8 @@
3361
3377
  "entries": [
3362
3378
  {
3363
3379
  "kind": "added",
3364
- "text": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path — the primitive a curve needs, where `addCylinder` is straight and axis-aligned. A tube carries a normal per ring vertex, so the flat shader shades it as the round thing it is, where a curve chained out of boxes has hard corners and six distinct normals and reads as a staircase however finely it is stepped. The frame is parallel-transported rather than built from a fixed world up, because a world-up frame flips through 180 degrees where a path turns vertical and puts a visible seam at exactly the apex of an arch.",
3365
- "short": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path — the primitive a curve needs."
3380
+ "text": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path \u2014 the primitive a curve needs, where `addCylinder` is straight and axis-aligned. A tube carries a normal per ring vertex, so the flat shader shades it as the round thing it is, where a curve chained out of boxes has hard corners and six distinct normals and reads as a staircase however finely it is stepped. The frame is parallel-transported rather than built from a fixed world up, because a world-up frame flips through 180 degrees where a path turns vertical and puts a visible seam at exactly the apex of an arch.",
3381
+ "short": "`MeshBuilder.addTube` sweeps a round tube of varying radius along an arbitrary path \u2014 the primitive a curve needs."
3366
3382
  },
3367
3383
  {
3368
3384
  "kind": "added",
@@ -3371,12 +3387,12 @@
3371
3387
  },
3372
3388
  {
3373
3389
  "kind": "added",
3374
- "text": "`AudioGraph.slamLowEnd` boosts the score's bass and slams a low-pass shut for a fraction of a second, then lets both back. It is a parallel path rather than an in-line stage, so an idle graph is sample-identical to one without it, and it is tapped upstream of the airborne lift — the lift is a high-pass, so a slam taken after it would be weakest exactly where it is most wanted.",
3390
+ "text": "`AudioGraph.slamLowEnd` boosts the score's bass and slams a low-pass shut for a fraction of a second, then lets both back. It is a parallel path rather than an in-line stage, so an idle graph is sample-identical to one without it, and it is tapped upstream of the airborne lift \u2014 the lift is a high-pass, so a slam taken after it would be weakest exactly where it is most wanted.",
3375
3391
  "short": "`AudioGraph.slamLowEnd` boosts the bass and slams a low-pass shut for a fraction of a second, then lets both back."
3376
3392
  },
3377
3393
  {
3378
3394
  "kind": "fixed",
3379
- "text": "Filtered noise is actually filtered. Every noise source in the engine ran through a single pole, which is 6 dB per octave — three or four octaves above its own cutoff it has taken off barely twenty decibels, so what survives is still broadband, and lowering the cutoff could never have helped because the leak is the slope rather than the corner. Three poles in series is 18 dB per octave, and every existing caller gets it without changing any of its own numbers.",
3395
+ "text": "Filtered noise is actually filtered. Every noise source in the engine ran through a single pole, which is 6 dB per octave \u2014 three or four octaves above its own cutoff it has taken off barely twenty decibels, so what survives is still broadband, and lowering the cutoff could never have helped because the leak is the slope rather than the corner. Three poles in series is 18 dB per octave, and every existing caller gets it without changing any of its own numbers.",
3380
3396
  "short": "Filtered noise is actually filtered. Every noise source ran through a single pole, which is 6 dB per octave."
3381
3397
  },
3382
3398
  {
@@ -3396,12 +3412,12 @@
3396
3412
  },
3397
3413
  {
3398
3414
  "kind": "added",
3399
- "text": "A water body may declare itself a mirror. Reflection strength was the Fresnel term alone, which is right for an ocean seen across its own surface and means almost nothing standing over a basin looking down: the angle is steep, the term is a few percent, and a reflection nobody can see is the same as no reflection. A reflection in a fountain was visible and far too faint to read as one. `WaterBody.mirror` blends Fresnel toward one — a lie about optics and an honest one about what a small pool is for, which is looking into it to see something. The sea keeps zero and behaves exactly as before.",
3400
- "short": "A water body may declare itself a mirror, blending Fresnel toward one — a lie about optics, honest about what a pool is for."
3415
+ "text": "A water body may declare itself a mirror. Reflection strength was the Fresnel term alone, which is right for an ocean seen across its own surface and means almost nothing standing over a basin looking down: the angle is steep, the term is a few percent, and a reflection nobody can see is the same as no reflection. A reflection in a fountain was visible and far too faint to read as one. `WaterBody.mirror` blends Fresnel toward one \u2014 a lie about optics and an honest one about what a small pool is for, which is looking into it to see something. The sea keeps zero and behaves exactly as before.",
3416
+ "short": "A water body may declare itself a mirror, blending Fresnel toward one \u2014 a lie about optics, honest about what a pool is for."
3401
3417
  },
3402
3418
  {
3403
3419
  "kind": "added",
3404
- "text": "Text as world geometry. `buildTextMesh` puts a string in the world as boxes, with a separate cell height for text read at a shallow angle, because a marking on a road is foreshortened along the direction it is read from. `MeshBuilder.addOrientedMesh` merges a mesh under an orthonormal basis, which is what lets any of it follow a surface instead of the world's axes; it refuses a left-handed basis, since a mirror reverses winding and every face would then be culled — which renders with no GL error and no pixels.",
3420
+ "text": "Text as world geometry. `buildTextMesh` puts a string in the world as boxes, with a separate cell height for text read at a shallow angle, because a marking on a road is foreshortened along the direction it is read from. `MeshBuilder.addOrientedMesh` merges a mesh under an orthonormal basis, which is what lets any of it follow a surface instead of the world's axes; it refuses a left-handed basis, since a mirror reverses winding and every face would then be culled \u2014 which renders with no GL error and no pixels.",
3405
3421
  "short": "Text as world geometry: `buildTextMesh` puts a string in the world as boxes, with its own cell height for shallow angles."
3406
3422
  },
3407
3423
  {
@@ -3411,7 +3427,7 @@
3411
3427
  },
3412
3428
  {
3413
3429
  "kind": "changed",
3414
- "text": "The canvas stops claiming to be see-through. The WebGL context asked for an alpha channel it has never used: `beginFrame` clears with an alpha of 1 and source-alpha blending leaves the destination at 1, so every pixel handed to the browser was already opaque — but the default is `alpha: true`, and believing it, the compositor blends the whole drawing buffer over the page every frame and may not treat the layer as opaque. That is the fast path a fullscreen page is otherwise given, declined in exchange for a promise nothing was keeping.",
3430
+ "text": "The canvas stops claiming to be see-through. The WebGL context asked for an alpha channel it has never used: `beginFrame` clears with an alpha of 1 and source-alpha blending leaves the destination at 1, so every pixel handed to the browser was already opaque \u2014 but the default is `alpha: true`, and believing it, the compositor blends the whole drawing buffer over the page every frame and may not treat the layer as opaque. That is the fast path a fullscreen page is otherwise given, declined in exchange for a promise nothing was keeping.",
3415
3431
  "short": "The canvas stops claiming to be see-through. It asked for an alpha channel it has never used."
3416
3432
  },
3417
3433
  {
@@ -3421,12 +3437,12 @@
3421
3437
  },
3422
3438
  {
3423
3439
  "kind": "fixed",
3424
- "text": "The tap that the hold had already resolved is no longer eaten. A caller ticks once a frame, so a still touch is always promoted to the held form before it is released, and the press was queued only from `pending` — every tap past the 90 ms resolve was discarded, which on a phone is most of them. `isTapRelease` decides on travel and duration instead, so a promoted touch released quickly is still a tap. The file's own tap test passed throughout because it never ticked between the start and the end of its tap, which is the one sequence the real loop never skips. With it: a held thumb can steer, since the hold used to be terminal; touches after the first are no longer dropped outright and may tap or flick; and the tap window is sized against a platformer's coyote time rather than being generous for its own sake.",
3440
+ "text": "The tap that the hold had already resolved is no longer eaten. A caller ticks once a frame, so a still touch is always promoted to the held form before it is released, and the press was queued only from `pending` \u2014 every tap past the 90 ms resolve was discarded, which on a phone is most of them. `isTapRelease` decides on travel and duration instead, so a promoted touch released quickly is still a tap. The file's own tap test passed throughout because it never ticked between the start and the end of its tap, which is the one sequence the real loop never skips. With it: a held thumb can steer, since the hold used to be terminal; touches after the first are no longer dropped outright and may tap or flick; and the tap window is sized against a platformer's coyote time rather than being generous for its own sake.",
3425
3441
  "short": "The tap that the hold had already resolved is no longer eaten."
3426
3442
  },
3427
3443
  {
3428
3444
  "kind": "changed",
3429
- "text": "A `Spline` measures its centripetal knots once per segment instead of once per evaluation. They cost three hypotenuses and three square roots and depend only on which segment you are in, never on where you are inside it — and `sampleAt` evaluates three times, once for the point and once either side for the central-difference tangent, so a single sample was paying for nine of each. The arc-length table is thousands of evaluations per curve, `buildRibbon` is thousands more, and the simulation samples the route every tick. The four control points a segment interpolates are hoisted the same way, into a point list carrying a reflected phantom at each end, which also takes the two end-case branches out of the inner loop. Measured through a consumer: building a world went from 99 ms to 86, and laying its route from 24 ms to 14. **No number moved**: the arithmetic is the same expression on the same inputs, and twenty-five days of colliders, meshes, lights, anchors, secrets, scatter and segment windows hash identically before and after, which is what the replay contract needs.",
3445
+ "text": "A `Spline` measures its centripetal knots once per segment instead of once per evaluation. They cost three hypotenuses and three square roots and depend only on which segment you are in, never on where you are inside it \u2014 and `sampleAt` evaluates three times, once for the point and once either side for the central-difference tangent, so a single sample was paying for nine of each. The arc-length table is thousands of evaluations per curve, `buildRibbon` is thousands more, and the simulation samples the route every tick. The four control points a segment interpolates are hoisted the same way, into a point list carrying a reflected phantom at each end, which also takes the two end-case branches out of the inner loop. Measured through a consumer: building a world went from 99 ms to 86, and laying its route from 24 ms to 14. **No number moved**: the arithmetic is the same expression on the same inputs, and twenty-five days of colliders, meshes, lights, anchors, secrets, scatter and segment windows hash identically before and after, which is what the replay contract needs.",
3430
3446
  "short": "A `Spline` measures its centripetal knots once per segment instead of once per evaluation."
3431
3447
  }
3432
3448
  ]
@@ -3437,7 +3453,7 @@
3437
3453
  "entries": [
3438
3454
  {
3439
3455
  "kind": "fixed",
3440
- "text": "Point-light shadow maps are borrowed from a pool of twelve instead of owned one per world light, forever. A world with 50 lamps allocated roughly 327 MB of depth cubemaps so that the eight MAX_POINT_LIGHTS the shader can bind could be read; 42 of them were unreadable by construction. Measured 388 MB of texture memory down to 160 MB at 1080p. Nothing is lost visually, and that is by construction rather than by hope: a light outside the sampled set has no sampler bound to it and could not be read however long its map was kept. The cost is a six-face bake when a light re-enters the set, which is also why load no longer bakes anything — it used to render six passes over the static world for every lamp in the day, most of them never looked at.",
3456
+ "text": "Point-light shadow maps are borrowed from a pool of twelve instead of owned one per world light, forever. A world with 50 lamps allocated roughly 327 MB of depth cubemaps so that the eight MAX_POINT_LIGHTS the shader can bind could be read; 42 of them were unreadable by construction. Measured 388 MB of texture memory down to 160 MB at 1080p. Nothing is lost visually, and that is by construction rather than by hope: a light outside the sampled set has no sampler bound to it and could not be read however long its map was kept. The cost is a six-face bake when a light re-enters the set, which is also why load no longer bakes anything \u2014 it used to render six passes over the static world for every lamp in the day, most of them never looked at.",
3441
3457
  "short": "Point-light shadow maps are borrowed from a pool of twelve instead of one per world light, kept forever."
3442
3458
  },
3443
3459
  {
@@ -3447,8 +3463,8 @@
3447
3463
  },
3448
3464
  {
3449
3465
  "kind": "fixed",
3450
- "text": "Multisampling is requested only when something can use it. With `screenEffects` on, the scene lands in a single-sampled off-screen target and the only thing reaching the default framebuffer is one fullscreen triangle, which has no interior edges — so the multisampled backbuffer was allocated and resolved every frame for a provable no-op. Forcing it off moved gl.SAMPLES from 4 to 0 with screenshots that compare identical, and it stops competing for memory on parts where system RAM is the VRAM.",
3451
- "short": "Multisampling is requested only when something can use it — with screen effects on, nothing can."
3466
+ "text": "Multisampling is requested only when something can use it. With `screenEffects` on, the scene lands in a single-sampled off-screen target and the only thing reaching the default framebuffer is one fullscreen triangle, which has no interior edges \u2014 so the multisampled backbuffer was allocated and resolved every frame for a provable no-op. Forcing it off moved gl.SAMPLES from 4 to 0 with screenshots that compare identical, and it stops competing for memory on parts where system RAM is the VRAM.",
3467
+ "short": "Multisampling is requested only when something can use it \u2014 with screen effects on, nothing can."
3452
3468
  }
3453
3469
  ]
3454
3470
  },
@@ -3458,7 +3474,7 @@
3458
3474
  "entries": [
3459
3475
  {
3460
3476
  "kind": "added",
3461
- "text": "A critically damped spring that rides a moving target. A first-order lag has to accumulate error before it can travel at all, so anything following a 14 m/s subject sits a fixed distance behind it — a rig asking for a 7 m arm held 9.2 m — while a spring stepped against the target's own velocity keeps station. Shot placement is now a function separate from the rig that follows it, so a rig can evaluate the same shot at two poses in one frame and learn the velocity it should be riding.",
3477
+ "text": "A critically damped spring that rides a moving target. A first-order lag has to accumulate error before it can travel at all, so anything following a 14 m/s subject sits a fixed distance behind it \u2014 a rig asking for a 7 m arm held 9.2 m \u2014 while a spring stepped against the target's own velocity keeps station. Shot placement is now a function separate from the rig that follows it, so a rig can evaluate the same shot at two poses in one frame and learn the velocity it should be riding.",
3462
3478
  "short": "A critically damped spring that rides a moving target, so a follower does not have to fall behind before it can travel."
3463
3479
  },
3464
3480
  {
@@ -3478,7 +3494,7 @@
3478
3494
  },
3479
3495
  {
3480
3496
  "kind": "added",
3481
- "text": "A mix can be rendered rather than heard. `AudioGraph` takes the context it runs on and a clock saying where scheduled work lands, so an offline render produces the same mix with the same time constants and no wall clock anywhere in the chain. `AmbientLoop` takes the same clock — it read the context's own time, which offline stands still for the entire render, so every bed would have played a whole file at whatever the last frame computed.",
3497
+ "text": "A mix can be rendered rather than heard. `AudioGraph` takes the context it runs on and a clock saying where scheduled work lands, so an offline render produces the same mix with the same time constants and no wall clock anywhere in the chain. `AmbientLoop` takes the same clock \u2014 it read the context's own time, which offline stands still for the entire render, so every bed would have played a whole file at whatever the last frame computed.",
3482
3498
  "short": "A mix can be rendered rather than heard: `AudioGraph` takes the context it runs on and a clock saying where work lands."
3483
3499
  },
3484
3500
  {
@@ -3488,7 +3504,7 @@
3488
3504
  },
3489
3505
  {
3490
3506
  "kind": "added",
3491
- "text": "Clip encoding on a timeline the caller chooses. Frames go in with their own index and come out stamped where they belong rather than when they arrived, so a slow machine makes a slower export and not a worse file — worst deviation from the ideal 1/60 grid, 0.3 microseconds. A rendered score reads back as a beat pulse too, so a light flashing on the kick is exact, and identical on every export of the same material.",
3507
+ "text": "Clip encoding on a timeline the caller chooses. Frames go in with their own index and come out stamped where they belong rather than when they arrived, so a slow machine makes a slower export and not a worse file \u2014 worst deviation from the ideal 1/60 grid, 0.3 microseconds. A rendered score reads back as a beat pulse too, so a light flashing on the kick is exact, and identical on every export of the same material.",
3492
3508
  "short": "Clip encoding on a timeline the caller chooses. Frames go in with their own index and come out stamped where they belong."
3493
3509
  },
3494
3510
  {
@@ -3498,8 +3514,8 @@
3498
3514
  },
3499
3515
  {
3500
3516
  "kind": "added",
3501
- "text": "A trample field — eight recent presses that decay, applied in the vertex shader from a single uniform upload a frame — so instanced foliage can be pressed down and recover without re-uploading its instance buffer.",
3502
- "short": "A trample field — eight recent presses that decay, applied in the vertex shader from one uniform upload a frame."
3517
+ "text": "A trample field \u2014 eight recent presses that decay, applied in the vertex shader from a single uniform upload a frame \u2014 so instanced foliage can be pressed down and recover without re-uploading its instance buffer.",
3518
+ "short": "A trample field \u2014 eight recent presses that decay, applied in the vertex shader from one uniform upload a frame."
3503
3519
  },
3504
3520
  {
3505
3521
  "kind": "added",
@@ -3507,7 +3523,7 @@
3507
3523
  },
3508
3524
  {
3509
3525
  "kind": "added",
3510
- "text": "Box surfaces, composable with the ribbon surface, so a route can be made of pads as well as swept ribbon and both answer one ground query — including which deck is meant, since the query now takes the asker's own height and stops handing back the upper deck of an overpass.",
3526
+ "text": "Box surfaces, composable with the ribbon surface, so a route can be made of pads as well as swept ribbon and both answer one ground query \u2014 including which deck is meant, since the query now takes the asker's own height and stops handing back the upper deck of an overpass.",
3511
3527
  "short": "Box surfaces, composable with the ribbon surface, so a route can be made of pads as well as swept ribbon."
3512
3528
  },
3513
3529
  {
@@ -3517,7 +3533,7 @@
3517
3533
  },
3518
3534
  {
3519
3535
  "kind": "changed",
3520
- "text": "Point shadows behave like light. Filter taps sit on a disk perpendicular to the light ray and the disk is rotated per fragment, sample directions are kept inside their own cube face, the depth comparison gains tolerance across a face seam, and occlusion fades with distance from its caster — crisp for two metres, readable for four, gone by eight, which is what a metre-wide flame does.",
3536
+ "text": "Point shadows behave like light. Filter taps sit on a disk perpendicular to the light ray and the disk is rotated per fragment, sample directions are kept inside their own cube face, the depth comparison gains tolerance across a face seam, and occlusion fades with distance from its caster \u2014 crisp for two metres, readable for four, gone by eight, which is what a metre-wide flame does.",
3521
3537
  "short": "Point shadows behave like light: filter taps sit on a disk perpendicular to the ray, rotated per fragment."
3522
3538
  },
3523
3539
  {
@@ -3527,17 +3543,17 @@
3527
3543
  },
3528
3544
  {
3529
3545
  "kind": "fixed",
3530
- "text": "Exactly one node reaches the audio destination. Send returns were connected straight to it while a recording tapped the master, so every captured mix carried the dry signal and none of the reverb or delay: the tail measured 0.00000 at the tap before and 0.03876 after. Not attenuated — absent.",
3546
+ "text": "Exactly one node reaches the audio destination. Send returns were connected straight to it while a recording tapped the master, so every captured mix carried the dry signal and none of the reverb or delay: the tail measured 0.00000 at the tap before and 0.03876 after. Not attenuated \u2014 absent.",
3531
3547
  "short": "Exactly one node reaches the audio destination. Send returns were connected straight to it as well."
3532
3548
  },
3533
3549
  {
3534
3550
  "kind": "fixed",
3535
- "text": "A suspended audio context is not a failure. The graph awaited a resume and treated the rejection — which is exactly what a browser that has not seen a tap gives you — as proof the device could not play sound, returning nothing and staying silent for the rest of the session.",
3551
+ "text": "A suspended audio context is not a failure. The graph awaited a resume and treated the rejection \u2014 which is exactly what a browser that has not seen a tap gives you \u2014 as proof the device could not play sound, returning nothing and staying silent for the rest of the session.",
3536
3552
  "short": "A suspended audio context is not a failure. The graph treated a browser's refusal to start sound as one."
3537
3553
  },
3538
3554
  {
3539
3555
  "kind": "fixed",
3540
- "text": "A band built from a descending lateral range came out inside-out, and under back-face culling an inside-out slab is invisible from outside: signed volume +256 one way round and −256 the other. Ranges are normalised where they are consumed, because a caller describing space has said nothing about winding.",
3556
+ "text": "A band built from a descending lateral range came out inside-out, and under back-face culling an inside-out slab is invisible from outside: signed volume +256 one way round and \u2212256 the other. Ranges are normalised where they are consumed, because a caller describing space has said nothing about winding.",
3541
3557
  "short": "A band built from a descending lateral range came out inside-out, and an inside-out slab is invisible under back-face culling."
3542
3558
  },
3543
3559
  {
@@ -3574,7 +3590,7 @@
3574
3590
  },
3575
3591
  {
3576
3592
  "kind": "added",
3577
- "text": "Text as real geometry — a pixel font extruded into the scene, lit, fogged and occluded like everything else in it."
3593
+ "text": "Text as real geometry \u2014 a pixel font extruded into the scene, lit, fogged and occluded like everything else in it."
3578
3594
  },
3579
3595
  {
3580
3596
  "kind": "changed",
@@ -3598,7 +3614,7 @@
3598
3614
  },
3599
3615
  {
3600
3616
  "kind": "added",
3601
- "text": "`MessageQueue` — one message at a time, most important first, deduped, bounded and interruptible."
3617
+ "text": "`MessageQueue` \u2014 one message at a time, most important first, deduped, bounded and interruptible."
3602
3618
  },
3603
3619
  {
3604
3620
  "kind": "added",
@@ -3682,7 +3698,7 @@
3682
3698
  },
3683
3699
  {
3684
3700
  "kind": "added",
3685
- "text": "Input as devices and generic mechanisms — keyboard, pointer, a configurable virtual stick and a tap/hold/swipe recogniser — none of which name a gameplay verb."
3701
+ "text": "Input as devices and generic mechanisms \u2014 keyboard, pointer, a configurable virtual stick and a tap/hold/swipe recogniser \u2014 none of which name a gameplay verb."
3686
3702
  },
3687
3703
  {
3688
3704
  "kind": "added",