diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5a8774bdac..c2ab6e7e9a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -619,7 +619,7 @@ jobs: fog-native: name: Linux native fog correctness needs: [fog] - # Reuse the proven native-platforms / native-assets ARM QuickJS route without changing defaults. + # Use the shipped ARM V8/wgpu default for native fog correctness. runs-on: ubuntu-24.04-arm timeout-minutes: 60 env: @@ -810,7 +810,7 @@ jobs: name: Linux native fluid correctness if: ${{ needs.paths.outputs.fluid-native == 'true' && !github.event.pull_request.draft }} needs: paths - # Established native-platforms ARM QuickJS/wgpu route; no default backend change. + # Use the shipped ARM V8/wgpu default for native fluid correctness. runs-on: ubuntu-24.04-arm timeout-minutes: 60 env: @@ -888,8 +888,7 @@ jobs: name: packaged native asset fallback if: ${{ !github.event.pull_request.draft && needs.paths.outputs.native-assets == 'true' }} needs: paths - # Match native-platforms starter-linux: this architecture selects QuickJS/wgpu without - # changing any runtime default. Native dependencies use the repository's locked provisioner. + # This fallback proof explicitly selects QuickJS; the ARM desktop default is V8. runs-on: ubuntu-24.04-arm timeout-minutes: 60 env: @@ -934,8 +933,12 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev - - name: Build the same QuickJS host the normal ARM desktop lane ships - run: pnpm --filter @threenative/runtime-native native:build + - name: Build the QuickJS host required by the packaged fallback + run: | + pnpm --filter @threenative/runtime-native native:build + node packages/runtime-native/scripts/download-deps.mjs --only quickjs + cmake -S packages/runtime-native -B packages/runtime-native/build/tn-linux -DMYSTRAL_USE_V8=OFF -DMYSTRAL_USE_QUICKJS=ON + cmake --build packages/runtime-native/build/tn-linux --target mystral --parallel 2 - name: Build the real fixture and capture its packaged native executable env: THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index 2a0a2a3f90..c182dc57c5 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -19,6 +19,11 @@ on: required: false type: boolean default: false + build_linux_arm64_v8: + description: "Build and cache the Linux arm64 V8 source payload (up to 210 minutes)" + required: false + type: boolean + default: false workflow_call: inputs: selection_plan: @@ -94,10 +99,10 @@ jobs: # inputs there), so a row an ordinary pull request does not owe is dropped by shaping the # matrix instead. x64 and arm64 run the identical release-container proof; the arm64 leg is # GitHub's free hosted `ubuntu-24.04-arm` runner, because there is no V8 or Dawn prebuilt for - # Linux arm64 (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64) and a - # source V8 build is not allowed in CI — so that host is built with QuickJS over wgpu-native, - # the one WebGPU backend publishing an arm64 Linux archive, which scripts/download-deps.mjs - # and scripts/native-build.mjs select from `process.arch`. A reduced run keeps x64 alone, and + # Linux arm64 (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64). + # The ARM host consumes the locked source-built V8 payload over wgpu-native; dependency + # verification fails closed rather than building V8 in a consumer lane. + # A reduced run keeps x64 alone, and # x64 routes through the same TN_RUNNER switch every other movable Linux job uses (PRD-480). - id: rows env: @@ -254,6 +259,178 @@ jobs: --notes "Built Android V8 payloads, one asset per recipe hash, for CI to download instead of rebuilding. Not a product release." gh release upload ci-v8-android "$ASSET" --repo "$GITHUB_REPOSITORY" --clobber + # PRD-615 phase 1: opt-in cache producer; starter selection changes in the consumer phase. + linux-arm64-v8-source: + name: Linux arm64 V8 source payload + needs: scope + if: github.event_name == 'workflow_dispatch' && inputs.build_linux_arm64_v8 == true && needs.scope.outputs.selection == 'full' && needs.scope.outputs.native_tier != 'reduced' && inputs.ios_only != true + runs-on: ubuntu-24.04-arm + timeout-minutes: 210 + permissions: + contents: read + outputs: + recipe-hash: ${{ steps.recipe.outputs.hash }} + published: ${{ steps.published.outputs.hit }} + env: + TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.scope.outputs.candidate_sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@v7 + with: + node-version: 20 + - name: Compute the Linux arm64 V8 recipe hash + id: recipe + env: + RECIPE_HASH: >- + ${{ hashFiles( + 'packages/runtime-native/scripts/build-linux-arm64-v8.mjs') }} + run: echo "hash=$RECIPE_HASH" >> "$GITHUB_OUTPUT" + - name: Download the published Linux arm64 V8 payload + id: published + env: + GH_TOKEN: ${{ github.token }} + ASSET: v8-linux-arm64-${{ steps.recipe.outputs.hash }}.tgz + run: | + set -euo pipefail + if gh release download ci-v8-linux-arm64 --repo "$GITHUB_REPOSITORY" --pattern "$ASSET" \ + --dir "$RUNNER_TEMP" --clobber; then + mkdir -p packages/runtime-native/third_party/v8-linux-arm64 + tar -xzf "$RUNNER_TEMP/$ASSET" -C packages/runtime-native/third_party/v8-linux-arm64 + node packages/runtime-native/scripts/build-linux-arm64-v8.mjs --verify + echo "Published Linux arm64 V8 asset hit: $ASSET; no rebuild." + echo "hit=true" >> "$GITHUB_OUTPUT" + else + echo "No published $ASSET; falling back to cache and source build." + echo "hit=false" >> "$GITHUB_OUTPUT" + fi + - name: Restore the Linux arm64 V8 payload + id: payload + if: steps.published.outputs.hit != 'true' + uses: actions/cache/restore@v4 + with: + path: packages/runtime-native/third_party/v8-linux-arm64 + key: linux-arm64-v8-payload-${{ runner.os }}-${{ runner.arch }}-${{ steps.recipe.outputs.hash }} + - name: Restore resumable Linux arm64 V8 source + if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true' + uses: actions/cache/restore@v4 + with: + path: | + packages/runtime-native/third_party/.v8-linux-arm64-source + packages/runtime-native/third_party/v8-linux-arm64 + key: linux-arm64-v8-source-${{ runner.os }}-${{ runner.arch }}-${{ steps.recipe.outputs.hash }}-${{ github.run_id }}-${{ github.run_attempt }} + # Exact completed compilation from the packaging-only LICENSE repair; no broad fallback. + restore-keys: | + linux-arm64-v8-source-${{ runner.os }}-${{ runner.arch }}-${{ steps.recipe.outputs.hash }}- + linux-arm64-v8-source-Linux-ARM64-e6cd6d572634333391f1f0a607f71a51a9d3ead5b94a6545649654ee48e2f90d-38711129527-1 + - name: Install the pinned Linux arm64 V8 compiler + if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true' + run: | + sudo apt-get update + sudo apt-get install -y --allow-downgrades clang-18=1:18.1.3-1ubuntu1 lld-18=1:18.1.3-1ubuntu1 llvm-18=1:18.1.3-1ubuntu1 libclang-rt-18-dev=1:18.1.3-1 ninja-build build-essential python3 git pkg-config libglib2.0-dev + pkg-config --modversion glib-2.0 gmodule-2.0 gobject-2.0 gthread-2.0 + # The adjacent Linux runtime config links libatomic when using the system C++ library. + printf 'int main() { return 0; }\n' | /usr/lib/llvm-18/bin/clang++ -x c++ - -latomic -o "$RUNNER_TEMP/v8-linux-link-preflight" + - name: Build the Linux arm64 V8 payload + id: build + if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true' + env: + # Fixed destination of the one-off packaging repair, never derived at runtime. + THREENATIVE_V8_PACKAGING_REPAIR_SCRIPT_SHA: cf3a8edabe1fd91691285d9402d3cec716a8723ff32b92789a3b04b509712beb + run: | + set -euo pipefail + export THREENATIVE_V8_BUILD_JOBS="${THREENATIVE_V8_BUILD_JOBS:-$(nproc)}" + timeout --signal=TERM --kill-after=30s 180m \ + node packages/runtime-native/scripts/build-linux-arm64-v8.mjs + - name: Verify and package the Linux arm64 V8 payload + id: verify + env: + ASSET: v8-linux-arm64-${{ steps.recipe.outputs.hash }}.tgz + run: | + set -euo pipefail + node packages/runtime-native/scripts/build-linux-arm64-v8.mjs --verify + mkdir -p "$RUNNER_TEMP/linux-arm64-v8-proof" + mapfile -t libraries < <(find packages/runtime-native/third_party/v8-linux-arm64 -name libv8_monolith.a -type f) + test "${#libraries[@]}" -eq 1 + library="${libraries[0]}" + ar t "$library" > "$RUNNER_TEMP/linux-arm64-v8-proof/ar-members.txt" + member="$(sed -n '1p' "$RUNNER_TEMP/linux-arm64-v8-proof/ar-members.txt")" + test -n "$member" + ar p "$library" "$member" > "$RUNNER_TEMP/v8-arm64-member.o" + file "$RUNNER_TEMP/v8-arm64-member.o" | tee "$RUNNER_TEMP/linux-arm64-v8-proof/file.txt" + readelf -h "$RUNNER_TEMP/v8-arm64-member.o" | tee "$RUNNER_TEMP/linux-arm64-v8-proof/elf-header.txt" + grep -Eq 'Machine:.*AArch64' "$RUNNER_TEMP/linux-arm64-v8-proof/elf-header.txt" + if [ "${{ steps.published.outputs.hit }}" != 'true' ]; then + tar -czf "$RUNNER_TEMP/$ASSET" -C packages/runtime-native/third_party/v8-linux-arm64 . + fi + (cd "$RUNNER_TEMP" && sha256sum "$ASSET") > "$RUNNER_TEMP/linux-arm64-v8-proof/payload.sha256" + - name: Save the verified Linux arm64 V8 payload + if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true' && steps.verify.outcome == 'success' + uses: actions/cache/save@v4 + with: + path: packages/runtime-native/third_party/v8-linux-arm64 + key: linux-arm64-v8-payload-${{ runner.os }}-${{ runner.arch }}-${{ steps.recipe.outputs.hash }} + - name: Save interrupted Linux arm64 V8 source + if: >- + always() && steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true' && + (steps.build.outcome == 'failure' || + (steps.build.outcome == 'cancelled' && github.event_name != 'pull_request')) + uses: actions/cache/save@v4 + with: + path: | + packages/runtime-native/third_party/.v8-linux-arm64-source + packages/runtime-native/third_party/v8-linux-arm64 + key: linux-arm64-v8-source-${{ runner.os }}-${{ runner.arch }}-${{ steps.recipe.outputs.hash }}-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/upload-artifact@v7 + with: + name: linux-arm64-v8-${{ needs.scope.outputs.candidate_sha }} + path: | + ${{ runner.temp }}/v8-linux-arm64-${{ steps.recipe.outputs.hash }}.tgz + ${{ runner.temp }}/linux-arm64-v8-proof + if-no-files-found: error + retention-days: 2 + + # Release writes are isolated from candidate source and restricted to trusted refs. + publish-linux-arm64-v8: + name: Publish the Linux arm64 V8 payload + needs: [scope, linux-arm64-v8-source] + if: >- + needs.linux-arm64-v8-source.outputs.published == 'false' && + github.event_name != 'pull_request' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v7 + with: + name: linux-arm64-v8-${{ needs.scope.outputs.candidate_sha }} + path: payload + - name: Publish the exact verified Linux arm64 V8 tarball + env: + GH_TOKEN: ${{ github.token }} + ASSET: v8-linux-arm64-${{ needs.linux-arm64-v8-source.outputs.recipe-hash }}.tgz + TARGET: ${{ needs.scope.outputs.candidate_sha }} + run: | + set -euo pipefail + (cd payload && sha256sum --check linux-arm64-v8-proof/payload.sha256) + gh release view ci-v8-linux-arm64 --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || + gh release create ci-v8-linux-arm64 --repo "$GITHUB_REPOSITORY" --target "$TARGET" \ + --prerelease --latest=false --title "CI cache: Linux arm64 V8 payload" \ + --notes "Linux arm64 V8 source payloads keyed by recipe hash. Not a product release." + if gh release view ci-v8-linux-arm64 --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' | grep -Fx "$ASSET"; then + mkdir -p "$RUNNER_TEMP/published-v8" + gh release download ci-v8-linux-arm64 --repo "$GITHUB_REPOSITORY" --pattern "$ASSET" \ + --dir "$RUNNER_TEMP/published-v8" + cmp -- "payload/$ASSET" "$RUNNER_TEMP/published-v8/$ASSET" + else + gh release upload ci-v8-linux-arm64 "payload/$ASSET" --repo "$GITHUB_REPOSITORY" + fi + performance-coverage: name: Native collector evidence coverage needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator] @@ -2009,6 +2186,80 @@ jobs: TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS=30000 \ TN_PLAYTEST_ALLOW_SOFTWARE=1 \ pnpm --dir "$target" test:native + - name: Compare paired starter JS frame duration on Linux ARM64 + if: matrix.platform == 'linux-arm64' + shell: bash + env: + TN_PLAYTEST_ALLOW_SOFTWARE: "1" + run: | + set -euo pipefail + runtime="$PWD/packages/runtime-native" + target="$RUNNER_TEMP/threenative-starter-native" + evidence="$PWD/packages/runtime-native/artifacts/starter-linux-arm64/js-frame-pair" + mkdir -p "$evidence" + # Keep the V8 host before reconfiguring this same build: every non-engine feature + # (including Canvas2D and the web UI library) stays in the same CMake cache. + cp "$runtime/build/tn-linux/mystral" "$runtime/build/tn-linux/mystral-v8" + cp "$runtime/build/tn-linux/CMakeCache.txt" "$evidence/v8-CMakeCache.txt" + mapfile -t feature_flags < <(sed -n 's/^\(TN_ENABLE_[^:]*\):[^=]*=\(.*\)$/-D\1=\2/p' "$evidence/v8-CMakeCache.txt") + restore_v8() { + cp "$runtime/build/tn-linux/mystral-v8" "$runtime/build/tn-linux/mystral" + cmake --preset tn-linux -S "$runtime" -DMYSTRAL_USE_V8=ON -DMYSTRAL_USE_QUICKJS=OFF \ + -DMYSTRAL_USE_DAWN=OFF -DMYSTRAL_USE_WGPU=ON "${feature_flags[@]}" + } + trap restore_v8 EXIT + ( + cd "$runtime" + cmake --preset tn-linux -DMYSTRAL_USE_V8=OFF -DMYSTRAL_USE_QUICKJS=ON \ + -DMYSTRAL_USE_DAWN=OFF -DMYSTRAL_USE_WGPU=ON "${feature_flags[@]}" + cmake --build build/tn-linux --target mystral --parallel 2 + ) + cp "$runtime/build/tn-linux/CMakeCache.txt" "$evidence/quickjs-CMakeCache.txt" + # Fail if selecting an engine changed a feature rather than comparing unlike hosts. + diff <(sed -n '/^TN_ENABLE_.*:/p' "$evidence/v8-CMakeCache.txt" | sort) \ + <(sed -n '/^TN_ENABLE_.*:/p' "$evidence/quickjs-CMakeCache.txt" | sort) + grep -qx 'TN_ENABLE_CANVAS2D:BOOL=ON' "$evidence/quickjs-CMakeCache.txt" + # Pin the existing resolution override in this temporary scaffold for BOTH arms. + # Cook and bundle once for QuickJS; both engines use the same prepared assets. + cp "$target/threenative.config.ts" "$evidence/original-config.ts" + # The cook replaces the consumer whose exact bytes the starter receipt already verified. + cp -p "$target/dist-native/threenative-starter-native" "$evidence/verified-consumer" + trap 'cp -p "$evidence/verified-consumer" "$target/dist-native/threenative-starter-native"; cp "$evidence/original-config.ts" "$target/threenative.config.ts"; restore_v8' EXIT + sed -i 's/resolutionScale: "auto"/resolutionScale: 1/' "$target/threenative.config.ts" + THREENATIVE_RUNTIME_BINARY="$runtime/build/tn-linux/mystral" \ + pnpm --dir "$target" exec threenative build --target desktop + cp "$target/.threenative/build/game.js" "$target/dist-native/perf-game.js" + cp "$target/public/assets.manifest.json" "$evidence/assets.manifest.json" + cp "$target/threenative.config.ts" "$evidence/paired-config.ts" + cp "$evidence/original-config.ts" "$target/threenative.config.ts" + # Hosted software Vulkan is intentional here: JS callback durations only, no FPS/GPU claim. + sh scripts/xvfb.sh node "$runtime/scripts/measure-starter-js-frame-pair.mjs" \ + "$runtime/build/tn-linux/mystral" "$runtime/build/tn-linux/mystral-v8" \ + "$target/dist-native/perf-game.js" "$evidence" + # Separate visual proof uses the shipped deterministic scenario (seed 90210), + # not elapsed wall time. Preserve full reports for camera/tick/scene comparison. + for engine in quickjs v8; do + binary="$runtime/build/tn-linux/mystral" + [ "$engine" != v8 ] || binary="$runtime/build/tn-linux/mystral-v8" + for capture in 1 2; do + mkdir -p "$evidence/same-pose/$engine-$capture" + sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js \ + "$target/native-playtests/render-chain.playtest.json" --target desktop \ + --project "$target/public" --executable "$binary" \ + --host-arg run --host-arg "$target/dist-native/perf-game.js" \ + --host-arg --ui --host-arg "$target/dist-native/ui" \ + --artifacts "$evidence/same-pose/$engine-$capture" \ + > "$evidence/same-pose/$engine-$capture/report.json" \ + 2> "$evidence/same-pose/$engine-$capture/runner.stderr.log" + done + done + - name: Upload paired starter JS frame evidence + if: always() && matrix.platform == 'linux-arm64' + uses: actions/upload-artifact@v7 + with: + name: native-starter-js-frame-pair-linux-arm64 + path: packages/runtime-native/artifacts/starter-linux-arm64/js-frame-pair/ + if-no-files-found: error # PRD-365 acceptance criterion 1, Linux flavour of the desktop release container: tar.gz, no # zip, and the verifier finds the packaged xvfb.sh itself. The debug step above is untouched. - name: Build the desktop release container @@ -2434,7 +2685,7 @@ jobs: completion: name: Native required coverage receipt - needs: [scope, web-reference, android-v8-source, android-emulator-parity, desktop-parity, release-reports, performance-coverage, networking-matrix, desktop, starter-linux, ios-simulator, publish-android-v8] + needs: [scope, web-reference, android-v8-source, android-emulator-parity, desktop-parity, release-reports, performance-coverage, networking-matrix, desktop, starter-linux, ios-simulator, publish-android-v8, linux-arm64-v8-source, publish-linux-arm64-v8] # Isolated iOS dispatch remains advisory and emits no full qualification receipt. if: ${{ always() && inputs.ios_only != true }} runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} diff --git a/docs/PRDs/done/PRD-615-v8-on-linux-arm64.md b/docs/PRDs/done/PRD-615-v8-on-linux-arm64.md new file mode 100644 index 0000000000..667bcc6229 --- /dev/null +++ b/docs/PRDs/done/PRD-615-v8-on-linux-arm64.md @@ -0,0 +1,176 @@ +# PRD-615 — V8 on the linux-arm64 native host + +**Status:** COMPLETE — implementation and both acceptance criteria are verified: cached V8 startup, 300 non-blank ARM64 frames, strict six-run improvement and same-pose visual acceptance. Final-head full CI, required joins, PR readiness and merge queue remain pending delivery gates; no whole-workflow green result is claimed. +**Priority:** P1 — supported linux-arm64 engine selection and measured improvement; the filed scope is now complete. +**Complexity:** 6 (MEDIUM) — about 6 implementation files, a new V8 build recipe, a CI build-and-cache boundary; risk override: none. +**Owner:** João +**Depends on:** PRD-614 arm64 dependency pins ([PR #507](https://github.com/ThreeNativeHQ/threenative-engine/pull/507)); the four prerequisite dependency/build files are inherited from its published commit `a8b355190`. + +## Context + +Before this change, the linux-arm64 desktop host ran QuickJS over wgpu-native (`packages/runtime-native/scripts/download-deps.mjs:1548`). +Every other desktop host runs V8 13.1.201.22 from `kuoruan/libv8`, which publishes no Linux arm64 +archive (`native-deps.lock.json`, `v8-linux-x64`). The comment in `native-platforms.yml:96` says a +source V8 build is not allowed in CI, but the same workflow already builds Android V8 from source +once per recipe and caches the payload in the `ci-v8-android` release +(`native-platforms.yml:180-255`, `.github/actions/android-v8-source`, `scripts/build-android-v8.mjs`). + +João, 2026-10-14: "i dont like QuickJS. V8 is way more performant." Upstream MystralNative also +ships linux-arm64 with QuickJS only (see [PRD-614](https://github.com/ThreeNativeHQ/threenative-engine/pull/507)), +so this is fork-owned work with no upstream to port. + +## Solution + +Build V8 13.1.201.22 for linux-arm64 with the same build-once, cache-by-recipe pattern as Android. +Then let the arm64 host select V8. + +```mermaid +flowchart LR + A[recipe hash] -->|cache miss| B[linux-arm64-v8-source job on ubuntu-24.04-arm] + B --> C[ci-v8-linux-arm64 release asset] + A -->|cache hit| C + C --> D[download-deps.mjs on arm64] + D --> E[native-build.mjs: V8 + wgpu] + E --> F[linux-arm64 leg: starter 300 frames] +``` + +- **Recipe.** Same V8 version, same `v8_monolith` shape and same GN flags as the x64 archive, so + the headers and ABI the runtime compiles against do not change. Build natively on + `ubuntu-24.04-arm`, not by cross-compile. Follow the Android producer's fetch, integrity, + transactional promotion and recipe-hash pattern while keeping its script and cache unchanged. +- **Cache.** A `linux-arm64-v8-source` job in `native-platforms.yml` runs only on a recipe-hash + miss and uploads `v8-linux-arm64-.tgz` to a `ci-v8-linux-arm64` release, as the + Android job does. No new workflow file. +- **Select.** `download-deps.mjs` fetches that asset on arm64. `native-build.mjs` stops forcing + QuickJS on arm64. Other targets keep QuickJS where they use it today. + +Risks: a native V8 build on a hosted runner can exceed its time limit. Measure the first run. If it +does not fit, cross-compile on the x64 pool with `target_cpu="arm64"` and a Debian arm64 sysroot. +The hosted arm64 runner has no GPU, so the speed comparison measures JS frame time, not GPU time. + +## Acceptance Criteria + +- [x] AC-1 [shared]: The linux-arm64 starter boots on V8 and presents 300 non-blank frames. proof: current run `38784079933`, arm64 job `116591638231`, starter artifact `11998526511`: report passes 300 frames, 13,139 colors, 187 cyan and 201 magenta asset pixels, with overlay; raw runtime identifies V8 `13.1.201.22`. Screenshot SHA-256 `5bcd522ba2cd23a5ccdacf685fb64bfe0d324ddbcbba42424c09b74f94357ed3`; raw proof `/tmp/prd615-epoch-arm-starter`. The same published archive is consumed; source-cache reuse was proven in run `38728195060`. +- [x] AC-2 [shared]: On the same arm64 runner, the starter's median JS frame time on V8 is lower than on QuickJS, across paired runs. proof: run `38784079933` at `c312cee5a9ad5bdf60da347bfd89423af0022c38`, strict six-run comparison PASS: QuickJS 30.42/31.05/30.70 ms (median 30.70), V8 6.08/6.17/6.06 ms (median 6.08); twelve steady windows report `compiling=false` with the same bundle, features and adapter. Four actual aligned ARM captures pass seven assertions each; fresh read-only visual judge is NEUTRAL / PASS overall, for both QuickJS/V8 pairs and both within-engine repeat comparisons (ARM software Vulkan only). + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| V8 on linux-arm64 | `pnpm native:build` on arm64 → `scripts/native-build.mjs` engine selection → `mystral` binary | QuickJS stops being the arm64 default; QuickJS stays where other targets use it | AC-1 | +| Cached arm64 V8 payload | `native-platforms.yml` recipe-hash check → `ci-v8-linux-arm64` release → `download-deps.mjs` | New; same pattern as `ci-v8-android` | AC-1 | + +## Decisions + +- 2026-10-14 (João): linux-arm64 uses V8, not QuickJS, for speed. +- 2026-10-15: the pinned V8 toolchain downloads Linux x64 binaries, so the native arm64 producer uses the DEPS-pinned `gn/gn/linux-arm64` CIPD binary (exact SHA-256 and AArch64 ELF checked, native `--version` required before generation), plus Ubuntu arm64 LLVM 18.1.3 packages at exact version `1:18.1.3-1ubuntu1`, recorded in its receipt. The producer is an explicit manual-dispatch auxiliary job; ordinary product qualification retains its existing inventory. Linux keeps its small fetch/integrity helpers local because modifying the Android build script invalidates its expensive cache. + +## Execution Phases + +#### Phase 1: arm64 V8 payload +**Status:** COMPLETE — source compilation, full payload/receipt/header audit, immutable publication and the second release-cache hit passed in runs `38720103918` and `38728195060`; compiler installation and source compilation were skipped on the cache hit. +Cold run [38699446987](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38699446987), source job `116303925220`, fetched the pinned sources and installed the exact compiler packages, then failed before compilation: depot_tools shadowed distro Ninja with its uninitialized launcher. Both GN bootstrap and V8 compilation now call `/usr/bin/ninja` explicitly; the 20 producer tests pass. A real distro Ninja 1.11.1 command-selection probe in an isolated Debian container passed both calls despite a poisoned PATH whose generic Ninja exited 88. No payload was produced or published. Retry [38701515417](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38701515417), source job `116310671878`, compiled all 295 GN bootstrap targets, then failed copying over the read-only CIPD launcher. Bounded replan found upstream already provides the pinned native GN: exact V8 DEPS selects `gn/gn/linux-${arch}` at revision `95b0f8fe31a992a33c040bbe3867901335c12762`. Its official arm64 ZIP SHA-256 is `c6d06639cb71ae9771b4e5881e387476293c2bf47c64645d2ce1260a0509dc0b` (3,245,617 bytes); extracted GN is AArch64 ELF64 with SHA-256 `31739b62d88843b32726c4ace0c93b36b65efbcba89bb033891d454766d9e689`. Recipe 2 removes the unused GN source bootstrap/overwrite, fixes relative `custom_deps` keys, and verifies the native DEPS GN binary/hash/version before use. All V8/build license files remain; GN is a build tool and is not distributed. Run [38702992130](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38702992130), source job `116315553882`, passed native GN identity checks and reached GN generation, then failed because GLib development pkg-config modules were absent. The Linux source job now installs `libglib2.0-dev` alongside pkg-config, records their exact installed package version/architecture and all four GLib module versions, and checks the adjacent system-C++ `libatomic` link prerequisite before building. Pinned Linux GN configs expose no other pkg-config modules in that adjacent compiler path. Subsequent run [38705493315](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38705493315), source job `116324390005`, generated 565 GN targets successfully but Ninja stopped before compilation: Chromium defaults expected Clang 20 builtins under its target-triple layout, while the pinned compiler is Ubuntu Clang 18. The producer now declares Clang 18 and maps a private SDK directory to the exact compiler, resource headers and pinned compiler-rt 18 archive; V8 source files and features are unchanged. The official runtime package `libclang-rt-18-dev=1:18.1.3-1` contains 278 verified AArch64 objects with archive SHA-256 `af24a6be2920609e5dea1218caad93a17885497e764cef2dc78512fdec159e98`. The installed compiler-rt package copyright ships as a hashed build-dependency notice; embedding is not assumed and the actual monolith must be audited before publication. Its receipt identity is mandatory; native compilation/linking/execution of a tiny builtins caller must pass before GN/Ninja. Run [38709487399](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38709487399), source job `116337148007`, stopped earlier at APT: the hosted image already contained newer compiler-rt, disproving the pristine-image assumption. The solver planned exactly one compiler-rt downgrade and no removals; the exact-version install now explicitly allows that downgrade. Producer recipe bytes remain unchanged; the SDK adapter has not executed on arm64 yet. Cancellation was requested for superseded own failed-source runs `38699446987`, `38701515417`, `38702992130`, `38705493315` and `38709487399` to release unused Android/Windows chains; all five source logs are retained, the first four saved their source cache, and the fifth stopped before source modification. No PRD-614 run was cancelled. Source contracts pass 24 tests; native preflight and full V8 compilation still need a fresh cold run. +The published PRD-614 prerequisite files are inherited byte-for-byte: `CMakeLists.txt`, `native-deps.lock.json`, `scripts/download-deps.mjs` and `tests/dependency-provenance.test.mjs`. The package's dependency-provenance spec passed 10 tests; version-pin, build-matrix and native-profile contracts passed 19 tests. The coherent PRD-614 native runtime prerequisite patch is also prepared locally: all 11 files byte-match its immutable destination blobs (patch SHA-256 `8062f0d25a3414839ab0eadf8a37ec42ca137cc8850254e299df7a804a9e44c9`), including the final per-frame binding/driver fixes and Dawn pin. The same 29 focused dependency/profile checks pass. The coherent runtime is included in PRD-614's published candidate `15d39`; both engines use the same inherited runtime bytes for final measurements. The seven related regression/conformance files and measured coverage record are inherited exactly from `bccd65166`; the coverage source/test digest matches all 240 inputs. The verified coverage-generator ratchet repair, its semantic regression test and canonical regenerated record are inherited from `1c908738e2`; the 240-input digest matches `4694f6c260feec4929114d04f323617c5d8b260073cdb3394f70e751c2122dce`. The recorded platform floor is now 63.81%; all seven root coverage-floor fixtures and three native digest-scope fixtures pass. Linux routing specs pass three tests. The earlier regression selection had 54 passes, two skips and four failures due to missing native binaries. After this checkout built its own fixtures, the same selection passed 59 tests with one skip; this does not claim arm64 runtime acceptance. +**Files:** `packages/runtime-native/scripts/build-linux-arm64-v8.mjs` (new, follows the Android producer pattern without changing its cache inputs), `.github/workflows/native-platforms.yml` +**Implementation:** Pin the recipe (version, source commit, GN args, recipe number). Add the build-and-upload job on a recipe-hash miss. +The source job requires `gh workflow run native-platforms.yml --ref feat/prd615-linux-arm64-v8 -f build_linux_arm64_v8=true`. On a feature branch it uploads only the verified tarball and SHA-256 artifact; publication is restricted to main/develop. The parent manually publishes those exact reviewed artifact bytes to `ci-v8-linux-arm64` before the second cache-hit dispatch. Existing recipe-named assets are immutable: identical bytes are reused and a mismatch fails. +- [x] Recipe builds a `v8_monolith` for aarch64 with the x64 archive's headers. proof: source job `116372375638` in [run 38720103918](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38720103918) passed; the actual V8 13.1.201.22 header and all 1,286 monolith members verify as AArch64. The complete 126-file receipt verifies locally. All 108 public `*.h` files have identical relative paths and SHA-256 values in the installed x64 archive and verified arm64 payload (zero missing, extra or differing headers). +- [x] A second run reuses the cached release asset with no rebuild. proof: verified run `38728195060`, source job `116399254006`: the job logged `Published Linux arm64 V8 asset hit: v8-linux-arm64-1632c350bb78a60721a092ab696f0b3d274dbb0e5b926ebdca2c1b5edb8bb262.tgz; no rebuild.` at `2026-10-15T11:34:19.0996589Z`; compiler installation and source compilation were skipped. + +#### Phase 2: Select V8 on arm64 +**Status:** COMPLETE — default V8 selection, fail-closed receipt/SBOM consumption and AC-1 startup are verified. Historical implementation and qualification record: local default selection and fail-closed locked-cache consumption are implemented; actual published payload descriptor is pinned locally. Fresh/cached downloader receipt and SBOM sidecar integration pass; canonical nested receipts take precedence after architecture changes. AC-1 is now proven by the corrected arm64 starter below; paired performance remains open. The first cached qualification (`38728195060`, arm64 job `116399253883`) failed before scaffolding at the final native executable links: V8 `isolate.o` and `wasm-code-manager.o` require `__atomic_compare_exchange`, but the consumer link omitted `libatomic`. Both `mystral` and `mystral-tools` failed; no starter, paired-performance or passing identity artifact was produced. The source cache-hit proof remains valid. The local correction appends `atomic` only to V8 system libraries on Linux `aarch64`/`arm64`, preserving x64 and Android. Executing the existing CMake system-library branch across five platform/architecture cases failed both ARM64 cases before the correction and passed all five after it; the native profile and registered-temporary-directory selection passed 12 tests. Full typecheck and lint pass. The broader focused selection initially passed 28 tests and correctly rejected the inherited coverage record as stale because the CMake input changed. Canonical `native:coverage` then passed with build parallelism two: 47 contract targets executed, three explicitly disabled targets retained, 19,470/24,835 lines covered (78.40%), and every retained floor unchanged. The generated record now carries digest `41cd2268ff5ae99a9e7a01b1353e376cff1113ad30646e32384d6cd1c3584dae`; freshness/profile/build-matrix/temp-cleanup/evidence-budget checks passed 38 tests, native coverage source fixtures passed nine tests, and `pnpm budgets` passed. No digest, measurement or floor was edited manually. Actual arm64 link/startup/performance acceptance remains open; the corrected full workflow has not run. +**Files:** `scripts/download-deps.mjs`, `scripts/native-build.mjs`, `native-deps.lock.json`, `packages/runtime-native/AGENTS.md` (all under `packages/runtime-native/` except where named) +**Implementation:** Add `v8` to the arm64 `desktopDeps`, resolved from the cached asset with its SHA-256. Drop the arm64 QuickJS override. Update the arm64 comments in `download-deps.mjs` and `native-platforms.yml`. +- [x] Engine-selection and pin specs pass. proof: version-pin/build-matrix/profile/qualification/paired-harness specs pass 54 tests; dependency provenance and SBOM specs pass 16 tests. The actual released archive installs through the downloader with one locked fetch and reuses its verified cache with zero fetches; SBOM includes `v8-linux-arm64`. This is acquisition proof on the x64 development host with the platform selector isolated, not an arm64 runtime claim. +- [x] AC-1 arm64 starter on V8. proof: current run `38784079933`, arm64 job `116591638231`, starter artifact `11998526511` passes 300 non-blank frames, 13,139 colors, 187 cyan and 201 magenta pixels, with overlay; raw log identifies V8 `13.1.201.22` (raw proof `/tmp/prd615-epoch-arm-starter`). + +#### Phase 3: Speed proof +**Status:** COMPLETE — run `38784079933` at `c312cee5a9ad5bdf60da347bfd89423af0022c38` passes the strict same-VM three-pair comparison (QuickJS median 30.70 ms, V8 median 6.08 ms); four aligned ARM captures pass the actual pose audit and fresh read-only visual judgement (NEUTRAL / PASS, ARM software Vulkan scope). +**Files:** `.github/workflows/native-platforms.yml` (paired perf step in the arm64 leg) +**Implementation:** Build both engines in one job and run the same starter scenario on each, alternating, at least three runs per engine. +- [x] AC-2 V8 beats QuickJS on JS frame time. proof: run `38784079933`, unchanged strict paired collector PASS across three runs per engine: 30.70 ms QuickJS median versus 6.08 ms V8 median; actual same-pose audit PASS and fresh visual judgement NEUTRAL / PASS, scoped to ARM software Vulkan. + + +## Historical execution record + +The following records retain earlier failures and the pending state at each checkpoint. They do not override the current acceptance results below. + +The sixth producer attempt (`38711129527`, source job `116342598739`) completed all 1,624 Ninja targets, including the V8 monolith, after native SDK preflight and GN generation. Packaging then failed because the pinned Chromium build checkout has no root `LICENSE`. The required build notice now comes from its owning Chromium revision `054463d932e1883832422ab7280c71899cde8ae7`, verified as 1,536 bytes and SHA-256 `368cca1106be99d39ecd32a38d8305585d802a475effb66380b91ffc9bcf709b`. A one-off migration accepts only the exact saved attempt cache and old/new producer script hashes with every other build-state field identical; native identity checks, SDK preflight, GN and Ninja rerun. The successful follow-up producer, verified receipt and cache publication are recorded below. Runtime acceptance criteria remain open. + + +The corrected source job `116372375638` succeeded in run `38720103918`: it restored the exact previous compiled-source cache, regenerated 565 GN targets and reported `ninja: no work to do`. Root independently verified the downloaded artifact. The exact 18,745,524-byte archive is published as release asset `643500847` in [`ci-v8-linux-arm64`](https://github.com/ThreeNativeHQ/threenative-engine/releases/tag/ci-v8-linux-arm64), with SHA-256 `ac379519d7e26039831ee7945018c67b48d98e30d04fe760bf5c684d61b82081`; a fresh remote download matches. The tag matches none of the npm/native product-release triggers, and no workflow subscribes to release publication. The payload receipt SHA-256 is `dbec689e6213490b1a581b27cc44fe86c5e6e54dfba2fb1235902f3cc074ab11`. All six required notices are included; the pinned compiler-rt archive's 278 objects share no member names or identical object bytes with the 1,286-object monolith, so its copyright is described as a build dependency notice. A second producer release-cache-hit workflow and actual arm64 starter/performance proof remain pending. The earlier x64 desktop UI budget run failed at 2.14 ms p95 against the unchanged below-2-ms requirement; the subsequent desktop parity job `116374639053` in run `38720103918` passed. The historical failure is retained without a gate waiver. + + +Local full `pnpm typecheck` and `pnpm lint` pass after building the checkout's missing workspace outputs. This checkout built its own V8/Dawn host plus native contract targets (502 tasks) and QuickJS host/two required contract targets (411 tasks), sequentially with two workers, Canvas2D on and native web UI/physics off. The previously missing-binary regression selection now passes 59 tests with one skip; this is x64 fixture proof, not arm64 starter or performance acceptance. For the pending visual judge, the ARM64 job separately runs the existing starter `native-playtests/render-chain.playtest.json` twice per engine on the same measured bundle, seeded scene and default fixed-step harness clock. Full reports/screenshots retain the actual camera, scene and recorded tick observations for comparison; BEFORE/AFTER labels, observed same-pose verification and a fresh judge remain pending. These four captures replace wall-time screenshot launches and do not alter the six performance launches. + + +Fresh read-only review of local candidate `4bd73df426` found that warm V8 cache validation did not enforce the lock's raw producer receipt SHA-256. Root cancelled its still-running full suite (`93125`, exit 130) to repair this real defect; that interrupted run is not a passing gate. The downloader now requires the selected payload's 64-hex receipt pin and exact raw `build-receipt.json` hash on fresh and warm paths before the existing full producer verification. A regression first reproduced acceptance of a substituted AArch64 object plus recomputed self-consistent receipt; it now fails before any fetch. Fresh acquisition also rejects absent, malformed or incorrect pins. Dependency-provenance/SBOM tests pass 16 tests; required full typecheck and lint pass after the repair. The real published archive also passes the new warm receipt-pin check with zero fetches. Producer bytes, workflow, native feature criteria and published asset are unchanged; the new full suite and arm64 acceptance remain pending. + + +Root's subsequent full suite at `339c3540b` completed with six failures (10,623 passes, 20 skips): five synthetic CI receipts still named the former ARM64 QuickJS default, while the strict production verifier correctly required V8; the temp-directory guard also found two test files bypassing the registered cleanup helper. The smallest failing selection reproduced all six failures (85 passes). The shared synthetic receipt now names V8, and both test files use the existing `makeTempDirSync` cleanup registration without a guard exception. Production receipt verification, producer bytes, workflow, runtime pins and acceptance criteria are unchanged. The focused CI/producer/temp selection passes 119 tests; dependency-provenance/SBOM tests pass 16 tests. Required full typecheck and lint pass after this fixture repair. The repaired full-suite result is pending. + + +The corrected frozen local candidate `c0bef30f7cd97cb5abe778882c042358b1797ff3` passed root's complete `pnpm test` run: 779 root test files passed (three skipped), 10,629 tests passed (20 skipped), 552.95 seconds; the native package passed 1,591 tests (62 skipped), and physics parity passed 29 tests. Suite-owned temporary directory counts stayed at one before and after. Fresh read-only review of the repaired production receipt path reported no actionable defects, and root reviewed the final fixture-only correction. Full typecheck and lint pass. These local gates permit cached qualification; they do not prove either arm64 runtime acceptance criterion. The first cached native workflow `38728195060` demonstrated the release-cache hit but failed the arm64 consumer link as described above. Corrected qualification must still demonstrate the V8 starter's 300 non-blank frames, three paired runs per engine on one arm64 VM with a lower V8 median, and fresh same-pose visual acceptance. + + +The frozen atomic-link candidate `574779a98b0da5ac741553aaf77c84d945cc1005` passed root's complete `pnpm test` run in 535.16 seconds: 779 root files passed (three skipped), 10,634 tests passed (20 skipped), 1,591 native tests passed (62 skipped), and 29 physics parity tests passed. Suite-owned temporary directories remained one before and after. Fresh bounded Codex review reported no actionable defects. Canonical native coverage passed at 78.40% with retained floors unchanged and current digest `41cd2268ff5ae99a9e7a01b1353e376cff1113ad30646e32384d6cd1c3584dae`. At that checkpoint, the corrected arm64 link, starter, performance and visual proof remained pending the next cached qualification. + + +Corrected qualification `38733537384` at `6f19dcb7f58e34b1af76f38433d498df8a03d531` cleared both native executable links and passed the V8 starter gate above. Its first QuickJS performance launch failed before steady-state sampling: `TN_ASSETS_UNRESOLVED` for `native-proof.glb`, because the direct decoder-free bundle reused Meshopt-compressed assets prepared by the V8 build instead of running the shipped engine-aware native asset compiler. The raw run presents only one frame and produces no performance JSON, comparison or same-pose images. Pair artifact `11972980580` preserves this failure; no performance or visual acceptance is claimed. The local correction uses the existing installed native build/cook entrypoint once with QuickJS selected, then reuses its canonical bundle and prepared public asset root for both engines and all captures. The actual starter scene, original proof asset inputs, materials, feature profile and strict performance criterion are preserved. + + +The installed cook correction passes actual local x64/Dawn startup on both own production profiles, with UI overlay, native physics and Canvas2D enabled and all feature flags matched: each existing render-chain scenario passed six assertions and 660 frames, reached `ready`, and captured a non-blank image (black fraction zero) without asset or UI startup errors. The same canonical `.threenative/build/game.js` (SHA-256 `a4e21f8b66f7044e3fb37c6aa17e1d8fb5a0eac748e297ff21cde7951850b19f`) and all 11 bundle/public-file hashes stayed identical before and after both runs; `public/assets.manifest.json` is retained in paired evidence. QuickJS game SHA-256 is `2372661acd3962522d5772c88096f0a3e45de4af579c71f05d52d6d795d93f8c`; V8 game SHA-256 is `27f7ed6b5c36f7fac7fcf9c7306bc41dbd917db0d163557e45dc481161e2c0d1`. Packaging used own V8 helper SHA-256 `cff68eb4f343aac9b6aaaf9de6a8d1ac3fc5f0b5a66c3465468a1a8ca9c76654` through the shipped `THREENATIVE_CLI_TOOLS` override (`src/cli/tool_dispatch.cpp:22`), matching the workflow's retained original helper; this tool is outside the timed game. The unrequired QuickJS tools link encountered duplicate `rust_eh_personality` between SWC and the UI overlay, so it was not retried or changed. The predecessor workflow failed the new executed standard-cook command regression; the corrected command and capture contracts pass. Typecheck, lint, 320 focused CI/profile/collector/freshness tests and 39 documentation fixtures pass; the committed coverage record remains fresh without regeneration because CMake/native source bytes are unchanged. The corrected candidate's full suite and next arm64 qualification remain pending. This local cook/startup proof is not arm64 performance or a visual verdict. + + +The frozen cook-fix candidate `857c98e7146cd2f24d7e32430f67965890e977bc` passed root's complete `pnpm test` run: 779 root files passed (three skipped), 10,635 tests passed (20 skipped), 455.67 seconds; 1,591 native tests passed (62 skipped), and 29 physics parity tests passed. Suite-owned temporary directories stayed one before and after. Fresh bounded Codex review exited successfully with no actionable defects. The next corrected arm64 qualification must still produce all six paired performance runs, the strict V8 improvement and fresh same-pose visual acceptance. + +Qualification `38741054041` at `9c16c18717e16ba7bdf115c99b1bb87cd37eb318` hit the exact published V8 cache without rebuilding, passed the starter gate and completed all six paired collector reports on the same arm64 job (`116442672062`). QuickJS JS-frame p50s were 31.04, 30.48 and 30.88 ms (median 30.88 ms); V8 p50s were 6.14, 6.24 and 6.45 ms (median 6.24 ms). These numbers are not accepted AC-2 evidence: the strict comparison correctly rejected missing `surface.compiling` observations in both steady windows. The producer omitted false values, so no comparison or same-pose captures were produced. Pair artifact `11976610745` preserves the six reports; AC-2 and visual acceptance remain open. + +The engine correction emits the actual window compilation boolean explicitly. The real Game regression failed before the fix and now proves active compilation, completed compilation-count changes and a clean window as `true, true, false`; missing or true steady observations still fail the unchanged paired guard. Core was rebuilt, packed and reinstalled into the existing starter: built and installed core bytes match SHA-256 `4d58a09492083538a2a48888b6186101d64a296799892bdc2641250eb2c28f04`. The new standard-cooked bundle SHA-256 is `f789ea75dc60e3780abcd363ab670bee1a66b6d2dc0be48ae505fd3a06c529c3`; all 11 bundle/public-file hashes remained identical across both own production binaries. Each existing render-chain startup scenario passed six assertions and 660 ticks/frames with a non-blank capture. Normal-clock raw frame-budget windows on both engines explicitly emitted `true, false, false` at 1280×720, scale 1 and four samples. The V8 collector exited zero. The QuickJS collector exited one at a separate Dawn pipeline-log parsing failure (`TN_PIPELINE_CAPTURE_MALFORMED`, invalid JSON at position 389); its valid raw window observations and startup scenario are preserved, but no QuickJS local collector pass is claimed. This local x64/Dawn proof does not establish arm64 performance or visual acceptance. + +The telemetry slice passes full typecheck and lint (1,442 retained warnings), 258 focused Game/window/pair/CI/profile/coverage-freshness tests, documentation checks and 39 documentation fixtures. The existing native coverage record remains fresh; no native coverage regeneration or runtime rebuild was needed. The previous complete suite predates this telemetry change, so its new full-suite review and corrected arm64 qualification remain pending. + +The first complete telemetry-candidate suite (`30787`) failed before root tests: the conformance dry-run returned null status after 120.2 seconds against its 120-second subprocess timeout, and the V8 native compute timestamp query returned equal timestamps. Both failures remain recorded; their cause is unproven. A bounded quiet-host diagnostic required three consecutive 20-second CPU/GPU/load/memory samples, then ran the two unchanged test files once with one worker: all 84 tests passed in 32.83 seconds (CPU 2.75%, GPU 1%, load 3.48 before; CPU 15.65%, GPU 0%, load 4.63 after). Root's single subsequent complete retry on frozen `470e4e2af245c976be6025f4ceb4253e8b12e0e8` passed: 779 root files (three skipped), 10,636 tests (20 skipped), 477.54 seconds; 1,591 native tests (62 skipped) plus 29 physics parity tests. Temporary directories stayed one before and after. Fresh read-only review found no actionable defects. Corrected arm64 paired comparison, four same-pose captures and visual acceptance remain pending. + +Corrected telemetry qualification `38749390500` at `a55a88c1f89650e95773d13c384696bedb14fa48` passed cached V8 startup and produced four valid performance reports with explicit false compilation observations in both steady windows. Launch five (QuickJS) then failed the unchanged strict parser: a Rust `TN_UI_SNAPSHOT` record physically interrupted a native `TN_PIPELINE_EVENT` JSON line. This repeats the previously preserved local Dawn logging failure; no automatic retry was made. Only two complete runs per engine exist, so neither their numbers nor the earlier six-run medians establish AC-2. There is no comparison or same-pose capture. All non-arm64 selected product jobs passed; the required receipt failure follows the arm64 job failure. + +The bounded engine repair composes complete pipeline and JS-console records before one checked C stdio write and flush, and moves only the two Linux Rust snapshot stdout writers to that same FILE sink. QuickJS error stderr and Android logging are preserved. This coordinates cooperating stdout writers; it does not claim atomicity across arbitrary merged stderr or other processes. The actual old Rust writer bypassed a held C stdout FILE lock; its replacement blocks correctly, rejects embedded NUL and restores test environment/lock state. Rust tests passed 24 cases with one existing display test ignored. The first C++ contention fixture incorrectly assumed compute labels were emitted and is invalid RED evidence. The corrected real render-pipeline fixture uses a supported 96-KiB label, a 96-KiB console record, competing C stdio UI-shaped output and slow-pipe backpressure. Its shipped assertion rejects the old producer's actual UI splice and passes the repair. The unchanged parser validates all physical marker records after repair: 16 markers, seven events, two actual runtime completion records summing to seven events and one pipeline line larger than 64 KiB. Whole-fixture capture parsing is not claimed because that existing fixture contains two runtime metadata records. + +Both own production game hosts and the shipped Rust release library were rebuilt sequentially with parallelism two. The same standard-cooked starter and all 11 shared bundle/public-file hashes remain identical across both engines. Both startup scenarios pass six assertions and 660 frames, and both normal-clock collectors pass with explicit settled false observations. Unchanged full capture parsing on each actual single-runtime log reports complete true, 39 recorded/created pipeline events, zero pending/dropped events and no incomplete reasons; the real Rust UI emits 18 QuickJS and 15 V8 snapshot observations. These are local x64/Dawn producer proofs, not arm64 performance or a visual verdict. Canonical native coverage passed all 47 configured contract targets (three explicitly disabled targets retained), covering 19,489/24,858 lines (78.40%) with every retained floor unchanged. The generated source digest is `1a8ed34cf063a9530e8fa11b2722ae0b4c475402ee1baef998b5dea0acd3a4d7`. Typecheck and lint pass (1,442 retained warnings), with 319 root CI/profile/freshness tests and 99 native focused tests passing (one explicit skip). Documentation checks, 39 documentation fixtures and budgets pass. Fresh read-only Codex review found no actionable defects on the unchanged five-file source/test diff. The logging candidate's complete suite passed on frozen `34997a15d9cf325f2e73476153df3c7c351102b9`: 779 root files (three skipped), 10,636 tests (20 skipped), 538.95 seconds; 1,591 native tests (62 skipped) plus 29 physics parity tests. Temporary directories stayed one before and after. An earlier quiet-host observer exited 75 without running tests; it is not a full-suite failure. Corrected arm64 qualification, the strict three-pair result and fresh same-pose visual acceptance remain pending. + + +Qualification `38764309492` on frozen `d87b42ac666753758747a86bed2b4cd9678a649a`, arm64 job `116523552429`, completed all six paired runs. QuickJS sampled JS-frame p50s were 30.01, 29.99 and 30.17 ms (median 30.01 ms); V8 p50s were 6.06, 6.28 and 6.06 ms (median 6.06 ms). The unchanged strict comparison passed. Pair artifact `11988223305` retains the comparison and four captures. The final sample ticks were 741 and 742 for QuickJS, 740 for both V8 captures; observed camera positions and forward vectors also differ. Exact pose equivalence and the fresh visual verdict remain open, so AC-2 stays unticked. + +The same arm64 job failed the strict starter receipt binding after the performance cook replaced the already verified consumer executable. Downloaded starter artifact `11988293674` has a successful desktop row, five assertions, no failures and artifact SHA-256 `36ff601c8d76cd416a64f7d7445cf4aea0cdf9c49ae6ef9e9991b7551afbf7b0`; only the executable-hash operand can explain the receipt rejection. The final debug executable digest was not retained; the release container's stripped executable is a different artifact. The workflow now snapshots the verified consumer with `cp -p` before the cook and restores its bytes and mode in the existing EXIT trap on success or failure. The V8 binary, CMake selection and config restoration remain in place. Tests executing the actual extracted shell step failed both restoration cases before the fix and pass all five paired tests after it; the forced failure still exits 23. Each capture now retains literal CLI stdout as `report.json` and stderr as `runner.stderr.log`. The strict verifier is unchanged. This local workflow slice passes 54 focused paired/workflow/receipt tests and 247 documentation/workflow contract tests; documentation checks resolve 3,097 relative links. The corrected qualification has not run. + +Other failures in this qualification remain separate: desktop UI measured 2.19 ms across 11 samples against a strict less-than-2-ms limit; the Android emulator lane reported an unknown ZIP input, whose cause remains unverified. Neither limit nor gate was relaxed. These failures and the visual acceptance gap prevent a complete qualification claim. + +The corrected startup slice in the reviewed source diff (SHA-256 `b66e14034cde59bb4acdbfde61eba940874b1bb58887193506ade9dd4b016c9b`) preserves the existing 60-tick prime and authored holds while admitting physics, scene and fixed compute together. The actual Game/SoftBody regression reproduced scene/physics 60 but solver 0 before the fix and proves 60/60/60 afterward. Compile-only warmup uses zero-dispatch WebGPU work; real row-75 browser and native proofs preserve all seven solver buffers, then ordinary solver work changes four. The native deadline case uses an explicitly controlled clock (0→20 ms against the unchanged 10-ms budget), not a wall-time performance claim. Browser and native GPU proof remain separate from starter buffer history, which is not exposed by its probes. + +The authored render-chain scenario now anchors its counted clock at tick 600: observed startup reached at most tick 89, and 600 reuses the existing settle window before exact alignment, failing closed on overshoot. Four local production-profile captures (two per engine) each pass six assertions and 660 scenario frames; resource samples reach 660 and 1261, with observed flag steps 659 and 1260. Cameras, player transforms, physics cadence, odometer, peakRise and the remaining observed applied state match exactly. Equality excludes only pickup UUID, asynchronous flagReadbacks, the skipped quality-decision leaves costMs/cpuMs/gpuMs/fallback/meter/overloadBudgetMs/reason, and the post-correction groundClearance observer (QuickJS 0 versus V8 −5.551115123125783e-17 at tick 660). Every sampled quality decision has changed=false and skips before threshold evaluation; applied tier/stages/dropped/material-lighting remain equal. Raw differences are retained, without rounding or tolerance; missing scale/shadow observations and starter solver-buffer history remain unavailable. Literal reports and responses are in `/tmp/prd615-aligned-startup-proof/`, with continuation logs `/tmp/prd615-aligned-startup-proof-continuation.log` and `/tmp/prd615-aligned-startup-proof-final.log`. The canonical native bundle `54dfcf82b40d30c8b02bac883f2ae1673cf28cececedaeaae1f9471f1bbad022` and all 11 shared inputs stayed unchanged throughout the native campaign. + +One separate shipped-starter browser render-chain run passes six assertions using headed WebGPU on NVIDIA Turing, high tier and all six stages including antialias; resource samples are 661 and 1261. Its standard web cook prepares web-target assets, so this is browser functionality proof, not cross-platform pose equality. Raw output and image are in `/tmp/prd615-aligned-startup-browser/`. One unchanged stock desktop UI gate with the own production V8 host passes at 0.05 ms p95 across ten samples; the staged host's original bytes and mode were restored. Raw log: `/tmp/prd615-epoch-ui-frame.log`. This local result does not explain the retained Azure 2.19-ms/11-sample failure, and the Android ZIP/SDK cause remains unproven. AC-2 stays open at 75%: fresh visual judgement, final ARM qualification and current-candidate CI remain pending. Final focused checks pass 814 tests; typecheck, lint, budgets and documentation checks pass after fixture-only corrections for required `release: true` and the starter hash changed solely by `startTick: 600`; those fixture failures are not runtime RED evidence. The complete suite on frozen `33f753c230cc90a741ed2b062d278cdef04e696f` passes 779 test files (three skipped) and 10,662 tests (20 skipped) in 590.02 seconds; suite-owned temporary directories remain one before and after. + +## Current acceptance and delivery state + +At the earlier documentation-only finishing checkpoint, the accepted executable source candidate was `c312cee5a9ad5bdf60da347bfd89423af0022c38`; that completion/archive edit changed no executable, producer, runtime or workflow inputs. The later Node editor/CI repairs below have their own verification and do not inherit an all-executable identity claim. AC-1 retains the verified immutable V8 archive and release-cache reuse, with the current 300-frame starter result above. The current ARM qualification [38784079933](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38784079933) passes the strict six-run comparison: QuickJS JS-frame p50s 30.42/31.05/30.70 ms (median 30.70), V8 6.08/6.17/6.06 ms (median 6.08). All twelve steady windows report `compiling=false`, the same bundle and features, and the same ARM llvmpipe LLVM 20.1.2 Vulkan adapter. Antialias is dropped and Image is undefined in both arms; the result is a JS-frame improvement on ARM software Vulkan, not a hardware-GPU or cross-platform performance claim. + +Four actual ARM captures, two per engine, pass seven assertions each (six scenario assertions plus diagnostics) and 660 frames. At aligned ticks 600/660/1261, scene, camera, entities, physics and render chain agree exactly. Before alignment the observed ticks differ (80/81/79/78); residual differences are confined to UUIDs, quality CPU/GPU costs and asynchronous-flag readbacks. A fresh read-only visual judge returns NEUTRAL / PASS for both QuickJS/V8 pairs, both within-engine repeat comparisons and overall, scoped to ARM software Vulkan. The four actual pose images are immutable at commit `736c78915fb7c3eadd52c39c7bbf5c1d16a198ac` under `fixed-pose/{quickjs-1,quickjs-2,v8-1,v8-2}.png` on the PR's screenshot branch; publication passed normal hooks. This closes the actual pose/visual requirement without treating historical unaligned captures as acceptable. + +The complete local suite on frozen `33f753c230cc90a741ed2b062d278cdef04e696f` passed 779 test files (three skipped), 10,662 tests (20 skipped), in 590.02 seconds; suite-owned temporary directories remained one before and after. The final executable slice also passed the 814 focused tests, typecheck, lint, budgets and documentation checks recorded above. These results establish local verification, not final-head CI. + +At this finishing commit, whole manual workflow `38784079933` is **FAILED**, despite the ARM acceptance result: the Windows receipt transport hit a 30-second GitHub API timeout, leaving authoritative Windows receipt coverage unproven and the required join unsuccessful. All other selected jobs, including Android, succeeded. This transport failure is not a Windows product-test pass and the run is not whole-workflow green; it also does not explain the separate historical UI/SDK failures retained above. Final-head full CI and its required joins, taking the PR out of draft and merge-queue admission remain pending. No ready, queued or merged state is claimed at this checkpoint. Current delivery state is tracked on [PR #508](https://github.com/ThreeNativeHQ/threenative-engine/pull/508). + +Finishing prose-only checks pass: `pnpm check:docs` resolves 3,097 relative links; the required six documentation/CI-contract spec files pass all 247 tests; both working-tree and staged diff whitespace checks pass. `pnpm prd:progress docs/PRDs/done/PRD-615-v8-on-linux-arm64.md` reports 3/3 phases, 5/5 phase boxes and 2/2 acceptance boxes complete. Its computed `prd:100% — ready` label describes PRD progress only; actual PR readiness still awaits the final-head delivery gates above. + +## Bounded CI repair plan + +The accepted ARM V8 startup, cache, performance and visual criteria above remain valid; final-head CI remains pending. The following repairs address later CI failures and do not turn the historical whole workflow green. Product changes require the reviewed bounded plan before implementation. + +1. Terrain editor revision delivery: the real Linux server loses a supported same-directory atomic external save immediately after a POST. In `/tmp/prd615-sse-atomic-diagnostic.log`, the watcher is ready before POST, accepted-revision public change precedes the external rename by 1 ms, and raw filesystem events observe the new disk revision, but no document event arrives within the unchanged five-second deadline. Earlier in-place saves did not satisfy the stated atomic-save contract and are not product RED evidence. Implemented scope is `packages/terrain/src/editor/server.ts` plus its existing document test: reuse the same watcher's public raw events, match only the configured document using the observed raw path/watchedPath forms, and call existing synchronous refresh; register/remove the named listener with existing close cleanup. Preserve validation, last-valid recovery, revision deduplication and synchronous commit ordering; add no watcher, polling, delay or private API. Verified on Linux: the same valid atomic save and queued actual heartbeat pass after the raw-listener repair; all eight document tests pass, including last-valid recovery, atomic transaction rejection, exact absolute/directory-relative/file-watched target forms, unrelated-path rejection, missing/undefined/non-string raw-path rejection without throws or extra refresh, and listener cleanup. The package build/publint, package typecheck and all 13 consumer tests pass. Documentation checks resolve 3,097 links and pass all 248 selected fixtures; PRD progress remains 3/3 phases, 5/5 phase boxes and 2/2 criteria. Focused lint exits zero with three existing server complexity warnings and one new buffered-test-reader complexity warning (26). +2. QuickJS fallback CI selection: the actual CI RED exposed an engine-selection mismatch in the packaged fallback/cook path. The repair executes the existing CMake/cook shell paths with QuickJS selected and preserves native feature flags; both focused execution checks pass (2/2). The actual QuickJS, decoder-absence, no-WASM and KTX2-refusal assertions remain unchanged. Local QuickJS runtime acquisition is UNVERIFIED: `/tmp/prd615-local-quickjs-assets.log` and `/tmp/prd615-local-quickjs-assets-tools.log` both exited 127 because the first host's sibling `mystral-tools` and the second explicit `build/tn-linux/mystral-tools` were absent. No third GPU attempt or local QuickJS runtime PASS is claimed; fresh ARM CI remains pending. +3. Decal verifier default: the actual CI RED exposed a stale fixture expectation for the shipped asset/decal default. The focused asset/decal unit selection passes (25/25). The verifier changes its exact engine expectation from QuickJS to V8 and retains the ARM, geometry and pixel assertions; a fresh ARM runtime verdict remains unverified until CI. The accepted earlier ARM visual result does not turn this later CI failure green. Final new-head CI remains pending. + +Terrain repair proof: `/tmp/prd615-sse-atomic-diagnostic.log` is the contract-valid old-server RED (ready watcher, accepted revision, atomic external rename, raw new-revision events, then five-second document deadline). `/tmp/prd615-sse-guard-green.log` is the final actual built repaired-server whole-file GREEN including malformed raw-path cases (8/8, 198 ms); `/tmp/prd615-sse-guard-build.log`, `/tmp/prd615-sse-guard-typecheck.log`, `/tmp/prd615-sse-consumer.log` and `/tmp/prd615-sse-guard-lint.log` retain the focused checks. The first attempted GREEN still imported stale `dist` and is not new-source proof; a one-time package rebuild and actual dist listener/registration/cleanup readback preceded the valid GREEN. The intermediate TS2532 failure was assertion typing, not behavioral RED. The test reader buffers decoded chunks across calls, ignores only blank/comment frames, validates document/data/revision presence, retains the five-second deadline and original payload assertions, and restores the original real timer spy before the external save with cancellation in `finally`. + +This repair changes the Node terrain editor server and its tests; the separately bounded CI selection/fixture repairs have their own owning-lane proof. The C++ host, V8 producer, core runtime and measured starter workload source remain unchanged by this Node-only repair, so no new rendering or ARM performance claim is made. Raw-event delivery is platform-specific: only the actual Linux public-watcher path was exercised here. The accepted V8 criteria remain supported by the earlier ARM evidence; final new-head CI and required joins remain pending. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index c39a4e022d..dc90f94cb5 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -13,20 +13,20 @@ targets could not be built and are named below. | `src/cli/` | 1690 | 1242 | 73.49% | | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | -| `src/js/` | 2785 | 2212 | 79.43% | -| `src/platform/` | 1460 | 1003 | 68.70% | +| `src/js/` | 2793 | 2216 | 79.34% | +| `src/platform/` | 1465 | 1008 | 68.81% | | `src/raytracing/` | 461 | 399 | 86.55% | -| `src/runtime.cpp` | 2425 | 1890 | 77.94% | +| `src/runtime.cpp` | 2442 | 1896 | 77.64% | | `src/screenshot_gate.cpp` | 27 | 24 | 88.89% | | `src/storage/` | 327 | 286 | 87.46% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 9842 | 7566 | 76.87% | +| `src/webgpu/` | 9885 | 7581 | 76.69% | | `src/webtransport/` | 1391 | 1078 | 77.50% | | `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **24785** | **19459** | **78.51%** | +| **TOTAL** | **24858** | **19489** | **78.40%** | -Source digest: `sha256:2828ae43f9f7f863426676c8b76acf9a02576361e410f0fafac09a8df706b1b9` +Source digest: `sha256:1a8ed34cf063a9530e8fa11b2722ae0b4c475402ee1baef998b5dea0acd3a4d7` 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 @@ -41,7 +41,7 @@ change requires this opt-in command to refresh the record. | `src/fs/` | 75.43% | | `src/http/` | 86.95% | | `src/js/` | 74.50% | -| `src/platform/` | 63.70% | +| `src/platform/` | 63.81% | | `src/raytracing/` | 81.55% | | `src/runtime.cpp` | 73.28% | | `src/screenshot_gate.cpp` | 88.89% | diff --git a/examples/prd242-compute-lifetime/src/conformance.js b/examples/prd242-compute-lifetime/src/conformance.js index a1ad9fea5e..fe5d854518 100644 --- a/examples/prd242-compute-lifetime/src/conformance.js +++ b/examples/prd242-compute-lifetime/src/conformance.js @@ -1,8 +1,11 @@ +import { Mesh, MeshBasicNodeMaterial, PlaneGeometry } from "three/webgpu"; // The root package bundles its Three.js dependency; this source-module import keeps this // conformance bundle on the same Three.js runtime as the scene while exercising the exported // registry implementation. import { ComputeDrivenRegistry } from "../../../packages/core/src/compute-driven.ts"; -import { warmUpScene } from "../../../packages/core/src/warmup.ts"; +import { createPipelineCensus } from "../../../packages/core/src/pipeline-census.ts"; +import { SoftBody3D } from "../../../packages/core/src/softbody.ts"; +import { warmUpComputeNodes, warmUpScene } from "../../../packages/core/src/warmup.ts"; import { assertCondition, startVisualScene, @@ -13,12 +16,123 @@ async function waitForSubmittedWork(renderer) { await renderer.backend?.device?.queue?.onSubmittedWorkDone?.(); } +// Cached-pipeline proof: the baseline prepares GPU buffers with zero workgroups first. +async function assertCachedClothWarmupPreservesBuffers(renderer) { + assertCondition(renderer.backend?.isWebGPUBackend === true, "cloth warmup proof requires WebGPU"); + const cloth = new SoftBody3D( + new Mesh(new PlaneGeometry(1, 1, 2, 2), new MeshBasicNodeMaterial()), + { + damping: 1.8, + gravity: [0, 0, 0], + pinned: [0], + stiffness: 36, + wind: [0, 0.4, 4.5], + }, + ); + const attributes = new Set(); + const backend = renderer.backend; + const originalCreate = backend.createStorageAttribute; + const census = createPipelineCensus({ kind: "webgpu", adapterIdentity: "conformance-probe" }); + census.installRenderer(renderer); + backend.createStorageAttribute = function (attribute) { + attributes.add(attribute); + return originalCreate.call(this, attribute); + }; + try { + for (const node of cloth.warmupNodes) await renderer.computeAsync(node, 0); + } finally { + backend.createStorageAttribute = originalCreate; + } + const pipelines = census + .snapshot() + .events.filter((event) => event.kind === "compute" && event.status === "created"); + census.dispose(); + assertCondition( + pipelines.length === 2, + "cached cloth baseline must observe both real compute pipelines", + ); + // This snapshots every solver storage attribute, including both position and velocity pairs. + assertCondition( + attributes.size >= 4, + "cloth proof must capture all four nonempty solver buffers", + ); + const snapshots = async () => + Promise.all( + [...attributes].map(async (attribute) => { + const bytes = new Uint8Array(await renderer.getArrayBufferAsync(attribute)); + assertCondition(bytes.byteLength > 0, "cloth GPU readback must not be empty"); + return bytes.slice(); + }), + ); + const before = await snapshots(); + let calls = 0; + let clock = 0; + let completion; + let report; + const descriptor = Object.getOwnPropertyDescriptor(performance, "now"); + const seam = { + backend, + computeAsync(node, count) { + calls += 1; + completion = renderer.computeAsync(node, count); + clock = 20; + return completion; + }, + }; + // Controlled deadline, not a wall-clock timeout; GPU dispatch and readback remain real. + try { + Object.defineProperty(performance, "now", { configurable: true, value: () => clock }); + report = await warmUpComputeNodes(seam, cloth.warmupNodes, { + budgetMs: 10, + compileTimeoutMs: 10, + }); + } finally { + if (descriptor === undefined) Reflect.deleteProperty(performance, "now"); + else Object.defineProperty(performance, "now", descriptor); + } + assertCondition(completion?.then !== undefined, "cloth proof must await its real GPU delegate"); + await completion; + await waitForSubmittedWork(renderer); + const after = await snapshots(); + assertCondition( + calls === 1 && report.compiled === 0 && report.timedOut && report.abandoned === 2, + `cached cloth warmup must time out during the first half of its ordered pair: ${JSON.stringify({ calls, report })}`, + ); + const equal = (left, right) => + left.length === right.length && left.every((value, index) => value === right[index]); + assertCondition( + before.every((bytes, index) => equal(bytes, after[index])), + "cached cloth warmup mutated live GPU storage at its controlled half-pair deadline", + ); + cloth.process({ kind: "webgpu", compute: (node) => renderer.compute(node) }); + await waitForSubmittedWork(renderer); + const advanced = await snapshots(); + const changed = after.filter((bytes, index) => !equal(bytes, advanced[index])).length; + assertCondition( + changed >= 4, + "the next real cloth solver step must mutate both position and velocity pairs", + ); + cloth.detach(); + const detail = { + pipelines: pipelines.length, + buffers: before.length, + unchanged: true, + changed, + calls, + report, + deadline: { budgetMs: 10, start: 0, end: 20 }, + }; + console.info(`TN_COMPUTE_WARMUP_CACHED_CONTROLLED:${JSON.stringify(detail)}`); + return detail; +} + export async function startScene(canvas, dimensions) { return startVisualScene( canvas, dimensions, "compute-driven-lifetime", async ({ camera, renderer, scene }) => { + const cachedClothWarmup = await assertCachedClothWarmupPreservesBuffers(renderer); const registry = new ComputeDrivenRegistry(); const add = (object) => { scene.add(object); @@ -74,6 +188,7 @@ export async function startScene(canvas, dimensions) { return { field: second, detail: { + cachedClothWarmup, attachments: 2, firstReleased: first.released, passesPerStep: 2, diff --git a/packages/core/__tests__/animation.spec.ts b/packages/core/__tests__/animation.spec.ts index 81cf85d1f5..2038b46af2 100644 --- a/packages/core/__tests__/animation.spec.ts +++ b/packages/core/__tests__/animation.spec.ts @@ -869,6 +869,20 @@ describe("AnimationPlayer stride sync on in-place clips", () => { ), ]); + it("preserves the root's exact Euler while measuring a foot-plant stride", () => { + const root = new Object3D(); + const foot = new Object3D(); + foot.name = "Foot"; + root.add(foot); + root.rotation.set(0, Math.PI, 0); + const player = new AnimationPlayer({ root, clips: [plantedWalk()] }); + player.play("walk"); + player.update(1 / 60); + + expect(player.stride.clipGroundSpeed).toBeGreaterThan(0); + expect(root.rotation.toArray()).toEqual([0, Math.PI, 0, "XYZ"]); + }); + it("matches an in-place walk cycle from the ground its planted foot sweeps", () => { const { body, player } = character({ clips: [plantedWalk()] }); player.play("walk"); diff --git a/packages/core/__tests__/game-frame-budget-surface.spec.ts b/packages/core/__tests__/game-frame-budget-surface.spec.ts index d9f7b3d16f..06027a000c 100644 --- a/packages/core/__tests__/game-frame-budget-surface.spec.ts +++ b/packages/core/__tests__/game-frame-budget-surface.spec.ts @@ -145,6 +145,7 @@ describe("the frame budget names the surface the game's own loop drew", () => { const reported = JSON.parse(marker?.slice(FRAME_BUDGET_MARKER.length + 1) ?? "{}"); expect(reported.surface).toEqual({ atFloor: false, + compiling: false, drawingBufferHeight: 540, drawingBufferWidth: 1200, resolutionScale: 0.5, @@ -399,3 +400,75 @@ describe("the frame budget names the surface the game's own loop drew", () => { } }); }); + +describe("game frame budget surface compilation telemetry", () => { + it("reports explicit compiling state across active compile overlap and clean windows", async () => { + const canvas = testCanvas(); + let frame: ((time: number) => void) | undefined; + const surfaces: Array = []; + const game = defineGame({ + frameBudget: { + report: () => {}, + reportEvery: 1, + onWindow: (window) => { + surfaces.push(window.surface?.compiling); + }, + }, + renderer: { + canvas, + preferWebGPU: false, + webgl2Factory: () => ({ + compileAsync: () => Promise.resolve(), + domElement: canvas, + render: () => undefined, + setSize: () => undefined, + }), + }, + scenes: { test: EmptySceneForReporting }, + start: "test", + }); + const requestFrame = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: (time: number) => void) => { + frame = callback; + return 1; + }, + }); + try { + await game.start(); + if (frame === undefined) throw new Error("Game did not start its loop."); + let time = performance.now(); + for (let index = 0; index < 10; index += 1) { + time += 8; + frame(time); + await Promise.resolve(); + } + await game.ctx?.startup.whenReady(); + surfaces.length = 0; + const ctx = game.ctx; + if (ctx === undefined) throw new Error("Game context was not created."); + const active = ctx.renderer.compileAsync(ctx.scene, ctx.camera); + expect(ctx.renderer.compiling).toBe(true); + time += 16.7; + frame(time); + await active; + await Promise.resolve(); + await ctx.renderer.compileAsync(ctx.scene, ctx.camera); + expect(ctx.renderer.compiling).toBe(false); + time += 16.7; + frame(time); + await Promise.resolve(); + time += 16.7; + frame(time); + await Promise.resolve(); + expect(surfaces.slice(-3)).toEqual([true, true, false]); + } finally { + await game.stop(); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: requestFrame, + }); + } + }); +}); diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index acb047fe25..32f115011c 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -7,13 +7,16 @@ import { MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, + PlaneGeometry, SphereGeometry, } from "three"; +import { MeshBasicNodeMaterial } from "three/webgpu"; import { describe, expect, it, vi } from "vitest"; import { type IGamePlatformSource, defineGame } from "../src/game.js"; import { InputMap } from "../src/input.js"; import type { IRenderPerformanceSample } from "../src/loop.js"; import { type ICtx, Scene } from "../src/scene.js"; +import { SoftBody3D } from "../src/softbody.js"; import { UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge } from "../src/ui-bridge.js"; import { subscribeUiState } from "../src/ui-state.js"; @@ -258,17 +261,16 @@ describe("IGame", () => { } }); - // Compute dispatch waits for the first rendered world; gameplay does not. Holding gameplay back - // as well was tried and reverted: it read well as one startup policy, and it stopped every - // template's playtest dead — chasers never left the spawn, coins were never collected, the - // player never rose. Ten golden-path and template lanes are the evidence, so what this asserts - // is the split, not the symmetry. - it("keeps startup compute behind the first rendered world while gameplay keeps running", async () => { + it("keeps fixed compute in the gameplay and physics cadence behind the startup loader", async () => { const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } }); const trace: string[] = []; - const computePhases: string[] = []; + const computeNodes: unknown[] = []; const gameplayPhases: string[] = []; let frame: ((time: number) => void) | undefined; + let advance: ((ticks: number) => void) | undefined; + let cloth: SoftBody3D | undefined; class StartupTraceScene extends Scene { static override readonly initialState = {}; @@ -278,35 +280,51 @@ describe("IGame", () => { } override enter(ctx: ICtx): void { - const driven = new Group() as Group & { - readonly released: boolean; - readonly warmupNodes: readonly unknown[]; - attachRenderer: () => void; - detach: () => void; - process: () => void; - }; - Object.assign(driven, { - released: false, - warmupNodes: [], - attachRenderer: () => undefined, - detach: () => undefined, - process: () => { - trace.push("computeDispatched"); - computePhases.push(ctx.startup.phase); - }, + cloth = new SoftBody3D(new Mesh(new PlaneGeometry(1, 1), new MeshBasicNodeMaterial()), { + pinned: [0], + stiffness: 35, + damping: 1.8, + gravity: [0, 0, 0], + wind: [0, 0, 0], + }); + ctx.add(cloth); + let releaseHold: () => void = () => undefined; + ctx.startup.hold( + "simulation initialization", + new Promise((resolve) => { + releaseHold = resolve; + }), + ); + ctx.afterPhysics(() => { + trace.push("physicsAdvanced"); + releaseHold(); }); - ctx.add(driven); ctx.canvasLayer.scene.name = "overlay"; ctx.canvasLayer.scene.add(new Mesh()); ctx.canvasLayer.opaque = true; } } const game = defineGame({ + plugins: [ + { + setup: (_ctx, runtime) => { + runtime?.freezeClock?.(); + advance = runtime?.fixedStep; + return undefined; + }, + }, + ], renderer: { canvas, - preferWebGPU: false, - webgl2Factory: () => ({ + webgpuFactory: () => ({ + dispose: () => undefined, + init: async () => undefined, compileAsync: () => Promise.resolve(), + computeAsync: () => Promise.resolve(), + compute: (node: unknown) => { + computeNodes.push(node); + trace.push("computeDispatched"); + }, domElement: canvas, render: (scene: { name?: string }) => { trace.push(scene.name === "overlay" ? "overlayRendered" : "worldRendered"); @@ -328,23 +346,39 @@ describe("IGame", () => { try { await game.start(); - if (frame === undefined || game.ctx === undefined) - throw new Error("Game did not start its loop."); - + if ( + frame === undefined || + game.ctx === undefined || + cloth === undefined || + advance === undefined + ) + throw new Error("Game did not start its loop and solver."); + const solver = cloth; const firstTime = (globalThis.performance?.now() ?? 0) + 16; frame(firstTime); - // What the first frame has to prove is that nothing was dispatched and no world was drawn - // behind the opaque loader — not the exact contents of the frame. `toEqual([...])` also - // pinned whether a fixed-step gameplay update ran inside this frame, and that is a function - // of the wall-clock gap between the loop starting and this timestamp, not of the startup - // policy. A CI runner produces a longer first frame than a quiet machine, runs one fixed - // step before the render, and the assertion failed with - // ["gameplayUpdated", "overlayRendered"] while every invariant below still held. - // Gameplay's ordering is asserted by index at the end of this test, where it belongs. + expect({ + scene: trace.filter((event) => event === "gameplayUpdated").length, + afterPhysics: trace.filter((event) => event === "physicsAdvanced").length, + solver: cloth.steps, + }).toEqual({ scene: 60, afterPhysics: 60, solver: 60 }); + expect(computeNodes).toHaveLength(60 * solver.warmupNodes.length); + computeNodes.forEach((node, index) => + expect(node).toBe(solver.warmupNodes[index % solver.warmupNodes.length]), + ); + expect(trace.indexOf("gameplayUpdated")).toBeLessThan(trace.indexOf("computeDispatched")); + expect(trace.indexOf("computeDispatched")).toBeLessThan(trace.indexOf("physicsAdvanced")); expect(trace).toContain("overlayRendered"); - expect(trace).not.toContain("computeDispatched"); expect(trace).not.toContain("worldRendered"); expect(game.ctx.startup.phase).toBe("collapsing"); + expect(gameplayPhases[0]).not.toBe("ready"); + advance(3); + expect(cloth.steps).toBe(63); + expect(trace.filter((event) => event === "gameplayUpdated")).toHaveLength(63); + expect(trace.filter((event) => event === "physicsAdvanced")).toHaveLength(63); + expect(computeNodes).toHaveLength(63 * solver.warmupNodes.length); + computeNodes.forEach((node, index) => + expect(node).toBe(solver.warmupNodes[index % solver.warmupNodes.length]), + ); for (let time = firstTime + 16; time <= firstTime + 304; time += 16) { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -353,30 +387,15 @@ describe("IGame", () => { } await new Promise((resolve) => setTimeout(resolve, 0)); frame(firstTime + 320); - await Promise.resolve(); - - const firstCompute = trace.indexOf("computeDispatched"); - const firstGameplay = trace.indexOf("gameplayUpdated"); - const firstWorld = trace.indexOf("worldRendered"); - const firstOverlay = trace.indexOf("overlayRendered"); expect(game.ctx.startup.phase).toBe("ready"); - expect(firstGameplay).toBeGreaterThanOrEqual(0); - expect(firstCompute).toBeGreaterThanOrEqual(0); - expect(firstWorld).toBeGreaterThanOrEqual(0); - expect(firstOverlay).toBeGreaterThanOrEqual(0); - expect(firstGameplay).toBeLessThan(firstCompute); - // Gameplay runs while the loader is still up, so it is seen before readiness arrives. - // Requiring "ready" here is what broke the templates. - expect(gameplayPhases[0], "gameplay was held back until startup finished").not.toBe("ready"); - // The gate on compute is the first rendered world, not the readiness phase — a warm-up - // dispatch before anything has been drawn is the cost this exists to avoid. - expect(firstCompute).toBeGreaterThan(firstWorld); - expect(computePhases).not.toHaveLength(0); - expect(firstOverlay).toBeLessThan(firstWorld); + expect(trace.indexOf("overlayRendered")).toBeLessThan(trace.indexOf("worldRendered")); + expect(cloth.steps).toBe(63); } finally { game.stop(); if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame"); else Object.defineProperty(globalThis, "requestAnimationFrame", { value: requestFrame }); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); } }); diff --git a/packages/core/__tests__/warmup.spec.ts b/packages/core/__tests__/warmup.spec.ts index e5bac209b0..d35b12571b 100644 --- a/packages/core/__tests__/warmup.spec.ts +++ b/packages/core/__tests__/warmup.spec.ts @@ -757,3 +757,30 @@ describe("warm-up absolute budget", () => { }, ); }); + +describe("compute warm-up dispatch", () => { + test.each([false, true])( + "WebGPU warm-up uses zero kernel invocations (raw wrapper: %s)", + async (wrapped) => { + const node = {}; + const computeAsync = vi.fn(async () => undefined); + const receiver = { backend: { isWebGPUBackend: true }, computeAsync }; + const report = await warmUpComputeNodes(wrapped ? { raw: receiver } : receiver, [node]); + expect(report.compiled).toBe(1); + expect(computeAsync).toHaveBeenCalledExactlyOnceWith(node, 0); + expect(computeAsync.mock.contexts[0]).toBe(receiver); + }, + ); + + test.each([undefined, { isWebGLBackend: true }])( + "other compute seams retain their existing call (%j)", + async (backend) => { + const node = {}; + const computeAsync = vi.fn(async () => undefined); + const receiver = { backend, computeAsync }; + const report = await warmUpComputeNodes(receiver, [node]); + expect(report.compiled).toBe(1); + expect(computeAsync).toHaveBeenCalledExactlyOnceWith(node); + }, + ); +}); diff --git a/packages/core/src/animation.ts b/packages/core/src/animation.ts index d53ede1c0b..0fe5ddfbb5 100644 --- a/packages/core/src/animation.ts +++ b/packages/core/src/animation.ts @@ -139,6 +139,11 @@ function footPlantSpeed(root: Object3D, clip: AnimationClip): number { quaternion: object.quaternion.clone(), scale: object.scale.clone(), })); + // The root belongs to the game, which turns it with `rotation.y +=`. `quaternion.copy` rewrites + // `rotation` as the equivalent Euler, so a yaw of PI comes back as (PI, 0, PI) and the next + // increment turns the figure the mirrored way. Bones keep the exact quaternion: a round trip + // through Euler would lose precision on every joint. + const rootRotation = root.rotation.clone(); const mixer = new AnimationMixer(root); const action = mixer.clipAction(clip); @@ -166,6 +171,7 @@ function footPlantSpeed(root: Object3D, clip: AnimationClip): number { entry.object.quaternion.copy(entry.quaternion); entry.object.scale.copy(entry.scale); } + root.rotation.copy(rootRotation); root.updateMatrixWorld(true); } diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index 4749176949..a907be8bf9 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -1576,7 +1576,7 @@ class GameImpl, TPhysics> return { ...renderer.surface(), ...(scaler === undefined ? {} : { atFloor: scaler.atFloor }), - ...(compilingInWindow ? { compiling: true } : {}), + compiling: compilingInWindow, }; }, }); @@ -1984,11 +1984,7 @@ class GameImpl, TPhysics> if (this.#scene !== scene || this.#sceneFrame !== frame) return; for (const plugin of this.#activePlugins) plugin.update?.(ctx, dt); this.#entities?.sweep(); - const computeBlockedByStartup = - !worldRendered && - ((this.#warmUpConfiguredExplicitly() && !explicitWarmUpSettled) || - (startupCoverActive() && !this.#warmUpConfiguredExplicitly())); - if (this.#renderer !== undefined && this.#sceneEntered && !computeBlockedByStartup) + if (this.#renderer !== undefined && this.#sceneEntered) this.#computeDriven.process(this.#renderer); }, onFrame: (frameMs) => startupReadiness.observe(frameMs), diff --git a/packages/core/src/warmup.ts b/packages/core/src/warmup.ts index 19fdc98fb6..7117b58d90 100644 --- a/packages/core/src/warmup.ts +++ b/packages/core/src/warmup.ts @@ -322,12 +322,17 @@ export interface IComputeWarmUpReport { } function computeAsyncOf(renderer: IWarmUpRenderer): ((node: unknown) => Promise) | undefined { - if (typeof renderer.computeAsync === "function") return renderer.computeAsync.bind(renderer); - const raw = renderer.raw; - if (typeof raw !== "object" || raw === null) return undefined; - const computeAsync = (raw as { computeAsync?: unknown }).computeAsync; + const receiver = typeof renderer.computeAsync === "function" ? renderer : renderer.raw; + if (typeof receiver !== "object" || receiver === null) return undefined; + const { backend, computeAsync } = receiver as { + backend?: { isWebGPUBackend?: boolean }; + computeAsync?: (node: unknown, dispatchSize?: number) => Promise; + }; if (typeof computeAsync !== "function") return undefined; - return computeAsync.bind(raw) as (node: unknown) => Promise; + // WebGPU compiles zero workgroups without running a solver against its live storage buffers. + return backend?.isWebGPUBackend === true + ? (node) => computeAsync.call(receiver, node, 0) + : computeAsync.bind(receiver); } /** diff --git a/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts index 59fef526fb..23c2807d6c 100644 --- a/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts +++ b/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts @@ -45,7 +45,7 @@ const summary: { sourceSha, pass: false, qualification: - "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.", + "Packaged Linux ARM V8 software-adapter correctness. No mobile or hardware-performance claim.", results: [], }; await write(join(artifacts, "attempt.json"), { @@ -78,8 +78,8 @@ try { assert.equal(process.platform, "linux"); assert.equal(process.arch, "arm64"); const version = execFileSync(runtime, ["--version"], { encoding: "utf8", timeout: 10_000 }); - assert.match(version, /\+ quickjs build\b/iu, "Expected the actual ARM QuickJS host."); - const capabilities = { engine: "quickjs", version: version.trim() }; + assert.match(version, /\+ v8 build\b/iu, "Expected the actual ARM V8 host."); + const capabilities = { engine: "v8", version: version.trim() }; const runtimeBytes = await readFile(runtime); const assets = ["receiver.glb", "assets.manifest.json"]; const assetHashes = Object.fromEntries( diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index e150f20b3c..2c069987e0 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -277,7 +277,7 @@ describe("starter visual floor", () => { ]); expect(scenario.warmupFrames).toBe(1); expect(scenario.steps[1]).toMatchObject({ - holdTicks: 140, + holdTicks: 196, kind: "input", press: "ArrowRight", }); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 6c6de1cf0a..789e9ca752 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -265,7 +265,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { sailing: "73da167ba1fdc6360c58e61b51a2f3dba047fbe1c4e88648e9bce4abb64325da", shooter: "ca09b0b25f3ba689af53772d82be0978d82cdaff8f997b04b160874428ec3e54", snow: "482c1c7f1cd2f233d43cc3fd378c983b4ab8f13dca4bd6fca008e5b120cb0705", - starter: "74df32f5ea8f1aeeea4d2eaf9c423f2fca93fbb1fa392de7acdb6b8bd994db8c", + starter: "fe84f7a992a2ffa07792e10efe4b673e01499f3d4d21bc09dac8e4397645c1c4", "tower-defense": "7d816e530aee8842027a1666782d96c51b2cbfde55c4c23a7cdd1b0fd6a267ac", }; @@ -401,10 +401,10 @@ const STARTER_PATHS = [ "src/ui/main.tsx", "src/ui/App.tsx", "src/state.ts", - // PRD-449: three, and only the three that prove a new game works. The other 21 are engine - // guards in `packages/create-threenative/template-playtests/starter/`, which `pnpm test:templates` - // copies into the scaffold; the "exactly three" assertion below is what keeps them out. + // Ship the starter smoke, outcome, readiness and movement scenarios. Engine guards stay in + // `packages/create-threenative/template-playtests/starter/`, copied by `pnpm test:templates`. "playtests/survives.playtest.json", + "playtests/movement.playtest.json", "playtests/play.playtest.json", "native-playtests/react-hud.playtest.json", "playtests/production-readiness.playtest.json", @@ -938,7 +938,7 @@ describe("create-threenative", () => { readFile(path.join(result.target, relativePath), "utf8"), ).resolves.toBeTruthy(); } - // The whole point of the cut (PRD-449): a new game proves itself with three scenarios, and + // The whole point of the cut (PRD-449): a new game proves itself with four scenarios, and // the engine's own guards live outside the template. `readdir` rather than the STARTER_PATHS // membership, so a file nobody added to the list still fails here. expect( @@ -946,6 +946,7 @@ describe("create-threenative", () => { name.endsWith(".playtest.json"), ), ).toEqual([ + "movement.playtest.json", "play.playtest.json", "production-readiness.playtest.json", "survives.playtest.json", diff --git a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts index 3aaa3b8558..8f4af67b57 100644 --- a/packages/create-threenative/__tests__/template-runtime-cost.spec.ts +++ b/packages/create-threenative/__tests__/template-runtime-cost.spec.ts @@ -18,12 +18,14 @@ import { Registry } from "../../core/src/entities.js"; import { createRandom } from "../../core/src/random.js"; import { rapier } from "../../physics/src/index.js"; import type { IPhysicsContext } from "../../physics/src/plugin.js"; +import type { Player } from "../templates/starter/src/entities/Player.js"; import { templatedRig } from "./templated-rig.js"; /** The clips the packaged `mannequin.glb` ships, by the name `Player.ts` plays them under. */ const MANNEQUIN_CLIPS = [ "Idle_Loop", "Jog_Fwd_Loop", + "Walk_Loop", "Jump_Start", "Jump_Loop", "Jump_Land", @@ -598,6 +600,7 @@ describe("generated template ordinary-frame runtime cost", () => { vi.stubGlobal("Worker", IdleWorker); try { const context = sceneContext(starterPhysics.physics, starter.Play.initialState); + expect(context.state.getState().progress).toBe(0); const play = new starter.Play(); await play.load(context as never); const update = play.enter(context as never) as (ctx: unknown, dt: number) => void; @@ -617,6 +620,19 @@ describe("generated template ordinary-frame runtime cost", () => { expect(context.patchIdentities.size, "starter Play state-patch high-water sentinel").toBe( patchWarmHighWater, ); + const player = context.entities.get("player") as Player; + for (const [x, progress] of [ + [-4, 0], + [-2, 0], + [0, (100 * 2) / 8.4], + [2, (100 * 4) / 8.4], + [6.4, 100], + [8, 100], + ] as const) { + player.body.teleport({ x, y: 0.9, z: 0 }); + update(context, DT); + expect(context.state.getState().progress, `progress at x=${x}`).toBeCloseTo(progress); + } } finally { vi.unstubAllGlobals(); starterPhysics.dispose(); diff --git a/packages/create-threenative/template-playtests/starter/assets.playtest.json b/packages/create-threenative/template-playtests/starter/assets.playtest.json index 488a0dc538..866305a25c 100644 --- a/packages/create-threenative/template-playtests/starter/assets.playtest.json +++ b/packages/create-threenative/template-playtests/starter/assets.playtest.json @@ -18,15 +18,15 @@ { "kind": "input", "label": "run-toward-the-flag", - "press": "ArrowRight", - "holdTicks": 205, + "press": ["ArrowRight", "ShiftLeft"], + "holdTicks": 90, "release": false }, { "kind": "input", "label": "jump-the-gap", - "press": "Space", - "holdTicks": 2, + "press": ["ArrowRight", "ShiftLeft", "Space"], + "holdTicks": 36, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/coyote.playtest.json b/packages/create-threenative/template-playtests/starter/coyote.playtest.json index 7255b0f357..3aaa2460c4 100644 --- a/packages/create-threenative/template-playtests/starter/coyote.playtest.json +++ b/packages/create-threenative/template-playtests/starter/coyote.playtest.json @@ -19,14 +19,14 @@ "kind": "input", "label": "leave-ledge", "press": "ArrowLeft", - "holdTicks": 91, + "holdTicks": 130, "release": true }, { "kind": "input", "label": "off-ledge", "press": "ArrowLeft", - "holdTicks": 3, + "holdTicks": 4, "release": false }, { diff --git a/packages/create-threenative/template-playtests/starter/gameover.playtest.json b/packages/create-threenative/template-playtests/starter/gameover.playtest.json index 731242b8c1..e793260906 100644 --- a/packages/create-threenative/template-playtests/starter/gameover.playtest.json +++ b/packages/create-threenative/template-playtests/starter/gameover.playtest.json @@ -19,7 +19,7 @@ "kind": "input", "label": "walk-off-three-times", "press": "ArrowDown", - "holdTicks": 420, + "holdTicks": 540, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/goal.playtest.json b/packages/create-threenative/template-playtests/starter/goal.playtest.json index 84ee5ea1db..b1d2efc876 100644 --- a/packages/create-threenative/template-playtests/starter/goal.playtest.json +++ b/packages/create-threenative/template-playtests/starter/goal.playtest.json @@ -18,15 +18,15 @@ { "kind": "input", "label": "run-up", - "press": "ArrowRight", - "holdTicks": 205, + "press": ["ArrowRight", "ShiftLeft"], + "holdTicks": 90, "release": false }, { "kind": "input", "label": "jump-the-gap", - "press": "Space", - "holdTicks": 2, + "press": ["ArrowRight", "ShiftLeft", "Space"], + "holdTicks": 36, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/hot-reload.playtest.json b/packages/create-threenative/template-playtests/starter/hot-reload.playtest.json index 095aa49d04..e62a63ca24 100644 --- a/packages/create-threenative/template-playtests/starter/hot-reload.playtest.json +++ b/packages/create-threenative/template-playtests/starter/hot-reload.playtest.json @@ -24,7 +24,7 @@ "kind": "input", "label": "collect", "press": "ArrowRight", - "holdTicks": 100, + "holdTicks": 142, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/look.playtest.json b/packages/create-threenative/template-playtests/starter/look.playtest.json index 27079749b8..acae78d38a 100644 --- a/packages/create-threenative/template-playtests/starter/look.playtest.json +++ b/packages/create-threenative/template-playtests/starter/look.playtest.json @@ -22,7 +22,7 @@ "kind": "input", "label": "move-across-the-platform", "press": "ArrowRight", - "holdTicks": 140, + "holdTicks": 196, "release": true }, { @@ -86,7 +86,9 @@ "tier": "medium" }, "mobile-low": { - "contributions": { "graphOutputChanged": ["bloom", "vignette", "antialias"] }, + "contributions": { + "graphOutputChanged": ["bloom", "vignette", "antialias"] + }, "stages": { "includes": ["bloom", "vignette", "antialias", "grade"], "excludes": ["grain"], @@ -95,7 +97,9 @@ "tier": "low" }, "mobile-mid": { - "contributions": { "graphOutputChanged": ["bloom", "vignette", "antialias"] }, + "contributions": { + "graphOutputChanged": ["bloom", "vignette", "antialias"] + }, "stages": { "includes": ["bloom", "vignette", "antialias", "grade"], "excludes": ["grain"], @@ -104,7 +108,9 @@ "tier": "low" }, "software": { - "contributions": { "graphOutputChanged": ["bloom", "vignette", "antialias"] }, + "contributions": { + "graphOutputChanged": ["bloom", "vignette", "antialias"] + }, "stages": { "includes": ["bloom", "vignette", "antialias", "grade"], "excludes": ["grain"], diff --git a/packages/create-threenative/template-playtests/starter/models.playtest.json b/packages/create-threenative/template-playtests/starter/models.playtest.json index 298122094e..fe5b59fbf1 100644 --- a/packages/create-threenative/template-playtests/starter/models.playtest.json +++ b/packages/create-threenative/template-playtests/starter/models.playtest.json @@ -18,15 +18,15 @@ { "kind": "input", "label": "run-toward-the-flag", - "press": "ArrowRight", - "holdTicks": 205, + "press": ["ArrowRight", "ShiftLeft"], + "holdTicks": 90, "release": false }, { "kind": "input", "label": "jump-the-gap", - "press": "Space", - "holdTicks": 2, + "press": ["ArrowRight", "ShiftLeft", "Space"], + "holdTicks": 36, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/monitoring.playtest.json b/packages/create-threenative/template-playtests/starter/monitoring.playtest.json index d6d1a08391..bd4a99178d 100644 --- a/packages/create-threenative/template-playtests/starter/monitoring.playtest.json +++ b/packages/create-threenative/template-playtests/starter/monitoring.playtest.json @@ -19,21 +19,21 @@ "kind": "input", "label": "collect", "press": "ArrowRight", - "holdTicks": 100, + "holdTicks": 142, "release": true }, { "kind": "input", "label": "leave", "press": "ArrowLeft", - "holdTicks": 25, + "holdTicks": 50, "release": true }, { "kind": "input", "label": "reenter-while-disabled", "press": "ArrowRight", - "holdTicks": 25, + "holdTicks": 50, "release": true } ], diff --git a/packages/create-threenative/template-playtests/starter/respawn.playtest.json b/packages/create-threenative/template-playtests/starter/respawn.playtest.json index 49b9c542a1..262cb4e3ff 100644 --- a/packages/create-threenative/template-playtests/starter/respawn.playtest.json +++ b/packages/create-threenative/template-playtests/starter/respawn.playtest.json @@ -19,13 +19,13 @@ "kind": "input", "label": "run-off-world", "press": "ArrowRight", - "holdTicks": 240, + "holdTicks": 329, "release": true }, { "kind": "wait", "label": "respawn", - "waitTicks": 90, + "waitTicks": 240, "release": true } ], diff --git a/packages/create-threenative/template-playtests/starter/restart.playtest.json b/packages/create-threenative/template-playtests/starter/restart.playtest.json index e2c0143de9..b213292a37 100644 --- a/packages/create-threenative/template-playtests/starter/restart.playtest.json +++ b/packages/create-threenative/template-playtests/starter/restart.playtest.json @@ -19,7 +19,7 @@ "kind": "input", "label": "collected", "press": "ArrowRight", - "holdTicks": 100, + "holdTicks": 142, "release": true }, { diff --git a/packages/create-threenative/template-playtests/starter/textures.playtest.json b/packages/create-threenative/template-playtests/starter/textures.playtest.json index f8312e27b4..594f8cfe02 100644 --- a/packages/create-threenative/template-playtests/starter/textures.playtest.json +++ b/packages/create-threenative/template-playtests/starter/textures.playtest.json @@ -18,15 +18,15 @@ { "kind": "input", "label": "run-toward-the-flag", - "press": "ArrowRight", - "holdTicks": 205, + "press": ["ArrowRight", "ShiftLeft"], + "holdTicks": 90, "release": false }, { "kind": "input", "label": "jump-the-gap", - "press": "Space", - "holdTicks": 2, + "press": ["ArrowRight", "ShiftLeft", "Space"], + "holdTicks": 36, "release": true }, { diff --git a/packages/create-threenative/templates/starter/native-playtests/react-hud.playtest.json b/packages/create-threenative/templates/starter/native-playtests/react-hud.playtest.json index 9e37a51b6e..f15e218c53 100644 --- a/packages/create-threenative/templates/starter/native-playtests/react-hud.playtest.json +++ b/packages/create-threenative/templates/starter/native-playtests/react-hud.playtest.json @@ -14,7 +14,7 @@ "path": "uiReady", "atSteps": [{ "label": "ui-ready", "equals": true }] }, - { "id": "state", "path": "playerX", "changed": true } + { "id": "state", "path": "progress", "changed": true } ] } } diff --git a/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json b/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json index 1bdac043b3..e04dfd68b9 100644 --- a/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json +++ b/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json @@ -7,6 +7,7 @@ "height": 720 }, "warmupFrames": 2, + "startTick": 600, "steps": [ { "kind": "input", diff --git a/packages/create-threenative/templates/starter/playtests/movement.playtest.json b/packages/create-threenative/templates/starter/playtests/movement.playtest.json new file mode 100644 index 0000000000..2660652265 --- /dev/null +++ b/packages/create-threenative/templates/starter/playtests/movement.playtest.json @@ -0,0 +1,192 @@ +{ + "name": "movement", + "target": "web", + "schemaVersion": 1, + "viewport": { + "width": 1920, + "height": 1080 + }, + "warmupFrames": 60, + "steps": [ + { + "kind": "wait", + "label": "play-scene-loads", + "waitTicks": 240, + "release": true + }, + { + "kind": "input", + "label": "forward", + "press": ["KeyW"], + "holdTicks": 40, + "release": false + }, + { + "kind": "input", + "label": "right", + "press": ["KeyD"], + "holdTicks": 40, + "release": false + }, + { + "kind": "input", + "label": "back", + "press": ["KeyS"], + "holdTicks": 40, + "release": false + }, + { + "kind": "input", + "label": "left", + "press": ["KeyA"], + "holdTicks": 40, + "release": false + }, + { + "kind": "input", + "label": "running", + "press": ["KeyD", "ShiftLeft"], + "holdTicks": 30, + "release": false + }, + { + "kind": "input", + "label": "run-back", + "press": ["KeyA", "ShiftLeft"], + "holdTicks": 30, + "release": false + }, + { + "kind": "input", + "label": "stopped", + "press": [], + "holdTicks": 45, + "release": false + }, + { + "kind": "input", + "label": "jump", + "press": ["Space"], + "holdTicks": 3, + "release": false + }, + { + "kind": "input", + "label": "landed", + "press": [], + "holdTicks": 90, + "release": false + }, + { + "kind": "input", + "label": "aligned-with-crate", + "press": ["KeyW"], + "holdTicks": 60, + "release": false + }, + { + "kind": "input", + "label": "pushed-crate", + "press": ["KeyD"], + "holdTicks": 120, + "release": true + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "runtimeReady": true + }, + "components": [ + { + "entity": "player", + "component": "facingAxis", + "atSteps": [ + { + "label": "forward", + "equals": "-z" + }, + { + "label": "right", + "equals": "+x" + }, + { + "label": "back", + "equals": "+z" + }, + { + "label": "left", + "equals": "-x" + } + ] + }, + { + "entity": "player", + "component": "clip", + "atSteps": [ + { + "label": "forward", + "equals": "Walk_Loop" + }, + { + "label": "right", + "equals": "Walk_Loop" + }, + { + "label": "stopped", + "equals": "Idle_Loop" + }, + { + "label": "jump", + "equals": "Jump_Start" + }, + { + "label": "running", + "equals": "Jog_Fwd_Loop" + } + ] + }, + { + "entity": "player", + "component": "strideSynced", + "atSteps": [ + { + "label": "forward", + "equals": true + } + ] + }, + { + "entity": "player", + "component": "position", + "path": "2", + "gte": -1.7, + "lte": -1.3, + "changed": true + }, + { + "entity": "crate", + "component": "position", + "path": "0", + "gte": 0, + "changed": true + }, + { + "entity": "player", + "component": "gait", + "atSteps": [ + { + "label": "forward", + "equals": "walk" + }, + { + "label": "running", + "equals": "run" + } + ] + } + ] + }, + "subject": "player" +} diff --git a/packages/create-threenative/templates/starter/playtests/play.playtest.json b/packages/create-threenative/templates/starter/playtests/play.playtest.json index 892e51884e..d3a45e5fbe 100644 --- a/packages/create-threenative/templates/starter/playtests/play.playtest.json +++ b/packages/create-threenative/templates/starter/playtests/play.playtest.json @@ -17,7 +17,7 @@ { "kind": "input", "press": "ArrowRight", - "holdTicks": 120, + "holdTicks": 169, "release": true }, { @@ -49,11 +49,21 @@ "renderChain": { "tier": "high", "perAdapter": { - "integrated": { "tier": "medium" }, - "mobile-high": { "tier": "medium" }, - "mobile-low": { "tier": "low" }, - "mobile-mid": { "tier": "low" }, - "software": { "tier": "low" } + "integrated": { + "tier": "medium" + }, + "mobile-high": { + "tier": "medium" + }, + "mobile-low": { + "tier": "low" + }, + "mobile-mid": { + "tier": "low" + }, + "software": { + "tier": "low" + } } } } diff --git a/packages/create-threenative/templates/starter/playtests/production-readiness.playtest.json b/packages/create-threenative/templates/starter/playtests/production-readiness.playtest.json index 246574d307..d6d083d66b 100644 --- a/packages/create-threenative/templates/starter/playtests/production-readiness.playtest.json +++ b/packages/create-threenative/templates/starter/playtests/production-readiness.playtest.json @@ -19,7 +19,7 @@ "kind": "input", "label": "collected", "press": "ArrowRight", - "holdTicks": 100, + "holdTicks": 142, "release": true }, { diff --git a/packages/create-threenative/templates/starter/src/entities/Crate.ts b/packages/create-threenative/templates/starter/src/entities/Crate.ts index 5aa2c1c562..7e3d0e78ef 100644 --- a/packages/create-threenative/templates/starter/src/entities/Crate.ts +++ b/packages/create-threenative/templates/starter/src/entities/Crate.ts @@ -27,6 +27,10 @@ export class Crate { }); } + debug(): { position: number[] } { + return { position: this.mesh.position.toArray() }; + } + dispose(): void { this.body.dispose(); this.mesh.removeFromParent(); diff --git a/packages/create-threenative/templates/starter/src/entities/Player.ts b/packages/create-threenative/templates/starter/src/entities/Player.ts index 3d4e97a977..d74be645b1 100644 --- a/packages/create-threenative/templates/starter/src/entities/Player.ts +++ b/packages/create-threenative/templates/starter/src/entities/Player.ts @@ -1,6 +1,14 @@ -import { type ICtx, SkeletalMesh3D } from "@threenative/core"; +import { BlendSpace1D, type ICtx, SkeletalMesh3D } from "@threenative/core"; import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; -import { type AnimationClip, Group, MathUtils, type Mesh, type Object3D, Vector3 } from "three"; +import { + type AnimationClip, + Group, + MathUtils, + type Mesh, + type Object3D, + Quaternion, + Vector3, +} from "three"; import { type IStarterConventions, preparePlayerConventions } from "../conventions.js"; import type { ITouchInput } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -8,16 +16,15 @@ import type { GameState } from "../state.js"; type GameCtx = ICtx; // Tune these two timers for jump feel; they forgive a late or early button press. -// 0.12s is 7.2 ticks at the 1/60 step, which is what a jump scenario measures against: 3 ticks -// walking off the ledge, 5 more airborne, then the jump — the jump lands about 5 ticks after the -// player actually leaves the ground, inside the window. Change this and those two step lengths -// move with it. Tightening the airborne hold does NOT make the scenario safer: with 2 the player -// has not left the ledge yet, and the jump is counted as an ordinary one -// (`jumps: 1, coyoteJumps: 0`), which is what it measured when I tried. +// 0.12s is 7.2 ticks at 60 Hz. The coyote scenario approaches at 1.5 m/s for 130 ticks, +// then holds 4 ticks toward the edge and 5 more before jumping. Easing adds about 8.5 ticks +// of startup lag; the capsule leaves support near the end of the edge hold, about 5–6 ticks before +// the jump, inside the window. Keep these distances and the airborne delay together. const COYOTE_TIME = 0.12; const JUMP_BUFFER = 0.14; const JUMP_SPEED = 5; -const MOVE_SPEED = 2; +const WALK_SPEED = 1.5; +const RUN_SPEED = 5; /** Capsule for a 1.8 m figure: 0.6 m half-height plus a 0.3 m radius at each end. */ const HALF_HEIGHT = 0.6; const RADIUS = 0.3; @@ -30,7 +37,8 @@ const SPAWN = { x: -2, y: PLAYER_STAND_Y, z: 0 } as const; /** The clips `assets/mannequin.glb` ships, by the name this file plays them under. */ const CLIPS = { idle: "Idle_Loop", - jog: "Jog_Fwd_Loop", + walk: "Walk_Loop", + run: "Jog_Fwd_Loop", jumpStart: "Jump_Start", jumpLoop: "Jump_Loop", jumpLand: "Jump_Land", @@ -51,6 +59,8 @@ export class Player { #jumps = 0; #coyoteJumps = 0; #odometer = 0; + #speed = 0; + #gaits = new BlendSpace1D([WALK_SPEED, RUN_SPEED]); #landing = 0; #wasGrounded = true; #supportSurfaceY = 0; @@ -98,6 +108,8 @@ export class Player { autostep: { maxHeight: 0.4, minWidth: 0.2 }, object: this.mesh, physics: ctx.physics, + // Walking into dynamic props transfers the character's motion to them. + pushesDynamicBodies: true, shape: CollisionShape3D.capsule(HALF_HEIGHT, RADIUS), }); } @@ -110,6 +122,14 @@ export class Player { ): void { if (this.#hasPreviousPosition) { this.#odometer += this.mesh.position.distanceTo(this.#previousPosition); + // The solver applies moveAndSlide after update; measure its last completed step. + this.#speed = + dt > 0 + ? Math.hypot( + this.mesh.position.x - this.#previousPosition.x, + this.mesh.position.z - this.#previousPosition.z, + ) / dt + : 0; } const grounded = this.body.grounded; this.#coyoteTime = Math.max(0, this.#coyoteTime - dt); @@ -132,8 +152,13 @@ export class Player { move.y += touch.move.y; move.clampLength(0, 1); } - this.body.velocity.x = move.x * MOVE_SPEED; - this.body.velocity.z = -move.y * MOVE_SPEED; + // Touch walks until the stick is fully deflected; no separate run button. + const running = ctx.input.pressed("run") || (touch?.move.length() ?? 0) >= 1; + const speed = running ? RUN_SPEED : WALK_SPEED; + const ease = 1 - Math.exp(-dt / 0.15); + const moveZ = -move.y; + this.body.velocity.x += (move.x * speed - this.body.velocity.x) * ease; + this.body.velocity.z += (moveZ * speed - this.body.velocity.z) * ease; this.#previousPosition.copy(this.mesh.position); this.body.moveAndSlide(dt); this.#hasPreviousPosition = true; @@ -147,7 +172,7 @@ export class Player { this.#conventions.groundSnap.enabled = canCorrectGrounding; this.#conventions.applyGrounding(this.#supportSurfaceY, dt); this.#captureVisualBodyOffset(); - this.#animate(dt, move.length(), jumped); + this.#animate(dt, move.x, moveZ, jumped); } respawn(): void { @@ -155,10 +180,15 @@ export class Player { this.#coyoteTime = 0; this.#jumpBuffer = 0; this.#hasPreviousPosition = false; + this.#speed = 0; } debug(): { + clip: string | undefined; coyoteJumps: number; + facing: number; + facingAxis: "+x" | "-x" | "+z" | "-z"; + gait: "idle" | "walk" | "run" | "air"; grounded: boolean; groundClearance: number | null; groundCorrectionEnabled: boolean; @@ -167,12 +197,34 @@ export class Player { normaliseFactor: number; odometer: number; position: number[]; + speed: number; + strideSynced: boolean; visualAttached: boolean; visualAttachmentDrift: number; } { const visualAttachmentDrift = this.#visualAttachmentDrift(); + const forward = new Vector3(0, 0, 1).applyQuaternion( + this.character.root.getWorldQuaternion(new Quaternion()), + ); return { + clip: this.character.current, coyoteJumps: this.#coyoteJumps, + facing: Math.atan2(forward.x, forward.z), + facingAxis: + Math.abs(forward.x) > Math.abs(forward.z) + ? forward.x >= 0 + ? "+x" + : "-x" + : forward.z >= 0 + ? "+z" + : "-z", + gait: !this.body.grounded + ? "air" + : this.#speed <= 0.05 + ? "idle" + : this.character.current === CLIPS.run + ? "run" + : "walk", grounded: this.body.grounded, groundClearance: this.#conventions.groundSnap.clearance, groundCorrectionEnabled: this.#conventions.groundSnap.enabled, @@ -181,6 +233,8 @@ export class Player { normaliseFactor: this.#conventions.normaliseFactor, odometer: this.#odometer, position: this.mesh.position.toArray(), + speed: this.#speed, + strideSynced: this.character.stride.synced, visualAttached: visualAttachmentDrift <= VISUAL_ATTACHMENT_TOLERANCE, visualAttachmentDrift, }; @@ -208,10 +262,10 @@ export class Player { } /** Faces the direction of travel and picks a clip from what the body is actually doing. */ - #animate(dt: number, speed: number, jumped: boolean): void { + #animate(dt: number, inputX: number, inputZ: number, jumped: boolean): void { const figure = this.character.root; - if (speed > 0.05) { - const heading = Math.atan2(this.body.velocity.x, this.body.velocity.z); + if (Math.hypot(inputX, inputZ) > 0.05) { + const heading = Math.atan2(inputX, inputZ); const turn = MathUtils.euclideanModulo(heading - figure.rotation.y + Math.PI, Math.PI * 2); figure.rotation.y += (turn - Math.PI) * Math.min(1, dt * 12); } @@ -223,8 +277,15 @@ export class Player { else if (grounded && !this.#wasGrounded) { this.#landing = 0.25; this.character.play(CLIPS.jumpLand, { fade: 0.06, mode: "once" }); - } else if (grounded && this.#landing <= 0) - this.character.play(speed > 0.05 ? CLIPS.jog : CLIPS.idle, { fade: 0.2 }); + } else if (grounded && this.#landing <= 0) { + if (this.#speed > 0.05) { + const weights = this.#gaits.sample(this.#speed); + this.character.playWeighted([ + { clip: CLIPS.walk, weight: weights[0] ?? 0 }, + { clip: CLIPS.run, weight: weights[1] ?? 0 }, + ]); + } else this.character.play(CLIPS.idle, { fade: 0.2 }); + } this.#wasGrounded = grounded; this.character.update(dt); } diff --git a/packages/create-threenative/templates/starter/src/game.ts b/packages/create-threenative/templates/starter/src/game.ts index fc944b0b3e..0c5d17d474 100644 --- a/packages/create-threenative/templates/starter/src/game.ts +++ b/packages/create-threenative/templates/starter/src/game.ts @@ -19,6 +19,7 @@ const game = defineGame({ right: ["ArrowRight", "KeyD"], up: ["ArrowUp", "KeyW"], }, + run: { keys: ["ShiftLeft", "ShiftRight"] }, jump: { buttons: [0], keys: ["Space"] }, flagGust: { keys: ["KeyG"] }, restart: { keys: ["KeyR"] }, diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index a004f657bf..0c6191a48f 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -67,6 +67,7 @@ export class Play extends Scene { paused: false, peakRise: 0, playerX: -2, + progress: 0, respawns: 0, score: 0, status: "playing", @@ -230,7 +231,7 @@ export class Play extends Scene { ); ctx.add(fixed(floorMesh)); ctx.add(fixed(floorBase)); - new Crate(ctx, levelX, 4, -1.5, propMaterial); + ctx.entities.add("crate", new Crate(ctx, levelX, 4, -1.5, propMaterial)); const pickupBase = block(0.42, 0.14, 0.42, propMaterial); const pickupStem = tube(0.08, 0.08, 0.3, propMaterial); const pickupOrb = ball(0.16, propMaterial); @@ -379,6 +380,15 @@ export class Play extends Scene { // 1.8 m figure would report its own height as a jump. frameState.peakRise = Math.max(previous.peakRise, player.mesh.position.y - PLAYER_STAND_Y); frameState.playerX = player.mesh.position.x; + frameState.progress = Math.max( + 0, + Math.min( + 100, + ((frameState.playerX - Play.initialState.playerX) / + (ISLAND.x - ISLAND.width / 2 - Play.initialState.playerX)) * + 100, + ), + ); frameState.respawns = previous.respawns + (respawned ? 1 : 0); const current = frameCtx.state.getState(); const changed = @@ -391,6 +401,7 @@ export class Play extends Scene { frameState.odometer !== current.odometer || frameState.peakRise !== current.peakRise || frameState.playerX !== current.playerX || + frameState.progress !== current.progress || frameState.respawns !== current.respawns; if (changed) frameCtx.state.set(frameState); if (respawned) frameCtx.state.flush(); diff --git a/packages/create-threenative/templates/starter/src/state.ts b/packages/create-threenative/templates/starter/src/state.ts index 2e8b6cff78..a89323507a 100644 --- a/packages/create-threenative/templates/starter/src/state.ts +++ b/packages/create-threenative/templates/starter/src/state.ts @@ -15,6 +15,7 @@ export type GameState = { uiReady: boolean; peakRise: number; playerX: number; + progress: number; respawns: number; score: number; /** The run: "playing" until the flag is reached ("won") or the last life is gone ("lost"). */ diff --git a/packages/create-threenative/templates/starter/src/ui/Hud.tsx b/packages/create-threenative/templates/starter/src/ui/Hud.tsx index 487822077e..6ae3a94ef0 100644 --- a/packages/create-threenative/templates/starter/src/ui/Hud.tsx +++ b/packages/create-threenative/templates/starter/src/ui/Hud.tsx @@ -16,7 +16,7 @@ export function Hud() { // Nothing to draw until the game publishes its first snapshot, a few milliseconds in. Rendering // zeroes instead would put a wrong score on screen and then correct it. if (state === undefined) return null; - const position = Math.max(0, Math.min(100, Math.abs(state.playerX) * 10)); + const position = state.progress; const won = state.status === "won"; return ( diff --git a/packages/create-threenative/templates/starter/src/ui/Menu.tsx b/packages/create-threenative/templates/starter/src/ui/Menu.tsx index 4d3c4135f2..70665ec956 100644 --- a/packages/create-threenative/templates/starter/src/ui/Menu.tsx +++ b/packages/create-threenative/templates/starter/src/ui/Menu.tsx @@ -43,7 +43,7 @@ export function Menu() { > restart - WASD / arrows to move · space to jump the gap · reach the flag + WASD / arrows to move · shift to run · space to jump the gap · reach the flag ); } diff --git a/packages/physics/__tests__/character.spec.ts b/packages/physics/__tests__/character.spec.ts index 3fe64753d3..421f33a4f9 100644 --- a/packages/physics/__tests__/character.spec.ts +++ b/packages/physics/__tests__/character.spec.ts @@ -4,6 +4,7 @@ import { BoxGeometry, Group, Mesh, Vector3 } from "three"; import { afterEach, describe, expect, it } from "vitest"; import "../src/index.js"; import { FixedStepLoop } from "../../core/src/loop.js"; +import { roundedBox } from "../../create-threenative/templates/starter/src/render/shapes.js"; import { CharacterBody3D } from "../src/CharacterBody3D.js"; import { CollisionShape3D } from "../src/CollisionShape3D.js"; import { RigidBody3D } from "../src/RigidBody3D.js"; @@ -44,6 +45,65 @@ afterEach(() => { }); describe("CharacterBody3D", () => { + it("falls beside a trimesh box while moving into its vertical face", async () => { + const { ctx, plugin } = await setup(); + const wall = new Mesh(new BoxGeometry(3, 10, 2.6)); + wall.position.set(1.5, -5, 0); + const body = new RigidBody3D({ + object: wall, + physics: ctx.physics, + shape: CollisionShape3D.fromMesh(wall, "trimesh"), + type: "fixed", + }); + const mesh = new Group(); + mesh.position.set(-0.32, -1, 0); + const character = new CharacterBody3D({ + autostep: { maxHeight: 0.4, minWidth: 0.2 }, + object: mesh, + physics: ctx.physics, + shape: CollisionShape3D.capsule(0.6, 0.3), + }); + for (let tick = 0; tick < 60; tick += 1) { + character.velocity.x = 2; + character.moveAndSlide(1 / 60); + plugin.update?.(ctx, 1 / 60); + expect(character.grounded, `tick ${tick}`).toBe(false); + } + expect(mesh.position.y).toBeLessThan(-2); + character.dispose(); + body.dispose(); + }); + + it("falls beside the starter's rounded island wall instead of reading it as ground", async () => { + const { ctx, plugin } = await setup(); + // The template's `roundedBox` bevels with a 3x3 face grid. Pressed into its side, a capsule + // used to report `grounded` and sink at 0.16 m/s along a wall, never reaching the kill plane. + const wall = new Mesh(roundedBox(3, 5.4, 2.6, 0.1).clone().translate(7.9, -3.3, 0)); + const body = new RigidBody3D({ + object: wall, + physics: ctx.physics, + shape: CollisionShape3D.fromMesh(wall, "trimesh"), + type: "fixed", + }); + const mesh = new Group(); + mesh.position.set(6, -0.2, 0); + const character = new CharacterBody3D({ + autostep: { maxHeight: 0.4, minWidth: 0.2 }, + object: mesh, + physics: ctx.physics, + shape: CollisionShape3D.capsule(0.6, 0.3), + }); + for (let tick = 0; tick < 90; tick += 1) { + character.velocity.x = 1.5; + character.moveAndSlide(1 / 60); + plugin.update?.(ctx, 1 / 60); + } + expect(character.grounded).toBe(false); + expect(mesh.position.y).toBeLessThan(-3); + character.dispose(); + body.dispose(); + }); + it("should accumulate gravity into velocity when airborne", async () => { const { ctx, plugin } = await setup(); const mesh = new Mesh(new BoxGeometry(0.6, 1, 0.6)); diff --git a/packages/physics/src/simulation.ts b/packages/physics/src/simulation.ts index 7d862f702a..e5f2d69306 100644 --- a/packages/physics/src/simulation.ts +++ b/packages/physics/src/simulation.ts @@ -757,7 +757,11 @@ export function createWebPhysicsShape( else if (shape.kind === "trimesh") { if (shape.vertices === undefined || shape.indices === undefined) throw new Error("CollisionShape3D.trimesh is missing mesh data."); - descriptor = rapier.ColliderDesc.trimesh(shape.vertices, shape.indices); + descriptor = rapier.ColliderDesc.trimesh( + shape.vertices, + shape.indices, + rapier.TriMeshFlags.FIX_INTERNAL_EDGES, + ); } else if (shape.kind === "convexHull") { if (shape.vertices === undefined) throw new Error("CollisionShape3D.convexHull is missing vertices."); @@ -841,10 +845,17 @@ function characterState( let groundCollider: number | undefined; let groundBody: IPhysicsBodyHandle | undefined; let groundNormal: IPhysicsVector3 | undefined; + // Rapier flags a contact as ground at any positive up component, so a wall that leans back a + // degree reads as floor. When the only contacts are such walls, the character is not grounded; + // a snap-to-ground step records no contact, so no contacts leaves Rapier's answer alone. + let walkable = false; + let contacts = 0; for (let index = 0; index < controller.numComputedCollisions(); index += 1) { const collision = controller.computedCollision(index); if (collision?.collider === null || collision?.collider === undefined) continue; + contacts += 1; if ((collision.normal1.y ?? Number.NEGATIVE_INFINITY) >= 0.5) { + walkable = true; const contacted = byCollider.get(collision.collider.handle); if (contacted !== undefined) { groundCollider = contacted.id; @@ -854,7 +865,7 @@ function characterState( } } } - const grounded = controller.computedGrounded(); + const grounded = controller.computedGrounded() && (walkable || contacts === 0); if (groundCollider !== undefined) simulationBody.groundCollider = groundCollider; else if (!grounded) simulationBody.groundCollider = undefined; state.grounded = grounded; diff --git a/packages/playtest/AGENTS.md b/packages/playtest/AGENTS.md index fa40754c34..0d299e4079 100644 --- a/packages/playtest/AGENTS.md +++ b/packages/playtest/AGENTS.md @@ -117,6 +117,12 @@ the native host, which installs the same global before the game module loads. Ev says `clock: "wall-clock"`. A counted desktop run refuses an inherited `TN_PLAYTEST_CLOCK`. Full rule: [docs/determinism.md](docs/determinism.md). +Optional scenario `startTick` is an absolute nonnegative safe integer fixed-step anchor, reached +after startup readiness and warmup, before baseline observations or input. It requires +`runtime.fixedStep` and an observed fixed-step clock; `--live-clock`, missing ticks, startup +overshoot and a resampled tick unequal to the anchor fail closed. It neither resets the game +nor changes relative step counts. Absent `startTick` leaves existing behavior unchanged. + ## Where things live | Concern | File | diff --git a/packages/playtest/CLAUDE.md b/packages/playtest/CLAUDE.md index 1169e3d212..30e25225ea 100644 --- a/packages/playtest/CLAUDE.md +++ b/packages/playtest/CLAUDE.md @@ -119,6 +119,12 @@ the native host, which installs the same global before the game module loads. Ev says `clock: "wall-clock"`. A counted desktop run refuses an inherited `TN_PLAYTEST_CLOCK`. Full rule: [docs/determinism.md](docs/determinism.md). +Optional scenario `startTick` is an absolute nonnegative safe integer fixed-step anchor, reached +after startup readiness and warmup, before baseline observations or input. It requires +`runtime.fixedStep` and an observed fixed-step clock; `--live-clock`, missing ticks, startup +overshoot and a resampled tick unequal to the anchor fail closed. It neither resets the game +nor changes relative step counts. Absent `startTick` leaves existing behavior unchanged. + ## Where things live | Concern | File | diff --git a/packages/playtest/__tests__/bridgeClient.spec.ts b/packages/playtest/__tests__/bridgeClient.spec.ts index 7c00838c18..3f2221c22f 100644 --- a/packages/playtest/__tests__/bridgeClient.spec.ts +++ b/packages/playtest/__tests__/bridgeClient.spec.ts @@ -277,3 +277,23 @@ test("routes bulk advance and ordinary bridge calls through their intended timeo { method: "ready", timeoutMs: PLAYTEST_PROTOCOL_LIMITS.operationTimeoutMs }, ]); }); + + +test("startTick alone requires the fixed-step provider during the handshake", async () => { + const description: IPlaytestBridgeDescription = { + capabilities: [], limits: PLAYTEST_PROTOCOL_LIMITS, name: "anchor", protocolVersion: PLAYTEST_PROTOCOL_VERSION, + }; + const transport: IBridgeTransport = { + capabilities: [], + call: async (method: string) => (method === "describe" ? description : { ready: true }) as T, + close: async () => undefined, + waitForBridge: async () => true, + }; + const scenario: IPlaytestScenario = { + artifacts: { screenshots: false }, name: "anchor", schemaVersion: 1, startTick: 0, + steps: [{ release: true, waitFrames: 1 }], target: "web", viewport: { height: 180, width: 320 }, warmupFrames: 0, + }; + await expect(connectPlaytestBridgeTransport(transport, scenario)).rejects.toThrow("runtime.fixedStep"); + description.capabilities = ["runtime.fixedStep"]; + await expect(connectPlaytestBridgeTransport(transport, scenario)).resolves.toBeDefined(); +}); diff --git a/packages/playtest/__tests__/desktop-playtest.spec.ts b/packages/playtest/__tests__/desktop-playtest.spec.ts index c8579402ec..35edfdd993 100644 --- a/packages/playtest/__tests__/desktop-playtest.spec.ts +++ b/packages/playtest/__tests__/desktop-playtest.spec.ts @@ -654,7 +654,28 @@ test.skipIf(process.platform === "win32")("desktop screenshot timeout can be rai } }); +test("desktop startTick-only alignment follows readiness and warmup, before baseline and input", async () => { + const events: string[] = []; + const { report } = await runDesktopScenario(2, { startTick: 10, startupReadyAtTick: 2, events }); + expect(report.pass).toBe(true); + expect(report.before?.tick).toBe(10); + expect(report.after?.tick).toBe(13); + expect(report.frames).toBe(3); + const ready = events.indexOf("ready:2"); + const aligned = events.indexOf("advance:2:8"); + const baseline = events.indexOf("baseline:10"); + const input = events.indexOf("keydown:10"); + expect(ready).toBeGreaterThan(-1); + expect(aligned).toBeGreaterThan(ready); + expect(events.slice(ready + 1, aligned)).toEqual(["sample:2", "sample:2", "sample:2"]); + expect(baseline).toBeGreaterThan(aligned); + expect(input).toBeGreaterThan(baseline); +}); + interface IDesktopScenarioOptions { + startTick?: number; + startupReadyAtTick?: number; + events?: string[]; network?: JsonValue; assertNetwork?: boolean; console?: { text: string; type: string }[]; @@ -685,6 +706,7 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario await writeFile(scenarioPath, JSON.stringify({ artifacts: { screenshots: options.screenshots ?? false }, assert: { movement: { entity: "player", minDistance }, ...(options.assertNetwork ? { diagnostics: { noNetworkErrors: true } } : {}) }, + ...(options.startTick === undefined ? {} : { startTick: options.startTick }), name: "desktop-cross-target-scenario", schemaVersion: 1, steps: [{ @@ -696,12 +718,30 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario subject: "player", target: "desktop", viewport: { height: 360, width: 640 }, - warmupFrames: 0, + warmupFrames: options.startTick === undefined ? 0 : 2, })); const endpoint = `http://127.0.0.1:${await availablePort()}/playtest`; const moving = movingBridge(); + const startupReadyAtTick = options.startupReadyAtTick; + if (startupReadyAtTick !== undefined) { + const describeStartup = moving.bridge.describe; + moving.bridge.describe = async () => ({ ...await describeStartup(), capabilities: [...(await describeStartup()).capabilities, "runtime.startup"] }); + moving.bridge.ready = () => { + options.events?.push(`ready:${moving.tick()}`); + return { ready: true, startup: { phase: moving.tick() >= startupReadyAtTick ? "ready" : "collapsing", progress: 1 } }; + }; + } + const advance = moving.bridge.advance; + if (advance === undefined) throw new Error("Desktop fixture requires its fixed-step advance provider."); + moving.bridge.advance = async (ticks) => { + options.events?.push(`advance:${moving.tick()}:${ticks}`); + return advance(ticks); + }; const sample = moving.bridge.sample; - moving.bridge.sample = async (request) => ({ ...await sample(request), network: (options.network === undefined ? { requests: 0, entries: [] } : options.network) as IPlaytestObservationSnapshot["network"] }); + moving.bridge.sample = async (request) => { + options.events?.push(`${request.include?.length === 0 ? "sample" : "baseline"}:${moving.tick()}`); + return { ...await sample(request), network: (options.network === undefined ? { requests: 0, entries: [] } : options.network) as IPlaytestObservationSnapshot["network"] }; + }; const describe = moving.bridge.describe; moving.bridge.describe = async () => ({ ...await describe(), capabilities: [...(await describe()).capabilities, "runtime.network"] }); const driver = new FakeDesktopDriver( @@ -720,7 +760,7 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario const previous = host.__THREENATIVE_NATIVE__; host.__THREENATIVE_NATIVE__ = { playtestInput: { - keyboard: (type) => moving.setHeld(type === "keydown"), + keyboard: (type) => { options.events?.push(`${type}:${moving.tick()}`); moving.setHeld(type === "keydown"); }, pointer: () => undefined, }, }; diff --git a/packages/playtest/__tests__/fixtures/app.html b/packages/playtest/__tests__/fixtures/app.html index 15a763de19..346ff7c7ca 100644 --- a/packages/playtest/__tests__/fixtures/app.html +++ b/packages/playtest/__tests__/fixtures/app.html @@ -17,6 +17,7 @@ // not a fixture bug. const params = new URLSearchParams(location.search); const mode = params.get("mode") ?? "good"; + const fixedClock = mode === "physics" || params.get("clock") === "fixed-step"; const canvas = document.getElementById("view"); let startupPollsRemaining = mode === "startup-delayed" ? 3 : Infinity; @@ -224,7 +225,7 @@ } requestAnimationFrame(function frame() { - if (mode !== "physics") step(); + if (!fixedClock) step(); requestAnimationFrame(frame); }); @@ -268,7 +269,8 @@ "entity.observe", "entity.setup", "runtime.resources", - ...(mode === "physics" ? ["runtime.fixedStep", "runtime.physics"] : []), + ...(fixedClock ? ["runtime.fixedStep"] : []), + ...(mode === "physics" ? ["runtime.physics"] : []), ...(mode === "startup-delayed" || mode === "startup-stuck" ? ["runtime.startup"] : []), ], limits: { @@ -318,7 +320,7 @@ const diagnostics = runtimeDiagnostics(); const contract = bridge.describe(); return { - clock: mode === "physics" + clock: fixedClock ? { mode: "fixed-step", tick } : { mode: "render-frame", timeMs: tick * 16 }, // `silent-drop` answers every sample with zero entities. The runner has diff --git a/packages/playtest/__tests__/runner-orchestration.spec.ts b/packages/playtest/__tests__/runner-orchestration.spec.ts index ed7372c4a1..fb9e27c6a0 100644 --- a/packages/playtest/__tests__/runner-orchestration.spec.ts +++ b/packages/playtest/__tests__/runner-orchestration.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { boundedTeardownStep, settledTeardownValue } from "../src/runner/server.js"; import { @@ -185,3 +185,51 @@ describe("teardown reaches a browser that is still launching", () => { .toBeUndefined(); }); }); + + +describe("absolute scenario start tick", () => { + async function source(ticks: unknown[] = [3, 10], mode = "fixed-step", capabilities = ["runtime.fixedStep"]) { + const { alignScenarioStartTick } = await import("../src/runner/runner-support.js"); + const sample = vi.fn(async () => ({ clock: { mode, tick: ticks.shift() } })); + const advance = vi.fn(async () => undefined); + const bridge = { description: { capabilities }, sample, advance }; + return { align: (startTick?: number, liveClock = false) => alignScenarioStartTick(bridge as never, startTick, liveClock), sample, advance }; + } + test("absent alignment leaves the bridge untouched", async () => { + const bridge = await source(); + await bridge.align(); + expect(bridge.sample).not.toHaveBeenCalled(); + expect(bridge.advance).not.toHaveBeenCalled(); + }); + test("advances exactly the remaining ticks then verifies the authoritative clock", async () => { + const bridge = await source(); + await bridge.align(10); + expect(bridge.advance).toHaveBeenCalledExactlyOnceWith(7); + expect(bridge.sample).toHaveBeenCalledTimes(2); + }); + test.each([0, 10])("an already aligned clock %s is resampled without advancing", async (tick) => { + const bridge = await source([tick, tick]); + await bridge.align(tick); + expect(bridge.advance).not.toHaveBeenCalled(); + expect(bridge.sample).toHaveBeenCalledTimes(2); + }); + test.each([undefined, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])("rejects invalid observed tick %s", async (tick) => { + const bridge = await source([tick]); + await expect(bridge.align(10)).rejects.toThrow("startTick"); + expect(bridge.advance).not.toHaveBeenCalled(); + }); + test("rejects overshoot and drift, including drift from an already aligned clock", async () => { + for (const ticks of [[11], [3, 11], [10, 11]]) { + const bridge = await source(ticks); + await expect(bridge.align(10)).rejects.toThrow("startTick"); + } + }); + test("rejects live, nonfixed, missing capability and missing bridge", async () => { + const live = await source(); + await expect(live.align(10, true)).rejects.toThrow("startTick"); + await expect((await source([3], "wall-clock")).align(10)).rejects.toThrow("startTick"); + await expect((await source([3], "fixed-step", [])).align(10)).rejects.toThrow("startTick"); + const { alignScenarioStartTick } = await import("../src/runner/runner-support.js"); + await expect(alignScenarioStartTick(undefined, 10)).rejects.toThrow("startTick"); + }); +}); diff --git a/packages/playtest/__tests__/runner.spec.ts b/packages/playtest/__tests__/runner.spec.ts index 336859a9a2..6b68de8042 100644 --- a/packages/playtest/__tests__/runner.spec.ts +++ b/packages/playtest/__tests__/runner.spec.ts @@ -1079,7 +1079,7 @@ test("buttonless pointer movement drives anonymous movement through the browser } }, 60_000); -test("browser runner samples only after the advertised startup phase is ready", async () => { +test.each([undefined, 10])("browser runner samples after startup and optional startTick %s", async (startTick) => { const fixtureHtml = await readFile(new URL("./fixtures/app.html", import.meta.url), "utf8"); const server = createServer((_request, response) => { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); @@ -1094,6 +1094,7 @@ test("browser runner samples only after the advertised startup phase is ready", JSON.stringify({ artifacts: { screenshots: false }, assert: { movement: { entity: "player", minDistance: 0.2 } }, + ...(startTick === undefined ? {} : { startTick }), name: "runner-startup", schemaVersion: 1, steps: [{ holdFrames: 10, press: "KeyW", release: true }], @@ -1112,10 +1113,15 @@ test("browser runner samples only after the advertised startup phase is ready", scenarioPath: "scenario.json", timeoutMs: 15_000, trace: false, - url: `http://127.0.0.1:${address.port}/?mode=startup-delayed`, + url: `http://127.0.0.1:${address.port}/?mode=startup-delayed${startTick === undefined ? "" : "&clock=fixed-step"}`, }); - expect(report.pass).toBe(true); + expect(report.pass, JSON.stringify(report.diagnostics)).toBe(true); + if (startTick !== undefined) { + expect(report.before?.tick).toBe(startTick); + expect(report.after?.tick).toBe(startTick + 10); + } + expect(report.frames).toBe(10); expect(report.startup).toMatchObject({ phase: "ready", rule: "sustained-frames" }); expect(report.observations?.resources.startup).toEqual({ before: { sampledBeforeReady: false }, diff --git a/packages/playtest/__tests__/scenario.spec.ts b/packages/playtest/__tests__/scenario.spec.ts index 3f600e0f60..c1e340307b 100644 --- a/packages/playtest/__tests__/scenario.spec.ts +++ b/packages/playtest/__tests__/scenario.spec.ts @@ -1001,3 +1001,20 @@ test("should evaluate all registered families through the public entry", async ( expect(mapped || entry.kind === "camera", `RED observed: registered family has no evaluator for '${entry.kind}'`).toBe(true); } }); + + +test("startTick alone requires fixed-step capability and preserves safe integer anchors", async () => { + const directory = await makeTempDir("playtest-start-tick-"); + for (const startTick of [0, 600, Number.MAX_SAFE_INTEGER]) { + await writeFile(join(directory, "scenario.json"), JSON.stringify({ name: "anchor", schemaVersion: 1, startTick, steps: [{ waitFrames: 1 }] })); + const parsed = await loadPlaytestScenario(directory, "scenario.json"); + expect(parsed.startTick).toBe(startTick); + expect(requiredPlaytestCapabilities(parsed)).toContain("runtime.fixedStep"); + } +}); + +test.each([-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "600", null, true])("startTick rejects invalid anchor %s", async (startTick) => { + const directory = await makeTempDir("playtest-start-tick-invalid-"); + await writeFile(join(directory, "scenario.json"), JSON.stringify({ name: "anchor", schemaVersion: 1, startTick, steps: [{ waitFrames: 1 }] })); + await expect(loadPlaytestScenario(directory, "scenario.json")).rejects.toThrow("startTick"); +}); diff --git a/packages/playtest/src/assertion-schema.ts b/packages/playtest/src/assertion-schema.ts index 215a55a898..8fe5323a2c 100644 --- a/packages/playtest/src/assertion-schema.ts +++ b/packages/playtest/src/assertion-schema.ts @@ -671,6 +671,7 @@ export function requiredPlaytestCapabilities( target?: string, ): PlaytestCapability[] { const required = new Set(); + if (scenario.startTick !== undefined) required.add("runtime.fixedStep"); if (scenario.steps.some((step) => step.waitForResource !== undefined)) { required.add("runtime.resources"); } diff --git a/packages/playtest/src/diagnostics.ts b/packages/playtest/src/diagnostics.ts index c9ef167cdc..05b4213900 100644 --- a/packages/playtest/src/diagnostics.ts +++ b/packages/playtest/src/diagnostics.ts @@ -20,6 +20,7 @@ export type PlaytestDiagnosticCode = | "TN_PLAYTEST_PAYLOAD_TOO_LARGE" | "TN_PLAYTEST_SERVER_FAILED" | "TN_PLAYTEST_SETUP_UNAPPLIED" + | "TN_PLAYTEST_START_TICK_INVALID" | "TN_PLAYTEST_STARTUP_ABORTED" | "TN_PLAYTEST_STARTUP_HOST_EXITED" | "TN_PLAYTEST_STARTUP_NOT_READY" diff --git a/packages/playtest/src/runner/androidRunner.ts b/packages/playtest/src/runner/androidRunner.ts index 038cff16a5..822f910f32 100644 --- a/packages/playtest/src/runner/androidRunner.ts +++ b/packages/playtest/src/runner/androidRunner.ts @@ -33,6 +33,7 @@ import { PlaytestBridgeError, type IPlaytestBridgeClient, } from "./bridgeClient.js"; +import { alignScenarioStartTick } from "./runner-support.js"; import { waitForStartupReady } from "./startupReady.js"; import { waitForResource } from "./wait-for-resource.js"; import type { IStandalonePlaytestConfig } from "./config.js"; @@ -261,6 +262,7 @@ async function runDevicePlaytestInternal( } await throwIfAborted(target); + await alignScenarioStartTick(bridge, scenario.startTick, config.liveClock === true); const entityIds = observedEntityIds(scenario); const sampleRequest = { ...(entityIds === undefined ? {} : { entities: entityIds }), diff --git a/packages/playtest/src/runner/runner-support.ts b/packages/playtest/src/runner/runner-support.ts index a0d96d1589..9b0bf8ba25 100644 --- a/packages/playtest/src/runner/runner-support.ts +++ b/packages/playtest/src/runner/runner-support.ts @@ -72,6 +72,40 @@ import type { IStandalonePlaytestReport } from "./shared.js"; import type { ILabeledPlaytestSample, IMovementSampleInterval, IRunnerConsoleEntry } from "./shared.js"; // Extracted verbatim from runner.ts (PRD-182 Phase 4); do not edit semantics here. +/** Align only explicitly anchored scenarios; sample again even when already at the target. */ +export async function alignScenarioStartTick( + bridge: IPlaytestBridgeClient | undefined, + startTick: number | undefined, + liveClock = false, +): Promise { + if (startTick === undefined) return; + const fail = (reason: string): never => { + throw new PlaytestBridgeError(playtestDiagnostic( + "TN_PLAYTEST_START_TICK_INVALID", + `Scenario startTick ${startTick}: ${reason}`, + "Use a fixed-step bridge and an explicit safe integer anchor at or after startup readiness.", + )); + }; + if (!Number.isSafeInteger(startTick) || startTick < 0) fail("anchor must be a nonnegative safe integer."); + const client = bridge ?? fail("requires an installed bridge."); + if (liveClock || !client.description.capabilities.includes("runtime.fixedStep")) { + fail("requires a counted runtime.fixedStep bridge, without --live-clock."); + } + const readTick = async (): Promise => { + const { clock } = await client.sample({ entities: [], include: [] }); + const tick = clock.tick; + if (clock.mode !== "fixed-step" || typeof tick !== "number" || !Number.isSafeInteger(tick) || tick < 0) { + return fail("requires an observed fixed-step clock with a nonnegative safe integer tick."); + } + return tick; + }; + const tick = await readTick(); + if (tick > startTick) fail(`startup already passed the anchor at tick ${tick}.`); + if (tick < startTick) await client.advance(startTick - tick); + const aligned = await readTick(); + if (aligned !== startTick) fail(`clock reached tick ${aligned} instead of the exact anchor.`); +} + export function preflightDisplay( config: Pick, scenario: Pick, diff --git a/packages/playtest/src/runner/runner.ts b/packages/playtest/src/runner/runner.ts index 6f13946724..50aded52f7 100644 --- a/packages/playtest/src/runner/runner.ts +++ b/packages/playtest/src/runner/runner.ts @@ -3,7 +3,7 @@ import type { IPlaytestToneCaptureObservation } from "../tone.js"; import { waitFrames, captureVisualSurface, runStep, sampleVisualElementBounds, screenshotObservations, sampleAfterTransition } from "./steps.js"; import type { StepInputState } from "./steps.js"; import { withPerformanceBudget } from "./buildReport.js"; -import { preflightDisplay, acquireRunnerCaptureLock, provideRunDisplay, buildReport, addPreflightDiagnostic, isJudgeMarkerRequestFailure } from "./runner-support.js"; +import { alignScenarioStartTick, preflightDisplay, acquireRunnerCaptureLock, provideRunDisplay, buildReport, addPreflightDiagnostic, isJudgeMarkerRequestFailure } from "./runner-support.js"; import type { IPageLifecycle } from "./server.js"; import { stopManagedServer, boundedTeardownStep, settledTeardownValue, assertManagedUrlAvailable, startManagedServer, waitForUrl, openPageAndConnectBridge, pageLifecycleDiagnostic, findFreePort, withPort } from "./server.js"; import { sampleHud } from "./sampling.js"; @@ -467,6 +467,7 @@ async function runStandalonePlaytestInternal( declaredSoftware: activeConfig.allowSoftwareAdapter === true, pump: () => waitFrames(activePage, 1), }); + await alignScenarioStartTick(bridge, scenario.startTick, activeConfig.liveClock === true); const runtimeReady = await page.evaluate(() => document.readyState !== "loading" && document.querySelector("canvas") !== null, ).catch(() => false); diff --git a/packages/playtest/src/scenario/schema-base.ts b/packages/playtest/src/scenario/schema-base.ts index 8a287a692a..4f617236f0 100644 --- a/packages/playtest/src/scenario/schema-base.ts +++ b/packages/playtest/src/scenario/schema-base.ts @@ -769,6 +769,8 @@ export interface IPlaytestScenario { schemaVersion: 1; setup?: IPlaytestScenarioSetup; sourcePath?: string; + /** Absolute fixed-step tick reached after startup and warmup, before baseline and input. */ + startTick?: number; steps: IPlaytestStep[]; subject?: string; target: PlaytestTarget; diff --git a/packages/playtest/src/scenario/schema-validate.ts b/packages/playtest/src/scenario/schema-validate.ts index 6e6a829658..5f57cecd78 100644 --- a/packages/playtest/src/scenario/schema-validate.ts +++ b/packages/playtest/src/scenario/schema-validate.ts @@ -19,6 +19,7 @@ export const PLAYTEST_ROOT_KEYS = [ "reducedMotion", "schemaVersion", "setup", + "startTick", "steps", "subject", "target", @@ -44,6 +45,9 @@ export function validatePlaytestScenario(value: unknown, scenarioPath: string, a if (value.awaitStartup !== undefined && typeof value.awaitStartup !== "boolean") { throw invalidScenario(scenarioPath, "Scenario awaitStartup must be a boolean when present."); } + if (value.startTick !== undefined && (!Number.isSafeInteger(value.startTick) || (value.startTick as number) < 0)) { + throw invalidScenario(scenarioPath, "Scenario startTick must be a nonnegative safe integer when present."); + } if (value.bootFailure !== undefined && value.bootFailure !== "renderer-no-adapter") { throw invalidScenario(scenarioPath, "Scenario bootFailure must be 'renderer-no-adapter' when present."); } @@ -102,6 +106,7 @@ export function validatePlaytestScenario(value: unknown, scenarioPath: string, a ? {} : { setup: validateSetup(requireRecord(value.setup, scenarioPath, "setup"), scenarioPath, subject) }), ...(absolutePath === undefined ? {} : { sourcePath: absolutePath }), + ...(value.startTick === undefined ? {} : { startTick: value.startTick as number }), steps, ...(subject === undefined ? {} : { subject }), target, diff --git a/packages/runtime-native/CMakeLists.txt b/packages/runtime-native/CMakeLists.txt index d390a68570..3f0f03d0f5 100644 --- a/packages/runtime-native/CMakeLists.txt +++ b/packages/runtime-native/CMakeLists.txt @@ -844,6 +844,9 @@ if(MYSTRAL_USE_V8) elseif(UNIX) # V8 on Linux needs pthread and dl set(V8_SYSTEM_LIBS pthread dl) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$") + list(APPEND V8_SYSTEM_LIBS atomic) + endif() elseif(WIN32) # V8 on Windows needs these set(V8_SYSTEM_LIBS winmm dbghelp shlwapi) @@ -1096,10 +1099,22 @@ else() endif() else() set(SKIA_PLATFORM_DIR "linux-gpu") - set(SKIA_LIB_SUBDIR "x64") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(SKIA_LIB_SUBDIR "arm64") + else() + set(SKIA_LIB_SUBDIR "x64") + endif() endif() endif() +# Linux arm64 ships an arm64 Skia prebuilt (library-builder chrome/m145), so a build that asked +# for Canvas2D and cannot find it is a missing dependency, not a host that never had Canvas2D. +# Disabling it there made the arm64 desktop artifact quietly differ from every other platform. +if(TN_ENABLE_CANVAS2D AND MYSTRAL_PLATFORM STREQUAL "linux" + AND CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(TN_SKIA_ARM64_REQUIRED ON) +endif() + if(TN_ENABLE_CANVAS2D AND EXISTS ${SKIA_TARGET_DIR}) message(STATUS "Found Skia directory: ${SKIA_TARGET_DIR}") @@ -1131,6 +1146,13 @@ if(TN_ENABLE_CANVAS2D AND EXISTS ${SKIA_TARGET_DIR}) message(STATUS "Found Skia: ${SKIA_LIBRARY}") message(STATUS "Skia includes: ${SKIA_INCLUDE_DIR}") else() + if(TN_SKIA_ARM64_REQUIRED) + message(FATAL_ERROR + "TN_SKIA_ARM64_MISSING: Canvas2D was requested on arm64 but Skia is incomplete.\n" + " Library: ${SKIA_LIB_PATH}\n" + " Include: ${SKIA_INCLUDE_DIR}\n" + " Run 'node scripts/download-deps.mjs --only skia' on an arm64 host, or configure with -DTN_ENABLE_CANVAS2D=OFF to drop the surface deliberately.") + endif() message(WARNING "Skia library or headers not found:") message(WARNING " Library: ${SKIA_LIB_PATH}") message(WARNING " Include: ${SKIA_INCLUDE_DIR}") @@ -1139,6 +1161,11 @@ else() if(NOT TN_ENABLE_CANVAS2D) message(STATUS "Skia disabled because TN_ENABLE_CANVAS2D=OFF") endif() + if(TN_SKIA_ARM64_REQUIRED) + message(FATAL_ERROR + "TN_SKIA_ARM64_MISSING: Canvas2D was requested on arm64 but no Skia was found at ${SKIA_TARGET_DIR}.\n" + " Run 'node scripts/download-deps.mjs --only skia' on an arm64 host, or configure with -DTN_ENABLE_CANVAS2D=OFF to drop the surface deliberately.") + endif() if(MYSTRAL_PLATFORM STREQUAL "ios") message(STATUS "Skia iOS not found - Canvas 2D support disabled. Run 'node scripts/download-deps.mjs --only skia-ios' to enable.") else() diff --git a/packages/runtime-native/__tests__/linux-video-driver-routing.spec.ts b/packages/runtime-native/__tests__/linux-video-driver-routing.spec.ts new file mode 100644 index 0000000000..97f3c01c01 --- /dev/null +++ b/packages/runtime-native/__tests__/linux-video-driver-routing.spec.ts @@ -0,0 +1,38 @@ +/** + * The desktop core gate must run on the video driver a caller actually asked for. + * + * `SDL_VIDEODRIVER=wayland pnpm native:verify:desktop` is the documented Wayland command and SDL 3 + * reads `SDL_VIDEO_DRIVER`, so the gate that used to pin `SDL_VIDEODRIVER=x11` and wrap every run in + * Xvfb proved X11 and called it Wayland. These cases pin the requested-driver routing. + */ + +import { describe, expect, it } from "vitest"; + +// @ts-expect-error -- the desktop verifier is plain ESM with no type declarations. +import { requestedLinuxVideoDriver } from "../scripts/verify-desktop-core.mjs"; + +describe("requestedLinuxVideoDriver", () => { + it("honors the SDL 2 spelling, the wayland request included", () => { + expect(requestedLinuxVideoDriver({ SDL_VIDEODRIVER: "wayland" })).toEqual({ + driver: "wayland", + wayland: true, + }); + }); + + it("prefers the SDL 3 spelling when both are set", () => { + expect( + requestedLinuxVideoDriver({ SDL_VIDEO_DRIVER: "x11", SDL_VIDEODRIVER: "wayland" }), + ).toEqual({ + driver: "x11", + wayland: false, + }); + }); + + it("keeps x11 as the default, and does not read an empty variable as a request", () => { + expect(requestedLinuxVideoDriver({})).toEqual({ driver: "x11", wayland: false }); + expect(requestedLinuxVideoDriver({ SDL_VIDEO_DRIVER: "", SDL_VIDEODRIVER: "" })).toEqual({ + driver: "x11", + wayland: false, + }); + }); +}); diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 18ecd85b7e..a7bb7740e5 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -1310,6 +1310,19 @@ "desktopGate": false, "required": true }, + { + "id": "91-queue-write-buffer-zero", + "title": "zero-length queue writeBuffer preserves nonzero writes and rejects invalid sources", + "category": "runtime", + "status": "implemented", + "scene": "conformance/scenes/shared/queue-write-buffer-zero.js", + "tolerance": { + "pixelMismatchRatio": 0.01, + "perceptualDeltaE": 3.0 + }, + "desktopGate": true, + "required": true + }, { "id": "91-frame-upload-stream", "title": "steady-state frame upload stream", diff --git a/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-proof.js b/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-proof.js new file mode 100644 index 0000000000..6189455d8d --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-proof.js @@ -0,0 +1,71 @@ +export async function verifyQueueWriteBuffer(device) { + if (!device?.queue || typeof device.createBuffer !== "function") { + throw new Error("writeBuffer proof requires a real GPU device"); + } + const buffer = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, + }); + const readback = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + const failures = []; + try { + device.queue.writeBuffer(buffer, 0, new Uint32Array([11, 22, 33, 44])); + const source = new Uint32Array([99]); + const emptyWrites = [ + [new Uint8Array(0)], + [new ArrayBuffer(0)], + [new DataView(new ArrayBuffer(0))], + [source, 0, 0], + [source, 1, 0], + ]; + for (const args of emptyWrites) { + try { + if (device.queue.writeBuffer(buffer, 0, ...args) !== undefined) { + failures.push("zero-length write did not return undefined"); + } + } catch (error) { + failures.push(`zero-length ${args[0].constructor.name}: ${String(error.message || error)}`); + } + } + const invalidSources = [ + [source, -1], + [source, 0, 2], + [{}], + [{ byteLength: 0 }], + [new Uint8Array(0), 1], + [new ArrayBuffer(0), 0, 1], + [new DataView(new ArrayBuffer(0)), 1], + ]; + for (const args of invalidSources) { + let rejected = false; + try { + device.queue.writeBuffer(buffer, 0, ...args); + } catch { + rejected = true; + } + if (!rejected) failures.push("invalid nonempty source was accepted"); + } + device.queue.writeBuffer(buffer, 8, new Uint32Array([55, 66])); + const encoder = device.createCommandEncoder(); + encoder.copyBufferToBuffer(buffer, 0, readback, 0, 16); + device.queue.submit([encoder.finish()]); + await readback.mapAsync(GPUMapMode.READ); + const observed = Array.from(new Uint32Array(readback.getMappedRange())); + readback.unmap(); + if (observed.join(",") !== "11,22,55,66") { + failures.push(`nonzero writes or no-op preservation failed: ${observed}`); + } + if (failures.length) throw new Error(failures.join("; ")); + return { + emptyWrites: emptyWrites.length, + invalidSourcesRejected: invalidSources.length, + observed, + }; + } finally { + buffer.destroy(); + readback.destroy(); + } +} diff --git a/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-zero.js b/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-zero.js new file mode 100644 index 0000000000..6a96cae19f --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/queue-write-buffer-zero.js @@ -0,0 +1,11 @@ +import { startBehaviorScene } from "./scene-support.js"; +import { verifyQueueWriteBuffer } from "./queue-write-buffer-proof.js"; + +export function startScene(canvas, dimensions) { + return startBehaviorScene(canvas, dimensions, "queue-write-buffer-zero", async () => { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) throw new Error("writeBuffer proof requires a GPU adapter"); + const device = await adapter.requestDevice(); + return verifyQueueWriteBuffer(device); + }); +} diff --git a/packages/runtime-native/include/mystral/webgpu_compat.h b/packages/runtime-native/include/mystral/webgpu_compat.h index b9624feec0..0f3f2d3561 100644 --- a/packages/runtime-native/include/mystral/webgpu_compat.h +++ b/packages/runtime-native/include/mystral/webgpu_compat.h @@ -39,6 +39,10 @@ typedef WGPUSurfaceDescriptorFromWindowsHWND WGPUSurfaceDescriptorFromWindowsHWN typedef WGPUSurfaceDescriptorFromXlibWindow WGPUSurfaceDescriptorFromXlibWindow_Compat; #define WGPUSType_SurfaceDescriptorFromXlibWindow_Compat WGPUSType_SurfaceDescriptorFromXlibWindow +// Surface descriptors - Linux Wayland +typedef WGPUSurfaceDescriptorFromWaylandSurface WGPUSurfaceDescriptorFromWaylandSurface_Compat; +#define WGPUSType_SurfaceDescriptorFromWaylandSurface_Compat WGPUSType_SurfaceDescriptorFromWaylandSurface + // Surface descriptors - Android ANativeWindow typedef WGPUSurfaceDescriptorFromAndroidNativeWindow WGPUSurfaceDescriptorFromAndroidNativeWindow_Compat; #define WGPUSType_SurfaceDescriptorFromAndroidNativeWindow_Compat WGPUSType_SurfaceDescriptorFromAndroidNativeWindow @@ -133,6 +137,10 @@ typedef WGPUSurfaceSourceWindowsHWND WGPUSurfaceDescriptorFromWindowsHWND_Compat typedef WGPUSurfaceSourceXlibWindow WGPUSurfaceDescriptorFromXlibWindow_Compat; #define WGPUSType_SurfaceDescriptorFromXlibWindow_Compat WGPUSType_SurfaceSourceXlibWindow +// Surface descriptors - Linux Wayland +typedef WGPUSurfaceSourceWaylandSurface WGPUSurfaceDescriptorFromWaylandSurface_Compat; +#define WGPUSType_SurfaceDescriptorFromWaylandSurface_Compat WGPUSType_SurfaceSourceWaylandSurface + // Surface descriptors - Android ANativeWindow typedef WGPUSurfaceSourceAndroidNativeWindow WGPUSurfaceDescriptorFromAndroidNativeWindow_Compat; #define WGPUSType_SurfaceDescriptorFromAndroidNativeWindow_Compat WGPUSType_SurfaceSourceAndroidNativeWindow diff --git a/packages/runtime-native/native-deps.lock.json b/packages/runtime-native/native-deps.lock.json index 58ac1d92df..3ce08ae343 100644 --- a/packages/runtime-native/native-deps.lock.json +++ b/packages/runtime-native/native-deps.lock.json @@ -225,13 +225,13 @@ "payloads": [ { "id": "dawn-ubuntu", - "url": "https://github.com/google/dawn/releases/download/v20260117.152313/Dawn-d14ae3d97ad74100e9f382efef5e9c0872ddbeb2-ubuntu-latest-Release.tar.gz", - "sha256": "34f042447836b2d36204b05cbc76ec8186eedf9c58fbcdca8c1f0607d8b9826d", - "bytes": 28197558, + "url": "https://github.com/ThreeNativeHQ/threenative-engine/releases/download/dawn-linux-x64-v1/Dawn-d14ae3d97ad74100e9f382efef5e9c0872ddbeb2-ubuntu-latest-Release.tar.gz", + "sha256": "bafce657936c161dc14642ddb61f71dcf6528b7ae90c40698831797e9c43d530", + "bytes": 28193047, "targets": [ "desktop-linux" ], - "bootstrap": "two-run-fetch-2026-09-11" + "bootstrap": "github-release-digest-2026-10-15" }, { "id": "dawn-macos-arm64", @@ -299,6 +299,28 @@ ], "bootstrap": "two-run-fetch-2026-09-11" }, + { + "id": "v8-linux-arm64", + "url": "https://github.com/ThreeNativeHQ/threenative-engine/releases/download/ci-v8-linux-arm64/v8-linux-arm64-1632c350bb78a60721a092ab696f0b3d274dbb0e5b926ebdca2c1b5edb8bb262.tgz", + "sha256": "ac379519d7e26039831ee7945018c67b48d98e30d04fe760bf5c684d61b82081", + "bytes": 18745524, + "targets": [ + "desktop-linux-arm64" + ], + "bootstrap": "verified-source-run-38720103918-and-release-roundtrip-2026-10-15", + "source": { + "repository": "https://chromium.googlesource.com/v8/v8.git", + "revision": "9c09e7876ff830e1f9a731aa930040d1028ff5a1" + }, + "provenance": { + "runId": 38720103918, + "artifactId": 11964493465, + "assetId": 643500847, + "producerRevision": "4ea2e7851f2e3f88758d908290da9b79d66a1727", + "recipeHash": "1632c350bb78a60721a092ab696f0b3d274dbb0e5b926ebdca2c1b5edb8bb262", + "receiptSha256": "dbec689e6213490b1a581b27cc44fe86c5e6e54dfba2fb1235902f3cc074ab11" + } + }, { "id": "v8-macos-arm64", "url": "https://github.com/kuoruan/libv8/releases/download/v13.1.201.22/v8_macOS_arm64.tar.xz", @@ -488,6 +510,16 @@ ], "bootstrap": "two-run-fetch-2026-09-11" }, + { + "id": "quiche-linux-aarch64", + "url": "https://github.com/mystralengine/library-builder/releases/download/quiche-0.24.6-4/quiche-linux-arm64.zip", + "sha256": "5e316678246fdf7390845bdd81ea815a8018f3ad5a22d0fe7d42361835f3e843", + "bytes": 19682417, + "targets": [ + "desktop" + ], + "bootstrap": "github-release-digest-2026-10-14" + }, { "id": "quiche-mac-arm64", "url": "https://github.com/mystralengine/library-builder/releases/download/quiche-0.24.6-3/quiche-mac-arm64.zip", @@ -676,6 +708,16 @@ ], "bootstrap": "two-run-fetch-2026-09-11" }, + { + "id": "webp-linux-aarch64", + "url": "https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.5.0-linux-aarch64.tar.gz", + "sha256": "ec874dd2e52097f11dd4999176ae6b6fb0ecb165bc1e05d3ecc9b29014fb003e", + "bytes": 10138687, + "targets": [ + "desktop" + ], + "bootstrap": "download-sha256-2026-10-14" + }, { "id": "webp-mac-arm64", "url": "https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.5.0-mac-arm64.tar.gz", @@ -761,6 +803,16 @@ ], "bootstrap": "two-run-fetch-2026-09-11" }, + { + "id": "skia-linux-aarch64", + "url": "https://github.com/mystralengine/library-builder/releases/download/chrome%2Fm145/skia-build-linux-arm64-gpu-release.zip", + "sha256": "1cd646e55a6abd4a97935939bb419c11ea7a1bb6f8ccd6cf324da305cf2d982a", + "bytes": 41600223, + "targets": [ + "desktop" + ], + "bootstrap": "github-release-digest-2026-10-14" + }, { "id": "skia-mac-arm64", "url": "https://github.com/olilarkin/skia-builder/releases/download/chrome%2Fm145/skia-build-mac-arm64-gpu-release.zip", @@ -879,6 +931,16 @@ ], "bootstrap": "two-run-fetch-2026-09-11" }, + { + "id": "swc-linux-aarch64", + "url": "https://github.com/mystralengine/library-builder/releases/download/swc-41/swc-linux-arm64.zip", + "sha256": "63ce88b0d943709c3c80cc07afe9a37455d4724fd2e50bba605bd74219f8b69d", + "bytes": 12933800, + "targets": [ + "desktop" + ], + "bootstrap": "github-release-digest-2026-10-14" + }, { "id": "swc-mac-arm64", "url": "https://github.com/mystralengine/library-builder/releases/download/swc-11/swc-mac-arm64.zip", diff --git a/packages/runtime-native/native/physics/src/lib.rs b/packages/runtime-native/native/physics/src/lib.rs index 7577f3005d..abe798bbc7 100644 --- a/packages/runtime-native/native/physics/src/lib.rs +++ b/packages/runtime-native/native/physics/src/lib.rs @@ -403,7 +403,11 @@ impl Simulation { vertices: Vec>, indices: Vec<[u32; 3]>, ) -> bool { - let Ok(collider) = ColliderBuilder::trimesh(vertices, indices) else { + let Ok(collider) = ColliderBuilder::trimesh_with_flags( + vertices, + indices, + TriMeshFlags::FIX_INTERNAL_EDGES, + ) else { return false; }; self.insert_body(options, collider) @@ -1092,6 +1096,11 @@ impl Simulation { .collect(); let mut ground_collider = None; let mut ground_normal = None; + // Rapier flags ground at any positive up component. Contacts that are all steeper + // than 60 degrees from up mean a wall, matching the web backend; a snap-to-ground + // step records no contact and keeps Rapier's answer. + let mut walkable = false; + let mut contacts = 0u32; let mut collisions = Vec::new(); let movement = character.controller.move_shape( delta_time, @@ -1100,7 +1109,9 @@ impl Simulation { body.position(), desired, |collision| { + contacts += 1; if collision.hit.normal1.y >= 0.5 { + walkable = true; ground_collider = collider_ids.get(&collision.handle).copied(); ground_normal = Some([ collision.hit.normal1.x, @@ -1134,11 +1145,12 @@ impl Simulation { body.set_next_kinematic_translation(current + movement.translation); body.set_next_kinematic_rotation(rotation); let state = self.characters.get_mut(&id).expect("character was checked"); - state.grounded = movement.grounded; + let grounded = movement.grounded && (walkable || contacts == 0); + state.grounded = grounded; state.ground_collider = ground_collider - .or_else(|| movement.grounded.then_some(state.ground_collider).flatten()); + .or_else(|| grounded.then_some(state.ground_collider).flatten()); state.ground_normal = ground_normal.unwrap_or_else(|| { - if movement.grounded { + if grounded { state.ground_normal } else { [0.0, 1.0, 0.0] diff --git a/packages/runtime-native/native/ui-overlay/src/offscreen.rs b/packages/runtime-native/native/ui-overlay/src/offscreen.rs index 8733e50bea..5753c0b9b9 100644 --- a/packages/runtime-native/native/ui-overlay/src/offscreen.rs +++ b/packages/runtime-native/native/ui-overlay/src/offscreen.rs @@ -724,6 +724,34 @@ impl Cadence { } } +// Share libc's stdout FILE lock with the C++ diagnostics; Rust stdout has a separate lock. +fn write_snapshot_line(line: &str) -> std::io::Result<()> { + unsafe extern "C" { + static stdout: *mut std::ffi::c_void; + fn puts(line: *const std::ffi::c_char) -> std::ffi::c_int; + fn fflush(stream: *mut std::ffi::c_void) -> std::ffi::c_int; + } + let line = std::ffi::CString::new(line) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + // puts writes the entire string and its newline while holding the FILE lock. + unsafe { + if puts(line.as_ptr()) < 0 { + return Err(std::io::Error::last_os_error()); + } + if fflush(stdout) != 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) +} + +fn emit_snapshot_line(line: &str) { + if let Err(error) = write_snapshot_line(line) { + use std::io::Write; + let _ = writeln!(std::io::stderr().lock(), "UI snapshot diagnostic write failed: {error}"); + } +} + /// One line per snapshot asked for and answered, only when `TN_UI_SNAPSHOT_TRACE` is set. /// /// `TN_UI_SNAPSHOT` aggregates a second, and a second cannot separate the two reasons a gap appears @@ -737,7 +765,7 @@ fn trace_snapshot(event: &str, request: u64, detail: &str) { if !*ENABLED { return; } - println!("TN_UI_SNAPSHOT_TRACE:{{\"event\":\"{event}\",\"n\":{request}{detail}}}"); + emit_snapshot_line(&format!("TN_UI_SNAPSHOT_TRACE:{{\"event\":\"{event}\",\"n\":{request}{detail}}}")); } /// The web thread's state machine: drain the game's requests, then take a snapshot if one is due. @@ -817,7 +845,7 @@ impl Driver { self.last_report = Instant::now(); let now = self.last_report; let waiting = in_flight_since.map(|at| at.elapsed().as_millis()).unwrap_or(0); - println!( + emit_snapshot_line(&format!( "TN_UI_SNAPSHOT:{{\"requests\":{},\"inFlight\":{},\"waitingMs\":{},\"lastRoundTripMs\":{},\"intervalMs\":{},\"quietMs\":{},\"counter\":{}}}", self.requests, in_flight_since.is_some(), @@ -826,7 +854,7 @@ impl Driver { self.cadence.wait(now).as_millis(), self.cadence.quiet(now).as_millis(), self.shared.frames.published() - ); + )); } fn loaded(&mut self) { @@ -1174,6 +1202,54 @@ fn serde_frame(frame: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn snapshot_writer_rejects_embedded_nul() { + let error = super::write_snapshot_line("TN_UI_SNAPSHOT:before\0after").unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + fn snapshot_writer_waits_for_the_c_stdout_lock() { + unsafe extern "C" { + static stdout: *mut std::ffi::c_void; + fn flockfile(stream: *mut std::ffi::c_void); + fn funlockfile(stream: *mut std::ffi::c_void); + } + struct TraceEnv(Option); + impl Drop for TraceEnv { + fn drop(&mut self) { + match &self.0 { + Some(value) => std::env::set_var("TN_UI_SNAPSHOT_TRACE", value), + None => std::env::remove_var("TN_UI_SNAPSHOT_TRACE"), + } + } + } + struct StdoutLock; + impl Drop for StdoutLock { + fn drop(&mut self) { + unsafe { funlockfile(stdout) }; + } + } + let _trace_env = TraceEnv(std::env::var_os("TN_UI_SNAPSHOT_TRACE")); + std::env::set_var("TN_UI_SNAPSHOT_TRACE", "1"); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + unsafe { flockfile(stdout) }; + let stdout_lock = StdoutLock; + let writer = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + super::trace_snapshot("stdio-lock-test", 0, ""); + done_tx.send(()).unwrap(); + }); + started_rx.recv().unwrap(); + let while_locked = done_rx.recv_timeout(std::time::Duration::from_millis(100)); + drop(stdout_lock); + writer.join().unwrap(); + assert!(matches!(while_locked, Err(std::sync::mpsc::RecvTimeoutError::Timeout)), + "the actual snapshot writer bypassed the C stdout FILE lock"); + done_rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap(); + } + #[test] #[ignore = "requires an X11 display with WebKitGTK; run through test:ui-native"] fn ordinary_controls_use_native_focus_and_editing() { diff --git a/packages/runtime-native/scripts/build-linux-arm64-v8.d.mts b/packages/runtime-native/scripts/build-linux-arm64-v8.d.mts new file mode 100644 index 0000000000..e4ae27fd59 --- /dev/null +++ b/packages/runtime-native/scripts/build-linux-arm64-v8.d.mts @@ -0,0 +1,89 @@ +// Types for the plain-Node producer consumed by the TypeScript CI contracts. +interface IBuildIdentity { + recipe: number; + version: string; + source: string; + depotTools: string; + gn: string; + gnPackage: string; + gnSha256: string; + toolchain: { + base: string; + version: string; + packageVersion: string; + architecture: string; + index: string; + compilerRt: { + packageVersion: string; + index: string; + archive: string; + objects: number; + sha256: string; + }; + packages: Record; + }; +} +interface IToolchainReceipt { + packages: Record; + binaries: Record< + "clang" | "clang++" | "ld.lld" | "llvm-ar" | "llvm-nm", + { sha256: string; version: string } + >; + linux: { packages: Record; modules: Record }; + compilerRt: { archive: string; sha256: string; objects: number }; + gn?: { package: string; sha256: string; version: string }; +} +interface IBuildLicense { + revision: string; + url: string; + bytes: number; + sha256: string; +} +interface IV8Receipt { + schemaVersion: number; + build: IBuildIdentity; + args: string; + buildLicense: IBuildLicense; + toolchain: IToolchainReceipt & { gn: NonNullable }; + objects: number; + files: Record; +} +export declare const LINUX_ARM64_V8_BUILD: IBuildIdentity; +export declare const CHROMIUM_BUILD_LICENSE: IBuildLicense; +export declare function reusableLinuxArm64V8State( + previous: string, + expected: string, + repairScriptSha?: string, +): boolean; +export declare function fetchChromiumBuildLicense( + run?: (program: string, args: string[]) => Buffer | string, +): Buffer; +export declare function linuxArm64V8GnArgs(): string; +export declare function assertArm64V8Archive(bytes: Buffer): number; +export declare function inspectLinuxArm64V8Toolchain(options?: { + run?: (program: string, args: string[]) => string; + hash?: (path: string) => string; + read?: (path: string) => Buffer; +}): IToolchainReceipt; +export declare function createLinuxArm64V8Receipt( + root: string, + toolchain: IToolchainReceipt, +): IV8Receipt; +export declare function verifyLinuxArm64V8Installation(root: string): IV8Receipt; +export declare function prepareLinuxArm64V8Sdk( + source: string, + run?: (program: string, args: string[]) => unknown, +): void; +export declare function promoteV8Payload(stage: string, destination: string, backup: string): void; +export declare function provisionLinuxArm64V8( + destination: string, + options?: { force?: boolean; env?: NodeJS.ProcessEnv }, +): IV8Receipt; +export declare function runLinuxArm64V8Command( + args: string[], + options?: { + root?: string; + verify?: (root: string) => IV8Receipt; + provision?: (root: string, options: { force: boolean }) => IV8Receipt; + }, +): IV8Receipt; diff --git a/packages/runtime-native/scripts/build-linux-arm64-v8.mjs b/packages/runtime-native/scripts/build-linux-arm64-v8.mjs new file mode 100644 index 0000000000..aaa9462be9 --- /dev/null +++ b/packages/runtime-native/scripts/build-linux-arm64-v8.mjs @@ -0,0 +1,506 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Native Ubuntu noble arm64 packages; producer installs these exact versions before invoking us. +export const LINUX_ARM64_V8_BUILD = Object.freeze({ + recipe: 2, + version: "13.1.201.22", + source: "9c09e7876ff830e1f9a731aa930040d1028ff5a1", + depotTools: "08f3e8c0eb66d6de3a048a757d0ff708dbc8ea34", + gn: "95b0f8fe31a992a33c040bbe3867901335c12762", + gnPackage: "gn/gn/linux-arm64", + gnSha256: "31739b62d88843b32726c4ace0c93b36b65efbcba89bb033891d454766d9e689", + toolchain: Object.freeze({ + base: "/usr/lib/llvm-18", + version: "18.1.3", + packageVersion: "1:18.1.3-1ubuntu1", + architecture: "arm64", + index: + "https://ports.ubuntu.com/ubuntu-ports/dists/noble-updates/universe/binary-arm64/Packages.xz", + compilerRt: Object.freeze({ + packageVersion: "1:18.1.3-1", + index: "https://ports.ubuntu.com/ubuntu-ports/dists/noble/universe/binary-arm64/Packages.xz", + archive: "/usr/lib/llvm-18/lib/clang/18/lib/linux/libclang_rt.builtins-aarch64.a", + objects: 278, + sha256: "af24a6be2920609e5dea1218caad93a17885497e764cef2dc78512fdec159e98", + }), + packages: Object.freeze({ + "libclang-rt-18-dev": "daeaee613fd98916b48201352499b417131c605601d73429c6c71cb4c81ee56d", + "clang-18": "e3a70e054d3c0d0841cd7d1555deeab82d75609d34438b1cfc1b187b0add7d77", + "lld-18": "c33a2e048536e6d902a9f209d114d049b5becbedb94d11eb77f313cad95a830c", + "llvm-18": "1d9c87ff9fe17d0ee00703ec0f339ab687e2807b3508b51cf517e05281de104d", + }), + }), +}); +const BUILD_REVISION = "61966507f36426729edb3cf308d78b80eecb7b73"; +export const CHROMIUM_BUILD_LICENSE = Object.freeze({ + revision: "054463d932e1883832422ab7280c71899cde8ae7", + url: "https://chromium.googlesource.com/chromium/src/+/054463d932e1883832422ab7280c71899cde8ae7/LICENSE?format=TEXT", + bytes: 1536, + sha256: "368cca1106be99d39ecd32a38d8305585d802a475effb66380b91ffc9bcf709b", +}); +// Packaging-only repair of attempt 38711129527; all other state fields must match exactly. +const PREVIOUS_BUILD_SCRIPT = "3a5872301fb0fb8b9ed380b1172ca15de414fa370e23812356c736ffc233b1e8"; +export function reusableLinuxArm64V8State(previous, expected, repairScriptSha) { + if (previous === expected) return true; + const current = JSON.parse(expected); + return current.buildScript === repairScriptSha && previous === JSON.stringify({ + ...current, buildScript: PREVIOUS_BUILD_SCRIPT, + }); +} +export function fetchChromiumBuildLicense(run = (program, args) => execFileSync(program, args)) { + const bytes = Buffer.from(run("curl", ["--fail", "--location", "--silent", "--show-error", CHROMIUM_BUILD_LICENSE.url]).toString().trim(), "base64"); + if (bytes.length !== CHROMIUM_BUILD_LICENSE.bytes || + createHash("sha256").update(bytes).digest("hex") !== CHROMIUM_BUILD_LICENSE.sha256) + throw new Error("Pinned Chromium build license checksum mismatch"); + return bytes; +} +const SDK = ".threenative-llvm-18"; +const BUILTINS = "lib/clang/18/lib/aarch64-unknown-linux-gnu/libclang_rt.builtins.a"; +const packageVersion = (name) => name === "libclang-rt-18-dev" + ? LINUX_ARM64_V8_BUILD.toolchain.compilerRt.packageVersion + : LINUX_ARM64_V8_BUILD.toolchain.packageVersion; +const RECEIPT = "build-receipt.json"; +const LINUX_PACKAGES = ["libglib2.0-dev", "pkg-config"]; +const GLIB_MODULES = ["glib-2.0", "gmodule-2.0", "gobject-2.0", "gthread-2.0"]; +const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); + +// Keep this producer independent: changing Android's script invalidates its expensive cache. +function payloadFiles(root, directory = "") { + const result = []; + for (const entry of readdirSync(join(root, directory)).sort()) { + const relative = directory ? `${directory}/${entry}` : entry; + if (relative === RECEIPT) continue; + const stats = lstatSync(join(root, relative)); + if (stats.isDirectory()) result.push(...payloadFiles(root, relative)); + else if (stats.isFile() && stats.size > 0) result.push(relative); + else throw new Error(`V8 payload has an empty or non-regular file: ${relative}`); + } + return result; +} + +function checkoutV8Source(directory, url, revision, run) { + mkdirSync(directory, { recursive: true }); + run("git", ["init"], directory); + run("git", ["fetch", "--depth=1", url, revision], directory); + run("git", ["checkout", "--detach", "FETCH_HEAD"], directory); + const actual = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: directory, encoding: "utf8", + }).trim(); + if (actual !== revision) throw new Error(`V8 source revision mismatch: ${actual} != ${revision}`); +} + +export function promoteV8Payload(stage, destination, backup) { + rmSync(backup, { recursive: true, force: true }); + if (existsSync(destination)) renameSync(destination, backup); + try { + renameSync(stage, destination); + } catch (error) { + if (existsSync(backup)) renameSync(backup, destination); + throw error; + } +} +const BINARIES = ["clang", "clang++", "ld.lld", "llvm-ar", "llvm-nm"]; +const LICENSES = { + "V8-LICENSE": "LICENSE", + "V8-LICENSE.v8": "LICENSE.v8", + "V8-FDLIBM": "LICENSE.fdlibm", + "V8-STRONGTALK": "LICENSE.strongtalk", + // Build dependency notice; inclusion does not assert that compiler-rt objects are embedded. + "COMPILER-RT-COPYRIGHT": "/usr/share/doc/libclang-rt-18-dev/copyright", +}; + +export function linuxArm64V8GnArgs() { + return [ + 'target_os="linux"', + 'target_cpu="arm64"', + 'v8_target_cpu="arm64"', + "is_debug=false", + "is_component_build=false", + "v8_monolithic=true", + "v8_enable_i18n_support=false", + "v8_enable_test_features=false", + "v8_enable_gdbjit=false", + "v8_use_external_startup_data=false", + "v8_enable_pointer_compression=false", + "v8_enable_sandbox=false", + "symbol_level=0", + "strip_debug_info=true", + "treat_warnings_as_errors=false", + "use_sysroot=false", + "use_custom_libcxx=false", + "use_custom_libcxx_for_host=false", + "clang_use_chrome_plugins=false", + "is_clang=true", + `clang_base_path="//${SDK}"`, + 'clang_version="18"', + "use_lld=true", + "use_thin_lto=false", + ].join(" "); +} + +/** Check every archive object, including GNU long-name and BSD extended-name members. */ +export function assertArm64V8Archive(bytes) { + if (bytes.subarray(0, 8).toString() !== "!\n") + throw new Error("V8 monolith is not a regular ar archive"); + let offset = 8; + let objects = 0; + while (offset < bytes.length) { + const header = bytes.subarray(offset, offset + 60); + const sizeText = header.subarray(48, 58).toString().trim(); + const size = Number(sizeText); + if ( + header.length !== 60 || + header.subarray(58).toString() !== "`\n" || + !/^\d+$/u.test(sizeText) || + !Number.isSafeInteger(size) || + offset + 60 + size > bytes.length + ) + throw new Error("Malformed V8 ar member"); + let name = header.subarray(0, 16).toString().trim(); + let object = bytes.subarray(offset + 60, offset + 60 + size); + if (name.startsWith("#1/")) { + const length = Number(name.slice(3)); + if (!Number.isSafeInteger(length) || length < 1 || length > size) + throw new Error("Malformed V8 BSD ar name"); + name = object.subarray(0, length).toString().replace(/\0+$/u, ""); + object = object.subarray(length); + } + if (!["/", "//", "/SYM64/", "__.SYMDEF", "__.SYMDEF SORTED"].includes(name)) { + if ( + object.length < 64 || + !object.subarray(0, 7).equals(Buffer.from([127, 69, 76, 70, 2, 1, 1])) || + object.readUInt16LE(16) !== 1 || + object.readUInt16LE(18) !== 183 + ) + throw new Error(`V8 ar member ${name} is not an aarch64 ELF relocatable object`); + objects++; + } + offset += 60 + size + (size % 2); + } + if (!objects || offset !== bytes.length) + throw new Error("V8 ar archive has no objects or is truncated"); + return objects; +} + +function validateLinuxPrerequisites(linux) { + for (const name of LINUX_PACKAGES) { + const [actual, version, architecture, status] = (linux?.packages?.[name] ?? "").split("\t"); + if (actual !== name || !/^\d[\w.+:~_-]*$/u.test(version ?? "") || + architecture !== "arm64" || status !== "install ok installed") + throw new Error(`V8 Linux prerequisite requires installed native ${name} with exact version identity`); + } + for (const name of GLIB_MODULES) { + if (!/^\d+\.\d+\.\d+$/u.test(linux?.modules?.[name] ?? "")) + throw new Error(`V8 Linux prerequisite pkg-config module missing or malformed: ${name}`); + } +} + +export function inspectLinuxArm64V8Toolchain({ + run = (program, args) => execFileSync(program, args, { encoding: "utf8" }), + hash = sha256, + read = readFileSync, +} = {}) { + const pin = LINUX_ARM64_V8_BUILD.toolchain; + const packages = {}; + for (const name of Object.keys(pin.packages)) { + const identity = run("dpkg-query", [ + "-W", + "-f=${Package}\t${Version}\t${Architecture}\t${Status}", + name, + ]).trim(); + if (identity !== `${name}\t${packageVersion(name)}\tarm64\tinstall ok installed`) + throw new Error(`V8 requires native ${name}=${packageVersion(name)}: ${identity}`); + if (run("dpkg", ["--verify", name]).trim()) + throw new Error(`V8 toolchain package ${name} failed installed-file integrity`); + packages[name] = identity; + } + const binaries = {}; + for (const name of BINARIES) { + const path = join(pin.base, "bin", name); + const version = run(path, ["--version"]).trim(); + if (!new RegExp(`\\b${pin.version.replaceAll(".", "\\.")}\\b`, "u").test(version)) + throw new Error(`V8 toolchain ${name} must be ${pin.version}`); + binaries[name] = { sha256: hash(path), version }; + } + const linux = { packages: {}, modules: {} }; + for (const name of LINUX_PACKAGES) { + linux.packages[name] = run("dpkg-query", ["-W", "-f=${Package}\t${Version}\t${Architecture}\t${Status}", name]).trim(); + if (run("dpkg", ["--verify", name]).trim()) + throw new Error(`V8 Linux prerequisite ${name} failed installed-file integrity`); + } + for (const name of GLIB_MODULES) + linux.modules[name] = run("/usr/bin/pkg-config", ["--modversion", name]).trim(); + validateLinuxPrerequisites(linux); + const archive = pin.compilerRt.archive; + const compilerRt = { archive, sha256: hash(archive), objects: assertArm64V8Archive(read(archive)) }; + if (compilerRt.sha256 !== pin.compilerRt.sha256 || compilerRt.objects !== pin.compilerRt.objects) + throw new Error("V8 compiler-rt archive checksum mismatch"); + return { packages, binaries, linux, compilerRt }; +} + +function validateToolchain(receipt) { + validateLinuxPrerequisites(receipt?.linux); + const pin = LINUX_ARM64_V8_BUILD.toolchain; + if (receipt?.compilerRt?.archive !== pin.compilerRt.archive || + receipt?.compilerRt?.sha256 !== pin.compilerRt.sha256 || + receipt?.compilerRt?.objects !== pin.compilerRt.objects) + throw new Error("V8 compiler-rt receipt is incomplete or mismatched"); + if ( + !receipt || + Object.keys(receipt.packages ?? {}).length !== Object.keys(pin.packages).length || + Object.keys(receipt.binaries ?? {}).length !== BINARIES.length + ) + throw new Error("V8 toolchain receipt is incomplete"); + if (receipt.gn?.package !== LINUX_ARM64_V8_BUILD.gnPackage || + receipt.gn?.sha256 !== LINUX_ARM64_V8_BUILD.gnSha256 || + typeof receipt.gn?.version !== "string" || !receipt.gn.version.includes(LINUX_ARM64_V8_BUILD.gn.slice(0, 8))) + throw new Error("V8 GN receipt must match the pinned native CIPD package, SHA-256 and revision"); + for (const name of Object.keys(pin.packages)) { + if (receipt.packages[name] !== `${name}\t${packageVersion(name)}\tarm64\tinstall ok installed`) + throw new Error("V8 toolchain package identity mismatch"); + } + for (const name of BINARIES) { + const binary = receipt.binaries[name]; + if ( + !/^[a-f0-9]{64}$/u.test(binary?.sha256 ?? "") || + typeof binary?.version !== "string" || + !new RegExp(`\\b${pin.version.replaceAll(".", "\\.")}\\b`, "u").test(binary.version) + ) + throw new Error(`V8 toolchain binary receipt mismatch: ${name}`); + } +} + +export function createLinuxArm64V8Receipt(root, toolchain) { + validateToolchain(toolchain); + const files = payloadFiles(root); + for (const path of [ + "include/v8.h", + "include/v8-version.h", + "libv8_monolith.a", + "licenses/BUILD-LICENSE", + ...Object.keys(LICENSES).map((name) => `licenses/${name}`), + ]) { + if (!files.includes(path)) throw new Error(`Linux arm64 V8 is missing ${path}`); + } + if (sha256(join(root, "licenses/BUILD-LICENSE")) !== CHROMIUM_BUILD_LICENSE.sha256) + throw new Error("Pinned Chromium build license checksum mismatch"); + const header = readFileSync(join(root, "include/v8-version.h"), "utf8"); + const version = ["MAJOR_VERSION", "MINOR_VERSION", "BUILD_NUMBER", "PATCH_LEVEL"] + .map((part) => new RegExp(`^#define V8_${part} (\\d+)\\s*$`, "mu").exec(header)?.[1]) + .join("."); + if (version !== LINUX_ARM64_V8_BUILD.version) + throw new Error(`Linux arm64 V8 headers do not match ${LINUX_ARM64_V8_BUILD.version}`); + const objects = assertArm64V8Archive(readFileSync(join(root, "libv8_monolith.a"))); + return { + schemaVersion: 1, + build: LINUX_ARM64_V8_BUILD, + args: linuxArm64V8GnArgs(), + buildLicense: CHROMIUM_BUILD_LICENSE, + toolchain, + objects, + files: Object.fromEntries(files.map((path) => [path, sha256(join(root, path))])), + }; +} + +export function verifyLinuxArm64V8Installation(root) { + const path = join(root, RECEIPT); + if (!existsSync(path)) throw new Error(`Linux arm64 V8 build receipt is missing: ${path}`); + const receipt = JSON.parse(readFileSync(path, "utf8")); + if ( + receipt.schemaVersion !== 1 || + JSON.stringify(receipt.build) !== JSON.stringify(LINUX_ARM64_V8_BUILD) || + receipt.args !== linuxArm64V8GnArgs() || + JSON.stringify(receipt.buildLicense) !== JSON.stringify(CHROMIUM_BUILD_LICENSE) + ) + throw new Error("Linux arm64 V8 recipe/flags mismatch"); + const actual = createLinuxArm64V8Receipt(root, receipt.toolchain); + if ( + JSON.stringify(receipt.files) !== JSON.stringify(actual.files) || + receipt.objects !== actual.objects + ) + throw new Error("Linux arm64 V8 payload checksum/coverage mismatch"); + return actual; +} + +// Chromium's pinned layout differs from Ubuntu's; never modify either upstream tree or SDK. +export function prepareLinuxArm64V8Sdk(source, run = execFileSync) { + const sdk = join(source, SDK); + rmSync(sdk, { recursive: true, force: true }); + mkdirSync(join(sdk, dirname(BUILTINS)), { recursive: true }); + symlinkSync(join(LINUX_ARM64_V8_BUILD.toolchain.base, "bin"), join(sdk, "bin")); + symlinkSync("/usr/lib/llvm-18/lib/clang/18/include", join(sdk, "lib/clang/18/include")); + symlinkSync(LINUX_ARM64_V8_BUILD.toolchain.compilerRt.archive, join(sdk, BUILTINS)); + const input = join(sdk, "preflight.cc"); + const output = join(sdk, "preflight"); + // __multi3 forces a real builtins dependency; headers also exercise Clang's resource lookup. + writeFileSync(input, "#include \nextern \"C\" __int128 __multi3(__int128, __int128);\nint main() { return __multi3(6, 7) != 42; }\n"); + run(join(sdk, "bin/clang++"), ["-fuse-ld=lld", input, join(sdk, BUILTINS), "-latomic", "-o", output]); + const elf = readFileSync(output); + if (elf.length < 64 || !elf.subarray(0, 7).equals(Buffer.from([127, 69, 76, 70, 2, 1, 1])) || + ![2, 3].includes(elf.readUInt16LE(16)) || elf.readUInt16LE(18) !== 183) + throw new Error("V8 compiler preflight is not an aarch64 ELF executable"); + run(output, []); +} + +export function provisionLinuxArm64V8(destination, { force = false, env = process.env } = {}) { + const work = join(dirname(destination), ".v8-linux-arm64-source"); + const backup = join(work, "previous"); + if (!existsSync(destination) && existsSync(backup)) renameSync(backup, destination); + if (!force && existsSync(destination)) { + try { + return verifyLinuxArm64V8Installation(destination); + } catch (error) { + console.warn(`Replacing stale Linux arm64 V8: ${error.message}`); + } + } + if (process.platform !== "linux" || process.arch !== "arm64") + throw new Error( + "V8 source build requires a native Linux arm64 host; cross-compilation is unsupported", + ); + const toolchain = inspectLinuxArm64V8Toolchain(); + const jobs = Number(env.THREENATIVE_V8_BUILD_JOBS ?? 3); + if (!Number.isInteger(jobs) || jobs < 1 || jobs > 32) + throw new Error("THREENATIVE_V8_BUILD_JOBS must be an integer between 1 and 32"); + const source = join(work, "v8"); + const depot = join(work, "depot_tools"); + const gn = join(source, "buildtools/linux64/gn"); + const stage = join(work, "payload"); + const statePath = join(work, ".recipe.json"); + const state = JSON.stringify({ + build: LINUX_ARM64_V8_BUILD, + toolchain, + buildScript: sha256(fileURLToPath(import.meta.url)), + }); + const baseEnv = { + ...env, + DEPOT_TOOLS_UPDATE: "0", + DEPOT_TOOLS_METRICS: "0", + PATH: `${depot}:${env.PATH ?? ""}`, + CC: "/usr/lib/llvm-18/bin/clang", + CXX: "/usr/lib/llvm-18/bin/clang++", + AR: "/usr/lib/llvm-18/bin/llvm-ar", + }; + const run = (program, args, cwd = work) => + execFileSync(program, args, { cwd, env: baseEnv, stdio: "inherit" }); + try { + if ( + force || + !existsSync(statePath) || + !reusableLinuxArm64V8State(readFileSync(statePath, "utf8"), state, env.THREENATIVE_V8_PACKAGING_REPAIR_SCRIPT_SHA) || + !existsSync(gn) + ) { + rmSync(work, { recursive: true, force: true }); + mkdirSync(work, { recursive: true }); + checkoutV8Source( + depot, + "https://chromium.googlesource.com/chromium/tools/depot_tools.git", + LINUX_ARM64_V8_BUILD.depotTools, + run, + ); + // Keep DEPS-pinned native GN; skip unused reclient and x64 LLVM without running hooks. + writeFileSync( + join(work, ".gclient"), + `solutions = [{"name": "v8", "url": "https://chromium.googlesource.com/v8/v8.git", "managed": False, "custom_vars": {"checkout_linux": False}, "custom_deps": {"buildtools/reclient": None, "third_party/llvm-build/Release+Asserts": None}}]\n`, + ); + run(join(depot, "gclient"), [ + "sync", + "--revision", + `v8@${LINUX_ARM64_V8_BUILD.source}`, + "--no-history", + "--nohooks", + ]); + const deps = readFileSync(join(source, "DEPS"), "utf8"); + if (!deps.includes(`'/chromium/src/build.git' + '@' + '${BUILD_REVISION}'`)) + throw new Error("Pinned V8 DEPS Chromium build revision mismatch"); + if (!deps.includes(`'gn_version': 'git_revision:${LINUX_ARM64_V8_BUILD.gn}'`)) + throw new Error("Pinned V8 DEPS GN revision mismatch"); + writeFileSync(statePath, state); + } + for (const [directory, revision] of [ + [source, LINUX_ARM64_V8_BUILD.source], + [depot, LINUX_ARM64_V8_BUILD.depotTools], + [join(source, "build"), BUILD_REVISION], + ]) { + const actual = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: directory, + encoding: "utf8", + }).trim(); + if (actual !== revision) throw new Error(`V8 cached source revision mismatch: ${directory}`); + } + const gnBytes = readFileSync(gn); + if (!gnBytes.subarray(0, 7).equals(Buffer.from([127, 69, 76, 70, 2, 1, 1])) || + gnBytes.length < 20 || gnBytes.readUInt16LE(18) !== 183 || sha256(gn) !== LINUX_ARM64_V8_BUILD.gnSha256) + throw new Error("Pinned GN must match the DEPS linux-arm64 CIPD binary SHA-256 and ELF architecture"); + const gnVersion = execFileSync(gn, ["--version"], { env: baseEnv, encoding: "utf8" }).trim(); + if (!gnVersion.includes(LINUX_ARM64_V8_BUILD.gn.slice(0, 8))) + throw new Error(`Pinned GN version mismatch: ${gnVersion}`); + toolchain.gn = { package: LINUX_ARM64_V8_BUILD.gnPackage, sha256: sha256(gn), version: gnVersion }; + prepareLinuxArm64V8Sdk(source, run); + run(gn, ["gen", "out/linux-arm64", `--args=${linuxArm64V8GnArgs()}`], source); + writeFileSync(statePath, state); + run("/usr/bin/ninja", [`-j${jobs}`, "-C", "out/linux-arm64", "v8_monolith"], source); + rmSync(stage, { recursive: true, force: true }); + mkdirSync(join(stage, "licenses"), { recursive: true }); + cpSync(join(source, "include"), join(stage, "include"), { recursive: true }); + copyFileSync( + join(source, "out/linux-arm64/obj/libv8_monolith.a"), + join(stage, "libv8_monolith.a"), + ); + writeFileSync(join(stage, "licenses/BUILD-LICENSE"), fetchChromiumBuildLicense()); + for (const [name, path] of Object.entries(LICENSES)) + copyFileSync(resolve(source, path), join(stage, "licenses", name)); + const receipt = createLinuxArm64V8Receipt(stage, toolchain); + writeFileSync(join(stage, RECEIPT), `${JSON.stringify(receipt, null, 2)}\n`); + verifyLinuxArm64V8Installation(stage); + promoteV8Payload(stage, destination, backup); + try { + rmSync(work, { recursive: true, force: true }); + } catch (error) { + console.warn(`Installed V8; could not clean ${work}: ${error.message}`); + } + return receipt; + } catch (error) { + throw new Error( + `Linux arm64 V8 source build failed; previous install is unchanged. Resume from ${work}. ${error.message}`, + { cause: error }, + ); + } +} + +export function runLinuxArm64V8Command( + args, + { + root = resolve(dirname(fileURLToPath(import.meta.url)), "../third_party/v8-linux-arm64"), + verify = verifyLinuxArm64V8Installation, + provision = provisionLinuxArm64V8, + } = {}, +) { + if (args.length > 1 || args.some((arg) => !["--verify", "--force"].includes(arg))) + throw new Error("Usage: build-linux-arm64-v8.mjs [--verify | --force]"); + if (args[0] === "--verify") return verify(root); + return provision(root, { force: args[0] === "--force" }); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + runLinuxArm64V8Command(process.argv.slice(2)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/packages/runtime-native/scripts/desktop-build-profile.mjs b/packages/runtime-native/scripts/desktop-build-profile.mjs index ad56723b76..120f134083 100644 --- a/packages/runtime-native/scripts/desktop-build-profile.mjs +++ b/packages/runtime-native/scripts/desktop-build-profile.mjs @@ -6,9 +6,9 @@ export function desktopPreset(platform = process.platform) { return "tn-linux"; } -// Linux arm64 has neither a V8 nor a Dawn prebuilt. +// Linux arm64 uses the locked V8 cache and wgpu-native; Dawn remains x64-only. export function desktopBuildOverrides(platform = process.platform, arch = process.arch) { return platform === "linux" && arch === "arm64" - ? { MYSTRAL_USE_V8: "OFF", MYSTRAL_USE_QUICKJS: "ON", MYSTRAL_USE_DAWN: "OFF", MYSTRAL_USE_WGPU: "ON" } + ? { MYSTRAL_USE_V8: "ON", MYSTRAL_USE_QUICKJS: "OFF", MYSTRAL_USE_DAWN: "OFF", MYSTRAL_USE_WGPU: "ON" } : {}; } diff --git a/packages/runtime-native/scripts/download-deps.mjs b/packages/runtime-native/scripts/download-deps.mjs index a6cc308a83..6a89eeb40a 100644 --- a/packages/runtime-native/scripts/download-deps.mjs +++ b/packages/runtime-native/scripts/download-deps.mjs @@ -29,6 +29,7 @@ import { tmpdir } from 'node:os'; import { SDL3_ANDROID_VERSION } from './package-android.mjs'; import { SDL3_IOS_VERSION } from './package-ios.mjs'; import { provisionAndroidV8 } from './build-android-v8.mjs'; +import { verifyLinuxArm64V8Installation } from './build-linux-arm64-v8.mjs'; import { assertAndroid16KbAlignment } from './check-android-16kb-alignment.mjs'; import { resolveWgpuCacheToolchain } from './wgpu-cache-toolchain.mjs'; @@ -84,6 +85,11 @@ const WGPU_DEPS = new Set(['wgpu', 'wgpu-ios', 'wgpu-android']); let wgpuVersionOverride = null; // Dependency versions and URLs +// Linux arm64 pins. x64 stays on the tags above; these are the first library-builder +// releases that publish a linux arm64 archive for the dependency named in the comment. +const LINUX_ARM64_QUICHE = 'quiche-0.24.6-4'; +const LINUX_ARM64_SWC = 'swc-41'; + const DEPS = { wgpu: { version: DEFAULT_WGPU_VERSION, @@ -141,7 +147,7 @@ const DEPS = { extractTo: 'sdl3-ios', }, dawn: { - // Dawn prebuilts from official releases: https://github.com/google/dawn/releases + // Official Dawn prebuilts; Linux uses our same-revision Wayland+X11 dependency cache. // Naming: Dawn-{commit}-{platform}-Release.tar.gz // Headers: dawn-headers-{commit}.tar.gz // @@ -158,7 +164,7 @@ const DEPS = { const variant = ARCH === 'arm64' ? 'macos-latest' : 'macos-15-intel'; return `https://github.com/google/dawn/releases/download/${DEPS.dawn.version}/Dawn-${commit}-${variant}-Release.tar.gz`; }if (platformName === 'linux') { - return `https://github.com/google/dawn/releases/download/${DEPS.dawn.version}/Dawn-${commit}-ubuntu-latest-Release.tar.gz`; + return `https://github.com/ThreeNativeHQ/threenative-engine/releases/download/dawn-linux-x64-v1/Dawn-${commit}-ubuntu-latest-Release.tar.gz`; }if (platformName === 'windows') { return `https://github.com/google/dawn/releases/download/${DEPS.dawn.version}/Dawn-${commit}-windows-latest-Release.tar.gz`; } @@ -173,16 +179,20 @@ const DEPS = { needsHeaders: true, }, v8: { - // V8 prebuilts from kuoruan/libv8 (see docs/V8_PREBUILTS.md for fork info) - // https://github.com/kuoruan/libv8/releases + // Linux arm64 uses the locked source-built cache; other targets use kuoruan/libv8. version: 'v13.1.201.22', - getUrl: () => { + getUrl: (lock) => { + if (platformName === 'linux' && ARCH === 'arm64') { + const payload = lock.payloads.get('v8-linux-arm64'); + if (!payload) throw new Error('TN_NATIVE_DEP_LOCK_MISSING: v8-linux-arm64 requires the verified cached archive descriptor; refusing x64 fallback or a source build.'); + return payload.url; + } // Platform mapping for kuoruan/libv8 releases if (platformName === 'macos') { const arch = ARCH === 'arm64' ? 'arm64' : 'x64'; return `https://github.com/kuoruan/libv8/releases/download/${DEPS.v8.version}/v8_macOS_${arch}.tar.xz`; }if (platformName === 'linux') { - // Only x64 available for Linux + // Linux x64 keeps the upstream prebuilt. return `https://github.com/kuoruan/libv8/releases/download/${DEPS.v8.version}/v8_Linux_x64.tar.xz`; }if (platformName === 'windows') { // Only x64 available for Windows (7z format) @@ -214,11 +224,16 @@ const DEPS = { // https://github.com/mystralengine/library-builder/releases version: 'quiche-0.24.6-3', getUrl: () => { - const baseUrl = 'https://github.com/mystralengine/library-builder/releases/download/quiche-0.24.6-3'; + // Linux arm64 first reached a published prebuilt at -4; x64 stays on -3. + const tag = platformName === 'linux' && ARCH === 'arm64' ? LINUX_ARM64_QUICHE : DEPS.quiche.version; + const baseUrl = `https://github.com/mystralengine/library-builder/releases/download/${tag}`; if (platformName === 'macos') { const arch = ARCH === 'arm64' ? 'arm64' : 'x86_64'; return `${baseUrl}/quiche-mac-${arch}.zip`; }if (platformName === 'linux') { + if (ARCH === 'arm64') { + return `${baseUrl}/quiche-linux-arm64.zip`; + } if (ARCH !== 'x64') { console.warn(`quiche prebuilts not available for ${platformName}-${archName}`); return null; @@ -293,7 +308,8 @@ const DEPS = { const arch = ARCH === 'arm64' ? 'arm64' : 'x86-64'; return `https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-mac-${arch}.tar.gz`; }if (platformName === 'linux') { - return `https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-linux-x86-64.tar.gz`; + const arch = ARCH === 'arm64' ? 'aarch64' : 'x86-64'; + return `https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-linux-${arch}.tar.gz`; }if (platformName === 'windows') { return `https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-windows-x64.zip`; } @@ -333,12 +349,17 @@ const DEPS = { // version: 'chrome/m145', getUrl: () => { - const baseUrl = 'https://github.com/olilarkin/skia-builder/releases/download/chrome%2Fm145'; + // olilarkin/skia-builder publishes Linux x64 only; library-builder mirrors the same + // chrome/m145 build for linux arm64, which is the arm64 archive this project pins. + const baseUrl = ARCH === 'arm64' && platformName === 'linux' + ? 'https://github.com/mystralengine/library-builder/releases/download/chrome%2Fm145' + : 'https://github.com/olilarkin/skia-builder/releases/download/chrome%2Fm145'; if (platformName === 'macos') { const arch = ARCH === 'arm64' ? 'arm64' : 'x86_64'; return `${baseUrl}/skia-build-mac-${arch}-gpu-release.zip`; }if (platformName === 'linux') { - return `${baseUrl}/skia-build-linux-x64-gpu-release.zip`; + const arch = ARCH === 'arm64' ? 'arm64' : 'x64'; + return `${baseUrl}/skia-build-linux-${arch}-gpu-release.zip`; }if (platformName === 'windows') { return `${baseUrl}/skia-build-win-x64-gpu-release.zip`; } @@ -350,11 +371,16 @@ const DEPS = { swc: { version: 'swc-11', getUrl: () => { - const baseUrl = 'https://github.com/mystralengine/library-builder/releases/download/swc-11'; + // swc-41 is the first library-builder tag with swc-linux-arm64.zip; x64 stays on swc-11. + const tag = platformName === 'linux' && ARCH === 'arm64' ? LINUX_ARM64_SWC : DEPS.swc.version; + const baseUrl = `https://github.com/mystralengine/library-builder/releases/download/${tag}`; if (platformName === 'macos') { const arch = ARCH === 'arm64' ? 'arm64' : 'x86_64'; return `${baseUrl}/swc-mac-${arch}.zip`; }if (platformName === 'linux') { + if (ARCH === 'arm64') { + return `${baseUrl}/swc-linux-arm64.zip`; + } if (ARCH !== 'x64') { console.warn(`SWC prebuilts not available for ${platformName}-${archName}`); return null; @@ -784,9 +810,23 @@ function lockPayloadForUrl(payloads, url) { } function receiptPathFor(destDir) { + // The frozen V8 build receipt covers every file in its installation tree. + if (platformName === 'linux' && ARCH === 'arm64' && destDir === join(THIRD_PARTY, 'v8')) { + return join(THIRD_PARTY, LOCK_RECEIPT_DIRNAME, 'v8-linux-arm64.json'); + } return join(destDir, LOCK_RECEIPT_DIRNAME, 'receipt.json'); } +function verifyLockedLinuxArm64V8(destDir, payload) { + const expected = payload.provenance?.receiptSha256; + if (!/^[a-f0-9]{64}$/u.test(expected ?? '')) + throw new Error(`TN_V8_RECEIPT_PIN_MISSING: ${payload.id} requires a locked producer receipt SHA-256.`); + const actual = sha256Bytes(readFileSync(join(destDir, 'build-receipt.json'))); + if (actual !== expected) + throw new Error(`TN_V8_RECEIPT_PIN_MISMATCH: ${payload.id} producer receipt differs from the locked archive.`); + return verifyLinuxArm64V8Installation(destDir); +} + function readReceipt(destDir, lockHash) { const path = receiptPathFor(destDir); if (!existsSync(path)) return undefined; @@ -1228,7 +1268,7 @@ export async function downloadDep(name, options = {}) { return false; } - console.log(`\n=== Downloading ${name} ${dep.version} ===`); + console.log(`\n=== Downloading ${name} ===`); const lock = options.lockContext ?? readNativeDepsLockContext(); if (name === 'v8-android') { @@ -1297,27 +1337,34 @@ export async function downloadDep(name, options = {}) { } } - const url = dep.getUrl(); + const url = dep.getUrl(lock); if (!url) { console.warn(`Skipping ${name} - no prebuilt available for this platform`); return false; } - if (!lockPayloadForUrl(lock.payloads, url)) { + // Same-lock receipts can name another architecture; require the selected payload too. + const selectedPayload = lockPayloadForUrl(lock.payloads, url); + if (!selectedPayload) { console.error( `TN_NATIVE_DEP_LOCK_MISSING: no lock payload covers ${url} (requested as '${name}'). Refusing an unreviewed download.`, ); return false; } + console.log(`Selected locked payload ${selectedPayload.id}: ${url}`); + // Check if already downloaded if (existsSync(destDir)) { console.log(`${name} already exists at ${destDir}`); if (!process.argv.includes('--force')) { if (!WGPU_DEPS.has(name)) { const receipt = readReceipt(destDir, lock.lockHash); - if (!receipt) { - console.warn(`${name} has no receipt for the current lock; replacing the stale dependency cache`); + if (!receipt || !Array.isArray(receipt.payloads) || !receipt.payloads.includes(selectedPayload.id)) { + console.warn(`${name} has no receipt for ${selectedPayload.id} under the current lock; replacing the stale dependency cache`); } else { + if (name === 'v8' && platformName === 'linux' && ARCH === 'arm64') { + verifyLockedLinuxArm64V8(destDir, selectedPayload); + } console.log('Skipping (use --force to re-download)'); return true; } @@ -1378,6 +1425,9 @@ export async function downloadDep(name, options = {}) { console.log(`Headers merged into ${destDir}`); } } + if (name === 'v8' && platformName === 'linux' && ARCH === 'arm64') { + verifyLockedLinuxArm64V8(destDir, payload); + } writeReceipt(destDir, { schemaVersion: 1, dependency: name, lockHash: lock.lockHash, payloads: [payload.id, ...verifiedExtra], files: listInstalledFiles(destDir), @@ -1545,18 +1595,11 @@ async function main() { if (requestedBackend === 'dawn') assertDawnAndroidArchive(); if (requestedWgpuVersion) configureWgpuOverride(requestedWgpuVersion); - // Linux arm64 has no prebuilt for the x64 desktop stack: kuoruan/libv8 ships Linux x64 only, - // Dawn's `ubuntu-latest` release asset is x64, and skia/swc/quiche/webp publish no arm64 Linux - // archive. A source V8 build is not allowed in CI. The arm64 desktop host is therefore built with - // the QuickJS engine over wgpu-native, whose `wgpu-linux-aarch64` prebuilt is already in the - // dependency lock; the CMake build disables the optional features whose libraries are absent - // (Canvas2D/Skia, WebTransport/quiche, TS/SWC) instead of linking a foreign architecture. libuv - // has no arm64 prebuilt either, so arm64 also fetches the architecture-independent `libuv-source` - // and CMake builds it from that pinned source (see CMakeLists.txt). See scripts/native-build.mjs - // for the matching configure overrides. + // Linux arm64 uses cached V8 over wgpu-native; QuickJS remains available for paired builds. + // Keep the desktop feature set; libuv builds from locked source where no prebuilt exists. const linuxArm64 = platformName === 'linux' && ARCH === 'arm64'; const desktopDeps = linuxArm64 - ? ['wgpu', 'sdl3', 'quickjs', 'stb', 'libuv-source'] + ? ['wgpu', 'sdl3', 'v8', 'quickjs', 'stb', 'webp', 'skia', 'swc', 'quiche', 'libuv-source'] : ['wgpu', 'sdl3', 'dawn', 'v8', 'quickjs', 'stb', 'webp', platformName === 'windows' ? 'skia-win-static' : 'skia', 'swc', 'libuv', 'libuv-source', 'quiche']; // iOS deps (only downloaded with --only or --ios) diff --git a/packages/runtime-native/scripts/generate-native-sbom.mjs b/packages/runtime-native/scripts/generate-native-sbom.mjs index 3b192b70a0..72acbb1139 100644 --- a/packages/runtime-native/scripts/generate-native-sbom.mjs +++ b/packages/runtime-native/scripts/generate-native-sbom.mjs @@ -40,9 +40,17 @@ export function collectReceipts(thirdPartyRoot = THIRD_PARTY, expectedLockHash) if (!existsSync(thirdPartyRoot)) return receipts; for (const entry of readdirSync(thirdPartyRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - const receiptPath = join(thirdPartyRoot, entry.name, RECEIPT_DIRNAME, 'receipt.json'); + // The arm64 V8 producer hashes its entire tree, so acquisition metadata lives outside it. + const v8Sidecar = join(thirdPartyRoot, RECEIPT_DIRNAME, 'v8-linux-arm64.json'); + const nestedReceipt = join(thirdPartyRoot, entry.name, RECEIPT_DIRNAME, 'receipt.json'); + const externalV8 = entry.name === 'v8' && !existsSync(nestedReceipt) && existsSync(v8Sidecar); + const receiptPath = externalV8 ? v8Sidecar : nestedReceipt; if (!existsSync(receiptPath)) continue; const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); + if (externalV8 && (receipt.dependency !== 'v8' || !Array.isArray(receipt.payloads) || + receipt.payloads.length !== 1 || receipt.payloads[0] !== 'v8-linux-arm64')) { + fail('TN_NATIVE_SBOM_RECEIPT_MALFORMED', 'arm64 V8 sidecar must name v8 and its selected payload'); + } receipts.push({ dir: entry.name, ...receipt }); } if (expectedLockHash !== undefined) { diff --git a/packages/runtime-native/scripts/measure-native-coverage.mjs b/packages/runtime-native/scripts/measure-native-coverage.mjs index 1316788600..82f0774700 100644 --- a/packages/runtime-native/scripts/measure-native-coverage.mjs +++ b/packages/runtime-native/scripts/measure-native-coverage.mjs @@ -318,7 +318,9 @@ export function retainedCoverageFloors(previousRecord, subsystems) { } return subsystems.map(({ name, percent }) => { const path = `src/${name}${name.includes(".") ? "" : "/"}`; - return { name: path, percent: recorded.get(path) ?? percent }; + // Match the gate's five-point margin without ever loosening an established floor. + const ratcheted = Number((percent - 5).toFixed(2)); + return { name: path, percent: Math.max(recorded.get(path) ?? percent, ratcheted) }; }); } diff --git a/packages/runtime-native/scripts/measure-starter-js-frame-pair.mjs b/packages/runtime-native/scripts/measure-starter-js-frame-pair.mjs new file mode 100644 index 0000000000..bc5fc51781 --- /dev/null +++ b/packages/runtime-native/scripts/measure-starter-js-frame-pair.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LINUX_ARM64_V8_BUILD } from './build-linux-arm64-v8.mjs'; + +export const launchOrder = ['quickjs', 'v8', 'v8', 'quickjs', 'quickjs', 'v8']; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const median = (values) => { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; +const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex'); +function requireEvidence(condition, message) { + if (!condition) throw new Error(`TN_STARTER_JS_PAIR: ${message}`); +} + +export function compareRuns(runs) { + requireEvidence(runs.length === 6, 'six paired runs required'); + let expectedSurface; + let expectedFrames; + let expectedAdapter; + let expectedFeatures; + const binaryHashes = new Map(); + const summaries = runs.map((run, index) => { + const { engine, report, log } = run; + requireEvidence(engine === launchOrder[index], 'launch order mismatch'); + requireEvidence(/^[a-f0-9]{64}$/.test(run.bundleSha256) && run.bundleSha256 === runs[0].bundleSha256, 'bundle identity mismatch'); + requireEvidence(/^[a-f0-9]{64}$/.test(run.binarySha256), 'binary identity missing'); + binaryHashes.set(engine, binaryHashes.get(engine) ?? run.binarySha256); + requireEvidence(binaryHashes.get(engine) === run.binarySha256, 'binary changed between runs'); + const name = engine === 'v8' ? 'V8' : 'QuickJS'; + const other = engine === 'v8' ? 'QuickJS' : 'V8'; + requireEvidence(log.includes(`[${name}] Engine created successfully`) && !log.includes(`[${other}] Engine created successfully`), 'engine identity mismatch'); + const version = log.match(/\[V8\] Version: (\S+)/)?.[1]; + requireEvidence(engine !== 'v8' || version === LINUX_ARM64_V8_BUILD.version, 'V8 version differs from pinned producer'); + const adapter = log.match(/\[WebGPU\] Adapter: (.+)/)?.[1]?.trim(); + const backend = log.match(/\[WebGPU\] Backend: (.+)/)?.[1]?.trim(); + requireEvidence(adapter && /llvmpipe|lavapipe|software/i.test(adapter) && backend?.includes('Vulkan'), 'software Vulkan adapter not observed'); + const featureLines = [...log.matchAll(/TN_WORLD_ENVIRONMENT:(\{[^\r\n]+\})/g)]; + const features = featureLines.at(-1)?.[1]; + requireEvidence(features && Array.isArray(JSON.parse(features).stages) && JSON.parse(features).stages.length > 0 && !log.includes('TN_GRADE_TABLE '), 'render features missing or asset failed'); + expectedFeatures ??= features; + requireEvidence(features === expectedFeatures, 'render feature workload changed'); + expectedAdapter ??= adapter; + requireEvidence(adapter === expectedAdapter, 'adapter changed'); + requireEvidence(report?.pass === true && report.violations?.length === 0 && JSON.stringify(report.discardedWindows) === '[1]', 'perf reader rejected run or startup not discarded'); + requireEvidence(report.budgets?.length === 3, 'exactly startup plus two steady windows required'); + const windows = report.budgets.slice(1); + for (const [offset, window] of windows.entries()) { + requireEvidence(window.window === offset + 2 && Number.isInteger(window.frames) && window.frames > 0, 'steady window missing'); + requireEvidence(['mean', 'p50', 'p95'].every((key) => Number.isFinite(window.frame?.[key]) && window.frame[key] > 0), 'JS frame summary missing or malformed'); + const surface = window.surface; + requireEvidence(surface && surface.drawingBufferWidth === 1280 && surface.drawingBufferHeight === 720 && surface.resolutionScale === 1 && Number.isInteger(surface.sampleCount) && surface.sampleCount > 0 && surface.compiling === false, 'resolution/sample/compilation observation missing or changed'); + const identity = JSON.stringify([surface.drawingBufferWidth, surface.drawingBufferHeight, surface.resolutionScale, surface.sampleCount]); + expectedSurface ??= identity; + expectedFrames ??= window.frames; + requireEvidence(identity === expectedSurface && window.frames === expectedFrames, 'sample surface or frame count mismatch'); + } + return { engine, version, adapter, backend, bundleSha256: run.bundleSha256, binarySha256: run.binarySha256, windowP50sMs: windows.map((w) => w.frame.p50), runP50Ms: windows[1].frame.p50 }; + }); + requireEvidence(binaryHashes.get('v8') !== binaryHashes.get('quickjs'), 'same binary used for both engines'); + const quickjsMedianMs = median(summaries.filter((run) => run.engine === 'quickjs').map((run) => run.runP50Ms)); + const v8MedianMs = median(summaries.filter((run) => run.engine === 'v8').map((run) => run.runP50Ms)); + return { + pass: v8MedianMs < quickjsMedianMs, + metric: 'JS callback frame duration: window 3 frame.p50 per run; arm = median of three run p50s', + scope: 'same hosted Linux ARM64 VM; software Vulkan; no GPU performance claim', + protocol: { launchOrder, warmup: 'discard window 1 per launch', steadyWindows: [2, 3], validationWindow: 2, sampleWindow: 3, framesPerWindow: expectedFrames, width: 1280, height: 720 }, + features: JSON.parse(expectedFeatures), quickjsMedianMs, v8MedianMs, runs: summaries, + }; +} + +function command(executable, args, options = {}) { + const result = spawnSync(executable, args, { encoding: 'utf8', timeout: 240_000, maxBuffer: 64 * 1024 * 1024, ...options }); + requireEvidence(!result.error && result.status === 0, `${executable} failed: ${result.error?.message ?? result.stderr ?? result.status}`); + return result.stdout; +} + +function main() { + const [quickjs, v8, bundle, output] = process.argv.slice(2).map((path) => resolve(path)); + requireEvidence(quickjs && v8 && bundle && output && quickjs !== v8, 'usage: '); + requireEvidence(process.platform === 'linux' && process.arch === 'arm64', 'requires Linux ARM64'); + mkdirSync(output, { recursive: true }); + const binaries = { quickjs, v8 }; + const bundleSha256 = hash(bundle); + const binaryHashes = { quickjs: hash(quickjs), v8: hash(v8) }; + const cwd = resolve(dirname(bundle), '../public'); + const env = { ...process.env, SDL_VIDEODRIVER: 'x11' }; + // CPU profiling changes absolutes; inherited diagnostics must not contaminate either arm. + for (const key of ['TN_JS_CPU_PROFILE', 'TN_JS_CPU_PROFILE_START_FRAME', 'TN_V8_FLAGS']) delete env[key]; + const runs = []; + for (const [index, engine] of launchOrder.entries()) { + requireEvidence(hash(bundle) === bundleSha256 && hash(binaries[engine]) === binaryHashes[engine], 'input mutated'); + const prefix = join(output, `${index + 1}-${engine}`); + // exec preserves the host PID so the shipped perf collector's SIGTERM reaches the host. + // tee retains the exact raw stream without adding measurement instrumentation. + const stdout = command(process.execPath, [ + join(root, 'packages/playtest/dist/runner/cli.js'), 'perf', '--require-windows', '2', '--timeout', '180', + '--executable', '/bin/bash', '--host-arg', '-c', '--host-arg', + 'exec "$1" run "$2" --ui "$4" --width 1280 --height 720 > >(tee "$3") 2>&1', + '--host-arg', 'starter-perf', '--host-arg', binaries[engine], '--host-arg', bundle, '--host-arg', `${prefix}.log`, '--host-arg', join(dirname(bundle), 'ui'), + ], { cwd, env }); + writeFileSync(`${prefix}.perf.json`, stdout); + requireEvidence(hash(bundle) === bundleSha256 && hash(binaries[engine]) === binaryHashes[engine], 'input mutated during run'); + runs.push({ engine, bundleSha256, binarySha256: binaryHashes[engine], log: readFileSync(`${prefix}.log`, 'utf8'), report: JSON.parse(stdout) }); + } + const comparison = compareRuns(runs); + writeFileSync(join(output, 'comparison.json'), `${JSON.stringify(comparison, null, 2)}\n`); + console.log(JSON.stringify(comparison, null, 2)); + requireEvidence(comparison.pass, 'V8 median JS frame duration must be strictly lower than QuickJS'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/packages/runtime-native/scripts/native-build.mjs b/packages/runtime-native/scripts/native-build.mjs index e6446ffab3..b1608e1130 100644 --- a/packages/runtime-native/scripts/native-build.mjs +++ b/packages/runtime-native/scripts/native-build.mjs @@ -89,11 +89,8 @@ if (process.env.TN_ENABLE_CSS_UI === '1') { `-DTHREENATIVE_CSS_UI_LIBRARY=${cssUiLibraryPath(root)}`, ); } -// Linux arm64 has no V8 or Dawn prebuilt (kuoruan/libv8 ships Linux x64 only; Dawn's -// `ubuntu-latest` asset is x64) and a source V8 build is not allowed in CI. Build the arm64 -// desktop host with the QuickJS engine over the wgpu-native backend — the combination that does -// have an arm64 path (`wgpu-linux-aarch64`). Command-line cache entries override the tn-linux -// preset's V8+Dawn defaults, so the binary directory the CI lane reads stays `build/tn-linux`. +// Linux arm64 selects cached V8 over wgpu-native. Cache entries override the tn-linux +// preset's Dawn default while preserving the build/tn-linux directory consumed by CI. configureArgs.push(...Object.entries(desktopBuildOverrides()).map(([key, value]) => `-D${key}=${value}`)); const vcpkgRoot = process.env.VCPKG_ROOT ?? process.env.VCPKG_INSTALLATION_ROOT; diff --git a/packages/runtime-native/scripts/verify-desktop-core.mjs b/packages/runtime-native/scripts/verify-desktop-core.mjs index ac545c70a5..d391375d97 100644 --- a/packages/runtime-native/scripts/verify-desktop-core.mjs +++ b/packages/runtime-native/scripts/verify-desktop-core.mjs @@ -281,6 +281,19 @@ export function analyzePresentTicks(log, { minTicks = 1 } = {}) { return { failures: [...new Set(failures)], ticks }; } +/** + * The Linux video driver this run must use, from the environment a caller set. + * + * SDL 3 reads `SDL_VIDEO_DRIVER`; `SDL_VIDEODRIVER` is the SDL 2 spelling the documented + * `SDL_VIDEODRIVER=wayland pnpm native:verify:desktop` still uses, and `src/platform/window.cpp` + * honours it for the same reason. SDL_VIDEO_DRIVER wins when both are set. Absent either, this gate + * keeps its X11 default. + */ +export function requestedLinuxVideoDriver(env = process.env) { + const driver = env.SDL_VIDEO_DRIVER || env.SDL_VIDEODRIVER || 'x11'; + return { driver, wayland: driver === 'wayland' }; +} + export function verifyDesktopCore({ frames = 300 } = {}) { const preset = process.platform === 'darwin' ? 'tn-macos' @@ -308,13 +321,20 @@ export function verifyDesktopCore({ frames = 300 } = {}) { // Not `xvfb-run`: on xorg-server-xvfb 21.1.24 its cleanup `kill` fails after Xvfb has already // exited and that failing kill's status replaces the command's, so this gate reported a red // 300-frame run that had in fact rendered every frame and written a good screenshot. - const command = process.platform === 'linux' ? 'sh' : binary; - const args = process.platform === 'linux' + const requested = requestedLinuxVideoDriver(); + // Xvfb is an X11 server, so wrapping a Wayland request in it would run the proof on X11 and report + // it as Wayland. A Wayland run needs the compositor the caller started, and it reads the window + // handles SDL's Wayland driver hands back. + const command = process.platform === 'linux' && !requested.wayland ? 'sh' : binary; + const args = process.platform === 'linux' && !requested.wayland ? [join(workspace, 'scripts', 'xvfb.sh'), binary, ...runtimeArgs] : runtimeArgs; const runtimeEnv = { ...process.env }; if (process.platform === 'linux') { - runtimeEnv.SDL_VIDEODRIVER = 'x11'; + console.info(`desktop core gate: SDL video driver ${requested.driver}${requested.wayland ? ' (no Xvfb wrapper; the compositor comes from the environment)' : ''}`); + // Both spellings, so the child resolves the same driver this gate read. + runtimeEnv.SDL_VIDEO_DRIVER = requested.driver; + runtimeEnv.SDL_VIDEODRIVER = requested.driver; // Same reason as the video driver: this gate runs headless on a runner with no sound card, and // SDL aborts before the first frame with // [Audio] Failed to open audio device: ALSA: Couldn't open audio device: No such file or diff --git a/packages/runtime-native/scripts/xvfb.sh b/packages/runtime-native/scripts/xvfb.sh index 17d3bc99dd..b7c3f8cf23 100644 --- a/packages/runtime-native/scripts/xvfb.sh +++ b/packages/runtime-native/scripts/xvfb.sh @@ -33,6 +33,17 @@ display_file="$runtime/display" Xvfb -displayfd 3 +extension COMPOSITE +extension SHAPE -screen 0 "$screen" -nolisten tcp 3>"$display_file" & xvfb_pid=$! +# This script hands the command an X11 server, so it is also the honest place to answer a Wayland +# request: say which display the run really uses instead of letting an X11 result pass as Wayland. +# Both spellings are set because SDL 3 reads SDL_VIDEO_DRIVER and src/platform/window.cpp also +# honours the SDL 2 spelling SDL_VIDEODRIVER. +if [ "${SDL_VIDEO_DRIVER:-${SDL_VIDEODRIVER:-}}" = "wayland" ]; then + echo "scripts/xvfb.sh: SDL_VIDEO_DRIVER/SDL_VIDEODRIVER asks for Wayland; this gate runs on the X11 display this script provides." >&2 +fi +SDL_VIDEO_DRIVER=x11 +SDL_VIDEODRIVER=x11 +export SDL_VIDEO_DRIVER SDL_VIDEODRIVER + compositor_pid="" cleanup() { diff --git a/packages/runtime-native/src/cli/main.cpp b/packages/runtime-native/src/cli/main.cpp index 2370739a00..909a114d0c 100644 --- a/packages/runtime-native/src/cli/main.cpp +++ b/packages/runtime-native/src/cli/main.cpp @@ -1124,7 +1124,18 @@ static std::unique_ptr createConfiguredRuntime(const CLIOption // Only the web overlay needs this. The CSS UI backend attaches to no window system at all — it // hands the compositor pixels — so it must not cost a Wayland session its native video driver. if (!opts.uiRoot.empty() && !opts.cssUiRenderer) { - // The web overlay attaches to an X11 surface, also on Wayland via XWayland. + // The web overlay attaches to an X11 surface, also on Wayland via XWayland. SDL_HINT_OVERRIDE + // below replaces any explicit driver, so a Wayland request with this overlay would run on X11 + // without saying so. Refuse it instead and name the two ways forward. + const char* sdl3Driver = std::getenv("SDL_VIDEO_DRIVER"); + const char* legacyDriver = std::getenv("SDL_VIDEODRIVER"); + const char* requestedDriver = (sdl3Driver != nullptr && *sdl3Driver != '\0') ? sdl3Driver : legacyDriver; + if (requestedDriver != nullptr && std::string(requestedDriver) == "wayland") { + std::cerr << "[Mystral] The web UI overlay needs X11, but SDL_VIDEO_DRIVER/SDL_VIDEODRIVER " + "asks for Wayland. Set ui.renderer: \"native-css\", or unset the variable." + << std::endl; + std::exit(1); + } // Select both backends before SDL/GTK initialization and worker creation; a desktop // session's GDK_BACKEND=wayland must not leave the game's requested HUD unattached. SDL_SetHintWithPriority(SDL_HINT_VIDEO_DRIVER, "x11", SDL_HINT_OVERRIDE); diff --git a/packages/runtime-native/src/js/quickjs_engine.cpp b/packages/runtime-native/src/js/quickjs_engine.cpp index e02ff18a4b..25988bdab8 100644 --- a/packages/runtime-native/src/js/quickjs_engine.cpp +++ b/packages/runtime-native/src/js/quickjs_engine.cpp @@ -8,6 +8,7 @@ #include "mystral/js/engine.h" #include "mystral/cold_start.h" #include "mystral/js/module_system.h" +#include #include #include #include @@ -1216,36 +1217,41 @@ class QuickJSEngine : public Engine { return oss.str(); } + static JSValue writeConsoleLine(JSContext* ctx, FILE* stream, const std::string& line) { + const size_t written = std::fwrite(line.data(), 1, line.size(), stream); + const int flushed = std::fflush(stream); + if (written != line.size() || flushed != 0) + return JS_ThrowInternalError(ctx, "Console write failed"); + return JS_UNDEFINED; + } + static JSValue js_console_log(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { std::string msg = buildConsoleMessage(ctx, argc, argv); - std::cout << "[log] " << msg << std::endl; + const std::string full = "[log] " + msg; #ifdef __ANDROID__ // __android_log_write, not LOGI/__android_log_print: the print formatter caps at // LOG_BUF_SIZE 1024 (1023 bytes), which truncated TN_FRAME_BUDGET windows. - const std::string full = "[log] " + msg; __android_log_write(ANDROID_LOG_INFO, ANDROID_LOG_TAG, full.c_str()); #endif - return JS_UNDEFINED; + return writeConsoleLine(ctx, stdout, full + '\n'); } static JSValue js_console_warn(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { std::string msg = buildConsoleMessage(ctx, argc, argv); - std::cout << "[warn] " << msg << std::endl; -#ifdef __ANDROID__ const std::string full = "[warn] " + msg; +#ifdef __ANDROID__ __android_log_write(ANDROID_LOG_WARN, ANDROID_LOG_TAG, full.c_str()); #endif - return JS_UNDEFINED; + return writeConsoleLine(ctx, stdout, full + '\n'); } static JSValue js_console_error(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { std::string msg = buildConsoleMessage(ctx, argc, argv); - std::cerr << "[error] " << msg << std::endl; -#ifdef __ANDROID__ const std::string full = "[error] " + msg; +#ifdef __ANDROID__ __android_log_write(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, full.c_str()); #endif - return JS_UNDEFINED; + return writeConsoleLine(ctx, stderr, full + '\n'); } static JSValue js_performance_now(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) { diff --git a/packages/runtime-native/src/js/v8_engine.cpp b/packages/runtime-native/src/js/v8_engine.cpp index 25038daf46..7b0f486867 100644 --- a/packages/runtime-native/src/js/v8_engine.cpp +++ b/packages/runtime-native/src/js/v8_engine.cpp @@ -1707,13 +1707,20 @@ class V8Engine : public Engine { line += (*str ? *str : ""); if (i < info.Length() - 1) line += " "; } - std::cout << "[" << *prefixUtf8 << "] " << line << std::endl; + std::string full = std::string("[") + *prefixUtf8 + "] " + line; #if defined(__ANDROID__) // __android_log_write, not __android_log_print: the print formatter caps at // LOG_BUF_SIZE 1024 (1023 bytes), which truncated TN_FRAME_BUDGET windows. - const std::string full = std::string("[") + *prefixUtf8 + "] " + line; __android_log_write(ANDROID_LOG_INFO, "MystralJS", full.c_str()); #endif + full += '\n'; + const size_t written = std::fwrite(full.data(), 1, full.size(), stdout); + const int flushed = std::fflush(stdout); + if (written != full.size() || flushed != 0) { + isolate->ThrowException(v8::Exception::Error( + v8::String::NewFromUtf8(isolate, "Console stdout write failed").ToLocalChecked())); + return; + } }, v8::String::NewFromUtf8(isolate_, prefix).ToLocalChecked())->GetFunction(context).ToLocalChecked(); }; diff --git a/packages/runtime-native/src/platform/window.cpp b/packages/runtime-native/src/platform/window.cpp index 61e26347ce..ca4be7d7d0 100644 --- a/packages/runtime-native/src/platform/window.cpp +++ b/packages/runtime-native/src/platform/window.cpp @@ -228,9 +228,15 @@ bool createWindow( std::cout << "[Window] Creating window: " << title << " (" << width << "x" << height << ")" << std::endl; #if defined(__linux__) && !defined(__ANDROID__) - // The Linux WebGPU surface currently consumes SDL's X11 handles. Prefer that supported - // backend even in a Wayland session; an explicit SDL_VIDEODRIVER remains an override. - SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "x11"); + // X11 stays the default because it is the backend the UI overlay and the verified desktop lane + // use. SDL 3 reads SDL_VIDEO_DRIVER only, so an explicit request written with the SDL 2 + // spelling (SDL_VIDEODRIVER) is honoured here as well; SDL_VIDEO_DRIVER wins when both are set. + const char* sdl3Driver = std::getenv("SDL_VIDEO_DRIVER"); + const char* legacyDriver = std::getenv("SDL_VIDEODRIVER"); + const char* requestedDriver = (sdl3Driver != nullptr && *sdl3Driver != '\0') ? sdl3Driver + : ((legacyDriver != nullptr && *legacyDriver != '\0') ? legacyDriver : "x11"); + std::cout << "[Window] SDL video driver request: " << requestedDriver << std::endl; + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, requestedDriver); #endif // Initialize SDL if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_GAMEPAD)) { diff --git a/packages/runtime-native/src/runtime.cpp b/packages/runtime-native/src/runtime.cpp index fa53aa38cb..cb0efe1c64 100644 --- a/packages/runtime-native/src/runtime.cpp +++ b/packages/runtime-native/src/runtime.cpp @@ -652,34 +652,53 @@ class RuntimeImpl : public Runtime { return false; } #elif defined(__linux__) - // Linux: Use X11 from SDL (Wayland support would need additional work) + // Linux: use whichever handles SDL actually gave us. The driver is named here because the + // receipt for "this ran on Wayland" has to come from SDL, not from an environment variable + // a caller set. SDL_Window* sdlWindow = platform::getSDLWindow(); if (!sdlWindow) { std::cerr << "[Mystral] Failed to get SDL window" << std::endl; return false; } - // Try X11 first - void* xdisplay = SDL_GetPointerProperty(SDL_GetWindowProperties(sdlWindow), - SDL_PROP_WINDOW_X11_DISPLAY_POINTER, nullptr); - if (xdisplay) { - auto xwindow = static_cast(SDL_GetNumberProperty(SDL_GetWindowProperties(sdlWindow), + SDL_PropertiesID windowProps = SDL_GetWindowProperties(sdlWindow); + std::cout << "[Mystral] SDL video driver: " + << (SDL_GetCurrentVideoDriver() ? SDL_GetCurrentVideoDriver() : "none") << std::endl; + + void* wlDisplay = SDL_GetPointerProperty(windowProps, + SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, nullptr); + if (wlDisplay) { + void* wlSurface = SDL_GetPointerProperty(windowProps, + SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, nullptr); + if (!wlSurface) { + std::cerr << "[Mystral] Wayland display without a surface" << std::endl; + return false; + } + std::cout << "[Mystral] Using Wayland display: " << wlDisplay << " surface: " << wlSurface << std::endl; + if (!webgpu_->createSurfaceWithDisplay(wlDisplay, wlSurface, webgpu::Context::PLATFORM_WAYLAND)) { + std::cerr << "[Mystral] Failed to create WebGPU surface" << std::endl; + return false; + } + } else { + void* xdisplay = SDL_GetPointerProperty(windowProps, + SDL_PROP_WINDOW_X11_DISPLAY_POINTER, nullptr); + if (!xdisplay) { + std::cerr << "[Mystral] Wayland or X11 display not available." << std::endl; + return false; + } + auto xwindow = static_cast(SDL_GetNumberProperty(windowProps, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0)); - if (xwindow) { - std::cout << "[Mystral] Using X11 display: " << xdisplay << " window: " << xwindow << std::endl; - // Pass both display and window pointer for proper X11 surface creation - // Dawn requires the display pointer, wgpu-native can work with just window - if (!webgpu_->createSurfaceWithDisplay(xdisplay, reinterpret_cast(xwindow), webgpu::Context::PLATFORM_XLIB)) { - std::cerr << "[Mystral] Failed to create WebGPU surface" << std::endl; - return false; - } - } else { + if (!xwindow) { std::cerr << "[Mystral] Failed to get X11 window" << std::endl; return false; } - } else { - std::cerr << "[Mystral] X11 display not available. Wayland not yet supported." << std::endl; - return false; + std::cout << "[Mystral] Using X11 display: " << xdisplay << " window: " << xwindow << std::endl; + // Pass both display and window pointer for proper X11 surface creation + // Dawn requires the display pointer, wgpu-native can work with just window + if (!webgpu_->createSurfaceWithDisplay(xdisplay, reinterpret_cast(xwindow), webgpu::Context::PLATFORM_XLIB)) { + std::cerr << "[Mystral] Failed to create WebGPU surface" << std::endl; + return false; + } } #else std::cerr << "[Mystral] WebGPU surface creation not implemented for this platform" << std::endl; diff --git a/packages/runtime-native/src/webgpu/bindings.cpp b/packages/runtime-native/src/webgpu/bindings.cpp index dc3efa4a8b..f292254304 100644 --- a/packages/runtime-native/src/webgpu/bindings.cpp +++ b/packages/runtime-native/src/webgpu/bindings.cpp @@ -1688,8 +1688,29 @@ static js::JSValueHandle handleGpuQueueWriteBuffer(BindingsState* state, Binding size_t dataSize = 0; void* dataPtr = state->engine->getArrayBufferData(args[2], &dataSize); if (!dataPtr || dataSize == 0) { - state->engine->throwException("writeBuffer: invalid data"); - return state->engine->newUndefined(); + // Empty backing stores may have no pointer; brand-check before accepting a no-op. + js::JSValueGuard isEmptySource(*state->engine, state->engine->evalScriptWithResult(R"JS((data) => { + const typedArrays = Object.getPrototypeOf(Uint8Array.prototype); + const emptySource = (prototype, probe) => { + let empty; + try { empty = Object.getOwnPropertyDescriptor(prototype, "byteLength").get.call(data) === 0; } + catch { return false; } + if (!empty) return false; + try { probe(data); } catch { return false; } + return true; + }; + // A detached ArrayBuffer or TypedArray still reports byteLength 0, so probe it: + // both probes throw on a detached receiver and run no user code. + if (emptySource(ArrayBuffer.prototype, (value) => new Uint8Array(value))) return true; + if (emptySource(typedArrays, (value) => typedArrays.values.call(value))) return true; + return emptySource(DataView.prototype, () => {}); + })JS", "write-buffer-empty-source.js")); + js::JSValueGuard validEmpty(*state->engine, state->engine->call( + isEmptySource.get(), state->engine->newUndefined(), {args[2]})); + if (!state->engine->toBoolean(validEmpty.get())) { + state->engine->throwException("writeBuffer: invalid data"); + return state->engine->newUndefined(); + } } // WebGPU expresses dataOffset/size in elements for a TypedArray, // but in bytes for ArrayBuffer and DataView inputs. @@ -1716,6 +1737,7 @@ static js::JSValueHandle handleGpuQueueWriteBuffer(BindingsState* state, Binding state->engine->throwException("writeBuffer: source range exceeds the supplied buffer view"); return state->engine->newUndefined(); } + if (writeSize == 0) return state->engine->newUndefined(); const uint8_t* source = static_cast(dataPtr) + dataOffset; if (buffer && state->queue) { const size_t alignedWriteSize = (writeSize + 3) & ~size_t(3); diff --git a/packages/runtime-native/src/webgpu/bindings_pipelines.cpp b/packages/runtime-native/src/webgpu/bindings_pipelines.cpp index 8a3737a40f..aca79a105d 100644 --- a/packages/runtime-native/src/webgpu/bindings_pipelines.cpp +++ b/packages/runtime-native/src/webgpu/bindings_pipelines.cpp @@ -10,6 +10,7 @@ #include "mystral/webgpu/wrapper_factories.h" #include +#include #include #include #include @@ -390,6 +391,12 @@ const char* pipelineBackendName(WGPUBackendType backend) { } } +static void writePipelineLine(const std::string& line) { + const size_t written = std::fwrite(line.data(), 1, line.size(), stdout); + const int flushed = std::fflush(stdout); + if (written != line.size() || flushed != 0) std::abort(); +} + void reportPipelineCaptureMetadata(WGPUAdapter adapter) { std::string device; std::string vendor; @@ -411,25 +418,31 @@ void reportPipelineCaptureMetadata(WGPUAdapter adapter) { for (const std::string& part : {vendor, device, architecture, description}) { if (!part.empty()) identity += "/" + part; } - std::cout << "TN_PIPELINE_CAPTURE:{\"version\":1,\"build\":{\"identity\":" + std::ostringstream line; + line << "TN_PIPELINE_CAPTURE:{\"version\":1,\"build\":{\"identity\":" << pipelineJsonString(std::string("mystral-native@") + mystral::getVersion()) << "},\"adapter\":{\"identity\":" << pipelineJsonString(identity) << ",\"thermal\":\"unavailable\"},\"clock\":{\"source\":\"steady\",\"originMs\":0}}" - << std::endl; + << '\n'; + writePipelineLine(line.str()); } void reportPipelineFirstPresent() { - std::cout << std::setprecision(17) + std::ostringstream line; + line << std::setprecision(17) << "TN_PIPELINE_FIRST_PRESENT:{\"version\":1,\"boundaryMs\":" - << pipelineClockMs() << "}" << std::endl; + << pipelineClockMs() << "}" << '\n'; + writePipelineLine(line.str()); } void reportPipelineCaptureComplete(uint64_t eventCount) { - std::cout << "TN_PIPELINE_COMPLETE:{\"version\":1,\"eventCount\":" - << eventCount << "}" << std::endl; + std::ostringstream line; + line << "TN_PIPELINE_COMPLETE:{\"version\":1,\"eventCount\":" + << eventCount << "}" << '\n'; + writePipelineLine(line.str()); } -/** One writer at a time, so no marker line is ever spliced into the middle of another. */ +/** Serialize pipeline event writes with their counters and checkpoint snapshots. */ std::mutex& pipelineOutputMutex() { static std::mutex mutex; return mutex; @@ -455,7 +468,8 @@ static void emitPipelineEvent(BindingsState* state, const double queueMs = std::max(0.0, completion.startedMs - completion.enqueuedMs); const double serviceMs = std::max(0.0, completion.finishedMs - completion.startedMs); const double wallMs = std::max(0.0, completion.finishedMs - completion.enqueuedMs); - std::cout << std::setprecision(17) + std::ostringstream line; + line << std::setprecision(17) << "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":" << completion.requestId << ",\"pipelineIdentity\":\"native-" << kind << '-' << identity << "\",\"programIdentity\":" << pipelineJsonString(program) @@ -468,22 +482,23 @@ static void emitPipelineEvent(BindingsState* state, << ",\"queueMs\":" << queueMs << ",\"serviceMs\":" << serviceMs << ",\"wallMs\":" << wallMs << ",\"provenance\":{\"unknown\":true}"; if (!completion.vertexHash.empty()) { - std::cout << (completion.render ? ",\"vertex\":{\"hash\":" : ",\"compute\":{\"hash\":") + line << (completion.render ? ",\"vertex\":{\"hash\":" : ",\"compute\":{\"hash\":") << pipelineJsonString(completion.vertexHash) << ",\"bytes\":" << completion.vertexBytes << '}'; } if (!completion.fragmentHash.empty()) { - std::cout << ",\"fragment\":{\"hash\":" + line << ",\"fragment\":{\"hash\":" << pipelineJsonString(completion.fragmentHash) << ",\"bytes\":" << completion.fragmentBytes << '}'; } // PRD-368. What the host did, never what the backend found: the cache API reports no // per-pipeline hit or miss, so this says a cache of this mode was in force for the creation. - std::cout << ",\"cache\":" << pipelineJsonString(state->pipelineCache.mode); + line << ",\"cache\":" << pipelineJsonString(state->pipelineCache.mode); if (!completion.label.empty()) - std::cout << ",\"label\":" << pipelineJsonString(completion.label); - if (!success) std::cout << ",\"error\":" << pipelineJsonString(error); - std::cout << "}\n"; + line << ",\"label\":" << pipelineJsonString(completion.label); + if (!success) line << ",\"error\":" << pipelineJsonString(error); + line << "}\n"; + writePipelineLine(line.str()); state->asyncPipelines.emitted += 1; } @@ -521,9 +536,11 @@ void reportPipelineCheckpoint(BindingsState* state, uint64_t presentCount) { pool.checkpointRequested = requested; pool.checkpointEmitted = emitted; pool.checkpointOutstanding = outstanding; - std::cout << "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":" << presentCount + std::ostringstream line; + line << "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":" << presentCount << ",\"requested\":" << requested << ",\"emitted\":" << emitted - << ",\"outstanding\":" << outstanding << "}" << std::endl; + << ",\"outstanding\":" << outstanding << "}" << '\n'; + writePipelineLine(line.str()); } // PRD-368. Persistence and lifecycle live in bindings_pipeline_cache.cpp. Attachment stays beside diff --git a/packages/runtime-native/src/webgpu/context.cpp b/packages/runtime-native/src/webgpu/context.cpp index 23224077bd..8a6ca3b98a 100644 --- a/packages/runtime-native/src/webgpu/context.cpp +++ b/packages/runtime-native/src/webgpu/context.cpp @@ -1042,6 +1042,7 @@ bool Context::createSurfaceWithDisplay(void* display, void* window, int platform #if defined(__linux__) && !defined(__ANDROID__) WGPUSurfaceDescriptorFromXlibWindow_Compat xlibDesc = {}; + WGPUSurfaceDescriptorFromWaylandSurface_Compat waylandDesc = {}; if (platformType == PLATFORM_XLIB) { xlibDesc.chain.sType = WGPUSType_SurfaceDescriptorFromXlibWindow_Compat; @@ -1049,8 +1050,14 @@ bool Context::createSurfaceWithDisplay(void* display, void* window, int platform xlibDesc.window = reinterpret_cast(window); surfaceDesc.nextInChain = (WGPUChainedStruct*)&xlibDesc; std::cout << "[WebGPU] Creating X11 surface with display: " << display << " window: " << window << std::endl; + } else if (platformType == PLATFORM_WAYLAND) { + waylandDesc.chain.sType = WGPUSType_SurfaceDescriptorFromWaylandSurface_Compat; + waylandDesc.display = display; // wl_display* + waylandDesc.surface = window; // wl_surface* + surfaceDesc.nextInChain = (WGPUChainedStruct*)&waylandDesc; + std::cout << "[WebGPU] Creating Wayland surface with display: " << display << " surface: " << window << std::endl; } else { - std::cerr << "[WebGPU] createSurfaceWithDisplay only supports PLATFORM_XLIB on Linux" << std::endl; + std::cerr << "[WebGPU] createSurfaceWithDisplay only supports PLATFORM_XLIB or PLATFORM_WAYLAND on Linux" << std::endl; return false; } #else diff --git a/packages/runtime-native/tests/dependency-provenance.test.mjs b/packages/runtime-native/tests/dependency-provenance.test.mjs index ec102a2ee4..96c2eb7033 100644 --- a/packages/runtime-native/tests/dependency-provenance.test.mjs +++ b/packages/runtime-native/tests/dependency-provenance.test.mjs @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { test } from 'vitest'; +import { makeTempDirSync } from '../../../test-support/temp-dir.js'; const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const LOCK_PATH = join(PACKAGE_ROOT, 'native-deps.lock.json'); @@ -51,6 +53,8 @@ test('Linux arm64 fetches libuv source where no prebuilt exists, and CMake build const arm64 = downloader.match(/const desktopDeps = linuxArm64\n\s*\? \[([^\]]*)\]/u)?.[1]; assert.ok(arm64, 'download-deps must keep an explicit Linux arm64 desktop set'); assert.match(arm64, /'libuv-source'/u); + assert.deepEqual([...arm64.matchAll(/'([^']+)'/gu)].map((match) => match[1]), + ['wgpu', 'sdl3', 'v8', 'quickjs', 'stb', 'webp', 'skia', 'swc', 'quiche', 'libuv-source']); const cmake = readFileSync(join(PACKAGE_ROOT, 'CMakeLists.txt'), 'utf8'); assert.match( @@ -65,6 +69,36 @@ test('Linux arm64 fetches libuv source where no prebuilt exists, and CMake build ); }); +test('Linux arm64 payloads name arm64 assets and leave the x64 pins alone', () => { + // The arm64 desktop set fetches skia/webp/swc/quiche, so the lock is what an arm64 download + // resolves and hashes. A payload id that says aarch64 while its URL names x64 bytes is the + // failure this catches: the download succeeds and links a foreign architecture. Which platform + // selects which payload is proven by the arm64 download run and its ELF check, not by reading + // the resolver's source text. + const lock = readLock(); + const urlOf = (component, id) => + lock.components.find((c) => c.name === component)?.payloads.find((p) => p.id === id)?.url; + + for (const [component, id, asset] of [ + ['v8', 'v8-linux-arm64', '.tgz'], + ['quiche', 'quiche-linux-aarch64', 'quiche-linux-arm64.zip'], + ['webp', 'webp-linux-aarch64', 'libwebp-1.5.0-linux-aarch64.tar.gz'], + ['skia', 'skia-linux-aarch64', 'skia-build-linux-arm64-gpu-release.zip'], + ['swc', 'swc-linux-aarch64', 'swc-linux-arm64.zip'], + ]) { + const url = urlOf(component, id); + assert.ok(url, `lock component '${component}' must carry payload '${id}'`); + assert.ok(url.endsWith(asset), `payload '${id}' must point at ${asset}, got ${url}`); + assert.ok(!/x64|x86_64/u.test(url), `payload '${id}' must not link a foreign architecture`); + } + + // x64 keeps the assets it always had: arm64 is an added payload, not a re-pin of the old one. + assert.ok(urlOf('quiche', 'quiche-linux-x64')?.endsWith('quiche-linux-x64.zip')); + assert.ok(urlOf('swc', 'swc-linux-x64')?.endsWith('swc-linux-x64.zip')); + assert.ok(urlOf('skia', 'skia-linux')?.endsWith('skia-build-linux-x64-gpu-release.zip')); + assert.ok(urlOf('webp', 'webp-linux')?.endsWith('libwebp-1.5.0-linux-x86-64.tar.gz')); +}); + test('should reject a tampered archive before extraction when downloaded bytes differ', () => { // PRD-059 Phase 1 archive-before-extract gate, run against the real transaction // helper path: bytes that do not match the lock digest never reach an extractor. @@ -118,11 +152,73 @@ test('should report the locked matrix without network access', () => { // PRD-059 Phase 1 user verification: deps:verify lists every selectable // payload and performs no download, extraction, or toolchain invocation. const output = execFileSync(process.execPath, [DOWNLOADER, '--check-lock'], { encoding: 'utf8' }); - assert.match(output, /payloads: 63/); + const count = readLock().components.reduce((total, component) => total + component.payloads.length, 0); + assert.ok(output.includes(`payloads: ${count}`)); assert.match(output, /wgpu:/); assert.match(output, /gradle-wrapper:/); }); +test('a same-lock receipt naming another payload must not count as a cache hit', async () => { + // A receipt states which locked payload bytes are installed, not only which lock they came from. + // An x64 checkout that selects the arm64 payload shares the lock hash, so a lock-hash-only check + // reports a hit and the arm64 build keeps linking foreign-architecture quiche/swc/webp. The + // selected payload id belongs to the hit; a receipt without it is the stale-cache path. + const root = makeTempDirSync('tn-dep-cache-'); + const realFetch = globalThis.fetch; + try { + // The real downloader in an isolated package root: destDir is derived from the module's own + // location, so copying the scripts keeps this checkout's third_party/ tree untouched. + cpSync(join(PACKAGE_ROOT, 'scripts'), join(root, 'scripts'), { recursive: true }); + cpSync(LOCK_PATH, join(root, 'native-deps.lock.json')); + cpSync(join(PACKAGE_ROOT, 'package.json'), join(root, 'package.json')); + const downloader = await import(pathToFileURL(join(root, 'scripts', 'download-deps.mjs')).href); + const component = readLock().components.find((c) => c.name === 'webp'); + const lockContext = { + lockHash: 'fixture-lock-hash', + payloads: new Map(component.payloads.map((p) => [p.id, { ...p, component: 'webp', version: component.version }])), + }; + const destDir = join(root, 'third_party', 'webp'); + const fetched = []; + globalThis.fetch = async (url) => { fetched.push(url); return { ok: false, status: 599 }; }; + + // The resolver's own choice, learned from the real download path with no cache present, so the + // fixture never guesses which architecture this machine selects. + assert.equal(await downloader.downloadDep('webp', { lockContext }), false); + const selectedUrl = fetched.at(-1); + const selected = component.payloads.find((p) => p.url === selectedUrl); + assert.ok(selected, `no locked webp payload covers the selected url ${selectedUrl}`); + const foreign = component.payloads.find((p) => p.url !== selectedUrl); + assert.ok(foreign, 'webp must lock a second architecture for a wrong-payload receipt to exist'); + + const seed = (payloadIds) => { + rmSync(destDir, { recursive: true, force: true }); + mkdirSync(join(destDir, '.threenative-receipts'), { recursive: true }); + writeFileSync(join(destDir, 'libwebp.a'), 'installed'); + writeFileSync(join(destDir, '.threenative-receipts', 'receipt.json'), JSON.stringify({ + schemaVersion: 1, dependency: 'webp', lockHash: lockContext.lockHash, + payloads: payloadIds, files: ['libwebp.a'], + })); + }; + + seed([foreign.id]); + fetched.length = 0; + assert.equal(await downloader.downloadDep('webp', { lockContext }), false, + 'a receipt naming another payload under the same lock must miss'); + assert.deepEqual(fetched, [selectedUrl], 'the stale tree must be replaced by the selected payload'); + assert.equal(existsSync(join(destDir, 'libwebp.a')), false, 'replacement must not keep the foreign tree'); + + seed([selected.id]); + fetched.length = 0; + assert.equal(await downloader.downloadDep('webp', { lockContext }), true, + 'a receipt naming the selected payload must hit'); + assert.deepEqual(fetched, [], 'a cache hit must not download'); + assert.equal(existsSync(join(destDir, 'libwebp.a')), true, 'a cache hit must keep the installed tree'); + } finally { + globalThis.fetch = realFetch; + rmSync(root, { recursive: true, force: true }); + } +}); + test('should print usage and exit without touching caches for --help', () => { // Block acquisition before importing the downloader, even if the help guard regresses. const denyNetwork = ` @@ -153,3 +249,120 @@ test('should print usage and exit without touching caches for --help', () => { assert.match(output, /--backend /); } }); + + +test('arm64 V8 consumes only its locked archive and verifies fresh and cached receipts', async () => { + const root = makeTempDirSync('tn-v8-consumer-'); + const realFetch = globalThis.fetch; + const architecture = Object.getOwnPropertyDescriptor(process, 'arch'); + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + try { + cpSync(join(PACKAGE_ROOT, 'scripts'), join(root, 'scripts'), { recursive: true }); + cpSync(join(PACKAGE_ROOT, 'package.json'), join(root, 'package.json')); + Object.defineProperty(process, 'arch', { value: 'arm64' }); + Object.defineProperty(process, 'platform', { value: 'linux' }); + const downloader = await import(pathToFileURL(join(root, 'scripts/download-deps.mjs')).href); + Object.defineProperty(process, 'arch', architecture); + Object.defineProperty(process, 'platform', platform); + const producer = await import(pathToFileURL(join(root, 'scripts/build-linux-arm64-v8.mjs')).href); + const { collectReceipts } = await import(pathToFileURL(join(root, 'scripts/generate-native-sbom.mjs')).href); + const fetched = []; + globalThis.fetch = async (url) => { fetched.push(url); return { ok: false, status: 404 }; }; + const x64 = readLock().components.find((component) => component.name === 'v8').payloads.find((payload) => payload.id === 'v8-linux-x64'); + const lockContext = { lockHash: 'fixture', payloads: new Map([[x64.id, x64]]) }; + await assert.rejects(downloader.downloadDep('v8', { lockContext }), /TN_NATIVE_DEP_LOCK_MISSING: v8-linux-arm64/); + assert.deepEqual(fetched, [], 'missing arm64 descriptor must never fetch x64 or build source'); + + const stage = join(root, 'payload'); + mkdirSync(join(stage, 'include'), { recursive: true }); + mkdirSync(join(stage, 'licenses')); + writeFileSync(join(stage, 'include/v8.h'), '// fixture'); + writeFileSync(join(stage, 'include/v8-version.h'), + ['MAJOR_VERSION 13', 'MINOR_VERSION 1', 'BUILD_NUMBER 201', 'PATCH_LEVEL 22'] + .map((part) => `#define V8_${part}\n`).join('')); + for (const name of ['V8-LICENSE', 'V8-LICENSE.v8', 'V8-FDLIBM', 'V8-STRONGTALK', 'BUILD-LICENSE', 'COMPILER-RT-COPYRIGHT']) + writeFileSync(join(stage, 'licenses', name), 'fixture license'); + writeFileSync(join(stage, 'licenses/BUILD-LICENSE'), Buffer.from('Ly8gQ29weXJpZ2h0IDIwMTUgVGhlIENocm9taXVtIEF1dGhvcnMKLy8KLy8gUmVkaXN0cmlidXRpb24gYW5kIHVzZSBpbiBzb3VyY2UgYW5kIGJpbmFyeSBmb3Jtcywgd2l0aCBvciB3aXRob3V0Ci8vIG1vZGlmaWNhdGlvbiwgYXJlIHBlcm1pdHRlZCBwcm92aWRlZCB0aGF0IHRoZSBmb2xsb3dpbmcgY29uZGl0aW9ucyBhcmUKLy8gbWV0OgovLwovLyAgICAqIFJlZGlzdHJpYnV0aW9ucyBvZiBzb3VyY2UgY29kZSBtdXN0IHJldGFpbiB0aGUgYWJvdmUgY29weXJpZ2h0Ci8vIG5vdGljZSwgdGhpcyBsaXN0IG9mIGNvbmRpdGlvbnMgYW5kIHRoZSBmb2xsb3dpbmcgZGlzY2xhaW1lci4KLy8gICAgKiBSZWRpc3RyaWJ1dGlvbnMgaW4gYmluYXJ5IGZvcm0gbXVzdCByZXByb2R1Y2UgdGhlIGFib3ZlCi8vIGNvcHlyaWdodCBub3RpY2UsIHRoaXMgbGlzdCBvZiBjb25kaXRpb25zIGFuZCB0aGUgZm9sbG93aW5nIGRpc2NsYWltZXIKLy8gaW4gdGhlIGRvY3VtZW50YXRpb24gYW5kL29yIG90aGVyIG1hdGVyaWFscyBwcm92aWRlZCB3aXRoIHRoZQovLyBkaXN0cmlidXRpb24uCi8vICAgICogTmVpdGhlciB0aGUgbmFtZSBvZiBHb29nbGUgTExDIG5vciB0aGUgbmFtZXMgb2YgaXRzCi8vIGNvbnRyaWJ1dG9ycyBtYXkgYmUgdXNlZCB0byBlbmRvcnNlIG9yIHByb21vdGUgcHJvZHVjdHMgZGVyaXZlZCBmcm9tCi8vIHRoaXMgc29mdHdhcmUgd2l0aG91dCBzcGVjaWZpYyBwcmlvciB3cml0dGVuIHBlcm1pc3Npb24uCi8vCi8vIFRISVMgU09GVFdBUkUgSVMgUFJPVklERUQgQlkgVEhFIENPUFlSSUdIVCBIT0xERVJTIEFORCBDT05UUklCVVRPUlMKLy8gIkFTIElTIiBBTkQgQU5ZIEVYUFJFU1MgT1IgSU1QTElFRCBXQVJSQU5USUVTLCBJTkNMVURJTkcsIEJVVCBOT1QKLy8gTElNSVRFRCBUTywgVEhFIElNUExJRUQgV0FSUkFOVElFUyBPRiBNRVJDSEFOVEFCSUxJVFkgQU5EIEZJVE5FU1MgRk9SCi8vIEEgUEFSVElDVUxBUiBQVVJQT1NFIEFSRSBESVNDTEFJTUVELiBJTiBOTyBFVkVOVCBTSEFMTCBUSEUgQ09QWVJJR0hUCi8vIE9XTkVSIE9SIENPTlRSSUJVVE9SUyBCRSBMSUFCTEUgRk9SIEFOWSBESVJFQ1QsIElORElSRUNULCBJTkNJREVOVEFMLAovLyBTUEVDSUFMLCBFWEVNUExBUlksIE9SIENPTlNFUVVFTlRJQUwgREFNQUdFUyAoSU5DTFVESU5HLCBCVVQgTk9UCi8vIExJTUlURUQgVE8sIFBST0NVUkVNRU5UIE9GIFNVQlNUSVRVVEUgR09PRFMgT1IgU0VSVklDRVM7IExPU1MgT0YgVVNFLAovLyBEQVRBLCBPUiBQUk9GSVRTOyBPUiBCVVNJTkVTUyBJTlRFUlJVUFRJT04pIEhPV0VWRVIgQ0FVU0VEIEFORCBPTiBBTlkKLy8gVEhFT1JZIE9GIExJQUJJTElUWSwgV0hFVEhFUiBJTiBDT05UUkFDVCwgU1RSSUNUIExJQUJJTElUWSwgT1IgVE9SVAovLyAoSU5DTFVESU5HIE5FR0xJR0VOQ0UgT1IgT1RIRVJXSVNFKSBBUklTSU5HIElOIEFOWSBXQVkgT1VUIE9GIFRIRSBVU0UKLy8gT0YgVEhJUyBTT0ZUV0FSRSwgRVZFTiBJRiBBRFZJU0VEIE9GIFRIRSBQT1NTSUJJTElUWSBPRiBTVUNIIERBTUFHRS4K', 'base64')); + const elf = Buffer.alloc(64); + Buffer.from([127, 69, 76, 70, 2, 1, 1]).copy(elf); + elf.writeUInt16LE(1, 16); + elf.writeUInt16LE(183, 18); + writeFileSync(join(stage, 'libv8_monolith.a'), Buffer.concat([ + Buffer.from('!\n'), + Buffer.from(`${'object.o/'.padEnd(16)}${'0'.padEnd(12)}${'0'.padEnd(6)}${'0'.padEnd(6)}${'644'.padEnd(8)}${'64'.padEnd(10)}\x60\n`), elf, + ])); + const toolchain = producer.inspectLinuxArm64V8Toolchain({ + run: (program, args) => program === 'dpkg-query' + ? `${args.at(-1)}\t${args.at(-1) === 'libclang-rt-18-dev' ? '1:18.1.3-1' : '1:18.1.3-1ubuntu1'}\tarm64\tinstall ok installed` + : program === 'dpkg' ? '' : program === '/usr/bin/pkg-config' ? '2.80.0' : 'LLVM version 18.1.3', + hash: (path) => path.endsWith('.a') ? producer.LINUX_ARM64_V8_BUILD.toolchain.compilerRt.sha256 : 'a'.repeat(64), + read: () => { + const fixture = readFileSync(join(stage, 'libv8_monolith.a')); + return Buffer.concat([fixture, ...Array.from({ length: 277 }, () => fixture.subarray(8))]); + }, + }); + toolchain.gn = { package: producer.LINUX_ARM64_V8_BUILD.gnPackage, sha256: producer.LINUX_ARM64_V8_BUILD.gnSha256, version: '2200 (95b0f8fe31a9)' }; + writeFileSync(join(stage, 'build-receipt.json'), JSON.stringify(producer.createLinuxArm64V8Receipt(stage, toolchain))); + const archive = join(root, 'fixture.tgz'); + execFileSync('tar', ['-czf', archive, '-C', stage, '.']); + let bytes = readFileSync(archive); + const payload = { id: 'v8-linux-arm64', url: 'https://example.invalid/ci-v8-linux-arm64/v8-linux-arm64-fixture.tgz' }; + const receiptSha256 = createHash('sha256').update(readFileSync(join(stage, 'build-receipt.json'))).digest('hex'); + const setBytes = () => lockContext.payloads.set(payload.id, { + ...payload, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length, + provenance: { receiptSha256 }, + }); + setBytes(); + globalThis.fetch = async (url) => { fetched.push(url); return new Response(bytes); }; + assert.equal(await downloader.downloadDep('v8', { lockContext }), true); + assert.deepEqual(fetched, [payload.url], 'the exact locked arm64 URL is the only fetch'); + const installed = join(root, 'third_party/v8'); + assert.equal(producer.verifyLinuxArm64V8Installation(installed).build.version, '13.1.201.22'); + const freshReceipts = collectReceipts(join(root, 'third_party'), lockContext.lockHash); + assert.equal(freshReceipts.length, 1); + assert.equal(freshReceipts[0].dir, 'v8'); + assert.deepEqual(freshReceipts[0].payloads, ['v8-linux-arm64']); + assert.throws(() => collectReceipts(join(root, 'third_party'), 'stale'), /TN_NATIVE_SBOM_RECEIPT_STALE.*v8/); + fetched.length = 0; + assert.equal(await downloader.downloadDep('v8', { lockContext }), true); + assert.deepEqual(fetched, [], 'verified installation is a cache hit'); + assert.deepEqual(collectReceipts(join(root, 'third_party'), lockContext.lockHash), freshReceipts); + const originalArchive = readFileSync(join(installed, 'libv8_monolith.a')); + const originalReceipt = readFileSync(join(installed, 'build-receipt.json')); + const substituted = Buffer.from(originalArchive); + substituted.writeUInt8(substituted.readUInt8(76) ^ 1, 76); + writeFileSync(join(installed, 'libv8_monolith.a'), substituted); + writeFileSync(join(installed, 'build-receipt.json'), JSON.stringify(producer.createLinuxArm64V8Receipt(installed, toolchain))); + assert.equal(producer.verifyLinuxArm64V8Installation(installed).objects, 1, 'substitution has a self-consistent AArch64 receipt'); + await assert.rejects(downloader.downloadDep('v8', { lockContext }), /TN_V8_RECEIPT_PIN_MISMATCH/); + assert.deepEqual(fetched, [], 'recomputed cached receipt is rejected before fetching'); + writeFileSync(join(installed, 'libv8_monolith.a'), originalArchive); + writeFileSync(join(installed, 'build-receipt.json'), originalReceipt); + writeFileSync(join(installed, 'include/v8.h'), 'tampered'); + await assert.rejects(downloader.downloadDep('v8', { lockContext }), /checksum\/coverage mismatch/); + assert.deepEqual(fetched, [], 'tampered cache fails before a fallback fetch'); + + rmSync(installed, { recursive: true }); + const lockReceipt = join(root, 'third_party/.threenative-receipts/v8-linux-arm64.json'); + rmSync(lockReceipt); + for (const pin of [undefined, 'not-hex', 'f'.repeat(64)]) { + setBytes(); + const descriptor = lockContext.payloads.get(payload.id); + lockContext.payloads.set(payload.id, { ...descriptor, provenance: pin === undefined ? undefined : { receiptSha256: pin } }); + assert.equal(await downloader.downloadDep('v8', { lockContext }), false, 'fresh acquisition rejects missing or wrong receipt pin'); + assert.equal(existsSync(lockReceipt), false, 'bad pin cannot create a trusted acquisition receipt'); + rmSync(installed, { recursive: true, force: true }); + } + rmSync(join(stage, 'build-receipt.json')); + execFileSync('tar', ['-czf', archive, '-C', stage, '.']); + bytes = readFileSync(archive); + setBytes(); + assert.equal(await downloader.downloadDep('v8', { lockContext }), false, 'fresh archive without a build receipt must fail'); + assert.equal(existsSync(lockReceipt), false); + } finally { + Object.defineProperty(process, 'arch', architecture); + Object.defineProperty(process, 'platform', platform); + globalThis.fetch = realFetch; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-native/tests/desktop-core-gate.test.mjs b/packages/runtime-native/tests/desktop-core-gate.test.mjs index 94a8b46642..1b5654cb43 100644 --- a/packages/runtime-native/tests/desktop-core-gate.test.mjs +++ b/packages/runtime-native/tests/desktop-core-gate.test.mjs @@ -240,7 +240,13 @@ test('desktop verifier preserves evidence and scopes its SDL drivers to Linux', /if \(process\.platform === 'linux'\) \{\n([\s\S]*?)\n\x20{2}\}/u, )?.[1]; expect(linuxBranch).toBeDefined(); - expect(linuxBranch).toMatch(/runtimeEnv\.SDL_VIDEODRIVER = 'x11'/); + // The driver is the caller's, not a literal: a Wayland request has to reach the child, which is + // what src/cli/main.cpp reads before it refuses the web UI over Wayland. Both spellings are set, + // so the child resolves the driver this gate read; x11 stays the default inside + // requestedLinuxVideoDriver, which linux-video-driver-routing.spec.ts proves. + expect(linuxBranch).toMatch(/runtimeEnv\.SDL_VIDEO_DRIVER = requested\.driver/); + expect(linuxBranch).toMatch(/runtimeEnv\.SDL_VIDEODRIVER = requested\.driver/); + expect(linuxBranch).not.toMatch(/SDL_VIDEODRIVER = 'x11'/); // The audio driver is a *default*, not an override: this gate makes no audio assertion, and a // machine with a real device must keep using it. `verify-desktop-audio.mjs` owns the audio // contract and runs before this gate in the same command. diff --git a/packages/runtime-native/tests/frame_op_stream_replay_test.cpp b/packages/runtime-native/tests/frame_op_stream_replay_test.cpp index 9e98654695..d6b0f309e8 100644 --- a/packages/runtime-native/tests/frame_op_stream_replay_test.cpp +++ b/packages/runtime-native/tests/frame_op_stream_replay_test.cpp @@ -4,6 +4,7 @@ #include "../src/webgpu/bindings_state.h" #include +#include #include #include #include @@ -1000,7 +1001,98 @@ void runArrayLayerUploadContract() { "offset writeTexture lands layer 2's bytes in layer 2 and leaves layer 1 alone: " + observed); } +void runWriteBufferContract(bool direct, const char* proofBundle) { + mystral::RuntimeConfig config; + config.width = 1; + config.height = 1; + config.noSdl = true; + const auto runtime = mystral::Runtime::create(config); + if (!runtime || !runtime->getWebGPUBindingsState()) { + expect(false, "writeBuffer proof requires native WebGPU bindings"); + return; + } + auto* state = static_cast(runtime->getWebGPUBindingsState()); + auto* engine = state->engine; + state->profiling.disableFrameOpStreamForTesting = direct; + frameReplayBackendEntries = 0; + state->profiling.frameOpStreamNativeCallObserver = [](const char* operation) { + if (std::string(operation) == "writeBuffer") frameReplayBackendEntries += 1; + }; + std::ifstream input(proofBundle); + std::ostringstream source; + source << input.rdbuf(); + if (!input || source.str().empty()) { + expect(false, "writeBuffer proof bundle must exist and be nonempty"); + return; + } + expect(engine->evalScript(source.str().c_str(), proofBundle), "shared writeBuffer proof loaded"); + expect(engine->evalScript(R"JS((async () => { + try { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) throw new Error("missing native GPU adapter"); + const device = await adapter.requestDevice(); + await writeBufferProof.verifyQueueWriteBuffer(device); + if (typeof ArrayBuffer.prototype.transfer !== "function") { + throw new Error("ArrayBuffer.prototype.transfer is required to prove detached source rejection"); + } + // Desktop-focused arm only: the portable shared proof stays transfer-free because Android V8 + // cannot detach a buffer, so the detached-source contract is proved here on Dawn + V8. + const detachedBuffer = device.createBuffer({size: 16, usage: GPUBufferUsage.COPY_DST}); + try { + const detached = [ + ["ArrayBuffer", new ArrayBuffer(4)], + ["Uint8Array", new Uint8Array(4)], + ["DataView", new DataView(new ArrayBuffer(4))], + ]; + for (const [, detachedSource] of detached) { + if (detachedSource.buffer) detachedSource.buffer.transfer(); + else detachedSource.transfer(); + } + const accepted = []; + for (const [name, detachedSource] of detached) { + try { + device.queue.writeBuffer(detachedBuffer, 0, detachedSource); + accepted.push(name); + } catch {} + } + if (accepted.length) throw new Error(`detached sources were accepted: ${accepted.join(", ")}`); + globalThis.__tnDetachedRejected = detached.length; + } finally { + detachedBuffer.destroy(); + } + globalThis.__tnWriteBufferPassed = true; + } catch (error) { + globalThis.__tnWriteBufferError = String(error.stack || error); + } finally { + globalThis.__tnWriteBufferDone = true; + } + })())JS", "tn-write-buffer-proof.js"), "writeBuffer proof started"); + awaitFlag(runtime.get(), engine, "__tnWriteBufferDone"); + expect(engine->toBoolean(engine->getGlobalProperty("__tnWriteBufferPassed")), + "native writeBuffer proof: " + engine->toString(engine->getGlobalProperty("__tnWriteBufferError"))); + expect(direct ? !state->profiling.frameOpStreamDrain.ptr : !!state->profiling.frameOpStreamDrain.ptr, + "requested direct/stream transport was installed"); + if (!direct) { + expect(state->profiling.frameOpStreamReplayCrossings > 0, + "writeBuffer proof crossed the native frame replay decoder"); + expect(frameReplayBackendEntries == 7, + "decoder reached all five zero-byte and two nonzero production writeBuffer records: " + + std::to_string(frameReplayBackendEntries)); + std::cout << "Decoded writeBuffer records: " << frameReplayBackendEntries + << " (five zero-byte, two nonzero); GPU readback checked" << std::endl; + } +} + int main(int argc, char** argv) { + if (argc > 1 && (std::string(argv[1]) == "write-buffer-direct" || + std::string(argv[1]) == "write-buffer-stream")) { + if (argc != 3) { + std::cerr << "write-buffer-direct|write-buffer-stream requires the shared IIFE proof bundle" << std::endl; + return 1; + } + runWriteBufferContract(std::string(argv[1]) == "write-buffer-direct", argv[2]); + return failures == 0 ? 0 : 1; + } const bool disableStreamControl = argc > 1 && std::string(argv[1]) == "disabled-stream-control"; runContract(disableStreamControl); diff --git a/packages/runtime-native/tests/native-sbom.test.mjs b/packages/runtime-native/tests/native-sbom.test.mjs index abbf77cb82..6330156032 100644 --- a/packages/runtime-native/tests/native-sbom.test.mjs +++ b/packages/runtime-native/tests/native-sbom.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { test } from 'vitest'; @@ -81,3 +81,17 @@ test('should expose deterministic generation through the CLI', () => { assert.ok(readFileSync(join(outDir, 'native-dependencies.cdx.json'), 'utf8').includes('CycloneDX')); void lockHash; }); + + +test('canonical nested V8 receipt takes precedence over a stale cross-architecture sidecar', () => { + const root = makeTempDirSync('threenative-sbom-v8-sidecar-'); + mkdirSync(join(root, 'v8', '.threenative-receipts'), { recursive: true }); + mkdirSync(join(root, '.threenative-receipts')); + writeFileSync(join(root, '.threenative-receipts', 'v8-linux-arm64.json'), + JSON.stringify({ dependency: 'v8', lockHash: 'stale', payloads: ['v8-linux-arm64'] })); + writeFileSync(join(root, 'v8', '.threenative-receipts', 'receipt.json'), + JSON.stringify({ dependency: 'v8', lockHash: 'current', payloads: ['v8-linux-x64'] })); + const receipts = collectReceipts(root, 'current'); + assert.equal(receipts.length, 1); + assert.deepEqual(receipts[0].payloads, ['v8-linux-x64']); +}); diff --git a/packages/runtime-native/tests/runtime-next-contract.test.mjs b/packages/runtime-native/tests/runtime-next-contract.test.mjs index 6d72121113..4497954414 100644 --- a/packages/runtime-native/tests/runtime-next-contract.test.mjs +++ b/packages/runtime-native/tests/runtime-next-contract.test.mjs @@ -791,7 +791,9 @@ test('CLI build tools are separate units behind an unchanged dispatch surface', // tool body, so the cap moves with it rather than the split being loosened. PRD-400 adds runtime // web-UI readiness/deadline wiring here; the direct split assertions below remain the contract. // The native-css renderer adds 17 lines of the same kind: choosing which overlay backend attaches. - assert.ok(main.split('\n').length <= 1902, 'main.cpp still contains a build-time tool body'); + // PRD-614 adds 11 net lines: refusing the web UI when the environment asks SDL for Wayland, read from + // the same variables the desktop gate routes. + assert.ok(main.split('\n').length <= 1913, 'main.cpp still contains a build-time tool body'); assert.doesNotMatch(main, /static int (compileBundle|bakeLightmaps)\(/u); assert.match(main, /dispatchBuildTool\(argc, argv\)/u); assert.match(dispatcher, /mystral::vfs::getExecutablePath\(\)[\s\S]*mystral-tools/u); diff --git a/packages/runtime-native/tests/webgpu_comprehensive_test.cpp b/packages/runtime-native/tests/webgpu_comprehensive_test.cpp index 37a9b168f6..1bdb3072c7 100644 --- a/packages/runtime-native/tests/webgpu_comprehensive_test.cpp +++ b/packages/runtime-native/tests/webgpu_comprehensive_test.cpp @@ -9,7 +9,9 @@ #include "mystral/webgpu/context.h" #include "mystral/runtime.h" +#include #include +#include #include #include #include @@ -17,8 +19,66 @@ #include #include +#if defined(_WIN32) +#include +#include +#define TN_TEST_DUP _dup +#define TN_TEST_DUP2 _dup2 +#define TN_TEST_FILENO _fileno +#define TN_TEST_CLOSE _close +#define TN_TEST_READ _read +#else +#include +#define TN_TEST_DUP dup +#define TN_TEST_DUP2 dup2 +#define TN_TEST_FILENO fileno +#define TN_TEST_CLOSE close +#define TN_TEST_READ read +#endif + namespace { +void writeTestLine(const std::string& line) { + const size_t written = std::fwrite(line.data(), 1, line.size(), stdout); + const int flushed = std::fflush(stdout); + if (written != line.size() || flushed != 0) std::abort(); +} + +// Descriptor redirection captures both C stdio and synchronized C++ streams. +class StdoutCapture { +public: + StdoutCapture() : file_(std::tmpfile()) { + if (!file_ || std::fflush(stdout) != 0) std::abort(); + saved_ = TN_TEST_DUP(TN_TEST_FILENO(stdout)); + if (saved_ < 0 || TN_TEST_DUP2(TN_TEST_FILENO(file_), TN_TEST_FILENO(stdout)) < 0) + std::abort(); + } + ~StdoutCapture() { finish(); } + std::string read() { + if (std::fflush(stdout) != 0 || std::fseek(file_, position_, SEEK_SET) != 0) + std::abort(); + std::string text; + char buffer[4096]; + size_t count; + while ((count = std::fread(buffer, 1, sizeof(buffer), file_)) != 0) + text.append(buffer, count); + if (std::ferror(file_)) std::abort(); + position_ = std::ftell(file_); + if (position_ < 0 || std::fseek(file_, 0, SEEK_END) != 0) std::abort(); + return text; + } + void finish() { + if (saved_ < 0) return; + if (std::fflush(stdout) != 0 || TN_TEST_DUP2(saved_, TN_TEST_FILENO(stdout)) < 0 || + TN_TEST_CLOSE(saved_) != 0 || std::fclose(file_) != 0) std::abort(); + saved_ = -1; + } +private: + FILE* file_; + int saved_ = -1; + long position_ = 0; +}; + constexpr const char* kScript = R"JS((async () => { try { const adapter = await navigator.gpu.requestAdapter(); @@ -855,22 +915,20 @@ bool parseCheckpoint(const std::string& text, PipelineCheckpoint& checkpoint) { * silent. Nothing below kills the compile pool, and the runtime is left running afterwards. */ bool checkLivePipelineCheckpoint(mystral::Runtime& runtime, mystral::webgpu::BindingsState* state) { - std::ostringstream captured; - std::streambuf* const previous = std::cout.rdbuf(captured.rdbuf()); + StdoutCapture captured; // Read beside the checkpoint, never after the compile below: this run keeps handing out // request ids, and comparing a snapshot against a later reading measures the gap, not the count. const uint64_t requestedAtFirst = state->asyncPipelines.nextRequestId - 1; mystral::webgpu::reportPipelineCheckpoint(state, 1); - const std::string firstText = captured.str(); - captured.str(""); + const std::string firstText = captured.read(); // A second present that changed nothing: a checkpoint per frame would bury the log it rides in. mystral::webgpu::reportPipelineCheckpoint(state, 2); - const std::string unchangedText = captured.str(); - captured.str(""); + const std::string unchangedText = captured.read(); const bool compiled = runtime.evalScript(kCheckpointCompileScript, "checkpoint_compile.js"); mystral::webgpu::reportPipelineCheckpoint(state, 3); - const std::string secondText = captured.str(); - std::cout.rdbuf(previous); + const std::string secondText = captured.read(); + captured.finish(); + writeTestLine(firstText + unchangedText + secondText); PipelineCheckpoint first; PipelineCheckpoint second; @@ -928,6 +986,88 @@ bool checkLivePipelineCheckpoint(mystral::Runtime& runtime, mystral::webgpu::Bin return true; } +bool checkPipelineOutputContention(mystral::Runtime& runtime, + mystral::webgpu::BindingsState* state) { + int descriptors[2]; +#if defined(_WIN32) + if (_pipe(descriptors, 4096, _O_BINARY) != 0) std::abort(); +#else + if (pipe(descriptors) != 0) std::abort(); +#endif + if (std::fflush(stdout) != 0) std::abort(); + const int saved = TN_TEST_DUP(TN_TEST_FILENO(stdout)); + if (saved < 0 || TN_TEST_DUP2(descriptors[1], TN_TEST_FILENO(stdout)) < 0 || + TN_TEST_CLOSE(descriptors[1]) != 0) std::abort(); + std::string captured; + std::thread reader([&] { + char buffer[1024]; + for (;;) { + const auto count = TN_TEST_READ(descriptors[0], buffer, sizeof(buffer)); + if (count < 0) std::abort(); + if (count == 0) break; + captured.append(buffer, static_cast(count)); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + if (TN_TEST_CLOSE(descriptors[0]) != 0) std::abort(); + }); + std::atomic started{false}; + std::atomic done{false}; + std::thread competingWriter([&] { + do { + writeTestLine("TN_UI_SNAPSHOT:{\"visible\":true,\"text\":\"pipeline contention\"}\n"); + started.store(true); + std::this_thread::sleep_for(std::chrono::microseconds(100)); + } while (!done.load()); + }); + while (!started.load()) std::this_thread::yield(); + const uint64_t before = state->asyncPipelines.emitted; + const bool compiled = runtime.evalScript(R"JS((() => { + __device.createRenderPipeline({ + label: "stdio-contention-" + "L".repeat(96 * 1024), + layout: __device.createPipelineLayout({ bindGroupLayouts: [] }), + vertex: { + module: __device.createShaderModule({ + code: "@vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }", + }), + entryPoint: "vs", + }, + fragment: { + module: __device.createShaderModule({ + code: "@fragment fn fs() -> @location(0) vec4f { return vec4f(1); }", + }), + entryPoint: "fs", + targets: [{ format: "rgba8unorm" }], + }, + }); + console.log("stdio-console-" + "C".repeat(96 * 1024)); + })())JS", "stdio_contention.js"); + mystral::webgpu::reportPipelineCheckpoint(state, 4); + done.store(true); + competingWriter.join(); + if (std::fflush(stdout) != 0 || TN_TEST_DUP2(saved, TN_TEST_FILENO(stdout)) < 0 || + TN_TEST_CLOSE(saved) != 0) std::abort(); + reader.join(); + // Replay the actual producer bytes for the unchanged external strict parser. + writeTestLine(captured); + std::istringstream records(captured); + std::string record; + while (std::getline(records, record)) { + const bool critical = record.find("TN_PIPELINE_") != std::string::npos || + record.find("[log] stdio-console-") != std::string::npos; + if (critical && record.find("TN_UI_SNAPSHOT:") != std::string::npos) { + std::cerr << "UI snapshot spliced into a pipeline/console producer record\n"; + return false; + } + } + if (!compiled || state->asyncPipelines.emitted != before + 1 || + captured.find("stdio-contention-" + std::string(96 * 1024, 'L')) == std::string::npos || + captured.find("stdio-console-" + std::string(96 * 1024, 'C')) == std::string::npos) { + std::cerr << "pipeline/console contention lost a complete large producer record\n"; + return false; + } + return true; +} + } // namespace int main() { @@ -1029,7 +1169,8 @@ int main() { std::cerr << "webgpu comprehensive script did not expose its device\n"; return 1; } - if (!checkLivePipelineCheckpoint(*runtime, state)) return 1; + if (!checkLivePipelineCheckpoint(*runtime, state) || + !checkPipelineOutputContention(*runtime, state)) return 1; auto nativeEncoder = mystral::webgpu::handleGpuDeviceCreateCommandEncoder(state, deviceHandle, {}); if (!nativeEncoder.ptr || engine->isUndefined(nativeEncoder) || !engine->setGlobalProperty("__nativeEncoder", nativeEncoder)) { diff --git a/packages/terrain/__tests__/editor-document.spec.ts b/packages/terrain/__tests__/editor-document.spec.ts index 2b84cd8606..9a4eec82ef 100644 --- a/packages/terrain/__tests__/editor-document.spec.ts +++ b/packages/terrain/__tests__/editor-document.spec.ts @@ -1,10 +1,10 @@ -import { readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { createServer as createHttpServer, request } from "node:http"; import { join } from "node:path"; import { Terrain } from "@threenative/terrain"; import { TerrainEditorDocument, terrainEditor } from "@threenative/terrain/editor/server"; import { type ViteDevServer, createServer } from "vite"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { makeTempDirSync } from "../../../test-support/temp-dir.js"; const servers: ViteDevServer[] = []; @@ -190,33 +190,123 @@ describe("project terrain editor document", () => { expect(() => new TerrainEditorDocument("relative.json")).toThrow("absolute"); }); it("streams accepted revisions and observes complete external file saves", async () => { - const { path, base, get, post } = await api(); - const before = await get(); + const originalInterval = globalThis.setInterval; + let heartbeat: (() => void) | undefined; + const intervalSpy = vi + .spyOn(globalThis, "setInterval") + .mockImplementation((callback, delay, ...args) => { + if (delay === 15000 && typeof callback === "function") heartbeat = () => callback(...args); + return originalInterval(callback, delay, ...args); + }); const cancellation = new AbortController(); - const stream = await fetch(`${base}events`, { signal: cancellation.signal }); - const reader = stream.body?.getReader(); - expect(reader).toBeTruthy(); - const initial = new TextDecoder().decode((await reader?.read())?.value); - expect(initial).toContain(before.revision); - const edit = await post({ - baseRevision: before.revision, - commands: [{ op: "update", id: "hills", patch: { params: { amplitude: 7 } } }], - }); - const next = await edit.json(); - expect(new TextDecoder().decode((await reader?.read())?.value)).toContain(next.revision); - writeFileSync( - path, - JSON.stringify({ - ...next.document, - recipe: Terrain.fromJSON(next.document.recipe) - .noise({ id: "hills", amplitude: 13 }) - .toJSON(), - }), - ); - const changed = new TextDecoder().decode((await reader?.read())?.value); - expect(changed).toContain('"amplitude":13'); - cancellation.abort(); - await reader?.cancel().catch(() => {}); + let reader: ReadableStreamDefaultReader | undefined; + try { + const { path, base, get, post } = await api(); + const before = await get(); + const stream = await fetch(`${base}events`, { signal: cancellation.signal }); + reader = stream.body?.getReader(); + if (!reader) throw new Error("Missing editor event stream"); + const eventReader = reader; + const decoder = new TextDecoder(); + let buffered = ""; + let heartbeatSeen: (() => void) | undefined; + const nextDocument = async (): Promise => { + const timeout = setTimeout( + () => cancellation.abort(new Error("Editor event deadline")), + 5000, + ); + try { + for (;;) { + const end = buffered.indexOf("\n\n"); + if (end < 0) { + const chunk = await eventReader.read(); + if (chunk.done) throw new Error("Editor event stream ended before a document"); + buffered += decoder.decode(chunk.value, { stream: true }); + continue; + } + const frame = buffered.slice(0, end); + buffered = buffered.slice(end + 2); + const fields = frame.split("\n").filter((line) => line !== "" && !line.startsWith(":")); + if (fields.length === 0) { + if (frame === ": keepalive") heartbeatSeen?.(); + continue; + } + expect(fields).toHaveLength(1); + const data = fields[0]; + if (!data?.startsWith("data: ")) throw new Error("Expected editor document data event"); + const snapshot = JSON.parse(data.slice(6)); + expect(snapshot.document).toBeTruthy(); + expect(snapshot.revision).toBeTypeOf("string"); + return JSON.stringify(snapshot); + } + } finally { + clearTimeout(timeout); + } + }; + const initial = await nextDocument(); + expect(initial).toContain(before.revision); + const edit = await post({ + baseRevision: before.revision, + commands: [{ op: "update", id: "hills", patch: { params: { amplitude: 7 } } }], + }); + const next = await edit.json(); + expect(await nextDocument()).toContain(next.revision); + expect(heartbeat).toBeTypeOf("function"); + const sawHeartbeat = new Promise((resolve) => { + heartbeatSeen = resolve; + }); + const changed = nextDocument(); + heartbeat?.(); + await Promise.race([ + sawHeartbeat, + changed.then(() => { + throw new Error("Document preceded heartbeat"); + }), + ]); + intervalSpy.mockRestore(); + const externalSave = `${path}.external.tmp`; + writeFileSync( + externalSave, + JSON.stringify({ + ...next.document, + recipe: Terrain.fromJSON(next.document.recipe) + .noise({ id: "hills", amplitude: 13 }) + .toJSON(), + }), + ); + renameSync(externalSave, path); + expect(await changed).toContain('"amplitude":13'); + } finally { + intervalSpy.mockRestore(); + cancellation.abort(); + await reader?.cancel().catch(() => {}); + } + }); + it("refreshes only the configured document for raw watcher path forms", async () => { + const { path, root, server } = await api(); + const refresh = vi.spyOn(TerrainEditorDocument.prototype, "refresh"); + try { + server.watcher.emit("raw", "change", path, {}); + server.watcher.emit("raw", "rename", "world.json", { watchedPath: root }); + server.watcher.emit("raw", "change", "world.json", { watchedPath: path }); + expect(refresh).toHaveBeenCalledTimes(3); + server.watcher.emit("raw", "change", join(root, "other.json"), {}); + server.watcher.emit("raw", "rename", "other.json", { watchedPath: root }); + server.watcher.emit("raw", "change", "world.json", { watchedPath: 1 }); + server.watcher.emit("raw", "change", "world.json"); + expect(() => { + server.watcher.emit("raw", "change"); + server.watcher.emit("raw", "change", undefined, { watchedPath: path }); + server.watcher.emit("raw", "change", 1, { watchedPath: path }); + server.watcher.emit("raw", "change", null, { watchedPath: path }); + }).not.toThrow(); + expect(refresh).toHaveBeenCalledTimes(3); + await server.close(); + server.watcher.emit("raw", "change", path, {}); + expect(refresh).toHaveBeenCalledTimes(3); + } finally { + refresh.mockRestore(); + } }); it("uses an explicitly configured private forward and rejects a failed forward", async () => { let target: string | undefined; diff --git a/packages/terrain/src/editor/server.ts b/packages/terrain/src/editor/server.ts index f259a17da4..b3475ca9c5 100644 --- a/packages/terrain/src/editor/server.ts +++ b/packages/terrain/src/editor/server.ts @@ -350,7 +350,26 @@ export function terrainEditor(options: { const refresh = (path: string): void => { if (resolve(path) === document.path) document.refresh(); }; - vite.watcher.on("add", refresh).on("change", refresh).on("unlink", refresh); + const refreshRaw = ( + _event: string, + path: string, + details?: { watchedPath?: unknown }, + ): void => { + if (typeof path !== "string") return; + const watchedPath = details?.watchedPath; + if ( + (isAbsolute(path) && resolve(path) === document.path) || + (typeof watchedPath === "string" && + (resolve(watchedPath) === document.path || + resolve(watchedPath, path) === document.path)) + ) + refresh(document.path); + }; + vite.watcher + .on("add", refresh) + .on("change", refresh) + .on("unlink", refresh) + .on("raw", refreshRaw); const heartbeat = setInterval(() => { for (const response of clients) if (!response.write(": keepalive\n\n")) response.end(); }, 15000); @@ -359,7 +378,11 @@ export function terrainEditor(options: { clearInterval(heartbeat); unsubscribe(); for (const response of clients) response.end(); - vite.watcher.off("add", refresh).off("change", refresh).off("unlink", refresh); + vite.watcher + .off("add", refresh) + .off("change", refresh) + .off("unlink", refresh) + .off("raw", refreshRaw); }); vite.middlewares.use((request, response, next) => { const path = request.url?.split("?")[0]; diff --git a/scripts/__tests__/check-native-coverage.spec.ts b/scripts/__tests__/check-native-coverage.spec.ts index 4520617589..ddf6399b96 100644 --- a/scripts/__tests__/check-native-coverage.spec.ts +++ b/scripts/__tests__/check-native-coverage.spec.ts @@ -65,7 +65,7 @@ describe("native coverage gate", () => { ).toThrow(/duplicate row: src\/js\//u); }); - it("should retain the established floor when regeneration measures a regression", async () => { + it("should ratchet improved coverage while retaining established regression floors", async () => { const coverageModule = (await import( new URL("../../packages/runtime-native/scripts/measure-native-coverage.mjs", import.meta.url) .href @@ -75,6 +75,16 @@ describe("native coverage gate", () => { subsystems: Array<{ name: string; percent: number }>, ): Array<{ name: string; percent: number }>; }; + const improved = record.replace("34.00%", "20.00%"); + expect( + coverageModule.retainedCoverageFloors(improved, [ + { name: "webgpu", percent: 35 }, + { name: "js", percent: 40 }, + ]), + ).toEqual([ + { name: "src/webgpu/", percent: 30 }, + { name: "src/js/", percent: 40 }, + ]); const established = record.replace("34.00%", "33.82%"); expect( coverageModule.retainedCoverageFloors(established, [ diff --git a/scripts/__tests__/ci-efficiency.spec.ts b/scripts/__tests__/ci-efficiency.spec.ts index 172324c3e5..c207e5788c 100644 --- a/scripts/__tests__/ci-efficiency.spec.ts +++ b/scripts/__tests__/ci-efficiency.spec.ts @@ -262,8 +262,8 @@ describe("PRD-380 an ordinary pull request owes only the Linux native rows", () .filter(([name, section]) => name !== "scope" && /^ {4}if: .*native_tier/mu.test(section)) .map(([name]) => name) .sort(); - // Only the macOS, Windows and iOS legs are not Linux rows. - expect(gated).toEqual(["desktop", "ios-simulator"]); + // Platform legs and the opt-in cache producer skip reduced runs. + expect(gated).toEqual(["desktop", "ios-simulator", "linux-arm64-v8-source"]); expect(executable.get("desktop")).toContain("needs.scope.outputs.native_tier == 'full'"); expect(executable.get("ios-simulator")).toContain("needs.scope.outputs.native_tier == 'full'"); // A job's `if` cannot read `matrix`, so the arm64 row drops through the matrix itself, shaped diff --git a/scripts/__tests__/ci-native-chain.spec.ts b/scripts/__tests__/ci-native-chain.spec.ts index 9b5cdb4940..700de2e01d 100644 --- a/scripts/__tests__/ci-native-chain.spec.ts +++ b/scripts/__tests__/ci-native-chain.spec.ts @@ -506,3 +506,56 @@ it("excludes only the compiler cache from source dirt while exposing adjacent un rmSync(root, { recursive: true, force: true }); } }); + +describe("opt-in Linux arm64 V8 auxiliary producer", () => { + const workflow = readFileSync( + path.join(repository, ".github/workflows/native-platforms.yml"), + "utf8", + ); + it("validates auxiliary jobs without growing either product inventory", () => { + expect(() => validateNativeWorkflow(workflow)).not.toThrow(); + const common = [ + "scope", + "web-reference", + "android-v8-source", + "android-emulator-parity", + "desktop-parity", + "release-reports", + "performance-coverage", + "networking-matrix", + ]; + for (const nativeTier of ["full", "reduced"]) { + const legs = nativeInventory({ + jobs: { "native-platforms": { required: true } }, + nativeTier, + }); + expect(legs.map((leg: { key: string }) => leg.key)).toEqual([ + ...common, + ...(nativeTier === "full" ? ["desktop:Windows", "desktop:macOS"] : []), + "starter-linux:linux-x64", + ...(nativeTier === "full" ? ["starter-linux:linux-arm64"] : []), + ]); + } + }); + it.each([ + ["manual event gate", "github.event_name == 'workflow_dispatch' && ", ""], + ["explicit choice gate", "inputs.build_linux_arm64_v8 == true && ", ""], + [ + "disabled choice", + "inputs.build_linux_arm64_v8 == true", + "inputs.build_linux_arm64_v8 != false", + ], + ["producer registration", " linux-arm64-v8-source:\n", " unknown-cache-producer:\n"], + ["completion join", ", linux-arm64-v8-source, publish-linux-arm64-v8]", "]"], + ])("rejects a changed %s", (_label, before, after) => { + expect(workflow).toContain(before); + expect(() => validateNativeWorkflow(workflow.replace(before, after))).toThrow( + "CI_NATIVE_WORKFLOW_POLICY", + ); + }); + it("rejects an additional unregistered job", () => { + expect(() => + validateNativeWorkflow(`${workflow}\n unknown-cache-producer:\n name: Unknown\n`), + ).toThrow("CI_NATIVE_WORKFLOW_POLICY"); + }); +}); diff --git a/scripts/__tests__/ci-native-profile.spec.ts b/scripts/__tests__/ci-native-profile.spec.ts index a297b6e435..a2976250d2 100644 --- a/scripts/__tests__/ci-native-profile.spec.ts +++ b/scripts/__tests__/ci-native-profile.spec.ts @@ -1,8 +1,12 @@ -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; const profile = await import( new URL("../../packages/runtime-native/scripts/desktop-build-profile.mjs", import.meta.url).href ); +const qualification = await import(new URL("../ci-native-qualification.mjs", import.meta.url).href); const coverage = await import( new URL("../../packages/runtime-native/scripts/native-test-lane.mjs", import.meta.url).href ); @@ -31,10 +35,50 @@ describe("shipping desktop identity preserves measured coverage defaults", () => ])("matches supported %s preset", (platform, expected) => { expect(profile.desktopPreset(platform)).toBe(expected); }); - it("retains only the existing Linux ARM64 QuickJS/wgpu overrides", () => { + it("reports V8/wgpu in the arm64 CI envelope", () => { + expect( + qualification.nativeBuildProfile({ job: "starter-linux", platform: "linux-arm64" }), + ).toEqual({ + buildVariant: "Release", + backend: "wgpu", + engine: "V8", + }); + }); + it.each([ + ["Linux", "aarch64", "FALSE", "FALSE", "TRUE", "pthread;dl;atomic"], + ["Linux", "arm64", "FALSE", "FALSE", "TRUE", "pthread;dl;atomic"], + ["Linux", "x86_64", "FALSE", "FALSE", "TRUE", "pthread;dl"], + ["Android", "aarch64", "FALSE", "TRUE", "TRUE", ""], + ["Windows", "ARM64", "FALSE", "FALSE", "FALSE", "winmm;dbghelp;shlwapi"], + ])( + "evaluates V8 system libraries for %s/%s", + (system, processor, apple, android, unix, expected) => { + const cmake = readFileSync( + new URL("../../packages/runtime-native/CMakeLists.txt", import.meta.url), + "utf8", + ); + const start = cmake.indexOf(" # V8 requires additional system libraries"); + const end = cmake.indexOf("\n else()", start); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + const script = join(makeTempDirSync("v8-system-libs-"), "libraries.cmake"); + writeFileSync( + script, + `set(CMAKE_SYSTEM_NAME "${system}")\nset(CMAKE_SYSTEM_PROCESSOR "${processor}")\nset(APPLE ${apple})\nset(ANDROID ${android})\nset(UNIX ${unix})\nset(WIN32 ${system === "Windows" ? "TRUE" : "FALSE"})\n${cmake.slice(start, end)}\nmessage("V8_SYSTEM_LIBS=${"${V8_SYSTEM_LIBS}"}")\n`, + ); + const output = spawnSync("cmake", ["-P", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(output.status).toBe(0); + expect(output.stderr.trim()).toBe(`V8_SYSTEM_LIBS=${expected}`); + }, + ); + + it("selects V8/wgpu explicitly on Linux ARM64", () => { expect(profile.desktopBuildOverrides("linux", "arm64")).toEqual({ - MYSTRAL_USE_V8: "OFF", - MYSTRAL_USE_QUICKJS: "ON", + MYSTRAL_USE_V8: "ON", + MYSTRAL_USE_QUICKJS: "OFF", MYSTRAL_USE_DAWN: "OFF", MYSTRAL_USE_WGPU: "ON", }); @@ -43,8 +87,8 @@ describe("shipping desktop identity preserves measured coverage defaults", () => ([key, value]) => `-D${key}=${value}`, ), ).toEqual([ - "-DMYSTRAL_USE_V8=OFF", - "-DMYSTRAL_USE_QUICKJS=ON", + "-DMYSTRAL_USE_V8=ON", + "-DMYSTRAL_USE_QUICKJS=OFF", "-DMYSTRAL_USE_DAWN=OFF", "-DMYSTRAL_USE_WGPU=ON", ]); diff --git a/scripts/__tests__/ci-native-qualification.spec.ts b/scripts/__tests__/ci-native-qualification.spec.ts index 2c3acf7a32..73a73d335a 100644 --- a/scripts/__tests__/ci-native-qualification.spec.ts +++ b/scripts/__tests__/ci-native-qualification.spec.ts @@ -75,7 +75,7 @@ function fixture() { buildVariant: leg.job === "android-emulator-parity" ? "Debug" : "Release", backend: leg.platform === "linux-arm64" || leg.job === "android-emulator-parity" ? "wgpu" : "Dawn", - engine: leg.platform === "linux-arm64" ? "QuickJS" : "V8", + engine: "V8", artifactSha256: "c".repeat(64), configSha256: "d".repeat(64), reportSha256: "e".repeat(64), diff --git a/scripts/__tests__/ci-structure.spec.ts b/scripts/__tests__/ci-structure.spec.ts index 7e5cf38589..64c1a19c92 100644 --- a/scripts/__tests__/ci-structure.spec.ts +++ b/scripts/__tests__/ci-structure.spec.ts @@ -1097,6 +1097,7 @@ describe("CI pipeline structure", () => { new Set([ "android-emulator-parity", "publish-android-v8", + "publish-linux-arm64-v8", "web-reference", "android-v8-source", ]), @@ -1151,6 +1152,83 @@ describe("CI pipeline structure", () => { }, ); + it("isolates the cached Linux arm64 V8 producer from consumers and release credentials", async () => { + const native = await readFile( + path.join(repo, ".github/workflows/native-platforms.yml"), + "utf8", + ); + const source = requiredJob(native, "linux-arm64-v8-source"); + const publish = requiredJob(native, "publish-linux-arm64-v8"); + expect(declaredNeeds(source)).toEqual(["scope"]); + expect(source).toContain("runs-on: ubuntu-24.04-arm"); + expect(source).toContain("github.event_name == 'workflow_dispatch'"); + expect(source).toContain("inputs.build_linux_arm64_v8 == true"); + expect(native).toMatch( + /build_linux_arm64_v8:\n {8}description: .+\n {8}required: false\n {8}type: boolean\n {8}default: false/u, + ); + expect(source).toContain("needs.scope.outputs.selection == 'full'"); + expect(source).toContain("needs.scope.outputs.native_tier != 'reduced'"); + expect(source).toContain("inputs.ios_only != true"); + expect(source).toContain("ref: ${{ needs.scope.outputs.candidate_sha }}"); + expect(source).toContain("timeout-minutes: 210"); + expect(source).toContain("timeout --signal=TERM --kill-after=30s 180m"); + expect(source).toContain("contents: read"); + expect(source).not.toContain("contents: write"); + const recipe = source.match(/hashFiles\(([\s\S]*?)\)/u)?.[1] ?? ""; + expect([...recipe.matchAll(/'([^']+)'/gu)].map((match) => match[1])).toEqual([ + "packages/runtime-native/scripts/build-linux-arm64-v8.mjs", + ]); + expect(source).toContain("gh release download ci-v8-linux-arm64"); + expect(source).toContain("v8-linux-arm64-${{ steps.recipe.outputs.hash }}.tgz"); + expect(source).toContain( + "node packages/runtime-native/scripts/build-linux-arm64-v8.mjs --verify", + ); + expect(source).toContain( + "if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true'", + ); + expect(source).toContain("Published Linux arm64 V8 asset hit: $ASSET; no rebuild."); + expect(source).toContain("packages/runtime-native/third_party/.v8-linux-arm64-source"); + expect(source).toContain("steps.verify.outcome == 'success'"); + expect(source).toContain("steps.build.outcome == 'failure'"); + expect(source).toContain('ar p "$library" "$member"'); + expect(source).toContain('file "$RUNNER_TEMP/v8-arm64-member.o"'); + expect(source).toContain("grep -Eq 'Machine:.*AArch64'"); + expect(source).toContain('sha256sum "$ASSET"'); + expect(source).toContain("if-no-files-found: error"); + expect(declaredNeeds(publish)).toEqual(["scope", "linux-arm64-v8-source"]); + expect(publish).toContain("github.event_name != 'pull_request'"); + expect(publish).toContain( + "github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'", + ); + expect(publish).toContain("contents: write"); + expect(publish).not.toContain("actions/checkout"); + expect(publish).not.toContain("node "); + expect(publish).toContain("sha256sum --check linux-arm64-v8-proof/payload.sha256"); + expect(publish).toContain('gh release upload ci-v8-linux-arm64 "payload/$ASSET"'); + expect(publish).not.toContain("--clobber"); + expect(publish).toContain('cmp -- "payload/$ASSET" "$RUNNER_TEMP/published-v8/$ASSET"'); + expect(declaredNeeds(requiredJob(native, "starter-linux"))).not.toContain( + "linux-arm64-v8-source", + ); + expect(declaredNeeds(requiredJob(native, "completion"))).toEqual( + expect.arrayContaining(["linux-arm64-v8-source", "publish-linux-arm64-v8"]), + ); + const install = + source + .split(" - name: Install the pinned Linux arm64 V8 compiler\n")[1] + ?.split(" - name:")[0] ?? ""; + expect(install).toContain( + "if: steps.published.outputs.hit != 'true' && steps.payload.outputs.cache-hit != 'true'", + ); + expect(install).toContain("sudo apt-get update"); + expect(install).toContain( + "sudo apt-get install -y --allow-downgrades clang-18=1:18.1.3-1ubuntu1 lld-18=1:18.1.3-1ubuntu1 llvm-18=1:18.1.3-1ubuntu1 libclang-rt-18-dev=1:18.1.3-1 ninja-build build-essential python3 git pkg-config libglib2.0-dev", + ); + expect(source.indexOf("Install the pinned Linux arm64 V8 compiler")).toBeLessThan( + source.indexOf("Build the Linux arm64 V8 payload"), + ); + }); + // PRD-480's light lane: the jobs whose whole work is a script or a summary — no workspace build // and no test suite; a JSON aggregator may still `pnpm install` its own toolchain. `scope` // classifies the diff, `build` and `golden-path` @@ -2953,6 +3031,87 @@ describe("CI pipeline structure", () => { expect(callers).toBe(9); }); + it("selects QuickJS for the packaged fallback without changing native features", async () => { + const integration = await readFile( + path.join(repo, ".github/workflows/integration.yml"), + "utf8", + ); + const job = requiredJob(integration, "packaged-quickjs"); + const name = "Build the QuickJS host required by the packaged fallback"; + const step = job.slice(job.indexOf(` - name: ${name}`)).split("\n - ")[0]; + if (step === undefined) throw new Error(`workflow step ${name} was not found.`); + const script = step.includes(" run: |") + ? workflowRunScript(job, name) + : step.match(/ {8}run: (.+)/u)?.[1]; + expect(script).toBeTruthy(); + const directory = await makeTempDir("packaged-quickjs-selection-"); + const runtime = path.join(directory, "packages/runtime-native"); + await mkdir(runtime, { recursive: true }); + await writeFile( + path.join(runtime, "CMakeLists.txt"), + ` +cmake_minimum_required(VERSION 3.20) +project(fallback_selection NONE) +foreach(flag MYSTRAL_USE_V8 MYSTRAL_USE_QUICKJS MYSTRAL_USE_WGPU MYSTRAL_USE_DAWN TN_ENABLE_CANVAS2D TN_ENABLE_NATIVE_PHYSICS TN_ENABLE_UI_OVERLAY) + option(\${flag} "fixture feature" OFF) +endforeach() +if(MYSTRAL_USE_V8) + set(engine v8) +else() + set(engine quickjs) +endif() +file(WRITE "\${CMAKE_BINARY_DIR}/mystral" "#!/bin/sh\nprintf 'Native WebGPU JS runtime - wgpu-native + \${engine} build\\n'\n") +file(CHMOD "\${CMAKE_BINARY_DIR}/mystral" PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE) +add_custom_target(mystral) +`, + ); + const result = spawnSync( + "bash", + [ + "-e", + "-u", + "-o", + "pipefail", + "-c", + ` +pnpm() { + cmake -S packages/runtime-native -B packages/runtime-native/build/tn-linux \\ + -DMYSTRAL_USE_V8=ON -DMYSTRAL_USE_QUICKJS=OFF -DMYSTRAL_USE_WGPU=ON -DMYSTRAL_USE_DAWN=OFF \\ + -DTN_ENABLE_CANVAS2D=ON -DTN_ENABLE_NATIVE_PHYSICS=ON -DTN_ENABLE_UI_OVERLAY=ON +} +node() { test "$*" = "packages/runtime-native/scripts/download-deps.mjs --only quickjs"; } +${script} +`, + ], + { cwd: directory, encoding: "utf8", timeout: 30_000 }, + ); + expect(result.status, result.stderr).toBe(0); + const cache = await readFile(path.join(runtime, "build/tn-linux/CMakeCache.txt"), "utf8"); + expect(cache).toMatch(/^MYSTRAL_USE_V8:BOOL=OFF$/mu); + expect(cache).toMatch(/^MYSTRAL_USE_QUICKJS:BOOL=ON$/mu); + for (const feature of [ + "MYSTRAL_USE_WGPU", + "TN_ENABLE_CANVAS2D", + "TN_ENABLE_NATIVE_PHYSICS", + "TN_ENABLE_UI_OVERLAY", + ]) + expect(cache).toMatch(new RegExp(`^${feature}:BOOL=ON$`, "mu")); + expect(cache).toMatch(/^MYSTRAL_USE_DAWN:BOOL=OFF$/mu); + const { resolveRuntimeAssetCapabilities } = await import( + "../../packages/create-threenative/src/build.js" + ); + expect( + resolveRuntimeAssetCapabilities("desktop", path.join(runtime, "build/tn-linux/mystral")), + ).toMatchObject({ + engine: "quickjs", + webAssembly: false, + decoders: { ktx2: false, meshopt: false, draco: false }, + }); + expect(job).toContain( + "THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral", + ); + }); + it("keeps the native contracts and primary CI documentation honest", async () => { const ci = await readFile(path.join(repo, ".github/workflows/ci.yml"), "utf8"); const test = requiredJob(ci, "test-native"); diff --git a/scripts/__tests__/linux-arm64-v8-source.spec.ts b/scripts/__tests__/linux-arm64-v8-source.spec.ts new file mode 100644 index 0000000000..bb154d8df2 --- /dev/null +++ b/scripts/__tests__/linux-arm64-v8-source.spec.ts @@ -0,0 +1,430 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + readFileSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + CHROMIUM_BUILD_LICENSE, + LINUX_ARM64_V8_BUILD, + assertArm64V8Archive, + createLinuxArm64V8Receipt, + fetchChromiumBuildLicense, + inspectLinuxArm64V8Toolchain, + linuxArm64V8GnArgs, + prepareLinuxArm64V8Sdk, + provisionLinuxArm64V8, + reusableLinuxArm64V8State, + runLinuxArm64V8Command, + verifyLinuxArm64V8Installation, +} from "../../packages/runtime-native/scripts/build-linux-arm64-v8.mjs"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; + +import { promoteV8Payload } from "../../packages/runtime-native/scripts/build-linux-arm64-v8.mjs"; + +const chromiumLicense = Buffer.from( + "Ly8gQ29weXJpZ2h0IDIwMTUgVGhlIENocm9taXVtIEF1dGhvcnMKLy8KLy8gUmVkaXN0cmlidXRpb24gYW5kIHVzZSBpbiBzb3VyY2UgYW5kIGJpbmFyeSBmb3Jtcywgd2l0aCBvciB3aXRob3V0Ci8vIG1vZGlmaWNhdGlvbiwgYXJlIHBlcm1pdHRlZCBwcm92aWRlZCB0aGF0IHRoZSBmb2xsb3dpbmcgY29uZGl0aW9ucyBhcmUKLy8gbWV0OgovLwovLyAgICAqIFJlZGlzdHJpYnV0aW9ucyBvZiBzb3VyY2UgY29kZSBtdXN0IHJldGFpbiB0aGUgYWJvdmUgY29weXJpZ2h0Ci8vIG5vdGljZSwgdGhpcyBsaXN0IG9mIGNvbmRpdGlvbnMgYW5kIHRoZSBmb2xsb3dpbmcgZGlzY2xhaW1lci4KLy8gICAgKiBSZWRpc3RyaWJ1dGlvbnMgaW4gYmluYXJ5IGZvcm0gbXVzdCByZXByb2R1Y2UgdGhlIGFib3ZlCi8vIGNvcHlyaWdodCBub3RpY2UsIHRoaXMgbGlzdCBvZiBjb25kaXRpb25zIGFuZCB0aGUgZm9sbG93aW5nIGRpc2NsYWltZXIKLy8gaW4gdGhlIGRvY3VtZW50YXRpb24gYW5kL29yIG90aGVyIG1hdGVyaWFscyBwcm92aWRlZCB3aXRoIHRoZQovLyBkaXN0cmlidXRpb24uCi8vICAgICogTmVpdGhlciB0aGUgbmFtZSBvZiBHb29nbGUgTExDIG5vciB0aGUgbmFtZXMgb2YgaXRzCi8vIGNvbnRyaWJ1dG9ycyBtYXkgYmUgdXNlZCB0byBlbmRvcnNlIG9yIHByb21vdGUgcHJvZHVjdHMgZGVyaXZlZCBmcm9tCi8vIHRoaXMgc29mdHdhcmUgd2l0aG91dCBzcGVjaWZpYyBwcmlvciB3cml0dGVuIHBlcm1pc3Npb24uCi8vCi8vIFRISVMgU09GVFdBUkUgSVMgUFJPVklERUQgQlkgVEhFIENPUFlSSUdIVCBIT0xERVJTIEFORCBDT05UUklCVVRPUlMKLy8gIkFTIElTIiBBTkQgQU5ZIEVYUFJFU1MgT1IgSU1QTElFRCBXQVJSQU5USUVTLCBJTkNMVURJTkcsIEJVVCBOT1QKLy8gTElNSVRFRCBUTywgVEhFIElNUExJRUQgV0FSUkFOVElFUyBPRiBNRVJDSEFOVEFCSUxJVFkgQU5EIEZJVE5FU1MgRk9SCi8vIEEgUEFSVElDVUxBUiBQVVJQT1NFIEFSRSBESVNDTEFJTUVELiBJTiBOTyBFVkVOVCBTSEFMTCBUSEUgQ09QWVJJR0hUCi8vIE9XTkVSIE9SIENPTlRSSUJVVE9SUyBCRSBMSUFCTEUgRk9SIEFOWSBESVJFQ1QsIElORElSRUNULCBJTkNJREVOVEFMLAovLyBTUEVDSUFMLCBFWEVNUExBUlksIE9SIENPTlNFUVVFTlRJQUwgREFNQUdFUyAoSU5DTFVESU5HLCBCVVQgTk9UCi8vIExJTUlURUQgVE8sIFBST0NVUkVNRU5UIE9GIFNVQlNUSVRVVEUgR09PRFMgT1IgU0VSVklDRVM7IExPU1MgT0YgVVNFLAovLyBEQVRBLCBPUiBQUk9GSVRTOyBPUiBCVVNJTkVTUyBJTlRFUlJVUFRJT04pIEhPV0VWRVIgQ0FVU0VEIEFORCBPTiBBTlkKLy8gVEhFT1JZIE9GIExJQUJJTElUWSwgV0hFVEhFUiBJTiBDT05UUkFDVCwgU1RSSUNUIExJQUJJTElUWSwgT1IgVE9SVAovLyAoSU5DTFVESU5HIE5FR0xJR0VOQ0UgT1IgT1RIRVJXSVNFKSBBUklTSU5HIElOIEFOWSBXQVkgT1VUIE9GIFRIRSBVU0UKLy8gT0YgVEhJUyBTT0ZUV0FSRSwgRVZFTiBJRiBBRFZJU0VEIE9GIFRIRSBQT1NTSUJJTElUWSBPRiBTVUNIIERBTUFHRS4K", + "base64", +); +function archive(machine = 183, name = "object.o/", type = 1) { + const elf = Buffer.alloc(64); + Buffer.from([127, 69, 76, 70, 2, 1, 1]).copy(elf); + elf.writeUInt16LE(type, 16); + elf.writeUInt16LE(machine, 18); + return Buffer.concat([ + Buffer.from("!\n"), + Buffer.from( + `${name.padEnd(16)}${"0".padEnd(12)}${"0".padEnd(6)}${"0".padEnd(6)}${"644".padEnd(8)}${String(elf.length).padEnd(10)}\`\n`, + ), + elf, + ]); +} +const version = LINUX_ARM64_V8_BUILD.toolchain.version; +function toolchain() { + return { + ...inspectLinuxArm64V8Toolchain({ + run: (program: string, args: string[]) => + program === "dpkg-query" + ? `${args.at(-1)}\t${args.at(-1) === "libclang-rt-18-dev" ? "1:18.1.3-1" : "1:18.1.3-1ubuntu1"}\tarm64\tinstall ok installed` + : program === "dpkg" + ? "" + : program === "/usr/bin/pkg-config" + ? "2.80.0" + : `LLVM version ${version}`, + hash: (path: string) => + path.endsWith(".a") ? LINUX_ARM64_V8_BUILD.toolchain.compilerRt.sha256 : "a".repeat(64), + read: () => + Buffer.concat([archive(), ...Array.from({ length: 277 }, () => archive().subarray(8))]), + }), + gn: { + package: LINUX_ARM64_V8_BUILD.gnPackage, + sha256: LINUX_ARM64_V8_BUILD.gnSha256, + version: "2200 (95b0f8fe31a9)", + }, + }; +} +function fixture() { + const root = makeTempDirSync("linux-arm64-v8-"); + + mkdirSync(join(root, "include")); + mkdirSync(join(root, "licenses")); + writeFileSync(join(root, "include/v8.h"), "// fixture"); + writeFileSync( + join(root, "include/v8-version.h"), + ["MAJOR_VERSION 13", "MINOR_VERSION 1", "BUILD_NUMBER 201", "PATCH_LEVEL 22"] + .map((part) => `#define V8_${part}\n`) + .join(""), + ); + for (const name of [ + "V8-LICENSE", + "V8-LICENSE.v8", + "V8-FDLIBM", + "V8-STRONGTALK", + "BUILD-LICENSE", + "COMPILER-RT-COPYRIGHT", + ]) + writeFileSync(join(root, "licenses", name), "license fixture"); + writeFileSync(join(root, "licenses/BUILD-LICENSE"), chromiumLicense); + writeFileSync(join(root, "libv8_monolith.a"), archive()); + const receipt = createLinuxArm64V8Receipt(root, toolchain()); + writeFileSync(join(root, "build-receipt.json"), JSON.stringify(receipt)); + return root; +} +function editReceipt( + root: string, + edit: (receipt: ReturnType) => void, +) { + const path = join(root, "build-receipt.json"); + const receipt = JSON.parse(readFileSync(path, "utf8")); + edit(receipt); + writeFileSync(path, JSON.stringify(receipt)); +} + +describe("Linux arm64 V8 receipt", () => { + it("verifies through the command consumer without provisioning", () => { + const root = fixture(); + const provision = vi.fn(() => { + throw new Error("must not build"); + }); + expect(runLinuxArm64V8Command(["--verify"], { root, provision }).objects).toBe(1); + expect(provision).not.toHaveBeenCalled(); + }); + it("fails for a missing receipt or required license", () => { + const root = fixture(); + rmSync(join(root, "build-receipt.json")); + expect(() => runLinuxArm64V8Command(["--verify"], { root })).toThrow("receipt is missing"); + rmSync(join(root, "licenses/BUILD-LICENSE")); + expect(() => createLinuxArm64V8Receipt(root, toolchain())).toThrow( + "missing licenses/BUILD-LICENSE", + ); + }); + it("rejects GN receipt substitutions in package, binary hash and revision", () => { + for (const field of ["package", "sha256", "version"] as const) { + const root = fixture(); + editReceipt(root, (receipt) => { + receipt.toolchain.gn[field] = "wrong"; + }); + expect(() => verifyLinuxArm64V8Installation(root)).toThrow("GN receipt"); + } + }); + it("rejects missing Linux development package identities and GLib modules", () => { + for (const corrupt of [ + (receipt: ReturnType) => { + receipt.toolchain.linux.packages["libglib2.0-dev"] = + "libglib2.0-dev\t2.80.0-6ubuntu3\tamd64\tinstall ok installed"; + }, + (receipt: ReturnType) => { + Reflect.deleteProperty(receipt.toolchain.linux.packages, "pkg-config"); + }, + (receipt: ReturnType) => { + receipt.toolchain.linux.modules["gobject-2.0"] = ""; + }, + ]) { + const root = fixture(); + editReceipt(root, corrupt); + expect(() => verifyLinuxArm64V8Installation(root)).toThrow("Linux prerequisite"); + } + }); + it.each(["tamper", "extra", "empty", "symlink"])("rejects %s payloads", (kind) => { + const root = fixture(); + if (kind === "tamper") writeFileSync(join(root, "include/v8.h"), "changed"); + if (kind === "extra") writeFileSync(join(root, "extra"), "unrecorded"); + if (kind === "empty") writeFileSync(join(root, "extra"), ""); + if (kind === "symlink") symlinkSync(join(root, "include/v8.h"), join(root, "extra")); + expect(() => verifyLinuxArm64V8Installation(root)).toThrow(); + }); + it("rejects mismatched headers, recipe, flags and incomplete hashes", () => { + const root = fixture(); + writeFileSync(join(root, "include/v8-version.h"), "#define V8_MAJOR_VERSION 12\n"); + expect(() => verifyLinuxArm64V8Installation(root)).toThrow("headers do not match"); + for (const edit of [ + (receipt: ReturnType) => { + receipt.build = { ...receipt.build, recipe: receipt.build.recipe + 1 }; + }, + (receipt: ReturnType) => { + receipt.args += ' host_cpu="x64"'; + }, + (receipt: ReturnType) => { + Reflect.deleteProperty(receipt.files, "include/v8.h"); + }, + (receipt: ReturnType) => { + receipt.toolchain.packages["clang-18"] = "untrusted"; + }, + (receipt: ReturnType) => { + Reflect.deleteProperty(receipt.toolchain.binaries, "clang"); + }, + ]) { + const other = fixture(); + editReceipt(other, edit); + expect(() => verifyLinuxArm64V8Installation(other)).toThrow(); + } + }); + it("refuses a wrong-architecture object even with freshly computed hashes", () => { + const root = fixture(); + writeFileSync(join(root, "libv8_monolith.a"), archive(62)); + expect(() => createLinuxArm64V8Receipt(root, toolchain())).toThrow("not an aarch64 ELF"); + }); +}); + +describe("payload promotion and recovery", () => { + it("reuses a verified payload without requiring a compiler on the consumer", () => { + const root = fixture(); + expect(provisionLinuxArm64V8(root).build.version).toBe("13.1.201.22"); + }); + it("recovers a prior install after interrupted promotion", () => { + const root = fixture(); + const parent = makeTempDirSync("v8-recovery-"); + + const work = join(parent, ".v8-linux-arm64-source"); + mkdirSync(work); + renameSync(root, join(work, "previous")); + const destination = join(parent, "v8-linux-arm64"); + expect(provisionLinuxArm64V8(destination).objects).toBe(1); + }); + it("restores the previous payload if promotion fails", () => { + const root = fixture(); + const parent = makeTempDirSync("v8-promotion-"); + + const backup = join(parent, "previous"); + expect(() => promoteV8Payload(join(parent, "missing-stage"), root, backup)).toThrow(); + expect(verifyLinuxArm64V8Installation(root).objects).toBe(1); + const stage = fixture(); + promoteV8Payload(stage, root, backup); + expect(verifyLinuxArm64V8Installation(root).objects).toBe(1); + expect(verifyLinuxArm64V8Installation(backup).objects).toBe(1); + }); +}); + +describe("archive validation", () => { + it("checks every object, not just the first", () => { + expect(assertArm64V8Archive(archive())).toBe(1); + expect(() => assertArm64V8Archive(Buffer.concat([archive(), archive(62).subarray(8)]))).toThrow( + "aarch64 ELF", + ); + expect(() => assertArm64V8Archive(archive(183, "object.o/", 3))).toThrow("relocatable"); + }); + it.each([Buffer.from("!\n"), Buffer.from("!\n"), archive().subarray(0, 100)])( + "rejects thin, empty and truncated archives", + (bytes) => { + expect(() => assertArm64V8Archive(bytes)).toThrow(); + }, + ); + it("allows GNU name tables but never counts metadata as objects", () => { + const table = archive().subarray(0, 68); + table.write("//".padEnd(16), 8); + table.write("0".padEnd(10), 56); + expect(assertArm64V8Archive(Buffer.concat([table, archive(183, "/0").subarray(8)]))).toBe(1); + expect(() => assertArm64V8Archive(table)).toThrow("no objects"); + }); +}); + +describe("recipe and toolchain", () => { + it("matches the desktop monolith ABI using the native compiler", () => { + const args = linuxArm64V8GnArgs(); + for (const arg of [ + 'target_cpu="arm64"', + "v8_monolithic=true", + "v8_enable_pointer_compression=false", + "v8_enable_sandbox=false", + "v8_use_external_startup_data=false", + "use_sysroot=false", + "use_custom_libcxx=false", + "clang_use_chrome_plugins=false", + 'clang_base_path="//.threenative-llvm-18"', + 'clang_version="18"', + "use_lld=true", + ]) + expect(args).toContain(arg); + expect(args).not.toContain("x64"); + }); + it("rejects wrong package identities, modified installs and wrong compiler versions", () => { + for (const output of [ + "clang-18\twrong\tarm64\tinstall ok installed", + "clang-18\t1:18.1.3-1ubuntu1\tamd64\tinstall ok installed", + ]) + expect(() => inspectLinuxArm64V8Toolchain({ run: () => output })).toThrow("requires native"); + const run = (program: string, args: string[]) => + program === "dpkg-query" + ? `${args.at(-1)}\t${args.at(-1) === "libclang-rt-18-dev" ? "1:18.1.3-1" : "1:18.1.3-1ubuntu1"}\tarm64\tinstall ok installed` + : program === "dpkg" + ? "modified" + : "clang version 20.0.0"; + expect(() => inspectLinuxArm64V8Toolchain({ run })).toThrow("installed-file integrity"); + expect(() => + inspectLinuxArm64V8Toolchain({ + run: (program: string, args: string[]) => (program === "dpkg" ? "" : run(program, args)), + }), + ).toThrow("must be 18.1.3"); + }); + it("rejects absent, substituted or incomplete compiler-rt receipts", () => { + for (const corrupt of [ + (rt: Record) => { + rt.sha256 = "a".repeat(64); + }, + (rt: Record) => { + rt.objects = 1; + }, + (rt: Record) => { + Reflect.deleteProperty(rt, "archive"); + }, + ]) { + const root = fixture(); + editReceipt(root, (receipt) => corrupt(receipt.toolchain.compilerRt)); + expect(() => verifyLinuxArm64V8Installation(root)).toThrow("compiler-rt receipt"); + } + }); + it("adapts only the private SDK and requires a native preflight executable to run", () => { + const root = makeTempDirSync("v8-sdk-"); + + const run = vi.fn((_program: string, args: string[]) => { + if (args.length) { + const elf = archive().subarray(68); + elf.writeUInt16LE(3, 16); + const output = args.at(-1); + if (output === undefined) throw new Error("missing preflight output argument"); + writeFileSync(output, elf); + } + }); + prepareLinuxArm64V8Sdk(root, run); + const sdk = join(root, ".threenative-llvm-18"); + expect(readlinkSync(join(sdk, "bin"))).toBe("/usr/lib/llvm-18/bin"); + expect(readlinkSync(join(sdk, "lib/clang/18/include"))).toBe( + "/usr/lib/llvm-18/lib/clang/18/include", + ); + const builtins = join(sdk, "lib/clang/18/lib/aarch64-unknown-linux-gnu/libclang_rt.builtins.a"); + expect(readlinkSync(builtins)).toBe(LINUX_ARM64_V8_BUILD.toolchain.compilerRt.archive); + expect(run.mock.calls[0]?.[1]).toContain(builtins); + expect(run.mock.calls[1]).toEqual([join(sdk, "preflight"), []]); + expect(() => + prepareLinuxArm64V8Sdk(root, (_program: string, args: string[]) => { + const output = args.at(-1); + if (output === undefined) throw new Error("missing preflight output argument"); + writeFileSync(output, archive(62).subarray(68)); + }), + ).toThrow("aarch64 ELF executable"); + expect(() => + prepareLinuxArm64V8Sdk(root, () => { + throw new Error("link failed"); + }), + ).toThrow("link failed"); + }); + it("validates command flags and routes --force", () => { + const provision = vi.fn(); + runLinuxArm64V8Command(["--force"], { root: "/unused", provision }); + expect(provision).toHaveBeenCalledWith("/unused", { force: true }); + for (const args of [["--wat"], ["--verify", "--force"]]) + expect(() => runLinuxArm64V8Command(args, { provision })).toThrow("Usage"); + }); + it("the real CLI fails closed for unsupported flags", () => { + const result = spawnSync( + process.execPath, + [resolve("packages/runtime-native/scripts/build-linux-arm64-v8.mjs"), "--wat"], + { encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); +}); + +describe("packaging-only source-cache migration", () => { + it("pins the migration destination to the final producer bytes in CI", () => { + const workflow = readFileSync(".github/workflows/native-platforms.yml", "utf8"); + const pin = workflow.match(/THREENATIVE_V8_PACKAGING_REPAIR_SCRIPT_SHA: ([a-f0-9]{64})/u)?.[1]; + const actual = createHash("sha256") + .update(readFileSync("packages/runtime-native/scripts/build-linux-arm64-v8.mjs")) + .digest("hex"); + expect(pin).toBe(actual); + }); + + const previousScript = "3a5872301fb0fb8b9ed380b1172ca15de414fa370e23812356c736ffc233b1e8"; + const expected = { + build: LINUX_ARM64_V8_BUILD, + toolchain: toolchain(), + buildScript: "b".repeat(64), + }; + it("accepts only the exact previous script with otherwise identical full state", () => { + expect(reusableLinuxArm64V8State(JSON.stringify(expected), JSON.stringify(expected))).toBe( + true, + ); + expect( + reusableLinuxArm64V8State( + JSON.stringify({ ...expected, buildScript: previousScript }), + JSON.stringify(expected), + expected.buildScript, + ), + ).toBe(true); + for (const changed of [ + { ...expected, buildScript: "c".repeat(64) }, + { ...expected, buildScript: previousScript, build: { ...expected.build, source: "wrong" } }, + { + ...expected, + buildScript: previousScript, + toolchain: { ...expected.toolchain, compilerRt: { sha256: "wrong" } }, + }, + { ...expected, buildScript: previousScript, unexpected: true }, + ]) + expect( + reusableLinuxArm64V8State( + JSON.stringify(changed), + JSON.stringify(expected), + expected.buildScript, + ), + ).toBe(false); + }); + it("rejects predecessor reuse by a future script even with unchanged build and toolchain", () => { + const old = JSON.stringify({ ...expected, buildScript: previousScript }); + const future = JSON.stringify({ ...expected, buildScript: "c".repeat(64) }); + expect(reusableLinuxArm64V8State(old, future, expected.buildScript)).toBe(false); + expect(reusableLinuxArm64V8State(old, JSON.stringify(expected))).toBe(false); + expect(reusableLinuxArm64V8State(old, JSON.stringify(expected), "wrong")).toBe(false); + }); + it("fetches the exact owning Chromium license and rejects missing or altered bytes", () => { + const run = vi.fn(() => Buffer.from(chromiumLicense.toString("base64"))); + expect(fetchChromiumBuildLicense(run)).toEqual(chromiumLicense); + expect(run.mock.calls[0]).toEqual([ + "curl", + ["--fail", "--location", "--silent", "--show-error", CHROMIUM_BUILD_LICENSE.url], + ]); + expect(() => fetchChromiumBuildLicense(() => Buffer.from(""))).toThrow("checksum mismatch"); + const altered = Buffer.from(chromiumLicense); + altered.writeUInt8(altered.readUInt8(0) ^ 1, 0); + expect(() => fetchChromiumBuildLicense(() => Buffer.from(altered.toString("base64")))).toThrow( + "checksum mismatch", + ); + expect(() => + fetchChromiumBuildLicense(() => { + throw new Error("404"); + }), + ).toThrow("404"); + const root = fixture(); + writeFileSync(join(root, "licenses/BUILD-LICENSE"), altered); + expect(() => createLinuxArm64V8Receipt(root, toolchain())).toThrow("checksum mismatch"); + }); +}); diff --git a/scripts/__tests__/starter-js-frame-pair.spec.ts b/scripts/__tests__/starter-js-frame-pair.spec.ts new file mode 100644 index 0000000000..c4e2f6eca2 --- /dev/null +++ b/scripts/__tests__/starter-js-frame-pair.spec.ts @@ -0,0 +1,246 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; + +const { compareRuns, launchOrder } = await import( + new URL( + "../../packages/runtime-native/scripts/measure-starter-js-frame-pair.mjs", + import.meta.url, + ).href +); + +function runs() { + return launchOrder.map((engine: string, index: number) => ({ + engine, + bundleSha256: "a".repeat(64), + binarySha256: (engine === "v8" ? "b" : "c").repeat(64), + log: `[${engine === "v8" ? "V8" : "QuickJS"}] Engine created successfully\n${engine === "v8" ? "[V8] Version: 13.1.201.22\n" : ""}[WebGPU] Adapter: llvmpipe\n[WebGPU] Backend: Vulkan\nTN_WORLD_ENVIRONMENT:{"stages":[{"name":"grade","applied":true}]}`, + report: { + pass: true, + discardedWindows: [1], + violations: [], + budgets: [1, 2, 3].map((window) => ({ + window, + frames: 300, + frame: { + mean: 10, + p50: + window === 2 + ? 999 + : engine === "v8" + ? [4, 5, 6][Math.floor(index / 2)] + : [9, 10, 100][Math.floor(index / 2)], + p95: 120, + }, + surface: { + drawingBufferWidth: 1280, + drawingBufferHeight: 720, + resolutionScale: 1, + sampleCount: 1, + compiling: false, + }, + })), + }, + })); +} + +describe("starter paired JS frame proof", () => { + it("uses alternating pairs and strict median of sampled run p50s, rejecting incomplete or incomparable evidence", () => { + expect(launchOrder).toEqual(["quickjs", "v8", "v8", "quickjs", "quickjs", "v8"]); + expect(compareRuns(runs())).toMatchObject({ pass: true, quickjsMedianMs: 10, v8MedianMs: 5 }); + for (const corrupt of [ + (r: ReturnType) => r.pop(), + (r: ReturnType) => { + r[0].report.budgets.pop(); + }, + (r: ReturnType) => { + r[0].report.budgets[1].frame.p50 = Number.NaN; + }, + (r: ReturnType) => { + Reflect.deleteProperty(r[0].report.budgets[1], "frame"); + }, + (r: ReturnType) => { + r[0].report.pass = false; + }, + (r: ReturnType) => { + r[0].log = "[V8] Engine created successfully"; + }, + (r: ReturnType) => { + r[1].log = r[1].log.replace("13.1.201.22", "13.0.1"); + }, + (r: ReturnType) => { + r[0].bundleSha256 = "d".repeat(64); + }, + (r: ReturnType) => { + r[0].report.budgets[1].surface.resolutionScale = 0.5; + }, + (r: ReturnType) => { + Reflect.deleteProperty(r[0].report.budgets[1].surface, "compiling"); + }, + (r: ReturnType) => { + r[0].report.budgets[1].surface.compiling = true; + }, + (r: ReturnType) => { + r[0].log = r[0].log.replace('"applied":true', '"applied":false'); + }, + ]) { + const invalid = runs(); + corrupt(invalid); + expect(() => compareRuns(invalid)).toThrow(); + } + const tied = runs(); + for (const run of tied) for (const window of run.report.budgets) window.frame.p50 = 10; + expect(compareRuns(tied)).toMatchObject({ pass: false, quickjsMedianMs: 10, v8MedianMs: 10 }); + }); +}); + +it("captures two fixed-step starter poses per engine using the same measured bundle before restoration", () => { + const workflow = readFileSync(".github/workflows/native-platforms.yml", "utf8"); + const step = workflow.slice( + workflow.indexOf("- name: Compare paired starter JS"), + workflow.indexOf("- name: Upload paired starter JS"), + ); + expect(step).toContain("trap restore_v8 EXIT"); + expect(step).toContain("for engine in quickjs v8; do"); + expect(step).toContain("for capture in 1 2; do"); + expect(step).toContain('"$target/native-playtests/render-chain.playtest.json" --target desktop'); + expect(step).toContain('--project "$target/public" --executable "$binary"'); + expect(step).toContain('--host-arg run --host-arg "$target/dist-native/perf-game.js"'); + expect(step).toContain('--host-arg --ui --host-arg "$target/dist-native/ui"'); + expect(step).toContain('--artifacts "$evidence/same-pose/$engine-$capture"'); + expect(step.indexOf('"$target/dist-native/perf-game.js" "$evidence"')).toBeLessThan( + step.indexOf("for engine in quickjs v8"), + ); + expect(step).not.toContain("--live-clock"); + const helper = readFileSync( + "packages/runtime-native/scripts/measure-starter-js-frame-pair.mjs", + "utf8", + ); + expect(helper).not.toContain("--screenshot"); + expect(helper).toContain("V8 median JS frame duration must be strictly lower"); +}); + +it("executes the installed QuickJS cook before freezing its canonical bundle and asset manifest", () => { + const workflow = readFileSync(".github/workflows/native-platforms.yml", "utf8"); + const step = workflow.slice( + workflow.indexOf("- name: Compare paired starter JS"), + workflow.indexOf("- name: Upload paired starter JS"), + ); + const start = step.indexOf(" THREENATIVE_RUNTIME_BINARY="); + const end = step.indexOf( + ' cp "$target/threenative.config.ts" "$evidence/paired-config.ts"', + start, + ); + expect(start).toBeGreaterThan(0); + expect(end).toBeGreaterThan(start); + const root = makeTempDirSync("starter-paired-cook-"); + for (const path of ["project/dist-native", "project/public", "evidence"]) + mkdirSync(join(root, path), { recursive: true }); + const script = join(root, "cook.sh"); + writeFileSync( + script, + `set -eu +runtime="$1/runtime" +target="$1/project" +evidence="$1/evidence" +pnpm() { + test "$THREENATIVE_RUNTIME_BINARY" = "$runtime/build/tn-linux/mystral" + test "$*" = "--dir $target exec threenative build --target desktop" + mkdir -p "$target/.threenative/build" + printf shared-native-bundle > "$target/.threenative/build/game.js" + printf cooked-native-assets > "$target/public/assets.manifest.json" +} +${step.slice(start, end)} +`, + ); + const run = spawnSync("bash", [script, root], { encoding: "utf8" }); + expect(run.status, run.stderr).toBe(0); + expect(readFileSync(join(root, "project/dist-native/perf-game.js"), "utf8")).toBe( + "shared-native-bundle", + ); + expect(readFileSync(join(root, "evidence/assets.manifest.json"), "utf8")).toBe( + "cooked-native-assets", + ); + expect( + step.indexOf('cp "$runtime/build/tn-linux/CMakeCache.txt" "$evidence/quickjs-CMakeCache.txt"'), + ).toBeLessThan(start); + expect(end).toBeLessThan( + step.indexOf('sh scripts/xvfb.sh node "$runtime/scripts/measure-starter-js-frame-pair.mjs"'), + ); + expect(step).not.toContain('node "$runtime/scripts/bundle.mjs"'); +}); + +for (const failPair of [false, true]) { + it(`restores the verified consumer after the actual paired step ${failPair ? "fails" : "succeeds"}`, () => { + const workflow = readFileSync(".github/workflows/native-platforms.yml", "utf8"); + const step = workflow.slice( + workflow.indexOf("- name: Compare paired starter JS"), + workflow.indexOf("- name: Upload paired starter JS"), + ); + const shell = step + .slice(step.indexOf(" set -euo pipefail")) + .split("\n") + .map((line) => line.replace(/^ {10}/u, "")) + .join("\n"); + const root = makeTempDirSync("starter-paired-restore-"); + const target = join(root, "threenative-starter-native"); + const runtime = join(root, "packages/runtime-native"); + const evidence = join(runtime, "artifacts/starter-linux-arm64/js-frame-pair"); + for (const path of [ + join(runtime, "build/tn-linux"), + join(target, "dist-native"), + join(target, "public"), + ]) + mkdirSync(path, { recursive: true }); + const consumer = join(target, "dist-native/threenative-starter-native"); + writeFileSync(consumer, "verified consumer bytes"); + chmodSync(consumer, 0o751); + writeFileSync(join(runtime, "build/tn-linux/mystral"), "verified V8 host"); + writeFileSync(join(runtime, "build/tn-linux/CMakeCache.txt"), "TN_ENABLE_CANVAS2D:BOOL=ON\n"); + writeFileSync(join(target, "threenative.config.ts"), 'resolutionScale: "auto"'); + const stubs = ` +cmake() { + case "$*" in + *--build*) printf quickjs-host > "$runtime/build/tn-linux/mystral" ;; + esac +} +pnpm() { + mkdir -p "$target/.threenative/build" + printf changed-consumer > "$target/dist-native/threenative-starter-native" + chmod 600 "$target/dist-native/threenative-starter-native" + printf shared-native-bundle > "$target/.threenative/build/game.js" + printf cooked-native-assets > "$target/public/assets.manifest.json" +} +sh() { + case "$*" in + *measure-starter-js-frame-pair.mjs*) return "$FAIL_PAIR" ;; + *) printf '{"pass":true}\\n'; printf runner-diagnostic >&2 ;; + esac +} +`; + const run = spawnSync("bash", ["-c", stubs + shell], { + cwd: root, + env: { ...process.env, RUNNER_TEMP: root, FAIL_PAIR: failPair ? "23" : "0" }, + encoding: "utf8", + timeout: 5_000, + }); + expect(run.status, run.stderr).toBe(failPair ? 23 : 0); + expect(readFileSync(consumer, "utf8")).toBe("verified consumer bytes"); + expect(statSync(consumer).mode & 0o777).toBe(0o751); + expect(readFileSync(join(runtime, "build/tn-linux/mystral"), "utf8")).toBe("verified V8 host"); + expect(readFileSync(join(target, "threenative.config.ts"), "utf8")).toBe( + 'resolutionScale: "auto"', + ); + if (!failPair) + for (const arm of ["quickjs-1", "quickjs-2", "v8-1", "v8-2"]) { + expect( + JSON.parse(readFileSync(join(evidence, "same-pose", arm, "report.json"), "utf8")), + ).toEqual({ pass: true }); + expect(readFileSync(join(evidence, "same-pose", arm, "runner.stderr.log"), "utf8")).toBe( + "runner-diagnostic", + ); + } + }); +} diff --git a/scripts/ci-native-qualification.mjs b/scripts/ci-native-qualification.mjs index 3e3237f35a..15b9b41ece 100644 --- a/scripts/ci-native-qualification.mjs +++ b/scripts/ci-native-qualification.mjs @@ -20,8 +20,10 @@ export const NATIVE_JOBS = Object.freeze({ "starter-linux": "Scaffolded starter desktop artifact (${{ matrix.platform }})", }); const EXEMPT = { + "linux-arm64-v8-source": "Linux arm64 V8 source payload", "ios-simulator": "iOS simulator runtime and no-Xcode consumer handoff", "publish-android-v8": "Publish the Android V8 payload", + "publish-linux-arm64-v8": "Publish the Linux arm64 V8 payload", completion: "Native required coverage receipt", }; const full = "needs.scope.outputs.selection == 'full' && inputs.ios_only != true"; @@ -29,6 +31,8 @@ const GATES = { scope: "", "web-reference": "needs.scope.outputs.selection == 'full' && inputs.ios_only == false", "android-v8-source": full, + "linux-arm64-v8-source": + "github.event_name == 'workflow_dispatch' && inputs.build_linux_arm64_v8 == true && needs.scope.outputs.selection == 'full' && needs.scope.outputs.native_tier != 'reduced' && inputs.ios_only != true", "android-emulator-parity": `${full} && needs.android-v8-source.result == 'success'`, "desktop-parity": full, "release-reports": "needs.scope.outputs.selection != 'prose' && inputs.ios_only == false", @@ -40,6 +44,8 @@ const GATES = { "needs.scope.outputs.selection == 'full' && needs.scope.outputs.native_tier == 'full'", "publish-android-v8": "needs.android-v8-source.outputs.published == 'false' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')", + "publish-linux-arm64-v8": + "needs.linux-arm64-v8-source.outputs.published == 'false' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')", completion: "${{ always() && inputs.ios_only != true }}", }; const RUNTIME = new Set(["android-emulator-parity", "desktop-parity", "desktop", "starter-linux"]); @@ -94,7 +100,13 @@ export function validateNativeWorkflow(source) { !jobs.get("starter-linux").includes("matrix: ${{ fromJSON(needs.scope.outputs.starter_rows) }}") ) fail("WORKFLOW_POLICY", "starter expansion differs from supported policy"); - const expectedNeeds = [...Object.keys(NATIVE_JOBS), "ios-simulator", "publish-android-v8"].sort(); + const expectedNeeds = [ + ...Object.keys(NATIVE_JOBS), + "ios-simulator", + "publish-android-v8", + "linux-arm64-v8-source", + "publish-linux-arm64-v8", + ].sort(); const needs = /^ {4}needs: \[([^\]]+)\]$/mu .exec(jobs.get("completion"))?.[1] .split(",") diff --git a/test-support/ci-integration-fixture.ts b/test-support/ci-integration-fixture.ts index 12ee26e036..20756face6 100644 --- a/test-support/ci-integration-fixture.ts +++ b/test-support/ci-integration-fixture.ts @@ -178,7 +178,7 @@ export function nativeEvidence( leg.job === "android-emulator-parity" || leg.platform === "linux-arm64" ? "wgpu" : "Dawn", - engine: leg.platform === "linux-arm64" ? "QuickJS" : "V8", + engine: "V8", artifactSha256: "c".repeat(64), configSha256: "d".repeat(64), reportSha256: "e".repeat(64),