diff --git a/docs/L1-predictions.md b/docs/L1-predictions.md index 1c3caf7..b8eacdf 100644 --- a/docs/L1-predictions.md +++ b/docs/L1-predictions.md @@ -219,3 +219,70 @@ together. Full account: `sots-re/findings/control-flow/hive-creation-rng.md` and `turn1-state.sav` and ending one turn produces a **different** post-turn autosave in every process, including with `hooks=off` and nothing installed. One field moves — player 3's research target — and the strategic generator does not. See the finding's §6.1. + +--- + +# P7 — the AI seed probe (added 2026-09-08, before the build, at the coordinator's request) + +**The question.** `turn1-state → turn2` is not reproducible across processes; exactly one of the +three AI players picks a different research target each run. Is that a **seed** effect (a per-process +generator seed) or an **ordering** effect (a tie broken by container order under ASLR)? + +**The probe.** Hook `RNG_Seed` 0x0049fdf0 (`thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`) and +`StrategyApp::RunAI` 0x008706f0 (`ret 0x10`, whose 4th argument lane AI1 identified as the AI +client's seed). Launch twice from `turn1-state.sav`, **load only — no End Turn is needed**, because +the client and its generator are constructed on load. Compare the two ordered sequences of +`(this, seed)`. + +**Prediction: the seeds are IDENTICAL across the two processes, and the AI client's seed is 0.** + +Reasoning, and it is lane AI1's rather than mine: `SNMRunAI` takes the seed it passes to `RunAI` from +the static `Mars::RNG` in `.data` whose only static initialiser writes the *`IStreamable`* vftable +rather than the one `RNG_Seed` installs — so **none of the image's six `RNG_Seed` call sites targets +it**, its `mt[624]` is zero-initialised BSS, and an all-zero MT19937 state is a fixed point of the +twist. Every draw from it is 0. A per-process time seed on a *client* generator would also desync +lockstep multiplayer, which this engine has (`multiplayer-tier0-verified.md`). + +So I expect this probe to **rule the seed out** and leave the ordering hypothesis — which agrees with +the coordinator's expectation, and I am saying so explicitly rather than pretending to have arrived +independently. + +**Falsifiers, and each is more interesting than the prediction holding:** + +| symptom | what it would mean | +|---|---| +| the two processes' seed sequences **differ** | AI1's reading is wrong; the seed is per-process and the ordering hypothesis is unnecessary. Find what feeds `RNG_Seed`. | +| seeds identical but the AI client's is **non-zero** | the static generator is seeded somewhere AI1's six-site sweep did not reach. Still constant, still an ordering effect, but AI1's "every draw returns 0" needs correcting. | +| the **number or order** of `RNG_Seed` calls differs between processes | something upstream of the seeding is already process-dependent, which is a bigger finding than either hypothesis. | +| `RunAI` is not entered at all on a load | the AI clients are not (re)constructed on load, so the probe measures nothing and must move to the turn itself. This is the one that would waste the run, so `RunAI` is hooked as much for that as for its argument. | + +**What this probe cannot do:** it cannot confirm the ordering hypothesis, only fail to refute it. +Confirming it needs the candidate list the varying empire builds — several equal-priority candidates +where the other two AIs have a unique best. That is a different hook and is not attempted here. + +## P7 outcome — wrong, and that is the useful part + +**Falsified on the first falsifier row.** Two launches, same save, load only: + +| `RunAI` | net id | process 1 | process 2 | +|---|---|---|---| +| 1 | 32 | `0x75F692C0` | `0x414F415E` | +| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` | +| 3 | 512 | `0x165A2ADB` | `0xC218DBF8` | + +**Every AI client seed is fresh per process.** The record *structure* is identical — 8 records, same +order, same net ids, same personality, same `ai_data` — and one of the four `Seed` calls takes +`seed = 0` and produces a byte-identical state in both runs, which is the built-in control against +"the instrument randomised it". + +So it is a **seed** effect, not an ordering effect, and lane AI1's "every draw from the static +generator returns 0" is falsified by measurement (it was flagged by its own author as arithmetic +rather than measurement, with a prediction attached — this is that prediction coming back negative). + +The coordinator's supporting argument — *"a time-seeded per-client RNG would move all three"* — does +not follow: all three seeds **do** move, and a different stream only shows up in the save where the +decision it feeds actually depends on the draw. Two of the three empires evidently have a unique best +research candidate and the third does not. + +Written up in `sots-re/findings/subsystems/ai-client-seed-is-per-process.md`, including the one thing +this probe did **not** establish — where the seed comes from — and the single hook that would. diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index cae9deb..98ca786 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -1466,4 +1466,120 @@ void SlaversRefuelUpdateDifficultyTierHook::coverage(trace::Coverage& c) { "arg:predict_path against region:cdiff before/after"); } +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- + +namespace { + +// The seeded state, so two processes can be compared on the generator itself and not only on the +// argument. `mt[0]` IS the seed (RNG_Seed writes `mt[0] = seed` before the Knuth fill), so a run +// where mt[0] disagrees with `seed` means the object moved between entry and exit. +Tv describe_seeded_rng(const void* p, std::size_t size, unsigned) { + Tv s = tv::struct_(); + if (size < A::RNG_off_Left + 4) return s; + std::uint32_t mt[4] = {0, 0, 0, 0}; + std::memcpy(mt, static_cast(p) + A::RNG_off_State, sizeof mt); + s.add("mt0", tv::u32(mt[0])); + s.add("mt1", tv::u32(mt[1])); + s.add("mt2", tv::u32(mt[2])); + s.add("mt623_left", tv::i32(peek(p, A::RNG_off_Left))); + return s; +} + +} // namespace + +void RngSeedHook::describe_args(std::vector& out, void* self, std::uint32_t seed) { + out.push_back(tv::ptr(self).named("rng")); + out.push_back(tv::u32(seed).named("seed")); + // Whether this is the strategic generator is decidable here and nowhere else in the log. + out.push_back(tv::boolean(g_server != nullptr && rng_of_server(g_server) == self) + .named("is_strategic_generator")); +} + +trace::Tv RngSeedHook::describe_ret(void* r) { return tv::ptr(r); } + +void RngSeedHook::regions(std::vector& out, void* self, std::uint32_t) { + if (!readable(self, kRngSize)) return; + trace::Region r; + r.name = "rng"; + r.ptr = self; + r.size = kRngSize; + r.describe = &describe_seeded_rng; + out.push_back(r); +} + +RngSeedHook::Args RngSeedHook::rebind(trace::Scratch&, void* self, std::uint32_t seed) { + return Args(self, seed); +} + +void* RngSeedHook::ours(void* self, std::uint32_t seed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("Mars::RNG::Seed"); + if (H::original) return H::original(self, seed); + } + return nullptr; +} + +void RngSeedHook::coverage(trace::Coverage& c) { + c.unmodelled("this hook exists to compare two PROCESSES, not to check one", + trace::Risk::Low, + "a single run's record says nothing. The result is the diff between the ordered " + "(rng, seed) sequences of two launches from the same save: identical sequences " + "rule the seed out as the source of the turn1->turn2 nondeterminism and leave " + "the ordering hypothesis", + "arg:seed and region:rng.after.mt0, which must agree"); + c.unmodelled("the caller of each Seed call is not recorded", + trace::Risk::Medium, + "the image has six call sites and this hook cannot say which one it is in. " + "`is_strategic_generator` distinguishes the one that matters most, and the " + "RunAI hook names the AI client's seed independently; the rest are identified " + "by their position in the sequence, which is only valid while the sequence is " + "stable -- and whether it is stable is exactly what is being measured", + "arg:is_strategic_generator; the RunAI record"); +} + +void StrategyAppRunAIHook::describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed) { + out.push_back(tv::ptr(self).named("app")); + out.push_back(tv::i32(netId).named("player_net_id")); + out.push_back((readable(customData, 1) ? tv::str(customData) : tv::null()).named("ai_data")); + out.push_back(tv::u32(personality).named("personality")); + out.push_back(tv::u32(rngSeed).named("rng_seed")); +} + +void StrategyAppRunAIHook::regions(std::vector&, void*, std::int32_t, const char*, + std::uint8_t, std::uint32_t) {} + +StrategyAppRunAIHook::Args StrategyAppRunAIHook::rebind(trace::Scratch&, void* self, + std::int32_t netId, const char* customData, + std::uint8_t personality, + std::uint32_t rngSeed) { + return Args(self, netId, customData, personality, rngSeed); +} + +void StrategyAppRunAIHook::ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed) { + using H = trace::Hook; + if (H::mode == trace::Mode::Replace) { + refuse_replace("StrategyApp::RunAI"); + if (H::original) H::original(self, netId, customData, personality, rngSeed); + } +} + +void StrategyAppRunAIHook::coverage(trace::Coverage& c) { + c.unmodelled("everything RunAI does is unmodelled; only its arguments are read", + trace::Risk::Low, + "it allocates the StrategyClient, seeds its generator, builds the AI agent and " + "loads the save's AI blob. This hook declares no region and checks nothing -- it " + "reports the seed and proves the path was taken on a load at all", + "arg:rng_seed"); + c.unmodelled("`ai_data` is read as a NUL-terminated string with no length bound", + trace::Risk::Low, + "lane AI1 types the second argument as `const char* aiCustomDataName`. A " + "non-string pointer here would be reported as garbage text rather than as an " + "error; the pointer itself is not printed separately", + "none -- read the value with suspicion if it is not a plausible name"); +} + } // namespace shim::hooks diff --git a/src/shim/hooks/tail_rng.h b/src/shim/hooks/tail_rng.h index 78e7f17..a5a4afa 100644 --- a/src/shim/hooks/tail_rng.h +++ b/src/shim/hooks/tail_rng.h @@ -324,6 +324,55 @@ struct SlaversRefuelUpdateDifficultyTierHook { static void coverage(trace::Coverage& c); }; +// ---- lane L1: the AI seed probe ---------------------------------------------------------------- +// +// `turn1-state -> turn2` is not reproducible across processes: exactly one of the three AI players +// picks a different research target every run, including with `hooks=off` +// (`findings/subsystems/turn1-to-turn2-nondeterminism.md`). Two hypotheses -- a per-process +// generator SEED, or an ORDERING effect (a tie broken by container order under ASLR) -- and these +// two hooks separate them in two launches with no End Turn, because the AI client and its +// generator are built on load. +// +// `Mars::RNG::Seed` 0x0049fdf0, `thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`. Six call sites +// image-wide, so this is a handful of records per load, not a flood. +struct RngSeedHook { + static constexpr const char* name = "Mars::RNG::Seed"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void*; // returns `this` + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::uint32_t seed); + static trace::Tv describe_ret(void* r); + static void regions(std::vector& out, void* self, std::uint32_t seed); + static Args rebind(trace::Scratch& s, void* self, std::uint32_t seed); + static void* ours(void* self, std::uint32_t seed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + +// `Game::StrategyApp::RunAI` 0x008706f0, `ret 0x10` (lane AI1). Its FOURTH argument is the seed the +// `StrategyClient` constructor hands to `RNG_Seed` for that client's generator. Hooked as much to +// prove the AI clients are constructed on load at all as for the value: if this never fires, the +// seed probe measured nothing and must move into the turn. +struct StrategyAppRunAIHook { + static constexpr const char* name = "Game::StrategyApp::RunAI"; + static constexpr trace::CallConv conv = trace::CallConv::Thiscall; + using Ret = void; + using Args = std::tuple; + + static void describe_args(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, + std::uint32_t rngSeed); + static void regions(std::vector& out, void* self, std::int32_t netId, + const char* customData, std::uint8_t personality, std::uint32_t rngSeed); + static Args rebind(trace::Scratch& s, void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static void ours(void* self, std::int32_t netId, const char* customData, + std::uint8_t personality, std::uint32_t rngSeed); + static trace::HookPolicy policy() { return trace::HookPolicy{}; } + static void coverage(trace::Coverage& c); +}; + // Process facts the hooks need (exe base for RVAs, a line logger). Call once before installing. void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)); diff --git a/src/shim/main.cpp b/src/shim/main.cpp index aaa5121..0431f0e 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -277,6 +277,11 @@ void InstallHooks(shim::trace::Tracer& tracer) { InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives); InstallTemplateHook(tracer, exeBase, sots::addr::SVSOSlaversRefuel_UpdateDifficultyTier); + // Lane L1, the AI seed probe: is `turn1-state -> turn2`'s nondeterminism a per-process SEED or + // an ordering effect? Two launches, load only, and diff the ordered (rng, seed) sequences. + // Both hooks are off in every config except shim.cfg.l1seed. + InstallTemplateHook(tracer, exeBase, sots::addr::RNG_Seed); + InstallTemplateHook(tracer, exeBase, sots::addr::StrategyApp_RunAI); // Per-call-site attribution: detour the SEVEN generator entry points and record // __builtin_return_address(0) with the word cost of each call. These are NOT template hooks -- diff --git a/src/shim/shim.cfg.l1control b/src/shim/shim.cfg.l1control index b5163e0..aedb766 100644 --- a/src/shim/shim.cfg.l1control +++ b/src/shim/shim.cfg.l1control @@ -27,3 +27,5 @@ watch=off watch.players=2 watch.mode=snlv watch.out=C:\SOTS\shim.watch.txt +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1hive b/src/shim/shim.cfg.l1hive index c21449f..cd1cc48 100644 --- a/src/shim/shim.cfg.l1hive +++ b/src/shim/shim.cfg.l1hive @@ -44,6 +44,8 @@ hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=trace fpu.sample_turn=off fpu.sample_ticks=off watch=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off # The generator is 0x9cc bytes and every record carries it twice; `describe` already reduces it to # {left, index, block, words, block_hash}. diff --git a/src/shim/shim.cfg.l1off b/src/shim/shim.cfg.l1off index cf6684b..fb9b85f 100644 --- a/src/shim/shim.cfg.l1off +++ b/src/shim/shim.cfg.l1off @@ -50,3 +50,5 @@ watch=off trace.inline_max=64 trace.path=C:\SOTS\shim.trace.jsonl trace.flush=always +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off diff --git a/src/shim/shim.cfg.l1seed b/src/shim/shim.cfg.l1seed new file mode 100644 index 0000000..41cb98f --- /dev/null +++ b/src/shim/shim.cfg.l1seed @@ -0,0 +1,47 @@ +# Lane L1 -- the AI seed probe. Copy over C:\SOTS\shim.cfg, launch, LOAD `l1-turn1.sav`, and STOP. +# No End Turn is needed: the AI clients and their generators are built on load. +# +# The result is the diff between two launches' ordered (rng, seed) sequences. Identical sequences +# rule the seed out as the source of the turn1->turn2 nondeterminism +# (findings/subsystems/turn1-to-turn2-nondeterminism.md) and leave the ordering hypothesis. +# +# `hooks=trace` with everything named turned off, rather than `hooks=off` with two overrides, +# because that is the pattern every other config in this tree uses and is known to open the log. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off + +hook.Mars::RNG::Seed=trace +hook.Game::StrategyApp::RunAI=trace + +fpu.sample_turn=off +fpu.sample_ticks=off +watch=off +probes=off +trace.inline_max=64 +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always diff --git a/src/shim/shim.cfg.l1snlv b/src/shim/shim.cfg.l1snlv index 27a89c8..1596bef 100644 --- a/src/shim/shim.cfg.l1snlv +++ b/src/shim/shim.cfg.l1snlv @@ -27,3 +27,5 @@ watch=on watch.players=2 watch.mode=snlv watch.out=C:\SOTS\shim.watch.txt +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off