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:
1 parent
eb81ca61cb
commit
4737a5bae8
28 files changed
+4459
-122
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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()]
|
||||
@@ -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
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user