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>
332 lines
14 KiB
JavaScript
332 lines
14 KiB
JavaScript
"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)); });
|