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

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

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

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

95 lines
2.9 KiB
Python

"""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()]