Commit graph

29 commits

Author SHA1 Message Date
alex
618ccb1c25 shim: research.replace_cascade, and a completion counter that prints in replace mode too
The B3 hook's unlock cascade was compare-mode only, so a replace run left the
completed node unstamped and no tech unlocked -- and its only per-call counter was
gated on the cascade, so a replace run had no instrument of its own saying a
completion had happened at all. Lane CR needs both halves to ask whether
ProcessResearch can be displaced.

research.replace_cascade=on (default off, so the shipped behaviour is unchanged and
still measurable) lets a replace-mode call write the four TechNode words
SetResearched stamps and the tree's completion-order counter. The ServerPlayer half
of the callback stays unmodelled and stays declared: no event is posted, no
ObservedTech element is appended, no tech effect is applied. The pair of settings is
the measurement -- same binary, one config line, and the leaf difference between the
two autosaves is the cascade's own contribution.

The per-call log line now prints in every mode and counts completions from the pass's
own step results, so 'a completion fired' is a statement by the instrument rather
than an inference from the artefact under test.
2026-09-09 09:15:47 -04:00
alex
55408a980a shim: aivisit, the fleet-assignment visit-order log (lane BU)
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.
2026-09-09 03:10:52 -04:00
alex
c36b560cf0 merge lane L3 (header regenerated) 2026-09-08 19:47:40 -04:00
alex
e1995e6b11 PAR: roll parity -- the AI's per-turn RNG word count is NOT fixed, at any scope
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.
2026-09-08 19:32:19 -04:00
alex
6699f543bc watch.mode=cont: both containers, both ends, re-armed every turn; and hooks=off no longer disarms the watchpoints
Lane L3 needed to watch the trade-route and spy-program vectors across a whole game
played forward, not one turn of one save. `modcount` and `tshn` arm once on purpose --
their targets are picked from one turn's state and re-picking them would move the
measurement -- so this is a third mode rather than a change to either.

`cont` puts all four debug slots on the two containers, `_Myfirst` as well as `_Mylast`.
Both vectors are default-constructed with all three pointers zero, so the first element
writes all three: watching only `_Mylast` cannot separate "allocated for the first time"
from "appended to an existing buffer", and those are different events in the model this
lane set out to falsify. It re-arms and re-logs on every End Turn, and the canary
self-test's counter is therefore read as a delta -- on the arm-once modes the delta is
the old value, so their log lines are byte-identical.

`ReportContainer` is factored out of `ArmTshnSlots` so both modes emit the same container
line. Lane W3's published count=0 is the control every later count is compared against,
and a reformatted line would have made that comparison a judgement call.

The defect: `Shim_Init` returned before `install_watchpoints` whenever the trace mode was
`off`, so `hooks=off watch=on` printed `watch=on` in the banner and armed absolutely
nothing -- a config that reports a confident zero, which is the failure method rule 1
exists to catch. The watchpoints are an independent instrument with their own arming
detour and no trace records, and a long play session wants them without paying 30-45 s
per End Turn for template hooks that measure nothing it is asking about. MinHook is now
initialised and the module installed on the `hooks=off` path when `watch=on`.

Configs: shim.cfg.l3cont / .l3control differ in exactly one key for rule 19;
.l3probe is lane H's hp11 verbatim plus the three watch keys, so the entry counts stay
comparable to lane H's empty-container baseline line for line.

Gates: clean_room_check OK; host ctest 54/54; CT111 shim cross-build OK, exports 66
names identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:10:10 -04:00
alex
7c0c395bea sync header after L4 merge 2026-09-08 18:32:19 -04:00
alex
4f25f1e8c0 merge lane L1 (header regenerated from addresses.json + fragments, not hand-resolved) 2026-09-08 18:25:53 -04:00
alex
fa53e02b53 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
2026-09-08 18:22:14 -04:00
lane-l4
4a5c218a35 L4: read the AI's command block out of the running game
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.
2026-09-08 18:01:19 -04:00
alex
b39bb290e3 L5: the interest literals verified live at a boundary, with a control that fails
`ComputeBudget`'s savings-interest term is now compared against the running game at
a treasury the corpus actually contains. Three runs on VM146 from turn1-state.sav:

  A (widened floats, as shipped)   3,895 calls, 0 diverged, 0 undeclared writes
  B (exact decimals, the control)  2,718 calls, 1,359 diverged

The game fills savingsInterest with 499 at a treasury of 50,000, and with 380 at
38,100 -- the exact decimals pay 500 and 381. Every divergence in B lands on a
treasury that is a multiple of 100 and no other state diverges at all, which is
exactly the arithmetic. G3's rule-23 reading is now measured, not inferred, and the
one-money error is shown to propagate into `available` and `researchMoney` too.

The control also settles why the earlier 4,437-call green run was green: slot 5 IS
diffed and the harness CAN see it, so that run simply presented no boundary state.
Coverage is therefore reported as distinct states, not calls: 5 distinct treasuries,
2 of them on the boundary.

Two further rule-23 constants found in the same routine by an operand-width sweep,
corrected, and honestly marked UNVERIFIED because no reference turn can see them:

  - the research-yield factor is a widened 0.85f while its two neighbours in the
    same product are exact doubles. Boundary: research money a multiple of 40,000;
    the run presented 9 distinct values and none is.
  - the three research modifiers are summed in single precision, not double.
    Boundary: two of the three non-zero; the corpus has shrm = TRM = 0.

Both are pinned by boundary cases in test_economy.cpp that fail with the decimals.

Also verified live, in the same run:
  - T31's difficulty-column recovery. The live ServerPlayer+0xf9 / NPC flags on all
    eight players are exactly what lane PL's save-only inversion claims, including
    the awkward system-owning player that is still ambiguous because it is an NPC.
  - BANKRUPTCY_PROTECTION_LIMIT_FACTOR reads 3.29999995 = (float)3.3. Its file image
    is zero because the loader fills it at run time, so lane PL-3 had to assume the
    value; it is now measured and the assumption was right.

