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>
95 lines
2.9 KiB
Python
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()]
|