diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0b09ed..534b9f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,9 +354,11 @@ jobs: - name: Synthetic scenario self-check run: ./build/bin/laige-detcheck --scenario=synthetic - name: Real scenario (M1-SAMPLE-01) - # Skipped until M1-SAMPLE-01 lands the hello scenario binary; - # M1-DET-04 replaces this step with the two-configuration - # comparison, e.g.: + # The hello template (samples/hello) is always built in this + # job, so this step always executes: two no-arg runs of the + # scenario — both must exit 0 and print the 301-line per-tick + # hash stream (the guard is defensive only). M1-DET-04 replaces + # this step with the two-configuration comparison, e.g.: # ./build/bin/laige-detcheck --run-a=build-asan/bin/hello # --run-b=build/bin/hello run: | diff --git a/.gitignore b/.gitignore index 47df9dd..c63d229 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ build-*/ Testing/ CMakeUserPresets.json +# Sample binaries (samples/hello builds into its own source-tree bin/ — +# the CI detcheck step invokes samples/hello/bin/hello from the repo +# root; M1-SAMPLE-01) +samples/*/bin/ + # Compiled artifacts (in case they ever land outside build trees) *.o *.obj diff --git a/CMakeLists.txt b/CMakeLists.txt index fbad70d..394e966 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -242,6 +242,13 @@ endfunction() add_subdirectory(src/laige-core) add_subdirectory(src/laige-sim) +# Samples (M1-SAMPLE-01): the hello.laige headless template game. +# Unconditional (not test-gated): the sample is a shipped project, and +# the CI detcheck step builds and runs it from the default tree. Its +# binary lands at the source-tree path samples/hello/bin/hello (its own +# CMakeLists sets RUNTIME_OUTPUT_DIRECTORY — see there why). +add_subdirectory(samples/hello) + # --------------------------------------------------------------------------- # Tests (gated by LAIGE_BUILD_TESTS; conventions in tests/README.md) # --------------------------------------------------------------------------- diff --git a/docs/README.md b/docs/README.md index 37b3f23..685c0e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,11 @@ temp+rename, size-bounded), `Engine::startReplayRecording`, and `laige-run --replay`; M1-DET-03: replay execution — `World::stateHash` (the deterministic state hash), `runReplay` (the identity-checked re-run and its per-tick hash stream), and the -`laige-replay` runner). +`laige-replay` runner; M1-SAMPLE-01: `hello.laige` — the headless +template game (samples/hello: one component, one system, one entity, +the per-tick `World::stateHash` stream on stdout, record → replay +through the scenario's own binary, the PRD §9.4 line budget — +[samples/hello/README.md](../../samples/hello/README.md)). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -35,6 +39,10 @@ still to land. status. Tool commands: `laige-run`, `laige-replay`, `laige-fuzz`, `laige-bench`, `laige-detcheck`, the `laige-api` manifest target, and the include-graph lint. +- [hello.laige](../../samples/hello/README.md) — the headless template + game (M1-SAMPLE-01): the canonical smallest-complete Laige game — + build it with the repository, run it headless, record and replay it, + and learn every component/system/tick mark a Laige game uses. ## Concepts diff --git a/docs/api/detcheck.md b/docs/api/detcheck.md index ea7a510..e22f9c5 100644 --- a/docs/api/detcheck.md +++ b/docs/api/detcheck.md @@ -14,9 +14,11 @@ state across configurations (NFR-8.3, FR-1.4). The engine state-hash API landed with **M1-DET-03** (`World::stateHash`, [api/entity.md](entity.md)); this tool works against the **hash-file output contract** defined below and compares two such streams. The -current scenario fixture still prints its own ad-hoc hash stream; the -scenario wiring that switches it to the `World::stateHash` stream lands -with M1-SAMPLE-01 (the tool's line-by-line comparison is unchanged). +real scenario now exists: **M1-SAMPLE-01**'s `hello` (samples/hello) +prints exactly this contract — the tick-0 line plus one +`World::stateHash` line per completed tick — and the CI job's real- +scenario step runs it; **M1-DET-04** activates the two-configuration +comparison of it (the tool's line-by-line comparison is unchanged). ## Scenario contract @@ -159,10 +161,9 @@ body index and a nudge in [-4, 3] applied to x. Per-tick hash: FNV-1a 64 and compiler by the language standard; no float anywhere in the workload. **Hash scope (M0):** tick counter + seed + body words. The Prng position is a pure function of (seed, nudge history) in this workload; the -definitive scope — including PRNG state and the exact hash function — -is M1-DET-03's `World::stateHash` -([api/entity.md](entity.md)), which the scenario wiring switches to -with M1-SAMPLE-01 (the tool's line-by-line comparison is unchanged). +definitive scope — including PRNG state and the exact hash function — is +M1-DET-03's `World::stateHash` ([api/entity.md](entity.md)), which the +`hello` scenario (M1-SAMPLE-01) prints directly on its stdout stream. ## Performance and bounds @@ -190,7 +191,12 @@ script asserting both the exit code and the required output fragments The `detcheck` CI job (`.github/workflows/ci.yml` and `ci-pull.yml`) runs the built-in self-check on every PR and merge (like `include-lint` and `api-manifest`, independent of the `ci:*` label selector — it is a -tooling check, not an additional P0 OS build). The real-scenario -comparison — two build configurations of M1-SAMPLE-01's `hello` — is -**skipped until that sample exists**; **M1-DET-04** activates it and -records the result per ARCH-010. +tooling check, not an additional P0 OS build). It also runs the **real +scenario** (M1-SAMPLE-01): both `--run-a` and `--run-b` point at +`samples/hello/bin/hello` (the no-arg 300-tick headless run — the step +activates once the sample binary exists, which it now does), so every +PR and merge executes the template game's detcheck contract: both runs +must complete (exit 0) and print the 301-line hash stream. **M1-DET-04** +replaces this same-configuration step with the two-configuration +comparison (`build-asan/bin/hello` vs `build/bin/hello`) and records the +result per ARCH-010. diff --git a/docs/api/replay.md b/docs/api/replay.md index ef039ef..115ebf3 100644 --- a/docs/api/replay.md +++ b/docs/api/replay.md @@ -349,10 +349,13 @@ laige-replay --log LOG --config CONFIG.json [--expect BASELINE] (the ADR 0003 bounded-read precedent). - **Scope:** `laige-replay` replays logs recorded by `laige-run --headless --replay` (the engine's built-in registration). A - game-scenario log is replayed by the scenario binary itself through - the same `runReplay` library (the M1-SAMPLE-01 scenario wires it; - M1-DET-04's detcheck `--run-a/--run-b` compares two scenarios' hash - streams). + game-scenario log is replayed by the scenario binary itself: it loads + the log (`loadReplay`), checks the replay identity against its own + (world, config) (`replayIdentityDiff` — a mismatch is a rejected + replay, never a silent divergence), and re-runs the log's frame count + through the same tick loop `runReplay` drives (M1-SAMPLE-01's + `hello --log` wires this; M1-DET-04's detcheck `--run-a/--run-b` + compares two scenarios' hash streams). ## Performance (PERF-002/003, LOG-003) diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index f78957b..fc43f77 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -38,6 +38,8 @@ The canonical-commands table in [roadmap/README.md](../../roadmap/README.md) | Benchmarks | `./build/bin/laige-bench --suite=` | | Determinism check | `./build/bin/laige-detcheck --scenario=` | | Replay runner | `./build/bin/laige-replay --log LOG --config CONFIG.json [--expect BASELINE]` | +| hello template (run) | `./samples/hello/bin/hello [--replay LOG]` | +| hello template (replay) | `./samples/hello/bin/hello --log LOG` | | API manifest | `cmake --build build --target laige-api` | | Include-graph lint + dependency count | `python3 tools/laige-include-lint` | | Determinism source scan (sim module) | `python3 tools/laige-determinism-lint` | @@ -58,7 +60,16 @@ Notes: and exits 1 at the first diverging tick — contract in [docs/api/replay.md](../api/replay.md)), and target `laige-api` (M0-TOOL-01). Their command forms were fixed here when they were - reserved, so no step can drift them. + reserved, so no step can drift them. The `hello` rows are the + M1-SAMPLE-01 template game (samples/hello): it builds into the + source-tree path `samples/hello/bin/hello` (its own CMake target + property — the CI detcheck step invokes it from the repository + root with no arguments), prints the 301-line per-tick state-hash + stream on stdout (the detcheck scenario contract — + [docs/api/detcheck.md](../api/detcheck.md)), and exits 0/1/2; the + `hello_*` CTest entries (`tests/sample`) are its CI form, and + `--replay` is a debug-build feature. Contract: + [samples/hello/README.md](../../samples/hello/README.md). Fuzz and randomized-test seeds: fixed default `0x1F055EED`, overridable (`laige-fuzz --seed=…`; tests via the `LAIGE_TEST_SEED` environment variable) — see [docs/testing.md](../testing.md). diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index 145d420..39ceebd 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -271,7 +271,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `laige-bench --suite=sim-tick` avg ≤ 3.0 ms and p99 ≤ 5 ms on both backends on the CI reference machine (if local machine differs, record measured value + CI is the gate); baseline file exists. - **Size:** ~200 lines + baseline doc -- [ ] **M1-SAMPLE-01 · `hello.laige` (headless template)** +- [x] **M1-SAMPLE-01 · `hello.laige` (headless template)** - **Refs:** NFR-13.5 (template < 100 lines), PRD §13 - **Depends:** M1-HEAD-01, M1-DET-03 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 02f0ecc..e5e2308 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 15 | 🚧 in progress (M1-DET-02) | +| M1 | 25 | 17 | 🚧 in progress (M1-SAMPLE-01) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -210,6 +210,7 @@ One line per completed (or split/renumbered) step. | 2026-09-15 | M1-HEAD-01 | `e80ccc8` / PR #31 | Headless engine run (FR-1.6, ARCH-003, AC-6.2; M1-HEAD-01 scope, nothing else): `Engine` (new public header `src/laige-sim/include/laige/sim/engine.h` + `src/laige-sim/engine.cpp`) — `EngineConfig` (`tickRateHz` 20–120 default 60, `entityCapacity` 0–65536 default 0 = empty scene, `churnPerFrameBudget` 0–4294967295 default 256) + `parseEngineConfig` over the bounded JSON (M0-CORE-07): unknown key → one `config/unknown_key` warn, ignored (forward-compatible); rejections `config/{not_an_object,tick_rate_invalid,entity_budget_invalid,churn_budget_invalid}` (first failure wins; one rate-limited warn each, NFR-13.3 5-field grammar); `Engine::create` pre-validates the tick rate, creates the `World`, registers `Position2DFpx16` FIRST (ARCH-010 stable component order; ADR 0002 default backend — math selection is M1-DET-01); `run_headless(maxTicks, frameBudgetTicks = kDefaultMaxCatchUpTicks)`: `scheduleSystems` → `GameLoop` (with the engine's per-tick hook — snapshot exists before the hook can fire) → first `frame()` (0 ticks, establishes the start reference) → `PresentationSnapshot` anchored on the loop's exact `startReferenceNs()` (ARCH-009) → wall-clock-paced frames (ONE `steady_clock` read per frame + one bounded sleep; the exact integer due computation, M1-LOOP-01) → the run **ALWAYS ends in the ordered shutdown** (CONC-006: loop → world clear → snapshot → world release → logging flush) — success or failure; the shutdown is IDEMPOTENT (double/triple shutdown safe; `world()` reads back `nullptr`; a second `run_headless` on a stopped engine → `InvalidArgument` with no log — the moved-out `GameLoop` precedent); `maxTicks == 0` = the server form (runs until the process ends); frame budget 1 → the bounded run lands EXACTLY on the target under any cadence (a late frame drops, never overshoots); lifecycle Info pair `engine/run_started`/`engine/run_finished` (structured fields incl. `status`; no logging on the healthy frame path — PERF-003/LOG-003); per-run setup = exactly three one-shot allocations (the `GameLoop` object, the `PresentationSnapshot` object, the 24 B/slot record table) and **zero per-frame allocations** — verified with the test-only `operator new` counter: the count is identical for 1/2/3/10 ticks (machine-greppable `engine-zeroalloc ticks=… allocs=3`; the M1-ALLOC-01 pool accounting supersedes the probe); `laige-run` binary (new `tools/run`, target `laige-run`): `--headless CONFIG` (1 MiB bounded read — over-bound `MalformedInput`, read error `IoError`), `--ticks N` (digits-only `strtoull`), `--replay LOG` **stub** (accepted, warned `replay/replay_deferred`, ignored — M1-DET-02), `--help`; exit codes 0 ok / 1 engine run failure / 2 usage-IO-config; one machine-greppable stdout summary `laige-run headless ticks=… dropped_ticks=… dropped_frames=… status=…`; the CLI calls `shutdown()` a second time (the idempotency demo); `laige_run_smoke` CTest entry (`--ticks 1000` against `tests/laige-sim/fixtures/headless_smoke.json` — 60 Hz, 10000 slots, churn 256; TIMEOUT 300, PASS_REGULAR_EXPRESSION `status=ok`, TSan `TSAN_OPTIONS=halt_on_error=1` — the step's CI Verify on every P0 OS job); `engine` CTest entry (20 tests: create + config validation + the JSON parse surface, the bounded run + loop accounting, the zero-frame-budget rejection (warn `loop/catchup_invalid`, engine still shut down), the stopped-state second run (no log), the double-shutdown idempotency ×2, the world release, the zero-alloc window; added to the TSan property list); docs (DOC-007, same change): new `docs/api/engine.md` (full contract: lifecycle, the provisional config surface, the run contract, presentation wiring, the determinism scope, the CLI + exit codes, the Performance section, misuse warnings) + cross-refs in `docs/README.md` (API list + M1 status line + the laige-sim doc list + the tool command list), `docs/getting-started/building.md` (the canonical `laige-run` command row + the tool-row note), `tools/README.md`; `laige-api.json` regenerated (555 → 573 symbols; +18: `Engine` + 9 members, `EngineConfig` + 3 fields, `parseEngineConfig`; `api-real-tree` green). **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the JSON config surface is **PROVISIONAL** (three unversioned keys; `parseEngineConfig` documented as provisional in `engine.md`, the header preamble, and this log line) — M1-CFG-01 owns the final versioned schema and will fold this parse in; local Verify: zero-warning 47/47 `ctest` on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang` 22.1.8, `build-release`, `build-shared`), `ctest -R engine` green (20/20), `ctest -R laige_run_smoke` green (≈16.7 s, `status=ok`), `tools/laige-include-lint` OK (33 source files, 1/10 vendored deps); Progress Board 13/25 (total 33/193) | | 2026-09-15 | M1-DET-01 | `56f2835` / PR #34 | Deterministic mode + sim math rules (FR-1.4, S-7, PRD §10.3; ARCH-010; M1-DET-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/determinism.h` — `SimMathBackend` (`FixedPoint16_16` default / `FloatPinned32`), `DeterminismConfig {enabled, math}`, the G-R8 compile-time trait (`detail::IsDeterminismSafe`: false by default; true for integers, enums, `fpx16_16`, `float` (the fp32_pinned Scalar), the four `SimMath::Vec2/Vec3`; `double` intentionally never safe — no backend uses it), `detail::areDeterminismSafeMembers` (the &&-fold), and `LAIGE_DETERMINISM_SAFE(Type, Members...)` (declares the member list IS the storage; a non-safe member — e.g. `double` — is a compile error AT THE MARK SITE, a new `static_assert` in the specialization, before any system can use the component); the trait is enforced by a third `static_assert` in `World::registerSystem` (entity.h) folding `detail::IoComponentSafety>` over the declared I/O, with an actionable message naming the fix and pointing at the docs; PRNG substreams wired: `World::Options` gains `seed`/`deterministic` (world state carried through create/move/assign; entity.cpp), `registerSystem` derives each system's substream `Prng::deriveSubstream(seed, systemId)` (id 0 = master, never assigned) into `detail::SystemRecord.rng` (`std::optional`), and `runSystems` hands the NON-const record's stream to `SystemContext.rng` (a new `Prng*` field, NSDMI — advanced in place during draws: the stream state IS the replay state); `EngineConfig` appends `seed` (full u64, `kDefaultSimulationSeed = 0`) + `DeterminismConfig determinism` (existing 3-member aggregate inits keep compiling); `parseEngineConfig` gains `seed` (0..2^53 — the ADR 0003 exact-double bound; 2^53+1 is indistinguishable from 2^53 and accepted as 2^53, 2^53+2 is the smallest rejectable value) + the `determinism` object (`enabled` bool, `math` ∈ the two ids; unknown nested key → one `config/unknown_key` warn, ignored — first failure wins across keys) with new rejection events `config/seed_invalid` / `config/determinism_invalid` / `config/determinism_enabled_invalid` / `config/determinism_math_invalid`; `Engine::create` forwards seed + mode to the world and registers the backend-matching built-in FIRST (`Position2DFpx16` / `Position2DFp32`) and builds the presentation snapshot for the same backend (type-erased `detail::PresentationHandle` — one setup allocation, the fnptr-deleter `unique_ptr` idiom, zero added allocations: the headless setup path stays exactly 3); `engine/run_started` gains `seed`/`determinism`/`math` fields (the `laige-run` CLI summary line is unchanged); `tools/laige-determinism-lint` (NEW; Python 3 stdlib, the `laige-include-lint` style) — the sim-source scan over `src/laige-sim/**`: D1a raw `float`/`double` type tokens, D1b float literals, D1c double literals, D2 `unordered_{map,set,multimap,multiset}`, D3 malformed exception markers; a char scanner strips `//`/`/* */` comments, string/char literals, and raw strings before matching (case-sensitive, word-bounded: `Float`/`fromFloat`/`next_float01` do not match); the documented false-positive policy = same-line `// LAIGE-DETERM-EXCEPTION: G-R8 ` markers (15 legitimate in-tree: the M1-SYS-03 wall-clock diagnostics, the presentation alpha conversion, the ADR 0003 JSON number policy, the trait's own `float` registration); every suppressed line is counted + printed (EXC-006: exceptions stay visible in every CI run); exit 0/1/2. Tests: `tests/laige-sim/determinism_tests.cpp` (suites `DeterminismMode`/`DeterminismEngine`/`DeterminismConfigParse`; CTest `determinism_mode` = the step's Verify command) — a trivial moving-entity sim (two entities, `Position2DFpx16` + a marked `DetVel` component, a mover system doing one `ctx.rng->next_range(0,5)` draw per tick at a fixed position + `pos += vel` through SimMathFpx16 ops only) produces BIT-IDENTICAL FNV-1a per-tick state hashes (tick + handle words + raw component words, each<> order) over 256 ticks in two consecutive runs (machine-greppable `determinism-tick-stream` line); a different seed diverges; the system's draws equal an independently constructed `Prng::deriveSubstream(seed, id)` exactly (golden cross-check) and two systems' streams are independent; `deterministic == false` → `ctx.rng == nullptr`; backend selection (fp32 config → `Position2DFp32` duplicate-rejected / `Position2DFpx16` available + 30-tick run completes; default → the inverse); the config keys (defaults, valid values, the rejection table incl. the 2^53 bound exactness, unknown-nested-key forward-compat, first-failure-wins). `tests/laige-sim/compile_fail/` (4 fixtures + `expect-compile-result.cmake.in`, CTest `trait_compile_*`): the positive fixture compiles (exit 0); the three negatives (a `double` member, an unmarked user struct, a `double` in the mark's member list) each FAIL to compile with the G-R8 message (exit-code + stderr-fragment assertions — an incidental compiler error cannot masquerade as the trait). `tests/tools` gains the `determinism-lint-*` fixture tests (clean tree with one marked exception → exit 0; one violation per rule → exit 1; real tree → exit 0) reusing the include-lint pattern; CI: `determinism-lint` job added to BOTH `.github/workflows/ci-pull.yml` and `ci.yml` (ubuntu-24.04, `python3 tools/laige-determinism-lint`). Docs (DOC-007, same change): NEW `docs/concepts/determinism.md` (the ARCH-010 scope statement — what is deterministic, at what scope, verified how, what it is not; the two-layer G-R8 enforcement; the exception policy; the PRNG substreams; the mode table; the provisional config surface) + NEW `docs/api/determinism.md` (the trait API contract) + updates to `docs/api/engine.md` (the seed/determinism keys, backend selection, run_started fields, the determinism scope), `docs/api/system_registry.md` (the `ctx.rng` bullet + the G-R8 validation row), `docs/api/entity.md` (the `World::Options` seed/deterministic fields), `docs/api/sim_math.md` (the G-R8 enforcement note), `docs/testing.md` (the determinism test entries), `docs/concepts/README.md`, `docs/README.md`, `src/laige-sim/README.md`. `laige-api.json` regenerated (573 → 588 symbols; +15: `SimMathBackend` + 2, `DeterminismConfig` + 2, `LAIGE_DETERMINISM_SAFE`, `World::Options` + 2, `SystemContext::rng`, `EngineConfig` + 2, `kDefaultSimulationSeed`; `api-real-tree` green). Verified: `ctest -R determinism_mode` green (14/14), `ctest -R trait_compile` green (4/4), `ctest -R determinism-lint` green (3/3), `python3 tools/laige-include-lint` OK, `python3 tools/laige-determinism-lint` OK (17 files, 15 marked exceptions), full `ctest` 55/55 on `build` (Debug GCC 16.2.1) and 55/55 on `build-asan` (ASan+UBSan leak-free); zero new warnings under NFR-8.10. **Deviation (surfaced, not silent):** declared dependency M1-CFG-01 has NOT landed — the `seed`/`determinism` keys sit on the PROVISIONAL `parseEngineConfig` surface (documented as provisional in engine.md, the header preamble, and this log line); M1-CFG-01 owns the final versioned schema. | | 2026-09-16 | M1-DET-02 | `817ebe9` | Replay recorder (FR-1.4, FR-11.3, PRD Appendix A — replay = input log + seed — ADR 0002, ARCH-007, SCALE-005; M1-DET-02 scope, nothing else; replay EXECUTION — `world.state_hash` + the `laige-replay` runner — is M1-DET-03): the versioned replay LOG FORMAT (v1; `src/laige-sim/include/laige/sim/replay.h` + `replay.cpp`): 40-byte header — magic `LGRP`, `formatVersion` u16 (= 1, the single version gate — unsupported versions rejected explicitly, ARCH-007), reserved u16, then the ADR 0002 REPLAY IDENTITY: seed u64, tickRateHz u32, `componentSchemaHash` u64, `mathBackendId` u32, `configHash` u64 — + per-tick frame records (tick u64, strictly sequential from 1; byteLength u32 ≤ `kMaxReplayFrameBytes` = 1 MiB; the payload an OPAQUE byte blob — M1 frames are zero-length, the input data shape lands with M3-INPUT-03) + 16-byte trailer (frameCount u64 + `fileHash` u64 = canonical byte-stream FNV-1a 64 over every prior byte — truncation and bit rot self-detected); all integers little-endian on every platform (SCALE-005: byte order specified, not assumed); the PARSER is total over malformed input: every structural violation (size < header, null data, bad magic, unsupported version, non-zero reserved, frame length over cap — checked before any overrun read, payload past the body, tick not previous+1, trailer count mismatch, fileHash mismatch, trailing garbage) is a `MalformedInput` Status — never a crash, never a silent skip (CORE-008/TEST-005); the IDENTITY HASHES are pure integers (ARCH-010: no addresses, no wall clock) — word-stream FNV-1a 64, big-endian byte order per u64 (the house convention: determinism state hashes, Prng golden vectors, laige-detcheck): `componentSchemaHash(World)` over [componentCount, then per registered type in id order: id, size, alignment] (a function of the REGISTRATION order — same types same order → same hash; stack-only, 769 words max, no allocation), `configHash(EngineConfig)` over [tag 1, tickRateHz, entityCapacity, churnPerFrameBudget, seed, determinism.enabled, determinism.math] (M1-CFG-01 refines the encoding with the schema, under the format's versioning), `makeReplayIdentity(World, EngineConfig)` assembles the header; the REPLAY RECORDER (`ReplayRecorder`, move-only: `create(identity, path, maxBytes)` → `writeFrame(tick, data, len)` → `finish()`): writes `path + ".tmp"` (same filesystem — the final `rename` is atomic; MSVC `MoveFileExA(MOVEFILE_REPLACE_EXISTING)`) and publishes `path` only on a successful finish — an interrupted/failed recorder leaves NO file at the final path (temp removed by the destructor; a rename failure leaves the temp for inspection, documented), SIZE-BOUNDED (maxBytes counts header + frames + trailer together; 0 = `kDefaultReplaySizeLimit` 128 MiB; a cap below `kMinReplaySizeLimit` = 56 rejected at create as `InvalidArgument`; a cap breach mid-run is `BudgetExhausted`, sticky — every later call returns the same Status; `finish()` on a cap that cannot fit the trailer is the same error), strict 1,2,3,... tick sequence (`InvalidArgument` otherwise), frame over 1 MiB `InvalidArgument`, empty path `InvalidArgument`, open/write/rename I/O `IoError`; the READER: `parseReplay(const uint8_t*, size)` (in-memory, O(size), one output allocation per frame) and `loadReplay(path, maxBytes = default)` (bounded read — a file larger than maxBytes is a `MalformedInput`, the ADR 0003 JSON-bound precedent; missing file `IoError`, empty path `MalformedInput`); the ENGINE WIRING (`engine.{h,cpp}`): `Engine::startReplayRecording(path, maxBytes)` — called ONCE, after all component/system registration, before `run_headless` (the identity is captured from the live world + config at call time — a later registration makes the recorded schema hash stale, the schedule-stale precedent, M1-SYS-02) — OPT-IN and DEBUG BUILDS ONLY (`NDEBUG` → `InvalidArgument` + one `replay/record_disabled` warn — recording is development tooling; the format and wiring exist in every build, the opt-in does not), disabled cost one null check per tick (PERF-003/LOG-003), enabled cost one bounded stdio write per completed tick (the explicit, opt-in, visible cost — PERF-002); the loop's `onTick` hook writes one ZERO-LENGTH frame per COMPLETED tick (M1: no input system yet — the frame bytes are the future input blob); a recording failure mid-run STOPS the run: `run_headless` returns the recorder's Status (≤ 1 frame of extra ticks, the loop's bounded frame contract), the log is NOT published (no partial file at the final path), and the ordered shutdown still runs (CONC-006); a successful bounded run finalizes (trailer + atomic rename) and logs `replay/record_finished` (Info: path, bytes, frames); a failed run's shutdown logs `replay/record_aborted` (Warn: path, bytes) and discards the temp; a second start is `InvalidArgument` + `replay/record_already_started` (Warn); a stopped engine's start is a no-op failure WITHOUT logging (the stopped-state precedent); accessors `replayRecordingActive()`/`replayBytesWritten()`; move ctor/assign transfer the active recorder (move-assign abandons the source's via shutdown); structured events, subsystem `replay` (NFR-13.3 5-field grammar, rate-limited where repeated): `record_started` (Info: path, size_limit, seed, math_backend, config_hash, schema_hash), `record_finished` (Info), `record_failed` (Error: path, tick, error), `record_aborted` (Warn), `record_already_started` (Warn), `record_start_failed` (Warn), `record_disabled` (Warn, release builds); the CLI (`tools/run/laige-run.cpp`): `--replay LOG` is now REAL (it was the M1-HEAD-01 stub — accepted, warned, ignored): laige-run registers no game components (the built-in registration is complete at `create`), so the identity capture is at the right phase; on a clean bounded run the log is atomically published at LOG; a start failure exits 2 (`laige-run: replay: {error text}`, the run did not happen), a mid-run failure exits 1 (the `status=` summary line carries the error name) with no partial log; usage text updated; the FUZZ TARGET (`tools/fuzz/laige-fuzz.cpp`): `replay_parse` (any Status ok — only a crash/sanitizer report fails the run, the runner contract) — the parser's malformed-input surface (TEST-005, NFR-8.7: the SCALE-005 parser fuzz requirement) — 1000 deterministic runs in `fuzz_replay_parse` (TIMEOUT 120, TSan `halt_on_error=1` in the TSan tree); the corpus gains a valid v1 replay log (68 bytes: header + one zero-length frame + a correct FNV-1a trailer, local encoder with the house FNV constants) as a mutate/truncate base (the runner's generate-mutate-truncate modes feed the parser real-shaped input); laige-fuzz now links `laige-sim` (arrows only downward, PRD §10.1; laige-core's headers come through it — CPP-010); the TEST SUITE (`tests/laige-sim/replay_record_tests.cpp`, 22 tests, CTest entry `replay_record` — the step's Verify command; added to the TSan property list; NFR-8.10 static_assert self-checks): `ReplayFormat` — the round trip (record 8 PRNG-payload frames → raw bytes → parse → every identity field + frame byte identical — the step's "record N ticks → parse back → identical bytes" clause; the `replay-roundtrip frames=… bytes=… filehash=…` machine-greppable line; also: file form via `loadReplay`, deterministic re-encoding byte-identical, the zero-frame log, the EXACT 1 MiB frame boundary, and the full malformed table (every truncation cut of a valid log, bad magic, unsupported version, non-zero reserved, a length field of 1 MiB+1 — the overrun-read-preventing branch, out-of-sequence tick, trailer count mismatch, a flipped body byte (fileHash branch), trailing garbage, empty input, null data)); `ReplayRecorder` — atomic publish (final file + no temp), interruption leaves NO file (temp removed by the destructor), the size limit at the EXACT boundary (cap 56: frame 1 fits 52 ≤ 56, frame 2 breaches 64 > 56 → `BudgetExhausted`; sticky failure; no partial file; machine-checked), the size limit AT FINISH (cap 67: both frames fit exactly, the 16-byte trailer cannot → `BudgetExhausted`), the tick sequence (first tick 2, repeated tick 1 → `InvalidArgument`), write-after-finish and the one-shot finish, create validation (empty path, cap 55 below the minimum, the 0 = default cap + header read-back), move transfers the file (source inert, destination finishes); `ReplayIdentity` — schema hash stable for the same registration order, different for a different order (`replay-identity schema-order h1=… h3=…` line), config hash per-field sensitivity (seed/tick rate/capacity/churn/enabled/math), `makeReplayIdentity` echoes seed/tick rate/math id + both hashes; `ReplayEngine` — 8-tick run with recording ON → 8 zero-length frames (ticks 1..8) + identity matching the pre-run world+config capture (the `replay-engine ticks=… bytes=… schema=…` line; 152 bytes = 40 + 8×12 + 16), the mid-run failure stop (cap 56 → `run_headless` returns `BudgetExhausted`, no partial log, engine shut down, sink: `record_failed` = 1 + `record_aborted` = 1 — sink read BEFORE `restoreLogger`, the engine_tests pattern), double start (`InvalidArgument` + `record_already_started` = 1), stopped-engine start (`InvalidArgument`, NO log — the stopped-state precedent, zero accessors); CMake (`src/laige-sim/CMakeLists.txt`, `tests/laige-sim/CMakeLists.txt`, `tools/fuzz/CMakeLists.txt`): `replay.cpp` into `LAIGE_SIM_SOURCES` (step comment), `replay_record_tests.cpp` into `LAIGE_SIM_TEST_SOURCES` + the `replay_record` entry (unquoted gtest filter `Replay*`, the M1 pattern) + the TSan property list, the fuzz target + entry; `laige-api.json` regenerated (630 symbols from 20 headers; `api-real-tree` green); DOCS in the same change (DOC-007): new `docs/api/replay.md` (the format spec — layout, the malformed-input table, the identity hashes, the recorder contract, the readers, the engine + CLI integration, the Performance section (disabled = one null check/tick; enabled = one bounded stdio write/tick; the 1 MiB frame + 128 MiB total bounds), misuse warnings, testing/CI) linked from `docs/README.md` (API list + the per-module list + the M1 progress summary + the compatibility bullet); `docs/api/engine.md` (new "Replay recording (M1-DET-02)" section, the M1-DET-02/03 scope split fixed, the `--replay` CLI bullet de-stubbed, the misuse bullet, the Testing+CI entries); `docs/concepts/determinism.md` ("What it is not (yet)": recording landed, execution = M1-DET-03, PRNG introspection joins M1-DET-03's `state_hash`, Related link); `docs/compatibility/README.md` (the replay log is the FIRST persistent engine format — added to the formats table: version 1, magic `LGRP`, strict validation); `docs/testing.md` (the `replay_parse` target + its command-table row); `tools/README.md` (`--replay` de-stubbed, the fuzz target list); `src/laige-sim/README.md` status updated; ROADMAP: M1-DET-02 box checked (`M1-heartbeat.md`), progress board M1 14→15 of 25, total 34→35 (this line's hash is the first commit of the two-commit change; the progress board update is in that commit). Local Verify: `ctest -R replay_record` green (22/22, Debug), full ctest 57/57 on `build` (Debug GCC), `build-asan` (ASan+UBSan — leak-free, the recorder's interrupted-state cleanup proven), and `build-tsan` (TSan `halt_on_error=1`, incl. `replay_record` + `fuzz_replay_parse`), `laige-fuzz replay_parse --runs=1000` + `json_parse --runs=1000` clean (default seed), `tools/laige-include-lint` OK (36 source files, 1/10 vendored deps), `laige-api` regenerated with `api-real-tree` green, zero new warnings under NFR-8.10. Untested paths: the MSVC `_fsopen`/`MoveFileExA` and AppleClang branches (CI-only), the release-build `record_disabled` branch (not exercisable in a Debug tree — the `NDEBUG` path is a compile-time constant), and a real > 128 MiB run (the size-limit branches are boundary-tested at 56/67 instead). Compat: additive only — the M1-HEAD-01 `--replay` stub behavior is replaced by the real recording (the flag's documented contract was always this step); no existing symbol or behavior changed. | +| 2026-09-17 | M1-SAMPLE-01 | `75d1df6` | hello.laige headless template game (NFR-13.5, PRD §9.4/§13, TEST-004, ADR 0002, ARCH-010, CORE-008): the first complete Laige game — samples/hello (hello.cpp: 99 code lines < the PRD §9.4 budget of 100, the ~76 NFR-13.5 comment lines do not count; one component PlayerPos + LAIGE_COMPONENT/LAIGE_DETERMINISM_SAFE at namespace scope, one system MovePlayer (LAIGE_SYSTEM; constant +1/tick per axis, wrapped in the [-16, 16] box), one entity at the box center; the canonical config (60 Hz / 8-entity / 256-churn / 0x1F055EED / fpx16_16) is embedded in the binary with config.json as its declarative record (hello_config_valid CTest runs laige-run --headless against it); the stdout stream is the detcheck scenario contract (tick-0 + one World::stateHash line per completed tick — 301 lines at 300 ticks, docs/api/detcheck.md), stderr = engine logging + the hello {headless|replay} ticks=N status=ok summary, exit 0/1/2; replay: --replay records its own run (debug builds only — the engine startReplayRecording policy parity, exit 2 under NDEBUG), --log loads the log, checks the replay identity against its own (world, config) via replayIdentityDiff, and re-runs the log's frame count through the same tick loop — record → replay is byte-identical (hello_replay CTest); laige-replay cannot replay a game log (it registers only the built-ins — different identity), so the scenario binary carries the mode (docs/api/replay.md); CI: tests/sample (9 CTest entries, TSan env blocks) — hello_scenario, hello_replay (Debug-gated), hello_replay_missing/_malformed, hello_mode_exclusive, hello_usage_unknown/_missing_value, hello_config_valid, hello_line_budget (CMake-core code-line counter: non-blank, non-// lines; semicolon-protected split, explicit whitespace class — CMake regexes have no POSIX classes); the detcheck job's real-scenario step self-activates (both no-arg runs must exit 0 — M1-DET-04 swaps in the build-asan vs build two-configuration comparison); docs: detcheck.md / replay.md / building.md / docs/README.md updated in the same change; local verification: Debug 76/76 + ASan 9/9 + Release gate + both CI forms byte-identical + determinism/include lints + api-manifest (no new public header) — additive only, no existing symbol or behavior changed. | --- diff --git a/samples/hello/CMakeLists.txt b/samples/hello/CMakeLists.txt new file mode 100644 index 0000000..392cc7a --- /dev/null +++ b/samples/hello/CMakeLists.txt @@ -0,0 +1,17 @@ +# hello.laige (M1-SAMPLE-01): the headless template game. +# +# The binary lands at the SOURCE-TREE path samples/hello/bin/hello +# (RUNTIME_OUTPUT_DIRECTORY override): the CI detcheck step +# (.github/workflows/ci.yml, "Real scenario (M1-SAMPLE-01)") invokes +# it from the repository root with no arguments — the canonical +# config is embedded in the source, so no CWD-relative file access. +# The build-tree convention (/bin/) is unchanged for every +# other target. +add_executable(hello hello.cpp) +laige_apply_engine_policy(hello) +laige_apply_simmath_policy(hello) +# laige-sim is the only internal dependency (PRD §10.1: arrows only +# downward); laige-core's public headers come through it (CPP-010). +target_link_libraries(hello PRIVATE laige-sim) +set_target_properties(hello PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bin) diff --git a/samples/hello/LICENSE b/samples/hello/LICENSE new file mode 100644 index 0000000..53868f1 --- /dev/null +++ b/samples/hello/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Laige Engine contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/samples/hello/README.md b/samples/hello/README.md new file mode 100644 index 0000000..0f7485c --- /dev/null +++ b/samples/hello/README.md @@ -0,0 +1,143 @@ +# hello.laige — the headless template game (M1-SAMPLE-01) + +The canonical Laige game shape in its smallest complete form (PRD §13, +NFR-13.5): one component (`PlayerPos`), one system (`MovePlayer`), one +entity (the player), deterministic by default. It is the reference +pattern for AI agents and new developers: every mark, declaration, +registration, and tick primitive a Laige game uses appears here, in +order. + +## Project files + +| File | Role | +|---|---| +| `hello.laige` | The project manifest (provisional M1 format: `laige.project` v1 JSON. The asset-pipeline format it replaces is owned by M3-ASSET-01). | +| `config.json` | The canonical sample config in the engine's declarative JSON surface (`laige-run`'s format, M1-HEAD-01). The values are identical to the config embedded in `hello.cpp` (see "Config" below). | +| `hello.cpp` | The game source (the only source). | +| `LICENSE` | Per-sample license (ADR 0001: each sample ships its own license; MIT, matching the repository). | + +## Build and run + +The sample builds with the repository (no separate configure): + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build -j +``` + +The binary lands at the **source-tree** path `samples/hello/bin/hello` +(a `RUNTIME_OUTPUT_DIRECTORY` override — the CI detcheck step invokes it +from the repository root without arguments). Run it from anywhere. Note +the path is shared across build trees: building a second tree (e.g. +`build-asan/`) overwrites it, so rebuild the tree you intend to run +before executing the binary. + +```sh +./samples/hello/bin/hello # 300 ticks, no replay +./samples/hello/bin/hello --replay LOG # 300 ticks + record LOG (debug builds) +./samples/hello/bin/hello --log LOG # replay LOG, print its hash stream +``` + +- **stdout** carries exactly the hash stream — nothing else: `N+1` + lines of ` ` (tick `0` = the initial state, then one line + per completed tick; the hash is 16 lowercase hex digits, the + `World::stateHash` value — the detcheck scenario contract, + [docs/api/detcheck.md](../../docs/api/detcheck.md)). `N` is 300 + (the M1 CI scenario length) or, in `--log` mode, the log's frame + count. +- **stderr** carries the one-line summary (`hello headless ticks=300 + status=ok` / `hello replay ticks=300 status=ok`) and diagnostics. +- **Exit codes:** `0` ok; `1` a run failure (a tick failed, a replay + write failed, or the log finalization failed); `2` usage, IO, or + replay-identity error (nothing was run). +- `--replay` and `--log` are mutually exclusive; an unknown option or a + missing option value is a usage error (`2`). + +## The game + +The player starts at the box center `(0, 0)` and moves 1 unit per tick +along the diagonal, wrapping in a 32-unit box (−16..16 on each axis): a +full crossing is 32 ticks (~0.53 s at 60 Hz). The step is exact in +`fpx16_16` (ADR 0002): one step crosses at most one wrap boundary. + +The file reads top to bottom in the order a game is built: + +1. **The component** — `PlayerPos` (the player's 2D position), marked + with `LAIGE_COMPONENT` and `LAIGE_DETERMINISM_SAFE` (FR-1.2, G-R8). +2. **The system** — `MovePlayer`, declared with + `LAIGE_SYSTEM(MovePlayer, 1)` (1 ms budget, FR-1.3) and declared + `Write` access on `PlayerPos`. +3. **The run** — `main` shows the setup phase (world creation, component + and system registration, entity creation + component placement, the + pre-run system schedule), the tick loop (one `beginFrame()` + one + `runSystems()` per tick — the `runReplay` shape), and the ordered + teardown (CONC-006: finalize the replay log only on success, clear + the world, shut down the logging facade). + +The tick loop uses the engine's own tick primitives rather than +`laige::Engine::run_headless`: the detcheck contract needs a +per-completed-tick state hash on stdout, and the M1 Engine owns its own +loop and exposes no per-tick hook. The Engine remains the runner for +the built-in (no-game) scenario (`laige-run`); this template is the +runner for a game scenario, and `laige-replay`'s scope note +([docs/api/replay.md](../../docs/api/replay.md)) documents the split. + +## Config + +The canonical sample config is **embedded** in `hello.cpp` (the +aggregate at the top of `main`): 60 Hz, scene budget 8, the engine +churn default (256), the house seed `0x1F055EED` +([docs/testing.md](../../docs/testing.md)), deterministic +`fpx16_16` — the only backend this template supports (selecting +`float_pinned_32` would make the config lie about the math the state +was computed in: the replay identity, ADR 0002). `config.json` is the +declarative record of the same values in the engine's JSON surface — +it is what `laige-run --headless samples/hello/config.json` consumes +and what the M1-DET-04 baseline tooling reads; the template binary +keeps its CLI minimal (CORE-004) and does not re-read it. + +## Replay + +`--replay LOG` records the run's replay log (zero-length M1 frames — +no input system exists yet) through `laige::ReplayRecorder` +([docs/api/replay.md](../../docs/api/replay.md)); the log's identity is +the world's component schema + the config (seed, tick rate, math +backend, config hash — ADR 0002). **Recording is a debug-build +feature** (matching the engine's `startReplayRecording` policy): a +Release (NDEBUG) binary rejects `--replay` with exit 2. + +`--log LOG` replays the log on a freshly set-up world: it loads the +log, checks the replay identity against this (world, config) — a +mismatch is a rejected replay (`2`), never a silent divergence — and +re-runs the log's frame count through the same tick loop, printing the +replayed hash stream. The replayed stream is bit-identical to the +original (determinism, S-7): the `hello_replay` CTest test asserts +byte-identity between the recorded and replayed runs. + +Note: `laige-replay` (the engine tool) replays only logs recorded by +`laige-run` (the engine's built-in registrations); a game scenario's +log is replayed by the scenario's own binary — which is why this +template carries the `--log` mode. + +## Tests + +`ctest --test-dir build -R '^hello'` (registered in `tests/sample`): + +| Test | Checks | +|---|---| +| `hello_scenario` | The CI scenario: no-arg run, exit 0, exactly 301 hash lines on stdout, `ticks=300 status=ok` on stderr. | +| `hello_replay` | Record (`--replay`) then replay (`--log`): exit 0, 301 hash lines, and the replayed stdout byte-identical to the original. | +| `hello_replay_missing` | `--log` on a missing log: exit 2, the `replay` error on stderr. | +| `hello_replay_malformed` | `--log` on a corrupt log (fixture): exit 2, the `replay` error on stderr. | +| `hello_mode_exclusive` | `--replay` + `--log` together: exit 2. | +| `hello_usage_unknown` | Unknown option: exit 2. | +| `hello_usage_missing_value` | A dangling option: exit 2. | +| `hello_config_valid` | `config.json` parses cleanly on the engine's config surface (`laige-run --headless` consumes it). | +| `hello_line_budget` | The game-code budget (PRD §9.4): non-comment, non-blank lines of `hello.cpp` < 100. | + +## Line budget (PRD §9.4, NFR-13.5) + +The game source is budgeted at **< 100 lines of code** (counted as +non-blank, non-comment lines of `hello.cpp`; the `hello_line_budget` +test enforces it). The file's comments carry the NFR-13.5 +"heavily commented" requirement and do not count against the budget. diff --git a/samples/hello/config.json b/samples/hello/config.json new file mode 100644 index 0000000..44d8965 --- /dev/null +++ b/samples/hello/config.json @@ -0,0 +1,10 @@ +{ + "tick_rate_hz": 60, + "entity_budget": 8, + "churn_per_frame_budget": 256, + "seed": 520445677, + "determinism": { + "enabled": true, + "math": "fixed_point_16_16" + } +} diff --git a/samples/hello/hello.cpp b/samples/hello/hello.cpp new file mode 100644 index 0000000..a7f8ea0 --- /dev/null +++ b/samples/hello/hello.cpp @@ -0,0 +1,176 @@ +// hello.laige (M1-SAMPLE-01) — the headless template game. +// +// The canonical Laige game shape (PRD §13, NFR-13.5): one component, +// one system, one entity, deterministic by default (S-7). It shows the +// component marks (FR-1.2, G-R8), the LAIGE_SYSTEM declaration (FR-1.3), +// the registration order (part of the replay identity — ADR 0002), and +// the per-tick state-hash stream (docs/api/detcheck.md). +// +// Usage: hello [--replay LOG] | hello --log LOG +// stdout N+1 ' ' lines (tick 0 = initial state, then one +// per completed tick; 16 lowercase hex) — nothing else +// stderr the one-line summary + diagnostics +// exit 0 ok, 1 run failure, 2 usage / IO / identity error +// +// N = 300 (the M1 CI scenario length, kScenarioTicks); in --log mode the +// log's frame count defines N. The config is the canonical sample +// config, embedded below and documented in config.json (identical +// values): the M1 template keeps the CLI minimal (CORE-004); the +// engine's CLI (laige-run) exercises the declarative config surface. +// +// The tick loop uses the engine's own tick primitives (one beginFrame() +// + one runSystems() per tick — the runReplay shape, laige/sim/ +// replay.h): the detcheck contract needs a per-completed-tick state hash +// on stdout, and the M1 Engine (run_headless) owns its loop and exposes +// no per-tick hook. The Engine remains the runner for the built-in +// scenario (laige-run, tools/run). Details: samples/hello/README.md. + +#include +#include +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/result.h" +#include "laige/sim/engine.h" // EngineConfig +#include "laige/sim/replay.h" // ReplayRecorder, loadReplay, identity + +using M = laige::sim::SimMathFpx16; // the ADR 0002 default backend + +// The game's only component (FR-1.2): the player's 2D position (PRD §4: +// the simulation is 2D). Marked a component and determinism-safe (G-R8): +// its single member is a SimMath vector. The marks specialize engine +// trait templates (laige::detail) and therefore live at namespace +// scope, outside the file's anonymous namespace. +struct PlayerPos { M::Vec2 pos{}; }; +LAIGE_COMPONENT(PlayerPos) +LAIGE_DETERMINISM_SAFE(PlayerPos, M::Vec2) + +namespace { + +// Named gameplay constants (CORE-005), world units: the player moves 1 +// unit per tick along the diagonal and wraps in a 32-unit box — a full +// crossing is 32 ticks (~0.53 s at the canonical 60 Hz). +constexpr std::uint32_t kScenarioTicks = 300; // the M1 CI scenario +constexpr M::Scalar kVelocity = M::Scalar::fromInt32(1); // 1 unit/tick +constexpr M::Scalar kBoxMin = M::Scalar::fromInt32(-16); +constexpr M::Scalar kBoxMax = M::Scalar::fromInt32(16); +constexpr M::Scalar kBoxSize = M::Scalar::sub(kBoxMax, kBoxMin); // 32 units + +// The game's only system (FR-1.3: plain function, 1 ms budget, declared +// Write on PlayerPos): advance each axis by the constant velocity and +// wrap inside the box. One step crosses at most one wrap boundary — +// exact in fixed point (ADR 0002). +LAIGE_SYSTEM(MovePlayer, 1) +void MovePlayer(laige::World&, laige::SystemContext& ctx) { + // A system returns nothing (FR-1.3): a failed each (a nested + // iteration or a write-during-read guard) warns through the logging + // facade in release and asserts in debug — the query guard's + // documented behavior (query.h), so the Status is cast away here. + static_cast(ctx.each([](laige::Entity, PlayerPos& p) { + auto stepAxis = [](M::Scalar& v) { + v = M::add(v, kVelocity); + if (v >= kBoxMax) v = M::sub(v, kBoxSize); + if (v < kBoxMin) v = M::add(v, kBoxSize); + }; + stepAxis(p.pos.x); + stepAxis(p.pos.y); + }, laige::Write{})); +} + +void report(const char* stage, laige::ErrorCode code) { + std::fprintf(stderr, "hello: %s: %s\n", stage, laige::errorText(code)); +} + +} // namespace + +int main(int argc, char** argv) { + std::string replayPath, logPath; + std::uint32_t ticks = kScenarioTicks; + if (argc % 2 == 0) { std::fprintf(stderr, "hello: an option is missing its value\n"); return 2; } + for (int i = 1; i < argc; i += 2) { + const std::string_view a = argv[i], v = argv[i + 1]; + if (a == "--replay") replayPath = v; + else if (a == "--log") logPath = v; + else { std::fprintf(stderr, "hello: unknown option '%.*s' (see samples/hello/README.md)\n", static_cast(a.size()), a.data()); return 2; } + } + if (!logPath.empty() && !replayPath.empty()) { std::fprintf(stderr, "hello: --log and --replay are exclusive\n"); return 2; } + + // 1. The canonical config (identical to config.json): 60 Hz, scene + // budget 8, the engine churn default, the house seed + // (docs/testing.md), deterministic fpx16_16 — the only backend this + // template supports (ADR 0002). + const laige::EngineConfig config{60, 8, 256, 0x1F055EED, {true, laige::SimMathBackend::FixedPoint16_16}}; + + // 2. World + the game's registration (the setup phase; the registration + // order is part of the replay identity — ADR 0002), then the player + // entity at the box center (the initial state the tick-0 line covers) + // and the pre-run system schedule (M1-SYS-02). + laige::World::Options o{config.entityCapacity, config.churnPerFrameBudget, config.seed, config.determinism.enabled}; + auto wr = laige::World::create(o); + if (wr.isError()) { report("world", wr.error()); return 2; } + laige::World world = std::move(wr).takeValue(); + const laige::Status setup = [&world] { + const auto c = world.registerComponent(); + if (c.isError()) return laige::Status(c.error()); + const auto s = world.registerSystem(MovePlayer_Def, laige::Io{}); + if (s.isError()) return laige::Status(s.error()); + const auto e = world.create(); + if (e.isError()) return laige::Status(e.error()); + return world.addComponent(e.value(), PlayerPos{}); + }(); + if (setup.isError()) { report("world", setup.error()); return 2; } + laige::SystemSchedule schedule; + if (auto ss = world.scheduleSystems(schedule); ss.isError()) { report("world", ss.error()); return 2; } + + // 3. Replay mode: load the recorded log and check its replay identity + // against this (world, config) (ADR 0002: a mismatch is a rejected + // replay, never a silent divergence). The log's frame count defines + // the run length. + if (!logPath.empty()) { + const auto lg = laige::loadReplay(logPath); + if (lg.isError()) { report("replay", lg.error()); return 2; } + if (!laige::replayIdentityDiff(lg.value(), world, config).empty()) { report("replay: identity mismatch", laige::ErrorCode::InvalidArgument); return 2; } + ticks = static_cast(lg.value().frames.size()); + } + + // 4. Run mode: optional replay recording (opt-in, DEBUG BUILDS ONLY — + // the engine's startReplayRecording policy; release rejects it). + std::unique_ptr recorder; + if (!replayPath.empty()) { +#if defined(NDEBUG) + std::fprintf(stderr, "hello: replay: recording is a debug-build feature (NDEBUG)\n"); + return 2; +#else + auto r = laige::ReplayRecorder::create(laige::makeReplayIdentity(world, config), replayPath, laige::kDefaultReplaySizeLimit); + if (r.isError()) { report("replay", r.error()); return 2; } + recorder = std::make_unique(std::move(r).takeValue()); +#endif + } + + // 5. The tick loop: the tick-0 line (initial state), then one line per + // completed tick — stdout carries nothing else. + std::printf("0 %016llx\n", static_cast(world.stateHash(0))); + laige::Status runStatus{}; + std::uint64_t completed = 0; + for (std::uint64_t tick = 1; tick <= ticks; ++tick) { + world.beginFrame(); + const laige::Status step = world.runSystems(schedule); + if (step.isError()) { runStatus = step; break; } + if (recorder != nullptr) { const laige::Status f = recorder->writeFrame(tick, nullptr, 0); if (f.isError()) { runStatus = f; break; } } + ++completed; + std::printf("%llu %016llx\n", static_cast(tick), static_cast(world.stateHash(tick))); + } + + // 6. Ordered teardown (CONC-006): finalize the log only on success (an + // unfinished recorder's destructor removes its temp file), clear the + // world, shut down the logging facade. + if (recorder != nullptr) runStatus = runStatus.ok() ? recorder->finish() : runStatus; + std::fprintf(stderr, "hello %s ticks=%llu status=%s\n", logPath.empty() ? "headless" : "replay", static_cast(completed), runStatus.ok() ? "ok" : laige::errorName(runStatus.error())); + static_cast(world.clear()); + laige::log::Logger::instance().shutdown(); + return runStatus.ok() ? 0 : 1; +} diff --git a/samples/hello/hello.laige b/samples/hello/hello.laige new file mode 100644 index 0000000..74eee2a --- /dev/null +++ b/samples/hello/hello.laige @@ -0,0 +1,7 @@ +{ + "format": "laige.project", + "version": 1, + "name": "hello", + "config": "config.json", + "sources": ["hello.cpp"] +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 779a85e..d7a658d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -89,3 +89,8 @@ add_subdirectory(detcheck) # Replay runner checks (M1-DET-03): laige-replay replays a recorded # log and compares the per-tick state hashes (tests/replay). add_subdirectory(replay) + +# hello.laige checks (M1-SAMPLE-01): the template game's scenario run, +# its record -> replay pipeline, its failure paths, its config, and +# its PRD §9.4 line budget (tests/sample). +add_subdirectory(sample) diff --git a/tests/sample/CMakeLists.txt b/tests/sample/CMakeLists.txt new file mode 100644 index 0000000..a8c8e75 --- /dev/null +++ b/tests/sample/CMakeLists.txt @@ -0,0 +1,163 @@ +# hello.laige CTest suite (M1-SAMPLE-01). +# +# The template game is tested end to end against the scenario contract: +# the no-arg 300-tick headless run (exactly what the CI detcheck job's +# "Real scenario" step executes, from the repository root), the +# record -> replay pipeline (a recorded run and its replay are +# byte-identical on stdout — the determinism proof behind M1-DET-04), +# the hash-line contract on stdout (tick 0 first; 16 lowercase hex; one +# line per completed tick), the usage/IO/identity failure paths (exit +# 2, actionable stderr), and the PRD §9.4 game-code line budget +# (< 100 lines of code in hello.cpp — the NFR-13.5 comment lines do not +# count). +# +# Each test is a generated `cmake -P` check script +# (expect-sample-result.cmake.in, the tests/replay + tests/detcheck +# pattern) that asserts the exit code AND the required stdout/stderr +# fragments — CTest inverts PASS_REGULAR_EXPRESSION with WILL_FAIL, so +# the content must be checked in the script. One run of 300 ticks is +# unpaced (no wall-clock delay — the template drives the tick loop +# directly), so every test is fast under any cadence. + +set(LAIGE_SAMPLE_TEST_ENV + "SAMPLE_HELLO=$" + "SAMPLE_CONFIG=${CMAKE_SOURCE_DIR}/samples/hello/config.json" + "SAMPLE_OUT_DIR=${CMAKE_CURRENT_BINARY_DIR}") + +# laige_sample_test(name [SETUP s] [CMD c] [SECOND_CMD c] [EXPECT_EXIT n] +# [EXPECT_LINES n] [EXPECT_IDENTICAL bool] +# [CHECK_OUT regex]* [CHECK_ERR regex]*) +# Builds one generated check script; CMD/SECOND_CMD may reference +# (expanded at run time from the environment). +function(laige_sample_test name) + set(oneValueArgs SETUP CMD SECOND_CMD + EXPECT_EXIT EXPECT_LINES EXPECT_IDENTICAL) + set(multiValueArgs CHECK_OUT CHECK_ERR) + cmake_parse_arguments(ARG "" "${oneValueArgs}" "${multiValueArgs}" + ${ARGN}) + # The template's @VAR@ names (configure_file @ONLY scope). + set(TEST_NAME "${name}") + set(SETUP_CMD "${ARG_SETUP}") + set(CMD "${ARG_CMD}") + set(SECOND_CMD "${ARG_SECOND_CMD}") + set(EXPECT_EXIT "${ARG_EXPECT_EXIT}") + set(EXPECT_LINES "${ARG_EXPECT_LINES}") + set(EXPECT_IDENTICAL "${ARG_EXPECT_IDENTICAL}") + set(_checks "") + foreach(_frag IN LISTS ARG_CHECK_OUT) + string(APPEND _checks " +if(NOT _check_out MATCHES \"${_frag}\") + set(_problems \"${_problems}; stdout missing fragment '${_frag}'\") +endif() +") + endforeach() + foreach(_frag IN LISTS ARG_CHECK_ERR) + string(APPEND _checks " +if(NOT _check_err MATCHES \"${_frag}\") + set(_problems \"${_problems}; stderr missing fragment '${_frag}'\") +endif() +") + endforeach() + set(CHECKS "${_checks}") + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-sample-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} + COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") + set_tests_properties(${name} PROPERTIES + ENVIRONMENT "${LAIGE_SAMPLE_TEST_ENV}" + TIMEOUT 300) + if(LAIGE_TSAN) + # The first race report is fatal (NFR-8.2 — the repo convention). + set_tests_properties(${name} PROPERTIES + ENVIRONMENT "${LAIGE_SAMPLE_TEST_ENV};TSAN_OPTIONS=halt_on_error=1") + endif() +endfunction() + +# 1) The CI scenario: a no-arg run from any CWD (the canonical config is +# embedded — no CWD-relative file access). Exit 0, exactly 301 hash +# lines on stdout (tick 0 = initial state + 300 completed ticks), the +# one-line summary on stderr. This is the exact invocation of the CI +# detcheck job's "Real scenario (M1-SAMPLE-01)" step. +laige_sample_test(hello_scenario + CMD "" + EXPECT_EXIT 0 + EXPECT_LINES 301 + CHECK_ERR "hello headless ticks=300 status=ok") + +# 2) The record -> replay pipeline: record a 300-tick run (--replay), +# then replay the log (--log): exit 0, 301 hash lines, and the +# replayed stdout byte-identical to the recorded run's stdout +# (determinism, S-7 — the bit-identity M1-DET-04 promotes to a +# two-configuration detcheck comparison). --replay is a debug-build +# feature (the engine's startReplayRecording policy, the #if +# defined(NDEBUG) gate in hello.cpp), so the test is registered only +# for debug builds (CI's P0 jobs are Debug) and pinned to the Debug +# configuration in multi-config trees. +if(NOT CMAKE_BUILD_TYPE MATCHES "^(Release|RelWithDebInfo|MinSizeRel)$") + laige_sample_test(hello_replay + CMD " --replay /hello_scenario.log" + SECOND_CMD " --log /hello_scenario.log" + EXPECT_EXIT 0 + EXPECT_LINES 301 + EXPECT_IDENTICAL true + CHECK_ERR "hello replay ticks=300 status=ok") + set_tests_properties(hello_replay PROPERTIES CONFIGURATIONS Debug) +endif() + +# 3) --log on a missing log: exit 2, the replay error on stderr. +laige_sample_test(hello_replay_missing + CMD " --log /does_not_exist.log" + EXPECT_EXIT 2 + CHECK_ERR "hello: replay:") + +# 4) --log on a corrupt log (the committed garbage fixture): exit 2, +# the replay error on stderr (a malformed log is a loud load failure, +# never a partial replay — CORE-008). +laige_sample_test(hello_replay_malformed + CMD " --log ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/garbage.log" + EXPECT_EXIT 2 + CHECK_ERR "hello: replay:") + +# 5) The modes are exclusive: exit 2. +laige_sample_test(hello_mode_exclusive + CMD " --replay /a.log --log /b.log" + EXPECT_EXIT 2 + CHECK_ERR "exclusive") + +# 6) Unknown option: exit 2, the actionable line on stderr. +laige_sample_test(hello_usage_unknown + CMD " --bogus value" + EXPECT_EXIT 2 + CHECK_ERR "unknown option") + +# 7) A dangling option (missing its value): exit 2. +laige_sample_test(hello_usage_missing_value + CMD " --replay" + EXPECT_EXIT 2 + CHECK_ERR "missing its value") + +# 8) config.json is a valid engine config: laige-run (the engine's CLI, +# the M1-HEAD-01 declarative surface) consumes it cleanly — a bounded +# 16-tick headless run exits 0. This keeps the declarative record in +# sync with the embedded config's format (the values themselves are +# documented identical in samples/hello/README.md). +add_test(NAME hello_config_valid + COMMAND laige-run --headless + ${CMAKE_SOURCE_DIR}/samples/hello/config.json + --ticks 16) +set_tests_properties(hello_config_valid PROPERTIES TIMEOUT 300 + PASS_REGULAR_EXPRESSION "status=ok") +if(LAIGE_TSAN) + set_tests_properties(hello_config_valid + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") +endif() + +# 9) The game-code line budget (PRD §9.4): < 100 lines of code in +# hello.cpp (non-comment, non-blank lines — the NFR-13.5 comment +# lines do not count). CMake-core script, no compiler involved. +add_test(NAME hello_line_budget + COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_SOURCE_DIR}/check-line-budget.cmake") +set_tests_properties(hello_line_budget PROPERTIES + ENVIRONMENT "SAMPLE_HELLO_SRC=${CMAKE_SOURCE_DIR}/samples/hello/hello.cpp") diff --git a/tests/sample/check-line-budget.cmake b/tests/sample/check-line-budget.cmake new file mode 100644 index 0000000..f278fac --- /dev/null +++ b/tests/sample/check-line-budget.cmake @@ -0,0 +1,50 @@ +# hello line-budget check (M1-SAMPLE-01; PRD §9.4, NFR-13.5). +# +# The game-code budget: the game source (samples/hello/hello.cpp) is +# < 100 lines of CODE. A code line is a line that is neither blank nor +# a // comment (the file uses only // comments): the NFR-13.5 +# "heavily commented" requirement lives in the comment lines and does +# not count against the budget (PRD §9.4: "< 100 lines of code"). +# Platform-independent: CMake core only, no compiler involved. + +cmake_minimum_required(VERSION 3.16) + +if(NOT DEFINED ENV{SAMPLE_HELLO_SRC}) + message(FATAL_ERROR "hello_line_budget: SAMPLE_HELLO_SRC environment " + "variable is not set (configure error)") +endif() + +file(READ "$ENV{SAMPLE_HELLO_SRC}" _content) +string(REPLACE "\r\n" "\n" _content "${_content}") +# Protect C++ semicolons before using the semicolon as the CMake list +# separator: a naive newline split would let every `;` in a line become +# an extra list element (and multi-`;` lines would count as several code +# lines). The sentinel must not itself contain a semicolon (it would +# become a list separator) and cannot occur in a C++ source file. +string(REPLACE ";" "@S@" _protected "${_content}") +string(REPLACE "\n" ";" _lines "${_protected}") + +set(_code 0) +set(_comments 0) +foreach(_line IN LISTS _lines) + # CMake's regex engine has no POSIX character classes, so the + # whitespace class is spelled explicitly (spaces/tabs — the file's + # indentation alphabet). + if(_line MATCHES "^[ \t]*(//.*)?$") + if(NOT _line MATCHES "^[ \t]*$") + math(EXPR _comments "${_comments} + 1") + endif() + else() + math(EXPR _code "${_code} + 1") + endif() +endforeach() + +if(_code GREATER_EQUAL 100) + message(FATAL_ERROR + "hello line budget: ${_code} code lines in hello.cpp " + "(the PRD §9.4 budget is < 100 lines of code; " + "${_comments} comment lines do not count)") +endif() + +message(STATUS "hello_line_budget: OK (${_code} code lines < 100; " + "${_comments} comment lines)") diff --git a/tests/sample/expect-sample-result.cmake.in b/tests/sample/expect-sample-result.cmake.in new file mode 100644 index 0000000..ed39637 --- /dev/null +++ b/tests/sample/expect-sample-result.cmake.in @@ -0,0 +1,159 @@ +# Generated by tests/sample/CMakeLists.txt (@TEST_NAME@) — do not edit. +# +# The `cmake -P` check-script pattern (tests/replay, tests/detcheck): +# an optional setup step, then the hello command under test, then an +# optional second run. It asserts the exit code, the stdout hash-line +# contract (structurally: CMake's regex engine does not support {n} +# quantifiers and does not interpret `\n` in a pattern — a newline in +# a pattern is written as a real `\n` escape, which CMake turns into a +# newline character), the required stderr fragments, and — when +# requested — the byte-identity of the two runs' stdout (the record -> +# replay determinism check). On failure the FATAL_ERROR carries the +# captured output. Captured stdout/stderr is normalized CRLF -> LF +# (run_cmd below): on Windows a child's text-mode streams are CRLF and +# whether the capture retains the \r is CMake-version-dependent, so +# the script normalizes itself — the per-line contract sees the +# platform-independent LF form (already-normalized content is +# untouched). + +cmake_minimum_required(VERSION 3.16) + +# --- Inputs (substituted at configure time) ------------------------------ +set(_test_name "@TEST_NAME@") +set(_setup_cmd "@SETUP_CMD@") +set(_cmd "@CMD@") +set(_cmd2 "@SECOND_CMD@") +set(_expect_exit "@EXPECT_EXIT@") +set(_expect_lines "@EXPECT_LINES@") +set(_expect_identical "@EXPECT_IDENTICAL@") + +# --- Command expansion: ---------------------------- +# resolve to the binary/fixture paths from the ctest ENVIRONMENT property +# ($ generator expressions cannot reach configure_file). +function(expand_vars var) + string(REPLACE "" "$ENV{SAMPLE_HELLO}" _s "${${var}}") + string(REPLACE "" "$ENV{SAMPLE_CONFIG}" _s "${_s}") + string(REPLACE "" "$ENV{SAMPLE_OUT_DIR}" _s "${_s}") + set(${var} "${_s}" PARENT_SCOPE) +endfunction() + +function(run_cmd cmd out_var err_var rc_var) + string(REPLACE " " ";" _list "${cmd}") + execute_process(COMMAND ${_list} + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err) + # CRLF tolerance (the detcheck contract): on Windows a child process + # writes CRLF to its text-mode stdout/stderr, and whether the + # capture keeps the \r is CMake-version-dependent — normalize to + # LF BEFORE any line parsing (the hash-line contract). + # Already-LF content is untouched. + string(REPLACE "\r\n" "\n" _out "${_out}") + string(REPLACE "\r\n" "\n" _err "${_err}") + set(${out_var} "${_out}" PARENT_SCOPE) + set(${err_var} "${_err}" PARENT_SCOPE) + set(${rc_var} "${_rc}" PARENT_SCOPE) +endfunction() + +set(_problems "") + +# --- The setup step -------------------------------------------------------- +if(NOT _setup_cmd STREQUAL "") + expand_vars(_setup_cmd) + run_cmd("${_setup_cmd}" _setup_out _setup_err _setup_rc) + if(NOT _setup_rc EQUAL 0) + message(FATAL_ERROR "@TEST_NAME@: setup step failed [rc=${_setup_rc}]\n" + "--- setup stdout ---\n${_setup_out}\n" + "--- setup stderr ---\n${_setup_err}") + endif() +endif() + +# --- The command under test ------------------------------------------------ +expand_vars(_cmd) +run_cmd("${_cmd}" _out _err _rc) + +# The stdout hash-line contract (one ' ' line per tick, tick +# 0 first, 16 lowercase hex digits — checked structurally, per line). +if(NOT _expect_lines STREQUAL "") + string(REPLACE "\n" ";" _ols "${_out}") + list(LENGTH _ols _ol_count) + if(_out MATCHES "\n$") + math(EXPR _lines "${_ol_count} - 1") + elseif(NOT _out STREQUAL "") + math(EXPR _lines "${_ol_count}") + else() + set(_lines 0) + endif() + if(NOT _lines EQUAL _expect_lines) + set(_problems "${_problems}; stdout line count ${_lines} " + "(expected ${_expect_lines})") + else() + math(EXPR _lines_last "${_lines} - 1") + foreach(_i RANGE 0 ${_lines_last}) + list(GET _ols ${_i} _line) + if(NOT _line MATCHES "^[0-9]+ [0-9a-f]+$") + set(_problems "${_problems}; stdout line ${_i} malformed: '${_line}'") + break() + endif() + string(FIND "${_line}" " " _sp) + math(EXPR _sp1 "${_sp} + 1") + string(SUBSTRING "${_line}" 0 ${_sp} _tick) + string(SUBSTRING "${_line}" ${_sp1} -1 _hash) + string(LENGTH "${_hash}" _hl) + if(NOT _tick STREQUAL "${_i}" OR NOT _hl EQUAL 16) + set(_problems "${_problems}; stdout line ${_i} contract " + "violation (tick=${_tick}, hash_len=${_hl})") + break() + endif() + endforeach() + endif() +endif() + +# --- The optional second run ----------------------------------------------- +if(NOT _cmd2 STREQUAL "") + expand_vars(_cmd2) + run_cmd("${_cmd2}" _out2 _err2 _rc2) + if(_expect_identical STREQUAL "true" AND NOT _out2 STREQUAL _out) + set(_problems "${_problems}; second run stdout differs from the first") + endif() +endif() + +# The exit-code and fragment checks apply to the LAST run (the replay +# comparison output lands on the second run; on single-run tests it is +# the first). +set(_check_rc "${_rc}") +set(_check_out "${_out}") +set(_check_err "${_err}") +if(NOT _cmd2 STREQUAL "") + set(_check_rc "${_rc2}") + set(_check_out "${_out2}") + set(_check_err "${_err2}") +endif() +if(NOT _check_rc EQUAL _expect_exit) + set(_problems "exit code ${_check_rc} (expected ${_expect_exit})") +endif() + +@CHECKS@ + +if(NOT _problems STREQUAL "") + if(_problems MATCHES "^; ") + string(SUBSTRING "${_problems}" 2 -1 _problems) + endif() + if(NOT _cmd2 STREQUAL "") + set(_second_out " +--- second run stdout --- +${_out2} +--- second run stderr --- +${_err2}") + else() + set(_second_out "") + endif() + message(FATAL_ERROR + "@TEST_NAME@: hello check failed [${_problems}]\n" + "--- run stdout ---\n${_out}\n" + "--- run stderr ---\n${_err}\n" + "${_second_out}\n" + "--- end of run output ---") +endif() + +message(STATUS "@TEST_NAME@: OK (exit ${_rc}, expected ${_expect_exit})") diff --git a/tests/sample/fixtures/garbage.log b/tests/sample/fixtures/garbage.log new file mode 100644 index 0000000..11e05d1 --- /dev/null +++ b/tests/sample/fixtures/garbage.log @@ -0,0 +1 @@ +«««««««««««««««««««««««««««««««««««««««««««««««««««««««««««««««« \ No newline at end of file