Method dispatch axis: map id -> body via Runner::run switch
Recovers how a script method id maps to its implementation, the foundation for body-level normalisation. Each CMC_*_Runner::run is a switch(id) (vtable slot 17); every case is the method body — inline (MSVC6) or a tail-call to a separate show()/load() (MSVC8). The extractor parses the jump table at the disassembly level (Ghidra's decompiler jump-table recovery silently dropped the big runners), fingerprints each case by its ordered CALL anchors (Class::method / vtbl+0xNN), and expands thin wrappers one level so MSVC8 lines up with MSVC6. Validated on the golden pair: Animo SHOW..RESUME (id 1-4) yield identical leaves (getAnimo + vtbl+0xa0/0xa4/0x4c/0x50) across both compilers. Coverage 30/32 runners; Piklib 475 / BlooMoo 619 dispatch rows. - extract_engine_surface.py: extract_method_dispatch (schema_version -> 4) - snapshots regenerated with the method_dispatch axis - ams: Snapshot.method_dispatch; diff axis keyed (owner,id) on [impl,calls] with method-name join; render METHOD BODIES section; cli --only dispatch; owner filter - UI: "Ciała metod" diff axis + browse tab - tests: body-change unit + cross-compiler vtbl assertion -> 29/29 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
10
README.md
10
README.md
@@ -35,6 +35,7 @@ działa na MSVC6 (Piklib) i MSVC8 (BlooMoo) mimo różnego kodu wynikowego.
|
||||
| Eventy | `CMC_*::getBehavioursList`: lista literałów `CXString` | ✅ (lista per klasa, bez dziedziczenia) |
|
||||
| Pola (skryptowe) | ctory `CMC_*`: literały czytane przez `CMElement::getProperty<T>Value` → nazwa + typ pola (FPS, PRELOAD, VISIBLE…) | ✅ (+ `field_inheritance`) |
|
||||
| Layout C++ (bonus) | ctory `CMC_*`: store'y `this+offset` przez P-code (rozmyte, `is_vtable`) | ✅ pod `struct_layout` |
|
||||
| Ciała metod | `CMC_*_Runner::run`: `switch(id)` (vtable slot 17) → per case kotwice CALL (`Klasa::metoda` / `vtbl+0xNN`), rozwinięcie wrapperów | ✅ pod `method_dispatch` (id→`impl_addr`+`calls`) |
|
||||
|
||||
## Uruchomienie ekstraktora
|
||||
|
||||
@@ -101,10 +102,13 @@ czysty Python. Snapshot dostaje doklejony blok `binary.acquisition` (źródło,
|
||||
|
||||
```bash
|
||||
python -m ams OLD.snapshot.json NEW.snapshot.json [--owner CMC_Animo] \
|
||||
[--only types,methods,events,fields,layout] [--json]
|
||||
[--only types,methods,events,fields,layout,dispatch] [--json]
|
||||
```
|
||||
Porównuje dwa snapshoty po 4 osiach (added/removed/changed) + wykrywa metody przeniesione
|
||||
w hierarchii. Oś `struct_layout` jest sensowna tylko między wersjami tego samego kompilatora.
|
||||
Porównuje dwa snapshoty po osiach (added/removed/changed) + wykrywa metody przeniesione
|
||||
w hierarchii. Oś `dispatch` (ciała metod, klucz `owner`+`id`) diffuje fingerprint wywołań
|
||||
każdej metody — wykrywa **zmiany ciała** między wersjami; jak `struct_layout`, najczystsza
|
||||
między wersjami tego samego kompilatora (cross-compiler proste metody i tak się zgadzają,
|
||||
np. Animo `SHOW`→`vtbl+0xa0` na MSVC6 i MSVC8).
|
||||
|
||||
## Backend (FastAPI + katalog)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const state = { games: [], snaps: [], byId: {}, a: null, b: null, jobStatus: {}, axes: { types: true, methods: true, events: true, fields: true, layout: false } };
|
||||
const state = { games: [], snaps: [], byId: {}, a: null, b: null, jobStatus: {}, axes: { types: true, methods: true, events: true, fields: true, layout: false, dispatch: false } };
|
||||
|
||||
// tiny DOM helper
|
||||
function el(tag, props, ...kids) {
|
||||
@@ -39,6 +39,9 @@ const AXES = [
|
||||
{ key: "struct_layout", uikey: "layout", title: "Layout C++ (bonus)",
|
||||
fmt: (x) => `${x.owner} @${hex(x.offset)} size ${x.size}${x.is_vtable ? " vtable" : ""}`,
|
||||
name: (x) => `${x.owner}@${x.offset}` },
|
||||
{ key: "method_dispatch", uikey: "dispatch", title: "Ciała metod",
|
||||
fmt: (m) => `${m.owner} · ${m.name || ("id " + m.id)}${m.impl ? " → " + m.impl : ""} [${(m.calls || []).length} calls]`,
|
||||
name: (m) => `${m.owner}.${m.name || m.id}` },
|
||||
];
|
||||
const hex = (n) => "0x" + Number(n).toString(16);
|
||||
|
||||
@@ -238,7 +241,10 @@ function axisCard(ax, block) {
|
||||
for (const it of sortByName(block.added)) body.append(el("div", { class: "row r-add" }, ax.fmt(it)));
|
||||
for (const it of sortByName(block.removed)) body.append(el("div", { class: "row r-del" }, ax.fmt(it)));
|
||||
for (const ch of block.changed.slice().sort((x, y) => ax.name(x.item).localeCompare(ax.name(y.item)))) {
|
||||
const deltas = Object.entries(ch.changes).map(([f, v]) => `${f}: ${v[0]} → ${v[1]}`).join(", ");
|
||||
const deltas = Object.entries(ch.changes).map(([f, v]) =>
|
||||
(Array.isArray(v[0]) || Array.isArray(v[1]))
|
||||
? `${f}: ${(v[0] || []).length} → ${(v[1] || []).length}`
|
||||
: `${f}: ${v[0]} → ${v[1]}`).join(", ");
|
||||
body.append(el("div", { class: "row r-chg" }, ax.name(ch.item), " ", el("span", { class: "delta" }, deltas)));
|
||||
}
|
||||
return el("details", { class: "axis", open: true },
|
||||
@@ -270,6 +276,7 @@ async function browse(id) {
|
||||
["Metody", (d.methods || []).map((m) => `${m.owner} · ${m.name} (id ${m.id})`)],
|
||||
["Eventy", (d.events || []).map((e) => `${e.owner} · ${e.name}`)],
|
||||
["Pola", (d.fields || []).map((f) => `${f.owner} · ${f.name} : ${f.type}`)],
|
||||
["Ciała", (d.method_dispatch || []).map((m) => `${m.owner} · id ${m.id}${m.impl ? " → " + m.impl : ""} [${(m.calls || []).join(" ")}]`)],
|
||||
];
|
||||
out.innerHTML = "";
|
||||
out.append(el("div", { class: "diff-head" }, "Przegląd: ", el("b", {}, `${snap.binary_name} [${snap.engine}/${snap.compiler}]`)));
|
||||
|
||||
@@ -13,7 +13,7 @@ from .diff import compute_diff, filter_by_owner
|
||||
from .render import render_text
|
||||
from .snapshot import Snapshot
|
||||
|
||||
_AXES = ["types", "methods", "events", "fields", "layout"]
|
||||
_AXES = ["types", "methods", "events", "fields", "layout", "dispatch"]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
20
ams/diff.py
20
ams/diff.py
@@ -69,6 +69,22 @@ def _detect_method_moves(old_m: list[Item], new_m: list[Item]) -> list[Item]:
|
||||
return moves
|
||||
|
||||
|
||||
def _dispatch_key(x: Item) -> Hashable:
|
||||
return (x["owner"], x["id"])
|
||||
|
||||
|
||||
def _dispatch_with_names(snap: Snapshot) -> list[Item]:
|
||||
"""Attach the method name (from the methods axis, joined on owner+id) to each dispatch row,
|
||||
so a body-level diff reads as 'SHOW body changed' rather than 'CMC_Animo id 1 changed'."""
|
||||
name_by = {(m["owner"], m.get("id")): m["name"] for m in snap.methods}
|
||||
out = []
|
||||
for r in snap.method_dispatch:
|
||||
rr = dict(r)
|
||||
rr["name"] = name_by.get((r["owner"], r["id"]))
|
||||
out.append(rr)
|
||||
return out
|
||||
|
||||
|
||||
def compute_diff(old: Snapshot, new: Snapshot) -> dict[str, Any]:
|
||||
return {
|
||||
"binary": {"from": old.binary, "to": new.binary},
|
||||
@@ -78,6 +94,8 @@ def compute_diff(old: Snapshot, new: Snapshot) -> dict[str, Any]:
|
||||
"fields": keyed_diff(old.fields, new.fields, _owner_name_key, ["type"]),
|
||||
"struct_layout": keyed_diff(old.struct_layout, new.struct_layout, _layout_key,
|
||||
["size", "is_vtable"]),
|
||||
"method_dispatch": keyed_diff(_dispatch_with_names(old), _dispatch_with_names(new),
|
||||
_dispatch_key, ["impl", "calls"]),
|
||||
"method_inheritance": keyed_diff(old.method_inheritance, new.method_inheritance,
|
||||
lambda x: x["runner"], ["base_runner"]),
|
||||
"field_inheritance": keyed_diff(old.field_inheritance, new.field_inheritance,
|
||||
@@ -90,7 +108,7 @@ def compute_diff(old: Snapshot, new: Snapshot) -> dict[str, Any]:
|
||||
def _item_owner(axis: str, item: Item) -> str | None:
|
||||
if axis == "types":
|
||||
return item.get("cpp_class")
|
||||
if axis in ("methods", "events", "fields", "struct_layout"):
|
||||
if axis in ("methods", "events", "fields", "struct_layout", "method_dispatch"):
|
||||
return item.get("owner")
|
||||
if axis == "method_inheritance":
|
||||
return item.get("runner")
|
||||
|
||||
@@ -78,6 +78,40 @@ def _section_owned(out: list[str], title: str, block: dict, fmt: Callable[[dict]
|
||||
name_of(it), _fmt_changes(change_by_id[id(it)]["changes"])))
|
||||
|
||||
|
||||
def _dispatch_name(r: dict) -> str:
|
||||
return r.get("name") or "id {0}".format(r.get("id"))
|
||||
|
||||
|
||||
def _section_dispatch(out: list[str], block: dict) -> None:
|
||||
"""Method-body fingerprints (per owner+id). `calls` deltas are summarised by length so the
|
||||
line stays readable; the full anchor lists live in the JSON."""
|
||||
out.append("")
|
||||
out.append("{0:<16} {1}".format("METHOD BODIES", _counts(block)))
|
||||
owner_of = lambda r: r["owner"]
|
||||
added = _group_by(block["added"], owner_of)
|
||||
removed = _group_by(block["removed"], owner_of)
|
||||
changed = _group_by([c["item"] for c in block["changed"]], owner_of)
|
||||
change_by_id = {id(c["item"]): c for c in block["changed"]}
|
||||
for owner in sorted(set(added) | set(removed) | set(changed)):
|
||||
out.append(" {0}".format(owner))
|
||||
for it in sorted(added.get(owner, []), key=_dispatch_name):
|
||||
out.append(" + {0}".format(_dispatch_name(it)))
|
||||
for it in sorted(removed.get(owner, []), key=_dispatch_name):
|
||||
out.append(" - {0}".format(_dispatch_name(it)))
|
||||
for it in sorted(changed.get(owner, []), key=_dispatch_name):
|
||||
ch = change_by_id[id(it)]["changes"]
|
||||
bits = []
|
||||
if "impl" in ch:
|
||||
bits.append("impl {0} -> {1}".format(ch["impl"][0], ch["impl"][1]))
|
||||
if "calls" in ch:
|
||||
a, b = ch["calls"]
|
||||
bits.append("calls {0} -> {1}".format(len(a or []), len(b or [])))
|
||||
out.append(" ~ {0:<22} {1}".format(_dispatch_name(it), "; ".join(bits)))
|
||||
|
||||
|
||||
_EMPTY = {"added": [], "removed": [], "changed": []}
|
||||
|
||||
|
||||
def _is_empty(block: dict) -> bool:
|
||||
return not (block["added"] or block["removed"] or block["changed"])
|
||||
|
||||
@@ -107,6 +141,8 @@ def render_text(diff: dict[str, Any], only: set[str] | None = None) -> str:
|
||||
if want("layout") and not _is_empty(diff["struct_layout"]):
|
||||
_section_owned(out, "STRUCT LAYOUT", diff["struct_layout"], _fmt_layout,
|
||||
lambda x: x["owner"], lambda x: "@{0:#x}".format(x["offset"]))
|
||||
if want("dispatch") and not _is_empty(diff.get("method_dispatch", _EMPTY)):
|
||||
_section_dispatch(out, diff["method_dispatch"])
|
||||
if want("methods") and diff["moved_methods"]:
|
||||
out.append("")
|
||||
out.append("MOVED METHODS {0}".format(len(diff["moved_methods"])))
|
||||
@@ -114,7 +150,8 @@ def render_text(diff: dict[str, Any], only: set[str] | None = None) -> str:
|
||||
out.append(" {0}: {1} -> {2}".format(
|
||||
m["name"], ",".join(m["from_owners"]), ",".join(m["to_owners"])))
|
||||
|
||||
if all(_is_empty(diff[a]) for a in ("types", "methods", "events", "fields", "struct_layout")):
|
||||
if all(_is_empty(diff.get(a, _EMPTY))
|
||||
for a in ("types", "methods", "events", "fields", "struct_layout", "method_dispatch")):
|
||||
out.append("")
|
||||
out.append("(no differences)")
|
||||
return "\n".join(out)
|
||||
|
||||
@@ -43,6 +43,10 @@ class Snapshot:
|
||||
def struct_layout(self) -> list[dict]:
|
||||
return self.raw.get("struct_layout", [])
|
||||
|
||||
@property
|
||||
def method_dispatch(self) -> list[dict]:
|
||||
return self.raw.get("method_dispatch", [])
|
||||
|
||||
@property
|
||||
def method_inheritance(self) -> list[dict]:
|
||||
return self.raw.get("method_inheritance", [])
|
||||
|
||||
@@ -314,6 +314,222 @@ def extract_methods(program):
|
||||
return methods, inheritance
|
||||
|
||||
|
||||
_VTBL_OFF = re.compile(r"\[\w+ \+ (0x[0-9a-fA-F]+)\]")
|
||||
_MEM_OFF = re.compile(r"\[\w+ \+ (0x[0-9a-fA-F]+)\]")
|
||||
|
||||
|
||||
def _is_generic_name(name):
|
||||
"""A compiler-assigned placeholder, not a real symbol."""
|
||||
return (not name) or name.startswith("FUN_") or name.startswith("thunk_") or name.startswith("LAB_")
|
||||
|
||||
|
||||
def _qualified(f):
|
||||
if f is None:
|
||||
return None
|
||||
ns = f.getParentNamespace()
|
||||
nm = f.getName()
|
||||
return (ns.getName() + "::" + nm) if (ns is not None and ns.getName() != "Global") else nm
|
||||
|
||||
|
||||
def _call_anchor(program, instr):
|
||||
"""A normalisable, compiler-tolerant fingerprint of one CALL inside a switch case.
|
||||
|
||||
Direct call -> "Namespace::name". On MSVC8 the symbol sits on the ILT *stub* while the body
|
||||
is an unnamed FUN_, so we keep the stub's name and only fall back to the thunk-resolved body
|
||||
when the direct name is itself a placeholder. Indirect virtual call -> "vtbl+0xNN" from the
|
||||
displacement, which abstracts away the register holding `this`."""
|
||||
cf = call_target(program, instr)
|
||||
if cf is not None:
|
||||
if not _is_generic_name(cf.getName()):
|
||||
return _qualified(cf)
|
||||
resolved = resolve_thunk(cf)
|
||||
if resolved is not None and not _is_generic_name(resolved.getName()):
|
||||
return _qualified(resolved)
|
||||
return _qualified(cf)
|
||||
m = _VTBL_OFF.search(instr.toString())
|
||||
if m is not None:
|
||||
return "vtbl+" + m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _walk_calls(program, start_addr, stops, limit=80):
|
||||
"""Walk a straight-line block from `start_addr`, returning (anchors, funcs):
|
||||
|
||||
* `anchors` - ordered CALL fingerprints (see `_call_anchor`), additionally recovering the
|
||||
`MOV reg,[base+0xNN]` / `CALL reg` virtual-call idiom (MSVC8) as `vtbl+0xNN`, so it matches
|
||||
the `CALL [reg+0xNN]` form (MSVC6).
|
||||
* `funcs` - the (anchor, entry) of each *direct* call to a real function, used to detect a
|
||||
thin wrapper case that just forwards to a named/unnamed submethod.
|
||||
|
||||
Stops at a RET, an unconditional jump, a `stops` address, or after `limit` instructions."""
|
||||
listing = program.getListing()
|
||||
instr = listing.getInstructionAt(start_addr)
|
||||
anchors = []
|
||||
funcs = []
|
||||
regoff = {} # register -> vtable offset most recently loaded into it
|
||||
n = 0
|
||||
while instr is not None and n < limit:
|
||||
if n > 0 and instr.getAddress() in stops:
|
||||
break
|
||||
n += 1
|
||||
mn = instr.getMnemonicString()
|
||||
if mn == "MOV" and instr.getNumOperands() >= 2:
|
||||
dst = instr.getDefaultOperandRepresentation(0)
|
||||
m = _MEM_OFF.search(instr.toString())
|
||||
if m is not None:
|
||||
regoff[dst] = m.group(1)
|
||||
else:
|
||||
regoff.pop(dst, None)
|
||||
elif mn == "CALL":
|
||||
a = _call_anchor(program, instr)
|
||||
if a is None: # CALL reg -> use the offset last loaded into that register
|
||||
op0 = instr.getDefaultOperandRepresentation(0)
|
||||
if op0 in regoff:
|
||||
a = "vtbl+" + regoff[op0]
|
||||
if a is not None:
|
||||
anchors.append(a)
|
||||
cf = call_target(program, instr)
|
||||
if cf is not None:
|
||||
body = resolve_thunk(cf)
|
||||
if body is not None:
|
||||
funcs.append((a, body.getEntryPoint()))
|
||||
ft = instr.getFlowType()
|
||||
if ft.isTerminal() or (ft.isJump() and not ft.isConditional()):
|
||||
break
|
||||
instr = instr.getNext()
|
||||
return anchors, funcs
|
||||
|
||||
|
||||
_SWITCH_JMP = re.compile(r"\[(\w+)\*0x4 \+ (0x[0-9a-fA-F]+)\]")
|
||||
_LEA_DISP = re.compile(r"\[\w+ \+ (-?0x[0-9a-fA-F]+)\]")
|
||||
|
||||
|
||||
def _lea_disp(instr):
|
||||
"""Signed displacement of a `LEA reg,[base + disp]`, parsed from text when getScalar misses."""
|
||||
m = _LEA_DISP.search(instr.toString())
|
||||
return int(m.group(1), 16) if m is not None else None
|
||||
|
||||
|
||||
def _parse_switch(program, func):
|
||||
"""Recover the dense jump-table switch of a `run` function at the disassembly level
|
||||
(decompiler-independent, so it survives the big inline-heavy runners). Both MSVC6 and
|
||||
MSVC8 emit the same shape:
|
||||
|
||||
LEA idx,[reg - base] ; CMP idx, range ; JA default ; JMP [idx*4 + TABLE]
|
||||
|
||||
Returns {table, base, count} or None. `id = table_index + base`; `count = range + 1`."""
|
||||
listing = program.getListing()
|
||||
instrs = []
|
||||
it = listing.getInstructions(func.getBody(), True)
|
||||
while it.hasNext():
|
||||
instrs.append(it.next())
|
||||
|
||||
idx_reg = table = jmp_addr = None
|
||||
for instr in instrs:
|
||||
if instr.getMnemonicString() == "JMP" and instr.getFlowType().isComputed():
|
||||
m = _SWITCH_JMP.search(instr.toString())
|
||||
if m is not None:
|
||||
idx_reg = m.group(1)
|
||||
space = program.getAddressFactory().getDefaultAddressSpace()
|
||||
table = space.getAddress(int(m.group(2), 16))
|
||||
jmp_addr = instr.getAddress()
|
||||
break
|
||||
if table is None:
|
||||
return None
|
||||
|
||||
base = 0
|
||||
count = None
|
||||
for instr in instrs:
|
||||
if instr.getAddress().equals(jmp_addr):
|
||||
break
|
||||
if instr.getNumOperands() == 0 or instr.getDefaultOperandRepresentation(0) != idx_reg:
|
||||
continue
|
||||
mn = instr.getMnemonicString()
|
||||
s = instr.getScalar(1)
|
||||
if mn == "CMP" and s is not None:
|
||||
count = int(s.getValue()) + 1
|
||||
elif mn == "LEA": # LEA idx,[reg - k] -> id = index + k
|
||||
disp = int(s.getValue()) if s is not None else _lea_disp(instr)
|
||||
if disp is not None:
|
||||
base = -disp
|
||||
elif mn == "SUB" and s is not None:
|
||||
base = int(s.getValue())
|
||||
elif mn == "ADD" and s is not None:
|
||||
base = -int(s.getValue())
|
||||
elif mn == "DEC":
|
||||
base = 1
|
||||
return {"table": table, "base": base, "count": count}
|
||||
|
||||
|
||||
def extract_method_dispatch(program):
|
||||
"""For each CMC_*_Runner::run, recover how method ids map to their implementation.
|
||||
|
||||
`run(int id, ...)` is a `switch(id)` (vtable slot 17, overridden per runner) whose every
|
||||
`case id:` is the method body - either a tail-call to a named submethod (BlooMoo/MSVC8
|
||||
keeps show()/load()/... as separate functions) or inline code whose leaves are virtual
|
||||
calls on the wrapped object (Piklib/MSVC6). We fingerprint each case by its ordered CALL
|
||||
anchors, so a later pass can diff method *bodies* by (owner, id). Join names via `methods`."""
|
||||
fm = program.getFunctionManager()
|
||||
out = []
|
||||
it = fm.getFunctions(True)
|
||||
while it.hasNext():
|
||||
f = it.next()
|
||||
if f.getName() != "run":
|
||||
continue
|
||||
ns = f.getParentNamespace()
|
||||
runner = ns.getName() if ns is not None else "?"
|
||||
if not runner.endswith("_Runner"):
|
||||
continue
|
||||
try:
|
||||
out.extend(_dispatch_from_run(program, f, _owner_from_runner(runner), runner))
|
||||
except Exception as e: # one malformed runner shouldn't sink the whole axis
|
||||
print("[!] method_dispatch %s: %s" % (runner, e))
|
||||
return out
|
||||
|
||||
|
||||
def _dispatch_from_run(program, run_func, owner, runner):
|
||||
run_func = resolve_thunk(run_func)
|
||||
sw = _parse_switch(program, run_func)
|
||||
if sw is None:
|
||||
return []
|
||||
count = sw["count"]
|
||||
if count is None or count < 1 or count > 4096:
|
||||
return []
|
||||
|
||||
mem = program.getMemory()
|
||||
space = program.getAddressFactory().getDefaultAddressSpace()
|
||||
targets = []
|
||||
for i in range(count):
|
||||
try:
|
||||
val = mem.getInt(sw["table"].add(i * 4)) & 0xffffffff
|
||||
except Exception:
|
||||
break
|
||||
targets.append(space.getAddress(val))
|
||||
|
||||
stops = set(targets)
|
||||
rows = []
|
||||
for i in range(len(targets)):
|
||||
anchors, funcs = _walk_calls(program, targets[i], stops)
|
||||
# A thin wrapper case forwards to one submethod: the real body (and its leaf anchors)
|
||||
# live in that function. Expanding one level makes MSVC8 (separate show()/load()) line up
|
||||
# with MSVC6 (inline), so `calls` is a compiler-tolerant body fingerprint.
|
||||
if len(anchors) == 1 and len(funcs) == 1 and funcs[0][0] == anchors[0]:
|
||||
impl = funcs[0][0]
|
||||
impl_entry = funcs[0][1]
|
||||
impl_addr = "0x%x" % impl_entry.getOffset()
|
||||
calls, _ = _walk_calls(program, impl_entry, set())
|
||||
else:
|
||||
impl = None
|
||||
impl_addr = "0x%x" % targets[i].getOffset() # body is inline in the case block
|
||||
calls = anchors
|
||||
rows.append({
|
||||
"owner": owner, "runner": runner, "id": i + sw["base"],
|
||||
"case_addr": "0x%x" % targets[i].getOffset(),
|
||||
"impl": impl, "impl_addr": impl_addr, "calls": calls,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def extract_events(program):
|
||||
"""Per CMC_*::getBehavioursList, collect the ordered event-name literals (ONINIT, ONDONE, ...).
|
||||
|
||||
@@ -583,9 +799,10 @@ def run():
|
||||
events = extract_events(program)
|
||||
fields = extract_script_fields(program)
|
||||
struct_layout, field_inheritance = extract_struct_layout(program)
|
||||
method_dispatch = extract_method_dispatch(program)
|
||||
|
||||
snapshot = {
|
||||
"schema_version": 3,
|
||||
"schema_version": 4,
|
||||
"binary": {
|
||||
"name": program.getName(),
|
||||
"sha256": sha256_of(program),
|
||||
@@ -600,6 +817,7 @@ def run():
|
||||
"fields": fields,
|
||||
"field_inheritance": field_inheritance,
|
||||
"struct_layout": struct_layout,
|
||||
"method_dispatch": method_dispatch,
|
||||
}
|
||||
|
||||
args = getScriptArgs()
|
||||
@@ -610,9 +828,9 @@ def run():
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
print("[+] %s [%s/%s]: %d types, %d methods, %d events, %d fields (%d layout) -> %s" % (
|
||||
print("[+] %s [%s/%s]: %d types, %d methods, %d events, %d fields (%d layout, %d dispatch) -> %s" % (
|
||||
program.getName(), engine, compiler, len(types), len(methods),
|
||||
len(events), len(fields), len(struct_layout), out_path))
|
||||
len(events), len(fields), len(struct_layout), len(method_dispatch), out_path))
|
||||
|
||||
|
||||
run()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,26 @@ def test_field_type_change_and_owner_filter():
|
||||
assert d["fields"]["added"] == [] and d["fields"]["removed"] == []
|
||||
|
||||
|
||||
def test_method_dispatch_body_change():
|
||||
old = _snap(
|
||||
methods=[{"owner": "CMC_Animo", "name": "SHOW", "id": 1}],
|
||||
method_dispatch=[{"owner": "CMC_Animo", "id": 1, "impl": None,
|
||||
"impl_addr": "0x1", "calls": ["CMC_Animo::getAnimo", "vtbl+0xa0"]}],
|
||||
)
|
||||
new = _snap(
|
||||
methods=[{"owner": "CMC_Animo", "name": "SHOW", "id": 1}],
|
||||
method_dispatch=[{"owner": "CMC_Animo", "id": 1, "impl": None,
|
||||
"impl_addr": "0x1", "calls": ["CMC_Animo::getAnimo", "vtbl+0xa4"]}],
|
||||
)
|
||||
d = compute_diff(old, new)["method_dispatch"]
|
||||
assert len(d["changed"]) == 1
|
||||
ch = d["changed"][0]
|
||||
assert ch["item"]["name"] == "SHOW" # name joined from the methods axis on (owner, id)
|
||||
assert ch["changes"]["calls"] == [["CMC_Animo::getAnimo", "vtbl+0xa0"],
|
||||
["CMC_Animo::getAnimo", "vtbl+0xa4"]]
|
||||
assert "METHOD BODIES" in render_text(compute_diff(old, new))
|
||||
|
||||
|
||||
def test_render_no_diff():
|
||||
out = render_text(compute_diff(_snap(), _snap()))
|
||||
assert "(no differences)" in out
|
||||
@@ -101,3 +121,10 @@ def test_golden_pair_piklib_to_bloomoo():
|
||||
# rendering must not raise and must mention the new types
|
||||
text = render_text(d)
|
||||
assert "GRBUFFER" in text and "MOUSE" in text
|
||||
|
||||
# method bodies recovered cross-compiler: Animo SHOW (id 1) maps to the same vtable leaf
|
||||
# despite MSVC6 inlining it and MSVC8 keeping it as a separate show() function
|
||||
disp_old = {(r["owner"], r["id"]): r for r in old.method_dispatch}
|
||||
disp_new = {(r["owner"], r["id"]): r for r in new.method_dispatch}
|
||||
assert disp_old[("CMC_Animo", 1)]["calls"][-1] == "vtbl+0xa0"
|
||||
assert disp_new[("CMC_Animo", 1)]["calls"][-1] == "vtbl+0xa0"
|
||||
|
||||
Reference in New Issue
Block a user