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>
This commit is contained in:
magdevandClaude Fable 5 committed 2026-09-29 11:10:43 +02:00
1 parent eb81ca61cb
commit 4737a5bae8
28 files changed
+4459 -122

No files matched your search

View File
Whitespace-only changes.
+210
View File
@@ -0,0 +1,210 @@
"""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)
+267
View File
@@ -0,0 +1,267 @@
"""Pure Python implementation of Boxsim's A2F codec (decompression).
Reverse engineered from `ExpandA2F` at VA 0xa11880 in Boxsim 2.1. No part of
this needs Boxsim.exe; `boxsim._oracle` runs the original machine code and is
used only to differentially test this module.
Stream shape
------------
A bit stream, MSB first, read through a 31 bit accumulator. Symbols come from a
canonical Huffman code over 356 symbols decoded through a 65536 entry direct
lookup table:
0x000..0x0FF literal byte
0x100 end of stream
0x101 new code lengths follow, rebuild the tables
0x102 define N entries of the 64 entry digram dictionary
0x103..0x10A match, length 3, distance in k+3 bits
0x10B..0x114 match, length 4, distance in k+3 bits
0x115..0x123 match, unary coded length class, distance in k+3 bits
0x124..0x163 emit the two bytes of digram dictionary entry k
Distances use k = symbol - class base: k+3 raw bits biased by 2**(k+3) - 7, so
the classes tile 1.. without gaps.
"""
from __future__ import annotations
NSYM = 0x164 # 356 symbols
TABLE_BITS = 16
TABLE_SIZE = 1 << TABLE_BITS
DICT_SIZE = 0x40
INITIAL_CODE_LENGTH = 9
MAX_CODE_LENGTH = 16
SYM_EOS = 0x100
SYM_REBUILD = 0x101
SYM_DIGRAM_DEF = 0x102
SYM_MATCH3 = 0x103 # 8 symbols
SYM_MATCH4 = 0x10B # 10 symbols
SYM_MATCHN = 0x115 # 15 symbols
SYM_DIGRAM = 0x124 # 64 symbols
# Unary length class -> (extra bits, base). Class 0 is unreachable because the
# unary read always consumes at least one bit.
LENGTH_CLASSES = {
1: (2, 5),
2: (4, 9),
3: (6, 0x19),
4: (8, 0x59),
5: (10, 0x159),
6: (12, 0x559),
}
class A2FError(ValueError):
"""Raised when the stream is malformed or uses an unreachable encoding."""
#: Bytes of zero padding tolerated past the end of the stream. Boxsim decodes
#: from a 0x4000 byte staging buffer and its refill reads on past the valid
#: length, so the final symbol's 16 bit lookup window routinely extends beyond
#: the last real byte. Those bits are never significant: the end-of-stream code
#: is fully contained in real bytes and the lookup table maps every window
#: sharing that prefix to the same symbol. The allowance stays small so a
#: genuinely truncated file still fails loudly instead of decoding garbage.
PAD_ALLOWANCE = 8
class _BitReader:
"""MSB-first bit reader with Boxsim's 23 bit accumulator mask.
The mask is load bearing: Boxsim shifts `(acc & 0x7FFFFF) << 8` on every
refill, which is safe only because a refill step runs when fewer than 19
bits are buffered, so no live bit is ever above bit 22.
"""
__slots__ = ("data", "pos", "acc", "navail", "padding")
def __init__(self, data: bytes):
self.data = data
self.pos = 0
self.acc = 0
self.navail = 0
self.padding = 0
def refill(self, n: int) -> None:
data, pos, acc, navail = self.data, self.pos, self.acc, self.navail
end = len(data)
while navail < n:
if pos < end:
byte = data[pos]
else:
self.padding += 1
if self.padding > PAD_ALLOWANCE:
raise A2FError(
"input exhausted: needed more than "
f"{PAD_ALLOWANCE} bytes of padding past the stream end"
)
byte = 0
acc = ((acc & 0x7FFFFF) << 8) | byte
pos += 1
navail += 8
self.pos, self.acc, self.navail = pos, acc, navail
def take(self, n: int) -> int:
"""Consume the next n bits."""
self.navail -= n
return (self.acc >> self.navail) & ((1 << n) - 1)
def peek16(self) -> int:
return (self.acc >> (self.navail - TABLE_BITS)) & 0xFFFF
class _Huffman:
"""Canonical Huffman decoder built exactly like Boxsim's VA 0xa10548."""
__slots__ = ("lengths", "symtab", "lentab")
def __init__(self) -> None:
self.lengths = [INITIAL_CODE_LENGTH] * NSYM
self.symtab = [0] * TABLE_SIZE
self.lentab = [0] * TABLE_SIZE
self.build()
def build(self) -> None:
lengths = self.lengths
for sym, length in enumerate(lengths):
if not 0 < length <= MAX_CODE_LENGTH:
raise A2FError(f"symbol {sym} has invalid code length {length}")
# Boxsim accumulates codes in a 24 bit register, adding 2**(24-len) per
# assigned symbol, which is a plain canonical Huffman walk.
codes = [0] * NSYM
code = 0
for bitlen in range(1, MAX_CODE_LENGTH + 1):
step = 1 << (24 - bitlen)
shift = 24 - bitlen
for sym in range(NSYM):
if lengths[sym] == bitlen:
codes[sym] = code >> shift
code += step
if code > 0x1000000:
raise A2FError("over-subscribed Huffman code")
symtab, lentab = self.symtab, self.lentab
for sym in range(NSYM):
length = lengths[sym]
start = codes[sym] << (TABLE_BITS - length)
count = 1 << (TABLE_BITS - length)
symtab[start : start + count] = [sym] * count
lentab[start : start + count] = [length] * count
def decode(self, bits: _BitReader) -> int:
bits.refill(TABLE_BITS)
idx = bits.peek16()
length = self.lentab[idx]
if length == 0:
raise A2FError("incomplete Huffman code: undefined table entry")
bits.navail -= length
return self.symtab[idx]
def read_lengths(self, bits: _BitReader) -> None:
"""Symbol 0x101 payload: 356 code lengths.
Two zero bits repeat the previous length. Otherwise a 4 bit nibble plus
one gives the length, and the value 5 is an escape meaning a second
nibble follows and replaces it.
"""
lengths = self.lengths
prev = -1
for i in range(NSYM):
bits.refill(8)
if (bits.acc >> (bits.navail - 2)) & 3 == 0:
lengths[i] = prev
bits.navail -= 2
else:
value = bits.take(4) + 1
if value == 5:
value = bits.take(4) + 1
lengths[i] = value
prev = value
self.build()
def _distance(bits: _BitReader, k: int) -> int:
"""Distance for match class index k: k+3 raw bits biased by 2**(k+3) - 7."""
bits.refill(0x13)
nbits = k + 3
return bits.take(nbits) + (1 << nbits) - 7
def expand(data: bytes, *, skip_magic: int = 8) -> bytes:
"""Decompress an A2F stream, skipping `skip_magic` leading bytes."""
bits = _BitReader(data[skip_magic:])
huff = _Huffman()
digrams = [0] * DICT_SIZE
dptr = 0
out = bytearray()
while True:
sym = huff.decode(bits)
if sym < SYM_EOS:
out.append(sym)
continue
if sym == SYM_EOS:
break
if sym == SYM_REBUILD:
huff.read_lengths(bits)
continue
if sym == SYM_DIGRAM_DEF:
bits.refill(6)
count = bits.take(5) + 1
for _ in range(count):
bits.refill(0x11)
high = huff.decode(bits)
bits.refill(0x11)
low = huff.decode(bits)
digrams[dptr] = ((high << 8) | low) & 0xFFFF
dptr = (dptr + 1) & (DICT_SIZE - 1)
continue
if sym >= SYM_DIGRAM:
pair = digrams[sym - SYM_DIGRAM]
out.append(pair >> 8)
out.append(pair & 0xFF)
continue
# Match symbols.
if sym < SYM_MATCH4:
length = 3
dist = _distance(bits, sym - SYM_MATCH3)
elif sym < SYM_MATCHN:
length = 4
dist = _distance(bits, sym - SYM_MATCH4)
else:
k = sym - SYM_MATCHN
bits.refill(0x11)
unary = 0
while True:
bit = (bits.acc >> (bits.navail - 1)) & 1
unary += 1
bits.navail -= 1
if not bit:
break
bits.refill(0xD)
try:
nbits, base = LENGTH_CLASSES[unary]
except KeyError:
raise A2FError(f"unsupported length class {unary}") from None
length = bits.take(nbits) + base
dist = _distance(bits, k)
if dist > len(out):
raise A2FError(f"match distance {dist} exceeds {len(out)} bytes of output")
start = len(out) - dist
if dist >= length:
out += out[start : start + length]
else:
# Overlapping copy, byte by byte, as the original does.
for i in range(start, start + length):
out.append(out[i])
return bytes(out)
+211
View File
@@ -0,0 +1,211 @@
"""Visaton parts catalogue, as shipped with Boxsim.
`Weichenbauteile/Visatonteile.txt` is tab separated text:
<type>\t<series>\t<value>[\t<dcr>]
Type is R, C or L. Series strings are exactly the `_bem` values Boxsim writes
into a project, which makes this the authoritative mapping from project data to
a real part. Resistors are in Ohm, capacitors in microfarad, inductors in
millihenry with the fourth column giving the DC resistance in Ohm.
Beware: the file has no trailing newline, so `wc -l` undercounts it by one and
the final part is easy to lose.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from .model import PartType
#: Catalogue series name -> library symbol series. Inductor gauges are carried
#: separately because the library encodes them as a `(0.6)` style suffix.
SERIES_TO_SYMBOL = {
"MOX 10W": "MOX-Widerstand",
"Keramik 10W": "Keramikwiderstand",
"Elko rau": "Elko",
"MKT-A": "MKT-A",
"MKP": "MKP",
"FC-Spule": "FC-Spule",
"LR-Spule": "LR-Spule",
"KN-Spule": "KN-Spule",
"SP 0,6mm": "SP-Spule",
"SP 1,0mm": "SP-Spule",
"SP 1,3mm": "SP-Spule",
}
#: Wire gauge per SP series, matching the library's symbol name suffix.
SERIES_GAUGE = {"SP 0,6mm": "0.6", "SP 1,0mm": "1.0", "SP 1,3mm": "1.3"}
TYPE_OF = {"R": PartType.RESISTOR, "C": PartType.CAPACITOR, "L": PartType.INDUCTOR}
#: Catalogue units to SI.
SI_SCALE = {
PartType.RESISTOR: 1.0,
PartType.CAPACITOR: 1e-6,
PartType.INDUCTOR: 1e-3,
}
#: Fallback series when a project leaves `_bem` empty and no sibling settles it.
#: The widest series for the type, so the substitution is at least stockable.
DEFAULT_SERIES = {
PartType.CAPACITOR: "MKT-A",
PartType.RESISTOR: "Keramik 10W",
}
#: Shape of the catalogue shipped with Boxsim 2.1 DE (12.2025), verified
#: 2026-09-29. Asserted when bundling the catalogue into an image, because
#: `cross_check` only proves the catalogue is a subset of the library: a
#: truncated catalogue would pass that and then reject valid projects with
#: "not stocked by Visaton". Bump these deliberately when Visaton's list changes.
EXPECTED_PART_COUNT = 95
EXPECTED_SERIES_COUNT = 11
class CatalogError(ValueError):
pass
@dataclass(frozen=True)
class CatalogPart:
typ: PartType
series: str
value: float # SI: Ohm, Farad, Henry
dcr: float | None # Ohm, inductors only
raw_value: float # as printed in the catalogue
@property
def gauge(self) -> str | None:
return SERIES_GAUGE.get(self.series)
@property
def symbol_series(self) -> str:
return SERIES_TO_SYMBOL[self.series]
class Catalog:
"""All parts Boxsim will let a designer pick."""
def __init__(self, parts: list[CatalogPart]):
self.parts = parts
self._by_series: dict[str, list[CatalogPart]] = {}
for p in parts:
self._by_series.setdefault(p.series, []).append(p)
def __len__(self) -> int:
return len(self.parts)
@property
def series_names(self) -> list[str]:
return sorted(self._by_series)
def of_series(self, series: str) -> list[CatalogPart]:
return self._by_series.get(series, [])
def find(self, series: str, value: float, rtol: float = 1e-4) -> CatalogPart | None:
"""Nearest catalogue part in a series, within a relative tolerance."""
best: CatalogPart | None = None
best_err = None
for p in self.of_series(series):
if p.value == 0:
continue
err = abs(p.value - value) / p.value
if err <= rtol and (best_err is None or err < best_err):
best, best_err = p, err
return best
def nearest(self, series: str, value: float, count: int = 3) -> list[CatalogPart]:
"""Closest stocked values, for error messages."""
return sorted(
self.of_series(series), key=lambda p: abs(p.value - value)
)[:count]
def parse(text: str) -> Catalog:
parts: list[CatalogPart] = []
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
for line in lines[1:]: # first line is the "Visaton-Weichenbauteile" banner
fields = line.split("\t")
if len(fields) < 3:
continue
kind = fields[0].strip()
series = fields[1].strip()
if kind not in TYPE_OF or not series or "---" in series:
continue
raw = fields[2].strip()
if not raw:
continue
try:
raw_value = float(raw.replace(",", "."))
except ValueError:
continue
typ = TYPE_OF[kind]
dcr = None
if len(fields) > 3 and fields[3].strip():
try:
dcr = float(fields[3].strip().replace(",", "."))
except ValueError:
dcr = None
if series not in SERIES_TO_SYMBOL:
raise CatalogError(
f"catalogue series {series!r} has no library mapping; "
"add it to SERIES_TO_SYMBOL"
)
parts.append(
CatalogPart(
typ=typ,
series=series,
value=raw_value * SI_SCALE[typ],
dcr=dcr,
raw_value=raw_value,
)
)
if not parts:
raise CatalogError("catalogue contained no parts")
return Catalog(parts)
def check_shape(catalog: Catalog) -> None:
"""Assert the catalogue is complete, not merely self-consistent."""
if len(catalog) != EXPECTED_PART_COUNT or (
len(catalog.series_names) != EXPECTED_SERIES_COUNT
):
raise CatalogError(
f"catalogue has {len(catalog)} parts across "
f"{len(catalog.series_names)} series, expected "
f"{EXPECTED_PART_COUNT} across {EXPECTED_SERIES_COUNT}. "
"A short catalogue is usually a truncated file (note it has no "
"trailing newline). If Visaton really changed their range, update "
"EXPECTED_PART_COUNT and EXPECTED_SERIES_COUNT."
)
missing = sorted(set(SERIES_TO_SYMBOL) - set(catalog.series_names))
if missing:
raise CatalogError(f"catalogue is missing series: {', '.join(missing)}")
def default_path() -> Path:
"""Where the catalogue lives, overridable for a relocated distribution."""
env = os.environ.get("BOXSIM_CATALOG")
if env:
return Path(env)
root = Path(__file__).resolve().parent.parent
return (
root
/ "boxsim_dl"
/ "BoxSim 2.1 DE (Stand 12.2025)"
/ "Weichenbauteile"
/ "Visatonteile.txt"
)
def load(path: str | os.PathLike[str] | None = None) -> Catalog:
p = Path(path) if path else default_path()
if not p.exists():
raise CatalogError(
f"Visaton catalogue not found at {p}; set BOXSIM_CATALOG to its location"
)
return parse(p.read_text(encoding="latin1"))
+293
View File
@@ -0,0 +1,293 @@
"""Command line interface.
boxsim decode file.bpj raw INI payload
boxsim extract file.bpj structured JSON model
boxsim netlist file.bpj crossover netlist
boxsim build file.bpj outdir/ a KiCad 10 project
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from . import catalog as catalog_mod
from . import container, ini, model
from . import project as project_mod
from . import symbols as symbols_mod
from .model import Project
def _add_library_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--library",
help="path to the kicad-visaton-lib checkout or its Visaton.kicad_sym "
"(default: $BOXSIM_KICAD_LIB, else the copy bundled with this tool)",
)
p.add_argument(
"--catalog",
help="path to Visatonteile.txt (default: $BOXSIM_CATALOG, else the "
"copy bundled with this tool)",
)
def _read(path: str) -> bytes:
p = Path(path)
if not p.exists():
raise SystemExit(f"boxsim: {path}: no such file")
return p.read_bytes()
def _load_project(path: str) -> tuple[Project, str]:
raw = _read(path)
doc = ini.parse(container.decode_text(raw))
return model.from_ini(doc), Path(path).stem
def cmd_decode(args: argparse.Namespace) -> int:
raw = _read(args.file)
if args.oracle:
from . import _oracle
payload = _oracle.decode_bpj(raw)
else:
payload = container.decode(raw)
if args.output:
Path(args.output).write_bytes(payload)
print(f"{args.file}: {len(payload)} bytes -> {args.output}")
else:
sys.stdout.buffer.write(payload)
return 0
def _model_to_dict(project: Project, resolutions=None) -> dict:
by_slot = {r.part.slot: r for r in (resolutions or [])}
out: dict = {
"version": project.version,
"amp_count": project.amp_count,
"chassis_count": project.chassis_count,
"comments": project.comments,
"chassis": [
{
"index": c.index,
"name": c.name,
"comment": c.comment,
"re_ohm": c.re,
"fs_hz": c.fs,
"vas_m3": c.vas,
"qes": c.qes,
"qms": c.qms,
}
for c in project.chassis
],
"enclosures": [
{"index": e.index, "type": e.typ, "vb_m3": e.vb, "fb_hz": e.fb}
for e in project.enclosures
],
"baffles": [
{"index": b.index, "width_m": b.width, "height_m": b.height,
"shared_enclosure": b.shared}
for b in project.baffles
],
"crossovers": [],
}
for xo in project.crossovers:
netlist = xo.build_netlist()
parts = []
for p in xo.parts:
entry = {
"slot": p.slot,
"type": p.typ.name.lower(),
"orientation": p.orientation.name.lower(),
"row": p.row,
"col": p.col,
"nodes": [p.node_a, p.node_b],
"nets": [netlist.net_of(p.node_a), netlist.net_of(p.node_b)],
}
if p.value is not None:
entry["value_si"] = p.value
if p.label:
entry["series"] = p.label
if p.dcr is not None:
entry["dcr_ohm"] = p.dcr
if p.chassis_index is not None:
entry["chassis_index"] = p.chassis_index
if p.inverted:
entry["inverted"] = True
res = by_slot.get(p.slot)
if res is not None:
entry["symbol"] = res.symbol
entry["value_text"] = res.value_text
if res.notes:
entry["notes"] = res.notes
parts.append(entry)
out["crossovers"].append(
{
"index": xo.index,
"component_count": len(xo.components),
"wire_count": len(xo.wires),
"net_count": netlist.net_count,
"dangling_nets": netlist.dangling(),
"parts": parts,
}
)
return out
def cmd_extract(args: argparse.Namespace) -> int:
project, _ = _load_project(args.file)
resolutions = None
if not args.no_symbols:
try:
cat, lib = project_mod.load_parts(args.catalog, args.library)
resolver = symbols_mod.Resolver(cat, lib)
resolutions = [
r
for xo in project.crossovers
for r in resolver.resolve_all(xo.components)
]
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
print(f"boxsim: symbol resolution skipped: {exc}", file=sys.stderr)
payload = json.dumps(_model_to_dict(project, resolutions), indent=2, sort_keys=False)
if args.output:
Path(args.output).write_text(payload + "\n", encoding="utf-8")
print(f"{args.file} -> {args.output}")
else:
print(payload)
return 0
def cmd_netlist(args: argparse.Namespace) -> int:
project, _ = _load_project(args.file)
for xo in project.crossovers:
netlist = xo.build_netlist()
print(f"# crossover {xo.index}: {len(xo.components)} components, "
f"{netlist.net_count} nets")
groups: dict[int, list[str]] = {}
for p in xo.components:
label = f"Part{p.slot}({p.typ.name.lower()}"
label += f" {p.value:g})" if p.value is not None else ")"
for node in (p.node_a, p.node_b):
groups.setdefault(netlist.net_of(node), []).append(label)
for net in sorted(groups):
print(f" net {net:3d}: {', '.join(sorted(groups[net]))}")
dangling = netlist.dangling()
if dangling:
print(f" WARNING dangling nets: {dangling}")
return 0
def cmd_build(args: argparse.Namespace) -> int:
project, name = _load_project(args.file)
try:
cat, lib = project_mod.load_parts(args.catalog, args.library)
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
extras = symbols_mod.cross_check(cat, lib)
if extras and args.verbose:
print(f"note: library has {len(extras)} symbol(s) Boxsim cannot select: "
f"{', '.join(extras)}")
outdir = Path(args.outdir)
try:
result = project_mod.build(
project, args.name or name, outdir, cat, lib, pitch=args.pitch
)
except (symbols_mod.SymbolError, NotImplementedError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
print(f"{result.schematic.name}: {len(result.sheet.placements)} symbols, "
f"{len(result.sheet.wires)} wires, {len(result.sheet.junctions)} junctions, "
f"paper {result.sheet.paper}")
for note in result.notes:
print(f" note: {note}")
if args.verify:
from . import verify as verify_mod
try:
gate = verify_mod.run_gate(
result.schematic, project.crossovers[0], result.sheet.placements
)
except verify_mod.VerifyError as exc:
raise SystemExit(f"boxsim: {exc}") from exc
for m in gate.messages:
print(f" {m}")
status = "PASS" if gate.ok else "FAIL"
print(f" gate {status}: KiCad {gate.kicad_version}, ERC "
f"{'clean' if gate.erc_ok else 'failed'}, netlist "
f"{'matches' if gate.netlist_matches else 'differs'} "
f"({len(gate.reference_nets)} nets)")
if not gate.ok:
return 1
return 0
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
prog="boxsim",
description="Read Boxsim loudspeaker projects and turn their crossovers "
"into KiCad projects.",
)
sub = ap.add_subparsers(dest="command", required=True)
p = sub.add_parser("decode", help="write the raw INI payload of a container")
p.add_argument("file")
p.add_argument("-o", "--output")
p.add_argument(
"--oracle",
action="store_true",
help="decode by emulating Boxsim.exe instead (development only)",
)
p.set_defaults(func=cmd_decode)
p = sub.add_parser("extract", help="write the structured project model as JSON")
p.add_argument("file")
p.add_argument("-o", "--output")
p.add_argument(
"--no-symbols", action="store_true", help="skip KiCad symbol resolution"
)
_add_library_args(p)
p.set_defaults(func=cmd_extract)
p = sub.add_parser("netlist", help="print the crossover netlist")
p.add_argument("file")
p.set_defaults(func=cmd_netlist)
p = sub.add_parser("build", help="write a KiCad project")
p.add_argument("file")
p.add_argument("outdir")
p.add_argument("--name", help="project name (default: input file stem)")
p.add_argument(
"--pitch", type=float, default=None,
help="grid pitch in mm (default 25.4, must exceed 22.86)",
)
p.add_argument(
"--verify", action="store_true",
help="run ERC and compare the extracted netlist against Boxsim "
"(requires kicad-cli 10)",
)
p.add_argument("-v", "--verbose", action="store_true")
_add_library_args(p)
p.set_defaults(func=cmd_build)
return ap
def main(argv: list[str] | None = None) -> int:
ap = build_parser()
args = ap.parse_args(argv)
if getattr(args, "pitch", None) is None and args.command == "build":
from .schematic import DEFAULT_PITCH_MM
args.pitch = DEFAULT_PITCH_MM
try:
return args.func(args)
except (container.ContainerError, model.ModelError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
"""The MyIni2CK container: magic, XOR scrambling, A2F compression.
Boxsim uses one container for project files (`.bpj`) and driver files (`.bch`).
"""
from __future__ import annotations
from . import a2f
from .krypt import unscramble
MAGIC = b"MyIni2CK"
class ContainerError(ValueError):
pass
def is_container(raw: bytes) -> bool:
return raw[: len(MAGIC)] == MAGIC
def decode(raw: bytes) -> bytes:
"""Decode a Boxsim container to its raw INI payload."""
if not is_container(raw):
raise ContainerError(
f"not a MyIni2CK file (magic {raw[:8]!r}, expected {MAGIC!r})"
)
# The compressed layer reads its own 8 byte header back, so hand it the
# magic followed by the descrambled body exactly as Boxsim does.
return a2f.expand(MAGIC + unscramble(raw[len(MAGIC) :]))
def decode_text(raw: bytes) -> str:
"""Decode to text. Boxsim writes Windows-1252 with CRLF line ends."""
return decode(raw).decode("cp1252")
+94
View File
@@ -0,0 +1,94 @@
"""Parser for the INI-ish text Boxsim stores inside its containers.
Shape: `#Section` headers, `key=<TAB>value` pairs, CRLF line ends, German
decimal commas, and arrays as `;`-separated values. Section names are
case sensitive: a project contains both `#AktFilter1` and `#Aktfilter1`.
"""
from __future__ import annotations
from collections.abc import Iterator
class Section(dict):
"""One `#Name` block. A plain dict of str -> str, plus the section name."""
def __init__(self, name: str):
super().__init__()
self.name = name
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"Section({self.name!r}, {len(self)} keys)"
class IniDocument:
"""Ordered collection of sections, tolerant of repeated section names."""
def __init__(self) -> None:
self.sections: list[Section] = []
def __iter__(self) -> Iterator[Section]:
return iter(self.sections)
def __contains__(self, name: str) -> bool:
return any(s.name == name for s in self.sections)
def first(self, name: str) -> Section | None:
for s in self.sections:
if s.name == name:
return s
return None
def require(self, name: str) -> Section:
s = self.first(name)
if s is None:
raise KeyError(f"missing section #{name}")
return s
def all(self, name: str) -> list[Section]:
return [s for s in self.sections if s.name == name]
def merged(self, *names: str) -> Section:
"""Merge several sections into one, for split blocks like AktFilter."""
out = Section(names[0])
for name in names:
for s in self.all(name):
out.update(s)
return out
def parse(text: str) -> IniDocument:
doc = IniDocument()
current: Section | None = None
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw_line.rstrip("\n")
if not line.strip():
continue
if line.startswith("#"):
current = Section(line[1:].strip())
doc.sections.append(current)
continue
if current is None:
# Content before any header: keep it in an unnamed section rather
# than dropping it silently.
current = Section("")
doc.sections.append(current)
key, sep, value = line.partition("=")
if not sep:
continue
current[key.strip()] = value.strip()
return doc
def number(value: str) -> float:
"""Parse a Boxsim number. German decimal comma, exponents like `1,E-5`."""
return float(value.strip().replace(",", "."))
def integer(value: str) -> int:
return int(float(value.strip().replace(",", ".")))
def number_list(value: str) -> list[float]:
"""Parse a `;`-separated array, tolerating trailing separators."""
return [number(p) for p in value.split(";") if p.strip()]
+55
View File
@@ -0,0 +1,55 @@
"""Boxsim file scrambler: the XOR layer of the MyIni2CK container.
Reverse engineered from Boxsim 2.1 (Init at VA 0xa12be4, next byte at 0xa12c9c).
A table-shuffled combination of three linear congruential generators, seeded
with the same constants for every file Boxsim has ever written.
"""
M32 = 1 << 32
SEED1 = 123
SEED2 = 456
# Compared against a global at VA 0xb10588, which the image ships as 0xffffffff
# and no code path ever writes.
THRESHOLD = 0xFFFFFFFF
class KryptPRNG:
"""Keystream generator for the XOR layer."""
__slots__ = ("s1", "s2", "s3", "table", "thr")
def __init__(self, s1: int = SEED1, s2: int = SEED2, threshold: int = THRESHOLD):
self.s1, self.s2, self.thr = s1, s2, threshold
self.s3 = (s1 % 0xFFFE) * (s2 % 0x7F23)
self.table = [0] * 256
for i in range(256):
self._step()
self.table[i] = (self.s1 + self.s2) % M32
def _step(self) -> None:
self.s1 = (self.s1 * 0x10B95 + 1) % M32
self.s2 = (self.s2 * 0x10BA9 + 1) % M32
if self.s2 >= self.thr:
self.s2 = 0
self.s3 = (self.s3 * 0x1155D + 1) % M32
def next_byte(self) -> int:
self._step()
idx = self.s3 & 0xFF
out = self.table[idx]
self.table[idx] = (self.s1 + self.s2) % M32
return out & 0xFF
def keystream(self, n: int) -> bytes:
return bytes([self.next_byte() for _ in range(n)])
def unscramble(body: bytes) -> bytes:
"""XOR-decrypt the container body (everything after the 8-byte magic)."""
ks = KryptPRNG().keystream(len(body))
return bytes(c ^ k for c, k in zip(body, ks, strict=True))
# The layer is symmetric, but keep both names so call sites read correctly.
scramble = unscramble
+369
View File
@@ -0,0 +1,369 @@
"""Typed model of a Boxsim project, including the crossover netlist.
The crossover editor is an 11 x 18 grid of nodes. A part occupies a slot
between two adjacent nodes, and the slot number encodes both position and
orientation:
1..180 horizontal, 10 per row, joins node(r,c) to node(r,c+1)
181..367 vertical, 11 per row, joins node(r,c) to node(r+1,c)
368..375 free link, endpoints given explicitly by _nod1 and _nod2
Nodes are numbered row-major and zero based: node = row * 11 + col.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from . import ini
from .ini import IniDocument
COLS = 11
ROWS = 18
H_SLOTS = (COLS - 1) * ROWS # 180
V_BASE = H_SLOTS + 1 # 181
V_SLOTS = COLS * (ROWS - 1) # 187
LINK_BASE = V_BASE + V_SLOTS # 368
LINK_SLOTS = 8
TOTAL_SLOTS = LINK_BASE + LINK_SLOTS - 1 # 375
class PartType(IntEnum):
EMPTY = 0
WIRE = 1
RESISTOR = 2
CAPACITOR = 3
INDUCTOR = 4
DRIVER = 5
AMP_INPUT = 6
#: SI unit per component type, for reporting.
UNIT = {
PartType.RESISTOR: "Ohm",
PartType.CAPACITOR: "F",
PartType.INDUCTOR: "H",
}
class Orientation(IntEnum):
HORIZONTAL = 0
VERTICAL = 1
LINK = 2
class ModelError(ValueError):
pass
def slot_geometry(slot: int) -> tuple[Orientation, int, int]:
"""Return (orientation, row, col) for a grid slot. Links have no cell."""
if 1 <= slot <= H_SLOTS:
idx = slot - 1
return Orientation.HORIZONTAL, idx // (COLS - 1), idx % (COLS - 1)
if V_BASE <= slot < V_BASE + V_SLOTS:
idx = slot - V_BASE
return Orientation.VERTICAL, idx // COLS, idx % COLS
if LINK_BASE <= slot <= TOTAL_SLOTS:
return Orientation.LINK, -1, -1
raise ModelError(f"slot {slot} is outside 1..{TOTAL_SLOTS}")
def node_at(row: int, col: int) -> int:
return row * COLS + col
@dataclass
class Part:
"""One populated slot of the crossover grid."""
slot: int
typ: PartType
orientation: Orientation
row: int
col: int
node_a: int
node_b: int
value: float | None = None
label: str = "" # Boxsim `_bem`, the Visaton series name
dcr: float | None = None # `_Rvalue`, inductor series resistance in Ohm
inverted: bool = False # `_verpolt`
chassis_index: int | None = None # `_chassisnr`
optimise: bool = False # `_bOpti`
@property
def is_component(self) -> bool:
"""True for anything that becomes a schematic symbol."""
return self.typ not in (PartType.EMPTY, PartType.WIRE)
def __str__(self) -> str:
name = self.typ.name.lower()
if self.value is not None:
return f"Part{self.slot} {name} {self.value:g}"
return f"Part{self.slot} {name}"
@dataclass
class Chassis:
"""A driver, from a `#Chassis<n>` section."""
index: int
name: str
comment: str = ""
re: float | None = None
fs: float | None = None
vas: float | None = None
qes: float | None = None
qms: float | None = None
@dataclass
class Enclosure:
"""A `#Einbau<n>` section. Type 1 is closed, 2+ are vented variants."""
index: int
typ: int
vb: float | None = None # net volume in m^3
fb: float | None = None # tuning frequency in Hz
@dataclass
class Baffle:
"""A `#Schallwand<n>` section. All lengths in metres."""
index: int
width: float | None = None
height: float | None = None
shared: bool = False
@dataclass
class Netlist:
"""Electrical netlist: components joined through wires and links."""
nets: dict[int, int] # grid node -> canonical net id
components: list[Part]
def net_of(self, node: int) -> int:
return self.nets[node]
@property
def net_count(self) -> int:
return len({self.nets[n] for p in self.components for n in (p.node_a, p.node_b)})
def dangling(self) -> list[int]:
"""Net ids with fewer than two component terminals attached."""
counts: dict[int, int] = {}
for p in self.components:
for node in (p.node_a, p.node_b):
net = self.nets[node]
counts[net] = counts.get(net, 0) + 1
return sorted(net for net, n in counts.items() if n < 2)
@dataclass
class Crossover:
"""One `#Weiche<n>` section: the populated grid."""
index: int
parts: list[Part] = field(default_factory=list)
@property
def components(self) -> list[Part]:
return [p for p in self.parts if p.is_component]
@property
def wires(self) -> list[Part]:
return [p for p in self.parts if p.typ == PartType.WIRE]
def build_netlist(self) -> Netlist:
"""Union-find over wires and free links to collapse nodes into nets."""
parent: dict[int, int] = {}
def find(x: int) -> int:
parent.setdefault(x, x)
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # path compression
parent[x], x = root, parent[x]
return root
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for p in self.parts:
find(p.node_a)
find(p.node_b)
if p.typ == PartType.WIRE:
union(p.node_a, p.node_b)
nets = {node: find(node) for node in list(parent)}
return Netlist(nets=nets, components=self.components)
def occupied_cells(self) -> list[tuple[int, int]]:
return [
(p.row, p.col) for p in self.parts if p.orientation != Orientation.LINK
]
def bounding_box(self) -> tuple[int, int, int, int]:
"""(min_row, min_col, max_row, max_col) over occupied cells."""
cells = self.occupied_cells()
if not cells:
raise ModelError("crossover has no populated slots")
rows = [r for r, _ in cells]
cols = [c for _, c in cells]
return min(rows), min(cols), max(rows), max(cols)
@dataclass
class Project:
"""A whole Boxsim project."""
version: int = 0
amp_count: int = 1
chassis_count: int = 0
comments: list[str] = field(default_factory=list)
crossovers: list[Crossover] = field(default_factory=list)
chassis: list[Chassis] = field(default_factory=list)
enclosures: list[Enclosure] = field(default_factory=list)
baffles: list[Baffle] = field(default_factory=list)
def chassis_name(self, index: int) -> str:
for c in self.chassis:
if c.index == index:
return c.name
return f"Chassis{index + 1}"
def _opt_number(section: dict, key: str) -> float | None:
raw = section.get(key)
if raw is None or not raw.strip():
return None
try:
return ini.number(raw)
except ValueError:
return None
def _parse_crossover(section: dict, index: int) -> Crossover:
"""Collect `_Part<N>_<field>` keys into Part objects."""
fields: dict[int, dict[str, str]] = {}
for key, value in section.items():
if not key.startswith("_Part"):
continue
rest = key[len("_Part") :]
slot_text, sep, attr = rest.partition("_")
if not sep or not slot_text.isdigit():
continue
fields.setdefault(int(slot_text), {})[attr] = value
parts: list[Part] = []
for slot in sorted(fields):
attrs = fields[slot]
typ_raw = attrs.get("typ")
if typ_raw is None:
continue
typ = PartType(ini.integer(typ_raw))
if typ == PartType.EMPTY:
continue
orientation, row, col = slot_geometry(slot)
if orientation == Orientation.LINK:
node_a = ini.integer(attrs.get("nod1", "0"))
node_b = ini.integer(attrs.get("nod2", "0"))
if node_a == node_b:
continue # unused link slot
elif orientation == Orientation.HORIZONTAL:
node_a, node_b = node_at(row, col), node_at(row, col + 1)
else:
node_a, node_b = node_at(row, col), node_at(row + 1, col)
parts.append(
Part(
slot=slot,
typ=typ,
orientation=orientation,
row=row,
col=col,
node_a=node_a,
node_b=node_b,
value=_opt_number(attrs, "value"),
label=attrs.get("bem", "").strip(),
dcr=_opt_number(attrs, "Rvalue"),
inverted=attrs.get("verpolt", "1").strip().startswith("-"),
chassis_index=(
ini.integer(attrs["chassisnr"]) if "chassisnr" in attrs else None
),
optimise=attrs.get("bOpti", "0").strip() not in ("", "0"),
)
)
return Crossover(index=index, parts=parts)
def from_ini(doc: IniDocument) -> Project:
"""Build a Project from a parsed INI document."""
header = doc.first("Header") or {}
general = doc.require("General")
project = Project(
version=ini.integer(header.get("Version", "0")),
amp_count=ini.integer(general.get("nAmps", "1")),
chassis_count=ini.integer(general.get("nChassis", "0")),
comments=[
general.get(k, "") for k in ("Comment1", "Comment2") if general.get(k, "")
],
)
for n in range(1, project.amp_count + 1):
section = doc.first(f"Weiche{n}")
if section is not None:
project.crossovers.append(_parse_crossover(section, n))
for n in range(1, project.chassis_count + 1):
section = doc.first(f"Chassis{n}")
if section is None:
continue
project.chassis.append(
Chassis(
index=n - 1,
name=section.get("Name", "").strip(),
comment=section.get("Kommentar", "").strip(),
re=_opt_number(section, "Re"),
fs=_opt_number(section, "fs"),
vas=_opt_number(section, "Vas"),
qes=_opt_number(section, "Qes"),
qms=_opt_number(section, "Qms"),
)
)
enc = doc.first(f"Einbau{n}")
if enc is not None:
project.enclosures.append(
Enclosure(
index=n - 1,
typ=ini.integer(enc.get("Typ", "0")),
vb=_opt_number(enc, "Vb"),
fb=_opt_number(enc, "fb"),
)
)
baf = doc.first(f"Schallwand{n}")
if baf is not None:
project.baffles.append(
Baffle(
index=n - 1,
width=_opt_number(baf, "b"),
height=_opt_number(baf, "a"),
shared=baf.get("usegemgeh", "0").strip() not in ("", "0"),
)
)
return project
def load(raw: bytes) -> Project:
"""Decode a `.bpj` container and build the project model."""
from .container import decode_text
return from_ini(ini.parse(decode_text(raw)))
+161
View File
@@ -0,0 +1,161 @@
"""Write a complete KiCad project directory.
Emits the schematic, a `.kicad_pro`, and project-local library tables pointing
at the Visaton library so the project opens with every symbol and footprint
resolved. No board is generated: KiCad's own *Update PCB from Schematic* does
that, and every Visaton symbol already carries a `Footprint` property.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from . import symbols as symbols_mod
from .catalog import Catalog
from .model import Project
from .schematic import DEFAULT_PITCH_MM, SchematicBuilder, Sheet
from .symbols import Resolution, Resolver, SymbolLibrary
LIB_NICKNAME = "Visaton"
@dataclass
class BuildResult:
directory: Path
schematic: Path
project_file: Path
sheet: Sheet
resolutions: list[Resolution]
@property
def notes(self) -> list[str]:
out = []
for r in self.resolutions:
for note in r.notes:
out.append(f"Part{r.part.slot}: {note}")
return out
def _pro_contents(name: str) -> dict:
"""Minimal but complete project file. KiCad fills in the rest on open."""
return {
"board": {"design_settings": {}, "layer_presets": [], "viewports": []},
"boards": [],
"cvpcb": {"equivalence_files": []},
"libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},
"meta": {"filename": f"{name}.kicad_pro", "version": 3},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.8,
"via_diameter": 1.2,
"via_drill": 0.6,
"wire_width": 6,
}
],
"meta": {"version": 4},
},
"pcbnew": {"page_layout_descr_file": ""},
"schematic": {
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {"version": 1},
"page_layout_descr_file": "",
},
"sheets": [],
"text_variables": {},
}
def _lib_table(kind: str, entries: list[tuple[str, str]]) -> str:
"""Render a sym-lib-table or fp-lib-table."""
lines = [f"({kind}", "\t(version 7)"]
for nickname, uri in entries:
lines.append(
f'\t(lib (name "{nickname}")(type "KiCad")(uri "{uri}")'
f'(options "")(descr "Visaton crossover parts"))'
)
lines.append(")")
return "\n".join(lines) + "\n"
def resolve_library_root(library: SymbolLibrary) -> Path:
"""The checkout root, derived from the symbol file location."""
path = library.path.resolve()
if path.parent.name == "symbols":
return path.parent.parent
return path.parent
def build(
project: Project,
raw_name: str,
outdir: Path,
catalog: Catalog,
library: SymbolLibrary,
*,
pitch: float = DEFAULT_PITCH_MM,
crossover_index: int = 0,
) -> BuildResult:
if not project.crossovers:
raise ValueError("project contains no crossover")
if project.amp_count > 1:
raise NotImplementedError(
f"project has {project.amp_count} amplifier channels; "
"this version handles single-channel projects only"
)
crossover = project.crossovers[crossover_index]
resolver = Resolver(catalog, library)
resolutions = resolver.resolve_all(crossover.components)
builder = SchematicBuilder(library, pitch=pitch)
sheet = builder.build(project, crossover, resolutions, title=raw_name)
text = builder.render(sheet, raw_name)
outdir.mkdir(parents=True, exist_ok=True)
sch_path = outdir / f"{raw_name}.kicad_sch"
sch_path.write_text(text, encoding="utf-8")
pro_path = outdir / f"{raw_name}.kicad_pro"
pro_path.write_text(json.dumps(_pro_contents(raw_name), indent=2) + "\n", encoding="utf-8")
root = resolve_library_root(library)
(outdir / "sym-lib-table").write_text(
_lib_table("sym_lib_table", [(LIB_NICKNAME, str(root / "symbols" / "Visaton.kicad_sym"))]),
encoding="utf-8",
)
(outdir / "fp-lib-table").write_text(
_lib_table("fp_lib_table", [(LIB_NICKNAME, str(root / "footprints" / "Visaton.pretty"))]),
encoding="utf-8",
)
return BuildResult(
directory=outdir,
schematic=sch_path,
project_file=pro_path,
sheet=sheet,
resolutions=resolutions,
)
def load_parts(
catalog_path: str | None = None, library_path: str | None = None
) -> tuple[Catalog, SymbolLibrary]:
from . import catalog as catalog_mod
cat = catalog_mod.load(catalog_path)
lib = symbols_mod.load(library_path)
return cat, lib
+462
View File
@@ -0,0 +1,462 @@
"""Emit a KiCad 10 schematic from a Boxsim crossover.
The Boxsim crossover editor is already a schematic grid, so the layout is
carried over cell for cell instead of being re-derived: a node at grid (row,
col) becomes a sheet coordinate, wire slots become wire segments, and component
slots place a symbol between their two nodes.
Symbol placement uses KiCad's pin transform, determined empirically from the
shipped demo schematics over several thousand pin placements:
rot 0 pin (px, py) -> (X + px, Y - py)
rot 90 pin (px, py) -> (X - py, Y - px)
rot 180 pin (px, py) -> (X - px, Y + py)
rot 270 pin (px, py) -> (X + py, Y + px)
A symbol's origin is not necessarily its pin midpoint (the `Amplifier` symbol
has both pins at px=3.81), so the origin is derived by placing the transformed
pin midpoint on the slot midpoint.
"""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass, field
from .model import Crossover, Orientation, Part, PartType, Project
from .symbols import Resolution, SymbolLibrary
#: Schematic format emitted by KiCad 10.0.x.
SCH_VERSION = "20260101"
GENERATOR = "boxsim"
GENERATOR_VERSION = "10.0"
#: Must exceed the widest symbol's pin span (22.86 mm for `FC-Spule 10mH`)
#: so neighbouring cells cannot overlap.
DEFAULT_PITCH_MM = 25.4
MARGIN_MM = 25.4
#: KiCad standard sheets, landscape, smallest first.
PAPER_SIZES = [
("A4", 297.0, 210.0),
("A3", 420.0, 297.0),
("A2", 594.0, 420.0),
("A1", 841.0, 594.0),
("A0", 1189.0, 841.0),
]
#: Reference designator prefix per component type.
REFDES_PREFIX = {
PartType.RESISTOR: "R",
PartType.CAPACITOR: "C",
PartType.INDUCTOR: "L",
PartType.DRIVER: "LS",
PartType.AMP_INPUT: "J",
}
#: Stable namespace so regenerating a project yields identical UUIDs.
UUID_NS = uuid.UUID("b0c51a3e-7d61-5f0a-9c2e-0b0a5e1d4f77")
_PIN_RE = re.compile(
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
re.S,
)
_PIN_NAME_RE = re.compile(
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
r'(?:(?!\(pin\b).)*?\(name\s+"([^"]*)"'
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
re.S,
)
def stable_uuid(*parts: object) -> str:
return str(uuid.uuid5(UUID_NS, "|".join(str(p) for p in parts)))
def transform(rot: int, px: float, py: float) -> tuple[float, float]:
if rot == 0:
return px, -py
if rot == 90:
return -py, -px
if rot == 180:
return -px, py
if rot == 270:
return py, px
raise ValueError(f"unsupported rotation {rot}")
def _fmt(value: float) -> str:
"""KiCad writes millimetres with at most 6 decimals and no trailing zeros."""
text = f"{value:.6f}".rstrip("0").rstrip(".")
return text if text not in ("", "-0") else "0"
@dataclass
class SymbolPins:
"""Pin geometry of a library symbol, in library coordinates."""
by_number: dict[str, tuple[float, float]]
names: dict[str, str]
@property
def numbers(self) -> list[str]:
return sorted(self.by_number, key=lambda n: (len(n), n))
@property
def axis(self) -> Orientation:
"""Whether the pins are separated mainly in x or in y."""
nums = self.numbers
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
return (
Orientation.HORIZONTAL
if abs(x1 - x2) >= abs(y1 - y2)
else Orientation.VERTICAL
)
@property
def midpoint(self) -> tuple[float, float]:
nums = self.numbers
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
return (x1 + x2) / 2, (y1 + y2) / 2
def positive_number(self) -> str | None:
for num, name in self.names.items():
if name.strip() == "+":
return num
return None
def symbol_pins(library: SymbolLibrary, name: str) -> SymbolPins:
body = library.geometry_source(name)
by_number: dict[str, tuple[float, float]] = {}
names: dict[str, str] = {}
for m in _PIN_RE.finditer(body):
by_number.setdefault(m.group(3), (float(m.group(1)), float(m.group(2))))
for m in _PIN_NAME_RE.finditer(body):
names.setdefault(m.group(4), m.group(3))
if len(by_number) < 2:
raise ValueError(f"symbol {name!r} has fewer than two pins")
return SymbolPins(by_number=by_number, names=names)
@dataclass
class Placement:
"""A resolved component positioned on the sheet."""
resolution: Resolution
refdes: str
origin: tuple[float, float]
rotation: int
pin_targets: dict[str, tuple[float, float]] # pin number -> node coordinate
pin_positions: dict[str, tuple[float, float]]
pin_nodes: dict[str, int] # pin number -> Boxsim grid node, for verification
@dataclass
class Sheet:
"""Everything that goes into one `.kicad_sch`."""
title: str
paper: str
placements: list[Placement] = field(default_factory=list)
wires: list[tuple[tuple[float, float], tuple[float, float]]] = field(
default_factory=list
)
junctions: list[tuple[float, float]] = field(default_factory=list)
texts: list[tuple[str, tuple[float, float]]] = field(default_factory=list)
used_symbols: list[str] = field(default_factory=list)
class SchematicBuilder:
def __init__(
self,
library: SymbolLibrary,
pitch: float = DEFAULT_PITCH_MM,
margin: float = MARGIN_MM,
):
self.library = library
self.pitch = pitch
self.margin = margin
if pitch <= 22.86:
raise ValueError(
f"pitch {pitch} mm is not wider than the widest symbol (22.86 mm)"
)
# -- geometry ---------------------------------------------------------
def _node_xy(self, node: int, min_row: int, min_col: int) -> tuple[float, float]:
from .model import COLS
row, col = divmod(node, COLS)
return (
self.margin + (col - min_col) * self.pitch,
self.margin + (row - min_row) * self.pitch,
)
def _choose_paper(self, width: float, height: float) -> str:
for name, w, h in PAPER_SIZES:
if width <= w and height <= h:
return name
return PAPER_SIZES[-1][0]
# -- building ---------------------------------------------------------
def build(
self,
project: Project,
crossover: Crossover,
resolutions: list[Resolution],
title: str,
) -> Sheet:
min_row, min_col, max_row, max_col = crossover.bounding_box()
# A slot extends one node past its cell, so the node grid is one wider.
max_node_row, max_node_col = max_row + 1, max_col + 1
def xy(node: int) -> tuple[float, float]:
return self._node_xy(node, min_row, min_col)
width = 2 * self.margin + (max_node_col - min_col) * self.pitch
height = 2 * self.margin + (max_node_row - min_row) * self.pitch
sheet = Sheet(title=title, paper=self._choose_paper(width, height))
endpoints: dict[tuple[float, float], int] = {}
def add_wire(a: tuple[float, float], b: tuple[float, float]) -> None:
if a == b:
return
sheet.wires.append((a, b))
endpoints[a] = endpoints.get(a, 0) + 1
endpoints[b] = endpoints.get(b, 0) + 1
# Wires and free links.
for part in crossover.parts:
if part.typ != PartType.WIRE:
continue
a, b = xy(part.node_a), xy(part.node_b)
if part.orientation == Orientation.LINK and a[0] != b[0] and a[1] != b[1]:
corner = (b[0], a[1]) # route orthogonally, never diagonally
add_wire(a, corner)
add_wire(corner, b)
else:
add_wire(a, b)
# Components.
counters: dict[str, int] = {}
by_slot = {r.part.slot: r for r in resolutions}
for part in sorted(crossover.components, key=lambda p: p.slot):
res = by_slot.get(part.slot)
if res is None:
continue
placement = self._place(part, res, xy, counters, project)
sheet.placements.append(placement)
if res.symbol not in sheet.used_symbols:
sheet.used_symbols.append(res.symbol)
for num, target in placement.pin_targets.items():
add_wire(placement.pin_positions[num], target)
endpoints[target] = endpoints.get(target, 0) + 1
sheet.junctions = sorted(p for p, n in endpoints.items() if n >= 3)
sheet.texts = self._describe(project, crossover, height)
return sheet
def _place(
self,
part: Part,
res: Resolution,
xy,
counters: dict[str, int],
project: Project,
) -> Placement:
pins = symbol_pins(self.library, res.symbol)
a, b = xy(part.node_a), xy(part.node_b)
slot_axis = (
Orientation.HORIZONTAL if abs(a[0] - b[0]) >= abs(a[1] - b[1])
else Orientation.VERTICAL
)
rotation = 0 if pins.axis == slot_axis else 90
mx, my = pins.midpoint
tmx, tmy = transform(rotation, mx, my)
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
origin = (mid[0] - tmx, mid[1] - tmy)
positions = {}
for num, (px, py) in pins.by_number.items():
tx, ty = transform(rotation, px, py)
positions[num] = (round(origin[0] + tx, 6), round(origin[1] + ty, 6))
# Assign pins to nodes along the slot axis.
nums = pins.numbers[:2]
key = (lambda n: positions[n][0]) if slot_axis == Orientation.HORIZONTAL \
else (lambda n: positions[n][1])
first, second = sorted(nums, key=key)
node_first, node_second = (a, b) if (
(a[0] <= b[0]) if slot_axis == Orientation.HORIZONTAL else (a[1] <= b[1])
) else (b, a)
targets = {first: node_first, second: node_second}
# `_verpolt` reverses a driver or the amplifier input. Swap the two
# terminals so the polarity marking matches Boxsim.
if part.inverted and pins.positive_number() is not None:
targets = {first: node_second, second: node_first}
prefix = REFDES_PREFIX[part.typ]
counters[prefix] = counters.get(prefix, 0) + 1
refdes = f"{prefix}{counters[prefix]}"
if part.typ == PartType.DRIVER and part.chassis_index is not None:
res.value_text = project.chassis_name(part.chassis_index)
coord_to_node = {a: part.node_a, b: part.node_b}
return Placement(
resolution=res,
refdes=refdes,
origin=(round(origin[0], 6), round(origin[1], 6)),
rotation=rotation,
pin_targets=targets,
pin_positions=positions,
pin_nodes={num: coord_to_node[xyc] for num, xyc in targets.items()},
)
def _describe(
self, project: Project, crossover: Crossover, height: float
) -> list[tuple[str, tuple[float, float]]]:
"""A short note recording which enclosure this crossover belongs to."""
lines = [f"Boxsim crossover {crossover.index} - {len(crossover.components)} parts"]
for ch in project.chassis:
enc = next((e for e in project.enclosures if e.index == ch.index), None)
baf = next((b for b in project.baffles if b.index == ch.index), None)
bits = [f"LS{ch.index + 1}: {ch.name}"]
if enc is not None and enc.vb:
kind = {1: "closed", 2: "vented", 3: "bandpass"}.get(enc.typ, f"type {enc.typ}")
bits.append(f"{kind}, Vb {enc.vb * 1000:g} l")
if baf is not None and baf.width and baf.height:
bits.append(f"baffle {baf.width * 1000:g} x {baf.height * 1000:g} mm")
lines.append(" ".join(bits))
y = height - self.margin / 2
return [(line, (self.margin, y + i * 4.0)) for i, line in enumerate(lines)]
# -- serialisation ----------------------------------------------------
def render(self, sheet: Sheet, project_name: str) -> str:
sheet_uuid = stable_uuid("sheet", project_name)
out: list[str] = []
w = out.append
w("(kicad_sch")
w(f'\t(version {SCH_VERSION})')
w(f'\t(generator "{GENERATOR}")')
w(f'\t(generator_version "{GENERATOR_VERSION}")')
w(f'\t(uuid "{sheet_uuid}")')
w(f'\t(paper "{sheet.paper}")')
w("\t(title_block")
w(f'\t\t(title "{sheet.title}")')
w('\t\t(comment 1 "Generated from a Boxsim project by boxsim")')
w("\t)")
w("\t(lib_symbols")
for name in sheet.used_symbols:
body = self.library.definition(name)
body = body.replace(f'(symbol "{name}"', f'(symbol "Visaton:{name}"', 1)
for line in body.splitlines():
w("\t\t" + line.strip() if not line.startswith("\t") else "\t\t" + line.lstrip("\t"))
w("\t)")
for (x1, y1), (x2, y2) in sheet.wires:
w("\t(wire")
w(f"\t\t(pts (xy {_fmt(x1)} {_fmt(y1)}) (xy {_fmt(x2)} {_fmt(y2)}))")
w("\t\t(stroke (width 0) (type default))")
w(f'\t\t(uuid "{stable_uuid("wire", x1, y1, x2, y2)}")')
w("\t)")
for x, y in sheet.junctions:
w("\t(junction")
w(f"\t\t(at {_fmt(x)} {_fmt(y)})")
w("\t\t(diameter 0)")
w("\t\t(color 0 0 0 0)")
w(f'\t\t(uuid "{stable_uuid("junction", x, y)}")')
w("\t)")
for text, (x, y) in sheet.texts:
w(f'\t(text "{text}"')
w("\t\t(exclude_from_sim yes)")
w(f"\t\t(at {_fmt(x)} {_fmt(y)} 0)")
w("\t\t(effects (font (size 1.27 1.27)) (justify left bottom))")
w(f'\t\t(uuid "{stable_uuid("text", x, y, text)}")')
w("\t)")
for p in sheet.placements:
self._render_symbol(out, p, project_name, sheet_uuid)
w("\t(sheet_instances")
w('\t\t(path "/"')
w('\t\t\t(page "1")')
w("\t\t)")
w("\t)")
w("\t(embedded_fonts no)")
w(")")
return "\n".join(out) + "\n"
def _render_symbol(
self, out: list[str], p: Placement, project_name: str, sheet_uuid: str
) -> None:
w = out.append
res = p.resolution
part = res.part
x, y = p.origin
sym_uuid = stable_uuid("symbol", part.slot, res.symbol)
w("\t(symbol")
w(f'\t\t(lib_id "Visaton:{res.symbol}")')
w(f"\t\t(at {_fmt(x)} {_fmt(y)} {p.rotation})")
w("\t\t(unit 1)")
w("\t\t(exclude_from_sim no)")
w("\t\t(in_bom yes)")
w("\t\t(on_board yes)")
w("\t\t(dnp no)")
w(f'\t\t(uuid "{sym_uuid}")')
# Keep the labels clear of the body: above and below for a horizontal
# symbol, left and right for a rotated one, which would otherwise have
# its value text printed straight over the component.
if p.rotation == 0:
ref_at, val_at = (x, y - 5.08), (x, y + 5.08)
else:
ref_at, val_at = (x - 7.62, y), (x + 7.62, y)
props = [
("Reference", p.refdes, ref_at),
("Value", res.value_text, val_at),
]
if res.series:
props.append(("Visaton_Series", res.series, (x, y)))
if part.dcr is not None:
props.append(("DCR_Ohm", f"{part.dcr:g}", (x, y)))
if part.chassis_index is not None:
props.append(("Boxsim_Chassis", str(part.chassis_index + 1), (x, y)))
if part.inverted:
props.append(("Boxsim_Polarity", "inverted", (x, y)))
props.append(("Boxsim_Slot", str(part.slot), (x, y)))
for i, (name, value, (pxx, pyy)) in enumerate(props):
hidden = i >= 2
w(f'\t\t(property "{name}" "{value}"')
w(f"\t\t\t(at {_fmt(pxx)} {_fmt(pyy)} 0)")
if hidden:
w("\t\t\t(hide yes)")
w("\t\t\t(effects (font (size 1.27 1.27)))")
w("\t\t)")
for num in sorted(p.pin_positions):
w(f'\t\t(pin "{num}"')
w(f'\t\t\t(uuid "{stable_uuid("pin", part.slot, num)}")')
w("\t\t)")
w("\t\t(instances")
w(f'\t\t\t(project "{project_name}"')
w(f'\t\t\t\t(path "/{sheet_uuid}"')
w(f'\t\t\t\t\t(reference "{p.refdes}")')
w("\t\t\t\t\t(unit 1)")
w("\t\t\t\t)")
w("\t\t\t)")
w("\t\t)")
w("\t)")
+401
View File
@@ -0,0 +1,401 @@
"""Index of the KiCad Visaton symbol library, and the resolver.
The library's own value strings are irregular: `MKP 1.0μF` sits next to
`MKP 10μF`, and `SP-Spule 1.0mH (0.6)` next to `LR-Spule 10mH`. Formatting a
number into a name would therefore break on some series, so the library is
parsed into `(series, value, gauge)` triples and matched numerically instead.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from .catalog import DEFAULT_SERIES, SERIES_TO_SYMBOL, Catalog
from .model import Part, PartType
#: Symbols that carry no value and are addressed by name.
AMP_SYMBOL = "Amplifier"
DRIVER_SYMBOL = "Speaker"
#: Unit suffix per component type as the library writes it.
UNIT_SUFFIX = {
PartType.RESISTOR: "Ω",
PartType.CAPACITOR: "μF",
PartType.INDUCTOR: "mH",
}
#: Scale from SI to the unit the library names use.
NAME_SCALE = {
PartType.RESISTOR: 1.0,
PartType.CAPACITOR: 1e6,
PartType.INDUCTOR: 1e3,
}
_SP_RE = re.compile(r"^SP-Spule\s+([\d.]+)mH\s+\(([\d.]+)\)$")
_PLAIN_RE = re.compile(r"^(.*?)\s+([\d.]+)(mH|μF|Ω)$")
_SYMBOL_RE = re.compile(r'^\s*\(symbol\s+"([^"]+)"', re.MULTILINE)
_UNIT_SUFFIX_RE = re.compile(r"_\d+_\d+$")
_EXTENDS_RE = re.compile(r'\(extends\s+"([^"]+)"\)')
_LIB_VERSION_RE = re.compile(r"\(version\s+(\d+)\)")
#: Oldest symbol library format this tool will read. KiCad 6 era libraries use
#: `(id N)` properties and bare `hide` tokens, which cannot be embedded into a
#: current schematic verbatim. `kicad-cli sym upgrade` converts them.
MIN_LIB_VERSION = 20211014
class SymbolError(ValueError):
pass
@dataclass(frozen=True)
class LibrarySymbol:
name: str
series: str | None # library-side series, e.g. "SP-Spule"
value: float | None # SI
gauge: str | None # "0.6" etc, SP inductors only
@dataclass
class Resolution:
"""The outcome of resolving one Boxsim part to a library symbol."""
part: Part
symbol: str
value_text: str
series: str | None = None
inferred_series: bool = False
dcr_note: str | None = None
@property
def notes(self) -> list[str]:
out = []
if self.inferred_series:
out.append(f"series inferred as {self.series!r} (Boxsim left _bem empty)")
if self.dcr_note:
out.append(self.dcr_note)
return out
def _parse_symbol_name(name: str) -> LibrarySymbol:
m = _SP_RE.match(name)
if m:
return LibrarySymbol(name, "SP-Spule", float(m.group(1)) * 1e-3, m.group(2))
m = _PLAIN_RE.match(name)
if m:
series, number, unit = m.group(1), float(m.group(2)), m.group(3)
scale = {"mH": 1e-3, "μF": 1e-6, "Ω": 1.0}[unit]
return LibrarySymbol(name, series, number * scale, None)
return LibrarySymbol(name, None, None, None)
class SymbolLibrary:
"""Parsed `Visaton.kicad_sym`."""
def __init__(self, path: Path, names: list[str], source: str):
self.path = path
self.source = source
self.symbols = [_parse_symbol_name(n) for n in names]
self._by_name = {s.name: s for s in self.symbols}
self._index: dict[tuple[str, str | None], dict[int, LibrarySymbol]] = {}
for s in self.symbols:
if s.series is None or s.value is None:
continue
self._index.setdefault((s.series, s.gauge), {})[round(s.value, 12)] = s
def __len__(self) -> int:
return len(self.symbols)
def __contains__(self, name: str) -> bool:
return name in self._by_name
@property
def names(self) -> list[str]:
return [s.name for s in self.symbols]
def lookup(
self, series: str, value: float, gauge: str | None = None, rtol: float = 1e-6
) -> LibrarySymbol | None:
bucket = self._index.get((series, gauge))
if not bucket:
return None
exact = bucket.get(round(value, 12))
if exact is not None:
return exact
best, best_err = None, None
for v, sym in bucket.items():
if v == 0:
continue
err = abs(v - value) / v
if err <= rtol and (best_err is None or err < best_err):
best, best_err = sym, err
return best
def raw_definition(self, name: str) -> str:
"""The s-expression block for one symbol, exactly as stored."""
needle = f'(symbol "{name}"'
start = self.source.find(needle)
if start < 0:
raise SymbolError(f"symbol {name!r} not found in {self.path}")
# Walk balanced parentheses, ignoring those inside strings. Symbol names
# such as `SP-Spule 0.82mH (0.6)` contain parentheses of their own.
depth, i, in_str, escaped = 0, start, False, False
while i < len(self.source):
ch = self.source[i]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return self.source[start : i + 1]
i += 1
raise SymbolError(f"unbalanced s-expression for symbol {name!r}")
def base_of(self, name: str) -> str | None:
"""The symbol this one `extends`, if any."""
m = _EXTENDS_RE.search(self.raw_definition(name))
return m.group(1) if m else None
def geometry_source(self, name: str) -> str:
"""The definition that actually carries pins and graphics."""
seen = set()
current = name
while True:
base = self.base_of(current)
if base is None:
return self.raw_definition(current)
if base in seen:
raise SymbolError(f"circular extends chain at {base!r}")
seen.add(base)
current = base
def definition(self, name: str) -> str:
"""A self-contained symbol block suitable for a schematic's lib_symbols.
A derived symbol is flattened: its own properties are kept and the base
symbol's graphical sub-units are copied in under the derived name.
KiCad never writes `extends` into a schematic cache, so neither do we.
"""
own = self.raw_definition(name)
base = self.base_of(name)
if base is None:
return own
body = _EXTENDS_RE.sub("", own, count=1)
closing = body.rstrip()
if not closing.endswith(")"):
raise SymbolError(f"malformed symbol block for {name!r}")
body = closing[:-1].rstrip()
base_block = self.geometry_source(base)
units = []
for unit_name in _SYMBOL_RE.findall(base_block):
if not _UNIT_SUFFIX_RE.search(unit_name):
continue
suffix = unit_name[len(base) :] if unit_name.startswith(base) else ""
unit_block = self._sub_block(base_block, unit_name)
units.append(
unit_block.replace(
f'(symbol "{unit_name}"', f'(symbol "{name}{suffix}"', 1
)
)
if not units:
raise SymbolError(f"base symbol {base!r} of {name!r} has no graphics")
indented = "\n".join("\t\t" + u.replace("\n", "\n\t\t") for u in units)
return f"{body}\n{indented}\n\t)"
@staticmethod
def _sub_block(text: str, name: str) -> str:
needle = f'(symbol "{name}"'
start = text.find(needle)
if start < 0:
raise SymbolError(f"sub-symbol {name!r} not found")
depth, i, in_str, escaped = 0, start, False, False
while i < len(text):
ch = text[i]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return text[start : i + 1]
i += 1
raise SymbolError(f"unbalanced sub-symbol {name!r}")
def default_path() -> Path:
env = os.environ.get("BOXSIM_KICAD_LIB")
if env:
p = Path(env)
return p if p.suffix == ".kicad_sym" else p / "symbols" / "Visaton.kicad_sym"
bundled = Path(__file__).resolve().parent / "data" / "Visaton.kicad_sym"
if bundled.exists():
return bundled
return Path("/home/magdev/workspaces/other/kicad-visaton-lib/symbols/Visaton.kicad_sym")
def load(path: str | os.PathLike[str] | None = None) -> SymbolLibrary:
p = Path(path) if path else default_path()
if not p.exists():
raise SymbolError(
f"KiCad symbol library not found at {p}; "
"set BOXSIM_KICAD_LIB to the kicad-visaton-lib checkout"
)
source = p.read_text(encoding="utf-8")
m = _LIB_VERSION_RE.search(source[:400])
if m is None:
raise SymbolError(f"{p} has no (version ...) token; is it a symbol library?")
version = int(m.group(1))
if version < MIN_LIB_VERSION:
raise SymbolError(
f"{p} is in the legacy format (version {version}, need "
f"{MIN_LIB_VERSION} or newer). Convert it with:\n"
f" kicad-cli sym upgrade --force {p}"
)
names = [n for n in _SYMBOL_RE.findall(source) if not _UNIT_SUFFIX_RE.search(n)]
if not names:
raise SymbolError(f"no symbols found in {p}")
return SymbolLibrary(p, names, source)
def cross_check(catalog: Catalog, library: SymbolLibrary) -> list[str]:
"""Every catalogue part must exist in the library. Returns extra symbols."""
missing = []
matched = set()
for part in catalog.parts:
sym = library.lookup(part.symbol_series, part.value, part.gauge)
if sym is None:
missing.append(f"{part.series} {part.raw_value:g}")
else:
matched.add(sym.name)
if missing:
raise SymbolError(
f"{len(missing)} catalogue part(s) have no library symbol: "
+ ", ".join(missing[:10])
)
return sorted(
s.name
for s in library.symbols
if s.name not in matched and s.name not in (AMP_SYMBOL, DRIVER_SYMBOL)
)
def _format_value(typ: PartType, value: float) -> str:
scaled = value * NAME_SCALE[typ]
text = f"{scaled:g}"
return f"{text}{UNIT_SUFFIX[typ]}"
class Resolver:
"""Maps Boxsim parts onto library symbols using the Visaton catalogue."""
def __init__(self, catalog: Catalog, library: SymbolLibrary):
self.catalog = catalog
self.library = library
def _series_for(self, part: Part, siblings: list[Part]) -> tuple[str, bool]:
"""Catalogue series for a part, inferring one when `_bem` is empty."""
if part.label:
if part.label in SERIES_TO_SYMBOL:
return part.label, False
# Tolerate stray whitespace or case differences before giving up.
for known in SERIES_TO_SYMBOL:
if known.lower() == part.label.lower():
return known, False
raise SymbolError(
f"Part{part.slot}: series {part.label!r} is not in the Visaton "
f"catalogue (known: {', '.join(sorted(SERIES_TO_SYMBOL))})"
)
# Empty _bem: prefer a sibling of the same type and value.
for other in siblings:
if (
other is not part
and other.typ == part.typ
and other.label
and other.value is not None
and part.value is not None
and abs(other.value - part.value) <= abs(part.value) * 1e-9
):
return other.label, True
fallback = DEFAULT_SERIES.get(part.typ)
if fallback is None:
raise SymbolError(
f"Part{part.slot}: no series given and no sibling or default for "
f"{part.typ.name.lower()}"
)
return fallback, True
def resolve(self, part: Part, siblings: list[Part] | None = None) -> Resolution:
siblings = siblings or []
if part.typ == PartType.AMP_INPUT:
return Resolution(part=part, symbol=AMP_SYMBOL, value_text="Amp")
if part.typ == PartType.DRIVER:
return Resolution(part=part, symbol=DRIVER_SYMBOL, value_text="")
if part.value is None:
raise SymbolError(f"Part{part.slot}: {part.typ.name.lower()} has no value")
series, inferred = self._series_for(part, siblings)
entry = self.catalog.find(series, part.value)
if entry is None:
near = self.catalog.nearest(series, part.value)
offered = ", ".join(f"{p.raw_value:g}" for p in near) or "none"
raise SymbolError(
f"Part{part.slot}: {series} "
f"{_format_value(part.typ, part.value)} is not stocked by Visaton; "
f"nearest available: {offered}"
)
sym = self.library.lookup(entry.symbol_series, entry.value, entry.gauge)
if sym is None:
raise SymbolError(
f"Part{part.slot}: catalogue part {series} {entry.raw_value:g} "
f"has no symbol in {self.library.path.name}"
)
dcr_note = None
if (
part.dcr is not None
and entry.dcr is not None
and abs(part.dcr - entry.dcr) > 1e-9
):
dcr_note = (
f"DCR {part.dcr:g} Ohm differs from catalogue {entry.dcr:g} Ohm "
"(edited in Boxsim)"
)
return Resolution(
part=part,
symbol=sym.name,
value_text=_format_value(part.typ, part.value),
series=series,
inferred_series=inferred,
dcr_note=dcr_note,
)
def resolve_all(self, parts: list[Part]) -> list[Resolution]:
return [self.resolve(p, parts) for p in parts]
+167
View File
@@ -0,0 +1,167 @@
"""Verify a generated project against the Boxsim data it came from.
The gate that matters is not ERC: a schematic can be perfectly well formed and
still wired to the wrong cells. Instead the netlist KiCad extracts from the
generated sheet is compared against the netlist derived independently from the
Boxsim grid. Two derivations that agree is real evidence; ERC alone is not.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from .model import Crossover
from .schematic import Placement
MIN_KICAD_MAJOR = 10
_NET_RE = re.compile(r"\(net\b(.*?)(?=\n\t\t\(net\b|\n\t\)\s*$)", re.S)
_NAME_RE = re.compile(r'\(name\s+"([^"]*)"\)')
_NODE_RE = re.compile(r'\(node\s*\(ref\s+"([^"]+)"\)\s*\(pin\s+"([^"]+)"\)', re.S)
class VerifyError(RuntimeError):
pass
@dataclass
class GateResult:
erc_ok: bool
erc_violations: int
netlist_matches: bool
kicad_version: str
reference_nets: list[frozenset[str]] = field(default_factory=list)
kicad_nets: list[frozenset[str]] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.erc_ok and self.netlist_matches
def kicad_cli() -> str:
exe = shutil.which("kicad-cli")
if exe is None:
raise VerifyError(
"kicad-cli not found. KiCad 10 is a hard dependency of the "
"verification gate; install it or run `boxsim build` without --verify."
)
return exe
def kicad_version() -> str:
out = subprocess.run(
[kicad_cli(), "version"], capture_output=True, text=True, check=True
).stdout.strip()
major = out.split(".")[0]
if not major.isdigit() or int(major) != MIN_KICAD_MAJOR:
raise VerifyError(
f"kicad-cli reports version {out!r}, but this tool targets "
f"KiCad {MIN_KICAD_MAJOR}.x schematics"
)
return out
def reference_partition(
crossover: Crossover, placements: list[Placement]
) -> list[frozenset[str]]:
"""Group `REFDES.PIN` terminals by the net they sit on in the Boxsim grid."""
netlist = crossover.build_netlist()
groups: dict[int, set[str]] = {}
for p in placements:
for pin, node in p.pin_nodes.items():
net = netlist.net_of(node)
groups.setdefault(net, set()).add(f"{p.refdes}.{pin}")
return sorted((frozenset(v) for v in groups.values()), key=_net_key)
def parse_netlist(text: str) -> list[frozenset[str]]:
"""Read the `(nets ...)` block of a KiCad sexpr netlist into a partition."""
start = text.find("\t(nets")
if start < 0:
raise VerifyError("exported netlist has no (nets ...) section")
body = text[start:]
out = []
for chunk in body.split("\t\t(net")[1:]:
terminals = {f"{ref}.{pin}" for ref, pin in _NODE_RE.findall(chunk)}
if terminals:
out.append(frozenset(terminals))
if not out:
raise VerifyError("exported netlist contained no nets")
return sorted(out, key=_net_key)
def _net_key(net: frozenset[str]) -> tuple:
return (len(net), tuple(sorted(net)))
def run_gate(
schematic: Path,
crossover: Crossover,
placements: list[Placement],
*,
workdir: Path | None = None,
) -> GateResult:
version = kicad_version()
workdir = workdir or schematic.parent
erc_report = workdir / f"{schematic.stem}-erc.rpt"
netlist_path = workdir / f"{schematic.stem}.net"
erc = subprocess.run(
[
kicad_cli(), "sch", "erc",
"--severity-error", "--exit-code-violations",
"-o", str(erc_report), str(schematic),
],
capture_output=True, text=True,
)
violations = 0
if erc_report.exists():
m = re.search(r"Errors (\d+)", erc_report.read_text(encoding="utf-8"))
if m:
violations = int(m.group(1))
erc_ok = erc.returncode == 0 and violations == 0
export = subprocess.run(
[
kicad_cli(), "sch", "export", "netlist",
"--format", "kicadsexpr", "-o", str(netlist_path), str(schematic),
],
capture_output=True, text=True,
)
if export.returncode != 0:
raise VerifyError(f"netlist export failed: {export.stderr.strip()}")
reference = reference_partition(crossover, placements)
extracted = parse_netlist(netlist_path.read_text(encoding="utf-8"))
messages = []
if not erc_ok:
messages.append(f"ERC reported {violations} error(s); see {erc_report.name}")
matches = reference == extracted
if not matches:
only_ref = [sorted(n) for n in reference if n not in extracted]
only_kicad = [sorted(n) for n in extracted if n not in reference]
messages.append(
f"netlist mismatch: {len(reference)} reference nets vs "
f"{len(extracted)} extracted"
)
for net in only_ref[:8]:
messages.append(f" only in Boxsim reference: {net}")
for net in only_kicad[:8]:
messages.append(f" only in KiCad netlist: {net}")
return GateResult(
erc_ok=erc_ok,
erc_violations=violations,
netlist_matches=matches,
kicad_version=version,
reference_nets=reference,
kicad_nets=extracted,
messages=messages,
)