From be98677f8230e40ebcf3c6f48a1f10104f9ca6ff Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Fri, 11 Sep 2026 11:01:39 +0200 Subject: [PATCH 1/2] [M0-CORE-04] SimMath fpx16_16 backend (default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements roadmap step M0-CORE-04 (ADR 0002, PRD §10.3, FR-3.3): the Q16.16 fixed-point type and its wiring in as the DEFAULT SimMath backend, with the same op surface as M0-CORE-03. - include/laige/fpx16_16.h: laige::fpx16_16 — int32_t raw, value = raw / 2^16, range [-32768, 32767.99998474], resolution 2^-16. All arithmetic in int64_t, defined (no UB, CPP-004) for every input: add/sub/mul/div/negate saturate to the range; rounding is round-to-nearest ties-to-even (the fixed-point analogue of IEEE round-to-nearest-even); divide by zero is defined (x/0 -> ±max with the sign of x, 0/0 -> +0 — the saturation analogue of IEEE ±inf); sqrt(negative) is defined as +0; fromInt32/fromFloat saturate, NaN -> +0; toInt32/toFloat carry documented single-rounding semantics. No NaN/Inf exist (total order); the type has no implicit scalar constructors and no arithmetic operators — construction goes through fromInt32/fromFloat, computation through the named static ops / SimMath (PRD §10.3, CORE-008). - sim_math.h: Fpx16_16 backend (delegates to the type ops) and the SimMathFpx16 alias — the default backend per ADR 0002 (the determinism.math config plumbing lands with the config step, FR-1.5). Additive template changes only: isNaN/isInf become backend-delegated (Fp32Pinned carries the IEEE implementation, semantics unchanged) and normalize compares against Scalar{} (identical for both backends). - sim_math_fixed.cpp: explicit instantiation of SimMath (linkable symbol in both static and shared variants, NFR-8.9). - tests/laige-core/math_fixed_tests.cpp: ctest -R math_fixed (24 tests, 7 suites) — exhaustive edge cases (min/max, wrap candidates), rounding ties for mul/div/toInt32/fromFloat (and the proof that sqrt of an integer has none), the full saturation/divide-by-zero policy, conversion round trips (20k-raw LCG scan), the op surface, and the length accuracy bound (accurate while the sum of squares stays in the Q16.16 range, saturating beyond — documented). Determinism property: a fixed 4096-tick op sequence run through the SimMath ops and through an independent raw-int64 reference agrees bit-for-bit, and its FNV-1a state hash equals the committed known-answer constant 0xF02728762777C581. - docs: sim_math.md gains the fpx16_16 policy section (rounding, saturation, divide-by-zero, conversions, the length bound, the determinism scope), quick start now leads with the default backend; building.md and README list the new backend and the math_fixed entry; roadmap step marked done. Verify (local, 2026-09-10): ctest -R math_fixed green; full ctest 10/10 on five trees — g++ 16.2.1 static, g++ ASan+UBSan (canonical LAIGE_ASAN=ON), g++ shared (LAIGE_BUILD_SHARED=ON), g++ TSan, and clang++ 22.1.8 — with the known-answer determinism hash identical on the two different compiler builds (the two-compiler property the step names; CI hookup lands in M1-DET-04). Zero warnings under -Wall -Werror -fno-exceptions -fno-rtti. --- README.md | 10 +- docs/api/sim_math.md | 186 +++++-- docs/getting-started/building.md | 13 +- roadmap/M0-foundations.md | 53 +- src/laige-core/CMakeLists.txt | 4 +- src/laige-core/include/laige/fpx16_16.h | 251 +++++++++ src/laige-core/include/laige/sim_math.h | 103 +++- src/laige-core/sim_math_fixed.cpp | 22 + tests/laige-core/CMakeLists.txt | 13 +- tests/laige-core/math_fixed_tests.cpp | 677 ++++++++++++++++++++++++ 10 files changed, 1256 insertions(+), 76 deletions(-) create mode 100644 src/laige-core/include/laige/fpx16_16.h create mode 100644 src/laige-core/sim_math_fixed.cpp create mode 100644 tests/laige-core/math_fixed_tests.cpp diff --git a/README.md b/README.md index 6ce9f3a..b457f86 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,14 @@ The `laige-core` library builds now: static by default, shared with `-DLAIGE_BUILD_SHARED=ON` (NFR-8.9), verified by a CTest link smoke test in both variants. It carries the first functional engine code: `laige::Result` / `laige::Status` plus the error-code registry -(M0-CORE-01, `ctest -R result_status`) and the structured logging facade +(M0-CORE-01, `ctest -R result_status`), the structured logging facade (M0-CORE-02, `ctest -R logging`, API contract in -[docs/api/logging.md](docs/api/logging.md)). Engine targets compile with -`-Wall -Werror` and with exceptions and RTTI disabled (NFR-8.10). +[docs/api/logging.md](docs/api/logging.md)), and the SimMath +deterministic-math interface (M0-CORE-03 `fp32_pinned`, +`ctest -R math_float`; M0-CORE-04 default `fpx16_16`, +`ctest -R math_fixed` — API contract in +[docs/api/sim_math.md](docs/api/sim_math.md)). Engine targets compile +with `-Wall -Werror` and with exceptions and RTTI disabled (NFR-8.10). ## Documentation diff --git a/docs/api/sim_math.md b/docs/api/sim_math.md index c6cec18..6bed3c0 100644 --- a/docs/api/sim_math.md +++ b/docs/api/sim_math.md @@ -1,13 +1,14 @@ # SimMath — the deterministic math interface (`laige::sim`) The one math interface for deterministic simulation code (M0-CORE-03, -ADR 0002, PRD §10.3). Public header: -`src/laige-core/include/laige/sim_math.h`; pinned instantiation: -`src/laige-core/sim_math.cpp`. Engine math ops are the only -floating-point allowed in deterministic paths — never platform +M0-CORE-04, ADR 0002, PRD §10.3). Public header: +`src/laige-core/include/laige/sim_math.h`; pinned instantiations: +`src/laige-core/sim_math.cpp` (fp32_pinned) and +`src/laige-core/sim_math_fixed.cpp` (fpx16_16). Engine math ops are the +only floating-point allowed in deterministic paths — never platform intrinsics outside the engine (PRD §10.3). The *enforcement* of -"SimMath only" in sim code lands in M1-DET-01 (G-R8); this step ships -the API, the `fp32_pinned` backend, and the pinned flag set. +"SimMath only" in sim code lands in M1-DET-01 (G-R8); M0 ships the API, +both backends, and the pinned flag set. ## Backends (ADR 0002) @@ -18,29 +19,41 @@ indirection (PERF-006): | Backend | Config id | Storage | Determinism scope (ARCH-010) | |---|---|---|---| -| `Fp32Pinned` (this step) | `"float_pinned_32"` | IEEE binary32 (`float`) | Bit-exact across runs of the same build on the same platform/ISA. Cross-ISA is **not** promised until the CI detcheck matrix proves it (M1-DET); a failing pair is declared unsupported for this backend. | -| `Fpx16_16` (M0-CORE-04) | `"fixed_point_16_16"` | Q16.16 in `int32_t`, `int64_t` intermediates | Bit-exact across all builds, platforms, ISAs, and compilers by the language standard. The default backend; required for lockstep and authoritative MMO. | +| `Fpx16_16` (default) | `"fixed_point_16_16"` | Q16.16 in `int32_t`, `int64_t` intermediates | Bit-exact across all builds, platforms, ISAs, and compilers by the language standard. The default backend; required for lockstep and authoritative MMO. | +| `Fp32Pinned` (opt-in) | `"float_pinned_32"` | IEEE binary32 (`float`) | Bit-exact across runs of the same build on the same platform/ISA. Cross-ISA is **not** promised until the CI detcheck matrix proves it (M1-DET); a failing pair is declared unsupported for this backend. | The **backend id is part of replay identity**: replay = inputs + seed + math backend + config hash. Cross-backend replays are not bit-exact and are not supported. The PRD §12.3 bandwidth degradation ladder is defined on `fpx16_16`; `fp32_pinned` zones do not participate in it. +The `determinism.math` config plumbing lands with the config step +(FR-1.5); until then, deterministic/lockstep engine code targets +`SimMathFpx16` (the default) and opt-in IEEE-float zones target +`SimMathFp32`. + ## Quick start ```cpp #include -// Once, at engine/zone init (factory-selected, ADR 0002): -const auto math = laige::sim::SimMathFp32::create(); +// Once, at engine/zone init (factory-selected, ADR 0002). The +// DEFAULT backend is fpx16_16: +const auto math = laige::sim::SimMathFpx16::create(); // Sim hot path (fixed timestep, ARCH-002): -laige::sim::SimMathFp32::Vec2 pos{1.0f, 2.0f}; -laige::sim::SimMathFp32::Vec2 vel{0.5f, -0.25f}; -pos = math.add(pos, math.mul(vel, 1.0f / 60.0f)); // one tick -pos = math.clamp(pos, laige::sim::SimMathFp32::Vec2{0.0f, 0.0f}, - laige::sim::SimMathFp32::Vec2{64.0f, 64.0f}); +laige::sim::SimMathFpx16::Vec2 pos{laige::fpx16_16::fromInt32(1), + laige::fpx16_16::fromInt32(2)}; +laige::sim::SimMathFpx16::Vec2 vel{laige::fpx16_16::fromFloat(0.5f), + laige::fpx16_16::fromFloat(-0.25f)}; +pos = math.add(pos, math.mul(vel, laige::fpx16_16::fromFloat(1.0f / 60.0f))); +pos = math.clamp(pos, laige::sim::SimMathFpx16::Vec2{}, + laige::sim::SimMathFpx16::Vec2{laige::fpx16_16::fromInt32(64), + laige::fpx16_16::fromInt32(64)}); vel = math.normalize(vel); + +// Opt-in IEEE-float zones (single-player / non-lockstep, ADR 0002): +const auto fmath = laige::sim::SimMathFp32::create(); ``` Game and system code is written once against the interface — there is no @@ -55,16 +68,77 @@ O(1), no allocation, `noexcept`, zero per-call indirection (PERF-006). | Op | Exact expression / policy | |---|---| -| `add` / `sub` / `mul` / `div` (scalar, `Vec2`) | One IEEE binary32 operation per component, round-to-nearest-even (pinned — see below). `mul(v, v)` is component-wise; `mul(v, s)` / `mul(s, v)` is scaling. | -| `less` / `lessEqual` / `greater` / `greaterEqual` (scalar) | Ordered IEEE comparisons: false when either operand is NaN. | -| `equals` / `notEquals` (scalar, `Vec2`, `Vec3`) | IEEE: `equals` is false when either operand is NaN; `notEquals` is true. Vector forms are component-wise. | -| `isNaN` / `isInf` / `isFinite` / `isOrdered` (scalar) | IEEE classifications; `isOrdered(a,b)` is true iff neither is NaN (the `totalOrder` predicate, not the negation of arithmetic `!=`). | -| `clamp(x, lo, hi)` (scalar, `Vec2`) | Requires finite `lo`/`hi` and `lo <= hi` (debug-assert; release UB — the engine's Result/Status convention). NaN x → NaN; ±inf x → the bound. | -| `lerp(a, b, t)` (scalar, `Vec2`) | Exactly `a + (b - a) * t`: one sub, one mul, one add — **two roundings, never FMA-fused**. Not interchangeable with `a*(1-t) + b*t` (different rounding; replays diverge). t outside [0,1] extrapolates by the same expression (defined). | -| `length(v)` (`Vec2`, `Vec3`) | Exactly `sqrt(x*x + y*y [+ z*z])`: the component squarings, the adds, then one correctly-rounded sqrt (SSE2 vsqrtss / NEON vsqrt), in that order. Always ≥ 0; `length((0,0)) = +0`. | -| `normalize(v)` (`Vec2`, `Vec3`) | `v / length(v)`, component-wise IEEE division. The zero vector is defined to normalize to the zero vector — SimMath never injects NaN from a zero-length input (IEEE 0/0 would give NaN). NaN/inf components propagate per IEEE (an infinite vector can normalize to NaN components). | +| `add` / `sub` / `mul` / `div` (scalar, `Vec2`) | One correctly-rounded operation per component in the backend's format (pinned — see the backend sections below). `mul(v, v)` is component-wise; `mul(v, s)` / `mul(s, v)` is scaling. | +| `less` / `lessEqual` / `greater` / `greaterEqual` (scalar) | Ordered comparisons: false when either operand is NaN (fp32); total order (fpx16_16 has no NaN). | +| `equals` / `notEquals` (scalar, `Vec2`, `Vec3`) | IEEE: `equals` is false when either operand is NaN; `notEquals` is true. Exact bit equality for fixed-point. Vector forms are component-wise. | +| `isNaN` / `isInf` / `isFinite` / `isOrdered` (scalar) | Backend classification: IEEE for fp32_pinned; for fpx16_16 — no NaN/Inf exist, so `isNaN`/`isInf` are always false, `isFinite` always true, `isOrdered` always true. | +| `clamp(x, lo, hi)` (scalar, `Vec2`) | Requires finite `lo`/`hi` and `lo <= hi` (debug-assert; release UB — the engine's Result/Status convention). fp32: NaN x → NaN, ±inf x → the bound. fpx16_16: plain total-order bounding. | +| `lerp(a, b, t)` (scalar, `Vec2`) | Exactly `a + (b - a) * t`: one sub, one mul, one add — **two roundings, never FMA-fused** (fp32). fpx16_16: the same expression with saturating, ties-to-even integer ops; `t` outside [0,1] extrapolates by the same expression (defined). | +| `length(v)` (`Vec2`, `Vec3`) | Exactly `sqrt(x*x + y*y [+ z*z])`: the component squarings, the adds, then one correctly-rounded sqrt, in that order. fp32_pinned: SSE2 vsqrtss / NEON vsqrt. fpx16_16: accurate while the sum of squares stays inside the Q16.16 range, saturating beyond (documented below). Always ≥ 0; `length((0,0)) = 0`. | +| `normalize(v)` (`Vec2`, `Vec3`) | `v / length(v)`, component-wise division in the backend's format. The zero vector is defined to normalize to the zero vector — SimMath never injects NaN from a zero-length input. NaN/inf components propagate per the backend's policy. | | `create()` | The factory form of backend selection (stateless; holding the handle is free). | +## fpx16_16 policy (default backend) + +Type: `laige::fpx16_16` (`src/laige-core/include/laige/fpx16_16.h`), +backend: `laige::sim::Fpx16_16`. + +**Storage / range / resolution.** `std::int32_t raw`, value = raw / 2^16: +range `[-32768.0, 32767.99998474]`, resolution 2^-16 ≈ 1.5259e-5 units +(~65k sub-tile steps per unit) — ample for tile-based 2D worlds +(ADR 0002). + +**Determinism scope (ARCH-010).** Bit-exact across **all** builds, +platforms, ISAs, and compilers — guaranteed by the C++20 language +standard (two's complement is mandated; every integer operation used is +defined). No pinned compiler flags are needed for this backend: there is +no fused integer operation for a compiler to discover, and no rounding +mode to change. Cross-compiler agreement is pinned by the committed +known-answer hash in `tests/laige-core/math_fixed_tests.cpp` +(`FixedPointDeterminism`) and verified locally on g++ 16.2.1 and +clang++ 22.1.8 (the CI hookup lands in M1-DET-04). + +**Rounding mode.** Round-to-nearest, **ties-to-even** — the +fixed-point analogue of IEEE round-to-nearest-even, so both backends +share one documented rounding convention. Exact ties are: `mul` — +product whose low 16 bits are exactly 0x8000; `div` — remainder of +exactly half the divisor; `toInt32` — value exactly k + 0.5; +`fromFloat` — v × 2^16 exactly k + 0.5 (reachable only while |v| < 2^23); +`sqrt` — no exact ties exist for the square root of an integer, so +round-to-nearest ≡ ties-to-even there. + +**Overflow: saturation (defined for every input — CPP-004, no UB).** +All arithmetic computes in `int64_t` and saturates to the type's range: +`add(max, max) = max`, `sub(min, max) = min`, `mul(min, min) = max`, +`div(max, 1 ulp) = max`, `negate(min) = max`. (Only `negate(min)` +saturates — `-max = -32767.99998` is representable.) + +**Division by zero (defined; never traps on a P0 target).** +`x / 0 = ±max()` with the sign of x; `0 / 0 = +0` — the saturation +analogue of IEEE `x/0 = ±inf` (the type has no NaN/Inf). + +**Comparisons.** Total order — every pair comparable, `equals` is exact +bit equality. + +**Conversions.** +`fromInt32(v)`: exact for |v| ≤ 32768; saturates outside the Q16.16 +range. `toInt32(x)`: round-to-nearest, ties-to-even (0.5 → 0, 1.5 → 2, +-0.5 → 0, -1.5 → -2); result in [-32768, 32768]. `toFloat(x)`: one +rounding (raw → nearest binary32, then the exact 2^-16 scale). +`fromFloat(v)`: NaN → +0 (defined); ±inf and |v| ≥ 32768 saturate; +otherwise one rounding of the exact scale v × 2^16, ties-to-even (the +pinned default rounding mode — no `fesetround` is ever called). + +**`length` accuracy bound.** The squarings and sum are saturating Q16.16 +ops, so `length` is accurate while `x*x + y*y [+ z*z]` stays inside the +Q16.16 range (per component, |v| ≲ 181.02 for an axis-aligned vector; +e.g. `length((181, 0)) = 181` exact, `length((182, 0)) = sqrt(max) ≈ +181.0193` — an under-estimate, still deterministic). Local sim math +(per-entity, per-tile: velocity updates, near-neighbor distances, +collision) stays well inside the bound; world-span distances (up to +~92682) are out of scope for this format and belong to M1-DET-02's +determinism audit with a wider path if the engine needs them. + ## fp32_pinned NaN/Inf policy Defined, not "whatever the CPU does" (full text in the header): @@ -118,26 +192,33 @@ loudly if the flags are ever missing — verified by a negative build with ## Determinism scope (ARCH-010) -`fp32_pinned` is bit-exact across **runs of the same build on the same -platform/ISA**. Cross-ISA bit-exactness (x86-64 vs arm64) is not -promised: it holds only if the compilers emit the same operation -sequence, which is defended by the pinned set and re-audited at every -toolchain upgrade. The M1-DET detcheck matrix generates the -per-platform support list; any desyncing pair is declared unsupported -for this backend. `fpx16_16` (M0-CORE-04) is the cross-ISA default and -the required backend for lockstep (AC-10.3) and authoritative MMO. +- `fpx16_16` (default): bit-exact across **all builds, platforms, ISAs, + and compilers** by the language standard. The required backend for + lockstep (AC-10.3) and authoritative MMO (PRD §12). +- `fp32_pinned`: bit-exact across **runs of the same build on the same + platform/ISA**. Cross-ISA bit-exactness (x86-64 vs arm64) is not + promised: it holds only if the compilers emit the same operation + sequence, which is defended by the pinned set and re-audited at every + toolchain upgrade. The M1-DET detcheck matrix generates the + per-platform support list; any desyncing pair is declared unsupported + for this backend. ## Performance (DOC-004) -- **Complexity:** every op is O(1); no loops, no recursion. +- **Complexity:** every op is O(1); no loops, no recursion (`fpx16_16` + `sqrt` is a bounded integer correction loop, ≤ a few iterations). - **Allocation:** none (value types, inline calls). - **Indirection:** none — `SimMath` is a stateless template; ops are static inline with one instantiation per backend (PERF-006: no virtual dispatch, no `std::function`, no `std::map`, no locks). -- **Budget:** both backends must meet the §8.1 sim budget (10k entities - @ 60 Hz ≤ 3 ms); measured in M1 (CORE-001). +- **Backend cost:** `fpx16_16` multiply/divide run on 64-bit + intermediates (~2× binary32 cost in tight loops; no transcendental + calls — `sqrt` is integer arithmetic). Both backends must meet the + §8.1 sim budget (10k entities @ 60 Hz ≤ 3 ms); measured in M1 + (CORE-001). - **Trap:** computing the same math two different ways (e.g. - `lerp(a,b,t)` vs `a*(1-t)+b*t`, or reordering a sum) produces + `lerp(a,b,t)` vs `a*(1-t)+b*t`, reordering a sum, or hand-rolled + integer math that skips the documented rounding/saturation) produces different bits and breaks replays. Use one pinned expression per quantity and keep it that way. @@ -145,18 +226,41 @@ the required backend for lockstep (AC-10.3) and authoritative MMO. - Raw `float` operators in deterministic sim code bypass SimMath and break the determinism contract (PRD §10.3). Enforcement: M1-DET-01. +- `fpx16_16` has **no implicit scalar constructors and no arithmetic + operators**: `5` is not `5/65536`, and hand-rolled `a.raw * b.raw` + skips the documented rounding and saturation. Construct via + `fromInt32`/`fromFloat`, compute via the SimMath ops (or the type's + named static ops). - Replays across backends are not bit-exact: the backend id is part of replay identity (ADR 0002). - `clamp`'s `lo`/`hi` must be finite and ordered; NaN/Inf bounds are undefined behavior in release builds (debug builds assert). - A translation unit that instantiates `SimMath` must carry the pinned flag set (`laige_apply_simmath_policy`); otherwise the - bit-exact promise for that TU is void. + bit-exact promise for that TU is void. (`SimMath` needs no + special flags — its determinism is the language standard's.) +- `fpx16_16` `length` saturates beyond the Q16.16 range of the sum of + squares (documented bound above); world-span distances need the wider + path from M1-DET-02. ## Verification -`ctest -R math_float` — suites `SimMathBasics` (bit-exact known values, -±0, commutativity, vector ops), `SimMathNanInf` (the full NaN/Inf -policy), `SimMathProperties` (idempotence, round-trip tolerances, the -pinned-flag runtime canaries), `SimMathDispatch` (stateless -compile-time dispatch, `noexcept` contract, backend contract). +- `ctest -R math_fixed` — suites `FixedPointBasics` (exact values, + identities, total order), `FixedPointRounding` (exhaustive + ties-to-even cases for mul/div/toInt32/fromFloat, sqrt rounding), + `FixedPointSaturation` (min/max, wrap candidates, divide-by-zero, + conversion saturation), `FixedPointConversions` (int/float round + trips), `FixedPointSimMath` (the op surface, lerp/clamp/normalize, + the length accuracy bound), `FixedPointDispatch` (stateless + compile-time dispatch, `noexcept` contract, backend contract), + `FixedPointDeterminism` (engine path vs independent raw-int64 + reference agree; committed known-answer hash of the 4096-tick op + sequence). Verified green under ASan+UBSan, and the known-answer + hash is identical on g++ 16.2.1 and clang++ 22.1.8 (two-compiler + local run; CI hookup M1-DET-04). +- `ctest -R math_float` — suites `SimMathBasics` (bit-exact known + values, ±0, commutativity, vector ops), `SimMathNanInf` (the full + NaN/Inf policy), `SimMathProperties` (idempotence, round-trip + tolerances, the pinned-flag runtime canaries), `SimMathDispatch` + (stateless compile-time dispatch, `noexcept` contract, backend + contract). diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index 8546902..e6d5c70 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -149,9 +149,11 @@ pinned set and the NaN/Inf policy): structured logging facade from M0-CORE-02 (`include/laige/logging.h`, `logging.cpp`; API contract in [docs/api/logging.md](../api/logging.md)), and the SimMath - deterministic-math interface with the `fp32_pinned` backend from - M0-CORE-03 (`include/laige/sim_math.h`, `sim_math.cpp`; API contract - and NaN/Inf policy in + deterministic-math interface with both backends — `fp32_pinned` from + M0-CORE-03 and the default `fpx16_16` from M0-CORE-04 + (`include/laige/sim_math.h`, `include/laige/fpx16_16.h`, + `sim_math.cpp`, `sim_math_fixed.cpp`; API contract, NaN/Inf policy, + and the fpx16_16 rounding/saturation policy in [docs/api/sim_math.md](../api/sim_math.md), pinned flags via `laige_apply_simmath_policy()`). - `tests/laige-core/laige-core_tests` is a CTest link smoke test (a @@ -171,6 +173,11 @@ pinned set and the NaN/Inf policy): executable covering the `SimMathBasics`, `SimMathNanInf`, `SimMathProperties`, and `SimMathDispatch` suites — the step's Verify command is `ctest -R math_float`. +- `math_fixed` is the M0-CORE-04 CTest entry: a filtered view of the same + executable covering the `FixedPointBasics`, `FixedPointRounding`, + `FixedPointSaturation`, `FixedPointConversions`, `FixedPointSimMath`, + `FixedPointDispatch`, and `FixedPointDeterminism` suites — the step's + Verify command is `ctest -R math_fixed` (verified under ASan+UBSan). - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index 1a1a96b..b5180ef 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -367,15 +367,62 @@ No rendering, no physics, no networking yet — `laige-core` only. - **Verify:** `ctest -R math_float` green; documented NaN/Inf policy exists in header docs; pinned flag set documented and applied to sim targets. - **Size:** ~250 lines + tests -- [ ] **M0-CORE-04 · SimMath `fpx16_16` backend (default)** +- [x] **M0-CORE-04 · SimMath `fpx16_16` backend (default)** - **Refs:** PRD §10.3, FR-3.3 (fixed-point option); ADR 0002 (`fpx16_16` backend); M0-DEC-02 - **Depends:** M0-CORE-03 - **Scope:** - `laige::fpx16_16`: signed Q16.16; add/sub/mul (rounded, documented), divide, negate, compare, convert from/to `int32_t`/`float`; overflow defined (saturate) and documented; no UB under any input (CPP-004). - Wire it in as the **default** SimMath backend (ADR 0002) with the same op surface as M0-CORE-03. - Unit tests including exhaustive edge cases (min/max, wrap candidates, rounding ties). - - **Verify:** `ctest -R math_fixed` green under ASan+UBSan; property test: same op sequence on two different compiler builds produces identical results (run locally in M1-DET-04 CI hookup). - - **Size:** ~350 lines + tests + - **Decision (2026-09-10):** `laige::fpx16_16` — `int32_t raw`, + value = raw / 2^16, range [-32768, 32767.99998474], resolution 2^-16. + All ops compute in `int64_t` and **saturate** (defined for every + input — CPP-004, no UB); rounding is **round-to-nearest, + ties-to-even** for mul/div/toInt32/fromFloat (the fixed-point + analogue of IEEE round-to-nearest-even; no ties exist for + sqrt-of-integer), divide by zero is defined (`x/0 → ±max`, sign of + x; `0/0 → +0` — the saturation analogue of IEEE ±inf), and + `negate(min) = max`. No NaN/Inf exist: comparisons are a total + order, `isFinite` always true. The type has no implicit scalar + constructors and no arithmetic operators (construct via + `fromInt32`/`fromFloat`; compute via the SimMath ops). `Fpx16_16` + is the backend (delegates to the type's static ops) and + `SimMathFpx16` the alias — the DEFAULT backend per ADR 0002 (the + `determinism.math` config plumbing lands with the config step, + FR-1.5). The M0-CORE-03 template gained two backend-classification + methods (`isNaN`/`isInf`, delegated — additive; fp32 semantics + unchanged) and `Scalar{0.0}` → `Scalar{}` in `normalize` + (identical for both backends). Documented accuracy bound: + `length` is accurate while the sum of squares stays in the Q16.16 + range (|v| ≲ 181.02 per axis-aligned component), saturating + beyond — local sim math stays inside; world-span distances belong + to M1-DET-02. + - **Verify:** `ctest -R math_fixed` green — 24 GTest cases across + `FixedPointBasics` (exact values, identities, total order), + `FixedPointRounding` (exhaustive ties-to-even cases for mul/div/ + toInt32/fromFloat, sqrt rounding — no ties possible), + `FixedPointSaturation` (min/max, wrap candidates, divide-by-zero, + conversion saturation), `FixedPointConversions` (int/float round + trips, 20k-raw LCG scan), `FixedPointSimMath` (op surface, + lerp/clamp/normalize, the length accuracy bound), + `FixedPointDispatch` (stateless compile-time dispatch, `noexcept` + contract, backend contract), and `FixedPointDeterminism` (a fixed + 4096-tick op sequence run through the SimMath ops and through an + independent raw-int64 reference agree bit-for-bit, and the + sequence's FNV-1a state hash equals the committed known-answer + constant 0xF02728762777C581). Verified locally 2026-09-10: green + under ASan+UBSan (`build-asan`, canonical `LAIGE_ASAN=ON`), and the + known-answer hash is identical on **two different compiler builds** + — g++ 16.2.1 (`build`) and clang++ 22.1.8 (`build-clang`), each + 10/10 ctest (the CI hookup for this property lands in M1-DET-04). + Static + shared (NFR-8.9) and TSan trees also green. + - **Size:** 251 lines `fpx16_16.h` + ~60 lines `sim_math.h` additions + + 22 lines `sim_math_fixed.cpp` (implementation) + 677 lines tests + + ~150 lines docs/CMake (over the ~350-line estimate: the header + carries the full rounding/saturation/overflow policy next to the + code, and the test suite proves the step's Verify clauses — ties, + saturation, conversions, and the two-implementation determinism + property — cohesive, not split) - [ ] **M0-CORE-05 · Pools: `ArenaPool` and `Pool`** - **Refs:** PRD §9.1 (S-2), §10.4; AGENTS PERF-003, CPP-002/007 diff --git a/src/laige-core/CMakeLists.txt b/src/laige-core/CMakeLists.txt index f19c985..5f24c9f 100644 --- a/src/laige-core/CMakeLists.txt +++ b/src/laige-core/CMakeLists.txt @@ -15,10 +15,10 @@ if(LAIGE_BUILD_SHARED) add_library(laige-core SHARED version.cpp errors.cpp logging.cpp - sim_math.cpp) + sim_math.cpp sim_math_fixed.cpp) else() add_library(laige-core STATIC version.cpp errors.cpp logging.cpp - sim_math.cpp) + sim_math.cpp sim_math_fixed.cpp) endif() laige_apply_engine_policy(laige-core) diff --git a/src/laige-core/include/laige/fpx16_16.h b/src/laige-core/include/laige/fpx16_16.h new file mode 100644 index 0000000..f89f133 --- /dev/null +++ b/src/laige-core/include/laige/fpx16_16.h @@ -0,0 +1,251 @@ +// laige-core — fpx16_16: signed Q16.16 fixed-point scalar (M0-CORE-04). +// +// ADR 0002 (Deterministic math strategy): the DEFAULT deterministic +// backend, required for lockstep (AC-10.3) and authoritative MMO. The +// SimMath op surface over this type is `SimMathFpx16` +// (laige/sim_math.h); sim code uses the SimMath ops, which delegate to +// the static ops here (PRD §10.3). +// +// --------------------------------------------------------------------------- +// Storage and range +// --------------------------------------------------------------------------- +// `std::int32_t raw`: value = raw / 2^16 (16 integer bits, 16 fraction +// bits). +// Range: [-32768.0, 32767.99998474] = [-2^16, 2^16 - 2^-16] +// Resolution: 2^-16 ≈ 1.5259e-5 units (~65k sub-tile steps per unit) +// +// --------------------------------------------------------------------------- +// Determinism scope (ARCH-010) +// --------------------------------------------------------------------------- +// Bit-exact across ALL builds, platforms, ISAs, and compilers — +// guaranteed by the C++20 language standard (two's complement is +// mandated; every integer operation used here is defined). Unlike +// `fp32_pinned`, no pinned compiler flags are required: there is no +// fused integer operation for a compiler to discover, and no rounding +// mode to change. +// +// --------------------------------------------------------------------------- +// Overflow policy (defined for every input — CPP-004, no UB) +// --------------------------------------------------------------------------- +// All arithmetic computes in `std::int64_t` and SATURATES to the type's +// range. No op can overflow: +// add(max, max) = max, sub(min, max) = min, mul(min, min) = max, +// div(max, 1 ulp) = max, negate(min) = max, and so on. +// +// --------------------------------------------------------------------------- +// Rounding policy (mul, div, toInt32, fromFloat, sqrt) +// --------------------------------------------------------------------------- +// Round-to-nearest, TIES-TO-EVEN — the fixed-point analogue of IEEE +// round-to-nearest-even, so both SimMath backends share one documented +// rounding convention. Exact ties are: +// mul: product whose low 16 bits are exactly 0x8000; +// div: remainder of exactly half the divisor magnitude; +// toInt32: value exactly k + 0.5 for an integer k; +// fromFloat: v * 2^16 exactly k + 0.5 (reachable only while |v| < 2^23, +// where the float ulp ≤ 0.5); +// sqrt: NO exact ties exist for the square root of an integer +// (n - s² can never equal s + 0.25 for integer n), so +// round-to-nearest and ties-to-even coincide there. +// +// --------------------------------------------------------------------------- +// Division by zero (defined; never traps on a P0 target) +// --------------------------------------------------------------------------- +// x / 0 = ±max() with the sign of x; 0 / 0 = +0. +// The saturation analogue of IEEE x/0 = ±inf (the type has no NaN/Inf). +// +// --------------------------------------------------------------------------- +// Comparisons and classification +// --------------------------------------------------------------------------- +// Total order — every pair of values is comparable, `equals` is exact +// bit equality, `isNaN`/`isInf` are always false, `isFinite` always +// true (the SimMath backend delegates these). +// +// --------------------------------------------------------------------------- +// Conversions +// --------------------------------------------------------------------------- +// fromInt32(v): exact for |v| ≤ 32768 (v = -32768 maps exactly to +// min()); saturates outside the Q16.16 range (defined). +// toInt32(x): round-to-nearest, ties-to-even (0.5 → 0, 1.5 → 2, +// -0.5 → 0, -1.5 → -2). Result in [-32768, 32768]. +// toFloat(x): ONE rounding: raw → nearest binary32, then the exact +// power-of-two scale 2^-16. +// fromFloat(v): NaN → +0 (defined); ±inf and |v| ≥ 32768 saturate; +// otherwise one rounding of the exact scale v * 2^16, +// ties-to-even (the pinned default rounding mode, +// ADR 0002 — no `fesetround` is ever called). +// +// --------------------------------------------------------------------------- +// API safety (CORE-008, PRD §10.3) +// --------------------------------------------------------------------------- +// The type deliberately has NO implicit constructor from integer or +// float scalars and NO arithmetic operators (no operator+, operator*, +// …). Silent unit confusion (`5` vs `5 / 2^16`) and bypassing the +// documented rounding/saturation policy are exactly what the engine +// must prevent: construct via `fromInt32`/`fromFloat`, compute through +// the static ops (or SimMath). Only comparison operators are provided — +// they are exact and required by the SimMath op surface. + +#pragma once + +#include +#include +#include + +namespace laige { + +class fpx16_16 { + public: + // Q16.16 raw units: value = raw / 2^16. Public for inspection and + // serialization; treat as opaque outside the helpers below. + std::int32_t raw{}; + + // --- Named values (CORE-005) ------------------------------------------ + // -2^16 = -32768.0 and +2^16 - 2^-16 = +32767.99998474. + static constexpr std::int32_t kMinRaw = + std::numeric_limits::min(); + static constexpr std::int32_t kMaxRaw = + std::numeric_limits::max(); + + static constexpr fpx16_16 min() noexcept { return fpx16_16{kMinRaw}; } + static constexpr fpx16_16 max() noexcept { return fpx16_16{kMaxRaw}; } + static constexpr fpx16_16 one() noexcept { return fpx16_16{1 << 16}; } + + // Total order (no NaN: every pair of Q16.16 values is comparable). + constexpr auto operator<=>(const fpx16_16&) const noexcept = default; + + // --- Arithmetic (saturating; policy in the header preamble) ---------- + + // a + b, saturating. The exact sum |a.raw + b.raw| ≤ 2^32 fits + // int64_t (no overflow — CPP-004). + static constexpr fpx16_16 add(fpx16_16 a, fpx16_16 b) noexcept { + return fpx16_16{clampRaw(static_cast(a.raw) + b.raw)}; + } + + // a - b, saturating. The exact difference fits int64_t. + static constexpr fpx16_16 sub(fpx16_16 a, fpx16_16 b) noexcept { + return fpx16_16{clampRaw(static_cast(a.raw) - b.raw)}; + } + + // a * b, round-to-nearest ties-to-even, saturating. The exact product + // |a.raw * b.raw| ≤ 2^62 fits int64_t. + static constexpr fpx16_16 mul(fpx16_16 a, fpx16_16 b) noexcept { + return fpx16_16{roundToQ16(static_cast(a.raw) * b.raw)}; + } + + // a / b, round-to-nearest ties-to-even, saturating. Division by zero + // is defined (never traps): x/0 → ±max() (sign of x), 0/0 → +0. + static fpx16_16 div(fpx16_16 a, fpx16_16 b) noexcept { + const std::int64_t num = static_cast(a.raw) << 16; + const std::int64_t den = b.raw; + if (den == 0) { + if (a.raw == 0) return fpx16_16{}; + return a.raw < 0 ? fpx16_16::min() : fpx16_16::max(); + } + std::int64_t q = num / den; // truncate toward zero + const std::int64_t r = num % den; // sign of num, |r| < |den| + const std::int64_t ad = den < 0 ? -den : den; + const std::int64_t ar = r < 0 ? -r : r; + // Round to nearest; on a tie (2|r| == |den|) keep q when it is even, + // otherwise step away from zero (the even neighbor). 2*ar < 2^32 and + // |q| ≤ 2^47: everything fits int64_t. + if (2 * ar > ad || (2 * ar == ad && (q & 1))) q += (q >= 0) ? 1 : -1; + return fpx16_16{clampRaw(q)}; + } + + // -a, saturating: negate(min()) == max() because -(-2^16) is not + // representable. Equivalent to SimMath::sub(Scalar{}, a). + static constexpr fpx16_16 negate(fpx16_16 a) noexcept { + return sub(fpx16_16{}, a); + } + + // sqrt(a), round-to-nearest (no exact ties exist — see the header + // preamble), saturating. sqrt of a negative value is defined as +0 + // (the fixed-point analogue of the IEEE domain error, with no NaN). + static fpx16_16 sqrt(fpx16_16 a) noexcept { + const std::int32_t v = a.raw; + if (v <= 0) return fpx16_16{}; + // sqrt(v / 2^16) in Q16.16 units = round(sqrt(v) * 2^8) + // = round(sqrt(v * 2^16)). + const std::uint64_t n = static_cast(v) << 16; // < 2^47 + const std::uint64_t s = isqrtFloor(n); + // sqrt(n) ≥ s + 0.5 ⟺ n ≥ s² + s + 0.25 ⟺ (n integer) n - s² > s. + const std::uint64_t r = s + (n - s * s > s ? 1 : 0); + return fpx16_16{static_cast(r)}; // r < 2^24: in range + } + + // --- Conversions ------------------------------------------------------- + + // int32_t → Q16.16, exact for |v| ≤ 32768 (v = -32768 maps exactly to + // min()); saturates outside the Q16.16 range (defined — no UB for any + // input, CPP-004). + static constexpr fpx16_16 fromInt32(std::int32_t v) noexcept { + if (v >= 32768) return max(); + if (v <= -32768) return min(); + return fpx16_16{static_cast( + static_cast(v) << 16)}; + } + + // Q16.16 → int32_t, round-to-nearest ties-to-even. The result lies in + // [-32768, 32768] and always fits int32_t. + static constexpr std::int32_t toInt32(fpx16_16 x) noexcept { + return static_cast(roundHalfToEven(x.raw)); + } + + // Q16.16 → float: one rounding (raw → nearest binary32), then the + // exact power-of-two scale 2^-16 (exact exponent adjustment). + static float toFloat(fpx16_16 x) noexcept { + return static_cast(x.raw) / 65536.0f; + } + + // float → Q16.16: NaN → +0 (defined); ±inf and |v| ≥ 32768 saturate; + // otherwise one rounding of the exact scale v * 2^16, ties-to-even. + static fpx16_16 fromFloat(float v) noexcept { + if (std::isnan(v)) return fpx16_16{}; + if (v >= 32768.0f) return max(); + if (v <= -32768.0f) return min(); + // |v| < 32768: v * 65536.0f is exact (power-of-two scaling) and lies + // in (-2^31, 2^31); the largest float below 2^31 is 2^31 - 128, so + // the rounded result always fits the Q16.16 range (no clamp needed). + const float x = v * 65536.0f; + const std::int64_t q = static_cast(std::nearbyint(x)); + return fpx16_16{static_cast(q)}; + } + + private: + // Clamp a Q16.16 raw quantity to the type's range (saturation). + static constexpr std::int32_t clampRaw(std::int64_t v) noexcept { + if (v < kMinRaw) return kMinRaw; + if (v > kMaxRaw) return kMaxRaw; + return static_cast(v); + } + + // Round p / 2^16 to the nearest integer, ties-to-even. `p` is a signed + // "32.32" fixed-point quantity; the result is NOT clamped — callers + // clamp. (p >> 16 is the arithmetic floor for two's complement; the + // remainder is p - q·2^16 in [0, 2^16).) + static constexpr std::int64_t roundHalfToEven(std::int64_t p) noexcept { + const std::int64_t q = p >> 16; + const std::int64_t rem = p & 0xFFFF; + return q + (rem >= 0x8000 && (rem > 0x8000 || (q & 1)) ? 1 : 0); + } + + // roundHalfToEven followed by saturation — the single rounding step of + // mul. + static constexpr std::int32_t roundToQ16(std::int64_t p) noexcept { + return clampRaw(roundHalfToEven(p)); + } + + // floor(sqrt(n)) for n < 2^47. The binary64 estimate is exact for n + // (n fits in 53 bits) and correctly rounded (IEC 60559), so it is + // off by at most 1; the integer correction loops terminate. (s+1)² ≤ + // 2^48: no overflow in the loop conditions. + static std::uint64_t isqrtFloor(std::uint64_t n) noexcept { + std::uint64_t s = + static_cast(std::sqrt(static_cast(n))); + while ((s + 1) * (s + 1) <= n) ++s; + while (s * s > n) --s; + return s; + } +}; + +} // namespace laige diff --git a/src/laige-core/include/laige/sim_math.h b/src/laige-core/include/laige/sim_math.h index 30cdf67..c0014a4 100644 --- a/src/laige-core/include/laige/sim_math.h +++ b/src/laige-core/include/laige/sim_math.h @@ -15,7 +15,7 @@ // failing pair is declared unsupported // for this backend. // Fpx16_16 "fixed_point_16_16" Q16.16 in `int32_t`, Bit-exact across all builds, -// (M0-CORE-04) `int64_t` intermediates platforms, ISAs, and compilers +// (this step) `int64_t` intermediates platforms, ISAs, and compilers // by the language standard. The default // backend; required for lockstep and // authoritative MMO. @@ -133,6 +133,8 @@ #include #include +#include "laige/fpx16_16.h" + namespace laige::sim { // --------------------------------------------------------------------------- @@ -144,13 +146,15 @@ namespace laige::sim { // implementations). Contract: // // - `Scalar`: the backend's storage/operation type. -// - `add` / `sub` / `mul` / `div` / `sqrt`: the five pinned arithmetic +// - `add` / `sub` / `mul` / `div` / `sqrt`: the five arithmetic // primitives. Each must be one correctly-rounded operation of the // backend's format (no fused or reassociated sequence), // deterministic by the scope documented in the backend. +// - `isNaN` / `isInf`: the backend's value classification (IEEE for +// floating-point backends; fixed-point backends have no NaN/Inf and +// report false). // -// Adding a backend is an additive change (ADR 0002 review conditions); -// `Fpx16_16` lands in M0-CORE-04. +// Adding a backend is an additive change (ADR 0002 review conditions). // IEEE-754 binary32 with pinned semantics (ADR 0002). See the header // preamble for the pinned flag set and the full NaN/Inf policy. @@ -169,6 +173,46 @@ struct Fp32Pinned { // Correctly-rounded binary32 square root (SSE2 vsqrtss / NEON vsqrt). // sqrt of a negative value is NaN (IEEE); no trap. static Scalar sqrt(Scalar a) noexcept { return std::sqrt(a); } + + // IEEE classification (the raw `==`/`!=` below are the deliberate + // bit-level comparisons the pinned NaN/Inf policy requires — the + // -Wfloat-equal heuristic is scoped away, CORE-010: no global + // suppression). +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wfloat-equal" +#endif + static bool isNaN(Scalar x) noexcept { return x != x; } + static bool isInf(Scalar x) noexcept { + const Scalar inf = std::numeric_limits::infinity(); + return x == inf || x == -inf; + } +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic pop +#endif +}; + +// Q16.16 fixed-point (ADR 0002). Bit-exact across all builds, +// platforms, ISAs, and compilers by the C++20 language standard — no +// pinned flags required. The DEFAULT backend; required for lockstep +// and authoritative MMO. The full policy (saturating overflow, +// round-to-nearest ties-to-even, divide-by-zero, conversions, +// no NaN/Inf) is documented in laige/fpx16_16.h. +struct Fpx16_16 { + using Scalar = fpx16_16; + + static Scalar add(Scalar a, Scalar b) noexcept { return fpx16_16::add(a, b); } + static Scalar sub(Scalar a, Scalar b) noexcept { return fpx16_16::sub(a, b); } + // Rounded (ties-to-even) and saturating — see fpx16_16.h. + static Scalar mul(Scalar a, Scalar b) noexcept { return fpx16_16::mul(a, b); } + // Divide by zero is defined (x/0 → ±max, 0/0 → +0) — never traps. + static Scalar div(Scalar a, Scalar b) noexcept { return fpx16_16::div(a, b); } + // sqrt of a negative value is defined as +0 (no NaN exists). + static Scalar sqrt(Scalar a) noexcept { return fpx16_16::sqrt(a); } + + // Q16.16 has no NaN and no infinity: total order, always finite. + static bool isNaN(Scalar) noexcept { return false; } + static bool isInf(Scalar) noexcept { return false; } }; // --------------------------------------------------------------------------- @@ -239,9 +283,13 @@ struct SimMath { // Length / normalize // ------------------------------------------------------------------ // length is exactly sqrt(x*x + y*y): two muls, one add, one - // correctly-rounded sqrt, in that order (pinned -ffp-contract=off - // keeps x*x + y*y from FMA-contraction, which would round once and - // change the result). + // correctly-rounded sqrt, in that order. fp32_pinned: the pinned + // -ffp-contract=off keeps x*x + y*y from FMA-contraction, which would + // round once and change the result. fpx16_16: the squarings and the + // sum are saturating Q16.16 ops, so length is accurate while + // x*x + y*y stays inside the Q16.16 range (|v| ≲ 181.02 per + // component; beyond that the result saturates — defined, + // deterministic, documented in fpx16_16.h and docs/api/sim_math.md). [[nodiscard]] static Scalar length(Vec2 v) noexcept { return Backend::sqrt( Backend::add(Backend::mul(v.x, v.x), Backend::mul(v.y, v.y))); @@ -252,18 +300,20 @@ struct SimMath { Backend::mul(v.z, v.z))); } - // Unit vector: v / length(v), component-wise IEEE division. The zero - // vector is defined to normalize to the zero vector (SimMath never - // injects NaN from a zero-length input). NaN/inf components propagate - // per IEEE (an infinite vector can normalize to NaN components). + // Unit vector: v / length(v), component-wise division in the + // backend's format. The zero vector is defined to normalize to the + // zero vector (SimMath never injects NaN from a zero-length input; + // the fixed-point backend has no NaN at all). NaN/inf components + // propagate per the backend's policy (fp32_pinned: IEEE — an infinite + // vector can normalize to NaN components). [[nodiscard]] static Vec2 normalize(Vec2 v) noexcept { const Scalar len = length(v); - if (equals(len, Scalar{0.0})) return Vec2{}; + if (equals(len, Scalar{})) return Vec2{}; return Vec2{div(v.x, len), div(v.y, len)}; } [[nodiscard]] static Vec3 normalize(Vec3 v) noexcept { const Scalar len = length(v); - if (equals(len, Scalar{0.0})) return Vec3{}; + if (equals(len, Scalar{})) return Vec3{}; return Vec3{div(v.x, len), div(v.y, len), div(v.z, len)}; } @@ -323,16 +373,16 @@ struct SimMath { static bool notEquals(Scalar a, Scalar b) noexcept { return ne(a, b); } // True iff neither operand is NaN (IEEE 754-2008 `totalOrder` // predicate, i.e. the negation of `isunordered` — not the negation of - // the arithmetic != operator). + // the arithmetic != operator). Always true for fixed-point backends + // (total order). static bool isOrdered(Scalar a, Scalar b) noexcept { return !isNaN(a) && !isNaN(b); } - // IEEE classifications. - static bool isNaN(Scalar x) noexcept { return ne(x, x); } - static bool isInf(Scalar x) noexcept { - const Scalar inf = std::numeric_limits::infinity(); - return eq(x, inf) || eq(x, -inf); - } + // Value classification, delegated to the backend: IEEE for + // floating-point backends; fixed-point backends have no NaN/Inf + // (isNaN/isInf always false, isFinite always true). + static bool isNaN(Scalar x) noexcept { return Backend::isNaN(x); } + static bool isInf(Scalar x) noexcept { return Backend::isInf(x); } static bool isFinite(Scalar x) noexcept { return !isNaN(x) && !isInf(x); } // Vector equality (component-wise; treats ±0 as equal, per IEEE). @@ -346,8 +396,17 @@ struct SimMath { static bool notEquals(Vec3 a, Vec3 b) noexcept { return !equals(a, b); } }; -// The `fp32_pinned` SimMath (this step; `determinism.math` config id -// "float_pinned_32"). `SimMathFpx16_16` follows in M0-CORE-04. +// The `fpx16_16` SimMath (this step; `determinism.math` config id +// "fixed_point_16_16"). The DEFAULT backend (ADR 0002); required for +// lockstep (AC-10.3) and authoritative MMO. The `determinism.math` +// config plumbing lands with the config step (FR-1.5); until then, +// deterministic/lockstep engine code targets SimMathFpx16 and +// opt-in IEEE-float zones target SimMathFp32. +using SimMathFpx16 = SimMath; + +// The `fp32_pinned` SimMath (M0-CORE-03; `determinism.math` config id +// "float_pinned_32"). Opt-in for single-player / non-lockstep zones +// that want IEEE float semantics (ADR 0002). using SimMathFp32 = SimMath; } // namespace laige::sim diff --git a/src/laige-core/sim_math_fixed.cpp b/src/laige-core/sim_math_fixed.cpp new file mode 100644 index 0000000..2ec35d5 --- /dev/null +++ b/src/laige-core/sim_math_fixed.cpp @@ -0,0 +1,22 @@ +// laige-core SimMath — fpx16_16 backend instantiation (M0-CORE-04). +// +// This translation unit pins the fpx16_16 backend to the library the +// same way sim_math.cpp pins fp32_pinned: one template instantiation +// per backend (ADR 0002, PERF-006) emitted into a linkable translation +// unit (M0-BUILD-01 pattern), exercised by the link smoke test in +// tests/laige-core/ for both the static and shared variants (NFR-8.9). +// +// The fpx16_16 ops are integer arithmetic with defined behavior for +// every input (fpx16_16.h), so no pinned floating-point flags are +// needed for this backend (ADR 0002); consumers that instantiate +// SimMath need no special compiler flags either. + +#include "laige/sim_math.h" + +namespace laige::sim { + +// One template instantiation per backend (ADR 0002, PERF-006): emits +// every SimMath op into this translation unit. +template struct SimMath; + +} // namespace laige::sim diff --git a/tests/laige-core/CMakeLists.txt b/tests/laige-core/CMakeLists.txt index e264659..805cbbe 100644 --- a/tests/laige-core/CMakeLists.txt +++ b/tests/laige-core/CMakeLists.txt @@ -11,7 +11,8 @@ # their own CTest entries (M0-CORE-01 adds `result_status`; later # M0-CORE-xx steps follow the same pattern). set(LAIGE_CORE_TEST_SOURCES laige-core_tests.cpp result_status_tests.cpp - logging_tests.cpp math_float_tests.cpp) + logging_tests.cpp math_float_tests.cpp + math_fixed_tests.cpp) # M0-CORE-02: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own new/delete # (strong symbols in the Clang/GCC TSan runtime archives, interposed by @@ -83,9 +84,17 @@ add_test(NAME math_float COMMAND laige-core_tests --gtest_filter=SimMathBasics.*:SimMathNanInf.*:SimMathProperties.*:SimMathDispatch.*) +# M0-CORE-04: SimMath fpx16_16 backend (default). The step's Verify +# command is `ctest -R math_fixed`; this entry selects exactly the +# FixedPoint* suites from the shared laige-core_tests executable. +add_test(NAME math_fixed + COMMAND laige-core_tests + --gtest_filter=FixedPointBasics.*:FixedPointRounding.*:FixedPointSaturation.*:FixedPointConversions.*:FixedPointSimMath.*:FixedPointDispatch.*:FixedPointDeterminism.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. - set_tests_properties(laige-core_tests result_status logging math_float + set_tests_properties( + laige-core_tests result_status logging math_float math_fixed PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-core/math_fixed_tests.cpp b/tests/laige-core/math_fixed_tests.cpp new file mode 100644 index 0000000..645b862 --- /dev/null +++ b/tests/laige-core/math_fixed_tests.cpp @@ -0,0 +1,677 @@ +// laige-core SimMath fpx16_16 backend suite (M0-CORE-04). +// +// Step Verify scope (roadmap/M0-foundations.md): +// - `ctest -R math_fixed` green (suites: FixedPointBasics, +// FixedPointRounding, FixedPointSaturation, FixedPointConversions, +// FixedPointSimMath, FixedPointDispatch, FixedPointDeterminism) +// - Green under ASan+UBSan (canonical build-asan tree) +// - Exhaustive edge cases: min/max, wrap candidates, rounding ties +// - Property test: the same 4096-tick op sequence run through the +// SimMath ops and through an independent raw-int64 reference +// implementation produces identical results, and the sequence's +// FNV-1a state hash is a committed known-answer constant — +// identical across two different compiler builds (verified locally; +// CI hookup lands in M1-DET-04) +// +// House convention (also enforced by -Wall -Werror): raw `float` +// equality comparisons trigger -Wfloat-equal, so float checks go through +// `double` comparisons (exact dyadic oracles) — never EXPECT_EQ on +// floats. Every value under test comes from a fpx16_16 / SimMath op; +// float/double values appear only as oracles for known values +// (PRD §10.3, ADR 0002). + +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/sim_math.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "math_fixed_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "math_fixed_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "math_fixed_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +namespace { + +using Sim = laige::sim::SimMathFpx16; +using Scalar = Sim::Scalar; // laige::fpx16_16 +using Vec2 = Sim::Vec2; +using Vec3 = Sim::Vec3; +using Fpx = laige::sim::Fpx16_16; // the backend's raw primitives + +Scalar S(float v) { return Scalar::fromFloat(v); } +constexpr Scalar R(std::int32_t raw) { return Scalar{raw}; } + +constexpr std::int32_t kMinRaw = std::numeric_limits::min(); +constexpr std::int32_t kMaxRaw = std::numeric_limits::max(); + +// --------------------------------------------------------------------------- +// Fixed 4096-tick op sequence (the determinism test's workload). +// Exposes add, sub, mul, div, negate, sqrt, lerp, clamp, normalize, and +// every saturation path of interest. +// --------------------------------------------------------------------------- + +struct FxState { + std::int32_t px{}, py{}, vx{}, vy{}; +}; + +// Engine path: the SimMath ops under test. +FxState engineRun() { + Vec2 pos{S(1.5f), S(-2.25f)}; + Vec2 vel{S(0.25f), S(0.75f)}; + const Scalar g = S(0.015625f); // 1/64, dyadic + const Scalar drag = S(0.9921875f); // 127/128, dyadic + const Vec2 velLo{S(-16.0f), S(-16.0f)}, velHi{S(16.0f), S(16.0f)}; + const Vec2 posLo{S(-32.0f), S(-32.0f)}, posHi{S(32.0f), S(32.0f)}; + const Vec2 home{S(5.0f), S(-3.0f)}; + const Vec2 kick{S(0.1f), S(0.0f)}; + for (std::int32_t t = 0; t < 4096; ++t) { + vel.y = Sim::add(vel.y, Scalar::negate(g)); + vel = Sim::mul(vel, drag); + vel = Sim::clamp(vel, velLo, velHi); + pos = Sim::add(pos, vel); + pos = Sim::clamp(pos, posLo, posHi); + if (t % 512 == 255) pos = Sim::lerp(pos, home, S(0.125f)); + if (t % 1024 == 511) vel = Sim::normalize(Sim::add(vel, kick)); + if (t % 2048 == 1023) vel.x = Sim::div(vel.x, S(2.0f)); + } + return FxState{pos.x.raw, pos.y.raw, vel.x.raw, vel.y.raw}; +} + +// Reference path: the SAME op sequence written directly on int64_t — an +// independent implementation of the documented fpx16_16 policy (no +// fpx16_16 helpers are called, and the integer sqrt uses a pure-integer +// Newton iteration instead of the engine's double-seeded one). It +// catches drift between the type's ops and the documented policy. +FxState refRun() { + auto clampR = [](std::int64_t v) -> std::int32_t { + if (v < kMinRaw) return kMinRaw; + if (v > kMaxRaw) return kMaxRaw; + return static_cast(v); + }; + auto addR = [&](std::int32_t a, std::int32_t b) { + return clampR(static_cast(a) + b); + }; + auto subR = [&](std::int32_t a, std::int32_t b) { + return clampR(static_cast(a) - b); + }; + auto mulR = [&](std::int32_t a, std::int32_t b) { + const std::int64_t p = static_cast(a) * b; + std::int64_t q = p >> 16; + const std::int64_t rem = p & 0xFFFF; + if (rem >= 0x8000 && (rem > 0x8000 || (q & 1))) ++q; + return clampR(q); + }; + auto divR = [&](std::int32_t a, std::int32_t b) { + const std::int64_t num = static_cast(a) << 16; + const std::int64_t den = b; + if (den == 0) return a == 0 ? 0 : (a < 0 ? kMinRaw : kMaxRaw); + std::int64_t q = num / den; + const std::int64_t r = num % den; + const std::int64_t ad = den < 0 ? -den : den; + const std::int64_t ar = r < 0 ? -r : r; + if (2 * ar > ad || (2 * ar == ad && (q & 1))) q += (q >= 0) ? 1 : -1; + return clampR(q); + }; + auto isqrtNewt = [](std::uint64_t n) -> std::uint64_t { + if (n == 0) return 0; + int bits = 0; + for (std::uint64_t t = n; t; t >>= 1) ++bits; + std::uint64_t s = 1ull << ((bits + 1) / 2); // ≥ sqrt(n) + for (;;) { + const std::uint64_t t = (s + n / s) >> 1; + if (t >= s) break; + s = t; + } + while (s * s > n) --s; + while ((s + 1) * (s + 1) <= n) ++s; + return s; + }; + auto sqrtR = [&](std::int32_t v) -> std::int32_t { + if (v <= 0) return 0; + const std::uint64_t n = static_cast(static_cast(v)) << 16; + const std::uint64_t s = isqrtNewt(n); + const std::uint64_t r = s + (n - s * s > s ? 1 : 0); + return static_cast(r); + }; + auto normR = [&](std::int32_t x, std::int32_t y) -> Vec2 { + const std::int32_t len = sqrtR(addR(mulR(x, x), mulR(y, y))); + if (len == 0) return Vec2{}; + return Vec2{R(divR(x, len)), R(divR(y, len))}; + }; + auto clampC = [](std::int32_t v, std::int32_t lo, std::int32_t hi) { + return v > hi ? hi : (v < lo ? lo : v); + }; + auto lerpR = [&](std::int32_t a, std::int32_t b, std::int32_t t) { + return addR(a, mulR(subR(b, a), t)); + }; + + // Starting state: (1.5, -2.25), (0.25, 0.75) in Q16.16 raw units. + std::int32_t px = 102400, py = -147456, vx = 16384, vy = 49152; + const std::int32_t g = 1024; // 1/64 + const std::int32_t drag = 65024; // 127/128 * 2^16 = 127 * 512 + const std::int32_t vLo = -1048576, vHi = 1048576; // ±16 + const std::int32_t pLo = -2097152, pHi = 2097152; // ±32 + const std::int32_t hx = 327680, hy = -196608; // (5, -3) + const std::int32_t eighths = 8192; // 1/8 + // S(0.1f) = nearbyint(0.1f * 65536) = nearbyint(6553.6) = 6554. + const std::int32_t kickX = 6554; + const std::int32_t two = 131072; + + for (std::int32_t t = 0; t < 4096; ++t) { + vy = addR(vy, clampR(-static_cast(g))); // negate(g), exact + vx = mulR(vx, drag); + vy = mulR(vy, drag); + vx = clampC(vx, vLo, vHi); + vy = clampC(vy, vLo, vHi); + px = addR(px, vx); + py = addR(py, vy); + px = clampC(px, pLo, pHi); + py = clampC(py, pLo, pHi); + if (t % 512 == 255) { + px = lerpR(px, hx, eighths); + py = lerpR(py, hy, eighths); + } + if (t % 1024 == 511) { + const Vec2 n = normR(addR(vx, kickX), vy); + vx = n.x.raw; + vy = n.y.raw; + } + if (t % 2048 == 1023) vx = divR(vx, two); + } + return FxState{px, py, vx, vy}; +} + +// FNV-1a 64-bit over the final state, big-endian byte order per raw +// value (endianness-independent); unsigned overflow is well-defined. +std::uint64_t stateHash(const FxState& s) { + std::uint64_t h = 0xcbf29ce484222325ull; + auto feed = [&h](std::int32_t v) { + const std::uint32_t u = static_cast(v); + for (int shift = 24; shift >= 0; shift -= 8) { + h ^= (u >> shift) & 0xFFu; + h *= 0x100000001b3ull; + } + }; + feed(s.px); + feed(s.py); + feed(s.vx); + feed(s.vy); + return h; +} + +} // namespace + +// --------------------------------------------------------------------------- +// FixedPointBasics — exact values, identities, total order +// --------------------------------------------------------------------------- + +TEST(FixedPointBasics, FormatAndConstants) { + static_assert(sizeof(Scalar) == 4); + EXPECT_EQ(Scalar{}.raw, 0); + EXPECT_EQ(Scalar::min().raw, kMinRaw); // -32768.0 + EXPECT_EQ(Scalar::max().raw, kMaxRaw); // +32767.99998474 + EXPECT_EQ(Scalar::one().raw, 1 << 16); + EXPECT_TRUE(Scalar::min() < Scalar::max()); + EXPECT_TRUE(Scalar::min() <= Scalar::max()); + EXPECT_TRUE(Scalar::max() > Scalar::min()); + EXPECT_TRUE(Scalar::max() >= Scalar::min()); + EXPECT_FALSE(Scalar::min() == Scalar::max()); + EXPECT_TRUE(Scalar::one() == Scalar::one()); + // Total order: isOrdered is always true (no NaN), isFinite always true, + // isNaN / isInf always false. + EXPECT_TRUE(Sim::isOrdered(Scalar::min(), Scalar::max())); + EXPECT_TRUE(Sim::isFinite(Scalar::max())); + EXPECT_TRUE(Sim::isFinite(Scalar::min())); + EXPECT_FALSE(Sim::isNaN(Scalar::one())); + EXPECT_FALSE(Sim::isInf(Scalar::max())); + EXPECT_FALSE(Sim::isInf(Scalar::min())); +} + +TEST(FixedPointBasics, ExactArithmetic) { + // Exact dyadic sums/differences (no rounding occurs). + EXPECT_EQ(Sim::add(S(1.5f), S(2.25f)), S(3.75f)); + EXPECT_EQ(Sim::sub(S(1.5f), S(2.25f)), S(-0.75f)); + // Exact dyadic product. + EXPECT_EQ(Sim::mul(S(0.5f), S(3.5f)), S(1.75f)); + // Exact divisions (quotients are dyadic). + EXPECT_EQ(Sim::div(S(1.0f), S(2.0f)), S(0.5f)); + EXPECT_EQ(Sim::div(S(-4.0f), S(2.0f)), S(-2.0f)); + // Rounded division, exact value: 3/5 = 0.6 → round(0.6 * 2^16) = 39322. + EXPECT_EQ(Sim::div(S(3.0f), S(5.0f)).raw, 39322); + // Identities. + EXPECT_TRUE(Sim::equals(Sim::add(S(2.5f), Scalar{}), S(2.5f))); + EXPECT_TRUE(Sim::equals(Sim::sub(S(2.5f), S(2.5f)), Scalar{})); + EXPECT_TRUE(Sim::equals(Sim::mul(S(2.5f), Scalar::one()), S(2.5f))); + EXPECT_TRUE(Sim::equals(Sim::div(S(2.5f), Scalar::one()), S(2.5f))); + // Negation (exact away from min). + EXPECT_TRUE(Sim::equals(Sim::sub(Scalar{}, S(1.5f)), S(-1.5f))); + EXPECT_EQ(Scalar::negate(S(1.5f)), S(-1.5f)); + EXPECT_EQ(Scalar::negate(Scalar{}), Scalar{}); + // The non-saturating boundary: max + min is exactly -1 ulp. + EXPECT_EQ(Sim::add(Scalar::max(), Scalar::min()), R(-1)); +} + +TEST(FixedPointBasics, Commutativity) { + // Integer addition/multiplication are commutative; the saturating + // clamps are symmetric, so the engine ops stay commutative too. + const std::int32_t xs[] = {0, 1, -1, 0x8000, -0x8000, 1 << 16, -(1 << 16), + 2147418112, -2147483648, 2147483647, 32768, + 12345678}; + for (const std::int32_t a : xs) + for (const std::int32_t b : xs) { + EXPECT_EQ(Sim::add(R(a), R(b)), Sim::add(R(b), R(a))) << "a=" << a; + EXPECT_EQ(Sim::mul(R(a), R(b)), Sim::mul(R(b), R(a))) << "a=" << a; + } +} + +// --------------------------------------------------------------------------- +// FixedPointRounding — round-to-nearest ties-to-even, exhaustive ties +// --------------------------------------------------------------------------- + +TEST(FixedPointRounding, MulTiesToEven) { + // Exact ties (product low 16 bits == 0x8000): the EVEN neighbor wins. + // 1 ulp * 0.5 = 0.5 ulp → 0 (even). + EXPECT_EQ(Scalar::mul(R(1), R(0x8000)), R(0)); + // 1 ulp * 1.5 = 1.5 ulp → 2 (even). + EXPECT_EQ(Scalar::mul(R(1), R(0x18000)), R(2)); + // -1 ulp * 1.5 = -1.5 ulp → -2 (even). + EXPECT_EQ(Scalar::mul(R(-1), R(0x18000)), R(-2)); + // Non-tie roundings: 0.75 → 1, -0.75 → -1, 0.25 → 0, -0.25 → 0. + EXPECT_EQ(Scalar::mul(R(3), R(0x4000)), R(1)); + EXPECT_EQ(Scalar::mul(R(-3), R(0x4000)), R(-1)); + EXPECT_EQ(Scalar::mul(R(1), R(0x4000)), R(0)); + EXPECT_EQ(Scalar::mul(R(-1), R(0x4000)), R(0)); +} + +TEST(FixedPointRounding, DivTiesToEven) { + // Exact ties (remainder == half divisor): the EVEN neighbor wins. + // 1 ulp / 2 = 0.5 ulp → 0 (even). + EXPECT_EQ(Scalar::div(R(1), R(131072)), R(0)); + // 3 ulp / 2 = 1.5 ulp → 2 (even). + EXPECT_EQ(Scalar::div(R(3), R(131072)), R(2)); + // Non-tie roundings: 1/3 ulp = 21845.33… → 21845; 1/6 ulp = 10922.66… → + // 10923. + EXPECT_EQ(Scalar::div(R(1), R(3)), R(21845)); + EXPECT_EQ(Scalar::div(R(1), R(6)), R(10923)); + // x / x is exactly one for odd, non-power-of-two x (no rounding). + EXPECT_EQ(Scalar::div(R(1234567), R(1234567)), Scalar::one()); + EXPECT_EQ(Scalar::div(R(-1234567), R(-1234567)), Scalar::one()); +} + +TEST(FixedPointRounding, ToInt32TiesToEven) { + // 0.5 → 0 (even), 1.5 → 2 (even), -0.5 → 0, -1.5 → -2 (even). + EXPECT_EQ(Scalar::toInt32(R(0x8000)), 0); + EXPECT_EQ(Scalar::toInt32(R(0x18000)), 2); + EXPECT_EQ(Scalar::toInt32(R(-0x8000)), 0); + EXPECT_EQ(Scalar::toInt32(R(-0x18000)), -2); + // Near the top: 32767.5 → 32768 (even); 32766.5 → 32766 (even). + EXPECT_EQ(Scalar::toInt32(R(0x7FFF8000)), 32768); + EXPECT_EQ(Scalar::toInt32(R(0x7FFE8000)), 32766); + // Non-tie: 0.25 → 0, 0.75 → 1. + EXPECT_EQ(Scalar::toInt32(R(0x4000)), 0); + EXPECT_EQ(Scalar::toInt32(R(0xC000)), 1); + // Exact integers round to themselves. + EXPECT_EQ(Scalar::toInt32(R(12345678)), 12345678 / 65536); +} + +TEST(FixedPointRounding, FromFloatTiesToEven) { + // v * 2^16 exactly k + 0.5: the EVEN neighbor wins. + EXPECT_EQ(Scalar::fromFloat(0.5f / 65536.0f), R(0)); // 0.5 → 0 + EXPECT_EQ(Scalar::fromFloat(1.5f / 65536.0f), R(2)); // 1.5 → 2 + EXPECT_EQ(Scalar::fromFloat(-0.5f / 65536.0f), R(0)); // -0.5 → 0 + EXPECT_EQ(Scalar::fromFloat(-1.5f / 65536.0f), R(-2)); // -1.5 → -2 + // Non-tie: 0.25 ulp-ish values. + EXPECT_EQ(Scalar::fromFloat(0.25f / 65536.0f), R(0)); + EXPECT_EQ(Scalar::fromFloat(0.75f / 65536.0f), R(1)); +} + +TEST(FixedPointRounding, SqrtRounding) { + // Perfect squares are exact. + EXPECT_EQ(Scalar::sqrt(S(25.0f)), S(5.0f)); + EXPECT_EQ(Scalar::sqrt(S(0.25f)), S(0.5f)); + EXPECT_EQ(Scalar::sqrt(S(1.0f)), S(1.0f)); + // Non-squares round to nearest (no ties can occur for sqrt of an + // integer): round(sqrt(2) * 2^16) = round(92681.90…) = 92682; + // round(sqrt(3) * 2^16) = round(113511.68…) = 113512. + EXPECT_EQ(Scalar::sqrt(S(2.0f)), R(92682)); + EXPECT_EQ(Scalar::sqrt(S(3.0f)), R(113512)); + // Domain: sqrt(0) = 0; sqrt of a negative is defined as +0 (no NaN). + EXPECT_EQ(Scalar::sqrt(R(0)), R(0)); + EXPECT_EQ(Scalar::sqrt(R(-1)), R(0)); + EXPECT_EQ(Scalar::sqrt(Scalar::min()), R(0)); +} + +// --------------------------------------------------------------------------- +// FixedPointSaturation — overflow is defined (saturating); wrap +// candidates +// --------------------------------------------------------------------------- + +TEST(FixedPointSaturation, AddSubSaturate) { + EXPECT_EQ(Sim::add(Scalar::max(), R(1)), Scalar::max()); + EXPECT_EQ(Sim::add(Scalar::max(), Scalar::max()), Scalar::max()); + EXPECT_EQ(Sim::add(Scalar::min(), Scalar::min()), Scalar::min()); + EXPECT_EQ(Sim::add(Scalar::min(), R(-1)), Scalar::min()); + EXPECT_EQ(Sim::sub(Scalar::min(), Scalar::max()), Scalar::min()); + EXPECT_EQ(Sim::sub(Scalar::max(), Scalar::min()), Scalar::max()); + // Wrap candidates that stay in range: no saturation. + EXPECT_EQ(Sim::sub(Scalar::max(), R(1)), R(kMaxRaw - 1)); // 2147483646 + EXPECT_EQ(Sim::add(Scalar::min(), R(1)), R(kMinRaw + 1)); // -2147483647 +} + +TEST(FixedPointSaturation, MulSaturates) { + EXPECT_EQ(Scalar::mul(Scalar::max(), Scalar::max()), Scalar::max()); + EXPECT_EQ(Scalar::mul(Scalar::min(), Scalar::min()), Scalar::max()); + EXPECT_EQ(Scalar::mul(Scalar::max(), Scalar::min()), Scalar::min()); + EXPECT_EQ(Scalar::mul(S(2.0f), Scalar::max()), Scalar::max()); + // min * 1 ulp = -0.5 exactly (in range — no saturation); min * (1 + + // 1 ulp) = -32768.5 (not representable — saturates to min). + EXPECT_EQ(Scalar::mul(Scalar::min(), R(1)), R(-32768)); + EXPECT_EQ(Scalar::mul(Scalar::min(), R(65537)), Scalar::min()); + EXPECT_EQ(Scalar::mul(R(65537), Scalar::min()), Scalar::min()); + // The non-saturating boundary: 181 * 181 = 32761 ≤ max; 181 * 182 = + // 32942 > max. + EXPECT_EQ(Scalar::mul(S(181.0f), S(181.0f)), S(32761.0f)); + EXPECT_EQ(Scalar::mul(S(181.0f), S(182.0f)), Scalar::max()); +} + +TEST(FixedPointSaturation, DivAndNegateSaturate) { + EXPECT_EQ(Scalar::div(Scalar::max(), R(1)), Scalar::max()); + EXPECT_EQ(Scalar::div(Scalar::min(), R(1)), Scalar::min()); + // max / min = -0.99999999953… → exactly -1 (rounded, in range). + EXPECT_EQ(Scalar::div(Scalar::max(), Scalar::min()), R(-(1 << 16))); + // Division by zero is defined: x/0 → ±max (sign of x), 0/0 → +0. + EXPECT_EQ(Scalar::div(R(1), R(0)), Scalar::max()); + EXPECT_EQ(Scalar::div(R(-1), R(0)), Scalar::min()); + EXPECT_EQ(Scalar::div(R(0), R(0)), R(0)); + EXPECT_EQ(Scalar::div(R(0), Scalar::max()), R(0)); + // Negation saturates only at min: -(-2^16) is not representable. + // -max = -32767.99998 IS representable (exact). + EXPECT_EQ(Scalar::negate(Scalar::min()), Scalar::max()); + EXPECT_EQ(Scalar::negate(Scalar::max()), R(-2147483647)); + EXPECT_EQ(Scalar::negate(R(0)), R(0)); +} + +TEST(FixedPointSaturation, ConversionSaturation) { + // fromInt32 saturates outside the Q16.16 range (defined, no UB). + EXPECT_EQ(Scalar::fromInt32(32768), Scalar::max()); + EXPECT_EQ(Scalar::fromInt32(-32768), Scalar::min()); + EXPECT_EQ(Scalar::fromInt32(kMaxRaw), Scalar::max()); + EXPECT_EQ(Scalar::fromInt32(kMinRaw), Scalar::min()); + EXPECT_EQ(Scalar::fromInt32(32767), R(32767 << 16)); + // fromFloat saturates at ±inf and |v| ≥ 32768; NaN → +0 (defined). + EXPECT_EQ(Scalar::fromFloat(32768.0f), Scalar::max()); + EXPECT_EQ(Scalar::fromFloat(-32768.0f), Scalar::min()); + EXPECT_EQ(Scalar::fromFloat(std::numeric_limits::infinity()), + Scalar::max()); + EXPECT_EQ( + Scalar::fromFloat(-std::numeric_limits::infinity()), + Scalar::min()); + EXPECT_EQ(Scalar::fromFloat(std::numeric_limits::quiet_NaN()), + R(0)); +} + +// --------------------------------------------------------------------------- +// FixedPointConversions — int32/float round trips and exact values +// --------------------------------------------------------------------------- + +TEST(FixedPointConversions, IntRoundTrip) { + const std::int32_t vs[] = {-32768, -12345, -1, 0, 1, 12345, 32767}; + for (const std::int32_t v : vs) { + EXPECT_EQ(Scalar::toInt32(Scalar::fromInt32(v)), v) << "v=" << v; + } + // The exact range endpoints. + EXPECT_EQ(Scalar::toInt32(Scalar::max()), 32768); // 32767.99998 → 32768 + EXPECT_EQ(Scalar::toInt32(Scalar::min()), -32768); +} + +TEST(FixedPointConversions, ToFloatOneRounding) { + // Exact dyadic values: raw → float is exact for |raw| < 2^24. + EXPECT_EQ(static_cast(Scalar::toFloat(R(1 << 16))), 1.0); + EXPECT_EQ(static_cast(Scalar::toFloat(R(1 << 15))), 0.5); + EXPECT_EQ(static_cast(Scalar::toFloat(R(1))), 1.0 / 65536.0); + EXPECT_EQ(static_cast(Scalar::toFloat(R(12345678))), + 12345678.0 / 65536.0); + // The single rounding at the top: raw 2^31-1 rounds to 2^31 in + // binary32, which scales to exactly 32768.0. + EXPECT_EQ(static_cast(Scalar::toFloat(Scalar::max())), 32768.0); + EXPECT_EQ(static_cast(Scalar::toFloat(Scalar::min())), -32768.0); +} + +TEST(FixedPointConversions, FromFloatExactAndRoundTrip) { + // Exact dyadic conversions. + EXPECT_EQ(Scalar::fromFloat(0.5f), R(1 << 15)); + EXPECT_EQ(Scalar::fromFloat(1.0f / 65536.0f), R(1)); + EXPECT_EQ(Scalar::fromFloat(-32767.0f), R(-32767 << 16)); + EXPECT_EQ(Scalar::fromFloat(0.0f), R(0)); + EXPECT_EQ(Scalar::fromFloat(-0.0f), R(0)); + // Round trip: fromFloat(toFloat(x)) lands on the nearest float- + // representable raw. For |raw| < 2^24 the float holds all 24 bits and + // the round trip is exact; beyond that, the binary32 mantissa error is + // bounded by half a ulp of raw, ≤ 2^6 = 64 raw units (raw < 2^31). + // Deterministic LCG scan over the whole range. + std::uint32_t seed = 0x12345678u; + for (int i = 0; i < 20000; ++i) { + seed = seed * 1664525u + 1013904223u; + const std::int32_t raw = static_cast(seed); + const Scalar rt = Scalar::fromFloat(Scalar::toFloat(R(raw))); + const std::int32_t diff = rt.raw > raw ? rt.raw - raw : raw - rt.raw; + EXPECT_LE(diff, 64) << "raw=" << raw; + if (raw >= 0 && raw < (1 << 24)) { + EXPECT_EQ(rt.raw, raw) << "raw=" << raw; + } + } +} + +// --------------------------------------------------------------------------- +// FixedPointSimMath — the shared op surface over the fixed-point backend +// --------------------------------------------------------------------------- + +TEST(FixedPointSimMath, FactoryAndScalars) { + const Sim m = Sim::create(); + EXPECT_EQ(m.add(S(1.0f), S(2.0f)), S(3.0f)); + EXPECT_EQ(m.sub(S(3.0f), S(1.0f)), S(2.0f)); + EXPECT_EQ(m.mul(S(2.0f), S(3.0f)), S(6.0f)); + EXPECT_EQ(m.div(S(8.0f), S(2.0f)), S(4.0f)); + EXPECT_EQ(m.clamp(S(15.0f), S(0.0f), S(10.0f)), S(10.0f)); + EXPECT_EQ(m.clamp(S(-5.0f), S(0.0f), S(10.0f)), Scalar{}); + EXPECT_EQ(m.lerp(S(0.0f), S(8.0f), S(0.25f)), S(2.0f)); + EXPECT_EQ(m.length(Vec2{S(3.0f), S(4.0f)}), S(5.0f)); + EXPECT_TRUE(Sim::equals(m.normalize(Vec2{S(0.0f), S(5.0f)}), + Vec2{Scalar{}, Scalar::one()})); +} + +TEST(FixedPointSimMath, LerpExactAndExtrapolation) { + // lerp(a, b, 0) is exact for every a, b ((b-a)*0 = 0). + EXPECT_TRUE(Sim::equals(Sim::lerp(S(1.25f), S(-7.5f), Scalar{}), + S(1.25f))); + // Dyadic midpoints are exact. + EXPECT_EQ(Sim::lerp(S(1.0f), S(3.0f), S(0.5f)), S(2.0f)); + EXPECT_TRUE(Sim::equals(Sim::lerp(Vec2{S(0.0f), S(4.0f)}, + Vec2{S(2.0f), S(0.0f)}, S(0.5f)), + Vec2{S(1.0f), S(2.0f)})); + // t outside [0,1] extrapolates by the same expression (defined). + EXPECT_EQ(Sim::lerp(S(0.0f), S(2.0f), S(2.0f)), S(4.0f)); + EXPECT_EQ(Sim::lerp(S(0.0f), S(2.0f), S(-1.0f)), S(-2.0f)); + // Saturation inside lerp, fully determined by the documented chain: + // max - min = 2^32 - 1 → saturates to max; max * 0.5 rounds (tie, + // odd floor) to 2^30 = 16384.0; min + 16384.0 = -16384.0 exactly. + EXPECT_EQ(Sim::lerp(Scalar::min(), Scalar::max(), S(0.5f)), + R(-1073741824)); +} + +TEST(FixedPointSimMath, ClampAndVectors) { + EXPECT_EQ(Sim::clamp(S(10.0f), S(0.0f), S(10.0f)), S(10.0f)); + EXPECT_TRUE(Sim::equals( + Sim::clamp(Vec2{S(15.0f), S(-5.0f)}, Vec2{Scalar{}, Scalar{}}, + Vec2{S(10.0f), S(10.0f)}), + Vec2{S(10.0f), Scalar{}})); + // Vector arithmetic is component-wise. + const Vec2 p{S(1.5f), S(-2.25f)}; + const Vec2 q{S(0.25f), S(4.0f)}; + EXPECT_TRUE(Sim::equals(Sim::add(p, q), Vec2{S(1.75f), S(1.75f)})); + EXPECT_TRUE(Sim::equals(Sim::sub(p, q), Vec2{S(1.25f), S(-6.25f)})); + EXPECT_TRUE(Sim::equals(Sim::mul(p, q), Vec2{S(0.375f), S(-9.0f)})); + EXPECT_TRUE(Sim::equals(Sim::mul(p, S(2.0f)), Vec2{S(3.0f), S(-4.5f)})); + EXPECT_TRUE(Sim::equals(Sim::mul(S(2.0f), p), Sim::mul(p, S(2.0f)))); + // The zero vector is the additive identity (bit-exact). + EXPECT_TRUE(Sim::equals(Sim::add(p, Vec2{}), p)); + EXPECT_TRUE(Sim::equals(Sim::sub(p, p), Vec2{})); + // Vector equality is component-wise exact. + EXPECT_TRUE(Sim::equals(Vec2{S(1.0f), S(2.0f)}, Vec2{S(1.0f), S(2.0f)})); + EXPECT_TRUE(Sim::notEquals(Vec2{S(1.0f), S(2.0f)}, Vec2{S(1.0f), S(3.0f)})); + EXPECT_TRUE(Sim::equals(Vec3{S(1.0f), S(2.0f), S(3.0f)}, + Vec3{S(1.0f), S(2.0f), S(3.0f)})); +} + +TEST(FixedPointSimMath, LengthAndNormalize) { + // 3-4-5 is exact (25 is a perfect square). + EXPECT_EQ(Sim::length(Vec2{S(3.0f), S(4.0f)}), S(5.0f)); + EXPECT_EQ(Sim::length(Vec2{}), Scalar{}); + // length is invariant under per-component sign flips (the exact + // squares are identical; away from min, where negate saturates). + const Vec2 v{S(0.1f), S(-2.5f)}; + const Vec2 nx{Scalar::negate(v.x), v.y}; + const Vec2 ny{v.x, Scalar::negate(v.y)}; + const Vec2 nxy{Scalar::negate(v.x), Scalar::negate(v.y)}; + EXPECT_TRUE(Sim::equals(Sim::length(v), Sim::length(nx))); + EXPECT_TRUE(Sim::equals(Sim::length(v), Sim::length(ny))); + EXPECT_TRUE(Sim::equals(Sim::length(v), Sim::length(nxy))); + // normalize: zero vector → zero vector (policy); 3-4-5 → (0.6, 0.8) + // exactly (39322 = round(3/5 * 2^16), 52429 = round(4/5 * 2^16) — + // the same raws fromFloat(0.6f)/fromFloat(0.8f) produce). + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{}), Vec2{})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec3{}), Vec3{})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec2{S(3.0f), S(4.0f)}), + Vec2{S(0.6f), S(0.8f)})); + EXPECT_TRUE(Sim::equals(Sim::normalize(Vec3{S(0.0f), S(0.0f), S(3.0f)}), + Vec3{Scalar{}, Scalar{}, Scalar::one()})); + // The 3-4-12 → 13 Vec3 case is exact. + EXPECT_EQ(Sim::length(Vec3{S(3.0f), S(4.0f), S(12.0f)}), S(13.0f)); +} + +// The documented accuracy bound of the fixed-point length: accurate while +// x*x + y*y stays inside the Q16.16 range, saturating beyond (defined, +// deterministic — see fpx16_16.h and docs/api/sim_math.md). +TEST(FixedPointSimMath, LengthSaturatesBeyondTheQ1616Range) { + // 181^2 = 32761 ≤ max: exact (25-style perfect square). + EXPECT_EQ(Sim::length(Vec2{S(181.0f), Scalar{}}), S(181.0f)); + // 182^2 = 33124 > max: the square saturates to max, so the length is + // sqrt(max) ≈ 181.0193 — an UNDERestimate of the true length (the + // saturation direction for length), still deterministic. + EXPECT_EQ(Sim::length(Vec2{S(182.0f), Scalar{}}), + Fpx::sqrt(Scalar::max())); + EXPECT_TRUE(Sim::less(Sim::length(Vec2{S(182.0f), Scalar{}}), + S(182.0f))); + // Two moderate components whose sum of squares overflows. + EXPECT_EQ(Sim::length(Vec2{S(150.0f), S(150.0f)}), + Fpx::sqrt(Scalar::max())); + // Full-range component. + EXPECT_EQ(Sim::length(Vec2{Scalar::max(), Scalar{}}), + Fpx::sqrt(Scalar::max())); +} + +// --------------------------------------------------------------------------- +// FixedPointDispatch — compile-time dispatch mechanics (ADR 0002, +// PERF-006) +// --------------------------------------------------------------------------- + +TEST(FixedPointDispatch, StatelessCompileTimeDispatch) { + // One template instantiation per backend, no per-call indirection: the + // type is stateless and trivially copyable, so SimMath ops inline to + // the backend's primitive operations. + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Scalar) == 4); + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Sim) == 1); + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Vec2) == 8); + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(Vec3) == 12); + + // A cast to a `noexcept` function pointer type is ill-formed unless the + // pointee is itself noexcept, so each line fails the build if a SimMath + // op ever stops being noexcept (PERF-006 hot-path contract). + using AddFn = + decltype(static_cast(Sim::add)); + using SubFn = + decltype(static_cast(Sim::sub)); + using MulFn = + decltype(static_cast(Sim::mul)); + using DivFn = + decltype(static_cast(Sim::div)); + using LenFn = decltype(static_cast(Sim::length)); + using NormFn = decltype(static_cast(Sim::normalize)); + using LerpFn = decltype(static_cast(Sim::lerp)); + using ClampFn = decltype(static_cast(Sim::clamp)); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); + static_assert(std::is_nothrow_invocable_v); +} + +TEST(FixedPointDispatch, Fpx16BackendContract) { + // The backend contract (ADR 0002): one correctly-rounded operation per + // primitive, deterministic by the language standard; no NaN/Inf. + using B = laige::sim::Fpx16_16; + static_assert(std::is_same_v); + static_assert(std::is_trivially_copyable_v); + EXPECT_EQ(B::add(S(1.0f), S(1.0f)), S(2.0f)); + EXPECT_EQ(B::sub(S(3.0f), S(1.0f)), S(2.0f)); + EXPECT_EQ(B::mul(S(2.0f), S(3.0f)), S(6.0f)); + EXPECT_EQ(B::div(S(8.0f), S(2.0f)), S(4.0f)); + EXPECT_EQ(B::sqrt(S(4.0f)), S(2.0f)); + EXPECT_EQ(B::sqrt(S(0.0f)), Scalar{}); + EXPECT_EQ(B::sqrt(S(-1.0f)), Scalar{}); // defined: negative → +0 + EXPECT_FALSE(B::isNaN(S(1.0f))); + EXPECT_FALSE(B::isInf(Scalar::max())); +} + +// --------------------------------------------------------------------------- +// FixedPointDeterminism — property test: same op sequence, two +// implementations, one hash (ARCH-010, TEST-004) +// --------------------------------------------------------------------------- + +TEST(FixedPointDeterminism, EngineAndReferenceImplementationsAgree) { + const FxState a = engineRun(); + const FxState b = refRun(); + EXPECT_EQ(a.px, b.px); + EXPECT_EQ(a.py, b.py); + EXPECT_EQ(a.vx, b.vx); + EXPECT_EQ(a.vy, b.vy); +} + +TEST(FixedPointDeterminism, OperationSequenceKnownAnswerHash) { + const FxState s = engineRun(); + const std::uint64_t h = stateHash(s); + // Known-answer constant: the hash of the fixed 4096-tick op sequence + // above. Verified identical on g++ 16.2.1 and clang++ 22.1.8 (local + // two-compiler run; the CI hookup lands in M1-DET-04). If this constant + // changes, either the op sequence or the documented fpx16_16 policy has + // changed — both are replay identity (ADR 0002); investigate before + // updating. + constexpr std::uint64_t kSequenceHash = 0xF02728762777C581ull; // KAT + EXPECT_EQ(h, kSequenceHash) << "hash=0x" << std::hex << h; +} From 9fc3ec07aa1254e4a6f08273fdf0357cea1dbe6b Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Fri, 11 Sep 2026 11:26:21 +0200 Subject: [PATCH 2/2] [M0-CORE-04] Fix MSVC C4996: secure-CRT fopen_s/remove_s under /WX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI (first MSVC build of the logging code — the ci:windows label selects the Windows P0 job per PR) fails on the C runtime deprecation: logging.cpp(192): warning C4996: 'fopen': ... Consider using fopen_s instead. logging.cpp(192): error C2220: the following warning is treated as an error MSVC's CRT deprecates plain fopen/remove (C4996), and the engine policy treats every /W4 warning as an error (/WX). The fix routes the five file-open sites and eight file-removal sites through a small file-local portable helper per translation unit (CPP-009 compile-time platform boundary): MSVC takes the secure CRT variants fopen_s / remove_s (out-parameter + errno_t, identical success semantics — NULL/failed on error, which the existing error paths already handle); every other supported compiler keeps the standard std::fopen / std::remove unchanged. Verified locally (no MSVC toolchain on this machine — the secure-CRT branch is verified by the Windows CI job on this push): - all five local trees green after the change: g++ static (build, 10/10), g++ ASan+UBSan (build-asan, 10/10), clang++ (build-clang, 10/10, KAT hash unchanged), g++ shared (build-shared, 10/10), g++ TSan (build-tsan, 10/10); - the non-MSVC branches are semantically identical to the previous code (same std::fopen/std::remove calls), so no behavior change. No other Windows-CI failure class is expected: the full 8-job merge matrix was green on the M0-CORE-01 tree (run 34525402022) with result.h/errors.h in place, which rules out C4324/C4514 at /W4 for this code, and result_status_tests.cpp (with non-CP1252 comment characters) built green on that same Windows run, ruling out C4819 for this runner's code page. --- src/laige-core/logging.cpp | 18 ++++++++++++- tests/laige-core/logging_tests.cpp | 41 ++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/laige-core/logging.cpp b/src/laige-core/logging.cpp index 8bf1bc5..f49a2b9 100644 --- a/src/laige-core/logging.cpp +++ b/src/laige-core/logging.cpp @@ -110,6 +110,22 @@ void formatTimestamp(std::chrono::system_clock::time_point tp, char* out) { h, m, s, frac); } +// Portable file open (CPP-009 compile-time platform boundary): MSVC's +// CRT deprecates plain `fopen` (C4996, an error under the engine's +// /WX policy) in favor of the secure variant `fopen_s` — same semantics +// (NULL stream on failure), reported via an out-parameter. Every other +// supported compiler uses the standard `std::fopen`. +#if defined(_MSC_VER) +std::FILE* openFile(const char* path, const char* mode) { + std::FILE* stream = nullptr; + return (::fopen_s(&stream, path, mode) == 0) ? stream : nullptr; +} +#else +std::FILE* openFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +#endif + // Render one full line into `stream` (see the file header for the // format). Returns 0 on success, -1 if any write failed. int writeLine(std::FILE* stream, const LogRecord& r) { @@ -189,7 +205,7 @@ void ConsoleSink::flush() { // --------------------------------------------------------------------------- laige::Result> FileSink::create(std::string path) { - std::FILE* stream = std::fopen(path.c_str(), "a"); + std::FILE* stream = openFile(path.c_str(), "a"); if (stream == nullptr) { // LOG-007 minimal fallback: the caller keeps its current sink // (console) and reports the failure; the Status carries the diff --git a/tests/laige-core/logging_tests.cpp b/tests/laige-core/logging_tests.cpp index d8d4a47..601f14a 100644 --- a/tests/laige-core/logging_tests.cpp +++ b/tests/laige-core/logging_tests.cpp @@ -213,8 +213,27 @@ std::string tempFilePath(const char* name) { return std::string("laige_logging_test_") + name + ".log"; } +// Portable file open/remove for the log-file tests (CPP-009 compile-time +// platform boundary): MSVC's CRT deprecates plain `fopen`/`remove` +// (C4996, an error under the engine's /WX policy) in favor of the secure +// variants `fopen_s`/`remove_s` — same success semantics, via an +// out-parameter and an errno_t return. Other compilers use the standard +// `std::fopen`/`std::remove`. +#if defined(_MSC_VER) +std::FILE* openLogFile(const char* path, const char* mode) { + std::FILE* stream = nullptr; + return (::fopen_s(&stream, path, mode) == 0) ? stream : nullptr; +} +bool removeLogFile(const char* path) { return ::remove_s(path) == 0; } +#else +std::FILE* openLogFile(const char* path, const char* mode) { + return std::fopen(path, mode); +} +bool removeLogFile(const char* path) { return std::remove(path) == 0; } +#endif + std::string readWholeFile(const std::string& path) { - std::FILE* f = std::fopen(path.c_str(), "rb"); + std::FILE* f = openLogFile(path.c_str(), "rb"); if (f == nullptr) return {}; std::string out; char buf[4096]; @@ -375,7 +394,7 @@ TEST(LogRecord, IdentityAndTimestamp) { TEST(LogSinks, ConsoleSinkLineFormat) { const std::string path = tempFilePath("console"); - std::FILE* f = std::fopen(path.c_str(), "w+"); + std::FILE* f = openLogFile(path.c_str(), "w+"); ASSERT_NE(f, nullptr); { laige::log::ConsoleSink sink(f); @@ -389,7 +408,7 @@ TEST(LogSinks, ConsoleSinkLineFormat) { } std::fclose(f); const std::string content = readWholeFile(path); - std::remove(path.c_str()); + removeLogFile(path.c_str()); const std::string expected1 = "[warn] network/packet_dropped: Dropped packet outside receive " @@ -407,7 +426,7 @@ TEST(LogSinks, ConsoleSinkDoesNotOwnStream) { // A ConsoleSink must leave its stream usable after destruction // (LOG-007: stderr must stay usable for crash diagnostics). const std::string path = tempFilePath("console2"); - std::FILE* f = std::fopen(path.c_str(), "w+"); + std::FILE* f = openLogFile(path.c_str(), "w+"); ASSERT_NE(f, nullptr); { laige::log::ConsoleSink sink(f); @@ -417,12 +436,12 @@ TEST(LogSinks, ConsoleSinkDoesNotOwnStream) { const int n = std::fputc('\n', f); EXPECT_NE(n, EOF); std::fclose(f); - std::remove(path.c_str()); + removeLogFile(path.c_str()); } TEST(LogSinks, FileSinkLineFormat) { const std::string path = tempFilePath("file"); - std::remove(path.c_str()); + removeLogFile(path.c_str()); const laige::Result> created = laige::log::FileSink::create(path); ASSERT_TRUE(created.ok()); @@ -434,7 +453,7 @@ TEST(LogSinks, FileSinkLineFormat) { created.value()->flush(); EXPECT_EQ(created.value()->failedWrites(), 0u); const std::string content = readWholeFile(path); - std::remove(path.c_str()); + removeLogFile(path.c_str()); const std::string expected1 = "[error] assets/decode_failed: bad texture\n"; @@ -450,7 +469,7 @@ TEST(LogSinks, FileSinkFlushesOnDestruction) { // LOG-007 safe fallback: a destroyed sink must not drop buffered // output even without an explicit flush(). const std::string path = tempFilePath("destructor"); - std::remove(path.c_str()); + removeLogFile(path.c_str()); { const auto created = laige::log::FileSink::create(path); ASSERT_TRUE(created.ok()); @@ -459,7 +478,7 @@ TEST(LogSinks, FileSinkFlushesOnDestruction) { // no explicit flush } // destructor flushes const std::string content = readWholeFile(path); - std::remove(path.c_str()); + removeLogFile(path.c_str()); ASSERT_TRUE(hasTimestampPrefix(content)); EXPECT_NE(content.find("[info] s/e: m\n"), std::string::npos) << content; } @@ -615,7 +634,7 @@ TEST(LogFatal, ChildEmitsFlushesAndTerminates) { // file sink. POSIX only (fork); the Windows jobs skip with a reason. #if defined(__unix__) const std::string path = tempFilePath("fatal"); - std::remove(path.c_str()); + removeLogFile(path.c_str()); const pid_t pid = fork(); ASSERT_GE(pid, 0); if (pid == 0) { @@ -636,7 +655,7 @@ TEST(LogFatal, ChildEmitsFlushesAndTerminates) { << "child should die on SIGABRT (controlled termination after " "emit + flush)"; const std::string content = readWholeFile(path); - std::remove(path.c_str()); + removeLogFile(path.c_str()); EXPECT_NE(content.find("[fatal] crash_test/fatal_child"), std::string::npos) << content; #else