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..c43a32f 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": 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)" + }, { "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/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/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/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/src/laige-render/gl_context.cpp b/src/laige-render/gl_context.cpp index 2318a70..8c8fba5 100644 --- a/src/laige-render/gl_context.cpp +++ b/src/laige-render/gl_context.cpp @@ -251,23 +251,42 @@ 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); } 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 + // 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/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..8ac829b 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 ac4Cfg = + (name == kAc4IsoFixtureName) ? kAc4IsoConfig : kAc4SideConfig; + auto sceneR = buildAc4Scene(ac4Cfg); + 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/golden/references/ac4_iso.png b/tools/golden/references/ac4_iso.png new file mode 100644 index 0000000..12ef654 Binary files /dev/null and b/tools/golden/references/ac4_iso.png differ diff --git a/tools/golden/references/ac4_side_view.png b/tools/golden/references/ac4_side_view.png new file mode 100644 index 0000000..2c80688 Binary files /dev/null and b/tools/golden/references/ac4_side_view.png differ 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))