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

211 lines
7.6 KiB
Python

"""Development-only reference decoder: runs Boxsim's own machine code.
This module emulates `ExpandA2F` (VA 0xa11880) out of an unmodified Boxsim.exe
under Unicorn, with faked Delphi TStream objects. It is exact by construction
and exists solely as the oracle that the pure Python decoder in `boxsim.a2f`
is differentially tested against.
It is NOT part of the shipped tool: it needs `unicorn`, `pefile` and an 8 MB
proprietary Windows binary. Point BOXSIM_EXE at a Boxsim.exe to use it.
python -m boxsim.cli decode --oracle file.bpj
"""
from __future__ import annotations
import os
import struct
MAGIC = b"MyIni2CK"
ENTRY = 0xA11880 # ExpandA2F(self, instream, outstream)
ERR_FUNC = 0x97E584 # Boxsim's error reporter
RAISE_FUNC = 0x80ACC4 # Delphi RaiseExcept
OBJ_BASE = 0x20000000 # decoder object: ~11 MB of zero-init tables
OBJ_SIZE = 0x01000000
FAKE_BASE = 0x40000000 # fake stream objects, vtables, trap stubs
STACK_BASE = 0x60000000
STACK_SIZE = 0x100000
GDT_BASE = 0x50000000
FS_SEG = 0x50002000 # Delphi SEH chain scratch space
SENTINEL = 0x7F00DEAD
IN_OBJ, OUT_OBJ = FAKE_BASE, FAKE_BASE + 0x100
IN_VT, OUT_VT = FAKE_BASE + 0x1000, FAKE_BASE + 0x2000
STUB = FAKE_BASE + 0x3000
S_IN_SIZE, S_IN_READ, S_IN_SEEK, S_OUT_WRITE, S_OUT_SIZE, S_OUT_SEEK = range(6)
STUB_SPAN = 256
def default_exe() -> str:
return os.environ.get(
"BOXSIM_EXE",
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"boxsim_dl",
"BoxSim 2.1 DE (Stand 12.2025)",
"Boxsim.exe",
),
)
def _gdt_entry(base: int, limit: int, access: int, gran: int) -> bytes:
e = limit & 0xFFFF
e |= (base & 0xFFFFFF) << 16
e |= (access & 0xFF) << 40
e |= ((limit >> 16) & 0xF) << 48
e |= (gran & 0xF) << 52
e |= ((base >> 24) & 0xFF) << 56
return struct.pack("<Q", e)
def expand_a2f(data: bytes, exe: str | None = None) -> bytes:
"""Decompress a descrambled MyIni2CK stream using Boxsim's own decoder."""
import pefile
from unicorn import UC_ARCH_X86, UC_HOOK_CODE, UC_MODE_32, Uc
from unicorn.x86_const import (
UC_X86_REG_CS,
UC_X86_REG_DS,
UC_X86_REG_EAX,
UC_X86_REG_ECX,
UC_X86_REG_EDX,
UC_X86_REG_ES,
UC_X86_REG_ESP,
UC_X86_REG_FS,
UC_X86_REG_GDTR,
UC_X86_REG_GS,
UC_X86_REG_SS,
)
exe = exe or default_exe()
if not os.path.exists(exe):
raise FileNotFoundError(
f"oracle needs Boxsim.exe; set BOXSIM_EXE (looked at {exe!r})"
)
pe = pefile.PE(exe)
base = pe.OPTIONAL_HEADER.ImageBase
img = pe.get_memory_mapped_image(ImageBase=base)
uc = Uc(UC_ARCH_X86, UC_MODE_32)
uc.mem_map(base, (len(img) + 0xFFF) & ~0xFFF)
uc.mem_write(base, img)
for addr, size in (
(OBJ_BASE, OBJ_SIZE),
(FAKE_BASE, 0x10000),
(STACK_BASE, STACK_SIZE),
(GDT_BASE, 0x4000),
):
uc.mem_map(addr, size)
uc.mem_write(IN_OBJ, struct.pack("<I", IN_VT))
uc.mem_write(OUT_OBJ, struct.pack("<I", OUT_VT))
layout = (
(IN_VT, {0x00: S_IN_SIZE, 0x0C: S_IN_READ, 0x28: S_IN_SEEK}),
(OUT_VT, {0x00: S_OUT_SIZE, 0x0C: S_IN_READ, 0x10: S_OUT_WRITE, 0x28: S_OUT_SEEK}),
)
for vt, stubs in layout:
for off in range(0, 0x30, 4):
sid = stubs.get(off, 15)
uc.mem_write(vt + off, struct.pack("<I", STUB + sid * 16))
for sid in range(16):
# Delphi Seek is `stdcall`-ish with 8 bytes of args; the rest are cdecl.
body = b"\xc2\x08\x00" if sid in (S_IN_SEEK, S_OUT_SEEK) else b"\xc3"
uc.mem_write(STUB + sid * 16, body)
# A full flat GDT. Loading FS alone resets the other segment registers'
# hidden descriptors in unicorn, which faults the very first push.
uc.mem_write(GDT_BASE + 8, _gdt_entry(0, 0xFFFFF, 0x9B, 0xC))
uc.mem_write(GDT_BASE + 16, _gdt_entry(0, 0xFFFFF, 0x93, 0xC))
uc.mem_write(GDT_BASE + 24, _gdt_entry(FS_SEG, 0xFFF, 0x93, 0x4))
uc.reg_write(UC_X86_REG_GDTR, (0, GDT_BASE, 0x1F, 0))
uc.reg_write(UC_X86_REG_CS, 8)
for seg in (UC_X86_REG_DS, UC_X86_REG_ES, UC_X86_REG_SS, UC_X86_REG_GS):
uc.reg_write(seg, 16)
uc.reg_write(UC_X86_REG_FS, 24)
state = {"pos": 0, "out": bytearray(), "err": None}
def hook_code(u, addr, _size, _ud):
if STUB <= addr < STUB + STUB_SPAN:
sid = (addr - STUB) // 16
eax = u.reg_read(UC_X86_REG_EAX)
edx = u.reg_read(UC_X86_REG_EDX)
ecx = u.reg_read(UC_X86_REG_ECX)
if sid == S_IN_READ and eax == IN_OBJ:
n = min(ecx, len(data) - state["pos"])
if n > 0:
u.mem_write(edx, data[state["pos"] : state["pos"] + n])
state["pos"] += n
u.reg_write(UC_X86_REG_EAX, n)
elif sid == S_IN_SIZE:
u.reg_write(UC_X86_REG_EAX, len(data))
u.reg_write(UC_X86_REG_EDX, 0)
elif sid == S_IN_SEEK:
esp = u.reg_read(UC_X86_REG_ESP)
off = struct.unpack("<i", u.mem_read(esp + 4, 4))[0]
origin = edx & 0xFF
state["pos"] = (
off
if origin == 0
else state["pos"] + off
if origin == 1
else len(data) + off
)
u.reg_write(UC_X86_REG_EAX, state["pos"] & 0xFFFFFFFF)
u.reg_write(UC_X86_REG_EDX, 0)
elif sid == S_OUT_WRITE:
state["out"] += bytes(u.mem_read(edx, ecx))
u.reg_write(UC_X86_REG_EAX, ecx)
elif sid in (S_OUT_SIZE, S_OUT_SEEK):
u.reg_write(UC_X86_REG_EAX, len(state["out"]))
u.reg_write(UC_X86_REG_EDX, 0)
else:
state["err"] = f"unexpected vtable stub {sid}"
u.emu_stop()
elif addr == ERR_FUNC:
eax = u.reg_read(UC_X86_REG_EAX)
msg = bytes(u.mem_read(eax, 64)).split(b"\x00")[0]
state["err"] = f"boxsim error: {msg.decode('latin1')}"
u.emu_stop()
elif addr == RAISE_FUNC:
state["err"] = "delphi exception"
u.emu_stop()
# Bind the hooks to the trap addresses only. A single unbounded UC_HOOK_CODE
# fires the callback on every emulated instruction and costs ~5x wall clock.
uc.hook_add(UC_HOOK_CODE, hook_code, None, STUB, STUB + STUB_SPAN - 1)
uc.hook_add(UC_HOOK_CODE, hook_code, None, ERR_FUNC, ERR_FUNC)
uc.hook_add(UC_HOOK_CODE, hook_code, None, RAISE_FUNC, RAISE_FUNC)
esp = STACK_BASE + STACK_SIZE - 0x1000
uc.mem_write(esp, struct.pack("<I", SENTINEL))
uc.reg_write(UC_X86_REG_ESP, esp)
# Codec config, as the compressor entry point (0xa1013c) sets it.
uc.mem_write(OBJ_BASE + 0x04, b"\x01\x01")
uc.mem_write(OBJ_BASE + 0x08, struct.pack("<I", 0x20))
uc.mem_write(OBJ_BASE + 0x0C, struct.pack("<I", 0x47))
uc.mem_write(OBJ_BASE + 0x10, b"\x01")
uc.reg_write(UC_X86_REG_EAX, OBJ_BASE)
uc.reg_write(UC_X86_REG_EDX, IN_OBJ)
uc.reg_write(UC_X86_REG_ECX, OUT_OBJ)
uc.emu_start(ENTRY, SENTINEL, timeout=600 * 1000000)
if state["err"]:
raise RuntimeError(state["err"])
if state["pos"] != len(data):
raise RuntimeError(f"decoder stopped early: {state['pos']}/{len(data)}")
del uc, img, pe
return bytes(state["out"])
def decode_bpj(raw: bytes, exe: str | None = None) -> bytes:
"""Full container decode using the oracle for the compressed layer."""
from .krypt import unscramble
if raw[:8] != MAGIC:
raise ValueError("not a MyIni2CK file")
return expand_a2f(MAGIC + unscramble(raw[8:]), exe=exe)