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.
14 KiB
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=5andmod=0,rm=4,sib.base=5are disp32 with no base — an absolute global address, not a member offset. Excluded from member queries. (Without this, everymov eax,[0x00a2bd88]in the binary shows up as a "displacement".)mod=1disp8 is sign-extended:-0x08must not be reported as+0xf8.mod=3is a register operand with no displacement at all.0x67address-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 sizeInBytesand lost 11% of functions to mid-instruction truncation — Ghidra'ssizeInBytesunderstates real bodies often enough to matter (it was cutting validmov esp,ebp; pop ebp; retepilogues 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.