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.
22 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.
Superseded 2026-09-08 by lane S — see §9. The table as first written called the embedded string 0x18 bytes and left
+0x08,+0x24,+0x28unaccounted.+0x24is 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.
ObservedTech::Write0x00817cf0serialises+0x04, +0x06, +0x08, +0x0c, +0x28. No+0x24. (Read0x00817c40mirrors it.)ObservedTech_ctor0x008562a0initialises+0x00, +0x04, +0x08, +0x0c(string), +0x28. No+0x24— while zeroing every other scalar in the object.- The copy constructor, inlined at
0x0079a184inside the vector's uninitialised-copy helperFUN_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/espbases are stack temporaries, not members. A local string at[ebp-0x2c]shows accesses at-0x1cand-0x18— which read exactly like two sibling fields inside the span. All 30 such sites are excluded.N+0x10andN+0x14are the string's own_Mysize/_Myres, touched inline whenever the compiler expands the_Myres >= 16 ? _Ptr : _Buftest at a call site.
Independent corroboration outside the scan
FUN_00699bd0walks avector<std::string>doing the SSO test at[esi+0x14]/[esi]— element base is string base — and advancesadd esi,0x1c(0x00699c29). The stride of a vector of strings issizeof(std::string).EventStorage::PostEvent0x008862b0takes two by-valuestd::strings at[ebp+0x08]and[ebp+0x24](spacing0x1c) andRET 0x4c=2*0x1c + 5*4.MoraleEventis 0x50 bytes with its name string at+0x34:0x34 + 0x1c = 0x50exactly.
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.