Skip to content

Strata terrain PR chain: PRD-806 leftovers, then the MCP terrain path - #635

Merged
jonit-dev merged 261 commits into
developfrom
strata-chain
Nov 10, 2026
Merged

jonit-dev merged 261 commits into
developfrom
strata-chain

Conversation

@jonit-dev

Copy link
Copy Markdown
Contributor

What is this PR about?

It runs the Strata terrain PR chain (docs/PRDs/strata-terrain-hardening/PR-CHAIN.md) in one branch: the leftover PRD-806 epic-review fixes first, then the chain steps in order.

Why does it matter?

After the EPIC merge (#483), four reviewed fixes still sat on unmerged branches, and the MCP terrain path (PRD-762, P0) gives an agent a game that does not start.

Done

  • Step 0: E73 (native aux depth test for depthWrite=false draws), E46 (scaled far cards on a quantized POSITION), E56 (crown normals bend once), E62 (landform cook plants the kit's tree). E49 and E52 were already on develop.

Still open

  • Step 0: test results and PRD-806 ticks, visual judge for E46/E56/E62, README refresh, worktree cleanup
  • PR-1 PRD-762, PR-2 PRD-585, Step 3, PR-4 PRD-591, PR-5 … PR-12 (see the chain)

…e kit ships (PRD-806 E62)

The biome files name the game's own tree (spruce/sapling, which the ordered scatter
and the preview's art are keyed by), so a terrain_generate {kit, landform} cook drew
no foliage and the 35-pair matrix pinned every world empty. Renaming them, as the
finding sketches, would break orderedScatterRules and the preview's art, so the cook
now reads the kit's own stand-in: an asset marked "tree": true (the CC0 fir for the
preview's licensed spruce) plants the biome's tree row at its own count and spacing.
Forest and coastal pairs now place firs; alpine, desert and tundra ship no tree, and
the matrix derives each pair's expected stand from the data instead of pinning zero.
…cides the terrain, ground vegetation, mountains)
…orm, water and size, and the reply names each choice (PRD-854 phase 1)
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Nov 8, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Step 0 visual judge: before and after (PRD-806 E46, E56, E73)

Headed WebGPU, NVIDIA RTX 2080 (adapter checked), same poses, 2 runs per arm. Rows top to bottom: BEFORE-1, BEFORE-2, AFTER-1, AFTER-2. Columns: meadow-close, overview, player, river.

Verdict from a fresh judge: ❌ REGRESSION on alpine player and river: the boulders lose their cast shadows. The other 18 frames are NEUTRAL.

Isolation: the same build with only E73 reverted brings the shadows back. So E73 is reverted on this branch (d55471289), and PRD-806 keeps E73 open with this evidence. E46 and E56 stay (NEUTRAL, red-green specs).

Rows: BEFORE, AFTER with E73, AFTER without E73.
alpine E73 isolation

All five worlds

forest
coastal
alpine
desert
tundra

…al assertion and stays red on four timing budgets; enterMs regressed to 444 ms
…tom MRT slot at 1x (PRD-806 E73)"

This reverts commit d554712.
…ream three build, not E73; E73 is restored and waits for an owned-engine judge
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Correction: E73 did not remove the alpine shadows

The capture builds above ship upstream three: the preview's build adds the owned engine only with TN_ENGINE=threenative (examples/strata-terrain-preview/vite.config.ts:129), so a C++ engine change cannot reach those frames. Two more alpine runs of one unchanged build show the long boulder shadows in one run and not in the other, so their presence at that pose is run-to-run flake.

  • ✅ E73 is restored on this branch (8c1c9695d). PRD-806 records the corrected finding.
  • ⚠️ E73 still needs a judge on a TN_ENGINE=threenative build.
  • ⚠️ The flaky shadow at the alpine player pose is a real defect to file: the capture can land before every cast shadow is drawn.

alpine flake

jonit-dev added a commit that referenced this pull request Nov 8, 2026
…ibution, fails closed without the tile, and takes a ground override (PRD-854 phases 2 and 3)
…r-ring slices behind the curtain; fromMesh reads a plain attribute's array (PRD-585 enterMs)
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:25% PRD phase 1 verified labels Nov 8, 2026
jonit-dev added a commit that referenced this pull request Nov 8, 2026
jonit-dev added a commit that referenced this pull request Nov 8, 2026
jonit-dev added a commit that referenced this pull request Nov 8, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Mountain stripes removed (PRD-856), and E73 judged on the owned engine

Owner report: level, artificial lines on the backdrop mountains. Cause: src/render/terrain.ts drew level rock beds every 30 m, at the same spacing, and put a snow line on each ledge. Fix: the beds now dip about 20°, their spacing varies, and they break along the face. Ledge snow is weaker and broken, and steep rock gets fall-line runoff streaks.

  • ✅ Visual judge (fresh subagent, owned engine TN_ENGINE=threenative, headed WebGPU, RTX 2080, 2 runs per arm): IMPROVEMENT. The stripes are gone, and the forest, sky and fog show no regression.
  • ✅ Alpine, desert and tundra overview frames are pixel-identical: the beds draw only on the collar backdrop.
  • ⚠️ Remaining rock defects (PRD-856 open): faces read as clay with no crags or gullies, snow lies in soft blobs that ignore slope, and the fine grain does not scale with distance.

mountains before/after
forest meadow-close sheet

E73 (PRD-806): the owned-engine judge returned NEUTRAL over 5 worlds × 4 poses × 2 runs per arm. E73 is ticked.

…t, so native_engine_no_blocking_waits passes

The loader's queue moves into a small struct whose wait has the one TN_WORKER_ONLY shape
CheckNoBlockingWaits.cmake accepts; begin only notifies. The check failed on the old
gltfQueueWake.wait and blocked the native coverage refresh, which is regenerated here.
Proof: the check passes on 237 engine sources and fails on the old file; the threaded web
engine loads the KTX2 model playtest (bc7, zero console errors); pnpm budgets exit 0.
…s knolls

- The cook aims the massif along the kit's spawn camera (+-25 deg) and retries
  up to six placements for a wooded foot (woodedFoot >= 0.6).
- A floor spot with over 2% dead-flat cells (a hydro-flattened lake) is skipped.
- The forest grass row has no height ceiling: its 66 m fade stopped trees at
  57.6 m, which left seed 1's knoll bare. The pinned world tops out at 38.4 m.
- far-forest.spec follows the near-band default.

Judge: seed 1 IMPROVEMENT (overview, vista), seed 0 NEUTRAL; seeds 2 and 3
CONSISTENT, NO REGRESSION on the massif bearing cook.
…he owner

AC-1: judge PASS on the shipped frames (overview and vista IMPROVEMENT
against the commit before this PRD). AC-3: the default near band costs
about 23 ms GPU on the RTX 2080 (overview 35.5 -> 57.5 ms, vista 40.4 ->
64.2 ms). Every lossless lever tried moved 0-2.6 ms, and the coarse
impostor march that cut it darkened the canopy (judge REGRESSION, reverted).
Only the owner's call is left, so the PRD moves to
BLOCKED/requires-owner-decision.
… full parse takes the coarse parse's whole file

Review of #643, finding 7. The coarse parse already held the whole file; the full model's parse
downloaded it again. The test was red ([22256, undefined]) before the fix.
…rap and transform the game set

The AC-6 judge returned REGRESSION: Midway sets anisotropy on each loaded texture, the upgrade
swapped in the whole file with the default 1, and the SBD's wing star smeared at grazing angles.
The test was red (expected 1 to be 8) before the fix.
…hich its loader now decodes

The lane flipped webRuntimeDecoders to { ktx2: true, meshopt: true } and left this test on the old
refusal; the laptop unit run found it red.
…, streamed, with a NEUTRAL judge

AC-5, AC-6, AC-7 and the end-to-end box ticked with their evidence; the PRD moves to done/.
… search by its own words

'an asset pops in after the reveal', 'a model texture looks blurry right after load' and
TN_COLLIDER_COARSE found nothing in the first three; search.spec now pins 16 PR queries (3 were red).
# Conflicts:
#	docs/verification/native-coverage-2026-08-28.md
#	packages/create-threenative/agent-docs/references/capability-reference.md
#	packages/create-threenative/capabilities.json
#	packages/runtime-native/src/engine/renderer/render_database.cpp
#	packages/runtime-native/src/engine/renderer/renderer.cpp
#	packages/runtime-native/src/engine/renderer/renderer.h
#	packages/runtime-native/src/engine/wasm/web_host.cpp
#	packages/runtime-native/tests/native-engine/abi_test.cpp
#	packages/runtime-native/tests/native-engine/material_test.cpp
#	packages/runtime-native/tests/native-engine/render_database_test.cpp
#	packages/three-native/src/browser-renderer.ts
#	packages/three-native/src/render-target.ts
…lTF loader) into strata-chain

