466 lines
18 KiB
Python
466 lines
18 KiB
Python
"""Cross-binary **compatibility table**, grouped by library (DLL) name.
|
||
|
||
A documentation aid: take a set of catalogued snapshots and lay out, per engine library
|
||
(``PIKLIB8.dll``, ``bloomoodll.dll`` …), which games ship it. The catch the user cares about:
|
||
the *same* library name can appear in several games yet not actually be the same engine —
|
||
``PIKLIB8.dll`` in game A and game B may diverge. So within each library name we **cluster
|
||
variants by engine surface** (the compiler-agnostic identity sets that `ams.similarity` /
|
||
`ams.diff` already key on) and flag every non-reference variant with an **asterisk**, carrying a
|
||
``vs_reference`` delta so the footnote can say *what* differs.
|
||
|
||
The builder is pure (lists of snapshots in, JSON-able dict out); `render_markdown` /
|
||
`render_json` turn that into the downloadable file. Both the CLI (`python -m ams.compat`) and the
|
||
`/compat` API endpoint sit on top of this.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Iterable
|
||
|
||
from .diff import compute_diff
|
||
from .similarity import _AXES as _SIM_AXES # axis -> (accessor, identity-key fn)
|
||
from .similarity import _keys, surface_similarity
|
||
from .snapshot import Snapshot
|
||
|
||
# The four script-facing axes we cluster on (same set similarity scores over).
|
||
_AXES = list(_SIM_AXES)
|
||
|
||
|
||
@dataclass
|
||
class Entry:
|
||
"""One catalogued binary: the column label (game) plus its surface snapshot."""
|
||
|
||
game: str
|
||
snapshot: Snapshot
|
||
|
||
|
||
# --- surface signature -------------------------------------------------------------------------
|
||
def surface_signature(snap: Snapshot) -> str:
|
||
"""A stable hash of the engine surface: the sorted identity sets of every axis.
|
||
|
||
Two snapshots with the same signature expose the same script-facing API — i.e. they are
|
||
*compatible* even across recompiles/compilers, so they collapse into one variant. Any
|
||
difference in the sets (a method added, a field retyped, a type gone) yields a new signature
|
||
and thus an asterisk."""
|
||
payload = {axis: sorted(list(k) for k in _keys(snap, axis)) for axis in _AXES}
|
||
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||
return hashlib.sha1(blob).hexdigest()
|
||
|
||
|
||
def _surface_counts(snap: Snapshot) -> dict[str, int]:
|
||
"""Identity-set size per axis (mirrors the signature's notion of 'how big is the surface')."""
|
||
return {axis: len(_keys(snap, axis)) for axis in _AXES}
|
||
|
||
|
||
def surface_api(snap: Snapshot) -> dict[str, Any]:
|
||
"""The full *supported* surface of one variant: every type, and per class the methods, events
|
||
and fields it exposes. This is the baseline — 'what is there' — so the diff against the
|
||
reference variant has a point of reference to read against."""
|
||
classes: dict[str, dict[str, list]] = {}
|
||
|
||
def cls(owner: str | None) -> dict[str, list]:
|
||
return classes.setdefault(owner or "?", {"methods": [], "events": [], "fields": []})
|
||
|
||
for m in snap.methods:
|
||
cls(m.get("owner"))["methods"].append({"name": m["name"], "id": m.get("id")})
|
||
for e in snap.events:
|
||
cls(e.get("owner"))["events"].append({"name": e["name"], "order": e.get("order")})
|
||
for f in snap.fields:
|
||
cls(f.get("owner"))["fields"].append({"name": f["name"], "type": f.get("type")})
|
||
|
||
for d in classes.values():
|
||
d["methods"].sort(key=lambda x: x["name"])
|
||
d["events"].sort(key=lambda x: (x["order"] if x["order"] is not None else 1 << 30, x["name"]))
|
||
d["fields"].sort(key=lambda x: x["name"])
|
||
|
||
types = sorted(
|
||
({"script_name": t["script_name"], "cpp_class": t.get("cpp_class"),
|
||
"object_size": t.get("object_size")} for t in snap.types),
|
||
key=lambda x: x["script_name"],
|
||
)
|
||
return {"types": types, "classes": dict(sorted(classes.items()))}
|
||
|
||
|
||
def _axis_counts(block: dict[str, Any]) -> dict[str, int]:
|
||
return {
|
||
"added": len(block["added"]),
|
||
"removed": len(block["removed"]),
|
||
"changed": len(block["changed"]),
|
||
}
|
||
|
||
|
||
def _changes_str(changes: dict[str, list]) -> str:
|
||
return ", ".join("{0} {1}→{2}".format(f, v[0], v[1]) for f, v in sorted(changes.items()))
|
||
|
||
|
||
def _types_named(block: dict[str, Any]) -> dict[str, list[str]]:
|
||
"""Type-axis changes as display strings (types are global, not owner-scoped)."""
|
||
return {
|
||
"added": sorted("{0} → {1} (size {2})".format(
|
||
t["script_name"], t.get("cpp_class") or "?", t.get("object_size")) for t in block["added"]),
|
||
"removed": sorted(t["script_name"] for t in block["removed"]),
|
||
"changed": sorted("{0} ({1})".format(c["item"]["script_name"], _changes_str(c["changes"]))
|
||
for c in block["changed"]),
|
||
}
|
||
|
||
|
||
def _owner_named(block: dict[str, Any]) -> dict[str, Any]:
|
||
"""Owner-scoped axis (methods/events/fields) grouped by class, so the diff reads as
|
||
'which class gained/lost/changed which member' — the bit that was missing from the counts."""
|
||
by: dict[str, dict[str, list[str]]] = {}
|
||
|
||
def slot(owner: str) -> dict[str, list[str]]:
|
||
return by.setdefault(owner or "?", {"added": [], "removed": [], "changed": []})
|
||
|
||
for it in block["added"]:
|
||
slot(it["owner"])["added"].append(it["name"])
|
||
for it in block["removed"]:
|
||
slot(it["owner"])["removed"].append(it["name"])
|
||
for c in block["changed"]:
|
||
it = c["item"]
|
||
slot(it["owner"])["changed"].append("{0} ({1})".format(it["name"], _changes_str(c["changes"])))
|
||
|
||
return {"by_owner": {o: {k: sorted(v) for k, v in d.items()} for o, d in sorted(by.items())}}
|
||
|
||
|
||
def _vs_reference(reference: Snapshot, variant: Snapshot) -> dict[str, Any]:
|
||
"""How a divergent variant differs from its library's reference variant: per-axis +/-/~ counts,
|
||
the pooled surface-overlap score, and a `detail` block naming *which* classes/methods/events/
|
||
fields changed (so the table can say 'CMC_Animo lost SHOW', not just 'methods −1')."""
|
||
diff = compute_diff(reference, variant)
|
||
return {
|
||
"overall": surface_similarity(reference, variant)["overall"],
|
||
"axes": {axis: _axis_counts(diff[axis]) for axis in _AXES},
|
||
"moved_methods": len(diff["moved_methods"]),
|
||
"detail": {
|
||
"types": _types_named(diff["types"]),
|
||
"methods": _owner_named(diff["methods"]),
|
||
"events": _owner_named(diff["events"]),
|
||
"fields": _owner_named(diff["fields"]),
|
||
},
|
||
}
|
||
|
||
|
||
# --- table builder -----------------------------------------------------------------------------
|
||
# Stored binaries are prefixed with the first 8 hex of their sha256 to keep filenames unique on
|
||
# disk (e.g. ``333ab0c4_PIKLIB8.dll``). That prefix is per-file, so it must be stripped before
|
||
# grouping — otherwise every copy of PIKLIB8.dll lands in its own row and nothing ever clusters.
|
||
_HASH_PREFIX = re.compile(r"^[0-9a-fA-F]{8}_")
|
||
|
||
|
||
def library_name(raw: str) -> str:
|
||
"""The display/grouping name: the stored binary name with any ``<sha8>_`` prefix removed."""
|
||
return _HASH_PREFIX.sub("", raw)
|
||
|
||
|
||
def _library_name(snap: Snapshot) -> str:
|
||
return library_name(snap.binary.get("name") or "?")
|
||
|
||
|
||
def _variant_id(index: int) -> str:
|
||
"""0 -> A, 1 -> B, …, 26 -> AA (spreadsheet-style), so cells read 'A', 'B*', 'C*'."""
|
||
out = ""
|
||
index += 1
|
||
while index:
|
||
index, rem = divmod(index - 1, 26)
|
||
out = chr(ord("A") + rem) + out
|
||
return out
|
||
|
||
|
||
def _build_library(name: str, entries: list[Entry]) -> dict[str, Any]:
|
||
"""Cluster one library name's entries into variants and assemble its table row."""
|
||
# group by surface signature -> the entries that share it
|
||
by_sig: dict[str, list[Entry]] = {}
|
||
for e in entries:
|
||
by_sig.setdefault(surface_signature(e.snapshot), []).append(e)
|
||
|
||
# deterministic order: biggest cluster first, ties broken by earliest game name. The first
|
||
# is the reference variant (no asterisk); the rest diverge.
|
||
clusters = sorted(
|
||
by_sig.values(),
|
||
key=lambda es: (-len(es), min(e.game for e in es)),
|
||
)
|
||
reference_snap = clusters[0][0].snapshot
|
||
|
||
variants: list[dict[str, Any]] = []
|
||
variant_of_game: dict[str, list[str]] = {}
|
||
for i, cluster in enumerate(clusters):
|
||
vid = _variant_id(i)
|
||
is_ref = i == 0
|
||
sample = cluster[0].snapshot
|
||
members = [
|
||
{
|
||
"game": e.game,
|
||
"file": e.snapshot.binary.get("name"), # stored name, incl. the <sha8>_ prefix
|
||
"engine": e.snapshot.binary.get("engine"),
|
||
"compiler": e.snapshot.binary.get("compiler"),
|
||
"sha256": e.snapshot.binary.get("sha256"),
|
||
}
|
||
for e in sorted(cluster, key=lambda e: e.game)
|
||
]
|
||
for e in cluster:
|
||
variant_of_game.setdefault(e.game, []).append(vid)
|
||
variants.append(
|
||
{
|
||
"id": vid,
|
||
"reference": is_ref,
|
||
"surface": _surface_counts(sample),
|
||
"api": surface_api(sample),
|
||
"members": members,
|
||
"vs_reference": None if is_ref else _vs_reference(reference_snap, sample),
|
||
}
|
||
)
|
||
|
||
cells: dict[str, dict[str, Any]] = {}
|
||
for game, vids in variant_of_game.items():
|
||
uniq = sorted(set(vids))
|
||
cells[game] = {
|
||
"present": True,
|
||
"variants": uniq,
|
||
"diverges": any(v != "A" for v in uniq),
|
||
}
|
||
|
||
return {
|
||
"name": name,
|
||
"variant_count": len(variants),
|
||
"diverges": len(variants) > 1,
|
||
"cells": cells,
|
||
"variants": variants,
|
||
}
|
||
|
||
|
||
def compatibility_table(entries: Iterable[Entry]) -> dict[str, Any]:
|
||
"""Build the full compatibility table from catalogued entries.
|
||
|
||
Groups by library name (case-insensitive, keeping the most common spelling for display),
|
||
clusters each group into surface variants, and returns a JSON-able dict: a `games` column
|
||
list and a `libraries` list of rows. Libraries with >1 variant are the ones carrying
|
||
asterisks."""
|
||
entries = list(entries)
|
||
|
||
by_lib: dict[str, list[Entry]] = {}
|
||
spellings: dict[str, Counter] = {}
|
||
for e in entries:
|
||
name = _library_name(e.snapshot)
|
||
key = name.casefold()
|
||
by_lib.setdefault(key, []).append(e)
|
||
spellings.setdefault(key, Counter())[name] += 1
|
||
|
||
libraries = [
|
||
_build_library(spellings[key].most_common(1)[0][0], es)
|
||
for key, es in by_lib.items()
|
||
]
|
||
libraries.sort(key=lambda lib: lib["name"].casefold())
|
||
|
||
games = sorted({e.game for e in entries})
|
||
return {
|
||
"schema": "compat/1",
|
||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||
"games": games,
|
||
"libraries": libraries,
|
||
}
|
||
|
||
|
||
# --- renderers ---------------------------------------------------------------------------------
|
||
def render_json(table: dict[str, Any]) -> str:
|
||
return json.dumps(table, indent=2, sort_keys=True, ensure_ascii=False)
|
||
|
||
|
||
def _cell_marker(lib: dict[str, Any], game: str) -> str:
|
||
"""Matrix cell: empty if absent; ✓ for a single-variant library; else the variant letter(s)
|
||
with an asterisk on every non-reference (divergent) variant."""
|
||
cell = lib["cells"].get(game)
|
||
if not cell:
|
||
return ""
|
||
if lib["variant_count"] == 1:
|
||
return "✓"
|
||
return " / ".join(v + ("" if v == "A" else "\\*") for v in cell["variants"])
|
||
|
||
|
||
def _short(sha: str | None) -> str:
|
||
return (sha or "?")[:8]
|
||
|
||
|
||
def _signed(sign: str, items: list[str], cap: int = 15) -> str:
|
||
head = [sign + it for it in items[:cap]]
|
||
if len(items) > cap:
|
||
head.append("…+{0}".format(len(items) - cap))
|
||
return ", ".join(head)
|
||
|
||
|
||
def _emit_detail(out: list[str], vs: dict[str, Any]) -> None:
|
||
"""Nested, named breakdown of a divergent variant: which types changed, and per class which
|
||
methods/events/fields were added (`+`), removed (`−`) or changed (`~`)."""
|
||
det = vs.get("detail") or {}
|
||
|
||
t = det.get("types") or {}
|
||
toks = []
|
||
if t.get("added"):
|
||
toks.append(_signed("+", t["added"]))
|
||
if t.get("removed"):
|
||
toks.append(_signed("−", t["removed"]))
|
||
if t.get("changed"):
|
||
toks.append(_signed("~", t["changed"]))
|
||
if toks:
|
||
out.append(" - typy: {0}".format(" ".join(toks)))
|
||
|
||
for axis, label in (("methods", "metody"), ("events", "eventy"), ("fields", "pola")):
|
||
by = (det.get(axis) or {}).get("by_owner") or {}
|
||
if not by:
|
||
continue
|
||
out.append(" - {0} (klasy ze zmianami):".format(label))
|
||
for owner in by: # already sorted at build time
|
||
d = by[owner]
|
||
parts = []
|
||
if d.get("added"):
|
||
parts.append(_signed("+", d["added"]))
|
||
if d.get("removed"):
|
||
parts.append(_signed("−", d["removed"]))
|
||
if d.get("changed"):
|
||
parts.append(_signed("~", d["changed"]))
|
||
out.append(" - **{0}**: {1}".format(owner, " ".join(parts)))
|
||
|
||
|
||
def render_markdown(table: dict[str, Any]) -> str:
|
||
games = table["games"]
|
||
libs = table["libraries"]
|
||
out: list[str] = ["# Tablica kompatybilności silników", ""]
|
||
out.append(
|
||
"Grupowane po nazwie biblioteki. Komórka: pusta = brak; `✓` = obecna (jedyny wariant); "
|
||
"litera wariantu (`A`, `B\\*`, …) gdy ta sama nazwa biblioteki ma **różne powierzchnie "
|
||
"silnika** — `\\*` oznacza rozbieżność względem wariantu referencyjnego (`A`). "
|
||
"Sekcja *Warianty* niżej wymienia per klasa **które** metody/eventy/pola doszły (`+`), "
|
||
"zniknęły (`−`) lub się zmieniły (`~`)."
|
||
)
|
||
out.append("")
|
||
out.append("_Wygenerowano: {0} · gier: {1} · bibliotek: {2}_".format(
|
||
table.get("generated_at", "?"), len(games), len(libs)))
|
||
out.append("")
|
||
|
||
if not libs:
|
||
out.append("_(katalog pusty)_")
|
||
return "\n".join(out)
|
||
|
||
# --- matrix: library × game ---
|
||
header = "| Biblioteka | " + " | ".join(games) + " |"
|
||
sep = "|" + "---|" * (len(games) + 1)
|
||
out.append(header)
|
||
out.append(sep)
|
||
for lib in libs:
|
||
mark = "" if lib["variant_count"] == 1 else " \\*"
|
||
row = ["**{0}**{1}".format(lib["name"], mark)]
|
||
row += [_cell_marker(lib, g) for g in games]
|
||
out.append("| " + " | ".join(row) + " |")
|
||
out.append("")
|
||
|
||
# --- variant breakdown: only the interesting (multi-variant) libraries ---
|
||
divergent = [lib for lib in libs if lib["variant_count"] > 1]
|
||
if divergent:
|
||
out.append("## Warianty (\\* = rozbieżność powierzchni)")
|
||
out.append("")
|
||
for lib in divergent:
|
||
out.append("### {0} — {1} warianty".format(lib["name"], lib["variant_count"]))
|
||
for v in lib["variants"]:
|
||
s = v["surface"]
|
||
tag = "referencyjny" if v["reference"] else "rozbieżny \\*"
|
||
games_str = ", ".join(
|
||
"{0} ({1}/{2}, {3})".format(
|
||
m["game"], m["engine"] or "?", m["compiler"] or "?", _short(m["sha256"]))
|
||
for m in v["members"]
|
||
)
|
||
class_names = list(v["api"]["classes"])
|
||
out.append(
|
||
"- **{0}** ({1}) — klasy {2} / typy {3} / metody {4} / eventy {5} / pola {6}".format(
|
||
v["id"], tag, len(class_names), s["types"], s["methods"], s["events"], s["fields"]))
|
||
out.append(" - gry: {0}".format(games_str))
|
||
if class_names:
|
||
out.append(" - klasy: {0}".format(_signed("", class_names, cap=30)))
|
||
vs = v["vs_reference"]
|
||
if vs:
|
||
ax = vs["axes"]
|
||
deltas = ", ".join(
|
||
"{0} +{1}/-{2}/~{3}".format(a, ax[a]["added"], ax[a]["removed"], ax[a]["changed"])
|
||
for a in _AXES
|
||
)
|
||
moved = " · przeniesionych metod {0}".format(vs["moved_methods"]) if vs["moved_methods"] else ""
|
||
out.append(
|
||
" - vs **A**: overlap {0}% · {1}{2}".format(vs["overall"], deltas, moved))
|
||
_emit_detail(out, vs)
|
||
out.append("")
|
||
|
||
return "\n".join(out).rstrip() + "\n"
|
||
|
||
|
||
# --- CLI: build the table from snapshot.json files (no DB/server) -------------------------------
|
||
def _label_and_path(token: str) -> tuple[str | None, str]:
|
||
"""Parse a CLI token: 'Game Name=path.json' pins the column label, a bare 'path.json' lets the
|
||
label default (acquisition source, else the file's basename). The '=' must precede a real
|
||
file so paths that happen to contain '=' still work."""
|
||
import os
|
||
|
||
if "=" in token:
|
||
lhs, rhs = token.split("=", 1)
|
||
if os.path.exists(rhs):
|
||
return lhs.strip(), rhs
|
||
return None, token
|
||
|
||
|
||
def _default_label(path: str, snap: Snapshot) -> str:
|
||
import os
|
||
|
||
acq = snap.binary.get("acquisition") or {}
|
||
if acq.get("source"):
|
||
return str(acq["source"])
|
||
base = os.path.basename(path)
|
||
for suffix in (".snapshot.json", ".json"):
|
||
if base.endswith(suffix):
|
||
return base[: -len(suffix)]
|
||
return base
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
import argparse
|
||
|
||
p = argparse.ArgumentParser(
|
||
prog="ams.compat",
|
||
description="Build a cross-binary compatibility table grouped by library name.",
|
||
)
|
||
p.add_argument(
|
||
"snapshots",
|
||
nargs="+",
|
||
help="snapshot.json files; prefix with 'Label=' to set the game/column label",
|
||
)
|
||
p.add_argument("--format", choices=["md", "json"], default="md", help="output format")
|
||
p.add_argument("-o", "--out", help="write here instead of stdout")
|
||
args = p.parse_args(argv)
|
||
|
||
entries: list[Entry] = []
|
||
for token in args.snapshots:
|
||
label, path = _label_and_path(token)
|
||
snap = Snapshot.load(path)
|
||
entries.append(Entry(game=label or _default_label(path, snap), snapshot=snap))
|
||
|
||
table = compatibility_table(entries)
|
||
text = render_json(table) if args.format == "json" else render_markdown(table)
|
||
|
||
if args.out:
|
||
with open(args.out, "w", encoding="utf-8") as fh:
|
||
fh.write(text if text.endswith("\n") else text + "\n")
|
||
print("[+] {0} libraries, {1} games -> {2}".format(
|
||
len(table["libraries"]), len(table["games"]), args.out))
|
||
else:
|
||
print(text)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
sys.exit(main())
|