lane X: x86 displacement xref scanner; pin sizeof(ObservedTech) and its append site

Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC
idiom for taking a member's address -- is invisible to find-constant-uses. That
blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the
~1,600 classes still to map.

tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes,
ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from
Ghidra's 41,089 function starts so decodes begin on real instruction boundaries.
2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70
desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms
(mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets.
Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local
cache in dumps/ rather than hammering CT111.

Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage
(0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in
ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known
offsets.

sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic
divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and
0x007b735b, and the search stride add edi,0x2c at 0x007ba257.

Append site: RecordObservedTech+0xdf (0x007ba27f) --
  lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320
RecordObservedTech (0x007ba1a0) is a direct callee of OnTechResearched and
de-duplicates by tech name before appending. The realloc through 0x007b5820 is
why lane R's guard saw all three vector words move. Element carries a vptr
(RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the
four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read
does NOT determine, and is not guessed.

Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64
(its only +0x64 accesses are loads off a different base), and ProcessResearch's
int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with
lane R's guard seeing 0 changes in 4284 calls.

Honest limits are recorded in the note and the board: this is a recall tool, not
an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x
search-space cut that still needs one call-graph check. Cohort ranking must not
be used as a hard filter -- it would have discarded the correct answer here.

Ghidra writeback: labels + plate comments on RecordObservedTech,
vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign,
vector_44B_grow, vftable_ObservedTech.
This commit is contained in:
alex 2026-09-08 04:44:18 -04:00
parent 1a58bcdf90
commit 460cb7ca2b
8 changed files with 1235 additions and 8 deletions

View file

@ -68,7 +68,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| RollResearchEvent draw (behavioural) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane R.** On turn 6 (`IND_TRKSTL`, tech 10094) `research_roll_pending` was true going in; the original drew exactly one word (rng `left` 375->374, `next_index` 249->250) and cleared the flag, and `ours` reproduced both bit-for-bit on the scratch generator. 0 divergences on that call. Needs 5 End Turns from ref-turn2 to reach - the reference turn has no completion at all |
| golden-trace recapture (post-guards) | verify | verified | high | 100% | 2026-09-08 | DONE on the live game, build `recap-7584bad-20260908T0615Z` (NO source change needed - the audit's machinery did all of it). **B3 ProcessResearch: the defect is VISIBLE** - `side.events.after.v.next_id orig=4 ours=3`, one divergent call of 3 on the reference turn and its ONLY divergent field; bit-for-bit the `EvNxID 4->3` that previously needed a 609 KB save diff. Over 5 turns 15 calls / 3 diverged, **RNG matched 15/15**, and the two completion calls miss TWO event ids each. **B1 ComputeBudget: verdict held** - 4284 compared, 0 diverged, exit 0, `budget_object` guard caught 0 undeclared writes (Budget+0x64 never changed value). **MoveFleet: 8 of 45 diverge by 1 ULP of position** (new; see its own row). First guarded captures for OnTechResearched (2 calls), ServerSystem::ProcessTurn (140 calls) and MoveFleet (45). Guards mapped SetResearched live (ConMod[0..2], OutMod, PopMod, ResTNm, TechTree+0x20 order counter) and found an UNDECLARED `vector<ObservedTech> otch` append at player+0x274. Oracle held on every run's first End Turn. Report `findings/subsystems/golden-trace-recapture.md`; engine `docs/R-recapture.md`; traces `verify/traces/recap-*`, reports `verify/results/compare/recap-*`. sots-engine branch `wip/recapture` e50d5e5 (merged with main 82ef52f; ctest 32/32, clean-room OK) |
| MoveFleet position rounding (1 ULP) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane M.** Mechanism read off the instruction stream, not fitted: the engine's `Mars_Vec3_Normalize` (0x00422520, 123 callers) narrows to float32 FOUR times - `sumsq = f32(x*x+y*y+z*z)` (products/adds stay in 53-bit regs, only the SUM is stored), `len = f32(sqrt(sumsq))`, `inv = f32(1.0/len)` a RECIPROCAL that is MULTIPLIED through rather than three divides, and `dir.c = f32(delta.c*inv)`; and MoveFleet stores each `dest.c - pos.c` BACK TO A FLOAT32 SLOT before calling it, and takes the leg distance from that same call's return value. `ours` did all of it in double. The position tail (`f32(pos + f32(dir*move))`) was already right, which is exactly why the error was a constant ABSOLUTE ~1.2e-7. Confirmed OFFLINE first (an arrival copies the destination verbatim, so calls 115/155 hand you fleet 34's and fleet 50's exact float32 destinations = 8 fully determined legs; the 5-narrowing model reproduces the ORIGINAL bit-for-bit on all 8, the old double model reproduces `ours` on the 3 divergent ones), then LIVE: control run 8/45 diverged exit 1, fixed run **0/45 diverged exit 0**, with identical args, identical pos.before and identical ORIGINAL pos.after on all 45 calls. Report `findings/subsystems/movefleet-position-rounding.md`, engine `docs/M-movefleet.md`, branch `wip/movefleet` 2aa8cba |
| undeclared ObservedTech append | verify | backlog | — | 0% | 2026-09-08 | NEW (lane R). A tech completion grows `vector<ObservedTech> otch` at ServerPlayer+0x274 (all three vector words move = a realloc). Seen as an undeclared write by BOTH the ProcessResearch `player` guard and the OnTechResearched `player` guard. It is serialized ServerPlayer state and it is in NO coverage note anywhere - a third list append in the same neighbourhood as the event list. B3's replace oracle never saw it because turn 1 of ref-turn2 has no completion |
| undeclared ObservedTech append | verify | backlog | — | 0% | 2026-09-08 | NEW (lane R). A tech completion grows `vector<ObservedTech> otch` at ServerPlayer+0x274 (all three vector words move = a realloc). Seen as an undeclared write by BOTH the ProcessResearch `player` guard and the OnTechResearched `player` guard. It is serialized ServerPlayer state and it is in NO coverage note anywhere - a third list append in the same neighbourhood as the event list. B3's replace oracle never saw it because turn 1 of ref-turn2 has no completion | **APPEND SITE NOW NAMED (lane X): `RecordObservedTech` 0x007ba1a0, called from OnTechResearched; stride 0x2c.** Still needs a declared region + a model in ours.
| unnamed offsets from guard hits | verify | backlog | — | 0% | 2026-09-08 | NEW (lane R). Three spans the guards report every run and no addresses.json entry names: **ServerSystem+0xd8 (1 B)** and **ServerSystem+0x238 (4 B)** - written by the AI home system on every colony turn, alongside the fleet-vector growth; **StarFleet+0xdc (1 B)** - written on every moving MoveFleet call, just past Speed (FPsp2 @0xd8). Cheap wins for the contract |
| waypoint types 2-5 have no coverage | verify | backlog | — | 0% | 2026-09-08 | NEW (lane M, promoted from a coverage line to its own row because it is now the biggest gap in `MoveFleet`). Types 2 (node line), 3 (node route), 4 (gate teleport) and 5 (probabilistic jump) have NEVER fired in any capture, and the node-line step is WRONG BY CONSTRUCTION - `sim::NodeLineStep` and `sim::BuildStutterSegments` are written and host-tested but are NOT wired into the hook, which steps every waypoint type as `speed x dt`. **`ref-turn2` structurally cannot exercise them**: lane M held the VM and tried. The only mover in that save is the AI, which travels straight runs; the player that would travel a node line has `DE 00 CR 00 DN 00` at its home system (screenshot `verify/results/shim/mf-human-home-no-ships.png`), so Move/Manage Fleets are greyed out on every turn. Needs a ship built over several turns, or - much cheaper - a PURPOSE-BUILT SAVE with a fleet already in orbit next to a node line. Same save would unblock the gate-traffic and probabilistic-jump rows. Also owed on that path: `sim::Distance` is still plain double, and `Mars_Vec3_Length` (0x004224b0) says every vector length in the engine is float32-narrowed twice, so the stutter geometry is probably 1 ULP out the same way the position update was - deliberately left alone by lane M because there is no behavioural evidence to correct it against |
| ref-turn2 has no tech completion | meta | verified | high | 100% | 2026-09-08 | TRAP for anyone writing a workload (lane R). The documented one-End-Turn recipe produces **zero** `OnTechResearched` calls - an empty log that still passes. It takes 5 End Turns (to turn 7) to reach a completion. Also: only the FIRST End Turn is reproducible - it hashed to the oracle on all four runs and its research calls reproduce docs/B3.md exactly, but from turn 4 the AI picks a different research target than B3 recorded while the point totals stay nearly identical. Treat anything past turn 1 as *a* run, not *the* run |
@ -85,4 +85,5 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| MoveFleet waypoint types 2-5 | verify | backlog | — | 0% | 2026-09-08 | Still ZERO behavioural coverage after lane M. Not for lack of trying: the only mover in ref-turn2 is the AI (straight runs only), and the player that would travel a node line has DE/CR/DN all 00 at its home system, so Move/Manage Fleets are greyed out every turn - there is nothing to send along the node lines the map draws. Needs a ship built over several turns or a purpose-built save. The type-2 node-line step is still WRONG BY CONSTRUCTION (B4). Also: sim::Distance deliberately left in double (only stutter geometry uses it); Mars_Vec3_Length says it is probably 1 ULP out the same way, but there is zero behavioural evidence to correct it against - do not "fix" it blind |
| P2-P event posting in ours | phase2 | mapped | high | 80% | 2026-09-08 | HOST-VERIFIED, VM RUN QUEUED (lane F holds VM140). next_id reaches 4 in a host reproduction of recap-b3 call 0, fixture rebuilt from raw bytes at the real 0x1c/0x18/0x74 strides and cross-checked against turn3-state.sav with lane C's state_checksum --tree. Count-only (lane E option a): ours never calls the game's PostEvent and REPLACE MODE WRITES NOTHING - a bumped EvNxID with no record behind it would corrupt the very save the oracle hashes. Three design points: the event scan is taken in describe_args BEFORE the original (taken after, ours would dedup against the original's own posts and agree for the wrong reason); dedup risk is MEASURED and reported as events_dedup_risk, not assumed; KeylessEventText resolves keys to "%s" so the shim carries no prose. VERIFIED from the instruction stream: SetResearched 0x00581e10 calls owner vft+0x10 with (flags>>2)&1 and ProcessResearch passes flags=2, so silent=false and the completion event IS posted - previously only inferable from "EvNxID moved by two". ctest 33/33, shim cross-builds on CT111 (lane P could only syntax-check) |
| EVENT_TECHS_UNLOCKED not posted (predicted residual) | verify | backlog | — | 0% | 2026-09-08 | Lane P FLAGGED RATHER THAN GUESSED. Trigger IS pinned (SetResearched's sweep sets state=2 + stamps turnAvailable sticky at -1; tail loop collects state==2 && turnAvailable==currentTurn) but evaluating it needs the unlock cascade ours deliberately does not run. The driver takes the unlock list as an INPUT and is handed nullptr ("no list") - deliberately distinct from an empty list ("computed, empty"). PREDICTED RESIDUAL: next_id short by exactly 1 on every completion call. Posting it "whenever something completed" would score on this save and be WRONG the first time a completion unlocks nothing - the exact false-pass shape this project keeps catching |
| sizeof(ObservedTech) unpinned | objects | backlog | — | 0% | 2026-09-08 | Lane P declared the region at ServerPlayer+0x274 but did NOT model the bytes. The append site is in neither OnTechResearched's nor SetResearched's decompilation, and `find-constant-uses` for 0x274 returns only unrelated objects because GHIDRA DOES NOT INDEX `lea` DISPLACEMENTS - a systemic blind spot, not a one-off. The declared region's byte delta on a completion call will MEASURE the struct size. Worth a raw-byte scanner for lea-displacement xrefs as a reusable tool |
| sizeof(ObservedTech) unpinned | objects | verified | high | 100% | 2026-09-08 | **PINNED (lane X).** `sizeof(Game::ObservedTech) = 0x2c (44)` -- three independent proofs: the magic divide `0x2e8ba2e9 sar 3` (= /44, exact) at 0x0087239f, `imul reg,reg,0x2c` at 0x0087243a / 0x007b735b, and the search stride `add edi,0x2c` at 0x007ba257. **Append site = `RecordObservedTech+0xdf` (0x007ba27f): `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`** -- a de-duplicating append, direct callee of OnTechResearched 0x00891790; the realloc through 0x007b5820 is why all three vector words move. Element: vptr 0x00a2439c at +0 (RTTI `.?AVObservedTech@Game@@`), std::string at +0x0c (0x18 B); +0x04/+0x06 (16-bit) and +0x08/+0x24/+0x28 hold the four on-disk ints in an order NOT yet determined. Built the general tool the row asked for: `tools/x86disp.py`, an x86 displacement xref scanner (100% code coverage, 0.17% desync). `findings/subsystems/observedtech-append.md` |
| lea-displacement xref scanner | meta | verified | high | 100% | 2026-09-08 | `tools/x86disp.py` -- fixes the systemic blind spot that Ghidra does not index ModRM displacements. Full x86-32 length decoder swept from Ghidra's 41,089 function starts: 2,174,504 instructions, 612,166 displacement sites, **100.0% code coverage, 70 desyncs (0.17%), zero unknown opcodes**. Validated against ground truth before use (re-finds `lea eax,[ecx+0x29c]` in GetEventStorage, both OnTechResearched +0x29c sites, and one NEW ProcessTurn site). HONEST LIMITS: it is a **recall** tool, not an oracle -- class-level precision at 0x274 is ~13% by function (99 sites / 45 functions, ~6 real), i.e. a 900x search-space cut that still needs one call-graph check. The naive byte scan it replaces is not wrong so much as **blind**: it misses 80/99 real sites at 0x274 and 13,784/14,611 at disp8 0x14. `cohort` ranking must never be used as a hard filter -- it would have discarded the correct ObservedTech answer. Works off a gitignored local cache in `dumps/`, so it does not hammer CT111 |

View file

@ -284,6 +284,11 @@ said "an arriving call was simply clean"; that is still true and still means not
pinned against the instruction stream.
* `ServerSystem+0xd8` and `ServerSystem+0x238` are unnamed and are written every turn by the
AI home system. `StarFleet+0xdc` likewise, on every move.
> **Follow-up 2026-09-08 (lane X):** the append is now named — `RecordObservedTech` `0x007ba1a0`,
> called directly from `OnTechResearched`; `sizeof(ObservedTech) = 0x2c`; the realloc that moves all
> three words is `vector_44B_grow` `0x007b5820`. See `findings/subsystems/observedtech-append.md`.
> That note also corrects harness-audit row 11: nothing in `ComputeBudget` stores to `Budget+0x64`.
* `vector<ObservedTech> otch` (`ServerPlayer+0x274`) is undeclared everywhere and is save
state.
* Nothing here exercises: replace mode (any hook), a Zuul double roll, a rebellion, an

View file

@ -0,0 +1,278 @@
# `vector<ObservedTech> otch` — pinned, and the displacement scanner that pinned it
Lane X. Static/host only; VM140 was held by lane F and the game was never run.
Two things here: a general-purpose tool (`tools/x86disp.py`) for a class of cross-reference
Ghidra cannot index, and the `ServerPlayer+0x274` answer it was built to get.
---
## 1. The gap the tool fills
Ghidra indexes **immediate** operands. It does not index **ModRM displacements**. On a
41,411-function MSVC C++ binary `lea reg,[reg+disp]` is *the* idiom for taking the address of
a member — it is how every `std::vector`, every `std::string`, every embedded sub-object gets
passed to a method or a constructor. So `find-constant-uses 0x274` returned 13 unrelated hits
and none of them was the vector, and lane P correctly concluded the search was a limitation
rather than evidence of absence.
Lane E only found `EventStorage` at `+0x29c` because a whole accessor function happened to
exist (`lea eax,[ecx+0x29c]; ret`). We were blind to this whole reference class all campaign,
with ~1,600 classes still to go.
## 2. The tool — `tools/x86disp.py`
A real x86-32 **length decoder** (legacy prefixes, 1-/2-/3-byte opcodes, ModRM, SIB, disp8
sign-extended, disp32, every immediate form), swept from Ghidra's function starts so every
decode begins on a genuine instruction boundary. It indexes every memory operand that carries
a displacement, then answers "what code touches offset N off some object?" with the containing
function, instruction address, base register and decoded instruction.
Correctness points that would otherwise produce confident garbage, all handled:
* `mod=0,rm=5` and `mod=0,rm=4,sib.base=5` are **disp32 with no base** — an absolute global
address, not a member offset. Excluded from member queries. (Without this, every
`mov eax,[0x00a2bd88]` in the binary shows up as a "displacement".)
* `mod=1` disp8 is **sign-extended**: `-0x08` must not be reported as `+0xf8`.
* `mod=3` is a register operand with no displacement at all.
* `0x67` address-size prefix means 16-bit ModRM, a different layout entirely — refused rather
than mis-decoded.
Commands: `build`, `query <disp>`, `cohort`, `func <va>`, `dis <va>`, `stats`, `brute`.
**Decode quality:**
```
functions swept : 41089
instructions : 2174504
disp sites : 612166
desyncs : 70 (0.17% of functions)
code coverage : 6142049/6142358 (100.0%)
```
Zero unknown-opcode desyncs — the opcode tables cover everything this binary contains. All 70
desyncs are truncations at a section/function boundary, not lost sync inside real code.
> Gotcha worth carrying: the first build clipped each sweep at `fva + Ghidra's sizeInBytes`
> and lost 11% of functions to mid-instruction truncation — Ghidra's `sizeInBytes` understates
> real bodies often enough to matter (it was cutting valid `mov esp,ebp; pop ebp; ret`
> epilogues in half). Sweeping to the **next function start** instead took coverage 89% → 100%.
The tool works off a local cache (`dumps/`, gitignored: the exe, the function list, the index)
so it never hammers the shared Ghidra box. `tools/cache_functions.py` pulls the function list
once.
## 3. Validation — it rediscovers what we already knew
Run before any new claim was made.
**GT1 — `ServerPlayer::GetEventStorage`, the one accessor we already had:**
```
$ uv run python3 tools/x86disp.py func 0x0080db00
ServerPlayer_GetEventStorage @ 0x0080db00 size 7
0x0080db00 lea eax,[ecx+0x29c] ServerPlayer_GetEventStorage+0x0 (8d819c020000)
```
**GT2 — lane E's two `OnTechResearched` sites, plus one it did not have:**
```
$ uv run python3 tools/x86disp.py query 0x29c --lea
ProcessTurn @0x00891340
0x00891713 lea ecx,[esi+0x29c] ProcessTurn+0x3d3 <-- NEW
OnTechResearched @0x00891790
0x008919ab lea ecx,[esi+0x29c] OnTechResearched+0x21b <-- known
0x0089241d lea ecx,[esi+0x29c] OnTechResearched+0xc8d <-- known
```
**GT3 — `EvNxID` at `ServerPlayer+0x2b0`:** 90 sites found; the `ServerPlayer` ones are present.
This one is also the honest illustration of the precision problem — see §5.
**GT4 — positive control for the cohort ranker.** Given the 50 `ServerPlayer` offsets already
in `struct-recovery.md`, `FUN_0087fac0` scores **50 co-hits out of 50** — it is the
`ServerPlayer` serializer, and nothing else in the binary comes close. A scanner that could
not surface that function from a bare offset query would not be worth using.
## 4. The answer: `sizeof(ObservedTech)` and the append site
### `sizeof(Game::ObservedTech) = 0x2c` (44 bytes) — verified, three independent ways
**(a)** `vector<ObservedTech>::operator=` at `0x00872380` divides the vector's byte span by a
constant using MSVC's magic-number sequence:
```
0x00872398 mov ecx,[edi+0x4] ; src._Mylast
0x0087239b mov edi,[edi] ; src._Myfirst
0x0087239d sub ecx,edi ; byte span
0x0087239f mov eax,0x2e8ba2e9
0x008723a4 imul ecx
0x008723a6 sar edx,3
```
`ceil(2^35 / 44) == 0x2e8ba2e9` **exactly**, and emulating the full sequence reproduces
n = 0,1,2,3,10,71,1000 from spans of 0,44,88,132,440,3124,44000. Divisor = 44, unambiguously.
**(b)** The same function then multiplies back by the literal stride:
```
0x0087243a imul ecx,ecx,0x2c
0x0087243d add ecx,[esi] ; this->_Myfirst + n*44
0x00872441 mov [esi+0x4],ecx ; this->_Mylast = ...
```
Same literal at `0x00872468` and at `0x007b735b` inside `push_back`.
**(c)** The linear search in the append function advances its iterator by `add edi,0x2c`
(`0x007ba257`).
This matches the on-disk lower bound *exactly* — 4 × `int` + one `0x1c` `std::string` = 44 —
so there is no padding slack anywhere in the element.
### The append site
```
FUN_007ba1a0 = RecordObservedTech (direct callee of ServerPlayer::OnTechResearched 0x00891790)
0x007ba221 mov edi,[esi+0x274] ; it = observer->otch._Myfirst
0x007ba227 cmp edi,[esi+0x278] ; ... != _Mylast ?
0x007ba22d je 0x007ba274 ; empty -> append
loop: compare each element's name string (this = elem+0x0c, length = [elem+0x1c])
0x007ba257 add edi,0x2c ; ++it *** stride 44 ***
0x007ba25a cmp edi,[esi+0x278]
0x007ba260 jne loop
0x007ba274 lea ecx,[ebp-0x3c]
0x007ba277 call 0x008562a0 ; ObservedTech::ObservedTech() on the stack
0x007ba27e push eax
0x007ba27f lea ecx,[esi+0x274] ; this = &player->otch <<< THE APPEND
0x007ba288 call 0x007b7320 ; vector<ObservedTech>::push_back
```
`push_back` (`0x007b7320`) grows via `0x007b5820` when `_Mylast == _Myend` — **that realloc is
exactly why lane R's guard saw all three of `player+0x274/0x278/0x27c` move on a completion**,
rather than only `_Mylast`.
`RecordObservedTech` is called from `OnTechResearched` (`0x00891790`) and from `0x007be228`,
`0x007be4e1`, `0x007be535`. It is a **de-duplicating** append: it appends only if no existing
element already carries that tech name — worth knowing for the reimplementation, since a naive
`push_back` would diverge on a re-observation.
Neither `push_back` nor `operator=` is COMDAT-ambiguous: `0x007b7320` has exactly one caller
(`RecordObservedTech`, `ecx = player+0x274`) and `0x00872380` has two, both passing
`ServerPlayer+0x274`. `0x007b5820` **is** shared with `FUN_0086dec0` and may be a folded body,
so it is labelled `vector_44B_grow`, not as ObservedTech-specific.
### Element layout — as far as the evidence actually goes
The default ctor at `0x008562a0` writes vtable `0x00a2439c` at `+0x00`. RTTI:
COL `0x00a81c78` → type descriptor `0x00aeede4` → **`.?AVObservedTech@Game@@`**. So
`ObservedTech` is polymorphic and its first word is a vptr, not a data field — which the
on-disk shape does not tell you.
| offset | size | evidence |
|---|---|---|
| `+0x00` | 4 | vptr `0x00a2439c`; ctor writes it, RTTI-confirmed |
| `+0x04` | 2 | `mov word [eax-0x28],cx` at `0x007ba2b0` (`eax` = `_Mylast`, element = `_Mylast-0x2c`), source `[ebx+0xc]` |
| `+0x06` | 2 | `mov word [eax-0x26],dx` at `0x007ba2c6`, **same source word** `[ebx+0xc]` |
| `+0x08` | 4 | **unaccounted** |
| `+0x0c..+0x23` | 0x18 | `std::string` (the `otch` tech name). Object base is `+0x0c`, MSVC layout `{_Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14}`. Ctor writes `[+0x0c]=0`, `[+0x1c]=0`, `[+0x20]=0xf`; the search loop reads `[+0x1c]` as the length and calls the compare with `this = +0x0c`; the post-append assign uses `lea ecx,[_Mylast-0x20]` = `+0x0c` |
| `+0x24` | 4 | **unaccounted** |
| `+0x28` | 4 | **unaccounted** |
Note the string here is **0x18 bytes**, not the 0x1c the `ServerPlayer::pswd` row in
`struct-recovery.md` implies — three separate reads inside this element agree on
`{buf16, _Mysize@+0x10, _Myres@+0x14}`. Worth re-checking `pswd` against that.
The four on-disk ints (`otnF`, `otnL`, `odet`, `owith`) have to map onto `+0x04`/`+0x06` (two
16-bit fields) and the three 4-byte slots `+0x08`, `+0x24`, `+0x28`. **That mapping is not
determined here and is deliberately not guessed.** The one suggestive observation, flagged as a
*hypothesis only*: `+0x04` and `+0x06` are two adjacent 16-bit fields written from the same
source word on first observation, which is the shape you would expect of a first-seen /
last-seen turn pair — but nothing here proves it.
What would settle the mapping: read `ObservedTech`'s `Read`/`Write` serializer, where the
member order is explicit. Lane P's `observed_techs` region byte delta remains a valid live
cross-check and should now come back as exactly 44 per completion.
## 5. False-positive rate, honestly
Two different error rates, and the interesting one is not the one you'd expect.
**Decode-level error of the naive method is low.** A raw byte scan for `8D` + ModRM + the
displacement — the thing you'd write without a decoder — is mostly *right*:
| query | naive candidates | not actually an instruction | real sites the naive scan **missed** |
|---|---|---|---|
| `lea`, disp32 `0x274` | 19 | 0 (0.0%) | **80 of 99** |
| 11 common opcodes, disp32 `0x274` | 89 | 1 (1.1%) | 11 of 99 |
| `lea`, disp8 `0x14` | 828 | 1 (0.1%) | **13,784 of 14,611** |
| 11 common opcodes, disp8 `0x14` | 10,326 | 59 (0.6%) | 4,344 of 14,611 |
So the decoder's win is **recall, not decode precision**. A hand-written opcode set misses
80–94% of the real accesses, because a member is read, written, compared, and float-loaded far
more often than its address is taken, and you cannot enumerate those opcodes by hand.
**Class-level precision is the real problem, and it is poor.** The scanner knows the
displacement; it cannot know what class the base register holds. `query 0x274` returns 99 sites
in 45 functions and only about 6 of those functions are actually touching `ServerPlayer::otch`
— roughly **13% precision by function**. The honest way to state the value is as search-space
reduction: 41,411 functions → 45, about **900×**, down to a list a human reads in two minutes.
**The cohort ranker helps, and has a trap.** Scoring functions by how many *already-known*
offsets of the same class they touch pulls real class methods to the top (the serializer hits
50/50). But it would have **thrown away the correct answer**: `RecordObservedTech` touches only
`0x274` and `0x278` and nothing else on `ServerPlayer`, so any `--min>=1` filter drops it. Use
cohort as a ranker, never as a filter. This is now written into the tool's own docstring.
What actually closed the case was the plain `query 0x274` list **intersected with one call-graph
lookup** (`OnTechResearched`'s direct callees). Displacement scan for recall, call graph for
disambiguation — neither alone was enough.
## 6. Previously anonymous offsets — attribution results
The payoff was smaller than hoped, because most of the audit's anonymous offsets had already
been named by other lanes since the audit was written.
| offset | status before | after |
|---|---|---|
| `player+0x274/0x278/0x27c` | "vector grew, append site unknown" | **`RecordObservedTech+0xdf` (0x007ba27f)**, `sizeof` = 0x2c |
| `player+0x2b0` | already named by lane E | unchanged (`EvNxID`) |
| `TechTree+0x20` | already `TechTree_off_OrderCounter` | unchanged |
| `ServerPlayer+0x308` | already `ServerPlayer_off_NodeBore` | unchanged |
| `Budget+0x64` | harness-audit row 11: "the original writes it" | **not supported.** See below |
**`Budget+0x64` (harness-audit row 11) — a correction.** `ServerPlayer::ComputeBudget`
(`0x00863030`) writes its `Budget*` out-param through `esi`, and every such store lands in
`+0x00..+0x54` (the 22-int block). The only two `+0x64` accesses in the whole function are
**loads off a different base register** (`mov ecx,[eax+0x64]` at `0x0086328c`,
`mov edx,[eax+0x64]` at `0x0086335d`). There is no store to `Budget+0x64` in `ComputeBudget`.
Separately, `TechTree::ProcessResearch`'s `int* overbudget` fourth argument is **not**
`Budget+0x64`: its only caller is `ProcessTurn` (`0x00891340`) and the call at `0x008914a5`
passes `lea edx,[ebp-0x14]` — a stack local, consumed immediately after the call
(`mov eax,[ebp-0x14]; cmp eax,0; jle`).
This agrees with lane R, whose `budget_object` guard saw `Budget+0x64` change in **0 of 4284
calls**. Row 11 should be reclassified from "the original writes it and B1 never checked" to
"nothing has been shown to write it"; the remaining way to settle it is a write watchpoint on
that word, not another static search.
## 7. Verdict on the technique
Worth keeping, with its limits stated. It answered a question that had been parked, and the
`0x29c` validation turned up a `ProcessTurn` `EventStorage` site nobody had. But it is a
**recall** tool that produces a 20–100 line candidate list per offset, not an oracle: every
result still needs a call-graph or decompiler check before it is a fact. For the ~1,600
remaining classes the realistic workflow is `query <offset>` → read the list → confirm with one
cross-reference call.
## 8. Ghidra writeback
Labels: `RecordObservedTech` `0x007ba1a0`, `vector_ObservedTech_push_back` `0x007b7320`,
`ObservedTech_ctor` `0x008562a0`, `vector_ObservedTech_assign` `0x00872380`,
`vector_44B_grow` `0x007b5820`, `vftable_ObservedTech` `0x00a2439c`. Plate comments carrying
the stride evidence on the first four.
`addresses.json`: `ObservedTech_sizeof`, `ObservedTech_vftable`, `ObservedTech_off_Name`,
`RecordObservedTech`, `vector_ObservedTech_push_back`, `vector_ObservedTech_assign`,
`ObservedTech_ctor`, `vector_44B_grow`; `ServerPlayer_off_ObservedTechs` updated from
"NOT PINNED" to `verified`.

View file

@ -109,7 +109,15 @@ New `addresses.json` entries from this read: `TechNode_off_TurnAvailable` 0x20,
`TechNode_off_TurnResearched` 0x24, `TechNode_off_Order` 0x28, `TechNode_off_Children` 0x04,
`TechEdge_off_CostRP` 0x1c, `TechEdge_off_ChildDef` 0x40, `TechTree_off_OrderCounter` 0x20.
## 5. `vector<ObservedTech> otch` at `ServerPlayer+0x274` — still not pinned
## 5. `vector<ObservedTech> otch` at `ServerPlayer+0x274` — ~~still not pinned~~ **PINNED**
> **RESOLVED 2026-09-08 by lane X** — see `findings/subsystems/observedtech-append.md`.
> `sizeof(Game::ObservedTech) = 0x2c (44)`. The append is `RecordObservedTech+0xdf` (`0x007ba27f`):
> `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`. `RecordObservedTech`
> (`0x007ba1a0`) is a **direct callee of `OnTechResearched`** and de-duplicates by tech name before
> appending. The suspicion recorded below was right: `find-constant-uses` missed it because the
> encoding is a `lea` displacement. `tools/x86disp.py` now indexes those.
> The rest of this section is left as written, as the record of what was known at the time.
Lane R's guards caught all three vector words moving on every tech completion, in both the
`ProcessResearch` and the `OnTechResearched` player guards, and it is serialized `ServerPlayer`

View file

@ -1454,9 +1454,9 @@
"name": "ServerPlayer_off_ObservedTechs",
"offset": "0x274",
"convention": "offset",
"prototype": "std::vector<ObservedTech> otch (3 words {first,last,end}); save tag otch, element {int otnF, otnL, odet; string otch; int owith}. All three words move on every tech completion (a realloc) -- observed live by both the ProcessResearch and the OnTechResearched player guards. NOT PINNED: the append call site and sizeof(ObservedTech); the byte span the observed_techs region reports is what will measure the stride",
"status": "verified-by-save",
"source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/golden-trace-recapture.md (player guard, both completion calls)"
"prototype": "std::vector<ObservedTech> otch -- 3 words {_Myfirst@0x274,_Mylast@0x278,_Myend@0x27c}; save tag otch. sizeof(ObservedTech) = 0x2c (44), PINNED three ways: magic divide 0x2e8ba2e9 sar 3 (=/44) in vector_ObservedTech_assign 0x0087239f, imul reg,reg,0x2c at 0x0087243a / 0x007b735b, and the linear search stride `add edi,0x2c` at 0x007ba257. Append site = RecordObservedTech+0xdf (0x007ba27f): lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320. All three words move because push_back reallocs through vector_44B_grow 0x007b5820.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "ServerPlayer_off_IncMod",
@ -3169,6 +3169,70 @@
"prototype": "site inside StrategyServer::MoveFleet /* the move != distance branch: exactly two roundings per component, tmp.c = float32(dir.c * move) then pos.c = float32(pos.c + tmp.c). `move` is reloaded from a float32 slot. The sibling branch (move == distance, an EXACT float compare) copies the destination's three words verbatim with mov, so an arrival never steps onto its destination. */",
"status": "verified",
"source": "lane M own disassembly pass 2026-09-08"
},
{
"name": "ObservedTech_sizeof",
"offset": "0x2c",
"convention": "constant",
"prototype": "sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. Matches the on-disk lower bound exactly (4 int + one 0x1c std::string = 44), so there is no padding slack.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "ObservedTech_vftable",
"addr": "0x00a2439c",
"convention": "data",
"prototype": "Game::ObservedTech vftable. RTTI COL 0x00a81c78 -> type descriptor 0x00aeede4 = '.?AVObservedTech@Game@@'. Written to element+0x00 by ObservedTech_ctor 0x008562a0 -- so ObservedTech is polymorphic and its first word is the vptr, not a data field.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "ObservedTech_off_Name",
"offset": "0x0c",
"convention": "offset",
"prototype": "std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x18 bytes, spanning +0x0c..+0x23. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14 -- i.e. ObservedTech+0x1c is the length and +0x20 the capacity. Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. NOTE this string is 0x18 bytes, not the 0x1c implied by the ServerPlayer pswd row in struct-recovery.md. UNACCOUNTED in the element: +0x08, +0x24, +0x28 (4 bytes each) plus two 16-bit fields at +0x04/+0x06 written from one source word at 0x007ba2b0 / 0x007ba2c6 -- the mapping of the four on-disk ints (otnF, otnL, odet, owith) onto those slots is NOT determined.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "RecordObservedTech",
"addr": "0x007ba1a0",
"convention": "thiscall",
"prototype": "void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes two 16-bit fields into the new element at +0x04 and +0x06 from param_1+0xc. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "vector_ObservedTech_push_back",
"addr": "0x007b7320",
"convention": "thiscall",
"prototype": "void (std::vector<ObservedTech>* this, ObservedTech* value) RET 4. Stride 0x2c. Calls vector_44B_grow 0x007b5820 when _Mylast==_Myend -- the realloc that moves all three vector words. Exactly one caller (RecordObservedTech), so this instantiation is not COMDAT-ambiguous.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "vector_ObservedTech_assign",
"addr": "0x00872380",
"convention": "thiscall",
"prototype": "std::vector<ObservedTech>& operator=(const std::vector<ObservedTech>&) RET 4. Both callers pass ServerPlayer+0x274 (0x00878091 in the settings copy-out, 0x00892507 in the copy-in).",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "ObservedTech_ctor",
"addr": "0x008562a0",
"convention": "thiscall",
"prototype": "Game::ObservedTech* (ObservedTech* this) -- default ctor; sets vptr 0x00a2439c and empties the name string.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
{
"name": "vector_44B_grow",
"addr": "0x007b5820",
"convention": "thiscall",
"prototype": "vector<T>::_Reserve/grow for a 44-byte element type. Shared with FUN_0086dec0, so it may be a COMDAT-folded body -- do NOT assume it is ObservedTech-specific.",
"status": "mapped",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
}
]
}

View file

@ -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 @ b0ef139, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 1a58bcd, 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>
@ -369,7 +369,7 @@ constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c;
constexpr uint32_t ServerPlayer_off_Sav = 0x00000284;
// offset Tech* current research target (ResT); NULL = none [verified-by-save]
constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294;
// offset std::vector<ObservedTech> otch (3 words {first,last,end}); save tag otch, element {int otnF, otnL, odet; string otch; int owith}. All three words move on every tech completion (a realloc) -- observed live by both the ProcessResearch and the OnTechResearched player guards. NOT PINNED: the append call site and sizeof(ObservedTech); the byte span the observed_techs region reports is what will measure the stride [verified-by-save]
// offset std::vector<ObservedTech> otch -- 3 words {_Myfirst@0x274,_Mylast@0x278,_Myend@0x27c}; save tag otch. sizeof(ObservedTech) = 0x2c (44), PINNED three ways: magic divide 0x2e8ba2e9 sar 3 (=/44) in vector_ObservedTech_assign 0x0087239f, imul reg,reg,0x2c at 0x0087243a / 0x007b735b, and the linear search stride `add edi,0x2c` at 0x007ba257. Append site = RecordObservedTech+0xdf (0x007ba27f): lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320. All three words move because push_back reallocs through vector_44B_grow 0x007b5820. [verified]
constexpr uint32_t ServerPlayer_off_ObservedTechs = 0x00000274;
// offset float IncMod [verified-by-save]
constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c;
@ -799,5 +799,21 @@ constexpr uint32_t Mars_Vec3_NormaliseEpsilon = 0x005e1ef8;
constexpr uint32_t StrategyServer_MoveFleet_straight_leg = 0x003da0f2;
// site site inside StrategyServer::MoveFleet /* the move != distance branch: exactly two roundings per component, tmp.c = float32(dir.c * move) then pos.c = float32(pos.c + tmp.c). `move` is reloaded from a float32 slot. The sibling branch (move == distance, an EXACT float compare) copies the destination's three words verbatim with mov, so an arrival never steps onto its destination. */ [verified]
constexpr uint32_t StrategyServer_MoveFleet_position_update = 0x003da2ac;
// constant sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. Matches the on-disk lower bound exactly (4 int + one 0x1c std::string = 44), so there is no padding slack. [verified]
constexpr uint32_t ObservedTech_sizeof = 0x0000002c;
// data Game::ObservedTech vftable. RTTI COL 0x00a81c78 -> type descriptor 0x00aeede4 = '.?AVObservedTech@Game@@'. Written to element+0x00 by ObservedTech_ctor 0x008562a0 -- so ObservedTech is polymorphic and its first word is the vptr, not a data field. [verified]
constexpr uint32_t ObservedTech_vftable = 0x0062439c;
// offset std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x18 bytes, spanning +0x0c..+0x23. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14 -- i.e. ObservedTech+0x1c is the length and +0x20 the capacity. Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. NOTE this string is 0x18 bytes, not the 0x1c implied by the ServerPlayer pswd row in struct-recovery.md. UNACCOUNTED in the element: +0x08, +0x24, +0x28 (4 bytes each) plus two 16-bit fields at +0x04/+0x06 written from one source word at 0x007ba2b0 / 0x007ba2c6 -- the mapping of the four on-disk ints (otnF, otnL, odet, owith) onto those slots is NOT determined. [verified]
constexpr uint32_t ObservedTech_off_Name = 0x0000000c;
// thiscall void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes two 16-bit fields into the new element at +0x04 and +0x06 from param_1+0xc. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find. [verified]
constexpr uint32_t RecordObservedTech = 0x003ba1a0;
// thiscall void (std::vector<ObservedTech>* this, ObservedTech* value) RET 4. Stride 0x2c. Calls vector_44B_grow 0x007b5820 when _Mylast==_Myend -- the realloc that moves all three vector words. Exactly one caller (RecordObservedTech), so this instantiation is not COMDAT-ambiguous. [verified]
constexpr uint32_t vector_ObservedTech_push_back = 0x003b7320;
// thiscall std::vector<ObservedTech>& operator=(const std::vector<ObservedTech>&) RET 4. Both callers pass ServerPlayer+0x274 (0x00878091 in the settings copy-out, 0x00892507 in the copy-in). [verified]
constexpr uint32_t vector_ObservedTech_assign = 0x00472380;
// thiscall Game::ObservedTech* (ObservedTech* this) -- default ctor; sets vptr 0x00a2439c and empties the name string. [verified]
constexpr uint32_t ObservedTech_ctor = 0x004562a0;
// thiscall vector<T>::_Reserve/grow for a 44-byte element type. Shared with FUN_0086dec0, so it may be a COMDAT-folded body -- do NOT assume it is ObservedTech-specific. [mapped]
constexpr uint32_t vector_44B_grow = 0x003b5820;
} // namespace sots::addr

57
tools/cache_functions.py Normal file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Cache the full Ghidra function list (address, size, name) to dumps/functions.json.
Ghidra/ReVa is a shared resource -- pull the list once, then work offline.
Usage: uv run python3 tools/cache_functions.py [--refresh]
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
OUT = os.path.join(REPO, "dumps", "functions.json")
PROG = "/Sword of the Stars.exe"
def call(tool, args):
r = subprocess.run(
["uv", "run", "python3", os.path.join(HERE, "reva_call.py"), tool, json.dumps(args)],
capture_output=True, text=True, cwd=REPO, timeout=300)
if r.returncode != 0:
sys.exit(f"reva_call {tool} failed: {r.stderr[:400]}")
return r.stdout
def main():
if os.path.exists(OUT) and "--refresh" not in sys.argv:
print(f"{OUT} exists; use --refresh to re-pull")
return
funcs = {}
start = 0
while True:
raw = call("get-functions", {
"programPath": PROG, "startIndex": start, "filterDefaultNames": False})
lines = [ln for ln in raw.splitlines() if ln.strip()]
header = json.loads(lines[0])
for ln in lines[1:]:
try:
f = json.loads(ln)
except json.JSONDecodeError:
continue
funcs[f["address"]] = [f["name"], f.get("sizeInBytes", 0)]
nxt = header.get("nextStartIndex")
total = header.get("totalCount")
print(f" {len(funcs)}/{total}", file=sys.stderr)
if nxt is None or nxt <= start or len(funcs) >= total:
break
start = nxt
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w") as fh:
json.dump(funcs, fh)
print(f"wrote {len(funcs)} functions to {OUT}")
if __name__ == "__main__":
main()

798
tools/x86disp.py Normal file
View file

@ -0,0 +1,798 @@
#!/usr/bin/env python3
"""x86-32 displacement cross-referencer for the SOTS1 exe.
Why this exists
---------------
Ghidra indexes immediate operands but NOT ModRM displacements. On an MSVC C++
binary `lea reg,[reg+disp]` is *the* idiom for taking the address of a member --
it is how every std::vector / std::string / embedded sub-object is passed to a
method or ctor. `find-constant-uses 0x274` therefore cannot see the append site
for a vector member at +0x274. This tool answers the question Ghidra can't:
"what code takes the address of, or accesses, offset N off some object?"
How it works
------------
x86 is variable-length and not self-synchronising, so a naive byte scan for
"8D 8E <disp32>" produces confident garbage. Instead we run a full instruction
*length* decoder (prefixes / 1-,2-,3-byte opcodes / ModRM / SIB / disp / imm)
seeded from Ghidra's 41k function starts, so every decode begins on a real
instruction boundary. `--brute` runs the naive scan too, purely so the
false-positive rate of the naive method can be *measured* rather than guessed.
Correctness notes that matter (each of these is a silent-garbage source):
* mod=0,rm=5 -> disp32 with NO base: an absolute global address, not a
member offset. Excluded from member queries by default.
* mod=0,rm=4,
sib.base=5 -> disp32, no base, index only. Same: excluded.
* mod=1 -> disp8 SIGN-EXTENDED. -0x08 must not be reported as 0xf8.
* mod=3 -> register operand, no memory, no displacement at all.
* 0x67 addr-size -> 16-bit ModRM, entirely different layout. Skipped, flagged.
Usage
-----
uv run python3 tools/x86disp.py build # decode + cache index
uv run python3 tools/x86disp.py query 0x274 # who touches +0x274
uv run python3 tools/x86disp.py query 0x29c --lea # lea only
uv run python3 tools/x86disp.py func 0x0080db00 # dump one function
uv run python3 tools/x86disp.py stats
"""
import bisect
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
EXE = os.path.join(REPO, "dumps", "sots.exe")
FUNCS = os.path.join(REPO, "dumps", "functions.json")
INDEX = os.path.join(REPO, "dumps", "dispindex.json")
R32 = ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]
# ---------------------------------------------------------------- opcode maps
# value: (has_modrm, imm_kind)
# imm kinds: 0 none | 'b' 1 | 'w' 2 | 'd' 4 | 'z' 2-if-66-else-4
# 'p' far ptr 6 (4 with 66) | 'a' moffs (addr-size) | 'e' enter 3
# 'g6' F6 group (b if reg<2) | 'g7' F7 group (z if reg<2)
_ONE = {}
def _fill(rng, modrm, imm):
for o in rng:
_ONE[o] = (modrm, imm)
# 00..3F: the eight ALU ops, each /r /r /r /r AL,ib eAX,iz + 2 seg ops
for _base in (0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38):
_fill(range(_base, _base + 4), True, 0)
_ONE[_base + 4] = (False, "b")
_ONE[_base + 5] = (False, "z")
_ONE[_base + 6] = (False, 0) # PUSH seg / prefix-or-ascii-adjust
_ONE[_base + 7] = (False, 0)
_fill(range(0x40, 0x60), False, 0) # INC/DEC/PUSH/POP r32
_fill([0x60, 0x61], False, 0) # PUSHA/POPA
_ONE[0x62] = (True, 0) # BOUND
_ONE[0x63] = (True, 0) # ARPL
_ONE[0x68] = (False, "z") # PUSH iz
_ONE[0x69] = (True, "z") # IMUL r,Ev,iz
_ONE[0x6A] = (False, "b") # PUSH ib
_ONE[0x6B] = (True, "b") # IMUL r,Ev,ib
_fill(range(0x6C, 0x70), False, 0) # INS/OUTS
_fill(range(0x70, 0x80), False, "b") # Jcc rel8
_ONE[0x80] = (True, "b")
_ONE[0x81] = (True, "z")
_ONE[0x82] = (True, "b")
_ONE[0x83] = (True, "b")
_fill(range(0x84, 0x90), True, 0) # TEST/XCHG/MOV/MOV-seg/LEA/POP Ev
_fill(range(0x90, 0x9A), False, 0) # NOP/XCHG/CWDE/CDQ
_ONE[0x9A] = (False, "p") # CALLF
_fill(range(0x9B, 0xA0), False, 0)
_fill(range(0xA0, 0xA4), False, "a") # MOV moffs
_fill(range(0xA4, 0xA8), False, 0) # MOVS/CMPS
_ONE[0xA8] = (False, "b")
_ONE[0xA9] = (False, "z")
_fill(range(0xAA, 0xB0), False, 0) # STOS/LODS/SCAS
_fill(range(0xB0, 0xB8), False, "b") # MOV r8,ib
_fill(range(0xB8, 0xC0), False, "z") # MOV r32,iz
_ONE[0xC0] = (True, "b")
_ONE[0xC1] = (True, "b")
_ONE[0xC2] = (False, "w") # RET imm16
_ONE[0xC3] = (False, 0)
_ONE[0xC4] = (True, 0) # LES
_ONE[0xC5] = (True, 0) # LDS
_ONE[0xC6] = (True, "b") # MOV Eb,Ib
_ONE[0xC7] = (True, "z") # MOV Ev,Iz
_ONE[0xC8] = (False, "e") # ENTER iw,ib
_ONE[0xC9] = (False, 0)
_ONE[0xCA] = (False, "w") # RETF imm16
_fill([0xCB, 0xCC], False, 0)
_ONE[0xCD] = (False, "b") # INT ib
_fill([0xCE, 0xCF], False, 0)
_fill(range(0xD0, 0xD4), True, 0) # shift group by 1 / by CL
_ONE[0xD4] = (False, "b") # AAM
_ONE[0xD5] = (False, "b") # AAD
_fill([0xD6, 0xD7], False, 0)
_fill(range(0xD8, 0xE0), True, 0) # x87 -- always ModRM
_fill(range(0xE0, 0xE4), False, "b") # LOOP*/JECXZ
_fill(range(0xE4, 0xE8), False, "b") # IN/OUT ib
_ONE[0xE8] = (False, "z") # CALL rel32
_ONE[0xE9] = (False, "z") # JMP rel32
_ONE[0xEA] = (False, "p") # JMPF
_ONE[0xEB] = (False, "b") # JMP rel8
_fill(range(0xEC, 0xF0), False, 0) # IN/OUT DX
_fill(range(0xF0, 0xF6), False, 0) # LOCK/INT1/REP*/HLT/CMC
_ONE[0xF6] = (True, "g6")
_ONE[0xF7] = (True, "g7")
_fill(range(0xF8, 0xFE), False, 0)
_ONE[0xFE] = (True, 0)
_ONE[0xFF] = (True, 0)
_TWO = {}
def _fill2(rng, modrm, imm):
for o in rng:
_TWO[o] = (modrm, imm)
_fill2(range(0x00, 0x05), True, 0)
_fill2(range(0x05, 0x0D), False, 0)
_TWO[0x0D] = (True, 0)
_TWO[0x0E] = (False, 0)
_TWO[0x0F] = (True, "b") # 3DNow!
_fill2(range(0x10, 0x18), True, 0)
_fill2(range(0x18, 0x20), True, 0) # hint-NOP / prefetch
_fill2(range(0x20, 0x25), True, 0)
_fill2(range(0x28, 0x30), True, 0)
_fill2(range(0x30, 0x38), False, 0)
_fill2(range(0x40, 0x50), True, 0) # CMOVcc
_fill2(range(0x50, 0x70), True, 0) # SSE/MMX
_TWO[0x70] = (True, "b")
_fill2(range(0x71, 0x74), True, "b")
_fill2(range(0x74, 0x77), True, 0)
_TWO[0x77] = (False, 0) # EMMS
_fill2(range(0x78, 0x80), True, 0)
_fill2(range(0x80, 0x90), False, "z") # Jcc rel32
_fill2(range(0x90, 0xA0), True, 0) # SETcc
_fill2([0xA0, 0xA1, 0xA2], False, 0)
_TWO[0xA3] = (True, 0) # BT
_TWO[0xA4] = (True, "b") # SHLD ib
_TWO[0xA5] = (True, 0) # SHLD CL
_fill2([0xA8, 0xA9, 0xAA], False, 0)
_TWO[0xAB] = (True, 0) # BTS
_TWO[0xAC] = (True, "b") # SHRD ib
_fill2([0xAD, 0xAE, 0xAF], True, 0)
_fill2(range(0xB0, 0xBA), True, 0)
_TWO[0xBA] = (True, "b") # group8 BT/BTS/BTR/BTC ib
_fill2(range(0xBB, 0xC0), True, 0)
_fill2([0xC0, 0xC1], True, 0) # XADD
_TWO[0xC2] = (True, "b") # CMPPS
_TWO[0xC3] = (True, 0) # MOVNTI
_fill2([0xC4, 0xC5, 0xC6], True, "b")
_TWO[0xC7] = (True, 0) # group9 CMPXCHG8B
_fill2(range(0xC8, 0xD0), False, 0) # BSWAP
_fill2(range(0xD0, 0x100), True, 0) # MMX/SSE bulk
# minimal mnemonics -- enough to read a report, not a full disassembler
_ALU = ["add", "or", "adc", "sbb", "and", "sub", "xor", "cmp"]
_G1 = _ALU
_G5 = ["inc", "dec", "call", "callf", "jmp", "jmpf", "push", "?"]
_G3 = ["test", "test", "not", "neg", "mul", "imul", "div", "idiv"]
_SHIFT = ["rol", "ror", "rcl", "rcr", "shl", "shr", "shl", "sar"]
def mnemonic(op2, op, reg):
"""Best-effort mnemonic. op2 True => the opcode was 0F-escaped."""
if op2:
if 0x10 <= op <= 0x17 or 0x28 <= op <= 0x2F or 0x51 <= op <= 0x5F:
return "sse"
if 0x40 <= op <= 0x4F:
return "cmov"
if 0x90 <= op <= 0x9F:
return "setcc"
if op in (0xB6, 0xB7):
return "movzx"
if op in (0xBE, 0xBF):
return "movsx"
if op == 0xAF:
return "imul"
if op in (0x6E, 0x6F, 0x7E, 0x7F, 0xD6):
return "movq/movd"
return f"0f{op:02x}"
if op < 0x40 and (op & 7) < 6:
return _ALU[op >> 3]
if op in (0x88, 0x89, 0x8A, 0x8B, 0xC6, 0xC7):
return "mov"
if op == 0x8D:
return "lea"
if op in (0x84, 0x85):
return "test"
if op in (0x86, 0x87):
return "xchg"
if op in (0x80, 0x81, 0x82, 0x83):
return _G1[reg]
if op in (0xC0, 0xC1, 0xD0, 0xD1, 0xD2, 0xD3):
return _SHIFT[reg]
if op in (0xF6, 0xF7):
return _G3[reg]
if op == 0xFF:
return _G5[reg]
if op == 0xFE:
return ["inc", "dec"][reg] if reg < 2 else "?"
if op == 0x8F:
return "pop"
if op in (0x69, 0x6B):
return "imul"
if 0xD8 <= op <= 0xDF:
return "x87"
if op == 0x62:
return "bound"
return f"{op:02x}"
class Desync(Exception):
pass
def decode(buf, i, end):
"""Decode one instruction at buf[i]. Returns (length, info|None).
info = dict(mnem, base, index, scale, disp, dispsize, modrm_reg, is_lea)
for instructions with a memory operand carrying a displacement; None
otherwise. Raises Desync on an unknown/invalid opcode.
"""
start = i
opsize66 = False
addr67 = False
while i < end:
b = buf[i]
if b == 0x66:
opsize66 = True
i += 1
elif b == 0x67:
addr67 = True
i += 1
elif b in (0xF0, 0xF2, 0xF3, 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65):
i += 1
else:
break
else:
raise Desync("prefix run to end")
if i - start > 14:
raise Desync("prefix flood")
op = buf[i]
i += 1
op2 = op3 = False
if op == 0x0F:
if i >= end:
raise Desync("truncated 0f")
op = buf[i]
i += 1
op2 = True
if op in (0x38, 0x3A):
three_imm = "b" if op == 0x3A else 0
if i >= end:
raise Desync("truncated 0f3x")
op = buf[i]
i += 1
op3 = True
has_modrm, imm = True, three_imm
else:
ent = _TWO.get(op)
if ent is None:
raise Desync(f"unknown 0f{op:02x}")
has_modrm, imm = ent
else:
ent = _ONE.get(op)
if ent is None:
raise Desync(f"unknown {op:02x}")
has_modrm, imm = ent
info = None
if has_modrm:
if i >= end:
raise Desync("truncated modrm")
modrm = buf[i]
i += 1
mod = modrm >> 6
reg = (modrm >> 3) & 7
rm = modrm & 7
if imm == "g6":
imm = "b" if reg < 2 else 0
elif imm == "g7":
imm = "z" if reg < 2 else 0
if mod != 3:
if addr67:
# 16-bit ModRM: different table entirely. Rare in MSVC code and
# never the member-address idiom -- skip rather than mis-decode.
raise Desync("16-bit addressing (0x67)")
base = index = None
scale = 1
if rm == 4:
if i >= end:
raise Desync("truncated sib")
sib = buf[i]
i += 1
scale = 1 << (sib >> 6)
idx = (sib >> 3) & 7
bse = sib & 7
index = None if idx == 4 else R32[idx]
if bse == 5 and mod == 0:
base = None # disp32 absolute + index
else:
base = R32[bse]
elif rm == 5 and mod == 0:
base = None # disp32 absolute
else:
base = R32[rm]
disp = 0
dispsize = 0
if mod == 1:
if i >= end:
raise Desync("truncated disp8")
disp = struct.unpack_from("<b", buf, i)[0] # SIGN-EXTENDED
dispsize = 1
i += 1
elif mod == 2 or base is None:
if i + 4 > end:
raise Desync("truncated disp32")
disp = struct.unpack_from("<i", buf, i)[0]
dispsize = 4
i += 4
if dispsize:
info = {
"mnem": mnemonic(op2, op, reg), "base": base,
"index": index, "scale": scale, "disp": disp,
"dispsize": dispsize, "reg": R32[reg],
"lea": (not op2 and op == 0x8D),
}
else:
reg = 0
n = 0
if imm == "b":
n = 1
elif imm == "w":
n = 2
elif imm == "d":
n = 4
elif imm == "z":
n = 2 if opsize66 else 4
elif imm == "p":
n = 4 if opsize66 else 6
elif imm == "a":
n = 2 if addr67 else 4
elif imm == "e":
n = 3
i += n
if i > end:
raise Desync("truncated imm")
if i == start:
raise Desync("zero length")
return i - start, info
# ------------------------------------------------------------------- PE / IO
def load_pe(path):
"""Return (image_base, [(va_start, va_end, bytes, name)]) for exec sections."""
data = open(path, "rb").read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe:pe + 4] == b"PE\0\0", "not a PE"
nsec = struct.unpack_from("<H", data, pe + 6)[0]
optsz = struct.unpack_from("<H", data, pe + 20)[0]
base = struct.unpack_from("<I", data, pe + 24 + 28)[0]
secs = []
off = pe + 24 + optsz
for k in range(nsec):
s = off + k * 40
name = data[s:s + 8].rstrip(b"\0").decode("latin1")
vsize, vaddr, rsize, raddr = struct.unpack_from("<IIII", data, s + 8)
chars = struct.unpack_from("<I", data, s + 36)[0]
if not (chars & 0x20000000): # IMAGE_SCN_MEM_EXECUTE
continue
n = min(vsize, rsize) if vsize else rsize
secs.append((base + vaddr, base + vaddr + n, data[raddr:raddr + n], name))
return base, secs
def load_funcs():
with open(FUNCS) as fh:
raw = json.load(fh)
fl = sorted((int(a, 16), n, sz) for a, (n, sz) in raw.items())
return fl
# --------------------------------------------------------------------- build
def build():
_, secs = load_pe(EXE)
funcs = load_funcs()
starts = [f[0] for f in funcs]
sites = [] # [va, disp, base, index, scale, mnem, lea, funcidx, hex]
covered = 0
total_code = sum(e - s for s, e, _, _ in secs)
desyncs = 0
decoded_ins = 0
for fi, (fva, fname, fsz) in enumerate(funcs):
sec = next((s for s in secs if s[0] <= fva < s[1]), None)
if sec is None or fsz <= 0:
continue
sva, eva, buf, _ = sec
# Sweep to the NEXT function start, not fva+fsz: Ghidra's sizeInBytes
# understates ~11% of bodies (it clips valid epilogues mid-instruction).
# Sites past fsz are still attributed to this function but flagged, so
# a caller can tell body-proper from possible inter-function padding.
j = bisect.bisect_right(starts, fva)
limit = min(starts[j] if j < len(starts) else eva, eva)
body_end = fva + fsz
i = fva - sva
end = limit - sva
while i < end:
try:
ln, info = decode(buf, i, end)
except Desync:
desyncs += 1
break
decoded_ins += 1
if info and info["base"] is not None and info["disp"] != 0:
va = sva + i
sites.append([va, info["disp"], info["base"], info["index"],
info["scale"], info["mnem"], info["lea"], fi,
buf[i:i + min(ln, 10)].hex(),
1 if va < body_end else 0, info["reg"]])
i += ln
covered += max(0, i - (fva - sva))
idx = {}
for k, s in enumerate(sites):
idx.setdefault(str(s[1]), []).append(k)
out = {
"sites": sites,
"byDisp": idx,
"funcs": [[f[0], f[1], f[2]] for f in funcs],
"stats": {"functions": len(funcs), "instructions": decoded_ins,
"sites": len(sites), "desyncs": desyncs,
"bytesCovered": covered, "codeBytes": total_code},
}
with open(INDEX, "w") as fh:
json.dump(out, fh)
st = out["stats"]
print(f"functions swept : {st['functions']}")
print(f"instructions : {st['instructions']}")
print(f"disp sites : {st['sites']}")
print(f"desyncs : {st['desyncs']} "
f"({100.0 * st['desyncs'] / st['functions']:.2f}% of functions)")
print(f"code coverage : {st['bytesCovered']}/{st['codeBytes']} "
f"({100.0 * st['bytesCovered'] / st['codeBytes']:.1f}%)")
# --------------------------------------------------------------------- query
def load_index():
if not os.path.exists(INDEX):
sys.exit("no index; run: uv run python3 tools/x86disp.py build")
with open(INDEX) as fh:
return json.load(fh)
THIS_REGS = ("ecx", "esi", "edi", "ebx") # typical `this` carriers in MSVC
def fmt(site, funcs):
va, disp, base, index, scale, mnem, lea, fi, hx, inbody, dst = site
fva, fname, _ = funcs[fi]
ea = f"[{base}"
if index:
ea += f"+{index}*{scale}"
ea += f"{'+' if disp >= 0 else '-'}0x{abs(disp):x}]"
txt = f"{mnem} {dst},{ea}" if lea else f"{mnem} {ea}"
return (f" 0x{va:08x} {txt:<32} "
f"{fname}+0x{va - fva:x}{'' if inbody else ' [past-body]'} ({hx})")
def query(argv):
want = int(argv[0], 0)
only_lea = "--lea" in argv
only_this = "--this" in argv
show_all = "--all" in argv
d = load_index()
funcs = d["funcs"]
keys = d["byDisp"].get(str(want), [])
hits = [d["sites"][k] for k in keys]
if only_lea:
hits = [h for h in hits if h[6]]
if only_this:
hits = [h for h in hits if h[2] in THIS_REGS]
hits.sort(key=lambda h: h[0])
ranked, other = [], []
for h in hits:
(ranked if h[2] in THIS_REGS else other).append(h)
print(f"displacement 0x{want:x} ({want}): {len(hits)} site(s)")
print(f"\n== base is a likely `this` ({'/'.join(THIS_REGS)}): {len(ranked)} ==")
byfn = {}
for h in ranked:
byfn.setdefault(h[7], []).append(h)
for fi in sorted(byfn, key=lambda f: funcs[f][0]):
print(f" {funcs[fi][1]} @0x{funcs[fi][0]:08x}")
for h in byfn[fi]:
print(fmt(h, funcs))
if other:
print(f"\n== other base regs (esp/ebp = locals, eax/edx = temps): {len(other)} ==")
if show_all:
for h in other:
print(fmt(h, funcs))
else:
cnt = {}
for h in other:
cnt[h[2]] = cnt.get(h[2], 0) + 1
print(" " + ", ".join(f"{k}:{v}" for k, v in sorted(cnt.items()))
+ " (--all to list)")
def func_dump(argv):
fva = int(argv[0], 0)
d = load_index()
funcs = d["funcs"]
fi = next((i for i, f in enumerate(funcs) if f[0] == fva), None)
if fi is None:
sys.exit(f"no function at 0x{fva:08x}")
print(f"{funcs[fi][1]} @ 0x{fva:08x} size {funcs[fi][2]}")
for s in d["sites"]:
if s[7] == fi:
print(fmt(s, funcs))
def stats():
d = load_index()
st = d["stats"]
for k, v in st.items():
print(f"{k:16}: {v}")
hist = {}
for s in d["sites"]:
if s[2] in THIS_REGS:
hist[s[1]] = hist.get(s[1], 0) + 1
print(f"\ndistinct displacements off ecx/esi/edi/ebx: {len(hist)}")
top = sorted(hist.items(), key=lambda kv: -kv[1])[:15]
print("most common: " + ", ".join(f"0x{k:x}({v})" for k, v in top))
_STORE = {0x89, 0x01, 0x29, 0x39, 0x09, 0x21, 0x31, 0x19, 0x11,
0x88, 0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38}
def _reg_form(raw):
"""Render the mod=3 (register-to-register) forms, which are exactly the
ones that carry the arithmetic you care about when recovering a struct
stride: `imul ecx,ecx,0x2c`, `sar edx,3`, `sub ecx,edi`."""
p = 0
while p < len(raw) and raw[p] in (0x66, 0x67, 0xF0, 0xF2, 0xF3,
0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65):
p += 1
op = raw[p]
if op == 0x0F or p + 1 >= len(raw):
return mnemonic(op == 0x0F, raw[p + 1] if op == 0x0F else op, 0)
m = raw[p + 1]
mod, reg, rm = m >> 6, (m >> 3) & 7, m & 7
if mod != 3:
# memory operand: render it and show which register is the other half,
# so a load reads `mov ecx,[edi+4]` not a bare `mov`.
q = p + 2
ea = "["
if rm == 4:
sib = raw[q]; q += 1
bse, ix = sib & 7, (sib >> 3) & 7
ea += ("" if (bse == 5 and mod == 0) else R32[bse])
if ix != 4:
ea += f"+{R32[ix]}*{1 << (sib >> 6)}"
elif not (rm == 5 and mod == 0):
ea += R32[rm]
if mod == 1 and q < len(raw):
dv = struct.unpack_from("<b", raw, q)[0]
ea += f"{'+' if dv >= 0 else '-'}0x{abs(dv):x}"
elif mod == 2 and q + 4 <= len(raw):
dv = struct.unpack_from("<i", raw, q)[0]
ea += f"{'+' if dv >= 0 else '-'}0x{abs(dv):x}"
ea += "]"
nm = mnemonic(False, op, reg)
if op in _STORE:
return f"{nm} {ea},{R32[reg]}"
if op in (0x80, 0x81, 0x83, 0xC6, 0xC7, 0xF6, 0xF7, 0xFF, 0xFE,
0xC0, 0xC1, 0xD0, 0xD1, 0xD2, 0xD3) or op >= 0xD8:
return f"{nm} {ea}"
return f"{nm} {R32[reg]},{ea}"
r, x = R32[reg], R32[rm]
if op in (0x89, 0x01, 0x29, 0x39, 0x09, 0x21, 0x31, 0x19, 0x11, 0x85, 0x87):
a, b = x, r # Ev,Gv -> dst is rm
else:
a, b = r, x # Gv,Ev -> dst is reg
nm = mnemonic(False, op, reg)
if op in (0xC0, 0xC1): # shift group, imm8
return f"{_SHIFT[reg]} {x},{raw[p + 2]}"
if op in (0xD1, 0xD3):
return f"{_SHIFT[reg]} {x},{'cl' if op == 0xD3 else '1'}"
if op == 0x6B: # imul r,rm,imm8 <- the stride!
return f"imul {r},{x},0x{struct.unpack_from('<b', raw, p + 2)[0] & 0xff:x}"
if op == 0x69:
return f"imul {r},{x},0x{struct.unpack_from('<I', raw, p + 2)[0]:x}"
if op in (0x83, 0x81, 0x80):
v = (raw[p + 2] if op != 0x81
else struct.unpack_from("<I", raw, p + 2)[0])
return f"{_G1[reg]} {x},0x{v:x}"
if op in (0xF7, 0xF6):
return f"{_G3[reg]} {x}"
if op == 0xFF:
return f"{_G5[reg]} {x}"
return f"{nm} {a},{b}"
def dis(argv):
"""Linear disassembly window: `dis <va> [count]`. Length-accurate; the
mnemonics are minimal but call/jmp targets are resolved to function names,
which is what you actually need to follow a `lea this,[obj+N]; call` pair."""
va = int(argv[0], 0)
n = int(argv[1]) if len(argv) > 1 else 24
_, secs = load_pe(EXE)
d = load_index()
fmap = {f[0]: f[1] for f in d["funcs"]}
sec = next((s for s in secs if s[0] <= va < s[1]), None)
if sec is None:
sys.exit("address not in an executable section")
sva, eva, buf, _ = sec
i = va - sva
for _ in range(n):
try:
ln, info = decode(buf, i, len(buf))
except Desync as e:
print(f"0x{sva + i:08x} <desync: {e}>")
return
raw = buf[i:i + ln]
cur = sva + i
txt = ""
# resolve the two rel32 forms and the rel8 jumps by hand
if raw[0] in (0xE8, 0xE9) and ln == 5:
tgt = cur + 5 + struct.unpack_from("<i", raw, 1)[0]
txt = (f"{'call' if raw[0] == 0xE8 else 'jmp'} 0x{tgt:08x}"
f" {fmap.get(tgt, '')}")
elif raw[0] == 0x0F and 0x80 <= raw[1] <= 0x8F and ln == 6:
tgt = cur + 6 + struct.unpack_from("<i", raw, 2)[0]
txt = f"jcc 0x{tgt:08x}"
elif raw[0] == 0xEB or 0x70 <= raw[0] <= 0x7F:
tgt = cur + ln + struct.unpack_from("<b", raw, ln - 1)[0]
txt = f"jmp/jcc 0x{tgt:08x}"
elif 0x50 <= raw[0] <= 0x57:
txt = f"push {R32[raw[0] - 0x50]}"
elif 0x58 <= raw[0] <= 0x5F:
txt = f"pop {R32[raw[0] - 0x58]}"
elif raw[0] == 0x6A:
txt = f"push 0x{raw[1]:x}"
elif raw[0] == 0x68:
txt = f"push 0x{struct.unpack_from('<I', raw, 1)[0]:x}"
elif 0xB8 <= raw[0] <= 0xBF and ln == 5:
txt = f"mov {R32[raw[0] - 0xB8]},0x{struct.unpack_from('<I', raw, 1)[0]:x}"
elif raw[0] == 0xC3:
txt = "ret"
elif raw[0] == 0xC2:
txt = f"ret 0x{struct.unpack_from('<H', raw, 1)[0]:x}"
else:
txt = _reg_form(raw)
lbl = fmap.get(cur, "")
print(f"0x{cur:08x} {raw.hex():<18} {txt:<44} {lbl}")
i += ln
def cohort(argv):
"""Rank functions by how many offsets of a KNOWN class layout they touch.
This is the answer to the real precision problem. A bare query for 0x274
returns ~99 sites and the tool cannot know which base register holds a
ServerPlayer. But a function that touches 0x274 *and* 0x29c *and* 0x244
*and* 0x254 is not doing that by coincidence -- those are ServerPlayer's
members. Feed the offsets we have already pinned and the class's own
methods float to the top.
uv run python3 tools/x86disp.py cohort 0x274 --known=0x29c,0x244,0x254,...
WARNING, learned the hard way on the ObservedTech hunt: use this as a
RANKER, never as a hard filter. It finds fat *class methods* (the
ServerPlayer serializer scores 50/50) but it actively hides narrow helpers.
`RecordObservedTech` -- the actual append site -- touches only 0x274 and
0x278 and nothing else on ServerPlayer, so every --min>=1 setting drops it.
Read the full `query` output before trusting a cohort shortlist.
"""
want = int(argv[0], 0)
known = []
minhits = 2
for a in argv[1:]:
if a.startswith("--known="):
known = [int(x, 0) for x in a[8:].split(",")]
elif a.startswith("--min="):
minhits = int(a[6:])
if not known:
sys.exit("need --known=<comma-separated offsets of the same class>")
d = load_index()
funcs = d["funcs"]
kset = set(known)
touched = {} # funcidx -> set(offset)
target = {} # funcidx -> [sites at `want`]
for s in d["sites"]:
if s[2] not in THIS_REGS:
continue
if s[1] in kset:
touched.setdefault(s[7], set()).add(s[1])
if s[1] == want:
target.setdefault(s[7], []).append(s)
rows = []
for fi, sites in target.items():
hits = touched.get(fi, set()) - {want}
if len(hits) >= minhits:
rows.append((len(hits), fi, sites, hits))
rows.sort(key=lambda r: -r[0])
print(f"functions touching 0x{want:x} AND >={minhits} other known offsets "
f"of this class: {len(rows)} of {len(target)} candidates "
f"({100.0 * len(rows) / max(1, len(target)):.0f}% kept)")
for n, fi, sites, hits in rows:
fva, fname, _ = funcs[fi]
hx = ",".join(f"0x{h:x}" for h in sorted(hits))
print(f"\n {fname} @0x{fva:08x} [{n} co-hits: {hx}]")
for s in sites:
print(fmt(s, funcs))
# ------------------------------------------------- naive scan, for FP measure
def brute(argv):
"""Naive 'search the raw bytes' scan -- the thing you'd write without a
decoder. Only exists so we can put a NUMBER on how wrong it is.
`--ops 8d` (default) = lea only. `--ops 8d,89,8b,01,03,39,3b` = the wider
scan you would actually need, since a member is read/written far more often
than its address is taken. The wider the opcode set and the smaller the
displacement, the worse the naive method gets -- that is the point.
"""
want = int(argv[0], 0)
ops = {0x8D}
for a in argv[1:]:
if a.startswith("--ops="):
ops = {int(x, 16) for x in a[6:].split(",")}
_, secs = load_pe(EXE)
d = load_index()
real = {s[0] for s in d["sites"] if s[1] == want}
found = []
d8 = want if -128 <= want <= 127 else None
pat32 = struct.pack("<i", want)
for sva, eva, buf, _ in secs:
for i in range(len(buf) - 6):
if buf[i] not in ops:
continue
m = buf[i + 1]
mod, rm = m >> 6, m & 7
if mod == 3 or mod == 0:
continue
k = i + 2 + (1 if rm == 4 else 0)
if mod == 1 and d8 is not None:
if struct.unpack_from("<b", buf, k)[0] == want:
found.append(sva + i)
elif mod == 2 and buf[k:k + 4] == pat32:
found.append(sva + i)
fp = [a for a in found if a not in real]
tag = ",".join(f"{o:02x}" for o in sorted(ops))
print(f"naive byte-scan (opcodes {tag}) for disp 0x{want:x}: "
f"{len(found)} candidate(s)")
print(f" real instructions at that address : {len(found) - len(fp)}")
print(f" NOT an instruction boundary : {len(fp)} "
f"({100.0 * len(fp) / max(1, len(found)):.1f}% false positive)")
missed = len(real) - (len(found) - len(fp))
print(f" real sites the naive scan MISSED : {missed} of {len(real)}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd, rest = sys.argv[1], sys.argv[2:]
{"build": lambda a: build(), "query": query, "func": func_dump,
"stats": lambda a: stats(), "brute": brute, "cohort": cohort,
"dis": dis}[cmd](rest)