Files
magdevandClaude Fable 5 4737a5bae8 Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now
reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to
decode a container; the emulator survives as a development-only oracle that the
codec is differentially tested against.

Verified:
- 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0
  files rejected by both implementations (tools/difftest.py --self-test)
- Default.BPJ: 22 components, 14 nets, no dangling terminal
- 22 of 22 parts resolve to library symbols; one series inferred from a
  sibling, two DC resistance deviations reported
- ERC clean and the netlist KiCad extracts from the generated sheet is an
  identical partition to the one derived from the Boxsim grid
- container build byte-identical to the host build
- 32 tests passing, ruff clean

Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses)
-> 1.6 s (pure Python).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 11:10:43 +02:00

181 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Differentially test boxsim.a2f against the Boxsim.exe oracle.
Every container in the Boxsim distribution is decoded twice, once by the pure
Python codec and once by emulating Boxsim's own machine code, and the results
must be byte identical. Writes the oracle digests to a manifest so the fast
test suite can guard regressions without needing Unicorn or Boxsim.exe.
Needs the dev venv (unicorn, pefile):
.venv/bin/python tools/difftest.py [--jobs N] [--limit N]
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
MANIFEST = ROOT / "tests" / "oracle_manifest.json"
def find_containers(dist: Path) -> list[Path]:
files = sorted(
p
for p in dist.rglob("*")
if p.is_file() and p.suffix.upper() in (".BPJ", ".BCH")
)
if not files:
raise SystemExit(f"no .bpj/.bch files found under {dist}")
return files
def check_one(path_str: str) -> dict:
"""Decode with both implementations and compare. Returns a result record."""
from boxsim import _oracle
from boxsim.container import decode
path = Path(path_str)
raw = path.read_bytes()
rec = {"file": str(path.relative_to(ROOT)), "size": len(raw)}
try:
mine = decode(raw)
rec["sha_python"] = hashlib.sha256(mine).hexdigest()
rec["len_python"] = len(mine)
except Exception as exc: # noqa: BLE001 - report, do not mask
rec["error_python"] = f"{type(exc).__name__}: {exc}"
try:
theirs = _oracle.decode_bpj(raw)
rec["sha_oracle"] = hashlib.sha256(theirs).hexdigest()
rec["len_oracle"] = len(theirs)
except Exception as exc: # noqa: BLE001
rec["error_oracle"] = f"{type(exc).__name__}: {exc}"
both_failed = "error_python" in rec and "error_oracle" in rec
if both_failed and "not a MyIni2CK file" in rec["error_python"]:
# Pre-2.0 Boxsim used a different container with no MyIni2CK magic.
# Both implementations refusing it is agreement, not a pass: count it
# separately so it can never masquerade as coverage.
rec["verdict"] = "rejected"
elif both_failed:
rec["verdict"] = "both-errored"
elif "error_python" in rec or "error_oracle" in rec:
rec["verdict"] = "one-errored"
elif rec["sha_python"] == rec["sha_oracle"]:
rec["verdict"] = "identical"
else:
rec["verdict"] = "mismatch"
rec["ok"] = rec["verdict"] in ("identical", "rejected")
return rec
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--dist",
default=str(ROOT / "boxsim_dl"),
help="Boxsim distribution directory to sweep",
)
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 2) - 1))
ap.add_argument("--limit", type=int, default=0, help="only the first N files")
ap.add_argument(
"--self-test",
action="store_true",
help="corrupt one expectation to prove the comparison has teeth",
)
args = ap.parse_args()
files = find_containers(Path(args.dist))
if args.limit:
files = files[: args.limit]
print(f"{len(files)} containers, {args.jobs} workers", flush=True)
started = time.monotonic()
results = []
with ProcessPoolExecutor(max_workers=args.jobs) as pool:
for i, rec in enumerate(pool.map(check_one, [str(f) for f in files]), 1):
results.append(rec)
print(f" [{i}/{len(files)}] {rec['verdict']:10s} {rec['file']}", flush=True)
if args.self_test:
# Feed the comparison a deliberately wrong expectation. If this does not
# get reported as a mismatch, the gate is decorative.
print("\nself-test: corrupting one expectation", flush=True)
decoded = [r for r in results if r["verdict"] == "identical"]
if not decoded:
print("SELF-TEST INCONCLUSIVE: no decoded file to corrupt")
return 2
poisoned = dict(decoded[0])
poisoned["sha_oracle"] = "0" * 64
if poisoned["sha_python"] == poisoned["sha_oracle"]:
print("SELF-TEST FAILED: corrupt expectation still compared equal")
return 2
print(
f"self-test passed: a corrupt digest for {poisoned['file']} "
"is reported as a mismatch"
)
failures = [r for r in results if not r["ok"]]
elapsed = time.monotonic() - started
counts: dict[str, int] = {}
for r in results:
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
print(f"\nverdicts after {elapsed:.0f}s: " + ", ".join(
f"{v}={n}" for v, n in sorted(counts.items())))
print(
f"{counts.get('identical', 0)} byte-identical, "
f"{counts.get('rejected', 0)} rejected by both as pre-2.0 containers"
)
if failures:
print("\nfailures:")
for r in failures:
print(f" {r['file']}")
for key in ("error_python", "error_oracle"):
if key in r:
print(f" {key}: {r[key]}")
if "sha_python" in r and "sha_oracle" in r:
print(f" python {r['sha_python'][:16]} len {r.get('len_python')}")
print(f" oracle {r['sha_oracle'][:16]} len {r.get('len_oracle')}")
return 1
decoded = {
r["file"]: {"sha256": r["sha_oracle"], "len": r["len_oracle"]}
for r in results
if r["verdict"] == "identical"
}
rejected = sorted(r["file"] for r in results if r["verdict"] == "rejected")
MANIFEST.parent.mkdir(exist_ok=True)
MANIFEST.write_text(
json.dumps(
{
"note": "oracle digests from boxsim._oracle, see tools/difftest.py",
"files": decoded,
"rejected_not_myini2ck": rejected,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
print(
f"wrote {MANIFEST.relative_to(ROOT)}: "
f"{len(decoded)} digests, {len(rejected)} rejected files recorded"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())