Files
Patryk Gensch b0d3d22445 Body normalisation: per-method similarity score + leaf delta
Turns the dispatch axis from a binary changed/unchanged into a "how much" measure
of code change — the original goal. ams.normalize compares two body fingerprints
(the ordered leaf-call anchors) with difflib after collapsing consecutive-duplicate
anchors (a load-twice codegen artefact), yielding a 0-100 similarity and the exact
leaves that appeared/vanished.

Every dispatch `changed` entry now carries body={similarity, added, removed}, and the
block carries a summary={shared, identical, changed, mean_similarity}.

Golden pair (cross-compiler): 470 shared bodies, 131 identical, mean 66% similar;
Animo SHOW/HIDE/PAUSE/RESUME come out 100% despite MSVC6 vs MSVC8, LOAD 50% with the
swapped leaves spelled out.

- normalize.py: canonical / body_similarity / body_delta
- diff: _dispatch_diff enriches changed with body + adds summary
- render: METHOD BODIES shows %, leaf delta, summary line
- UI: similarity % + leaf delta + axis summary
- tests: 5 new -> 34/34

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:23:15 +02:00

348 lines
15 KiB
JavaScript
Raw Permalink 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)));
const leaves = (arr) => "[" + arr.slice(0, 4).join(", ") + (arr.length > 4 ? "…+" + (arr.length - 4) : "") + "]";
for (const ch of block.changed.slice().sort((x, y) => ax.name(x.item).localeCompare(ax.name(y.item)))) {
let deltas, sim = null;
if (ch.body) { // method-body diff: similarity score + leaf-level delta
sim = ch.body.similarity;
const parts = [];
if (ch.body.added && ch.body.added.length) parts.push("+" + leaves(ch.body.added));
if (ch.body.removed && ch.body.removed.length) parts.push("" + leaves(ch.body.removed));
deltas = parts.join(" ") || (ch.changes.impl ? `impl ${ch.changes.impl[0]}${ch.changes.impl[1]}` : "");
} else {
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(", ");
}
const row = el("div", { class: "row r-chg" }, ax.name(ch.item), " ");
if (sim != null) row.append(el("span", { class: "simpct" }, sim + "%"), " ");
row.append(el("span", { class: "delta" }, deltas));
body.append(row);
}
const sum = block.summary
? el("span", { class: "axsum" }, `śr. ${block.summary.mean_similarity}% · ${block.summary.changed}/${block.summary.shared} zmienionych`)
: null;
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), sum),
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)); });