sots-re/tools/serializers.py
alex d7ea0a048c lane D: automated struct recovery from the IStreamable serializers
Every serializable class carries an enumeration of its own fields -- its
Write(Stream&), walking the members in order with a 4-char tag. This decodes
that idiom mechanically for the whole binary in 0.35 s.

Validation first (tools/serializers.py validate), against answers the campaign
already had before the tool existed:
  A  305/307 field offsets+kinds exact across 17 classes, 0 WRONG, vs
     struct-recovery.md 1-4 and observedtech-append.md
  B  sizeof from the container-stride divides: ObservedTech 0x2c, MoraleEvent
     0x50, PlayerReport 0x30, DiplomacyStats 0x24 -- all matching
  C  22 of save_reader.py's shapes, tag order identical (Sys 78 tags,
     Player 104, CreateParams 25, Ship 22): 22 agree, 0 disagree
  D  Read/Write cross-check on every class: 437/437 field offsets agree

At scale: 386 classes with a Write, 1,682 member fields.
  verified 87 (542 fields) | clean 77 (328) | unnamed 176 (471)
  partial 31 (341) | empty 15
  58 classes with a sizeof corroborated by a second line of evidence
  (45 container stride, 13 enumeration meeting the embedding bound); the rest
  report a lower bound and say so.

Four things each worth 10-170 classes: the RTTI class hierarchy descriptor as
the only honest "is this an IStreamable" test (a 3-slot vftable also matches
TacAISquadRule_* and the row parsers); mod=0 memory operands, which x86disp.py
cannot index and which hide every field at offset 0; the member->id pointer
idiom behind every handle field; and sub-writers, both base-class and private
(StrategyServer's six id lists live in FUN_00794cd0).

Failure classes are enumerated in the finding -- 176 anonymous-tag classes are
a hard limit on names but not on layout, and the other 64 are bounded
mechanical fixes. Two fields lost to a value assembled across a branch were
left unrecovered rather than patched with an unverifiable heuristic.

Write-back: 288 structures + 328 labels into Ghidra (0 failures), +201
addresses.json entries, header regenerated with tools/gen_addresses.py.

Note: ghidra/addresses.json also carries lane V's already-written live
confirmation text on ObservedTech_sizeof and ServerPlayer_off_ObservedTechs --
their edit, swept in only because we share the file.
2026-09-08 05:51:37 -04:00

1425 lines
61 KiB
Python

#!/usr/bin/env python3
"""Automated struct recovery from the `Mars::IStreamable` serializers.
The idea
--------
Lane S's rule: never size a struct member from the offsets the code *touches* --
size it from an **enumeration**. `std::allocator<char>` is an empty class; it
occupies a word and is never loaded or stored, so a touch-scan undercounts every
`std::string` by exactly 4 and every `std::vector` by exactly 4. An enumeration
can show *absence*; a touch-scan cannot.
Every serializable class in this exe carries such an enumeration: its
`Write(Stream&)`, which walks the class's fields in order, each with a 4-char
name tag. `ObservedTech::Write` @0x00817cf0 gave the complete field list, tags
and all, and it matched `save_reader.py`'s on-disk order exactly. This tool does
that mechanically for every serializer in the binary.
The idioms it decodes
---------------------
Writer side, two calling conventions, one shape:
push 0xff ; default-value argument
<member expression> ; lea r,[this+D] | mov r,[this+D] | movzx ...
push r
push <tag> ; -> .rdata "otch", "pswd", ...
push <stream> / mov ecx,<stream>
call WriteString / WriteBool / ... | call [streamvft+slot]
so the push immediately before the tag push is always the member. Wrapper
helpers are direct `call rel32`; the compiler also inlines them as
`call [[stream]+slot]`, and the slot is what names the type:
+0x18 string +0x1c bool +0x20 float +0x24 int +0x28 nested
+0x30 raw bytes (n=8 -> int64)
Nested members go through a `StreamableHelper<T>` / `VectorHelper<T>` built on
the stack as `{vptr, 0, T*}`; the vptr is an RTTI'd vftable, so the *type* of the
nested member is recovered by name, not guessed.
Sizes are then assigned from the kind, never from the touched offsets:
bool 1 | int16 2 | int/float/enum/handle 4 | int64 8
std::string 0x1c (allocator-last, invisible to a touch scan)
std::vector 0x10 (allocator-last, likewise)
nested T sizeof(T), resolved recursively when T is itself serializable
Usage
-----
uv run python3 tools/serializers.py find # enumerate serializers
uv run python3 tools/serializers.py dump 0x00817cf0
uv run python3 tools/serializers.py validate # known layouts, first
uv run python3 tools/serializers.py all # -> objects/layouts.json
"""
import bisect
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)
import x86disp # noqa: E402
from x86disp import decode, Desync, EXE, FUNCS # noqa: E402
from rtti_map import Image, load as load_rtti # noqa: E402
OUT = os.path.join(REPO, "objects")
R32 = ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]
PREFIX = {0x66, 0x67, 0xF0, 0xF2, 0xF3, 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65}
# Stream vftable slots (derived by disassembling the wrappers below, not assumed)
SLOT_KIND = {0x18: "string", 0x1c: "bool", 0x20: "float", 0x24: "int",
0x28: "nested", 0x2c: "nested2", 0x30: "raw"}
# Wrapper helpers: VA -> (kind, fixed width or None)
HELPERS = {
0x008b9d70: ("string", 0x1c), 0x008b9d90: ("string", 0x1c),
0x008b9c20: ("bool", 1), 0x008b9c00: ("bool", 1),
0x008b9be0: ("float", 4), 0x008b9bc0: ("float", 4),
0x008b9d50: ("int", 4), 0x008b9d20: ("int", 4),
0x008b9d00: ("int16", 2), 0x008b9cd0: ("int16", 2),
0x008b9c60: ("int64", 8), 0x008b9c40: ("int64", 8),
0x00816490: ("handle", 4), 0x008164d0: ("handle", 4),
}
READ_HELPERS = {0x008b9d90, 0x008b9c00, 0x008b9bc0, 0x008b9d20,
0x008b9cd0, 0x008b9c40, 0x008164d0}
KIND_SIZE = {"string": 0x1c, "bool": 1, "float": 4, "int": 4, "int16": 2,
"int64": 8, "handle": 4, "enum": 4, "vector": 0x10}
# ------------------------------------------------------------------ anatomy
def _has_modrm(raw):
"""Return (op2, opcode, modrm_index or None) for one instruction's bytes."""
j = 0
while j < len(raw) and raw[j] in PREFIX:
j += 1
if j >= len(raw):
return False, None, None
op2 = False
op = raw[j]
j += 1
if op == 0x0F:
op2 = True
op = raw[j]
j += 1
ent = x86disp._TWO.get(op)
else:
ent = x86disp._ONE.get(op)
if ent is None:
return op2, op, None
return op2, op, (j if ent[0] else None)
class Insn:
__slots__ = ("va", "raw", "info", "op", "op2", "modrm", "mod", "reg", "rm")
def __init__(self, va, raw, info):
self.va, self.raw, self.info = va, raw, info
self.op2, self.op, mi = _has_modrm(raw)
if mi is not None and mi < len(raw):
self.modrm = raw[mi]
self.mod = self.modrm >> 6
self.reg = (self.modrm >> 3) & 7
self.rm = self.modrm & 7
# x86disp only reports operands that carry a *displacement*, so a
# `mov eax,[edi]` -- member at offset 0 -- is invisible to it. For
# a plain struct (`.?AU...`, no vptr) offset 0 is a real field, so
# synthesise the mod=0 case here.
if self.info is None and self.mod == 0 and self.rm != 5:
if self.rm == 4:
sib = raw[mi + 1] if mi + 1 < len(raw) else None
if sib is not None and (sib & 7) != 5:
idx = (sib >> 3) & 7
self.info = {"base": R32[sib & 7],
"index": None if idx == 4 else R32[idx],
"scale": 1 << (sib >> 6), "disp": 0,
"dispsize": 0, "reg": R32[self.reg],
"lea": (not self.op2 and self.op == 0x8D),
"mnem": ""}
else:
self.info = {"base": R32[self.rm], "index": None,
"scale": 1, "disp": 0, "dispsize": 0,
"reg": R32[self.reg],
"lea": (not self.op2 and self.op == 0x8D),
"mnem": ""}
else:
self.modrm = self.mod = self.reg = self.rm = None
def body(img, funcs_sorted, starts, fva):
"""Decode a function body, sweeping to the next function start."""
j = bisect.bisect_right(starts, fva)
limit = starts[j] if j < len(starts) else None
buf, off, _ = img.find(fva)
if buf is None:
return []
end = len(buf) if limit is None else min(len(buf), off + (limit - fva))
out, i = [], off
while i < end:
try:
ln, info = decode(buf, i, end)
except Desync:
break
out.append(Insn(fva + (i - off), buf[i:i + ln], info))
i += ln
if len(out) > 60000:
break
return out
# -------------------------------------------------------------- extraction
class Extractor:
def __init__(self, img, rtti_vfts, fnames, funcs_sorted, starts):
self.img = img
self.vfts = rtti_vfts
self.fnames = fnames
self.funcs = funcs_sorted
self.starts = starts
self.extra = {} # discovered forwarding sub-writers
# -- tag strings ------------------------------------------------------
def tag(self, va):
if va == 0:
return "."
buf, o, sec = self.img.find(va)
if buf is None or sec not in (".rdata", ".data"):
return None
e = buf.find(b"\0", o, o + 24)
if e < 0:
return None
s = buf[o:e]
if not (1 <= len(s) <= 16):
return None
try:
t = s.decode("ascii")
except UnicodeDecodeError:
return None
return t if t.isprintable() and not t.isspace() else None
# -- one serializer ---------------------------------------------------
def run(self, fva, debug=False):
ins = body(self.img, self.funcs, self.starts, fva)
if not ins:
return None
n = len(ins)
this_regs = {"ecx"}
this_spill = set()
regdef = {} # reg -> record
stkdef = {} # (base, disp) -> record
prov = {} # reg -> (this-base, disp, insn idx)
pushes = [] # records since the last call
side = [0, 0] # [write-side calls, read-side calls]
containers = {} # (base, disp) -> element stride
fields = []
calls = [] # non-helper calls (base-class Write, etc.)
notes = []
def mem_rec(info, width, kind):
b = info["base"]
return {"k": kind, "base": b, "disp": info["disp"], "width": width,
"this": b in this_regs, "index": info["index"]}
for k, x in enumerate(ins):
info, raw = x.info, x.raw
op, op2, mod = x.op, x.op2, x.mod
# ---- calls -------------------------------------------------
is_call = (op == 0xE8 and not op2) or \
(op == 0xFF and not op2 and x.reg == 2)
if is_call:
self._call(x, k, ins, pushes, regdef, stkdef, fields, calls,
this_regs, notes, debug, prov, k, side)
pushes = []
for r in ("eax", "ecx", "edx"):
regdef.pop(r, None)
this_regs.discard(r)
prov.pop(r, None)
continue
# ---- pushes ------------------------------------------------
if not op2 and op == 0x68 and len(raw) == 5:
pushes.append({"k": "imm",
"v": struct.unpack_from("<I", raw, 1)[0]})
continue
if not op2 and op == 0x6A:
pushes.append({"k": "imm",
"v": struct.unpack_from("<b", raw, 1)[0] & 0xFFFFFFFF})
continue
if not op2 and 0x50 <= op <= 0x57 and len(raw) == 1:
r = R32[op - 0x50]
if r in this_regs:
# the member is at offset 0, so the compiler pushed `this`
# itself rather than emitting `lea r,[r+0]`
pushes.append({"k": "addr", "base": r, "disp": 0,
"this": True, "width": None, "reg": r})
else:
pushes.append(dict(regdef.get(r, {"k": "?", "reg": r}),
reg=r))
continue
if not op2 and op == 0xFF and x.reg == 6:
if info:
key = (info["base"], info["disp"])
if info["base"] in ("ebp", "esp") and key in stkdef:
pushes.append(dict(stkdef[key]))
else:
pushes.append(mem_rec(info, 4, "load"))
else:
pushes.append({"k": "?"})
continue
if not op2 and op == 0x8D and info: # lea
dst = R32[x.reg]
regdef[dst] = mem_rec(info, None, "addr")
if info["base"] in prov and info["base"] not in this_regs:
regdef[dst]["prov"] = prov[info["base"]][:2]
if dst != info["base"]:
prov.pop(dst, None)
this_regs.discard(dst)
continue
# ---- register / stack definitions ---------------------------
if not op2 and op == 0x8B: # mov r, rm
dst = R32[x.reg]
if mod == 3:
src = R32[x.rm]
if src in this_regs:
this_regs.add(dst)
else:
this_regs.discard(dst)
if src in regdef:
regdef[dst] = regdef[src]
else:
regdef.pop(dst, None)
elif info:
key = (info["base"], info["disp"])
# `mov edi,[edi+0x1c0]` overwrites the this-register with a
# pointer member -- so the base's this-ness has to be read
# BEFORE the destination is retired, or the field is lost.
base_this = info["base"] in this_regs
base_prov = prov.get(info["base"])
if key in this_spill:
this_regs.add(dst)
else:
this_regs.discard(dst)
if info["base"] in ("ebp", "esp") and key in stkdef:
regdef[dst] = stkdef[key]
else:
regdef[dst] = mem_rec(info, 4, "load")
regdef[dst]["this"] = base_this
if base_prov and not base_this:
# loading *through* a pointer member: the value
# serialised is `member->id` / `member->name`, so
# the member is the pointer at the remembered
# this-offset. This is the `NetworkObject id @+4`
# handle idiom, and it is why a plain "which offset
# was pushed" read misses every handle field.
regdef[dst]["prov"] = base_prov[:2]
r = regdef.get(dst, {})
if r.get("k") == "load" and r.get("this"):
prov[dst] = (r["base"], r["disp"], k)
elif "prov" not in r:
prov.pop(dst, None)
else:
regdef.pop(dst, None)
this_regs.discard(dst)
continue
if op2 and op in (0xB6, 0xB7, 0xBE, 0xBF) and info: # movzx/movsx
dst = R32[x.reg]
w = 1 if op in (0xB6, 0xBE) else 2
regdef[dst] = mem_rec(info, w, "load")
regdef[dst]["signed"] = op in (0xBE, 0xBF)
this_regs.discard(dst)
continue
if not op2 and 0xB8 <= op <= 0xBF and len(raw) >= 5: # mov r,imm32
dst = R32[op - 0xB8]
regdef[dst] = {"k": "imm",
"v": struct.unpack_from("<I", raw, len(raw) - 4)[0]}
this_regs.discard(dst)
continue
if not op2 and op == 0x33 and mod == 3 and x.reg == x.rm: # xor r,r
dst = R32[x.reg]
regdef[dst] = {"k": "imm", "v": 0}
this_regs.discard(dst)
continue
if not op2 and op == 0x89: # mov rm, r
src = R32[x.reg]
if mod == 3:
dst = R32[x.rm]
if src in this_regs:
this_regs.add(dst)
else:
this_regs.discard(dst)
if src in regdef:
regdef[dst] = regdef[src]
else:
regdef.pop(dst, None)
elif info:
key = (info["base"], info["disp"])
if info["base"] in ("ebp", "esp"):
stkdef[key] = dict(regdef.get(src, {"k": "?"}))
if src in this_regs:
this_spill.add(key)
else:
this_spill.discard(key)
continue
if not op2 and op == 0xC7 and info and mod != 3: # mov rm, imm32
key = (info["base"], info["disp"])
if info["base"] in ("ebp", "esp") and len(raw) >= 4:
stkdef[key] = {"k": "imm",
"v": struct.unpack_from("<I", raw, len(raw) - 4)[0]}
continue
# ---- container size: (_Mylast - _Myfirst) / stride -----------
# A count argument is never a member; it is computed from the
# container. Recovering the *container's* offset from the span
# subtraction is what turns "NumFlts" into a real vector member.
if not op2 and op == 0x2B: # sub r32, rm32
dst = R32[x.reg]
d0 = regdef.get(dst)
span = None
if mod == 3:
d1 = regdef.get(R32[x.rm])
if d0 and d1 and d0.get("k") == "load" and \
d1.get("k") == "load" and d0.get("base") and \
d0["base"] == d1["base"] and \
d0["disp"] == d1["disp"] + 4:
span = (d1["base"], d1["disp"], d1.get("this"))
elif info and d0 and d0.get("k") == "load" and \
d0.get("base") == info["base"] and \
d0.get("disp") == info["disp"] + 4:
span = (info["base"], info["disp"], info["base"] in this_regs)
if span:
regdef[dst] = {"k": "vecspan", "base": span[0],
"disp": span[1], "this": span[2],
"width": 0x10, "magic": None, "shift": None}
else:
regdef.pop(dst, None)
this_regs.discard(dst)
continue
if not op2 and op == 0xF7 and mod == 3 and x.reg in (4, 5):
# mul/imul rm -> edx:eax. The MSVC magic-number divide by a
# non-power-of-two stride runs the span through this.
src = regdef.get(R32[x.rm], {})
if src.get("k") == "vecspan":
m = regdef.get("eax", {})
src = dict(src)
if m.get("k") == "imm":
src["magic"] = m["v"]
regdef["edx"] = dict(src)
regdef["eax"] = dict(src)
else:
regdef.pop("edx", None)
regdef.pop("eax", None)
this_regs.discard("eax")
this_regs.discard("edx")
continue
# add/sub reg,imm on a this-pointer: a sub-object adjustment
if not op2 and op in (0x83, 0x81) and mod == 3 and x.reg in (0, 5):
dst = R32[x.rm]
imm = struct.unpack_from("<b" if op == 0x83 else "<i",
raw, len(raw) - (1 if op == 0x83 else 4))[0]
if x.reg == 5:
imm = -imm
if dst in this_regs:
regdef[dst] = {"k": "addr", "base": dst, "disp": imm,
"this": True, "width": None}
this_regs.discard(dst)
continue
d0 = regdef.get(dst)
if d0 and d0.get("k") == "addr" and d0.get("this"):
regdef[dst] = dict(d0, disp=d0["disp"] + imm)
continue
if d0 and d0.get("k") == "vecspan":
continue
regdef.pop(dst, None)
continue
if (not op2 and op in (0xC1, 0xD1) and mod == 3) or \
(not op2 and op in (0x69, 0x6B)) or (op2 and op == 0xAF):
dst = R32[x.reg] if (op in (0x69, 0x6B) or op2) else R32[x.rm]
d0 = regdef.get(dst, {})
if d0.get("k") == "vecspan":
# `sar reg,k` completes the divide: with a preceding magic
# multiply the divisor is the element stride, without one
# the stride is 1<<k. Either way this is the container's
# element size, read off the code rather than guessed.
if x.reg == 7 and d0.get("shift") is None:
k = raw[-1] if op == 0xC1 else 1
d0["shift"] = k
d0["stride"] = (stride_from_magic(d0["magic"], k)
if d0.get("magic") else (1 << k))
containers[(d0["base"], d0["disp"],
bool(d0.get("this")))] = d0.get("stride")
continue # span survives
regdef.pop(dst, None)
this_regs.discard(dst)
continue
# ---- anything else that writes a register --------------------
if not op2 and op in (0x01, 0x03, 0x29, 0x2B, 0x31, 0x21, 0x09,
0x0B, 0x23, 0x19, 0x1B, 0x11, 0x13) \
and mod == 3:
dst = R32[x.rm] if op in (0x01, 0x29, 0x31, 0x21, 0x09, 0x19,
0x11) else R32[x.reg]
regdef.pop(dst, None)
this_regs.discard(dst)
elif not op2 and 0x40 <= op <= 0x4F:
r = R32[(op - 0x40) & 7]
regdef.pop(r, None)
this_regs.discard(r)
return {"va": fva, "fields": fields, "calls": calls, "notes": notes,
"insns": n, "side": side,
"strides": {d: v for (b, d, t), v in containers.items()
if v and t},
"containers": [{"base": b, "disp": d, "this": t, "stride": v}
for (b, d, t), v in containers.items() if v]}
# -- one call site ----------------------------------------------------
def _call(self, x, k, ins, pushes, regdef, stkdef, fields, calls,
this_regs, notes, debug, prov=None, idx=0, side=None):
# target
tgt = None
slot = None
if x.op == 0xE8:
tgt = (x.va + len(x.raw) +
struct.unpack_from("<i", x.raw, 1)[0]) & 0xFFFFFFFF
else:
if x.mod == 3: # call reg
r = R32[x.rm]
d = regdef.get(r)
if d and d.get("k") == "load" and d.get("disp") in SLOT_KIND:
slot = d["disp"]
elif x.info and x.info["disp"] in SLOT_KIND: # call [reg+slot]
slot = x.info["disp"]
kind = width = None
if tgt is not None and tgt in HELPERS:
kind, width = HELPERS[tgt]
if side is not None:
side[1 if tgt in READ_HELPERS else 0] += 1
elif tgt is not None and tgt in self.extra:
kind, width = self.extra[tgt]
elif slot is not None:
kind = SLOT_KIND[slot]
if side is not None:
side[0] += 1
elif tgt is not None:
# not a stream primitive: could be a base-class Write, called
# thiscall with ecx = this (+ a fixed sub-object adjustment).
adj = None
if "ecx" in this_regs:
adj = 0
else:
d = regdef.get("ecx")
if d and d.get("k") == "addr" and d.get("this"):
adj = d["disp"]
calls.append({"tgt": tgt, "va": x.va, "adj": adj})
return
else:
return
# tag = the last pushed .rdata short string; member = the push before it
ti = None
for i in range(len(pushes) - 1, -1, -1):
p = pushes[i]
if p.get("k") == "imm" and self.tag(p["v"]) is not None:
ti = i
break
if ti is None or ti == 0:
# A *forwarding* writer -- `f(stream, name, member)` that passes the
# caller's name straight through to a stream primitive -- has no
# constant tag of its own. Spotting them is what lets the caller's
# `push "PlayerIDs"; call f` be read as a field at all.
if any(p.get("k") == "load" and p.get("base") == "ebp" and
p.get("disp") == 0xC for p in pushes):
notes.append("FORWARDER")
else:
notes.append(f"0x{x.va:08x} {kind}: no tag/member pair")
return
tag = self.tag(pushes[ti]["v"])
m = pushes[ti - 1]
rec = {"va": x.va, "tag": tag, "kind": kind, "slot": slot,
"helper": tgt if tgt in HELPERS else None}
# raw-bytes writes carry their length as the push before the pointer
if kind == "raw":
nb = pushes[ti - 2] if ti >= 2 else None
if nb and nb.get("k") == "imm":
rec["bytes"] = nb["v"]
if nb["v"] == 8:
kind = rec["kind"] = "int64"
m = pushes[ti - 1]
# `WriteInt`/`WriteFloat`/... take a *pointer*, so a computed value (a
# container count, a NULL-test gate) is spilled to a stack local whose
# address is pushed -- sometimes stored after the push, before the call.
if m.get("k") == "addr" and m.get("base") in ("ebp", "esp") \
and kind not in ("nested", "nested2"):
sp = stkdef.get((m["base"], m["disp"]))
if sp is not None:
m = sp
if kind in ("nested", "nested2"):
self._nested(rec, m, stkdef)
elif m.get("k") == "vecspan":
# the argument was a container *count*, computed as
# (_Mylast - _Myfirst)/stride -- so the member is the container
rec["kind"] = "vector"
rec["base"] = m.get("base")
rec["off"] = m.get("disp")
rec["this"] = bool(m.get("this"))
rec["size"] = 0x10
rec["count_of"] = True
elif m.get("prov") and not m.get("this"):
# a pointer member, serialised through what it points at
rec["kind"] = "handle"
rec["base"] = m["prov"][0]
rec["off"] = m["prov"][1]
rec["this"] = True
rec["size"] = 4
rec["via"] = "deref"
elif m.get("k") in ("addr", "load"):
rec["base"] = m.get("base")
rec["off"] = m.get("disp")
rec["this"] = bool(m.get("this"))
if kind == "int" and m.get("k") == "load" and m.get("width"):
w = m["width"]
if w == 2:
rec["kind"] = "int16"
elif w == 1:
rec["kind"] = "int8"
rec["size"] = KIND_SIZE.get(rec["kind"], 4)
elif m.get("k") == "imm":
rec["kind"] = "const"
rec["const"] = m["v"]
else:
rec["kind"] = rec["kind"] + "?"
rec["unresolved"] = True
fields.append(rec)
def _nested(self, rec, m, stkdef):
"""A nested member goes through {vptr, 0, T*} built on the stack."""
if m.get("k") != "addr" or m.get("base") not in ("ebp", "esp"):
rec["unresolved"] = True
return
b, d = m["base"], m["disp"]
vp = stkdef.get((b, d))
tp = stkdef.get((b, d + 8))
if vp and vp.get("k") == "imm" and vp["v"] in self.vfts:
hv = self.vfts[vp["v"]]
rec["helper_vftable"] = vp["v"]
rec["type"] = hv["class"]
inner, isvec = helper_inner(hv["mangled"])
rec["inner"] = inner
if isvec:
rec["kind"], rec["size"] = "vector", 0x10
elif inner.startswith("enum:"):
rec["kind"], rec["size"] = "enum", 4
else:
rec["kind"] = "object"
if tp and tp.get("k") == "addr":
rec["base"] = tp.get("base")
rec["off"] = tp.get("disp")
rec["this"] = bool(tp.get("this"))
elif tp and tp.get("k") == "load":
rec["base"] = tp.get("base")
rec["off"] = tp.get("disp")
rec["this"] = bool(tp.get("this"))
rec["ptr"] = True
rec["size"] = 4
else:
rec["unresolved"] = True
def stride_from_magic(magic, shift):
"""MSVC emits `n / s` as `(n * ceil(2^(32+k)/s)) >> (32+k)`; invert it.
This is the container-stride enumeration: it reads `sizeof(element)` off
the divide the compiler emitted, so an element's size comes from the code
rather than from adding up the offsets the code happens to touch.
"""
if not magic:
return None
for s in range(1, 8192):
n = 1 << (32 + shift)
if -(-n // s) == magic:
return s
return None
def helper_inner(mangled):
"""`.?AU?$StreamableHelper@VObservedTech@Game@@@Mars@@` -> (Game::ObservedTech, False)
`?$VectorHelper@V...` -> (T, True)"""
m = mangled
for p in (".?AV", ".?AU", ".?AW", ".?AT"):
if m.startswith(p):
m = m[len(p):]
isvec = m.startswith("?$VectorHelper@")
if isvec:
m = m[len("?$VectorHelper@"):]
elif m.startswith("?$StreamableHelper@"):
m = m[len("?$StreamableHelper@"):]
else:
return mangled, False
if m.endswith("@Mars@@@Mars@@"):
m = m[:-len("@Mars@@@Mars@@")]
elif m.endswith("@Mars@@"):
m = m[:-len("@Mars@@")]
if m[:1] in ("V", "U", "W", "T") and m[1:2] == "?":
m = m[1:]
if m.startswith("?$StreamableEnum@"):
inner = m[len("?$StreamableEnum@"):].rstrip("@")
return "enum:" + BUILTIN.get(inner, inner.split("@")[0].lstrip("W4V")), \
isvec
# MSVC builtin type codes (a template argument that is not a class)
if m in BUILTIN:
return BUILTIN[m], isvec
if m[:1] in ("V", "U", "W", "T") and not m.startswith("?$"):
m = m[1:]
parts = [p for p in m.split("@") if p]
return "::".join(reversed(parts)), isvec
BUILTIN = {"C": "int8", "D": "char", "E": "uint8", "F": "int16", "G": "uint16",
"H": "int", "I": "uint", "J": "long", "K": "ulong", "M": "float",
"N": "double", "_N": "bool", "X": "void", "PAX": "void*",
"PAD": "char*"}
# ------------------------------------------------------------------ driver
class Lab:
def __init__(self):
self.img = Image()
_, _, self.vfts = load_rtti()
raw = json.load(open(FUNCS))
self.funcs = sorted((int(a, 16), n, sz) for a, (n, sz) in raw.items())
self.starts = [f[0] for f in self.funcs]
self.fnames = {f[0]: f[1] for f in self.funcs}
self.ex = Extractor(self.img, self.vfts, self.fnames, self.funcs,
self.starts)
self._cache = {}
self.writers = self.writer_set()
self.forwarders = self.find_forwarders()
self.ex.extra = self.forwarders
self._cache = {} # re-extract knowing the forwarders
self.infos = self.classes()
for i in self.infos.values(): # base-class splicing needs these
self.writers.setdefault(i["write"], []).append(
(i["class"], i["col_offset"], i["vftable"]))
def find_forwarders(self):
"""Sub-writers `f(stream, name, member)` that pass the tag through.
Without these, every field they write is invisible: the caller's
`push "PlayerIDs"; call f` looks like an ordinary call. Discovered, not
listed by hand -- a hand list is exactly the kind of thing that silently
under-reports on the 1,600 classes nobody has read.
"""
cand = set()
for wva in self.writers:
r = self.raw(wva)
if r:
cand.update(c["tgt"] for c in r["calls"])
out = {}
for va in sorted(cand):
if va in HELPERS or va in self.writers:
continue
r = self.raw(va)
if r and "FORWARDER" in r["notes"]:
out[va] = ("opaque", None)
return out
# -- the serializer set ----------------------------------------------
def serializer_vftables(self):
"""IStreamable sub-vftables: 3 slots {dtor, Read, Write}, on a class
whose RTTI base list actually contains `Mars::IStreamable`.
The 3-slot shape alone is not a test: TacAISquadRule_*, the CSV row
parsers and ~100 other classes have three virtuals and no relation to
the stream at all. The class hierarchy descriptor settles it."""
return [(va, v) for va, v in self.vfts.items()
if len(v["slots"]) == 3 and
("Mars::IStreamable" in v.get("bases", []) or
v["class"] == "Mars::IStreamable")]
def writer_set(self):
"""VA -> [class names] for every slot-2 function of a 3-slot vftable."""
w = {}
for va, v in self.serializer_vftables():
w.setdefault(v["slots"][2], []).append((v["class"], v["offset"], va))
return w
def thunk_target(self, va):
"""`push ebp; mov ebp,esp; mov ecx,[ecx+8]; pop ebp; jmp writer`.
A POD type (Vector3, OutputRates, ...) has no vftable of its own; its
serializer is reached only through this specialised StreamableHelper
thunk, so without resolving it those classes are invisible.
"""
buf, o, _ = self.img.find(va)
if buf is None or o + 12 > len(buf):
return None
# Write thunk loads the helper's T* from +8, the Read thunk from +4.
if buf[o:o + 4] != b"\x55\x8b\xec\x8b" or buf[o + 4] != 0x49 or \
buf[o + 5] not in (4, 8) or buf[o + 6] != 0x5D or \
buf[o + 7] != 0xE9:
return None
return (va + 12 + struct.unpack_from("<i", buf, o + 8)[0]) & 0xFFFFFFFF
def helper_writers(self):
"""inner type name -> writer VA, for the POD helper thunks."""
out = {}
for va, v in self.vfts.items():
if len(v["slots"]) != 3 or \
"?$StreamableHelper@" not in v["mangled"]:
continue
t = self.thunk_target(v["slots"][2])
if t is None:
continue
inner, isvec = helper_inner(v["mangled"])
if isvec:
continue
out[inner] = {"write": t, "read": self.thunk_target(v["slots"][1]),
"helper_vftable": va}
return out
def raw(self, fva):
if fva not in self._cache:
self._cache[fva] = self.ex.run(fva)
return self._cache[fva]
def layout(self, fva, depth=0, seen=None):
"""Fields of one serializer, with base-class serializers spliced in."""
seen = seen or set()
if fva in seen or depth > 6:
return []
seen = seen | {fva}
r = self.raw(fva)
if r is None:
return []
# A base-class Write is emitted at its call site, so splice in program
# order -- that order is the on-disk order.
ev = [(f["va"], 0, f) for f in r["fields"]]
for c in r["calls"]:
if c["adj"] is None or c["tgt"] == fva:
continue
# (a) a base-class Write, reached through its own vftable, or
# (b) a private sub-writer of the same class -- e.g. StrategyServer
# writes its six id lists in FUN_00794cd0, and without this the
# six `PlayerIDs`-style fields are simply absent.
if c["tgt"] in self.writers or \
(self.raw(c["tgt"]) and self.raw(c["tgt"])["fields"]):
ev.append((c["va"], 1, c))
out = []
for va, kind, item in sorted(ev, key=lambda e: e[0]):
if kind == 0:
out.append(item)
continue
for f in self.layout(item["tgt"], depth + 1, seen):
g = dict(f)
if g.get("this") and isinstance(g.get("off"), int):
g["off"] += item["adj"]
g["from"] = item["tgt"]
out.append(g)
return out
# -- sizeof oracle ----------------------------------------------------
def sizeof_from_vectorhelper(self):
"""sizeof(T) read off `VectorHelper<T>::Write`'s element divide.
This is the container-stride enumeration applied binary-wide: the
helper iterates `(_Mylast - _Myfirst)/sizeof(T)`, so the divisor MSVC
compiled in *is* sizeof(T) -- independent of which offsets any code
touches, and therefore able to see the trailing allocator words that a
touch-scan cannot.
"""
out = {}
for va, v in self.vfts.items():
if len(v["slots"]) != 3 or "?$VectorHelper@" not in v["mangled"]:
continue
inner, _ = helper_inner(v["mangled"])
r = self.raw(v["slots"][2])
if not r:
continue
st = {c["stride"] for c in r["containers"]}
if len(st) == 1:
s = st.pop()
if inner in out and out[inner] != s:
out[inner] = None # contradiction: report it
elif inner not in out:
out[inner] = s
return {k: v for k, v in out.items() if v}
# -- assembled layouts -------------------------------------------------
def classes(self):
"""class name -> {write, read, vftable, col_offset}. One entry per
(class, IStreamable sub-object)."""
out = {}
for va, v in self.serializer_vftables():
if v["mangled"].startswith(".?AU?$") or \
v["mangled"].startswith(".?AV?$"):
continue # template adaptors, not classes
cur = out.get(v["class"])
if cur and cur["write"] == v["slots"][2]:
continue
r = self.raw(v["slots"][2])
nf = len(r["fields"]) if r else 0
if cur and cur["nfields"] >= nf:
continue
out[v["class"]] = {"class": v["class"], "vftable": va,
"col_offset": v["offset"],
"dtor": v["slots"][0], "read": v["slots"][1],
"write": v["slots"][2], "nfields": nf}
# When IStreamable is the FIRST base the compiler merges its three
# slots into the class's primary vftable, so there is no 3-slot table
# to find and ~170 classes would silently go missing. Take slots
# [1]/[2] there, but only after confirming that [2] really is
# write-side and [1] read-side -- guessing the slot is how you publish
# a layout built from the wrong function.
for va, v in self.vfts.items():
c = v["class"]
if c in out or c.startswith("?$") or len(v["slots"]) < 3:
continue
if "Mars::IStreamable" not in v.get("bases", []):
continue
w, rd = self.raw(v["slots"][2]), self.raw(v["slots"][1])
if not w or w["side"][0] == 0 or w["side"][0] <= w["side"][1]:
continue
out[c] = {"class": c, "vftable": va, "col_offset": v["offset"],
"dtor": v["slots"][0], "read": v["slots"][1],
"write": v["slots"][2], "nfields": len(w["fields"]),
"merged_vftable": True,
"read_side_ok": bool(rd and rd["side"][1] >= rd["side"][0])}
return out
def build(self, info, sizes):
"""One class -> a layout record, with fields at absolute offsets."""
adj = info["col_offset"]
fields, elems, extern = [], [], []
for f in self.layout(info["write"]):
g = dict(f)
if g.get("this") and isinstance(g.get("off"), int):
g["off_abs"] = g["off"] + adj
if g.get("kind") in ("object",) and not g.get("ptr"):
g["size"] = sizes.get(g.get("inner"))
fields.append(g)
elif isinstance(g.get("off"), int) and \
g.get("base") not in ("ebp", "esp"):
elems.append(g) # container element / iterator
else:
extern.append(g)
fields.sort(key=lambda x: (x["off_abs"], x["va"]))
# merge duplicate offsets (a field written twice, e.g. legacy tags)
seen, uniq = {}, []
for f in fields:
k = f["off_abs"]
if k in seen:
seen[k].setdefault("alt_tags", []).append(f["tag"])
continue
seen[k] = f
uniq.append(f)
lower = 0
unknown = 0
for f in uniq:
if isinstance(f.get("size"), int):
lower = max(lower, f["off_abs"] + f["size"])
else:
unknown += 1
gaps = []
for a, b in zip(uniq, uniq[1:]):
if isinstance(a.get("size"), int):
d = b["off_abs"] - (a["off_abs"] + a["size"])
if d > 3:
gaps.append({"after": a["tag"], "at": a["off_abs"],
"bytes": d})
# Independent cross-check: the class's Read must land on the same
# member addresses as its Write. Read reaches by-value ints through a
# stack temporary, so only the pointer-passed fields are comparable --
# but where they are comparable, disagreement means the layout is wrong
# or the wrong function was taken for the Write.
ragree = rtotal = 0
if info.get("read"):
# Only *named* tags are cross-checkable. A NULL writer name is
# "." on disk and carries no identity, so matching those between
# Read and Write would be positional -- and one side resolving a
# field the other did not then shifts every later position and
# manufactures disagreements that are not real.
rl = {}
for f in self.layout(info["read"]):
if f.get("this") and isinstance(f.get("off"), int) \
and f["tag"] != ".":
rl.setdefault(f["tag"], set()).add(f["off"] + adj)
dup = {t for t in [f["tag"] for f in uniq]
if [f["tag"] for f in uniq].count(t) > 1}
for f in uniq:
# `ServerPlayer` writes two different members as `Team`; a tag
# used twice cannot be matched by name, so skip it rather than
# report a conflict that is really a name collision.
if f["tag"] != "." and f["tag"] in rl and f["tag"] not in dup:
rtotal += 1
ragree += (f["off_abs"] in rl[f["tag"]])
return {"class": info["class"], "write": info["write"],
"read_agree": ragree, "read_comparable": rtotal,
"read": info["read"], "vftable": info["vftable"],
"col_offset": adj, "fields": uniq, "elements": elems,
"computed": extern, "sizeof_lower": lower,
"sizeof_stride": sizes.get(info["class"]),
"unsized_fields": unknown, "gaps": gaps,
"strides": self.raw(info["write"])["strides"]}
# -- the whole binary -------------------------------------------------
def run_all(self):
sizes = dict(self.sizeof_from_vectorhelper())
infos = dict(self.infos)
for t, h in self.helper_writers().items():
if t in infos or not h["write"]:
continue
infos[t] = {"class": t, "vftable": h["helper_vftable"],
"col_offset": 0, "dtor": None, "read": h["read"],
"write": h["write"], "nfields": 0, "pod": True}
# fixpoint: nested object sizes feed each other's lower bounds
lay = {}
for _ in range(6):
lay = {c: self.build(i, sizes) for c, i in infos.items()}
changed = False
for c, L in lay.items():
if c not in sizes and L["sizeof_lower"] and \
not L["unsized_fields"]:
sizes[c] = L["sizeof_lower"]
changed = True
if not changed:
break
# upper bounds from embeddings: an object at +D followed by a field at
# +D' cannot be larger than D'-D
ub = {}
for L in lay.values():
fs = L["fields"]
for a, b in zip(fs, fs[1:]):
t = a.get("inner")
if a["kind"] == "object" and not a.get("ptr") and t:
d = b["off_abs"] - a["off_abs"]
if d > 0:
ub[t] = min(ub.get(t, 1 << 30), d)
for c, L in lay.items():
L["sizeof_upper"] = ub.get(c)
L["sizeof_vectorstride"] = self.sizeof_from_vectorhelper().get(c)
lo, up = L["sizeof_lower"], L["sizeof_upper"]
st = L["sizeof_vectorstride"]
if st:
L["sizeof"], L["sizeof_by"] = st, "container stride"
elif lo and up and lo <= up < lo + 4 and not L["unsized_fields"]:
L["sizeof"], L["sizeof_by"] = up, "enumeration meets embedding"
else:
L["sizeof"], L["sizeof_by"] = None, None
L["grade"], L["why"] = grade(L)
return lay
def grade(L):
"""Tier + why. The failure classes are the point of this function."""
fs = L["fields"]
if not fs and not L["elements"] and not L["computed"]:
return "empty", "serializer writes no tagged field (default/stub Write)"
if not fs:
return "partial", "only container elements / computed values recovered"
bad = [f for f in fs if f.get("unresolved")]
unsized = [f for f in fs if not isinstance(f.get("size"), int)]
opaque = [f for f in fs if f["kind"] == "opaque"]
if bad:
return "partial", f"{len(bad)} field(s) unresolved (value built across " \
f"a branch, or a non-stack helper)"
if opaque:
return "partial", f"{len(opaque)} field(s) via an untyped sub-writer"
if unsized:
return "partial", f"{len(unsized)} nested field(s) of unknown size"
if L["read_comparable"] and L["read_agree"] < L["read_comparable"]:
return "conflict", (f"Read disagrees on "
f"{L['read_comparable'] - L['read_agree']} of "
f"{L['read_comparable']} comparable field offset(s)")
anon = sum(1 for f in fs if f["tag"] == ".")
if anon > len(fs) // 2:
return "unnamed", ("offsets and types recovered; the writer passes a "
"NULL name so the fields have no on-disk names and "
"cannot be matched against Read by name")
if L["read_comparable"] and L["read_agree"] == L["read_comparable"]:
return "verified", (f"fields exact; Read agrees on all "
f"{L['read_comparable']} comparable offsets"
+ ("; sizeof corroborated" if L["sizeof"] else ""))
if L["sizeof"]:
return "clean", "fields exact; sizeof corroborated"
return "clean", "fields exact; sizeof is a lower bound only"
def fmt_field(f):
off = f.get("off")
o = f"+0x{off:x}" if isinstance(off, int) and off >= 0 else \
(f"-0x{-off:x}" if isinstance(off, int) else "?")
sz = f.get("size")
s = f"0x{sz:x}" if isinstance(sz, int) else "?"
extra = ""
if f.get("inner"):
extra = f" <{f['inner']}>"
if f.get("ptr"):
extra += " (ptr)"
if not f.get("this", True):
extra += f" [base={f.get('base')}]"
if f.get("unresolved"):
extra += " UNRESOLVED"
return f" {o:>8} {s:>5} {f['kind']:<10} {f['tag']!r}{extra}"
def validate(lab, verbose=False):
"""Reproduce, exactly, the layouts the campaign already had.
Nothing new is claimed until this passes: a recovered layout that disagrees
with `save_reader.py` or with `struct-recovery.md` is a finding to
investigate, not a number to publish.
"""
from serializers_golden import GOLDEN, GOLDEN_SIZEOF, DISK_ORDER
sizes = lab.sizeof_from_vectorhelper()
hw = lab.helper_writers()
ok = miss = wrong = 0
print("=" * 72)
print("A. field offsets and kinds vs struct-recovery.md / observedtech-append.md")
print("=" * 72)
for cls, g in sorted(GOLDEN.items()):
info = {"class": cls, "write": g["write"], "read": 0, "vftable": 0,
"col_offset": None}
vf = [v for v in lab.vfts.values()
if len(v["slots"]) == 3 and v["slots"][2] == g["write"]]
if vf:
info["col_offset"] = vf[0]["offset"]
elif cls in hw:
info["col_offset"] = 0 # POD helper thunk: no sub-object
else:
print(f" {cls}: no 3-slot vftable for 0x{g['write']:08x}")
continue
lay = lab.build(info, sizes)
got = {}
for f in lay["fields"]:
got.setdefault(f["tag"], []).append(f)
c_ok = c_miss = c_wrong = 0
bad = []
for tag, off, kind in g["fields"]:
cands = got.get(tag, [])
hit = [f for f in cands if f["off_abs"] == off]
if hit:
k = hit[0]["kind"]
if k == kind or (kind == "object" and k in ("object", "vector")) \
or (kind == "handle" and k in ("handle", "int")) \
or (kind == "int" and k in ("int", "handle", "enum")):
c_ok += 1
else:
c_wrong += 1
bad.append(f" {tag} +0x{off:x}: kind {k} != {kind}")
elif cands:
c_wrong += 1
bad.append(f" {tag}: at +0x{cands[0]['off_abs']:x}, "
f"expected +0x{off:x}")
else:
c_miss += 1
bad.append(f" {tag} +0x{off:x}: not recovered")
ok, miss, wrong = ok + c_ok, miss + c_miss, wrong + c_wrong
n = len(g["fields"])
flag = "OK " if c_wrong == 0 and c_miss == 0 else \
("MISS" if c_wrong == 0 else "FAIL")
print(f" [{flag}] {cls:38s} {c_ok:3d}/{n:<3d} exact"
f"{'' if not c_miss else f' {c_miss} missing'}"
f"{'' if not c_wrong else f' {c_wrong} WRONG'}")
if bad and (verbose or c_wrong):
print("\n".join(bad))
tot = ok + miss + wrong
print(f"\n fields: {ok}/{tot} exact, {miss} not recovered, {wrong} WRONG")
print()
print("=" * 72)
print("B. sizeof, from the container-stride divides")
print("=" * 72)
for cls, want in sorted(GOLDEN_SIZEOF.items()):
got = sizes.get(cls)
print(f" [{'OK ' if got == want else 'FAIL'}] {cls:38s} "
f"got 0x{got:x}" if got else
f" [-- ] {cls:38s} no VectorHelper stride "
f"(expected 0x{want:x})")
print()
print("=" * 72)
print("C. on-disk tag order vs save_reader.py")
print("=" * 72)
exp = disk_expectations()
agree = disagree = 0
for va, name in sorted(DISK_ORDER.items()):
want = exp.get(name)
if not want:
continue
r = lab.raw(va)
if not r:
continue
got = [f["tag"] for f in lab.layout(va)]
i, missing = 0, []
for t in want:
j = got.index(t, i) if t in got[i:] else -1
if j < 0:
missing.append(t)
else:
i = j + 1
if missing:
disagree += 1
print(f" [FAIL] {name:22s} 0x{va:08x} {len(want)-len(missing)}"
f"/{len(want)} in order; not found in order: "
f"{missing[:8]}{'...' if len(missing) > 8 else ''}")
else:
agree += 1
print(f" [OK ] {name:22s} 0x{va:08x} {len(want)} tags, "
f"same relative order as the save oracle")
print(f"\n {agree} shapes agree, {disagree} disagree")
return 0 if (wrong == 0 and disagree == 0) else 1
def disk_expectations():
"""Top-level tag sequence of each save_reader.py Shape."""
sys.path.insert(0, os.path.join(REPO, "verify", "save-reader"))
import save_reader as SR
out = {}
def tags(shape):
seq = []
fields = shape.fields if isinstance(shape, (SR.Shape, SR.Seq)) else []
for f in fields:
nm = getattr(f, "name", None)
cls = type(f).__name__
if cls in ("Opt", "If", "Rest", "Repeat"):
continue # legacy / conditional / open-ended
if not getattr(f, "auth", False):
continue # R(): community field name, not a disk tag
if nm and nm != ".":
seq.append(nm)
return seq
for nm in dir(SR):
v = getattr(SR, nm)
if isinstance(v, SR.Shape):
out[nm] = tags(v)
return out
CNAME = {"bool": "bool", "int": "int", "int16": "short", "int8": "char",
"float": "float", "int64": "longlong", "enum": "int"}
C_KEYWORDS = {
"auto", "break", "case", "char", "const", "continue", "default", "do",
"double", "else", "enum", "extern", "float", "for", "goto", "if", "inline",
"int", "long", "register", "restrict", "return", "short", "signed",
"sizeof", "static", "struct", "switch", "typedef", "union", "unsigned",
"void", "volatile", "while", "bool", "class", "new", "delete", "this",
"operator", "template", "typename", "namespace", "public", "private"}
def cident(s, used):
out = "".join(c if (c.isalnum() or c == "_") else "_" for c in s)
if not out or out[0].isdigit():
out = "f_" + out
if out in C_KEYWORDS:
out += "_"
base, n = out, 1
while out in used:
out = f"{base}_{n}"
n += 1
used.add(out)
return out
VALID_ID = __import__("re").compile(r"^[A-Za-z_]\w*$")
def emit_c(cls, L, sizes, emitted):
"""One recovered layout as a C struct, gaps made explicit as padding."""
nm = cls.replace("::", "_")
if not VALID_ID.match(nm):
return None
lines, used, cur, k = [], set(), 0, 0
for f in L["fields"]:
off, kind = f["off_abs"], f["kind"]
sz = f.get("size")
if kind in ("object", "vector") and f.get("ptr"):
ctype, sz = "void *", 4
elif kind == "vector":
ctype, sz = "Mars_vector", 0x10
elif kind == "string":
ctype, sz = "Mars_string", 0x1C
elif kind == "handle":
ctype, sz = "void *", 4
elif kind == "object":
t = (f.get("inner") or "").replace("::", "_")
if t in emitted and VALID_ID.match(t) and sizes.get(f.get("inner")):
ctype, sz = t, sizes[f["inner"]]
elif isinstance(sz, int) and sz > 0:
ctype = f"char[{sz}]"
else:
return None # unsized nested member
elif kind in CNAME:
ctype = CNAME[kind]
else:
return None
if not isinstance(sz, int) or sz <= 0 or off < cur:
return None
if off > cur:
lines.append(f" char _pad{k}[{off - cur}];")
k += 1
fname = cident(f["tag"] if f["tag"] != "." else f"f{off:x}", used)
if ctype.endswith("]"):
base, n = ctype.split("[")
lines.append(f" {base} {fname}[{n};")
else:
lines.append(f" {ctype} {fname};")
cur = off + sz
if not lines:
return None
total = L["sizeof"] or L["sizeof_lower"]
if total and total > cur:
lines.append(f" char _pad{k}[{total - cur}];")
return f"struct {nm} {{\n" + "\n".join(lines) + "\n};"
def build_c_header(lay, sizes):
"""All emittable layouts, nested types first."""
order, emitted, out = [], set(), []
todo = [c for c, L in lay.items()
if L["grade"] in ("verified", "clean", "unnamed")]
for _ in range(8):
progress = False
for c in list(todo):
L = lay[c]
deps = {(f.get("inner") or "") for f in L["fields"]
if f["kind"] == "object" and not f.get("ptr")}
if any(d in lay and d.replace("::", "_") not in emitted and d != c
for d in deps):
continue
s = emit_c(c, L, sizes, emitted)
todo.remove(c)
if s:
emitted.add(c.replace("::", "_"))
order.append(c)
out.append(s)
progress = True
if not progress:
break
return order, out
def write_markdown(lay):
p = os.path.join(OUT, "layouts.md")
with open(p, "w") as fh:
fh.write("# Recovered class layouts (`tools/serializers.py all`)\n\n"
"Generated -- do not hand-edit. Offsets are absolute (object "
"base). `sizeof` is quoted only where a second, independent "
"line of evidence agrees with the field enumeration; "
"otherwise a lower bound is given.\n\n")
for c in sorted(lay):
L = lay[c]
if L["grade"] == "empty":
continue
sz = (f"0x{L['sizeof']:x} ({L['sizeof_by']})" if L["sizeof"]
else f">= 0x{L['sizeof_lower']:x} (lower bound)")
fh.write(f"## `{c}`\n\n"
f"Write `0x{L['write']:08x}`, Read "
f"`{('0x%08x' % L['read']) if L['read'] else '-'}`, "
f"IStreamable vftable `0x{L['vftable']:08x}` "
f"(COL offset +0x{L['col_offset']:x}). "
f"sizeof {sz}. **{L['grade']}** -- {L['why']}\n\n")
if L["fields"]:
fh.write("| offset | size | kind | tag | type |\n"
"|---|---|---|---|---|\n")
for f in L["fields"]:
s = f"0x{f['size']:x}" if isinstance(f.get("size"), int) \
else "?"
fh.write(f"| 0x{f['off_abs']:x} | {s} | {f['kind']}"
f"{' (ptr)' if f.get('ptr') else ''} | "
f"`{f['tag']}` | {f.get('inner', '')} |\n")
fh.write("\n")
if L["elements"]:
fh.write("Container-element fields (offsets are within the "
"element / map node, not the object): "
+ ", ".join(f"`{f['tag']}`@+0x{f['off']:x}"
for f in L["elements"]) + "\n\n")
print(f"wrote {p}")
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else "find"
lab = Lab()
if cmd == "validate":
return validate(lab, "-v" in sys.argv)
if cmd == "dump":
va = int(sys.argv[2], 0)
owners = lab.writers.get(va, [])
print(f"0x{va:08x} {lab.fnames.get(va,'')} "
f"{[o[0] for o in owners]}")
for f in lab.layout(va):
print(fmt_field(f))
r = lab.raw(va)
for n in r["notes"]:
print(" note: " + n)
elif cmd == "all":
lay = lab.run_all()
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "layouts.json"), "w") as fh:
json.dump(lay, fh, indent=1, sort_keys=True)
by = {}
for c, L in lay.items():
by.setdefault(L["grade"], []).append(c)
nf = sum(len(L["fields"]) for L in lay.values())
print(f"serializable classes with a Write : {len(lay)}")
for g in ("verified", "clean", "unnamed", "partial", "conflict",
"empty"):
print(f" {g:8s}: {len(by.get(g, []))}")
print(f"member fields recovered : {nf}")
print(f"classes with a corroborated sizeof: "
f"{sum(1 for L in lay.values() if L['sizeof'])}")
ra = sum(L["read_agree"] for L in lay.values())
rt = sum(L["read_comparable"] for L in lay.values())
bad = [c for c, L in lay.items()
if L["read_comparable"] and L["read_agree"] < L["read_comparable"]]
print(f"Read/Write cross-check : {ra}/{rt} fields agree "
f"({len(bad)} class(es) with any disagreement)")
for c in bad[:12]:
L = lay[c]
print(f" {c}: {L['read_agree']}/{L['read_comparable']}")
why = {}
for L in lay.values():
if L["grade"] not in ("verified", "clean"):
why[L["why"]] = why.get(L["why"], 0) + 1
print("\nwhy not clean:")
for w, n in sorted(why.items(), key=lambda x: -x[1]):
print(f" {n:4d} {w}")
write_markdown(lay)
sizes = {c: (L["sizeof"] or L["sizeof_lower"]) for c, L in lay.items()}
order, defs = build_c_header(lay, sizes)
hdr = ("// GENERATED by tools/serializers.py -- do not hand-edit.\n"
"// Layouts recovered from the Mars::IStreamable serializers.\n"
"// `Mars_string` is 0x1c and `Mars_vector` 0x10 -- allocator-LAST\n"
"// in this build's STL, which is why a touch-scan undercounts\n"
"// both by exactly 4 (findings/objects/struct-recovery.md 0).\n"
"struct Mars_string { char _bx[16]; unsigned int size; "
"unsigned int res; void* alval; };\n"
"struct Mars_vector { void* first; void* last; void* end; "
"void* alval; };\n\n" + "\n\n".join(defs) + "\n")
with open(os.path.join(OUT, "layouts.h"), "w") as fh:
fh.write(hdr)
print(f"wrote {OUT}/layouts.h ({len(defs)} structs)")
elif cmd == "find":
vs = lab.serializer_vftables()
print(f"3-slot vftables : {len(vs)}")
print(f"distinct Write slots : {len(lab.writers)}")
classes = {c for o in lab.writers.values() for c, _, _ in o}
print(f"distinct classes : {len(classes)}")
return 0
if __name__ == "__main__":
sys.exit(main() or 0)