From cb40697274772ccd1bc6d51d9e9fa5fa088640da Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 08:39:07 +0200 Subject: [PATCH 01/14] [M2-AC-01] AC-4.x acceptance suite (projection-mode independence, 60 FPS sort gate) AC-4.1: one engine build, config-only difference. The golden tool now renders multi-pass frames; two new fixtures (ac4_iso, ac4_side_view) carry the SAME 96-sprite world + 3-widget UI under the two projection configs (Iso at the ADR 0005 preset, SideView via planeOrtho). The GL-free GoldenFixtures.Ac4* tests pin the shared scene shape and the config-only difference. Documented geometry note: SpriteItem carries the 2D ground-plane position (no elevation field), so the side-view world pass is degenerate by construction; the side-view frame's visible content is the screen-space UI pass (the AC-4.1 assertion is on the scene, not the world raster). AC-4.2: tools/laige-surface-lint (Python stdlib) proves no laige-sim / laige-net public surface mentions ProjectionMode (RENDER-006): exit 0 OK / 1 violation / 2 structural, with the ac4_surface* CTest entries (fixture trees for each exit path + the real tree, the laige-include-lint pattern). AC-4.3 + AC-4.4: the laige-bench ac4 suite (alias render-sort-10k): 10 000 overlapping sprites (100 x 10 grid, 10 stacked per cell - equal-key ties + overdraw), 4 (atlas, material, blend) groups, one 60 FPS frame (sprites_10k_frame budget, mean <= 16.7 ms) with the batcher zero-allocation assertion; setup asserts the AC-4.4 engine default (ProjectionView{} is Iso). Budgets.json gains sprites_10k_frame (the BudgetHarnessTable pin moves 17 -> 18); the laige_bench_ac4_fpx16/_fp32 CTest entries join the ci:slow:bench-50k label-gated jobs (both workflows). The two ac4 golden references are committed in this PR's second commit (the label-gated golden-references job prints them; the first PR run is expected to fail golden on the missing references - the M2-GOLD-01 bootstrap contract). Local 4-tree gate green: 121/121 gcc, 121/121 clang, 118/118 asan, 118/118 tsan (14 GL-creating entries excluded, the documented GPU-restricted local gate). --- .github/workflows/ci-pull.yml | 51 ++-- .github/workflows/ci.yml | 54 ++-- budgets.json | 8 + docs/api/budget_harness.md | 48 ++- docs/testing.md | 26 +- tests/golden/golden_compare_tests.cpp | 180 ++++++++++- tests/golden/golden_fixtures.h | 276 ++++++++++++++++- tests/laige-core/budget_harness_tests.cpp | 7 +- tests/tools/CMakeLists.txt | 74 +++++ tests/tools/expect-lint-result.cmake.in | 4 +- tools/README.md | 20 +- tools/bench/CMakeLists.txt | 53 +++- tools/bench/laige-bench.cpp | 350 +++++++++++++++++++++- tools/golden/laige-golden.cpp | 284 ++++++++++++------ tools/golden/references/README.md | 14 +- tools/laige-surface-lint | 167 +++++++++++ 16 files changed, 1433 insertions(+), 183 deletions(-) create mode 100755 tools/laige-surface-lint diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index 4ebbff0..31bcf15 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -27,11 +27,16 @@ # ci:slow:particle particle_render gcc clang tsan # ci:slow:zero-alloc zero_alloc asan tsan # ci:slow:ecs-stress ecs_stress asan tsan -# ci:slow:bench-50k laige_bench_render_50k_fpx16 / _fp32 gcc clang +# ci:slow:bench-50k laige_bench_render_50k_fpx16 / _fp32 and +# laige_bench_ac4_fpx16 / _fp32 (M2-AC-01) gcc clang # # Evidence (run 38312355303, per-entry times in s, threshold 30 s): # laige_bench_render_50k_fpx16 gcc 577.05 clang 578.42 # laige_bench_render_50k_fp32 gcc 593.82 clang 566.52 +# laige_bench_ac4_fpx16 / _fp32 NEW in M2-AC-01 — estimated ~60 s +# from the render-50k evidence (577 s over 4000 frames of 116k +# quads vs the ac4 frame's 10k quads); first-run times replace this +# estimate in the header once the entry has a master-run measurement # laige-sim_tests gcc 59.97 clang 53.79 asan 124.03 tsan 159.52 # laige-render_tests gcc 53.83 clang 44.45 asan 43.20 tsan 60.41 # particle_render gcc 38.76 clang 34.67 asan 28.84 tsan 39.56 @@ -148,7 +153,7 @@ jobs: # gated run. run: | ctest --test-dir build --output-on-failure \ - -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32)$' + -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32|laige_bench_ac4_fpx16|laige_bench_ac4_fp32)$' linux-clang: name: Linux x64 (clang++) @@ -182,7 +187,7 @@ jobs: # gated run. run: | ctest --test-dir build --output-on-failure \ - -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32)$' + -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32|laige_bench_ac4_fpx16|laige_bench_ac4_fp32)$' linux-asan: name: Linux x64 ASan+UBSan (clang++) @@ -352,11 +357,11 @@ jobs: run: ctest --test-dir build --output-on-failure -R '^particle_render$' linux-gcc-bench-fpx16: - name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fpx16" + name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fpx16 + laige_bench_ac4_fpx16" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (577.05 s in the evidence - # run) plus runner variance. The pre-split 45-min exception minus - # the rest of the suite. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner + # variance. The pre-split 45-min exception minus the rest of the suite. timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'ci:slow:bench-50k') steps: @@ -373,14 +378,14 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fpx16)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fpx16$' + - name: "Test (slow bench: render-50k + ac4, fpx16)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fpx16|laige_bench_ac4_fpx16)$' linux-gcc-bench-fp32: - name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fp32" + name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fp32 + laige_bench_ac4_fp32" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (593.82 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'ci:slow:bench-50k') steps: @@ -397,8 +402,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fp32)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fp32$' + - name: "Test (slow bench: render-50k + ac4, fp32)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fp32|laige_bench_ac4_fp32)$' linux-clang-sim: name: "Linux x64 (clang++) slow: laige-sim_tests" @@ -473,10 +478,10 @@ jobs: run: ctest --test-dir build --output-on-failure -R '^particle_render$' linux-clang-bench-fpx16: - name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fpx16" + name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fpx16 + laige_bench_ac4_fpx16" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (578.42 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'ci:slow:bench-50k') steps: @@ -493,14 +498,14 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fpx16)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fpx16$' + - name: "Test (slow bench: render-50k + ac4, fpx16)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fpx16|laige_bench_ac4_fpx16)$' linux-clang-bench-fp32: - name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fp32" + name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fp32 + laige_bench_ac4_fp32" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (566.52 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'ci:slow:bench-50k') steps: @@ -517,8 +522,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fp32)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fp32$' + - name: "Test (slow bench: render-50k + ac4, fp32)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fp32|laige_bench_ac4_fp32)$' linux-asan-sim: name: "Linux x64 ASan+UBSan (clang++) slow: laige-sim_tests" @@ -790,8 +795,10 @@ jobs: golden-references: name: Golden references (label-gated, ubuntu-24.04, g++, llvmpipe) runs-on: ubuntu-24.04 - # 30: build (~1.5 min) + two 1280x720 software-GL renders + font - # rasterization, plus runner variance. + # 30: build (~1.5 min) + four 1280x720 software-GL renders (the + # sprite + UI fixtures, and the two M2-AC-01 ac4 fixtures — two + # passes each, small scenes) + font rasterization, plus runner + # variance. timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'ci:golden-update') steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd87bb9..cd6043d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,18 @@ # slow-zero-alloc / ci:slow:zero-alloc zero_alloc asan tsan # slow-ecs-stress / ci:slow:ecs-stress ecs_stress asan tsan # slow-bench-50k / ci:slow:bench-50k laige_bench_render_50k_fpx16, -# laige_bench_render_50k_fp32 gcc clang +# laige_bench_render_50k_fp32, +# laige_bench_ac4_fpx16, +# laige_bench_ac4_fp32 (M2-AC-01) gcc clang # # Evidence (run 38312355303, per-entry times in s, threshold 30 s): # laige_bench_render_50k_fpx16 gcc 577.05 clang 578.42 # laige_bench_render_50k_fp32 gcc 593.82 clang 566.52 +# laige_bench_ac4_fpx16 / _fp32 NEW in M2-AC-01 — estimated ~60 s +# from the render-50k evidence (577 s over 4000 frames of 116k +# quads vs the ac4 frame's 10k quads); first-run times replace +# this estimate in the header once the entry has a master-run +# measurement # laige-sim_tests gcc 59.97 clang 53.79 asan 124.03 tsan 159.52 # laige-render_tests gcc 53.83 clang 44.45 asan 43.20 tsan 60.41 # particle_render gcc 38.76 clang 34.67 asan 28.84 tsan 39.56 @@ -184,8 +191,9 @@ # (laige_bench_sim_tick_smoke): it exercises the workload and asserts # its bit-identical fpx16_16 final_hash on every platform, without # checking timing. The render-50k budget entries -# (laige_bench_render_50k_fpx16 / _fp32) are the SLOW bench jobs above — -# reference-platform-scoped, gated by ci:slow:bench-50k / the +# (laige_bench_render_50k_fpx16 / _fp32) and the AC-4 budget entries +# (laige_bench_ac4_fpx16 / _fp32, M2-AC-01) are the SLOW bench jobs +# above — reference-platform-scoped, gated by ci:slow:bench-50k / the # slow-bench-50k checkbox, and run at every milestone gate (AGENTS.md # §5). # @@ -223,7 +231,7 @@ on: type: boolean default: false slow-bench-50k: - description: "Run the slow laige_bench_render_50k jobs (gcc, clang)" + description: "Run the slow bench jobs (laige_bench_render_50k + laige_bench_ac4, gcc, clang)" type: boolean default: false @@ -271,7 +279,7 @@ jobs: # gated run. run: | ctest --test-dir build --output-on-failure \ - -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32)$' + -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32|laige_bench_ac4_fpx16|laige_bench_ac4_fp32)$' linux-clang: name: Linux x64 (clang++) @@ -306,7 +314,7 @@ jobs: # gated run. run: | ctest --test-dir build --output-on-failure \ - -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32)$' + -E '^(laige-sim_tests|laige-render_tests|particle_render|laige_bench_render_50k_fpx16|laige_bench_render_50k_fp32|laige_bench_ac4_fpx16|laige_bench_ac4_fp32)$' linux-asan: name: Linux x64 ASan+UBSan (clang++) @@ -505,11 +513,11 @@ jobs: run: ctest --test-dir build --output-on-failure -R '^particle_render$' linux-gcc-bench-fpx16: - name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fpx16" + name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fpx16 + laige_bench_ac4_fpx16" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (577.05 s in the evidence - # run) plus runner variance. The pre-split 45-min exception minus - # the rest of the suite. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner + # variance. The pre-split 45-min exception minus the rest of the suite. timeout-minutes: 30 if: github.event.inputs.slow-bench-50k == 'true' steps: @@ -526,14 +534,14 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fpx16)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fpx16$' + - name: "Test (slow bench: render-50k + ac4, fpx16)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fpx16|laige_bench_ac4_fpx16)$' linux-gcc-bench-fp32: - name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fp32" + name: "Linux x64 (g++) slow bench: laige_bench_render_50k_fp32 + laige_bench_ac4_fp32" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (593.82 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: github.event.inputs.slow-bench-50k == 'true' steps: @@ -550,8 +558,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fp32)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fp32$' + - name: "Test (slow bench: render-50k + ac4, fp32)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fp32|laige_bench_ac4_fp32)$' linux-clang-sim: name: "Linux x64 (clang++) slow: laige-sim_tests" @@ -626,10 +634,10 @@ jobs: run: ctest --test-dir build --output-on-failure -R '^particle_render$' linux-clang-bench-fpx16: - name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fpx16" + name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fpx16 + laige_bench_ac4_fpx16" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (578.42 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: github.event.inputs.slow-bench-50k == 'true' steps: @@ -646,14 +654,14 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fpx16)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fpx16$' + - name: "Test (slow bench: render-50k + ac4, fpx16)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fpx16|laige_bench_ac4_fpx16)$' linux-clang-bench-fp32: - name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fp32" + name: "Linux x64 (clang++) slow bench: laige_bench_render_50k_fp32 + laige_bench_ac4_fp32" runs-on: ubuntu-24.04 # 30: build (~1.5 min) + the render-50k bench (566.52 s in the evidence - # run) plus runner variance. + # run) + the ac4 bench (estimated ~60 s, M2-AC-01) plus runner variance. timeout-minutes: 30 if: github.event.inputs.slow-bench-50k == 'true' steps: @@ -670,8 +678,8 @@ jobs: run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=clang++ - name: Build run: cmake --build build -j - - name: "Test (slow bench: laige_bench_render_50k_fp32)" - run: ctest --test-dir build --output-on-failure -R '^laige_bench_render_50k_fp32$' + - name: "Test (slow bench: render-50k + ac4, fp32)" + run: ctest --test-dir build --output-on-failure -R '^(laige_bench_render_50k_fp32|laige_bench_ac4_fp32)$' linux-asan-sim: name: "Linux x64 ASan+UBSan (clang++) slow: laige-sim_tests" diff --git a/budgets.json b/budgets.json index 18fd9ec..863eac9 100644 --- a/budgets.json +++ b/budgets.json @@ -42,6 +42,14 @@ "measured": 60.4450, "workload": "50k visible sprites (worst-case isometric overlap), 3 parallax layers, UI (PRD 8.1)" }, + { + "name": "sprites_10k_frame", + "metric": "mean", + "unit": "ms", + "target": 16.7, + "measured": 0, + "workload": "10 000 overlapping sprites (100 x 10 grid, 10 stacked per cell), whole frame sorted + drawn offscreen at 60 FPS (M2-AC-01 AC-4.3, PRD 4)" + }, { "name": "iso_depthkey_rebuild", "metric": "mean", diff --git a/docs/api/budget_harness.md b/docs/api/budget_harness.md index 3db282f..6b3b37c 100644 --- a/docs/api/budget_harness.md +++ b/docs/api/budget_harness.md @@ -49,12 +49,19 @@ The end-to-end form is the tool: - `--suite` — required; `synthetic` (the M0 harness stand-in), `sim-tick` (the M1-BENCH-01 PRD §8.1 workload: 10k entities, 2k dynamic bodies, 60 Hz, movement + per-tick state hash — its baseline: - `docs/benchmarks/baselines/m1-sim-tick.md`), or `render-50k` + `docs/benchmarks/baselines/m1-sim-tick.md`), `render-50k` (the M2-PERF-01 PRD §8.1 "50k visible sprites" workload: the FULL M2-SCENE-01 reference scene rendered offscreen at 1280x720, both SimMath backends — its baseline: - `docs/benchmarks/baselines/m2-render-50k.md`). `--list` prints the - suite table. + `docs/benchmarks/baselines/m2-render-50k.md`), or `ac4` / + `render-sort-10k` (the M2-AC-01 AC-4.x acceptance workload: 10k + overlapping sprites — a 100 x 10 grid, 10 stacked per cell — sorted + + drawn in one 60 FPS frame, both SimMath backends; the two names + select the same workload — `ac4` is the CI wiring's name + (`--suite=ac4`, `ctest -R ac4`), `render-sort-10k` the dedicated + suite name the AC-4.3 scope uses — its baseline: + `docs/benchmarks/baselines/m2-ac4.md`). `--list` prints the suite + table. - `--budget` — **repeatable**: one run checks every named budget (each entry's `metric` picks its statistic), printing one report per entry. A duplicate name is a usage error. Each named budget is @@ -62,12 +69,18 @@ The end-to-end form is the tool: generic iteration-timer histogram, except for a name a suite records itself — the render-50k suite records the batch-stage CPU window and the frame's draw-call count into two of its own - (`sprites_50k_cpu` and `sprites_50k_draw_calls`). The M1 gate form: - `--budget=sim_tick_avg --budget=sim_tick_p99`; the M2 gate form: - `--budget=sprites_50k_draw_calls --budget=sprites_50k_cpu`. -- `--math` — the SimMath backend (ADR 0002) the `sim-tick` and - `render-50k` suites run on; `fixed_point_16_16` (the default) or - `float_pinned_32`. Other suites ignore it. + (`sprites_50k_cpu` and `sprites_50k_draw_calls`), and the ac4 suite + records the whole frame's CPU time (batch stage + submit stage) + into its own `sprites_10k_frame` histogram — recorded only while an + offscreen GL 3.3 context is available (a CPU-only run leaves it + empty and the check fails `NO_SAMPLES`, loud). The M1 gate form: + `--budget=sim_tick_avg --budget=sim_tick_p99`; the M2 gate forms: + `--budget=sprites_50k_draw_calls --budget=sprites_50k_cpu` and + `--budget=sprites_10k_frame`. +- `--math` — the SimMath backend (ADR 0002) the `sim-tick`, + `render-50k`, and `ac4` / `render-sort-10k` suites run on; + `fixed_point_16_16` (the default) or `float_pinned_32`. Other + suites ignore it. - `--budgets` — the budgets file (default: `$LAIGE_BUDGETS_PATH`, then `budgets.json` in the working directory — the ctest gate entries set the env var to the repo root). @@ -76,8 +89,9 @@ The end-to-end form is the tool: Exit code 0 on pass, 2 on a failed budget check (any one of the named budgets failing fails the run — PRD 8.1 policy: a budget regression fails CI), 1 on usage/load errors or a broken workload contract -(the render-50k suite's zero-allocation assertion, a failed -declare/build/submit — the loud `simTickFail` shape). +(the render-50k and ac4 suites' zero-allocation assertions, a failed +declare/build/submit, the ac4 suite's AC-4.4 engine-default +assertion — the loud `simTickFail` shape). ## `laige::Histogram` @@ -245,7 +259,7 @@ is smaller than the run and then claiming "all samples"; using The harness measures wall-clock durations: it is *not* deterministic simulation state and plays no part in replay/lockstep (ARCH-010). All -three suites are deterministic by construction, which is what makes +five suites are deterministic by construction, which is what makes their baselines reproducible: `synthetic` (fixed LCG constants — Marsaglia 2003 — no RNG, no allocation; `docs/benchmarks/baselines/m0-synthetic.md`, M0-EXIT-01), `sim-tick` @@ -253,9 +267,15 @@ Marsaglia 2003 — no RNG, no allocation; no randomness in the measured path — the run's `final_hash` line is a bit-identical fingerprint across runs, build types, and compilers per the ADR 0002 scope; `docs/benchmarks/baselines/m1-sim-tick.md`, -M1-BENCH-01), and `render-50k` (the FULL M2-SCENE-01 reference scene at +M1-BENCH-01), `render-50k` (the FULL M2-SCENE-01 reference scene at fixed seed `0x1F055EED`, static camera, one particle update per frame — the scene's group structure is bit-identical per M2-SCENE-01, so the run's `draw_calls` line is a bit-identical fingerprint across runs, build types, and compilers; -`docs/benchmarks/baselines/m2-render-50k.md`, M2-PERF-01). +`docs/benchmarks/baselines/m2-render-50k.md`, M2-PERF-01), and +`ac4` / `render-sort-10k` (a fixed 10k-sprite workload built from its +index grid — no RNG at all — so the run's `ac4:` identity line, +including the frame's draw-call count and the default-projection +assertion, is a bit-identical fingerprint across runs, build types, +and compilers per the ADR 0002 scope; +`docs/benchmarks/baselines/m2-ac4.md`, M2-AC-01). diff --git a/docs/testing.md b/docs/testing.md index 028438b..dc53286 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -195,13 +195,29 @@ The fixtures (tests/golden/golden_fixtures.h, GL-free builders): (panel, button, text, image, slider) at fixed screen-px positions, the 4x4 UI atlas + the committed test font's 1024x1024 glyph atlas, the identity screen camera, the clear color (0, 0, 0, 255). +- `ac4_iso` / `ac4_side_view` — the M2-AC-01 AC-4.1 acceptance scenes: + the SAME 96-sprite world (the 12 x 8 grid at scale 1.0, four + (atlas, material, blend) groups of 24, the engine depth keys) + + the SAME 3-widget UI pass, differing only in the config struct — + `ac4_iso` with the engine-default Iso projection (the ADR 0005 + preset at k = 0.06) and `ac4_side_view` with the SideView plane + camera (`planeOrtho` at the same half extents and z slab). One + engine build renders both; the scene shape invariants are pinned + GL-free (the `GoldenFixtures.Ac4*` tests). Documented geometry + note: `SpriteItem` carries the 2D ground-plane position (no + elevation field), so the side-view frame's world pass is + degenerate by construction (all z = 0 sprites project to + zero-height quads under the z = 0 reference plane); the side-view + frame's visible content is the screen-space UI pass, identical in + both configs (the AC-4.1 "differing only in config" is asserted on + the scene, not on the world raster). -Both render at the canonical golden frame (1280x720, top-origin / +All four render at the canonical golden frame (1280x720, top-origin / y-down) with a fixed camera and no PRNG; the frame protocol is the -M2-SPRITE-02 offscreen one (clear, submit, one -`GlContext::readPixels` capture of the whole frame, rows flipped to -the screen convention). The scene shape invariants (item/quad -counts, groups, camera matrices, clear colors) are pinned by the +M2-SPRITE-02 offscreen one (clear, the declared passes in frame +order, one `GlContext::readPixels` capture of the whole frame, rows +flipped to the screen convention). The scene shape invariants (item/ +quad counts, groups, camera matrices, clear colors) are pinned by the GL-free `GoldenFixtures*` suite, ctest entry `golden_compare`. Tolerance contract (explicit, never hidden — the ctest entry passes diff --git a/tests/golden/golden_compare_tests.cpp b/tests/golden/golden_compare_tests.cpp index a08ca7d..e50e3d2 100644 --- a/tests/golden/golden_compare_tests.cpp +++ b/tests/golden/golden_compare_tests.cpp @@ -6,18 +6,21 @@ // max-channel test, the pixel budget, the frame pass/fail decision, // the bounded failing-pixel samples — TEST-008's explicit-tolerance // requirement), and the committed golden scene fixtures -// (golden_fixtures.h — the M2-SPRITE-02 and M2-UI-02 scenes' shape -// invariants at the canonical 1280x720 frame: the item/quad counts, -// the (atlas, material, blend) groups, the fixed camera matrices, the -// clear colors — the fixture drift a golden failure must never -// swallow silently). +// (golden_fixtures.h — the M2-SPRITE-02, M2-UI-02, and M2-AC-01 +// scenes' shape invariants at the canonical 1280x720 frame: the +// item/quad counts, the (atlas, material, blend) groups, the fixed +// camera matrices, the clear colors — the fixture drift a golden +// failure must never swallow silently). // // The suite map (the `golden_compare` CTest entry selects exactly // these; no GL anywhere — the offscreen render + PNG comparison is // the tool's job, ctest entry `golden`): // GoldenCompare the comparison core (the tolerance model + the // budget boundary + the sample bounds); -// GoldenFixtures the scene shape invariants (both fixtures). +// GoldenFixtures the scene shape invariants (all four fixtures — +// the Ac4* tests pin the M2-AC-01 AC-4.1 scenes: +// one world + one UI pass under the two projection +// configs, the configs differing in NOTHING else). // // The GoldenFixtures UI half reads the committed test font // (tests/laige-render/assets/vera.ttf — the LAIGE_TEST_FONT_PATH env @@ -51,9 +54,18 @@ using laige::testing::golden::Comparison; using laige::testing::golden::Tolerance; using laige::testing::golden::kGoldenHeight; using laige::testing::golden::kGoldenWidth; +using laige::testing::golden::Ac4Scene; +using laige::testing::golden::buildAc4Scene; using laige::testing::golden::buildSpriteScene; using laige::testing::golden::buildUiScene; using laige::testing::golden::compareFrames; +using laige::testing::golden::kAc4IsoConfig; +using laige::testing::golden::kAc4IsoScale; +using laige::testing::golden::kAc4SideConfig; +using laige::testing::golden::kAc4SideHalfHeight; +using laige::testing::golden::kAc4SideHalfWidth; +using laige::testing::golden::kAc4SideZFar; +using laige::testing::golden::kAc4SideZNear; using laige::testing::golden::uiAtlasRgba; // The test font (the house loadFont pattern, ui_render_tests.cpp). @@ -334,3 +346,159 @@ TEST(GoldenFixtures, UiSceneShape) { EXPECT_EQ(64u, uiAtlasRgba().size()); EXPECT_EQ(1024u * 1024u * 4u, scene.fontRgba.size()); } + +// --------------------------------------------------------------------------- +// The AC-4.1 fixture (M2-AC-01): one scene, two projection configs — +// the config is the only difference between the two fixtures (PRD +// AC-4.1), and the scene-shape invariants a golden failure must never +// swallow silently. +// --------------------------------------------------------------------------- + +// The world scene's shape invariants, shared by both configs: 96 +// sprites, the four (atlas, material, blend) groups of 24 each, the +// G-R11 depth keys, the three-widget UI pass (one group), the shared +// clear color. +void ac4SharedShape(Ac4Scene& scene) { + ASSERT_EQ(96u, scene.items.size()); + // The grid cell (x, y) = (-6, -4) is declared first (row-major, y + // then x). + EXPECT_EQ(-6.0f, scene.items.front().pos.x); + EXPECT_EQ(-4.0f, scene.items.front().pos.y); + // The G-R11 engine-owned key for that cell (step height 0, ground + // layer), bit-exact against the isoDepthKey builder. + using laige::sim::Fp32Pinned; + using laige::sim::SimMath; + EXPECT_EQ(laige::render::isoDepthKey( + SimMath::Vec2{-6.0f, -4.0f}, 0, 0), + scene.items.front().depthKey); + // The four (atlas, material, blend) groups: atlas 0 when (x + y) is + // even, Alpha when x is even — 24 instances each. + std::uint32_t g0a = 0, g0d = 0, g1a = 0, g1d = 0; + for (const SpriteItem& it : scene.items) { + if (it.atlasId == 0 && it.blend == BlendMode::Alpha) { + ++g0a; + } else if (it.atlasId == 0 && it.blend == BlendMode::Additive) { + ++g0d; + } else if (it.atlasId == 1 && it.blend == BlendMode::Alpha) { + ++g1a; + } else if (it.atlasId == 1 && it.blend == BlendMode::Additive) { + ++g1d; + } else { + ADD_FAILURE() << "unexpected (atlas, blend) pair"; + } + } + EXPECT_EQ(24u, g0a); + EXPECT_EQ(24u, g0d); + EXPECT_EQ(24u, g1a); + EXPECT_EQ(24u, g1d); + // The UI pass (identical in both configs): 3 quads, one group + // (7, 0, Alpha) — declared GL-free through the real UI pass. + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + SpriteBatcher batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE( + laige::render::declareUi(batcher, scene.table, scene.tree, + laige::render::UiDeclareOptions{}) + .ok()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(3u, batcher.frameCount()); + EXPECT_EQ(1u, batcher.batchCount()); + // The identity screen matrix at the canonical frame, bit-exact + // against the M2-UI-02 builder. + EXPECT_EQ(laige::render::uiScreenMatrix( + static_cast(kGoldenWidth), + static_cast(kGoldenHeight)), + scene.uiMatrix); + // The clear color (16, 24, 48 / 255) — shared by both configs. + EXPECT_FLOAT_EQ(16.0f / 255.0f, scene.clearR); + EXPECT_FLOAT_EQ(24.0f / 255.0f, scene.clearG); + EXPECT_FLOAT_EQ(48.0f / 255.0f, scene.clearB); + EXPECT_FLOAT_EQ(1.0f, scene.clearA); +} + +TEST(GoldenFixtures, Ac4SceneShapeIso) { + auto r = buildAc4Scene(kAc4IsoConfig); + ASSERT_TRUE(r.ok()); + Ac4Scene scene = std::move(r).takeValue(); + // The config: the engine-default iso mode (the ADR 0005 preset at + // the fixture scale). + EXPECT_EQ(laige::render::ProjectionMode::Iso, scene.config.mode); + EXPECT_FLOAT_EQ(kAc4IsoScale, scene.config.isoScale); + // The fixed camera: the ADR 0005 preset at kAc4IsoScale, bit-exact. + EXPECT_EQ(laige::render::isoDimetric2To1(kAc4IsoScale), scene.worldMatrix); + ac4SharedShape(scene); +} + +TEST(GoldenFixtures, Ac4SceneShapeSideView) { + auto r = buildAc4Scene(kAc4SideConfig); + ASSERT_TRUE(r.ok()); + Ac4Scene scene = std::move(r).takeValue(); + // The config: the side-view mode, the plane camera's parameters. + EXPECT_EQ(laige::render::ProjectionMode::SideView, scene.config.mode); + EXPECT_EQ(0.0f, scene.config.center.x); + EXPECT_EQ(0.0f, scene.config.center.y); + EXPECT_EQ(0.0f, scene.config.center.z); + EXPECT_FLOAT_EQ(kAc4SideHalfWidth, scene.config.halfWidth); + EXPECT_FLOAT_EQ(kAc4SideHalfHeight, scene.config.halfHeight); + EXPECT_FLOAT_EQ(kAc4SideZNear, scene.config.zNear); + EXPECT_FLOAT_EQ(kAc4SideZFar, scene.config.zFar); + // The fixed camera: the mode's documented base plane (right + // (1,0,0), up (0,0,1)), bit-exact. + EXPECT_EQ(laige::render::planeOrtho( + laige::render::Vec3{0.0f, 0.0f, 0.0f}, + laige::render::Vec3{1.0f, 0.0f, 0.0f}, + laige::render::Vec3{0.0f, 0.0f, 1.0f}, + kAc4SideHalfWidth, kAc4SideHalfHeight, kAc4SideZNear, + kAc4SideZFar), + scene.worldMatrix); + ac4SharedShape(scene); +} + +TEST(GoldenFixtures, Ac4ConfigsOnlyDiffer) { + auto ri = buildAc4Scene(kAc4IsoConfig); + auto rs = buildAc4Scene(kAc4SideConfig); + ASSERT_TRUE(ri.ok()); + ASSERT_TRUE(rs.ok()); + const Ac4Scene iso = std::move(ri).takeValue(); + const Ac4Scene side = std::move(rs).takeValue(); + // The ONLY difference is the config (PRD AC-4.1): the projection + // mode and its matrix-builder parameters. + EXPECT_EQ(laige::render::ProjectionMode::Iso, iso.config.mode); + EXPECT_EQ(laige::render::ProjectionMode::SideView, side.config.mode); + // The scene content is bit-identical (the items field by field — + // SpriteUvRect has no operator==, so field-wise). + ASSERT_EQ(iso.items.size(), side.items.size()); + for (std::size_t i = 0; i < iso.items.size(); ++i) { + const SpriteItem& a = iso.items[i]; + const SpriteItem& b = side.items[i]; + EXPECT_EQ(a.pos.x, b.pos.x); + EXPECT_EQ(a.pos.y, b.pos.y); + EXPECT_EQ(a.depthKey, b.depthKey); + EXPECT_EQ(a.uv.u0, b.uv.u0); + EXPECT_EQ(a.uv.v0, b.uv.v0); + EXPECT_EQ(a.uv.u1, b.uv.u1); + EXPECT_EQ(a.uv.v1, b.uv.v1); + EXPECT_EQ(a.scale.x, b.scale.x); + EXPECT_EQ(a.scale.y, b.scale.y); + EXPECT_EQ(a.rotation, b.rotation); + EXPECT_EQ(a.tint.r, b.tint.r); + EXPECT_EQ(a.tint.g, b.tint.g); + EXPECT_EQ(a.tint.b, b.tint.b); + EXPECT_EQ(a.tint.a, b.tint.a); + EXPECT_EQ(a.atlasId, b.atlasId); + EXPECT_EQ(a.materialId, b.materialId); + EXPECT_EQ(a.blend, b.blend); + } + EXPECT_EQ(iso.atlas0, side.atlas0); + EXPECT_EQ(iso.atlas1, side.atlas1); + EXPECT_EQ(iso.uiAtlas, side.uiAtlas); + EXPECT_EQ(iso.uiAtlasId, side.uiAtlasId); + EXPECT_EQ(iso.uiMatrix, side.uiMatrix); + EXPECT_EQ(iso.clearR, side.clearR); + EXPECT_EQ(iso.clearG, side.clearG); + EXPECT_EQ(iso.clearB, side.clearB); + EXPECT_EQ(iso.clearA, side.clearA); + // The config difference is visible: the world matrices differ. + EXPECT_NE(iso.worldMatrix, side.worldMatrix); +} diff --git a/tests/golden/golden_fixtures.h b/tests/golden/golden_fixtures.h index e937205..aa795ff 100644 --- a/tests/golden/golden_fixtures.h +++ b/tests/golden/golden_fixtures.h @@ -1,6 +1,6 @@ // laige-golden (M2-GOLD-01): the committed golden scene fixtures. // -// The first two fixtures of the golden infrastructure, both rendered +// The committed golden scene fixtures, all rendered // offscreen at the canonical golden frame (kGoldenWidth x // kGoldenHeight = 1280 x 720, top-origin / y-down) with a FIXED // camera and FIXED scene content — no PRNG at all (the "fixed seed" of @@ -31,6 +31,20 @@ // (0, 0, 0, 255). The widgets occupy the top-left // 128x128 region of the frame (screen px, y down); // the clear color fills the rest. +// ac4_iso the AC-4.1 scene (M2-AC-01) under the +// engine-default iso config (ProjectionMode::Iso, +// the ADR 0005 preset at kAc4IsoScale): the 96- +// sprite 12x8 grid world pass + the 3-widget UI +// pass (the two-pass frame protocol below). +// ac4_side_view the SAME scene under the side-view config +// (ProjectionMode::SideView, the plane camera with +// the mode's documented base axes) — the ONLY +// difference from ac4_iso is the config (PRD +// AC-4.1 "differing only in config"). The world +// sprites project to zero-height quads under the +// side-view matrix (the documented plane-camera +// geometry — the world pass's matrix is the only +// difference; the UI pass anchors the frame). // // The scene CONTENT is the smoke fixtures' content unchanged (the same // items, widgets, atlases, camera matrices — only the frame size and, @@ -57,6 +71,7 @@ #include "laige/render/iso_camera.h" #include "laige/render/iso_depth_key.h" #include "laige/render/matrices.h" +#include "laige/render/projection.h" #include "laige/render/sprite_batcher.h" #include "laige/render/text_items.h" #include "laige/render/ui_render.h" @@ -440,4 +455,263 @@ laige::Result buildUiScene( return laige::Result(std::move(scene)); } +// --------------------------------------------------------------------------- +// The AC-4.1 fixture (M2-AC-01): one scene, two projection configs. +// +// ac4_iso the scene below under the engine-default iso config +// (ProjectionMode::Iso, the ADR 0005 preset +// isoDimetric2To1 at kAc4IsoScale) +// ac4_side_view the SAME scene under the side-view config +// (ProjectionMode::SideView, planeOrtho with the +// mode's documented base: right (1,0,0), up (0,0,1)) +// +// "Differing only in config" (PRD AC-4.1) in executable form: the +// scene CONTENT is bit-identical between the two fixtures (the 96 +// world sprites, the 3 UI widgets, the atlases, the clear color) — +// only the Ac4SceneConfig (the projection mode + the parameters of the +// matrix builder it documents) differs. Both fixtures render +// offscreen through the SAME laige-golden binary — one world pass +// under the config's matrix, then one UI pass under the identity +// screen matrix (the M2-SPRITE-02 / M2-UI-02 two-pass frame protocol) +// — and are golden-verified against their committed references. +// +// The world scene: the 12 x 8 grid of world cells (x, y) in -6..5 x +// -4..3 (96 sprites, one per cell), each a 1x1 world tile (scale +// (1,1), the full-atlas uv), atlas 0 when (x + y) is even else atlas +// 1, Alpha when x is even else Additive — the batcher's four +// (atlas, material, blend) groups of 24 instances each (2 texture +// binds, 3 blend changes), depth keys through isoDepthKey (step +// height 0, the ground layer — the G-R11 engine-owned key). +// +// The UI scene: the three widgets (the opaque red panel, the green +// button, the blue image) at their fixed screen-px positions on the +// canonical-frame tree rect, the 4x4 UI atlas, the identity screen +// matrix, the null default font (no pass-generated text — the button +// declares its art quad only). +// +// The side-view geometry (projection.h, documented): the side-view +// plane camera's screen y is the world HEIGHT (+z), and the sprite +// pass projects the world point (x, y, z = 0) (the shader's world +// plane), so the 96 sprites project to ZERO-HEIGHT quads under the +// side-view matrix: zero-area primitives are not rasterized, the +// side-view frame's world pass contributes no visible texels, and +// the frame's visible content is the screen-space UI pass (identical +// in both configs — the world matrix is the ONLY config difference). +// The fixture still exercises the side-view matrix end to end (the +// matrix build, the world submit, the degenerate-quad handling, the +// deterministic output). A populated platformer frame needs +// elevation in the content model (world z in the sprite path) — not +// an M2 engine-surface change; the M2 SpriteItem's 2D position is the +// ground plane, and the elevation lives in the depth key (the sort +// value, not a render offset). +// --------------------------------------------------------------------------- + +// The projection config of one AC-4.1 fixture: the ONLY field that may +// differ between the two fixtures (the PRD AC-4.1 "differing only in +// config"). +struct Ac4SceneConfig { + laige::render::ProjectionMode mode; + // Iso: the ADR 0005 preset scale (isoDimetric2To1). + float isoScale{0.0f}; + // SideView: the plane camera's parameters (planeOrtho, the mode's + // documented base — the right/up axes are fixed by the mode). + laige::render::Vec3 center{0.0f, 0.0f, 0.0f}; + float halfWidth{1.0f}; + float halfHeight{1.0f}; + float zNear{-1.0f}; + float zFar{1.0f}; +}; + +// The iso config's preset scale, sized so the 96-sprite grid's quads +// fit the canonical frame: over the quad corners (x' in -6.5..5.5, +// y' in -4.5..3.5) the matrix's NDC x = s(2x' - y') spans [-16.5s, +// 15.5s] and NDC y = -s(x' + y') spans [-9s, 11s]; at s = 0.06 both +// are inside [-1, 1] with margin (NDC x in [-0.99, 0.93], NDC y in +// [-0.54, 0.66] — pixel bounds 7..1245 x, 122..555 y at 1280x720). +inline constexpr float kAc4IsoScale = 0.06f; + +// The side-view plane camera (the scene's x span [-6.5, 5.5] needs +// halfWidth > 5.5 — 8 with margin; the z = 0 sprites' slab: viewZ = +// -(y - 0) - zNear = 4 - y spans [1, 8] over the scene's y span +// [-4, 3], inside the slab [zNear, zFar] = [-4, 12], so no sprite is +// z-clipped). +inline constexpr float kAc4SideHalfWidth = 8.0f; +inline constexpr float kAc4SideHalfHeight = 4.0f; +inline constexpr float kAc4SideZNear = -4.0f; +inline constexpr float kAc4SideZFar = 12.0f; + +// The two fixture configs (the only difference between the fixtures). +inline const Ac4SceneConfig kAc4IsoConfig = Ac4SceneConfig{ + laige::render::ProjectionMode::Iso, kAc4IsoScale, + laige::render::Vec3{0.0f, 0.0f, 0.0f}, + kAc4SideHalfWidth, kAc4SideHalfHeight, kAc4SideZNear, kAc4SideZFar}; +inline const Ac4SceneConfig kAc4SideConfig = Ac4SceneConfig{ + laige::render::ProjectionMode::SideView, 0.0f, + laige::render::Vec3{0.0f, 0.0f, 0.0f}, + kAc4SideHalfWidth, kAc4SideHalfHeight, kAc4SideZNear, kAc4SideZFar}; + +// The config's world matrix: the mode's documented builder (the +// ProjectionView contract — the view's matrix IS the builder output). +// The caller has validated the config (the AC-4.1 domain: Iso and +// SideView only). +[[nodiscard]] laige::render::Mat4 ac4WorldMatrix(const Ac4SceneConfig& cfg) { + if (cfg.mode == laige::render::ProjectionMode::Iso) { + return laige::render::isoDimetric2To1(cfg.isoScale); + } + return laige::render::planeOrtho( + cfg.center, laige::render::Vec3{1.0f, 0.0f, 0.0f}, + laige::render::Vec3{0.0f, 0.0f, 1.0f}, cfg.halfWidth, cfg.halfHeight, + cfg.zNear, cfg.zFar); +} + +// The AC-4.1 world scene in declaration order (the grid's row-major +// order, y then x — deterministic; 96 items). +std::vector buildAc4WorldItems() { + using laige::render::BlendMode; + using laige::render::SpriteItem; + using laige::render::isoDepthKey; + using laige::sim::Fp32Pinned; + using laige::sim::SimMath; + + auto cellKey = [](std::int32_t x, std::int32_t y) { + return isoDepthKey( + SimMath::Vec2{static_cast(x), + static_cast(y)}, + /*stepHeight=*/0, /*layer=*/0); + }; + + std::vector items; + items.reserve(96); + for (std::int32_t y = -4; y <= 3; ++y) { + for (std::int32_t x = -6; x <= 5; ++x) { + const std::int32_t gx = x + 6; // the 0-based grid column + const std::int32_t gy = y + 4; // the 0-based grid row + SpriteItem it; + it.pos = {static_cast(x), static_cast(y)}; + it.depthKey = cellKey(x, y); + it.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + it.scale = {1.0f, 1.0f}; + it.rotation = 0.0f; + it.tint = {1.0f, 1.0f, 1.0f, 1.0f}; + it.atlasId = ((gx + gy) % 2 == 0) ? kSpriteAtlas0Id : kSpriteAtlas1Id; + it.materialId = 0; + it.blend = (gx % 2 == 0) ? BlendMode::Alpha : BlendMode::Additive; + items.push_back(it); + } + } + return items; +} + +// The complete AC-4.1 scene under one config (GL-free: the items, the +// atlases, the world matrix, the UI pass, the clear color). Move-only +// (the tree, the table). +struct Ac4Scene { + Ac4SceneConfig config; + std::vector items; // 96, declaration order + laige::render::Mat4 worldMatrix; // the config's world -> NDC matrix + std::array atlas0; // 4x4 checkerboard (the sprite + // fixture's atlas 0) + std::array atlas1; // 4x4 solid (the sprite fixture's + // atlas 1) + // The UI pass (identical in both configs). + laige::render::StringTable table; // empty (no pass-generated text) + laige::render::UiTree tree; // the canonical-frame screen rect + std::array uiAtlas; // the 4x4 UI atlas + laige::render::Mat4 uiMatrix; // uiScreenMatrix(1280, 720) + std::uint32_t uiAtlasId{kUiAtlasId}; + // The clear color (identical in both configs): bytes 16, 24, 48. + float clearR{16.0f / 255.0f}; + float clearG{24.0f / 255.0f}; + float clearB{48.0f / 255.0f}; + float clearA{1.0f}; +}; + +laige::Result buildAc4Scene(const Ac4SceneConfig& config) { + using laige::render::UiTree; + using laige::render::UiWidgetKind; + using laige::render::UiWidgetSpec; + using laige::render::SpriteUvRect; + using laige::render::StringTable; + + // The AC-4.1 config domain: the two fixture modes only (a config + // outside it is a caller bug — a loud InvalidArgument, nothing + // built). + if (config.mode != laige::render::ProjectionMode::Iso && + config.mode != laige::render::ProjectionMode::SideView) { + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + + Ac4Scene scene; + scene.config = config; + scene.worldMatrix = ac4WorldMatrix(config); + scene.items = buildAc4WorldItems(); + scene.atlas0 = spriteAtlas0Bytes(); + scene.atlas1 = spriteAtlas1Bytes(); + scene.uiAtlas = uiAtlasRgba(); + + auto tableR = StringTable::create(StringTable::Options{16, 64}); + if (tableR.isError()) { + return laige::Result::failure(tableR.error()); + } + scene.table = std::move(tableR).takeValue(); + + auto treeR = UiTree::create(UiTree::Options{ + 64, 16, static_cast(kGoldenWidth), + static_cast(kGoldenHeight)}); + if (treeR.isError()) { + return laige::Result::failure(treeR.error()); + } + scene.tree = std::move(treeR).takeValue(); + + // P opaque Panel (10, 10, 40, 30) uv texel (0,0) red atlas 7 + UiWidgetSpec s; + s.kind = UiWidgetKind::Panel; + s.opaque = true; + s.x = 10; + s.y = 10; + s.width = 40; + s.height = 30; + s.image = SpriteUvRect{0.0f, 0.0f, 0.25f, 0.25f}; + s.atlasId = kUiAtlasId; + if (!scene.tree.addChild(s).ok()) { + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + + // B Button (16, 16, 20, 14) uv texel (1,0) green no label + // (the null default font turns the pass-generated text off — + // the art quad only) + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Button; + s.x = 16; + s.y = 16; + s.width = 20; + s.height = 14; + s.image = SpriteUvRect{0.25f, 0.0f, 0.5f, 0.25f}; + s.atlasId = kUiAtlasId; + if (!scene.tree.addChild(s).ok()) { + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + + // I Image (80, 40, 16, 16) uv texel (2,0) blue + s = UiWidgetSpec{}; + s.kind = UiWidgetKind::Image; + s.x = 80; + s.y = 40; + s.width = 16; + s.height = 16; + s.image = SpriteUvRect{0.5f, 0.0f, 0.75f, 0.25f}; + s.atlasId = kUiAtlasId; + if (!scene.tree.addChild(s).ok()) { + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + + if (!scene.tree.layout().ok()) { + return laige::Result::failure(laige::ErrorCode::InvalidArgument); + } + scene.uiMatrix = laige::render::uiScreenMatrix( + static_cast(kGoldenWidth), + static_cast(kGoldenHeight)); + return laige::Result(std::move(scene)); +} + } // namespace laige::testing::golden diff --git a/tests/laige-core/budget_harness_tests.cpp b/tests/laige-core/budget_harness_tests.cpp index c963321..6401932 100644 --- a/tests/laige-core/budget_harness_tests.cpp +++ b/tests/laige-core/budget_harness_tests.cpp @@ -457,11 +457,12 @@ TEST(BudgetHarnessTable, LoadsTheRepoBudgetsFile) { ASSERT_TRUE(table.ok()) << laige::errorText(table.error()); const BudgetTable& t = table.value(); - // One entry per PRD 8.1 target (17 rows: sim tick, 50k sprites, + // One entry per PRD 8.1 target (18 rows: sim tick, 50k sprites, // cold start, build time, and zone server each carry two budgets, // plus the single-budget M2 render targets — iso_depthkey_rebuild, - // iso_picking, depth_sort_10k, particle_render_10k). - EXPECT_EQ(std::size_t(17), t.size()); + // iso_picking, depth_sort_10k, particle_render_10k, and + // sprites_10k_frame (M2-AC-01 AC-4.3's 60 FPS frame budget)). + EXPECT_EQ(std::size_t(18), t.size()); const BudgetEntry* frame = t.find("frame_time_render"); ASSERT_NE(frame, nullptr); diff --git a/tests/tools/CMakeLists.txt b/tests/tools/CMakeLists.txt index e0a9ead..688d1f9 100644 --- a/tests/tools/CMakeLists.txt +++ b/tests/tools/CMakeLists.txt @@ -198,6 +198,32 @@ function(laige_add_lint_test name expect_exit root) endforeach() set(PYTHON "${LAIGE_LINT_PYTHON}") set(LINT "${CMAKE_SOURCE_DIR}/tools/laige-include-lint") + set(LINT_NAME "laige-include-lint") + set(ROOT "${root}") + set(EXPECT_EXIT "${expect_exit}") + set(CHECKS "${_checks}") + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-lint-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") +endfunction() + +# The same generated-check pattern, pointed at the AC-4.2 surface lint +# (M2-AC-01: no ProjectionMode in the laige-sim/laige-net public +# surface; the companion of the include-lint R2 rule above). +function(laige_add_surface_lint_test name expect_exit root) + set(_checks "") + foreach(_needle IN LISTS ARGN) + string(REPLACE "\\" "\\\\" _esc "${_needle}") + string(REPLACE "\"" "\\\"" _esc "${_esc}") + string(APPEND _checks + "if(NOT _text MATCHES \"${_esc}\")\n" + " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" + "endif()\n") + endforeach() + set(PYTHON "${LAIGE_LINT_PYTHON}") + set(LINT "${CMAKE_SOURCE_DIR}/tools/laige-surface-lint") + set(LINT_NAME "laige-surface-lint") set(ROOT "${root}") set(EXPECT_EXIT "${expect_exit}") set(CHECKS "${_checks}") @@ -223,6 +249,54 @@ laige_add_lint_test(include-lint-sim-to-render 1 "${FX}/sim-to-render" laige_add_lint_test(include-lint-real-tree 0 "${CMAKE_SOURCE_DIR}" "laige-include-lint: OK") +# =========================================================================== +# surface-lint tests (M2-AC-01, AC-4.2) +# +# tools/laige-surface-lint scans the PUBLIC surface of laige-sim and +# laige-net for the render-side projection type ProjectionMode (the +# surface half of AC-4.2; the include-graph half is the include-lint +# R2 rule, the sim-to-render fixture above). Same fixture + +# generated-`cmake -P` pattern. +# =========================================================================== + +set(FXS "${CMAKE_BINARY_DIR}/surface-lint-fixtures") +file(REMOVE_RECURSE "${FXS}") + +# --- fixture: clean sim + net public headers (expect exit 0) ----------- +file(MAKE_DIRECTORY "${FXS}/clean/src/laige-sim/include/laige/sim") +file(MAKE_DIRECTORY "${FXS}/clean/src/laige-net/include/laige/net") +file(WRITE "${FXS}/clean/src/laige-sim/include/laige/sim/s.h" "#pragma once +// A sim concept that must not mention the projection type. +enum class Mode { A, B }; +") +file(WRITE "${FXS}/clean/src/laige-net/include/laige/net/n.h" "#pragma once +// The net module is README-only in the real tree (no CMake yet); +// the fixture gives it one header. +struct Packet { int id; }; +") + +# --- fixture: ProjectionMode in the sim public surface (expect 1) ----- +file(MAKE_DIRECTORY "${FXS}/violation/src/laige-sim/include/laige/sim") +file(WRITE "${FXS}/violation/src/laige-sim/include/laige/sim/pick.h" "#pragma once +// AC-4.2 violation: the projection type leaks into the sim surface. +enum class ProjectionMode { Iso, SideView }; +") + +# --- fixture: sim has code but no public-header root (expect 2) -------- +file(MAKE_DIRECTORY "${FXS}/structural/src/laige-sim") +file(WRITE "${FXS}/structural/src/laige-sim/sim.cpp" "#include +") + +# (name, expected exit, tree, required output fragments...) +laige_add_surface_lint_test(ac4_surface_clean 0 "${FXS}/clean" + "laige-surface-lint: OK") +laige_add_surface_lint_test(ac4_surface_violation 1 "${FXS}/violation" + "laige-surface-lint: FAIL" "'ProjectionMode'") +laige_add_surface_lint_test(ac4_surface_structural 2 "${FXS}/structural" + "no public-header root") +laige_add_surface_lint_test(ac4_surface 0 "${CMAKE_SOURCE_DIR}" + "laige-surface-lint: OK") + # =========================================================================== # determinism-source-scan tests (M1-DET-01) # diff --git a/tests/tools/expect-lint-result.cmake.in b/tests/tools/expect-lint-result.cmake.in index 6ebd14f..e5752e2 100644 --- a/tests/tools/expect-lint-result.cmake.in +++ b/tests/tools/expect-lint-result.cmake.in @@ -35,7 +35,7 @@ endif() @CHECKS@ if(NOT _problems STREQUAL "") message(FATAL_ERROR - "laige-include-lint check failed [${_problems}]\n" + "@LINT_NAME@ check failed [${_problems}]\n" "--- lint output ---\n${_text}\n--- end of lint output ---") endif() -message("laige-include-lint check OK: exit @EXPECT_EXIT@, required output present") +message("@LINT_NAME@ check OK: exit @EXPECT_EXIT@, required output present") diff --git a/tools/README.md b/tools/README.md index 3abef02..d5236fe 100644 --- a/tools/README.md +++ b/tools/README.md @@ -51,6 +51,19 @@ Engine tools and CI scripts, each landing with its roadmap step: `tests/tools` (fixture trees + the real tree). Scope, rules, and the exception policy: [docs/concepts/determinism.md](../docs/concepts/determinism.md). +- `laige-surface-lint` — projection-surface lint (M2-AC-01 AC-4.2). + Pure Python 3 stdlib; run it as `python3 tools/laige-surface-lint + [--root REPO_ROOT]`. The AC-4.2 proof that no simulation, physics, + pathing, or network code depends on the projection mode: the + public surface of `laige-sim` and `laige-net` (their `include/` + trees) MUST NOT mention `ProjectionMode` at all (RENDER-006). + Exit codes: `0` OK (prints the scanned header count) · `1` a + `ProjectionMode` mention in a public header (each hit printed as + `rel/path:line: text`) · `2` structural (a module has source but + no public-header root). Runs in CI on every PR and merge as the + CTest entries `ac4_surface*` in `tests/tools` (fixture trees for + each exit path + the real tree — the `laige-include-lint` CTest + pattern). - `laige-api` — public API manifest generator (M0-TOOL-01) - `laige-detcheck` — determinism checker skeleton (M0-TOOL-02, in `tools/detcheck`): runs a named scenario in two build configurations @@ -66,9 +79,10 @@ Engine tools and CI scripts, each landing with its roadmap step: runs the CLI determinism gate (ctest entry `scene_iso_cli`) - `laige-golden` — the golden image test tool (M2-GOLD-01, in `tools/golden`): renders the committed golden scene fixtures - (the M2-SPRITE-02 sprite scene + the M2-UI-02 UI scene) - offscreen at the canonical 1280x720 frame (fixed camera, no - PRNG), captures the frame (one `GlContext::readPixels`), and + (the M2-SPRITE-02 sprite scene, the M2-UI-02 UI scene, and the + M2-AC-01 `ac4_iso` / `ac4_side_view` acceptance scenes — one + engine build, config-only difference) offscreen at the canonical + 1280x720 frame (fixed camera, no PRNG), captures the frame (one `GlContext::readPixels`), and compares it against the committed reference PNG (`tools/golden/references`) with the documented tolerance (max channel diff 2 + 0.001% pixel budget — passed as explicit diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index e555887..da69a04 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -13,17 +13,20 @@ # Suites: `synthetic` (the M0 harness stand-in — laige-core only), # `sim-tick` (the M1-BENCH-01 PRD 8.1 workload — drives the laige-sim # World/systems/GameLoop: 10k entities, 2k dynamic bodies, 60 Hz, -# BenchMove + BenchHash systems, both SimMath backends), and +# BenchMove + BenchHash systems, both SimMath backends), # `render-50k` (the M2-PERF-01 PRD 8.1 "50k visible sprites" workload — # the FULL M2-SCENE-01 reference scene rendered offscreen through the -# batcher + submit stages, both SimMath backends). +# batcher + submit stages, both SimMath backends), and `ac4` / +# `render-sort-10k` (the M2-AC-01 AC-4.x acceptance workload — 10k +# overlapping sprites sorted + drawn at 60 FPS, both SimMath backends). add_executable(laige-bench laige-bench.cpp) laige_apply_engine_policy(laige-bench) -# The sim-tick and render-50k suites carry deterministic sim math -# (the BenchMove systems and the reference scene's fixed-point depth -# keys run on the SimMath op surface) — the ADR 0002 pinned flag set -# applies to the TU exactly as to every sim target. +# The sim-tick, render-50k, and ac4 suites carry deterministic sim +# math (the BenchMove systems, the reference scene's fixed-point depth +# keys, and the ac4 workload's per-backend isoDepthKey runs on the +# SimMath op surface) — the ADR 0002 pinned flag set applies to the TU +# exactly as to every sim target. laige_apply_simmath_policy(laige-bench) target_link_libraries(laige-bench PRIVATE laige-core laige-sim laige-render) @@ -120,6 +123,44 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") TIMEOUT 1800) endif() +# The M2-AC-01 CI gate (PRD §4 AC-4.3): the 10 000-overlapping-sprite +# workload (the 100 x 10 grid, 10 sprites stacked per cell — the +# sort's equal-key ties + the frame's overdraw) sorted + drawn at +# 60 FPS, both SimMath backends, in the canonical 3000-run / +# 1000-warmup shape, checked against the `sprites_10k_frame` budget +# (the whole frame's CPU ms, mean <= 16.7 ms); the suite's +# zero-allocation assertion (M1-ALLOC-01 extended to the batcher) +# aborts the run on the first allocating frame (exit 1, CORE-008), and +# the AC-4.4 engine-default assert (the default ProjectionView's mode +# is Iso) runs in setup. Platform scope as the render-50k gate: the +# absolute target is calibrated on the CI reference machine +# (methodology §5 — the P0 Linux jobs, ubuntu-24.04); the entry is +# Linux-only because the frame sample needs an offscreen GL 3.3 +# context (the CI's Mesa surfaceless EGL + llvmpipe stack, M2-GL-01 — +# a CPU-only run would leave the frame histogram empty and fail +# NO_SAMPLES, not skip, and the macOS runners have no usable GL) and +# the workload's 4000 x 10k-quad frames would not fit the other +# lanes' time budgets. Excluded from the sanitizer trees: +# instrumentation inflates the absolute cost (the m1-profiler-cost +# precedent). The 1800 s timeout bounds 4000 offscreen frames of the +# 10k-quad frame with wide margin (the render-50k gate's shape). +# `ctest -R ac4` selects these two entries together with the +# ac4_surface* lint entries (the AC-4.x suite in one ctest selection). +if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME laige_bench_ac4_fpx16 + COMMAND laige-bench --suite=ac4 --math=fixed_point_16_16 + --runs=3000 --warmup=1000 + --budget=sprites_10k_frame) + add_test(NAME laige_bench_ac4_fp32 + COMMAND laige-bench --suite=ac4 --math=float_pinned_32 + --runs=3000 --warmup=1000 + --budget=sprites_10k_frame) + set_tests_properties(laige_bench_ac4_fpx16 + laige_bench_ac4_fp32 + PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" + TIMEOUT 1800) +endif() + # All-platform sim-tick smoke (no budget gate): the sim-tick workload # in its canonical 4000-tick shape (the default fpx16_16 backend), run # in EVERY P0 platform's ctest — including the platforms where the diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 8ad18ee..8a23d1f 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -15,8 +15,8 @@ // and the pass/fail reports are printed; a failed check exits // non-zero so the command is CI-gateable (PRD 8.1 policy: a budget // regression fails CI). `--math` selects the SimMath backend (ADR -// 0002) the `sim-tick` and `render-50k` suites run on; the other -// suites ignore it. +// 0002) the `sim-tick`, `render-50k`, and `ac4` / `render-sort-10k` +// suites run on; the other suites ignore it. // // Exit codes: // 0 ok (and every budget check passed, when --budget was given) @@ -79,6 +79,22 @@ // the draw-call histogram stays empty (a named check // against it fails NO_SAMPLES, loud per CORE-008). // Its baseline: docs/benchmarks/baselines/m2-render-50k.md. +// ac4 M2-AC-01's AC-4.x acceptance workload (the dedicated +// AC-4.3 suite under the alias `render-sort-10k` — +// two entries, one workload): 10 000 overlapping +// sprites (a 100 x 10 cell grid, 10 sprites stacked +// per cell at the same position and step height — the +// sort's equal-key ties + the frame's overdraw), +// scale 1.0, two atlases x two blends = four +// (atlas, material, blend) groups, sorted and drawn in +// one 60 FPS frame (the `sprites_10k_frame` sample = +// the whole frame's CPU ms, batch stage + submit +// stage, 16.7 ms budget) with the batcher zero- +// allocation assertion; setup additionally asserts the +// AC-4.4 engine default (the default ProjectionView's +// mode is Iso — ADR 0005). Both SimMath backends +// (--math). Its baseline: +// docs/benchmarks/baselines/m2-ac4.md. // // The synthetic suite is a deterministic, allocation-free stand-in // workload; the sim-tick suite is deterministic too (fixed seed @@ -96,6 +112,7 @@ #include #include #include +#include #include #include @@ -104,7 +121,9 @@ #include "laige/errors.h" #include "laige/render/gl_context.h" #include "laige/render/iso_camera.h" +#include "laige/render/iso_depth_key.h" #include "laige/render/iso_reference_scene.h" +#include "laige/render/projection.h" #include "laige/render/sprite_batcher.h" #include "laige/render/sprite_renderer.h" #include "laige/render/ui_render.h" @@ -846,6 +865,319 @@ void render50Teardown() { } } +// --- The ac4 suite (M2-AC-01: PRD §4 AC-4.3 + AC-4.4, budgets +// sprites_10k_frame) ------------------------------------------------------ +// +// The AC-4.3 workload: 10 000 overlapping sprites sorted and drawn in +// one 60 FPS frame (16.7 ms, the §8.1 60 FPS frame budget). The sprites +// are stacked K deep over a 100 x 10 cell grid: 1000 cells at their +// centers (gx - 49.5, gy - 4.5), 10 sprites per cell at the SAME +// position and step height ((gx >> 4) + (gy >> 4), 0..6 — the G-R11 +// engine-owned isoDepthKey, 10-way equal-key ties per stack, broken by +// declaration order per RENDER-003), scale 1.0, atlas k mod 2, Alpha +// for k < 5 else Additive: four (atlas, material, blend) groups — +// 3000 / 2000 / 2000 / 3000 instances — so the frame is exactly four +// instanced draw calls (RENDER-001). The grid's 1x1 tiles edge-touch on +// screen and each stack overlaps itself 10 times: the sort's +// equal-key ties AND the overdraw are both exercised. +// +// One measured iteration = one frame (the AC-4.3 "sorted + drawn at +// 60 FPS" — the whole frame, batch stage + submit stage): the batch +// stage (beginFrame + 10 000 adds + build) with the M1-ALLOC-01 +// zero-allocation watch armed around it (a frame that allocates +// aborts, the render-50k batch-stage assertion's shape), then the +// submit stage's offscreen render. The `sprites_10k_frame` sample is +// the whole frame's CPU ms; it is recorded only when an offscreen GL +// 3.3 context is available — on a host without one the frame histogram +// stays empty and a named budget check against it fails NO_SAMPLES +// (loud, the render-50k draw-call precedent; the batch-stage +// zero-allocation still measures CPU-only). +// +// The AC-4.4 engine-default assertion lives in setup (the suite is the +// "asserted here" of the AC-4.4 scope): ProjectionView{}'s default +// mode MUST be Iso (ADR 0005) — a default drift is a loud failure, +// not a baseline shift. The world matrix is the Iso default's +// documented builder (isoDimetric2To1) at the grid-fit scale. +// +// The `render-sort-10k` suite entry is the same workload under the +// dedicated suite name the AC-4.3 scope uses; `ac4` is the acceptance +// name the CI wiring uses (--suite=ac4, ctest -R ac4). + +constexpr std::uint32_t kAc4Sprites = 10000; // the AC-4.3 sprite count +constexpr std::uint32_t kAc4GridCols = 100; // the x cell count +constexpr std::uint32_t kAc4GridRows = 10; // the y cell count +constexpr std::uint32_t kAc4StackPerCell = 10; // sprites stacked per cell +constexpr float kAc4BenchIsoScale = 1.0f / 128.0f; // the grid's NDC fit + // NDC x = s(2x - y) in + // [-0.83203125, 0.83203125], + // NDC y = -s(x + y) in + // [-0.4375, 0.4375], over the + // 1x1 tiles' quad corners + // (x' in [-50.5, 50.5], + // y' in [-5.5, 5.5]) +constexpr float kAc4SpriteScale = 1.0f; // the 1x1 world tile + +// The float cell center -> the backend's Vec2 (the RENDER-006 load +// boundary, the iso_reference_scene.h isoRefBackendVec2 pattern): +// exact for the workload's dyadic half-integer centers in both +// backends (|v| < 2^23, Q16.16 exact). +template +typename laige::sim::SimMath::Vec2 ac4BackendVec2(float x, + float y) { + using laige::sim::Fp32Pinned; + if constexpr (std::is_same_v) { + return typename laige::sim::SimMath::Vec2{x, y}; + } else { + return typename laige::sim::SimMath::Vec2{ + laige::fpx16_16::fromFloat(x), laige::fpx16_16::fromFloat(y)}; + } +} + +// The workload's items for one SimMath backend (the depth keys are +// backend-quantized — the per-backend state, the render-50k shape). +template +std::vector ac4Items() { + using laige::render::BlendMode; + using laige::render::SpriteItem; + using laige::render::isoDepthKey; + + std::vector items; + items.reserve(kAc4Sprites); + for (std::uint32_t gy = 0; gy < kAc4GridRows; ++gy) { + for (std::uint32_t gx = 0; gx < kAc4GridCols; ++gx) { + const float x = + static_cast(gx) - static_cast(kAc4GridCols) / 2.0f + + 0.5f; // the cell center, in -49.5..49.5 + const float y = + static_cast(gy) - static_cast(kAc4GridRows) / 2.0f + + 0.5f; // the cell center, in -4.5..4.5 + const int stepHeight = + static_cast(gx >> 4) + static_cast(gy >> 4); + for (std::uint32_t k = 0; k < kAc4StackPerCell; ++k) { + SpriteItem it; + it.pos = {x, y}; + it.depthKey = isoDepthKey(ac4BackendVec2(x, y), + stepHeight, /*layer=*/0); + it.uv = {0.0f, 0.0f, 1.0f, 1.0f}; + it.scale = {kAc4SpriteScale, kAc4SpriteScale}; + it.rotation = 0.0f; + it.tint = {1.0f, 1.0f, 1.0f, 1.0f}; + it.atlasId = k % 2u; + it.materialId = 0; + it.blend = (k < 5u) ? BlendMode::Alpha : BlendMode::Additive; + items.push_back(it); + } + } + } + return items; +} + +struct Ac4State { + std::vector items; + laige::render::Mat4 worldToNdc{}; // built once (the static matrix) + bool glAvailable = true; // false: CPU-only mode (no offscreen GL) + // The GL resources live AFTER the context in declaration order only + // so the REVERSED destruction order destroys them first (the + // renderer holds a non-owning context view). + laige::render::GlContext ctx; + std::unique_ptr batcher; + std::unique_ptr renderer; +}; + +Ac4State g_ac4Fpx; +Ac4State g_ac4Fp32; +bool g_ac4FpxActive = false; + +// The suite's quantities (the section header): the whole frame's CPU +// ms (batch + submit) and the frame's draw-call count. Both record on +// EVERY iteration (warmup included) and only while GL is available +// (a CPU-only run leaves them empty — the NO_SAMPLES loud-fail path). +struct Ac4Histograms { + std::unique_ptr frame; + std::unique_ptr drawCalls; +}; +Ac4Histograms g_ac4Hists; + +const laige::Histogram* ac4HistogramFor(std::string_view name) { + if (name == "sprites_10k_frame") return g_ac4Hists.frame.get(); + return nullptr; +} + +[[noreturn]] void ac4Fail(const std::string& what) { + std::fprintf(stderr, "laige-bench: ac4 failed: %s\n", what.c_str()); + std::fflush(stderr); + std::exit(1); +} + +template +bool ac4SetupImpl(const Config& cfg, Ac4State& st) { + // The AC-4.4 engine-default assertion (asserted HERE, the AC-4.4 + // scope's wording): the default ProjectionView's mode is Iso (ADR + // 0005) — a default drift breaks new-project templates (the + // isometric-first-class set) and is a loud failure. + const laige::render::ProjectionView view{}; + if (view.mode != laige::render::ProjectionMode::Iso) { + ac4Fail("the engine's default projection is not Iso (AC-4.4: the " + "default ProjectionView.mode drifted from ProjectionMode::Iso)"); + } + + st.items = ac4Items(); + st.worldToNdc = laige::render::isoDimetric2To1(kAc4BenchIsoScale); + + { + laige::render::SpriteBatcher::Options o{}; + o.maxSprites = kAc4Sprites; + auto r = laige::render::SpriteBatcher::create(o); + if (!r.ok()) ac4Fail("the world batcher create failed"); + st.batcher = + std::make_unique(std::move(r).takeValue()); + } + + // The offscreen GL 3.3 context (the CI headless mechanism — Mesa + // surfaceless EGL, the render-50k precedent). A host without it runs + // CPU-ONLY (loud): the batch-stage zero-allocation still measures; + // the frame histogram stays empty (the NO_SAMPLES loud-fail). + auto cr = laige::render::GlContext::createHeadless(kRender50Width, + kRender50Height); + if (cr.ok()) { + st.ctx = std::move(cr).takeValue(); + laige::render::SpriteRenderer::Options o{}; + o.maxInstances = kAc4Sprites; + o.maxAtlases = 2; // the workload's atlas ids 0..1 + auto r = laige::render::SpriteRenderer::create(st.ctx, o); + if (!r.ok()) ac4Fail("the world renderer create failed"); + st.renderer = std::make_unique( + std::move(r).takeValue()); + for (std::uint32_t id = 0; id < 2u; ++id) { + const std::vector atlas = render50SolidAtlas(id); + if (!st.renderer + ->bindAtlas(id, kRender50AtlasTexels, kRender50AtlasTexels, + atlas) + .ok()) { + ac4Fail("bindAtlas failed for the ac4 atlas " + std::to_string(id)); + } + } + } else { + st.glAvailable = false; + std::fprintf(stderr, + "laige-bench: ac4: no offscreen GL 3.3 context (%s) — " + "running CPU-only (the frame budget is NOT measured)\n", + laige::errorText(cr.error())); + std::fflush(stderr); + } + + g_ac4Hists.frame = + std::make_unique(laige::Histogram::Options{cfg.runs}); + g_ac4Hists.drawCalls = + std::make_unique(laige::Histogram::Options{cfg.runs}); + return true; +} + +bool ac4Setup(const Config& cfg) { + if (cfg.mathFpx16) { + g_ac4FpxActive = true; + return ac4SetupImpl(cfg, g_ac4Fpx); + } + g_ac4FpxActive = false; + return ac4SetupImpl(cfg, g_ac4Fp32); +} + +// One measured iteration = one frame (the section header): the batch +// stage with the zero-allocation watch armed around it, then the +// submit stage's offscreen render; the whole frame's CPU ms is the +// `sprites_10k_frame` sample (GL runs only). +void ac4Iteration(Ac4State& st) { + laige::allocWatchArm(); + laige::TimeIt timer; + st.batcher->beginFrame(); + for (const laige::render::SpriteItem& it : st.items) { + const laige::Result a = st.batcher->add(it); + if (!a.ok()) { + ac4Fail(std::string("batcher.add failed: ") + laige::errorName(a.error())); + } + } + const laige::Status wb = st.batcher->build(); + if (!wb.ok()) { + ac4Fail(std::string("the world batcher build failed: ") + + laige::errorName(wb.error())); + } + const laige::AllocWatchReading reading = laige::allocWatchRead(); + if (laige::allocWatchLive() && reading.allocs != 0u) { + ac4Fail("the batch stage allocated " + + std::to_string(reading.allocs) + + " heap block(s) this frame (first site " + + (reading.firstSite != nullptr ? "captured" : "unknown") + + ") — FR-2.2 zero-allocation contract"); + } + if (st.glAvailable) { + const laige::Status ws = + st.renderer->submit(*st.batcher, st.worldToNdc); + if (!ws.ok()) { + ac4Fail(std::string("the world submit failed: ") + + laige::errorName(ws.error())); + } + g_ac4Hists.frame->record(timer.elapsedMs()); + g_ac4Hists.drawCalls->record( + static_cast(st.renderer->frameStats().drawCalls)); + } +} + +void ac4IterationDispatch() { + if (g_ac4FpxActive) { + ac4Iteration(g_ac4Fpx); + } else { + ac4Iteration(g_ac4Fp32); + } +} + +void ac4TeardownImpl(const Ac4State& st, const char* name) { + // The machine-greppable identity line (the render-50k fingerprint + // precedent): the workload shape + the run's measured extremes. The + // frame statistics are printed only for GL runs — a CPU-only run + // records no frame samples (the NO_SAMPLES loud-fail, the + // render-50k draw-call precedent). + if (st.glAvailable) { + const laige::HistogramStats frame = g_ac4Hists.frame->stats(); + const std::uint32_t drawsMax = + static_cast(g_ac4Hists.drawCalls->stats().max); + // The RENDER-001 group count is part of the AC-4.3 contract (the + // four (atlas, material, blend) groups — one instanced draw each). + if (drawsMax != 4u) { + ac4Fail("the frame drew " + std::to_string(drawsMax) + + " draw call(s) — the four (atlas, material, blend) groups " + "must draw exactly four (RENDER-001)"); + } + std::printf( + "ac4: backend=%s sprites=%llu grid=%ux%u stack=%u groups=4 " + "default=Iso resolution=%ux%u mode=gl draw_calls=%u frames=%llu " + "frame_min_ms=%.6f frame_max_ms=%.6f\n", + name, + static_cast(st.items.size()), + kAc4GridCols, kAc4GridRows, kAc4StackPerCell, + kRender50Width, kRender50Height, + drawsMax, + static_cast(frame.n), + frame.min, frame.max); + } else { + std::printf( + "ac4: backend=%s sprites=%llu grid=%ux%u stack=%u groups=4 " + "default=Iso resolution=%ux%u mode=cpu\n", + name, + static_cast(st.items.size()), + kAc4GridCols, kAc4GridRows, kAc4StackPerCell, + kRender50Width, kRender50Height); + } + std::fflush(stdout); +} + +void ac4Teardown() { + if (g_ac4FpxActive) { + ac4TeardownImpl(g_ac4Fpx, "fixed_point_16_16"); + } else { + ac4TeardownImpl(g_ac4Fp32, "float_pinned_32"); + } +} + // --- The suite table --------------------------------------------------------- struct Suite { @@ -884,6 +1216,20 @@ const Suite kSuites[] = { "assertion; both SimMath backends (--math)", render50Setup, render50Iteration, render50Teardown, render50HistogramFor}, + {"ac4", + "M2-AC-01 AC-4.x acceptance: 10 000 overlapping sprites " + "(100 x 10 grid, 10 stacked per cell — equal-key ties + " + "overdraw) sorted + drawn in one 60 FPS frame " + "(sprites_10k_frame) + the batcher zero-allocation assertion + " + "the AC-4.4 engine-default projection assert (Iso); both " + "SimMath backends (--math). Same workload as render-sort-10k.", + ac4Setup, ac4IterationDispatch, ac4Teardown, ac4HistogramFor}, + {"render-sort-10k", + "PRD 4 AC-4.3 dedicated suite: 10 000 overlapping sprites " + "(100 x 10 grid, 10 stacked per cell) sorted + drawn at 60 FPS " + "(sprites_10k_frame) + the batcher zero-allocation assertion; " + "both SimMath backends (--math). Same workload as ac4.", + ac4Setup, ac4IterationDispatch, ac4Teardown, ac4HistogramFor}, }; bool parseUint(std::string_view text, std::uint64_t& out) { diff --git a/tools/golden/laige-golden.cpp b/tools/golden/laige-golden.cpp index db59ce3..d734730 100644 --- a/tools/golden/laige-golden.cpp +++ b/tools/golden/laige-golden.cpp @@ -83,12 +83,17 @@ using laige::render::Mat4; using laige::render::SpriteBatcher; using laige::render::SpriteItem; using laige::render::SpriteRenderer; +using laige::testing::golden::Ac4Scene; +using laige::testing::golden::Ac4SceneConfig; using laige::testing::golden::Comparison; using laige::testing::golden::SpriteScene; using laige::testing::golden::Tolerance; using laige::testing::golden::UiScene; +using laige::testing::golden::buildAc4Scene; using laige::testing::golden::buildSpriteScene; using laige::testing::golden::buildUiScene; +using laige::testing::golden::kAc4IsoConfig; +using laige::testing::golden::kAc4SideConfig; using laige::testing::golden::compareFrames; using laige::testing::golden::kGoldenHeight; using laige::testing::golden::kGoldenWidth; @@ -101,6 +106,8 @@ constexpr int kExitNoGl = 77; // The fixture names (the reference file is /.png). constexpr char const* kSpriteFixtureName = "sprite_iso_1000"; constexpr char const* kUiFixtureName = "ui_smoke"; +constexpr char const* kAc4IsoFixtureName = "ac4_iso"; +constexpr char const* kAc4SideFixtureName = "ac4_side_view"; // The per-fixture renderer/batcher budgets (the fixtures are tiny: // 1000 sprites, 7 quads; the ceilings are headroom, not tuning). @@ -120,11 +127,12 @@ void usage(char const* prog) { " [--references DIR] [--max-channel-diff N]\n" " [--pixel-budget-pct P]\n" "\n" - "Fixtures: %s, %s (tests/golden/golden_fixtures.h).\n" + "Fixtures: %s, %s, %s, %s (tests/golden/golden_fixtures.h).\n" "Exit codes: 0 ok, 1 compare failed, 2 usage/configuration\n" "error, 77 no usable OpenGL 3.3 environment (the documented\n" "environment contract).\n", - prog, kSpriteFixtureName, kUiFixtureName); + prog, kSpriteFixtureName, kUiFixtureName, kAc4IsoFixtureName, + kAc4SideFixtureName); } int parseArgs(int argc, char** argv, Config& cfg) { @@ -212,20 +220,27 @@ struct RenderedFrame { // frame convention) }; +// One pass of the fixture's frame: its world -> NDC matrix, its atlas +// binds, and its declares (the M2-SPRITE-02 world pass, or the +// M2-UI-02 screen-space UI pass — one SpriteBatcher and one +// SpriteRenderer per pass, the render-50k two-pass frame shape). +struct Pass { + Mat4 matrix; + std::function setupAtlases; + std::function declare; +}; + // The offscreen frame protocol of one fixture (the M2-SPRITE-02 -// protocol): clear before the first draw, submit, then read back the -// whole frame (one glReadPixels; the 921 600-pixel 1x1 readPixel loop -// is what readPixels replaced) and flip the GL bottom-left origin to -// the top-origin / y-down screen convention. The setup and declare -// callbacks carry the fixture-specific work (the atlas binds, the item -// declares); they run on the creating thread, single-threaded. A -// missing GL environment exits with 77 (the documented contract); -// every other failure exits with 2. -RenderedFrame renderFrame( - float clearR, float clearG, float clearB, float clearA, - const Mat4& matrix, - const std::function& setupAtlases, - const std::function& declare) { +// protocol): clear before the first draw, submit each pass in order, +// then read back the whole frame (one glReadPixels; the 921 600-pixel +// 1x1 readPixel loop is what readPixels replaced) and flip the GL +// bottom-left origin to the top-origin / y-down screen convention. +// The passes' setup and declare callbacks carry the fixture-specific +// work (the atlas binds, the item declares); they run on the creating +// thread, single-threaded. A missing GL environment exits with 77 +// (the documented contract); every other failure exits with 2. +RenderedFrame renderFrame(float clearR, float clearG, float clearB, + float clearA, std::vector passes) { RenderedFrame out; auto ctxR = GlContext::createHeadless( static_cast(kGoldenWidth), @@ -239,37 +254,39 @@ RenderedFrame renderFrame( std::exit(kExitNoGl); } GlContext ctx = std::move(ctxR).takeValue(); - auto rrR = SpriteRenderer::create( - ctx, SpriteRenderer::Options{kMaxInstances, kMaxAtlases}); - if (rrR.isError()) { - std::fprintf(stderr, "laige-golden: SpriteRenderer::create failed: " - "%s\n", - laige::errorText(rrR.error())); - std::exit(kExitConfigError); - } - SpriteRenderer renderer = std::move(rrR).takeValue(); - setupAtlases(renderer); - auto bcR = SpriteBatcher::create(SpriteBatcher::Options{kMaxInstances}); - if (bcR.isError()) { - std::fprintf(stderr, "laige-golden: SpriteBatcher::create failed: " - "%s\n", - laige::errorText(bcR.error())); - std::exit(kExitConfigError); - } - SpriteBatcher batcher = std::move(bcR).takeValue(); - batcher.beginFrame(); - declare(batcher); - if (!batcher.build().ok()) { - std::fprintf(stderr, "laige-golden: batcher.build() failed\n"); - std::exit(kExitConfigError); - } if (!ctx.clear(clearR, clearG, clearB, clearA).ok()) { std::fprintf(stderr, "laige-golden: clear failed\n"); std::exit(kExitConfigError); } - if (!renderer.submit(batcher, matrix).ok()) { - std::fprintf(stderr, "laige-golden: submit failed\n"); - std::exit(kExitConfigError); + for (const Pass& pass : passes) { + auto rrR = SpriteRenderer::create( + ctx, SpriteRenderer::Options{kMaxInstances, kMaxAtlases}); + if (rrR.isError()) { + std::fprintf(stderr, "laige-golden: SpriteRenderer::create failed: " + "%s\n", + laige::errorText(rrR.error())); + std::exit(kExitConfigError); + } + SpriteRenderer renderer = std::move(rrR).takeValue(); + pass.setupAtlases(renderer); + auto bcR = SpriteBatcher::create(SpriteBatcher::Options{kMaxInstances}); + if (bcR.isError()) { + std::fprintf(stderr, "laige-golden: SpriteBatcher::create failed: " + "%s\n", + laige::errorText(bcR.error())); + std::exit(kExitConfigError); + } + SpriteBatcher batcher = std::move(bcR).takeValue(); + batcher.beginFrame(); + pass.declare(batcher); + if (!batcher.build().ok()) { + std::fprintf(stderr, "laige-golden: batcher.build() failed\n"); + std::exit(kExitConfigError); + } + if (!renderer.submit(batcher, pass.matrix).ok()) { + std::fprintf(stderr, "laige-golden: submit failed\n"); + std::exit(kExitConfigError); + } } const std::uint32_t w = kGoldenWidth; const std::uint32_t h = kGoldenHeight; @@ -319,31 +336,100 @@ int runFixture(const std::string& name, const Config& cfg, std::exit(kExitConfigError); } SpriteScene scene = std::move(sceneR).takeValue(); + frame = renderFrame(scene.clearR, scene.clearG, scene.clearB, + scene.clearA, + {Pass{ + scene.matrix, + [&scene](SpriteRenderer& rr) { + if (!rr.bindAtlas( + laige::testing::golden::kSpriteAtlas0Id, + 4, 4, std::span{scene.atlas0}) + .ok()) { + std::fprintf(stderr, + "laige-golden: bindAtlas(0) " + "failed\n"); + std::exit(kExitConfigError); + } + if (!rr.bindAtlas( + laige::testing::golden::kSpriteAtlas1Id, + 4, 4, std::span{scene.atlas1}) + .ok()) { + std::fprintf(stderr, + "laige-golden: bindAtlas(1) " + "failed\n"); + std::exit(kExitConfigError); + } + }, + [&scene](SpriteBatcher& b) { + for (const SpriteItem& it : scene.items) { + if (!b.add(it).ok()) { + std::fprintf(stderr, + "laige-golden: batcher.add " + "failed\n"); + std::exit(kExitConfigError); + } + } + }, + }}); + } else if (name == kAc4IsoFixtureName || + name == kAc4SideFixtureName) { + // AC-4.1: the one scene under the two projection configs (the + // config is the only difference between the two fixtures). + const Ac4SceneConfig cfg = + (name == kAc4IsoFixtureName) ? kAc4IsoConfig : kAc4SideConfig; + auto sceneR = buildAc4Scene(cfg); + if (sceneR.isError()) { + std::fprintf(stderr, "laige-golden: ac4 fixture build failed: %s\n", + laige::errorText(sceneR.error())); + std::exit(kExitConfigError); + } + Ac4Scene scene = std::move(sceneR).takeValue(); frame = renderFrame( scene.clearR, scene.clearG, scene.clearB, scene.clearA, - scene.matrix, - [&scene](SpriteRenderer& rr) { - if (!rr.bindAtlas(laige::testing::golden::kSpriteAtlas0Id, 4, 4, - std::span{scene.atlas0}) - .ok()) { - std::fprintf(stderr, "laige-golden: bindAtlas(0) failed\n"); - std::exit(kExitConfigError); - } - if (!rr.bindAtlas(laige::testing::golden::kSpriteAtlas1Id, 4, 4, - std::span{scene.atlas1}) - .ok()) { - std::fprintf(stderr, "laige-golden: bindAtlas(1) failed\n"); - std::exit(kExitConfigError); - } - }, - [&scene](SpriteBatcher& b) { - for (const SpriteItem& it : scene.items) { - if (!b.add(it).ok()) { - std::fprintf(stderr, "laige-golden: batcher.add failed\n"); - std::exit(kExitConfigError); - } - } - }); + {Pass{ + scene.worldMatrix, + [&scene](SpriteRenderer& rr) { + if (!rr.bindAtlas(laige::testing::golden::kSpriteAtlas0Id, 4, 4, + std::span{scene.atlas0}) + .ok()) { + std::fprintf(stderr, "laige-golden: bindAtlas(0) failed\n"); + std::exit(kExitConfigError); + } + if (!rr.bindAtlas(laige::testing::golden::kSpriteAtlas1Id, 4, 4, + std::span{scene.atlas1}) + .ok()) { + std::fprintf(stderr, "laige-golden: bindAtlas(1) failed\n"); + std::exit(kExitConfigError); + } + }, + [&scene](SpriteBatcher& b) { + for (const SpriteItem& it : scene.items) { + if (!b.add(it).ok()) { + std::fprintf(stderr, "laige-golden: batcher.add failed\n"); + std::exit(kExitConfigError); + } + } + }, + }, + Pass{ + scene.uiMatrix, + [&scene](SpriteRenderer& rr) { + if (!rr.bindAtlas(scene.uiAtlasId, 4, 4, + std::span{scene.uiAtlas}) + .ok()) { + std::fprintf(stderr, "laige-golden: bindAtlas(ui) failed\n"); + std::exit(kExitConfigError); + } + }, + [&scene](SpriteBatcher& b) { + if (!laige::render::declareUi(b, scene.table, scene.tree, + laige::render::UiDeclareOptions{}) + .ok()) { + std::fprintf(stderr, "laige-golden: declareUi failed\n"); + std::exit(kExitConfigError); + } + }, + }}); } else { // kUiFixtureName auto sceneR = buildUiScene(font); if (sceneR.isError()) { @@ -352,30 +438,41 @@ int runFixture(const std::string& name, const Config& cfg, std::exit(kExitConfigError); } UiScene scene = std::move(sceneR).takeValue(); - frame = renderFrame( - scene.clearR, scene.clearG, scene.clearB, scene.clearA, - scene.matrix, - [&scene](SpriteRenderer& rr) { - if (!rr.bindAtlas(scene.uiAtlasId, 4, 4, std::span{scene.uiAtlas}) - .ok()) { - std::fprintf(stderr, "laige-golden: bindAtlas(ui) failed\n"); - std::exit(kExitConfigError); - } - if (!rr.bindAtlas(scene.fontAtlasId, 1024, 1024, - std::span{scene.fontRgba}) - .ok()) { - std::fprintf(stderr, "laige-golden: bindAtlas(font) failed\n"); - std::exit(kExitConfigError); - } - }, - [&scene](SpriteBatcher& b) { - if (!laige::render::declareUi(b, scene.table, scene.tree, - scene.declareOptions()) - .ok()) { - std::fprintf(stderr, "laige-golden: declareUi failed\n"); - std::exit(kExitConfigError); - } - }); + frame = renderFrame(scene.clearR, scene.clearG, scene.clearB, + scene.clearA, + {Pass{ + scene.matrix, + [&scene](SpriteRenderer& rr) { + if (!rr.bindAtlas(scene.uiAtlasId, 4, 4, + std::span{scene.uiAtlas}) + .ok()) { + std::fprintf(stderr, + "laige-golden: bindAtlas(ui) " + "failed\n"); + std::exit(kExitConfigError); + } + if (!rr.bindAtlas(scene.fontAtlasId, 1024, 1024, + std::span{scene.fontRgba}) + .ok()) { + std::fprintf(stderr, + "laige-golden: bindAtlas(font) " + "failed\n"); + std::exit(kExitConfigError); + } + }, + [&scene](SpriteBatcher& b) { + if (!laige::render::declareUi(b, scene.table, + scene.tree, + scene + .declareOptions()) + .ok()) { + std::fprintf(stderr, + "laige-golden: declareUi " + "failed\n"); + std::exit(kExitConfigError); + } + }, + }}); } const std::string path = cfg.references + "/" + name + ".png"; @@ -458,17 +555,20 @@ int main(int argc, char** argv) { } std::vector names = cfg.fixtures; if (names.empty()) { - names = {kSpriteFixtureName, kUiFixtureName}; + names = {kSpriteFixtureName, kUiFixtureName, kAc4IsoFixtureName, + kAc4SideFixtureName}; } for (const std::string& n : names) { - if (n != kSpriteFixtureName && n != kUiFixtureName) { + if (n != kSpriteFixtureName && n != kUiFixtureName && + n != kAc4IsoFixtureName && n != kAc4SideFixtureName) { std::fprintf(stderr, "laige-golden: unknown fixture %s\n", n.c_str()); usage(argv[0]); return kExitConfigError; } } // The ui_smoke fixture needs the committed test font; fail fast (2) - // before any GL work. + // before any GL work. (The ac4 fixtures declare no text widgets and + // use the null default font — no font needed.) std::vector font; for (const std::string& n : names) { if (n == kUiFixtureName) { diff --git a/tools/golden/references/README.md b/tools/golden/references/README.md index 9d64fc7..0b26c46 100644 --- a/tools/golden/references/README.md +++ b/tools/golden/references/README.md @@ -26,15 +26,21 @@ docs/testing.md). workflow_dispatch only sees workflows on the default branch, so a PR branch cannot be dispatched). -The committed PNGs were rendered by the label-gated `golden-references` -job of run 38468040338 (2026-10-13, branch -`feat/m2-gold-01-golden-image-tests`, the reference environment above), -extracted from that job's log between the +The `sprite_iso_1000` and `ui_smoke` PNGs were rendered by the +label-gated `golden-references` job of run 38468040338 (2026-10-13, +branch `feat/m2-gold-01-golden-image-tests`, the reference environment +above), extracted from that job's log between the `GOLDEN_REFERENCE_BEGIN` / `GOLDEN_REFERENCE_END` markers. The job's in-run determinism compare passed (both fixtures: failing=0, max_diff=0, budget 9). A reference change is a human decision: regenerate, review, commit. +The `ac4_iso` / `ac4_side_view` PNGs (M2-AC-01) were rendered by the +same job in the reference environment and committed in the PR that +added the fixtures: adding a fixture always costs one red `golden` run +(the entry compares every fixture) plus one reviewed regeneration — +the M2-GOLD-01 bootstrap contract. + ## Regeneration (human-approved path) 1. Trigger the generation on the branch that carries the rendering diff --git a/tools/laige-surface-lint b/tools/laige-surface-lint new file mode 100755 index 0000000..f52037d --- /dev/null +++ b/tools/laige-surface-lint @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# ============================================================================ +# laige-surface-lint — the AC-4.2 public-surface check +# +# Roadmap: roadmap/M2-rendering-2.5d.md, step M2-AC-01 (AC-4.2) +# PRD refs: §4 AC-4.2 ("proof no sim/physics/pathing/network code depends +# on projection mode: include-graph lint rule + surface check +# (no ProjectionMode in laige-sim/laige-net public surface) — +# automated in CI") +# AGENTS: RENDER-006 (camera projection must not leak into game logic), +# CORE-008 (no silent failure), TEST-007 (architecture changes +# need integration validation) +# +# Usage: +# python3 tools/laige-surface-lint [--root REPO_ROOT] +# +# REPO_ROOT defaults to the current directory. CI runs it as the CTest +# entry `ac4_surface` (tests/tools: fixture trees + the real tree) in +# every P0 job; the include-graph half of AC-4.2 is the existing +# include-lint R2 rule (the `include-lint-real-tree` entry) — R2 blocks +# every laige-sim / laige-net -> laige-render include, so a sim or net +# module cannot even USE a render type. +# +# What it does +# ------------ +# This is the SURFACE half of AC-4.2. Scans the PUBLIC surface of the +# simulation and networking modules — every public header under +# src/laige-sim/include/** and src/laige-net/include/** — for any +# textual mention of the render-side projection type `ProjectionMode` +# (a word-boundary match: `laige::render::ProjectionMode`, a comment, +# a default argument, a typedef — any of it puts a presentation +# concept into the sim/net contract). The scan is textual like the +# include-lint (no preprocessing); a mention inside a string literal +# is still a boundary violation by the AC-4.2 wording (the type name +# in the public surface), so no exclusion is needed. +# +# Exit codes: 0 = pass (no mentions) · 1 = surface violation (one or +# more mentions reported) · 2 = structural failure (a scanned module +# directory has code but no public-header root, or the module layout +# is otherwise ambiguous — loud, never silent). A module directory +# that is absent or README-only (laige-net has no CMake yet) is NOT a +# structural failure: it simply contributes zero headers to the scan. +# +# Python 3 stdlib only; no third-party imports (the include-lint +# policy — the lint must run on any CI runner's python3). +# ============================================================================ + +import os +import re +import sys + +# The AC-4.2 modules: the simulation and the networking surface. +MODULES = ["laige-sim", "laige-net"] + +# The render-side projection type (src/laige-render/include/laige/render/ +# projection.h). A word-boundary match: `projectionMode` (a local +# variable with a different case) does not trip the check; +# `SomeProjectionMode` or `ProjectionModes` do not either (the \b +# boundaries require non-word characters on both sides). +TOKEN_RE = re.compile(r"\bProjectionMode\b") + +# A module directory with code but no public-header root is ambiguous +# (the surface cannot be defined) — a structural failure, not a pass. +CODE_MARKERS = ("CMakeLists.txt",) +SOURCE_EXT = (".cpp", ".cc", ".cxx", ".h", ".hpp", ".c") + + +def fail_structural(root, msg): + print(f"laige-surface-lint: ERROR — {msg}") + sys.exit(2) + + +def module_public_headers(root, module): + """The public headers of one module: src//include/**/*.h{,pp}. + + Returns (headers, has_code). has_code is True when the module + directory holds code markers or source files (the structural-failure + test for a missing include root). + """ + mod_dir = os.path.join(root, "src", module) + inc_dir = os.path.join(mod_dir, "include") + if not os.path.isdir(mod_dir): + return [], False # the module is not in the tree yet (laige-net) + + has_code = any( + os.path.isfile(os.path.join(mod_dir, m)) for m in CODE_MARKERS + ) + for dirpath, _dirnames, filenames in os.walk(mod_dir): + for fn in filenames: + if fn.endswith(SOURCE_EXT): + has_code = True + break + if has_code: + break + + if not os.path.isdir(inc_dir): + if has_code: + fail_structural( + root, + f"src/{module} has code but no public-header root " + f"(src/{module}/include): the AC-4.2 surface cannot be " + "defined — add the public root or remove the code", + ) + return [], False # README-only module: no surface to scan + + headers = [] + for dirpath, _dirnames, filenames in os.walk(inc_dir): + for fn in sorted(filenames): + if fn.endswith((".h", ".hpp", ".hh")): + headers.append(os.path.join(dirpath, fn)) + return headers, has_code + + +def scan_header(path): + """The (line-number, line-text) hits of the token in one header.""" + hits = [] + with open(path, "r", encoding="utf-8", errors="replace") as f: + for lineno, line in enumerate(f, start=1): + if TOKEN_RE.search(line): + hits.append((lineno, line.rstrip("\n"))) + return hits + + +def main(argv): + root = os.getcwd() + args = argv[1:] + if "--root" in args: + i = args.index("--root") + if i + 1 >= len(args): + print("usage: laige-surface-lint [--root REPO_ROOT]", + file=sys.stderr) + return 2 + root = args[i + 1] + if not os.path.isdir(root): + print(f"laige-surface-lint: ERROR — root directory not found: " + f"'{root}' (pass --root REPO_ROOT)", file=sys.stderr) + return 2 + + total = 0 + violations = 0 + for module in MODULES: + headers, _has_code = module_public_headers(root, module) + total += len(headers) + for path in headers: + rel = os.path.relpath(path, root) + for lineno, line in scan_header(path): + violations += 1 + print(f"{rel}:{lineno}: {line.strip()}") + + if violations: + print( + f"laige-surface-lint: FAIL — {violations} 'ProjectionMode' " + f"mention(s) in the public surface of {', '.join(MODULES)} " + "(AC-4.2: no sim/physics/pathing/network code depends on the " + "projection mode)" + ) + return 1 + + print( + f"laige-surface-lint: OK — {total} public header(s) scanned " + f"({', '.join(MODULES)}), no 'ProjectionMode' mention" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From e387be38ed1b516f47016d2abb3acbcd08f19545 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 09:05:18 +0200 Subject: [PATCH 02/14] Fix MSVC C4457: rename the ac4 local config to ac4Cfg The runFixture local Ac4SceneConfig cfg shadowed the function's Config& cfg parameter (warning-as-error on the Windows lane, the only MSVC-compiled tree). Local 4-tree gate green after the fix (121/121 gcc, 121/121 clang, 118/118 asan, 118/118 tsan, the 14 GL-creating entries excluded). --- tools/golden/laige-golden.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/golden/laige-golden.cpp b/tools/golden/laige-golden.cpp index d734730..8ac829b 100644 --- a/tools/golden/laige-golden.cpp +++ b/tools/golden/laige-golden.cpp @@ -375,9 +375,9 @@ int runFixture(const std::string& name, const Config& cfg, name == kAc4SideFixtureName) { // AC-4.1: the one scene under the two projection configs (the // config is the only difference between the two fixtures). - const Ac4SceneConfig cfg = + const Ac4SceneConfig ac4Cfg = (name == kAc4IsoFixtureName) ? kAc4IsoConfig : kAc4SideConfig; - auto sceneR = buildAc4Scene(cfg); + auto sceneR = buildAc4Scene(ac4Cfg); if (sceneR.isError()) { std::fprintf(stderr, "laige-golden: ac4 fixture build failed: %s\n", laige::errorText(sceneR.error())); From d43871c18c90d99bdb4c89dcdc37d6061923840f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 09:35:51 +0200 Subject: [PATCH 03/14] TEMPORARY: gdb backtrace entry for the ac4 exit-time segfault Diagnostic only (removed once root-caused): laige_bench_ac4_fpx16_gdb runs the ac4 suite under gdb (reduced frame count) and prints the backtrace of the exit-time segfault seen on the CI Linux lanes. Local 4-tree gate green (the entry is in the GL-creating exclusion list, the documented GPU-restricted local gate). --- tools/bench/CMakeLists.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index da69a04..d9a87d3 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -159,6 +159,18 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") laige_bench_ac4_fp32 PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 1800) + + # TEMPORARY DIAGNOSTIC (removed once root-caused): the ac4 run's + # exit-time segfault backtrace (gdb, a reduced frame count — the + # crash is in the exit path, not the measured region). + add_test(NAME laige_bench_ac4_fpx16_gdb + COMMAND /usr/bin/gdb -batch -ex run -ex bt + --args laige-bench --suite=ac4 --math=fixed_point_16_16 + --runs=20 --warmup=5 + --budget=sprites_10k_frame) + set_tests_properties(laige_bench_ac4_fpx16_gdb + PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" + TIMEOUT 300) endif() # All-platform sim-tick smoke (no budget gate): the sim-tick workload From 4b49fa7bed9966d13d3a024232ed50232a5faeb4 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 09:47:16 +0200 Subject: [PATCH 04/14] TEMPORARY: install gdb in the diagnostic entry (not on ubuntu-24.04) --- tools/bench/CMakeLists.txt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index d9a87d3..d3e6433 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -162,15 +162,17 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # TEMPORARY DIAGNOSTIC (removed once root-caused): the ac4 run's # exit-time segfault backtrace (gdb, a reduced frame count — the - # crash is in the exit path, not the measured region). + # crash is in the exit path, not the measured region). gdb is not + # preinstalled on the ubuntu-24.04 runners — install it in-place. add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /usr/bin/gdb -batch -ex run -ex bt - --args laige-bench --suite=ac4 --math=fixed_point_16_16 - --runs=20 --warmup=5 - --budget=sprites_10k_frame) + COMMAND /bin/bash -c + "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && " + "gdb -batch -ex run -ex bt --args " + "$ --suite=ac4 --math=fixed_point_16_16 " + "--runs=20 --warmup=5 --budget=sprites_10k_frame") set_tests_properties(laige_bench_ac4_fpx16_gdb PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" - TIMEOUT 300) + TIMEOUT 600) endif() # All-platform sim-tick smoke (no budget gate): the sim-tick workload From 911a9b2b5249cb3f5c6f64745f27374aa5742032 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 10:24:27 +0200 Subject: [PATCH 05/14] TEMPORARY: fix the gdb diagnostic command + add an ASan diagnostic entry The generator expression in the test COMMAND was mangled by CMake; use the bare binary name (ctest's PATH includes build/bin). Also add the same reduced ac4 run under AddressSanitizer (heap errors report with a stack). Both entries are removed once the exit-time segfault is root-caused. Local 4-tree gate green (121/121 gcc, 121/121 clang, 118/118 asan, 118/118 tsan, GL-creating entries excluded). --- tools/bench/CMakeLists.txt | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index d3e6433..9e5d362 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -165,16 +165,30 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # crash is in the exit path, not the measured region). gdb is not # preinstalled on the ubuntu-24.04 runners — install it in-place. add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /bin/bash -c - "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && " - "gdb -batch -ex run -ex bt --args " - "$ --suite=ac4 --math=fixed_point_16_16 " - "--runs=20 --warmup=5 --budget=sprites_10k_frame") + COMMAND /bin/bash -c "sudo apt-get update -qq && " + "sudo apt-get install -y -qq gdb && " + "gdb -batch -ex run -ex bt --args laige-bench " + "--suite=ac4 --math=fixed_point_16_16 " + "--runs=20 --warmup=5 " + "--budget=sprites_10k_frame") set_tests_properties(laige_bench_ac4_fpx16_gdb PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 600) endif() +# TEMPORARY DIAGNOSTIC (removed once root-caused): the ac4 exit-time +# segfault under AddressSanitizer (the reduced frame count — the crash +# is in the exit path; ASan reports the first heap error with a stack). +if(LAIGE_ASAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME laige_bench_ac4_fpx16_asan_dbg + COMMAND laige-bench --suite=ac4 --math=fixed_point_16_16 + --runs=20 --warmup=5 + --budget=sprites_10k_frame) + set_tests_properties(laige_bench_ac4_fpx16_asan_dbg + PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" + TIMEOUT 600) +endif() + # All-platform sim-tick smoke (no budget gate): the sim-tick workload # in its canonical 4000-tick shape (the default fpx16_16 backend), run # in EVERY P0 platform's ctest — including the platforms where the From 6c77a75d8f54605026541d67b84391d627694ebd Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 10:48:23 +0200 Subject: [PATCH 06/14] TEMPORARY: one single-string bash -c in the gdb diagnostic CTest passes each quoted CMake literal as a separate argv element and bash only takes the first as its -c script; the multi-string form broke the command line (verified locally with a two-test experiment). Local 4-tree gate green (121/121, 121/121, 118/118, 118/118). --- tools/bench/CMakeLists.txt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index 9e5d362..2e7a9cc 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -164,13 +164,11 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # exit-time segfault backtrace (gdb, a reduced frame count — the # crash is in the exit path, not the measured region). gdb is not # preinstalled on the ubuntu-24.04 runners — install it in-place. + # One single CMake string: CTest passes each quoted literal as a + # separate argv element, and bash only takes the first as its -c + # script (the multi-string form breaks the command line). add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /bin/bash -c "sudo apt-get update -qq && " - "sudo apt-get install -y -qq gdb && " - "gdb -batch -ex run -ex bt --args laige-bench " - "--suite=ac4 --math=fixed_point_16_16 " - "--runs=20 --warmup=5 " - "--budget=sprites_10k_frame") + COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=20 --warmup=5 --budget=sprites_10k_frame") set_tests_properties(laige_bench_ac4_fpx16_gdb PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 600) From 6b5795a2e884b6ca5b3ed43faca6b19376d77ec5 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 10:59:08 +0200 Subject: [PATCH 07/14] TEMPORARY: absolute binary path in the gdb diagnostic CTest resolves bare command names itself, but the PATH the bash -c child inherits does not include build/bin. Local 4-tree gate green (121/121, 121/121, 118/118, 118/118). --- tools/bench/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index 2e7a9cc..2019224 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -166,9 +166,11 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # preinstalled on the ubuntu-24.04 runners — install it in-place. # One single CMake string: CTest passes each quoted literal as a # separate argv element, and bash only takes the first as its -c - # script (the multi-string form breaks the command line). + # script (the multi-string form breaks the command line). Absolute + # binary path: CTest resolves bare names itself, but the PATH the + # bash -c child inherits does not include build/bin. add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=20 --warmup=5 --budget=sprites_10k_frame") + COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=20 --warmup=5 --budget=sprites_10k_frame") set_tests_properties(laige_bench_ac4_fpx16_gdb PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 600) From f9483a66d74cee0e858a9b81dd80606978e460b9 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 11:15:15 +0200 Subject: [PATCH 08/14] TEMPORARY: run the gdb diagnostic at the canonical 3000/1000 shape The 20-frame run exits cleanly even on CI: the segfault only reproduces at the full frame count, so the diagnostic must use the same --runs=3000 --warmup=1000 as the crashing entries. Local 4-tree gate green (121/121, 121/121, 118/118, 118/118). --- tools/bench/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index 2019224..8e51925 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -168,9 +168,11 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") # separate argv element, and bash only takes the first as its -c # script (the multi-string form breaks the command line). Absolute # binary path: CTest resolves bare names itself, but the PATH the - # bash -c child inherits does not include build/bin. + # bash -c child inherits does not include build/bin. Full canonical + # shape: the 20-frame run exits cleanly even on CI, so the + # frame-count must match the crashing entries. add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=20 --warmup=5 --budget=sprites_10k_frame") + COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=3000 --warmup=1000 --budget=sprites_10k_frame") set_tests_properties(laige_bench_ac4_fpx16_gdb PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 600) From 920dc7a54a035522765e73b8a7480330e1106288 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 11:38:50 +0200 Subject: [PATCH 09/14] TEMPORARY: in-process crash capture for the ac4 exit segfault The crash is a Heisenbug: the 3000-frame ac4 entries segfault at exit on the CI Linux lanes but exit cleanly under gdb, under ASan, and locally. Capture the crashing stack in the plain process instead: a SIGSEGV/SIGBUS handler prints the crashed thread id (vs the main tid printed at startup) plus a backtrace (frame-pointer based, -O0 Debug) to the CI job log, then re-raises. -Wl,--export-dynamic on the bench target gives the symbols names. Removed with the root-cause fix. Local 4-tree gate green. --- tools/bench/CMakeLists.txt | 37 +++++------------------ tools/bench/laige-bench.cpp | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index 8e51925..d1abbe9 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -30,6 +30,13 @@ laige_apply_engine_policy(laige-bench) laige_apply_simmath_policy(laige-bench) target_link_libraries(laige-bench PRIVATE laige-core laige-sim laige-render) +# TEMPORARY DIAGNOSTIC (removed with the root-cause fix): export the +# dynamic symbol table so the in-process crash backtrace +# (Ac4CrashHandler in laige-bench.cpp) prints function names. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_options(laige-bench PRIVATE -Wl,--export-dynamic) +endif() + # The report's "build" context field records the build type (AGENTS 12); # CMake stamps it so the binary does not have to guess. target_compile_definitions(laige-bench PRIVATE @@ -159,36 +166,6 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") laige_bench_ac4_fp32 PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" TIMEOUT 1800) - - # TEMPORARY DIAGNOSTIC (removed once root-caused): the ac4 run's - # exit-time segfault backtrace (gdb, a reduced frame count — the - # crash is in the exit path, not the measured region). gdb is not - # preinstalled on the ubuntu-24.04 runners — install it in-place. - # One single CMake string: CTest passes each quoted literal as a - # separate argv element, and bash only takes the first as its -c - # script (the multi-string form breaks the command line). Absolute - # binary path: CTest resolves bare names itself, but the PATH the - # bash -c child inherits does not include build/bin. Full canonical - # shape: the 20-frame run exits cleanly even on CI, so the - # frame-count must match the crashing entries. - add_test(NAME laige_bench_ac4_fpx16_gdb - COMMAND /bin/bash -c "sudo apt-get update -qq && sudo apt-get install -y -qq gdb && gdb -batch -ex run -ex bt --args ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/laige-bench --suite=ac4 --math=fixed_point_16_16 --runs=3000 --warmup=1000 --budget=sprites_10k_frame") - set_tests_properties(laige_bench_ac4_fpx16_gdb - PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" - TIMEOUT 600) -endif() - -# TEMPORARY DIAGNOSTIC (removed once root-caused): the ac4 exit-time -# segfault under AddressSanitizer (the reduced frame count — the crash -# is in the exit path; ASan reports the first heap error with a stack). -if(LAIGE_ASAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") - add_test(NAME laige_bench_ac4_fpx16_asan_dbg - COMMAND laige-bench --suite=ac4 --math=fixed_point_16_16 - --runs=20 --warmup=5 - --budget=sprites_10k_frame) - set_tests_properties(laige_bench_ac4_fpx16_asan_dbg - PROPERTIES ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json" - TIMEOUT 600) endif() # All-platform sim-tick smoke (no budget gate): the sim-tick workload diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 8a23d1f..40b7940 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -136,6 +136,19 @@ #include "laige/sim_math.h" // SimMath backends (laige-core) #include "laige/sim/system.h" // SystemDef, LAIGE_SYSTEM, Io +// TEMPORARY DIAGNOSTIC (removed with the root-cause fix): the ac4 +// entries segfault at process exit on the CI Linux lanes but do not +// crash under gdb, under ASan, or locally (a Heisenbug), so capture +// the crashing stack in-process. -O0 Debug keeps frame pointers for +// backtrace(); --export-dynamic (a temporary CMake option below) +// gives backtrace_symbols real names. +#if defined(__linux__) +#include +#include +#include +#include +#endif + // The benchmark's workload component (FR-1.2, G-R8): the dynamic // body's 2D velocity in the selected SimMath backend's Vec2. The // marks specialize engine trait templates (laige::detail) and @@ -157,6 +170,34 @@ LAIGE_DETERMINISM_SAFE( namespace { +#if defined(__linux__) +// TEMPORARY DIAGNOSTIC: the in-process exit-time segfault capture +// (see the include above). The backtrace goes to stderr (the CI job +// log under ctest --output-on-failure); the default handler is +// restored and the signal re-raised, so the original crash outcome +// is unchanged. +[[noreturn]] void Ac4CrashHandler(int sig) { + // The crashed thread id matters: if it is not the main thread, the + // crash is inside a library worker racing with the exit path. + char line[128]; + std::snprintf(line, sizeof(line), + "ac4 diagnostic: fatal signal %d in exit path, " + "crashed tid=%ld, stack:\n", + sig, static_cast(::syscall(SYS_gettid))); + (void)!::write(STDERR_FILENO, line, + static_cast(std::strlen(line))); + void* frames[64]; + const int n = backtrace(frames, 64); + backtrace_symbols_fd(frames, n, STDERR_FILENO); + ::signal(sig, SIG_DFL); + ::raise(sig); + // The re-raised signal terminates the process (default disposition); + // reach here only if it was blocked or ignored — terminate directly + // (the process is dying either way, no cleanup to run). + std::_Exit(128 + sig); +} +#endif + // The parsed command-line configuration (defined before the suites — // the sim-tick suite's setup callback reads the selected backend). struct Config { @@ -1372,6 +1413,24 @@ int main(int argc, char** argv) { using laige::Histogram; using laige::TimeIt; +#if defined(__linux__) + // TEMPORARY DIAGNOSTIC: capture the exit-time segfault stack (the + // handler is process-wide, so it is still in force during the + // static-destruction phase after main returns). The main thread id + // is printed here so the crashed tid in the handler line can be + // compared against it. + { + char line[64]; + std::snprintf(line, sizeof(line), + "ac4 diagnostic: main tid=%ld\n", + static_cast(::syscall(SYS_gettid))); + (void)!::write(STDERR_FILENO, line, + static_cast(std::strlen(line))); + } + ::signal(SIGSEGV, &Ac4CrashHandler); + ::signal(SIGBUS, &Ac4CrashHandler); +#endif + Config cfg; if (!parseArgs(argc, argv, cfg)) { usage(stderr); From 6c30638a4496810b04059764a7280ad34d5c646f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 12:09:36 +0200 Subject: [PATCH 10/14] TEMPORARY: identify the worker threads that segfault at ac4 exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-process crash capture showed the exit segfault hits THREE non-main worker threads (tids 4209/4211/4212 vs main 4208) with no usable backtrace — the crash is inside library workers racing with the exit path. Dump the process's thread names (/proc/self/task comm) at startup and right after the GL context is created, to identify which component spawns them. Removed with the root-cause fix. Local 4-tree gate green. --- tools/bench/laige-bench.cpp | 51 ++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 40b7940..4e26a58 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -144,6 +144,8 @@ // gives backtrace_symbols real names. #if defined(__linux__) #include +#include +#include #include #include #include @@ -196,6 +198,35 @@ namespace { // (the process is dying either way, no cleanup to run). std::_Exit(128 + sig); } + +// TEMPORARY DIAGNOSTIC: dump the process's thread names from +// /proc/self/task (identifying the component that spawns the worker +// threads seen at exit). Returns "tid=name tid=name ...". +std::string Ac4ThreadNames() { + std::string out; + DIR* dir = ::opendir("/proc/self/task"); + if (dir == nullptr) return out; + while (const struct dirent* entry = ::readdir(dir)) { + if (entry->d_name[0] == '.') continue; + char commPath[288]; + std::snprintf(commPath, sizeof(commPath), "/proc/self/task/%s/comm", + entry->d_name); + char comm[128] = {0}; + if (std::FILE* file = std::fopen(commPath, "r")) { + if (std::fgets(comm, sizeof(comm), file) != nullptr) { + const std::size_t len = std::strlen(comm); + if (len > 0 && comm[len - 1] == '\n') comm[len - 1] = '\0'; + } + std::fclose(file); + } + out += entry->d_name; + out += "="; + out += comm; + out += ' '; + } + ::closedir(dir); + return out; +} #endif // The parsed command-line configuration (defined before the suites — @@ -1098,6 +1129,15 @@ bool ac4SetupImpl(const Config& cfg, Ac4State& st) { ac4Fail("bindAtlas failed for the ac4 atlas " + std::to_string(id)); } } +#if defined(__linux__) + // TEMPORARY DIAGNOSTIC: the thread census after the GL context is + // up (vs the startup census — did the GL init spawn the workers?). + { + const std::string line = + "ac4 diagnostic: post-GL threads=[" + Ac4ThreadNames() + "]\n"; + (void)!::write(STDERR_FILENO, line.data(), line.size()); + } +#endif } else { st.glAvailable = false; std::fprintf(stderr, @@ -1420,12 +1460,11 @@ int main(int argc, char** argv) { // is printed here so the crashed tid in the handler line can be // compared against it. { - char line[64]; - std::snprintf(line, sizeof(line), - "ac4 diagnostic: main tid=%ld\n", - static_cast(::syscall(SYS_gettid))); - (void)!::write(STDERR_FILENO, line, - static_cast(std::strlen(line))); + const std::string line = + "ac4 diagnostic: main tid=" + + std::to_string(static_cast(::syscall(SYS_gettid))) + + " threads=[" + Ac4ThreadNames() + "]\n"; + (void)!::write(STDERR_FILENO, line.data(), line.size()); } ::signal(SIGSEGV, &Ac4CrashHandler); ::signal(SIGBUS, &Ac4CrashHandler); From 0a1c0f334b18ff0387a459792d7061bbea1bd29f Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 12:17:40 +0200 Subject: [PATCH 11/14] TEMPORARY: fail-fast census for the ac4 and render-50k worker threads ctest job logs only show output of failing tests, and the segfault does not reproduce on every lane (run 9: 3 of 4 failed). Make the ac4 and render-50k entries fail with the thread census (startup + post-GL, with the render-50k entry as the clean-exit control) whenever worker threads are present, so the census always reaches the job log. Removed with the root-cause fix. Local 4-tree gate green (the diagnostic entries are GL-gated and excluded locally). --- tools/bench/laige-bench.cpp | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 4e26a58..db4dd57 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -227,6 +227,16 @@ std::string Ac4ThreadNames() { ::closedir(dir); return out; } + +// TEMPORARY DIAGNOSTIC: count the process's threads (/proc/self/task). +std::size_t Ac4ThreadCount() { + std::size_t n = 0; + DIR* dir = ::opendir("/proc/self/task"); + if (dir == nullptr) return 1; + while (::readdir(dir) != nullptr) ++n; + ::closedir(dir); + return n >= 2 ? n - 2 : 0; // drop "." and ".." +} #endif // The parsed command-line configuration (defined before the suites — @@ -760,6 +770,23 @@ bool render50SetupImpl(const Config& cfg, Render50State& st) { render50Fail("bindAtlas failed for the UI widget atlas"); } } +#if defined(__linux__) + // TEMPORARY DIAGNOSTIC: the render-50k control census (this + // workload exits cleanly on CI — compare its thread state against + // the ac4 one). Fails when workers are present, for log + // visibility (same reasoning as the ac4 check). + { + const std::string line = + "ac4 diagnostic: render-50k post-GL threads=[" + + Ac4ThreadNames() + "]\n"; + (void)!::write(STDERR_FILENO, line.data(), line.size()); + if (Ac4ThreadCount() > 1) { + render50Fail(std::to_string(Ac4ThreadCount() - 1) + + " worker thread(s) present after GL init (diagnostic " + "census above)"); + } + } +#endif } else { st.glAvailable = false; std::fprintf(stderr, @@ -1132,10 +1159,17 @@ bool ac4SetupImpl(const Config& cfg, Ac4State& st) { #if defined(__linux__) // TEMPORARY DIAGNOSTIC: the thread census after the GL context is // up (vs the startup census — did the GL init spawn the workers?). + // Like the startup check, fail when workers are present so the + // census is visible in the CI job log. { const std::string line = "ac4 diagnostic: post-GL threads=[" + Ac4ThreadNames() + "]\n"; (void)!::write(STDERR_FILENO, line.data(), line.size()); + if (Ac4ThreadCount() > 1) { + ac4Fail(std::to_string(Ac4ThreadCount() - 1) + + " worker thread(s) present after GL init (diagnostic " + "census above)"); + } } #endif } else { @@ -1476,6 +1510,22 @@ int main(int argc, char** argv) { return 1; } +#if defined(__linux__) + // TEMPORARY DIAGNOSTIC: for the ac4 suite, fail the run if worker + // threads are present, so the census is visible in the CI job log + // even when the segfault does not reproduce (ctest only shows + // output of failing tests). + if (cfg.suite == "ac4" && Ac4ThreadCount() > 1) { + std::fprintf(stderr, + "ac4 diagnostic: %zu worker thread(s) present at " + "startup — failing so the census is visible in the " + "job log\n", + Ac4ThreadCount() - 1); + std::fflush(stderr); + return 1; + } +#endif + const Suite* suite = nullptr; for (const Suite& s : kSuites) if (s.name == cfg.suite) suite = &s; From a11d5c831443d37df141a543e189665e086f76f1 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 12:37:45 +0200 Subject: [PATCH 12/14] Fix the ac4 exit segfault: drain pending GL work before context destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (CI evidence, runs 38543888405 + 38548562965): the exit segfault hits llvmpipe's worker pool threads (the /proc census named them llvmpipe-0..3; the crash tids 4209/4211/4212 are pool threads, main is 4208). The software rasterizer executes submitted GL work on that pool; the ac4 process destroys the EGL context — and dlclose's the driver — while the pool may still be inside it, so the pool threads segfault. The crash is timing-dependent (Heisenbug): it does not reproduce under gdb, under ASan, at 20 frames, or on every lane. render-50k escapes by luck of its longer per-frame drain tail. Fix: GlContext::Impl::destroy() now makes the context current and glFinish()es (draining all pending work, per the GL spec) before eglDestroyContext / glfwDestroyWindow, on both backends. One-shot teardown cost, never on the frame loop. The laige-bench crash handler (TID + backtrace, -Wl,--export-dynamic) stays TEMPORARILY for one verification run and is removed with it. Local 4-tree gate green (GL entries CI-only). --- src/laige-render/gl_context.cpp | 17 ++++++ tools/bench/laige-bench.cpp | 91 +-------------------------------- 2 files changed, 18 insertions(+), 90 deletions(-) diff --git a/src/laige-render/gl_context.cpp b/src/laige-render/gl_context.cpp index 2318a70..73e64af 100644 --- a/src/laige-render/gl_context.cpp +++ b/src/laige-render/gl_context.cpp @@ -251,6 +251,18 @@ struct GlContext::Impl { void destroy() { if (backend == Backend::egl) { + // Drain all GL work submitted to this context before destroying + // it: the software rasterizer (llvmpipe, the P0 CI headless + // path) executes submitted work on a worker pool, and the + // context — and, below, the driver library itself — must not be + // torn down while a pool thread is still inside it (the pool + // threads then segfault during exit; M2-AC-01 CI evidence). + // glFinish is one-shot teardown work, never on the frame loop. + if (eglContext != nullptr) { + egl.bindApi(egl::kOpenGlApi); + egl.makeCurrent(eglDisplay, nullptr, nullptr, eglContext); + glad_glFinish(); + } if (egl.destroyContext != nullptr && eglContext != nullptr) { egl.destroyContext(eglDisplay, eglContext); } @@ -268,6 +280,11 @@ struct GlContext::Impl { #endif } else { if (window != nullptr) { + // The same drain, on the windowed backend (driver command + // queues are asynchronous on every real GPU): the context is + // destroyed with the window, and must be idle first. + glfwMakeContextCurrent(window); + glad_glFinish(); glfwDestroyWindow(window); // destroys the context with it } } diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index db4dd57..7d049ca 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -145,7 +145,6 @@ #if defined(__linux__) #include #include -#include #include #include #include @@ -199,44 +198,6 @@ namespace { std::_Exit(128 + sig); } -// TEMPORARY DIAGNOSTIC: dump the process's thread names from -// /proc/self/task (identifying the component that spawns the worker -// threads seen at exit). Returns "tid=name tid=name ...". -std::string Ac4ThreadNames() { - std::string out; - DIR* dir = ::opendir("/proc/self/task"); - if (dir == nullptr) return out; - while (const struct dirent* entry = ::readdir(dir)) { - if (entry->d_name[0] == '.') continue; - char commPath[288]; - std::snprintf(commPath, sizeof(commPath), "/proc/self/task/%s/comm", - entry->d_name); - char comm[128] = {0}; - if (std::FILE* file = std::fopen(commPath, "r")) { - if (std::fgets(comm, sizeof(comm), file) != nullptr) { - const std::size_t len = std::strlen(comm); - if (len > 0 && comm[len - 1] == '\n') comm[len - 1] = '\0'; - } - std::fclose(file); - } - out += entry->d_name; - out += "="; - out += comm; - out += ' '; - } - ::closedir(dir); - return out; -} - -// TEMPORARY DIAGNOSTIC: count the process's threads (/proc/self/task). -std::size_t Ac4ThreadCount() { - std::size_t n = 0; - DIR* dir = ::opendir("/proc/self/task"); - if (dir == nullptr) return 1; - while (::readdir(dir) != nullptr) ++n; - ::closedir(dir); - return n >= 2 ? n - 2 : 0; // drop "." and ".." -} #endif // The parsed command-line configuration (defined before the suites — @@ -770,23 +731,6 @@ bool render50SetupImpl(const Config& cfg, Render50State& st) { render50Fail("bindAtlas failed for the UI widget atlas"); } } -#if defined(__linux__) - // TEMPORARY DIAGNOSTIC: the render-50k control census (this - // workload exits cleanly on CI — compare its thread state against - // the ac4 one). Fails when workers are present, for log - // visibility (same reasoning as the ac4 check). - { - const std::string line = - "ac4 diagnostic: render-50k post-GL threads=[" + - Ac4ThreadNames() + "]\n"; - (void)!::write(STDERR_FILENO, line.data(), line.size()); - if (Ac4ThreadCount() > 1) { - render50Fail(std::to_string(Ac4ThreadCount() - 1) + - " worker thread(s) present after GL init (diagnostic " - "census above)"); - } - } -#endif } else { st.glAvailable = false; std::fprintf(stderr, @@ -1156,22 +1100,6 @@ bool ac4SetupImpl(const Config& cfg, Ac4State& st) { ac4Fail("bindAtlas failed for the ac4 atlas " + std::to_string(id)); } } -#if defined(__linux__) - // TEMPORARY DIAGNOSTIC: the thread census after the GL context is - // up (vs the startup census — did the GL init spawn the workers?). - // Like the startup check, fail when workers are present so the - // census is visible in the CI job log. - { - const std::string line = - "ac4 diagnostic: post-GL threads=[" + Ac4ThreadNames() + "]\n"; - (void)!::write(STDERR_FILENO, line.data(), line.size()); - if (Ac4ThreadCount() > 1) { - ac4Fail(std::to_string(Ac4ThreadCount() - 1) + - " worker thread(s) present after GL init (diagnostic " - "census above)"); - } - } -#endif } else { st.glAvailable = false; std::fprintf(stderr, @@ -1496,8 +1424,7 @@ int main(int argc, char** argv) { { const std::string line = "ac4 diagnostic: main tid=" + - std::to_string(static_cast(::syscall(SYS_gettid))) + - " threads=[" + Ac4ThreadNames() + "]\n"; + std::to_string(static_cast(::syscall(SYS_gettid))) + "\n"; (void)!::write(STDERR_FILENO, line.data(), line.size()); } ::signal(SIGSEGV, &Ac4CrashHandler); @@ -1510,22 +1437,6 @@ int main(int argc, char** argv) { return 1; } -#if defined(__linux__) - // TEMPORARY DIAGNOSTIC: for the ac4 suite, fail the run if worker - // threads are present, so the census is visible in the CI job log - // even when the segfault does not reproduce (ctest only shows - // output of failing tests). - if (cfg.suite == "ac4" && Ac4ThreadCount() > 1) { - std::fprintf(stderr, - "ac4 diagnostic: %zu worker thread(s) present at " - "startup — failing so the census is visible in the " - "job log\n", - Ac4ThreadCount() - 1); - std::fflush(stderr); - return 1; - } -#endif - const Suite* suite = nullptr; for (const Suite& s : kSuites) if (s.name == cfg.suite) suite = &s; From 1bcf3a1d252944b58e849adcdfa53208a40c74b0 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 12:54:18 +0200 Subject: [PATCH 13/14] Stop dlclose'ing the EGL driver (live worker threads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glFinish drain alone did not fix the exit segfault (run 38550914045: a llvmpipe worker still segfaulted after the drain). The remaining hazard is the dlclose itself: the driver runs a worker pool it never joins, and dlclose'ing a library with live threads inside it is undefined behavior (its fini may wake the workers into unmapped pages). The dlopened libEGL handle is now kept for the process's remaining lifetime (one per created context; reclaimed at exit) — the glFinish drain before eglDestroyContext stays. Same one-time crash-handler diagnostic still in laige-bench for verification. Local 4-tree gate green. --- src/laige-render/gl_context.cpp | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/laige-render/gl_context.cpp b/src/laige-render/gl_context.cpp index 73e64af..8c8fba5 100644 --- a/src/laige-render/gl_context.cpp +++ b/src/laige-render/gl_context.cpp @@ -269,15 +269,17 @@ struct GlContext::Impl { if (egl.destroyDisplay != nullptr && eglDisplay != nullptr) { egl.destroyDisplay(eglDisplay); } - // libEGL is dlopened only on the Linux headless path (createHeadless - // #else branch); on Windows/macOS eglHandle is always null and - // is not included (see the include guard above), so the - // close is compiled only where the handle can exist. -#if !defined(__APPLE__) && !defined(_WIN32) - if (eglHandle != nullptr) { - dlclose(eglHandle); - } -#endif + // libEGL is dlopened only on the Linux headless path + // (createHeadless #else branch); on Windows/macOS eglHandle is + // always null and is not included (see the include + // guard above). The handle is NOT closed, even though the + // platform permits it: the driver (llvmpipe on the P0 CI + // runners) runs a worker pool it never joins, and dlclose'ing a + // library with live threads inside it is undefined behavior — + // the pool threads segfault during exit (M2-AC-01 CI evidence, + // the crash tids were llvmpipe workers). The mapping leaks for + // the process's remaining lifetime (bounded: one handle per + // created context; the process reclaims it at exit). } else { if (window != nullptr) { // The same drain, on the windowed backend (driver command From fd04a1f6c1c58890700012d971e41f467fc2c43d Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Wed, 14 Oct 2026 13:29:37 +0200 Subject: [PATCH 14/14] Record the M2-AC-01 acceptance: baseline, budgets, roadmap, references, final cleanup - docs/benchmarks/baselines/m2-ac4.md (the thirteenth baseline): the AC-4.x acceptance checklist, the first CI measurement (run 38526109942: 11.1168 / 12.6959 / 11.5452 / 12.1453 ms mean on the four lanes, draw_calls 4, zero allocation violations, 37.5% margin on the reference lane), and the root-cause record of the ac4 exit-time segfault (llvmpipe worker pool vs the context teardown/dlclose). - budgets.json: sprites_10k_frame measured 0 -> 12.1453 (the worse backend on the CI reference (Clang) lane, the M2-PERF-01 convention). - The two ac4 golden references (ac4_iso.png, ac4_side_view.png, 1280x720) from the label-gated golden-references job: the two-run bootstrap completes, the golden entries go green. - roadmap: M2-AC-01 checked + Shipped (incl. the segfault fix summary), changelog row, Progress Board M2 31/33 (total 78/194; the board's stale Total row corrected). - Final cleanup: the temporary crash handler, thread census, main-tid print, linux include block, and the --export-dynamic link option are removed with the fix. Local 4-tree gate green: gcc 121/121, clang 121/121, asan 118/118, tsan 118/118 (GL-creating entries CI-only per the GPU policy). --- budgets.json | 2 +- docs/benchmarks/baselines/README.md | 14 ++ docs/benchmarks/baselines/m2-ac4.md | 148 ++++++++++++++++++++++ roadmap/M2-rendering-2.5d.md | 54 +++++++- roadmap/README.md | 5 +- tools/bench/CMakeLists.txt | 7 - tools/bench/laige-bench.cpp | 59 --------- tools/golden/references/ac4_iso.png | Bin 0 -> 56894 bytes tools/golden/references/ac4_side_view.png | Bin 0 -> 37018 bytes 9 files changed, 219 insertions(+), 70 deletions(-) create mode 100644 docs/benchmarks/baselines/m2-ac4.md create mode 100644 tools/golden/references/ac4_iso.png create mode 100644 tools/golden/references/ac4_side_view.png diff --git a/budgets.json b/budgets.json index 863eac9..c43a32f 100644 --- a/budgets.json +++ b/budgets.json @@ -47,7 +47,7 @@ "metric": "mean", "unit": "ms", "target": 16.7, - "measured": 0, + "measured": 12.1453, "workload": "10 000 overlapping sprites (100 x 10 grid, 10 stacked per cell), whole frame sorted + drawn offscreen at 60 FPS (M2-AC-01 AC-4.3, PRD 4)" }, { diff --git a/docs/benchmarks/baselines/README.md b/docs/benchmarks/baselines/README.md index ce44c6a..c2cf25c 100644 --- a/docs/benchmarks/baselines/README.md +++ b/docs/benchmarks/baselines/README.md @@ -116,3 +116,17 @@ Recorded so far: `sprites_50k_draw_calls`; supersedes nothing (`measured` was 0); calibrates `sprites_50k_cpu` `target` 2.0 → **100.0 ms** in the same PR (PRD §8.1 v0.5), `measured` 0 → **60.4450** and **14**. +- [m2-ac4.md](m2-ac4.md) (M2-AC-01, 2026-10-14) — the PRD §4 AC-4.x + acceptance record: the `ac4` / `render-sort-10k` workload (10 000 + overlapping sprites, 100×10 grid, 10 stacked per cell, four (atlas, + material, blend) groups, 4 draw calls/frame, whole 60 FPS frame at + 1280×720 offscreen software GL, both backends, 3000 measured frames): + 12.1453 ms mean on the CI reference lane (worse backend fp32_pinned; + worst across lanes 12.6959 ms) against the 16.7 ms frame budget — + 37.5% margin, zero per-frame allocation, exactly 4 draw calls. + **The thirteenth baseline** — first recording of + `sprites_10k_frame`; supersedes nothing (`measured` was 0); carries + the AC-4.1…AC-4.4 checklist and the root-cause record of the ac4 + exit-time segfault (llvmpipe worker pool vs the context + teardown/dlclose — fixed by the `glFinish` drain in + `GlContext::Impl::destroy` + the end of the EGL driver `dlclose`). diff --git a/docs/benchmarks/baselines/m2-ac4.md b/docs/benchmarks/baselines/m2-ac4.md new file mode 100644 index 0000000..b795ea9 --- /dev/null +++ b/docs/benchmarks/baselines/m2-ac4.md @@ -0,0 +1,148 @@ +# Baseline: `m2-ac4` — first measurement of the PRD §4 AC-4.3 +# 60 FPS frame budget (`sprites_10k_frame`) + the AC-4.x acceptance record + +Recorded by the **M2-AC-01** step (2026-10-14). This is the **thirteenth** +baseline file; it is immutable (methodology §4 — superseding it later adds a +new file, it is never edited). It is the **first recording** of the +`sprites_10k_frame` budget (`measured` was 0 = "not yet measured" per +methodology §4) and **supersedes nothing**; it records the AC-4.x +acceptance checklist required by the step's Verify ("AC checklist +recorded in `docs/benchmarks/baselines/m2-ac4.md`"). + +## Why this baseline exists + +M2-AC-01 lands the PRD §4 AC-4.x acceptance criteria in executable form. +The `ac4` suite of `laige-bench` (the alias `render-sort-10k` is the same +workload under the dedicated suite name the AC-4.3 scope uses) sorts and +draws 10 000 overlapping sprites in one 60 FPS frame for 4000 frames +(1000 warm-up + 3000 measured, both SimMath backends, offscreen 1280×720, +software GL): the workload is a 100 × 10 cell grid (1000 cells at their +centers, x in −49.5..49.5, y in −4.5..4.5), 10 sprites stacked per cell at +the SAME position and step height `(gx >> 4) + (gy >> 4)` (0..6) — the +sort's equal-key ties (10-way, broken by declaration order per RENDER-003) +and the frame's 10× self-overdraw both exercised — scale 1.0, two atlases +× two blends = four (atlas, material, blend) groups, the engine-owned +`isoDepthKey` per item (backend-quantized), the Iso engine default +(the ADR 0005 preset) at the grid-fit scale k = 1/128. One measured sample +is one whole frame (the AC-4.3 "sorted + drawn at 60 FPS" — the batch stage +beginFrame + 10 000 adds + build, with the M1-ALLOC-01 zero-allocation watch +armed around it — a violating frame aborts the run, exit 1, CORE-008 — +plus the submit stage's offscreen render). The `sprites_10k_frame` sample +is the whole frame's CPU ms (mean ≤ 16.7 ms, the §8.1 60 FPS frame budget); +the frame's draw-call count is asserted to be exactly 4 in teardown +(RENDER-001's one-draw-per-group contract). The suite's setup additionally +asserts the AC-4.4 engine default: `ProjectionView{}.mode == Iso` (ADR +0005) — a default drift is a loud failure, not a baseline shift. + +## The AC-4.x acceptance checklist + +| AC | What it requires | Where it is proven | Status (this PR) | +|---|---|---|---| +| AC-4.1 | One engine build runs a side-view and an isometric scene, differing only in config | The `ac4_iso` / `ac4_side_view` golden fixtures (the SAME 96-sprite world + 3-widget UI pass, one binary, config-only difference) + the GL-free `GoldenFixtures.Ac4*` tests (scene shape, config-only diff, bit-exact camera matrices) | `ctest -R golden` green (Linux llvmpipe lanes; the two new references committed in this PR, bootstrapped two-run: run 38526109942's `golden-references` job rendered both, in-run determinism compare all four fixtures failing=0 max_diff=0 budget=9; the verification run compares all four against the committed references) + `golden_compare` green in every tree | +| AC-4.2 | No sim/physics/pathing/network code depends on the projection mode | `tools/laige-surface-lint`: no `ProjectionMode` mention in the `laige-sim` / `laige-net` public surface (exit 0 OK / 1 violation / 2 structural), CTest entries `ac4_surface*` (fixture trees for each exit path + the real tree) in every PR and merge run | `ctest -R ac4` green (all four entries, every tree) | +| AC-4.3 | 10k overlapping sprites sorted + drawn at 60 FPS on CI reference hardware | The `ac4` / `render-sort-10k` bench suites: `laige_bench_ac4_fpx16` / `_fp32` CTest entries (Linux, canonical 3000/1000, `--budget=sprites_10k_frame`), in the `ci:slow:bench-50k` label-gated jobs | All four entries PASS the 16.7 ms mean budget (worst mean 12.70 ms, g++ fp32) — the first CI measurement table below | +| AC-4.4 | Iso picking exact at all zooms; depth-key rebuild within §8.1; isometric is the template default | The existing M2-ISO-03 picking tests (`iso_picking`) and M2-ISO-02 depth-table tests (`iso_depth_table`, the `iso_depthkey_rebuild` budget entry) — both green in this PR's runs; the engine default (`ProjectionView{}.mode == Iso`) asserted in the ac4 suite's setup (the identity line's `default=Iso` field) | `iso_picking` + `iso_depth_table` green; the setup assertion runs on every ac4 entry (a drift fails the run) | + +## First CI measurement (run 38526109942, commit e387be3, 2026-10-14) + +Reference environment: ubuntu-24.04, CMake Debug, g++ (the linux-gcc job) +and clang++ (the linux-clang job), Mesa surfaceless EGL + llvmpipe software +GL, offscreen 1280×720, 4000 frames per entry (1000 warm-up + 3000 +measured), the canonical 3000/1000 shape. + +| Entry (job) | backend | frame mean (ms) | frame min (ms) | frame max (ms) | draw_calls (max) | budget (16.7 ms mean) | +|---|---|---|---|---|---|---| +| laige_bench_ac4_fpx16 (g++) | fixed_point_16_16 | 11.1168 | 7.5567 | 26.3255 | 4 | PASS | +| laige_bench_ac4_fp32 (g++) | float_pinned_32 | 12.6959 | 8.7657 | 30.7213 | 4 | PASS | +| laige_bench_ac4_fpx16 (clang) | fixed_point_16_16 | 11.5452 | 7.1321 | 19.2588 | 4 | PASS | +| laige_bench_ac4_fp32 (clang) | float_pinned_32 | 12.1453 | 8.1677 | 24.9973 | 4 | PASS | + +Zero-allocation: no abort on any of the 16 000 frames (4 entries × 4000 +frames) — the batch stage allocated nothing per frame (FR-2.2/PERF-003). +AC-4.4 assertion: the identity line's `default=Iso` printed on every entry +(a default drift would have failed the run in setup). + +A later pre-fix run (38543888405) recorded the same shape on different +runners (clang fpx16 mean 11.9165, clang fp32 10.1502, g++ fp32 9.85123 — +all PASS); its numbers are not part of the reference table above (the +table records the first complete measurement, per the M2-PERF-01 +convention). + +## The exit-time segfault (root-caused and fixed in this PR) + +During the first measurement runs (38526109942 through 38540997015) every +`laige_bench_ac4_*` entry SEGFAULTED on the CI Linux lanes **after** the +full report printed — in the exit path, not the measured region. +Investigation (this PR's history): + +- The crash is frame-count-dependent (a 20-frame run exits cleanly on CI; + 3000 frames crashes) and a Heisenbug (clean under gdb, under ASan, and on + the developer machine). +- An in-process SIGSEGV/SIGBUS capture (temporary, removed with the fix) + showed the crash hits **three worker threads, not the main thread** + (crashed tids 4209/4211/4212 vs main 4208), with no usable backtrace. +- A temporary `/proc/self/task` census named the workers: + **`llvmpipe-0..3`** — Mesa's software-rasterizer worker pool, spawned by + the EGL context creation. The render-50k entries (same driver, same + process shape) exit cleanly — the race is timing-luck, which is why the + crash does not reproduce deterministically. +- Root cause: the driver executes submitted GL work on that pool, and the + engine tore the context down (and `dlclose`d the driver library) while + pool threads could still be inside it. Two fixes, both in + `GlContext::Impl::destroy()`: (1) `glFinish()` — make the context + current and drain all pending work before `eglDestroyContext` (GL-spec + guarantee: after `glFinish` returns, all previously issued work is + complete); (2) **stop `dlclose`ing the dlopened libEGL handle** — a + library with live, never-joined threads must not be closed (dlclose'ing + under live threads is undefined behavior; the driver's fini could wake + the workers into unmapped pages). The handle is kept for the process's + remaining lifetime (one per created context; the kernel reclaims it at + exit). Both backends (EGL and GLFW windowed) get the drain. +- Verification: run 38552829408 (commit 1bcf3a1) — **all eight bench + entries (4 ac4 + 4 render-50k) PASS on all four Linux lanes**, no + segfault. The render-50k budget numbers are unchanged by the fix (the + drain is one-shot teardown work, never on the frame loop). + +## The `measured` value + +`budgets.json`: `sprites_10k_frame` `measured` 0 → 12.1453 — the latest +recorded value, the worse of the two backends on the reference (Clang) +lane (fp32 12.1453 vs fpx16 11.5452), per the M2-PERF-01 convention. +`target` 16.7 unchanged (the PRD §8.1 60 FPS frame budget — the first +recording of this budget, not a calibration of a pre-existing one). + +## Margin + +The target (16.7 ms mean) sits 37.5% above the worst recorded mean on the +reference (Clang) lane (12.1453 ms) and 31.5% above the worst across both +lanes (12.6959 ms, g++ fp32). Per the runner-degradation policy (methodology +§5): a future gate failure above 16.7 ms is evidence of reference-runner or +toolchain degradation (or a real regression, disambiguated by the identity +line), the remedy is a budget revision through the same process, never a +silent target move. + +## Job durations + +The four slow-bench jobs of the first measurement run (each runs one +render-50k entry plus one ac4 entry) finished in 586 s (g++ fpx16), 597 s +(g++ fp32), 595 s (clang fpx16) and 611 s (clang fp32); the verification +run's four jobs finished in 614 s, 661 s, 301 s and 559 s — all well inside +the 30-min cap. The ac4 entry itself is ≈25–50 s per entry on the CI +runners, so it stays on the gated side of the 30 s threshold until a +master run re-derives the split. + +## Open items + +- The side-view fixture's world pass is degenerate by documented geometry + (`SpriteItem` carries the 2D ground-plane position, no elevation field + — no new engine API added, CORE-004): all z = 0 sprites project to + zero-height quads under the z = 0 reference plane. The AC-4.1 + "differing only in config" is asserted on the scene (the + `GoldenFixtures.Ac4ConfigsOnlyDiffer` test), not on the world raster; a + platformer vertical scene needs the M3 elevation work. +- If the gate ever fails a P0 Linux job above 16.7 ms, the evidence + indicates reference-runner or toolchain degradation (or a real engine + regression — disambiguated by the identity line and the zero-allocation + record) — the remedy is another budget revision through this same + process (new baseline file + `budgets.json` + PRD revision + change + log), not a silent target move. diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index be5cfd1..33adaff 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -612,7 +612,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. (1851 → 1852 symbols). No `budgets.json` entry (the count stays 17). -- [ ] **M2-AC-01 · AC-4.x acceptance suite** +- [x] **M2-AC-01 · AC-4.x acceptance suite** - **Refs:** PRD §4 AC-4.1 … AC-4.4 - **Depends:** all M2 functional steps - **Scope:** @@ -623,6 +623,58 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - Suite wired into CI as `laige-bench --suite=ac4` + `ctest -R ac4`. - **Verify:** `ctest -R ac4` and the AC-4 bench green; AC checklist recorded in `docs/benchmarks/baselines/m2-ac4.md`. - **Size:** test wiring + ~150 lines + - **Shipped** (2026-10-14, PR #99): **AC-4.1** — the golden tool + now renders multi-pass frames, and the `ac4_iso` / `ac4_side_view` + fixtures run the SAME 96-sprite world (the 12 x 8 grid at scale + 1.0, four (atlas, material, blend) groups of 24, the engine depth + keys) plus the SAME 3-widget UI pass under the two projection + configs (the ADR 0005 Iso preset at k = 0.06 / the SideView plane + camera), one engine build, config-only difference; the GL-free + `GoldenFixtures.Ac4*` tests pin the shared scene shape and the + config-only difference. The documented geometry note stands: + `SpriteItem` carries the 2D ground-plane position (no elevation + field — no new engine API, CORE-004), so the side-view world pass + is degenerate by construction (all z = 0 sprites project to + zero-height quads under the z = 0 reference plane); the side-view + frame's visible content is the screen-space UI pass, identical in + both configs — the AC-4.1 assertion is on the scene, not the world + raster (a platformer vertical scene needs the M3 elevation work). + **AC-4.2** — `tools/laige-surface-lint` (Python stdlib, exit 0 OK / + 1 violation / 2 structural) proves no `ProjectionMode` mention in + the `laige-sim` / `laige-net` public surface (RENDER-006); CTest + entries `ac4_surface*` (fixture trees for each exit path + the + real tree, the `laige-include-lint` pattern) run in every PR and + merge. **AC-4.3 / AC-4.4** — the `ac4` / `render-sort-10k` bench + suites (10 000 overlapping sprites: 100 x 10 grid, 10 stacked per + cell — the sort's equal-key ties + the frame's overdraw — one + 60 FPS frame, the batcher zero-allocation assertion, and the + AC-4.4 engine-default `ProjectionView{}.mode == Iso` assert in + setup); CTest entries `laige_bench_ac4_fpx16` / `_fp32` (Linux, + canonical 3000/1000) in the `ci:slow:bench-50k` label-gated jobs + of both workflows. `budgets.json` gains `sprites_10k_frame` + (count 18 — the `BudgetHarnessTable` pin moves 17 → 18); + `laige-api.json` unchanged (no public-header change). `ctest -R + ac4` selects the bench + lint entries; the AC checklist and the + first CI measurement: `docs/benchmarks/baselines/m2-ac4.md` (the + two ac4 golden references were committed from this PR's + label-gated `golden-references` job — the M2-GOLD-01 two-run + bootstrap). **Exit-time segfault (root-caused and fixed here)** — + during the first measurement runs every ac4 entry segfaulted in + the exit path after the report (a Heisenbug: clean under gdb, + ASan, at 20 frames, and locally); the in-process capture showed + the crashed threads were Mesa's llvmpipe worker pool (the + `/proc` census named them `llvmpipe-0..3`), still inside the + driver when the engine destroyed the EGL context and `dlclose`d + the library. Fix in `GlContext::Impl::destroy()` (both + backends): make the context current and `glFinish`-drain all + pending work before `eglDestroyContext` / `glfwDestroyWindow`, + and stop `dlclose`ing the dlopened libEGL handle (a library with + live, never-joined threads must not be closed; the handle is kept + for the process's remaining lifetime). Verification run + 38552829408: all eight bench entries (4 ac4 + 4 render-50k) PASS + on all four Linux lanes; the temporary capture/census + diagnostics and the `--export-dynamic` link option were removed + with the fix. ## Template diff --git a/roadmap/README.md b/roadmap/README.md index ce3adc8..a18e6f2 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 30 | ⬜ in progress | +| M2 | 33 | 31 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **75** | | +| **Total** | **194** | **78** | | --- @@ -258,6 +258,7 @@ One line per completed (or split/renumbered) step. | 2026-10-12 | M2-PROF-01 | `—` / PR (open) | CPU/GPU render timing (RENDER-005 — CPU and GPU measured independently; M2-PROF-01 scope, nothing else): **API** — `SpriteBatcher` gained the per-frame CPU phase times (`SpriteBatchTiming` via `frameTiming()`: `batchMs` = the frame window, `beginFrame()`/create up to `build()` start — the declaration work; `sortMs` = `build()` itself; `timingTotals()` the since-construction sums, successful builds only); `SpriteRenderer::Options` gained `gpuTiming` (opt-in per-pass GPU time: ONE `GL_TIME_ELAPSED` query object created at `create` and reused every frame — no per-frame GL allocation, NO CPU/GPU synchronization (RENDER-005 — unlike the `primitiveQuery` diagnostic's `glFinish`)) + `stallThresholdUs` (documented default `kSpriteRendererDefaultStallUs` = 2000 us ≈ 12% of the default 60 Hz frame; domain `[1, 0xFFFFFFFF]`; the `create` validation, first failure wins, the `options_invalid` warn); `SpriteDrawStats` gained `submitMs` (the submit's CPU wall time), `uploadWallUs` / `uploadStalled` (the stall detection), and `gpuTimeUs` / `gpuTimeValid` (the GPU time is ONE FRAME LATE — frame N reports frame N−1's pass; `gpuTimeValid` false = "n/a" — the first frame, any frame the GPU is more than one frame behind, and every environment without working timing queries — the headless/offscreen fallback; never a fake number; a failed submit poisons only its own in-flight measurement); `SpriteDrawTotals` gained the `submitMs`, `uploadStallUs` / `uploadStallFrames`, and `gpuTimeUs` / `gpuTimeFrames` sums; the stall detection: the pass's only implicit CPU↔GPU sync point is the one `glBufferSubData` into the REUSED instance buffer — its CPU wall time above the threshold sets `uploadStalled` + ONE rate-limited Warn `sprite_renderer/submit_stall` (fields `stall_us`, `upload_bytes`) — observation, never an execution gate (the G-R2 precedent); **profiler feed** — the M1-PROF-01 `Profiler` gained the primitive-typed render feeds (`addRenderBatchMs` / `addRenderSortMs` / `addRenderSubmitMs` / `addRenderGpu` / `addRenderStall` / `setRenderTextureMemory` — `laige-sim` cannot depend on `laige-render` types, ARCH-001, so the render thread feeds the primitive values), feeding the `laige-profile render:` text line, the JSON report's `"render"` object (`gpu_us` is `null` until the first completed measurement), and the summary line's render fields (`render_gpu_us` renders `n/a` on the same rule); the VRAM estimate feeds through the pre-existing `textureMemoryBytes()` gauge; the engine's render-thread wiring lands with the engine integration step (the `addDrawCalls` feed precedent — the headless run feeds no render values); **tests** — NEW `tests/laige-render/render_timing_tests.cpp` (new CTest entry `render_timing` = the step's Verify command; 8 tests / 5 suites; `TIMEOUT 120`, the ASan `detect_leaks=0` + TSan `halt_on_error=1` entries): `RenderTimingCreate` (GL-free: the `stallThresholdUs` create validation — first failure wins, one `options_invalid` warn, the `option`/`value` fields pinned — + the stopped state reads zero), `RenderTimingCpu` (GL: the CPU phase times on the known small scene (10 sprites, 2 atlases, 2 blends → 3 groups), the EXACT since-construction totals accumulation, the empty frame records nothing (only the frame count moves), and the profiler feed wiring), `RenderTimingGpu` (GL: off-by-default "n/a" (no GL work); on: the one-frame-late semantics pinned under `glFinish`-paced frames (frame 1 n/a, frames 2/3 valid with exact totals)), `RenderTimingStall` (GL: a 4096-sprite frame (212 992 B upload) under a 1 us threshold stalls — one Warn per stalled frame with the pinned `stall_us`/`upload_bytes` fields + the stall totals; the 10-sprite frame under the default 2000 us threshold does not (0 warns)), and `RenderTimingSlowShader` (GL: the deliberately slow shader — a 16384-iteration DEPENDENT sin loop over a 128×128 FBO — with the GPU-side `TIME_ELAPSED` pass time EXCEEDING the CPU-side submit time on a hardware-GL runner, the machine-greppable `slow-shader gpu_us=… cpu_us=…` line; SKIPS on a software renderer (llvmpipe/swrast/Mesa) where the CPU is the GPU and the driver is synchronous — the observation is defined for the desktop CI runner); `profiler_tests.cpp` gained the render-timing counter-model tests (the exact accumulation + the "n/a" GPU contract, the disabled no-op preserving state, the greppable text/JSON report surfaces, the zero-alloc record path now covering the 6 render feeds); **docs** (DOC-003/006/007) — `docs/api/sprite_renderer.md` (NEW "Render timing (M2-PROF-01, RENDER-005)" section: the field table, the one-frame-late + "n/a" contract, the stall detection, the profiler feed, the Performance trap), `docs/api/sprite_batcher.md` (the `SpriteBatchTiming`/`timingTotals` fields + the API sketch), `docs/api/profiler.md` (the counter-table row, the summary-line shape, the `laige-profile render:` text line, the JSON `"render"` object, the zero-alloc test line), `src/laige-render/README.md` + `src/laige-sim/README.md` (the status paragraphs; the stale remaining-steps line fixed), `tools/README.md` (the report schema line); **API surface** — `laige-api.json` regenerated LAST (1815 → 1851 symbols, 48 headers); **budget** — no `budgets.json` entry (the count stays 17 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the timing is O(1) bookkeeping (two to four `steady_clock` reads per frame, no allocation, no GL work when `gpuTiming` is off) and the composite 50k render-CPU budget is measured with M2-PERF-01; **local verification** — the 4-tree Linux gate green: `gate-gcc` 117/117, `gate-clang` 117/117, `gate-asan` 112/112, `gate-tsan` 112/112 (the full unfiltered `ctest` minus the 10 GL-creating entries per the GPU local-execution policy — the CI lanes run them unfiltered; `render_timing` joins the GL-creating set), `tools/laige-include-lint` (80 source files) + `tools/laige-determinism-lint` (29 sim source files, 0 violations) green, `api-real-tree`/`api-check-fresh` in the gate; **size note** — beyond the roadmap's ~250-line guidance (the documented contract spans the renderer + batcher + profiler + five docs, per CORE-006/DOC-004); Progress Board M2 29/33 (total 75/194) | | 2026-10-13 | M0-CI-01 | `—` / PR (open) | **CI follow-up: slow suites moved to label-gated jobs** — measured on the latest master run 38312355303 (2026-10-12, all 11 jobs green): every ctest entry exceeding 30 s in a lane moved out of the main job into a dedicated job of its own (one per lane), run only when gated (the main job's `ctest` excludes it with `-E`, the slow job selects it with `-R`): `laige-sim_tests` (gcc 59.97 / clang 53.79 / asan 124.03 / tsan 159.52 s) and `laige-render_tests` (53.83 / 44.45 / 43.20 / 60.41 s) → new `linux-gcc-slow` / `linux-clang-slow` / `linux-asan-slow` / `linux-tsan-slow` jobs; `particle_render` (gcc 38.76 / clang 34.67 / tsan 39.56 s — asan 28.84 s stays in the main asan job) → the gcc/clang/tsan slow jobs; `zero_alloc` (asan 43.76 / tsan 54.75 s — gcc 16.16 / clang 14.39 s stay in the main jobs) and `ecs_stress` (asan 42.87 / tsan 54.70 s — gcc 16.34 / clang 14.45 s stay) → the asan/tsan slow jobs; `laige_bench_render_50k_fpx16` / `_fp32` (gcc 577.05 / 593.82 s, clang 578.42 / 566.52 s) → new `linux-gcc-bench` / `linux-clang-bench` jobs. No entry exceeds 30 s on windows/macOS (worst: `laige-sim_tests` 27.11 s on macos-intel), so those jobs keep the full suite unchanged. Each slow suite is gated by a repo label in the PR lane (`ci:slow:sim`, `ci:slow:render`, `ci:slow:particle`, `ci:slow:zero-alloc`, `ci:slow:ecs-stress`, `ci:slow:bench-50k` — created via the GitHub API) or a `workflow_dispatch` checkbox in the merge lane (`slow-sim` … `slow-bench-50k`). **Milestone gate** — a milestone-gate PR MUST carry ALL six `ci:slow:*` labels so every slow job runs and passes before merge (AGENTS.md §5, new "Slow CI suites and the milestone gate" section); after the gate merges, a `workflow_dispatch` on master with all six checkboxes re-runs the slow matrix. Timeouts: the main Linux jobs revert to 10 min (gcc/clang 45 and tsan 30 existed only for the moved suites), the slow suite jobs carry 20, the bench jobs 30 (the old 45-min rationale minus the rest of the suite). No engine code change — the two workflows + AGENTS.md §5 + the PRD §14 Perf row + this log line only | | 2026-10-13 | M2-GOLD-01 | `—` / PR (open) | Golden image test infrastructure (TEST-008 — golden tests, explicit tolerances; M2-GOLD-01 scope, nothing else): **API** — `GlContext` gained `readPixels(x, y, width, height, rgba)` (the rectangle readback beside `readPixel`: ONE `glReadPixels` per call, the same precondition contract — stopped / not-current-on-the-calling-thread / null buffer / the rectangle outside the render target → `InvalidArgument` — the caller's buffer must hold width × height × 4 bytes; (0, 0) is the GL bottom-left origin — `rgba` row 0 is the BOTTOM row, and top-origin / y-down callers (the golden frame capture) flip the rows themselves); **tool** — NEW `laige-golden` (tools/golden) renders the first two committed fixtures at the canonical 1280×720 frame with fixed cameras and no PRNG (the M2-SPRITE-02 1000-sprite scene — the 997-item lattice + the 3 probes declared last, atlases 0/1, the dimetric 2:1 @ 0.125f camera, the blue clear — and the M2-UI-02 UI scene — the smoke widgets' absolute positions on the 1280×720 screen rect, the identity UI matrix, the black clear, the committed test font's 1024² glyph atlas) offscreen (`GlContext::createHeadless` + `SpriteRenderer::submit`), flips the one `readPixels` capture to the top-origin golden frame convention, and compares against the committed reference PNGs with the EXPLICIT tolerance (max channel diff 2 INCLUSIVE — a diff of exactly 2 passes — + the 0.001% pixel budget = floor(pixels × pct / 100), 9 of 921 600 pixels on the canonical frame — passed as flags by the `golden` ctest entry, never hidden); `--update` re-renders the references atomically (temp file + rename) and is the human-approved regeneration path, NEVER run under ctest; **fixtures** — the scene builders are GL-free header-only (tests/golden/golden_fixtures.h — faithful ports of the M2-SPRITE-02 / M2-UI-02 smoke scenes; the sprite builder verified line-for-line against the smoke fixture, and the UI builder carries the font in a `unique_ptr` so the Text widgets' `const GlyphAtlas*` item pointers stay valid across the `UiScene` move — a moved member would dangle them into a silent zero-metrics glyph) and their shape invariants (the 1000 items, the group counts 598/201/201, the probe positions / uv / atlases, the EXACT camera matrices, the clear colors, the UI's 7 quads in 2 (atlas, material, blend) groups, the first quad's geometry) are pinned by the `GoldenFixtures*` gtest suite; **tests** — NEW ctest entries: `golden_compare` (the GL-free gtest — the comparison core: the documented defaults, identical frames, the diff-2-passes / diff-3-fails boundary, the max-over-channels-not-sum rule, the budget boundary (4 pass / 5 fail at 0.01% of 200×200), the bounded 32 failing-pixel samples; + the two fixture-shape suites — runs in the local gate and in every CI lane) and `golden` (the tool — `SKIP_RETURN_CODE 77` for the no-usable-OpenGL-3.3 environment contract (the macOS runners), `TIMEOUT 300`, the explicit tolerance flags; the Linux llvmpipe lanes run the real comparison (the Windows/macOS runners have no usable OpenGL 3.3 — the GL gtest suites GTEST_SKIP on the same runners — so the entry skips there); `ctest -R golden` selects BOTH entries, the unanchored regex, the documented tilemap precedent); **regeneration** — the PR lane gains the label-gated `golden-references` job of `ci-pull.yml` (label `ci:golden-update`, the bootstrap path — workflow_dispatch only sees workflows on the default branch, so a PR branch cannot be dispatched) and the NEW `workflow_dispatch`-only `.github/workflows/golden-update.yml` (the post-merge master path); both render the references with `--update`, re-run the comparison as the in-run determinism check, and print each reference PNG to the job log as single-line base64 between `GOLDEN_REFERENCE_BEGIN ` / `GOLDEN_REFERENCE_END ` markers for the committing human to decode, review, and commit (the PR lane cannot upload artifacts — the pull_request token is read-only — so the log is the transfer channel); `tools/golden/references/README.md` documents the reference environment (ubuntu-24.04, Debug, g++, Mesa surfaceless EGL + llvmpipe), the regeneration procedure, and the no-silent-tolerance-widening rule (a driver drift beyond the tolerance is a regeneration + human-approved recommit, never a flag change); **deps** — `stb_image` / `stb_image_write` join the existing pinned stb dependency (the laige-stb target compiles the three implementations; deps.lock re-hashed; PRD §11's stb row already covers both headers — the vendored count stays 5); **docs** — `docs/api/gl_context.md` (the readPixels row, the threading paragraph, the Performance section, the misuse warnings), `docs/testing.md` NEW §6 (the fixtures, the canonical frame, the tolerance contract, the ctest entries, the environment contract, the regeneration path, the command table), the module README status paragraph, `tools/README.md` (the laige-golden contract + the previously missing laige-scene-iso bullet); `laige-api.json` regenerated LAST (1851 → 1852 symbols, 48 headers); **budget** — no `budgets.json` entry (the count stays 17 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin); **local verification** — the 4-tree Linux gate green: `gate-gcc` 119/119, `gate-clang` 119/119, `gate-asan` 114/114, `gate-tsan` 114/114 (the full unfiltered `ctest` minus the 10 GL-creating entries per the GPU local-execution policy — the CI lanes run them unfiltered; `golden` joins the GL-creating set — it creates the headless context), `tools/laige-include-lint` (82 source files) + `tools/laige-determinism-lint` (29 sim source files, 0 violations) green, `api-real-tree` / `api-check-fresh` in the gate; **verification** — the reference PNGs were rendered on CI (the GPU local-execution policy) by the label-gated `golden-references` job (run 38468040338, in-run determinism compare failing=0 / max_diff=0) and committed from that job's log through the human-approved decode/review path — the first PR runs' expected `golden` failure (missing references) was the documented two-run bootstrap; Progress Board M2 30/33 (total 76/194) | +| 2026-10-14 | M2-AC-01 | `—` / PR #99 (open) | AC-4.x acceptance suite (PRD §4 AC-4.1…AC-4.4 in executable form): **AC-4.1** — the golden tool renders multi-pass frames and the NEW `ac4_iso` / `ac4_side_view` fixtures run the SAME 96-sprite world (12×8 grid, four (atlas, material, blend) groups of 24, engine depth keys) plus the SAME 3-widget UI pass under the two projection configs (the ADR 0005 Iso preset at k = 0.06 / the SideView plane camera), one engine build, config-only difference; the GL-free `GoldenFixtures.Ac4*` tests pin the shared scene shape and the config-only difference (the side-view world pass is degenerate by documented geometry — `SpriteItem` carries the 2D ground plane, no elevation field, no new engine API, CORE-004; the AC-4.1 assertion is on the scene, not the world raster); the two reference PNGs committed from this PR's label-gated `golden-references` job (the two-run bootstrap; in-run determinism compare failing=0 max_diff=0 budget=9). **AC-4.2** — NEW `tools/laige-surface-lint` (Python stdlib; no `ProjectionMode` mention in the `laige-sim` / `laige-net` public surface; exit 0 OK / 1 violation / 2 structural) + CTest entries `ac4_surface*` (fixture trees for each exit path + the real tree) in every PR and merge run. **AC-4.3 / AC-4.4** — the `laige-bench` `ac4` suite (alias `render-sort-10k`): 10 000 overlapping sprites (100×10 grid, 10 stacked per cell at the SAME position — the sort's equal-key ties + the frame's 10× self-overdraw), four (atlas, material, blend) groups, exactly 4 draw calls/frame asserted, whole 60 FPS frame offscreen 1280×720 software GL, both SimMath backends, canonical 3000 measured + 1000 warm-up, the M1-ALLOC-01 zero-allocation watch armed around the batch stage, and the AC-4.4 engine-default assert (`ProjectionView{}.mode == Iso`, ADR 0005) in setup; CTest entries `laige_bench_ac4_fpx16` / `_fp32` (Linux non-sanitizer, canonical shape, `--budget=sprites_10k_frame`, TIMEOUT 1800, `LAIGE_BUDGETS_PATH`) in the `ci:slow:bench-50k` label-gated jobs of both workflows. **Budget** — first measurement of `sprites_10k_frame` (the PRD §4 AC-4.3 / §8.1 60 FPS frame budget, 16.7 ms, no PRD revision — the target is pre-existing): first CI measurement (run 38526109942, commit e387be3): g++ fpx16 11.1168 / g++ fp32 12.6959 / clang fpx16 11.5452 / clang fp32 12.1453 ms mean (n=3000 each, draw_calls 4 on every entry, zero allocation violations), all PASS with 37.5% margin on the reference (Clang) lane; `budgets.json` gains `sprites_10k_frame` (`target` 16.7, `measured` 0 → **12.1453** = the worse backend on the CI reference (Clang) lane, the M2-PERF-01 convention; entry count 17 → 18, the `BudgetHarnessTable` pin moves 17 → 18). **Exit-time segfault — root-caused and fixed** — during the first measurement runs every ac4 entry SEGFAULTED in the exit path after the full report printed (a Heisenbug: clean under gdb, under ASan, at 20 frames, and locally); the temporary in-process capture showed the crashed threads were NOT the main thread but Mesa's llvmpipe worker pool (the temporary `/proc/self/task` census named them `llvmpipe-0..3`), still inside the driver when the engine destroyed the EGL context and `dlclose`d the library (dlclose'ing a library with live, never-joined threads is undefined behavior); fix in `GlContext::Impl::destroy()` (both backends): make the context current and `glFinish`-drain all pending work before `eglDestroyContext` / `glfwDestroyWindow`, plus the end of the EGL driver `dlclose` (the dlopened libEGL handle is kept for the process's remaining lifetime — one per created context, reclaimed at exit); verification run 38552829408 (commit 1bcf3a1): all eight bench entries (4 ac4 + 4 render-50k) PASS on all four Linux lanes; the temporary capture/census diagnostics and the `--export-dynamic` link option removed with the fix. **Baseline** — NEW `docs/benchmarks/baselines/m2-ac4.md` (the thirteenth baseline: the AC-4.x checklist, the first CI measurement table, the segfault root-cause record, the `measured` value) + the baselines index entry. **Docs** — `docs/testing.md` §6 (the ac4 suite + the lint entries), `tools/README.md` (the surface lint + the ac4 suite), `docs/benchmarks/budget_harness.md` (the suite table), the golden references README (the two ac4 fixtures). **Verification** — local 4-tree gate green on every push this step (final gate: gcc 121/121, clang 121/121, asan 118/118, tsan 118/118 — GL-creating entries excluded per the GPU local-execution policy, CI-only), `tools/laige-include-lint` + `tools/laige-determinism-lint` green, `laige-api.json` unchanged (no public-header change); CI runs 38526109942 (first measurement), 38543888405 + 38548562965 (diagnostic), 38552829408 (verification — all eight bench entries PASS, the `golden` entries green against the committed references); `ctest -R ac4` selects exactly six entries (2 bench + 4 lint). Progress Board M2 31/33 (total 78/194 — the board's stale Total row corrected) | --- diff --git a/tools/bench/CMakeLists.txt b/tools/bench/CMakeLists.txt index d1abbe9..da69a04 100644 --- a/tools/bench/CMakeLists.txt +++ b/tools/bench/CMakeLists.txt @@ -30,13 +30,6 @@ laige_apply_engine_policy(laige-bench) laige_apply_simmath_policy(laige-bench) target_link_libraries(laige-bench PRIVATE laige-core laige-sim laige-render) -# TEMPORARY DIAGNOSTIC (removed with the root-cause fix): export the -# dynamic symbol table so the in-process crash backtrace -# (Ac4CrashHandler in laige-bench.cpp) prints function names. -if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - target_link_options(laige-bench PRIVATE -Wl,--export-dynamic) -endif() - # The report's "build" context field records the build type (AGENTS 12); # CMake stamps it so the binary does not have to guess. target_compile_definitions(laige-bench PRIVATE diff --git a/tools/bench/laige-bench.cpp b/tools/bench/laige-bench.cpp index 7d049ca..8a23d1f 100644 --- a/tools/bench/laige-bench.cpp +++ b/tools/bench/laige-bench.cpp @@ -136,20 +136,6 @@ #include "laige/sim_math.h" // SimMath backends (laige-core) #include "laige/sim/system.h" // SystemDef, LAIGE_SYSTEM, Io -// TEMPORARY DIAGNOSTIC (removed with the root-cause fix): the ac4 -// entries segfault at process exit on the CI Linux lanes but do not -// crash under gdb, under ASan, or locally (a Heisenbug), so capture -// the crashing stack in-process. -O0 Debug keeps frame pointers for -// backtrace(); --export-dynamic (a temporary CMake option below) -// gives backtrace_symbols real names. -#if defined(__linux__) -#include -#include -#include -#include -#include -#endif - // The benchmark's workload component (FR-1.2, G-R8): the dynamic // body's 2D velocity in the selected SimMath backend's Vec2. The // marks specialize engine trait templates (laige::detail) and @@ -171,35 +157,6 @@ LAIGE_DETERMINISM_SAFE( namespace { -#if defined(__linux__) -// TEMPORARY DIAGNOSTIC: the in-process exit-time segfault capture -// (see the include above). The backtrace goes to stderr (the CI job -// log under ctest --output-on-failure); the default handler is -// restored and the signal re-raised, so the original crash outcome -// is unchanged. -[[noreturn]] void Ac4CrashHandler(int sig) { - // The crashed thread id matters: if it is not the main thread, the - // crash is inside a library worker racing with the exit path. - char line[128]; - std::snprintf(line, sizeof(line), - "ac4 diagnostic: fatal signal %d in exit path, " - "crashed tid=%ld, stack:\n", - sig, static_cast(::syscall(SYS_gettid))); - (void)!::write(STDERR_FILENO, line, - static_cast(std::strlen(line))); - void* frames[64]; - const int n = backtrace(frames, 64); - backtrace_symbols_fd(frames, n, STDERR_FILENO); - ::signal(sig, SIG_DFL); - ::raise(sig); - // The re-raised signal terminates the process (default disposition); - // reach here only if it was blocked or ignored — terminate directly - // (the process is dying either way, no cleanup to run). - std::_Exit(128 + sig); -} - -#endif - // The parsed command-line configuration (defined before the suites — // the sim-tick suite's setup callback reads the selected backend). struct Config { @@ -1415,22 +1372,6 @@ int main(int argc, char** argv) { using laige::Histogram; using laige::TimeIt; -#if defined(__linux__) - // TEMPORARY DIAGNOSTIC: capture the exit-time segfault stack (the - // handler is process-wide, so it is still in force during the - // static-destruction phase after main returns). The main thread id - // is printed here so the crashed tid in the handler line can be - // compared against it. - { - const std::string line = - "ac4 diagnostic: main tid=" + - std::to_string(static_cast(::syscall(SYS_gettid))) + "\n"; - (void)!::write(STDERR_FILENO, line.data(), line.size()); - } - ::signal(SIGSEGV, &Ac4CrashHandler); - ::signal(SIGBUS, &Ac4CrashHandler); -#endif - Config cfg; if (!parseArgs(argc, argv, cfg)) { usage(stderr); diff --git a/tools/golden/references/ac4_iso.png b/tools/golden/references/ac4_iso.png new file mode 100644 index 0000000000000000000000000000000000000000..12ef654f4066ef79a1931513a5b594e7ca059bb5 GIT binary patch literal 56894 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 literal 0 HcmV?d00001 diff --git a/tools/golden/references/ac4_side_view.png b/tools/golden/references/ac4_side_view.png new file mode 100644 index 0000000000000000000000000000000000000000..2c806880c66c6030cab6fa49de2748ccf0916ea0 GIT binary patch literal 37018 zcmeI4F=!J}7{_1EG{%sW5Jb+9s##PJ;lzMZ6l{~`lEETYw2P8h3c*RCf+C_RG*U=w z!9}_Tgf1=$Iyjt&0jGN4R8d>)P;jt_)()}l8O+?TgGWhz9j+a&TweR``~Uv$`@c7K zw$BU&q5-8;D3?v2Q>w%HQG4h2Ip1dPe7vC4WFeRCK7aG_;Lb^7WyT0(Qu%~edMOY2 zjq%#IqQ6qMq?e^o$@WN>v+K&A<<&Q%-Ce05r<+Dz|Z!N01;*UMjXM{?Hm{#3(uln}3d^qLn{z|7anD4T2G(Jl$ZuLxm z*4O%v4X+-Zc>H`5zZ&vf9kmt%mi;UtdLtUMn%9DN+?|3oaPZfMyEXgg5&7hFX6S%Z zPdny{v#~AsrnXesacM9WE#g}<2OrhLD@$Ka_P#o>uaJz^(NHwt&hHX7S+M_% z@EqYe!gH40D#sLH0k8mA04x9&01JQxzye?aHavZcj3yb)zsIg&0XRd0Gc<(f71C{{ z+f28aZZo2ZXd;@3rf9v1W)saOnoT%OPAeBI%QbIdGY~yZdYbe!>1m1>vr#qyO+XXS z1T+E7CPfNrC!h&v0-As(peZPp|84;^0Zl*?&;&FA%?*Qw%|tTU8L~5EXUNWwogq6z zc82VX$Zp~^ahfy%eV>AL^KgiL=(|OG!aciQ?%a1c>tUTz