merge lane Z: per-turn RNG ledger (header regenerated, CMakeLists union-resolved)
This commit is contained in:
commit
b48d860f8a
14 changed files with 2275 additions and 5 deletions
|
|
@ -73,6 +73,13 @@ add_library(shim_events STATIC src/shim/hooks/event_inputs.cpp)
|
|||
target_link_libraries(shim_events PUBLIC shim_trace sots_addresses sots_game_events)
|
||||
target_compile_options(shim_events PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
# ---- shim: the strategic RNG word ledger (lane Z) -- absolute generator position recovered
|
||||
# from (mt[624], left) alone, so a turn's RNG cost can be attributed to a phase without
|
||||
# hooking a single RNG primitive. Host-testable; linked into the hook descriptors below.
|
||||
add_library(shim_rng_ledger STATIC src/shim/hooks/rng_ledger.cpp)
|
||||
target_link_libraries(shim_rng_ledger PUBLIC shim_trace sots_addresses mars_rng)
|
||||
target_compile_options(shim_rng_ledger PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
if(WIN32)
|
||||
# ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ----
|
||||
add_library(minhook STATIC
|
||||
|
|
@ -89,11 +96,13 @@ if(WIN32)
|
|||
src/shim/hooks/compute_budget.cpp
|
||||
src/shim/hooks/colony_turn.cpp
|
||||
src/shim/hooks/fleet_movement.cpp
|
||||
src/shim/hooks/player_turn.cpp)
|
||||
src/shim/hooks/player_turn.cpp
|
||||
src/shim/hooks/tail_rng.cpp
|
||||
src/shim/hooks/draw_sites.cpp)
|
||||
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
|
||||
sots_game_effects mars_rng shim_budget shim_techfx
|
||||
shim_colony shim_movement shim_events
|
||||
shim_player_turn)
|
||||
shim_player_turn shim_rng_ledger)
|
||||
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
|
||||
|
||||
add_library(binkw32 SHARED src/shim/main.cpp src/shim/fpu_force.cpp src/shim/binkw32.def)
|
||||
|
|
@ -109,7 +118,7 @@ else()
|
|||
add_executable(addr_smoke tests/addr_smoke.cpp)
|
||||
target_link_libraries(addr_smoke PRIVATE sots_addresses)
|
||||
add_test(NAME addr_smoke COMMAND addr_smoke)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn app)
|
||||
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn shim_rng_ledger app)
|
||||
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
|
||||
add_subdirectory(tests/${_t})
|
||||
endif()
|
||||
|
|
|
|||
273
docs/Z-tail-rng.md
Normal file
273
docs/Z-tail-rng.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# Z — the RNG ledger for one strategic turn
|
||||
|
||||
Lane Z, 2026-09-08. Worktree `wip/tailrng`. **Written before the build was staged** (method rule 2).
|
||||
|
||||
The milestone this serves: *a standalone that loads a save, runs one strategic turn, and writes an
|
||||
autosave that byte-matches the original's.* Generator state is part of the saved state, so the turn
|
||||
is not reproduced until every word the strategic generator consumes is accounted for.
|
||||
|
||||
Lane K (`sots-re/findings/control-flow/combat-done-tail.md` §3) found two draw sites in
|
||||
`StrategyServer::OnAllCombatDone_Tail` that no lane models, both **before** the autosave. Every RNG
|
||||
accounting in the repo assumes the generator only advances inside `StrategyServer::ProcessTurn`.
|
||||
This lane measures what actually happens.
|
||||
|
||||
---
|
||||
|
||||
## 1. The instrument
|
||||
|
||||
Not an RNG-primitive hook. The generator's whole state is `mt[624] + left`, and the twist is a pure
|
||||
function of the block, so **position is recoverable from state alone**:
|
||||
|
||||
```
|
||||
position(block_index, left) = block_index * 624 + (624 - left)
|
||||
```
|
||||
|
||||
`RngLedger` (`src/shim/hooks/rng_ledger.{h,cpp}`, host-tested) keeps a forward-only chain of blocks
|
||||
from the first block it ever sees, memoised by a 64-bit hash of the 2496-byte block. Observing a
|
||||
state resolves its block to an index (extending the chain by twisting when the block is ahead of the
|
||||
frontier) and returns an absolute word position. **Word deltas between any two observations are then
|
||||
exact**, including across twists, and no assumption is made about how many words a `NextInt`
|
||||
rejection loop spent — the state says.
|
||||
|
||||
Ordering matters and is handled explicitly. `Hook<>` calls `describe_args` and `regions` at entry
|
||||
(chronological) but calls every region `describe` *after* the original returns, so a nested call's
|
||||
`before` snapshot would otherwise be described after the chain had already moved past it. Each hook
|
||||
therefore **observes at entry from `describe_args`**, which memoises the entry block; the `describe`
|
||||
that runs at exit then resolves it from the memo instead of walking backwards.
|
||||
|
||||
Six hooks, all `trace`, nested so the subtotals attribute:
|
||||
|
||||
| hook | address | why |
|
||||
|---|---|---|
|
||||
| `Game::StrategyHost::Autosave` | 0x00895210 | the two absolute markers. `endTurn=1` is the pre-turn state, `endTurn=0` the post-turn state; **the words between them are the turn's whole RNG cost as the two saves see it** |
|
||||
| `Game::StrategyServer::ProcessTurn` | 0x007dc6c0 | the half the repo already believes in |
|
||||
| `Game::StrategyServer::OnAllCombatDone_Tail` | 0x007d92a0 | the half nothing models |
|
||||
| `Game::StrategyServer::ApplyEncounterResult` | 0x007d8920 | tail phase 6, the combat-resolver subtree |
|
||||
| `Game::StrategyServer::NodeLineDecay` | 0x007ae010 | tail phase 11, the `Chance(0.5f)` per expired node line |
|
||||
| `Game::StrategyServer::ProcessNodeSpaceTravel` | 0x007a0e20 | runs **twice** a turn (ProcessTurn phase 7 and tail phase 10) and has never been swept for draws |
|
||||
|
||||
The residual — `Autosave(1)→Autosave(0)` total minus the sum of the attributed subtotals — is the
|
||||
number this lane exists to produce. It is the part of a turn a reimplementation would silently miss.
|
||||
|
||||
## 2. Predictions
|
||||
|
||||
**P1 — the tail runs on a turn with no combat.** Lane K flagged this as inferred from the
|
||||
determinism note, not from the instruction stream. Predicted: `OnAllCombatDone_Tail` records exactly
|
||||
one call per End Turn regardless of encounters.
|
||||
*Falsified by:* an End Turn where `Autosave(endTurn=0)` records a call and no tail call precedes it
|
||||
in the same turn. If that happens, §6's autosave mechanism is reached some other way and lane K's
|
||||
§7.1 (`S+0x8` advances twice per turn) is wrong too — so P1 and P6 fail together or not at all.
|
||||
|
||||
**P2 — on a quiet turn the tail consumes exactly 0 words.** Both known draw sites are conditional:
|
||||
phase 6's loop body never runs with an empty encounter vector, and phase 11 draws once per *expired*
|
||||
node line. Nothing else in the 36 phases reached a primitive in lane K's sweep.
|
||||
*Falsified by:* a nonzero tail delta on a turn whose encounter count is 0 and whose node-line decay
|
||||
subtotal is 0. That would be a draw site lane K's callee sweep missed, and it would be the most
|
||||
important single result of this lane — more important than a confirmation.
|
||||
|
||||
**P3 — the tail's contribution is 0 on most turns, which is why nothing broke.** The defect lane K
|
||||
found is latent, not active: our saves have never expired a node line or fought a battle at the point
|
||||
where the tail runs. Predicted: on `ref-turn2` and the `turn1/2/3-state` saves, tail delta = 0.
|
||||
*Falsified by:* any nonzero tail delta on the reference saves — which would mean existing "RNG
|
||||
matched" results were luckier than they looked (compare method rule 11).
|
||||
|
||||
**P4 — `ProcessTurn` consumes a small, nonzero, state-dependent number of words.** Order 1–20 on a
|
||||
two-player early-game turn: research rolls are 1 word per player whose gate passes and 2 on the
|
||||
plague path (lane T §3.1), plus whatever `ProcessStations` / `ProcessSurrenders` / `ProcessMissions`
|
||||
/ `ProcessSpecialProjects` spend — none of which has ever been measured.
|
||||
*Falsified by:* zero (meaning the research gate never passes and nothing else draws — possible, and
|
||||
then the interesting question moves entirely to what the AI does), or by hundreds (meaning a
|
||||
per-system or per-fleet draw nobody has found).
|
||||
|
||||
**P5 — the attributed subtotals do not sum to the bracket.** Predicted residual > 0, because combat
|
||||
itself (`RunCombatRound` 0x007cbe80 / the combat server `0x007cfd00`) runs *between*
|
||||
`ProcessTurn` and the tail and is hooked by nobody. On a **quiet** turn, though, predicted residual
|
||||
= **0** exactly: the two drivers should account for every word between the two autosaves.
|
||||
*Falsified by:* a nonzero residual on a quiet turn. That is a draw site outside both drivers and
|
||||
outside combat, and it would mean the turn has a third RNG consumer.
|
||||
|
||||
**P6 — `S+0x8` advances exactly twice per turn.** Lane K's §7.1 correction, live. Each hook records
|
||||
`S+0x8` and `S+0xc` at entry.
|
||||
*Falsified by:* any other count. Recorded because it is free and it settles a published correction.
|
||||
|
||||
**P7 — node-line expiry will probably not fire.** Phase 11 draws per *expired* line; lifetimes are
|
||||
long. Predicted: several End Turns on the node-route saves produce zero phase-11 draws, and this lane
|
||||
reports that plainly rather than claiming the path is covered (method rule 6 — a path no save
|
||||
exercises is a hypothesis, and it stays labelled one).
|
||||
*Falsified by:* a phase-11 subtotal > 0, which would make the model in §3 checkable.
|
||||
|
||||
**P8 — one generator, not several.** All six hooks watch the object at `S+0x16c`. Predicted: every
|
||||
observation resolves against a single forward chain.
|
||||
*Falsified by:* an `UNKNOWN` position, i.e. a block the chain cannot reach — which means a second
|
||||
generator instance, and every attribution above would need re-reading.
|
||||
|
||||
## 3. What `ours` models
|
||||
|
||||
`NodeLineDecay` carries a model: walk the 0x30-stride node-line records at `[S+0x154]+8/+0xc`, count
|
||||
the expired ones under the same predicate the original tests, and advance the scratch generator by
|
||||
that many words. In compare mode the diff on the `rng` region is then a real check of the count.
|
||||
|
||||
`OnAllCombatDone_Tail` and `ProcessTurn` carry **no** model and say so in `Coverage`: their cost is
|
||||
whatever their subtrees spend, and the honest statement is the measured number plus the named
|
||||
unmodelled subtrees, not a prediction dressed as one.
|
||||
|
||||
## 4. What this cannot settle
|
||||
|
||||
* The combat resolver `0x007d5af0` (7499 B) stays unread. Its draw count is measured here as part
|
||||
of the phase-6 subtotal, never modelled.
|
||||
* A turn with no combat cannot exercise phase 6 at all, so its subtotal on a quiet turn is 0 by
|
||||
construction and proves nothing about combat.
|
||||
* The ledger measures **words**, not draws. A `NextInt` that rejects three times is four words and
|
||||
one draw; this instrument reports four and cannot tell you it was one call. That is the right unit
|
||||
for save-state reproduction and the wrong unit for counting decisions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Outcome (added after the run; nothing above was edited)
|
||||
|
||||
Build `z-tailrng-20260908T1314Z` on `ref-turn2` (4 End Turns) and `z-tailrng2-20260908T1328Z` on
|
||||
`zuul-turn16-noderoute`. Full report: `sots-re/findings/control-flow/tail-rng-ledger.md`.
|
||||
|
||||
| # | prediction | outcome |
|
||||
|---|---|---|
|
||||
| P1 | the tail runs on a turn with no combat | **held, half** — one call per End Turn on 8 of 8. But every turn had exactly one *encounter* (a sighting, `res->+0x4 != 0`), so what is proved is "no battle", not "no encounter". Narrowed, not closed |
|
||||
| P2 | quiet turn → tail consumes 0 words | **held** — 0 on 8 of 8 |
|
||||
| P3 | tail = 0 on the reference saves; the defect is latent | **held** |
|
||||
| P4 | `ProcessTurn` spends a small nonzero state-dependent count, order 1–20 | **held** — 18, 19, 20, 22 across eight turns |
|
||||
| P5 | residual > 0 in general, **0 on a quiet turn** | **held** — residual exactly 0 on all six complete brackets |
|
||||
| P6 | `S+0x8` advances exactly twice per turn | **FALSIFIED** — it advances **12–14** times per turn; the two drivers are 2 of them. Lane K's "at least twice" was the right phrasing and its conclusion (never treat `S+0x8` as a turn number) is strengthened |
|
||||
| P7 | node-line expiry probably will not fire | **held**, and quantified rather than left as an absence: 51 of 53 lines on the Zuul map are permanent (`npt == 0`), the mortal ones are dug by the Zuul at ~1/turn, and every one is ~40 turns from expiry |
|
||||
| P8 | one generator, every observation resolves | **held** — no `words: null` in any record |
|
||||
|
||||
Two results worth more than the predictions:
|
||||
|
||||
* **The generator does not move between turns.** Each turn's `ProcessTurn` entry position equals the
|
||||
previous post-turn autosave position exactly. The interval a standalone must reproduce is closed.
|
||||
* **The ledger was checked against the save files.** The turn-6 autosave pair gives 18 words read straight
|
||||
out of the two `Sim.RNG` blobs — and with `twists == 0`, so that number does not go through anyone's twist
|
||||
implementation. The live hook said 18.
|
||||
|
||||
Two corrections went back into `combat-done-tail.md` in place: the node-line fleet check runs *after* the
|
||||
`Chance` call and cannot gate the draw, and `StrategyHost::Autosave` is `ret 8` returning the `std::string*`
|
||||
in EAX.
|
||||
|
||||
---
|
||||
|
||||
## 6. A second prediction, written before the run reached it
|
||||
|
||||
Committed at turn 34 of the `zuul-turn16-noderoute` long run, with the run still in flight.
|
||||
|
||||
The node-line population's minimum remaining life is now decrementing by **exactly 1 per turn** —
|
||||
43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 33, 32, 31, **30** at turn 34 — i.e. pure ageing, with the
|
||||
traffic term contributing nothing on this map. `NodePath::RemainingLife` clamps at 0 and the loop skips on
|
||||
`> 0`, so the oldest mortal line expires the turn its remaining life reaches 0.
|
||||
|
||||
**P9. The first phase-11 draw happens on turn 64, and costs exactly 1 word.** On that turn:
|
||||
`Game::StrategyServer::NodeLineDecay` records `np_min_life = 0`, `predict_words = 1`, and a measured
|
||||
`rng` delta of **1**; `OnAllCombatDone_Tail`'s total becomes **1** instead of 0 — the first non-zero tail
|
||||
cost this campaign has ever recorded — and the bracket total becomes `ProcessTurn + 1`.
|
||||
|
||||
*Falsified by:*
|
||||
* **it fires earlier than 64** — then the traffic term `nptf / npdtf` is contributing after all, and the
|
||||
ageing-only reading of the last fifteen turns is wrong;
|
||||
* **it fires later than 64, or not at all by turn 66** — then `min_life` is not the line that expires first
|
||||
(a line dug later with a shorter `npdtn` would do it), or `S+0xc` is not the `turn` the original passes;
|
||||
* **it costs more than 1 word** — then more than one line expires on the same turn, which the `np_within5`
|
||||
column would have warned about (it has read 0 on every turn so far), **or** node-line decay has a draw
|
||||
site the sweeps missed, which is exactly what method rule 16 says a call-graph sweep cannot rule out;
|
||||
* **it costs 0 with `np_min_life = 0`** — then the expiry predicate transcribed in §6.1 of the finding is
|
||||
wrong somewhere, most likely in the two never-expire early-outs.
|
||||
|
||||
This is the first genuinely falsifiable numeric claim this lane can make: every earlier one compared 0
|
||||
against 0.
|
||||
|
||||
---
|
||||
|
||||
## 7. P10, written with the encounter dialog on screen and unclicked
|
||||
|
||||
The turn-54 End Turn on the Zuul long run stopped on an **Encounter at Gallandro**: the player's 5 ships
|
||||
(3 DE Colonizer, 2 DE Armor) against a **Von Neumann**, with the dialog offering Fight Manually / Auto
|
||||
Resolve / Fight Manually If Opponent Does / Retreat. Auto Resolve has not been clicked yet.
|
||||
|
||||
This is the workload `sots-re/findings/control-flow/tail-rng-ledger.md` §8 says does not exist and lane J's
|
||||
`combat-resolver.md` asks for: **every encounter measured so far had `res->+0x4` set, so
|
||||
`ApplyEncounterResult` was a whole-function no-op and the combat resolver has never executed under any
|
||||
instrument this campaign has built.**
|
||||
|
||||
**P10. Clicking Auto Resolve produces the first non-zero tail cost this campaign has recorded.**
|
||||
Specifically: `ApplyEncounterResult` records `res_no_battle = 0` for the first time; its measured `rng`
|
||||
delta is **greater than 0**; and `OnAllCombatDone_Tail`'s total equals that delta, because node-line decay
|
||||
is still 30-odd turns from firing and every other phase has measured 0 across 54 turns.
|
||||
|
||||
On the size, following lane J's map of the three conditional sites: there is no node cannon here, so R1
|
||||
should not fire. A **Von Neumann is salvage** — R2 is one inlined `NextFloat` per back-engineering candidate
|
||||
per combatant with a non-zero salvage slot, and R3 one `NextInt` per successful R2 roll. So the expected
|
||||
cost is **small and odd-shaped: a handful of words, not a multiple of anything.** I am deliberately not
|
||||
naming a single number, because I have not read the resolver and lane J has: a range with a mechanism
|
||||
behind it is the honest form of this prediction.
|
||||
|
||||
*Falsified by:*
|
||||
* **tail cost 0 with `res_no_battle = 0`** — then the resolver really has no draw on a plain battle path,
|
||||
which would make lane J's "no unconditional draw" stronger than either of us expected, and would mean a
|
||||
battle is free to a reimplementation;
|
||||
* **a large cost (tens or hundreds of words)** — then something in the battle path draws per ship or per
|
||||
round, and the resolver's three sites are not the whole story;
|
||||
* **a non-zero cost that does NOT show up under `ApplyEncounterResult`** — then the draw happens in combat
|
||||
proper (`RunCombatRound` / the combat server), which runs *between* `ProcessTurn` and the tail and is
|
||||
hooked by nobody; the bracket residual would go positive for the first time and that is where it would
|
||||
appear.
|
||||
|
||||
The third case is the one to watch: it is the only way this workload can produce a **non-zero residual**,
|
||||
which every prediction so far has said should be 0 on a quiet turn.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8. P10 outcome — falsified, and the falsification is the result
|
||||
|
||||
`res_no_battle` flipped to **0** for the first time in 55 turns, the player's fleet was destroyed, and the
|
||||
measured cost was:
|
||||
|
||||
| | words |
|
||||
|---|---|
|
||||
| `ApplyEncounterResult` (the battle) | **0** |
|
||||
| `OnAllCombatDone_Tail` | **0** |
|
||||
| `StrategyServer::ProcessTurn` | 22 |
|
||||
| bracket total / residual | 22 / **0** |
|
||||
|
||||
P10's main claim ("greater than 0") is **wrong**, and it fell into its own first falsification branch: the
|
||||
resolver really has no draw on a plain battle path. The residual stayed 0, so combat proper drew nothing
|
||||
either — the third branch, the one worth watching, did not fire.
|
||||
|
||||
The consequence is worth more than the prediction would have been: **a reimplementation can model a
|
||||
strategic turn's RNG and nothing about combat, and still reproduce the generator through a battle.** Lane
|
||||
J's own cheap prediction — a plain fleet battle costs the same as a peaceful turn — holds exactly.
|
||||
|
||||
Caveats in `sots-re/findings/control-flow/tail-rng-ledger.md` §10.2; the short version is that this is one
|
||||
auto-resolved encounter against an NPC, `Auto Resolve` may not take a manually-fought battle's path, and a
|
||||
cost of 0 is the easiest number in the world to produce by accident.
|
||||
|
||||
---
|
||||
|
||||
## 9. P9 outcome — held on every clause
|
||||
|
||||
The game was played to turn 64.
|
||||
|
||||
| | predicted at turn 34 | measured at turn 64 |
|
||||
|---|---|---|
|
||||
| turn of the first draw | 64 | **64** |
|
||||
| `np_min_life` at that turn | 0 | 0 (`np_within5` = 0, `expired` = 1) |
|
||||
| `predict_words` | 1 | **1** |
|
||||
| node-line decay `rng` delta | 1 | **1** |
|
||||
| `OnAllCombatDone_Tail` total | 1 | **1** |
|
||||
| bracket | `ProcessTurn` + 1 | 20 + 1 = **21**, residual **0** |
|
||||
|
||||
Not one of the four falsification branches fired. `predict_words` is computed at hook entry from the
|
||||
transcribed `RemainingLife` predicate, *before* the original runs, so this is a real check of the model —
|
||||
unlike the 63 preceding turns where it said 0, the measurement said 0, and nothing was checked.
|
||||
|
||||
**And the instrument's own weakest point was exercised on the same turn.** §4 of the finding flagged that
|
||||
every live measurement so far had sat inside a single MT block, so the block-chain code had only ever run in
|
||||
host tests. On turn 64 `ProcessTurn` entered with `left = 11` and left with `left = 615` — it crossed a
|
||||
block boundary, the generator twisted, and the ledger reported `11 + (624 − 615) = 20`. The bracket still
|
||||
reconciled to a residual of 0.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ f9b744e, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 3d5f834, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1295,6 +1295,68 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
|
|||
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
|
||||
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
|
||||
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
|
||||
// thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified]
|
||||
constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670;
|
||||
// thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified]
|
||||
constexpr uint32_t Mars_RNG_FloatRange = 0x0007d8a0;
|
||||
// cdecl int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi) // plain RET. SIXTH DRAW ENTRY POINT. h = hi - lo; half = h/2 truncated toward zero (the `cdq; sub eax,edx; sar 1` idiom, so negative h rounds toward zero not down); returns lo + NextInt(half) + NextInt(h - half), both NextInt calls entered with ECX = rng+4 and both taking the bound BY POINTER. TRIANGULAR, not uniform. AT LEAST TWO MT WORDS -- each NextInt carries its own rejection loop. Not reachable from any turn driver by a direct call [verified]
|
||||
constexpr uint32_t Mars_RNG_IntRangeBell = 0x004e6d80;
|
||||
// cdecl int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi, int mode) // plain RET. SEVENTH DRAW ENTRY POINT and the only one whose cost is UNBOUNDED. Box-Muller with rejection; BOTH draws are INLINED (temper chains at 0x008e6eb8 and 0x008e6f34), so no call-graph RNG sweep sees them and the only edges left are two bare RNG_Twist calls. half = (hi-lo)/2; mean = 2.1 * ((mode-lo)/half - 1). Per attempt: z = sqrt(-2 * ln(1 - (y1 + 0.5) * 2^-32)) * cos(2*pi * y2 * 2^-32) + mean; REJECT and redraw while z > 2.1 or z < -2.1 (back-edges 0x008e6f8d and 0x008e6fa0 -> 0x008e6e7f). Result = lo + ftol(((z + 2.1) / 4.2) * (hi - lo)). TWO MT WORDS PER ATTEMPT. NOTE THE DIVISOR: this path scales by 2^-32 (0x00a3b6f0), NOT the 1/(2^32-1) at 0x009e61b0 that RNG_NextFloat uses -- the two are different constants in the same image. Three callers (0x00786200, 0x00786230, 0x00798040); not reachable from any turn driver by a direct call [verified]
|
||||
constexpr uint32_t Mars_RNG_GaussianRange = 0x004e6e30;
|
||||
// thiscall void (EncounterDetectCtx* this, std::vector<std::vector<void*>>* outBuckets, std::vector<void*>* detectors, std::vector<void*>* contacts) // RET 0xc. THE ONLY GAME FUNCTION WITH AN INLINED MT DRAW IN StrategyServer::ProcessTurn's CLOSURE (depth 4). this = {+0x00 StrategyServer* S, +0x04 TechDef* id 0x2729, +0x08 TechDef* id 0x2728}. For each contact (outer loop over `contacts`, index ebx) it walks `detectors` (inner loop, index edi) and draws ONE inlined NextFloat from S->rng (S+0x16c) PER (contact, detector) TRIAL, BEFORE the accept test. thresh = 0.25f (0x00a23a6c) if TechTree_HasTechComplete(detector->+0xf4, this->+0x8) or (detector->+0xf4, this->+0x4), else 0.0f; r = (float)unit; ACCEPT iff thresh >= r, and on accept the contact is pushed into outBuckets[detector] and the outer loop moves on. A detector with neither tech BURNS A WORD AND CAN NEVER ACCEPT (0.0f >= r only when the word is 0). WORDS PER CALL = sum over contacts of min(trials-to-first-accept, |detectors|); all-teched mean = |contacts| * 4 * (1 - 0.75^|detectors|), none-teched = |contacts| * |detectors| exactly. A vector<int> 'tried' bitset at [ebp-0x38] (|contacts| words, bit = detector index) and a vector<bool> 'assigned' at [ebp-0x4c] are NEVER cleared, so the repeat-until-no-progress outer loop at 0x007aa583 cannot redraw a pair and always terminates after at most two passes. RULE 17: Ghidra reports 944 bytes (end 0x007aa5f0, mid-instruction); the real body ends at 0x007aa5f9 -- the std::vector length_error throw stub at 0x007aa5ee/0x007aa5f3 is outside the reported range [verified]
|
||||
constexpr uint32_t EncounterDetect_AssignContacts = 0x003aa240;
|
||||
// site site in EncounterDetect_AssignContacts: the INLINED Mars::RNG::NextFloat. `mov esi,[edx+0x16c]; cmp [esi+0x9c8],0; jne +8; lea ecx,[esi+4]; call RNG_Twist` -- the Twist call is the LAZY TWIST INSIDE NextFloat, not a bare Twist. The temper runs 0x007aa3e1..0x007aa407, the fild/+2^32/*1-over-(2^32-1) 0x007aa40c..0x007aa41d. THIS IS THE SITE RULE 16 WAS WRITTEN FOR: the only call-graph edge it leaves is EncounterDetect_AssignContacts -> RNG_Twist [verified]
|
||||
constexpr uint32_t EncounterDetect_AssignContacts_Draw = 0x003aa3b6;
|
||||
// site site in EncounterDetect_AssignContacts: `fcompp; fnstsw ax; test ah,5; jp 0x007aa45a`. st0 = thresh, st1 = r. ah&5 is 0 when thresh > r, 0 when thresh == r, 1 when thresh < r, 5 when unordered; PF is even for 0 and 5, so the jp is TAKEN (ACCEPT) iff thresh >= r or unordered, and falls through to the next-detector path iff thresh < r. EQUALITY ACCEPTS. Derive the branch from the ISA, not from the mnemonic (rule 10) [verified]
|
||||
constexpr uint32_t EncounterDetect_AssignContacts_AcceptTest = 0x003aa435;
|
||||
// site site in EncounterDetect_AssignContacts: `cmp BYTE [ebp-0xd],0; je 0x007aa2d0` -- the repeat-until-no-progress back-edge that rule 17 exists to make you look for. [ebp-0xd] is set to 1 at 0x007aa2da at the top of each pass and cleared at 0x007aa360 by any (contact, detector) pair that is evaluated. It is NOT an unbounded loop: the 'tried' bitset it consults is never reset, so the second pass evaluates nothing, clears nothing, and the flag stays 1. At most two passes, and no pair is ever drawn for twice [verified]
|
||||
constexpr uint32_t EncounterDetect_AssignContacts_OuterBackEdge = 0x003aa583;
|
||||
// site the REAL end of EncounterDetect_AssignContacts. Ghidra reports sizeInBytes 944, i.e. an end of 0x007aa5f0, which falls inside the 5-byte `push 0x9e1f90` at 0x007aa5ee. The body's last instruction is the `call ds:0x9dd150` (std::vector length_error throw) at 0x007aa5f3, ending 0x007aa5f9; int3 padding runs to the next function start 0x007aa600. Real size 953. Rule 17 [verified]
|
||||
constexpr uint32_t EncounterDetect_AssignContacts_RealEnd = 0x003aa5f9;
|
||||
// thiscall void (EncounterDetectCtx* this, TeamRecord* rec /*the 0x74-byte record StrategyServer::DetectEncounters builds*/) // RET 4. THE ONLY CALLER OF EncounterDetect_AssignContacts. Gate at 0x007ca671: 0x007892d0(rec) is true iff some entry of rec->(+0x28..+0x2c) (stride 0x44) has entry[0]->+0xfc != 0; false -> whole function is a no-op and NO WORD IS DRAWN. Then `detectors` = 0x007949b0(rec) (entries whose object has +0xfc == 0 AND +0xfb == 0) and `contacts` = 0x00791460(rec) (entries whose object has +0xfc != 0); either empty -> return, still no draw. Otherwise buckets = vector<vector<void*>>(|detectors|) via 0x007b77c0, then the draw call at 0x007ca73a [verified]
|
||||
constexpr uint32_t EncounterDetect_ProcessTeamRecord = 0x003ca640;
|
||||
// thiscall EncounterDetectCtx* (EncounterDetectCtx* this, StrategyServer* S, std::vector<TeamRecord>* records) // RET 8, returns this in EAX. Constructs the 12-byte context {S, TechDef*(id 0x2729), TechDef*(id 0x2728)} -- the tech lookup is 0x0057d610(g_0x00b2d540->+0x110, id) -- then calls EncounterDetect_ProcessTeamRecord once per 0x74-byte record (the 0x8d3dcb09 / sar 6 divide-by-0x74 idiom). Called from StrategyServer::DetectEncounters 0x007d7f70 at 0x007d8470, i.e. inside the LAST phase of StrategyServer::ProcessTurn. This is the ONLY path by which a game-code inlined MT draw is reachable from ProcessTurn [verified]
|
||||
constexpr uint32_t EncounterDetect_Run = 0x003cb080;
|
||||
// thiscall bool (TechTree* this, TechDef* def) // RET 4. `if (!def) return false; node = this->+0x10[def->+0x00]; return node != NULL && node->+0x14 == 4;` -- state 4 is 'researched'. 28 callers image-wide. EncounterDetect_AssignContacts uses it to choose between the 0.25f detection threshold and 0.0f [verified]
|
||||
constexpr uint32_t TechTree_HasTechComplete = 0x0017d7e0;
|
||||
// site site in ProbabilisticJump: the SECOND draw, `mov ecx,[edx+0x16c]; call Mars_RNG_NextUInt`. Note ECX is the RNG OBJECT here while the NextFloat at 0x007b677c three dozen bytes earlier is entered at rng+4 -- two conventions on the same generator in one function. It is reached only when the arrival test at 0x007b67ae..0x007b67b5 FAILS (i.e. fleet->+0x58->+0x158 < float(NextFloat() * fleet->+0x58->+0x154)); on the arriving branch the function copies the destination position and never draws again. ONE EXTRA MT WORD. The word is handed to 0x008a6fa0, which returns a pointer to three floats used to scatter the fleet's position around the destination [verified]
|
||||
constexpr uint32_t ProbabilisticJump_NextUIntDraw = 0x003b67e7;
|
||||
// site NOT AN RNG SITE, recorded so nobody re-derives it. An image-wide scan for the tempering immediates reports 0x008cca30 as containing 0xffffdf8c, but the four bytes at 0x008cca90 are the rel32 displacement of `call 0x008caa20` (e8 8c df ff ff), not an `and r32,imm32`. FUN_008cca30 has no temper chain: no 0xff3a58ad, no `shr r32,0xb`, no shl 7/15/18. It appears in combat-resolver.md §0.1's list of 14 inlined-draw functions and must be struck from it [verified]
|
||||
constexpr uint32_t InlinedDrawScan_FalsePositive_008cca30 = 0x004cca8f;
|
||||
// constant 0xff3a58ad -- the first tempering mask AS THE IMAGE SPELLS IT, `y ^= (y & 0xff3a58ad) << 7`. This is the TEXTBOOK MT19937 tempering with the mask applied BEFORE the shift rather than after: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, because the mask bits above bit 24 shift past bit 31 and are dont-cares (0x9d2c5680 >> 7 == 0x013a58ad == 0xff3a58ad & 0x01ffffff). Verified over 200,000 random words. Mars::RNG is stock MT19937 -- our mars::rng model is NOT wrong -- but a scan for the textbook constants finds NOTHING in this image, which is why an inlined-draw sweep must scan for THIS value at real instruction boundaries (rule 16). 33 occurrences inside decoded instructions image-wide, all genuine `and r32,imm32` in a temper chain [verified]
|
||||
constexpr uint32_t MT_TemperMask1 = 0xff3a58ad;
|
||||
// constant 0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw [verified]
|
||||
constexpr uint32_t MT_TemperMask2 = 0xffffdf8c;
|
||||
// thiscall void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT_<TYPE>_FIGHT, EVENTSUM_<TYPE>, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng [verified]
|
||||
constexpr uint32_t CombatResolver_Run = 0x003d5af0;
|
||||
// thiscall CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act [verified]
|
||||
constexpr uint32_t CombatResolveContext_Ctor = 0x003b8460;
|
||||
// thiscall void (CombatResolveContext* this) // RNG SITE 1 OF 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d5be2), so it runs ONCE PER RESOLVED BATTLE. Body: return with NO DRAW if res(ctx+0xc)->+0xa8 == +0xac (the flung-entity vector is empty); else build a candidate destination list = every StarSystem in S->Systems (S+0x44/+0x48) with sys->+0xc5 == 0 and sys != enc->+0xc, sort it by distance from enc->+0x1c..+0x24 (FUN_00796590 with a 16-byte functor), TRUNCATE TO 3 (FUN_00459f70); if the list is non-empty draw EXACTLY ONE RNG_NextInt at 0x007bb69b with ecx = S->+0x16c + 4 and bound n = count-1 passed BY POINTER. NextInt is inclusive on [0,n], so a normal galaxy gives n = 2, mask = 3, and a y&3 == 3 draw is rejected: 1 MT word with p = 3/4, 2 with p = 3/16, mean 4/3 words. Then per flung entity HandleMap::Resolve(S+0x84, h) and FUN_007bb420 groups by (destination, obj->+0x10) into a 0x18-stride vector; the tail posts EVENT_NODECANNON_FLINGS and EVENT_NODECANNON_KILLS with no further draw [verified]
|
||||
constexpr uint32_t CombatResolve_NodeCannon = 0x003bb530;
|
||||
// thiscall void (CombatResolveContext* this) // RNG SITES 2 AND 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d77c8, AFTER the per-member loop), so it runs once per resolved battle -- but ITS OWN BODY IS A LOOP OVER THE ENCOUNTER MEMBERS, so the rolls are per combatant. WARNING: Ghidra sizes it 2670 B, ending at 0x007a89be, which CUTS OFF the outer back-edge at 0x007a89ab; the real body ends at 0x007a89cd and a dump that stops at Ghidra's size makes the whole outer loop read as straight-line code operating on members[0]. Structure: (1) 0x007a7f79..0x007a7f88 zero 32 slots of 0x10 at [ebp-0x310], a per-PlyrIdx 3-float salvage stat; (2) 0x007a7fc0..0x007a8071 per member, gate FUN_00787350(player), fill that player's slot via FUN_0078bcf0(ctx, player, slot), sticky flag [ebp-0x39d]; return with ZERO draws if the flag is clear (0x007a807e) or the member vector is empty (0x007a80a6); (3) OUTER LOOP 0x007a80ac..0x007a89ab over members, counter [ebp-0x3c4] -- per member: player = S->Players[m->+0x28], slot = [ebp-0x310] + PlyrIdx*0x10, skip with no draw if all three floats are 0.0 (0x007a8105), RESET the {void* def; float p} candidate vector [ebp-0x3d4]/[ebp-0x3d0]/[ebp-0x3cc] at 0x007a810d, rebuild it in the inner loop 0x007a8150..0x007a8406, then (4) ROLL LOOP 0x007a8452..0x007a8693 -- one INLINED Mars::RNG::NextFloat (see CombatResolve_SalvageBackEng_RollSite) per candidate whose def != 0, whose p > 0.0f, and for which FUN_0078f530(player, def, &out) is true; success is p >= roll (fcom + test ah,1, so equality succeeds); on success it composes "SPRJ_BACKENG_" + def->+0x20 and calls SpecialProject_UnlockRandomForPlayer, which draws one more word. No other RNG in the body: no direct call to RNG_NextInt/NextFloat/Chance and no second inlined draw anywhere in the 2782 real bytes [verified]
|
||||
constexpr uint32_t CombatResolve_SalvageBackEng = 0x003a7f30;
|
||||
// site AN INLINED Mars::RNG::NextFloat, byte-for-byte the body of RNG_NextFloat 0x0047d830: esi = S->+0x16c; if (esi->+0x9c8 /*left*/ == 0) RNG_Twist(esi+4) at 0x007a84cf; y = *esi->+0x9c4 /*next*/, next += 4, left--; temper with 0xff3a58ad and 0xffffdf8c; fild with the +2^32 fixup at 0x009e61b8, multiply by the 1/(2^32-1) double at 0x009e61b0, store as float32. THE CAMPAIGN'S RNG SWEEPS CANNOT SEE THIS: the only call-graph edge it leaves is FUN_007a7f30 -> RNG_Twist, which reads as a bare Twist and is not one. An image-wide instruction-boundary scan for the two tempering immediates finds FOURTEEN game functions with inlined MT draws besides the four RNG primitives: 0x004b1f20 (x4), 0x004f7670, 0x00507ac0 (x12), 0x005232a0, 0x006ec720, 0x006f65f0, 0x006f7890, 0x0079f7d0, 0x007a7f30, 0x007aa240, 0x007c2fa0 (x4), 0x007c4140, 0x008cca30, 0x008e6e30 (x2). Of those, 0x004f7670 and 0x007aa240 are in the direct-call closure of StrategyServer::ProcessTurn and 0x007a7f30 is in the closure of OnAllCombatDone_Tail -- three strategic-turn RNG sources that no call-graph accounting has counted [verified]
|
||||
constexpr uint32_t CombatResolve_SalvageBackEng_RollSite = 0x003a84bd;
|
||||
// thiscall std::string* (StrategyServer* S /*ecx, the S frame*/, std::string* outName, std::string* keyPrefix, ServerPlayer* player) // RET 0xc. If player == 0 it returns an empty string with NO DRAW; otherwise it forwards to SpecialProject_PickRandomAvailable(S->+0x160, outName, keyPrefix, player->PlyrIdx(+0x28), S->+0x16c). Reached only from CombatResolve_SalvageBackEng on a successful back-engineering roll, with keyPrefix = "SPRJ_BACKENG_" + the destroyed design's tag [verified]
|
||||
constexpr uint32_t SpecialProject_UnlockRandomForPlayer = 0x003a0540;
|
||||
// thiscall std::string* (void* projectMgr /*= StrategyServer+0x160*/, std::string* outName, std::string* keyPrefix, int plyrIdx, Mars::RNG* rng) // RET 0x10. RNG SITE 3 in the combat resolver's subtree. Builds a candidate vector<T*> via FUN_0059ec00 from keyPrefix; IF IT IS EMPTY it returns an empty string with NO DRAW; otherwise draws EXACTLY ONE RNG_NextInt at 0x00852ec7 (ecx = rng+4, bound n = count-1 by pointer, inclusive), marks the chosen element's per-player byte at elem[plyrIdx]++ and returns its name via FUN_008c97a0. Because the bound is inclusive and NextInt rejects on (y & mask) > n, the expected MT-word cost is 2^ceil(log2(count)) / count -- exactly 1 only when count is a power of two [verified]
|
||||
constexpr uint32_t SpecialProject_PickRandomAvailable = 0x00452d30;
|
||||
// thiscall Game::TacReport* (Game::CombatPlayerStats* this, int i) // RET 4. return (Game::TacReport*)(this->+0x04 + i * 0x94). Called by the combat resolver's inner loop B at 0x007d6f43 [verified]
|
||||
constexpr uint32_t CombatPlayerStats_TacReportAt = 0x00056c40;
|
||||
// thiscall int (Game::CombatPlayerStats* this) // plain RET. return (this->+0x08 - this->+0x04) / 0x94, by the 0xdd67c8a7 add-back / sar 7 reciprocal. Called by the combat resolver at 0x007d6f2b and 0x007d702d [verified]
|
||||
constexpr uint32_t CombatPlayerStats_TacReportCount = 0x00056c20;
|
||||
// layout sizeof(Game::TacReport) -- container stride, ENUMERATED TWICE and independently: the `imul eax,eax,0x94` in CombatPlayerStats_TacReportAt 0x00456c46, and the 0xdd67c8a7 add-back / sar 7 reciprocal divide in CombatPlayerStats_TacReportCount 0x00456c27. NOT sized by what the code touches. FLAG: objects/streams.json gives Game::TacReport 20 fields (two embedded Game::TacReportEvents of 0x20 each plus 18 scalars) which with a vptr accounts for at most 0x8c, so roughly 8 bytes are members the serializer never names -- carried, not named, in the sense of earned-rule 7. Not resolved here [verified]
|
||||
constexpr uint32_t sizeof_Game_TacReport = 0x00000094;
|
||||
// layout Game::CombatPlayerStats+0x04/+0x08/+0x0c = std::vector<Game::TacReport> (stride 0x94), read off both accessors 0x00456c20 and 0x00456c40. sizeof(Game::CombatPlayerStats) is already verified at 0x24, and its stream schema writes RPBon/RPBonT/SavBonus/MaintHF BEFORE TacReports -- so this vector sits at +0x04, ahead of every scalar the serializer emits first. A live instance of lane Q's rule: OFFSET ORDER IS NOT WRITE ORDER; align against objects/streams.json, never against an offset-sorted view [verified]
|
||||
constexpr uint32_t Game_CombatPlayerStats_off_TacReports = 0x00000004;
|
||||
// site site in FUN_007b9df0, reached from the combat resolver as 0x007d5af0 -> FUN_007baef0 -> FUN_007b9df0 (depth 2), on the arm that also posts EVENT_INDSYS_SURRENDERS_COMBAT and writes the winner index. `mov ecx,[esi+0x28]; imul ecx,ecx,0x11c; mov edx,[ebp-0x14]; mov eax,[edx+0x2f4]; lea ecx,[ecx+eax*1+0x90]; call 0x007a6630` -- a push_back of a 0x20-byte record (vptr 0x00a23c54) into the SETurnResults ACCUMULATOR at StrategyServer+0x2f4, member +0x90, indexed by PlyrIdx*0x11c. COMBAT WRITES TURN RESULTS: lane K's combat-done-tail.md 5A attributes the phase-6 write to ApplyEncounterResult 0x007d8f9e at member +0x24; this is a SECOND member written from inside the resolver's subtree. FUN_007b9df0 has seven direct callers (0x007baef0, 0x007bd490, 0x007bd520, 0x007bd930, 0x007be870, 0x007d0580, StrategyServer::ProcessTurn), so it is not only a ProcessTurn-phase-1 function. Not serialized (SETurnResults has no Read/Write pair) but live in memory at autosave time and dispatched to the client as strategy-event 0x25 [verified]
|
||||
constexpr uint32_t CombatResolve_TurnResultsWriteSite = 0x003ba140;
|
||||
// layout Game::EncounterResults+0x98/+0x9c = a 4-byte-stride vector of participant handle ids. The combat resolver's per-member loop linear-scans it for the member's ServerPlayer->+0x04 at 0x007d5c96 and SKIPS THE WHOLE MEMBER when absent -- so a combatant present in the Encounter but not in this vector contributes nothing to the resolver. LABELLED HYPOTHESIS on the element type: it is a 4-byte scalar and matches the schema's `carr<int>`, but the schema is in write order and no save observed here fixes the offset [hypothesis]
|
||||
constexpr uint32_t Game_EncounterResults_off_Participants = 0x00000098;
|
||||
// layout Game::EncounterResults+0x18/+0x1c = std::vector<Game::CombatPlayerStats>, stride 0x24 (verified sizeof), INDEXED BY THE ENCOUNTER MEMBER INDEX, not by PlyrIdx: the resolver computes base + i*0x24 with `lea edx,[eax+eax*8]; lea ebx,[eax+edx*4]` at 0x007d6f11 using the same i that drives the member loop, and skips the block when (+0x1c - +0x18)/0x24 <= i [verified]
|
||||
constexpr uint32_t Game_EncounterResults_off_PlayerStats = 0x00000018;
|
||||
// layout Game::EncounterResults+0xa8/+0xac = a 4-byte-stride vector of entity handles flung by a node cannon. Emptiness of THIS vector is the sole first gate on RNG site 1 of the combat resolver: CombatResolve_NodeCannon returns at 0x007bb575 with no draw when +0xa8 == +0xac. Each handle is resolved through the global HandleMap at GetGame()+0x84 (here reached as S+0x84) [verified]
|
||||
constexpr uint32_t Game_EncounterResults_off_NodeCannonFlung = 0x000000a8;
|
||||
// thiscall void (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records [verified]
|
||||
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0;
|
||||
// site site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf [verified]
|
||||
|
|
@ -1519,5 +1581,41 @@ constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
|
|||
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
|
||||
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
|
||||
constexpr uint32_t SVSODerelict_Write = 0x000fc2b0;
|
||||
// thiscall void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector<int>. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites [verified]
|
||||
constexpr uint32_t StrategyServer_NodeLineDecay = 0x003ae010;
|
||||
// site site, and a CORRECTION to findings/control-flow/combat-done-tail.md §3, which says "the roll is skipped for a line if any fleet with flag 0x20000 is targeting it". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife) [verified]
|
||||
constexpr uint32_t StrategyServer_NodeLineDecay_FleetSkipIsPostDraw = 0x003ae0b2;
|
||||
// thiscall int (NodePath* this, int turn) // RET 4, whole 122-byte body read as instructions. THE EXPIRY PREDICATE for node-line decay. `if (npt(+0x4) == 0) return INT_MAX; if (npdtn(+0x1c) == INT_MAX) return INT_MAX; aged = (npctm(+0x14) >= 0 && turn >= npctm) ? turn - npctm : 0; wear = (npdtf(+0x20) != INT_MAX && npdtf > 0) ? nptf(+0x24) / npdtf : 0; /* SIGNED idiv, nptf is never sign-checked */ rem = npdtn - wear - aged; return rem > 0 ? rem : 0;`. Writes nothing -- the lifetime is DERIVED from a creation stamp and a traffic accumulator, never ticked, so there is no decrement-ordering question. Two never-expire escape hatches (npt == 0, npdtn == INT_MAX). A line is expired exactly when this returns 0 [verified]
|
||||
constexpr uint32_t NodePath_RemainingLife = 0x002e2130;
|
||||
// thiscall int (NodePath* this, int turn) // 52 B. Wraps NodePath::RemainingLife and buckets it: <=2 -> 0, <=5 -> 1, <=10 -> 2, else 3. Called TWICE per record by loop 3 of node-line decay (once with turn-1, once with turn) to detect a stage transition and post the two decay-stage events. Draw-free -- but note that instrumenting node-line decay by counting RemainingLife CALLS rather than Chance calls over-counts badly because of this wrapper [verified]
|
||||
constexpr uint32_t NodePath_DecayStage = 0x002e21b0;
|
||||
// thiscall void (StrategyServer* this /*base S*/) // 2945 B. Called TWICE per turn: StrategyServer::ProcessTurn phase 7 (0x007dc93a) and OnAllCombatDone_Tail phase 10 (0x007d970b). Body unread; hooked by lane Z only to measure whether it advances the strategic generator, because a draw inside it would be double-counted by anyone who modelled it as running once [mapped]
|
||||
constexpr uint32_t StrategyServer_ProcessNodeSpaceTravel = 0x003a0e20;
|
||||
// offset Game::ServerNodeGraph* -- the node-line graph. Stored frame (S+4), so it is S+0x154 in the frame OnAllCombatDone_Tail and ProcessTurn receive; node-line decay reads it as `mov eax,[esi+0x154]` at 0x007ae03a with esi = S. Dereferenced without a null check by the original [verified]
|
||||
constexpr uint32_t StrategyServer_off_NodeGraph = 0x00000150;
|
||||
// offset std::vector<Game::NodePath> (_Myfirst @+0x8, _Mylast @+0xc). Element stride 0x30, confirmed four ways: the reciprocal 0x2aaaaaab / sar 3 at four sites in node-line decay, `add [ebp-0x14],0x30` in its loop 1, `add [ebp-0x18],0x30` in its loop 3, and `add eax,0x30` in ServerNodeGraph::FindPathById 0x006e23d0. Loop 1 of node-line decay re-reads both words every iteration but writes neither, so an entry snapshot is a valid prediction basis [verified]
|
||||
constexpr uint32_t ServerNodeGraph_off_Paths = 0x00000008;
|
||||
// thiscall NodePath* (ServerNodeGraph* this, int npid) // 59 B, linear scan of the 0x30-stride paths vector comparing npid(+0x8). Node-line decay collects npid HANDLES rather than record pointers in loop 1 and re-resolves them here in loop 2, which is how the original hedges against the collapse functions mutating the vector under it [verified]
|
||||
constexpr uint32_t ServerNodeGraph_FindPathById = 0x002e23d0;
|
||||
// thiscall bool (StarFleet* this, uint maskA, uint maskB) // RET 8, a 29-byte thunk onto 0x00702d70(this, maskA, maskB, out = 0). Returns count > 0 where count is the number of ships in the fleet's NShips vector (+0xa4/+0xa8) passing the two-mask ship-flag predicate 0x00814da0, itself gated on (fleet->+0xb8 & maskA) == maskA. Called from node-line decay's post-draw fleet scan with maskA = 0x20000 [verified]
|
||||
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
||||
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
|
||||
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
||||
// note NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name [verified]
|
||||
constexpr uint32_t StrategyServer_wire_ModCount_vs_Frame = 0x0039fb2f;
|
||||
// thiscall void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once [verified]
|
||||
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
|
||||
// thiscall ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right [verified]
|
||||
constexpr uint32_t StrategyServer_NPCPlayerAccessors = 0x00388de0;
|
||||
// offset sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md §3 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads [verified]
|
||||
constexpr uint32_t sizeof_Game_ServerPlayer = 0x000003e0;
|
||||
// thiscall void (ServerTradeManagerImpl* this, std::vector<TeamRecord>* records) // RET 4. THE DOMINANT STRATEGIC-RNG CONSUMER OF A TURN: 16 of every turn's 18-20 words on the reference save, measured live. `this+4` is the RAW StrategyServer base (GetServer 0x0080eb50 returns it minus 4). Loops over StrategyServer::Players (RAW+0x50/+0x54, 4-byte stride) -- 8 entries on the Human saves -- and per player rolls up to THREE Mars::RNG::Chance calls on the strategic generator at S+0x16c, each exactly one MT word because all three probabilities are strictly inside (0,1): +0x196 (0x00893426) Chance(TRADE_RAID_ODDS_PLAYER, image default 0.2f) -> kind 0; +0x283 (0x00893513) Chance(TRADE_RAID_ODDS_NPC, 0.05f) gated on 0.0f < S->+0x1a0, which is PLAYER-INDEPENDENT so the site is all-or-nothing per turn -> kind 1; +0x33e (0x008935ce) Chance(TRADE_RAID_ODDS_REFUGEE, 0.05f) gated on a subsystem manager being present -> kind 2. NO BACK-EDGE CONTAINS ANY OF THE THREE SITES, so the cost is a hard bound of one word per player per site. Two per-player skip gates exist (a visitedMask bit test at 0x00893302 and a `>2` pre-filter at 0x008933e0) and neither fired on ref-turn2. A SUCCESSFUL roll calls vslot 17 (ServerTradeManager_CreateRaidEncounter), which draws 0 or 1 FURTHER word. The record vector is the 0x74-stride TeamRecord vector at StrategyServer+0x1e8 -- the same one lane I's EncounterDetect_Run receives one instruction later. REAL SIZE 0x60a = 1546 BYTES, ending 0x0089389a; Ghidra reports 1532 and its end lands mid-instruction (method rule 17). Already named 'raid encounter generation' by findings/subsystems/strategic-turn-internals.md line 153 with these exact StrategyVars -- what was new is that it is where a turn's RNG goes [verified]
|
||||
constexpr uint32_t ServerTradeManager_GenerateTradeRaidEncounters = 0x00493290;
|
||||
// site site in StrategyServer::DetectEncounters 0x007d7f70, and THE CONCRETE FALSIFIER FOR 'not in the direct-call closure'. `mov ecx,[esi+0x158]; mov eax,[ecx]; mov edx,[eax+0x28]; push edi; call edx` -- a VIRTUAL dispatch through ServerTradeManagerImpl vftable slot 10 to ServerTradeManager_GenerateTradeRaidEncounters, passing the same TeamRecord vector that the DIRECT call at 0x007d8470 (lane I's EncounterDetect_Run) receives one instruction later. There are ZERO direct `call rel32` targets equal to 0x00893290 in the whole image, and exactly one dword 0x00893290 in .rdata, at 0x00a31b9c = vftable 0x00a31b74 + 0x28. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in that slot, so dispatch is the only way in. Lane I's 22-site inventory of ProcessTurn's closure follows E8/E9 rel32 only and says so; this edge is `call edx`, so THE LARGEST SINGLE RNG CONSUMER OF A TURN HANGS OFF A VIRTUAL EDGE INSIDE A FUNCTION THE CLOSURE ALREADY CONTAINS [verified]
|
||||
constexpr uint32_t StrategyServer_DetectEncounters_TradeRaidVCall = 0x003d8469;
|
||||
// thiscall bool (ServerTradeManagerImpl* this, TeamRecord* out, ServerPlayer* p, int kind, std::vector<TeamRecord>* records) // ServerTradeManagerImpl vftable 0x00a31b74 slot 17, called from GenerateTradeRaidEncounters at 0x0089345e / 0x00893548 / 0x00893603 on each successful Chance roll. Real body 0x008938a0..0x00893af9. Draws `Mars::RNG::NextInt(&S->rng.mt, cands.size()-1)` at 0x008939ee to pick a raid target -- ONE FURTHER MT WORD -- but returns false at 0x0089391c WITHOUT DRAWING when the candidate vector from 0x0083b110 is empty. So a successful raid roll costs 0 or 1 extra word. It drew 0 on ref-turn2 turns 3-5, which is consistent with no roll succeeding (P ~= 0.8^8 * 0.95^8 ~= 11% on the image defaults) OR with an empty candidate list every time; the two are not distinguishable from a word count and this is the cheapest remaining experiment on this path [verified]
|
||||
constexpr uint32_t ServerTradeManager_CreateRaidEncounter = 0x004938a0;
|
||||
// note Game::ServerTradeManagerImpl offset-0 vftable, 22 slots, bases ServerTradeManagerImpl -> ServerTradeManager -> TradeManager -> Mars::IStreamable. SLOT 10 (+0x28, at 0x00a31b9c) = ServerTradeManager_GenerateTradeRaidEncounters 0x00893290; SLOT 17 = ServerTradeManager_CreateRaidEncounter 0x008938a0. The interface vftable Game::ServerTradeManager 0x00a311a4 has purecall in all 21 non-destructor slots. The instance is constructed by 0x00858f70 (writes vptrs 0x00a31b74 / 0x00a31b64) from 0x007d7d7b and 0x007dd1fd, and stored at StrategyServer+0x158 in the S frame (+0x154 in the stored frame, which addresses.json already calls StrategyServer_off_TradeManager). Lane K's combat-done-tail.md §9 tier 4 calls the eight end-of-turn vtable calls on S+0x158 'the largest blind spot in the map' -- this closes two of that class's slots [verified]
|
||||
constexpr uint32_t ServerTradeManagerImpl_vftable = 0x00631b74;
|
||||
|
||||
} // namespace sots::addr
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ void logf(const char* fmt, ...) {
|
|||
bool g_forceEnabled = false;
|
||||
unsigned g_forceValue = 0;
|
||||
bool g_sampleTicks = true;
|
||||
// The StrategyServer::ProcessTurn sampler owns that address, and MinHook allows one hook per
|
||||
// target. Lane Z's RNG-ledger hook needs the same function, so this releases it on request.
|
||||
// Default `on` -- no existing run changes behaviour.
|
||||
bool g_sampleTurn = true;
|
||||
|
||||
// ---- state -----------------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -245,6 +249,18 @@ bool apply_config(const char* key, const char* value, std::string* err) {
|
|||
g_forceEnabled = true;
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "fpu.sample_turn") == 0) {
|
||||
if (std::strcmp(value, "on") == 0) {
|
||||
g_sampleTurn = true;
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(value, "off") == 0) {
|
||||
g_sampleTurn = false;
|
||||
return true;
|
||||
}
|
||||
if (err) *err = "expected on or off";
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "fpu.sample_ticks") == 0) {
|
||||
if (std::strcmp(value, "on") == 0) {
|
||||
g_sampleTicks = true;
|
||||
|
|
@ -276,6 +292,8 @@ void install(std::uintptr_t exeBase, void (*log)(const char*)) {
|
|||
logf("fpu: module init, entry cw=0x%04x %s/%s; force=%s value=0x%04x sample_ticks=%s", entry,
|
||||
pc_name(entry), rc_name(entry), g_forceEnabled ? "on" : "off", g_forceValue,
|
||||
g_sampleTicks ? "on" : "off");
|
||||
logf("fpu: sample_turn=%s (off releases StrategyServer::ProcessTurn for another hook)",
|
||||
g_sampleTurn ? "on" : "off");
|
||||
|
||||
struct Site {
|
||||
const char* name;
|
||||
|
|
@ -290,7 +308,7 @@ void install(std::uintptr_t exeBase, void (*log)(const char*)) {
|
|||
{"StrategyServer::BeginProcessTurn", sots::addr::StrategyServer_BeginProcessTurn,
|
||||
reinterpret_cast<void*>(&FpuBeginProcessTurnDetour), &g_origFpuBeginProcessTurn, true},
|
||||
{"StrategyServer::ProcessTurn", sots::addr::StrategyServer_ProcessTurn,
|
||||
reinterpret_cast<void*>(&FpuProcessTurnDetour), &g_origFpuProcessTurn, true},
|
||||
reinterpret_cast<void*>(&FpuProcessTurnDetour), &g_origFpuProcessTurn, g_sampleTurn},
|
||||
{"DemoApp::OnTick", sots::addr::DemoApp_OnTick, reinterpret_cast<void*>(&FpuOnTickDetour),
|
||||
&g_origFpuOnTick, g_sampleTicks},
|
||||
};
|
||||
|
|
|
|||
246
src/shim/hooks/draw_sites.cpp
Normal file
246
src/shim/hooks/draw_sites.cpp
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
#include "shim/hooks/draw_sites.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "generated/sots_addresses.h"
|
||||
#include "shim/trace/platform.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace A = sots::addr;
|
||||
|
||||
constexpr std::size_t kMaxSites = 96;
|
||||
constexpr std::int32_t kBlock = 624;
|
||||
|
||||
std::uintptr_t g_exe_base = 0;
|
||||
Mutex g_mu;
|
||||
DrawSiteRow g_rows[kMaxSites];
|
||||
std::size_t g_nrows = 0;
|
||||
std::uint32_t g_total_words = 0;
|
||||
std::uint32_t g_total_calls = 0;
|
||||
std::uint32_t g_other_words = 0;
|
||||
std::uint32_t g_other_calls = 0;
|
||||
std::uint32_t g_overflow = 0;
|
||||
const void* g_strategic = nullptr;
|
||||
|
||||
// `left` lives at RNG+0x9c8 (the object base). Every entry point below normalises its argument to
|
||||
// that base before reading it, because the image uses BOTH conventions: the inner primitives take
|
||||
// `&mt` = obj+4, the outer helpers take the object.
|
||||
std::int32_t left_of(const void* obj) {
|
||||
if (!obj) return -1;
|
||||
std::int32_t v = 0;
|
||||
std::memcpy(&v, static_cast<const char*>(obj) + A::RNG_off_Left, 4);
|
||||
return v;
|
||||
}
|
||||
|
||||
// Words consumed between two `left` readings of the same generator. See the header for the
|
||||
// single-twist assumption.
|
||||
std::uint32_t words_between(std::int32_t before, std::int32_t after) {
|
||||
if (before < 0 || after < 0) return 0;
|
||||
if (after <= before) return static_cast<std::uint32_t>(before - after);
|
||||
return static_cast<std::uint32_t>(before + kBlock - after);
|
||||
}
|
||||
|
||||
void record(DrawEntry e, const void* ret, const void* obj, std::uint32_t words) {
|
||||
const std::uint32_t rva =
|
||||
static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(ret) - g_exe_base);
|
||||
LockGuard g(g_mu);
|
||||
const bool strategic = g_strategic != nullptr && obj == g_strategic;
|
||||
if (strategic) {
|
||||
g_total_words += words;
|
||||
++g_total_calls;
|
||||
} else {
|
||||
g_other_words += words;
|
||||
++g_other_calls;
|
||||
}
|
||||
for (std::size_t i = 0; i < g_nrows; ++i) {
|
||||
if (g_rows[i].ret_rva == rva && g_rows[i].entry == e && g_rows[i].strategic == strategic) {
|
||||
++g_rows[i].calls;
|
||||
g_rows[i].words += words;
|
||||
if (words == 0) ++g_rows[i].zero_calls;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (g_nrows >= kMaxSites) {
|
||||
++g_overflow;
|
||||
return;
|
||||
}
|
||||
DrawSiteRow& r = g_rows[g_nrows++];
|
||||
r.ret_rva = rva;
|
||||
r.entry = e;
|
||||
r.calls = 1;
|
||||
r.words = words;
|
||||
r.zero_calls = words == 0 ? 1 : 0;
|
||||
r.strategic = strategic;
|
||||
}
|
||||
|
||||
// ---- trampolines -----------------------------------------------------------------------------
|
||||
|
||||
using NextFloatFn = float(SHIM_THISCALL*)(void*);
|
||||
using NextIntFn = std::uint32_t(SHIM_THISCALL*)(void*, std::uint32_t*);
|
||||
using ChanceFn = bool(SHIM_THISCALL*)(void*, float);
|
||||
using NextUIntFn = std::uint32_t(SHIM_THISCALL*)(void*);
|
||||
using FloatRangeFn = float(SHIM_THISCALL*)(void*, float, float);
|
||||
using IntRangeBellFn = int(SHIM_CDECL*)(void*, int, int);
|
||||
using GaussianRangeFn = int(SHIM_CDECL*)(void*, int, int, int);
|
||||
|
||||
void* g_tr_next_float = nullptr;
|
||||
void* g_tr_next_int = nullptr;
|
||||
void* g_tr_chance = nullptr;
|
||||
void* g_tr_next_uint = nullptr;
|
||||
void* g_tr_float_range = nullptr;
|
||||
void* g_tr_int_range_bell = nullptr;
|
||||
void* g_tr_gaussian_range = nullptr;
|
||||
|
||||
// The inner primitives are entered with ECX = &mt = object + 4; the outer helpers take the object.
|
||||
const void* obj_from_mt(void* mt) { return mt ? static_cast<char*>(mt) - A::RNG_off_State : nullptr; }
|
||||
|
||||
// ---- detours ---------------------------------------------------------------------------------
|
||||
//
|
||||
// Each one is the direct target of the game's `call`, so __builtin_return_address(0) is the game
|
||||
// instruction after that call -- the call site we are attributing to.
|
||||
|
||||
float SHIM_THISCALL DetourNextFloat(void* mt) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const void* obj = obj_from_mt(mt);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const float r = reinterpret_cast<NextFloatFn>(g_tr_next_float)(mt);
|
||||
record(DrawEntry::NextFloat, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
std::uint32_t SHIM_THISCALL DetourNextInt(void* mt, std::uint32_t* bound) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const void* obj = obj_from_mt(mt);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const std::uint32_t r = reinterpret_cast<NextIntFn>(g_tr_next_int)(mt, bound);
|
||||
record(DrawEntry::NextInt, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
bool SHIM_THISCALL DetourChance(void* obj, float p) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const bool r = reinterpret_cast<ChanceFn>(g_tr_chance)(obj, p);
|
||||
record(DrawEntry::Chance, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
std::uint32_t SHIM_THISCALL DetourNextUInt(void* obj) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const std::uint32_t r = reinterpret_cast<NextUIntFn>(g_tr_next_uint)(obj);
|
||||
record(DrawEntry::NextUInt, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
float SHIM_THISCALL DetourFloatRange(void* obj, float lo, float hi) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const float r = reinterpret_cast<FloatRangeFn>(g_tr_float_range)(obj, lo, hi);
|
||||
record(DrawEntry::FloatRange, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
int SHIM_CDECL DetourIntRangeBell(void* obj, int lo, int hi) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const int r = reinterpret_cast<IntRangeBellFn>(g_tr_int_range_bell)(obj, lo, hi);
|
||||
record(DrawEntry::IntRangeBell, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
int SHIM_CDECL DetourGaussianRange(void* obj, int lo, int hi, int mode) {
|
||||
const void* ret = __builtin_return_address(0);
|
||||
const std::int32_t before = left_of(obj);
|
||||
const int r = reinterpret_cast<GaussianRangeFn>(g_tr_gaussian_range)(obj, lo, hi, mode);
|
||||
record(DrawEntry::GaussianRange, ret, obj, words_between(before, left_of(obj)));
|
||||
return r;
|
||||
}
|
||||
|
||||
// `IntRangeBell` and `GaussianRange` call `NextInt` / draw inline on the SAME generator, so a naive
|
||||
// reading would count their words twice -- once against the helper's own site and once against the
|
||||
// inner `NextInt` site. `IntRangeBell`'s two inner calls are hooked too, and their return addresses
|
||||
// land inside 0x008e6d80, so they appear as their own rows and are recognisable as such. The
|
||||
// reconciliation in the report subtracts any row whose ret_rva falls inside another entry point's
|
||||
// body; nothing in this workload has ever hit either helper, so it has never mattered.
|
||||
|
||||
const DrawSiteHook kHooks[] = {
|
||||
{"Mars::RNG::NextFloat", A::RNG_NextFloat, reinterpret_cast<void*>(&DetourNextFloat), &g_tr_next_float},
|
||||
{"Mars::RNG::NextInt", A::RNG_NextInt, reinterpret_cast<void*>(&DetourNextInt), &g_tr_next_int},
|
||||
{"Mars::RNG::Chance", A::RNG_Chance, reinterpret_cast<void*>(&DetourChance), &g_tr_chance},
|
||||
{"Mars::RNG::NextUInt", A::Mars_RNG_NextUInt, reinterpret_cast<void*>(&DetourNextUInt), &g_tr_next_uint},
|
||||
{"Mars::RNG::FloatRange", A::Mars_RNG_FloatRange, reinterpret_cast<void*>(&DetourFloatRange), &g_tr_float_range},
|
||||
{"Mars::RNG::IntRangeBell", A::Mars_RNG_IntRangeBell, reinterpret_cast<void*>(&DetourIntRangeBell), &g_tr_int_range_bell},
|
||||
{"Mars::RNG::GaussianRange", A::Mars_RNG_GaussianRange, reinterpret_cast<void*>(&DetourGaussianRange), &g_tr_gaussian_range},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* draw_entry_name(DrawEntry e) {
|
||||
switch (e) {
|
||||
case DrawEntry::NextFloat: return "NextFloat";
|
||||
case DrawEntry::NextInt: return "NextInt";
|
||||
case DrawEntry::Chance: return "Chance";
|
||||
case DrawEntry::NextUInt: return "NextUInt";
|
||||
case DrawEntry::FloatRange: return "FloatRange";
|
||||
case DrawEntry::IntRangeBell: return "IntRangeBell";
|
||||
case DrawEntry::GaussianRange: return "GaussianRange";
|
||||
case DrawEntry::Count: break;
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const DrawSiteHook* draw_site_hooks(std::size_t* count) {
|
||||
if (count) *count = sizeof kHooks / sizeof kHooks[0];
|
||||
return kHooks;
|
||||
}
|
||||
|
||||
void init_draw_sites(std::uintptr_t exe_base) { g_exe_base = exe_base; }
|
||||
|
||||
void draw_sites_set_generator(const void* strategic_rng) {
|
||||
LockGuard g(g_mu);
|
||||
g_strategic = strategic_rng;
|
||||
}
|
||||
|
||||
void draw_sites_reset() {
|
||||
LockGuard g(g_mu);
|
||||
g_nrows = 0;
|
||||
g_total_words = 0;
|
||||
g_total_calls = 0;
|
||||
g_other_words = 0;
|
||||
g_other_calls = 0;
|
||||
g_overflow = 0;
|
||||
}
|
||||
|
||||
std::size_t draw_sites_snapshot(DrawSiteRow* out, std::size_t max) {
|
||||
LockGuard g(g_mu);
|
||||
const std::size_t n = g_nrows < max ? g_nrows : max;
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = g_rows[i];
|
||||
return n;
|
||||
}
|
||||
|
||||
std::uint32_t draw_sites_total_words() {
|
||||
LockGuard g(g_mu);
|
||||
return g_total_words;
|
||||
}
|
||||
std::uint32_t draw_sites_total_calls() {
|
||||
LockGuard g(g_mu);
|
||||
return g_total_calls;
|
||||
}
|
||||
std::uint32_t draw_sites_other_words() {
|
||||
LockGuard g(g_mu);
|
||||
return g_other_words;
|
||||
}
|
||||
std::uint32_t draw_sites_other_calls() {
|
||||
LockGuard g(g_mu);
|
||||
return g_other_calls;
|
||||
}
|
||||
std::uint32_t draw_sites_overflow() {
|
||||
LockGuard g(g_mu);
|
||||
return g_overflow;
|
||||
}
|
||||
|
||||
} // namespace shim::hooks
|
||||
96
src/shim/hooks/draw_sites.h
Normal file
96
src/shim/hooks/draw_sites.h
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Per-call-site attribution of every strategic RNG word, by return address.
|
||||
//
|
||||
// The boundary ledger (rng_ledger.h + tail_rng.h) answers "how many words did this turn spend, and
|
||||
// in which phase". It cannot answer "which call site spent them", because it deliberately never
|
||||
// looks at calls. This does the other half: it detours the generator's **entry points** and records
|
||||
// `__builtin_return_address(0)` — the instruction after the game's own `call` — together with the
|
||||
// word cost of that one call, taken from `left` before and after.
|
||||
//
|
||||
// WHY THIS IS ATTRIBUTION AND NOT DISCOVERY. Lane I closed the search space
|
||||
// (`sots-re/findings/control-flow/inlined-draws.md`): **seven** entry points, and eleven game
|
||||
// functions carrying inlined draws over 28 sites, with recall proved by a brute byte scan finding
|
||||
// zero orphans. Of those, `StrategyServer::ProcessTurn`'s direct-call closure contains exactly 22
|
||||
// draw sites — 21 calls to an entry point plus one inlined site. Twenty-one of the twenty-two are
|
||||
// therefore visible here by construction; the twenty-second (`EncounterDetect_AssignContacts`
|
||||
// 0x007aa240) is covered by its own boundary hook in tail_rng.h, and the one inlined site under
|
||||
// the combat resolver is covered by the `ApplyEncounterResult` boundary hook.
|
||||
//
|
||||
// **The check that makes this a fact rather than a model:** the per-site words must sum to the
|
||||
// bracket total the boundary ledger measured independently. A shortfall is an unattributed word,
|
||||
// and an unattributed word is evidence about the one thing lane I could not settle — indirect-call
|
||||
// reachability.
|
||||
//
|
||||
// COST. Two 4-byte reads per draw, no hashing, no allocation, no record written. The table is a
|
||||
// small fixed array scanned linearly; a turn touches a handful of entries.
|
||||
//
|
||||
// THE ONE ASSUMPTION, stated because it is the only place this can be wrong: a single call is taken
|
||||
// to cross at most one block boundary, so `left_after > left_before` means exactly one twist. That
|
||||
// holds for every entry point whose cost is bounded (all but two) and for a rejection loop unless
|
||||
// it spends 624+ words in one call, which would be a 1-in-2^624 event for `NextInt`. `GaussianRange`
|
||||
// is unbounded in attempts and is the one place the assumption could genuinely break; it is
|
||||
// reachable from no turn driver by a direct call, and if it ever fires the boundary ledger's total
|
||||
// will disagree with the site sum and say so.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
// The seven entry points, in the order lane I lists them.
|
||||
enum class DrawEntry : std::uint8_t {
|
||||
NextFloat, // 0x0047d830 thiscall(&mt) 1 word
|
||||
NextInt, // 0x004271c0 thiscall(&mt, uint* bound) 1 + rejections
|
||||
Chance, // 0x008e6dd0 thiscall(obj, float p) 0 or 1
|
||||
NextUInt, // 0x004f7670 thiscall(obj) 1
|
||||
FloatRange, // 0x0047d8a0 thiscall(obj, float, float) 1
|
||||
IntRangeBell, // 0x008e6d80 cdecl(obj, int, int) >= 2
|
||||
GaussianRange, // 0x008e6e30 cdecl(obj, int, int, int) 2 per attempt, unbounded
|
||||
Count
|
||||
};
|
||||
|
||||
const char* draw_entry_name(DrawEntry e);
|
||||
|
||||
struct DrawSiteRow {
|
||||
std::uint32_t ret_rva = 0; // the game instruction after the call
|
||||
DrawEntry entry = DrawEntry::Count;
|
||||
std::uint32_t calls = 0;
|
||||
std::uint32_t words = 0;
|
||||
std::uint32_t zero_calls = 0; // calls that consumed nothing (only Chance's two early-outs)
|
||||
// Whether the draw came from the STRATEGIC generator at StrategyServer+0x16c. The first run of
|
||||
// this instrument recorded 44 words against a bracket of 18 because it counted every generator
|
||||
// in the process; a site that draws from both appears as two rows.
|
||||
bool strategic = false;
|
||||
};
|
||||
|
||||
// What main.cpp needs to install the detours with MinHook.
|
||||
struct DrawSiteHook {
|
||||
const char* name;
|
||||
std::uint32_t rva;
|
||||
void* detour;
|
||||
void** trampoline;
|
||||
};
|
||||
const DrawSiteHook* draw_site_hooks(std::size_t* count);
|
||||
|
||||
// Call once before installing: the exe base, so return addresses are recorded as RVAs.
|
||||
void init_draw_sites(std::uintptr_t exe_base);
|
||||
|
||||
// Told by the boundary hooks whenever they resolve StrategyServer+0x16c, so each recorded draw can
|
||||
// say whether it came from the strategic generator or from another instance (the StrategyClient's
|
||||
// at +0x134, the tactical CombatSim's at +0x108, or a map-generation temporary). Without this the
|
||||
// per-site total cannot be reconciled against the bracket, because the bracket watches one object
|
||||
// and the detours see them all.
|
||||
void draw_sites_set_generator(const void* strategic_rng);
|
||||
|
||||
// Accumulator control. `reset` is called at the pre-turn autosave and `snapshot` at the post-turn
|
||||
// one, so a snapshot covers exactly the interval the boundary ledger's bracket covers.
|
||||
void draw_sites_reset();
|
||||
std::size_t draw_sites_snapshot(DrawSiteRow* out, std::size_t max);
|
||||
std::uint32_t draw_sites_total_words(); // strategic generator only
|
||||
std::uint32_t draw_sites_total_calls(); // strategic generator only
|
||||
std::uint32_t draw_sites_other_words(); // every other generator, summed
|
||||
std::uint32_t draw_sites_other_calls();
|
||||
// Sites seen since reset that did not fit in the table (a non-zero value invalidates the sum).
|
||||
std::uint32_t draw_sites_overflow();
|
||||
|
||||
} // namespace shim::hooks
|
||||
146
src/shim/hooks/rng_ledger.cpp
Normal file
146
src/shim/hooks/rng_ledger.cpp
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#include "shim/hooks/rng_ledger.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "generated/sots_addresses.h"
|
||||
#include "mars/rng/mt19937.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
namespace {
|
||||
|
||||
namespace A = sots::addr;
|
||||
|
||||
constexpr std::size_t kBlockBytes = std::size_t(RngLedger::kWordsPerBlock) * 4;
|
||||
|
||||
long long words_of(int block, int left) {
|
||||
return static_cast<long long>(block) * RngLedger::kWordsPerBlock +
|
||||
(RngLedger::kWordsPerBlock - left);
|
||||
}
|
||||
|
||||
// One block transform. MT19937::twist() is private, but `next_u32()` on an exhausted state
|
||||
// twists and then hands out a word, so loading `left = 0` and drawing once leaves `state()`
|
||||
// holding exactly the next block.
|
||||
void next_block(const std::uint32_t* in, std::uint32_t* out) {
|
||||
mars::rng::MT19937 g;
|
||||
g.load_state(in, 0);
|
||||
(void)g.next_u32();
|
||||
std::memcpy(out, g.state(), kBlockBytes);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::uint64_t hash_bytes(const void* p, std::size_t n) {
|
||||
const std::uint8_t* b = static_cast<const std::uint8_t*>(p);
|
||||
std::uint64_t h = 1469598103934665603ull;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
h ^= b[i];
|
||||
h *= 1099511628211ull;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
RngLedger& RngLedger::instance() {
|
||||
static RngLedger g;
|
||||
return g;
|
||||
}
|
||||
|
||||
RngPos RngLedger::observe(const std::uint32_t* mt, int left) {
|
||||
RngPos out;
|
||||
out.left = left;
|
||||
if (!mt || left < 0 || left > kWordsPerBlock) return out;
|
||||
out.hash = hash_bytes(mt, kBlockBytes);
|
||||
|
||||
LockGuard g(mu_);
|
||||
|
||||
if (!anchored_) {
|
||||
anchored_ = true;
|
||||
std::memcpy(frontier_, mt, kBlockBytes);
|
||||
frontier_block_ = 0;
|
||||
memo_[out.hash] = 0;
|
||||
out.known = true;
|
||||
out.block = 0;
|
||||
out.words = words_of(0, left);
|
||||
return out;
|
||||
}
|
||||
|
||||
const std::unordered_map<std::uint64_t, int>::const_iterator it = memo_.find(out.hash);
|
||||
if (it != memo_.end()) {
|
||||
out.known = true;
|
||||
out.block = it->second;
|
||||
out.words = words_of(out.block, left);
|
||||
// Keep the frontier at the furthest block actually seen, so the next forward walk is
|
||||
// as short as the generator's own progress and no shorter.
|
||||
if (out.block > frontier_block_) {
|
||||
std::memcpy(frontier_, mt, kBlockBytes);
|
||||
frontier_block_ = out.block;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (exhausted_ || unknown_.count(out.hash) != 0) {
|
||||
++misses_;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::uint32_t cur[kWordsPerBlock];
|
||||
std::uint32_t nxt[kWordsPerBlock];
|
||||
std::memcpy(cur, frontier_, kBlockBytes);
|
||||
int index = frontier_block_;
|
||||
for (int i = 0; i < kMaxTwistsPerLookup; ++i) {
|
||||
next_block(cur, nxt);
|
||||
++index;
|
||||
const std::uint64_t h = hash_bytes(nxt, kBlockBytes);
|
||||
memo_[h] = index;
|
||||
if (h == out.hash) {
|
||||
std::memcpy(frontier_, nxt, kBlockBytes);
|
||||
frontier_block_ = index;
|
||||
out.known = true;
|
||||
out.block = index;
|
||||
out.words = words_of(index, left);
|
||||
return out;
|
||||
}
|
||||
std::memcpy(cur, nxt, kBlockBytes);
|
||||
}
|
||||
unknown_.insert(out.hash);
|
||||
++misses_;
|
||||
if (misses_ >= kMaxMisses) exhausted_ = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
RngPos RngLedger::observe_object(const void* obj, std::size_t size) {
|
||||
RngPos out;
|
||||
if (!obj || size < A::RNG_size) return out;
|
||||
const char* p = static_cast<const char*>(obj);
|
||||
std::uint32_t mt[kWordsPerBlock];
|
||||
std::memcpy(mt, p + A::RNG_off_State, kBlockBytes);
|
||||
std::int32_t left = 0;
|
||||
std::memcpy(&left, p + A::RNG_off_Left, 4);
|
||||
return observe(mt, static_cast<int>(left));
|
||||
}
|
||||
|
||||
long long RngLedger::blocks_indexed() const {
|
||||
LockGuard g(mu_);
|
||||
return static_cast<long long>(memo_.size());
|
||||
}
|
||||
|
||||
long long RngLedger::misses() const {
|
||||
LockGuard g(mu_);
|
||||
return misses_;
|
||||
}
|
||||
|
||||
bool RngLedger::anchored() const {
|
||||
LockGuard g(mu_);
|
||||
return anchored_;
|
||||
}
|
||||
|
||||
void RngLedger::reset() {
|
||||
LockGuard g(mu_);
|
||||
anchored_ = false;
|
||||
exhausted_ = false;
|
||||
frontier_block_ = 0;
|
||||
memo_.clear();
|
||||
unknown_.clear();
|
||||
misses_ = 0;
|
||||
}
|
||||
|
||||
} // namespace shim::hooks
|
||||
95
src/shim/hooks/rng_ledger.h
Normal file
95
src/shim/hooks/rng_ledger.h
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// RngLedger — absolute word positions for the strategic Mersenne Twister, recovered from
|
||||
// state alone.
|
||||
//
|
||||
// WHY THIS EXISTS. The milestone is a standalone that runs one strategic turn and writes an
|
||||
// autosave that byte-matches the original's. The generator's state is part of that file, so
|
||||
// every word the turn consumes has to be accounted for. Counting *draws* by hooking
|
||||
// Mars::RNG::NextFloat / NextInt would undercount: NextInt rejection-samples, so one call can
|
||||
// spend several words, and Chance() sometimes spends none at all. The unit that matters for
|
||||
// save reproduction is the word, and the state says how many were spent whether or not anyone
|
||||
// hooked the function that spent them.
|
||||
//
|
||||
// HOW. MT19937's block transform is a pure function, so the blocks a generator visits form a
|
||||
// forward-only chain from wherever it starts. The ledger indexes that chain: the first block it
|
||||
// ever sees is index 0, and any later block is found by twisting forward from the frontier. A
|
||||
// state is then positioned exactly:
|
||||
//
|
||||
// words(block, left) = block * 624 + (624 - left)
|
||||
//
|
||||
// and the number of words consumed between any two observations is the difference — correct
|
||||
// across twists, across rejection loops, and across code nobody hooked.
|
||||
//
|
||||
// ORDERING. Observations may arrive out of chronological order: Hook<> takes its `before`
|
||||
// snapshot at entry but renders it (calling the region's `describe`) only after the original
|
||||
// returns, so a nested call's states are rendered first. Blocks are therefore memoised by hash
|
||||
// and resolved from the memo regardless of arrival order; a caller that observes at entry (from
|
||||
// `describe_args`) guarantees the entry block is already indexed by the time its `before`
|
||||
// snapshot is rendered. Walking *backwards* is impossible, which is exactly why entry
|
||||
// observation is not optional.
|
||||
//
|
||||
// LIMITS, stated because they are the failure modes:
|
||||
// * A block more than kMaxTwistsPerLookup twists ahead of the frontier is reported UNKNOWN
|
||||
// rather than guessed. So is every state seen before the anchor.
|
||||
// * Two generator instances share one ledger only by accident of the anchor: the second one's
|
||||
// blocks are not on the first one's chain, so they read UNKNOWN. That is the intended
|
||||
// signal, not a defect — one strategic generator is an assumption this measures.
|
||||
// * Position is relative to the anchor. Only differences are meaningful.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "shim/trace/platform.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
struct RngPos {
|
||||
bool known = false; // false: the block is not on the indexed chain
|
||||
long long words = 0; // absolute word position from the anchor (valid when `known`)
|
||||
int block = 0; // block index from the anchor (valid when `known`)
|
||||
int left = 0; // words still unread in the block, as read from the state
|
||||
std::uint64_t hash = 0; // FNV-1a 64 of the 2496-byte block
|
||||
};
|
||||
|
||||
class RngLedger {
|
||||
public:
|
||||
static constexpr int kWordsPerBlock = 624;
|
||||
// 4096 twists is 2.5M words: far more than a turn spends, small enough that a miss costs
|
||||
// milliseconds. A gap larger than this is a finding, not something to search harder for.
|
||||
static constexpr int kMaxTwistsPerLookup = 4096;
|
||||
// After this many unresolvable blocks the ledger stops walking. A second generator would
|
||||
// otherwise make every observation pay the full search.
|
||||
static constexpr int kMaxMisses = 8;
|
||||
|
||||
// The shim uses `instance()`; tests construct their own so one case cannot poison another.
|
||||
RngLedger() = default;
|
||||
static RngLedger& instance();
|
||||
|
||||
// `mt` is the 624-word block, `left` the counter. Out-of-range `left` returns unknown.
|
||||
RngPos observe(const std::uint32_t* mt, int left);
|
||||
// The same over a live Mars::RNG object (vptr, mt[624] at +4, next at +0x9c4, left at
|
||||
// +0x9c8). `size` must cover the object; a short region returns unknown.
|
||||
RngPos observe_object(const void* obj, std::size_t size);
|
||||
|
||||
long long blocks_indexed() const;
|
||||
long long misses() const;
|
||||
bool anchored() const;
|
||||
void reset();
|
||||
|
||||
private:
|
||||
mutable Mutex mu_;
|
||||
bool anchored_ = false;
|
||||
bool exhausted_ = false;
|
||||
std::uint32_t frontier_[kWordsPerBlock] = {};
|
||||
int frontier_block_ = 0;
|
||||
std::unordered_map<std::uint64_t, int> memo_;
|
||||
std::unordered_set<std::uint64_t> unknown_;
|
||||
long long misses_ = 0;
|
||||
};
|
||||
|
||||
// FNV-1a 64 over `n` bytes. Exposed so tests can pin the block identity.
|
||||
std::uint64_t hash_bytes(const void* p, std::size_t n);
|
||||
|
||||
} // namespace shim::hooks
|
||||
818
src/shim/hooks/tail_rng.cpp
Normal file
818
src/shim/hooks/tail_rng.cpp
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
#include "shim/hooks/tail_rng.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include "generated/sots_addresses.h"
|
||||
#include "mars/rng/mt19937.h"
|
||||
#include "shim/hooks/draw_sites.h"
|
||||
#include "shim/hooks/rng_ledger.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
using trace::Tv;
|
||||
namespace tv = trace::tv;
|
||||
|
||||
namespace {
|
||||
|
||||
namespace A = sots::addr;
|
||||
|
||||
constexpr std::size_t kRngSize = A::RNG_size;
|
||||
constexpr int kMtWords = mars::rng::MT19937::N;
|
||||
|
||||
// The `S` frame. Every StrategyServer_off_* in the generated header is an S+4 offset except
|
||||
// StrategyServer_off_RNG, which is already S-relative. Adding 4 is therefore correct for every
|
||||
// use below EXCEPT the generator, and each site says which it is using.
|
||||
constexpr std::size_t kSFrame = 4;
|
||||
|
||||
// Game::NodePath, stride 0x30, in the vector at ServerNodeGraph+0x8/+0xc. Field offsets and the
|
||||
// expiry formula are an instruction read of NodePath::RemainingLife 0x006e2130 (whole 122-byte
|
||||
// body) plus the loop at 0x007ae07a..0x007ae0af.
|
||||
constexpr std::size_t kServerOffNodeGraph = kSFrame + A::StrategyServer_off_NodeGraph; // S+0x154
|
||||
constexpr std::size_t kGraphOffPaths = A::ServerNodeGraph_off_Paths;
|
||||
constexpr std::size_t kNodePathStride = 0x30;
|
||||
constexpr std::size_t kNpType = 0x04; // npt -- 0 means the line never expires
|
||||
constexpr std::size_t kNpCreated = 0x14; // npctm -- creation turn
|
||||
constexpr std::size_t kNpLife = 0x1c; // npdtn -- lifetime budget; INT_MAX means immortal
|
||||
constexpr std::size_t kNpTrafficDiv = 0x20; // npdtf
|
||||
constexpr std::size_t kNpTraffic = 0x24; // nptf
|
||||
// A sane bound on the node-line count; a wilder number means we are reading the wrong object.
|
||||
constexpr std::size_t kMaxNodePaths = 65536;
|
||||
|
||||
struct Env {
|
||||
std::uintptr_t exe_base = 0;
|
||||
void (*log_line)(const char*) = nullptr;
|
||||
};
|
||||
Env g_env;
|
||||
|
||||
void logf(const char* fmt, ...) {
|
||||
if (!g_env.log_line) return;
|
||||
char line[512];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
std::vsnprintf(line, sizeof line, fmt, ap);
|
||||
va_end(ap);
|
||||
g_env.log_line(line);
|
||||
}
|
||||
|
||||
bool readable(const void* p, std::size_t n) {
|
||||
if (!p) return false;
|
||||
if (n == 0) return true;
|
||||
#if defined(_WIN32)
|
||||
const char* c = static_cast<const char*>(p);
|
||||
const char* const end = c + n;
|
||||
while (c < end) {
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
if (!VirtualQuery(c, &mbi, sizeof mbi)) return false;
|
||||
if (mbi.State != MEM_COMMIT) return false;
|
||||
if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false;
|
||||
const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
|
||||
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
if (!(mbi.Protect & ok)) return false;
|
||||
c = static_cast<const char*>(mbi.BaseAddress) + mbi.RegionSize;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
template <class T>
|
||||
T peek(const void* base, std::size_t off) {
|
||||
T v{};
|
||||
std::memcpy(&v, static_cast<const char*>(base) + off, sizeof v);
|
||||
return v;
|
||||
}
|
||||
void* ptr_at(const void* base, std::size_t off) { return peek<void*>(base, off); }
|
||||
|
||||
// ---- the generator ---------------------------------------------------------------------------
|
||||
|
||||
// The last StrategyServer any of these hooks saw, so the Autosave markers -- whose `this` is a
|
||||
// StrategyHost, not the server -- can reach the generator. Deliberately a cache rather than a
|
||||
// derivation: `StrategyHost::Autosave`'s `this` is the global at 0x00b29f98 and it has never
|
||||
// been proved to be the same object whose +0x54 holds the StrategyServer. The Autosave hook
|
||||
// records BOTH the cached pointer and the +0x54 candidate so a run says whether they agree.
|
||||
void* g_server = nullptr;
|
||||
|
||||
void* rng_of_server(void* self) {
|
||||
if (!readable(self, A::StrategyServer_off_RNG + 4)) return nullptr;
|
||||
void* r = ptr_at(self, A::StrategyServer_off_RNG); // S frame already; no +4
|
||||
return readable(r, kRngSize) ? r : nullptr;
|
||||
}
|
||||
|
||||
std::int32_t turn_of(void* self) {
|
||||
if (!readable(self, kSFrame + A::StrategyServer_off_ModCount + 4)) return -1;
|
||||
return peek<std::int32_t>(self, kSFrame + A::StrategyServer_off_ModCount);
|
||||
}
|
||||
std::int32_t phase_counter_of(void* self) {
|
||||
if (!readable(self, kSFrame + A::StrategyServer_off_PhaseCounter + 4)) return -1;
|
||||
return peek<std::int32_t>(self, kSFrame + A::StrategyServer_off_PhaseCounter);
|
||||
}
|
||||
std::int32_t encounter_count_of(void* self) {
|
||||
const std::size_t off = kSFrame + A::StrategyServer_off_TeamRecords; // S+0x1e8
|
||||
if (!readable(self, off + 8)) return -1;
|
||||
const char* first = static_cast<const char*>(ptr_at(self, off));
|
||||
const char* last = static_cast<const char*>(ptr_at(self, off + 4));
|
||||
if (!first || !last || last < first) return -1;
|
||||
return static_cast<std::int32_t>((last - first) / 0x74);
|
||||
}
|
||||
std::int32_t player_count_of(void* self) {
|
||||
const std::size_t off = kSFrame + A::StrategyServer_off_Players; // S+0x54
|
||||
if (!readable(self, off + 8)) return -1;
|
||||
const char* first = static_cast<const char*>(ptr_at(self, off));
|
||||
const char* last = static_cast<const char*>(ptr_at(self, off + 4));
|
||||
if (!first || !last || last < first) return -1;
|
||||
return static_cast<std::int32_t>((last - first) / 4);
|
||||
}
|
||||
|
||||
// The record every hook here emits as arguments: where the generator is when the call starts.
|
||||
// Observing HERE, at entry, is what makes the nested `before` snapshots resolvable -- Hook<>
|
||||
// renders them after the original returns, by which time the ledger's frontier has moved on and
|
||||
// walking backwards is impossible. See rng_ledger.h.
|
||||
struct RngEntry {
|
||||
void* rng = nullptr;
|
||||
RngPos pos;
|
||||
bool have = false;
|
||||
};
|
||||
|
||||
RngEntry observe_entry(void* self) {
|
||||
RngEntry e;
|
||||
e.rng = rng_of_server(self);
|
||||
if (!e.rng) return e;
|
||||
e.pos = RngLedger::instance().observe_object(e.rng, kRngSize);
|
||||
e.have = true;
|
||||
// Tell the per-call-site detours which generator is the strategic one, so their rows can be
|
||||
// split. Without it they count every RNG instance in the process and cannot reconcile.
|
||||
draw_sites_set_generator(e.rng);
|
||||
return e;
|
||||
}
|
||||
|
||||
void push_rng_args(std::vector<Tv>& out, const RngEntry& e) {
|
||||
out.push_back(tv::ptr(e.rng).named("rng"));
|
||||
out.push_back((e.have && e.pos.known ? tv::i64(e.pos.words) : tv::null()).named("rng_words_in"));
|
||||
out.push_back((e.have ? tv::i32(e.pos.left) : tv::null()).named("rng_left_in"));
|
||||
}
|
||||
|
||||
void push_server_args(std::vector<Tv>& out, void* self) {
|
||||
out.push_back(tv::i32(turn_of(self)).named("turn")); // S+0xc ModCount
|
||||
out.push_back(tv::i32(phase_counter_of(self)).named("phase_counter")); // S+0x8, lane K §7.1
|
||||
out.push_back(tv::i32(player_count_of(self)).named("players"));
|
||||
out.push_back(tv::i32(encounter_count_of(self)).named("encounters"));
|
||||
}
|
||||
|
||||
// The region describe. Called once on the `before` snapshot and once on the `after` snapshot,
|
||||
// both after the original returns; `words` is the ledger position, so the difference between
|
||||
// the two is exactly the number of words this call consumed.
|
||||
Tv describe_rng(const void* p, std::size_t size, unsigned) {
|
||||
const RngPos pos = RngLedger::instance().observe_object(p, size);
|
||||
Tv s = tv::struct_();
|
||||
s.add("left", tv::i32(pos.left));
|
||||
s.add("index", tv::i32(RngLedger::kWordsPerBlock - pos.left));
|
||||
if (pos.known) {
|
||||
s.add("block", tv::i32(pos.block));
|
||||
s.add("words", tv::i64(pos.words));
|
||||
} else {
|
||||
// Not on the indexed chain. Said plainly rather than guessed: this is the signal that a
|
||||
// second generator exists, or that the gap exceeded the ledger's search bound.
|
||||
s.add("block", tv::null());
|
||||
s.add("words", tv::null());
|
||||
}
|
||||
s.add("block_hash", tv::u64(pos.hash));
|
||||
return s;
|
||||
}
|
||||
|
||||
void push_rng_region(std::vector<trace::Region>& out, void* rng) {
|
||||
if (!rng || !readable(rng, kRngSize)) return;
|
||||
trace::Region r;
|
||||
r.name = "rng";
|
||||
r.ptr = rng;
|
||||
r.size = kRngSize;
|
||||
r.describe = &describe_rng;
|
||||
out.push_back(r);
|
||||
}
|
||||
|
||||
// ---- the node-line expiry model ---------------------------------------------------------------
|
||||
|
||||
// NodePath::RemainingLife 0x006e2130, whole body read as instructions. Two escape hatches return
|
||||
// INT_MAX (never expires); otherwise `npdtn - nptf/npdtf - (turn - npctm)`, clamped at 0 by the
|
||||
// callee. The division is a signed `idiv` and `nptf` is never sign-checked, so this reproduces
|
||||
// signed truncation deliberately.
|
||||
std::int32_t node_path_remaining_life(const void* rec, std::int32_t turn) {
|
||||
if (peek<std::int32_t>(rec, kNpType) == 0) return 0x7fffffff;
|
||||
const std::int32_t life = peek<std::int32_t>(rec, kNpLife);
|
||||
if (life == 0x7fffffff) return 0x7fffffff;
|
||||
|
||||
std::int32_t aged = 0;
|
||||
const std::int32_t created = peek<std::int32_t>(rec, kNpCreated);
|
||||
if (created >= 0 && turn >= created) aged = turn - created;
|
||||
|
||||
std::int32_t wear = 0;
|
||||
const std::int32_t div = peek<std::int32_t>(rec, kNpTrafficDiv);
|
||||
if (div != 0x7fffffff && div > 0) wear = peek<std::int32_t>(rec, kNpTraffic) / div;
|
||||
|
||||
const std::int32_t rem = life - wear - aged;
|
||||
return rem > 0 ? rem : 0;
|
||||
}
|
||||
|
||||
// How close the node-line population is to producing a draw at all. Without this, "we ran N turns
|
||||
// and phase 11 never fired" is an anecdote; with it, the report can say whether any reachable save
|
||||
// could have fired it and how far away the nearest line is. `min_life` is the smallest positive
|
||||
// remaining life over the lines that can expire at all -- the number of turns of pure ageing
|
||||
// between this save and the first phase-11 draw, if nothing adds traffic.
|
||||
struct NodePathStats {
|
||||
std::int32_t paths = -1;
|
||||
std::int32_t expired = -1; // remaining life <= 0 -> one word each
|
||||
std::int32_t permanent = -1; // npt == 0: never expires
|
||||
std::int32_t immortal = -1; // npdtn == INT_MAX: never expires
|
||||
std::int32_t mortal = -1; // the rest: the only ones that can ever draw
|
||||
std::int32_t min_life = -1; // smallest positive remaining life among the mortal ones
|
||||
std::int32_t within5 = -1; // mortal lines with remaining life <= 5
|
||||
};
|
||||
|
||||
// Words node-line decay 0x007ae010 will consume, predicted from the state at entry. The gate on the draw is
|
||||
// the expiry test and nothing else: the `Chance` result and the "a fleet with flag 0x20000 is
|
||||
// riding this line" scan both run AFTER the draw at 0x007ae095 and gate only the collapse.
|
||||
// (That corrects combat-done-tail.md §3, which reads as if the fleet check suppressed the roll.)
|
||||
// Returns -1 when the state could not be read, which is reported as unknown rather than as 0.
|
||||
std::int32_t predicted_node_line_words(void* self, NodePathStats* out) {
|
||||
NodePathStats st;
|
||||
if (out) *out = st;
|
||||
if (!readable(self, kServerOffNodeGraph + 4)) return -1;
|
||||
const char* graph = static_cast<const char*>(ptr_at(self, kServerOffNodeGraph));
|
||||
if (!readable(graph, kGraphOffPaths + 8)) return -1;
|
||||
const char* first = static_cast<const char*>(ptr_at(graph, kGraphOffPaths));
|
||||
const char* last = static_cast<const char*>(ptr_at(graph, kGraphOffPaths + 4));
|
||||
if (!first || !last || last < first) return -1;
|
||||
const std::size_t span = static_cast<std::size_t>(last - first);
|
||||
if (span % kNodePathStride != 0) return -1;
|
||||
const std::size_t n = span / kNodePathStride;
|
||||
if (n > kMaxNodePaths || !readable(first, span)) return -1;
|
||||
|
||||
const std::int32_t turn = turn_of(self);
|
||||
st.paths = static_cast<std::int32_t>(n);
|
||||
st.expired = st.permanent = st.immortal = st.mortal = st.within5 = 0;
|
||||
st.min_life = -1;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const char* r = first + i * kNodePathStride;
|
||||
if (peek<std::int32_t>(r, kNpType) == 0) {
|
||||
++st.permanent;
|
||||
continue;
|
||||
}
|
||||
if (peek<std::int32_t>(r, kNpLife) == 0x7fffffff) {
|
||||
++st.immortal;
|
||||
continue;
|
||||
}
|
||||
++st.mortal;
|
||||
const std::int32_t life = node_path_remaining_life(r, turn);
|
||||
if (life <= 0) {
|
||||
++st.expired;
|
||||
continue;
|
||||
}
|
||||
if (life <= 5) ++st.within5;
|
||||
if (st.min_life < 0 || life < st.min_life) st.min_life = life;
|
||||
}
|
||||
if (out) *out = st;
|
||||
return st.expired;
|
||||
}
|
||||
|
||||
void push_node_path_args(std::vector<Tv>& out, const NodePathStats& st, std::int32_t predicted,
|
||||
const char* predict_name) {
|
||||
out.push_back(tv::i32(st.paths).named("node_paths"));
|
||||
out.push_back(tv::i32(st.permanent).named("np_permanent"));
|
||||
out.push_back(tv::i32(st.immortal).named("np_immortal"));
|
||||
out.push_back(tv::i32(st.mortal).named("np_mortal"));
|
||||
out.push_back(tv::i32(st.min_life).named("np_min_life"));
|
||||
out.push_back(tv::i32(st.within5).named("np_within5"));
|
||||
out.push_back(tv::i32(predicted).named(predict_name));
|
||||
}
|
||||
|
||||
// Advance a scratch copy of the RNG object by `words` words, the way the original would.
|
||||
void advance_scratch_rng(void* scratch, std::uintptr_t live_base, int words) {
|
||||
if (!scratch || words < 0) return;
|
||||
char* dst = static_cast<char*>(scratch);
|
||||
std::uint32_t mt[kMtWords];
|
||||
std::memcpy(mt, dst + A::RNG_off_State, sizeof mt);
|
||||
std::int32_t left = 0;
|
||||
std::memcpy(&left, dst + A::RNG_off_Left, 4);
|
||||
mars::rng::MT19937 g;
|
||||
g.load_state(mt, static_cast<int>(left));
|
||||
for (int i = 0; i < words; ++i) (void)g.next_u32();
|
||||
std::memcpy(dst + A::RNG_off_State, g.state(), sizeof mt);
|
||||
const std::int32_t new_left = static_cast<std::int32_t>(g.left());
|
||||
std::memcpy(dst + A::RNG_off_Left, &new_left, 4);
|
||||
// `next` is an absolute cursor into the live object's own state block, so it is rebuilt
|
||||
// against the live base rather than the scratch's.
|
||||
void* next = reinterpret_cast<void*>(live_base + A::RNG_off_State +
|
||||
static_cast<std::size_t>(kMtWords - new_left) * 4);
|
||||
std::memcpy(dst + A::RNG_off_Next, &next, sizeof next);
|
||||
}
|
||||
|
||||
// ---- per-hook state ---------------------------------------------------------------------------
|
||||
//
|
||||
// Turn processing is single-threaded and every hook reads its own slot between describe_args and
|
||||
// regions/rebind/ours in the same call, so a plain global per hook is safe and matches the
|
||||
// pattern the other hook TUs use.
|
||||
|
||||
struct CallState {
|
||||
RngEntry entry;
|
||||
};
|
||||
CallState g_autosave, g_process_turn, g_tail, g_apply, g_nodespace;
|
||||
|
||||
struct NodeLineState {
|
||||
RngEntry entry;
|
||||
std::int32_t predicted = -1;
|
||||
NodePathStats stats;
|
||||
void* s_rng = nullptr;
|
||||
bool compare = false;
|
||||
};
|
||||
NodeLineState g_nld;
|
||||
|
||||
void refuse_replace(const char* who) {
|
||||
static bool warned = false;
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
logf("tail-rng: replace mode is not supported for %s; falling back to the original", who);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void init_tail_rng(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
|
||||
g_env.exe_base = exe_base;
|
||||
g_env.log_line = log_line;
|
||||
}
|
||||
|
||||
void tail_rng_common_coverage(trace::Coverage& c) {
|
||||
c.unmodelled("everything the original writes except the strategic generator",
|
||||
trace::Risk::High,
|
||||
"this hook family measures ONE thing -- how many words the generator advances "
|
||||
"and where. It declares no region over game state and makes no claim about it. "
|
||||
"A clean run here says the RNG accounting is right and says nothing whatever "
|
||||
"about whether the turn was computed correctly",
|
||||
"region:rng is the only check; the turn's own correctness is B1/B3/B4's job");
|
||||
c.unmodelled("the ledger reports WORDS, not draws",
|
||||
trace::Risk::Low,
|
||||
"a NextInt that rejects three times is four words and one call. Words are the "
|
||||
"unit that decides whether a save reproduces; they are the wrong unit for "
|
||||
"counting decisions, and nothing here should be read as a draw count",
|
||||
"region:rng carries left/block/words, never a call count");
|
||||
c.unmodelled("the ledger is deliberately blind to WHICH primitive spent a word",
|
||||
trace::Risk::Low,
|
||||
"that is the design, and it is why this instrument was preferred to hooking "
|
||||
"the primitives: the image has FOUR draw entry points (NextFloat 0x0047d830, "
|
||||
"NextInt 0x004271c0, Chance 0x008e6dd0 and NextUInt 0x004f7670, the last of "
|
||||
"which appears in no previous lane's primitive set) plus inlined draws in at "
|
||||
"least twelve functions, two of them reachable from the turn roots. A "
|
||||
"primitive-counting hook would have silently undercounted every one of those",
|
||||
"region:rng reads the state, so an inlined draw is as visible as a called one");
|
||||
c.unmodelled("a generator position the ledger cannot place reads `words: null`",
|
||||
trace::Risk::Medium,
|
||||
"a block more than 4096 twists ahead of the frontier, or any state behind the "
|
||||
"anchor, is reported unknown rather than guessed. A null in a ledger field is a "
|
||||
"measurement failure and must not be read as zero",
|
||||
"region:rng emits null explicitly; tracecmp shows it as a value, not a gap");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyHost::Autosave --------------------------------------------------------------
|
||||
|
||||
void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& out, void* self, void* name_out,
|
||||
bool end_turn) {
|
||||
// `this` is the global at 0x00b29f98, hardcoded by both call sites -- not an object handed
|
||||
// in, and not proved to be the same StrategyHost whose +0x54 holds the StrategyServer. So
|
||||
// the generator is reached through the pointer the turn drivers cached, and the +0x54
|
||||
// candidate is recorded beside it so one run settles whether the two are the same object.
|
||||
void* from_this = readable(self, 0x58) ? ptr_at(self, 0x54) : nullptr;
|
||||
g_autosave.entry = observe_entry(g_server);
|
||||
|
||||
out.push_back(tv::ptr(self).named("host"));
|
||||
out.push_back(tv::ptr(name_out).named("out_name"));
|
||||
out.push_back(tv::boolean(end_turn).named("end_turn"));
|
||||
out.push_back(tv::ptr(g_server).named("server_cached"));
|
||||
out.push_back(tv::ptr(from_this).named("host_plus_0x54"));
|
||||
out.push_back(tv::boolean(from_this != nullptr && from_this == g_server).named("server_agrees"));
|
||||
push_rng_args(out, g_autosave.entry);
|
||||
|
||||
// The per-call-site ledger covers exactly the bracket: reset at the pre-turn marker, emitted at
|
||||
// the post-turn one. Its total must equal the bracket total the region ledger measures
|
||||
// independently; a shortfall is an unattributed word, which is the whole point of collecting it.
|
||||
if (end_turn) {
|
||||
draw_sites_reset();
|
||||
return;
|
||||
}
|
||||
DrawSiteRow rows[64];
|
||||
const std::size_t n = draw_sites_snapshot(rows, sizeof rows / sizeof rows[0]);
|
||||
std::vector<Tv> sites;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
Tv one = tv::struct_();
|
||||
one.add("entry", tv::str(draw_entry_name(rows[i].entry)));
|
||||
one.add("ret_rva", tv::u32(rows[i].ret_rva));
|
||||
one.add("calls", tv::u32(rows[i].calls));
|
||||
one.add("words", tv::u32(rows[i].words));
|
||||
one.add("no_draw_calls", tv::u32(rows[i].zero_calls));
|
||||
one.add("strategic", tv::boolean(rows[i].strategic));
|
||||
sites.push_back(std::move(one));
|
||||
}
|
||||
out.push_back(tv::list(std::move(sites)).named("draw_sites"));
|
||||
out.push_back(tv::u32(draw_sites_total_words()).named("draw_site_words"));
|
||||
out.push_back(tv::u32(draw_sites_total_calls()).named("draw_site_calls"));
|
||||
out.push_back(tv::u32(draw_sites_other_words()).named("draw_site_words_other_rng"));
|
||||
out.push_back(tv::u32(draw_sites_other_calls()).named("draw_site_calls_other_rng"));
|
||||
out.push_back(tv::u32(draw_sites_overflow()).named("draw_site_overflow"));
|
||||
}
|
||||
|
||||
Tv StrategyHostAutosaveHook::describe_ret(void* r) { return tv::ptr(r); }
|
||||
|
||||
void StrategyHostAutosaveHook::regions(std::vector<trace::Region>& out, void*, void*, bool) {
|
||||
push_rng_region(out, g_autosave.entry.rng);
|
||||
}
|
||||
|
||||
StrategyHostAutosaveHook::Args StrategyHostAutosaveHook::rebind(trace::Scratch&, void* self,
|
||||
void* name_out, bool end_turn) {
|
||||
return Args(self, name_out, end_turn);
|
||||
}
|
||||
|
||||
void* StrategyHostAutosaveHook::ours(void* self, void* name_out, bool end_turn) {
|
||||
using H = trace::Hook<StrategyHostAutosaveHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyHost::Autosave");
|
||||
if (H::original) return H::original(self, name_out, end_turn);
|
||||
}
|
||||
// Compare mode: ours writes nothing. The autosave is a marker, not a model -- the useful
|
||||
// output is the pair of ledger positions in `side.rng`, and a model that "predicted" the
|
||||
// generator does not move here would be a check of nothing. The NRV slot is echoed back so
|
||||
// the return value is the one the caller expects.
|
||||
return name_out;
|
||||
}
|
||||
|
||||
void StrategyHostAutosaveHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("the whole save write: four path buffers, the ENDTURN pair removal, the "
|
||||
"backup rotation, the per-player connection detach/reattach, and "
|
||||
"SaveGame_WriteFile 0x00877070 itself",
|
||||
trace::Risk::Low,
|
||||
"this hook exists to timestamp the generator at the two moments the two save "
|
||||
"files are written. It is a marker and models nothing",
|
||||
"region:rng only");
|
||||
c.unmodelled("the generator is reached through a CACHED StrategyServer pointer, not from "
|
||||
"this call's own arguments",
|
||||
trace::Risk::Medium,
|
||||
"`this` is the global at 0x00b29f98, hardcoded by both call sites, and no "
|
||||
"argument here names the server. On the first pre-turn autosave after a load "
|
||||
"no turn driver has run yet, so the cache is empty and that record carries no "
|
||||
"ledger position -- the FIRST BRACKET OF A SESSION IS INCOMPLETE BY "
|
||||
"CONSTRUCTION and must not be read as a zero-cost turn",
|
||||
"arg:server_cached / host_plus_0x54 / server_agrees say which pointer was used "
|
||||
"and whether the +0x54 candidate is the same object");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyServer::ProcessTurn ---------------------------------------------------------
|
||||
|
||||
void StrategyServerProcessTurnHook::describe_args(std::vector<Tv>& out, void* self, float dt) {
|
||||
g_server = self;
|
||||
g_process_turn.entry = observe_entry(self);
|
||||
out.push_back(tv::ptr(self).named("server"));
|
||||
out.push_back(tv::f32(dt).named("dt"));
|
||||
push_server_args(out, self);
|
||||
push_rng_args(out, g_process_turn.entry);
|
||||
}
|
||||
|
||||
void StrategyServerProcessTurnHook::regions(std::vector<trace::Region>& out, void*, float) {
|
||||
push_rng_region(out, g_process_turn.entry.rng);
|
||||
}
|
||||
|
||||
StrategyServerProcessTurnHook::Args StrategyServerProcessTurnHook::rebind(trace::Scratch&,
|
||||
void* self, float dt) {
|
||||
return Args(self, dt);
|
||||
}
|
||||
|
||||
void StrategyServerProcessTurnHook::ours(void* self, float dt) {
|
||||
using H = trace::Hook<StrategyServerProcessTurnHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyServer::ProcessTurn");
|
||||
if (H::original) H::original(self, dt);
|
||||
}
|
||||
}
|
||||
|
||||
void StrategyServerProcessTurnHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("no model of the turn's RNG cost: 32 phases, each of which may draw",
|
||||
trace::Risk::High,
|
||||
"ProcessResearch's completion roll, RollResearchAccident's NextInt(100), the "
|
||||
"ResearchRollPending roll (one word, or two on the plague path), and whatever "
|
||||
"ProcessStations / ProcessSurrenders / ProcessMissions / ProcessSpecialProjects "
|
||||
"spend -- none of which has ever been measured. `ours` predicts nothing and the "
|
||||
"record reports the measurement",
|
||||
"region:rng measures the total; the per-phase split is not resolved here");
|
||||
c.unmodelled("this hook takes the address the fpu module also wants to sample",
|
||||
trace::Risk::Low,
|
||||
"MinHook allows one hook per target. `fpu.sample_turn=off` releases "
|
||||
"StrategyServer::ProcessTurn so this hook can install; with it on, this hook "
|
||||
"fails to install and the trace is missing half the ledger",
|
||||
"shim.log records the MH_CreateHook status for both");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyServer::OnAllCombatDone_Tail -------------------------------------------------
|
||||
|
||||
void OnAllCombatDoneTailHook::describe_args(std::vector<Tv>& out, void* self, void* results) {
|
||||
g_server = self;
|
||||
g_tail.entry = observe_entry(self);
|
||||
|
||||
// The message payload: `results` is `msg+4`, a vector<EncounterResults> of stride 0x178.
|
||||
std::int32_t result_count = -1;
|
||||
if (readable(results, 8)) {
|
||||
const char* first = static_cast<const char*>(ptr_at(results, 0));
|
||||
const char* last = static_cast<const char*>(ptr_at(results, 4));
|
||||
if (first && last && last >= first) result_count = static_cast<std::int32_t>((last - first) / 0x178);
|
||||
}
|
||||
NodePathStats st;
|
||||
const std::int32_t predicted = predicted_node_line_words(self, &st);
|
||||
|
||||
out.push_back(tv::ptr(self).named("server"));
|
||||
out.push_back(tv::ptr(results).named("results"));
|
||||
out.push_back(tv::i32(result_count).named("result_count"));
|
||||
push_server_args(out, self);
|
||||
// The tail's only predictable draw source, evaluated before the tail runs: if phase 11 is
|
||||
// the whole story on a quiet turn, the tail's measured word delta equals this number.
|
||||
push_node_path_args(out, st, predicted, "predict_nodeline_words");
|
||||
push_rng_args(out, g_tail.entry);
|
||||
}
|
||||
|
||||
void OnAllCombatDoneTailHook::regions(std::vector<trace::Region>& out, void*, void*) {
|
||||
push_rng_region(out, g_tail.entry.rng);
|
||||
}
|
||||
|
||||
OnAllCombatDoneTailHook::Args OnAllCombatDoneTailHook::rebind(trace::Scratch&, void* self,
|
||||
void* results) {
|
||||
return Args(self, results);
|
||||
}
|
||||
|
||||
void OnAllCombatDoneTailHook::ours(void* self, void* results) {
|
||||
using H = trace::Hook<OnAllCombatDoneTailHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyServer::OnAllCombatDone_Tail");
|
||||
if (H::original) H::original(self, results);
|
||||
}
|
||||
}
|
||||
|
||||
void OnAllCombatDoneTailHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("36 phases, of which two can draw and neither is modelled here",
|
||||
trace::Risk::High,
|
||||
"phase 6 reaches the unread 7499-byte combat resolver 0x007d5af0 (NextInt on "
|
||||
"the node-cannon path, Twist plus NextInt on the salvage path) and phase 11 "
|
||||
"draws one word per expired node line. `predict_nodeline_words` covers only the "
|
||||
"second, and the nested ApplyEncounterResult / NodeLineDecay hooks are what "
|
||||
"attribute the split",
|
||||
"region:rng plus the two nested hooks");
|
||||
c.unmodelled("whether this handler runs on a turn with NO combat is what this hook is here "
|
||||
"to settle, and until it has run it is a hypothesis",
|
||||
trace::Risk::Medium,
|
||||
"combat-done-tail.md §6 infers it from the determinism note -- the post-turn "
|
||||
"autosave appears on every End Turn and this handler is its only reachable "
|
||||
"caller -- not from the instruction stream",
|
||||
"arg:encounters says how many encounters this call saw; a call with 0 settles "
|
||||
"it");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyServer::ApplyEncounterResult -------------------------------------------------
|
||||
|
||||
void ApplyEncounterResultHook::describe_args(std::vector<Tv>& out, void* self, void* enc,
|
||||
void* res) {
|
||||
g_apply.entry = observe_entry(self);
|
||||
out.push_back(tv::ptr(self).named("server"));
|
||||
out.push_back(tv::ptr(enc).named("encounter"));
|
||||
out.push_back(tv::ptr(res).named("result"));
|
||||
// The three dispatch bytes. `+0x4 != 0` makes the whole function a no-op, so a call with it
|
||||
// set that still moves the generator would be a real surprise.
|
||||
if (readable(res, 8)) {
|
||||
out.push_back(tv::u8(peek<std::uint8_t>(res, 4)).named("res_no_battle"));
|
||||
out.push_back(tv::u8(peek<std::uint8_t>(res, 6)).named("res_peaceful"));
|
||||
out.push_back(tv::u8(peek<std::uint8_t>(res, 7)).named("res_surrendered"));
|
||||
}
|
||||
push_server_args(out, self);
|
||||
push_rng_args(out, g_apply.entry);
|
||||
}
|
||||
|
||||
void ApplyEncounterResultHook::regions(std::vector<trace::Region>& out, void*, void*, void*) {
|
||||
push_rng_region(out, g_apply.entry.rng);
|
||||
}
|
||||
|
||||
ApplyEncounterResultHook::Args ApplyEncounterResultHook::rebind(trace::Scratch&, void* self,
|
||||
void* enc, void* res) {
|
||||
return Args(self, enc, res);
|
||||
}
|
||||
|
||||
void ApplyEncounterResultHook::ours(void* self, void* enc, void* res) {
|
||||
using H = trace::Hook<ApplyEncounterResultHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyServer::ApplyEncounterResult");
|
||||
if (H::original) H::original(self, enc, res);
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyEncounterResultHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("the combat resolver 0x007d5af0 (7499 B) is completely unread",
|
||||
trace::Risk::High,
|
||||
"this hook measures what its subtree spends and models none of it. Nothing "
|
||||
"about combat determinism can be settled until that function is read; this "
|
||||
"only puts a number on the hole",
|
||||
"region:rng measures the subtotal");
|
||||
c.unmodelled("the ~0xea0-byte combat report, the CombatReport list append at S+0x1fc, the "
|
||||
"ClientEncounterResults push into S+0x2f4, the per-ship turn stamps and the "
|
||||
"pairwise engagement bits",
|
||||
trace::Risk::Medium,
|
||||
"all of it is game state this hook does not declare and does not check",
|
||||
"");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyServer::NodeLineDecay --------------------------------------------------------
|
||||
|
||||
void NodeLineDecayHook::describe_args(std::vector<Tv>& out, void* self) {
|
||||
g_nld.entry = observe_entry(self);
|
||||
g_nld.predicted = predicted_node_line_words(self, &g_nld.stats);
|
||||
out.push_back(tv::ptr(self).named("server"));
|
||||
push_server_args(out, self);
|
||||
// `predict_words` is written before the original runs: it is the falsifiable claim, not a
|
||||
// report of what happened. If the measured delta on `rng` is not this number, the model is
|
||||
// wrong. The np_* fields say how close the population is to firing at all, so "phase 11
|
||||
// never drew" can be reported as a distance rather than as an absence.
|
||||
push_node_path_args(out, g_nld.stats, g_nld.predicted, "predict_words");
|
||||
push_rng_args(out, g_nld.entry);
|
||||
}
|
||||
|
||||
void NodeLineDecayHook::regions(std::vector<trace::Region>& out, void*) {
|
||||
push_rng_region(out, g_nld.entry.rng);
|
||||
}
|
||||
|
||||
NodeLineDecayHook::Args NodeLineDecayHook::rebind(trace::Scratch& s, void* self) {
|
||||
g_nld.compare = true;
|
||||
g_nld.s_rng = (s.count() > 0 && s.size(0) >= kRngSize) ? s.ptr(0) : nullptr;
|
||||
return Args(self);
|
||||
}
|
||||
|
||||
void NodeLineDecayHook::ours(void* self) {
|
||||
using H = trace::Hook<NodeLineDecayHook>;
|
||||
const bool compare = g_nld.compare;
|
||||
g_nld.compare = false;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyServer::NodeLineDecay");
|
||||
if (H::original) H::original(self);
|
||||
return;
|
||||
}
|
||||
if (!compare) return;
|
||||
// The model: one word per expired node line, and nothing else in the 1117-byte body reaches
|
||||
// a generator (direct-call sweep to depth 5 over 140 functions, one hit). If the prediction
|
||||
// failed to read the graph it advances nothing, which diverges loudly rather than quietly.
|
||||
if (g_nld.s_rng && g_nld.entry.have && g_nld.predicted >= 0)
|
||||
advance_scratch_rng(g_nld.s_rng, reinterpret_cast<std::uintptr_t>(g_nld.entry.rng),
|
||||
g_nld.predicted);
|
||||
}
|
||||
|
||||
void NodeLineDecayHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("the collapse itself: 0x007a92e0 (690 B) and 0x007a4700 (2244 B) destroy "
|
||||
"or halt fleets and post EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE / "
|
||||
"_HALTED / _HALTED_VIANODE, and loop 3 posts two more decay-stage events",
|
||||
trace::Risk::High,
|
||||
"ours advances the generator and writes nothing else. In compare mode that is "
|
||||
"the intent -- the check is the word count -- but it means a clean verdict here "
|
||||
"says nothing about which lines actually collapsed",
|
||||
"region:rng only");
|
||||
c.unmodelled("the draw-count model is verified by a DIRECT-call sweep of the downstream "
|
||||
"pair; their subtrees contain unresolved indirect call sites",
|
||||
trace::Risk::Medium,
|
||||
"if one of those vtable slots reaches a generator, the measured delta will "
|
||||
"exceed `predict_words` and this hook will diverge -- which is the correct "
|
||||
"outcome, and the reason the prediction is recorded as an argument",
|
||||
"arg:predict_words vs region:rng is exactly that check");
|
||||
c.unmodelled("the expiry formula reproduces a signed idiv on nptf/npdtf without knowing "
|
||||
"whether nptf can be negative",
|
||||
trace::Risk::Low,
|
||||
"the original never sign-checks the traffic accumulator. The model truncates "
|
||||
"toward zero the same way; if the field is always non-negative the question "
|
||||
"never arises, and no save has been observed with a negative one",
|
||||
"");
|
||||
}
|
||||
|
||||
// ---- Game::EncounterDetect::AssignContacts -------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
struct AssignState {
|
||||
RngEntry entry;
|
||||
std::int32_t detectors = -1;
|
||||
std::int32_t contacts = -1;
|
||||
};
|
||||
AssignState g_assign;
|
||||
|
||||
std::int32_t ptr_vector_size(void* v) {
|
||||
if (!readable(v, 8)) return -1;
|
||||
const char* first = static_cast<const char*>(ptr_at(v, 0));
|
||||
const char* last = static_cast<const char*>(ptr_at(v, 4));
|
||||
if (!first || !last || last < first) return -1;
|
||||
return static_cast<std::int32_t>((last - first) / 4);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EncounterDetectAssignContactsHook::describe_args(std::vector<Tv>& out, void* self,
|
||||
void* buckets, void* det, void* con) {
|
||||
// ctx = {StrategyServer* S, TechDef*, TechDef*}; the generator is the server's, as always.
|
||||
void* server = readable(self, 4) ? ptr_at(self, 0) : nullptr;
|
||||
g_assign.entry = observe_entry(server);
|
||||
g_assign.detectors = ptr_vector_size(det);
|
||||
g_assign.contacts = ptr_vector_size(con);
|
||||
|
||||
out.push_back(tv::ptr(self).named("ctx"));
|
||||
out.push_back(tv::ptr(server).named("server"));
|
||||
out.push_back(tv::ptr(buckets).named("out_buckets"));
|
||||
out.push_back(tv::i32(g_assign.detectors).named("detectors"));
|
||||
out.push_back(tv::i32(g_assign.contacts).named("contacts"));
|
||||
// Lane I's worst case: one inlined NextFloat per (contact, detector) trial, drawn BEFORE the
|
||||
// accept test, so an unteched detector (threshold 0.0f) still costs the word. Recorded as a
|
||||
// bound, not a prediction -- the accept short-circuits the inner loop, so the measured cost
|
||||
// should be at most this.
|
||||
const std::int32_t bound = (g_assign.detectors > 0 && g_assign.contacts > 0)
|
||||
? g_assign.detectors * g_assign.contacts
|
||||
: -1;
|
||||
out.push_back(tv::i32(bound).named("max_trials"));
|
||||
push_rng_args(out, g_assign.entry);
|
||||
}
|
||||
|
||||
void EncounterDetectAssignContactsHook::regions(std::vector<trace::Region>& out, void*, void*,
|
||||
void*, void*) {
|
||||
push_rng_region(out, g_assign.entry.rng);
|
||||
}
|
||||
|
||||
EncounterDetectAssignContactsHook::Args EncounterDetectAssignContactsHook::rebind(
|
||||
trace::Scratch&, void* self, void* buckets, void* det, void* con) {
|
||||
return Args(self, buckets, det, con);
|
||||
}
|
||||
|
||||
void EncounterDetectAssignContactsHook::ours(void* self, void* buckets, void* det, void* con) {
|
||||
using H = trace::Hook<EncounterDetectAssignContactsHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("EncounterDetect::AssignContacts");
|
||||
if (H::original) H::original(self, buckets, det, con);
|
||||
}
|
||||
}
|
||||
|
||||
void EncounterDetectAssignContactsHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("the contact-to-detector assignment itself, and the two-pass outer loop",
|
||||
trace::Risk::Medium,
|
||||
"this hook exists because the draw here is INLINED and therefore invisible to "
|
||||
"every call-graph sweep and to the entry-point detours -- it is the one site in "
|
||||
"ProcessTurn's closure that neither instrument can see. It measures the word "
|
||||
"cost of the whole call and models nothing",
|
||||
"region:rng; arg:detectors/contacts/max_trials bound the expected count");
|
||||
c.unmodelled("the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the "
|
||||
"accept test short-circuits the inner loop",
|
||||
trace::Risk::Low,
|
||||
"so the measured cost is between |contacts| and |contacts| x |detectors| and the "
|
||||
"exact number depends on tech state this hook does not read",
|
||||
"arg:max_trials is the upper bound only");
|
||||
}
|
||||
|
||||
// ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------
|
||||
|
||||
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) {
|
||||
g_nodespace.entry = observe_entry(self);
|
||||
out.push_back(tv::ptr(self).named("server"));
|
||||
push_server_args(out, self);
|
||||
push_rng_args(out, g_nodespace.entry);
|
||||
}
|
||||
|
||||
void ProcessNodeSpaceTravelHook::regions(std::vector<trace::Region>& out, void*) {
|
||||
push_rng_region(out, g_nodespace.entry.rng);
|
||||
}
|
||||
|
||||
ProcessNodeSpaceTravelHook::Args ProcessNodeSpaceTravelHook::rebind(trace::Scratch&, void* self) {
|
||||
return Args(self);
|
||||
}
|
||||
|
||||
void ProcessNodeSpaceTravelHook::ours(void* self) {
|
||||
using H = trace::Hook<ProcessNodeSpaceTravelHook>;
|
||||
if (H::mode == trace::Mode::Replace) {
|
||||
refuse_replace("StrategyServer::ProcessNodeSpaceTravel");
|
||||
if (H::original) H::original(self);
|
||||
}
|
||||
}
|
||||
|
||||
void ProcessNodeSpaceTravelHook::coverage(trace::Coverage& c) {
|
||||
tail_rng_common_coverage(c);
|
||||
c.unmodelled("2945 bytes of node-space movement, entirely unmodelled and never swept for "
|
||||
"RNG by any lane",
|
||||
trace::Risk::Medium,
|
||||
"it is hooked here only because it runs TWICE a turn -- ProcessTurn phase 7 and "
|
||||
"tail phase 10 -- so a draw inside it would be double-counted by anyone "
|
||||
"modelling it once. The record says whether it draws at all",
|
||||
"region:rng");
|
||||
}
|
||||
|
||||
} // namespace shim::hooks
|
||||
190
src/shim/hooks/tail_rng.h
Normal file
190
src/shim/hooks/tail_rng.h
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// Lane Z — the per-turn RNG ledger. Six nested hooks that measure how many words the strategic
|
||||
// generator consumes in one End Turn and attribute them to a phase.
|
||||
//
|
||||
// WHY. `sots-re/findings/control-flow/combat-done-tail.md` §3 found two draw sites in
|
||||
// `StrategyServer::OnAllCombatDone_Tail` that nothing in the repo models -- one `NextFloat` per
|
||||
// expired node line (phase 11, instruction-verified at 0x007ae095) and whatever the combat
|
||||
// resolver spends under phase 6 -- and **both run before the autosave**. A reimplementation that
|
||||
// reproduces `StrategyServer::ProcessTurn` and `ServerPlayer::ProcessTurn` perfectly still
|
||||
// diverges the first turn a node line expires, because the generator's state is part of the
|
||||
// saved state. The defect is invisible on our current saves, which is why it survived.
|
||||
//
|
||||
// WHAT IS MEASURED, not asserted: every hook declares the live `Mars::RNG` object at
|
||||
// `StrategyServer+0x16c` as a Result region whose `describe` reports an ABSOLUTE WORD POSITION
|
||||
// (see rng_ledger.h). The delta between a record's `side.rng.before.words` and
|
||||
// `side.rng.after.words` is the exact number of words that call consumed, whatever spent them
|
||||
// and whether or not anyone hooked it. Because the hooks nest, the subtotals attribute:
|
||||
//
|
||||
// Autosave(endTurn=1) ....................... the pre-turn state marker
|
||||
// StrategyServer::ProcessTurn ............. the half the repo already models
|
||||
// (combat: RunCombatRound / the combat server -- HOOKED BY NOBODY)
|
||||
// StrategyServer::OnAllCombatDone_Tail .... the half nothing models
|
||||
// ApplyEncounterResult (x encounters) ... phase 6, the combat-resolver subtree
|
||||
// NodeLineDecay ......................... phase 11, one word per expired node line
|
||||
// ProcessNodeSpaceTravel .................. runs TWICE a turn; never swept for draws
|
||||
// Autosave(endTurn=0) ....................... the post-turn state marker
|
||||
//
|
||||
// and the residual -- bracket total minus the attributed subtotals -- is the part of a turn a
|
||||
// reimplementation would silently miss. That number is the deliverable.
|
||||
//
|
||||
// THE BASE. `this` for the four StrategyServer hooks is **S**, the frame `ProcessTurn` uses,
|
||||
// not `S+4`. Every `StrategyServer_off_*` in the generated header is an `S+4` offset EXCEPT
|
||||
// `StrategyServer_off_RNG`, which is already the S frame. This file adds 4 where it must and
|
||||
// says so at each site; reading the wrong base is a mistake this campaign has already paid for.
|
||||
//
|
||||
// REPLACE MODE IS REFUSED EVERYWHERE. These hooks model RNG consumption, nothing else; running
|
||||
// `ours` instead of a turn driver would produce a state no code path produces.
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include "shim/trace/hook.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
// Shared coverage note: what none of these hooks check. Each descriptor adds its own on top.
|
||||
void tail_rng_common_coverage(trace::Coverage& c);
|
||||
|
||||
// ---- the two absolute markers ---------------------------------------------------------------
|
||||
//
|
||||
// `StrategyHost::Autosave(outName, endTurn)` 0x00895210. Two call sites in the image:
|
||||
// `SendEndTurn` 0x007839d7 pushes `1` and writes the PRE-turn state, the `SNMAllCombatDone`
|
||||
// handler 0x00784e59 pushes `0` and writes the POST-turn state. The words between them are the
|
||||
// turn's whole RNG cost as the two save files see it.
|
||||
//
|
||||
// Two corrections to lane K §6.1, both read from the bytes: the epilogue is **`ret 8`**, not
|
||||
// `ret 4`, and the function **returns the `std::string*` in EAX** (the MSVC named-return slot),
|
||||
// which is why `Ret` is `void*` here -- declaring it `void` would drop EAX on the floor at
|
||||
// both call sites. `this` is not passed by the caller at all: both sites hardcode
|
||||
// `mov ecx,0xb29f98`.
|
||||
struct StrategyHostAutosaveHook {
|
||||
static constexpr const char* name = "Game::StrategyHost::Autosave";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void*; // the NRV std::string*, returned in EAX
|
||||
using Args = std::tuple<void*, void*, bool>; // this (the global), std::string* out, endTurn
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, void* name_out, bool end_turn);
|
||||
static trace::Tv describe_ret(void* r);
|
||||
static void regions(std::vector<trace::Region>& out, void* self, void* name_out, bool end_turn);
|
||||
static Args rebind(trace::Scratch& s, void* self, void* name_out, bool end_turn);
|
||||
static void* ours(void* self, void* name_out, bool end_turn);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// ---- the two turn drivers -------------------------------------------------------------------
|
||||
|
||||
// `StrategyServer::ProcessTurn(float dt)` 0x007dc6c0, `ret 4` (lane T §1).
|
||||
struct StrategyServerProcessTurnHook {
|
||||
static constexpr const char* name = "Game::StrategyServer::ProcessTurn";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*, float>; // this (StrategyServer* S), dt
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, float dt);
|
||||
static void regions(std::vector<trace::Region>& out, void* self, float dt);
|
||||
static Args rebind(trace::Scratch& s, void* self, float dt);
|
||||
static void ours(void* self, float dt);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// `StrategyServer::OnAllCombatDone_Tail(std::vector<EncounterResults>*)` 0x007d92a0, `ret 4`
|
||||
// (lane K). Exactly one caller: the `SNMAllCombatDone` case of `StrategyHost::OnMessage`.
|
||||
struct OnAllCombatDoneTailHook {
|
||||
static constexpr const char* name = "Game::StrategyServer::OnAllCombatDone_Tail";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*, void*>; // this (StrategyServer* S), results vector
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, void* results);
|
||||
static void regions(std::vector<trace::Region>& out, void* self, void* results);
|
||||
static Args rebind(trace::Scratch& s, void* self, void* results);
|
||||
static void ours(void* self, void* results);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// ---- the two phases inside the tail that can draw -------------------------------------------
|
||||
|
||||
// Tail phase 6: `StrategyServer::ApplyEncounterResult(Encounter*, EncounterResults*)`
|
||||
// 0x007d8920, `ret 8`. The gateway to the unread combat resolver `0x007d5af0`.
|
||||
struct ApplyEncounterResultHook {
|
||||
static constexpr const char* name = "Game::StrategyServer::ApplyEncounterResult";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*, void*, void*>; // this (S), Encounter*, EncounterResults*
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, void* enc, void* res);
|
||||
static void regions(std::vector<trace::Region>& out, void* self, void* enc, void* res);
|
||||
static Args rebind(trace::Scratch& s, void* self, void* enc, void* res);
|
||||
static void ours(void* self, void* enc, void* res);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// Tail phase 11: node-line decay, 0x007ae010. One `Mars::RNG::Chance(0.5f)` -- i.e. exactly one
|
||||
// word -- per EXPIRED node line, instruction-verified at 0x007ae095. This is the only hook here
|
||||
// that carries a model of its own draw count.
|
||||
struct NodeLineDecayHook {
|
||||
static constexpr const char* name = "Game::StrategyServer::NodeLineDecay";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*>; // this (StrategyServer* S)
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self);
|
||||
static void regions(std::vector<trace::Region>& out, void* self);
|
||||
static Args rebind(trace::Scratch& s, void* self);
|
||||
static void ours(void* self);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// `EncounterDetect_AssignContacts` 0x007aa240, `ret 0xc` (lane I). **The only game function with an
|
||||
// inlined MT draw in `StrategyServer::ProcessTurn`'s closure** — one `NextFloat` per (contact,
|
||||
// detector) trial, drawn BEFORE the accept test, so it draws even when the threshold is 0.0f and
|
||||
// nothing is ever assigned. It leaves no call-graph edge except a bare `RNG_Twist`, which is the
|
||||
// site method rule 16 was written for. Reached only through `EncounterDetect_Run` 0x007cb080 from
|
||||
// `StrategyServer::DetectEncounters`, the last phase of `ProcessTurn`.
|
||||
//
|
||||
// `|detectors|` and `|contacts|` are recorded as arguments so lane I's expected cost
|
||||
// (`|contacts| x |detectors|` trials at worst) becomes checkable against the measured word count
|
||||
// rather than asserted.
|
||||
struct EncounterDetectAssignContactsHook {
|
||||
static constexpr const char* name = "Game::EncounterDetect::AssignContacts";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*, void*, void*, void*>; // ctx, outBuckets, detectors, contacts
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self, void* buckets, void* det,
|
||||
void* con);
|
||||
static void regions(std::vector<trace::Region>& out, void* self, void* buckets, void* det,
|
||||
void* con);
|
||||
static Args rebind(trace::Scratch& s, void* self, void* buckets, void* det, void* con);
|
||||
static void ours(void* self, void* buckets, void* det, void* con);
|
||||
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||
static void coverage(trace::Coverage& c);
|
||||
};
|
||||
|
||||
// `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10)
|
||||
// and never swept for draws by any lane.
|
||||
struct ProcessNodeSpaceTravelHook {
|
||||
static constexpr const char* name = "Game::StrategyServer::ProcessNodeSpaceTravel";
|
||||
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||
using Ret = void;
|
||||
using Args = std::tuple<void*>; // this (StrategyServer* S)
|
||||
|
||||
static void describe_args(std::vector<trace::Tv>& out, void* self);
|
||||
static void regions(std::vector<trace::Region>& out, void* self);
|
||||
static Args rebind(trace::Scratch& s, void* self);
|
||||
static void ours(void* self);
|
||||
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));
|
||||
|
||||
} // namespace shim::hooks
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
#include "shim/hooks/fleet_movement.h"
|
||||
#include "shim/hooks/global_consts.h"
|
||||
#include "shim/hooks/research.h"
|
||||
#include "shim/hooks/draw_sites.h"
|
||||
#include "shim/hooks/tail_rng.h"
|
||||
#include "shim/hooks/tech_effects.h"
|
||||
#include "shim/trace/hook.h"
|
||||
#include "shim/trace/selftest.h"
|
||||
|
|
@ -166,6 +168,14 @@ using ColonyTurnHook = shim::trace::Hook<shim::hooks::ServerSystemProcessTurnHoo
|
|||
using PlayerTurnHook = shim::trace::Hook<shim::hooks::ServerPlayerProcessTurnHook>;
|
||||
using MoveFleetHook = shim::trace::Hook<shim::hooks::StrategyServerMoveFleetHook>;
|
||||
using FleetMovementHook = shim::trace::Hook<shim::hooks::StrategyServerProcessFleetMovementHook>;
|
||||
// Lane Z: the per-turn RNG ledger (docs/Z-tail-rng.md).
|
||||
using AutosaveHook = shim::trace::Hook<shim::hooks::StrategyHostAutosaveHook>;
|
||||
using ServerTurnHook = shim::trace::Hook<shim::hooks::StrategyServerProcessTurnHook>;
|
||||
using CombatDoneTailHook = shim::trace::Hook<shim::hooks::OnAllCombatDoneTailHook>;
|
||||
using ApplyEncounterHook = shim::trace::Hook<shim::hooks::ApplyEncounterResultHook>;
|
||||
using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>;
|
||||
using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>;
|
||||
using AssignContactsHook = shim::trace::Hook<shim::hooks::EncounterDetectAssignContactsHook>;
|
||||
|
||||
void InstallHooks(shim::trace::Tracer& tracer) {
|
||||
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
|
||||
|
|
@ -218,6 +228,41 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
|||
InstallTemplateHook<shim::hooks::StrategyServerMoveFleetHook>(tracer, exeBase, sots::addr::StrategyServer_MoveFleet);
|
||||
InstallTemplateHook<shim::hooks::StrategyServerProcessFleetMovementHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessFleetMovement);
|
||||
|
||||
// Lane Z: the per-turn RNG ledger. Six nested hooks bracketing one End Turn between the two
|
||||
// autosaves; every one declares the strategic generator and nothing else. Installed BEFORE
|
||||
// the fpu module because both want StrategyServer::ProcessTurn and MinHook allows one hook
|
||||
// per target -- `fpu.sample_turn=off` is the config that hands it over cleanly, and if it is
|
||||
// left on the fpu sampler's MH_CreateHook is what fails and says so.
|
||||
shim::hooks::init_tail_rng(exeBase, &ShimLogLine);
|
||||
InstallTemplateHook<shim::hooks::StrategyHostAutosaveHook>(tracer, exeBase, sots::addr::StrategyHost_Autosave);
|
||||
InstallTemplateHook<shim::hooks::StrategyServerProcessTurnHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessTurn);
|
||||
InstallTemplateHook<shim::hooks::OnAllCombatDoneTailHook>(tracer, exeBase, sots::addr::StrategyServer_OnAllCombatDone_Tail);
|
||||
InstallTemplateHook<shim::hooks::ApplyEncounterResultHook>(tracer, exeBase, sots::addr::StrategyServer_ApplyEncounterResult);
|
||||
InstallTemplateHook<shim::hooks::NodeLineDecayHook>(tracer, exeBase, sots::addr::StrategyServer_NodeLineDecay);
|
||||
InstallTemplateHook<shim::hooks::ProcessNodeSpaceTravelHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessNodeSpaceTravel);
|
||||
// Lane I's one inlined-draw site inside ProcessTurn's closure. It leaves no call-graph edge, so
|
||||
// neither a sweep nor the entry-point detours below can see it; only a boundary hook can.
|
||||
InstallTemplateHook<shim::hooks::EncounterDetectAssignContactsHook>(tracer, exeBase, sots::addr::EncounterDetect_AssignContacts);
|
||||
|
||||
// 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 --
|
||||
// they write no record and take no snapshot, just two 4-byte reads of `left` -- because
|
||||
// NextFloat alone has 109 call sites and a record per draw would drown the log. The table is
|
||||
// emitted once per turn on the post-turn autosave, where it can be reconciled against the
|
||||
// bracket total the region ledger measured independently.
|
||||
shim::hooks::init_draw_sites(exeBase);
|
||||
{
|
||||
std::size_t nsites = 0;
|
||||
const shim::hooks::DrawSiteHook* sites = shim::hooks::draw_site_hooks(&nsites);
|
||||
for (std::size_t i = 0; i < nsites; ++i) {
|
||||
void* t = reinterpret_cast<void*>(exeBase + sites[i].rva);
|
||||
MH_STATUS s1 = MH_CreateHook(t, sites[i].detour, sites[i].trampoline);
|
||||
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(t) : s1;
|
||||
Log("drawsite: %s rva=0x%08x -> va=%p create=%s enable=%s", sites[i].name, sites[i].rva,
|
||||
t, MH_StatusToString(s1), MH_StatusToString(s2));
|
||||
}
|
||||
}
|
||||
|
||||
// Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler.
|
||||
// Installed last so it is nowhere near the template hooks it is meant to measure.
|
||||
shim::fpu::install(exeBase, &ShimLogLine);
|
||||
|
|
@ -268,6 +313,13 @@ void Shim_Init(HMODULE self) {
|
|||
PlayerTurnHook::register_policy(tracer);
|
||||
MoveFleetHook::register_policy(tracer);
|
||||
FleetMovementHook::register_policy(tracer);
|
||||
AutosaveHook::register_policy(tracer);
|
||||
ServerTurnHook::register_policy(tracer);
|
||||
CombatDoneTailHook::register_policy(tracer);
|
||||
ApplyEncounterHook::register_policy(tracer);
|
||||
NodeDecayHook::register_policy(tracer);
|
||||
NodeSpaceHook::register_policy(tracer);
|
||||
AssignContactsHook::register_policy(tracer);
|
||||
// A hook that never stated what it does not check is a defect, not a detail: say so in
|
||||
// shim.log as well as in the trace's meta line (docs/harness-audit.md).
|
||||
for (const std::string& h : tracer.unstated_hooks())
|
||||
|
|
|
|||
41
src/shim/shim.cfg.zledger
Normal file
41
src/shim/shim.cfg.zledger
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Lane Z -- the per-turn RNG ledger, TRACE. Copy over C:\SOTS\shim.cfg.
|
||||
#
|
||||
# Every other hook is off so the log holds nothing but the ledger: six records per End Turn
|
||||
# instead of ~40 per player. Read `side.rng.before.words` and `side.rng.after.words` on each
|
||||
# record -- the difference is the number of 32-bit words the strategic generator consumed inside
|
||||
# that call. Because the hooks nest, subtracting the inner subtotals from the outer one
|
||||
# attributes the turn.
|
||||
#
|
||||
# fpu.sample_turn=off is REQUIRED: the fpu module samples the control word at
|
||||
# StrategyServer::ProcessTurn, MinHook allows one hook per target, and this config needs that
|
||||
# address for the ledger. With it left on, shim.log records which of the two failed to install.
|
||||
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::ServerPlayer::ProcessTurn=off
|
||||
hook.Game::StrategyServer::MoveFleet=off
|
||||
hook.Game::StrategyServer::ProcessFleetMovement=off
|
||||
|
||||
hook.Game::StrategyHost::Autosave=trace
|
||||
hook.Game::StrategyServer::ProcessTurn=trace
|
||||
hook.Game::StrategyServer::OnAllCombatDone_Tail=trace
|
||||
hook.Game::StrategyServer::ApplyEncounterResult=trace
|
||||
hook.Game::StrategyServer::NodeLineDecay=trace
|
||||
hook.Game::StrategyServer::ProcessNodeSpaceTravel=trace
|
||||
|
||||
fpu.sample_turn=off
|
||||
fpu.sample_ticks=off
|
||||
|
||||
# The generator is 0x9cc bytes and every record carries it twice. Inlining it as hex would make
|
||||
# each record ~5 kB of state nobody reads -- the `describe` already reduces it to
|
||||
# {left, index, block, words, block_hash}, and the raw bytes are only ever a sha256 line.
|
||||
trace.inline_max=64
|
||||
trace.path=C:\SOTS\shim.trace.jsonl
|
||||
trace.flush=always
|
||||
hook.Game::EncounterDetect::AssignContacts=trace
|
||||
6
tests/shim_rng_ledger/CMakeLists.txt
Normal file
6
tests/shim_rng_ledger/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Lane Z: the RNG word ledger (absolute generator position recovered from state alone).
|
||||
add_executable(shim_rng_ledger_unit_tests unit_tests.cpp)
|
||||
target_link_libraries(shim_rng_ledger_unit_tests PRIVATE shim_rng_ledger)
|
||||
target_include_directories(shim_rng_ledger_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim)
|
||||
target_compile_options(shim_rng_ledger_unit_tests PRIVATE -Wall -Wextra -Werror)
|
||||
add_test(NAME shim_rng_ledger_unit COMMAND shim_rng_ledger_unit_tests)
|
||||
182
tests/shim_rng_ledger/unit_tests.cpp
Normal file
182
tests/shim_rng_ledger/unit_tests.cpp
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Lane Z: the RNG word ledger.
|
||||
//
|
||||
// The whole point of the instrument is that a word delta is exact whatever spent the words, so
|
||||
// the tests drive a real MT19937 by known amounts and check the ledger's arithmetic against the
|
||||
// count -- including across block boundaries, across a NextInt rejection loop, and when the
|
||||
// observations arrive out of chronological order (which is how Hook<> renders nested calls).
|
||||
#include "shim/hooks/rng_ledger.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "check.h"
|
||||
#include "mars/rng/mt19937.h"
|
||||
|
||||
using shim::hooks::RngLedger;
|
||||
using shim::hooks::RngPos;
|
||||
using mars::rng::MT19937;
|
||||
|
||||
namespace {
|
||||
|
||||
RngPos observe(RngLedger& L, const MT19937& g) { return L.observe(g.state(), g.left()); }
|
||||
|
||||
void test_delta_within_one_block() {
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(12345);
|
||||
const RngPos a = observe(L, g);
|
||||
CHECK(a.known);
|
||||
for (int i = 0; i < 100; ++i) (void)g.next_u32();
|
||||
const RngPos b = observe(L, g);
|
||||
CHECK(b.known);
|
||||
CHECK_EQ(b.words - a.words, 100LL);
|
||||
CHECK_EQ(b.block, a.block);
|
||||
}
|
||||
|
||||
void test_delta_across_blocks() {
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(999);
|
||||
const RngPos a = observe(L, g);
|
||||
// Well past a block boundary, and not a multiple of 624 -- an off-by-one in the position
|
||||
// formula would survive a multiple.
|
||||
const int n = 624 * 3 + 17;
|
||||
for (int i = 0; i < n; ++i) (void)g.next_u32();
|
||||
const RngPos b = observe(L, g);
|
||||
CHECK(b.known);
|
||||
CHECK_EQ(b.words - a.words, static_cast<long long>(n));
|
||||
CHECK_EQ(b.block - a.block, 3);
|
||||
}
|
||||
|
||||
void test_exhausted_block_boundary() {
|
||||
// left == 0 is a real state: the block is spent and the next draw twists. Position must be
|
||||
// continuous across it, or every ledger entry straddling a boundary is off by 624.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(7);
|
||||
const RngPos a = observe(L, g);
|
||||
for (int i = 0; i < 624; ++i) (void)g.next_u32();
|
||||
const RngPos b = observe(L, g);
|
||||
CHECK_EQ(b.left, 0);
|
||||
CHECK_EQ(b.block, a.block); // still the same block; the twist has not happened yet
|
||||
CHECK_EQ(b.words - a.words, 624LL);
|
||||
(void)g.next_u32();
|
||||
const RngPos c = observe(L, g);
|
||||
CHECK_EQ(c.block, a.block + 1);
|
||||
CHECK_EQ(c.words - a.words, 625LL);
|
||||
}
|
||||
|
||||
void test_rejection_loop_counts_words_not_draws() {
|
||||
// next_int_inclusive can spend several words on one call. The ledger must report the words.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(4242);
|
||||
MT19937 shadow(4242);
|
||||
const RngPos a = observe(L, g);
|
||||
(void)g.next_int_inclusive(100);
|
||||
const RngPos b = observe(L, g);
|
||||
long long words = 0;
|
||||
for (;;) {
|
||||
const std::uint32_t r = shadow.next_u32() & MT19937::cover_mask(100);
|
||||
++words;
|
||||
if (r <= 100) break;
|
||||
}
|
||||
CHECK(words >= 1);
|
||||
CHECK_EQ(b.words - a.words, words);
|
||||
}
|
||||
|
||||
void test_out_of_order_observation() {
|
||||
// Hook<> renders a nested call's snapshots before the outer call's. The outer `before`
|
||||
// state is therefore observed twice: once at entry (in order) and once at render time,
|
||||
// by which point the chain has moved on. The second lookup must still resolve.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(31337);
|
||||
std::uint32_t outer_block[624];
|
||||
std::memcpy(outer_block, g.state(), sizeof outer_block);
|
||||
const int outer_left = g.left();
|
||||
const RngPos entry = L.observe(outer_block, outer_left); // observed at entry, in order
|
||||
CHECK(entry.known);
|
||||
|
||||
for (int i = 0; i < 624 * 2 + 5; ++i) (void)g.next_u32();
|
||||
const RngPos inner_after = observe(L, g); // rendered first
|
||||
CHECK(inner_after.known);
|
||||
for (int i = 0; i < 30; ++i) (void)g.next_u32();
|
||||
const RngPos outer_after = observe(L, g);
|
||||
CHECK(outer_after.known);
|
||||
|
||||
// ... and now the stale `before` snapshot is rendered.
|
||||
const RngPos rendered = L.observe(outer_block, outer_left);
|
||||
CHECK(rendered.known);
|
||||
CHECK_EQ(rendered.words, entry.words);
|
||||
CHECK_EQ(outer_after.words - rendered.words, static_cast<long long>(624 * 2 + 35));
|
||||
}
|
||||
|
||||
void test_backwards_without_entry_observation_is_unknown() {
|
||||
// The honest failure: a state behind the anchor cannot be positioned, and the ledger says
|
||||
// so rather than inventing a number. This is why hooks observe at entry.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(555);
|
||||
std::uint32_t early[624];
|
||||
std::memcpy(early, g.state(), sizeof early);
|
||||
const int early_left = g.left();
|
||||
for (int i = 0; i < 624 * 4; ++i) (void)g.next_u32();
|
||||
const RngPos anchor = observe(L, g); // the chain starts HERE
|
||||
CHECK(anchor.known);
|
||||
const RngPos behind = L.observe(early, early_left);
|
||||
CHECK(!behind.known);
|
||||
CHECK(L.misses() >= 1);
|
||||
}
|
||||
|
||||
void test_second_generator_reads_unknown() {
|
||||
// One ledger, two independent generators: the second one's blocks are not on the first
|
||||
// one's chain. That must read unknown, because it is the signal that the "one strategic
|
||||
// generator" assumption failed.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 a(1);
|
||||
MT19937 b(2);
|
||||
CHECK(observe(L, a).known);
|
||||
CHECK(!observe(L, b).known);
|
||||
}
|
||||
|
||||
void test_bad_left_is_rejected() {
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(8);
|
||||
CHECK(!L.observe(g.state(), -1).known);
|
||||
CHECK(!L.observe(g.state(), 625).known);
|
||||
CHECK(!L.observe(nullptr, 100).known);
|
||||
}
|
||||
|
||||
void test_observe_object_layout() {
|
||||
// The live-memory path: vptr, mt[624] at +4, next at +0x9c4, left at +0x9c8 (RNG_size
|
||||
// 0x9cc). A short region must be refused rather than read past its end.
|
||||
RngLedger L;
|
||||
L.reset();
|
||||
MT19937 g(2024);
|
||||
std::vector<char> obj(0x9cc, 0);
|
||||
std::memcpy(obj.data() + 4, g.state(), 624 * 4);
|
||||
const std::int32_t left = g.left();
|
||||
std::memcpy(obj.data() + 0x9c8, &left, 4);
|
||||
const RngPos a = L.observe_object(obj.data(), obj.size());
|
||||
CHECK(a.known);
|
||||
CHECK_EQ(a.left, left);
|
||||
CHECK(!L.observe_object(obj.data(), 0x100).known);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_delta_within_one_block();
|
||||
test_delta_across_blocks();
|
||||
test_exhausted_block_boundary();
|
||||
test_rejection_loop_counts_words_not_draws();
|
||||
test_out_of_order_observation();
|
||||
test_backwards_without_entry_observation_is_unknown();
|
||||
test_second_generator_reads_unknown();
|
||||
test_bad_left_is_rejected();
|
||||
test_observe_object_layout();
|
||||
return simtest::finish("shim_rng_ledger_unit");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue