sots-re/tools/strfootprint.py
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

210 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Whole-binary audit of the std::string member footprint in Sword of the Stars.exe.
Why this exists
---------------
`sizeof(std::string)` is load-bearing: it is embedded in ~65 recovered class
layouts, and a 4-byte error in it silently shifts every field that follows.
Lane X's ObservedTech work reported the embedded string as 0x18 bytes, against
the campaign-wide 0x1c. Arguing about it from one class is how you get a
plausible answer instead of a true one, so this settles it from the whole
binary at once.
Method
------
Every serialiser in this exe hands a member pointer to one of the Mars::Stream
primitive helpers with the same idiom:
push 0xff ; default arg
lea <r>,[<base>+<disp>] ; &this->member
push <r>
push <tag> ; -> .rdata "otch", "pswd", ...
push <stream>
call WriteString / ReadString / WriteBool / ...
So for each helper call we recover (base register, displacement, tag). Then,
per (function, base register), we take every OTHER displacement the same
function touches off that register. If sizeof(std::string) were 0x18 for some
instantiation, that class would necessarily have a real member somewhere in
(N+4 .. N+0x1b). The audit is: does one exist, anywhere?
Two things must be excluded or the answer is noise:
* ebp/esp bases -- those are stack temporaries, not class members. (A local
string at [ebp-0x2c] shows _Mysize at -0x1c and _Myres at -0x18, which
looks exactly like two "members" inside the span.)
* N+0x10 and N+0x14 -- the string's OWN _Mysize/_Myres, read inline whenever
the compiler inlines the SSO `_Myres >= 16 ? _Ptr : _Buf` test.
Result (2026-09-08, lane S): 65 string members off a non-stack base, ZERO with
a sibling inside the 0x1c span, and 51 of the 52 that have a next member have
it at exactly +0x1c. One layout, 0x1c, binary-wide.
Usage: uv run python3 tools/strfootprint.py [--all]
--all also lists every recovered string member and its gap.
"""
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from x86disp import decode, Desync, load_pe, EXE, FUNCS # noqa: E402
# Mars::Stream primitive wrappers (struct-recovery.md S0). All take
# (stream, tagname, &member, default) and are called from the class Read/Write.
HELPERS = {
0x008b9d70: "WriteString", 0x008b9d90: "ReadString",
0x008b9c20: "WriteBool", 0x008b9c00: "ReadBool",
0x008b9be0: "WriteFloat", 0x008b9bc0: "ReadFloat",
0x008b9d50: "WriteInt", 0x008b9d20: "ReadInt",
0x008b9d00: "WriteInt16", 0x008b9cd0: "ReadInt16",
0x008b9c60: "WriteInt64", 0x008b9c40: "ReadInt64",
}
STRING_HELPERS = ("WriteString", "ReadString")
STR_SIZE = 0x1c
SSO_OWN = (0x10, 0x14) # the string's own _Mysize / _Myres
STACK_BASES = ("ebp", "esp")
# ---------------------------------------------------------------- .rdata reader
def _load_rdata():
data = open(EXE, "rb").read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
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 = []
for i in range(nsec):
o = pe + 24 + optsz + i * 40
vsz, va, rsz, ro = struct.unpack_from("<IIII", data, o + 8)
secs.append((base + va, vsz, ro, rsz))
return data, secs
_DATA, _SECS = _load_rdata()
def cstr(va, maxn=16):
for sva, vsz, ro, rsz in _SECS:
if sva <= va < sva + max(vsz, rsz):
d = va - sva
if d >= rsz:
return None
o = ro + d
e = _DATA.find(b"\0", o, o + maxn)
if e < 0:
return None
try:
return _DATA[o:e].decode("ascii")
except UnicodeDecodeError:
return None
return None
def main():
show_all = "--all" in sys.argv
_, secs = load_pe(EXE)
sva, eva, buf, _ = secs[0]
funcs = json.load(open(FUNCS))
starts = sorted((int(k, 16), v[0]) for k, v in funcs.items())
svas = [s[0] for s in starts]
rd_lo, rd_hi = 0x9DD000, 0xAD9000
records, touch = [], {}
for idx, (fva, nm) in enumerate(starts):
if not (sva <= fva < eva):
continue
fend = svas[idx + 1] if idx + 1 < len(starts) else eva
ins, i, end = [], fva - sva, fend - sva
while i < end:
try:
ln, info = decode(buf, i, len(buf))
except Desync:
break
ins.append((sva + i, ln, info, buf[i:i + ln]))
i += ln
if not any(r[0] == 0xE8 and l == 5 and
(v + 5 + struct.unpack_from("<i", r, 1)[0]) in HELPERS
for v, l, _, r in ins):
continue
for v, l, info, r in ins:
if info and info["base"] is not None and info["disp"] is not None:
touch.setdefault((fva, info["base"]), set()).add(info["disp"])
for k, (v, l, _info, r) in enumerate(ins):
if not (r[0] == 0xE8 and l == 5):
continue
tgt = v + 5 + struct.unpack_from("<i", r, 1)[0]
if tgt not in HELPERS:
continue
tag, ptr_reg = None, None
for j in range(k - 1, max(-1, k - 15), -1):
rr, ll = ins[j][3], ins[j][1]
if rr[0] == 0x68 and ll == 5:
imm = struct.unpack_from("<I", rr, 1)[0]
if rd_lo <= imm < rd_hi:
s = cstr(imm)
if s and 1 <= len(s) <= 8 and s.isprintable():
tag = s
for m in range(j - 1, max(-1, j - 6), -1):
r2, l2 = ins[m][3], ins[m][1]
if 0x50 <= r2[0] <= 0x57 and l2 == 1:
ptr_reg = ["eax", "ecx", "edx", "ebx",
"esp", "ebp", "esi",
"edi"][r2[0] - 0x50]
break
break
base = disp = None
if ptr_reg:
for m in range(k - 1, max(-1, k - 25), -1):
i2 = ins[m][2]
if i2 and i2["lea"] and i2["reg"] == ptr_reg \
and i2["base"] is not None:
base, disp = i2["base"], i2["disp"]
break
records.append((fva, nm, base, disp, tag, HELPERS[tgt]))
strs = [r for r in records if r[5] in STRING_HELPERS]
members, stack, unresolved = [], 0, 0
for fva, nm, base, disp, tag, helper in strs:
if base is None or disp is None:
unresolved += 1
elif base in STACK_BASES:
stack += 1
else:
members.append((fva, nm, base, disp, tag, helper))
violations, gaps, seen = [], {}, set()
for fva, nm, base, disp, tag, helper in members:
others = sorted(d for d in touch.get((fva, base), set())
if d > disp and (d - disp) not in SSO_OWN)
inside = [d for d in others if d < disp + STR_SIZE]
if inside:
violations.append((fva, nm, base, disp, tag, helper, inside))
if others:
g = others[0] - disp
gaps[g] = gaps.get(g, 0) + 1
if show_all and (fva, base, disp, helper) not in seen:
seen.add((fva, base, disp, helper))
nxt = f"+0x{others[0]:x}" if others else "-"
print(f"0x{fva:08x} {nm:26s} {base} +0x{disp:<6x} {tag!r:12s} "
f"{helper:12s} next={nxt}")
print(f"\nstring helper call sites : {len(strs)}")
print(f" resolved to a class member offset : {len(members)}")
print(f" stack temporaries (ebp/esp base) : {stack}")
print(f" offset not reached by a plain lea : {unresolved}")
print(f"\nmembers with a sibling inside (N, N+0x{STR_SIZE:x}) "
f": {len(violations)} <-- must be 0 for sizeof(std::string)==0x1c")
for v in violations:
print(f" 0x{v[0]:08x} {v[1]} {v[2]}+0x{v[3]:x} {v[4]!r} "
f"inside={[hex(x) for x in v[6]]}")
print("\ngap from a string member to the next member on the same base:")
for g in sorted(gaps):
print(f" +0x{g:<4x} : {gaps[g]}")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main())