# laddercodec > Documentation for laddercodec # Guides # Adding New Instruction Types ## Quick workflow ``` uv run devtools/inspect_bin.py ``` This shows every instruction in the capture: known types display their model + `to_csv()` output, while `RawInstruction` and `UnknownInstruction` show a full tagged-field breakdown with tag IDs, sentinel types, and values. From the inspect output you'll see one of two cases: - **RawInstruction** — blob structure parsed, but no supported model matched yet. This can mean: - a new variant of an existing instruction class, or - an entirely new instruction class name - **UnknownInstruction** — blob boundary could not be parsed cleanly (truncated/novel layout); inspect raw bytes before modeling Tip: use the reported `class='...'` value. If the class name is already registered, you're likely adding a new variant; if it's new, you're likely adding a new instruction type. Note: `Email`, `Home`, `Position`, and `Velocity` are currently known enough to round-trip, but they still intentionally surface as `raw(...)` passthroughs until dedicated typed DSL models are added. ## Base classes All instruction dataclasses inherit from one of two base classes in `model.py`: - **`ConditionInstruction`** — condition-side (Contact, CompareContact) - **`AfInstruction`** — AF-side (Coil, Timer, Copy, BlockCopy, Fill, RawInstruction) Both declare `to_csv()`, `build_blob()`, and `cell_params()` stubs. The pipeline uses these base classes for isinstance dispatch, so **new instruction types that inherit from the right base class are automatically recognized** by the encoder, decoder, grid builder, and CSV writer. In addition, each instruction family module now exposes a small family spec in `src/laddercodec/instructions/family.py`. That spec is the shared source of truth for: - binary class names - CSV token names - CSV call parsing - supported pin names - minimum CSV row count (`min_csv_rows`) For most new instruction families, the main extension point is: **add the dataclass + codec logic in the family module, then register a `SPEC` for it.** ## Case 1: New variant of existing instruction Example: adding oneshot to the Out coil (func code 8205, field[2] = "-1"). ### 1. Read the field breakdown ``` [0][AF] RawInstruction class='Out' csv: raw(Out,4f00...) class: 'Out' type_marker: 0x2715 field[0]: tag=0x6066 (std) value='Y001' # operand field[1]: tag=0x6067 (std) value='' # range_end field[2]: tag=0x11F8 (std) value='-1' # oneshot ← new field[3]: tag=0x11F5 (std) value='0' # immediate field[4]: tag=0x3218 (std) value='8205' # func_code ← new field[5]: tag=0x0000 (std) value='' ``` Compare against existing field values (field[2] is normally "0", func code is normally "8193" for basic out). The diff tells you exactly what changed. ### 2. Update the instruction module In `src/laddercodec/instructions/.py`: - Add the new func code to the lookup table - Add any new fields to the dataclass (e.g. `oneshot: bool = False`) - Update `build_blob()` to emit the new field value - Update `to_csv()` to include the new parameter - Update `parse_blob()` to extract the new field from the func code table ### 3. Update the CSV-facing parse path If the variant is covered by an existing family spec, update the family module's CSV parsing path rather than adding a new central converter branch. For example, extend `from_csv_token()` or the family's `parse_af_call()` hook so the new kwarg is understood: ``` oneshot = call.kwargs.get("oneshot") == "1" return Coil(..., oneshot=oneshot) ``` ### 4. Update the coverage CSV The coverage fixture at `tests/fixtures/coverage/golden/.csv` should already exist for the variant. Verify the CSV token matches what your `to_csv()` produces (e.g. `out(C47,oneshot=1)`). Boolean flags use kwargs style: `oneshot=1`, not wrapper style. ### 5. Verify ``` make test && make lint ``` ## Case 2: Entirely new instruction type ### 1. Read the field breakdown Same as above. For new types, inspect will often show `RawInstruction` with a new class name (for example `class='End'`). `UnknownInstruction` is less common and usually means the blob format itself wasn't parsed cleanly. The inspect output shows the blob structure — class name, type marker, part count, field count, all tagged fields. ### 2. Create instruction module In `src/laddercodec/instructions/`: - New `@dataclass` inheriting from `AfInstruction` (or `ConditionInstruction`) - Implement `to_csv()`, `build_blob()`, `cell_params()`, and `parse_blob()` - Follow existing modules (coil.py, timer.py, copy.py) as templates - Add `InstructionType` enum value in `model.py` if it uses a new type marker **Tall instructions:** if the new instruction occupies more than 1 grid row in the binary/editor shape, return `{"visual_rows": N}` from `cell_params()`. If the instruction also needs auto-padding in CSV, set `min_csv_rows=N` on the family `SPEC`. These are related but not always identical: - `visual_rows` = binary/editor cell height - `min_csv_rows` = minimum CSV/logical rows to synthesize on read Example: timers currently use `visual_rows=2` and `min_csv_rows=2`, while counter/shift/drum families use explicit higher CSV row counts because their lower rows have pin semantics. Also add a family `SPEC` in the module: - `binary_class_names` - `csv_names` - `parse_blob` - `parse_csv_call` (for AF families) - `pin_names` if the family uses dot-prefixed continuation rows - `min_csv_rows` if the family should auto-pad in CSV ### 3. Register and export | File | What to update | | -------------------------- | -------------------------------------------------------------------------------------------------------- | | `instructions/__init__.py` | Register the family `SPEC` in `AF_FAMILY_SPECS` or `CONDITION_FAMILY_SPECS`, and re-export the new class | | `__init__.py` | Re-export the new class in the public API | `KNOWN_AF_NAMES` is now derived from the registered family specs, so `csv/ast.py` does not need manual token updates. Thanks to the base classes and family specs, `encode.py`, `_grid.py`, `devtools/inspect_bin.py`, and most of the CSV token dispatch need no changes. ### 4. Update family CSV parsing if needed For AF instructions, make sure the family `SPEC` can parse the CSV call: - add the CSV token name to `csv_names` - implement or extend `parse_csv_call` - handle positional args and kwargs there ### 5. Update CSV writer If the instruction has only generic blank padding, the existing tall-instruction stripping usually works automatically. If the instruction has semantic lower rows or pins (like timers, counters, shifts, or drums), update `csv/writer.py` and possibly `csv/converter.py` to handle the reverse row-shaping explicitly. ### 6. Update coverage CSV and verify Add `tests/fixtures/coverage/golden/.csv` files covering all variants. ``` make test && make lint ``` ## Coverage testing Coverage golden fixtures live in `tests/fixtures/coverage/golden/`. Each fixture is a hand-written CSV (`.csv`) paired with a generated binary (`.bin`). ### Adding a fixture 1. Create `tests/fixtures/coverage/golden/.csv` by hand — one rung per file, 33-column canonical format. 1. Run `make coverage-golden` to generate the `.bin` from the CSV. 1. Paste-verify in Click using `clicknick-rung guided`. 1. `make test` — each `.csv` with a matching `.bin` gets a parametrized test comparing encoded output to golden binaries. ### Regenerating coverage bins ``` make coverage-golden ``` Regenerates all `.bin` files from `.csv` sources and prunes the verify log for changed/deleted fixtures. Same workflow as `make golden` for ladder fixtures. ### Smoke-testing combined fixtures To quickly test multiple instruction types together in a single rung: ``` uv run devtools/combine_coverage.py # 4 random fixtures uv run devtools/combine_coverage.py -n 6 # 6 random fixtures uv run devtools/combine_coverage.py --seed 42 # reproducible pick ``` This picks N random coverage CSVs, stacks their data rows into one multi-row rung, and writes `devtools/combined.csv` + `devtools/combined.bin` for paste-testing. ## Clicknick compatibility The `clicknick-rung guided` tool extracts operand addresses from CSV tokens for MDB provisioning. It uses `to_csv()` + regex to find address patterns, so **new instruction types work automatically** — no clicknick changes needed. # CSV Format (v1) This document describes the canonical 33-column Click ladder CSV contract used by `laddercodec` CSV tooling and by `pyrung.click.to_ladder()`. Relevant entry points: - `laddercodec.read_csv(path)` - `laddercodec.write_csv(path, rungs)` - `laddercodec.csv.bundle.parse_bundle(directory)` - `pyrung.click.to_ladder(program, tag_map)` - `pyrung.click.LadderBundle.write(directory)` ## Scope and guarantees This is the canonical writer contract. `laddercodec` parsers may accept additional conveniences, but producers and downstream tools should target the format below. - Deterministic output - Fully expanded rows (no shorthand expansion required) - Strict prevalidation before emit - All-or-nothing export for `pyrung` bundle generation ## Bundle layout A bundle directory contains: 1. `main.csv` 1. One file per subroutine: `sub_.csv` Rules: - Consumers discover subroutines in lexical filename order. - `parse_bundle(directory)` requires `main.csv`. - When `pyrung` writes a bundle, the output directory is auto-created (`parents=True, exist_ok=True`) and existing files are overwritten. - `pyrung` slug generation: - Lowercase - Non-alphanumeric sequences become `_` - Leading/trailing `_` trimmed - Empty slug becomes `subroutine` - Collisions are suffixed (`_2`, `_3`, ...) ## CSV shape - UTF-8 CSV (standard comma-separated, quoted as needed by CSV writer) - Header is always present and exact: ``` marker,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,AA,AB,AC,AD,AE,AF ``` - Exactly 33 columns per row: - `marker` + `A..AE` (31 condition columns) + `AF` (output token) ## Row semantics - `marker`: - `R` => first row of a rung - `""` (blank) => continuation row of current rung - `#` => comment row - `AF`: - exactly one token or blank - blank means no output token on that row Rung segmentation rule for consumers: - A rung starts at each row with `marker == "R"` and continues until the next `R` or EOF. ## Comment rows Comment rows appear directly above the `R` row of the rung they annotate: - `marker` = `#` - Column `A` = comment text for that line - No additional columns Multi-line comments emit one `#` row per line. Example: ``` #,Initialize the light system. #,Activates when Button is pressed. R,X001,-,-,...,-,out(Y001) ``` Comment rows are metadata. Consumers may ignore them or display them as rung annotations. Writers preserve comment text verbatim; some toolchains use markdown-style markers inside the text (for example `**bold**`, `*italic*`, `__underline__`) to represent styled Click comments. ## Condition grid cell vocabulary (`A..AE`) Cells can contain: - Contact/operand tokens (for example `X001`, `DS10`, `C1`) - Negated contact: `~X001` - Edge contacts: `rise(X001)`, `fall(X001)` - Comparison terms (for example `DS1!=0`, `DS1==5`, `DS1`, `...`) are emitted. No explicit `+` topology token is emitted. ## Vertical topology OR conditions (`any_of`), multi-output stacking, and `branch()` conditions all use continuation rows within a rung. These mechanisms share the same vertical space - OR branch rows double as multi-output and branch rows. ### Bus patterns Two column patterns control vertical connectivity: **Convergent bus (OR merge)** - gathers parallel branches into a single downstream path: - `T` on the first row (right + down) - `|` on middle rows (vertical pass-through - up + down only, no right exit) - blank on the last row (bus terminates) Middle and last rows reach downstream columns by routing up through `|` to the `T`, then right. This ensures only the first row's horizontal path continues directly. **Divergent bus (output split)** - distributes one condition path to multiple output rows: - `T` on non-final rows (right + down) - `-` on the final row (right only) Every row has a right exit to its own AF output token. **Contact input bus (`T:` prefix)** - an OR variant where the contacts themselves carry the vertical bus instead of a separate wire column: - `T:` prefix on non-final branch contacts (right + down) - Bare contact on the final branch (right only) - Contacts at column 0 (power rail) are always bare - the rail connects all rows. The `T:` prefix applies to any contact token: `T:X002`, `T:~C1`, `T:rise(X001)`, `T:DS1==5`. ### T-junction physical model A `T` cell's vertical wire extends from the left edge of its cell downward. This wire connects to either: - A cell directly below in the same column, or - The right edge of a contact in the column to the left (when the cell below is blank) This diagonal adjacency is how contacts on continuation rows connect upward to merge/split columns even when their own column has no marker below. ### Continuation row rules - Only the first row (`marker = R`) carries the full condition path from power rail to output. - Continuation rows carry only their OR-branch-local contacts and branch-local conditions. Shared AND-prefix contacts from earlier columns are not repeated. - Each continuation row has at most one AF token or is blank in AF. ## `any_of(...)` OR expansion ### Simple OR at power rail `any_of(X001, X002, X003)`: ``` A B ... AF R X001 T -...- out(Y001) X002 | X003 ``` Contacts at col 0 are bare (power rail). Convergent bus at col B: `T` / `|` / blank. ### Mid-rung OR `X001, any_of(X002, C1)`: ``` A B C ... AF R X001 T:X002 T -...- out(Y001) C1 ``` `T:` prefix on `X002` (non-final, mid-rung). Convergent bus at col C: `T` / blank. Shared AND-prefix (`X001`) appears only on the first row. ### Series ORs `any_of(X001, X002), any_of(C1, C2)`: ``` A B C D ... AF R X001 T T:C1 T -...- out(Y001) X002 - C2 ``` First OR at power rail (bare contacts, convergent bus at col B). Second OR mid-rung (`T:` prefix on `C1`, convergent bus at col D). Continuation rows merge - `X002` and `C2` share the same row, connected by `-` wire at col B. ## Multi-output stacking Multiple output instructions from the same condition path stack vertically using a divergent bus: `X001, X002` -> `out(Y001)`, `latch(Y002)`, `reset(Y003)`: ``` A B C ... AF R X001 X002 T -...- out(Y001) T -...- latch(Y002) - -...- reset(Y003) ``` Divergent bus at col C: `T` / `T` / `-`. Each row continues right to its own output. ## `branch(...)` conditions Branch-local conditions are placed on continuation rows to the right of the output split column. The branch contact carries a `T:` prefix when it is not on the final row, maintaining the vertical bus for rows below it. `X001, X002` -> `out(Y001)`, `branch(C1): out(Y002)`, `out(Y003)`: ``` A B C D ... AF R X001 X002 T T -...- out(Y001) T T:C1 -...- out(Y002) - - -...- out(Y003) ``` Divergent bus at col C: `T` / `T` / `-`. Col D: `T` on row 0, `T:C1` on row 1, `-` on row 2. The `T:` prefix on `C1` ensures the down-wire continues to row 2 (dropping `C1` from conditions) so row 2 receives the parent condition without `C1`. Nested branches are not emitted (export error). ## Combined OR + multi-output + branch When a rung has both OR conditions and multiple outputs/branches, they share the same set of continuation rows. The OR branches provide the rows needed for the outputs. `X001, any_of(X002, X003, X004)` -> `out(Y001)`, `branch(C1): out(Y002)`, `out(Y003)`: ``` A B C D ... AF R X001 T:X002 T T -...- out(Y001) T:X003 | T:C1 -...- out(Y002) X004 - -...- out(Y003) ``` Col B: 3-way OR input bus (`T:` / `T:` / bare). Col C: convergent bus (`T` / `|` / blank). Col D: divergent bus with branch condition (`T` / `T:C1` / `-`). The 3 OR branches provide the 3 rows needed for the 3 outputs. `C1` slots in as a branch-local condition on the middle row. Middle OR rows reach the output split by routing up through `|` to `T`, then right and back down through the divergent bus. ## Builder pin continuation rows Builder side conditions are emitted as continuation rows with dot tokens in `AF`: - `.reset()` - `.down()` - `.clock()` - `.jump(step)` - `.jog()` Pin rows are independent left-rail paths, not AND-chained through the parent output row conditions. ## Parsing conveniences `laddercodec.read_csv()` accepts a few parsing conveniences beyond the canonical writer contract: - Timer instructions occupy 2 grid rows. - If a timer rung is written with only 1 data row, the parser auto-pads a blank continuation row. - Empty rows between rungs are ignored. ## For-loop lowering `forloop(count, oneshot=...)` lowers to: 1. `for(count)` or `for(count,oneshot=1)` row (`marker=R`) 1. Body instruction rows (`marker=R` per emitted body instruction row) 1. Closing `next()` row (`marker=R`) ## Subroutine tail guarantee Each subroutine CSV emitted by `pyrung` is guaranteed to end with `return()`: - If the last emitted instruction token is already `return()`, unchanged. - Otherwise the exporter appends an `R` row with `return()`. ## AF token format (canonical) All tokens are compact canonical function-style strings: - `name(pos1,pos2,key=val,...)` - Positional args come first, then keyword args as `key=value` - no extra whitespace - dot pins as `.name(...)` String rendering: - Strings are double-quoted. - Internal `"` is escaped as `""` (doubled quote). No backslash escaping. Boolean rendering: - `1` / `0` `None` rendering: - `none` Collections: - List/tuple-like values render as bracket lists, for example `[A,B]`, `[[1,0],[0,1]]`. ## Supported instruction tokens (canonical producer set) Positional args stay positional. Keyword-only args use `key=value` syntax. Conditional kwargs (marked with "if !=0") are omitted when the value is the default (`0`). Producer may emit: - `out(target)` or `out(target,oneshot=1)` - `latch(target)` - `reset(target)` - `copy(source,target)` or `copy(source,target,oneshot=1)` - `blockcopy(source,dest)` or `blockcopy(source,dest,oneshot=1)` - `fill(value,dest)` or `fill(value,dest,oneshot=1)` - `math(expression,dest,mode=decimal)` or `math(...,mode=hex,oneshot=1)` - `search(range cond value,result,found)` or `search(...,continuous=1,oneshot=1)` - `pack_bits(bit_block,dest)` or `pack_bits(bit_block,dest,oneshot=1)` - `pack_words(word_block,dest)` or `pack_words(word_block,dest,oneshot=1)` - `pack_text(source_range,dest)` or `pack_text(...,allow_whitespace=1,oneshot=1)` - `unpack_to_bits(source,bit_block)` or `unpack_to_bits(source,bit_block,oneshot=1)` - `unpack_to_words(source,word_block)` or `unpack_to_words(source,word_block,oneshot=1)` - `on_delay(done,acc,preset=N,unit=Tms)` - `off_delay(done,acc,preset=N,unit=Tms)` - `count_up(done,acc,preset=N)` - `count_down(done,acc,preset=N)` - `shift(bit_range)` - `event_drum(outputs=[...],events=[...],pattern=[[...],...],current_step=X,completion_flag=X)` - `time_drum(outputs=[...],presets=[...],unit=Tms,pattern=[[...],...],current_step=X,accumulator=X,completion_flag=X)` - `send(target=X,remote_start="addr",source=X,sending=X,success=X,error=X,exception_response=X,count=N)` - `receive(target=X,remote_start="addr",dest=X,receiving=X,success=X,error=X,exception_response=X,count=N)` - `call("subroutine_name")` - Subroutine names must not contain `"` - `return()` - `for(count)` or `for(count,oneshot=1)` - `next()` - `raw(ClassName,hex)` - opaque instruction passthrough for binary round-trip fidelity. `ClassName` is the Click binary class name (unquoted) and `hex` is the raw blob as a hex string. Runtime no-op; preserved so CSV -> DSL -> CSV round-trips losslessly for unrecognized instruction types. `Email`, `Home`, `Position`, and `Velocity` currently use this raw passthrough path because they are not yet modeled as dedicated DSL instruction classes. Pin tokens: - `.reset()` - `.down()` - `.clock()` - `.jump(step)` - `.jog()` Click supports additional instruction placeholders that canonical writers do not currently emit: - Empty instruction placeholder: `,:,...` - NOP instruction placeholder: `,:,NOP` ## Operand normalization notes - Tags render as mapped Click addresses (for example `X001`, `DS10`). - Block ranges render either: - contiguous compact form `BANKstart..BANKend` (same bank, +1 sequence), or - explicit list form `[A,B,C]`. - Indirect refs render as `BANK[pointer]` or `BANK[pointer+offset]` / `BANK[pointer-offset]`. - Copy converters are emitted as a `convert=` kwarg on the instruction: - `convert=to_value` - `convert=to_ascii` - `convert=to_binary` - `convert=to_text(suppress_zero=<0|1>,exponential=<0|1>,termination_code=)` ## Immediate handling Immediate operands are supported only in strict, explicit contexts. Allowed condition-cell forms: - `immediate(X001)` - `~immediate(X001)` Allowed AF token forms: - `out(immediate(Y001))` - `latch(immediate(Y001))` - `reset(immediate(Y001))` - `out(immediate(Y001..Y004))` (contiguous mapped range only) Rules: - `Tag.immediate` and `immediate(...)` wrapper style are both supported. - Immediate is allowed only for: - direct rung contacts (normal and negated), and - `out(...)`, `latch(...)`, `reset(...)` target operands. - Immediate is not allowed in: - edge contacts (`rise(...)`, `fall(...)`), - non-coil instruction operands (`copy`, `math`, `search`, etc.). - Immediate coil targets must resolve to `Y` bank addresses. - Immediate-wrapped ranges must resolve to one contiguous address span to emit compact `BANKstart..BANKend` form. - Non-contiguous mappings fail strict validation/export with explicit diagnostics. ## Strict validation and failure mode Before rendering, `pyrung` runs strict Click validation and extra export checks. On any issue: - `LadderExportError` is raised - includes structured `issues` entries (`path`, `message`, `source_file`, `source_line`) - no CSV bundle is returned/written ## Consumer recommendations 1. Validate exact header and column count (33). 1. Parse in row order and preserve ordering semantics. 1. Treat `AF` as an opaque canonical token string unless your decoder intentionally parses token grammar. 1. Treat unknown future token names as extension points (fail closed if strict). 1. Segment rungs by `marker == "R"`. # Decoding `decode()` reads a Click clipboard binary and returns structured data. It returns a `Rung` for a single-rung buffer and a `list[Rung]` for a multi-rung buffer. ## Single rung ``` from laddercodec import decode with open("capture.bin", "rb") as f: data = f.read() decoded = decode(data) ``` The returned `Rung` has the same fields that feed `encode()`: | Field | Type | Description | | -------------- | ---------------------------------------------- | ----------------------------------------------------- | | `logical_rows` | `int` | Number of rows (1..32) | | `conditions` | `list[list[str \| Contact \| CompareContact]]` | 31-column grid of wire tokens and instruction objects | | `instructions` | `list[str \| Coil \| Timer \| Counter \| ...]` | One AF token per row | | `comment` | `str \| None` | Plain text with markdown formatting | ### Instruction types All standard Click instruction types are decoded into domain objects: - **Contacts** → `Contact` (NO, NC, edge, immediate) - **Comparison contacts** → `CompareContact` (==, !=, >, \<, >=, \<=) - **Coils** → `Coil` (out, latch, reset, immediate, range, oneshot) - **Timers** → `Timer` (on_delay, off_delay, retentive) - **Counters** → `Counter` (count_up, count_down) - **Copy family** → `Copy`, `BlockCopy`, `Fill`, `Pack`, `Unpack` - **Math** → `Math` (decimal/hex expressions) - **Shift registers** → `Shift` - **Drum sequencers** → `Drum` (event/time) - **Table search** → `Search` - **Flow control** → `Call`, `Return`, `End`, `ForLoop`, `Next` - **Modbus** → `Send`, `Receive` Unrecognised cells fall back to `RawInstruction` with the raw bytes preserved. This keeps the decoder forward-compatible — unknown instruction types don't crash the pipeline. At the moment, Click `Email`, `Home`, `Position`, and `Velocity` instructions also surface this way. They are supported for binary/SCR round-trip, but they are not yet modeled as dedicated DSL classes, so they currently emit `raw(...)` tokens. ### Wire tokens Wire cells are decoded into string tokens: `"-"`, `"|"`, `"T"`, or `""` (blank). The segment flag is ignored during decoding — only the right and down flags determine the token. ### Comments RTF comment bodies are decoded to plain text with markdown formatting: - `{\b text}` → `**text**` - `{\i text}` → `*text*` - `{\ul text}` → `__text__` - `\par` → newline ## Multi-rung buffers `decode()` detects multi-rung buffers automatically and returns a list: ``` from laddercodec import decode decoded_list = decode(data) for rung in decoded_list: print(f"Rows: {rung.logical_rows}, Comment: {rung.comment}") ``` ## Binary to CSV Write decoded data directly to a CSV file: ``` from laddercodec import decode, write_csv result = decode(data) rungs = result if isinstance(result, list) else [result] write_csv("output.csv", rungs) ``` ## Decode a program file `decode_program()` reads a Click program file (`Scr*.tmp`) and returns a `Program` containing all rungs. These are the internal temp files Click Programming Software writes to disk — much more compact than clipboard format (~17x smaller). ``` from laddercodec import decode_program with open("Scr1.tmp", "rb") as f: data = f.read() program = decode_program(data) print(f"Program: {program.name}, index: {program.prog_idx}") for i, rung in enumerate(program.rungs): print(f" Rung {i}: {rung.logical_rows} rows") ``` The returned `Program` dataclass has: | Field | Type | Description | | ---------- | ------------ | --------------------------------------------------------- | | `name` | `str` | Program name from the file header | | `prog_idx` | `int` | Program index (0 = main, 1+ = subroutines) | | `rungs` | `list[Rung]` | Decoded rungs — same `Rung` objects as `decode()` returns | Each `Rung` in `program.rungs` has the same structure as clipboard-decoded rungs, so you can pass them to `encode()`, `write_csv()`, or any other API that accepts `Rung` objects. ## Round-trip identity For all supported instruction types: ``` from laddercodec import encode, decode, Rung rung = Rung(lr, conds, afs, cmt) decoded = decode(encode(rung)) assert decoded.logical_rows == lr assert decoded.conditions == conds assert decoded.instructions == afs assert decoded.comment == cmt ``` # Encoding `laddercodec.encode()` is the stable root entry point for producing Click clipboard binary. It accepts either: - a single `Rung` - a sequence of `Rung` objects for a multi-rung clipboard buffer If you already have canonical Click CSV, the shortest path is: ``` from laddercodec import encode, read_csv binary = encode(read_csv("main.csv")) ``` Low-level helpers such as `encode_rung()` and `encode_rungs()` are still available in submodules for advanced use, but the root `encode()` API is the preferred public surface. ## Build A Single Rung In Code ``` from laddercodec import Coil, Contact, Rung, encode rung = Rung( logical_rows=1, conditions=[ [Contact.from_csv_token("X001"), "-", Contact.from_csv_token("~X002")] + [""] * 28, ], instructions=[Coil.from_csv_token("out(Y001)")], comment="Motor start circuit", ) binary = encode(rung) ``` Every `Rung` has four fields: | Field | Type | Notes | | -------------- | -------------------- | ---------------------------------------------------------- | | `logical_rows` | `int` | `1..32` | | `conditions` | `list[list[object]]` | One row per logical row, exactly 31 condition columns each | | `instructions` | `list[object]` | One AF token per logical row | | `comment` | `str \| None` | Optional plain text with lightweight markdown styling | ## Wire Tokens Condition columns accept four wire tokens as strings: | Token | Meaning | | ----- | ------------------------------ | | `"-"` | Horizontal wire | | \`" | "\` | | `"T"` | Branch junction (right + down) | | `""` | Blank cell | ``` from laddercodec import Rung, encode conds = [ ["-", "T", "-", "-"] + [""] * 27, ["", "-", "-", "-"] + [""] * 27, ] binary = encode(Rung(logical_rows=2, conditions=conds, instructions=["NOP", "NOP"], comment=None)) ``` ## Condition-Side Instructions The most convenient public helpers are the instruction token parsers: ``` from laddercodec import CompareContact, Contact Contact.from_csv_token("X001") Contact.from_csv_token("~X001") Contact.from_csv_token("rise(X001)") Contact.from_csv_token("fall(X001)") Contact.from_csv_token("immediate(X001)") CompareContact.from_csv_token("DS1==DS2") ``` You can also construct comparison contacts directly: ``` from laddercodec import CompareContact compare = CompareContact(op=">=", left="DS1", right="100") ``` ## AF-Side Instructions Coils have a CSV-token helper as well: ``` from laddercodec import Coil Coil.from_csv_token("out(Y001)") Coil.from_csv_token("latch(Y001)") Coil.from_csv_token("reset(Y001)") Coil.from_csv_token("out(immediate(Y001))") Coil.from_csv_token("out(C1..C8)") ``` Timers are typically easiest to build directly: ``` from laddercodec import Contact, Rung, Timer, encode rung = Rung( logical_rows=2, conditions=[ [Contact.from_csv_token("X001")] + [""] * 30, [""] * 31, ], instructions=[ Timer( timer_type="on_delay", done_bit="T1", current="TD1", setpoint="1000", unit="Tms", ), "", ], comment=None, ) binary = encode(rung) ``` Tall instructions such as timers, copy-family instructions, shift, drum, and some counters occupy multiple visual rows. `encode()` accepts the fully expanded rows directly. When you come from canonical CSV, `read_csv()` handles the padding and pin-row shaping for you. ## Comments Comments are plain text with optional markdown-style inline formatting: ``` rung.comment = "Motor start circuit" rung.comment = "**Bold** and _italic_ and __underlined__" rung.comment = "Line one\nLine two" ``` The comment body limit is 1400 bytes after RTF encoding. For multi-row rungs the practical limit can be lower; see [binary format](https://ssweber.github.io/laddercodec/internals/binary-format/#comment-sizing). ## Multi-Rung Encoding Pass a list of `Rung` objects to `encode()`: ``` from laddercodec import Rung, encode rungs = [ Rung(logical_rows=1, conditions=[[""] * 31], instructions=["NOP"], comment=None), Rung(logical_rows=1, conditions=[[""] * 31], instructions=["NOP"], comment="Second rung"), ] binary = encode(rungs) ``` ## Nickname Display Click can show project-level tag names (nicknames) instead of raw addresses in math instruction blocks. Pass `show_nicknames=True` to `encode()`: ``` binary = encode(rungs, show_nicknames=True) ``` This sets the nickname display flag on every math instruction in the buffer. Click resolves the actual tag names from its own project nickname table — the codec doesn't need to know the mappings. The nicknames must already be loaded in the Click project before pasting. If a referenced address has no nickname, Click falls back to showing the raw address for that operand. ## Supported Instructions All standard Click instruction families are supported from the root package: - Condition side: `Contact`, `CompareContact` - AF side: `Coil`, `Timer`, `Counter`, `Copy`, `BlockCopy`, `Fill`, `Pack`, `Unpack`, `Math`, `Shift`, `Search`, `Drum`, `Call`, `Return`, `End`, `ForLoop`, `Next`, `Send`, `Receive` Unknown AF blobs can still be preserved via `RawInstruction`, which keeps the binary payload intact for lossless round-trip. # 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): ``` 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: ``` 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 has unexpected extra bytes, the blob length formula is probably wrong for that instruction type. **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 ### 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. # Internals # Binary Format This page documents the Click clipboard binary format as reverse-engineered from native captures. All offsets are hexadecimal. ## Buffer layout The clipboard buffer has these regions: ``` 0x0000 +-----------------------+ | Global header | Fixed template data. Not modified by the encoder. 0x0254 +-----------------------+ | Program header | Row count word at +0x00. 0x0260 +-----------------------+ | Rung 0 preamble | Comment flag +0x30, length +0x34. 0x0298 +-----------------------+ | Payload region | Comment RTF body (variable length, may be empty). | | When empty, this region is zero-length and the | | grid starts immediately at 0x0A60. 0x0A60 +-----------------------+ <-- grid start (in no-payload buffer) | Cell grid | 32 cells/row. Wire-only rows: 0x800 bytes/row. | | Instruction rows are larger (variable-length cells). | | Pushed forward by payload_len when a comment exists. +-----------------------+ | Page padding | Zero-filled to next 0x1000 (4096) boundary. +-----------------------+ ``` ## Global header (0x0000–0x0253) Fixed template data loaded from the scaffold binary. Contains GUI state and format markers. The encoder does not modify this region. ## Program header (0x0254–0x025F) A 12-byte structure immediately before the rung 0 preamble: | Offset | Size | Field | Value | | ------ | ---- | -------- | ------------------------------------- | | +0x00 | 2B | row_word | `total_grid_rows * 0x20` | | +0x02 | ... | (other) | GUI state, not load-bearing for paste | `total_grid_rows` includes data rows for all rungs, preamble rows for rungs 1+, and one terminal row. For a single N-row rung: `total_grid_rows = N + 1`. ## Rung preamble Every rung has a 0x40-byte preamble that holds its comment data: | Rung | Location | | -------- | --------------------------------------------------------- | | Rung 0 | Fixed at 0x0260 (between program header and cell grid) | | Rung N>0 | Cell 0 of the preamble row preceding the rung's data rows | Comment fields within the preamble: | Offset | Size | Field | | ------ | ---- | ------------------------------- | | +0x30 | 1B | Comment flag (1 = has comment) | | +0x34 | 4B | Comment body length (uint32 LE) | | +0x38 | var | Comment body (RTF) | ## Payload region and the push model When rung 0 has a comment, the RTF body is inserted at 0x0298 (preamble +0x38). This **pushes the cell grid forward** by `payload_len` bytes — everything after the insertion point shifts. The encoder builds the grid as a byte blob (concatenated cell objects) appended to the header. The insertion at 0x0298 pushes the grid bytes forward by `payload_len`, so everything lands at the correct absolute addresses in the final buffer. The final buffer is padded to the next 0x1000 boundary. For wire-only rungs, the pre-padding size is `GRID_FIRST_ROW_START + rows * GRID_ROW_STRIDE + payload_len`. Instruction cells are variable-length, so rows with instructions exceed the 0x800-byte baseline. ## Comment sizing - Maximum comment body: 1400 bytes (enforced by the encoder) - The practical limit per row count depends on where `minimal_end + payload_len` crosses a page boundary - Example: 2-row rung — body up to 1324 bytes stays at 0x2000 total; 1325+ bumps to 0x3000 ### RTF envelope Comments are stored as RTF with a fixed prefix and suffix: ``` {\rtf1\ansi\ansicpg1252\deff0\deflang1033{\fonttbl{\f0\fnil\fcharset0 Arial;}} \viewkind4\uc1\pard\fs20 \par } ``` Body encoding: - Plain text: cp1252-encoded directly - Bold: `{\b text}` - Italic: `{\i text}` - Underline: `{\ul text}` - Multi-line: `\par` between lines (not `\line`) ## Cell grid The cell grid starts at 0x0A60 (before any payload push). Each row has 32 cells. Wire-only rows are 0x800 bytes (32 x 0x40). Rows with instruction cells are larger because instruction cells are variable-length. Columns 0–30 are condition columns (A–AE); column 31 is the AF (output) column. ### Cell structure (wire/blank cells) Wire and blank cells are exactly 0x40 bytes: a 0x25-byte header, 0x0B bytes of padding, and a 16-byte tail. **Header (0x25 bytes):** | Offset | Size | Field | | ---------- | ---- | ------------------------------------------------------------------------------ | | +0x00 | 1B | Always 0x00 | | +0x01 | 4B | Column index (uint32 LE) | | +0x05 | 4B | Row byte (uint32 LE, `global_row + 1`) | | +0x09 | 1B | Row span (0x01 for single-row, 0x02+ for multi-row AF) | | +0x0A | 1B | Visual sub-rows (0x01 for single-row, 0x02+ for timers) | | +0x0B–0x0C | 2B | Structural: +0x0C = 0x01 in wire-only rungs, 0x00 in instruction-bearing rungs | | +0x0D | 4B | Instruction index (int32 LE; 0xFFFFFFFF for data cells) | | +0x11 | 4B | Structural flag (always 0x00000001) | | +0x15 | 4B | Enable/contact flag (uint32 LE) | | +0x19 | 4B | Segment flag (uint32 LE) | | +0x1D | 4B | Right flag (uint32 LE) | | +0x21 | 4B | Down flag (uint32 LE) | **Tail (16 bytes, at +0x30):** | Offset | Field | | ------ | -------------------------------------------------------------------- | | +0x08 | Marker (0x01 for condition cells and non-last-row AF data cells) | | +0x09 | Rung index | | +0x0C | Instruction count (AF data cell, last row of last rung only) | | +0x0D | Row hint (condition: `local_row + 1`; AF last rung: `local_row + 2`) | ### Cell structure (instruction cells) Instruction cells are **composite**: they carry wire flags (right=1 for contacts, right+down for T-junction contacts) with instruction data layered on top. The cell is variable-length: 0x25-byte header + instruction blob + 16-byte tail. See [instruction blobs](https://ssweber.github.io/laddercodec/internals/instruction-blobs/index.md) for the blob format. ### Cell boundary detection To walk a variable-length grid, detect cell boundaries by signature: - `+0x00 == 0x00` - `+0x01 == col` (expected column index) - `+0x05 == row_byte` (expected row) - `+0x09 == 0x01` - `+0x0A == 0x01` Do **not** use `+0x0D` for detection — it varies between 0x00, 0x01, and 0xFF across cell types. ## Multi-rung format Multi-rung buffers are **not** concatenated single-rung buffers. They share a single global header and program header, with interleaved data and preamble rows in one cell grid: ``` [rung 0 data rows] [rung 1 preamble row] [rung 1 data rows] [rung 2 preamble row] ... [terminal row] ``` The program header's row_word reflects the total grid row count across all rungs. Only rung 0's comment payload lives in the payload region (pushed at 0x0298). Rung N>0 comments are stored inline in their preamble row's cell 0, at the same +0x30/+0x34/+0x38 offsets. ## Page alignment All buffers are padded with zero bytes to the next 0x1000 (4096 byte) boundary. The minimum buffer size for a 1-row rung with no comment is 0x2000 (8192 bytes). ## Empty multi-row synthesis Empty rung buffers for N rows (1..32) are synthesized deterministically from a minimal scaffold binary. Payload length formula: ``` payload_len = 0x1000 * ((rows + 1) // 2 + 1) ``` This produces the correct buffer for any row count without needing 32 separate template files. ## Instruction blob tag wire types Instruction blobs use tagged fields where each tag is a 2-byte LE value. The tag's **high byte** encodes the wire type — i.e. how many bytes follow the tag and how to interpret them: | High byte | Wire type | Payload | | ---------------- | -------------- | ------------------------------------------------------------------------------------- | | 0x11, 0x12 | flag | No payload (tag presence is the signal) | | 0x20, 0x21, 0x22 | byte | 1 byte | | 0x32 | u16 | 2 bytes (uint16 LE) | | 0x3A | variant_u16 | Sequence of `[uint16 index, uint16 value]` pairs, terminated by 0xFFFF | | 0x60, 0x61, 0x62 | string | `[1B length][UTF-16LE value]` | | 0x68 | variant_string | Sequence of `[uint16 index, 1B length, UTF-16LE value]` entries, terminated by 0xFFFF | This rule applies identically to both clipboard and SCR instruction blobs — the tag IDs, wire types, and operand values are the same in both formats. Only the framing differs (see below). ## Clipboard vs SCR framing The instruction blob content (tag IDs, operand values, wire types) is **identical** between clipboard and SCR formats. The difference is how blobs are framed: **Clipboard:** blobs are embedded in the cell grid. Each instruction cell is a 0x25-byte cell header + blob + 16-byte cell tail. The blob boundary is found by scanning tagged fields (no explicit length). The 4-byte sentinel `FFFFFFFF` precedes each string value. **SCR (Scr\*.tmp):** blobs are stored in instruction sections with explicit framing. Each blob has embedded cell-header fields and an `end_offset` pointer: ``` [1B class_name_len][UTF-16LE class_name][2B type_code] [1B row_span][1B pad][2B structural][2B instr_index][1B visual_sub_rows] [visual_sub_rows counting bytes][4B end_offset] [tagged fields...] ``` The embedded fields at offsets +0 through +6 after the type code correspond to clipboard cell header offsets +0x09 through +0x10. The `end_offset` is an absolute file pointer to the blob boundary — the same boundary that clipboard's `find_blob_boundary()` derives by scanning tags. Tags use length-prefixed strings (no `FFFFFFFF` sentinel). ## SCR row-topology blocks SCR files store per-rung wire topology in structured blocks that precede each rung's instruction section. Each block encodes the right-wire and segment-flag data that clipboard stores per-cell in the grid. ``` [2B row_word][03 00 00] -- 5-byte header [leading-row wire blocks...] -- rows before row 0 (count_down bridge) [1B af_segment][1B entry_count][00] -- 3-byte trailer [entry_count x (1B seg_flag, 1B col_idx)] -- row 0 flag table [continuation-row wire blocks...] -- rows 1..N-1 [20 00] -- end marker [wire_down data] -- per-column vertical wire indices ``` Each continuation-row wire block: ``` [1B seg][1B right_count][00 00][pairs of (col_idx, next_seg)...][final_col] ``` The block is parsed forward: leading-row blocks are consumed until the 3-byte trailer is found, then the row-0 flag table, then continuation rows, then the `0x0020` marker, then wire-down data. # Instruction Blobs This page documents the variable-length instruction data that follows the cell header in instruction cells. The blob starts at cell offset +0x25. For field layouts, func code tables, and tag constants for each known instruction type, see the source in `src/laddercodec/instructions/` — each module (`contact.py`, `comparison.py`, `coil.py`, `timer.py`) is the authoritative reference. ## Generic blob structure All instruction blobs follow the same pattern: ``` [UTF-16LE class name, null-terminated] [type marker: uint32 LE, high byte always 0x27] [part count: uint16 LE (0x01 for contacts/coils, 0x02 for timers)] [extra bytes: (part_count - 1) bytes, if part_count > 1] [field count: uint32 LE] [tagged fields...] ``` ### Tagged fields Each field is: ``` [2-byte tag] [FF FF FF FF sentinel] [UTF-16LE null-terminated value] ``` Field values are string-encoded even for numeric data (e.g. `"1000"` for a timer preset). Timer fields 6–7 use a variant format with a 4-byte sub-marker instead of the `FF FF FF FF` sentinel. ## Known binary class names | Class name | Type markers | Instruction | Source | | ----------- | ------------------------ | -------------------------------------------- | --------------- | | `ContactNO` | 0x2711 (NO), 0x2712 (NC) | NO/NC contacts | `contact.py` | | `Edge` | 0x2713 | Rising/falling edge contacts | `contact.py` | | `Compare` | 0x2714 | Comparison contacts (==, !=, >, \<, >=, \<=) | `comparison.py` | | `Out` | 0x2715, 0x2716, 0x2717 | All coil types (out, latch, reset) | `coil.py` | | `Tmr` | 0x2718 | Timers (on_delay, off_delay) | `timer.py` | The encoder uses `Out` as the class name for all coil types; the type marker and func code determine the variant. The decoder also recognizes `Latch` and `Reset` class names from native Click captures. ## Math nickname flag Math blobs contain a `nickname_flag` field (tag `0x2224`). When set to `"1"`, Click displays project-level tag names instead of raw addresses in the math formula. The flag is purely a display hint — the expression itself always stores concrete addresses. The `encode()` API exposes this via `show_nicknames=True`, which sets the flag on all math instructions in the buffer. ## Blob boundary detection For unknown instruction types, the blob boundary can be detected using the generic multi-part formula: 1. Read the class name (UTF-16LE, null-terminated) 1. Read the type marker (uint32 LE) 1. Read part count (uint16 LE), skip `(part_count - 1)` extra bytes 1. Read field count (uint32 LE) 1. Walk through tagged fields (each: 2B tag + 4B sentinel/marker + UTF-16LE value) 1. The blob ends after all fields are consumed The `RawInstruction` fallback uses this formula to capture the complete blob as opaque bytes, enabling round-trip for unsupported instruction types via `raw(ClassName,hex)` CSV tokens. At the moment, `Email`, `Home`, `Position`, and `Velocity` still deliberately travel through this raw path. They round-trip correctly, but they are not yet modeled as dedicated DSL instruction classes. # Wire Rendering This page documents how Click renders wires in the ladder grid and how the encoder maps CSV wire tokens to binary flag bytes. ## Wire flag bytes Each cell has three flag bytes at fixed offsets: | Offset | Name | Purpose | | ------ | ------- | ---------------------------------- | | +0x19 | Segment | Branch zone membership (see below) | | +0x1D | Right | Horizontal connection to the right | | +0x21 | Down | Vertical connection downward | Wire tokens are classified by (right, down) only — the segment flag is independent: | Right | Down | Token | Meaning | | ----- | ---- | --------- | ------------------------------ | | 1 | 0 | `-` | Horizontal wire | | 0 | 1 | `\|` | Vertical down | | 1 | 1 | `T` | Branch junction (right + down) | | 0 | 0 | *(blank)* | No wire | Instruction cells (contacts, coils) also set right=1 — they behave like horizontal wires with instruction data layered on top. ## Left-edge rendering Click renders the T's down-wire at the **left edge** of the cell, not the center. This means two cells can connect to a single T's down-wire — one from each side of that edge: 1. **Same-column (DOWN):** A `-` directly below a T connects via the standard vertical edge. 1. **Diagonal (UP/RIGHT):** A `-` one column to the LEFT and one row BELOW connects UP one row and ONE COLUMN TO THE RIGHT to the T. The `-`'s right-wire meets the T's down-wire at the shared cell boundary. ``` Connected: Not connected: R, -, T, T, -, -, out(Y1) R, -, T, T, -, -, out(Y1) , -, -, -, -, -, out(Y2) , -, , -, -, -, out(Y2) ^ ^ B has "-" = bridge B is blank = gap ``` In the not-connected case: row 1 column A connects UP/RIGHT to T@B (rule 2), row 1 column C connects UP to T@C (rule 1), and the blank at B is the gap that keeps the two branches independent. ## Segment flag The segment flag (+0x19) determines branch zone membership. Getting it wrong causes contacts and wires to visually shift down to their own row in Click's editor. ### Boundary rules The encoder computes a per-row boundary column: **Row 0** is exempt — boundary=0, all non-blank cells get seg=1. **Row R (R > 0):** 1. Start with boundary = 0 1. From row R-1 only: `T` at column C → boundary = max(boundary, C+2); `|` at column C → boundary = max(boundary, C+1) 1. From rows 0..R-1: `Contact`/`CompareContact` at column C → boundary = max(boundary, C+2) 1. Non-blank cells at col < boundary get seg=0; at col >= boundary get seg=1 1. Blank cells and `|` cells are always seg=0 ### AF column segment rules Single-rung buffers: | Cell type | Row | Segment | | ------------- | --- | ------- | | Coil | 0 | 1 | | Coil | 1+ | 0 | | Timer | any | 0 | | NOP data cell | any | 1 | In multi-rung buffers, all AF instruction cells get segment=0 regardless of type or row. ### Note on native captures Click's native segment flags track **editor creation order** — "insert row above" keeps the original row exempt rather than row 0. The encoder always treats row 0 as exempt, matching top-down construction. Native captures built via "insert row above" will show a different exempt row; don't use those for segment flag validation. ## Instruction index Click stores a per-cell instruction index at +0x0D, but the value reflects **editor creation order**, not a structural rule. Click accepts any ordering on paste. The encoder uses a deterministic scheme: condition-side instructions numbered in row-major column-major order, then AF-side instructions in row order. ## Validation constraints - `T` and `|` tokens are rejected on the **last row** (vertical-down has nowhere to go) - `T` and `|` tokens are rejected on **column A** (leftmost condition column) - At most one `NOP` per rung # API Reference # API Reference This section is generated from an explicit, versioned public API manifest. ## Stable Core Pages - [Codec API](https://ssweber.github.io/laddercodec/reference/api/codec/index.md) - [CSV I/O API](https://ssweber.github.io/laddercodec/reference/api/csv/index.md) - [Instructions API](https://ssweber.github.io/laddercodec/reference/api/instructions/index.md) # Codec API **Tier:** Stable Core Encode and decode Click clipboard binary and program files. ## laddercodec.encode Ladder rung encoder — unified pipeline. #### Definitions Rung One logical unit of ladder logic. A rung has 1..32 rows, each with 31 condition columns (A..AE) and 1 output column (AF). The rung may also carry a single plain-text comment. Row A horizontal slice of the rung grid. Row 0 is the topmost visible row. The grid starts at absolute offset 0x0A60 with a stride of 0x800 per row (32 columns x 64 bytes per cell). Cell A 64-byte (0x40) block within the grid. Addressed by (row, column). Contains wire flags, structural control bytes, and (for instruction- bearing rungs) stream-placement metadata. Wire topology The arrangement of horizontal wires ("-"), vertical pass-throughs ("|"), and junction-down points ("T") across the condition grid. Encoded via three per-cell flag bytes: +0x19 (segment), +0x1D (right), +0x21 (down). Conditions also set these flags (like "-"). Condition An instruction placed on a condition column (A..AE). Includes `Contact` (NO/NC/edge) and `CompareContact` (GT/GE/LT/LE/EQ/NE). Condition cells are variable-length: a 0x25-byte header (wire flags, column, row, instruction index) followed by an instruction blob (class name, type marker, operand, func_code). Passed as `Contact` or `CompareContact` objects in the condition_rows grid. NOP The simplest AF-column instruction. At most one per rung. Encoded via a minimal byte model: col31 +0x1D = 1 (all rows), plus col0 +0x15 = 1 for non-first rows. Does not require an instruction stream entry. Comment Plain-text annotation on a rung. Stored as an RTF envelope (fixed prefix + cp1252 body + fixed suffix) in the payload region at 0x0298. The 4-byte payload length sits at 0x0294. The cell grid (always at 0x0A60 in a no-payload buffer) is pushed forward by payload_len bytes after insertion. Max 1400 bytes. Page A 0x1000 (4096) byte allocation unit. Buffer size is: pad_to_page(0x0A60 + logical_rows * 0x800 + payload_len). Program header A single 0x40-byte structure at 0x0254. Contains the row-count word (+0x00/+0x01 = (rows+1)\*0x20) and other GUI state. Not a 32-entry table — the range 0x0294–0x0A5F is the payload region. #### Supported checklist Verified in Click (encode → paste → copy back → decode round-trip): ``` [x] Empty rung, 1..32 rows [x] Wire topology, 1..32 rows (-, |, T in any valid position) [x] NOP on any row (with col0 +0x15 enable for non-first rows) [x] Plain comment, 1-row (empty, wire, NOP, max 1400 bytes) [x] Plain comment, 2-row (empty, NOP, wire incl. col-A, max 1324) [x] Plain comment, 3-row (empty, NOP, wire, mixed, max 1400) [x] Plain comment, 4..32 rows (wire combos, scaling) [x] Multi-line comment (\n → \par; verified 2026-03-12) [x] Styled comments (bold/italic/underline via markdown → RTF groups; verified 2026-03-12) [x] Contacts (NO, NC, edge, immediate — via Contact objects in condition_rows) [x] Coils (out, latch, reset, immediate, range — via Coil objects in af_tokens) [x] Comparison contacts (GT, GE, LT, LE, EQ, NE — via CompareContact objects) [x] Timers (on_delay, off_delay, retentive — via Timer objects in af_tokens) [ ] Full AF instruction set (counters, math, etc.) ``` #### Pipeline steps ``` 1. Header — load from synthesize_empty_multirow (includes row_word) 2. Grid — build 32 cell objects per row (wire flags + NOP baked in), concatenate to form the grid bytes 3. Assemble — header[:0x0A60] + grid_bytes 4. Comment — assemble RTF, insert at 0x0298, push grid forward 5. Pad — to next 0x1000 page boundary ``` Cell objects are bytes blobs built by `ClickCell.to_bytes()`. Wire cells are 0x40 bytes. Instruction cells (contacts, coils, timers) are larger, and the concatenation model handles variable-length cells naturally — no fixed-offset assumptions in the grid. ### encode_rung ``` encode_rung( logical_rows: int, condition_rows: Sequence[Sequence[ConditionToken]], af_tokens: Sequence[AfToken], comment: str | None = None, *, show_nicknames: bool = False, ) -> bytes ``` Encode a ladder rung to binary payload. ##### Parameters logical_rows: Number of rung rows (1..32). condition_rows: Row-major token grid. Each row has 31 condition-column entries. Supported: `""` blank, `"-"` horizontal wire, `"|"` vertical pass-through, `"T"` junction-down, or a `Contact` object. af_tokens: One per row. `"NOP"` encodes the NOP instruction on the AF column; `""` leaves it blank; a `Coil` object encodes the coil instruction. comment: Optional comment text (max 1400 bytes after RTF encoding). Stored as an RTF envelope inserted at 0x0298; the cell grid is pushed forward by payload_len bytes automatically. ``` Inline styles (markdown syntax): ``**text**`` → bold ``__text__`` → underline ``*text*`` → italic (asterisk) ``_text_`` → italic (underscore) Line breaks: ``\n`` becomes an RTF paragraph break (``\par``). ``\r\n`` and bare ``\r`` are normalized to ``\n`` first. ``` ##### Returns bytes Encoded binary payload ready for the target environment. ### encode ``` encode(rungs, *, show_nicknames: bool = False) ``` Encode one or more rungs to clipboard binary. ##### Parameters rungs: A single `Rung` object (single-rung encode) or a sequence of `Rung` objects (multi-rung encode). show_nicknames: When `True`, sets the nickname display flag on math instructions so Click shows project-level tag names instead of raw addresses. The nicknames must already be loaded in the Click project before pasting. ##### Returns bytes Encoded binary payload. ## laddercodec.decode Ladder rung decoder — binary clipboard buffer to structured data. Reads a Click clipboard binary and produces the same data structures that feed `encode_rung()` / `encode_rungs()`. #### Public API ``` decode_rung(data) -> Rung decode_rungs(data) -> list[Rung] ``` Round-trip identity: ``` decode_rung(encode_rung(lr, cr, af, cmt)) .logical_rows == lr .conditions == cr .instructions == af .comment == cmt ``` #### Instruction cells Contacts and coils are composite: a horizontal wire (`(1,1,0)`) with instruction data layered on top. The instruction payload starts at cell offset `+0x25` (UTF-16LE class name, type marker, operand, func code). Wire-only cells are exactly `0x40` bytes; instruction cells are larger. Known instruction types are decoded into domain objects (`Contact`, `Coil`, `Timer`, etc.) from `instructions/`. Unrecognised cells fall back to `UnknownCondition` / `UnknownInstruction` with raw bytes preserved. ### DecodeError Bases: `ValueError` Raised when a clipboard binary cannot be decoded. ### Rung Structured rung data — used for both decode output and encode input. ##### Attributes logical_rows: Number of rung rows (1..32). conditions: Row-major token grid. Each row has 31 condition-column entries. Wire-only cells are strings (`""` blank, `"-"` horizontal, `"|"` vertical, `"T"` junction-down). Contacts are `Contact` objects; unrecognised cells are `UnknownCondition`. instructions: One per row. `"NOP"` or `""` for wire-only cells. Coils are `Coil` objects; unrecognised cells are `UnknownInstruction`. comment: Markdown text (for CSV export), or `None`. comment_rtf: Raw RTF payload bytes, or `None`. Preserves byte-exact fidelity for re-encoding. ### CellDump Raw byte dump of a single cell, for RE/debugging. ##### Attributes rung: Rung index (0-based). row: Visual row within the rung (0-based). col: Column letter ("A".."AE" or "AF"). offset: Absolute byte offset in the buffer. size: Cell size in bytes (0x40 for wire-only, larger for instructions). raw: Raw cell bytes. flags: Flags `(segment, right, down)` read from `+0x19/+0x1D/+0x21`. token: Decoded wire/instruction token, or `None` if not decoded. #### hex ``` hex(cols: int = 16) -> str ``` Return a formatted hex dump with offset labels. ### decode ``` decode(data: bytes) -> Rung | list[Rung] ``` Decode a clipboard binary. Returns Rung for single-rung, list[Rung] for multi-rung. ##### Parameters data: Raw clipboard bytes (page-aligned, starts with `CLICK` magic). ##### Returns Rung | list[Rung] A single `Rung` for single-rung buffers, or a list of `Rung` objects for multi-rung buffers. ##### Raises DecodeError If the buffer is invalid. ### decode_rung ``` decode_rung(data: bytes) -> Rung ``` Decode a single-rung clipboard binary. ##### Parameters data: Raw clipboard bytes (page-aligned, starts with `CLICK` magic). ##### Returns Rung Decoded rung data matching `encode_rung()` input contract. ##### Raises DecodeError If the buffer is invalid or contains multiple rungs. ### decode_rungs ``` decode_rungs(data: bytes) -> list[Rung] ``` Decode a multi-rung clipboard binary. ##### Parameters data: Raw clipboard bytes (page-aligned, starts with `CLICK` magic). ##### Returns list[Rung] One entry per rung, in order. ##### Raises DecodeError If the buffer is invalid or contains only a single rung. ### inspect_cells ``` inspect_cells( data: bytes, cells: list[tuple[int, int, str]] ) -> list[CellDump] ``` Dump raw bytes for specific cells in a clipboard binary. ##### Parameters data: Raw clipboard bytes. cells: List of `(rung_index, visual_row, column_letter)` tuples. Column is `"A"`..`"AE"` for conditions or `"AF"` for output. ##### Returns list[CellDump] One entry per requested cell, in the same order as *cells*. Example:: ``` dumps = inspect_cells(raw, [(0, 1, "A"), (0, 1, "B")]) for d in dumps: print(d) ``` ## laddercodec.decode_program Program file decoder — Scr\*.tmp binary to structured Rung data. Reads Click Programming Software's internal temp files and produces the same `Rung` objects as the clipboard decoder. #### Public API ``` decode_program(data) -> Program ``` The SCR format is compact (~17x smaller than clipboard) and represents the full program as stored on disk. Instruction tag IDs and operand values are identical to clipboard format — only the framing differs. ### decode_program ``` decode_program(data: bytes) -> Program ``` Decode an SC-SCR temp file into a Program. ##### Parameters data: Raw bytes of a `Scr*.tmp` file (starts with `SC-SCR` magic). ##### Returns Program Program with name, prog_idx, and rungs parsed from the file. ##### Raises ValueError If the file cannot be parsed. ## laddercodec.Rung Structured rung data — used for both decode output and encode input. #### Attributes logical_rows: Number of rung rows (1..32). conditions: Row-major token grid. Each row has 31 condition-column entries. Wire-only cells are strings (`""` blank, `"-"` horizontal, `"|"` vertical, `"T"` junction-down). Contacts are `Contact` objects; unrecognised cells are `UnknownCondition`. instructions: One per row. `"NOP"` or `""` for wire-only cells. Coils are `Coil` objects; unrecognised cells are `UnknownInstruction`. comment: Markdown text (for CSV export), or `None`. comment_rtf: Raw RTF payload bytes, or `None`. Preserves byte-exact fidelity for re-encoding. ## laddercodec.Program A single PLC program (main or subroutine) with its rungs. ## laddercodec.Project A complete PLC project: main program plus subroutines. # CSV I/O API **Tier:** Stable Core Read and write Click Ladder CSV files. ## laddercodec.read_csv ``` read_csv( path: Path | str, *, strict: bool = True ) -> list[Rung] ``` Read any Click Ladder CSV and return one `Rung` per rung. Handles both single-rung and multi-rung CSV files in a single call. Each `Rung` is a dataclass with attributes `logical_rows`, `conditions`, `instructions`, `comment`, `comment_rtf`:: ``` rungs = read_csv(path) r = rungs[0] r.logical_rows # int r.conditions # list[list[ConditionToken]] r.instructions # list[AfToken] r.comment # str | None ``` Returns the exact data needed for `encode_rung()`. Instruction tokens (e.g. `X001`, `out(Y001)`) are parsed into `Contact` / `Coil` objects; wire tokens remain as strings. When *strict* is `False`, unsupported AF instructions are silently replaced with blank tokens instead of raising. ## laddercodec.write_csv ``` write_csv(path: Path | str, rungs: list[Rung]) -> None ``` Write decoded rungs to a canonical CSV file. #### Parameters path: Output file path. rungs: One or more decoded rungs (from `decode_rung()` or `decode_rungs()`). #### Raises WriterError If any rung contains unknown instructions that cannot be serialized. # Instructions API **Tier:** Stable Core Domain objects for ladder logic instructions. ## laddercodec.Contact Bases: `ConditionInstruction` A contact instruction (NO or NC). ### from_csv_token ``` from_csv_token(token: str) -> Contact ``` Parse NO/NC/immediate forms plus edge forms `rise(...)`/`fall(...)`. Also accepts wire-down prefix: `T:X001`, `T:rise(X002)`. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this contact cell. ## laddercodec.CompareContact Bases: `ConditionInstruction` A comparison contact (EQ, NE, GT, LT, GE, LE). Occupies a condition column. The binary class name is `Compare` (type marker 0x2714). ### from_csv_token ``` from_csv_token(token: str) -> CompareContact ``` Parse `DS1==1`, `DS2>=DS3`, etc. Also accepts wire-down prefix: `T:DS1==1`. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this compare contact cell. ## laddercodec.Coil Bases: `AfInstruction` An output coil instruction. ### from_csv_token ``` from_csv_token(token: str) -> Coil ``` Parse coil forms with inner immediate wrapper and ranges. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this coil cell. ## laddercodec.Timer Bases: `AfInstruction` A timer instruction (on_delay / off_delay). Occupies the AF column. The binary class name is `Tmr` (type marker 0x2718). Each timer rung adds an extra grid row for the timer's visual height. ### enable_func_code ``` enable_func_code: str ``` Compute the enable func_code: base + offset. ### reset_func_code ``` reset_func_code: str ``` Compute the reset func_code (retentive only; "0" otherwise). ### from_csv_token ``` from_csv_token(token: str) -> Timer ``` Parse v1 timer: `on_delay(T1,TD1,preset=1000,unit=Tms)`. Positional: done_bit, current. Kwargs: preset, unit. Retained is never in the CSV — it's set by `.reset()` pin presence. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this timer cell. ## laddercodec.Counter Bases: `AfInstruction` A counter instruction (count_up / count_down). ### from_csv_token ``` from_csv_token(token: str) -> Counter ``` Parse v1 counter: `count_up(CT1,CTD1,preset=100)`. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this counter cell. ## laddercodec.Copy Bases: `AfInstruction` A single copy instruction. #### Attributes source: Source operand or literal (e.g. `"DS7"`, `"42"`, `"3.14"`). destination: Destination operand (e.g. `"DS8"`). format: Copy modifier — `"none"`, `"value"`, `"text"`, `"binary"`, or `"ascii"`. oneshot: Execute once on OFF→ON transition. suppress_zero: Text option — `"0"` (don't suppress) or `"1"` (suppress). exponential: Text option — `"0"` (real) or `"1"` (exponential). termination_code: Text option — `"0"` (none) or `"$HH"` hex ASCII code (e.g. `"$13"` for 0x13). ### from_csv_token ``` from_csv_token(token: str) -> Copy ``` Parse `copy(source, dest)` with optional `convert=to_*` modifier. Examples:: ``` copy(DS7,DS8) copy(42,DS9) copy(DS26,DS27,oneshot=1) copy(TXT1,DS10,convert=to_value) copy(DS12,TXT2,convert=to_text(suppress_zero=1,exponential=0,termination_code=none)) ``` ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this copy cell. ## laddercodec.BlockCopy Bases: `AfInstruction` A block copy instruction (copy_type_idx=1). Copies a contiguous range of source registers to a destination range. #### Attributes source_start, source_end: Source range (e.g. `"DS28"` .. `"DS31"`). dest_start, dest_end: Destination range. format: `"none"` or `"value"` (convert to numeric value). oneshot: Execute once on OFF→ON transition. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this block copy cell. ## laddercodec.Fill Bases: `AfInstruction` A fill instruction (copy_type_idx=2). Fills a contiguous range of destination registers with a single value. #### Attributes value: Source value — literal (`"0"`) or tag (`"DS56"`). dest_start, dest_end: Destination range. oneshot: Execute once on OFF→ON transition. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this fill cell. ## laddercodec.Pack Bases: `AfInstruction` A pack instruction (copy_type_idx=3). Packs a range of source registers into a single destination. #### Attributes source_start, source_end: Source range (e.g. `"C108"` .. `"C123"`). destination: Destination operand (e.g. `"DS167"`). pack_type: `"bits"`, `"words"`, or `"text"`. allow_whitespace: Text option — allow whitespace in text packing. oneshot: Execute once on OFF→ON transition. ## laddercodec.Unpack Bases: `AfInstruction` An unpack instruction (copy_type_idx=4). Unpacks a single source into a range of destination registers. #### Attributes source: Source operand (e.g. `"DS172"`). dest_start, dest_end: Destination range (e.g. `"C128"` .. `"C143"`). unpack_type: `"bits"` or `"words"`. oneshot: Execute once on OFF→ON transition. ## laddercodec.Shift Bases: `AfInstruction` A shift register instruction. ### from_csv_token ``` from_csv_token(token: str) -> Shift ``` Parse `shift(C99..C106)`. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this shift register. ## laddercodec.Search Bases: `AfInstruction` A search instruction. #### Attributes table_start: Start of search table (e.g. `"DS72"`). table_end: End of search table (e.g. `"DS81"`). source: Value to search for — register (`"DS71"`) or literal (`"A"`). result: Destination register for the result index. found: Found flag bit (e.g. `"C81"`). comparison: Comparison operator: `"=="`, `"!="`, `">"`, `"<"`, `">="`, `"<="`. continuous: Search continuously (every scan). oneshot: Execute once on OFF→ON transition. ## laddercodec.Drum Bases: `AfInstruction` A drum sequencer instruction (event_drum / time_drum). Occupies the AF column. The binary class name is `Drum` (type marker 0x271B). Each drum rung takes 4 grid rows: row 0 = main, row 1 = reset, row 2 = jump, row 3 = jog. ### build_blob ``` build_blob() -> bytes ``` Build the instruction data blob for this drum cell. ## laddercodec.Math Bases: `AfInstruction` A math instruction. #### Attributes expression: The formula in Click syntax (e.g. `"DS65 + DS66"`). Spaces around operators, uppercase function names. result: Destination register (e.g. `"DS67"`). mode: `"decimal"` or `"hex"`. oneshot: Execute once on OFF→ON transition. ## laddercodec.Call Bases: `AfInstruction` Subroutine call instruction. ## laddercodec.Return Bases: `AfInstruction` Return instruction. ## laddercodec.End Bases: `AfInstruction` Program terminator instruction. ## laddercodec.ForLoop Bases: `AfInstruction` FOR loop instruction. ## laddercodec.Next Bases: `AfInstruction` NEXT loop instruction. ## laddercodec.Send Bases: `AfInstruction` A Modbus Send (SD) instruction. ## laddercodec.Receive Bases: `AfInstruction` A Modbus Receive (RD) instruction. ## laddercodec.ModbusAddress Non-CLICK remote address (MODBUS 984 or hex). ## laddercodec.ModbusRtuTarget RTU serial target. ## laddercodec.ModbusTcpTarget TCP/IP target. ## laddercodec.RawInstruction Bases: `AfInstruction` Opaque AF instruction — blob preserved for byte-exact round-trip. #### Attributes class_name: Binary class name (e.g. `"Copy"`, `"Cnt"`). Extracted from the blob for CSV readability; also present inside *blob*. blob: Full instruction blob bytes (from cell offset +0x25 to the end of tagged fields, excluding tail). part_count: Number of parts (1 = single-row, >1 = multi-row). Derived from the blob during construction. ### cell_params ``` cell_params() -> dict ``` Return ClickCell kwargs intrinsic to this instruction. ### build_blob ``` build_blob() -> bytes ``` Return the raw blob bytes (no-op — already stored). ### to_csv ``` to_csv() -> str ``` Serialize to decoded-fields `raw(ClassName,0xTTTT,N,...)` token. Falls back to legacy hex format if decomposition fails. ### from_csv_token ``` from_csv_token(token: str) -> RawInstruction ``` Parse a raw CSV token (decoded-fields or legacy hex). Decoded-fields format:: ``` raw(ClassName,0xTTTT,N,field_specs...) ``` Legacy hex format:: ``` raw(ClassName,hex_blob) ```