sots-re/tools/x86disp.py
alex 460cb7ca2b lane X: x86 displacement xref scanner; pin sizeof(ObservedTech) and its append site
Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC
idiom for taking a member's address -- is invisible to find-constant-uses. That
blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the
~1,600 classes still to map.

tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes,
ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from
Ghidra's 41,089 function starts so decodes begin on real instruction boundaries.
2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70
desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms
(mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets.
Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local
cache in dumps/ rather than hammering CT111.

Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage
(0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in
ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known
offsets.

sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic
divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and
0x007b735b, and the search stride add edi,0x2c at 0x007ba257.

Append site: RecordObservedTech+0xdf (0x007ba27f) --
  lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320
RecordObservedTech (0x007ba1a0) is a direct callee of OnTechResearched and
de-duplicates by tech name before appending. The realloc through 0x007b5820 is
why lane R's guard saw all three vector words move. Element carries a vptr
(RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the
four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read
does NOT determine, and is not guessed.

Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64
(its only +0x64 accesses are loads off a different base), and ProcessResearch's
int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with
lane R's guard seeing 0 changes in 4284 calls.

Honest limits are recorded in the note and the board: this is a recall tool, not
an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x
search-space cut that still needs one call-graph check. Cohort ranking must not
be used as a hard filter -- it would have discarded the correct answer here.

Ghidra writeback: labels + plate comments on RecordObservedTech,
vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign,
vector_44B_grow, vftable_ObservedTech.
2026-09-08 04:44:18 -04:00

798 lines
29 KiB
Python

#!/usr/bin/env python3
"""x86-32 displacement cross-referencer for the SOTS1 exe.
Why this exists
---------------
Ghidra indexes immediate operands but NOT ModRM displacements. On an MSVC C++
binary `lea reg,[reg+disp]` is *the* idiom for taking the address of a member --
it is how every std::vector / std::string / embedded sub-object is passed to a
method or ctor. `find-constant-uses 0x274` therefore cannot see the append site
for a vector member at +0x274. This tool answers the question Ghidra can't:
"what code takes the address of, or accesses, offset N off some object?"
How it works
------------
x86 is variable-length and not self-synchronising, so a naive byte scan for
"8D 8E <disp32>" produces confident garbage. Instead we run a full instruction
*length* decoder (prefixes / 1-,2-,3-byte opcodes / ModRM / SIB / disp / imm)
seeded from Ghidra's 41k function starts, so every decode begins on a real
instruction boundary. `--brute` runs the naive scan too, purely so the
false-positive rate of the naive method can be *measured* rather than guessed.
Correctness notes that matter (each of these is a silent-garbage source):
* mod=0,rm=5 -> disp32 with NO base: an absolute global address, not a
member offset. Excluded from member queries by default.
* mod=0,rm=4,
sib.base=5 -> disp32, no base, index only. Same: excluded.
* mod=1 -> disp8 SIGN-EXTENDED. -0x08 must not be reported as 0xf8.
* mod=3 -> register operand, no memory, no displacement at all.
* 0x67 addr-size -> 16-bit ModRM, entirely different layout. Skipped, flagged.
Usage
-----
uv run python3 tools/x86disp.py build # decode + cache index
uv run python3 tools/x86disp.py query 0x274 # who touches +0x274
uv run python3 tools/x86disp.py query 0x29c --lea # lea only
uv run python3 tools/x86disp.py func 0x0080db00 # dump one function
uv run python3 tools/x86disp.py stats
"""
import bisect
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
EXE = os.path.join(REPO, "dumps", "sots.exe")
FUNCS = os.path.join(REPO, "dumps", "functions.json")
INDEX = os.path.join(REPO, "dumps", "dispindex.json")
R32 = ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]
# ---------------------------------------------------------------- opcode maps
# value: (has_modrm, imm_kind)
# imm kinds: 0 none | 'b' 1 | 'w' 2 | 'd' 4 | 'z' 2-if-66-else-4
# 'p' far ptr 6 (4 with 66) | 'a' moffs (addr-size) | 'e' enter 3
# 'g6' F6 group (b if reg<2) | 'g7' F7 group (z if reg<2)
_ONE = {}
def _fill(rng, modrm, imm):
for o in rng:
_ONE[o] = (modrm, imm)
# 00..3F: the eight ALU ops, each /r /r /r /r AL,ib eAX,iz + 2 seg ops
for _base in (0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38):
_fill(range(_base, _base + 4), True, 0)
_ONE[_base + 4] = (False, "b")
_ONE[_base + 5] = (False, "z")
_ONE[_base + 6] = (False, 0) # PUSH seg / prefix-or-ascii-adjust
_ONE[_base + 7] = (False, 0)
_fill(range(0x40, 0x60), False, 0) # INC/DEC/PUSH/POP r32
_fill([0x60, 0x61], False, 0) # PUSHA/POPA
_ONE[0x62] = (True, 0) # BOUND
_ONE[0x63] = (True, 0) # ARPL
_ONE[0x68] = (False, "z") # PUSH iz
_ONE[0x69] = (True, "z") # IMUL r,Ev,iz
_ONE[0x6A] = (False, "b") # PUSH ib
_ONE[0x6B] = (True, "b") # IMUL r,Ev,ib
_fill(range(0x6C, 0x70), False, 0) # INS/OUTS
_fill(range(0x70, 0x80), False, "b") # Jcc rel8
_ONE[0x80] = (True, "b")
_ONE[0x81] = (True, "z")
_ONE[0x82] = (True, "b")
_ONE[0x83] = (True, "b")
_fill(range(0x84, 0x90), True, 0) # TEST/XCHG/MOV/MOV-seg/LEA/POP Ev
_fill(range(0x90, 0x9A), False, 0) # NOP/XCHG/CWDE/CDQ
_ONE[0x9A] = (False, "p") # CALLF
_fill(range(0x9B, 0xA0), False, 0)
_fill(range(0xA0, 0xA4), False, "a") # MOV moffs
_fill(range(0xA4, 0xA8), False, 0) # MOVS/CMPS
_ONE[0xA8] = (False, "b")
_ONE[0xA9] = (False, "z")
_fill(range(0xAA, 0xB0), False, 0) # STOS/LODS/SCAS
_fill(range(0xB0, 0xB8), False, "b") # MOV r8,ib
_fill(range(0xB8, 0xC0), False, "z") # MOV r32,iz
_ONE[0xC0] = (True, "b")
_ONE[0xC1] = (True, "b")
_ONE[0xC2] = (False, "w") # RET imm16
_ONE[0xC3] = (False, 0)
_ONE[0xC4] = (True, 0) # LES
_ONE[0xC5] = (True, 0) # LDS
_ONE[0xC6] = (True, "b") # MOV Eb,Ib
_ONE[0xC7] = (True, "z") # MOV Ev,Iz
_ONE[0xC8] = (False, "e") # ENTER iw,ib
_ONE[0xC9] = (False, 0)
_ONE[0xCA] = (False, "w") # RETF imm16
_fill([0xCB, 0xCC], False, 0)
_ONE[0xCD] = (False, "b") # INT ib
_fill([0xCE, 0xCF], False, 0)
_fill(range(0xD0, 0xD4), True, 0) # shift group by 1 / by CL
_ONE[0xD4] = (False, "b") # AAM
_ONE[0xD5] = (False, "b") # AAD
_fill([0xD6, 0xD7], False, 0)
_fill(range(0xD8, 0xE0), True, 0) # x87 -- always ModRM
_fill(range(0xE0, 0xE4), False, "b") # LOOP*/JECXZ
_fill(range(0xE4, 0xE8), False, "b") # IN/OUT ib
_ONE[0xE8] = (False, "z") # CALL rel32
_ONE[0xE9] = (False, "z") # JMP rel32
_ONE[0xEA] = (False, "p") # JMPF
_ONE[0xEB] = (False, "b") # JMP rel8
_fill(range(0xEC, 0xF0), False, 0) # IN/OUT DX
_fill(range(0xF0, 0xF6), False, 0) # LOCK/INT1/REP*/HLT/CMC
_ONE[0xF6] = (True, "g6")
_ONE[0xF7] = (True, "g7")
_fill(range(0xF8, 0xFE), False, 0)
_ONE[0xFE] = (True, 0)
_ONE[0xFF] = (True, 0)
_TWO = {}
def _fill2(rng, modrm, imm):
for o in rng:
_TWO[o] = (modrm, imm)
_fill2(range(0x00, 0x05), True, 0)
_fill2(range(0x05, 0x0D), False, 0)
_TWO[0x0D] = (True, 0)
_TWO[0x0E] = (False, 0)
_TWO[0x0F] = (True, "b") # 3DNow!
_fill2(range(0x10, 0x18), True, 0)
_fill2(range(0x18, 0x20), True, 0) # hint-NOP / prefetch
_fill2(range(0x20, 0x25), True, 0)
_fill2(range(0x28, 0x30), True, 0)
_fill2(range(0x30, 0x38), False, 0)
_fill2(range(0x40, 0x50), True, 0) # CMOVcc
_fill2(range(0x50, 0x70), True, 0) # SSE/MMX
_TWO[0x70] = (True, "b")
_fill2(range(0x71, 0x74), True, "b")
_fill2(range(0x74, 0x77), True, 0)
_TWO[0x77] = (False, 0) # EMMS
_fill2(range(0x78, 0x80), True, 0)
_fill2(range(0x80, 0x90), False, "z") # Jcc rel32
_fill2(range(0x90, 0xA0), True, 0) # SETcc
_fill2([0xA0, 0xA1, 0xA2], False, 0)
_TWO[0xA3] = (True, 0) # BT
_TWO[0xA4] = (True, "b") # SHLD ib
_TWO[0xA5] = (True, 0) # SHLD CL
_fill2([0xA8, 0xA9, 0xAA], False, 0)
_TWO[0xAB] = (True, 0) # BTS
_TWO[0xAC] = (True, "b") # SHRD ib
_fill2([0xAD, 0xAE, 0xAF], True, 0)
_fill2(range(0xB0, 0xBA), True, 0)
_TWO[0xBA] = (True, "b") # group8 BT/BTS/BTR/BTC ib
_fill2(range(0xBB, 0xC0), True, 0)
_fill2([0xC0, 0xC1], True, 0) # XADD
_TWO[0xC2] = (True, "b") # CMPPS
_TWO[0xC3] = (True, 0) # MOVNTI
_fill2([0xC4, 0xC5, 0xC6], True, "b")
_TWO[0xC7] = (True, 0) # group9 CMPXCHG8B
_fill2(range(0xC8, 0xD0), False, 0) # BSWAP
_fill2(range(0xD0, 0x100), True, 0) # MMX/SSE bulk
# minimal mnemonics -- enough to read a report, not a full disassembler
_ALU = ["add", "or", "adc", "sbb", "and", "sub", "xor", "cmp"]
_G1 = _ALU
_G5 = ["inc", "dec", "call", "callf", "jmp", "jmpf", "push", "?"]
_G3 = ["test", "test", "not", "neg", "mul", "imul", "div", "idiv"]
_SHIFT = ["rol", "ror", "rcl", "rcr", "shl", "shr", "shl", "sar"]
def mnemonic(op2, op, reg):
"""Best-effort mnemonic. op2 True => the opcode was 0F-escaped."""
if op2:
if 0x10 <= op <= 0x17 or 0x28 <= op <= 0x2F or 0x51 <= op <= 0x5F:
return "sse"
if 0x40 <= op <= 0x4F:
return "cmov"
if 0x90 <= op <= 0x9F:
return "setcc"
if op in (0xB6, 0xB7):
return "movzx"
if op in (0xBE, 0xBF):
return "movsx"
if op == 0xAF:
return "imul"
if op in (0x6E, 0x6F, 0x7E, 0x7F, 0xD6):
return "movq/movd"
return f"0f{op:02x}"
if op < 0x40 and (op & 7) < 6:
return _ALU[op >> 3]
if op in (0x88, 0x89, 0x8A, 0x8B, 0xC6, 0xC7):
return "mov"
if op == 0x8D:
return "lea"
if op in (0x84, 0x85):
return "test"
if op in (0x86, 0x87):
return "xchg"
if op in (0x80, 0x81, 0x82, 0x83):
return _G1[reg]
if op in (0xC0, 0xC1, 0xD0, 0xD1, 0xD2, 0xD3):
return _SHIFT[reg]
if op in (0xF6, 0xF7):
return _G3[reg]
if op == 0xFF:
return _G5[reg]
if op == 0xFE:
return ["inc", "dec"][reg] if reg < 2 else "?"
if op == 0x8F:
return "pop"
if op in (0x69, 0x6B):
return "imul"
if 0xD8 <= op <= 0xDF:
return "x87"
if op == 0x62:
return "bound"
return f"{op:02x}"
class Desync(Exception):
pass
def decode(buf, i, end):
"""Decode one instruction at buf[i]. Returns (length, info|None).
info = dict(mnem, base, index, scale, disp, dispsize, modrm_reg, is_lea)
for instructions with a memory operand carrying a displacement; None
otherwise. Raises Desync on an unknown/invalid opcode.
"""
start = i
opsize66 = False
addr67 = False
while i < end:
b = buf[i]
if b == 0x66:
opsize66 = True
i += 1
elif b == 0x67:
addr67 = True
i += 1
elif b in (0xF0, 0xF2, 0xF3, 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65):
i += 1
else:
break
else:
raise Desync("prefix run to end")
if i - start > 14:
raise Desync("prefix flood")
op = buf[i]
i += 1
op2 = op3 = False
if op == 0x0F:
if i >= end:
raise Desync("truncated 0f")
op = buf[i]
i += 1
op2 = True
if op in (0x38, 0x3A):
three_imm = "b" if op == 0x3A else 0
if i >= end:
raise Desync("truncated 0f3x")
op = buf[i]
i += 1
op3 = True
has_modrm, imm = True, three_imm
else:
ent = _TWO.get(op)
if ent is None:
raise Desync(f"unknown 0f{op:02x}")
has_modrm, imm = ent
else:
ent = _ONE.get(op)
if ent is None:
raise Desync(f"unknown {op:02x}")
has_modrm, imm = ent
info = None
if has_modrm:
if i >= end:
raise Desync("truncated modrm")
modrm = buf[i]
i += 1
mod = modrm >> 6
reg = (modrm >> 3) & 7
rm = modrm & 7
if imm == "g6":
imm = "b" if reg < 2 else 0
elif imm == "g7":
imm = "z" if reg < 2 else 0
if mod != 3:
if addr67:
# 16-bit ModRM: different table entirely. Rare in MSVC code and
# never the member-address idiom -- skip rather than mis-decode.
raise Desync("16-bit addressing (0x67)")
base = index = None
scale = 1
if rm == 4:
if i >= end:
raise Desync("truncated sib")
sib = buf[i]
i += 1
scale = 1 << (sib >> 6)
idx = (sib >> 3) & 7
bse = sib & 7
index = None if idx == 4 else R32[idx]
if bse == 5 and mod == 0:
base = None # disp32 absolute + index
else:
base = R32[bse]
elif rm == 5 and mod == 0:
base = None # disp32 absolute
else:
base = R32[rm]
disp = 0
dispsize = 0
if mod == 1:
if i >= end:
raise Desync("truncated disp8")
disp = struct.unpack_from("<b", buf, i)[0] # SIGN-EXTENDED
dispsize = 1
i += 1
elif mod == 2 or base is None:
if i + 4 > end:
raise Desync("truncated disp32")
disp = struct.unpack_from("<i", buf, i)[0]
dispsize = 4
i += 4
if dispsize:
info = {
"mnem": mnemonic(op2, op, reg), "base": base,
"index": index, "scale": scale, "disp": disp,
"dispsize": dispsize, "reg": R32[reg],
"lea": (not op2 and op == 0x8D),
}
else:
reg = 0
n = 0
if imm == "b":
n = 1
elif imm == "w":
n = 2
elif imm == "d":
n = 4
elif imm == "z":
n = 2 if opsize66 else 4
elif imm == "p":
n = 4 if opsize66 else 6
elif imm == "a":
n = 2 if addr67 else 4
elif imm == "e":
n = 3
i += n
if i > end:
raise Desync("truncated imm")
if i == start:
raise Desync("zero length")
return i - start, info
# ------------------------------------------------------------------- PE / IO
def load_pe(path):
"""Return (image_base, [(va_start, va_end, bytes, name)]) for exec sections."""
data = open(path, "rb").read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe:pe + 4] == b"PE\0\0", "not a PE"
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 = []
off = pe + 24 + optsz
for k in range(nsec):
s = off + k * 40
name = data[s:s + 8].rstrip(b"\0").decode("latin1")
vsize, vaddr, rsize, raddr = struct.unpack_from("<IIII", data, s + 8)
chars = struct.unpack_from("<I", data, s + 36)[0]
if not (chars & 0x20000000): # IMAGE_SCN_MEM_EXECUTE
continue
n = min(vsize, rsize) if vsize else rsize
secs.append((base + vaddr, base + vaddr + n, data[raddr:raddr + n], name))
return base, secs
def load_funcs():
with open(FUNCS) as fh:
raw = json.load(fh)
fl = sorted((int(a, 16), n, sz) for a, (n, sz) in raw.items())
return fl
# --------------------------------------------------------------------- build
def build():
_, secs = load_pe(EXE)
funcs = load_funcs()
starts = [f[0] for f in funcs]
sites = [] # [va, disp, base, index, scale, mnem, lea, funcidx, hex]
covered = 0
total_code = sum(e - s for s, e, _, _ in secs)
desyncs = 0
decoded_ins = 0
for fi, (fva, fname, fsz) in enumerate(funcs):
sec = next((s for s in secs if s[0] <= fva < s[1]), None)
if sec is None or fsz <= 0:
continue
sva, eva, buf, _ = sec
# Sweep to the NEXT function start, not fva+fsz: Ghidra's sizeInBytes
# understates ~11% of bodies (it clips valid epilogues mid-instruction).
# Sites past fsz are still attributed to this function but flagged, so
# a caller can tell body-proper from possible inter-function padding.
j = bisect.bisect_right(starts, fva)
limit = min(starts[j] if j < len(starts) else eva, eva)
body_end = fva + fsz
i = fva - sva
end = limit - sva
while i < end:
try:
ln, info = decode(buf, i, end)
except Desync:
desyncs += 1
break
decoded_ins += 1
if info and info["base"] is not None and info["disp"] != 0:
va = sva + i
sites.append([va, info["disp"], info["base"], info["index"],
info["scale"], info["mnem"], info["lea"], fi,
buf[i:i + min(ln, 10)].hex(),
1 if va < body_end else 0, info["reg"]])
i += ln
covered += max(0, i - (fva - sva))
idx = {}
for k, s in enumerate(sites):
idx.setdefault(str(s[1]), []).append(k)
out = {
"sites": sites,
"byDisp": idx,
"funcs": [[f[0], f[1], f[2]] for f in funcs],
"stats": {"functions": len(funcs), "instructions": decoded_ins,
"sites": len(sites), "desyncs": desyncs,
"bytesCovered": covered, "codeBytes": total_code},
}
with open(INDEX, "w") as fh:
json.dump(out, fh)
st = out["stats"]
print(f"functions swept : {st['functions']}")
print(f"instructions : {st['instructions']}")
print(f"disp sites : {st['sites']}")
print(f"desyncs : {st['desyncs']} "
f"({100.0 * st['desyncs'] / st['functions']:.2f}% of functions)")
print(f"code coverage : {st['bytesCovered']}/{st['codeBytes']} "
f"({100.0 * st['bytesCovered'] / st['codeBytes']:.1f}%)")
# --------------------------------------------------------------------- query
def load_index():
if not os.path.exists(INDEX):
sys.exit("no index; run: uv run python3 tools/x86disp.py build")
with open(INDEX) as fh:
return json.load(fh)
THIS_REGS = ("ecx", "esi", "edi", "ebx") # typical `this` carriers in MSVC
def fmt(site, funcs):
va, disp, base, index, scale, mnem, lea, fi, hx, inbody, dst = site
fva, fname, _ = funcs[fi]
ea = f"[{base}"
if index:
ea += f"+{index}*{scale}"
ea += f"{'+' if disp >= 0 else '-'}0x{abs(disp):x}]"
txt = f"{mnem} {dst},{ea}" if lea else f"{mnem} {ea}"
return (f" 0x{va:08x} {txt:<32} "
f"{fname}+0x{va - fva:x}{'' if inbody else ' [past-body]'} ({hx})")
def query(argv):
want = int(argv[0], 0)
only_lea = "--lea" in argv
only_this = "--this" in argv
show_all = "--all" in argv
d = load_index()
funcs = d["funcs"]
keys = d["byDisp"].get(str(want), [])
hits = [d["sites"][k] for k in keys]
if only_lea:
hits = [h for h in hits if h[6]]
if only_this:
hits = [h for h in hits if h[2] in THIS_REGS]
hits.sort(key=lambda h: h[0])
ranked, other = [], []
for h in hits:
(ranked if h[2] in THIS_REGS else other).append(h)
print(f"displacement 0x{want:x} ({want}): {len(hits)} site(s)")
print(f"\n== base is a likely `this` ({'/'.join(THIS_REGS)}): {len(ranked)} ==")
byfn = {}
for h in ranked:
byfn.setdefault(h[7], []).append(h)
for fi in sorted(byfn, key=lambda f: funcs[f][0]):
print(f" {funcs[fi][1]} @0x{funcs[fi][0]:08x}")
for h in byfn[fi]:
print(fmt(h, funcs))
if other:
print(f"\n== other base regs (esp/ebp = locals, eax/edx = temps): {len(other)} ==")
if show_all:
for h in other:
print(fmt(h, funcs))
else:
cnt = {}
for h in other:
cnt[h[2]] = cnt.get(h[2], 0) + 1
print(" " + ", ".join(f"{k}:{v}" for k, v in sorted(cnt.items()))
+ " (--all to list)")
def func_dump(argv):
fva = int(argv[0], 0)
d = load_index()
funcs = d["funcs"]
fi = next((i for i, f in enumerate(funcs) if f[0] == fva), None)
if fi is None:
sys.exit(f"no function at 0x{fva:08x}")
print(f"{funcs[fi][1]} @ 0x{fva:08x} size {funcs[fi][2]}")
for s in d["sites"]:
if s[7] == fi:
print(fmt(s, funcs))
def stats():
d = load_index()
st = d["stats"]
for k, v in st.items():
print(f"{k:16}: {v}")
hist = {}
for s in d["sites"]:
if s[2] in THIS_REGS:
hist[s[1]] = hist.get(s[1], 0) + 1
print(f"\ndistinct displacements off ecx/esi/edi/ebx: {len(hist)}")
top = sorted(hist.items(), key=lambda kv: -kv[1])[:15]
print("most common: " + ", ".join(f"0x{k:x}({v})" for k, v in top))
_STORE = {0x89, 0x01, 0x29, 0x39, 0x09, 0x21, 0x31, 0x19, 0x11,
0x88, 0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38}
def _reg_form(raw):
"""Render the mod=3 (register-to-register) forms, which are exactly the
ones that carry the arithmetic you care about when recovering a struct
stride: `imul ecx,ecx,0x2c`, `sar edx,3`, `sub ecx,edi`."""
p = 0
while p < len(raw) and raw[p] in (0x66, 0x67, 0xF0, 0xF2, 0xF3,
0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65):
p += 1
op = raw[p]
if op == 0x0F or p + 1 >= len(raw):
return mnemonic(op == 0x0F, raw[p + 1] if op == 0x0F else op, 0)
m = raw[p + 1]
mod, reg, rm = m >> 6, (m >> 3) & 7, m & 7
if mod != 3:
# memory operand: render it and show which register is the other half,
# so a load reads `mov ecx,[edi+4]` not a bare `mov`.
q = p + 2
ea = "["
if rm == 4:
sib = raw[q]; q += 1
bse, ix = sib & 7, (sib >> 3) & 7
ea += ("" if (bse == 5 and mod == 0) else R32[bse])
if ix != 4:
ea += f"+{R32[ix]}*{1 << (sib >> 6)}"
elif not (rm == 5 and mod == 0):
ea += R32[rm]
if mod == 1 and q < len(raw):
dv = struct.unpack_from("<b", raw, q)[0]
ea += f"{'+' if dv >= 0 else '-'}0x{abs(dv):x}"
elif mod == 2 and q + 4 <= len(raw):
dv = struct.unpack_from("<i", raw, q)[0]
ea += f"{'+' if dv >= 0 else '-'}0x{abs(dv):x}"
ea += "]"
nm = mnemonic(False, op, reg)
if op in _STORE:
return f"{nm} {ea},{R32[reg]}"
if op in (0x80, 0x81, 0x83, 0xC6, 0xC7, 0xF6, 0xF7, 0xFF, 0xFE,
0xC0, 0xC1, 0xD0, 0xD1, 0xD2, 0xD3) or op >= 0xD8:
return f"{nm} {ea}"
return f"{nm} {R32[reg]},{ea}"
r, x = R32[reg], R32[rm]
if op in (0x89, 0x01, 0x29, 0x39, 0x09, 0x21, 0x31, 0x19, 0x11, 0x85, 0x87):
a, b = x, r # Ev,Gv -> dst is rm
else:
a, b = r, x # Gv,Ev -> dst is reg
nm = mnemonic(False, op, reg)
if op in (0xC0, 0xC1): # shift group, imm8
return f"{_SHIFT[reg]} {x},{raw[p + 2]}"
if op in (0xD1, 0xD3):
return f"{_SHIFT[reg]} {x},{'cl' if op == 0xD3 else '1'}"
if op == 0x6B: # imul r,rm,imm8 <- the stride!
return f"imul {r},{x},0x{struct.unpack_from('<b', raw, p + 2)[0] & 0xff:x}"
if op == 0x69:
return f"imul {r},{x},0x{struct.unpack_from('<I', raw, p + 2)[0]:x}"
if op in (0x83, 0x81, 0x80):
v = (raw[p + 2] if op != 0x81
else struct.unpack_from("<I", raw, p + 2)[0])
return f"{_G1[reg]} {x},0x{v:x}"
if op in (0xF7, 0xF6):
return f"{_G3[reg]} {x}"
if op == 0xFF:
return f"{_G5[reg]} {x}"
return f"{nm} {a},{b}"
def dis(argv):
"""Linear disassembly window: `dis <va> [count]`. Length-accurate; the
mnemonics are minimal but call/jmp targets are resolved to function names,
which is what you actually need to follow a `lea this,[obj+N]; call` pair."""
va = int(argv[0], 0)
n = int(argv[1]) if len(argv) > 1 else 24
_, secs = load_pe(EXE)
d = load_index()
fmap = {f[0]: f[1] for f in d["funcs"]}
sec = next((s for s in secs if s[0] <= va < s[1]), None)
if sec is None:
sys.exit("address not in an executable section")
sva, eva, buf, _ = sec
i = va - sva
for _ in range(n):
try:
ln, info = decode(buf, i, len(buf))
except Desync as e:
print(f"0x{sva + i:08x} <desync: {e}>")
return
raw = buf[i:i + ln]
cur = sva + i
txt = ""
# resolve the two rel32 forms and the rel8 jumps by hand
if raw[0] in (0xE8, 0xE9) and ln == 5:
tgt = cur + 5 + struct.unpack_from("<i", raw, 1)[0]
txt = (f"{'call' if raw[0] == 0xE8 else 'jmp'} 0x{tgt:08x}"
f" {fmap.get(tgt, '')}")
elif raw[0] == 0x0F and 0x80 <= raw[1] <= 0x8F and ln == 6:
tgt = cur + 6 + struct.unpack_from("<i", raw, 2)[0]
txt = f"jcc 0x{tgt:08x}"
elif raw[0] == 0xEB or 0x70 <= raw[0] <= 0x7F:
tgt = cur + ln + struct.unpack_from("<b", raw, ln - 1)[0]
txt = f"jmp/jcc 0x{tgt:08x}"
elif 0x50 <= raw[0] <= 0x57:
txt = f"push {R32[raw[0] - 0x50]}"
elif 0x58 <= raw[0] <= 0x5F:
txt = f"pop {R32[raw[0] - 0x58]}"
elif raw[0] == 0x6A:
txt = f"push 0x{raw[1]:x}"
elif raw[0] == 0x68:
txt = f"push 0x{struct.unpack_from('<I', raw, 1)[0]:x}"
elif 0xB8 <= raw[0] <= 0xBF and ln == 5:
txt = f"mov {R32[raw[0] - 0xB8]},0x{struct.unpack_from('<I', raw, 1)[0]:x}"
elif raw[0] == 0xC3:
txt = "ret"
elif raw[0] == 0xC2:
txt = f"ret 0x{struct.unpack_from('<H', raw, 1)[0]:x}"
else:
txt = _reg_form(raw)
lbl = fmap.get(cur, "")
print(f"0x{cur:08x} {raw.hex():<18} {txt:<44} {lbl}")
i += ln
def cohort(argv):
"""Rank functions by how many offsets of a KNOWN class layout they touch.
This is the answer to the real precision problem. A bare query for 0x274
returns ~99 sites and the tool cannot know which base register holds a
ServerPlayer. But a function that touches 0x274 *and* 0x29c *and* 0x244
*and* 0x254 is not doing that by coincidence -- those are ServerPlayer's
members. Feed the offsets we have already pinned and the class's own
methods float to the top.
uv run python3 tools/x86disp.py cohort 0x274 --known=0x29c,0x244,0x254,...
WARNING, learned the hard way on the ObservedTech hunt: use this as a
RANKER, never as a hard filter. It finds fat *class methods* (the
ServerPlayer serializer scores 50/50) but it actively hides narrow helpers.
`RecordObservedTech` -- the actual append site -- touches only 0x274 and
0x278 and nothing else on ServerPlayer, so every --min>=1 setting drops it.
Read the full `query` output before trusting a cohort shortlist.
"""
want = int(argv[0], 0)
known = []
minhits = 2
for a in argv[1:]:
if a.startswith("--known="):
known = [int(x, 0) for x in a[8:].split(",")]
elif a.startswith("--min="):
minhits = int(a[6:])
if not known:
sys.exit("need --known=<comma-separated offsets of the same class>")
d = load_index()
funcs = d["funcs"]
kset = set(known)
touched = {} # funcidx -> set(offset)
target = {} # funcidx -> [sites at `want`]
for s in d["sites"]:
if s[2] not in THIS_REGS:
continue
if s[1] in kset:
touched.setdefault(s[7], set()).add(s[1])
if s[1] == want:
target.setdefault(s[7], []).append(s)
rows = []
for fi, sites in target.items():
hits = touched.get(fi, set()) - {want}
if len(hits) >= minhits:
rows.append((len(hits), fi, sites, hits))
rows.sort(key=lambda r: -r[0])
print(f"functions touching 0x{want:x} AND >={minhits} other known offsets "
f"of this class: {len(rows)} of {len(target)} candidates "
f"({100.0 * len(rows) / max(1, len(target)):.0f}% kept)")
for n, fi, sites, hits in rows:
fva, fname, _ = funcs[fi]
hx = ",".join(f"0x{h:x}" for h in sorted(hits))
print(f"\n {fname} @0x{fva:08x} [{n} co-hits: {hx}]")
for s in sites:
print(fmt(s, funcs))
# ------------------------------------------------- naive scan, for FP measure
def brute(argv):
"""Naive 'search the raw bytes' scan -- the thing you'd write without a
decoder. Only exists so we can put a NUMBER on how wrong it is.
`--ops 8d` (default) = lea only. `--ops 8d,89,8b,01,03,39,3b` = the wider
scan you would actually need, since a member is read/written far more often
than its address is taken. The wider the opcode set and the smaller the
displacement, the worse the naive method gets -- that is the point.
"""
want = int(argv[0], 0)
ops = {0x8D}
for a in argv[1:]:
if a.startswith("--ops="):
ops = {int(x, 16) for x in a[6:].split(",")}
_, secs = load_pe(EXE)
d = load_index()
real = {s[0] for s in d["sites"] if s[1] == want}
found = []
d8 = want if -128 <= want <= 127 else None
pat32 = struct.pack("<i", want)
for sva, eva, buf, _ in secs:
for i in range(len(buf) - 6):
if buf[i] not in ops:
continue
m = buf[i + 1]
mod, rm = m >> 6, m & 7
if mod == 3 or mod == 0:
continue
k = i + 2 + (1 if rm == 4 else 0)
if mod == 1 and d8 is not None:
if struct.unpack_from("<b", buf, k)[0] == want:
found.append(sva + i)
elif mod == 2 and buf[k:k + 4] == pat32:
found.append(sva + i)
fp = [a for a in found if a not in real]
tag = ",".join(f"{o:02x}" for o in sorted(ops))
print(f"naive byte-scan (opcodes {tag}) for disp 0x{want:x}: "
f"{len(found)} candidate(s)")
print(f" real instructions at that address : {len(found) - len(fp)}")
print(f" NOT an instruction boundary : {len(fp)} "
f"({100.0 * len(fp) / max(1, len(found)):.1f}% false positive)")
missed = len(real) - (len(found) - len(fp))
print(f" real sites the naive scan MISSED : {missed} of {len(real)}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
cmd, rest = sys.argv[1], sys.argv[2:]
{"build": lambda a: build(), "query": query, "func": func_dump,
"stats": lambda a: stats(), "brute": brute, "cohort": cohort,
"dis": dis}[cmd](rest)