L1: probe the AI client seed across two processes -- it is fresh every time
Two hooks, Mars::RNG::Seed 0x0049fdf0 and StrategyApp::RunAI 0x008706f0, and a config that turns everything else off. Two launches from the same save, load only -- the AI clients are constructed on load, so no End Turn is needed. Result: every AI client's generator seed differs between processes (net 32, 496 and 512 all move), while the record structure is byte-for-byte the same shape and one Seed call with seed=0 produces an identical state in both runs. So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering effect that was predicted, and lane AI1's 'every draw from the static generator returns 0' is falsified by measurement. The prediction said the opposite and is left in docs/L1-predictions.md with its outcome underneath. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
This commit is contained in:
parent
2947e24ed9
commit
fa53e02b53
9 changed files with 292 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<const char*>(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<std::int32_t>(p, A::RNG_off_Left)));
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RngSeedHook::describe_args(std::vector<Tv>& 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<trace::Region>& 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<RngSeedHook>;
|
||||
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<Tv>& 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<trace::Region>&, 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<StrategyAppRunAIHook>;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<void*, std::uint32_t>;
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, std::uint32_t seed);
|
||||
static trace::Tv describe_ret(void* r);
|
||||
static void regions(std::vector<trace::Region>& 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<void*, std::int32_t, const char*, std::uint8_t, std::uint32_t>;
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, std::int32_t netId,
|
||||
const char* customData, std::uint8_t personality,
|
||||
std::uint32_t rngSeed);
|
||||
static void regions(std::vector<trace::Region>& 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));
|
||||
|
||||
|
|
|
|||
|
|
@ -277,6 +277,11 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
|||
InstallTemplateHook<shim::hooks::SwarmQueenRegisterHivesHook>(tracer, exeBase, sots::addr::SVSOSwarmQueen_RegisterHives);
|
||||
InstallTemplateHook<shim::hooks::SwarmQueenTickHivesHook>(tracer, exeBase, sots::addr::SVSOSwarmQueen_TickHives);
|
||||
InstallTemplateHook<shim::hooks::SlaversRefuelUpdateDifficultyTierHook>(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<shim::hooks::RngSeedHook>(tracer, exeBase, sots::addr::RNG_Seed);
|
||||
InstallTemplateHook<shim::hooks::StrategyAppRunAIHook>(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 --
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
47
src/shim/shim.cfg.l1seed
Normal file
47
src/shim/shim.cfg.l1seed
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue