Commit graph

27 commits

Author SHA1 Message Date
alex
b48d860f8a merge lane Z: per-turn RNG ledger (header regenerated, CMakeLists union-resolved) 2026-09-08 10:58:10 -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
39c01422f7 src/app: the standalone -- load a save, run a turn, write a save
`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
2026-09-08 10:35:45 -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
fb23616a8d merge lane F: x87 control-word sensitivity - 53-bit and 64-bit identical; 24-bit and round-up each move 2 named leaves 2026-09-08 05:10:21 -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
lane P
f3c6d551de lane P: post the research events from ours (count-only), declare ObservedTech
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.
2026-09-08 04:16:22 -04:00
alex
a7348be72c game/events: the player event log and the five research events
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.
2026-09-08 02:45:57 -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
de779ad786 wire game/effects into host + shim builds 2026-09-07 22:33:40 -04:00
alex
8b231c0a86 merge m1 config loader (cmake union) 2026-09-07 22:16:55 -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
ba9ec951d2 merge game/design; wire; readme 2026-09-07 22:10:35 -04:00
alex
750ad59ff7 merge game/data; wire into builds; readme 2026-09-07 17:55:08 -04:00
alex
9e110b43ba merge shim/trace; ctest foreach union 2026-09-07 17:51:19 -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
563dbe3a87 merge mars/stream + mars/rng; single vendored miniz (3.1.2); wire into builds 2026-09-07 17:46:48 -04:00
alex
352577a48f merge mars/vfs; wire into host + shim builds; readme 2026-09-07 17:41:28 -04:00
alex
b0f0c0db2b merge game/sim; wire into host + shim builds 2026-09-07 17:28:50 -04:00
alex
afb1532d7a wire mars/parse + mars/text into host and shim builds; ctest; readme status 2026-09-07 17:27:17 -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