sots-re/findings/subsystems/observedtech-append.md
alex f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.

Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.

ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.

Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00

22 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.

Superseded 2026-09-08 by lane S — see §9. The table as first written called the embedded string 0x18 bytes and left +0x08, +0x24, +0x28 unaccounted. +0x24 is not a field: it is the string's own trailing allocator word. The corrected map is below; the original reasoning is kept in §9 because the way it went wrong is the useful part.

offset size member evidence
+0x00 4 vptr 0x00a2439c ctor writes it, RTTI-confirmed
+0x04 2 uint16 otnF (turn first observed) mov word [eax-0x28],cx at 0x007ba2b0 (eax = _Mylast, element = _Mylast-0x2c), source [ebx+0xc]; tag from ObservedTech::Write
+0x06 2 uint16 otnL (turn last observed) mov word [eax-0x26],dx at 0x007ba2c6, same source word [ebx+0xc] — first sighting sets first == last
+0x08 1 bool odet ctor stores a byte (mov [esi+0x8],bl, 0x008562f4); copy-ctor copies a byte; serialised with WriteBool/ReadBool
+0x0c..+0x27 0x1c std::string otch (tech name) object base +0x0c, MSVC {_Bx[16] @0, _Mysize @0x10, _Myres @0x14, _Alval @0x18}. Ctor writes [+0x0c]=0, [+0x1c]=0, [+0x20]=0xf; the search loop reads [+0x1c] as the length and calls compare with this = +0x0c; the post-append assign uses lea ecx,[_Mylast-0x20] = +0x0c. +0x24 is _Alval — never read, never written, by anything
+0x28 4 int owith ctor zeroes it; ObservedTech::Write emits it last

4 + 2 + 2 + 1(+3 pad) + 0x1c + 4 = 0x2c exactly — sizeof is fully accounted for, with no padding slack and no unaccounted field.

Where the mapping came from — the serializer

Game::ObservedTech's vftable 0x00a2439c is the usual 3 slots { [0] 0x00793610 scalar deleting dtor, [1] 0x00817c40 Read, [2] 0x00817cf0 Write }. Write enumerates the entire object, in order, and touches nothing else:

0x00817cff  movzx ecx,word [edi+0x04]   push 0xa2b38c "otnF"   -> stream vft+0x24 (int)
0x00817d0f  movzx ecx,word [edi+0x06]   push 0xa2b384 "otnL"   -> stream vft+0x24 (int)
0x00817d26  lea   eax,[edi+0x08]        push 0xa2b36c "odet"   -> WriteBool  0x008b9c20
0x00817d37  lea   ecx,[edi+0x0c]        push 0xa2b3e8 "otch"   -> WriteString 0x008b9d70
0x00817d46  mov   eax,[edi+0x28]        push 0xa2b364 "owith"  -> stream vft+0x24 (int)

Read (0x00817c40) is the exact mirror: otnF/otnL read as ints and stored back with 16-bit mov word [ebx],ax, odet through ReadBool, otch through ReadString, owith reached as add edi,0x28. That matches the on-disk order save_reader.py already had (Otch = otnF otnL odet otch(string) owith), which is a nice independent agreement between the disassembly and the save oracle.

Game::ObservedWeapon (Write 0x00817bc0, Read 0x00817b10) is the same element shape with tag owep (0x00a2b3f0) in place of otch — a second instance of the identical 0x2c layout.

Lane P's observed_techs region byte delta remains a valid live cross-check and should 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.

Lane S round (2026-09-08). Labels: ObservedTech_Write 0x00817cf0, ObservedTech_Read 0x00817c40, ObservedTech_scalar_deleting_dtor 0x00793610, ObservedWeapon_Write 0x00817bc0, ObservedWeapon_Read 0x00817b10, vector_ObservedTech_uninit_copy 0x0079a150, vector_string_find_by_name 0x00699bd0. Prototypes on the five class methods. Plate comments carrying the full member map on the two serializers, the ctor, the dtor and the copy helper, and the sizeof(std::string) = 0x1c fact on Stream::WriteString 0x008b9d70 and on the vector<string> stride site 0x00699bd0 — the two places a future lane is most likely to look. addresses.json +10 entries (std_string_sizeof, ObservedTech_Read/_Write/_dtor/_copy_ctor, ObservedTech_off_TurnFirst/_TurnLast/_Detected/_With, ObservedWeapon_Write), 4 corrected.


9. std::string is 0x1c, not 0x18 — the correction, and why it mattered (lane S, 2026-09-08)

§4 above originally reported the embedded string as 0x18 bytes, against the campaign-wide _Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18, sizeof 0x1c. Lane X flagged it as "worth re-checking" rather than asserting it, which was the right call: 0x1c is correct, and it is correct everywhere in this binary. ObservedTech+0x24 is the string's own trailing allocator word, not a data member.

