sots-engine/tests/shim_rng_ledger/unit_tests.cpp
alex 496a5124c9 Z: measure the strategic RNG, do not assume it
The turn's RNG cost has never been measured end to end. combat-done-tail.md
found two draw sites in OnAllCombatDone_Tail that nothing models and that both
run before the autosave, so a reimplementation that reproduces both ProcessTurn
functions exactly still diverges the first turn a node line expires.

RngLedger recovers an ABSOLUTE WORD POSITION from (mt[624], left) alone, by
indexing the forward-only chain of blocks the twist generates. Word deltas
between any two observations are then exact -- across twists, across NextInt
rejection loops, and across draws nobody hooked. That last point is not
theoretical: the image has four draw entry points, one of which (NextUInt
0x004f7670) appears in no previous lane's primitive set, plus inlined draws in
twelve functions. A primitive-counting hook would have undercounted silently.

Six nested trace hooks bracket one End Turn between the two autosaves and
attribute the words: the two turn drivers, the two tail phases that can draw,
and ProcessNodeSpaceTravel because it runs twice a turn. NodeLineDecay carries
a real model -- one word per expired node line under NodePath::RemainingLife --
so compare mode checks the count rather than reporting it.

Corrections from the instruction stream, both load-bearing:
  * StrategyHost::Autosave is ret 8, not ret 4, and returns the std::string* in
    EAX. A void-returning hook would have dropped it at both call sites.
  * node-line decay's 0x20000-fleet skip runs AFTER the Chance(0.5f) call, not
    before, so it cannot change the draw count -- combat-done-tail.md reads as
    if it gated the roll.

fpu.sample_turn releases StrategyServer::ProcessTurn, which the fpu sampler and
this ledger both want and MinHook grants to one of them. Default on: no
existing run changes behaviour.

Host ctest 37/37; shim cross-built on CT111; clean-room check OK.
2026-09-08 09:16:24 -04:00

182 lines
6.2 KiB
C++

// Lane Z: the RNG word ledger.
//
// The whole point of the instrument is that a word delta is exact whatever spent the words, so
// the tests drive a real MT19937 by known amounts and check the ledger's arithmetic against the
// count -- including across block boundaries, across a NextInt rejection loop, and when the
// observations arrive out of chronological order (which is how Hook<> renders nested calls).
#include "shim/hooks/rng_ledger.h"
#include <cstring>
#include <vector>
#include "check.h"
#include "mars/rng/mt19937.h"
using shim::hooks::RngLedger;
using shim::hooks::RngPos;
using mars::rng::MT19937;
namespace {
RngPos observe(RngLedger& L, const MT19937& g) { return L.observe(g.state(), g.left()); }
void test_delta_within_one_block() {
RngLedger L;
L.reset();
MT19937 g(12345);
const RngPos a = observe(L, g);
CHECK(a.known);
for (int i = 0; i < 100; ++i) (void)g.next_u32();
const RngPos b = observe(L, g);
CHECK(b.known);
CHECK_EQ(b.words - a.words, 100LL);
CHECK_EQ(b.block, a.block);
}
void test_delta_across_blocks() {
RngLedger L;
L.reset();
MT19937 g(999);
const RngPos a = observe(L, g);
// Well past a block boundary, and not a multiple of 624 -- an off-by-one in the position
// formula would survive a multiple.
const int n = 624 * 3 + 17;
for (int i = 0; i < n; ++i) (void)g.next_u32();
const RngPos b = observe(L, g);
CHECK(b.known);
CHECK_EQ(b.words - a.words, static_cast<long long>(n));
CHECK_EQ(b.block - a.block, 3);
}
void test_exhausted_block_boundary() {
// left == 0 is a real state: the block is spent and the next draw twists. Position must be
// continuous across it, or every ledger entry straddling a boundary is off by 624.
RngLedger L;
L.reset();
MT19937 g(7);
const RngPos a = observe(L, g);
for (int i = 0; i < 624; ++i) (void)g.next_u32();
const RngPos b = observe(L, g);
CHECK_EQ(b.left, 0);
CHECK_EQ(b.block, a.block); // still the same block; the twist has not happened yet
CHECK_EQ(b.words - a.words, 624LL);
(void)g.next_u32();
const RngPos c = observe(L, g);
CHECK_EQ(c.block, a.block + 1);
CHECK_EQ(c.words - a.words, 625LL);
}
void test_rejection_loop_counts_words_not_draws() {
// next_int_inclusive can spend several words on one call. The ledger must report the words.
RngLedger L;
L.reset();
MT19937 g(4242);
MT19937 shadow(4242);
const RngPos a = observe(L, g);
(void)g.next_int_inclusive(100);
const RngPos b = observe(L, g);
long long words = 0;
for (;;) {
const std::uint32_t r = shadow.next_u32() & MT19937::cover_mask(100);
++words;
if (r <= 100) break;
}
CHECK(words >= 1);
CHECK_EQ(b.words - a.words, words);
}
void test_out_of_order_observation() {
// Hook<> renders a nested call's snapshots before the outer call's. The outer `before`
// state is therefore observed twice: once at entry (in order) and once at render time,
// by which point the chain has moved on. The second lookup must still resolve.
RngLedger L;
L.reset();
MT19937 g(31337);
std::uint32_t outer_block[624];
std::memcpy(outer_block, g.state(), sizeof outer_block);
const int outer_left = g.left();
const RngPos entry = L.observe(outer_block, outer_left); // observed at entry, in order
CHECK(entry.known);
for (int i = 0; i < 624 * 2 + 5; ++i) (void)g.next_u32();
const RngPos inner_after = observe(L, g); // rendered first
CHECK(inner_after.known);
for (int i = 0; i < 30; ++i) (void)g.next_u32();
const RngPos outer_after = observe(L, g);
CHECK(outer_after.known);
// ... and now the stale `before` snapshot is rendered.
const RngPos rendered = L.observe(outer_block, outer_left);
CHECK(rendered.known);
CHECK_EQ(rendered.words, entry.words);
CHECK_EQ(outer_after.words - rendered.words, static_cast<long long>(624 * 2 + 35));
}
void test_backwards_without_entry_observation_is_unknown() {
// The honest failure: a state behind the anchor cannot be positioned, and the ledger says
// so rather than inventing a number. This is why hooks observe at entry.
RngLedger L;
L.reset();
MT19937 g(555);
std::uint32_t early[624];
std::memcpy(early, g.state(), sizeof early);
const int early_left = g.left();
for (int i = 0; i < 624 * 4; ++i) (void)g.next_u32();
const RngPos anchor = observe(L, g); // the chain starts HERE
CHECK(anchor.known);
const RngPos behind = L.observe(early, early_left);
CHECK(!behind.known);
CHECK(L.misses() >= 1);
}
void test_second_generator_reads_unknown() {
// One ledger, two independent generators: the second one's blocks are not on the first
// one's chain. That must read unknown, because it is the signal that the "one strategic
// generator" assumption failed.
RngLedger L;
L.reset();
MT19937 a(1);
MT19937 b(2);
CHECK(observe(L, a).known);
CHECK(!observe(L, b).known);
}
void test_bad_left_is_rejected() {
RngLedger L;
L.reset();
MT19937 g(8);
CHECK(!L.observe(g.state(), -1).known);
CHECK(!L.observe(g.state(), 625).known);
CHECK(!L.observe(nullptr, 100).known);
}
void test_observe_object_layout() {
// The live-memory path: vptr, mt[624] at +4, next at +0x9c4, left at +0x9c8 (RNG_size
// 0x9cc). A short region must be refused rather than read past its end.
RngLedger L;
L.reset();
MT19937 g(2024);
std::vector<char> obj(0x9cc, 0);
std::memcpy(obj.data() + 4, g.state(), 624 * 4);
const std::int32_t left = g.left();
std::memcpy(obj.data() + 0x9c8, &left, 4);
const RngPos a = L.observe_object(obj.data(), obj.size());
CHECK(a.known);
CHECK_EQ(a.left, left);
CHECK(!L.observe_object(obj.data(), 0x100).known);
}
} // namespace
int main() {
test_delta_within_one_block();
test_delta_across_blocks();
test_exhausted_block_boundary();
test_rejection_loop_counts_words_not_draws();
test_out_of_order_observation();
test_backwards_without_entry_observation_is_unknown();
test_second_generator_reads_unknown();
test_bad_left_is_rejected();
test_observe_object_layout();
return simtest::finish("shim_rng_ledger_unit");
}