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

80 lines
2.7 KiB
Python

"""Codec tests. The heavy differential sweep against Boxsim.exe lives in
tools/difftest.py; these guard regressions against the digests it recorded.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from boxsim import a2f, container
from boxsim.krypt import KryptPRNG
ROOT = Path(__file__).resolve().parent.parent
def test_keystream_is_stable():
"""Regression guard on the keystream every Boxsim file is scrambled with.
The value is not independently derived, it is the keystream under which all
134 containers in the distribution decode byte-identically to Boxsim's own
decoder (see tools/difftest.py). Its worth here is catching an accidental
change to the generator, not proving the generator correct.
"""
ks = KryptPRNG().keystream(8)
assert ks.hex() == "ebe14331cb3183e1"
def test_rejects_foreign_file():
with pytest.raises(container.ContainerError, match="not a MyIni2CK file"):
container.decode(b"not a boxsim file at all")
def test_truncated_stream_fails_loudly():
raw = (ROOT / "boxsim_dl" / "BoxSim 2.1 DE (Stand 12.2025)"
/ "Projekte" / "Default.BPJ")
if not raw.exists():
pytest.skip("distribution not present")
data = raw.read_bytes()
with pytest.raises(a2f.A2FError):
container.decode(data[: len(data) // 2])
def test_padding_allowance_is_bounded():
# A stream of zeros must not decode forever; it has to hit the limit.
with pytest.raises(a2f.A2FError):
a2f.expand(b"MyIni2CK", skip_magic=8)
@pytest.mark.needs_dist
def test_matches_oracle_digests(manifest: dict):
"""Every container the oracle decoded must still decode identically."""
files = manifest["files"]
assert files, "manifest has no entries"
checked = 0
for rel, expected in files.items():
path = ROOT / rel
if not path.exists():
pytest.fail(f"manifest references missing file {rel}")
payload = container.decode(path.read_bytes())
digest = hashlib.sha256(payload).hexdigest()
assert digest == expected["sha256"], f"{rel} digest changed"
assert len(payload) == expected["len"], f"{rel} length changed"
checked += 1
assert checked == len(files)
@pytest.mark.needs_dist
def test_pre20_containers_are_rejected(manifest: dict):
"""The BoxSim 1.2 files use an older container and must stay rejected."""
rejected = manifest["rejected_not_myini2ck"]
assert rejected, "manifest recorded no rejected files"
for rel in rejected:
path = ROOT / rel
if not path.exists():
continue
with pytest.raises(container.ContainerError):
container.decode(path.read_bytes())