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:
Patryk Gensch
2026-05-31 13:15:58 +02:00
parent 38be932abc
commit 27399a52b1
10 changed files with 15843 additions and 13 deletions

View File

@@ -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}]`)));

View File

@@ -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:

View File

@@ -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")

View File

@@ -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)

View File

@@ -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", [])