Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation

Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now
reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to
decode a container; the emulator survives as a development-only oracle that the
codec is differentially tested against.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
magdevandClaude Fable 5 committed 2026-09-29 11:10:43 +02:00
1 parent eb81ca61cb
commit 4737a5bae8
28 files changed
+4457 -120

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
*.zip *.zip
boxsim_dl/ boxsim_dl/
stage/
.venv/
*.egg-info/
brute brute
.claude/ .claude/
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `boxsim` command line tool with `decode`, `extract`, `netlist` and `build` subcommands.
- Pure Python implementation of the A2F codec, so decoding no longer needs `Boxsim.exe` or a CPU emulator. Verified byte-identical to Boxsim's own decoder across all 134 containers in the distribution.
- Project model covering `#General`, `#Weiche<n>`, `#Chassis<n>`, `#Einbau<n>` and `#Schallwand<n>`, with the crossover grid decoded to an electrical netlist.
- Symbol resolver driven by Visaton's `Visatonteile.txt` catalogue, matching values numerically against the KiCad library. Infers an omitted series from a sibling part, reports DC resistance deviations, and refuses to substitute an unstocked value.
- KiCad 10 schematic generation that preserves the Boxsim grid layout, including wires, junctions, flattened derived symbols and deterministic UUIDs.
- Verification gate comparing the netlist KiCad extracts from the generated sheet against the netlist derived from the Boxsim grid, plus ERC.
- Docker image bundling the tool, the Visaton library and the parts catalogue, with a build-time check that the bundled catalogue is complete.
- Development-only oracle that runs Boxsim's own `ExpandA2F` under Unicorn, and `tools/difftest.py` to differentially test the codec against it.
### Changed
- Emulator hooks are bound to their trap addresses instead of every instruction, cutting a decode from 69 s to 14.5 s with identical output. The pure Python codec then brought it to about 1.6 s.
+41
View File
@@ -0,0 +1,41 @@
# boxsim: decode Boxsim projects and generate KiCad projects from them.
#
# The image deliberately contains no KiCad and no Boxsim.exe:
# - KiCad 10 is a hard dependency of the *verification* gate only, and stays
# on the host. decode/extract/netlist/build all work without it.
# - The A2F codec is pure Python, so Boxsim's proprietary binary is not needed.
#
# Build via tools/build-image.sh, which stages the Visaton library and the
# Boxsim parts catalogue into the build context.
FROM python:3.13-slim AS base
LABEL org.opencontainers.image.title="boxsim" \
org.opencontainers.image.description="Boxsim project decoder and KiCad project generator" \
org.opencontainers.image.source="https://src.bundespruefstelle.ch/boxdev/re-boxsim" \
org.opencontainers.image.licenses="MIT"
WORKDIR /opt/boxsim
COPY pyproject.toml README.md ./
COPY boxsim ./boxsim
# Staged by tools/build-image.sh: the upgraded Visaton symbol library and the
# Visaton parts catalogue shipped with Boxsim.
COPY stage/Visaton.kicad_sym ./boxsim/data/Visaton.kicad_sym
COPY stage/Visatonteile.txt ./boxsim/data/Visatonteile.txt
COPY stage/footprints ./boxsim/data/footprints
COPY tools/selfcheck.py ./tools/selfcheck.py
RUN pip install --no-cache-dir .
ENV BOXSIM_CATALOG=/opt/boxsim/boxsim/data/Visatonteile.txt \
BOXSIM_KICAD_LIB=/opt/boxsim/boxsim/data/Visaton.kicad_sym
# Refuse to produce an image whose bundled library does not cover the catalogue.
RUN python tools/selfcheck.py
WORKDIR /work
ENTRYPOINT ["boxsim"]
CMD ["--help"]
+59
View File
@@ -0,0 +1,59 @@
# Plan
## Current state
v0.1.0 is delivered. The `boxsim` package decodes Boxsim containers in pure Python and generates KiCad 10 projects from their crossovers; see `README.md` for the format and the tool, and `CHANGELOG.md` for what changed.
Environment as of 2026-09-29: KiCad 10.0.6 installed natively, Python 3.13.15, Visaton library at `/home/magdev/workspaces/other/kicad-visaton-lib` (remote `pidock.local:3022`), upgraded to the KiCad 10 format as part of this work.
Reference project for all work: `boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Projekte/Default.BPJ`.
## v0.1.0 - `boxsim` CLI and KiCad project generation
Delivered 2026-09-29. All seven tickets landed; what follows records the outcome, including where it differed from the design.
### What shipped
- `boxsim` with `decode`, `extract`, `netlist` and `build`, installable and packaged as a `python:3.13-slim` image bundling the Visaton library and catalogue.
- A pure Python A2F codec. `Boxsim.exe` and Unicorn are gone from the shipped tool and the image.
- Grid to netlist extraction, catalogue-driven symbol resolution, KiCad 10 schematic generation preserving the Boxsim layout, and a netlist-equality verification gate.
### Measured results
| Gate | Result |
| ---- | ------ |
| Codec vs Boxsim's own decoder | 134 containers byte-identical, 270 pre-2.0 files rejected by both |
| Decode time for `Default.BPJ` | 69 s to 14.5 s (hook binding) to 1.6 s (pure Python) |
| `Default.BPJ` netlist | 22 components, 14 nets, no dangling terminal |
| Symbol resolution | 22 of 22, one series inferred, two DC resistance deviations reported |
| ERC | clean, 0 errors |
| KiCad netlist vs Boxsim netlist | identical partition, 14 nets |
| Container vs host build | `.kicad_sch` and `.kicad_pro` byte-identical |
| Tests / lint | 32 passing, ruff clean |
### Where it differed from the design
- **The library needed changing after all.** The plan said no library change was required. That was wrong: the KiCad 6 era syntax (`(id N)` properties, bare `hide` tokens) cannot be embedded into a current schematic's `lib_symbols` cache. `kicad-visaton-lib` was upgraded with `kicad-cli sym upgrade` and `fp upgrade`, and the tool now refuses a legacy library with an error naming the fix. A pre-existing broken footprint reference on the `MKT 22μF` symbol (`Visaton:MKT-Kondensator`, which never existed) was repointed at `MKT 22mF`; all 48 footprint references now resolve.
- **Derived symbols had to be flattened.** 19 of the 98 symbols use `(extends ...)`, and KiCad never writes `extends` into a schematic cache. Pin geometry follows the chain to the base symbol, and the base's graphics are inlined under the derived name.
- **The decoder needed a padding allowance.** Boxsim reads past the end of its staging buffer for the final symbol's 16 bit lookup window. Five files failed until the reader tolerated a bounded number of zero padding bytes; the bound keeps genuine truncation loud.
- **Rotation could not be assumed.** A symbol's origin is not its pin midpoint (`Amplifier` has both pins at px=3.81), and a symbol's pin axis does not follow its slot. Rotation is chosen per symbol from its own pin axis, the origin is derived so the transformed pin midpoint lands on the slot midpoint, and the four transform matrices were measured against KiCad's demo schematics rather than guessed.
- **The image self-check was decorative at first.** `cross_check` only proves the catalogue is a subset of the library, so a truncated catalogue passed it and would have shipped an image rejecting valid parts. `catalog.check_shape` now asserts the catalogue's full shape, and the failure was reproduced deliberately before the fix was accepted.
- **Grid pitch is 25.4 mm, not 20 mm.** The widest symbol spans 22.86 mm between pins, so 20 mm would overlap neighbouring cells. `Default.BPJ` therefore lands on A3 rather than A4.
### Ticket outcomes
- **BOX-0** emulator bound to trap addresses, moved to `boxsim/_oracle.py`, reachable as `boxsim decode --oracle`.
- **BOX-1** `boxsim/a2f.py`, differentially tested by `tools/difftest.py`, self-test proves the comparison detects a corrupt digest.
- **BOX-2** `boxsim/model.py`, slot partition covered by tests, netlist cross-checked against crossover physics (600 Hz and 5.7 kHz corners).
- **BOX-3** `boxsim/catalog.py` and `boxsim/symbols.py`.
- **BOX-4** `boxsim/schematic.py`.
- **BOX-5** `boxsim/cli.py` and `boxsim/project.py`.
- **BOX-6** `boxsim/verify.py`, with `test_gate_has_teeth` mis-wiring a component to prove the gate fails when it should.
- **BOX-7** `Dockerfile`, `tools/build-image.sh`, `tools/selfcheck.py`.
## Backlog
- Multi-channel projects (`nAmps` up to 6), probably one hierarchical sheet per channel.
- Active filter sections (`#AktFilter<n>`, `#Aktfilter<n>`), which Boxsim models as DSP biquads with no passive equivalent.
- Per-driver symbols instead of the generic `Speaker`, if the library grows them. The 400 `.BCH` files provide the model names and TSP data.
- Round trip: patch component values in a `.bpj` from an edited schematic. Needs the A2F compressor, of which only the decompressor is currently exercised.
+129 -32
View File
@@ -1,63 +1,160 @@
# Boxsim .bpj / .bch file format (reverse engineered) # boxsim
Boxsim (Visaton, Delphi, codec and container "MyIniFile" by UG 2004) stores projects (`.bpj`) and driver files (`.bch`) in a three-layer container. Reverse engineered 2026-09-28 from Boxsim 2.1 DE (`Boxsim.exe`, no prior public documentation existed). Read Boxsim loudspeaker projects and turn their crossovers into KiCad projects.
## Container layout [Boxsim](https://www.visaton.de/) is Visaton's free loudspeaker simulation program. Its project files (`.bpj`) and driver files (`.bch`) use an undocumented container, reverse engineered here. `boxsim` decodes them and generates a KiCad 10 project whose schematic mirrors the crossover you drew in Boxsim, with every part bound to a symbol and footprint from [kicad-visaton-lib](https://src.bundespruefstelle.ch/boxdev/kicad-visaton-lib).
## Install
```bash
pip install .
```
Nothing is required at runtime beyond Python 3.11+. KiCad 10 is a hard dependency of the verification gate (`boxsim build --verify`) only, and lives on the host.
Point the tool at the Visaton library and the Boxsim parts catalogue:
```bash
export BOXSIM_KICAD_LIB=/path/to/kicad-visaton-lib
export BOXSIM_CATALOG="/path/to/BoxSim 2.1 DE/Weichenbauteile/Visatonteile.txt"
```
The symbol library must be in the current KiCad format. Convert an older one with `kicad-cli sym upgrade --force symbols/Visaton.kicad_sym`.
## Use
```bash
boxsim decode project.bpj -o project.ini # raw INI payload
boxsim extract project.bpj -o project.json # structured model, symbols resolved
boxsim netlist project.bpj # crossover netlist
boxsim build project.bpj out/ --verify # a KiCad 10 project
```
`build` writes `<name>.kicad_sch`, `<name>.kicad_pro` and project-local `sym-lib-table` / `fp-lib-table`. No board is generated: use KiCad's *Update PCB from Schematic*, which works because every Visaton symbol carries a `Footprint` property.
### Docker
```bash
./tools/build-image.sh
docker run --rm -v "$PWD:/work" boxsim:0.1.0 build project.bpj out/
```
The image bundles the tool, the Visaton library and the catalogue. It contains no KiCad and no `Boxsim.exe`, so `decode`, `extract`, `netlist` and `build` work inside it while `--verify` does not. Run the gate on the host.
## The file format
| Layer | Content | | Layer | Content |
| ----- | ------- | | ----- | ------- |
| 1 | 8-byte plaintext magic `MyIni2CK` | | 1 | 8-byte plaintext magic `MyIni2CK` |
| 2 | Everything after the magic is XOR-encrypted with a position-based PRNG keystream, seeds (123, 456), identical for every file | | 2 | XOR against a keystream from a table-shuffled three-LCG generator, seeded (123, 456) for every file ever written |
| 3 | The decrypted body is a custom LZ + canonical-Huffman stream ("A2F" codec, `CompressA2F`/`ExpandA2F` at VA `0xa1013c`/`0xa11880`) | | 3 | A custom LZ plus canonical-Huffman codec ("A2F") |
| 4 | The decompressed payload is plain INI-style text: `key=<TAB>value` lines, CRLF, `#Section` headers, German decimal commas, arrays as `;`-separated values | | 4 | INI-style text: `#Section` headers, `key<TAB>value`, CRLF, German decimal commas, `;`-separated arrays |
## Layer 2: the Krypt PRNG Files written by Boxsim 1.2 use an older container without the magic and are rejected.
Table-shuffled combination of three LCGs (found at VA `0xa12be4` init, `0xa12c9c` next byte). All arithmetic mod 2^32: ### The Krypt keystream
Three linear congruential generators feeding a 256-entry shuffle table (Boxsim 2.1: init at VA `0xa12be4`, next byte at `0xa12c9c`). All arithmetic mod 2^32:
```text ```text
init(s1=123, s2=456): init(s1=123, s2=456):
s3 = (s1 mod 0xFFFE) * (s2 mod 0x7F23) s3 = (s1 mod 0xFFFE) * (s2 mod 0x7F23)
repeat 256 times: step(); table[i] = s1 + s2 256 times: step(); table[i] = s1 + s2
step(): step(): s1 = s1*0x10B95 + 1
s1 = s1 * 0x10B95 + 1 s2 = s2*0x10BA9 + 1 (reset to 0 if >= 0xFFFFFFFF)
s2 = s2 * 0x10BA9 + 1 (reset to 0 if >= 0xFFFFFFFF) s3 = s3*0x1155D + 1
s3 = s3 * 0x1155D + 1
next_byte(): next(): step(); i = s3 and 0xFF
step() out = table[i]; table[i] = s1 + s2
idx = s3 and 0xFF
out = table[idx]; table[idx] = s1 + s2
return out mod 256 return out mod 256
``` ```
`plaintext[i] = filebyte[8+i] XOR next_byte()`. ### The A2F codec
## Layer 3: the A2F codec A bit stream read MSB first through a 31-bit accumulator, with symbols from a canonical Huffman code over 356 symbols decoded via a 65536-entry direct lookup table. All code lengths start at 9.
Sliding-window LZ (1 MB window, flush keeps the last 0x3FFF8 bytes) with a 356-symbol canonical Huffman alphabet: 0..255 literals, 0x100 end-of-stream, 0x101 rebuild code-length tables (adaptive), 0x102 special, rest match lengths/distances. Decoding uses a 65536-entry direct lookup table; initial state gives every symbol a 9-bit code. Not re-implemented: `bpj2ini.py` runs the original `ExpandA2F` machine code under Unicorn instead, which is exact by construction. | Symbol | Meaning |
| ------ | ------- |
| `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 |
## Crossover encoding (`#Weiche<n>` section) Distances take `k+3` raw bits biased by `2**(k+3) - 7`, so the classes tile 1.. with no gaps. Code lengths are coded as two zero bits meaning "same as previous", otherwise a nibble plus one, where the value 5 escapes to a second nibble.
The crossover editor grid serializes as `_Part<N>_*` keys. Only non-empty slots are written: Boxsim decodes from a staging buffer and reads past the end of the input for the final symbol's lookup window, so the decoder tolerates a bounded number of zero padding bytes. Beyond that a truncated file fails loudly.
### The crossover grid
The crossover editor is an 11 x 18 node grid. A slot number encodes position and orientation:
| Range | Meaning |
| ----- | ------- |
| 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 by `_nod1` and `_nod2` |
Nodes are numbered row-major and zero based: `node = row * 11 + col`.
Keys in a `#Weiche<n>` section:
| Key | Meaning | | Key | Meaning |
| --- | ------- | | --- | ------- |
| `_Part<N>_typ` | 0 empty, 1 wire, 2 resistor, 3 capacitor, 4 inductor, 5 driver, 6 amplifier input | | `_Part<N>_typ` | 0 empty, 1 wire, 2 resistor, 3 capacitor, 4 inductor, 5 driver, 6 amplifier input |
| `_Part<N>_value` | Component value in SI base units (Ohm, Farad, Henry) | | `_Part<N>_value` | SI units: Ohm, Farad, Henry |
| `_Part<N>_Rvalue` | Series resistance of an inductor in Ohm | | `_Part<N>_Rvalue` | inductor DC resistance in Ohm |
| `_Part<N>_bem` | Part label, e.g. `MKT-A`, `SP 0,6mm`, `Keramik 10W` | | `_Part<N>_bem` | Visaton series name, matching `Visatonteile.txt` exactly |
| `_Part<N>_chassisnr` | Driver index (0-based) for typ 5 | | `_Part<N>_chassisnr` | zero-based driver index |
| `_Part<N>_verpolt` | Polarity flag (-1 / 1) for typ 5 and 6 | | `_Part<N>_verpolt` | polarity, -1 meaning inverted |
| `_Part368..375_nod1/nod2` | Extra node-to-node connections |
Other sections: `#General` (nAmps, nChassis), `#AktFilter<n>` (active filters), `#Chassis<n>` (TSP set plus measured frequency/impedance/phase arrays), `#Einbau<n>` (enclosure: Typ 1 closed with `Vb` in m³, etc.), `#Schallwand<n>` (baffle geometry in m), `#GemGeh` (shared enclosure). Other sections: `#General`, `#AktFilter<n>` and `#Aktfilter<n>` (active filters, note the differing case), `#Chassis<n>` (name plus Thiele-Small parameters and measured response arrays), `#Einbau<n>` (enclosure, `Vb` in m³), `#Schallwand<n>` (baffle geometry in m), `#GemGeh` (shared enclosure).
## Tool ## How the parts are resolved
`Weichenbauteile/Visatonteile.txt` is the authoritative parts list: 95 parts across 11 series, with inductor DC resistance in the fourth column. Its series strings are exactly the `_bem` values Boxsim writes, so resolution is a lookup rather than a guess.
Values are matched numerically against the library rather than formatted into symbol names, because the library's own strings are irregular (`MKP 1.0μF` beside `MKP 10μF`, `SP-Spule 1.0mH (0.6)` beside `LR-Spule 10mH`).
- An empty `_bem` is inferred from a sibling part of the same type and value in the same crossover, else from the widest series for that type. Every inference is logged.
- A `_Rvalue` that differs from the catalogue is reported, never corrected: it is a legitimate edit in Boxsim.
- A value Visaton does not stock is a hard error naming the nearest stocked values. Nothing is substituted silently.
The library contains one symbol the catalogue does not list, `MKT 22μF`. It is a real Visaton part (radial film capacitor, 250 V) that Boxsim does not offer in its picker, so no project can reference it.
## Verification
ERC only proves a schematic is well formed; it would happily pass one wired to the wrong cells. So `boxsim build --verify` compares the netlist KiCad extracts from the generated sheet against the netlist derived independently from the Boxsim grid, and both derivations must agree.
The codec is held to the same standard. `tools/difftest.py` decodes every container in the Boxsim distribution twice, once with the pure Python codec and once by emulating Boxsim's own `ExpandA2F` under Unicorn, and requires byte-identical output:
```bash ```bash
.venv/bin/python bpj2ini.py <input.bpj|.bch> [output.ini] .venv/bin/python tools/difftest.py --self-test
# 134 byte-identical, 270 rejected by both as pre-2.0 containers
``` ```
Needs `unicorn` and `pefile` (see `.venv/`) and `Boxsim.exe` (path via `BOXSIM_EXE` env, default: the bundled download under `boxsim_dl/`). Works for `.bpj` and `.bch` alike. Both gates are checked against a deliberately broken input so they are known to fail when they should: `--self-test` corrupts a digest, and `test_gate_has_teeth` mis-wires a component.
The oracle is development-only. It needs `unicorn`, `pefile` and a copy of `Boxsim.exe`:
```bash
pip install -e '.[oracle,dev]'
export BOXSIM_EXE="/path/to/Boxsim.exe"
```
## Tests
```bash
pytest # fast, no Boxsim.exe needed
ruff check boxsim tools tests
```
Tests needing KiCad or the Boxsim distribution skip themselves when those are absent.
## Licence
MIT.
View File
Whitespace-only changes.
+92 -88
View File
@@ -1,79 +1,56 @@
#!/usr/bin/env python3 """Development-only reference decoder: runs Boxsim's own machine code.
"""Decode Boxsim .bpj / .bch (MyIni2CK) files to plain INI text.
Pipeline (reverse engineered from Boxsim 2.1, TMyIniFile by UG): This module emulates `ExpandA2F` (VA 0xa11880) out of an unmodified Boxsim.exe
1. 8-byte magic "MyIni2CK" (plaintext) under Unicorn, with faked Delphi TStream objects. It is exact by construction
2. body XOR-encrypted with a table-shuffled 3-LCG PRNG, seeds (123, 456) and exists solely as the oracle that the pure Python decoder in `boxsim.a2f`
3. result is a custom canonical-Huffman + LZ stream ("A2F" codec) is differentially tested against.
4. decompressed: tab-separated INI text, CRLF line ends, German decimals
Step 3 is executed by emulating Boxsim's own ExpandA2F function (VA 0xa11880) It is NOT part of the shipped tool: it needs `unicorn`, `pefile` and an 8 MB
with Unicorn, so Boxsim.exe must be available (BOXSIM_EXE env or default path). proprietary Windows binary. Point BOXSIM_EXE at a Boxsim.exe to use it.
Usage: bpj2ini.py input.bpj [output.ini] python -m boxsim.cli decode --oracle file.bpj
""" """
from __future__ import annotations
import os import os
import struct import struct
import sys
import pefile
from unicorn import *
from unicorn.x86_const import *
EXE = os.environ.get(
"BOXSIM_EXE",
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"boxsim_dl", "BoxSim 2.1 DE (Stand 12.2025)", "Boxsim.exe"))
MAGIC = b"MyIni2CK" MAGIC = b"MyIni2CK"
ENTRY = 0xA11880 # ExpandA2F(obj, instream, outstream)
ERR_FUNC = 0x97E584 # Boxsim error logger ENTRY = 0xA11880 # ExpandA2F(self, instream, outstream)
ERR_FUNC = 0x97E584 # Boxsim's error reporter
RAISE_FUNC = 0x80ACC4 # Delphi RaiseExcept RAISE_FUNC = 0x80ACC4 # Delphi RaiseExcept
OBJ_BASE = 0x20000000 # decoder object (zero-init, ~11 MB of tables) OBJ_BASE = 0x20000000 # decoder object: ~11 MB of zero-init tables
OBJ_SIZE = 0x01000000 OBJ_SIZE = 0x01000000
FAKE_BASE = 0x40000000 # fake TStream objects, vtables, trap stubs FAKE_BASE = 0x40000000 # fake stream objects, vtables, trap stubs
STACK_BASE = 0x60000000 STACK_BASE = 0x60000000
STACK_SIZE = 0x100000 STACK_SIZE = 0x100000
GDT_BASE = 0x50000000 GDT_BASE = 0x50000000
FS_SEG = 0x50002000 # Delphi SEH chain scratch FS_SEG = 0x50002000 # Delphi SEH chain scratch space
SENTINEL = 0x7F00DEAD SENTINEL = 0x7F00DEAD
IN_OBJ, OUT_OBJ = FAKE_BASE, FAKE_BASE + 0x100 IN_OBJ, OUT_OBJ = FAKE_BASE, FAKE_BASE + 0x100
IN_VT, OUT_VT = FAKE_BASE + 0x1000, FAKE_BASE + 0x2000 IN_VT, OUT_VT = FAKE_BASE + 0x1000, FAKE_BASE + 0x2000
STUB = FAKE_BASE + 0x3000 STUB = FAKE_BASE + 0x3000
S_IN_SIZE, S_IN_READ, S_IN_SEEK, S_OUT_WRITE, S_OUT_SIZE, S_OUT_SEEK = range(6) S_IN_SIZE, S_IN_READ, S_IN_SEEK, S_OUT_WRITE, S_OUT_SIZE, S_OUT_SEEK = range(6)
STUB_SPAN = 256
M32 = 1 << 32
class KryptPRNG: def default_exe() -> str:
"""Boxsim file scrambler PRNG (Init at VA 0xa12be4, next at 0xa12c9c).""" return os.environ.get(
"BOXSIM_EXE",
def __init__(self, s1=123, s2=456, threshold=0xFFFFFFFF): os.path.join(
self.s1, self.s2, self.thr = s1, s2, threshold os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
self.s3 = (s1 % 0xFFFE) * (s2 % 0x7F23) "boxsim_dl",
self.table = [0] * 256 "BoxSim 2.1 DE (Stand 12.2025)",
for i in range(256): "Boxsim.exe",
self._step() ),
self.table[i] = (self.s1 + self.s2) % M32 )
def _step(self):
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):
self._step()
idx = self.s3 & 0xFF
out = self.table[idx]
self.table[idx] = (self.s1 + self.s2) % M32
return out & 0xFF
def _gdt_entry(base, limit, access, gran): def _gdt_entry(base: int, limit: int, access: int, gran: int) -> bytes:
e = limit & 0xFFFF e = limit & 0xFFFF
e |= (base & 0xFFFFFF) << 16 e |= (base & 0xFFFFFF) << 16
e |= (access & 0xFF) << 40 e |= (access & 0xFF) << 40
@@ -83,32 +60,62 @@ def _gdt_entry(base, limit, access, gran):
return struct.pack("<Q", e) return struct.pack("<Q", e)
def expand_a2f(data): def expand_a2f(data: bytes, exe: str | None = None) -> bytes:
"""Decompress a decrypted MyIni2CK stream by emulating Boxsim's decoder.""" """Decompress a descrambled MyIni2CK stream using Boxsim's own decoder."""
pe = pefile.PE(EXE) 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 base = pe.OPTIONAL_HEADER.ImageBase
img = pe.get_memory_mapped_image(ImageBase=base) img = pe.get_memory_mapped_image(ImageBase=base)
uc = Uc(UC_ARCH_X86, UC_MODE_32) uc = Uc(UC_ARCH_X86, UC_MODE_32)
uc.mem_map(base, (len(img) + 0xFFF) & ~0xFFF) uc.mem_map(base, (len(img) + 0xFFF) & ~0xFFF)
uc.mem_write(base, img) uc.mem_write(base, img)
for addr, size in ((OBJ_BASE, OBJ_SIZE), (FAKE_BASE, 0x10000), for addr, size in (
(STACK_BASE, STACK_SIZE), (GDT_BASE, 0x4000)): (OBJ_BASE, OBJ_SIZE),
(FAKE_BASE, 0x10000),
(STACK_BASE, STACK_SIZE),
(GDT_BASE, 0x4000),
):
uc.mem_map(addr, size) uc.mem_map(addr, size)
uc.mem_write(IN_OBJ, struct.pack("<I", IN_VT)) uc.mem_write(IN_OBJ, struct.pack("<I", IN_VT))
uc.mem_write(OUT_OBJ, struct.pack("<I", OUT_VT)) uc.mem_write(OUT_OBJ, struct.pack("<I", OUT_VT))
slot_map = ((IN_VT, {0x00: S_IN_SIZE, 0x0C: S_IN_READ, 0x28: S_IN_SEEK}), layout = (
(OUT_VT, {0x00: S_OUT_SIZE, 0x0C: S_IN_READ, (IN_VT, {0x00: S_IN_SIZE, 0x0C: S_IN_READ, 0x28: S_IN_SEEK}),
0x10: S_OUT_WRITE, 0x28: S_OUT_SEEK})) (OUT_VT, {0x00: S_OUT_SIZE, 0x0C: S_IN_READ, 0x10: S_OUT_WRITE, 0x28: S_OUT_SEEK}),
for vt, stubs in slot_map: )
for vt, stubs in layout:
for off in range(0, 0x30, 4): for off in range(0, 0x30, 4):
sid = stubs.get(off, 15) sid = stubs.get(off, 15)
uc.mem_write(vt + off, struct.pack("<I", STUB + sid * 16)) uc.mem_write(vt + off, struct.pack("<I", STUB + sid * 16))
for sid in range(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" body = b"\xc2\x08\x00" if sid in (S_IN_SEEK, S_OUT_SEEK) else b"\xc3"
uc.mem_write(STUB + sid * 16, body) 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 + 8, _gdt_entry(0, 0xFFFFF, 0x9B, 0xC))
uc.mem_write(GDT_BASE + 16, _gdt_entry(0, 0xFFFFF, 0x93, 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.mem_write(GDT_BASE + 24, _gdt_entry(FS_SEG, 0xFFF, 0x93, 0x4))
@@ -120,8 +127,8 @@ def expand_a2f(data):
state = {"pos": 0, "out": bytearray(), "err": None} state = {"pos": 0, "out": bytearray(), "err": None}
def hook_code(u, addr, _sz, _ud): def hook_code(u, addr, _size, _ud):
if STUB <= addr < STUB + 256: if STUB <= addr < STUB + STUB_SPAN:
sid = (addr - STUB) // 16 sid = (addr - STUB) // 16
eax = u.reg_read(UC_X86_REG_EAX) eax = u.reg_read(UC_X86_REG_EAX)
edx = u.reg_read(UC_X86_REG_EDX) edx = u.reg_read(UC_X86_REG_EDX)
@@ -129,7 +136,7 @@ def expand_a2f(data):
if sid == S_IN_READ and eax == IN_OBJ: if sid == S_IN_READ and eax == IN_OBJ:
n = min(ecx, len(data) - state["pos"]) n = min(ecx, len(data) - state["pos"])
if n > 0: if n > 0:
u.mem_write(edx, data[state["pos"]:state["pos"] + n]) u.mem_write(edx, data[state["pos"] : state["pos"] + n])
state["pos"] += n state["pos"] += n
u.reg_write(UC_X86_REG_EAX, n) u.reg_write(UC_X86_REG_EAX, n)
elif sid == S_IN_SIZE: elif sid == S_IN_SIZE:
@@ -139,9 +146,13 @@ def expand_a2f(data):
esp = u.reg_read(UC_X86_REG_ESP) esp = u.reg_read(UC_X86_REG_ESP)
off = struct.unpack("<i", u.mem_read(esp + 4, 4))[0] off = struct.unpack("<i", u.mem_read(esp + 4, 4))[0]
origin = edx & 0xFF origin = edx & 0xFF
state["pos"] = (off if origin == 0 else state["pos"] = (
state["pos"] + off if origin == 1 else off
len(data) + 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_EAX, state["pos"] & 0xFFFFFFFF)
u.reg_write(UC_X86_REG_EDX, 0) u.reg_write(UC_X86_REG_EDX, 0)
elif sid == S_OUT_WRITE: elif sid == S_OUT_WRITE:
@@ -162,12 +173,16 @@ def expand_a2f(data):
state["err"] = "delphi exception" state["err"] = "delphi exception"
u.emu_stop() u.emu_stop()
uc.hook_add(UC_HOOK_CODE, hook_code, None) # 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 esp = STACK_BASE + STACK_SIZE - 0x1000
uc.mem_write(esp, struct.pack("<I", SENTINEL)) uc.mem_write(esp, struct.pack("<I", SENTINEL))
uc.reg_write(UC_X86_REG_ESP, esp) uc.reg_write(UC_X86_REG_ESP, esp)
# codec config as set by the compressor entry (0xa1013c) # Codec config, as the compressor entry point (0xa1013c) sets it.
uc.mem_write(OBJ_BASE + 0x04, b"\x01\x01") uc.mem_write(OBJ_BASE + 0x04, b"\x01\x01")
uc.mem_write(OBJ_BASE + 0x08, struct.pack("<I", 0x20)) 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 + 0x0C, struct.pack("<I", 0x47))
@@ -176,31 +191,20 @@ def expand_a2f(data):
uc.reg_write(UC_X86_REG_EDX, IN_OBJ) uc.reg_write(UC_X86_REG_EDX, IN_OBJ)
uc.reg_write(UC_X86_REG_ECX, OUT_OBJ) uc.reg_write(UC_X86_REG_ECX, OUT_OBJ)
uc.emu_start(ENTRY, SENTINEL, timeout=120 * 1000000) uc.emu_start(ENTRY, SENTINEL, timeout=600 * 1000000)
if state["err"]: if state["err"]:
raise RuntimeError(state["err"]) raise RuntimeError(state["err"])
if state["pos"] != len(data): if state["pos"] != len(data):
raise RuntimeError(f"decoder stopped early: {state['pos']}/{len(data)}") raise RuntimeError(f"decoder stopped early: {state['pos']}/{len(data)}")
del uc, img, pe
return bytes(state["out"]) return bytes(state["out"])
def decode_bpj(raw): 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: if raw[:8] != MAGIC:
raise ValueError("not a MyIni2CK file") raise ValueError("not a MyIni2CK file")
prng = KryptPRNG() return expand_a2f(MAGIC + unscramble(raw[8:]), exe=exe)
body = bytes(c ^ prng.next_byte() for c in raw[8:])
return expand_a2f(MAGIC + body)
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.splitext(src)[0] + ".ini"
text = decode_bpj(open(src, "rb").read())
open(dst, "wb").write(text)
print(f"{src}: {len(text)} bytes -> {dst}")
if __name__ == "__main__":
main()
+267
View File
@@ -0,0 +1,267 @@
"""Pure Python implementation of Boxsim's A2F codec (decompression).
Reverse engineered from `ExpandA2F` at VA 0xa11880 in Boxsim 2.1. No part of
this needs Boxsim.exe; `boxsim._oracle` runs the original machine code and is
used only to differentially test this module.
Stream shape
------------
A bit stream, MSB first, read through a 31 bit accumulator. Symbols come from a
canonical Huffman code over 356 symbols decoded through a 65536 entry direct
lookup table:
0x000..0x0FF literal byte
0x100 end of stream
0x101 new code lengths follow, rebuild the tables
0x102 define N entries of the 64 entry digram dictionary
0x103..0x10A match, length 3, distance in k+3 bits
0x10B..0x114 match, length 4, distance in k+3 bits
0x115..0x123 match, unary coded length class, distance in k+3 bits
0x124..0x163 emit the two bytes of digram dictionary entry k
Distances use k = symbol - class base: k+3 raw bits biased by 2**(k+3) - 7, so
the classes tile 1.. without gaps.
"""
from __future__ import annotations
NSYM = 0x164 # 356 symbols
TABLE_BITS = 16
TABLE_SIZE = 1 << TABLE_BITS
DICT_SIZE = 0x40
INITIAL_CODE_LENGTH = 9
MAX_CODE_LENGTH = 16
SYM_EOS = 0x100
SYM_REBUILD = 0x101
SYM_DIGRAM_DEF = 0x102
SYM_MATCH3 = 0x103 # 8 symbols
SYM_MATCH4 = 0x10B # 10 symbols
SYM_MATCHN = 0x115 # 15 symbols
SYM_DIGRAM = 0x124 # 64 symbols
# Unary length class -> (extra bits, base). Class 0 is unreachable because the
# unary read always consumes at least one bit.
LENGTH_CLASSES = {
1: (2, 5),
2: (4, 9),
3: (6, 0x19),
4: (8, 0x59),
5: (10, 0x159),
6: (12, 0x559),
}
class A2FError(ValueError):
"""Raised when the stream is malformed or uses an unreachable encoding."""
#: Bytes of zero padding tolerated past the end of the stream. Boxsim decodes
#: from a 0x4000 byte staging buffer and its refill reads on past the valid
#: length, so the final symbol's 16 bit lookup window routinely extends beyond
#: the last real byte. Those bits are never significant: the end-of-stream code
#: is fully contained in real bytes and the lookup table maps every window
#: sharing that prefix to the same symbol. The allowance stays small so a
#: genuinely truncated file still fails loudly instead of decoding garbage.
PAD_ALLOWANCE = 8
class _BitReader:
"""MSB-first bit reader with Boxsim's 23 bit accumulator mask.
The mask is load bearing: Boxsim shifts `(acc & 0x7FFFFF) << 8` on every
refill, which is safe only because a refill step runs when fewer than 19
bits are buffered, so no live bit is ever above bit 22.
"""
__slots__ = ("data", "pos", "acc", "navail", "padding")
def __init__(self, data: bytes):
self.data = data
self.pos = 0
self.acc = 0
self.navail = 0
self.padding = 0
def refill(self, n: int) -> None:
data, pos, acc, navail = self.data, self.pos, self.acc, self.navail
end = len(data)
while navail < n:
if pos < end:
byte = data[pos]
else:
self.padding += 1
if self.padding > PAD_ALLOWANCE:
raise A2FError(
"input exhausted: needed more than "
f"{PAD_ALLOWANCE} bytes of padding past the stream end"
)
byte = 0
acc = ((acc & 0x7FFFFF) << 8) | byte
pos += 1
navail += 8
self.pos, self.acc, self.navail = pos, acc, navail
def take(self, n: int) -> int:
"""Consume the next n bits."""
self.navail -= n
return (self.acc >> self.navail) & ((1 << n) - 1)
def peek16(self) -> int:
return (self.acc >> (self.navail - TABLE_BITS)) & 0xFFFF
class _Huffman:
"""Canonical Huffman decoder built exactly like Boxsim's VA 0xa10548."""
__slots__ = ("lengths", "symtab", "lentab")
def __init__(self) -> None:
self.lengths = [INITIAL_CODE_LENGTH] * NSYM
self.symtab = [0] * TABLE_SIZE
self.lentab = [0] * TABLE_SIZE
self.build()
def build(self) -> None:
lengths = self.lengths
for sym, length in enumerate(lengths):
if not 0 < length <= MAX_CODE_LENGTH:
raise A2FError(f"symbol {sym} has invalid code length {length}")
# Boxsim accumulates codes in a 24 bit register, adding 2**(24-len) per
# assigned symbol, which is a plain canonical Huffman walk.
codes = [0] * NSYM
code = 0
for bitlen in range(1, MAX_CODE_LENGTH + 1):
step = 1 << (24 - bitlen)
shift = 24 - bitlen
for sym in range(NSYM):
if lengths[sym] == bitlen:
codes[sym] = code >> shift
code += step
if code > 0x1000000:
raise A2FError("over-subscribed Huffman code")
symtab, lentab = self.symtab, self.lentab
for sym in range(NSYM):
length = lengths[sym]
start = codes[sym] << (TABLE_BITS - length)
count = 1 << (TABLE_BITS - length)
symtab[start : start + count] = [sym] * count
lentab[start : start + count] = [length] * count
def decode(self, bits: _BitReader) -> int:
bits.refill(TABLE_BITS)
idx = bits.peek16()
length = self.lentab[idx]
if length == 0:
raise A2FError("incomplete Huffman code: undefined table entry")
bits.navail -= length
return self.symtab[idx]
def read_lengths(self, bits: _BitReader) -> None:
"""Symbol 0x101 payload: 356 code lengths.
Two zero bits repeat the previous length. Otherwise a 4 bit nibble plus
one gives the length, and the value 5 is an escape meaning a second
nibble follows and replaces it.
"""
lengths = self.lengths
prev = -1
for i in range(NSYM):
bits.refill(8)
if (bits.acc >> (bits.navail - 2)) & 3 == 0:
lengths[i] = prev
bits.navail -= 2
else:
value = bits.take(4) + 1
if value == 5:
value = bits.take(4) + 1
lengths[i] = value
prev = value
self.build()
def _distance(bits: _BitReader, k: int) -> int:
"""Distance for match class index k: k+3 raw bits biased by 2**(k+3) - 7."""
bits.refill(0x13)
nbits = k + 3
return bits.take(nbits) + (1 << nbits) - 7
def expand(data: bytes, *, skip_magic: int = 8) -> bytes:
"""Decompress an A2F stream, skipping `skip_magic` leading bytes."""
bits = _BitReader(data[skip_magic:])
huff = _Huffman()
digrams = [0] * DICT_SIZE
dptr = 0
out = bytearray()
while True:
sym = huff.decode(bits)
if sym < SYM_EOS:
out.append(sym)
continue
if sym == SYM_EOS:
break
if sym == SYM_REBUILD:
huff.read_lengths(bits)
continue
if sym == SYM_DIGRAM_DEF:
bits.refill(6)
count = bits.take(5) + 1
for _ in range(count):
bits.refill(0x11)
high = huff.decode(bits)
bits.refill(0x11)
low = huff.decode(bits)
digrams[dptr] = ((high << 8) | low) & 0xFFFF
dptr = (dptr + 1) & (DICT_SIZE - 1)
continue
if sym >= SYM_DIGRAM:
pair = digrams[sym - SYM_DIGRAM]
out.append(pair >> 8)
out.append(pair & 0xFF)
continue
# Match symbols.
if sym < SYM_MATCH4:
length = 3
dist = _distance(bits, sym - SYM_MATCH3)
elif sym < SYM_MATCHN:
length = 4
dist = _distance(bits, sym - SYM_MATCH4)
else:
k = sym - SYM_MATCHN
bits.refill(0x11)
unary = 0
while True:
bit = (bits.acc >> (bits.navail - 1)) & 1
unary += 1
bits.navail -= 1
if not bit:
break
bits.refill(0xD)
try:
nbits, base = LENGTH_CLASSES[unary]
except KeyError:
raise A2FError(f"unsupported length class {unary}") from None
length = bits.take(nbits) + base
dist = _distance(bits, k)
if dist > len(out):
raise A2FError(f"match distance {dist} exceeds {len(out)} bytes of output")
start = len(out) - dist
if dist >= length:
out += out[start : start + length]
else:
# Overlapping copy, byte by byte, as the original does.
for i in range(start, start + length):
out.append(out[i])
return bytes(out)
+211
View File
@@ -0,0 +1,211 @@
"""Visaton parts catalogue, as shipped with Boxsim.
`Weichenbauteile/Visatonteile.txt` is tab separated text:
<type>\t<series>\t<value>[\t<dcr>]
Type is R, C or L. Series strings are exactly the `_bem` values Boxsim writes
into a project, which makes this the authoritative mapping from project data to
a real part. Resistors are in Ohm, capacitors in microfarad, inductors in
millihenry with the fourth column giving the DC resistance in Ohm.
Beware: the file has no trailing newline, so `wc -l` undercounts it by one and
the final part is easy to lose.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from .model import PartType
#: Catalogue series name -> library symbol series. Inductor gauges are carried
#: separately because the library encodes them as a `(0.6)` style suffix.
SERIES_TO_SYMBOL = {
"MOX 10W": "MOX-Widerstand",
"Keramik 10W": "Keramikwiderstand",
"Elko rau": "Elko",
"MKT-A": "MKT-A",
"MKP": "MKP",
"FC-Spule": "FC-Spule",
"LR-Spule": "LR-Spule",
"KN-Spule": "KN-Spule",
"SP 0,6mm": "SP-Spule",
"SP 1,0mm": "SP-Spule",
"SP 1,3mm": "SP-Spule",
}
#: Wire gauge per SP series, matching the library's symbol name suffix.
SERIES_GAUGE = {"SP 0,6mm": "0.6", "SP 1,0mm": "1.0", "SP 1,3mm": "1.3"}
TYPE_OF = {"R": PartType.RESISTOR, "C": PartType.CAPACITOR, "L": PartType.INDUCTOR}
#: Catalogue units to SI.
SI_SCALE = {
PartType.RESISTOR: 1.0,
PartType.CAPACITOR: 1e-6,
PartType.INDUCTOR: 1e-3,
}
#: Fallback series when a project leaves `_bem` empty and no sibling settles it.
#: The widest series for the type, so the substitution is at least stockable.
DEFAULT_SERIES = {
PartType.CAPACITOR: "MKT-A",
PartType.RESISTOR: "Keramik 10W",
}
#: Shape of the catalogue shipped with Boxsim 2.1 DE (12.2025), verified
#: 2026-09-29. Asserted when bundling the catalogue into an image, because
#: `cross_check` only proves the catalogue is a subset of the library: a
#: truncated catalogue would pass that and then reject valid projects with
#: "not stocked by Visaton". Bump these deliberately when Visaton's list changes.
EXPECTED_PART_COUNT = 95
EXPECTED_SERIES_COUNT = 11
class CatalogError(ValueError):
pass
@dataclass(frozen=True)
class CatalogPart:
typ: PartType
series: str
value: float # SI: Ohm, Farad, Henry
dcr: float | None # Ohm, inductors only
raw_value: float # as printed in the catalogue
@property
def gauge(self) -> str | None:
return SERIES_GAUGE.get(self.series)
@property
def symbol_series(self) -> str:
return SERIES_TO_SYMBOL[self.series]
class Catalog:
"""All parts Boxsim will let a designer pick."""
def __init__(self, parts: list[CatalogPart]):
self.parts = parts
self._by_series: dict[str, list[CatalogPart]] = {}
for p in parts:
self._by_series.setdefault(p.series, []).append(p)
def __len__(self) -> int:
return len(self.parts)
@property
def series_names(self) -> list[str]:
return sorted(self._by_series)
def of_series(self, series: str) -> list[CatalogPart]:
return self._by_series.get(series, [])
def find(self, series: str, value: float, rtol: float = 1e-4) -> CatalogPart | None:
"""Nearest catalogue part in a series, within a relative tolerance."""
best: CatalogPart | None = None
best_err = None
for p in self.of_series(series):
if p.value == 0:
continue
err = abs(p.value - value) / p.value
if err <= rtol and (best_err is None or err < best_err):
best, best_err = p, err
return best
def nearest(self, series: str, value: float, count: int = 3) -> list[CatalogPart]:
"""Closest stocked values, for error messages."""
return sorted(
self.of_series(series), key=lambda p: abs(p.value - value)
)[:count]
def parse(text: str) -> Catalog:
parts: list[CatalogPart] = []
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
for line in lines[1:]: # first line is the "Visaton-Weichenbauteile" banner
fields = line.split("\t")
if len(fields) < 3:
continue
kind = fields[0].strip()
series = fields[1].strip()
if kind not in TYPE_OF or not series or "---" in series:
continue
raw = fields[2].strip()
if not raw:
continue
try:
raw_value = float(raw.replace(",", "."))
except ValueError:
continue
typ = TYPE_OF[kind]
dcr = None
if len(fields) > 3 and fields[3].strip():
try:
dcr = float(fields[3].strip().replace(",", "."))
except ValueError:
dcr = None
if series not in SERIES_TO_SYMBOL:
raise CatalogError(
f"catalogue series {series!r} has no library mapping; "
"add it to SERIES_TO_SYMBOL"
)
parts.append(
CatalogPart(
typ=typ,
series=series,
value=raw_value * SI_SCALE[typ],
dcr=dcr,
raw_value=raw_value,
)
)
if not parts:
raise CatalogError("catalogue contained no parts")
return Catalog(parts)
def check_shape(catalog: Catalog) -> None:
"""Assert the catalogue is complete, not merely self-consistent."""
if len(catalog) != EXPECTED_PART_COUNT or (
len(catalog.series_names) != EXPECTED_SERIES_COUNT
):
raise CatalogError(
f"catalogue has {len(catalog)} parts across "
f"{len(catalog.series_names)} series, expected "
f"{EXPECTED_PART_COUNT} across {EXPECTED_SERIES_COUNT}. "
"A short catalogue is usually a truncated file (note it has no "
"trailing newline). If Visaton really changed their range, update "
"EXPECTED_PART_COUNT and EXPECTED_SERIES_COUNT."
)
missing = sorted(set(SERIES_TO_SYMBOL) - set(catalog.series_names))
if missing:
raise CatalogError(f"catalogue is missing series: {', '.join(missing)}")
def default_path() -> Path:
"""Where the catalogue lives, overridable for a relocated distribution."""
env = os.environ.get("BOXSIM_CATALOG")
if env:
return Path(env)
root = Path(__file__).resolve().parent.parent
return (
root
/ "boxsim_dl"
/ "BoxSim 2.1 DE (Stand 12.2025)"
/ "Weichenbauteile"
/ "Visatonteile.txt"
)
def load(path: str | os.PathLike[str] | None = None) -> Catalog:
p = Path(path) if path else default_path()
if not p.exists():
raise CatalogError(
f"Visaton catalogue not found at {p}; set BOXSIM_CATALOG to its location"
)
return parse(p.read_text(encoding="latin1"))
+293
View File
@@ -0,0 +1,293 @@
"""Command line interface.
boxsim decode file.bpj raw INI payload
boxsim extract file.bpj structured JSON model
boxsim netlist file.bpj crossover netlist
boxsim build file.bpj outdir/ a KiCad 10 project
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from . import catalog as catalog_mod
from . import container, ini, model
from . import project as project_mod
from . import symbols as symbols_mod
from .model import Project
def _add_library_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--library",
help="path to the kicad-visaton-lib checkout or its Visaton.kicad_sym "
"(default: $BOXSIM_KICAD_LIB, else the copy bundled with this tool)",
)
p.add_argument(
"--catalog",
help="path to Visatonteile.txt (default: $BOXSIM_CATALOG, else the "
"copy bundled with this tool)",
)
def _read(path: str) -> bytes:
p = Path(path)
if not p.exists():
raise SystemExit(f"boxsim: {path}: no such file")
return p.read_bytes()
def _load_project(path: str) -> tuple[Project, str]:
raw = _read(path)
doc = ini.parse(container.decode_text(raw))
return model.from_ini(doc), Path(path).stem
def cmd_decode(args: argparse.Namespace) -> int:
raw = _read(args.file)
if args.oracle:
from . import _oracle
payload = _oracle.decode_bpj(raw)
else:
payload = container.decode(raw)
if args.output:
Path(args.output).write_bytes(payload)
print(f"{args.file}: {len(payload)} bytes -> {args.output}")
else:
sys.stdout.buffer.write(payload)
return 0
def _model_to_dict(project: Project, resolutions=None) -> dict:
by_slot = {r.part.slot: r for r in (resolutions or [])}
out: dict = {
"version": project.version,
"amp_count": project.amp_count,
"chassis_count": project.chassis_count,
"comments": project.comments,
"chassis": [
{
"index": c.index,
"name": c.name,
"comment": c.comment,
"re_ohm": c.re,
"fs_hz": c.fs,
"vas_m3": c.vas,
"qes": c.qes,
"qms": c.qms,
}
for c in project.chassis
],
"enclosures": [
{"index": e.index, "type": e.typ, "vb_m3": e.vb, "fb_hz": e.fb}
for e in project.enclosures
],
"baffles": [
{"index": b.index, "width_m": b.width, "height_m": b.height,
"shared_enclosure": b.shared}
for b in project.baffles
],
"crossovers": [],
}
for xo in project.crossovers:
netlist = xo.build_netlist()
parts = []
for p in xo.parts:
entry = {
"slot": p.slot,
"type": p.typ.name.lower(),
"orientation": p.orientation.name.lower(),
"row": p.row,
"col": p.col,
"nodes": [p.node_a, p.node_b],
"nets": [netlist.net_of(p.node_a), netlist.net_of(p.node_b)],
}
if p.value is not None:
entry["value_si"] = p.value
if p.label:
entry["series"] = p.label
if p.dcr is not None:
entry["dcr_ohm"] = p.dcr
if p.chassis_index is not None:
entry["chassis_index"] = p.chassis_index
if p.inverted:
entry["inverted"] = True
res = by_slot.get(p.slot)
if res is not None:
entry["symbol"] = res.symbol
entry["value_text"] = res.value_text
if res.notes:
entry["notes"] = res.notes
parts.append(entry)
out["crossovers"].append(
{
"index": xo.index,
"component_count": len(xo.components),
"wire_count": len(xo.wires),
"net_count": netlist.net_count,
"dangling_nets": netlist.dangling(),
"parts": parts,
}
)
return out
def cmd_extract(args: argparse.Namespace) -> int:
project, _ = _load_project(args.file)
resolutions = None
if not args.no_symbols:
try:
cat, lib = project_mod.load_parts(args.catalog, args.library)
resolver = symbols_mod.Resolver(cat, lib)
resolutions = [
r
for xo in project.crossovers
for r in resolver.resolve_all(xo.components)
]
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
print(f"boxsim: symbol resolution skipped: {exc}", file=sys.stderr)
payload = json.dumps(_model_to_dict(project, resolutions), indent=2, sort_keys=False)
if args.output:
Path(args.output).write_text(payload + "\n", encoding="utf-8")
print(f"{args.file} -> {args.output}")
else:
print(payload)
return 0
def cmd_netlist(args: argparse.Namespace) -> int:
project, _ = _load_project(args.file)
for xo in project.crossovers:
netlist = xo.build_netlist()
print(f"# crossover {xo.index}: {len(xo.components)} components, "
f"{netlist.net_count} nets")
groups: dict[int, list[str]] = {}
for p in xo.components:
label = f"Part{p.slot}({p.typ.name.lower()}"
label += f" {p.value:g})" if p.value is not None else ")"
for node in (p.node_a, p.node_b):
groups.setdefault(netlist.net_of(node), []).append(label)
for net in sorted(groups):
print(f" net {net:3d}: {', '.join(sorted(groups[net]))}")
dangling = netlist.dangling()
if dangling:
print(f" WARNING dangling nets: {dangling}")
return 0
def cmd_build(args: argparse.Namespace) -> int:
project, name = _load_project(args.file)
try:
cat, lib = project_mod.load_parts(args.catalog, args.library)
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
extras = symbols_mod.cross_check(cat, lib)
if extras and args.verbose:
print(f"note: library has {len(extras)} symbol(s) Boxsim cannot select: "
f"{', '.join(extras)}")
outdir = Path(args.outdir)
try:
result = project_mod.build(
project, args.name or name, outdir, cat, lib, pitch=args.pitch
)
except (symbols_mod.SymbolError, NotImplementedError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
print(f"{result.schematic.name}: {len(result.sheet.placements)} symbols, "
f"{len(result.sheet.wires)} wires, {len(result.sheet.junctions)} junctions, "
f"paper {result.sheet.paper}")
for note in result.notes:
print(f" note: {note}")
if args.verify:
from . import verify as verify_mod
try:
gate = verify_mod.run_gate(
result.schematic, project.crossovers[0], result.sheet.placements
)
except verify_mod.VerifyError as exc:
raise SystemExit(f"boxsim: {exc}") from exc
for m in gate.messages:
print(f" {m}")
status = "PASS" if gate.ok else "FAIL"
print(f" gate {status}: KiCad {gate.kicad_version}, ERC "
f"{'clean' if gate.erc_ok else 'failed'}, netlist "
f"{'matches' if gate.netlist_matches else 'differs'} "
f"({len(gate.reference_nets)} nets)")
if not gate.ok:
return 1
return 0
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
prog="boxsim",
description="Read Boxsim loudspeaker projects and turn their crossovers "
"into KiCad projects.",
)
sub = ap.add_subparsers(dest="command", required=True)
p = sub.add_parser("decode", help="write the raw INI payload of a container")
p.add_argument("file")
p.add_argument("-o", "--output")
p.add_argument(
"--oracle",
action="store_true",
help="decode by emulating Boxsim.exe instead (development only)",
)
p.set_defaults(func=cmd_decode)
p = sub.add_parser("extract", help="write the structured project model as JSON")
p.add_argument("file")
p.add_argument("-o", "--output")
p.add_argument(
"--no-symbols", action="store_true", help="skip KiCad symbol resolution"
)
_add_library_args(p)
p.set_defaults(func=cmd_extract)
p = sub.add_parser("netlist", help="print the crossover netlist")
p.add_argument("file")
p.set_defaults(func=cmd_netlist)
p = sub.add_parser("build", help="write a KiCad project")
p.add_argument("file")
p.add_argument("outdir")
p.add_argument("--name", help="project name (default: input file stem)")
p.add_argument(
"--pitch", type=float, default=None,
help="grid pitch in mm (default 25.4, must exceed 22.86)",
)
p.add_argument(
"--verify", action="store_true",
help="run ERC and compare the extracted netlist against Boxsim "
"(requires kicad-cli 10)",
)
p.add_argument("-v", "--verbose", action="store_true")
_add_library_args(p)
p.set_defaults(func=cmd_build)
return ap
def main(argv: list[str] | None = None) -> int:
ap = build_parser()
args = ap.parse_args(argv)
if getattr(args, "pitch", None) is None and args.command == "build":
from .schematic import DEFAULT_PITCH_MM
args.pitch = DEFAULT_PITCH_MM
try:
return args.func(args)
except (container.ContainerError, model.ModelError) as exc:
raise SystemExit(f"boxsim: {exc}") from exc
if __name__ == "__main__":
raise SystemExit(main())
+35
View File
@@ -0,0 +1,35 @@
"""The MyIni2CK container: magic, XOR scrambling, A2F compression.
Boxsim uses one container for project files (`.bpj`) and driver files (`.bch`).
"""
from __future__ import annotations
from . import a2f
from .krypt import unscramble
MAGIC = b"MyIni2CK"
class ContainerError(ValueError):
pass
def is_container(raw: bytes) -> bool:
return raw[: len(MAGIC)] == MAGIC
def decode(raw: bytes) -> bytes:
"""Decode a Boxsim container to its raw INI payload."""
if not is_container(raw):
raise ContainerError(
f"not a MyIni2CK file (magic {raw[:8]!r}, expected {MAGIC!r})"
)
# The compressed layer reads its own 8 byte header back, so hand it the
# magic followed by the descrambled body exactly as Boxsim does.
return a2f.expand(MAGIC + unscramble(raw[len(MAGIC) :]))
def decode_text(raw: bytes) -> str:
"""Decode to text. Boxsim writes Windows-1252 with CRLF line ends."""
return decode(raw).decode("cp1252")
+94
View File
@@ -0,0 +1,94 @@
"""Parser for the INI-ish text Boxsim stores inside its containers.
Shape: `#Section` headers, `key=<TAB>value` pairs, CRLF line ends, German
decimal commas, and arrays as `;`-separated values. Section names are
case sensitive: a project contains both `#AktFilter1` and `#Aktfilter1`.
"""
from __future__ import annotations
from collections.abc import Iterator
class Section(dict):
"""One `#Name` block. A plain dict of str -> str, plus the section name."""
def __init__(self, name: str):
super().__init__()
self.name = name
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"Section({self.name!r}, {len(self)} keys)"
class IniDocument:
"""Ordered collection of sections, tolerant of repeated section names."""
def __init__(self) -> None:
self.sections: list[Section] = []
def __iter__(self) -> Iterator[Section]:
return iter(self.sections)
def __contains__(self, name: str) -> bool:
return any(s.name == name for s in self.sections)
def first(self, name: str) -> Section | None:
for s in self.sections:
if s.name == name:
return s
return None
def require(self, name: str) -> Section:
s = self.first(name)
if s is None:
raise KeyError(f"missing section #{name}")
return s
def all(self, name: str) -> list[Section]:
return [s for s in self.sections if s.name == name]
def merged(self, *names: str) -> Section:
"""Merge several sections into one, for split blocks like AktFilter."""
out = Section(names[0])
for name in names:
for s in self.all(name):
out.update(s)
return out
def parse(text: str) -> IniDocument:
doc = IniDocument()
current: Section | None = None
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw_line.rstrip("\n")
if not line.strip():
continue
if line.startswith("#"):
current = Section(line[1:].strip())
doc.sections.append(current)
continue
if current is None:
# Content before any header: keep it in an unnamed section rather
# than dropping it silently.
current = Section("")
doc.sections.append(current)
key, sep, value = line.partition("=")
if not sep:
continue
current[key.strip()] = value.strip()
return doc
def number(value: str) -> float:
"""Parse a Boxsim number. German decimal comma, exponents like `1,E-5`."""
return float(value.strip().replace(",", "."))
def integer(value: str) -> int:
return int(float(value.strip().replace(",", ".")))
def number_list(value: str) -> list[float]:
"""Parse a `;`-separated array, tolerating trailing separators."""
return [number(p) for p in value.split(";") if p.strip()]
+55
View File
@@ -0,0 +1,55 @@
"""Boxsim file scrambler: the XOR layer of the MyIni2CK container.
Reverse engineered from Boxsim 2.1 (Init at VA 0xa12be4, next byte at 0xa12c9c).
A table-shuffled combination of three linear congruential generators, seeded
with the same constants for every file Boxsim has ever written.
"""
M32 = 1 << 32
SEED1 = 123
SEED2 = 456
# Compared against a global at VA 0xb10588, which the image ships as 0xffffffff
# and no code path ever writes.
THRESHOLD = 0xFFFFFFFF
class KryptPRNG:
"""Keystream generator for the XOR layer."""
__slots__ = ("s1", "s2", "s3", "table", "thr")
def __init__(self, s1: int = SEED1, s2: int = SEED2, threshold: int = THRESHOLD):
self.s1, self.s2, self.thr = s1, s2, threshold
self.s3 = (s1 % 0xFFFE) * (s2 % 0x7F23)
self.table = [0] * 256
for i in range(256):
self._step()
self.table[i] = (self.s1 + self.s2) % M32
def _step(self) -> None:
self.s1 = (self.s1 * 0x10B95 + 1) % M32
self.s2 = (self.s2 * 0x10BA9 + 1) % M32
if self.s2 >= self.thr:
self.s2 = 0
self.s3 = (self.s3 * 0x1155D + 1) % M32
def next_byte(self) -> int:
self._step()
idx = self.s3 & 0xFF
out = self.table[idx]
self.table[idx] = (self.s1 + self.s2) % M32
return out & 0xFF
def keystream(self, n: int) -> bytes:
return bytes([self.next_byte() for _ in range(n)])
def unscramble(body: bytes) -> bytes:
"""XOR-decrypt the container body (everything after the 8-byte magic)."""
ks = KryptPRNG().keystream(len(body))
return bytes(c ^ k for c, k in zip(body, ks, strict=True))
# The layer is symmetric, but keep both names so call sites read correctly.
scramble = unscramble
+369
View File
@@ -0,0 +1,369 @@
"""Typed model of a Boxsim project, including the crossover netlist.
The crossover editor is an 11 x 18 grid of nodes. A part occupies a slot
between two adjacent nodes, and the slot number encodes both position and
orientation:
1..180 horizontal, 10 per row, joins node(r,c) to node(r,c+1)
181..367 vertical, 11 per row, joins node(r,c) to node(r+1,c)
368..375 free link, endpoints given explicitly by _nod1 and _nod2
Nodes are numbered row-major and zero based: node = row * 11 + col.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from . import ini
from .ini import IniDocument
COLS = 11
ROWS = 18
H_SLOTS = (COLS - 1) * ROWS # 180
V_BASE = H_SLOTS + 1 # 181
V_SLOTS = COLS * (ROWS - 1) # 187
LINK_BASE = V_BASE + V_SLOTS # 368
LINK_SLOTS = 8
TOTAL_SLOTS = LINK_BASE + LINK_SLOTS - 1 # 375
class PartType(IntEnum):
EMPTY = 0
WIRE = 1
RESISTOR = 2
CAPACITOR = 3
INDUCTOR = 4
DRIVER = 5
AMP_INPUT = 6
#: SI unit per component type, for reporting.
UNIT = {
PartType.RESISTOR: "Ohm",
PartType.CAPACITOR: "F",
PartType.INDUCTOR: "H",
}
class Orientation(IntEnum):
HORIZONTAL = 0
VERTICAL = 1
LINK = 2
class ModelError(ValueError):
pass
def slot_geometry(slot: int) -> tuple[Orientation, int, int]:
"""Return (orientation, row, col) for a grid slot. Links have no cell."""
if 1 <= slot <= H_SLOTS:
idx = slot - 1
return Orientation.HORIZONTAL, idx // (COLS - 1), idx % (COLS - 1)
if V_BASE <= slot < V_BASE + V_SLOTS:
idx = slot - V_BASE
return Orientation.VERTICAL, idx // COLS, idx % COLS
if LINK_BASE <= slot <= TOTAL_SLOTS:
return Orientation.LINK, -1, -1
raise ModelError(f"slot {slot} is outside 1..{TOTAL_SLOTS}")
def node_at(row: int, col: int) -> int:
return row * COLS + col
@dataclass
class Part:
"""One populated slot of the crossover grid."""
slot: int
typ: PartType
orientation: Orientation
row: int
col: int
node_a: int
node_b: int
value: float | None = None
label: str = "" # Boxsim `_bem`, the Visaton series name
dcr: float | None = None # `_Rvalue`, inductor series resistance in Ohm
inverted: bool = False # `_verpolt`
chassis_index: int | None = None # `_chassisnr`
optimise: bool = False # `_bOpti`
@property
def is_component(self) -> bool:
"""True for anything that becomes a schematic symbol."""
return self.typ not in (PartType.EMPTY, PartType.WIRE)
def __str__(self) -> str:
name = self.typ.name.lower()
if self.value is not None:
return f"Part{self.slot} {name} {self.value:g}"
return f"Part{self.slot} {name}"
@dataclass
class Chassis:
"""A driver, from a `#Chassis<n>` section."""
index: int
name: str
comment: str = ""
re: float | None = None
fs: float | None = None
vas: float | None = None
qes: float | None = None
qms: float | None = None
@dataclass
class Enclosure:
"""A `#Einbau<n>` section. Type 1 is closed, 2+ are vented variants."""
index: int
typ: int
vb: float | None = None # net volume in m^3
fb: float | None = None # tuning frequency in Hz
@dataclass
class Baffle:
"""A `#Schallwand<n>` section. All lengths in metres."""
index: int
width: float | None = None
height: float | None = None
shared: bool = False
@dataclass
class Netlist:
"""Electrical netlist: components joined through wires and links."""
nets: dict[int, int] # grid node -> canonical net id
components: list[Part]
def net_of(self, node: int) -> int:
return self.nets[node]
@property
def net_count(self) -> int:
return len({self.nets[n] for p in self.components for n in (p.node_a, p.node_b)})
def dangling(self) -> list[int]:
"""Net ids with fewer than two component terminals attached."""
counts: dict[int, int] = {}
for p in self.components:
for node in (p.node_a, p.node_b):
net = self.nets[node]
counts[net] = counts.get(net, 0) + 1
return sorted(net for net, n in counts.items() if n < 2)
@dataclass
class Crossover:
"""One `#Weiche<n>` section: the populated grid."""
index: int
parts: list[Part] = field(default_factory=list)
@property
def components(self) -> list[Part]:
return [p for p in self.parts if p.is_component]
@property
def wires(self) -> list[Part]:
return [p for p in self.parts if p.typ == PartType.WIRE]
def build_netlist(self) -> Netlist:
"""Union-find over wires and free links to collapse nodes into nets."""
parent: dict[int, int] = {}
def find(x: int) -> int:
parent.setdefault(x, x)
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # path compression
parent[x], x = root, parent[x]
return root
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for p in self.parts:
find(p.node_a)
find(p.node_b)
if p.typ == PartType.WIRE:
union(p.node_a, p.node_b)
nets = {node: find(node) for node in list(parent)}
return Netlist(nets=nets, components=self.components)
def occupied_cells(self) -> list[tuple[int, int]]:
return [
(p.row, p.col) for p in self.parts if p.orientation != Orientation.LINK
]
def bounding_box(self) -> tuple[int, int, int, int]:
"""(min_row, min_col, max_row, max_col) over occupied cells."""
cells = self.occupied_cells()
if not cells:
raise ModelError("crossover has no populated slots")
rows = [r for r, _ in cells]
cols = [c for _, c in cells]
return min(rows), min(cols), max(rows), max(cols)
@dataclass
class Project:
"""A whole Boxsim project."""
version: int = 0
amp_count: int = 1
chassis_count: int = 0
comments: list[str] = field(default_factory=list)
crossovers: list[Crossover] = field(default_factory=list)
chassis: list[Chassis] = field(default_factory=list)
enclosures: list[Enclosure] = field(default_factory=list)
baffles: list[Baffle] = field(default_factory=list)
def chassis_name(self, index: int) -> str:
for c in self.chassis:
if c.index == index:
return c.name
return f"Chassis{index + 1}"
def _opt_number(section: dict, key: str) -> float | None:
raw = section.get(key)
if raw is None or not raw.strip():
return None
try:
return ini.number(raw)
except ValueError:
return None
def _parse_crossover(section: dict, index: int) -> Crossover:
"""Collect `_Part<N>_<field>` keys into Part objects."""
fields: dict[int, dict[str, str]] = {}
for key, value in section.items():
if not key.startswith("_Part"):
continue
rest = key[len("_Part") :]
slot_text, sep, attr = rest.partition("_")
if not sep or not slot_text.isdigit():
continue
fields.setdefault(int(slot_text), {})[attr] = value
parts: list[Part] = []
for slot in sorted(fields):
attrs = fields[slot]
typ_raw = attrs.get("typ")
if typ_raw is None:
continue
typ = PartType(ini.integer(typ_raw))
if typ == PartType.EMPTY:
continue
orientation, row, col = slot_geometry(slot)
if orientation == Orientation.LINK:
node_a = ini.integer(attrs.get("nod1", "0"))
node_b = ini.integer(attrs.get("nod2", "0"))
if node_a == node_b:
continue # unused link slot
elif orientation == Orientation.HORIZONTAL:
node_a, node_b = node_at(row, col), node_at(row, col + 1)
else:
node_a, node_b = node_at(row, col), node_at(row + 1, col)
parts.append(
Part(
slot=slot,
typ=typ,
orientation=orientation,
row=row,
col=col,
node_a=node_a,
node_b=node_b,
value=_opt_number(attrs, "value"),
label=attrs.get("bem", "").strip(),
dcr=_opt_number(attrs, "Rvalue"),
inverted=attrs.get("verpolt", "1").strip().startswith("-"),
chassis_index=(
ini.integer(attrs["chassisnr"]) if "chassisnr" in attrs else None
),
optimise=attrs.get("bOpti", "0").strip() not in ("", "0"),
)
)
return Crossover(index=index, parts=parts)
def from_ini(doc: IniDocument) -> Project:
"""Build a Project from a parsed INI document."""
header = doc.first("Header") or {}
general = doc.require("General")
project = Project(
version=ini.integer(header.get("Version", "0")),
amp_count=ini.integer(general.get("nAmps", "1")),
chassis_count=ini.integer(general.get("nChassis", "0")),
comments=[
general.get(k, "") for k in ("Comment1", "Comment2") if general.get(k, "")
],
)
for n in range(1, project.amp_count + 1):
section = doc.first(f"Weiche{n}")
if section is not None:
project.crossovers.append(_parse_crossover(section, n))
for n in range(1, project.chassis_count + 1):
section = doc.first(f"Chassis{n}")
if section is None:
continue
project.chassis.append(
Chassis(
index=n - 1,
name=section.get("Name", "").strip(),
comment=section.get("Kommentar", "").strip(),
re=_opt_number(section, "Re"),
fs=_opt_number(section, "fs"),
vas=_opt_number(section, "Vas"),
qes=_opt_number(section, "Qes"),
qms=_opt_number(section, "Qms"),
)
)
enc = doc.first(f"Einbau{n}")
if enc is not None:
project.enclosures.append(
Enclosure(
index=n - 1,
typ=ini.integer(enc.get("Typ", "0")),
vb=_opt_number(enc, "Vb"),
fb=_opt_number(enc, "fb"),
)
)
baf = doc.first(f"Schallwand{n}")
if baf is not None:
project.baffles.append(
Baffle(
index=n - 1,
width=_opt_number(baf, "b"),
height=_opt_number(baf, "a"),
shared=baf.get("usegemgeh", "0").strip() not in ("", "0"),
)
)
return project
def load(raw: bytes) -> Project:
"""Decode a `.bpj` container and build the project model."""
from .container import decode_text
return from_ini(ini.parse(decode_text(raw)))
+161
View File
@@ -0,0 +1,161 @@
"""Write a complete KiCad project directory.
Emits the schematic, a `.kicad_pro`, and project-local library tables pointing
at the Visaton library so the project opens with every symbol and footprint
resolved. No board is generated: KiCad's own *Update PCB from Schematic* does
that, and every Visaton symbol already carries a `Footprint` property.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from . import symbols as symbols_mod
from .catalog import Catalog
from .model import Project
from .schematic import DEFAULT_PITCH_MM, SchematicBuilder, Sheet
from .symbols import Resolution, Resolver, SymbolLibrary
LIB_NICKNAME = "Visaton"
@dataclass
class BuildResult:
directory: Path
schematic: Path
project_file: Path
sheet: Sheet
resolutions: list[Resolution]
@property
def notes(self) -> list[str]:
out = []
for r in self.resolutions:
for note in r.notes:
out.append(f"Part{r.part.slot}: {note}")
return out
def _pro_contents(name: str) -> dict:
"""Minimal but complete project file. KiCad fills in the rest on open."""
return {
"board": {"design_settings": {}, "layer_presets": [], "viewports": []},
"boards": [],
"cvpcb": {"equivalence_files": []},
"libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},
"meta": {"filename": f"{name}.kicad_pro", "version": 3},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.8,
"via_diameter": 1.2,
"via_drill": 0.6,
"wire_width": 6,
}
],
"meta": {"version": 4},
},
"pcbnew": {"page_layout_descr_file": ""},
"schematic": {
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {"version": 1},
"page_layout_descr_file": "",
},
"sheets": [],
"text_variables": {},
}
def _lib_table(kind: str, entries: list[tuple[str, str]]) -> str:
"""Render a sym-lib-table or fp-lib-table."""
lines = [f"({kind}", "\t(version 7)"]
for nickname, uri in entries:
lines.append(
f'\t(lib (name "{nickname}")(type "KiCad")(uri "{uri}")'
f'(options "")(descr "Visaton crossover parts"))'
)
lines.append(")")
return "\n".join(lines) + "\n"
def resolve_library_root(library: SymbolLibrary) -> Path:
"""The checkout root, derived from the symbol file location."""
path = library.path.resolve()
if path.parent.name == "symbols":
return path.parent.parent
return path.parent
def build(
project: Project,
raw_name: str,
outdir: Path,
catalog: Catalog,
library: SymbolLibrary,
*,
pitch: float = DEFAULT_PITCH_MM,
crossover_index: int = 0,
) -> BuildResult:
if not project.crossovers:
raise ValueError("project contains no crossover")
if project.amp_count > 1:
raise NotImplementedError(
f"project has {project.amp_count} amplifier channels; "
"this version handles single-channel projects only"
)
crossover = project.crossovers[crossover_index]
resolver = Resolver(catalog, library)
resolutions = resolver.resolve_all(crossover.components)
builder = SchematicBuilder(library, pitch=pitch)
sheet = builder.build(project, crossover, resolutions, title=raw_name)
text = builder.render(sheet, raw_name)
outdir.mkdir(parents=True, exist_ok=True)
sch_path = outdir / f"{raw_name}.kicad_sch"
sch_path.write_text(text, encoding="utf-8")
pro_path = outdir / f"{raw_name}.kicad_pro"
pro_path.write_text(json.dumps(_pro_contents(raw_name), indent=2) + "\n", encoding="utf-8")
root = resolve_library_root(library)
(outdir / "sym-lib-table").write_text(
_lib_table("sym_lib_table", [(LIB_NICKNAME, str(root / "symbols" / "Visaton.kicad_sym"))]),
encoding="utf-8",
)
(outdir / "fp-lib-table").write_text(
_lib_table("fp_lib_table", [(LIB_NICKNAME, str(root / "footprints" / "Visaton.pretty"))]),
encoding="utf-8",
)
return BuildResult(
directory=outdir,
schematic=sch_path,
project_file=pro_path,
sheet=sheet,
resolutions=resolutions,
)
def load_parts(
catalog_path: str | None = None, library_path: str | None = None
) -> tuple[Catalog, SymbolLibrary]:
from . import catalog as catalog_mod
cat = catalog_mod.load(catalog_path)
lib = symbols_mod.load(library_path)
return cat, lib
+462
View File
@@ -0,0 +1,462 @@
"""Emit a KiCad 10 schematic from a Boxsim crossover.
The Boxsim crossover editor is already a schematic grid, so the layout is
carried over cell for cell instead of being re-derived: a node at grid (row,
col) becomes a sheet coordinate, wire slots become wire segments, and component
slots place a symbol between their two nodes.
Symbol placement uses KiCad's pin transform, determined empirically from the
shipped demo schematics over several thousand pin placements:
rot 0 pin (px, py) -> (X + px, Y - py)
rot 90 pin (px, py) -> (X - py, Y - px)
rot 180 pin (px, py) -> (X - px, Y + py)
rot 270 pin (px, py) -> (X + py, Y + px)
A symbol's origin is not necessarily its pin midpoint (the `Amplifier` symbol
has both pins at px=3.81), so the origin is derived by placing the transformed
pin midpoint on the slot midpoint.
"""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass, field
from .model import Crossover, Orientation, Part, PartType, Project
from .symbols import Resolution, SymbolLibrary
#: Schematic format emitted by KiCad 10.0.x.
SCH_VERSION = "20260101"
GENERATOR = "boxsim"
GENERATOR_VERSION = "10.0"
#: Must exceed the widest symbol's pin span (22.86 mm for `FC-Spule 10mH`)
#: so neighbouring cells cannot overlap.
DEFAULT_PITCH_MM = 25.4
MARGIN_MM = 25.4
#: KiCad standard sheets, landscape, smallest first.
PAPER_SIZES = [
("A4", 297.0, 210.0),
("A3", 420.0, 297.0),
("A2", 594.0, 420.0),
("A1", 841.0, 594.0),
("A0", 1189.0, 841.0),
]
#: Reference designator prefix per component type.
REFDES_PREFIX = {
PartType.RESISTOR: "R",
PartType.CAPACITOR: "C",
PartType.INDUCTOR: "L",
PartType.DRIVER: "LS",
PartType.AMP_INPUT: "J",
}
#: Stable namespace so regenerating a project yields identical UUIDs.
UUID_NS = uuid.UUID("b0c51a3e-7d61-5f0a-9c2e-0b0a5e1d4f77")
_PIN_RE = re.compile(
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
re.S,
)
_PIN_NAME_RE = re.compile(
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
r'(?:(?!\(pin\b).)*?\(name\s+"([^"]*)"'
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
re.S,
)
def stable_uuid(*parts: object) -> str:
return str(uuid.uuid5(UUID_NS, "|".join(str(p) for p in parts)))
def transform(rot: int, px: float, py: float) -> tuple[float, float]:
if rot == 0:
return px, -py
if rot == 90:
return -py, -px
if rot == 180:
return -px, py
if rot == 270:
return py, px
raise ValueError(f"unsupported rotation {rot}")
def _fmt(value: float) -> str:
"""KiCad writes millimetres with at most 6 decimals and no trailing zeros."""
text = f"{value:.6f}".rstrip("0").rstrip(".")
return text if text not in ("", "-0") else "0"
@dataclass
class SymbolPins:
"""Pin geometry of a library symbol, in library coordinates."""
by_number: dict[str, tuple[float, float]]
names: dict[str, str]
@property
def numbers(self) -> list[str]:
return sorted(self.by_number, key=lambda n: (len(n), n))
@property
def axis(self) -> Orientation:
"""Whether the pins are separated mainly in x or in y."""
nums = self.numbers
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
return (
Orientation.HORIZONTAL
if abs(x1 - x2) >= abs(y1 - y2)
else Orientation.VERTICAL
)
@property
def midpoint(self) -> tuple[float, float]:
nums = self.numbers
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
return (x1 + x2) / 2, (y1 + y2) / 2
def positive_number(self) -> str | None:
for num, name in self.names.items():
if name.strip() == "+":
return num
return None
def symbol_pins(library: SymbolLibrary, name: str) -> SymbolPins:
body = library.geometry_source(name)
by_number: dict[str, tuple[float, float]] = {}
names: dict[str, str] = {}
for m in _PIN_RE.finditer(body):
by_number.setdefault(m.group(3), (float(m.group(1)), float(m.group(2))))
for m in _PIN_NAME_RE.finditer(body):
names.setdefault(m.group(4), m.group(3))
if len(by_number) < 2:
raise ValueError(f"symbol {name!r} has fewer than two pins")
return SymbolPins(by_number=by_number, names=names)
@dataclass
class Placement:
"""A resolved component positioned on the sheet."""
resolution: Resolution
refdes: str
origin: tuple[float, float]
rotation: int
pin_targets: dict[str, tuple[float, float]] # pin number -> node coordinate
pin_positions: dict[str, tuple[float, float]]
pin_nodes: dict[str, int] # pin number -> Boxsim grid node, for verification
@dataclass
class Sheet:
"""Everything that goes into one `.kicad_sch`."""
title: str
paper: str
placements: list[Placement] = field(default_factory=list)
wires: list[tuple[tuple[float, float], tuple[float, float]]] = field(
default_factory=list
)
junctions: list[tuple[float, float]] = field(default_factory=list)
texts: list[tuple[str, tuple[float, float]]] = field(default_factory=list)
used_symbols: list[str] = field(default_factory=list)
class SchematicBuilder:
def __init__(
self,
library: SymbolLibrary,
pitch: float = DEFAULT_PITCH_MM,
margin: float = MARGIN_MM,
):
self.library = library
self.pitch = pitch
self.margin = margin
if pitch <= 22.86:
raise ValueError(
f"pitch {pitch} mm is not wider than the widest symbol (22.86 mm)"
)
# -- geometry ---------------------------------------------------------
def _node_xy(self, node: int, min_row: int, min_col: int) -> tuple[float, float]:
from .model import COLS
row, col = divmod(node, COLS)
return (
self.margin + (col - min_col) * self.pitch,
self.margin + (row - min_row) * self.pitch,
)
def _choose_paper(self, width: float, height: float) -> str:
for name, w, h in PAPER_SIZES:
if width <= w and height <= h:
return name
return PAPER_SIZES[-1][0]
# -- building ---------------------------------------------------------
def build(
self,
project: Project,
crossover: Crossover,
resolutions: list[Resolution],
title: str,
) -> Sheet:
min_row, min_col, max_row, max_col = crossover.bounding_box()
# A slot extends one node past its cell, so the node grid is one wider.
max_node_row, max_node_col = max_row + 1, max_col + 1
def xy(node: int) -> tuple[float, float]:
return self._node_xy(node, min_row, min_col)
width = 2 * self.margin + (max_node_col - min_col) * self.pitch
height = 2 * self.margin + (max_node_row - min_row) * self.pitch
sheet = Sheet(title=title, paper=self._choose_paper(width, height))
endpoints: dict[tuple[float, float], int] = {}
def add_wire(a: tuple[float, float], b: tuple[float, float]) -> None:
if a == b:
return
sheet.wires.append((a, b))
endpoints[a] = endpoints.get(a, 0) + 1
endpoints[b] = endpoints.get(b, 0) + 1
# Wires and free links.
for part in crossover.parts:
if part.typ != PartType.WIRE:
continue
a, b = xy(part.node_a), xy(part.node_b)
if part.orientation == Orientation.LINK and a[0] != b[0] and a[1] != b[1]:
corner = (b[0], a[1]) # route orthogonally, never diagonally
add_wire(a, corner)
add_wire(corner, b)
else:
add_wire(a, b)
# Components.
counters: dict[str, int] = {}
by_slot = {r.part.slot: r for r in resolutions}
for part in sorted(crossover.components, key=lambda p: p.slot):
res = by_slot.get(part.slot)
if res is None:
continue
placement = self._place(part, res, xy, counters, project)
sheet.placements.append(placement)
if res.symbol not in sheet.used_symbols:
sheet.used_symbols.append(res.symbol)
for num, target in placement.pin_targets.items():
add_wire(placement.pin_positions[num], target)
endpoints[target] = endpoints.get(target, 0) + 1
sheet.junctions = sorted(p for p, n in endpoints.items() if n >= 3)
sheet.texts = self._describe(project, crossover, height)
return sheet
def _place(
self,
part: Part,
res: Resolution,
xy,
counters: dict[str, int],
project: Project,
) -> Placement:
pins = symbol_pins(self.library, res.symbol)
a, b = xy(part.node_a), xy(part.node_b)
slot_axis = (
Orientation.HORIZONTAL if abs(a[0] - b[0]) >= abs(a[1] - b[1])
else Orientation.VERTICAL
)
rotation = 0 if pins.axis == slot_axis else 90
mx, my = pins.midpoint
tmx, tmy = transform(rotation, mx, my)
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
origin = (mid[0] - tmx, mid[1] - tmy)
positions = {}
for num, (px, py) in pins.by_number.items():
tx, ty = transform(rotation, px, py)
positions[num] = (round(origin[0] + tx, 6), round(origin[1] + ty, 6))
# Assign pins to nodes along the slot axis.
nums = pins.numbers[:2]
key = (lambda n: positions[n][0]) if slot_axis == Orientation.HORIZONTAL \
else (lambda n: positions[n][1])
first, second = sorted(nums, key=key)
node_first, node_second = (a, b) if (
(a[0] <= b[0]) if slot_axis == Orientation.HORIZONTAL else (a[1] <= b[1])
) else (b, a)
targets = {first: node_first, second: node_second}
# `_verpolt` reverses a driver or the amplifier input. Swap the two
# terminals so the polarity marking matches Boxsim.
if part.inverted and pins.positive_number() is not None:
targets = {first: node_second, second: node_first}
prefix = REFDES_PREFIX[part.typ]
counters[prefix] = counters.get(prefix, 0) + 1
refdes = f"{prefix}{counters[prefix]}"
if part.typ == PartType.DRIVER and part.chassis_index is not None:
res.value_text = project.chassis_name(part.chassis_index)
coord_to_node = {a: part.node_a, b: part.node_b}
return Placement(
resolution=res,
refdes=refdes,
origin=(round(origin[0], 6), round(origin[1], 6)),
rotation=rotation,
pin_targets=targets,
pin_positions=positions,
pin_nodes={num: coord_to_node[xyc] for num, xyc in targets.items()},
)
def _describe(
self, project: Project, crossover: Crossover, height: float
) -> list[tuple[str, tuple[float, float]]]:
"""A short note recording which enclosure this crossover belongs to."""
lines = [f"Boxsim crossover {crossover.index} - {len(crossover.components)} parts"]
for ch in project.chassis:
enc = next((e for e in project.enclosures if e.index == ch.index), None)
baf = next((b for b in project.baffles if b.index == ch.index), None)
bits = [f"LS{ch.index + 1}: {ch.name}"]
if enc is not None and enc.vb:
kind = {1: "closed", 2: "vented", 3: "bandpass"}.get(enc.typ, f"type {enc.typ}")
bits.append(f"{kind}, Vb {enc.vb * 1000:g} l")
if baf is not None and baf.width and baf.height:
bits.append(f"baffle {baf.width * 1000:g} x {baf.height * 1000:g} mm")
lines.append(" ".join(bits))
y = height - self.margin / 2
return [(line, (self.margin, y + i * 4.0)) for i, line in enumerate(lines)]
# -- serialisation ----------------------------------------------------
def render(self, sheet: Sheet, project_name: str) -> str:
sheet_uuid = stable_uuid("sheet", project_name)
out: list[str] = []
w = out.append
w("(kicad_sch")
w(f'\t(version {SCH_VERSION})')
w(f'\t(generator "{GENERATOR}")')
w(f'\t(generator_version "{GENERATOR_VERSION}")')
w(f'\t(uuid "{sheet_uuid}")')
w(f'\t(paper "{sheet.paper}")')
w("\t(title_block")
w(f'\t\t(title "{sheet.title}")')
w('\t\t(comment 1 "Generated from a Boxsim project by boxsim")')
w("\t)")
w("\t(lib_symbols")
for name in sheet.used_symbols:
body = self.library.definition(name)
body = body.replace(f'(symbol "{name}"', f'(symbol "Visaton:{name}"', 1)
for line in body.splitlines():
w("\t\t" + line.strip() if not line.startswith("\t") else "\t\t" + line.lstrip("\t"))
w("\t)")
for (x1, y1), (x2, y2) in sheet.wires:
w("\t(wire")
w(f"\t\t(pts (xy {_fmt(x1)} {_fmt(y1)}) (xy {_fmt(x2)} {_fmt(y2)}))")
w("\t\t(stroke (width 0) (type default))")
w(f'\t\t(uuid "{stable_uuid("wire", x1, y1, x2, y2)}")')
w("\t)")
for x, y in sheet.junctions:
w("\t(junction")
w(f"\t\t(at {_fmt(x)} {_fmt(y)})")
w("\t\t(diameter 0)")
w("\t\t(color 0 0 0 0)")
w(f'\t\t(uuid "{stable_uuid("junction", x, y)}")')
w("\t)")
for text, (x, y) in sheet.texts:
w(f'\t(text "{text}"')
w("\t\t(exclude_from_sim yes)")
w(f"\t\t(at {_fmt(x)} {_fmt(y)} 0)")
w("\t\t(effects (font (size 1.27 1.27)) (justify left bottom))")
w(f'\t\t(uuid "{stable_uuid("text", x, y, text)}")')
w("\t)")
for p in sheet.placements:
self._render_symbol(out, p, project_name, sheet_uuid)
w("\t(sheet_instances")
w('\t\t(path "/"')
w('\t\t\t(page "1")')
w("\t\t)")
w("\t)")
w("\t(embedded_fonts no)")
w(")")
return "\n".join(out) + "\n"
def _render_symbol(
self, out: list[str], p: Placement, project_name: str, sheet_uuid: str
) -> None:
w = out.append
res = p.resolution
part = res.part
x, y = p.origin
sym_uuid = stable_uuid("symbol", part.slot, res.symbol)
w("\t(symbol")
w(f'\t\t(lib_id "Visaton:{res.symbol}")')
w(f"\t\t(at {_fmt(x)} {_fmt(y)} {p.rotation})")
w("\t\t(unit 1)")
w("\t\t(exclude_from_sim no)")
w("\t\t(in_bom yes)")
w("\t\t(on_board yes)")
w("\t\t(dnp no)")
w(f'\t\t(uuid "{sym_uuid}")')
# Keep the labels clear of the body: above and below for a horizontal
# symbol, left and right for a rotated one, which would otherwise have
# its value text printed straight over the component.
if p.rotation == 0:
ref_at, val_at = (x, y - 5.08), (x, y + 5.08)
else:
ref_at, val_at = (x - 7.62, y), (x + 7.62, y)
props = [
("Reference", p.refdes, ref_at),
("Value", res.value_text, val_at),
]
if res.series:
props.append(("Visaton_Series", res.series, (x, y)))
if part.dcr is not None:
props.append(("DCR_Ohm", f"{part.dcr:g}", (x, y)))
if part.chassis_index is not None:
props.append(("Boxsim_Chassis", str(part.chassis_index + 1), (x, y)))
if part.inverted:
props.append(("Boxsim_Polarity", "inverted", (x, y)))
props.append(("Boxsim_Slot", str(part.slot), (x, y)))
for i, (name, value, (pxx, pyy)) in enumerate(props):
hidden = i >= 2
w(f'\t\t(property "{name}" "{value}"')
w(f"\t\t\t(at {_fmt(pxx)} {_fmt(pyy)} 0)")
if hidden:
w("\t\t\t(hide yes)")
w("\t\t\t(effects (font (size 1.27 1.27)))")
w("\t\t)")
for num in sorted(p.pin_positions):
w(f'\t\t(pin "{num}"')
w(f'\t\t\t(uuid "{stable_uuid("pin", part.slot, num)}")')
w("\t\t)")
w("\t\t(instances")
w(f'\t\t\t(project "{project_name}"')
w(f'\t\t\t\t(path "/{sheet_uuid}"')
w(f'\t\t\t\t\t(reference "{p.refdes}")')
w("\t\t\t\t\t(unit 1)")
w("\t\t\t\t)")
w("\t\t\t)")
w("\t\t)")
w("\t)")
+401
View File
@@ -0,0 +1,401 @@
"""Index of the KiCad Visaton symbol library, and the resolver.
The library's own value strings are irregular: `MKP 1.0μF` sits next to
`MKP 10μF`, and `SP-Spule 1.0mH (0.6)` next to `LR-Spule 10mH`. Formatting a
number into a name would therefore break on some series, so the library is
parsed into `(series, value, gauge)` triples and matched numerically instead.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from .catalog import DEFAULT_SERIES, SERIES_TO_SYMBOL, Catalog
from .model import Part, PartType
#: Symbols that carry no value and are addressed by name.
AMP_SYMBOL = "Amplifier"
DRIVER_SYMBOL = "Speaker"
#: Unit suffix per component type as the library writes it.
UNIT_SUFFIX = {
PartType.RESISTOR: "Ω",
PartType.CAPACITOR: "μF",
PartType.INDUCTOR: "mH",
}
#: Scale from SI to the unit the library names use.
NAME_SCALE = {
PartType.RESISTOR: 1.0,
PartType.CAPACITOR: 1e6,
PartType.INDUCTOR: 1e3,
}
_SP_RE = re.compile(r"^SP-Spule\s+([\d.]+)mH\s+\(([\d.]+)\)$")
_PLAIN_RE = re.compile(r"^(.*?)\s+([\d.]+)(mH|μF|Ω)$")
_SYMBOL_RE = re.compile(r'^\s*\(symbol\s+"([^"]+)"', re.MULTILINE)
_UNIT_SUFFIX_RE = re.compile(r"_\d+_\d+$")
_EXTENDS_RE = re.compile(r'\(extends\s+"([^"]+)"\)')
_LIB_VERSION_RE = re.compile(r"\(version\s+(\d+)\)")
#: Oldest symbol library format this tool will read. KiCad 6 era libraries use
#: `(id N)` properties and bare `hide` tokens, which cannot be embedded into a
#: current schematic verbatim. `kicad-cli sym upgrade` converts them.
MIN_LIB_VERSION = 20211014
class SymbolError(ValueError):
pass
@dataclass(frozen=True)
class LibrarySymbol:
name: str
series: str | None # library-side series, e.g. "SP-Spule"
value: float | None # SI
gauge: str | None # "0.6" etc, SP inductors only
@dataclass
class Resolution:
"""The outcome of resolving one Boxsim part to a library symbol."""
part: Part
symbol: str
value_text: str
series: str | None = None
inferred_series: bool = False
dcr_note: str | None = None
@property
def notes(self) -> list[str]:
out = []
if self.inferred_series:
out.append(f"series inferred as {self.series!r} (Boxsim left _bem empty)")
if self.dcr_note:
out.append(self.dcr_note)
return out
def _parse_symbol_name(name: str) -> LibrarySymbol:
m = _SP_RE.match(name)
if m:
return LibrarySymbol(name, "SP-Spule", float(m.group(1)) * 1e-3, m.group(2))
m = _PLAIN_RE.match(name)
if m:
series, number, unit = m.group(1), float(m.group(2)), m.group(3)
scale = {"mH": 1e-3, "μF": 1e-6, "Ω": 1.0}[unit]
return LibrarySymbol(name, series, number * scale, None)
return LibrarySymbol(name, None, None, None)
class SymbolLibrary:
"""Parsed `Visaton.kicad_sym`."""
def __init__(self, path: Path, names: list[str], source: str):
self.path = path
self.source = source
self.symbols = [_parse_symbol_name(n) for n in names]
self._by_name = {s.name: s for s in self.symbols}
self._index: dict[tuple[str, str | None], dict[int, LibrarySymbol]] = {}
for s in self.symbols:
if s.series is None or s.value is None:
continue
self._index.setdefault((s.series, s.gauge), {})[round(s.value, 12)] = s
def __len__(self) -> int:
return len(self.symbols)
def __contains__(self, name: str) -> bool:
return name in self._by_name
@property
def names(self) -> list[str]:
return [s.name for s in self.symbols]
def lookup(
self, series: str, value: float, gauge: str | None = None, rtol: float = 1e-6
) -> LibrarySymbol | None:
bucket = self._index.get((series, gauge))
if not bucket:
return None
exact = bucket.get(round(value, 12))
if exact is not None:
return exact
best, best_err = None, None
for v, sym in bucket.items():
if v == 0:
continue
err = abs(v - value) / v
if err <= rtol and (best_err is None or err < best_err):
best, best_err = sym, err
return best
def raw_definition(self, name: str) -> str:
"""The s-expression block for one symbol, exactly as stored."""
needle = f'(symbol "{name}"'
start = self.source.find(needle)
if start < 0:
raise SymbolError(f"symbol {name!r} not found in {self.path}")
# Walk balanced parentheses, ignoring those inside strings. Symbol names
# such as `SP-Spule 0.82mH (0.6)` contain parentheses of their own.
depth, i, in_str, escaped = 0, start, False, False
while i < len(self.source):
ch = self.source[i]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return self.source[start : i + 1]
i += 1
raise SymbolError(f"unbalanced s-expression for symbol {name!r}")
def base_of(self, name: str) -> str | None:
"""The symbol this one `extends`, if any."""
m = _EXTENDS_RE.search(self.raw_definition(name))
return m.group(1) if m else None
def geometry_source(self, name: str) -> str:
"""The definition that actually carries pins and graphics."""
seen = set()
current = name
while True:
base = self.base_of(current)
if base is None:
return self.raw_definition(current)
if base in seen:
raise SymbolError(f"circular extends chain at {base!r}")
seen.add(base)
current = base
def definition(self, name: str) -> str:
"""A self-contained symbol block suitable for a schematic's lib_symbols.
A derived symbol is flattened: its own properties are kept and the base
symbol's graphical sub-units are copied in under the derived name.
KiCad never writes `extends` into a schematic cache, so neither do we.
"""
own = self.raw_definition(name)
base = self.base_of(name)
if base is None:
return own
body = _EXTENDS_RE.sub("", own, count=1)
closing = body.rstrip()
if not closing.endswith(")"):
raise SymbolError(f"malformed symbol block for {name!r}")
body = closing[:-1].rstrip()
base_block = self.geometry_source(base)
units = []
for unit_name in _SYMBOL_RE.findall(base_block):
if not _UNIT_SUFFIX_RE.search(unit_name):
continue
suffix = unit_name[len(base) :] if unit_name.startswith(base) else ""
unit_block = self._sub_block(base_block, unit_name)
units.append(
unit_block.replace(
f'(symbol "{unit_name}"', f'(symbol "{name}{suffix}"', 1
)
)
if not units:
raise SymbolError(f"base symbol {base!r} of {name!r} has no graphics")
indented = "\n".join("\t\t" + u.replace("\n", "\n\t\t") for u in units)
return f"{body}\n{indented}\n\t)"
@staticmethod
def _sub_block(text: str, name: str) -> str:
needle = f'(symbol "{name}"'
start = text.find(needle)
if start < 0:
raise SymbolError(f"sub-symbol {name!r} not found")
depth, i, in_str, escaped = 0, start, False, False
while i < len(text):
ch = text[i]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return text[start : i + 1]
i += 1
raise SymbolError(f"unbalanced sub-symbol {name!r}")
def default_path() -> Path:
env = os.environ.get("BOXSIM_KICAD_LIB")
if env:
p = Path(env)
return p if p.suffix == ".kicad_sym" else p / "symbols" / "Visaton.kicad_sym"
bundled = Path(__file__).resolve().parent / "data" / "Visaton.kicad_sym"
if bundled.exists():
return bundled
return Path("/home/magdev/workspaces/other/kicad-visaton-lib/symbols/Visaton.kicad_sym")
def load(path: str | os.PathLike[str] | None = None) -> SymbolLibrary:
p = Path(path) if path else default_path()
if not p.exists():
raise SymbolError(
f"KiCad symbol library not found at {p}; "
"set BOXSIM_KICAD_LIB to the kicad-visaton-lib checkout"
)
source = p.read_text(encoding="utf-8")
m = _LIB_VERSION_RE.search(source[:400])
if m is None:
raise SymbolError(f"{p} has no (version ...) token; is it a symbol library?")
version = int(m.group(1))
if version < MIN_LIB_VERSION:
raise SymbolError(
f"{p} is in the legacy format (version {version}, need "
f"{MIN_LIB_VERSION} or newer). Convert it with:\n"
f" kicad-cli sym upgrade --force {p}"
)
names = [n for n in _SYMBOL_RE.findall(source) if not _UNIT_SUFFIX_RE.search(n)]
if not names:
raise SymbolError(f"no symbols found in {p}")
return SymbolLibrary(p, names, source)
def cross_check(catalog: Catalog, library: SymbolLibrary) -> list[str]:
"""Every catalogue part must exist in the library. Returns extra symbols."""
missing = []
matched = set()
for part in catalog.parts:
sym = library.lookup(part.symbol_series, part.value, part.gauge)
if sym is None:
missing.append(f"{part.series} {part.raw_value:g}")
else:
matched.add(sym.name)
if missing:
raise SymbolError(
f"{len(missing)} catalogue part(s) have no library symbol: "
+ ", ".join(missing[:10])
)
return sorted(
s.name
for s in library.symbols
if s.name not in matched and s.name not in (AMP_SYMBOL, DRIVER_SYMBOL)
)
def _format_value(typ: PartType, value: float) -> str:
scaled = value * NAME_SCALE[typ]
text = f"{scaled:g}"
return f"{text}{UNIT_SUFFIX[typ]}"
class Resolver:
"""Maps Boxsim parts onto library symbols using the Visaton catalogue."""
def __init__(self, catalog: Catalog, library: SymbolLibrary):
self.catalog = catalog
self.library = library
def _series_for(self, part: Part, siblings: list[Part]) -> tuple[str, bool]:
"""Catalogue series for a part, inferring one when `_bem` is empty."""
if part.label:
if part.label in SERIES_TO_SYMBOL:
return part.label, False
# Tolerate stray whitespace or case differences before giving up.
for known in SERIES_TO_SYMBOL:
if known.lower() == part.label.lower():
return known, False
raise SymbolError(
f"Part{part.slot}: series {part.label!r} is not in the Visaton "
f"catalogue (known: {', '.join(sorted(SERIES_TO_SYMBOL))})"
)
# Empty _bem: prefer a sibling of the same type and value.
for other in siblings:
if (
other is not part
and other.typ == part.typ
and other.label
and other.value is not None
and part.value is not None
and abs(other.value - part.value) <= abs(part.value) * 1e-9
):
return other.label, True
fallback = DEFAULT_SERIES.get(part.typ)
if fallback is None:
raise SymbolError(
f"Part{part.slot}: no series given and no sibling or default for "
f"{part.typ.name.lower()}"
)
return fallback, True
def resolve(self, part: Part, siblings: list[Part] | None = None) -> Resolution:
siblings = siblings or []
if part.typ == PartType.AMP_INPUT:
return Resolution(part=part, symbol=AMP_SYMBOL, value_text="Amp")
if part.typ == PartType.DRIVER:
return Resolution(part=part, symbol=DRIVER_SYMBOL, value_text="")
if part.value is None:
raise SymbolError(f"Part{part.slot}: {part.typ.name.lower()} has no value")
series, inferred = self._series_for(part, siblings)
entry = self.catalog.find(series, part.value)
if entry is None:
near = self.catalog.nearest(series, part.value)
offered = ", ".join(f"{p.raw_value:g}" for p in near) or "none"
raise SymbolError(
f"Part{part.slot}: {series} "
f"{_format_value(part.typ, part.value)} is not stocked by Visaton; "
f"nearest available: {offered}"
)
sym = self.library.lookup(entry.symbol_series, entry.value, entry.gauge)
if sym is None:
raise SymbolError(
f"Part{part.slot}: catalogue part {series} {entry.raw_value:g} "
f"has no symbol in {self.library.path.name}"
)
dcr_note = None
if (
part.dcr is not None
and entry.dcr is not None
and abs(part.dcr - entry.dcr) > 1e-9
):
dcr_note = (
f"DCR {part.dcr:g} Ohm differs from catalogue {entry.dcr:g} Ohm "
"(edited in Boxsim)"
)
return Resolution(
part=part,
symbol=sym.name,
value_text=_format_value(part.typ, part.value),
series=series,
inferred_series=inferred,
dcr_note=dcr_note,
)
def resolve_all(self, parts: list[Part]) -> list[Resolution]:
return [self.resolve(p, parts) for p in parts]
+167
View File
@@ -0,0 +1,167 @@
"""Verify a generated project against the Boxsim data it came from.
The gate that matters is not ERC: a schematic can be perfectly well formed and
still wired to the wrong cells. Instead the netlist KiCad extracts from the
generated sheet is compared against the netlist derived independently from the
Boxsim grid. Two derivations that agree is real evidence; ERC alone is not.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from .model import Crossover
from .schematic import Placement
MIN_KICAD_MAJOR = 10
_NET_RE = re.compile(r"\(net\b(.*?)(?=\n\t\t\(net\b|\n\t\)\s*$)", re.S)
_NAME_RE = re.compile(r'\(name\s+"([^"]*)"\)')
_NODE_RE = re.compile(r'\(node\s*\(ref\s+"([^"]+)"\)\s*\(pin\s+"([^"]+)"\)', re.S)
class VerifyError(RuntimeError):
pass
@dataclass
class GateResult:
erc_ok: bool
erc_violations: int
netlist_matches: bool
kicad_version: str
reference_nets: list[frozenset[str]] = field(default_factory=list)
kicad_nets: list[frozenset[str]] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.erc_ok and self.netlist_matches
def kicad_cli() -> str:
exe = shutil.which("kicad-cli")
if exe is None:
raise VerifyError(
"kicad-cli not found. KiCad 10 is a hard dependency of the "
"verification gate; install it or run `boxsim build` without --verify."
)
return exe
def kicad_version() -> str:
out = subprocess.run(
[kicad_cli(), "version"], capture_output=True, text=True, check=True
).stdout.strip()
major = out.split(".")[0]
if not major.isdigit() or int(major) != MIN_KICAD_MAJOR:
raise VerifyError(
f"kicad-cli reports version {out!r}, but this tool targets "
f"KiCad {MIN_KICAD_MAJOR}.x schematics"
)
return out
def reference_partition(
crossover: Crossover, placements: list[Placement]
) -> list[frozenset[str]]:
"""Group `REFDES.PIN` terminals by the net they sit on in the Boxsim grid."""
netlist = crossover.build_netlist()
groups: dict[int, set[str]] = {}
for p in placements:
for pin, node in p.pin_nodes.items():
net = netlist.net_of(node)
groups.setdefault(net, set()).add(f"{p.refdes}.{pin}")
return sorted((frozenset(v) for v in groups.values()), key=_net_key)
def parse_netlist(text: str) -> list[frozenset[str]]:
"""Read the `(nets ...)` block of a KiCad sexpr netlist into a partition."""
start = text.find("\t(nets")
if start < 0:
raise VerifyError("exported netlist has no (nets ...) section")
body = text[start:]
out = []
for chunk in body.split("\t\t(net")[1:]:
terminals = {f"{ref}.{pin}" for ref, pin in _NODE_RE.findall(chunk)}
if terminals:
out.append(frozenset(terminals))
if not out:
raise VerifyError("exported netlist contained no nets")
return sorted(out, key=_net_key)
def _net_key(net: frozenset[str]) -> tuple:
return (len(net), tuple(sorted(net)))
def run_gate(
schematic: Path,
crossover: Crossover,
placements: list[Placement],
*,
workdir: Path | None = None,
) -> GateResult:
version = kicad_version()
workdir = workdir or schematic.parent
erc_report = workdir / f"{schematic.stem}-erc.rpt"
netlist_path = workdir / f"{schematic.stem}.net"
erc = subprocess.run(
[
kicad_cli(), "sch", "erc",
"--severity-error", "--exit-code-violations",
"-o", str(erc_report), str(schematic),
],
capture_output=True, text=True,
)
violations = 0
if erc_report.exists():
m = re.search(r"Errors (\d+)", erc_report.read_text(encoding="utf-8"))
if m:
violations = int(m.group(1))
erc_ok = erc.returncode == 0 and violations == 0
export = subprocess.run(
[
kicad_cli(), "sch", "export", "netlist",
"--format", "kicadsexpr", "-o", str(netlist_path), str(schematic),
],
capture_output=True, text=True,
)
if export.returncode != 0:
raise VerifyError(f"netlist export failed: {export.stderr.strip()}")
reference = reference_partition(crossover, placements)
extracted = parse_netlist(netlist_path.read_text(encoding="utf-8"))
messages = []
if not erc_ok:
messages.append(f"ERC reported {violations} error(s); see {erc_report.name}")
matches = reference == extracted
if not matches:
only_ref = [sorted(n) for n in reference if n not in extracted]
only_kicad = [sorted(n) for n in extracted if n not in reference]
messages.append(
f"netlist mismatch: {len(reference)} reference nets vs "
f"{len(extracted)} extracted"
)
for net in only_ref[:8]:
messages.append(f" only in Boxsim reference: {net}")
for net in only_kicad[:8]:
messages.append(f" only in KiCad netlist: {net}")
return GateResult(
erc_ok=erc_ok,
erc_violations=violations,
netlist_matches=matches,
kicad_version=version,
reference_nets=reference,
kicad_nets=extracted,
messages=messages,
)
+41
View File
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "boxsim"
version = "0.1.0"
description = "Read Boxsim loudspeaker projects and generate KiCad projects from their crossovers"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Marco Grätsch", email = "magdev3.0@gmail.com" }]
keywords = ["boxsim", "visaton", "kicad", "loudspeaker", "crossover"]
dependencies = []
[project.optional-dependencies]
# Development only: the reference decoder emulates Boxsim.exe to differentially
# test the pure Python codec. Not needed to use the tool.
oracle = ["unicorn>=2.0", "pefile>=2023.2.7"]
dev = ["pytest>=8", "ruff>=0.6"]
[project.scripts]
boxsim = "boxsim.cli:main"
[tool.setuptools]
packages = ["boxsim"]
[tool.setuptools.package-data]
boxsim = ["data/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 96
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
DIST = ROOT / "boxsim_dl" / "BoxSim 2.1 DE (Stand 12.2025)"
DEFAULT_BPJ = DIST / "Projekte" / "Default.BPJ"
MANIFEST = ROOT / "tests" / "oracle_manifest.json"
def pytest_configure(config):
config.addinivalue_line("markers", "needs_kicad: requires kicad-cli 10 on PATH")
config.addinivalue_line("markers", "needs_dist: requires the Boxsim distribution")
@pytest.fixture(scope="session")
def dist() -> Path:
if not DIST.exists():
pytest.skip(f"Boxsim distribution not present at {DIST}")
return DIST
@pytest.fixture(scope="session")
def default_bpj(dist: Path) -> Path:
if not DEFAULT_BPJ.exists():
pytest.skip("Default.BPJ not present")
return DEFAULT_BPJ
@pytest.fixture(scope="session")
def manifest() -> dict:
if not MANIFEST.exists():
pytest.skip("oracle manifest missing; run tools/difftest.py")
return json.loads(MANIFEST.read_text(encoding="utf-8"))
@pytest.fixture(scope="session")
def kicad_cli() -> str:
exe = shutil.which("kicad-cli")
if exe is None:
pytest.skip("kicad-cli not on PATH")
return exe
@pytest.fixture(scope="session")
def library():
from boxsim import symbols
try:
return symbols.load()
except symbols.SymbolError as exc:
pytest.skip(str(exc))
@pytest.fixture(scope="session")
def catalog():
from boxsim import catalog as catalog_mod
try:
return catalog_mod.load()
except catalog_mod.CatalogError as exc:
pytest.skip(str(exc))
@pytest.fixture(scope="session")
def default_project(default_bpj: Path):
from boxsim import model
return model.load(default_bpj.read_bytes())
+813
View File
@@ -0,0 +1,813 @@
{
"files": {
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL130-8.BCH": {
"len": 166589,
"sha256": "52563a9ec686c3fbb63ddcab63d21c18b3e42c1260816823c3be54e9296c7761"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL130M-8.BCH": {
"len": 166958,
"sha256": "6336ea4920427b8e90ef4d578f0d7c17a96eefd352f14aeead9e376dae6c6d03"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL170-8.BCH": {
"len": 167237,
"sha256": "85e527b16b6d4e5b9022899ffb68a377996d056b9759c1241560db2fafa7ade5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL200-8.BCH": {
"len": 166398,
"sha256": "0f3df9168ef1bcdf50fc1c04dbdf2d71882c9ae712576e2c11531dc025fee46a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B100-6.BCH": {
"len": 167545,
"sha256": "acb0479980734856d8daef68f2d334af3b1d8b271503a65a143df0ef4002d653"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B200-6.BCH": {
"len": 164948,
"sha256": "c9f66a0615ec5bbde6d04b0896729c1f127188501ebac074722a048240d71459"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B80-8.BCH": {
"len": 167758,
"sha256": "046d00c3288d515148a05a08312d3432d69c803a068b3f83cadfa2fe2577716b"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32-4.BCH": {
"len": 168059,
"sha256": "b93583230b24073f4322cc32603cd5587690941f53673cc5cef34b8dfc667975"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32-8.BCH": {
"len": 167706,
"sha256": "b0bb6c9101c297f4939b7147ecaef2c3326b852414626617e2543b07b295f3a9"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32SWP-8.BCH": {
"len": 168621,
"sha256": "24a8a65637b5668723496126d4dea7edfe6b6562df54f4410e643f3233a7de07"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF37-4.BCH": {
"len": 167856,
"sha256": "9b25d410d84252a18cf3c088d2a2586b94c57029ed41f104640407bb5a4b61eb"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF37-8.BCH": {
"len": 167492,
"sha256": "3635f5db87a7a893d071959badebb42443a1b5ae881bee905527d79962ec1d1c"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF45-4.BCH": {
"len": 168393,
"sha256": "589c6a365b2899b019c4c856eed621b2adf51951c39e9d24454421be923910cc"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF45-8.BCH": {
"len": 167753,
"sha256": "fb1387ec0c26d0975b4ee2bd2948240c0fca6830d595435b034c87ecd5619297"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG13P-8.BCH": {
"len": 167761,
"sha256": "2d7cc9dce5da0039c1cdb9808817dd6eb8a519832d8a0a32f99cbc0d82a2ab45"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG17-8.BCH": {
"len": 167705,
"sha256": "63e521cddb82620b4b9866f2417c20126c810576ae61f875b7b8dd12f0d35d5c"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG20-8.BCH": {
"len": 158456,
"sha256": "0c616486aaa06dd4d728cab520b545ee00ab10774b041a8ad26bcecb6091e9fd"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DMR120-8.BCH": {
"len": 167930,
"sha256": "e9e0c6fce89307a392899516243993038e3429f3b48eb38ede3f87ebefb0630f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM25FFL-8.BCH": {
"len": 169090,
"sha256": "7a29bb64a89f5c987b7991d1f6697a29bf81f997d027d2c85d0d3fe61fca6c3b"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM25FFL-8_WG.BCH": {
"len": 169024,
"sha256": "ff3769dc79fc4786beb1ee6769cb0ce11a3cff3defab1b9e053c5bc070beeb5e"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM50FFL-8.BCH": {
"len": 167782,
"sha256": "8a6e5a6fb43635e5f8f83002c931a1d6d4a9604b82b0736db94631c013661f63"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DT94-4.BCH": {
"len": 160517,
"sha256": "6667fef358fee4126876a61ab4f07e495f0470c675e2d6794b5dd89e9291b02d"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DT94-8.BCH": {
"len": 160866,
"sha256": "49125ad11ac0c6c8dde04c19fb3af43cc7c0eb98fb2e6717016b99839ee1a0ad"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DTW72-8.BCH": {
"len": 168472,
"sha256": "eaefc3e044514529b0d672df913a60e613e8882c4c2f1f5fe2e55b226dafe5eb"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DTW95FFL-8.BCH": {
"len": 168777,
"sha256": "4ed1a4afbf547551b0907d2c85afc4e9cce57c450f6f615803c1a6d5214035ac"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DX10-4.BCH": {
"len": 167756,
"sha256": "ecc54f732bf04757f58f1a2fa2875e0f034138eb9c67e2274ad236226f1e61d0"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DX13-4.BCH": {
"len": 168631,
"sha256": "d5c9ca84e49f26e021bfb7166163ceb23ec392f3be7b96a625c9469f3f839253"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10-4.BCH": {
"len": 167556,
"sha256": "88630ad18068860767b5c7a3d92d806c10bebc879f6a963b8a571a5651ae2206"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10-8.BCH": {
"len": 167133,
"sha256": "bfd49bdc06e1149dae80c7e0f70273ceb4755fcc82690dc487496858cf54c411"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HM-4.BCH": {
"len": 168758,
"sha256": "143dc6f5e8615a77cb88dba96a21fdddb9a3eb9f31d5064681111a127d2d504c"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HM-8.BCH": {
"len": 167612,
"sha256": "a805602adc1fedc1650dbea4380016e155e773b752ab7b88921e7eb957aae1aa"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HMP-4.BCH": {
"len": 169371,
"sha256": "a29084a06ba41dbe58261751922e81e9f988f77d56223488b8e67d188d8d1117"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR12-4.BCH": {
"len": 169156,
"sha256": "3aa62ed143eef3e2e50f743945bc69dfb92473f991a88e294117d87b88a8c2b0"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR12-8.BCH": {
"len": 168523,
"sha256": "e3766981e2a15f50bc108204e5a0065a070986ac48b20092d14bcc794d1cb62a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR58-4.BCH": {
"len": 168008,
"sha256": "9c9e7902e25f798da0cff00f0906c0fc9ddb8b459afa3fc53496de4c35078d76"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR58-8.BCH": {
"len": 167844,
"sha256": "37c87bec9e31d05a738533d8ab877446d26aafb0a0d567325075fe9595cd70df"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8JS-8.BCH": {
"len": 167871,
"sha256": "f76ffb278e5a76732ee3d255b870e66a1cb806ebbb1330736df8718fe5b4b30c"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8WP-4.BCH": {
"len": 168277,
"sha256": "e14621eb94f90aa72eea47a8c084f1a96839ec600e99c585fd1a1f519f3db97f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8WP-8.BCH": {
"len": 168292,
"sha256": "c9ba868f0ec01edf84d6d5488733b02f2f50853491d58068b75d7814e33980a1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR9.15-4_horizontal.BCH": {
"len": 169606,
"sha256": "914cc40a0f02b1efa87a9e63f68690067b5512fdb71b1e69884a122f218fbdec"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR9.15-4_vertikal.BCH": {
"len": 170049,
"sha256": "a88a684ce8a7f10aed0f72153c7f960bf06330331795eed68594b9915b55c00f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS5X-8.BCH": {
"len": 166753,
"sha256": "a523d440bab1a96de5a96cee96c52f7c81bcf2c5b0a49de1ff7f8a8d97a08a5a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7-4.BCH": {
"len": 167982,
"sha256": "2e3c1c49436cf6b9f4a00c966ec6ef25301614cff448c5540ba72275f7b1e81d"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7-8.BCH": {
"len": 168154,
"sha256": "3d119358c324194760264e0c26d6aec8c15d70fdb2b256456576c9d81bc22d49"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7S-8.BCH": {
"len": 167984,
"sha256": "0c387f6e5147f04add4aebdafa97da2e754f3da07782f0aad0179c15603797f4"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7W-8.BCH": {
"len": 168125,
"sha256": "520ab27b5bb120dde5a7a44f59e6d449996cb2c534b41332271847f781364785"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7XWP-8.BCH": {
"len": 159786,
"sha256": "38b4433cf3cb7e09fd971ab8b56d8f067fc2997a973829bd57a72aa1f9000b06"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8-4.BCH": {
"len": 167487,
"sha256": "5dd7e15bda43e727d8bcb34dbdbbdebdfd797b487af9b62d40440416537744fe"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8-8.BCH": {
"len": 167191,
"sha256": "798183c313ef04fa9d0e2403c4131b0ba41fe2b704a99c24257c9ad9be1b27dc"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8M-8.BCH": {
"len": 167527,
"sha256": "0382528f2a44afdfe60fcbff1367b585a96a90bd9cab999e1e40dfd45051f3c5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS4ND-8.BCH": {
"len": 168207,
"sha256": "63d9db35ef1e74ca0f080ef6c1a0b28d395aa971420bcb175c736e8b412849dc"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5-4.BCH": {
"len": 167564,
"sha256": "6c74e49fe8e9d6bf4f6fc225fafd1a7fcd429a6d35a9b6fdf4f37bd7fb585dfb"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5-8.BCH": {
"len": 167743,
"sha256": "f8ef5c31c3283c3653f3edd146b5bb649d476beb906b9622283da97167730200"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5SC-8.BCH": {
"len": 167460,
"sha256": "b07b81fed5818d2aa11b4364bd14e24b4f1b0caa6b661185a106abaea552a102"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX10-4.BCH": {
"len": 167699,
"sha256": "4f70649c267f6d824f710c901daff648da072028a354966b9fc95cfe001ee467"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX13-4.BCH": {
"len": 168491,
"sha256": "85c4ad73a053ff58676ffe31e5450bd9b2281d9910e05eeb71385e43df80d637"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX16-4.BCH": {
"len": 168407,
"sha256": "df02baa2f1688ceddc5192da8546ddfac2b5746a7b1a459e0f6cc1c176d47886"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX16WP-4.BCH": {
"len": 168916,
"sha256": "f6c8d03f26a5f942dce524ddaf11931ba10e1436c26bf978dd6e04a235cbc3ed"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G20SC-8.BCH": {
"len": 168972,
"sha256": "549ea20a17763d75052ad872b3e650639c56d49597f2c9f6fca747faeadcbab5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25FFL-8.BCH": {
"len": 169098,
"sha256": "7004466c21b4a7e438d09e4b8373c20f58250adbf4e68c1998365151ba46fef4"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25FFL-8_WG.BCH": {
"len": 169005,
"sha256": "85d51691fb41410442c60efc9782c0b80061279f8571fafcf88129591b2715c1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25NDWG-8.BCH": {
"len": 160482,
"sha256": "743de0c456c272620a8d5fcfe83e51a735f8ed88efa9681b95d1c4f4dc427abc"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G50FFL-8.BCH": {
"len": 168413,
"sha256": "951e5a3bcce8bb0453cf0e3a8f58a9c89fa690fb99fc00bb5de949317d2bcdd5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G50FFL-8_WG.BCH": {
"len": 168650,
"sha256": "b3010ea9780428570922a513749110bfa83953cc7aa3c2b24abd503645aa66d4"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-2.BCH": {
"len": 166568,
"sha256": "13aa73962985e626969e37159c9a08f600b41fcad91ab1e6ad52584fe2b8b811"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-4.BCH": {
"len": 165632,
"sha256": "90a3164b9a602cebdab476370101146d6d058fd39c58bfa958159831cddf105e"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-8.BCH": {
"len": 166762,
"sha256": "be26e8616356d2bf9bbb5418383efaf3bf3aa8d6883ee3e0fe0eadd5d1dca03a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HT21_DR45-8_horizontal.BCH": {
"len": 169380,
"sha256": "57e5bede970aa72e4f524cc66c4d6e7fae8f549cf1487f31b21b60dae7b2d781"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HT21_DR45-8_vertikal.BCH": {
"len": 169432,
"sha256": "3239c9fad549ed904f8b3df174e6a21325d5b0ace33e27fffcc001107358ca1e"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HTH8.7-8_horizontal.BCH": {
"len": 169140,
"sha256": "03e2a4e185191161987279d6aefb6d396a11acaef781f3dc4a3b766447b1ef1b"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HTH8.7-8_vertikal.BCH": {
"len": 169142,
"sha256": "dd51cbef66b6c5a848d1dc777ca4805d32f544ad89c7f53aa5bcbaf59f226fbb"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HX10-4.BCH": {
"len": 167294,
"sha256": "997a8f9ba8763e9309d2cb640e21d923a9344d29be263f2be1d1a1eed875f6f1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KE25SC-8.BCH": {
"len": 169009,
"sha256": "3c0a8a5ef64bf27a102c8dd09c0d87b456c8fdac006a6095d1304f7a1edd7184"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KE25SC-8_WG.BCH": {
"len": 169097,
"sha256": "81d987a1a2bf51ddf463d8422ce7881dc57b0cd7e0bd20fbd3e4f376abd69696"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KT100V-4.BCH": {
"len": 167368,
"sha256": "82c4f9d62fe3114c4280a6cd63343833adbff29f398a9f94485fd0f763dcec03"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/MHT12-8_horizontal.BCH": {
"len": 169158,
"sha256": "eea5c3d1c7d8c12ee26bf2f0e4e93d7bb9a57c80ded560cf356ba22ad1168605"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/MHT12-8_vertikal.BCH": {
"len": 169757,
"sha256": "b294b6988b5745efa71547307690c8f1d3e1316c2b1184819fe24d0baf28fb35"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAM13-8.BCH": {
"len": 167870,
"sha256": "aab4c8c1e559807b96c8a53d04091c0296dc9c5ba8877f56af066dcdcdcabd31"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAW25-8.BCH": {
"len": 168066,
"sha256": "b594a2ea9297fc74cca7f22705ece652fa37565fa8ae885d493147dfb5be88a6"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAW30ND-8.BCH": {
"len": 167976,
"sha256": "1e0bc20347273e08f6c6c29139aa7921214b175b42f89c6a0b8ab65893b54676"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/R10S-4.BCH": {
"len": 170298,
"sha256": "121c01119b6aeb20464c09a6f0cdc8e4cdb17562ef1b86d850bdcbe665ad3176"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/R10S-8.BCH": {
"len": 168270,
"sha256": "fc6019cfb41cf4acee2e79f30503734abc4d0ae023f0ddc31d41f15c53ea3d53"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC10N-8.BCH": {
"len": 169400,
"sha256": "9f5d425b353efbea1d3fba20f0bc18ec889b3c8164a9a7d15e6a7574a55cada8"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC13-8.BCH": {
"len": 167377,
"sha256": "691a2b026ba63f9374e852cac955a91229cc6817912a4b1c366c1f6492b7b329"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC4.6FL-8_horizontal.BCH": {
"len": 166596,
"sha256": "a80c5f54e0076c90b4caac9260b5aee77c8ee339d807963685d09c735b4eae6a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC4.6FL-8_vertikal.BCH": {
"len": 165391,
"sha256": "5e307e4738b21a52de10540d5fde9a1cbd22b272cb1a41751df82489eab5285a"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC5-8.BCH": {
"len": 168689,
"sha256": "de0d2a411130436e596ff2c1ad97bfa679ba92e7e2b166cb71329ac5768b93e4"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC8N-8.BCH": {
"len": 167245,
"sha256": "43fc053d9f583e2282660eec169f066ff4f92c904b46b5aba330d50d0cfe7d55"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL70NDV-4.BCH": {
"len": 166825,
"sha256": "5277f8c2712d4a5fe57b0b64dfdfe73a616932b57e58d1aed8ef77bade362555"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL713-4_horizontal.BCH": {
"len": 168817,
"sha256": "cfc643055f62e0a836e0f67720610663d32f6e3aba3c2c3bd0f14b86e3a3e6f8"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL713-4_vertikal.BCH": {
"len": 168657,
"sha256": "26cc25623bbeb0b022785a190dfde7bcbdd0785a5a41f363a4599d79cf31ef10"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL83-4.BCH": {
"len": 168108,
"sha256": "a35ff412b172c7ae23489ded7e12f2146c33bb3126d9a90a6ecf6c81c65f1ff1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87FE-8.BCH": {
"len": 167737,
"sha256": "115d35c6aa8fc19d52a146dec2810e524a1580c8b31f46f27ad8ab339ea6eee6"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87FS-8.BCH": {
"len": 159847,
"sha256": "ce73e4b9e8f98efe44d85bf9c67a63cb65546c88b1ee4e4a5ccf5af9758208ac"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87ND-8.BCH": {
"len": 166764,
"sha256": "1a2fa67bcd7cdddbfdeb30fd4b4f36d49ea11b44b52d195833cc9a9b9c490d1c"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TI100-8.BCH": {
"len": 167102,
"sha256": "c9ef7266472e0c0a75de8fa05173ff8e6004f0e83f044557243b867af70d7558"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW200XS-8.BCH": {
"len": 167275,
"sha256": "a57a4bfc5aa1185b758ba182da2417f98030d1f16ac658a84073e1894e270ff5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW250XS-8.BCH": {
"len": 167470,
"sha256": "e0acbe4e8a520e4cc3339bdbf8b9ee071e4580ecd575d03043f80bee7fe7fe24"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW300-8.BCH": {
"len": 167601,
"sha256": "1f04190ed3fa3010d45f6f00366bdd55ff94e357ab8a031bd264503186412440"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TW70-8.BCH": {
"len": 170337,
"sha256": "19083805ea97002c817871111cba605bc6b841bffba4373af2dc225b8ac2330e"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100S-4.BCH": {
"len": 166693,
"sha256": "e267b8524a33c50eb485d46c0f2606908c1c2181172841d0b7902792722054b2"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100S-8.BCH": {
"len": 166742,
"sha256": "bb509e566b55b46c1030b305fc6eeab22a52cca5323f94d45e9b92eb9b3d7e77"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-2.BCH": {
"len": 168225,
"sha256": "c4ecd29f912e680977eeb4ff8d786ab4ce1bc3b7cdbf5e68fbd32df5354e0c54"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-4.BCH": {
"len": 167513,
"sha256": "6b7eee3b7ca9e91e407409a9822a6706c13c375a0a423ba084f955fe0b6d39d0"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-8.BCH": {
"len": 168268,
"sha256": "3377174cf9e99fa61d30307fa3f54c99e9dd18c10143abae90d4ef316083dbd3"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130S-4.BCH": {
"len": 166620,
"sha256": "a4c52c72fac5c9ecffb0db7d4aa54ca82ec0d8cb6cd4bf9f3542401b277d2dcf"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130S-8.BCH": {
"len": 166750,
"sha256": "6ed33ac44bbf9046eddd59ba7b54c3a3785d09244648555ae847683982c6909f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-2.BCH": {
"len": 167957,
"sha256": "0683f476f4eea32bc73351045ee3e221053ad197297cdd3bc4a2843b128267c1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-4.BCH": {
"len": 167030,
"sha256": "53ae4cffdf3ed2bf4cb19fadd22ba8dd8762c1063dbf7818a109add348506cf3"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-8.BCH": {
"len": 167958,
"sha256": "2127c7eed9b6aa116ea2b5e3ddea0830e7816a6f61b66acd89b3912958fb594d"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W150X-4.BCH": {
"len": 169571,
"sha256": "8bc70cb3b380ab4d9bafefa6fbe72ebfb4c64b872dec02fc776f45194306e8d2"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W165A-2.BCH": {
"len": 167502,
"sha256": "660ad2655067e765d00ad735ae5a53a2b4b8eeda3934b9484361e04653c30053"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170-8.BCH": {
"len": 166591,
"sha256": "ff5564f0a05f880267ca2d528f8d9534bb90629139d27d56a9125f207a07acaf"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170S-4.BCH": {
"len": 166045,
"sha256": "83ae8d8d5c2f063b358ea4d2b7f60cc24c18af6d9a004d42266d2f1a2ac15846"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170S-8.BCH": {
"len": 166277,
"sha256": "63606eca63bf6cff1df12b72bb01add86b20ec1d958370173f00916dfe671d47"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200-4.BCH": {
"len": 167312,
"sha256": "2d2f991f058c141442a3bade8e849c63571ce5d965d4e4b5e1cd18dd6a57c0d5"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200-8.BCH": {
"len": 166917,
"sha256": "02fbe70c17a3d142c36253fe75647f2514c84a85ef8b943029b0549163b02cc1"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200S-4.BCH": {
"len": 167865,
"sha256": "1d18f05ec042c96834da5d8c8da5d778d1a90577ff3aeb5c5ec1ebf98f2367a7"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200S-8.BCH": {
"len": 167988,
"sha256": "acc138abeb612ba105eb1918caceec5db809dd14d1b63461f3b7b3c84bc0c555"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250-8.BCH": {
"len": 167424,
"sha256": "b91cdde6d85ded8133469e292e29ea3149375623ce2b8a89b5ce85d8f444c59f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250S-4.BCH": {
"len": 167964,
"sha256": "f5f1a678216f26c872668de068902e979bc3fc45ef14060760f74600f6976d4d"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250S-8.BCH": {
"len": 167668,
"sha256": "0ff699238b80b2a129ca44f0c97925b0b553250a1cb0d234c298358fb097df31"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W300-8.BCH": {
"len": 167900,
"sha256": "d0e9afe82f3124fa5e11325d6a7a8c4fb2970c0d64e7677a70412668e39cd791"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WF130ND-8.BCH": {
"len": 166121,
"sha256": "95bf3e21b4af281133f63c6ddbd305ddcdbc022bcedc495a974caa388c2bc7e6"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS13E-8.BCH": {
"len": 167806,
"sha256": "afe345e6be400e9780b5ceb9c3a90c4f4f095ea61196e10a45ce2b2072344c57"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS17E-4.BCH": {
"len": 167482,
"sha256": "55817cab04efd7582edcda75c17bdb1288e94ce8b591359393b19df84a4048f2"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS17E-8.BCH": {
"len": 167230,
"sha256": "a5a5f0ffd1bf9120fdf2de0033f5d07dc6aaf213fcc0296d81b627093e8873ea"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS20E-4.BCH": {
"len": 167030,
"sha256": "775e9162ce2d763b72ea53037541b89fdce980583836baa9e023937567595137"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS20E-8.BCH": {
"len": 167098,
"sha256": "658cc7a40aeaa3154bbcba033bf41076d14d014673528745d3324c3fe734833f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS25E-8.BCH": {
"len": 166786,
"sha256": "e99a0c69b3c590f276f165d2b45a79cb7d9a204045a0904ebe665beb3a568d21"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/DMR120-8.BCH": {
"len": 167930,
"sha256": "e9e0c6fce89307a392899516243993038e3429f3b48eb38ede3f87ebefb0630f"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/DTW95FFL-8.BCH": {
"len": 168777,
"sha256": "4ed1a4afbf547551b0907d2c85afc4e9cce57c450f6f615803c1a6d5214035ac"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/WS26SF-8.BCH": {
"len": 167601,
"sha256": "c8991c5973e0afa0298e3b6e5256fa72d6d56fca202d343c60376117c36872ff"
},
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Projekte/Default.BPJ": {
"len": 509573,
"sha256": "d687252660aeabff3be9203ebdc35cf35087266eda87eb3aa1c23b306983e552"
}
},
"note": "oracle digests from boxsim._oracle, see tools/difftest.py",
"rejected_not_myini2ck": [
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL130-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL130M-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL170-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL200-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL200DV-2x8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B100-6.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B200-6.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B80-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF32-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF37-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF45-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG13-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG13P-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG17-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG20-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG30NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BGS40-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BZ130P-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BZ300-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CF25-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 10 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 10 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 12 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 12 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CP13-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CT25A-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DHT9-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DHT9-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DK121-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DK8P-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL10-20.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL10-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL18-100V.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL18_2-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL5-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL8-20.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL8-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DMR120-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DMR35-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DR45N+M300-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM25FFL-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM38FFL-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM50FFL-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT90-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT90-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT94-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT94-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 104 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 110 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 110 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 SC - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 SC - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 78 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 78 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 SC - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 SC - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 87.130 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 87.130 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW110NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW72-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW72-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW8.12-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW8.12NG-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW95NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW97-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX10-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX13-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX16-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX4x6P-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX6x9-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/F8SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/F8SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10F-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10HM-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10HM-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR12-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR12-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR16WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR58-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR58-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR6.5-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR7-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR87-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8JS-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8WP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR9.15-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS10WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS10WP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5X-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5XWP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7W-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8M-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FX10-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G20SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25FFL-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25FFL-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25HE-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25ND-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G50FFL-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G50FFL-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-2.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-2_ 2Spulen_parallel.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4_1Spule_Kurzschluss.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4_1Spule_offen.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-2.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/H11-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 11.19 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 11.19 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 A - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 A - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTH8.7-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HX10-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50WP-50.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50WP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K57C-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K64WP-50.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K64WP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/KE25SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/KT100V-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 13 HE - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 13 HE - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 16 HE - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 16 HE - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 X - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 X - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/M10-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/M10-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MHT12-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MR130-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MR130-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW25-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW30ND-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW38-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW46-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC10N-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC13-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4.7ND-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4.7ND-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4ND-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9ND-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9ND-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC8-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC8N-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL713-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87ND-8.bch",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87WP-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87WP-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T130-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T200-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T250-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T300-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TI100-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TI100M-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW200XS-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW250-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW250XS-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW300-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-2.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360X-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-2.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL10B50-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL14CH.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL15D61-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL16H-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL4Falnico-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL8C50F-6.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TW6NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TW70-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-2.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200-4.bch",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200-8.bch",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200NG-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200SC-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200SC-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250NG-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300NG-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300NG-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W400S-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W400S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WF130ND-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 25 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 25 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 X - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 X - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WPC 30 - 4 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WPC 30 - 8 Ohm.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13BF-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13E-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13E-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17BF-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17E-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17E-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS20E-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS20E-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS25E-4.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS25E-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WSP21S-8.BCH",
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WSP26S-8.BCH"
]
}
+107
View File
@@ -0,0 +1,107 @@
"""End to end: generate a project, then check KiCad agrees with Boxsim.
The netlist comparison is the real gate, so it gets its own teeth test: a
deliberately mis-wired schematic must be reported as a mismatch. A gate that
has never been shown to fail is decoration.
"""
from __future__ import annotations
import pytest
from boxsim import project as project_mod
from boxsim import verify
from boxsim.schematic import DEFAULT_PITCH_MM, SchematicBuilder, transform
def test_rotation_transform_matches_kicad():
# Derived empirically from KiCad's own demo schematics.
assert transform(0, 3.81, 2.54) == (3.81, -2.54)
assert transform(90, 3.81, 2.54) == (-2.54, -3.81)
assert transform(180, 3.81, 2.54) == (-3.81, 2.54)
assert transform(270, 3.81, 2.54) == (2.54, 3.81)
def test_pitch_must_clear_widest_symbol(library):
with pytest.raises(ValueError, match="widest symbol"):
SchematicBuilder(library, pitch=20.0)
SchematicBuilder(library, pitch=DEFAULT_PITCH_MM) # must not raise
@pytest.fixture(scope="module")
def built(tmp_path_factory, default_project, catalog, library):
outdir = tmp_path_factory.mktemp("kicad")
return project_mod.build(default_project, "Default", outdir, catalog, library)
def test_project_files_written(built):
assert built.schematic.exists()
assert built.project_file.exists()
assert (built.directory / "sym-lib-table").exists()
assert (built.directory / "fp-lib-table").exists()
text = built.schematic.read_text(encoding="utf-8")
assert text.startswith("(kicad_sch")
assert "(version 20260101)" in text
assert "extends" not in text, "derived symbols must be flattened"
def test_all_components_placed(built, default_project):
assert len(built.sheet.placements) == len(default_project.crossovers[0].components)
refdes = [p.refdes for p in built.sheet.placements]
assert len(set(refdes)) == len(refdes), "reference designators must be unique"
assert sum(1 for r in refdes if r.startswith("LS")) == 3
assert sum(1 for r in refdes if r.startswith("J")) == 1
def test_output_is_deterministic(tmp_path, default_project, catalog, library):
a = project_mod.build(default_project, "Default", tmp_path / "a", catalog, library)
b = project_mod.build(default_project, "Default", tmp_path / "b", catalog, library)
assert a.schematic.read_text() == b.schematic.read_text(), (
"UUIDs must be derived, not random, so regeneration is reproducible"
)
@pytest.mark.needs_kicad
def test_gate_passes(built, default_project, kicad_cli):
gate = verify.run_gate(
built.schematic, default_project.crossovers[0], built.sheet.placements
)
assert gate.erc_ok, gate.messages
assert gate.netlist_matches, gate.messages
assert len(gate.reference_nets) == 14
@pytest.mark.needs_kicad
def test_gate_has_teeth(tmp_path, default_project, catalog, library, kicad_cli):
"""Swap one component's terminals and confirm the gate reports it.
The reference netlist is derived from the Boxsim grid while the compared one
is extracted from the sheet by KiCad, so mis-wiring a single part must break
the comparison. If this ever passes, the gate proves nothing.
"""
result = project_mod.build(
default_project, "Broken", tmp_path / "broken", catalog, library
)
crossover = default_project.crossovers[0]
# Pick a component whose two terminals are on different nets, then lie about
# which grid node one of its pins reaches.
netlist = crossover.build_netlist()
victim = next(
p
for p in result.sheet.placements
if len({netlist.net_of(n) for n in p.pin_nodes.values()}) == 2
)
pins = sorted(victim.pin_nodes)
victim.pin_nodes[pins[0]], victim.pin_nodes[pins[1]] = (
victim.pin_nodes[pins[1]],
victim.pin_nodes[pins[0]],
)
gate = verify.run_gate(result.schematic, crossover, result.sheet.placements)
assert not gate.netlist_matches, (
"a swapped terminal was not detected: the netlist comparison is not "
"actually comparing anything"
)
assert not gate.ok
assert any("mismatch" in m for m in gate.messages)
+79
View File
@@ -0,0 +1,79 @@
"""Codec tests. The heavy differential sweep against Boxsim.exe lives in
tools/difftest.py; these guard regressions against the digests it recorded.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from boxsim import a2f, container
from boxsim.krypt import KryptPRNG
ROOT = Path(__file__).resolve().parent.parent
def test_keystream_is_stable():
"""Regression guard on the keystream every Boxsim file is scrambled with.
The value is not independently derived, it is the keystream under which all
134 containers in the distribution decode byte-identically to Boxsim's own
decoder (see tools/difftest.py). Its worth here is catching an accidental
change to the generator, not proving the generator correct.
"""
ks = KryptPRNG().keystream(8)
assert ks.hex() == "ebe14331cb3183e1"
def test_rejects_foreign_file():
with pytest.raises(container.ContainerError, match="not a MyIni2CK file"):
container.decode(b"not a boxsim file at all")
def test_truncated_stream_fails_loudly():
raw = (ROOT / "boxsim_dl" / "BoxSim 2.1 DE (Stand 12.2025)"
/ "Projekte" / "Default.BPJ")
if not raw.exists():
pytest.skip("distribution not present")
data = raw.read_bytes()
with pytest.raises(a2f.A2FError):
container.decode(data[: len(data) // 2])
def test_padding_allowance_is_bounded():
# A stream of zeros must not decode forever; it has to hit the limit.
with pytest.raises(a2f.A2FError):
a2f.expand(b"MyIni2CK", skip_magic=8)
@pytest.mark.needs_dist
def test_matches_oracle_digests(manifest: dict):
"""Every container the oracle decoded must still decode identically."""
files = manifest["files"]
assert files, "manifest has no entries"
checked = 0
for rel, expected in files.items():
path = ROOT / rel
if not path.exists():
pytest.fail(f"manifest references missing file {rel}")
payload = container.decode(path.read_bytes())
digest = hashlib.sha256(payload).hexdigest()
assert digest == expected["sha256"], f"{rel} digest changed"
assert len(payload) == expected["len"], f"{rel} length changed"
checked += 1
assert checked == len(files)
@pytest.mark.needs_dist
def test_pre20_containers_are_rejected(manifest: dict):
"""The BoxSim 1.2 files use an older container and must stay rejected."""
rejected = manifest["rejected_not_myini2ck"]
assert rejected, "manifest recorded no rejected files"
for rel in rejected:
path = ROOT / rel
if not path.exists():
continue
with pytest.raises(container.ContainerError):
container.decode(path.read_bytes())
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import pytest
from boxsim import model
from boxsim.model import COLS, LINK_BASE, ROWS, TOTAL_SLOTS, Orientation, PartType
def test_slot_partition_is_exact():
"""The three slot ranges must tile 1..375 with no gap or overlap."""
seen = {}
for slot in range(1, TOTAL_SLOTS + 1):
orientation, row, col = model.slot_geometry(slot)
if orientation == Orientation.LINK:
continue
key = (orientation, row, col)
assert key not in seen, f"slot {slot} collides with {seen[key]}"
seen[key] = slot
horizontal = sum(1 for k in seen if k[0] == Orientation.HORIZONTAL)
vertical = sum(1 for k in seen if k[0] == Orientation.VERTICAL)
assert horizontal == (COLS - 1) * ROWS == 180
assert vertical == COLS * (ROWS - 1) == 187
assert LINK_BASE == 368 and TOTAL_SLOTS == 375
def test_slot_geometry_bounds():
with pytest.raises(model.ModelError):
model.slot_geometry(0)
with pytest.raises(model.ModelError):
model.slot_geometry(TOTAL_SLOTS + 1)
def test_slot_endpoints_are_adjacent_nodes():
orientation, row, col = model.slot_geometry(1)
assert (orientation, row, col) == (Orientation.HORIZONTAL, 0, 0)
orientation, row, col = model.slot_geometry(181)
assert (orientation, row, col) == (Orientation.VERTICAL, 0, 0)
# The last vertical slot must sit on the bottom-but-one row.
orientation, row, col = model.slot_geometry(367)
assert (orientation, row, col) == (Orientation.VERTICAL, ROWS - 2, COLS - 1)
def test_default_project_shape(default_project):
p = default_project
assert p.version == 2000
assert p.amp_count == 1
assert p.chassis_count == 3
assert [c.name for c in p.chassis] == [
"GF 200 - 8 Ohm",
"B 100 - 6 Ohm",
"G 20 SC - 8 Ohm",
]
def test_default_netlist_is_closed(default_project):
"""The published Default project must have no floating terminal."""
xo = default_project.crossovers[0]
netlist = xo.build_netlist()
assert len(xo.components) == 22
assert netlist.net_count == 14
assert netlist.dangling() == []
def test_drivers_and_amp_present(default_project):
xo = default_project.crossovers[0]
drivers = [p for p in xo.components if p.typ == PartType.DRIVER]
amps = [p for p in xo.components if p.typ == PartType.AMP_INPUT]
assert len(amps) == 1
assert sorted(d.chassis_index for d in drivers) == [0, 1, 2]
def test_bounding_box(default_project):
assert default_project.crossovers[0].bounding_box() == (0, 0, 8, 9)
def test_crossover_corner_frequencies(default_project):
"""Sanity-check the reconstruction against loudspeaker physics.
A wrong slot-to-node mapping would still produce a netlist, but not one
whose series/shunt pairs land on sensible crossover points.
"""
import math
xo = default_project.crossovers[0]
by_slot = {p.slot: p for p in xo.components}
# Woofer low-pass: 4.7 mH series with the 15 uF shunt.
fc = 1 / (2 * math.pi * math.sqrt(by_slot[5].value * by_slot[198].value))
assert 550 < fc < 650
# Tweeter high-pass: two series caps into the 0.22 mH shunt.
cser = 1 / (1 / by_slot[65].value + 1 / by_slot[67].value)
fc = 1 / (2 * math.pi * math.sqrt(by_slot[252].value * cser))
assert 5000 < fc < 6500
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import pytest
from boxsim import symbols
from boxsim.catalog import SERIES_TO_SYMBOL
from boxsim.model import Orientation, Part, PartType
def test_catalogue_shape(catalog):
"""Guard the figures the resolver design relies on."""
assert len(catalog) == 95
assert len(catalog.series_names) == 11
assert set(catalog.series_names) == set(SERIES_TO_SYMBOL)
# The last catalogue line is a real part; the file has no trailing newline
# and is easy to truncate by one.
assert catalog.find("SP 1,3mm", 2.2e-3) is not None
def test_every_catalogue_part_has_a_symbol(catalog, library):
extras = symbols.cross_check(catalog, library)
assert extras == ["MKT 22μF"], (
"library should contain exactly the catalogue plus Amplifier, Speaker "
"and the radial MKT 22uF that Boxsim cannot select"
)
def test_library_values_are_matched_numerically(library):
"""Irregular library names must still resolve: 1.0uF next to 10uF."""
assert library.lookup("MKP", 1e-6) is not None
assert library.lookup("MKP", 10e-6) is not None
assert library.lookup("SP-Spule", 1e-3, "0.6").name == "SP-Spule 1.0mH (0.6)"
assert library.lookup("LR-Spule", 10e-3).name == "LR-Spule 10mH"
def test_derived_symbols_resolve_geometry(library):
"""Most symbols use `extends`; pins live on the base symbol."""
from boxsim.schematic import symbol_pins
pins = symbol_pins(library, "SP-Spule 0.82mH (0.6)")
assert len(pins.by_number) == 2
assert library.base_of("SP-Spule 0.82mH (0.6)") == "FC-Spule 10mH"
def test_flattened_definition_has_no_extends(library):
body = library.definition("SP-Spule 0.82mH (0.6)")
assert "extends" not in body
assert '(symbol "SP-Spule 0.82mH (0.6)_' in body, "graphics must be inlined"
def test_default_project_fully_resolves(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
components = default_project.crossovers[0].components
results = resolver.resolve_all(components)
assert len(results) == len(components) == 22
assert all(r.symbol in library for r in results)
def test_empty_series_is_inferred_from_sibling(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
components = default_project.crossovers[0].components
results = {r.part.slot: r for r in resolver.resolve_all(components)}
part231 = results[231]
assert part231.inferred_series is True
assert part231.symbol == "MKT-A 15μF", "sibling Part198 is an MKT-A 15uF"
def test_dcr_deviation_is_reported_not_corrected(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
results = {
r.part.slot: r
for r in resolver.resolve_all(default_project.crossovers[0].components)
}
assert results[5].dcr_note is not None, "0.28 vs catalogue 0.33"
assert results[48].dcr_note is not None, "5 vs catalogue 4.8"
assert results[5].part.dcr == 0.28, "the project value must win"
def test_unstocked_value_is_a_hard_error(catalog, library):
resolver = symbols.Resolver(catalog, library)
part = Part(
slot=1,
typ=PartType.CAPACITOR,
orientation=Orientation.HORIZONTAL,
row=0,
col=0,
node_a=0,
node_b=1,
value=13e-6, # not a Visaton value
label="MKT-A",
)
with pytest.raises(symbols.SymbolError, match="not stocked"):
resolver.resolve(part)
def test_unknown_series_is_a_hard_error(catalog, library):
resolver = symbols.Resolver(catalog, library)
part = Part(
slot=2,
typ=PartType.CAPACITOR,
orientation=Orientation.HORIZONTAL,
row=0,
col=0,
node_a=0,
node_b=1,
value=10e-6,
label="Mundorf Supreme",
)
with pytest.raises(symbols.SymbolError, match="not in the Visaton catalogue"):
resolver.resolve(part)
def test_catalogue_shape_check_has_teeth(catalog):
"""A truncated catalogue must be rejected, not silently accepted.
`cross_check` only proves the catalogue is a subset of the library, so a
short catalogue passes it and then rejects valid projects at runtime with
"not stocked by Visaton". This is the check that catches it.
"""
from boxsim import catalog as catalog_mod
catalog_mod.check_shape(catalog) # the real thing must pass
short = catalog_mod.Catalog(catalog.parts[:40])
with pytest.raises(catalog_mod.CatalogError, match="expected"):
catalog_mod.check_shape(short)
# Losing a whole series must also be caught, even at the right total.
without_sp13 = catalog_mod.Catalog(
[p for p in catalog.parts if p.series != "SP 1,3mm"]
)
with pytest.raises(catalog_mod.CatalogError):
catalog_mod.check_shape(without_sp13)
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build the boxsim image, staging the Visaton library and parts catalogue.
#
# Both live outside this repository, so they are copied into a staging directory
# inside the build context first. Nothing proprietary is baked in: the Visaton
# library is our own, and the catalogue is a plain text parts list.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
LIB_DIR="${BOXSIM_KICAD_LIB_DIR:-/home/magdev/workspaces/other/kicad-visaton-lib}"
CATALOG="${BOXSIM_CATALOG:-$ROOT/boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Weichenbauteile/Visatonteile.txt}"
IMAGE="${IMAGE:-boxsim:0.1.0}"
SYM="$LIB_DIR/symbols/Visaton.kicad_sym"
FP="$LIB_DIR/footprints/Visaton.pretty"
# A missing input must stop the build, never silently produce an image without
# its library.
for path in "$SYM" "$FP" "$CATALOG"; do
if [[ ! -e "$path" ]]; then
echo "build-image: required input missing: $path" >&2
exit 1
fi
done
# The library must already be in the current KiCad format, otherwise the symbols
# cannot be embedded into a KiCad 10 schematic.
version="$(sed -n 's/.*(version \([0-9]\+\)).*/\1/p' "$SYM" | head -1)"
if [[ -z "$version" || "$version" -lt 20211014 ]]; then
echo "build-image: $SYM is in the legacy format (version ${version:-none})." >&2
echo " Run: kicad-cli sym upgrade --force '$SYM'" >&2
exit 1
fi
rm -rf stage
mkdir -p stage
cp "$SYM" stage/Visaton.kicad_sym
cp "$CATALOG" stage/Visatonteile.txt
cp -r "$FP" stage/footprints
echo "staged: symbol library (version $version), catalogue, $(ls stage/footprints | wc -l) footprints"
docker build -t "$IMAGE" .
rm -rf stage
echo "built $IMAGE"
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Differentially test boxsim.a2f against the Boxsim.exe oracle.
Every container in the Boxsim distribution is decoded twice, once by the pure
Python codec and once by emulating Boxsim's own machine code, and the results
must be byte identical. Writes the oracle digests to a manifest so the fast
test suite can guard regressions without needing Unicorn or Boxsim.exe.
Needs the dev venv (unicorn, pefile):
.venv/bin/python tools/difftest.py [--jobs N] [--limit N]
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
MANIFEST = ROOT / "tests" / "oracle_manifest.json"
def find_containers(dist: Path) -> list[Path]:
files = sorted(
p
for p in dist.rglob("*")
if p.is_file() and p.suffix.upper() in (".BPJ", ".BCH")
)
if not files:
raise SystemExit(f"no .bpj/.bch files found under {dist}")
return files
def check_one(path_str: str) -> dict:
"""Decode with both implementations and compare. Returns a result record."""
from boxsim import _oracle
from boxsim.container import decode
path = Path(path_str)
raw = path.read_bytes()
rec = {"file": str(path.relative_to(ROOT)), "size": len(raw)}
try:
mine = decode(raw)
rec["sha_python"] = hashlib.sha256(mine).hexdigest()
rec["len_python"] = len(mine)
except Exception as exc: # noqa: BLE001 - report, do not mask
rec["error_python"] = f"{type(exc).__name__}: {exc}"
try:
theirs = _oracle.decode_bpj(raw)
rec["sha_oracle"] = hashlib.sha256(theirs).hexdigest()
rec["len_oracle"] = len(theirs)
except Exception as exc: # noqa: BLE001
rec["error_oracle"] = f"{type(exc).__name__}: {exc}"
both_failed = "error_python" in rec and "error_oracle" in rec
if both_failed and "not a MyIni2CK file" in rec["error_python"]:
# Pre-2.0 Boxsim used a different container with no MyIni2CK magic.
# Both implementations refusing it is agreement, not a pass: count it
# separately so it can never masquerade as coverage.
rec["verdict"] = "rejected"
elif both_failed:
rec["verdict"] = "both-errored"
elif "error_python" in rec or "error_oracle" in rec:
rec["verdict"] = "one-errored"
elif rec["sha_python"] == rec["sha_oracle"]:
rec["verdict"] = "identical"
else:
rec["verdict"] = "mismatch"
rec["ok"] = rec["verdict"] in ("identical", "rejected")
return rec
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--dist",
default=str(ROOT / "boxsim_dl"),
help="Boxsim distribution directory to sweep",
)
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 2) - 1))
ap.add_argument("--limit", type=int, default=0, help="only the first N files")
ap.add_argument(
"--self-test",
action="store_true",
help="corrupt one expectation to prove the comparison has teeth",
)
args = ap.parse_args()
files = find_containers(Path(args.dist))
if args.limit:
files = files[: args.limit]
print(f"{len(files)} containers, {args.jobs} workers", flush=True)
started = time.monotonic()
results = []
with ProcessPoolExecutor(max_workers=args.jobs) as pool:
for i, rec in enumerate(pool.map(check_one, [str(f) for f in files]), 1):
results.append(rec)
print(f" [{i}/{len(files)}] {rec['verdict']:10s} {rec['file']}", flush=True)
if args.self_test:
# Feed the comparison a deliberately wrong expectation. If this does not
# get reported as a mismatch, the gate is decorative.
print("\nself-test: corrupting one expectation", flush=True)
decoded = [r for r in results if r["verdict"] == "identical"]
if not decoded:
print("SELF-TEST INCONCLUSIVE: no decoded file to corrupt")
return 2
poisoned = dict(decoded[0])
poisoned["sha_oracle"] = "0" * 64
if poisoned["sha_python"] == poisoned["sha_oracle"]:
print("SELF-TEST FAILED: corrupt expectation still compared equal")
return 2
print(
f"self-test passed: a corrupt digest for {poisoned['file']} "
"is reported as a mismatch"
)
failures = [r for r in results if not r["ok"]]
elapsed = time.monotonic() - started
counts: dict[str, int] = {}
for r in results:
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
print(f"\nverdicts after {elapsed:.0f}s: " + ", ".join(
f"{v}={n}" for v, n in sorted(counts.items())))
print(
f"{counts.get('identical', 0)} byte-identical, "
f"{counts.get('rejected', 0)} rejected by both as pre-2.0 containers"
)
if failures:
print("\nfailures:")
for r in failures:
print(f" {r['file']}")
for key in ("error_python", "error_oracle"):
if key in r:
print(f" {key}: {r[key]}")
if "sha_python" in r and "sha_oracle" in r:
print(f" python {r['sha_python'][:16]} len {r.get('len_python')}")
print(f" oracle {r['sha_oracle'][:16]} len {r.get('len_oracle')}")
return 1
decoded = {
r["file"]: {"sha256": r["sha_oracle"], "len": r["len_oracle"]}
for r in results
if r["verdict"] == "identical"
}
rejected = sorted(r["file"] for r in results if r["verdict"] == "rejected")
MANIFEST.parent.mkdir(exist_ok=True)
MANIFEST.write_text(
json.dumps(
{
"note": "oracle digests from boxsim._oracle, see tools/difftest.py",
"files": decoded,
"rejected_not_myini2ck": rejected,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
print(
f"wrote {MANIFEST.relative_to(ROOT)}: "
f"{len(decoded)} digests, {len(rejected)} rejected files recorded"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Post-install check for the bundled catalogue and symbol library.
Run during the image build so an image can never ship with an incomplete
library or a truncated catalogue. `cross_check` alone is not enough: it proves
only that the catalogue is a subset of the library, so a short catalogue passes
it and then rejects perfectly valid projects at runtime with "not stocked by
Visaton". `check_shape` is what gives this gate teeth.
"""
from __future__ import annotations
import sys
from boxsim import catalog as catalog_mod
from boxsim import symbols as symbols_mod
def main() -> int:
cat = catalog_mod.load()
catalog_mod.check_shape(cat)
lib = symbols_mod.load()
extras = symbols_mod.cross_check(cat, lib)
print(
f"bundled {len(cat)} catalogue parts across "
f"{len(cat.series_names)} series, {len(lib)} symbols; "
f"library-only symbols: {extras or 'none'}"
)
return 0
if __name__ == "__main__":
sys.exit(main())