From 6b25b8efb47cfdd7bd44d4d3784cc6efbc8b1e23 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Thu, 24 Sep 2026 19:15:28 +0200 Subject: [PATCH] Fix Windows CI: alloc_watch MSVC caller-site intrinsic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M1-ALLOC-01 allocation watch captures the first offending allocation's call site via LAIGE_ALLOC_CALLER_SITE(). The MSVC branch spelled the intrinsic GCC-style (__return_address), which does not exist on MSVC — C2065 (undeclared identifier) in all four operator new overrides, failing the windows-msvc Build step on master (run 36030996286, job 107739418055). Use the MSVC _ReturnAddress() intrinsic from (same caller-frame semantics as __builtin_return_address(0): the address of the allocating call), and update the two doc mentions that repeated the wrong name (docs/api/alloc_watch.md, roadmap/README.md). Verified: the change is inside the _MSC_VER branch only; a fresh canonical Debug g++ build of laige-core compiles warning-free. --- docs/api/alloc_watch.md | 2 +- roadmap/README.md | 2 +- src/laige-core/alloc_watch.cpp | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/api/alloc_watch.md b/docs/api/alloc_watch.md index cd203f7..e73e2d9 100644 --- a/docs/api/alloc_watch.md +++ b/docs/api/alloc_watch.md @@ -44,7 +44,7 @@ laige::AllocWatchReading r = laige::allocWatchRead(); **First-site semantics:** the first allocation after the arm is recorded (the caller's return address of the allocating call — -`__builtin_return_address` on GCC/Clang, `__return_address` on MSVC); +`__builtin_return_address` on GCC/Clang, `_ReturnAddress()` on MSVC); later offenders are counted but keep the first site (a relaxed CAS that fails once the first site is recorded). The first site is the actionable one: it is where the invariant broke first (FR-12.3). diff --git a/roadmap/README.md b/roadmap/README.md index 987f54c..0bc2ee6 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -216,7 +216,7 @@ One line per completed (or split/renumbered) step. | 2026-09-21 | M1-CFG-01 | `—` | Declarative game config (FR-1.5, ARCH-007, ADR 0002/0003, M1-CFG-01 scope, nothing else): the version 1 `config.json` schema in a new public header `src/laige-sim/include/laige/sim/config.h` (+ `config.cpp`) — `EngineConfig` MOVED from engine.h (the five original members keep their order, so existing aggregate initializers compile unchanged) gains the `budgets` block (system_time_default_ms, draw_calls_per_frame, particles_per_frame — declared values before their M2 consumers), the `camera` block (fov_degrees, zoom, follow_lerp_per_sec — stored, consumed M2), and `asset_roots` (non-empty strings; no existence check — the M2 asset pipeline owns it); the REQUIRED `version` key gates before every other key (missing → `config/version_missing`, non-integer → `config/version_invalid`, ≠ 1 → `config/version_unsupported` — the provisional M1-HEAD-01 documents migrate by adding `"version": 1`); unknown keys at any level warn `config/unknown_key` and are ignored (forward-compat); first failure wins in document order; every rejection is a rate-limited warn with the NFR-13.3 5-field text (one named constant per rejection, LOG-002) + `InvalidArgument`; `loadGameConfig(path)` (bounded 1 MiB read + the M0-CORE-07 parse + the schema) replaces the CLI's read/parse sequence (exit codes unchanged); `EngineConfigOverride` + `applyConfigOverride` (the FR-1.5 programmatic override-of-a-subset merge: per-leaf optionals, each set field validated in its documented domain, first set field that fails wins, value semantics, `assetRoots` replacement); `ConfigHotReloader` (move-only, no thread, caller-driven poll — debug builds ONLY, the replay/`record_disabled` pattern: release builds reject with `config/hot_reload_disabled`): baseline bytes + validated baseline, byte compare per poll, non-sim changes (camera.*, draw/particle budgets, asset_roots) apply in place + `config/hot_reload_applied` (Info, keys named) + baseline advance, sim-affecting changes (version, tick_rate_hz, entity_budget, churn_per_frame_budget, seed, determinism.*, budgets.system_time_default_ms) refused ATOMICALLY + `config/hot_reload_rejected` (Error, first key + old/new) with config/baseline untouched, read/parse/schema failures keep the previous config (`config/hot_reload_read_failed` or the loader's event), moved-from poll fails without logging (stopped-state precedent); the replay identity's `configHash` covers only the sim-affecting fields — the declared presentation values are deliberately excluded, so the encoding (tag 1) is UNCHANGED and every committed baseline/replay log stays valid (replay.h/.cpp comments updated); docs: NEW `docs/api/config.md` (key table, versioning + migration, rejection table, the override API, the hot-reload contract, Performance, misuse), engine.md config section rewritten to the final surface, replay.md/determinism.md/testing.md/docs-README/sim-README cross-refs updated; the provisional config surface in engine.h/engine.cpp folded into config.h/config.cpp (engine.h includes config.h; `Engine::create` re-validates the tick rate with the shared `kConfigTickRateInvalidMessage`); fixtures gain `"version": 1` (headless_smoke.json, samples/hello/config.json, the three replay smoke fixtures); NEW `game_config` CTest entry (38 tests: every rejection domain, the version gate, first-failure-wins, the file loader, the override merge, the hot-reload contract incl. the release-disabled path — `ConfigHotReload.*`); engine_tests drops the migrated `EngineConfigParse.*` (the suites live in game_config_tests.cpp); determinism_tests' config docs gain `"version": 1`; `laige-api.json` regenerated (734 symbols); local Verify: `ctest -R config` green (config_json + game_config + hello_config_valid), full `ctest` 88/88 on `build` (Debug g++), `build-asan` (leak-free), `build-release` (NDEBUG — the `hot_reload_disabled` path exercised), `build-clang`, `build-tsan` (config suites `halt_on_error=1`), `build-shared`; zero new warnings under NFR-8.10; untested: the MSVC `_fsopen` branch (CI-only, the M1-DET-02 precedent). Size: larger than the ~300-line guidance (the message table + hot reloader + 38-test suite) — noted here per the roadmap's split rule, not split | | 2026-09-23 | M1-PROF-02 | `0c7cf5c` / PR #49 | Frame graph / budget report (FR-11.2, PRD §9.1 S-6, §9.3 G-R5; M1-PROF-02 scope, nothing else): the `FrameBudgetRecorder` fixed 32-frame ring (`src/laige-sim/include/laige/sim/frame_budget.h` + `frame_budget.cpp` — `FrameBudgetRecord` per completed frame: the 0-based frame index, the tick delta, the frame's sim work ms, the pool-reservations sim-alloc delta, the G-R5 `budget_overrun`/`budget_critical` event deltas; `recordFrame` O(1) allocation-free hot path, `at(i)` oldest-first over the wrapping ring, `totalFrames()` keeps counting past the window — no silent truncation, CORE-008) and `buildFrameBudgetReport` (cold format pass, the AGENTS §12 field format): every DECLARED budget measured vs declared with a pass/flag — each system's declared `SystemDef::budgetMs` (fpx16_16, exact — ADR 0002) vs its M1-SYS-03 rolling window's **p99** (the G-R5 sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the `warns`/`errors` counters), `sim_tick_avg`/`sim_tick_p99` (budgets.json, M0-CORE-08) vs the `Profiler`'s tick window (mean/p99), `sim_heap_allocs` (the PRD §8.1 hard-zero budget) vs the per-frame sim-alloc deltas (max); the M0-CORE-08 `budgetCheck` blocks embedded verbatim; a missing entry is a loud `NO_ENTRY`, an empty window a loud `NO_SAMPLES` (a zero-tick run is never silent), the over-budget systems list ascending id, and `overall=PASS|FAIL` (a broken harness folds to FAIL — CORE-008); the ENGINE wiring (always-on per-frame accumulation — two O(1) reads + two O(systemCount) G-R5 counter passes + one O(1) ring write per frame, no allocation, no logging, PERF-003/LOG-003; the run-setup allocation count stays exactly three — `HeadlessFramePathAllocatesNothing` still pins it — the ring is created in `Engine::create`): `startBudgetReport(budgetsPath, lastNFrames)` (EVERY build — diagnostics, not replay state; loads the table now, builds + CACHES the report at the run's end on EVERY path — readable after the shutdown, the `profileStats()` precedent; a budget FAIL never fails the run — CORE-002; errors: stopped/empty-path/double-start `InvalidArgument`, load `IoError`/`MalformedInput` + `budget/report_*` structured events), `budgetReportRequested()`, `lastBudgetReport()`; the CLI surface (`tools/run/laige-run.cpp`): `--budget-report [N]` (1..32; omitted = all retained; printed to stdout after the profile summary line, even on a failed run), `--budgets ` (flag → `LAIGE_BUDGETS_PATH` env → `budgets.json` CWD — the laige-bench resolution order), `--fail-on-budget` (**exit 3** when the run COMPLETED but `overall=FAIL` — the PRD §8.1 CI gate; run failure stays exit 1, start/load failure exit 2); 14-test `budget_report` CTest entry (`tests/laige-sim/budget_report_tests.cpp`: the ring's newest-frames-oldest-first semantics, the SYNTHETIC OVER-BUDGET SYSTEM with correct numbers — the 2 ms burn vs a 0.1 ms fpx16_16 budget (20× — both G-R5 multipliers exceeded on the nominal floor, so the exact `runs=10 warns=10 errors=10` and p99 ≥ 2 assertions are preemption-tolerant): the declared budget echoed, the `over_budget:` line, `overall=FAIL`, the per-frame FAIL invariants — the engine's cached-after-shutdown report, the G-R5 events folded into the per-frame records (rate-limiting-off capture sink: 10 warns + 10 criticals exact), the healthy-world PASS, the loud NO_ENTRY/NO_SAMPLES, the start validation (double/empty/stopped/unreadable/malformed), and the record path's zero-allocation (`budget-report-recorder-zeroalloc frames=1000 allocs=0`, non-sanitizer trees)); `laige_run_budget` CLI smoke (every P0 OS job: 30-tick run, `--budget-report 4 --budgets /budgets.json --fail-on-budget`, passes iff `overall=PASS` + exit 0); sample report committed as `tests/laige-sim/fixtures/budget_report_sample.txt` (a sample, NOT a golden — the report carries wall-clock values); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostics — never enter sim state, hashes, or replays, ARCH-009; `tools/laige-determinism-lint` green); `laige-api.json` regenerated (24 headers; +`FrameBudgetRecord`/`kFrameBudgetWindow`/`FrameBudgetRecorder`/`FrameBudgetReportOptions`/`FrameBudgetReport`/`buildFrameBudgetReport` + the three `Engine` methods + `Profiler::tickWindow`); docs in the same change: `docs/api/frame_budget.md` (new — the declared budgets, the report format + pass/flag semantics, the Engine surface, the CLI + exit 3, Performance, determinism, Testing/CI) + engine.md (CLI flags, exit 3, per-frame cost, misuse, Testing) + profiler.md cross-ref + the docs/README + sim README indexes; local Verify: canonical g++ Debug tree zero-warning, `ctest -R budget_report` green (14/14), `ctest -R laige_run_budget` green, `ctest -R profiler`/`engine`/`system_timing` green (the zero-allocation pin unchanged), both lints OK | | 2026-09-21 | M1-PROF-01 | `a811297` / PR #47 | Always-on profiler counters (FR-11.1, DBG-008; M1-PROF-01 scope, nothing else): the `Profiler` core (`src/laige-sim/include/laige/sim/profiler.h` + `profiler.cpp`) — always-on, fixed-storage, no allocation after init: tick/frame time rolling windows (512/256 samples, M0-CORE-08 `Histogram`; record is O(1) allocation-free, drops the OLDEST, the since-construction counters keep counting), draw calls / texture binds / net bytes counters (0 in headless M1 — the fields exist per FR-11.1, the render/network subsystems feed them in M2/M3), and the cold `snapshot()` (own counters; `snapshot(world)` adds the world-pulled fields — entity total/alive/capacity, sim alloc count = `World::archetypeStats().totalReservations` (the M1-ECS-03 pool accounting, target 0), system count — read COLD, never copied; per-system windows stay in `World`, pulled only in the report); move-only (moved-from = stopped: records no-op, empty snapshot); `setEnabled`/`enabled` (disabled = one branch, no recording); report surface: `formatProfileSummaryLine` (the CLI one-liner), `formatProfileText` / `formatProfileJson` (version-1 schema: counters, tick/frame windows (n==0 → `n=0` text / JSON `null`, never NaN), world fields, per-system entries with the M1-SYS-03 window stats), `writeProfile` (truncating write, no partial file on failure, `Result` — `IoError`); wiring: `GameLoop::Options::profiler` (non-owning) — `runOneTick` times the tick body (the frame's `beginFrame` + one `runSystems` dispatch) with the M0-CORE-08 `TimeIt` and records on SUCCESS only (a failed tick is neither counted nor recorded), null/disabled = one branch; the engine owns the profiler (`Engine::create` constructs it — engine setup, not run setup, so the "exactly three one-shot allocations per run" claim stays true; the run loop adds the frame feed — two clock reads + one ring write per frame, excluding the pacing sleep, first frame not recorded, failed frames not recorded; `shutdown()` releases it in the pools step, abandoning a started-but-unfinalized report with `profiler/report_aborted`); the per-run report: `startProfileReport(path)` (EVERY build — diagnostics, not replay state, no `NDEBUG` gate), written at the END of the run on EVERY path (a zero-tick run writes a zero-tick report, CORE-008), a write failure does NOT fail the run (sticky `profileReportStatus()`, `profiler/report_write_failed` Error), `profileStats()` = the last run's cached snapshot (the world is released in shutdown); `laige-run --prof-out ` (start failure exits 2; a write failure leaves the run `status=ok` and exits 2) + the always-printed `laige-run profile: …` one-line summary (the byte-stable `status=ok` line untouched — the summary is a separate stdout line); structured events (subsystem `profiler`, NFR-13.3 5-field grammar): `report_started` (Info), `report_written` (Info), `report_write_failed` (Error), `report_aborted` / `report_already_started` / `report_path_invalid` (Warn); G-R8 exception markers on the raw `double` tokens (wall-clock diagnostic — never enters sim state, hashes, or replays, ARCH-009); 26-test `profiler` CTest entry (`tests/laige-sim/profiler_tests.cpp`: exact percentiles 1..100 → p50=50/p95=95/p99=99/mean=50.5, rollover cap, independent windows, zero-capacity drop, adders, disabled no-op + preserved state, moved-from stop, cold snapshot, per-completed-tick timing (failed tick unrecorded), the engine's per-run cache + report (written on every run path, version-1 JSON parseable, double-start/empty-path/stopped-engine rejections, write failure sticky without failing the run, pre-run shutdown abandonment with no file on disk), the greppable text form, the record path's zero-allocation (`profiler-zeroalloc ticks=1000 allocs=0`, non-sanitizer trees), and the enabled-cost gate ON vs OFF over 10k-entity ticks ≤ 1% (`profiler-cost on_p50=0.557288 off_p50=0.555746 overhead_pct=0.277465`, best-of-2 per arm, non-sanitizer trees — the LAIGE_ALLOC_COUNTER gate: sanitizer instrumentation inflates the fixed per-tick cost, 1.46% on the ASan tree)); docs in the same change: `docs/api/profiler.md` (new) + engine.md / game_loop.md updates + the docs/README, sim README, debugging README, tools README indexes; baseline `docs/benchmarks/baselines/m1-profiler-cost.md` (full AGENTS §12 metadata, verbatim runs — measured +0.28% vs the 1% gate); local Verify: canonical g++ tree zero-warning, full `ctest` 89/89 (the new `profiler` entry + `api-real-tree` + `determinism-lint-real-tree` green after the manifest regeneration), `laige-run --prof-out` smoke (the summary line + the valid JSON report on disk); cross-tree builds + `tools/laige-include-lint` in the PR branch | -| 2026-09-24 | M1-ALLOC-01 | `4564029` / PR #50 | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `__return_address` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK; CI (observed via the GitHub API): the PR ci-pull.yml run 35988075605 on c9587fa green — all 8 jobs passed (linux-gcc/clang/asan+UBSan/tsan each ctest 92/92, the determinism check + source scan, the include-graph lint + dependency count, the public API manifest drift); macOS/Windows jobs skipped (label-gated, default-Linux P0 selection) | +| 2026-09-24 | M1-ALLOC-01 | `4564029` / PR #50 | Zero sim-loop allocation assertion (G-R1, PRD §9.3, §8.1 `sim_heap_allocs` target 0; PERF-003, FR-12.3; M1-ALLOC-01 scope, nothing else): the allocation watch (new public header `src/laige-core/include/laige/alloc_watch.h` + `src/laige-core/alloc_watch.cpp`) — a process-wide heap-allocation counter behind strong global `operator new`/`new[]` (+ nothrow, + sized deletes) in laige-core, compiled into every non-sanitizer tree (`LAIGE_ALLOC_WATCH=1` PUBLIC on laige-core; the sanitizer trees degrade to inline no-ops with `allocWatchLive()` false — the established fallback: the leak-free sanitizer run + the pool reservation delta, M0-CORE-02/05 precedent): the armed-window model (`allocWatchArm()` = three relaxed stores — first-site, count, armed flag; `allocWatchRead()` = two relaxed loads → `AllocWatchReading{allocs, firstSite}`; the single-owner window, the sim owner thread, CONC-001; first-site semantics: the allocating call's own return address — `__builtin_return_address(0)` on GCC/Clang, `_ReturnAddress()` on MSVC — evaluated in the operator-new frame, the first offender winning via a relaxed CAS that fails once recorded); the attribution contract: `laige::detail::LoggingAllocationGuard` — the logging facade's emit path (the `LAIGE_LOG` macro block + `Logger::record`) marks its own heap work (the field value strings, the rate-state, the sink's message formatting) so it is not attributed to the sim loop's G-R1 window — the engine's documented in-tick degradations (a G-R5 `budget_overrun`/`budget_critical`, a replay `record_failed`, a guardrail warn) still log (NFR-13.3 5-field grammar, rate-limited, actionable) and never trip G-R1, while any other in-tick allocation (a system's local `std::vector`, engine storage growth) still fails at its call site; the per-tick check (`GameLoop::runOneTick`, `#if !NDEBUG`, game_loop.cpp): arm BEFORE the tick body (the frame's `beginFrame` + one `runSystems` dispatch + the attached profiler + the `onTick` hook + the replay recorder), read AFTER a completed tick (`status.ok()` — a failed tick is not checked, the profiler's "a failed tick is not recorded" contract): a nonzero count logs one `alloc/sim_tick_allocation` Error event (fields `tick`/`allocs`/`site`, NFR-13.3) and then fails the debug assert (FR-12.3: actionable, never silent) — the standing hot-path guardrail for every later sim/render step (roadmap README §6, "Global invariants"); release builds compile the whole check out (CPP-012) — an allocating tick degrades through the already-logged pool accounting (pool overflow, pools.md) and the per-frame `simAllocs` delta (profiler.md) instead, never a crash; tests: the new `ZeroAlloc` suite + `zero_alloc` CTest entry (added to the TSan property list) over the shared `laige-sim_tests` executable — the 10k-entity M1-ECS-07 workload through the `GameLoop` (700 direct warm-up ticks bring every archetype to its high water BEFORE the window; then 10k ticks at 60 Hz on the synthetic clock — `kTickNs = 16666667` = ceil(10⁹/60), the game_loop_tests constant; the floor 16666666 drifts off the exact due count over 10k ticks) — per-tick window reads 0 allocs (the engine's own arm resets the watch each tick), the reservation delta 0, rows/entity invariants, the FNV-1a visit checksum, and the machine-greppable `zero-alloc window:` line; a scratch system with a deliberate `std::vector` fails the tick assert — proven in a forked SIGABRT child (POSIX; `GTEST_SKIP` on Windows), the release/sanitizer branch running 5 clean ticks (the Verify clause's deliberate-then-revert scratch kept as the standing negative test — the violation lives in the test TU, never in engine code); the watch's first-site capture checked directly; the test-side counter shim moved to laige-core (`tests/**/logging_alloc_counter.h` wraps the watch; the `LAIGE_ALLOC_COUNTER` test definition is gated on the same trees as `LAIGE_ALLOC_WATCH`, so the probes and the engine's assertion always agree); `HeadlessFramePathAllocatesNothing` (engine_tests) now reads per-tick window semantics (the engine's three one-shot setup allocations land before the first arm); docs in the same change: `docs/api/alloc_watch.md` (new — the window model, the per-tick assertion, the attribution contract, release builds, scope, cost, threading, misuse, example) + `game_loop.md` (the zero-allocation section + the Performance cost line) + `profiler.md` cross-ref + the docs/README index; `laige-api.json` regenerated (820 symbols from 25 headers, api-real-tree green); local Verify: `ctest -R zero_alloc` green, the full canonical ctest 92/92, and 92/92 on build-release/build-shared/build-asan/build-tsan/build-clang, zero warnings on every tree, the determinism + include lints OK; CI (observed via the GitHub API): the PR ci-pull.yml run 35988075605 on c9587fa green — all 8 jobs passed (linux-gcc/clang/asan+UBSan/tsan each ctest 92/92, the determinism check + source scan, the include-graph lint + dependency count, the public API manifest drift); macOS/Windows jobs skipped (label-gated, default-Linux P0 selection) | --- diff --git a/src/laige-core/alloc_watch.cpp b/src/laige-core/alloc_watch.cpp index 6bcdfd7..86d723f 100644 --- a/src/laige-core/alloc_watch.cpp +++ b/src/laige-core/alloc_watch.cpp @@ -95,13 +95,14 @@ namespace { // call site (FR-12.3). Evaluated DIRECTLY in the operator new frame // (a helper function would add a frame and shift the return address // into the helper, not the allocating caller). GCC/Clang: the -// builtin; MSVC: the __return_address macro; any other compiler: no -// site (the count still works — the assert's message then points at -// the log event only). +// builtin; MSVC: the _ReturnAddress() intrinsic from ; +// any other compiler: no site (the count still works — the assert's +// message then points at the log event only). #if defined(__GNUC__) || defined(__clang__) # define LAIGE_ALLOC_CALLER_SITE() __builtin_return_address(0) #elif defined(_MSC_VER) -# define LAIGE_ALLOC_CALLER_SITE() __return_address +# include // _ReturnAddress +# define LAIGE_ALLOC_CALLER_SITE() _ReturnAddress() #else # define LAIGE_ALLOC_CALLER_SITE() static_cast(nullptr) #endif