Falsified, and recorded as such: the difficulty-mods record does NOT sit inline at
ServerPlayer+0x36c -- that field is a heap pointer on all eight players. The row IS
reachable from a ServerPlayer (which corrects the hook's standing coverage note),
but the fitted {3.0,1.5}/{1.0,1.0} pair remains unverified. The hook logs the
pointer and does not follow it.

The `verified` column stays 0, deliberately. Every phase this compare touches is
Partial for reasons upstream of it, and promoting one because part of it was checked
is the drift app_test_catalog exists to catch. What moved is models; see
docs/L5-live-verification.md for each one with its coverage.

Gates run separately: clean-room OK, host ctest 54/54, CT111 shim cross-build exit 0.
2026-09-08 17:48:42 -04:00
alex
2947e24ed9 L1: hook BeginProcessTurn and the three script-object writers a turn reaches
Lane SV recovered the script-object subsystem statically and predicted that
SVSOSwarmQueen::RegisterHives takes one RNG_NextInt per new hive inside
StrategyServer::BeginProcessTurn -- which runs inside lane Z's autosave bracket
and outside every one of its subtotals, so a draw there had never been
attributed by anything.

Five nested trace hooks, each declaring the strategic generator as a region and
each carrying a model evaluated at entry so the record can disagree with it:

  StrategyServer::BeginProcessTurn        the unhooked interval, plus a region
                                          over Frame so the increment is a fact
    SVSOSwarmQueen::OnTurnBegin           evt 0x13, vtable slot +0x60
      SVSOSwarmQueen::RegisterHives       predict_new_hives from the original's
                                          own two predicates; reads the LO/HI
                                          config pointers live
      SVSOSwarmQueen::TickHives           the NextQ slip and its three gates
    SVSOSlaversRefuel::UpdateDifficultyTier  tail phase 20; a 4-byte region over
                                          CDiff so a store and its ABSENCE are
                                          distinguishable (method rule 20)

Also watch.mode=snlv: one arming line moves slot 1 from the NVO map's _Mysize to
the target system's SnLv, and the arming sweep now prints SnLv with its decoded
per-player 2-bit levels for every system, which costs no debug register.

Measured on VM140: hive creation costs 2 words in BeginProcessTurn and the
residual outside the two turn drivers is 2, not 0; the next turn it is 0 again.
LO=20 HI=30. Spica's SnLv reads 0x200 with AFlags 0. The oracle reproduced
byte for byte with every one of these detours live.

Predictions and outcomes: docs/L1-predictions.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 17:43:10 -04:00
alex
1cc3b1348d fix my union-resolve of shim main.cpp: the probe_config branch was missing its close, breaking the cross-build 2026-09-08 15:33:56 -04:00
alex
cc77de3429 merge lane W2: watchpoints + multiplayer Tier 0 (CMake list and main.cpp union-resolved to keep both instruments; header regenerated) 2026-09-08 15:28:28 -04:00
alex
a26cd183f1 lane H: entry-probe module, ProcessTeamRecord hook, probes= config, and the outcome of the five predictions 2026-09-08 13:38:59 -04:00
alex
0ebc222f45 lane N: the population -> base-output term, live-verified
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
2026-09-08 12:11:36 -04:00
alex
2a9b97dcee Z: per-call-site draw ledger -- seven entry points, keyed by return address
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.
2026-09-08 10:42:05 -04:00
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
alex
a7ca208b63 T: hook for Game::ServerPlayer::ProcessTurn, with the prediction committed first
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.
2026-09-08 08:08:47 -04:00
alex
d462513f84 shim: force and verify the x87 control word around the turn gate
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.
2026-09-08 05:09:16 -04:00
alex
c110fb245b harness: compile-time Coverage on every descriptor, guard regions, replace-mode records; audit of 23 undeclared side effects 2026-09-08 01:28:35 -04:00
alex
4abf654983 b4: colony + movement hooks; 22 formula corrections (growth curve has no capacity term, range margin +0.05f, ties-to-even rounding); 3 verified signatures 2026-09-08 00:57:55 -04:00
alex
4a6e11d1ce b2: OnTechResearched hook + 10 effects-table corrections (float32, xenotech block size, no already-researched guard, sticky mask, AI bonus values) 2026-09-08 00:01:04 -04:00
alex
9cd997da93 merge b1 (ComputeBudget verified: 4437 compares, 0 divergences, oracle byte-identical); union shim hooks; regenerate header 2026-09-07 23:24:59 -04:00
alex
16f3aa90e5 b1: ComputeBudget hook + adapter; fix researchMoneyKept to be charged only with a research target; budget out-param is int[22] 2026-09-07 23:24:10 -04:00
alex
dd97137fb0 b3: research hook + RNG-snapshot compare design; fix draw mapping to y/(2^32-1), NextInt inclusive, float32 odds, spend floor, decay constants 2026-09-07 23:14:08 -04:00
alex
da679cd79f m2: manifest/id dictionary hooks + manifest_loader; thiscall support in Hook template; struct sizes pinned (+0x14 word) 2026-09-07 22:41:42 -04:00
alex
16e9d927af m1: GlobalConsts::LoadFile hook + game/config loader; trace 19 calls, compare 0 div, replace passes End-Turn oracle 2026-09-07 22:16:55 -04:00
alex
23696875cc shim/trace: JSONL emitter (byte-exact port), tracer, snapshot/diff, Hook<Descriptor> template, self-test; ctest 7/7 + tracecmp exit codes 2026-09-07 17:51:02 -04:00
alex
5f5bc41fd5 m0: binkw32 proxy shim, minhook, trace hook on Application::Initialize; build/deploy tooling 2026-09-07 17:22:15 -04:00