Files
Aidem-Media-DLL-Analysis/ams/api/static/app.js
Patryk Gensch 27399a52b1 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>
2026-05-31 13:15:58 +02:00

332 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
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) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
else if (v !== false && v != null) e.setAttribute(k, v === true ? "" : v);
}
for (const k of kids.flat()) {
if (k == null || k === false) continue;
e.append(k.nodeType ? k : document.createTextNode(String(k)));
}
return e;
}
const $ = (id) => document.getElementById(id);
async function jget(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(url + " → " + r.status);
return r.json();
}
// --- axis configuration (mirrors ams.diff / ams.render) --------------------------------------
const AXES = [
{ key: "types", title: "Typy",
fmt: (t) => `${t.script_name}${t.cpp_class || "?"} (size ${t.object_size})${t.via_module_iface ? " ⟨iface⟩" : ""}`,
name: (t) => t.script_name },
{ key: "methods", title: "Metody",
fmt: (m) => `${m.owner} · ${m.name} (id ${m.id})`, name: (m) => `${m.owner}.${m.name}` },
{ key: "events", title: "Eventy",
fmt: (e) => `${e.owner} · ${e.name} (#${e.order})`, name: (e) => `${e.owner}.${e.name}` },
{ key: "fields", title: "Pola skryptowe",
fmt: (f) => `${f.owner} · ${f.name} : ${f.type}`, name: (f) => `${f.owner}.${f.name}` },
{ 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);
// --- sidebar ----------------------------------------------------------------------------------
async function load() {
const [games, snaps] = await Promise.all([jget("/games"), jget("/snapshots")]);
state.games = games; state.snaps = snaps;
state.byId = Object.fromEntries(snaps.map((s) => [s.id, s]));
renderSidebar();
renderAxesControl();
}
function renderSidebar() {
const root = $("games"); root.innerHTML = "";
const byGame = new Map();
for (const s of state.snaps) {
const g = s.game_id == null ? 0 : s.game_id;
if (!byGame.has(g)) byGame.set(g, []);
byGame.get(g).push(s);
}
const gameName = (id) => (state.games.find((g) => g.id === id) || {}).name || "(bez gry)";
for (const [gid, list] of byGame) {
const box = el("div", { class: "game" }, el("div", { class: "game-name" }, gameName(gid)));
for (const s of list) box.append(snapRow(s));
root.append(box);
}
}
function snapRow(s) {
const mkAB = (slot) => el("button", {
class: state[slot] === s.id ? "on" : "", title: "ustaw jako " + slot.toUpperCase(),
onclick: () => { state[slot] = state[slot] === s.id ? null : s.id; refreshSelection(); },
}, slot.toUpperCase());
return el("div", { class: "snap", "data-id": s.id },
el("div", { class: "info", onclick: () => browse(s.id) },
el("div", { class: "bin" }, s.binary_name,
el("span", { class: "pill" }, `${s.engine || "?"}/${s.compiler || "?"}`)),
el("div", { class: "meta" }, `T${s.n_types} · M${s.n_methods} · E${s.n_events} · F${s.n_fields}`)),
el("div", { class: "ab" }, mkAB("a"), mkAB("b")));
}
function refreshSelection() {
document.querySelectorAll(".snap").forEach((row) => {
const id = Number(row.dataset.id);
const [a, b] = row.querySelectorAll(".ab button");
a.className = state.a === id ? "on" : "";
b.className = state.b === id ? "on" : "";
});
const lbl = (id) => { const s = state.byId[id]; return s ? `${s.binary_name} [${s.engine}]` : "—"; };
$("slot-a").innerHTML = "A: "; $("slot-a").append(el("em", {}, lbl(state.a)));
$("slot-b").innerHTML = "B: "; $("slot-b").append(el("em", {}, lbl(state.b)));
$("compare").disabled = !(state.a && state.b);
}
function renderAxesControl() {
const box = $("axes"); box.innerHTML = "";
for (const ax of AXES) {
const k = ax.uikey || ax.key;
box.append(el("label", {},
el("input", { type: "checkbox", ...(state.axes[k] ? { checked: true } : {}),
onchange: (e) => { state.axes[k] = e.target.checked; } }), " " + ax.title.split(" ")[0]));
}
}
// --- upload + jobs ----------------------------------------------------------------------------
const ACTIVE = new Set(["queued", "started"]);
function setupUpload() {
const form = $("upload-form");
$("upload-toggle").addEventListener("click", () => {
form.hidden = !form.hidden;
if (!form.hidden) $("upload-file").focus();
});
form.addEventListener("submit", submitUpload);
}
async function submitUpload(e) {
e.preventDefault();
const file = $("upload-file").files[0];
if (!file) return;
const msg = $("upload-msg");
const fd = new FormData();
fd.append("file", file);
const game = $("upload-game").value.trim();
if (game) fd.append("game", game);
$("upload-submit").disabled = true;
msg.className = "upload-msg";
msg.textContent = "wysyłanie…";
try {
const r = await fetch("/jobs", { method: "POST", body: fd });
if (!r.ok) {
const detail = await r.json().catch(() => ({}));
throw new Error(detail.detail || ("HTTP " + r.status));
}
const job = await r.json();
msg.textContent = "zlecono #" + job.id + " — analiza w toku…";
$("upload-form").reset();
pollJobs(); // kick the poller so the new job shows up + tracks to completion
} catch (err) {
msg.className = "upload-msg err";
msg.textContent = "błąd: " + err.message;
} finally {
$("upload-submit").disabled = false;
}
}
let _jobTimer = null;
async function pollJobs() {
let jobs;
try { jobs = await jget("/jobs"); }
catch { return; } // endpoint may be absent in a stripped deploy — fail quiet
// when a tracked job flips to finished, a new snapshot is in the catalog → refresh the list
let finished = false;
for (const j of jobs) {
if (state.jobStatus[j.id] && state.jobStatus[j.id] !== j.status && j.status === "finished")
finished = true;
state.jobStatus[j.id] = j.status;
}
renderJobs(jobs);
if (finished) load();
clearTimeout(_jobTimer);
if (jobs.some((j) => ACTIVE.has(j.status)))
_jobTimer = setTimeout(pollJobs, 2500);
}
function jobBadge(status) {
const cls = { queued: "b-q", started: "b-s", finished: "b-add", failed: "b-del" }[status] || "b-q";
return el("span", { class: "badge " + cls }, status);
}
function renderJobs(jobs) {
const root = $("jobs"); root.innerHTML = "";
if (!jobs.length) return;
root.append(el("div", { class: "jobs-title" }, "Zadania"));
for (const j of jobs.slice(0, 8)) {
const row = el("div", { class: "job", title: j.error || "" },
el("span", { class: "jname" }, j.source_name),
jobBadge(j.status));
if (j.status === "finished" && j.snapshot_id != null)
row.append(el("span", { class: "jlink", onclick: () => browse(j.snapshot_id) },
j.dll_name || "snapshot"));
if (j.status === "failed" && j.error)
row.append(el("span", { class: "jerr" }, j.error));
root.append(row);
}
}
// --- diff -------------------------------------------------------------------------------------
async function compare() {
if (!state.a || !state.b) return;
const owner = $("owner").value.trim();
const q = new URLSearchParams({ old: state.a, new: state.b });
if (owner) q.set("owner", owner);
const out = $("results"); out.innerHTML = "ładowanie…";
try {
const d = await jget("/diff?" + q.toString());
renderDiff(d, owner);
} catch (e) {
out.innerHTML = ""; out.append(el("div", { class: "err" }, "Błąd: " + e.message));
}
}
function renderDiff(d, owner) {
const out = $("results"); out.innerHTML = "";
const bf = d.binary.from, bt = d.binary.to;
out.append(el("div", { class: "diff-head" },
"from ", el("b", {}, `${bf.name} [${bf.engine}/${bf.compiler}]`),
" → to ", el("b", {}, `${bt.name} [${bt.engine}/${bt.compiler}]`),
owner ? ` (klasa: ${owner})` : ""));
let any = false;
for (const ax of AXES) {
const k = ax.uikey || ax.key;
if (!state.axes[k]) continue;
const block = d[ax.key];
if (!block) continue;
const n = block.added.length + block.removed.length + block.changed.length;
if (n === 0) continue;
any = true;
out.append(axisCard(ax, block));
}
if (d.moved_methods && d.moved_methods.length) {
any = true;
out.append(movedCard(d.moved_methods));
}
if (!any) out.append(el("div", { class: "empty" }, "Brak różnic w wybranych osiach."));
}
function badge(cls, label, n) { return el("span", { class: "badge " + cls }, `${label} ${n}`); }
function axisCard(ax, block) {
const body = el("div", { class: "body" });
const sortByName = (arr) => arr.slice().sort((x, y) => ax.name(x).localeCompare(ax.name(y)));
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]) =>
(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 },
el("summary", {}, el("span", { class: "title" }, ax.title),
badge("b-add", "+", block.added.length),
badge("b-del", "", block.removed.length),
badge("b-chg", "~", block.changed.length)),
body);
}
function movedCard(moves) {
const body = el("div", { class: "body" });
for (const m of moves.slice().sort((a, b) => a.name.localeCompare(b.name)))
body.append(el("div", { class: "row moved" }, `${m.name}: ${m.from_owners.join(",")}${m.to_owners.join(",")}`));
return el("details", { class: "axis", open: true },
el("summary", {}, el("span", { class: "title" }, "Metody przeniesione w hierarchii"),
badge("b-chg", "↦", moves.length)), body);
}
// --- single-snapshot browse -------------------------------------------------------------------
async function browse(id) {
const out = $("results"); out.innerHTML = "ładowanie…";
let snap;
try { snap = await jget("/snapshots/" + id); }
catch (e) { out.innerHTML = ""; out.append(el("div", { class: "err" }, "Błąd: " + e.message)); return; }
const d = snap.data;
const tabs = [
["Typy", (d.types || []).map((t) => `${t.script_name}${t.cpp_class || "?"} (size ${t.object_size})`)],
["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}]`)));
const simBox = el("div", { class: "similar" });
out.append(simBox);
loadSimilar(id, simBox);
const filter = el("input", { class: "owner browse-filter", placeholder: "filtruj…", oninput: () => render() });
const tabbar = el("div", {});
const list = el("div", {});
let active = 0;
function render() {
tabbar.innerHTML = "";
tabs.forEach(([name, items], i) =>
tabbar.append(el("span", { class: "btab" + (i === active ? " on" : ""), onclick: () => { active = i; render(); } },
`${name} (${items.length})`)));
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
const items = tabs[active][1].filter((s) => !q || s.toLowerCase().includes(q));
for (const s of items) list.append(el("div", { class: "bitem" }, s));
if (!items.length) list.append(el("div", { class: "empty" }, "—"));
}
out.append(tabbar, filter, list);
render();
}
async function loadSimilar(targetId, box) {
let hits;
try { hits = await jget("/snapshots/" + targetId + "/similar"); }
catch { return; } // endpoint absent / single-snapshot catalog — just show nothing
if (!hits.length) return;
box.append(el("div", { class: "similar-title" }, "Podobne wersje (overlap powierzchni)"));
for (const h of hits.slice(0, 6)) {
const s = h.snapshot;
const bar = el("span", { class: "simbar" },
el("span", { class: "simfill", style: "width:" + h.overall + "%" }));
box.append(el("div", { class: "simrow" },
el("span", { class: "simscore" }, h.overall + "%"),
bar,
el("span", { class: "simname", title: "przejrzyj", onclick: () => browse(s.id) },
`${s.binary_name} [${s.engine || "?"}]`),
h.fuzzy != null ? el("span", { class: "simfuzzy", title: "ssdeep binarki" }, "fuzzy " + h.fuzzy) : null,
el("span", { class: "simdiff", title: "porównaj tę wersję z aktualną",
onclick: () => { state.a = targetId; state.b = s.id; refreshSelection(); compare(); } }, "⇄ diff")));
}
}
// --- boot -------------------------------------------------------------------------------------
$("compare").addEventListener("click", compare);
$("owner").addEventListener("keydown", (e) => { if (e.key === "Enter") compare(); });
setupUpload();
pollJobs();
load().catch((e) => { $("results").innerHTML = ""; $("results").append(el("div", { class: "err" }, "Nie udało się załadować: " + e.message)); });