IMPORTANT: Don't use cd before commands. The working directory is already set to the project root.
IMPORTANT: Always use make commands, not direct uv run commands.
This file provides guidance to Claude Code when working with code in this repository.
laddercodec is a pure-Python binary codec for AutomationDirect CLICK PLC ladder clipboard format. It encodes and decodes the native clipboard binary used by CLICK Programming Software (v2.60–v3.80). Zero runtime dependencies. Licensed MPL-2.0.
Extracted from clicknick as a standalone library.
make # install + lint + test (default)
make install # uv sync --all-extras --dev
make lint # ruff (check + format) + ty
make test # pytest (src + tests) — fails if any golden is unverified
make golden # regenerate .bin from .csv + prune verify log + clean debris
make build # uv build
make docs-serve # local docs dev server (auto-reload)
make docs-build # build docs site (strict mode)Never inline multi-line content in bash commands (echo, printf, python -c, cat <<EOF). This triggers Claude Code's quoted-newline security check and stalls the workflow. Instead:
- Use the Write/Edit tool to create .py scripts, then run them with
uv run. - For quick Python one-liners, keep them truly single-line.
src/laddercodec/
├── __init__.py # Public API: encode(), decode(), decode_program(), read_csv(), write_csv(), Rung, ...
├── encode.py # Encoder: encode() + internal encode_rung(), constants, RTF helpers
├── encode_multi.py # Multi-rung encoder: internal encode_rungs()
├── _grid.py # Shared grid-building: _validate_rung, _compute_rung_metadata, _build_rung_grid
├── binary_helpers.py # Shared binary primitives: UTF-16LE encode/decode, tagged fields
├── decode.py # Decoder: decode() + internal decode_rung(), decode_rungs()
├── decode_program.py # Program file decoder: decode_program() — Scr*.tmp to Program
├── cell.py # Cell object builders: ClickCell, preamble, terminal, row
├── topology.py # Program header, rung preamble, cell offset math, wire flags
├── empty_multirow.py # Deterministic empty multi-row payload synthesis
├── model.py # InstructionType enum, operand validation, base classes (ConditionInstruction, AfInstruction)
├── instructions/ # Instruction dataclasses + blob builders/parsers
│ ├── __init__.py # Registry: INSTRUCTION_MODULES, parse_condition/af_blob()
│ ├── contact.py # Contact (NO/NC/edge/immediate), build_blob(), cell_params(), parser
│ ├── comparison.py # CompareContact (==, !=, >, <, >=, <=), build_blob(), cell_params(), parser
│ ├── coil.py # Coil (out/latch/reset, range, immediate, oneshot), build_blob(), cell_params(), parser
│ ├── timer.py # Timer (on_delay/off_delay, retentive), build_blob(), cell_params(), parser
│ ├── copy.py # Copy (single/block/fill), BlockCopy, Fill — build_blob(), cell_params(), parser
│ └── raw.py # RawInstruction (opaque blob passthrough), build_blob(), cell_params()
├── csv/ # CSV parsing subpackage
│ ├── __init__.py # read_csv, CSV_HEADER, CONDITION_COLUMNS
│ ├── ast.py # Typed AST (CanonicalRow, condition/AF nodes, RungAst)
│ ├── contract.py # Constants (CONDITION_COLUMNS, CSV_HEADER) + validators
│ ├── parser.py # CSV file parser (canonical syntax)
│ ├── converter.py # RungAst → Rung converter (pin rows, tall padding)
│ ├── writer.py # CSV writer (Rung → CSV file)
│ ├── bundle.py # Program bundle parser (main.csv + sub_*.csv)
│ └── token_parser.py # Condition + AF token parsers
└── resources/
└── empty_multirow_rule_minimal.scaffold.bin # Template for multi-row synthesis
- encode.py — Encoder. Public API is
encode()(acceptsRungorlist[Rung]). Internalencode_rung()orchestrates: validate → compute metadata → build grid → insert comment → pad to page. Constants, RTF helpers, type aliases (ConditionToken,AfToken), and_af_segment()/_compute_seg_boundaries()live here. - encode_multi.py — Multi-rung encoder. Internal
encode_rungs(). Combines N rungs into one buffer with per-rung preambles and data rows. Delegates grid building to_grid.py. - _grid.py — Shared grid-building functions used by both encoders.
_validate_rung()validates dimensions/tokens,_compute_rung_metadata()returns aRungMetadatadataclass (instruction indices, segment boundaries),_build_rung_grid()builds the cell grid for one rung. - binary_helpers.py — Shared binary serialization primitives. Encoding:
_utf16le_null(),_tagged_field(),_variant_tagged_field(). Decoding:_read_utf16le(),_parse_tagged_fields(),_parse_tagged_fields_verbose()(returns tag IDs + handles variant sentinels). Used by all instruction modules anddevtools/inspect_bin.py. - decode.py — Decoder. Public API is
decode()(auto-detects single vs multi-rung). ReturnsRungorlist[Rung]. Walks the variable-length cell grid, parses instruction blobs into Contact/Coil/Timer domain objects, decodes RTF comments to markdown. Falls back toRawInstructionfor unrecognised cell types. - decode_program.py — Program file decoder. Public API is
decode_program(). ReadsScr*.tmpfiles (Click's internal format, ~17x smaller than clipboard) and returns aProgramwith name, index, and decoded rungs. Same instruction parsing asdecode.pybut different framing. - cell.py — Cell object builders.
ClickCelldataclass builds 0x25-byte header + blob + 16-byte tail. Also:build_preamble_cell(),build_terminal_cell(),build_row(). - topology.py — Buffer structure constants: program header (0x0254), rung preamble layout (comment flag +0x30, length +0x34, body +0x38), cell offset math, cell flag constants (+0x19 segment, +0x1D right, +0x21 down).
- empty_multirow.py — Deterministic empty payload synthesis for 1–32 rows. Key formula: payload length =
0x1000 * (ceil((rows+1)/2) + 1). - model.py —
InstructionTypeenum (0x2711–0x2718), operand validation,ConditionInstructionandAfInstructionbase classes. All instruction dataclasses inherit from one of these; the pipeline uses them for isinstance dispatch so new types are recognized automatically. - instructions/ — Instruction dataclasses with
build_blob()andcell_params()methods, func code tables, and blob parsers. Each dataclass knows how to serialize itself — the grid loop just callstoken.build_blob()andtoken.cell_params(). Registry in__init__.pydispatches class name → module. - csv/contract.py — Constants (CONDITION_COLUMNS, CSV_HEADER) used by golden fixture IO and clicknick.
- csv/converter.py — RungAst → Rung converter. Handles pin rows (
.reset()→ retentive timers) and tall instruction auto-padding. - csv/parser.py, writer.py, bundle.py, token_parser.py — CSV parsing and writing.
parser.pyreads canonical CSV into AST;writer.pyserializes Rung to CSV;bundle.pyhandles multi-file programs;token_parser.pyparses condition/AF tokens.
tests/
├── test_smoke.py # Basic sanity check
├── test_coverage.py # Coverage golden fixture tests (instruction variants)
├── golden_io.py # Golden CSV/BIN read/write helpers
├── ladder/
│ ├── test_encode.py # encode() pipeline, multi-row, comments, wires, NOP
│ ├── test_encode_multi.py # encode() multi-rung golden fixture tests
│ ├── test_decode.py # decode() tests
│ ├── test_decode_multi.py # decode() multi-rung tests
│ ├── test_decode_program.py # decode_program() program file tests
│ ├── test_verify_status.py # Golden verification status check
│ ├── test_model.py # InstructionType, operand validation
│ └── test_empty_multirow.py # Payload synthesis for rows 1..32
├── csv/
│ ├── test_parser.py # CSV file parsing (canonical)
│ ├── test_converter.py # RungAst → Rung conversion
│ ├── test_contract.py # Constants and validators
│ ├── test_bundle.py # Program bundle parsing
│ ├── test_token_parser.py # Condition + AF token parsing
│ └── test_writer.py # CSV writer tests
└── fixtures/
└── ladder_captures/golden/ # Golden CSV/BIN fixtures
Golden fixtures verified through Click paste round-trip.
Full spec: docs/internals/binary-format.md — buffer layout, program header, rung preamble, payload push model, cell grid, multi-rung format.
Quick reference: three regions — program header (0x0254), payload region (0x0298, comment RTF), cell grid (0x0A60+, 32 cells/row × 0x40 bytes/cell). Comment payloads push the grid forward.
Full spec: docs/internals/wire-rendering.md — flag bytes, left-edge rendering, segment flag boundary rules.
Quick reference: three flag bytes per cell — segment (+0x19), right (+0x1D), down (+0x21). Wire tokens classified by (right, down) only. Segment flag boundary computed per-row. Row 0 exempt.
Full spec: docs/internals/instruction-blobs.md — blob structure, class names, field layouts.
All tested shapes pass Click round-trip (verified via paste → copy-back):
Single-rung, no comment:
- Empty rungs (1/2/3/4/5/8/9/13/17/32 rows)
- Wire topologies (horizontal, vertical, T-junction, mixed, partial)
- NOP on AF column (row 0, multi-row with wires)
- Edge cases (all 31 cols dashed, vertical B-only, T at column AE)
Single-rung, with comment:
- 1-row: empty, full wire, partial wire, NOP, full wire + NOP, max 1400-byte
- 2-row: empty, NOP, sparse wire, wire at col A, max 1324-byte
- 3-row: empty, NOP, wires, same-col wire, mixed wire, max 1400-byte
- 4/5/9/13/32-row: empty, partial wire, max 1400-byte
- Styled comments: bold, italic, underline, mixed styles, multiline
Multi-rung:
- 2-rung: empty, NOP, wire, 2-row
- 3-rung: empty, wire + NOP
- Comments on rung 0 only, rung 1 only, both rungs, all 3 rungs
- Comments with wires + NOP, 2-row rungs, styled text
The clipboard decoder (decode.py) reads Click clipboard binaries back into structured data. Validated against a 37-rung native capture covering all basic instruction types.
The program file decoder (decode_program.py) reads Click's internal Scr*.tmp files. Returns a Program with name, index, and all rungs. Validated against 114-rung coverage fixture plus shift and counter programs.
All standard Click instruction types decoded natively:
- Contacts: NO, NC, edge (rise/fall), immediate (NO/NC)
- Comparison contacts: GT, GE, LT, LE, EQ, NE
- Coils: out, latch, reset, immediate, range, oneshot
- Timers: on_delay, off_delay, retentive
- Counters: count_up, count_down
- Copy family: Copy, BlockCopy, Fill, Pack, Unpack
- Math: decimal/hex expressions
- Shift registers, drum sequencers (event/time), table search
- Flow control: Call, Return, End, ForLoop, Next
- Modbus: Send, Receive
- Wire tokens: classified by (right, down) only — segment flag ignored (T, -, |, blank)
- Comments: RTF → markdown round-trip
- Multi-rung buffers with interleaved preambles
- Program files (
Scr*.tmp) with row topology blocks - Unknown types:
RawInstructionfallback with raw bytes preserved
See instruction blobs for the binary blob structure.
All standard Click instruction types are natively supported. RawInstruction fallback handles any future/unknown types for lossless round-trip.
RE-first: understand the binary format thoroughly through native captures and byte-level diffing before building any byte→model decode layer. The encoder writes bytes directly from verified formulas — no premature abstractions over incompletely-understood structure.
- T/| tokens rejected on the last row (vertical-down has nowhere to go)
- T/| tokens rejected on column A
- At most one NOP per rung (multiple NOPs render as tiny dots in Click)
- Rung preamble model. Every rung has a 0x40-byte preamble at a fixed offset. Rung 0's is at 0x0260; rung N>0's is cell 0 of the preamble row preceding its data rows. Comment flag (+0x30), length (+0x34), and body (+0x38) live at the same offsets in every preamble.
- Payload push model. The cell grid always lives at 0x0A60 in a no-payload buffer. A comment payload inserted into a preamble pushes everything after it forward. Wire flags written to cell grid positions before insertion land at the right absolute addresses after insertion — no special stride encoding needed.
- Buffer sizing for comments. Truncate the base buffer to
GRID_FIRST_ROW_START + rows * GRID_ROW_STRIDEbefore inserting the payload. This keeps the page-aligned final size consistent:pad_to_page(minimal_end + payload_len). - Comment max depends on row count. Inserting > 1400-byte body is rejected outright. The practical per-row limit is determined by where
minimal_end + payload_lencrosses the next page boundary (e.g. 2-row: body ≤ 1324 bytes stays at 0x2000; 1325+ bumps to 0x3000). - Native captures are the ground truth. When something doesn't work, capture a native rung with the same shape and diff against synthetic.
When the user provides a .bin + .csv from a Click capture:
- Inspect the capture:
uv run devtools/inspect_bin.py <file.bin>— shows known instructions withto_csv()output, and RawInstruction/Unknown blobs with full tagged-field breakdown (tag IDs, sentinel types, values). - Identify what's new: RawInstruction means the class name is recognized but the func code isn't. The field breakdown shows exactly which func code / field values differ from existing support.
- Update the code: add func codes, fields, or new instruction modules as needed. Update
csv/converter.pyto pass new kwargs through. - Update coverage CSV:
tests/fixtures/coverage/golden/<name>.csv— use kwargs style for boolean flags (e.g.oneshot=1). - Verify:
make test+make lint.
See docs/guides/adding-instructions.md for the full guide.