sots-re/findings/subsystems/observedtech-append.md
alex 460cb7ca2b 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.
2026-09-08 04:44:18 -04:00

14 KiB
Raw Blame History

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.