Skip to content

fix(world): PRD-494 — bundle-safety walk stops reading pixels; main pass draw budget measured - #473

Merged
jonit-dev merged 182 commits into
developfrom
feat/prd-494-finish
Oct 9, 2026
Merged

jonit-dev merged 182 commits into
developfrom
feat/prd-494-finish

Conversation

@jonit-dev

Copy link
Copy Markdown
Contributor

TL;DR

Finishes PRD-494 (the main pass fits the draw budget). PR #423 already merged the PRD's engine code. This PR fixes a load stall that the merged code caused on Machinefall, and records AC-1 and the native leg.

Why

On develop f7dd08621, Machinefall ?scene=map-walk never finishes loading under the playtest harness: the bridge times out (TN_PLAYTEST_OPERATION_TIMEOUT, sample > 15 s). A Chrome trace showed one main-thread task of 22.9 s. 18 s of that was samplesFramebuffer, the bundleSafe material walk: it enumerated every texture's mip bytes through Object.values.

What changed

  • packages/core/src/world-cells.ts: the walk skips ArrayBuffer views (a pixel or vertex array holds numbers, never a node).
  • packages/core/__tests__/world-bundles.spec.ts: red-green spec. Red: the walk read a spy on a texture's pixel array 11 times (expected 11 to be +0). Green: 15 of 15.

Evidence so far

  • Trace on the fixed core: the samplesFramebuffer frames are gone; worst task 22.9 s -> 15.1 s (the rest is three's shader node build).
  • Machinefall map-walk on the fixed core: exit 0. 300-frame windows after the first: traversed main draws p95 31-78. The first window after startup: p95 157.

Done / missing

  • Load stall fixed, red-green
  • AC-1 measured on 3 runs, load window resolved
  • Native leg (--target desktop streamed world, TN_WORLD_BUNDLE in the log)
  • Visual gate (pnpm visuals:ab --raters 3)

… (PRD-493 phase 1)

Sixteen same-size uncompressed layers bound one sampler each, which is what
makes WebGPU's 16 sampled textures a stage the real cap on Android and iOS —
the hosts with no KTX2 transcoder, where nothing stacks today.

Each set now becomes one DataArrayTexture whose layers are filled by a
renderer.copyTextureToTexture out of their own textures: the pixels never
come back to the CPU, and the array is created with source.dataReady = false
so three never uploads placeholder bytes over the copies. Mixed sizes still
fall back, and TN_TERRAIN_SPLAT layers= samplers= stacked= reports what the
material actually costs, one line per material.

Also corrects the stale @constraint: the recipe is export_terrain_layers in
export_world.py, and the cap is now a sampler count the marker names.
capabilities.json and the generated reference carry both.
The material hard-coded metalness 0 / roughness 0.92 and gave every layer it,
so a Megascans-grade layer's own occlusion, roughness and metalness never
reached the screen. A layer with "orm": true in the table now reads
<id>_orm.jpg as linear data (r occlusion, g roughness, b metalness) through the
same stacked array and the same blend weights as its colour; a layer without one
uses the roughness and metalness the table states. A layer that states neither
now throws and names the recipe, rather than falling back to a number in the
package — the constant is gone.

The recipe writes _orm beside _diff and _nrm in both copies of export_world.py,
and refuses a table whose ORM layer names no orm source.
…t (PRD-492 Phase 1)

The starter's frame now ends in a colour grade and a film grain, both authored
stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier
and `public/grade.cube` is the table. Replacing that one file with a `.cube` from
any grading tool is the whole of "change the grade".

The filed design was a bijective log shaper inside the stage. It is measured and
dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family
over the frame's range found nothing under 2.0 8-bit steps per channel (the filed
W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in
light into several codes. So the fallback this PRD already named ships instead: a
stage may declare `afterOutputTransform`, the chain then installs the graph with
the renderer's automatic transform off and the stage applies three's own
`renderOutput` first — what three's FXAA example does. An identity table there is
within 0.875 of a step over every value the frame can hold, which is the PRD's own
box, and a `.cube` now means what a grading tool means by it.

Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a
third argument on `setOutputNode`, and the chain handing the renderer
`outputColorTransform: false` only when such a stage actually ran — a dropped one
would leave the frame untone-mapped with nothing to have applied it.

Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and
packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter
(webgpu, headed, adapterClass hardware): renderChain tier/includes/order/
contributions all pass with grade and grain applied; the tier-low leg on a
throwaway copy with the tier pinned reads `dropped: [{"name":"grain",
"reason":"grainIntensity:0"}]` with grade still applied.

Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of
16 ungraded — no delta to read) and the frame half of the identity round trip (the
starter's own animated content moves between runs by more than the claim; the same
build twice reads maxChannelDelta 114).
The pre-push drift gate refused the push on a formatter diff biome had already
reported once; this is the same one line, formatted.
… captures

Unit specs prove the mechanism in node; this example proves it on a real
WebGPU adapter. The package is sixteen layers with an albedo, a normal and an
ORM map each — 48 uncompressed JPEGs — written by the shipped
export_terrain_layers recipe from a table and map files a game would author,
so the thing under test is a DCC export, not a second format.

The run is green: TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3, 180 frames,
a non-blank changing viewport, and the scenario asserts those numbers through
material.userData rather than trusting a screenshot. The same package on
develop never reaches a frame:

  Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the
  Fragment stage exceeds the maximum per-stage limit (16).

Captures for the judge, same package and same pose:
.afk/scratch/prd493-captures/phase-1-2/{before,after}.png (4 KB uniform vs a
textured 16-band surface). Not a Machinefall result, so AC-1 stays open.
…hat could not run (PRD-492 Phase 2)

Phase 2's second box passes: `pnpm native:build` (409/409), then
`pnpm parity --target desktop --only-tests lut-grade` reports status pass with
`pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU
validation errors, against a web-lane reference capture of the same scene. The
table is a 17^3 per-channel gain rather than the identity, so a host that ignored
the upload or substituted a constant fails it, and the scene reads the parsed
Data3DTexture back in JS against that formula before the frame is compared. The
sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.

Phase 2's first box stays open, and the reason is the packager rather than the
grade: the desktop run's marker reads `dropped: [{"name":"grade","reason":
"lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read
error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing
else, so a public/ file the manifest does not declare never reaches the host —
every unmanaged public file on every native target, not this table alone. Fixing
the asset selection is mechanism work outside this PRD's filed file list, so it is
recorded rather than done here.

The scenario also could not run at all before: its `visibility` assertion is
refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed
before reading a stage. That row is dropped — the runner never produced the
observation on this target — and `grade`/`grain` are added to the stages and
contributions the scenario does read.

Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files)
and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a
browser session and touches nothing in this change), and the create-threenative
scaffold and looks contracts.
The browser lane proves the stacking; the hosts that matter most are the ones
with no KTX2 transcoder, and there nothing stacked before. This gives the
example a native identity (threenative.config.ts, one description read by both
the web dev server and `threenative build`) and the same assertions as a
desktop scenario: sixteen layers, at most four samplers, three stacked sets.

orbitSeconds is new because a native run that renders one frozen frame would
otherwise satisfy every cost assertion; the camera pose has to advance.

--target desktop on the built host: pass, layers=16 samplers=4 stacked=3,
orbitSeconds 2.03 -> 5.03, diagnostics clean, 0 console errors. The web
scenario with the same assertion is pass on the software-and-hardware
WebGPU recipe; world-terrain-splat.spec.ts 11/11.
The hosts this PRD is for cannot see KTX2, so nothing stacked there, and
nothing proved it: the browser lane cannot fail on a path the browser never
takes. terrain-splat-array builds a sixteen-layer package with an albedo, a
normal and an ORM map per layer — the shape Android and iOS ship — through
loadTerrainSplat, and the scene throws unless the marker reads
layers=16 samplers=4 stacked=3, so the row fails on a regression to one
sampler per map rather than merely looking different.

The 48 maps are DataTextures derived from a pure function of their cell and
the mask grid gives each layer its own region, so both lanes derive the same
frame from nothing but the scene file: the desktop host matches the browser
reference at pixelMismatchRatio 0 and perceptualDeltaE 0 over 1280x720.

The Android leg of the row is left open, not claimed: :app:verifyV8Dependency
refuses to bundle without a V8 build receipt that only an hours-long source
cross-compile writes, and the emulator was up and waiting when it stopped.

Gates: parity --target web pass (reference captured); parity --target desktop
pass, 0/0 metrics, 0 gpu validation errors; runtime-native conformance suite
76/76; pnpm typecheck and pnpm lint exit 0; pnpm check:docs exit 0.
A blind judge compared the starter WITH this PR's grade and grain against
WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey
concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast;
grain clearly visible as speckle over sky and flat walls and behind HUD text".
The owner's rule is that a default must never degrade the look, so both
numbers move and the spec says so red-first.

The pivot was the larger half and nothing had measured it. A contrast curve
anchored at 0.18 leaves everything above it brighter: on the captured frame
that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what
"washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread
13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame
keeps more separation than the ungraded one, and the table still lifts no
black. grade.spec.ts pins all three claims and is red on the old table.

grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by
the pixel's own value, so 1/255 is under half a step on a mid-grey surface —
below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps
naming grain as APPLIED at high, which is this PRD's own ticked box; low
still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the
values to raise it to.

The desktop box passes now, and the packager was never at fault. Phase 2 filed
"the staging loop copies selectManifestAssets(assets) and nothing else, so an
undeclared public/ file never reaches the host"; the selector's rule 3 keeps
everything no cook produced and its spec already proved that. The scaffold that
run used had no public/grade.cube in it at all — the AC-2 before-arm deletes
that file — so there was nothing to stage. With it present: pnpm build --target
desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909
bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json
exits 0 with all six assertions true at adapterClass hardware and a non-blank
screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now
covers a hand-placed file whose extension no asset pipeline emits.

Also recorded: a browser run that reports swiftshader/google is a CPU
rasteriser's picture of a hardware look (this lane's frame came back flat grey
with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not
prevent it — the runner launches chrome-headless-shell. Every capture ran
--headed, on turing/nvidia, at tier high.

Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table),
scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI
contracts 241, pnpm typecheck clean, biome clean on every touched file,
pnpm check:docs clean.

Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/
{before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x).
Measured between the pair the grade now moves the frame by rmse 2.22 codes and
-0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9.
Carries develop's WebGPU adapter-retention patch, the terrain-jobs work and
the CI scope changes. One conflict: the scaffold tree hashes, which develop
restated for its own merge and this branch restated for PRD-492's stage seat
plus the neutralised starter default. Resolved to the merged-tree
measurement — all thirteen trees re-measured through actual createProject
trees on this tree, `scaffold.spec.ts` 66 passed.
…is lane

Takes the develop CI merge that had already landed on the PR branch. Two
conflicts, both resolved to this lane's newer text: the scaffold tree hashes
(re-measured through actual createProject trees on this tree, `scaffold.spec.ts`
66 passed) and the PRD (the remote side was the pre-neutralisation file).
Merging PRD-493 under PRD-492 moved every no-install scaffold hash: the
rewritten `capabilities.json` entry and `capability-reference.md` reach a
scaffold verbatim. Measured through actual createProject trees on this merged
tree, which is what the table already claims to hold.
…walk, and the two reasons

Five map-walk runs on origin/develop's core, GPU quiet at every start (6-15%), host load 18-48, so
the CPU numbers are inflated against the earlier 11-19 runs and only the paired lane comparison counts.
Median walking render 13.4/25.9 ms, GPU 3.6/21.6 ms, 48 frames over 33 ms of 1024, worst present gap
17.1 s.

The pipeline census is what A7 needs: 8 creations attributable to a streamed batch after
startup.compileSettledMs in every run (7 main pass, 1 shadow caster half; device service 1.1-1.6 ms),
plus 16-22 compute creations and 12 main / 4-5 shadow for objects outside the shared batches.

Two causes, both named rather than inferred:

- #dressGpu cannot re-dress a mesh three has compiled (its instancing node binds the instanceMatrix it
  held then), so a key dressed after the prewarm gate gets a fresh object whose node and pipeline its
  first draw builds, on a walking frame.
- TN_WORLD_PREWARM reads shadowPrewarmed=0 castersUnbuilt=182 in every run: the prewarm mints an empty
  batch and waits for a draw to build its node, and three never submits one — getDrawParameters()
  returns null at count === 0.

AC-3 stays unticked with the measurement beside it. Two attempts to close it on the engine's own
compileAsync seam failed on the walk itself: deferring the swap to that compile hangs a live renderer
(900 s timeout, walk-a7-r2-1), and doing it behind the loading gate serialises ~200 key compiles
behind prewarmed and does the same (walk-a7b-r2-1, aborted at 10 min). A7 needs a preparation seam
that does not borrow the frame the walk is drawing, which is PRD-387's open Phase 1.
…t refuses

A7's measured cause, one of the two named on 2026-10-04: TN_WORLD_PREWARM read
shadowPrewarmed=0 castersUnbuilt=182 in every walk, because the prewarm leaves an empty batch
owed a draw that three never makes. `RenderObject.getDrawParameters` returns null at
`count === 0`, so the renderer skips the object, builds no node and never calls
`onBeforeRender`: 385 keys minted behind the gate prepared nothing, and the walk's first
shadow window move built all of them inside a shadow pass.

An empty batch still owed its draw now submits one instance of the zero matrix its own fresh
buffer already holds (`SharedBatch#publish`), which is the submission that builds the node and
the pipeline, main and shadow. It draws nothing — every vertex collapses onto one point — and
`prewarmDrew` puts the honest count back. `prewarmInstance` publishes on the mesh which count is
the prewarm's, because `casterPrewarmOwed` cannot answer it: a batch can owe its draw and hold
records at once. A batch leaving the world hands the borrow back rather than submitting a
degenerate instance for the rest of the load, and the prewarm's own submission is not recorded
into a draw bundle.

The red-green is the harness, not a new one: `world-cells-shadow-prewarm` counted a draw three
would not submit (no count gate), so it passed with 8 of 17 casters unbuilt. With three's own
gate in both arms it read `castersUnbuilt=8` before this change and `castersUnbuilt=0` after, and
every prewarmed caster's first draw is still behind the gate.

`pnpm exec vitest run packages/core/__tests__` 184/184 green, `pnpm typecheck` and `pnpm lint`
green. `pnpm test` still fails 22 native-host cases in `packages/runtime-native` that need
`pnpm native:build` in this worktree — untouched by this change.
…d it blocks the launch

The first of A7's two named causes is now closed at the mechanism level and unit-proved, and the
walk says the fix cannot merge as it stands. Alternating arms in one lane, map-walk with
tnFrameSpans=1, host load 24-32 throughout:

- dev pair-1 (core-dev2, 1a6298cd): pass in 2 min 59 s, 18 of 127 creations after settle
  attributable to a batch key (1.7 ms of device service), 13 of 1024 frames over 33 ms, walking
  render 8.2/15.7 ms, GPU 1.6/10.6 ms, 17.8 s to compileSettled over 1895 pipelines, and the
  baseline's own line unchanged: minted=385 shadowPrewarmed=0 castersUnbuilt=182.
- dev pair-3 (same core): fail, 2 state failures and 3 console/network errors over 4382 frames. That
  is this host's noise, not a result, and the lane claims no CPU comparison from these runs.
- a7 pair-2 and pair-4 (core-a7b): both stall at the loading gate. TN_STARTUP_STALLED reports 0.0 %
  progress for 45 s with nothing outstanding, the page's main thread never yields again (the bridge's
  `describe` exceeded 915000 ms), the 900 s timeout takes the run, the prewarm marker never prints
  and the walk never starts. No census, no frame series, nothing to capture.

What the row now knows: the mechanism is right in the fixture world and fatal at Machinefall's
scale, where 385 keys by two variants behind the gate is a different cost from 24 by two. What it
does not know is which of two costs the gate is paying — the node builds themselves, or
`#drainPrewarm`'s own loop, which invalidates the shadow levels on every update while a caster is
owed and now has every prewarmed caster to submit each time. The next step is one instrumented run
that separates them. AC-3 stays unticked, the A7 boxes stay unticked, and the arm stays a draft.
Reverts 2d6b930. With it, Machinefall map-walk never leaves the loading
gate (two of two runs, 900 s timeout, TN_STARTUP_STALLED). The runbook A7
row keeps the finding; the change returns once one instrumented run names
the cost the gate is paying.
…t its loading gate

PR #437's `loadTerrainSplat` threw when any terrain layer declared neither an ORM map
nor a roughness/metalness pair. Machinefall's exported `terrain/layers.json` predates
that column: its base layer states neither and its layers state roughness without
metalness. The world's `load()` therefore rejected, `MapWalk.load()` never returned,
`state.loading` stayed true, and the playtest timed out in `describe` with 0 frames.