Web host ABI 13 = the Strata lane's 12/13 over PRD-860's 12; tnw_web_load_gltf_finish also returns
every scene. Frame fields: textureCompressionBc 87, virtualShadowPendingLevels 88.
core-webgpu: drop develop's IndirectStorageBufferAttribute stub, the lane implements it (PRD-857).
Native C++ tests pass (basisu fetched); related unit tests 505 files green.
…longer races the others

- measureBiome validates the ground's samples before a bandSpan world re-measures its bands; a
  bandSpan world's bands follow its floor, not its range (the spec now says so).
- strata-loading carries posesFor and SHADOW_REACH into the buildProps sandbox.
- prop-lod and prop-streaming follow the 13 m variant-0 spruce and the procedural spruce's new look.
- STRATA_BAKE_OUT sends a bake to another folder; cooked-world.spec bakes there, so src/world, which
  bake-golden and the game read, never changes under a parallel run. baked.json follows outputDir.
- stamp.d.mts and recipe.d.mts type the two scripts the spec imports (TS7016 in pnpm typecheck).
…t three times

The heaviest blended frame marches; the other two read the same 3D hit point from their own view.
Overview 58 -> 38 ms GPU, vista 64 -> 42 ms GPU (RTX 2080, band near). Visual judge: PASS,
NEUTRAL on overview and vista, sky and rock byte-identical, interleaved A/B from one tree.
@jonit-dev
jonit-dev marked this pull request as ready for review November 10, 2026 18:08
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:50% PRD half the phases verified labels Nov 10, 2026
…apability search points agents to it

- A request with no biome word takes the biome its sea or shape suits (island -> coastal, canyon ->
  desert, rolling hills -> forest), else the forest, reported in decided.biome; it no longer throws.
  Unread words still come back in unused. An empty call (no request, kit or biome) still fails.
- New words: alps, fjord(s), glacier, snow, tropical, shore, lagoon, canyon(s), arid, badlands, mesa,
  spruce, fir, trees, jungle, rainforest, grove, frozen, permafrost, glacial; volcanic reads before
  cliffs, so a volcanic island is a volcano in the sea. The starters' aliases carry the same words.
- The tool text tells the agent to pass a real place as ground.location.
- bakeBiome's situations route 'generate terrain with mountains and a river', 'open world map',
  'procedural terrain' and 'a volcanic island level' to it (was Heightfield or nothing), and its
  summary names the threenative-terrain tool. Kit AGENTS: 'any terrain, from words'.
- Regenerated: capabilities.json, terrain-mcp-tools.json, kit AGENTS/CLAUDE mirrors, scaffold pins.
@jonit-dev
jonit-dev merged commit f7943d1 into develop Nov 10, 2026
13 checks passed
jonit-dev added a commit that referenced this pull request Nov 10, 2026
#635 widened each starter's aliases in biome-words.ts and biome.data.json but
left the pinned table in biome-values.spec.ts on the old lists, so the
strata-terrain-preview test job is red on develop and on every PR.
jonit-dev added a commit that referenced this pull request Nov 10, 2026
…oading (streamed meshes and textures, meshopt, compressed upload) (#643)

* docs(PRD-824): the mystral desktop host confines untrusted game JS

Store audit (threenative-store docs/security/engine-capabilities-audit.md):
playtest.respond/receive write, read and delete any path; file reads, module
loads and screenshots take any path on desktop; curl has no scheme limit.
Fixes keep dev, CLI, benchmark and playtest behavior unchanged.

* docs(PRD-828): JS keeps only game logic, engine work moves into C++, measured on the stress lab against three.js

* docs(PRD-832): untangle report and the urgent single-sources slice

Eight read-only DeepSeek scouts mapped hidden links, needless links,
surplus and 365 special cases across core, render, world, the
three-native seam, playtest, the scaffolder, scripts and the C++ engine.
PRD-832 batches the urgent items: kit inventory (recall skips three
kits), template render drift guard, one launch-flag reader, one tone
curve path, one core version and named tnw_web_frame fields.

* docs(PRD-833..842): one PRD per root fix in the untangle report

R1 template mechanism lives once (833), R2 cross-language contracts are
generated (834), R3 one registry entry per assertion (835), R4 one
committed capability manifest (836), R5 core owns each shared fact once
(837), N1 scripts share one runtime layer (838), N2 the CI contract is a
model (839), N3 platform difference is a value (840), N4 game.ts stops
being the hub (841), N5 native tests check behaviour (842).

* docs(PRD-843..852): Tailwind parity batch, one scene scored on web and native

Exploration: Tailwind 4.3.3 gives 490 coverage units (402 utility units + 88 variants);
the corpus proves 56 of 276 emitted properties; 14 of 14 kits have native-css findings;
every filter is dropped because anyrender_vello_cpu builds without its filters feature.
PRD-843 builds the scene, the meter, judge sheets and the ratchet; PRD-844..851 own every
unit by family; PRD-852 closes at 490/490 with the denylist gone and a CI ratchet.

* docs(strata): PR chain for the open terrain work, with the noise and real-world elevation spike

* docs(strata): PR-5 makes one terrain request decide the landform, water, size and real survey

* docs(strata): PR-5 estimate follows its wider scope

* docs(PRD-859): the game thread wins the CPU against everything else on the machine

* docs(PRD-860): the first scene plays before the game finishes downloading

Midway boots on its spawn bytes, then textures stream by mip and meshes by LOD.
Unreal 5.8.3 texture and mesh streaming read for the wanted-level rules and budgets.

* feat(core): a whole-file load after the reveal reports TN_ASSET_LATE (PRD-860)

The reveal is the first moment the player is playing, so a file the scene asks for
after it starts a download over a world already on screen — the pop-in a streaming
game exists to avoid, and one no screenshot can prove. One line per file, carrying
the bytes and how far into play the request landed, so a playtest counts pop-ins.

A file the game `prefetch`ed first is a deliberate stream, and the engine's idle
background fill is no promise either: it lags the link by design, so only `prefetch`
silences the line. `startBackgroundFill()` already marks the reveal, so the line needs
no new public API.

Measured on Midway: with all 228 cooked files awaited at boot the line stays at zero,
which is what a game that streams nothing should report.

* docs(PRD-860): execution measures 136.4 MB before first play and four findings

The cook cannot reach the tens of megabytes this PRD assumed: the default engine
refuses KTX2 and meshopt (TN_ASSETS_COMPRESSION_SKIPPED), so Midway ships 166.2 MB
cooked from 188 MB of sources and the load split alone has to reach the 50 MB
ceiling. Records the compact-instance/clone mismatch, the two game defects a cooked
build exposes, and the SwiftShader lane that cannot settle Midway here. No box is
ticked.

* docs(PRD-861): the owned engine's missing decoders get their own PRD

Asked why a cook for the owned engine cannot ship compressed assets: it is a missing
decoder in the engine's glTF loader, not a missing format. third_party/meshopt/ already
holds the decode-only meshopt subset and no CMake file compiles it; cgltf parses
EXT_meshopt_compression and documents view->data as the seam. KTX2 has no transcoder in
the tree, so it is sliced behind an owner decision. PRD-860 now points at it.

* docs(PRD-860): the runtime measures 139.5 MB awaited, and who waits it

A cooked briefing run settles on the owned engine (load 11.3 s, enter 15.6 s, 120
frames, tone and diagnostics green) and reports its own split: visible 42.2 MB,
unattributed 21.9 MB, off-screen 75.4 MB. Names the five files and flags that two of
them read not-placed because merged geometry loses its provenance, so they must be
checked before anything defers them. Also documents on startBackgroundFill that it is
the reveal seam TN_ASSET_LATE reads.

* feat(core): KTX2 level index, Unreal wanted-mip rule and coarse-first mip ranges (PRD-860)

Pure groundwork for Phase 2: parseKtx2LevelIndex reads the header, the SGD block a BasisLZ
level needs, and the level index, failing closed with TN_KTX2_HEADER. wantedMipLevels is
UE 5.8.3 StreamingTexture.cpp:309 (ceil(1 + log2(screenSize))). mipRangesToFetch returns the
missing mips smallest first. Not exported yet: the loader wiring decides the public shape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(playtest): the bridge handshake waits the startup window, not 30 s (PRD-860)

Core answers describe only after the start scene's load() settles, and a load is sized by the
scenario's own throttled link. Midway awaits 139.5 MB, 30 s at 40 Mbps, and failed describe
370 ms short of it (TN_PLAYTEST_OPERATION_TIMEOUT). describe and ready now get
PLAYTEST_STARTUP_READY_TIMEOUT_MS, as the capture session already does; waitForBridge keeps
its short budget, so a page with no bridge still fails fast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(core): TN_ASSET_UNUSED names the awaited models nothing drew (PRD-860)

A minute after the reveal the engine walks the scene once, stamps only, and warns with every
model the reveal waited for that no scene object came from, hidden nodes included. Midway paid
22.9 MB of its launch for three such files (samidare, the Yorktown import, the torpedo body);
TN_STARTUP_OFFSCREEN_BYTES could only call them not-placed. The fix line covers both readings:
delete a load nothing builds, prefetch one the game builds later. Measurement only, cancelled
by stop().

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(core): a failed start-scene load answers the runner's describe at once (PRD-860)

The start scene's load() catch tore the game down without rejecting the start gates, so a
runner's describe waited out its whole handshake budget (30 s, now 180 s) on a page that had
already failed with TN_ASSETS_UNRESOLVED. Every other start failure already rejected them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(PRD-860): AC-1 and AC-2 ticked with the 40 Mbps RTX 2080 runs; frame time stays open

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(web-engine): glTF models parse on a loader thread, not in play (PRD-860)

Midway's late fleet froze play for 2.4 s in tnw_web_load_gltf: one synchronous 100-500 ms task per
hull on the main thread (DevTools trace, RTX 2080). The web host now runs gltf::load on one
long-lived loader pthread (tnw_web_load_gltf_begin/done/finish); the main thread polls a ticket
every 4 ms and only shares the finished model into the context. A build without threads runs the
load at begin, as before. GLTFLoader's web parse awaits the result; the sync export stays.

The loader thread builds engine objects while the main thread builds its own, so the object,
material, geometry and attribute id counters are atomic. concurrent_ids (4 threads x 20 000
meshes, wasm-threads under node) failed 3/3 with the plain counters and passes 3/3 now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(web-engine): the glTF loader thread decodes on its own two workers (PRD-860)

Per-thread CPU while Midway's fleet streamed in: the 8 JobPool workers ran at 649% for ~2 s a
model on the loader's image decodes, and the main thread sat at 98-102%: a frame job that finds the
pool busy runs inline on the caller. JobPool::useOnThisThread gives a thread its own pool;
the loader thread uses one of 2 workers, so the frame keeps all 8. Idle workers measured at 1-2%
CPU each, so the profiler's busy samples on them are parked waits, not a spin.

thread_pool_override (wasm-threads, node): red without the API, green with it; for_each_once,
pool_starts_on_need, item_failure_reaches_the_caller and dispatch_floor stay green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(PRD-860): the loader thread and its pool, with the paired frame runs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Revert "perf(web-engine): the glTF loader thread decodes on its own two workers (PRD-860)"

This reverts commit 2bae24e.

* docs(PRD-860): quiet-host paired runs, and why the loader pool was reverted

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(core): addInSlices presents a frame between slices once the world is on screen (PRD-860)

In Chrome a scheduler.yield continuation runs ahead of rendering, so a chain
of 8 ms slices presented no frame until the ~100 ms starvation guard fired.
Midway streaming its cockpit after the reveal read a 102 ms host gap at p95
against 9 ms with nothing streaming. After the reveal, a slice now waits for
the loop's next frame, then one host yield, and spends only the headroom the
frame left (1-8 ms). Behind the curtain it keeps the host yield and 8 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(three-native): an InstancedBufferAttribute is isInstancedBufferAttribute (PRD-860)

Midway's smoke, fire and muzzle flashes were invisible on the owned engine: its particle batch
re-sends only the attributes three's flag names, and the flag read null, so the GPU kept the
zeros the batch was created with. defineTypeFlags now flags InstancedBufferAttribute,
InstancedBufferGeometry and InterleavedBufferAttribute, on web and on the desktop player.
Red-green: object-surface.spec.ts (1 failed, then 8/8).

* fix(web-engine): the loader-thread calls take web host ABI 12 (PRD-860)

c5d2ede added tnw_web_load_gltf_begin/done under ABI 11, so web-host-abi.spec.ts failed
(digest a60bf14c, pinned 6ca2e45b). Both numbers move to 12 and the digest is pinned.

* test(three-native): every bound class carries the is* flags three's own instance sets

A guard spec builds the web back end's classes from the registry and catalog, applies the same
flag passes, and compares each against a real three instance. It found nine more missing flags
(CubeTexture, Data3DTexture, DataArrayTexture, DepthTexture, MeshPhysicalMaterial, RenderTarget,
Matrix4, CatmullRomCurve3, Source/TextureSource); they join the hand list. Removing
InstancedBufferAttribute from the list turns the spec red, so the PRD-860 smoke bug cannot return
silently.

* test(three-native): the flag guard covers the JS ports and three/webgpu; node materials read their classic flag

The guard now builds the JS ports (QuadMesh, GridHelper, cube and 3D targets, EdgesGeometry, Box2,
typed attributes) over fake engine classes that answer any member, looks a class up in three/webgpu
too, and fails on any class it cannot build instead of skipping it. It found four more gaps: three's
node materials copy their classic defaults, so MeshStandardNodeMaterial reads isMeshStandardMaterial
(MeshBasic, MeshPhysical and Sprite likewise); the owned engine read undefined.

* feat(core): a KTX2 texture streams by Range, mip tail first (PRD-860 AC-4)

openKtx2Stream reads the header, metadata and mip tail in one 64 KiB Range read, hands back a whole
KTX2 file of the levels on hand, and reads finer levels on want, coarse first. A host that ignores
Range is read once and kept whole. Partial files keep the descriptor, key/value data and the kept
levels' BasisLZ image descriptors; three's ktx-parse reads every one.

* feat(assets): a LOD-chained model ships a coarse companion with its own vertices (PRD-860 Phase 3)

A chain's levels share the base level's vertex buffers, so nothing coarse can download first. The
model pass now writes <name>.coarse.<digest>.glb beside the model: each chained primitive draws its
coarsest level over compacted vertices, through the same writer, so its images are the model's own
shared files. The manifest lists it under streamedLods.

* fix(core): a LOD level's triangle count is read once, not every frame

ModelLod.triangles re-read geometry.index for every LOD mesh each frame; on the owned engine each read
is a bridge crossing, and Midway's fleet flight failed its playtest on TN_BRIDGE_WARNING (528 reads a
frame). A level is an index view nothing edits, so its count is cached per geometry.

* feat(core): a streamed model draws its coarse companion first, then the full model in place (PRD-860)

ctx.assets.model loads <name>.coarse.<digest>.glb when the manifest lists one and the game did not
prefetch the model, weighs the request by the coarse bytes, and fetches the full file at soon
priority. upgradeStreamedGeometry gives each coarse geometry the full base's index and attributes in
place and registers the full chain under it, so every mesh over it, the game's clones and a
projection mirror included, draws the full model and walks its LOD chain. A failed coarse load falls
back to the whole file. assets.streamMeshes: false is the named override (TN_ASSETS_STREAM_MESHES_OFF).
Ticks AC-3: the fleet playtest reaches Kaga at 673 m with zero TN_ASSET_LATE lines.

* fix(assets): the compile cache keys on the coarse companion

A warm cache from before the companion re-served every model without streamedLods: Midway's first
cook after 70ea62e wrote no coarse file. The model pass configuration now carries
MODEL_COARSE_COMPANION_VERSION.

* docs(PRD-860): AC-7 bytes pair and AC-8 web judge evidence

* docs(PRD-860): tick AC-8, web judge NEUTRAL and native desktop SAME

* feat(PRD-861): the owned web engine cooks and loads meshopt geometry

The Wasm engine links meshoptimizer's decoders (refusing to link without them),
GLTFLoader on the owned engine accepts a meshopt decoder it does not need, and
webRuntimeDecoders answers meshopt: true for engine "threenative".

Red-green: tests/web-host-meshopt.test.mjs loads the 912-byte meshopt GLB in the
real Wasm engine. Without meshopt: TN_NATIVE_GLTF_EXTENSION_UNSUPPORTED
EXT_meshopt_compression. With it: status 0, one scene handle.

* test(create-threenative): the native-engine web cook emits meshopt geometry

* docs(PRD-861): name PRD-860 without a link to a file this tree does not carry

* feat(PRD-861): the owned engine transcodes a model's KTX2 textures

The engine's glTF loader reads KHR_texture_basisu through Basis Universal v1_60
(Apache-2.0, pinned in download-deps and the lock, with its decode-only zstd).
It transcodes the base level to RGBA8, the one upload format the engine's
Texture has, so every GPU block target is compiled out. The Wasm engine refuses
to link without the transcoder.

The cook learns ktx2Files: a standalone .ktx2 (texture, lightmap) goes through
three's KTX2Loader, whose CompressedTexture the owned engine does not bind. The
owned web engine now answers { ktx2: true, meshopt: true, ktx2Files: false }:
model textures cook to KTX2, standalone textures keep their authored format.

Red-green:
- tn-native-engine-gltf-ktx2-test (Wasm toolchain, node): without basisu
  TN_NATIVE_GLTF_EXTENSION_UNSUPPORTED KHR_texture_basisu; with it ETC1S red and
  UASTC+zstd green decode within 8 levels, a damaged KTX2 is
  TN_NATIVE_GLTF_IMAGE_INVALID image 0.
- assets runtime-decoders.spec.ts: without ktx2Files the standalone texture pass
  still ran; with it only the model pass and the resize pass run.
- tests/web-host-decoders.test.mjs: the real Wasm engine loads both fixtures.
Web engine size: threaded 4,172,446 of 4,194,304 bytes (+103.6 KB).

* fix(PRD-861): the owned engine's web cook keeps KTX2 off, by measurement

Midway's web bake with model KTX2 on shipped 196.0 MB, against 124.7 MB with
meshopt and WebP model textures: the owned engine transcodes KTX2 to RGBA8, so a
KTX2 saves no GPU memory there and only costs bytes. webRuntimeDecoders answers
{ ktx2: false, meshopt: true } again and ktx2Files goes (no consumer left). The
loader keeps KHR_texture_basisu, so an authored KTX2 model now loads instead of
being refused, and the web engine no longer needs basisu to link.

The skip line named both decoders even when meshopt ran; it now names only the
one the target lacks (red: "no qualified meshopt and KTX2 decoder"; green: "no
qualified KTX2 decoder", report.spec.ts).

* docs(PRD-861): meshopt lands on the owned web engine; KTX2 decodes but stays out of its cook

AC-2, AC-3, AC-4 and AC-6 ticked with their evidence. AC-1 and AC-5 stay open
on the KTX2 line: the engine transcodes KTX2 to RGBA8 only, and that costs
Midway 71.3 MB, so the cook keeps KTX2 off until the renderer uploads a
compressed format.

* test(PRD-861): the hierarchy test stops expecting KHR_texture_basisu refused when the transcoder is linked

Red (native coverage build): KHR_texture_basisu: not refused by name (), 1 of
12 files differ. Green: native and Wasm ctest, hierarchy, ktx2 and meshopt 6/6.

* docs(PRD-861): refresh the native coverage record for the meshopt and KTX2 loader

pnpm --filter @threenative/runtime-native native:coverage exit 0; pnpm budgets exit 0.

* docs(PRD-867): plan the owned engine's compressed texture upload and single mip levels

* feat(PRD-867): the owned engine uploads BC7 and ETC2 textures, and KTX2 transcodes to them

A Texture whose source holds compressed levels (three's CompressedTexture.mipmaps, in
RGBA_BPTC_Format or RGBA_ETC2_EAC_Format) uploads each level in its block format; a
device without the feature refuses it by name. The renderer tells the loader which
format its device samples, and a KTX2 image transcodes to every level in that format.
A device with neither keeps RGBA8, and TN_TEXTURE_TRANSCODE_TARGET says so.

Red-green: on PRD-861's head the KTX2 map loaded as 256 RGBA bytes. Now, on Dawn
over Vulkan on the RTX 2080 (compressed_texture_test): 8x8 BC7, 64 bytes, a mapped
plane reads back 253 0 0 (RGBA control 255 0 0); a featureless device:
"TN_NATIVE_TEXTURE_UNSUPPORTED: BC7 needs the device feature texture-compression-bc".
Native ctest 9/9, Wasm toolchain 7/7, web-host-decoders 2/2.

Web engine: the transcoder builds at -Oz on the web (BC7+ETC2 cost 145,855 bytes at
-O3, 12,201 at -Oz). Threaded 4,184,647 of 4,194,304 bytes. ASTC deferred.

* feat(PRD-867): three's CompressedTexture and KTX2Loader run on the owned engine, and a finer mip level writes in place

The engine binds CompressedTexture: (levelCount, width, height, format), then one
`mipmaps.N.data` per level, coarse first. Texture::writeMipLevel fills a level; the
renderer uploads only the levels written since its last upload into the GPU texture
that is already there, and rebases the view on the finest resident level. The browser
facade builds three's CompressedTexture(mipmaps, ...) over it, answers
renderer.hasFeature("texture-compression-bc") from the device (a new web frame field),
and takes three's own Loader, FileLoader, LoadingManager and Cache modules, so
KTX2Loader constructs, detects BC7 and returns a CompressedTexture that draws.

Red-green: the facade spec failed on the old texture-sources.ts
(TN_BROWSER_ARGUMENT_UNSUPPORTED); the browser page failed with
TN_NATIVE_UNCATALOGUED_LOADER. Now: native ABI/GPU/KTX2/registry 66/66 on Dawn/Vulkan
(RTX 2080: a finer level uploads 64 bytes, coarse 0 0 0 then 254 0 0); three-native
578/578; examples/wasm-engine-boot playtest:compressed pass on nvidia turing (BC7 load
251,0,0; streamed coarse 0,0,0 then 253,0,0). Web engine threaded 4,193,090 of
4,194,304 bytes.

Phase 2 stays open by measurement: Midway's KTX2 cook is 189,852,625 B against
125,132,608 B with WebP (+51.7%, mostly UASTC images), so webRuntimeDecoders keeps
ktx2 off for the owned engine.

* feat(PRD-860): a streamed model's KTX2 textures draw their resident mips first and take the whole files when they land

The coarse parse reads each shared .ktx2 through the facade GLTFLoader's new imageSource hook,
which serves the resident levels (openKtx2Stream). The full parse loads whole files, and
upgradeStreamedTextures swaps them into the coarse materials. assets.streamTextures: false is the
named override; TN_STREAMING_POOL reports resident and deferred bytes.

* docs(PRD-860): the capability manifest names coarse-first model and texture streaming and their overrides

* feat(PRD-867): the web cook keeps a WebP a KTX2 cannot undercut, and the owned web engine cooks KTX2

Midway's 300 KB WebP metallic-roughness and normal maps became 4 MB UASTC
files because the keep-smaller-source rule kept only PNG and JPEG. On the
web every runtime reads a WebP, so it is now a source the cook may keep
(MODEL_WEBP_RETAIN_VERSION rides the cache key). Midway's KTX2 cook drops
from 189,852,625 B to 98,236,952 B, against 125,132,608 B for WebP, so
webRuntimeDecoders turns KTX2 on for the owned engine.

* fix(PRD-861): the owned web engine reads a meshopt-compressed LOD chain

Midway's models failed to load on the owned engine with
TN_WASM_GLTF_LOD_ACCESSOR: accessor N reads past the binary chunk. Since
PRD-861 the web cook meshopt-compresses this engine's models, and the LOD
chain's index views moved into a compressed stream, but the hosted LOD
plugin's accessor reader read the raw binary chunk. It now decodes a
compressed view with the MeshoptDecoder core already hands to
setMeshoptDecoder, and refuses by name without one.

* fix(PRD-867): a KTX2 decoded on a pool worker no longer hangs the threaded web engine

Midway's KTX2 build hung after its first load steps, the main thread
blocked for good. decodeImage printed TN_TEXTURE_TRANSCODE_TARGET from
inside the loader's JobPool. On the threaded Wasm engine a pthread's
stdout write is proxied to the main thread, which spins in forEach until
every worker leaves the job, so a worker that decoded the process's
first KTX2 never returned. The lines now print from the loading thread
(reportKtx2) after the pool joins. Threaded web engine 4,193,549 B,
755 B under budget.

* docs(PRD-867): tick AC-4 and PRD-861 AC-1 with the starter bake, record the cook and LOD fixes, refresh native coverage

* build(PRD-867): the web engine budget is 4 MiB + 64 KiB

With PRD-860 merged, the threaded web engine is 4,198,621 bytes (KTX2 transcode to BC7/ETC2 plus
streaming). The KTX2 cook it enables saves Midway 26.9 MB of download.

* fix(PRD-860/867): review findings on streamed models and the owned engine's KTX2

Fable 5.2 review of #643:
- A KTX2 model loads on a GPU with no compressed format three's KTX2Loader takes: the owned
  engine's loader transcodes KHR_texture_basisu itself, so its format check no longer gates it.
- The texture upgrade swaps only slots that still hold the coarse model's own textures, so a
  texture or material the game set meanwhile stays; unused full-model copies are released.
  It moves to streamed-textures.ts, so model-lod.ts stays under the no-appearance constraint.
- ctx.assets.model(path, { whole: true }) waits for the full file; CollisionShape3D.fromMesh
  names a trimesh or hull copied from a coarse model (TN_COLLIDER_COARSE); isStreamedGeometry.
- Auto-batch keeps a streamed coarse model out of a merge.
- The full load stops when the model is released, cleared or the loader disposed.
- A host without Range hands its whole KTX2 on instead of a second fetch; a short 206 and a
  section past the bytes read fail by name; unused mip helpers deleted.
- Threaded web engine: the main thread refreshes its heap views when a glTF load ends and once
  per frame; the hierarchy revision is atomic.

* feat(PRD-867): a KTX2 model draws on the owned web engine, proven on screen; PRD-861 and PRD-867 done

- examples/wasm-engine-boot playtest:ktx2-model loads ktx2-textured.glb through the engine's glTF
  loader and reads the ETC1S and UASTC maps' texels (251,0,0 and 0,255,0, bc7 on nvidia turing);
  red on an engine without the transcoder (TN_NATIVE_GLTF_EXTENSION_UNSUPPORTED KHR_texture_basisu).
- Capability manifest: createAssetLoader names the owned engine's KTX2 upload, its report and
  refusal lines, coarse-first levels and webRuntimeDecoders; modelPass names the codec ladder and
  its textures.overrides.
- PRD-867 AC-5 (bytes -21.5%, judge NEUTRAL, sea check) and PRD-861 AC-5 ticked; both moved to done.

* fix(PRD-860): the glTF loader thread parks in the reviewed worker wait, so native_engine_no_blocking_waits passes

The loader's queue moves into a small struct whose wait has the one TN_WORKER_ONLY shape
CheckNoBlockingWaits.cmake accepts; begin only notifies. The check failed on the old
gltfQueueWake.wait and blocked the native coverage refresh, which is regenerated here.
Proof: the check passes on 237 engine sources and fails on the old file; the threaded web
engine loads the KTX2 model playtest (bc7, zero console errors); pnpm budgets exit 0.

* fix(PRD-860): a host without Range sends each streamed KTX2 once; the full parse takes the coarse parse's whole file

Review of #643, finding 7. The coarse parse already held the whole file; the full model's parse
downloaded it again. The test was red ([22256, undefined]) before the fix.

* fix(PRD-860): a streamed texture's whole file keeps the anisotropy, wrap and transform the game set

The AC-6 judge returned REGRESSION: Midway sets anisotropy on each loaded texture, the upgrade
swapped in the whole file with the default 1, and the SBD's wing star smeared at grazing angles.
The test was red (expected 1 to be 8) before the fix.

* test(PRD-861): the web native engine's cook keeps Meshopt and KTX2, which its loader now decodes

The lane flipped webRuntimeDecoders to { ktx2: true, meshopt: true } and left this test on the old
refusal; the laptop unit run found it red.

* docs(PRD-860): done — Midway plays after 22.4 MB in 10.4 s at 40 Mbps, streamed, with a NEUTRAL judge

AC-5, AC-6, AC-7 and the end-to-end box ticked with their evidence; the PRD moves to done/.

* docs(PRD-860): every streaming abstraction of #643 answers capability search by its own words

'an asset pops in after the reveal', 'a model texture looks blurry right after load' and
TN_COLLIDER_COARSE found nothing in the first three; search.spec now pins 16 PR queries (3 were red).

* fix(PRD-860): the branch's 14 capability aliases become situations, so each alias keeps a corpus row

CI test-unit red: 'maps every authored alias to a named predecessor corpus row' rejected aliases
with no recall-corpus row. As situations they stay searchable; search.spec's 16 PR queries and the
gold-set floor still pass (140/140).

* test(PRD-867): source hygiene treats .ktx2 as a binary asset, like .webp and .glb

CI test-unit red: the NUL-byte check read examples/wasm-engine-boot/src/red-etc1s.ktx2, the
compressed-upload fixture, as a text source.

* fix(capabilities): parse .ts sources as TS so fetchAsset gets its manifest entry

The manifest builder parsed every source as TSX, so the generic arrow
`const cached = <T>(...) =>` in assets.ts read as JSX and every export after
it was dropped. fetchAsset had no entry and `pnpm budgets` failed on its
missing doc tags. Also add the missing createTweakBox @example.

* chore: format the manifest spec and record fetchAsset in the api surface

* test(strata): pin the biome aliases #635 added to biome-words

#635 widened each starter's aliases in biome-words.ts and biome.data.json but
left the pinned table in biome-values.spec.ts on the old lists, so the
strata-terrain-preview test job is red on develop and on every PR.

* test(runtime-native): pass ABI 13's scenes pointer in the web decoder test

ABI 13 added a scenes out-pointer to tnw_web_load_gltf and counts the default
scene, the clips, then every scene. The PRD-861 decoder test still passed six
arguments, so the host refused it with 'TN_WASM_GLTF: invalid arguments' and
test-native was red on develop.

* test(terrain): give the savanna cook-time check a noise margin

AC-3 compared one savanna cook with one forest cook using a strict <=. On a
shared CI runner the two vary by several percent either way (savanna 6026 ms
against forest 5654 ms on #643; locally 4267 against 4908), so the test failed
on noise twice. A 25% margin keeps the paired comparison and still fails a
savanna that is really slower.

* fix(ci): the suite temp-directory gate waits for late cleanup and names what stayed

The test job on #643 passed every test and still failed: before 1, after 2.
--suite-finish counted once, at once, and named nothing, while a test's async
cleanup can still be removing its directory when the last package ends. It now
gets the browser run's settle window (TN_ORPHAN_SETTLE_SECONDS, 30 s) and
prints each surviving directory, so a real leak points at its owner.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
jonit-dev added a commit that referenced this pull request Nov 11, 2026
…658)

#635 (PRD-861, PRD-867) added native_engine_gltf_ktx2_* ctest cases that need third_party/basisu.
The native-engine job, its Wasm-under-node step and the web engine action fetched only
stb, cgltf, webp-source and meshopt, so the loader refused KTX2 by name and the three cases
failed in 0.00 s (test-native red on every develop push since 39223c1).

Local core-only build, CI's dependency list: 0 of 3 pass. With basisu: 3 of 3 pass.
jonit-dev added a commit that referenced this pull request Nov 11, 2026
…n where Wasm has no threads

native_engine_scene_concurrent_ids (PRD-860, merged in #635) started four
std::thread producers. CI's "Build and test the engine core as Wasm under node"
step builds without -pthread, where a thread cannot start: node died with an
unhandled "#<Exception>" and the step failed on every pull request run since
the KTX2 failure stopped earlier steps from reaching it (runs 42940567205 and
42943256476). Without pthreads the producers now run one after another and
the ids must still be unique; threaded builds are unchanged.

Proof: the test fails on the old code and passes on the new under core-only
Wasm (emcmake, node) and passes natively; scene tests 15/15 under Wasm.
jonit-dev added a commit that referenced this pull request Nov 11, 2026
strata-script-census.spec.ts requires every file under the example's scripts/ and
playtests/ in its AGENTS.md. #635 (Strata terrain chain) added scripts/dem/massif.mjs,
the three massif-*.bin/.json tiles, recipe.d.mts, stamp.d.mts and
verify-variations.mjs without entries, so test-unit (2/4) failed (run 42958453742).
Each file now has a one-line entry; CLAUDE.md is the regenerated mirror.

Proof: strata-script-census red on origin/develop (10 files), green with the entries;
check:docs exit 0; sync-agent-docs, primary-docs and ci-structure specs 217/217.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant