Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.1.4

### Fixed
- **CSV**: preserve multi-row AF round-trips, including pinned/tall AF blocks
away from row 0, multiple tall blocks in one rung, and generic tall
continuation rows
- **Encode**: suppress AF summary data when a rung contains multi-row AFs,
avoiding grid misalignment and Click paste crashes
- **SCR decode**: restore omitted wiring for generic tall AF continuation rows
- **CSV writer**: fail loudly when emitted CSV rows lose decoded rung
semantics by reparsing writer output and checking for row, condition, or AF
mismatches before writing to disk

## 0.1.3

### Fixed
Expand Down
114 changes: 114 additions & 0 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Troubleshooting Encode Failures

When an encoded binary crashes Click on paste (or renders incorrectly), the problem is usually a structural byte difference in the cell grid — not the instruction blob content. This guide covers how to find it.

## Tools

| Tool | What it does |
|---|---|
| `devtools/inspect_bin.py` | Decodes a `.bin` and prints all instructions with `to_csv()` output. Good for checking logical correctness, but doesn't show structural bytes. |
| `inspect_cells()` | Dumps raw cell bytes, header fields, flags, and decoded tokens for specific cells. This is the primary debugging tool for encode failures. |

`inspect_cells` lives in `laddercodec.decode` (not yet in the public API — import it directly):

```python
from laddercodec.decode import inspect_cells

data = open("capture.bin", "rb").read()
dumps = inspect_cells(data, [(0, 0, "AF"), (0, 1, "AF"), (0, 2, "AF")])
for d in dumps:
print(d)
```

The second argument is a list of `(rung_index, visual_row, column_letter)` tuples. Column is `"A"` through `"AE"` for conditions, `"AF"` for the output column.

Each `CellDump` has:

- `offset` — absolute byte position in the buffer
- `size` — cell size (0x40 for wire cells, larger for instruction cells)
- `flags` — `(segment, right, down)` from `+0x19/+0x1D/+0x21`
- `token` — decoded instruction or wire token
- `raw` — full cell bytes
- `hex()` — formatted hex dump

## Workflow

### 1. Get a native capture

Paste the same rung shape manually in Click, then copy it back to get a native `.bin`. This is your ground truth. The native capture can come from `clicknick-rung guided` or by manually copying from Click's clipboard (format 522).

### 2. Compare with inspect_cells

Run `inspect_cells` on both the native capture and your encoded output. Focus on the AF column and any instruction cells:

```python
from laddercodec.decode import inspect_cells

native = open("native.bin", "rb").read()
ours = open("encoded.bin", "rb").read()

cells = [(0, r, "AF") for r in range(3)]

for label, data in [("NATIVE", native), ("OURS", ours)]:
print(f"\n=== {label} ===")
for d in inspect_cells(data, cells):
hdr = d.raw[:0x25]
print(f"[{d.row}][{d.col}] size={d.size} flags={d.flags}")
print(f" row_span={hdr[0x09]} vis_rows={hdr[0x0A]}")
print(f" instr_idx={int.from_bytes(hdr[0x0D:0x11], 'little', signed=True)}")
print(f" tail: {d.raw[-16:].hex(' ')}")
```

### 3. What to look for

The cell header is 0x25 (37) bytes. Key fields:

| Offset | Field | Notes |
|---|---|---|
| +0x01 | column | 4-byte LE, should match the column index |
| +0x05 | global_row | 4-byte LE, `row + 1` (varies by rung position — ok to differ) |
| +0x09 | row_span | How many grid rows this cell occupies |
| +0x0A | visual_rows | Visual sub-row count (1 = normal, 2 = timer, 3 = retained timer) |
| +0x0D | instr_index | 4-byte LE signed. `-1` (0xFFFFFFFF) for data cells |
| +0x15 | contact_flag | 4-byte LE |
| +0x19 | segment | 4-byte LE — load-bearing flag |
| +0x1D | wire_right | 4-byte LE |
| +0x21 | wire_down | 4-byte LE |

After the header: instruction blob (variable length), then a 16-byte tail.

**Size differences** are the most important signal. If a cell is 32 bytes larger in your output, you're probably emitting an AF summary block that shouldn't be there — or vice versa.

**Flag differences** (segment, wire_right, wire_down) can cause visual corruption but usually don't crash Click.

**Tail differences** are usually cosmetic (rung index encoding, row hints).

### 4. Don't bother with hex diffs

Raw `xxd` / hex diffs of the two binaries are nearly useless because:

- The global header (0x0000–0x0253) contains file paths, font tables, and MDB data that differ between every capture.
- Instruction cells are variable-length, so a single size difference shifts every subsequent byte.
- Comment RTF formatting varies slightly (e.g. `\par ` vs `\r\n\par `), shifting the entire grid region.

`inspect_cells` handles all of this — it walks the variable-length grid correctly and gives you per-cell structural data.

## Known pitfalls

### AF summary block

When a single-rung buffer has 2+ AF instructions, the encoder appends a 32-byte summary block to the last AF cell. **This summary must be suppressed when any AF is multi-row** (e.g. a retained timer with a `.reset()` pin row).

The summary is built by `_build_af_summary()` in `encode.py` and gated by `_compute_rung_metadata()` in `_grid.py`. If your rung has a mix of single-row AFs (coils, copies) and multi-row AFs (timers), check that the summary isn't being emitted.

Symptom: the last AF instruction cell is ~32 bytes larger than in the native capture. Click crashes on paste because the extra bytes misalign the rest of the grid.

### Tall instruction visual_rows

The byte at cell offset +0x0A (`visual_rows`) is not always the same as the instruction's `cell_params()["visual_rows"]`. For instructions with pin rows (retained timers, counters, shifts, drums), the native binary may use a different value that accounts for the pin rows.

If your instruction cell is the right size but Click still renders it wrong, compare +0x09 and +0x0A against the native capture.

### Payload space slots

The 31 slots between the rung preamble (0x0260) and the cell grid (0x0A60) contain structural bytes that differ between our encoder and native Click. These differences are tolerated — Click reads them but doesn't crash on mismatches. Don't chase these.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ nav:
- Decoding: guides/decoding.md
- CSV Format: guides/csv-format.md
- Adding Instructions: guides/adding-instructions.md
- Troubleshooting: guides/troubleshooting.md
- Internals:
- Binary Format: internals/binary-format.md
- Wire Rendering: internals/wire-rendering.md
Expand Down
11 changes: 9 additions & 2 deletions src/laddercodec/_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,17 @@ def _compute_rung_metadata(
idx += 1

# AF summary block — needed on the last AF instruction cell when 2+ AFs
# (single-rung only; multi-rung does not use af_summary).
# are all single-row (single-rung only; multi-rung does not use
# af_summary). Suppressed when any AF is multi-row (e.g. retained
# timer with reset pin) — native captures omit it in that case.
af_summary_block = b""
af_rows = sorted(af_instr_indices.keys())
if single_rung and len(af_rows) >= 2:
any_multi_row_af = any(
tok.cell_params().get("visual_rows", 1) > 1
for r in af_rows
if isinstance((tok := af_tokens[r]), AfInstruction)
)
if single_rung and len(af_rows) >= 2 and not any_multi_row_af:
af_entries: list[tuple[int, int, bool]] = []
for r in af_rows:
cond_count = sum(1 for t in condition_rows[r] if isinstance(t, ConditionInstruction))
Expand Down
Loading