A table the earlier export recipe wrote is not a malformed table, so the check now names
the layers it cannot honour and the surface builds: `DEFAULT_ROUGHNESS` (0.92, the
matte dielectric this material carried before the column existed) and metalness 0.
Loading a package is a promise the game cannot cancel, and a hang at the loading gate
costs a whole boot.

Machinefall loads its layers as KTX2 `CompressedTexture`s, so the uncompressed
`stackUncompressed` path the same diff added never runs for it; the throw was the only
new blocker.

Tests: the spec now feeds `loadTerrainSplat` Machinefall's own table shape (base with
no response, layers with roughness and no metalness) and red at the old throw, green
after. The old "refuses a layer that states neither" spec encoded the hanging contract
and became this one; PRD-493's sixteen-layer stacking and ORM specs stay green.
Machinefall's first frames after the loading overlay drew a meadow of canopy
shadows with no trees under them (about 1 run in 6; owner report on #437).
With the GPU scene on, the main pass draws only sources the seed has placed
into the dispatch, and the seed queues behind the admission budget
(`TN_WORLD_GPU_SCENE on ... instances=0`), while every caster half drew its
whole resident ring.

- WorldCells counts each caster half's placed sources (`SharedBatch.gpuPlaced`
  against `live`, kept in #placeSources / #clearSegment / the impostor
  handoff) and publishes `mainAdmitted` per half; the wide `@*` mesh casts once
  every cell of its asset is placed.
- VirtualShadowNode#probe hides a half that is not admitted for each level
  render (the existing #hidden restore buffer), so admission gates the draw,
  not only its tally. Admission, not visibility: an off-screen caster whose
  counterpart is placed still casts.
- Admission is the third bit of the per-caster flag word #pollCasters reads,
  so a flip redraws the cached levels covering that half; without it the maps
  drawn during the seed stayed shadowless.
- TN_WORLD_SHADOW_ADMISSION reports withheld cluster and wide halves and their
  records (Machinefall: 249 + 47 halves at the seed, 0 after).

GPU (RTX 2080, Machinefall map-walk, develop base vs this change, 6 runs each,
merge on and off): no orphan frame in the fix runs; forest-floor brightness
at the start frame 212 vs develop 211 (the withheld-but-cached state read
226-237); walk frames within 1-2 units, the residual is canopy self-shadow
speckle, not ground shadow.

Specs: the render-boundary admission spec, the per-half placement spec, the
seed-drain specs and two admission-flip invalidation specs, each red before
its change and green after; 246 world and shadow specs pass.
The launch hang blamed on the prewarm was the PRD-493 splat throw; with it
fixed the prewarm loads but prepares nothing on Machinefall and moves no
number (12 alternating runs, order effect only), so it stays off this PR.
No post-settle compile costs a frame (max 0.2 ms). The walk on this PR:
render p95 6.4 ms, GPU p95 6.5 ms on a loaded host; AC-1/AC-2 stay open
for the quiet interleaved rerun and AC-3 raters.
A1: PRD-389 Phase 4 reads 5/0 on origin/develop, one box per probe gap,
landed by #423. C1: #397 merged and PRD-339 is in done/ at 11/0.
PRD-492 AC-2: visuals:ab with 3 fresh blind raters scores the graded
starter frame equal to the ungraded one (4=4, 3=3, minimum detectable
effect 0); the HUD moves at most 1.9 codes against 112 in the scene.
The owner asked for a realistic goal before the merge. The main pass alone
is 25-45 ms on the Iris Xe at scale 1.0, so AC-1/AC-2 now ask for a 30 fps
median and at most one invalidation redraw per 3 s. Records the redraw
counters and ticks the GPU-priced invalidation delay (748aab4).
The laptop walk (live11, Iris Xe) still held the scale at 1.0: the stall
exception asked for presented p50 over budget, and that read 6-9 ms while
the main pass took 25-40 ms and the window ran at 26-45 fps. The exception
now asks for a window under target with fresh GPU over budget. A hitchy
game whose GPU meets the budget keeps the stall deferral.

resolution-scaler.spec.ts: the live11 window, red before, green after;
8 scaler spec files, 82 tests pass.
AC-1: traversed main draws p95 48 in the load window and 85 at worst over 3
Machinefall map-walk runs on the final core. The world gate shows 0 LOSS and 0
disallowed popping but exits 1 on its absolute 4/5 floor, which the reference arm
misses too; the PRD records that, the capture race on pose-start, and the
adaptive LOD bias as the likely owner of the mid-field frame.
Native leg: the streamed-world desktop scenario passes on the checkout host with
bundles on, after the native render bundle encoder gained indirect draws.
AC-2 sign re-confirmed under load; the quiet-host tick stands.

The runbook's link to the PRD follows it to done/.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Heads-up for whoever reviews or merges this PR: the body above was written when this branch held only PRD-494 work. The branch has since been fast-forwarded to the PRD-478 runbook branch (head d2bb5f649, from the runbook-120 worktree), so this PR now carries PRD-478 work and PRD-538/541/489 docs as well (218 files). Its body and its "finishes PRD-494" claim no longer describe it.

The PRD-494-only work is in #475 (branch feat/prd-494-main-record, from develop). It has the load-stall fix this PR started with, the sharded main record, the native render bundle fix, and the PRD moved to done/. I have not force-pushed here and I will not squash-merge this PR. Whether PRD-494 keeps a separate PR is a one-PR-per-PRD call for João.

The owner wants one PR for PRD-494. #475 carried the sharded main record,
native bundle indirect draws and the PRD close; it lands here instead.
Conflicts: world-cells.ts (kept gpuPlaced and the shard wording) and the
native census (regenerated with pnpm census).
CI quality-json.spec failed on #473: 5 suppression-class findings were new.
merge-parts.ts (3) and profiling/Spans.ts (1) casts get a quality-allow line
naming why the type cannot say it; water-surface.ts carried a waiver for a
cast that is no longer there. pnpm quality --json and quality-json.spec pass.
budgets and check-native-coverage.spec failed on #473 with a stale source
digest: the merged runtime-native bundle indirect draws changed the inputs.
pnpm --filter @threenative/runtime-native native:coverage, check passes.
…t resolve

live12 on the Iris Xe still held the scale at 1.0: every window reported
gpuAgeFrames 233-593, past maxGpuAgeFrames 16, so the scaler saw no GPU
reading at all, while each window held its own samples at a 29-50 ms median.
When the last resolve is stale and the window has at least 4 samples, the
scaler now reads their median. A GPU that resolves within 16 frames keeps
the old path.

resolution-scaler.spec.ts: the live12 window, red before, green after;
8 scaler spec files, 82 tests pass.
live13 (lodfix11): the scale went 1.0 -> 0.61 four windows into the walk
and held the floor; main-pass GPU median 29-50 ms -> 18-33 ms, late-walk fps
33 -> 47-50. Ticks the Phase 1 gate box with the three fixes it took.
Records the cold-start gap of the redraw delay (42 of 54 redraws before the
first GPU reading, then one per 7 s) as a new open box.
The starter's `.cube` grade is a Data3DTexture, so every scaffolded game
uploads one. Three asks for RENDER_ATTACHMENT on every uncompressed texture,
and WebGPU has no 3D render attachment, so wgpu-native rejected the table and
then every view, bind group and pass naming it. The patch already withheld the
usage from a compute-only 3D storage volume; it now withholds it from any 3D
texture that no render pass writes, which is what a sampled table is.

The upgrade migration blocks for the same three files and the lockfile patch
hash follow, so a project upgrading from a previous release lands on the same
bytes a fresh install gets.
…ract

dcb5204 made WorldProbe publish stats().bundle and dropped the web-only
diagnostics block from the desktop streamed-world scenario; the spec still
stubbed stats without bundle (5 TypeErrors) and still demanded the block.
The stub carries a bundle reading, desktop now asserts bundleOn instead,
android keeps the diagnostics block. world-capture-fixture.spec: 12/12.
… fix

Every scaffold copies core/patches/three@0.185.1.patch, so 1d394b0 moved
all 13 no-install tree hashes. Recomputed from the drift run; the byte-stable
test passes.
The PRD-351 ladder encoded all four compressed rungs of the starter's
4096x2048 sky.jpg (50 s cold, 90 s under load) and then shipped the source:
no rung beat the 282 KB JPEG. CI timed out template-assets-compile,
build.spec and the golden-path servers on it.

ETC1S is the ladder's lowest bitrate, so on an image over 256x256 an ETC1S
encode that cannot beat the source bytes now skips the UASTC rungs. Small
images walk the whole ladder; floors and smallest-passing selection are
unchanged. Starter cold cook: 90 s -> 22 s at load 27-44 (develop ~20 s).

model-texture-pass.spec: "stops encoding a large image once ETC1S cannot
beat its source bytes", red (4 encodes) then green (1); assets suite green
but for a worker-pool load timeout that passes alone.
temp-dir-guard.spec failed on #473: grade.spec.ts created a directory
with raw mkdtempSync. It now uses makeTempDirSync, which registers cleanup.
Desktop parity failed both new rows on PR 473 on pixel mismatch alone:
lut-grade 2.4% against 0.01, world-terrain-inline-hash 4.6% against 0.04.
The metric counts any channel inequality; per the downloaded captures only
0.04% of pixels differ by more than 2 levels and mean deltaE is 0.047 and
0.034 (limits 3 and 6), so the rest is llvmpipe-vs-Chrome rounding in the
3D table filter and shading. Tolerances 0.04 and 0.06 (the registry's
common value), each row's reason records the measurement.
…rites

Shard 1/4 still reported a grown temporary directory count after 877d671:
the identity table is written at module load, outside any test, so
makeTempDirSync fell back to a process-exit hook that a vitest worker never
runs, and tn-grade-* stayed behind. writeTable now removes its directory
itself. Shard 1/4 with an isolated TMPDIR left tn-grade-M7crt3 before; the
spec pair leaves nothing after (12/12).
Develop gained PRD-461 (#470) and PRD-536 (#458); PR 473 had gone
CONFLICTING and CI stopped running on it. Conflicts: world-terrain-splat
spec (both sides added tests, kept both) and the scaffold hashes (rain moved
with develop's template change; recomputed). Core typecheck clean, core
world specs 500/500, scaffold.spec 66/66.
pnpm census: 2 cells, total 267,266.
@jonit-dev
jonit-dev added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
The merge queue dropped PR 473 on golden-path-template (platformer): the
dev server answers only after the cold cook, one ETC1S encode of the
template's 4096x2048 sky is 21 s idle, and the 30 s wait ran out on a
loaded runner (TN_GOLDEN_PATH_FAILED at layer dev, 33 s after start).
verify-golden-path.spec 23/23.
lodfix11 (PR 473 merge build), quiet RTX 2080: walking render over 8.3 ms
pooled 4.1% (509/7200, 291/9600, 306/9900) and GPU 0.39% (12/3085), both
under the 5% bound. PRD-478 is left with the owner's aerial review only;
PRD-541 with its blind raters on the clamp build.
@jonit-dev jonit-dev added prd:100% — ready PRD complete, ready to merge and removed prd:50% PRD half the phases verified labels Oct 9, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Label prd:100% — ready: PRD-494 (this PR's PRD) is complete and filed in done/. Status of the PRDs consolidated here at the owner's request:

PRD State Left
PRD-494 main pass fits the draw budget 100 %, done/ —
PRD-478 open-world frame architecture acceptance 3/3; 12/13 boxes owner's side-by-side of the camp and highway aerials against ?refShadow=1
PRD-541 card LOD keeps the canopy AC-1, AC-2 pass; 7/8 boxes AC-3 blind raters rerun on the clamp build
PRD-549 weak GPU scaler fixed on the Iris Xe (1.0 → 0.61, fps 33 → 47–50); redraw delay first-redraw pricing, map halving, quiet laptop ACs
PRD-537 / PRD-538 PRD files only their own work

AC-1 (PRD-478) on the merge build, 3 quiet RTX 2080 runs: walking render frames over 8.3 ms pooled 4.1 % (bound 5 %), GPU 0.39 %.

CI fixes since the last review: 3D textures no longer request RENDER_ATTACHMENT (wgpu arm64), the PRD-351 ladder skips UASTC rungs a large texture cannot use (starter cook 90 s → 22 s), two grade.spec temp-dir leaks, the PRD-494 capture fixture spec, parity tolerances for 1-level rounding (measured), and a 90 s golden-path dev wait for the cold cook.

@jonit-dev
jonit-dev added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
@jonit-dev
jonit-dev merged commit afee25e into develop Oct 9, 2026
92 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:100% — ready PRD complete, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant