Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ e2e871f1feac6fa0f7dc4d372f30d3cb586a8f72:packages/core/__tests__/warmup-default.

# Verified 2026-10-11: PRD-595's slash-separated CellPrewarm counter names are prose, not a credential.
0c2f9e4b57382d980ef98424f6ac03b86e1d785b:docs/PRDs/strata-terrain-hardening/PRD-595-world-cells-splits-by-seam-and-survives-a-race-fuzz.md:generic-api-key:63
# Verified 2026-10-27: PRD-750 scripts/kit-loop.mjs line 45 is a KPI field id string (k2ScenarioPct), not a credential. The commit is on #575's branch; the full-history scan reads every fetched ref.
f799f51aba375ce12df5eb43db2a7c69b65d96ae:scripts/kit-loop.mjs:generic-api-key:45
# Verified 2026-10-27: line 31 of the commit below is the earlier wording of the comment above (it quoted the same field id), not a credential.
0fd2f4d574d82305373434bc8cf1c00600b3a36e:.gitleaksignore:generic-api-key:31
# Verified 2026-10-27: the same KPI field id line of scripts/kit-loop.mjs, on #574's branch (pr563-followup).
4e74eea0908f871c2f42c112e82c31952d9cdd89:scripts/kit-loop.mjs:generic-api-key:45
10 changes: 9 additions & 1 deletion PERFORMANCE-LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ PRD's "Tried and rejected" or "Decisions" section, for example

## Levers

### 2026-10-27 — Batching holds a projecting decision; render thread on by default — KEEP
- Where: [PRD-613](docs/PRDs/done/PRD-613-native-render-thread.md), `RenderDatabase::batch`, `player/run.cpp`.
- Result (Midway desktop, matched CPU, 3 alternating pairs): main-thread CPU per frame falls 3.4 to 7.5 ms per
pose; instructions per frame 40-48 M to 29-34 M; parity PASS; visual judge NEUTRAL.
- Cause: a projecting projection decision was judged again every frame (scene walk, content classes, group maps)
only to read the rigs' verdicts, about 11% of main-thread samples. The render thread alone moves about 0.6 ms of
encode, inside run-to-run noise in CPU ms; the instruction count shows it in every run.

### 2026-10-15 — Game thread pinned to the CPPC top-ranked core — REJECT
- Where: PRD-610 AC-20, commit `6878c53ff` (superseded by `61c7c1d06` and `c4be636dc`, same `ThreadPlacement` in `player/run.cpp`).
- Result: `q-place-auto-1` 10.65 / 11.07 / 15.56 ms against `q-place-off-1` 8.67 / 9.26 / 10.98 ms (IPC 0.46); load 12 to 16.
Expand Down Expand Up @@ -152,7 +160,7 @@ PRD's "Tried and rejected" or "Decisions" section, for example
message a third time.

### 2026-10-14 — PRD-613 Stage 0: render phase split — KEEP (diagnostic)
- Where: [PRD-613](docs/PRDs/native-engine/PRD-613-native-render-thread.md), commit 4abe3d020.
- Where: [PRD-613](docs/PRDs/done/PRD-613-native-render-thread.md), commit 4abe3d020.
- Result (loaded host, shares only): the ocean reflection pass is about half the render wall at cockpit and rear
gunner (0.85 / 2.67 / 4.32 ms); its encode costs about 2 ms for 8 items at cockpit, a per-pass fixed cost.
- Cause: one reflection pass per frame re-runs prepare and encode for its own camera.
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/PRDs/native-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Full text and rationale: [PRD-497 § Decisions](PRD-497-n00-architecture-decisio
| N20 | [PRD-533 — Platform qualification, performance and default promotion](PRD-533-n20-platform-qualification-performance-default-promotion.md) | CP1, N00–N16, N18, N19 |
| N21 | [PRD-535 — The JS engine is deleted](PRD-535-n21-the-js-engine-is-deleted.md) | N20 + one release, N19 |
| Perf | [PRD-612 — The web Wasm engine beats three.js on Midway](PRD-612-web-wasm-engine-beats-threejs-on-midway.md) (owner bar 1.5x) | N19, PRD-610 |
| Perf | [PRD-613 — Native render thread](PRD-613-native-render-thread.md) (PRD-610 AC-16) | N09, PRD-610 |
| Perf | [PRD-613 — Native render thread](../done/PRD-613-native-render-thread.md) (PRD-610 AC-16) | N09, PRD-610 |

```mermaid
flowchart LR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Both wrappers exit 0, actual reports PASS 3/3 with 4,398 reported frames, fixed-

- 2026-10-15 decision — REJECT the isolated pointer-compressed V8 13.1.201.22 build, not pointer compression generally: compiler/configuration can also differ. `v8pc-resume-base-1` → `v8pc-resume-on-1`, takeoff / cockpit / rear gunner CPU: 8.897 → 12.210 / 8.194 → 10.747 / 12.242 → 12.875 ms/frame; instructions: 20.449 → 20.871 / 22.762 → 23.144 / 29.996 → 30.299 M/frame; IPC: 0.577 → 0.420 / 0.704 → 0.529 / 0.607 → 0.575. Every pose worsened CPU and instructions, so the second pair was stopped. Both exit 0 and pass 3/3 assertions on RTX 2080; all six windows have exactly 1,000 frames/updates/renders; candidate adapter checks pass 27/27. Raw evidence: `~/.cache/tmp-tn/screens/v8pc-resume-{base,on}-1/`; dashboard rows are exploratory and excluded, with no qualified parity. Default baseline and candidate archive remain preserved; AC-20 stays open. Dashboard publication took priority; cycle `native-v8-pointer-compression` closed in 27.56 minutes (05:38:24–06:05:58 UTC), within the 30-minute target.

- [ ] AC-16 [local]: delivered by [PRD-613](../native-engine/PRD-613-native-render-thread.md) (owner, 2026-10-14: separate PRD). Cull, project and encode run on a render thread, and the player main thread's CPU per frame falls by at least 1.0 ms on the lockstep window. proof: a ctest on render-thread ordering (frame N encodes only frame N's snapshot; a mutation goes red), a fixed-step capture pair whose A-against-B difference is no larger than A against A, three alternating pairs per pose, visual judge NEUTRAL or better
- [ ] AC-16 [local]: delivered by [PRD-613](../done/PRD-613-native-render-thread.md) (owner, 2026-10-14: separate PRD). 2026-10-27: PRD-613 is done; the 1.0 ms bar and the four proofs pass, but cull and project stay on the main thread (its batch decision is held instead, see its Decisions), so this box waits for the owner. Cull, project and encode run on a render thread, and the player main thread's CPU per frame falls by at least 1.0 ms on the lockstep window. proof: a ctest on render-thread ordering (frame N encodes only frame N's snapshot; a mutation goes red), a fixed-step capture pair whose A-against-B difference is no larger than A against A, three alternating pairs per pose, visual judge NEUTRAL or better
- [ ] AC-17 [local]: animation costs the player main thread at most 0.4 ms per frame on the lockstep window (about 1.05 ms today), and an animated value the game reads is current for its frame. proof: a ctest on read-before-join (red when a game-read track evaluates in a job), the mixer meter on three alternating pairs, visual judge NEUTRAL or better at all three poses
- [ ] AC-18 [local]: the HUD bridge, positional audio and the player shim cost the player main thread at most 0.2 ms per frame on the lockstep window (about 0.45 ms today). proof: V8 profile owner rows before and after on the same window, an instruction A/B per moved piece, and a spec or conformance case per native fast path
- [ ] AC-5 [local]: the HUD snapshot round trip is under 16.7 ms p95 in flight. proof: `TN_UI_SNAPSHOT lastRoundTripMs` from `flight.sh desktop` — Evidence so far: p95 168 ms at filing, 26 ms on package T. The WPE backend and a state snapshot were rejected (see Context). Open lead from the 2026-10-13 trace: reading the offscreen window's own surface cost 0.6 ms, against 30 to 49 ms for a full-page snapshot.
Expand Down
12 changes: 6 additions & 6 deletions docs/verification/native-coverage-2026-08-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ targets could not be built and are named below.
| `src/audio/` | 1471 | 1301 | 88.44% |
| `src/canvas/` | 1334 | 1110 | 83.21% |
| `src/cli/` | 1691 | 1243 | 73.51% |
| `src/engine/` | 38678 | 32336 | 83.60% |
| `src/engine/` | 38672 | 32356 | 83.67% |
| `src/fs/` | 235 | 189 | 80.43% |
| `src/http/` | 410 | 377 | 91.95% |
| `src/js/` | 2835 | 2227 | 78.55% |
| `src/platform/` | 1507 | 1047 | 69.48% |
| `src/raytracing/` | 643 | 543 | 84.45% |
| `src/runtime.cpp` | 2410 | 1869 | 77.55% |
| `src/runtime.cpp` | 2410 | 1867 | 77.47% |
| `src/screenshot_gate.cpp` | 27 | 24 | 88.89% |
| `src/storage/` | 370 | 329 | 88.92% |
| `src/utils/` | 0 | 0 | 0.00% |
| `src/vfs/` | 239 | 195 | 81.59% |
| `src/webgpu/` | 10061 | 7716 | 76.69% |
| `src/webgpu/` | 10061 | 7719 | 76.72% |
| `src/webtransport/` | 1391 | 1078 | 77.50% |
| `src/workers/` | 615 | 524 | 85.20% |
| **TOTAL** | **63990** | **52168** | **81.53%** |
| `src/workers/` | 615 | 527 | 85.69% |
| **TOTAL** | **63984** | **52192** | **81.57%** |

Source digest: `sha256:b7d49175bb6939252fb198d3075a67ff9761587b7de71314d74f5f77f6be02d3`
Source digest: `sha256:1b2fa340692ebed80693a9e5e459c1b2005ad41c8aea925b6f2104dde06cc1bb`

The default `pnpm budgets` gate reads this committed measurement without configuring or compiling
the native host. Any native source, native C++ test, CTest registration, or coverage aggregation
Expand Down
6 changes: 3 additions & 3 deletions packages/create-threenative/__tests__/scaffold.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ const BUG_REPORT_SKILL_PATHS = [
// Re-measured 2026-10-20 (PRD-626): every tree gains eslint.config.js and biome.json, three scripts and
// two dev dependencies, and the pristine sources lose the unused bindings the new lint found.
// Refreshed 2026-10-24: shared authoring routes, delegation skills and prompt hook change all 13 trees.
// Recomputed 2026-10-26 (reference MCP hook): every template's AGENTS.md names classic-game
// mechanics references beside capability search in the authoring skill list, so all sixteen
// trees move. No other scaffold byte changed.
// Recomputed 2026-10-26 (reference MCP hook): every template's AGENTS.md names classic-game
// mechanics references beside capability search in the authoring skill list, so all sixteen
// trees move. No other scaffold byte changed.
const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly<Record<string, string>> = {
// PRD-723: RTS now ports its visually approved filmic/metered look, readable HUD/rings and full-resolution override.
// Other kits retain the measured phase-1 core imports, vendor removal and guidance.
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-native/cmake/NativeEngine.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi
native_engine_batched_vs_unbatched=batched_vs_unbatched
native_engine_skinned_batched_vs_unbatched=skinned_crowd
native_engine_skinned_decline_cadence=skinned_decline_cadence
native_engine_skinned_projecting_cadence=skinned_projecting_cadence
native_engine_skinned_normalized_weights=skinned_normalized_weights
native_engine_normal_map_tilt=normal_map_tilt
native_engine_renderer_gpu_mipmaps=gpu_mipmaps
Expand Down
36 changes: 2 additions & 34 deletions packages/runtime-native/src/engine/abi/bindings_scene.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -215,38 +215,6 @@ void registerEvents(ClassBinding& b, EventDispatcher* (*dispatcher)(void*), std:
}
}

// One number that changes whenever anything the projection's stability proof reads off the engine
// changes (projection-stability.ts): the walk order, each node's visibility, and for a mesh its
// geometry, material, flags, render order and layer mask. It walks the way that proof walks, so a
// light or LOD hides its children. A hash, so a match is a claim at 2^-53 and a mismatch only sends
// the caller to its own exact walk.
double structureStamp(Object3D& root) {
uint64_t h = 0xcbf29ce484222325ULL;
const auto mix = [&h](uint64_t v) {
h = (h ^ v) * 0x100000001b3ULL;
h ^= h >> 29;
};
std::vector<const Object3D*> stack(root.children.rbegin(), root.children.rend());
while (!stack.empty()) {
const Object3D* object = stack.back();
stack.pop_back();
const auto* mesh = dynamic_cast<const Mesh*>(object);
mix(object->id() * 2 + (object->visible() ? 1 : 0));
if (mesh != nullptr) {
mix(reinterpret_cast<uintptr_t>(mesh->geometry.get()));
mix(reinterpret_cast<uintptr_t>(mesh->material.get()));
mix(static_cast<uint64_t>(mesh->castShadow()) | static_cast<uint64_t>(mesh->receiveShadow()) << 1 |
static_cast<uint64_t>(mesh->frustumCulled) << 2);
mix(static_cast<uint64_t>(static_cast<int64_t>(mesh->renderOrder())));
mix(std::bit_cast<uint64_t>(mesh->layers().mask));
}
mix(object->children.size());
if (object->isLight() || object->type() == "LOD") continue;
for (auto it = object->children.rbegin(); it != object->children.rend(); ++it) stack.push_back(*it);
}
return static_cast<double>(h & ((1ULL << 53) - 1));
}

// core's host projection seam (renderProjection.ts): the verdict the native renderer's own batching gives a
// root, in one call per frame. It is projection::decide, the port of core's scanProjection, kept while the
// structure stamp it reads holds and re-judged on core's decline cadence (matrices move a verdict too: a
Expand Down Expand Up @@ -278,7 +246,7 @@ Value hostProjectionReport(Object3D& root, int minMeshes) {
if (settled) {
++host.sinceDecide;
} else {
const double stamp = structureStamp(root);
const double stamp = projection::structureStamp(root);
if (stamp != host.stamp || minMeshes != host.minMeshes || ++host.sinceDecide >= kDeclineRescanFrames) {
host.decision = projection::decide(root, minMeshes);
host.stamp = stamp;
Expand Down Expand Up @@ -561,7 +529,7 @@ void registerObject3D(ClassBinding& b) {
};
// core's projection-stability proof asks for this where it exists; three has no such method.
b.methods["__tnStructureStamp"] = [](void* self, const Args&, Store&) {
return Value::of(structureStamp(*as<Object3D>(self)));
return Value::of(projection::structureStamp(*as<Object3D>(self)));
};
// core's SceneRenderProjection asks for this where it exists, instead of building a mirror: the native
// renderer batches this scene itself, so one call answers what that batching will make of it.
Expand Down
10 changes: 9 additions & 1 deletion packages/runtime-native/src/engine/player/core-assets.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The owned engine consumes verified TNPK entries, never browser loaders or network fallbacks.
import { audio } from "./core-audio.mjs";
export function createAssetLoader(options = {}) {
if (Object.keys(options).some((key) => !["renderer", "basePath", "manifest", "sourcePath"].includes(key)))
if (Object.keys(options).some((key) => !["renderer", "basePath", "manifest", "sourcePath", "cache", "backgroundFill"].includes(key)))
throw new Error("TN_NATIVE_ASSET_OPTIONS_UNSUPPORTED: this player accepts cooked package paths only");
// core's fallback for a game without a manifest: the verbatim path, then `<sourcePath>/<path>`.
const sourcePath = (options.sourcePath ?? "assets").replace(/^\/+|\/+$/g, "");
Expand Down Expand Up @@ -55,5 +55,13 @@ export function createAssetLoader(options = {}) {
resolve: async (path) => [resolved.get(path)?.url ?? `tnpk:${path}`],
release: (kind, path) => cache.delete(`${kind}:${path}`),
clear() { cache.clear(); resolved.clear(); },
// core's disk cache (PRD-743) is web-only: a native game reads its installed package, so the report
// says so and prefetch and the background fill have nothing to fetch. `cache: false` changes nothing here.
get cache() {
return { enabled: false, persisted: false, hits: 0, misses: 0, bytesOnDisk: 0, bytesTotal: 0, filling: false,
backgroundFill: false, reason: "native host reads installed files", clear: async () => {} };
},
prefetch: async () => ({ requested: 0, requestedBytes: 0, settled: 0, settledBytes: 0 }),
startBackgroundFill: async () => {},
};
}
5 changes: 3 additions & 2 deletions packages/runtime-native/src/engine/player/run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,8 @@ struct Session {
double gpuThreadCpuMs = -1; // the GPU thread's CPU for the previous frame's present job
double gpuJobCpuMs = -1; // written by that job; copied above only after a wait
// PRD-613: the encode half of the frame (every pass, main and reflections) runs here, off the game
// thread, while the next frame's JS runs. TN_RENDER_THREAD=1 enables it; it needs the GPU thread.
// thread, while the next frame's JS runs. On by default; TN_RENDER_THREAD=0 turns it off and GPU timing runs
// keep it off. It needs the GPU thread.
// The game thread touches the renderer only after waitRenderThread() and before it posts the next job.
std::shared_ptr<GpuThread> renderThread;
std::vector<std::function<void()>> encodeJobs; // this frame's passes, in order; the job reads them, so they live here
Expand Down Expand Up @@ -774,7 +775,7 @@ int Session::initialize() {
gpuThread = std::make_shared<GpuThread>();
renderer->gpu().setThread(gpuThread);
// Only a game that fences its own renderer touches (setRenderBarrier) can share the renderer with a thread.
if (const char* rt = std::getenv("TN_RENDER_THREAD"); rt && std::strcmp(rt, "1") == 0 && game.setRenderBarrier) {
if (const char* rt = std::getenv("TN_RENDER_THREAD"); !gpuTiming && !(rt && std::strcmp(rt, "0") == 0) && game.setRenderBarrier) {
renderThread = std::make_shared<GpuThread>();
renderer->collectFrame();
game.setRenderBarrier([this] { waitRenderThread(false); });
Expand Down
41 changes: 41 additions & 0 deletions packages/runtime-native/src/engine/renderer/projection/plan.h
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ inline const char* skinnedLaneReason(const SkinnedMesh& rig) {
return nullptr;
}

// What decide reads off a rig on every frame, outside the structure stamp (PRD-613): its lane reason (material
// nodes, transparency, render order, skeleton) and whether its world matrix is a similarity and not mirrored.
// A held decision is judged again the frame this changes for any of its rigs.
inline uintptr_t rigLaneKey(const SkinnedMesh& rig) {
const double* e = rig.matrixWorld.elements.data();
return reinterpret_cast<uintptr_t>(skinnedLaneReason(rig)) * 4 + (isSimilarityTransform(e) ? 2 : 0) +
(rig.matrixWorld.determinant() > 0 ? 1 : 0);
}

// batchFlagsOf: layers, castShadow, receiveShadow, frustumCulled in one key.
inline double batchFlags(const Mesh& m) {
return double(m.layers().mask) * 8 + (m.castShadow() ? 4 : 0) + (m.receiveShadow() ? 2 : 0) + (m.frustumCulled ? 1 : 0);
Expand Down Expand Up @@ -390,6 +399,38 @@ inline std::shared_ptr<BufferGeometry> canonicalGeometry(const std::shared_ptr<B
return contentClassOf(geometry).canonical.lock();
}

// One number that changes whenever anything the projection's stability proof reads off the engine
// changes (projection-stability.ts): the walk order, each node's visibility, and for a mesh its
// geometry, material, flags, render order and layer mask. It walks the way that proof walks, so a
// light or LOD hides its children. A hash, so a match is a claim at 2^-53 and a mismatch only sends
// the caller to its own exact walk.
inline double structureStamp(Object3D& root) {
uint64_t h = 0xcbf29ce484222325ULL;
const auto mix = [&h](uint64_t v) {
h = (h ^ v) * 0x100000001b3ULL;
h ^= h >> 29;
};
std::vector<const Object3D*> stack(root.children.rbegin(), root.children.rend());
while (!stack.empty()) {
const Object3D* object = stack.back();
stack.pop_back();
const auto* mesh = dynamic_cast<const Mesh*>(object);
mix(object->id() * 2 + (object->visible() ? 1 : 0));
if (mesh != nullptr) {
mix(reinterpret_cast<uintptr_t>(mesh->geometry.get()));
mix(reinterpret_cast<uintptr_t>(mesh->material.get()));
mix(static_cast<uint64_t>(mesh->castShadow()) | static_cast<uint64_t>(mesh->receiveShadow()) << 1 |
static_cast<uint64_t>(mesh->frustumCulled) << 2);
mix(static_cast<uint64_t>(static_cast<int64_t>(mesh->renderOrder())));
mix(std::bit_cast<uint64_t>(mesh->layers().mask));
}
mix(object->children.size());
if (object->isLight() || object->type() == "LOD") continue;
for (auto it = object->children.rbegin(); it != object->children.rend(); ++it) stack.push_back(*it);
}
return static_cast<double>(h & ((1ULL << 53) - 1));
}

/**
* One frame's projection decision for `scene`, as a fresh SceneRenderProjection reconcile makes it.
* `skinnedVerdictsOnly` records a per-object verdict for rigs alone: the renderer asks no other object, and
Expand Down
Loading
Loading