Why the 0x18 reading looked right

Everything lane X observed was accurate. The string's live fields really do stop at +0x14 (_Bx@0, _Mysize@0x10, _Myres@0x14), because _Alval is std::allocator<char> — an empty class. It occupies a word of the object but is never loaded or stored, so it is invisible to any evidence based on what the code touches. Sizing a type from its accessed fields undercounts it by exactly the tail padding. The same trap is live for std::vector in this build, which is {_Myfirst, _Mylast, _Myend, _Alval} = 0x10 while only three words are ever read (events.md already records the _Alval word at EventStorage+0x10 and +0x14).

The three things that settle it inside ObservedTech

Each is a complete enumeration of the object, which is the right instrument here — an enumeration can show a field's absence, a touch-scan cannot.

  1. ObservedTech::Write 0x00817cf0 serialises +0x04, +0x06, +0x08, +0x0c, +0x28. No +0x24. (Read 0x00817c40 mirrors it.)
  2. ObservedTech_ctor 0x008562a0 initialises +0x00, +0x04, +0x08, +0x0c(string), +0x28. No +0x24 — while zeroing every other scalar in the object.
  3. The copy constructor, inlined at 0x0079a184 inside the vector's uninitialised-copy helper FUN_0079a150, copies +0x04, +0x06, +0x08, the string at +0x0c, and +0x28. No +0x24.

A 4-byte data member that the constructor, the copy constructor and the serializer all ignore is not a data member.

Binary-wide check — tools/strfootprint.py

One class is an anecdote, so the same question was put to the whole binary. Every serializer hands member pointers to the Mars::Stream primitive helpers with a fixed idiom (lea r,[base+disp]; push r; push tag; push stream; call helper), so the scanner recovers (base, disp, tag) for every string site and then asks: does the same function touch any other offset inside (N, N+0x1c) off the same base register?

string helper call sites            : 241
  resolved to a class member offset : 65
  stack temporaries (ebp/esp base)  : 30
  offset not reached by a plain lea : 146

members with a sibling inside (N, N+0x1c) : 0

gap from a string member to the next member on the same base:
  +0x1c   : 51
  +0x20   : 1

65 string members across every serializer in the exe, zero collisions, and 51 of the 52 measurable gaps are exactly 0x1c. The single +0x20 is StrategyServer::KeyPath at +0x134, whose Read side gives +0x1c to +0x150 — the writer simply skips a member. There is no 0x18 instantiation, no empty-base-optimised variant, and no game-local string class. One layout, 0x1c.

Two exclusions matter or the scan reports noise, and both are why a naive version of this would have "confirmed" 0x18:

  • ebp/esp bases are stack temporaries, not members. A local string at [ebp-0x2c] shows accesses at -0x1c and -0x18 — which read exactly like two sibling fields inside the span. All 30 such sites are excluded.
  • N+0x10 and N+0x14 are the string's own _Mysize/_Myres, touched inline whenever the compiler expands the _Myres >= 16 ? _Ptr : _Buf test at a call site.

Independent corroboration outside the scan

  • FUN_00699bd0 walks a vector<std::string> doing the SSO test at [esi+0x14]/[esi] — element base is string base — and advances add esi,0x1c (0x00699c29). The stride of a vector of strings is sizeof(std::string).
  • EventStorage::PostEvent 0x008862b0 takes two by-value std::strings at [ebp+0x08] and [ebp+0x24] (spacing 0x1c) and RET 0x4c = 2*0x1c + 5*4.
  • MoraleEvent is 0x50 bytes with its name string at +0x34: 0x34 + 0x1c = 0x50 exactly.

Blast radius — what actually had to change

Nothing in any recovered struct table. Every string-bearing layout in struct-recovery.md, save-editor-structs.md, events.md and schema-gaps-resolved.md already used 0x1c spans and 0x1c inter-field gaps, and the binary agrees with all of them. ServerPlayer::pswd at 0x2dc..0x2f7 — the row §4 asked to re-check — is correct: Write 0x008563e0 puts the next member exactly 0x1c above it. The corrections were confined to three prose statements that had propagated the 0x18 number (loader-prototypes.md conventions line, the GlobalConst_ParseString prototype in addresses.json, and §4 here) plus one transposition in struct-recovery.md §0 that said size@0x14, res@0x18 where the verified offsets are 0x10/0x14.

The lesson worth carrying, and the reason this was worth chasing rather than reconciling: do not size a struct member from the offsets the code touches. Trailing empty-allocator words in this build's STL are invisible to a touch-scan and cost exactly 4 bytes every time. Size types from an enumeration — a serializer, a constructor, a copy constructor, or a container stride.