Three function-entry detours, off unless `aivisit=on`:
* StrategyAIAgent::AssignFleetsAndIssueOrders -- the bracket, recording
(agent, task, pass, walked vector, its slots in index order);
* StrategyAIAgent::ClaimShipsOfFleet -- the head loop, one row per element;
* StrategyAIAgent::ReleaseShipsOfFleet -- the tail loop, one row per element.
The two per-element targets have exactly ONE caller each in the whole image and
that caller is the third target, so the module's traffic cannot come from
anywhere else in the game and no return-address filter is needed. The element
loop's own body is deliberately not patched: its first instruction is a branch
target, which is the shape rule 19 exists for.
Each element row carries the element address (the hypothesis under test), the
fleet's id, its ship ids and its Location pointer. The ship ids are the join key
to the command block's list 10; the join must not be by position because the
walk can visit more elements than it emits commands for.
Reads of game addresses are probed before every access and laundered through a
register, so a wrong offset logs a zero instead of faulting inside a detour.
Also: shim.cfg.bupin, which is shim.cfg.bppin plus exactly three non-comment
lines, and is marked `# exhaustive` so tools/check_shim_configs.py enforces
that every registered hook is named in it.
New shim module `ai_rng`: a bracket on StrategyClient::OnResumePlaying over the per-client
generator at +0x134, with two independent measurements per bracket (an observer on the existing
seven draw-site detours, and `left` read straight off the object) plus AI-call-site attribution
through the cl_* facades and an optional seed pin.
Measured on VM140/VM145, six runs, five fresh processes, unpinned autosaves byte-identical to the
published oracle:
turn2->turn3 human 0, AI 32 -> 3 words, AI 496 -> 0, AI 512 -> 0
turn1->turn2 human 0, AI 32 -> 7 words, AI 496 -> 0, AI 512 -> 1 word ... or 3
The last row is the answer: 512 makes ONE cl_RandRange call (the research-target tie-break at
0x006a8495, phase 18) and that one call cost 1 word in one process and 3 in another, because
RNG_NextInt is an unbounded rejection loop. The count is not fixed across clients, across turns,
or across processes with the path held fixed.
Also found: cl_RandFloat 0x00579c70, a third cl_* RNG facade that reaches RNG_NextFloat by a TAIL
JUMP and so leaves no rel32 edge for a call-graph sweep -- which is why ai-turn-logic.md 5's "zero
NextFloat calls from the AI module" reads as true when it is not.
The instrument caught its own defect: RNG_Chance calls RNG_NextFloat and both are detoured, so a
drawn word was reported twice. left_delta was never affected, which is the point of having two
measurements.
Gates run separately on a fresh build directory: clean_room_check OK, host ctest 55/55, CT111 shim
cross-build OK.
New shim module src/shim/hooks/ai_orders.{h,cpp}: one register-transparent entry
stub on StrategySim::ApplyTurnCommandBatch dumps every submitted TurnCommands block
(six gates, 27 list lengths, element bytes) at the point where all of them are
complete in memory; sixteen entry probes, with RunTaskList's stub recording the
pass so every later hit is pass-attributed.
Two workloads on VM145, one End Turn each. The rule-19 control passed with all
seventeen detours installed: both autosaves byte-identical to the published oracle.
What the AI actually emits, and three things no reading had produced:
- a list-23 element on EVERY turn, the first element ever observed in the free
half of the cost table -- and both turns still cost the measured 12;
- the ids in AI commands are client-allocated and travel in the command (design
18, fleet 34; neither exists in the input save);
- pass 0 emits nothing, measured from element counts rather than inferred.
tests/game_ai/test_live_blocks.cpp rebuilds both captured blocks through the public
OrderClient API and asserts the list profile, element values, gate counts and
ModCount total: 44 checks. Kept separate from test_orders.cpp, which stays the
record of what static reading predicted.
Gates: clean_room_check OK, host ctest 55/55, CT111 shim cross-build exit 0.
First module of game/ai, and the first piece of Rung B that is not scaffolding. It is the two
halves of the AI's task selection that are pure: the 33-value task type enumeration and the
ranking that decides which goal the AI acts on first.
* the priority table, verbatim -- higher runs first, and it is the entire default policy;
* the five overrides, kept out of the table on purpose. The two artifact tasks ignore their
table entries (1 and 2) and return 1260/1261; a port that only copied the table would rank
them last instead of fourth and fifth. The two tuned invade priorities are INPUTS
(TaskPriorityPolicy), not constants, because their loader is not yet identified;
* Rank() as a stable descending sort. The original sorts a std::list, so stability is the
behaviour, not a choice -- ties keep creation order;
* CreationOrder(species, policyNonZero), because that is what breaks the ties. Four arms: the
NPC species builds nothing, Hiver is the only arm with the gate families, Zuul the only one
with NodeBore, everyone else shares a fourth. Both defensive families are gated on the
player's policy value and DefendGateIncoming is Hiver-only on top of that.
193 checks in tests/game_ai, every expected value read off the original's tables rather than
produced by running this code. ctest 46/46 -> 47/47; clean-room check OK.
Derivation: sots-re findings/subsystems/ai-task-system.md (lane AI2), sections 1-3.
The strategic layer does not search for a route: the player or the AI picks the
destinations and the engine classifies each consecutive pair, deciding the waypoint
kind and whether the order is legal. This models that classifier as pure functions.
The waypoint kind of any leg that is neither a gate transit nor a node route is a
pure function of the owning species -- which is the whole answer to why kind 2 has
never been observed. Kind 2 is the Liir drive; the two node-drive races are Human
and Zuul, both of which map to kind 3, and every observation so far was taken on
one of those two.
Also modelled: the three refusal bits versus the nine advisory ones, the gate
transit that waives the grounded-fleet refusal, the projection radius that splits
gate-to-gate from gate-to-gateless, the single-hop node line lookup and bore, and
the fuel check whose range is squared at full precision while the distance is
narrowed -- the one floating-point asymmetry here that flips a decision.
The leading-destination drop is reproduced with its original off-by-one behind an
explicit flag rather than silently fixed.
120 hand-computed checks. Host ctest 43/43; clean-room check OK.
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.
The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.
A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.
Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.
sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.
Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
The strategic half of a battle: where beaten fleets go, which fleets split,
which are left empty, and who learns the system they were beaten at. Draw-free
end to end -- the whole sub-tree contains no random draw -- so this is a pure
function of its inputs and needs no generator.
What is modelled:
* the destination search: three independent nearest-system passes (owned /
no hostile presence / anything), each with its own best-so-far, over
squared float32 distances with a strict comparison. The independence is
load-bearing: a nearer system rejected by one predicate must not spoil
that pass's best, and a single-loop version gets it wrong.
* the hostility mask, including the rule that a system captured on the
current turn loses its owner's ceasefire cover.
* per-ship eligibility: already-departed, encounter-faction exclusions (one
hard-coded id plus a data-driven bitmask), and the dead-drive gate, which
tests against a single-precision epsilon rather than zero and which the
gate species skips because it does not fly out.
* grouping on all four key words (owner, destination, mode, variant).
* whole-versus-partial: a fleet runs whole only when every one of its ships
is in the group; otherwise the group gets one new fleet and the leftover
ships move into it, while ships of a wholly-retreating fleet stay put.
* the emptied-fleet list, which matters because destroying a fleet aborts
every intercept aimed at it.
Deliberately NOT modelled: applying the plan. Creating a fleet mints an object
id from a monotonic counter and appends to the master fleet list, and both of
those are saved state; that belongs above this layer, where the object store
lives. Keeping the decision separate is what makes it host-testable.
53 hand-computed checks. ctest 42/42, clean-room check OK.
The boundary ledger says which phase spends a turn's words; this says which
call site. Each entry point is detoured with its verified prototype and records
__builtin_return_address(0) plus the word cost from left before/after -- two
4-byte reads, no record per draw, since NextFloat alone has 109 call sites.
Each draw is tagged with WHICH generator it came from. That is not a detail:
the first build counted every Mars::RNG instance in the process and reported 44
words against a bracket of 18. The StrategyClient's generator at +0x134 draws 8
times a turn and must not be in the strategic total.
Also hooks EncounterDetect::AssignContacts, lane I's one inlined-draw site in
ProcessTurn's closure -- invisible to both a call-graph sweep and to the entry-
point detours, so only a boundary hook can see it. It did not fire on this
workload, which is consistent with the site sums reconciling exactly.
`sots_turn` loads a save through the engine's own reader, walks the published
phase order of all three turn drivers, runs what we hold, prints what we do
not, and writes the result back through the engine's own writer.
The phase catalog carries all 32 + 12 + 37 phases whether or not they are
implemented, so an unimplemented phase is a named no-op that appears in the run
log rather than a silent absence. 14 of the 44 turn-driver phases are modelled,
7 commit anything, 2 of the 37 tail phases are modelled.
Modelled but NOT committed is a first-class state. A phase whose formula we hold
and whose inputs we do not is evaluated, reported, and left unwritten unless
--commit-blocked is passed. That distinction was earned: committing phase 31's
player-status restore regressed two leaves that had agreed with the oracle
before the turn, because the phase writes 1 and the file carries 4.
Measured against the game's own post-turn saves, leaves localised by
state_checksum.py with coverage proved by re-serialisation:
turn1-state -> turn2-state 209 -> 204 diverging, closed 5, regressed 0
turn2-state -> turn3-state 108 -> 103 diverging, closed 5, regressed 0
Two tests: app_catalog (the tables stay complete and nothing claims to be
verified against a live game) and app_turn (11 saves driven; an untouched load
re-serialises byte-identically, a turn leaves the file re-readable, and no
blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR.
ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the
full gap list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
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.
Descriptor + pure adapter + host tests for the per-player turn driver. Not
deployed; the WIN32 half is unbuilt here (no cross-compiler on this host).
The declared boundary is narrower than the function on purpose. Phases 2, 3
and 6 -- the savings apply, the aid records and the research refund -- are pure
functions of ComputeBudget's 22 slots and ProcessResearch's overBudget, and
both live in the original's own stack frame. Reaching them would mean calling
ComputeBudget ourselves (it repairs ships in orbit, audit #6), reading the
nested B1/B3 hooks (audit #5, the self-fulfilling compare), or inferring them
from the Sav delta. So they are guarded, not checked, and the three formulas
are written and unit-tested but not wired into the verdict.
Declared: the phase-7 clear, the RebAI decay, the descending timed-bonus
sweep, plus roll_flags and rng as observations ours never writes. Guards over
the whole ServerPlayer and the TechTree header.
docs/T-turn-driver.md states, before any run: which regions must not diverge,
which checks are weak by construction on the reference save, what falsifies
the ResearchRollPending reading, and the save that would finally fire the
branch nobody has seen.
host ctest 36/36 (was 35/35); clean_room_check OK.
New fpu_force module (4 register-transparent asm stubs, same pattern as the M0
Initialize hook, so the [unverified] prototypes of the turn-gate functions are
never relied on) plus two shim.cfg keys:
fpu.force=<cw>|off fldcw at StrategyClient::EndTurn and
StrategyServer::BeginProcessTurn, and nowhere else
fpu.sample_ticks=on|off per-frame sampler, logs only when the word CHANGES
Forcing is deliberately one write per turn: re-forcing inside the pipeline would
guarantee the value is present without proving it ever held, which is the exact
false negative this experiment has to avoid. Verification is kept separate --
StrategyServer::ProcessTurn is hooked sample-only, and its reading plus the
existing per-hook fpu_cw fields (38 samples per turn across phases 4, 6 and 8)
are what establish that the setting lasted the whole turn.
Six shim.cfg variants, identical apart from the fpu.force line, and docs.
Used to settle STATE_CHECKSUM.md 3.5: 53-bit and 64-bit x87 produce byte-
identical turn results, so an x64/SSE port has no double-rounding budget to
preserve; 24-bit and round-up each move exactly one thing. Findings and evidence
live in the notes repo (findings/subsystems/fpu-precision-sensitivity.md).
clean_room_check.sh OK; host ctest 32/32.
TechTree::ProcessResearch's events region now compares a modelled value instead of
reporting a known defect. ours posts the pass's events into its own
sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the
original runs, and writes only the counts into the region's scratch copy. The game's
PostEvent is never called and no live byte moves; replace mode still posts nothing,
because a bumped EvNxID with no record behind it would corrupt the oracle's save.
- game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText
- shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter,
carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them
- research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and
turn / events_next_id_in / events_dedup_risk in the args so the count model's own
assumption is measured rather than assumed
- EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's
unlock cascade, which ours does not run. The driver takes the unlock list as an input
and is handed 'no list', so a missing input cannot look like a modelled negative.
Predicted residual: next_id short by exactly 1 on a completion call.
ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is
syntax-checked only: no MinGW cross toolchain on this box.
See docs/P-events-wiring.md for the exact prediction for the next VM run.
Recovers the game's event-posting API so the engine can post events and the
compare harness can see them. Until now the owner's event list was invisible to
every layer: B3's replace-mode oracle failed by exactly one item across 40,300
(an unposted EVENT_RESEARCH_OVERBUDGET) while its compare read clean, and B2's
clean compare bounds the economy fields only.
New pure module src/game/events:
* EventStorage / TurnEvents / PlayerEvent -- the list is bucketed by TURN, not
flat, which the save-editor struct notes had wrong.
* EventStorage::Post reproducing the original's rules, including the four that
change save bytes: the FLT_MAX (not infinity) default position; action 0 with
no subject and no position storing as 2; per-bucket dedup that compares
message/image/location/position/action but NOT summary; and EvNxID starting
at 0 and being promoted to 1 on the first post.
* PruneOldTurns reproduced with its off-by-one: of a leading run of buckets
older than turn-50 it erases n-1, so one stale bucket always survives. The
survivor is serialized, so correcting it would diverge.
* research_events: the five events the research path raises, their EvImg
identifiers and string-table keys, and the 0.8 completion split evaluated
against (double)0.8f rather than the decimal 0.8.
Localized text is deliberately absent: only the EVENTSUM_/EVENTMSG_ keys are
here and the text resolves through a caller-supplied lookup, as the game does.
The four event offsets the B3 hook carried as local literals now come from the
generated header; they are read off instructions rather than inferred from the
save schema.
tests/game_events: 112 checks including a replay of the event list
turn3-state.sav actually holds. ctest 31/31 -> 32/32.
docs/E-events.md carries the proposed region and Coverage wording for the next
B3 recapture.