350 lines
13 KiB
Python
350 lines
13 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 _axis_counts(block: dict[str, Any]) -> dict[str, int]:
|
||
return {
|
||
"added": len(block["added"]),
|
||
"removed": len(block["removed"]),
|
||
"changed": len(block["changed"]),
|
||
}
|
||
|
||
|
||
def _vs_reference(reference: Snapshot, variant: Snapshot) -> dict[str, Any]:
|
||
"""How a divergent variant differs from its library's reference variant: per-axis +/-/~ counts
|
||
plus the pooled surface-overlap score, reusing the existing diff/similarity engines."""
|
||
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"]),
|
||
}
|
||
|
||
|
||
# --- 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),
|
||
"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 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`). "
|
||
"Szczegóły rozbieżności w sekcji *Warianty* niżej."
|
||
)
|
||
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"]
|
||
)
|
||
out.append(
|
||
"- **{0}** ({1}) — typy {2} / metody {3} / eventy {4} / pola {5}".format(
|
||
v["id"], tag, s["types"], s["methods"], s["events"], s["fields"]))
|
||
out.append(" - gry: {0}".format(games_str))
|
||
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))
|
||
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())
|