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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/api/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,12 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG]
- `--headless CONFIG` — required: the JSON config file (bounded read,
1 MiB max; over-bound → `MalformedInput`; read error → `IoError`).
- `--ticks N` — the bounded run target (decimal digits only;
default 0 = the server form).
default 0 = the server form). A bounded run completes EXACTLY N
ticks: the CLI drives it with frame budget 1, so a late frame
drops its extra due tick (counted in `dropped_ticks`) instead of
overshooting the target by up to budget - 1 (the `run_headless`
contract). The completed tick count is therefore platform-stable —
what the `--replay` log contract (api/replay.md) relies on.
- `--replay LOG` — **records the run** (M1-DET-02; it was the
M1-HEAD-01 stub): opt-in, **debug builds only** (release builds
reject it with `InvalidArgument` + a `replay/record_disabled`
Expand Down
7 changes: 7 additions & 0 deletions docs/api/replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ laige-run --headless CONFIG.json [--ticks N] [--replay LOG]
game components of its own — the built-in registration is complete at
`create`), the run records one zero-length frame per completed tick,
and on a clean bounded run the log is atomically published at `LOG`.
- **The recorded tick count is platform-stable:** a bounded
`--ticks N` run completes exactly N ticks (the CLI's frame budget 1
— api/engine.md), so a log recorded for N ticks carries exactly N
frame records (N + 1 hash lines on replay) on every platform. A
run under the engine's default catch-up budget can overshoot the
target by up to budget - 1 ticks under overload; the CLI never
uses that budget for bounded runs.
- **Debug builds only**: in a release build the flag fails with
`InvalidArgument` (`replay/record_disabled`), the same contract as
the engine call.
Expand Down
6 changes: 6 additions & 0 deletions tests/replay/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ endfunction()

# The shared setup: record a 32-tick headless run (the M1-DET-02
# recorder wiring on laige-run; the engine's built-in registrations).
# The recorded tick count is PLATFORM-STABLE: laige-run drives a
# bounded run with frame budget 1, so the run lands exactly on 32
# under any cadence (a late frame drops its extra due tick — the
# EngineRun.BoundedRunCompletesExactly precedent — it does not
# overshoot; with the engine's default catch-up budget the macOS CI
# runners measured 33 ticks for --ticks 32).
set(RECORD_SETUP
"<RUN> --headless <FIXTURE> --ticks 32 --replay <OUT>/replay_smoke.log")
# The shared replay command (stdout: exactly the 33 hash lines).
Expand Down
14 changes: 13 additions & 1 deletion tests/replay/expect-replay-result.cmake.in
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
# 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. On failure the FATAL_ERROR carries the
# captured output.
# 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 and the
# baseline derive checks see the platform-independent LF form
# (already-normalized content is untouched).

cmake_minimum_required(VERSION 3.16)

Expand Down Expand Up @@ -44,6 +49,13 @@ function(run_cmd cmd out_var err_var rc_var)
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, the baseline
# capture, the derive checks). 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)
Expand Down
7 changes: 6 additions & 1 deletion tools/replay/laige-replay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,12 @@ int main(int argc, char** argv) {
(formatHex16(repHex, hashes[i]), repHex)) != 0) {
hashDiff = true;
firstDiff = i;
std::strncpy(baseHex, parsedHex, 16);
// Exactly 16 hex characters (parseBaselineLine wrote a
// 16-digit hash + NUL into parsedHex via formatHex16): a
// plain byte copy. MSVC's CRT deprecates strncpy (C4996,
// fatal under the engine's /WX policy) — the C4996 class
// the openFile/_fsopen precedent already handles here.
std::memcpy(baseHex, parsedHex, 16);
baseHex[16] = '\0';
break;
}
Expand Down
36 changes: 29 additions & 7 deletions tools/run/laige-run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
// --headless <config.json> run the engine headless with the given
// JSON config (required; the windowed mode
// is M2)
// --ticks N run until N completed ticks (N = 0 or
// omitted: the server form — run until the
// process ends)
// --ticks N run until N completed ticks — a bounded
// run completes EXACTLY N ticks (frame
// budget 1: a late frame drops its extra
// due tick, it does not overshoot); N = 0
// or omitted: the server form — run until
// the process ends
// --replay <log> REPLAY RECORDING (M1-DET-02): record the
// run's replay log (versioned format,
// laige/sim/replay.h) at <log> — opt-in,
Expand Down Expand Up @@ -87,9 +90,13 @@ void printUsage(std::FILE* out) {
"given\n"
" JSON config (required; the windowed\n"
" mode is M2)\n"
" --ticks N run until N completed ticks (N = 0\n"
" or omitted: the server form — run\n"
" until the process ends)\n"
" --ticks N run until N completed ticks — a\n"
" bounded run completes EXACTLY N\n"
" ticks (frame budget 1: a late frame\n"
" drops its extra due tick, it does not\n"
" overshoot); N = 0 or omitted: the\n"
" server form — run until the process\n"
" ends\n"
" --replay <log> record the run's replay log at\n"
" <log> (opt-in; DEBUG BUILDS ONLY —\n"
" release builds exit 2; written\n"
Expand Down Expand Up @@ -259,8 +266,23 @@ int main(int argc, char** argv) {
return 2;
}
}
// The frame budget (the run_headless contract, engine.h): a bounded
// run uses budget 1 — each frame runs AT MOST one tick, so the run
// lands EXACTLY on maxTicks under any cadence (a late frame drops
// its extra due tick, the M1-LOOP-01 overload behavior, counted in
// dropped_ticks — it never overshoots the target). The engine's
// default catch-up budget would let a late frame complete several
// due ticks at once and end the run up to budget - 1 ticks OVER
// the requested count (measured on the macOS CI runners:
// --ticks 32 completing 33); a recorded replay log (--replay) must
// carry a platform-stable tick count, and "--ticks N" reads as
// "exactly N ticks". The server form (maxTicks == 0) keeps the
// default budget: the run never ends on its own, and a stalled
// frame must be able to catch up.
const std::uint32_t frameBudgetTicks =
(maxTicks != 0) ? 1u : laige::kDefaultMaxCatchUpTicks;
const laige::Status runStatus =
engine.run_headless(maxTicks, laige::kDefaultMaxCatchUpTicks);
engine.run_headless(maxTicks, frameBudgetTicks);
const laige::GameLoopStats stats = engine.stats();
std::fprintf(stdout,
"laige-run headless ticks=%llu dropped_ticks=%llu "
Expand Down
Loading