# The bytecode gap

Roboteq's MicroBasic manual (V2.0, July 8, 2019 -- the current public reference
manual) is explicit that source is "interpreted into an intermediate string of
Bytecode instructions that are then downloaded and executed in the controller,"
and that compiled scripts can be exported as `.hex` from RoborunPlus and loaded
onto the drive over serial via a documented handshake:

```
PC -> drive:  %sld 321654987
drive -> PC:  HLD                 (ready for data)
PC -> drive:  <hex file, one line at a time>
drive -> PC:  +                   (after each line)
```

That handshake, and the Intel HEX container format wrapping it, are both
public and are implemented in `hexfile.py`. What is **not** public is the
bytecode itself: no opcode table, operand encoding, or program header layout
is documented anywhere in Roboteq's materials. RoborunPlus is the only known
implementation of the encoder, and it's closed source.

This means `codegen.py` cannot be written from documentation alone. It has to
be reverse-engineered.

## Proposed approach

1. **Install RoborunPlus** (free, from roboteq.com) against a real or virtual
   SDC2160 config, so scripts can be built and exported without needing
   hardware for every iteration.
2. **Build a corpus of minimal scripts**, starting from the empty script and
   changing exactly one thing at a time (e.g. add a single `Dim x As Integer`,
   then a single assignment, then a single `If`, etc). Export each to `.hex`.
3. **Diff successive `.hex` payloads** (use `hexfile.read_intel_hex` to get
   flat bytes) to isolate the bytes each incremental construct contributes.
   This is the same technique used for reverse-engineering any undocumented
   bytecode VM: differential analysis over a controlled corpus beats trying
   to read a single large dump.
4. **Build an opcode table incrementally** as constructs are identified:
   literals, variable slots, each operator, each statement type, control-flow
   targets (labels/GoTo probably become absolute or relative jump offsets --
   worth checking early since it constrains everything after it), array
   indexing, and the four builtin function categories (`GetConfig`/`SetConfig`/
   `SetCommand`/`GetValue`, timers, math functions, `Print`).
5. **Validate against a real drive** (or the RoborunPlus simulator) whenever
   possible -- a plausible-looking encoding that doesn't execute correctly is
   worse than no encoder, since it fails silently on hardware.

## Open questions to resolve early

- Program header: does the `.hex` payload start with a length/checksum/version
  header before the first real instruction, or is it instructions from byte 0?
  **Resolved for small synthetic programs (see Batches 1+2 below): no header,
  instructions start at byte 0, code runs straight into a 0xFF-padded
  region.** Still unconfirmed whether larger/real-world programs (which do
  seem to have extra leading bytes in the real corpus -- see "Real corpus
  available" below) have a real header or whether that's just more code.
- Variable addressing: are `Dim`'d variables assigned slots in declaration
  order, or does the compiler do something smarter (e.g. only allocate slots
  for variables actually used)? **Resolved: declaration order, 0-based (see
  Batches 1+2 below).**
- Jump encoding: absolute byte offset, instruction index, or relative offset?
  **Resolved: absolute byte offset into the program's own bytecode (see
  Batches 1+2 below), at least for the small forward-jump cases tested so
  far.**
- Whether the SDC2160's bytecode dialect is identical to other Roboteq
  products (the manual states "Depending on the product, programs can be from
  8192 to 32768 Bytecodes long," implying there's already at least one
  per-product difference: program size limit, and possibly others).

## Real corpus available

`X:\ESP\FIDO\PumpController\testdata` (sibling of this repo) has 8 pairs of
real-world `.mbs` source + RoborunPlus-compiled `.hex` output, spanning
revisions R0-3 through V1 of an actual pump-controller script. This is more
useful than it first looks: several adjacent revisions differ by only one or
two source-level edits, which gives clean isolated bytecode diffs for free
without having to hand-build the minimal-pair corpus described above.

`tools/hexdiff.py` (`dump` / `diff` subcommands, needs `pip install -e .`
first) decodes these `.hex` files and hexdumps or byte-diffs them.

### Confirmed findings (evidence-backed, from this corpus)

- **`Dim` emits no bytecode.** R0-3's first ~20 lines are all `Dim ... As
  Integer` declarations; the compiled payload's first bytes are already the
  first `Print` statement. Declarations are purely a compile-time slot
  allocation, not a runtime instruction.

- **String literals: `0x24 <len:u8> <raw bytes>`.** Diffing R0-7 -> R0-7A
  (source change: `print("...R0-7)")` -> `print("...R0-7A)")`, i.e. the
  literal grew by one character) shows the length byte at the same offset go
  `0x11` -> `0x12` (17 -> 18) and the string bytes shift accordingly.
  Diffing R0-7A -> R0-7Y (`"...R0-7A)"` -> `"...R0-7Y)"`, same length) shows
  a *single* byte change, `0x41` ('A') -> `0x59` ('Y'), at the exact position
  of that character -- confirming raw ASCII storage, not some transformed
  encoding.

- **`Print` literals over 32 characters are split into multiple chunks at
  compile time**, each with its own `0x24 <len> <bytes>` header, none
  exceeding 32 bytes. In R0-3, `print("\n\rStarting (R0-3-LowCurrentPressure)")`
  (36 chars including `\n\r`) compiles to a 32-byte chunk
  (`"\n\rStarting (R0-3-LowCurrentPress"`) immediately followed by a second
  chunk of the remaining 4 bytes (`"ure)"`). This lines up exactly with the
  manual's stated restriction: "The print command is therefore limited to 32
  characters per 1ms time slot." The compiler appears to enforce that limit
  at build time rather than leaving it to the runtime.

- **Small integer literals: `0x02 <value:u8>` (push int8), `0x03
  <value:u16 little-endian>` (push int16).** Diffing R0-7A -> R0-7Y (source
  change: `b=12` -> `b=2`) shows a single byte change, `0x0C` -> `0x02`, at
  the position immediately following a `0x02` opcode byte -- i.e. `12` and
  `2` themselves, one byte each. Separately, `#define rolloff (87)` in R0-6
  and `#define CONST_...` values elsewhere consistently appear as `0x03`
  followed by the value little-endian over 2 bytes (e.g. `87` decimal =
  `0x0057` -> bytes `57 00`). Not yet confirmed: the threshold for switching
  from the 1-byte to 2-byte form (presumably signed-8-bit range, i.e. -128 to
  127, with `0x03` used above that), or whether there's a 4-byte form for
  full 32-bit range.

- **First 2 bytes of the payload (`02 10` in every file checked so far)
  look like a fixed header**, not yet decoded -- possibly a program-type tag
  and/or a variable-count field (`0x10` = 16, though R0-3 declares 20
  variables, so if it's a count it isn't a simple 1:1 mapping with `Dim`
  count). Needs more samples with differing variable counts to pin down.

### Batches 1 + 2 (synthetic minimal-pair corpus) -- ISA model confirmed

`testdata/corpus/batch1/` (20 files) and `testdata/corpus/batch2/` (6 files,
built specifically to resolve batch 1's ambiguities) came back. All 26 files
now decode under a single, consistent model with **zero unexplained bytes
left in any of them** -- every byte in every file is accounted for. This is
a big step: enough of the ISA is nailed down to start writing a real (partial)
`codegen.py`.

#### Confirmed opcode table

| Bytes | Meaning |
|---|---|
| `00` | `Terminate` statement (1 byte) |
| *(implicit)* | Fixed 1-byte `00` sentinel appended after all compiled code, in every program, regardless of source (this is why a program that happens to end with `Terminate` looks like it ends in `00 00` -- it's `[Terminate][sentinel]`, two different things that happen to share the same value) |
| `01` | Operand encoding for the value/slot **zero** -- dual purpose: as an expression it means "push literal 0"; immediately before a `05`/`06` verb it means "reference variable slot 0" |
| `02 <byte>` | Operand encoding for a value/slot in **1-255** -- same dual purpose (push literal, or slot N before a `05`/`06` verb) |
| `04 <4 bytes, little-endian>` | Operand encoding for a full 32-bit value (confirmed for push; slot indices presumably never need this) |
| `05` | LOAD verb -- read the variable whose slot was just encoded |
| `06` | STORE verb -- write the variable whose slot was just encoded |
| `09` `0A` `0B` `0C` `0D` | `+` `-` `*` `/` `Mod` |
| `0F` | Unary negate |
| `14` | Increment (`++`, at least in bare-statement form) |
| `18` | Relational `=` |
| `1C` | Relational `<` |
| `03 <addr_lo> <addr_hi> <verb>` | Branch instruction family: 2-byte little-endian target address (an absolute byte offset into this program's own bytecode), then a verb byte selecting the branch type: `1F`=branch-if-false (used by `If`), `20`=unconditional (`GoTo`), `21`=`GoSub` (push return address, then jump) |
| `22` | `Return` (1 byte, no operand) |

Variable slots are allocated in `Dim` declaration order, 0-based: the first
declared variable is slot 0 (gets the compact 1-byte `01` form), the second
is slot 1, third is slot 2, etc. (general `02 <N>` form).

#### How this was confirmed

Working example -- `z = x + y` (from `10_op_add.mbs`, with `x`,`y`,`z` the
1st/2nd/3rd declared variables) decodes as:

```
02 05 01 06    x = 5        PUSH8(5) STORE(slot0)
01 05 02 01 06 y = x        LOAD(slot0) STORE(slot1)
01 05 02 01 05 09 02 02 06  z = x + y    LOAD(slot0) LOAD(slot1) ADD STORE(slot2)
00 00                       Terminate + sentinel
```

And the conditional branch in `15_if_line_eq.mbs` (`If x = y Then z = 1`)
resolves its own jump target correctly: the branch instruction reads
`03 18 00 1F` (address `0x0018` = 24, verb `1F` = branch-if-false), and byte
offset 24 in that file is exactly the `Terminate` opcode immediately after
the `z = 1` then-block -- i.e. "if the comparison is false, skip the
then-block," textbook `If` codegen. `16_if_line_lt.mbs` (`<` instead of `=`)
is byte-identical except the single relational-opcode byte (`18` -> `1C`),
and `17_if_block.mbs` (block-form `If`/`End If` instead of line-form)
compiled **byte-identical** to the line-form version -- the two source forms
apparently aren't distinguished after parsing.

Every one of the 26 files across both batches decodes this way with no
leftover bytes, including the ones that looked broken during batch 1's
manual analysis (`09_var_to_var`, `18_goto`) -- those were mis-segmented by
hand, not actual anomalies. The `GoSub`/`Return` file in particular
(`19_gosub_return.mbs`) is what surfaced the "1-byte `Terminate` + 1-byte
program sentinel" distinction, since it's the one program in the corpus
where `Terminate` appears mid-program rather than as the last statement --
everywhere else the two `00`s were indistinguishable.

### Batch 3 -- operators, loops, arrays: ISA now covers essentially all of
### scalar/array control flow and arithmetic

`testdata/corpus/batch3/` (25 files) closed nearly every remaining gap from
batches 1+2. Again: every byte in every file is accounted for, no leftovers.

#### Opcode table, updated and complete for arithmetic/logic/control-flow

| Bytes | Meaning |
|---|---|
| `00` | `Terminate` (1 byte) |
| *(implicit)* | fixed 1-byte `00` program-end sentinel, always present |
| `01` / `02 <byte>` / `04 <4B LE>` | operand encoding: value/slot 0 / 1-255 / full 32-bit -- same encoding reused for literals *and* variable slots *and*, per arrays below, computed addresses |
| `05` / `06` | LOAD / STORE |
| `09` `0A` `0B` `0C` `0D` | `+` `-` `*` `/` `Mod` |
| `0E` | **still unknown** -- the one gap left in an otherwise contiguous `09`-`1D` block |
| `0F` | unary negate |
| `10` `11` `12` `13` | `And` `Or` `XOr` `Not` |
| `14` `15` | `++` `--` |
| `16` `17` | `<<` `>>` |
| `18` `19` `1A` `1B` `1C` `1D` | `=` `<>` `>` `>=` `<` `<=` (confirmed all 6; the enum groups as equality/greater/less pairs, not source-code order) |
| `03 <addr_lo> <addr_hi> <verb>` | branch family, verb selects: `1E`=branch-if-**true**, `1F`=branch-if-**false**, `20`=GoTo, `21`=GoSub |
| `22` | `Return` |

Only real gap left in the core scalar ISA is `0x0E` -- everything else from
`0x09` to `0x22` is now identified.

#### New confirmed behaviors

- **Compound assignment has no dedicated opcode.** `x += y` compiles to
  exactly `LOAD(x) LOAD(y) ADD STORE(x)` -- byte-identical in structure to
  what `x = x + y` would produce. Same for `-=`/`*=`/`/=`. The manual's "is
  equivalent to" language for these operators is literally true at the
  bytecode level, not just semantically.
- **Prefix vs. postfix `++`/`--` genuinely compile differently when the
  result is consumed**, and both match the manual's documented semantics
  exactly. `y = ++x` -> `LOAD(x) INC STORE(x) LOAD(x) STORE(y)` (increment
  first, then read the new value for `y`). `y = x++` -> `LOAD(x) [LOAD(x)
  INC STORE(x)] STORE(y)` (the *old* value is pushed first and kept on the
  stack underneath the increment side-effect, then that old value -- not a
  fresh load -- is what gets stored into `y`). Genuinely elegant use of the
  stack to get pre/post semantics right.
- **The `#define`-vs-literal theory is dead.** `#define FOO 87` then `x =
  FOO` and a direct `x = 87` compile to **byte-identical** output
  (`02 57 01 06 00 00`, both). Whatever caused the real corpus's `#define
  rolloff (87)` to use a wider encoding, it isn't `#define` itself -- likely
  something about the *call context* it was used in (it was a `SetConfig`
  argument, not a plain assignment; worth testing `SetConfig(_ALIM, 1, 87)`
  directly against `x = 87` once device-I/O functions are tackled).
- **Backward jumps confirmed working**, same absolute-address encoding as
  forward jumps: `While`/`Do While` both compile to `LOAD(x) PUSH(10) LT
  branch(if-false, ->end) [x++] GoTo(->start) Terminate`, with the `GoTo`
  targeting address 0 (the very start of the program) -- a genuine backward
  jump. `While` and `Do While` produced **byte-identical** output.
- **Arrays reuse the same operand encoding as everything else, just computed
  at runtime instead of loaded from a compile-time constant.**
  `arr[1] = 5` compiles to `PUSH(5) PUSH(arr_base_slot) PUSH(1) ADD STORE`
  -- i.e. the effective slot address is computed with a genuine `ADD` at
  runtime (`arr`'s declared slot + the index), then fed to the same `STORE`
  verb used everywhere else, which apparently just pops whatever's on top of
  the stack as its target address rather than requiring the static
  `01`/`02 <N>` operand forms. Array reads work the same way with `LOAD`.
  (Only tested with a compile-time-constant index so far -- an index that's
  itself a variable/expression isn't confirmed, though the mechanism above
  suggests it should just work unmodified.)
- **Traditional `For`/`Next` is dramatically more expensive than the
  C-style form, and now fully decoded end-to-end** (`51_for_traditional.mbs`,
  66 bytes vs. c-style's 32 for an equivalent loop) -- it allocates **two
  compiler-synthesized hidden variable slots** beyond the user's `Dim`s (one
  for the loop limit, one for the step), and compiles a step-sign-generic
  exit test: `(step >= 0 AND i > limit) OR (step < 0 AND i < limit)`, using
  the newly-discovered branch-if-*true* verb (`1E`) to jump out when that
  compound condition holds. This is exactly what you'd need to correctly
  support both ascending and descending `For` loops without knowing the
  step's sign at compile time -- and it's a direct, concrete confirmation of
  the manual's claim that traditional `For`/`Next` is "not recommended due
  to its inefficient execution."
- **C-style `For ... AndWhile ... Next` auto-increments the loop variable by
  1 each iteration even with no explicit `Evaluate` clause** -- not stated
  outright in the manual (its examples always show `Evaluate`), but
  `52_for_cstyle.mbs` has no `Evaluate` and still compiles an `i++` into the
  loop, confirmed by decoding the full 32-byte program with zero leftover
  bytes.

### Batch 4 -- device I/O, math, Boolean, remaining loops/control flow: ISA
### essentially complete

`testdata/corpus/batch4/` (28 files) closed almost everything. Two notes on
the batch itself before the findings: `55_unary_plus.mbs` (`z = +x`) **failed
to build in RoborunPlus** -- despite the manual describing `+` as usable
unary-or-binary, the real compiler apparently rejects a bare unary `+`
statement. That's a genuine, useful negative result: `0x0E` isn't unary
plus. And `69_cos.mbs` needed a second pass (came back empty the first time,
then supplied) -- resolved, see below.

#### Opcode table, final

| Bytes | Meaning |
|---|---|
| `00` | `Terminate` (1 byte) + fixed 1-byte `00` program-end sentinel |
| `01` / `02 <byte>` / `03 <2B LE>` / `04 <4B LE>` | operand encoding, now confirmed as a real variable-length integer scheme: 0 / 1-255 / 256-65535 / full 32-bit. (The `03 <2B>` tier hadn't shown up before batch 4 -- earlier tests jumped straight from the 1-byte form to the 4-byte form. This also means `0x03` is doing double duty as both a push-operand opcode *and* the branch-instruction prefix, disambiguated structurally: a branch always has a trailing verb byte in the `1E`-`21` range, a 2-byte push doesn't.) |
| `05` / `06` | LOAD / STORE, **Integer** variables |
| `07` / `08` | LOAD / STORE, **Boolean** variables (`08`=STORE confirmed via `p = True`/`p = False`; `07`=LOAD is the predicted counterpart, not directly exercised -- nothing in the corpus so far reads a Boolean variable back out) |
| `09`-`0D` | `+ - * / Mod` |
| `0E` | `Abs` |
| `0F` | unary negate |
| `10`-`13` | `And Or XOr Not` |
| `14` `15` | `++` `--` |
| `16` `17` | `<<` `>>` |
| `18`-`1D` | `= <> > >= < <=` |
| `1E` `1F` | branch-if-true / branch-if-false (part of the `03 <addr> <verb>` branch family, alongside `20`=GoTo, `21`=GoSub) |
| `22` | `Return` |
| `24 <len> <bytes>` | string literal -- self-contained "push and print", no separate verb needed (confirmed originally from the real corpus) |
| `25` | `Print` of an integer/expression value (needs a preceding LOAD/PUSH) |
| `26` | `Print(ToBool(...))` -- a dedicated print-as-boolean verb, not a composition of `ToBool` + `Print` |
| `27` `28` `29` `2A` | `SetConfig` `GetConfig` `SetCommand` `GetValue` -- a clean contiguous block for the four core device-I/O functions |
| `2B` `2C` `2D` `2E` | `GetTimerCount` `SetTimerCount` `SetTimerState` `GetTimerState` |
| `2F` | `ToBool` |
| `3C` `3D` `3E` `3F` | `Sqrt` `Sin` `Cos` `Atan` |

Command-code constants (the `_XXX` names): `_VAR` = `6`, `_ALIM` = `42`
(`0x2A`) -- confirmed by decoding `GetValue`/`SetCommand`/`GetConfig`/
`SetConfig` calls using them. These are presumably just the same numeric
IDs documented for the serial protocol in the main Controllers User Manual,
substituted in directly.

`True` = `PUSH32(0xFFFFFFFF)` (all-ones, i.e. -1, not `1`). `False` = the
same `01`-opcode push-zero form used for integer 0. Boolean variables use
their own separate LOAD/STORE opcodes (`07`/`08`) and, per
`74_bool_tobool.mbs`, their **own separate slot-numbering space** from
Integer variables -- a script's first-declared Boolean gets Boolean-slot 0
regardless of how many Integers were declared before it, and vice versa.
This matches the manual's claim of separate storage pools for the two
types.

`Print` with mixed string/variable arguments (`57_print_multi.mbs`,
`Print("x=", x, " y=", y, "\n")`) decodes cleanly to alternating
self-contained string chunks (`24 <len> <bytes>`, no verb needed) and
LOAD-then-`25` pairs for each variable, exactly as you'd expect from
treating each `Print` argument as its own independent push-and-output step.

Loop variants all confirmed, and instructively different from each other:
`Do Until`/`Loop` uses branch-if-**true** to exit (mirrors `While`/`Do
While`'s branch-if-false, since `Until` is the negation of `While`). The
post-test forms (`Do...Loop While`, `Do...Loop Until`) are the most compact
of all the loop shapes -- body first, then a single conditional branch back
to address 0, no separate forward-exit jump needed since falling through
already lands on `Terminate`. `Exit While` compiles to a plain unconditional
`GoTo` targeting the enclosing loop's exit address; `Continue While`
compiles to a plain unconditional `GoTo` targeting the loop's condition-check
(top) address -- both fully decoded end-to-end with zero leftover bytes
across 33-byte programs.

**Array access with a variable index** (`80_array_var_index.mbs`, `arr[i]`
rather than `arr[1]`) works exactly as predicted from the constant-index
case: the only difference is a `LOAD` of the index variable instead of a
`PUSH` of a constant, feeding the same runtime `ADD` + `LOAD`/`STORE`.

**The `#define` mystery remains open, now ruled out twice over.**
`81_define_in_setconfig.mbs` / `82_literal_in_setconfig.mbs`
(`setconfig(_ALIM, 1, FOO)` where `FOO` is `#define`d as 87, vs.
`setconfig(_ALIM, 1, 87)` directly) compiled **byte-identical**, both using
the compact 1-byte literal form. Combined with batch 3's plain-assignment
result, `#define` substitution is confirmed to never affect codegen. The
real corpus's `#define rolloff (87)` still used a wider encoding for
reasons not reproduced by anything tested here -- possibly something
specific to that file/RoborunPlus version, not a general rule. Not worth
chasing further; it doesn't block writing a correct encoder, since nothing
requires matching RoborunPlus's exact micro-optimization choices, only
producing valid bytecode.

#### What's still genuinely unconfirmed

- `0x07` (LOAD for Boolean variables) -- predicted by symmetry with `0x08`,
  never directly exercised.
- Unary `+` doesn't compile at all (confirmed negative result) -- not a gap,
  just not a real construct.
- Whether jump offsets ever need the `03 <2B>`/`04 <4B>` wide forms (all
  branch targets seen so far fit in the default 2-byte address field
  regardless of program size, so this is really asking whether
  *addresses* -- as opposed to literal operands -- ever grow beyond 2 bytes;
  no evidence either way since no test program is anywhere near 65535 bytes).
- The real corpus's `02 10` leading-header-bytes question, and the exact
  reason for its `#define` value's wider encoding.
- `Exit`/`Continue` for `For` and `Do` loops specifically (only the `While`
  form was tested; the mechanism -- plain `GoTo` to a known address -- should
  generalize trivially, but not independently confirmed).

None of these block starting on `codegen.py`: the confirmed subset now
covers integers, Booleans, arrays, all arithmetic/logical/relational/bitwise
operators, assignment (including compound and pre/post inc/dec), every
control-flow construct (`If`, all four loop shapes, `GoTo`/`GoSub`/`Return`,
`Exit`/`Continue`), `Print`, and the full device-I/O surface (config
read/write, commands, runtime values, timers, math functions, `ToBool`).

### Batch 5 -- compound expressions and a composite realistic script:
### model holds with zero surprises

`testdata/corpus/batch5/` (9 files) was specifically designed to break the
model built from batches 1-4, since everything up to this point tested one
construct in isolation. It didn't break. Every file decodes end-to-end with
zero leftover bytes, including a 70-byte composite script combining most of
the confirmed ISA at once.

- **`Wait(500)` -> `0x23`**, closing the one gap that mattered most since
  it's used in every real pump-controller script. Sits cleanly between
  `Return` (`0x22`) and the string-literal opcode (`0x24`).
- **Operator precedence is correctly implemented**, and does so the
  standard way: `z = x + y * 2` compiles to `LOAD(x) LOAD(y) PUSH(2) MUL
  ADD` -- multiply evaluated first (`y*2`), then added to `x`. `z = (x + y)
  * 2` (explicit parens) compiles to `LOAD(x) LOAD(y) ADD PUSH(2) MUL` --
  genuinely different instruction order, confirming parens actually
  override default precedence rather than being cosmetic.
- **Same-precedence operators are left-associative**: `z = x - y - 1`
  compiles to `LOAD(x) LOAD(y) SUB PUSH(1) SUB` -- i.e. `(x-y)-1`, computed
  as two chained subtractions in source order, not `x-(y-1)`.
- **`If`/`ElseIf`/`Else` chains compile to the textbook pattern**: each
  condition gets its own `(check, branch-if-false-to-next-check, body,
  goto-end)` block, and every branch/goto target in the 47-byte
  three-way-branch test resolves to exactly the right address with nothing
  left over.
- **Logical operator precedence resolved**: `x=1 And y=1 Or Not z=1`
  (no parens) evaluates as `((x=1) And (y=1)) Or (Not (z=1))` -- i.e. `Not`
  binds tightest, then `And`, then `Or`. Standard precedence, now confirmed
  rather than assumed.
- **Nested control flow (`If`/`Else` inside `While`) resolves correctly**:
  all branch targets in the 43-byte test land exactly where expected, with
  the loop's backward jump still correctly targeting the condition check
  rather than being confused by the nested branch addresses in between.
- **A function-call result composes into a larger expression exactly like
  any other value**: `x = (getvalue(_VAR, 1) - y) >> 2` decodes as
  `PUSH(_VAR) PUSH(1) GETVALUE LOAD(y) SUB PUSH(2) SHR STORE(x)` -- the
  `GetValue` call just leaves its result on the stack, and everything
  downstream treats it identically to a loaded variable or a pushed
  literal.
- **The 70-byte composite script** (`GoTo`-based loop with a label,
  `GetValue`, a nested `If`/`Else` that assigns a `Boolean` and calls
  `SetCommand` in each branch, `Print` with mixed string/variable args,
  `Wait`, and a backward jump skipping over the already-executed
  initialization) decodes **perfectly**, byte for byte, with the loop-back
  `GoTo` correctly targeting the label position rather than the very start
  of the program (i.e. it skips re-running `limit = 100` on every
  iteration, exactly as real code would need).

At this point the model has been validated against something structurally
very close to real production code, not just synthetic minimal pairs, and
it held with zero discrepancies. Good sign for starting `codegen.py`.

### Batch 6 -- command codes, and a real bug in the SetCommand assumption

`testdata/corpus/batch6/` (8 files) targeted the last known gap standing
between the compiler and the real `PumpController*.mbs` scripts: 8 unresolved
command constants (`_MS`, `_GO`, `_ESTOP`, `_AI`, `_BATAMPS`, `_MOTAMPS`,
`_MOTPWR`, `_V`), mirroring the real scripts' exact call shapes.

Confirmed codes:

| Constant | Code | Verb context |
|---|---|---|
| `_MS` | 16 (0x10) | `SetCommand` |
| `_GO` | 0 | `SetCommand` |
| `_ESTOP` | 14 (0x0E) | `SetCommand` |
| `_AI` | 16 (0x10) | `GetValue` |
| `_BATAMPS` | 12 (0x0C) | `GetValue` |
| `_MOTAMPS` | 0 | `GetValue` |
| `_MOTPWR` | 2 | `GetValue` |
| `_V` | 13 (0x0D) | `GetValue` |

`_MS`/`_AI` sharing code 16, and `_GO`/`_MOTAMPS` sharing code 0, is not a
collision -- `SetCommand`'s "commands" and `GetValue`'s "operating values"
are evidently separate numeric tables in Roboteq's underlying protocol, and
the verb determines which table applies.

**This also corrected a real bug**, not just filled in a table. The
previous assumption in `codegen.py` was "however many args `SetCommand` is
given get pushed, then the verb" -- untested, because every batch 1-5 test
happened to use the full 3-arg form. `92_cmd_ms.mbs` (`SetCommand(_MS, 1)`,
2 args -- Item and Value, no Index) decodes to pushed values `[16, 0, 1]`,
not `[16, 1]`. `94_cmd_estop.mbs` confirms the same pattern independently
(`[14, 0, 1]`). The compiler substitutes a literal `0` for the omitted
*middle* Index parameter -- i.e. the true call shape is always `SetCommand(
Item, Index, Value)`, and when the source omits Index the compiler inserts
`PUSH(0)` in its place before pushing Value. `93_cmd_go.mbs`'s full 3-arg
form (`SetCommand(_GO, 1, 50)` -> `[0, 1, 50]`, no defaulting) confirms the
argument order is `(Item, Index, Value)`.

`codegen.py` now implements this defaulting explicitly (`DEVICE_IO_SPECS`).
By symmetry, `GetValue`/`GetConfig`'s optional Index -- which the manual's
syntax box places *last*, `GetValue(Item, [Index])`, unlike SetCommand's
middle placement -- is assumed to default the same way (trailing `PUSH(0)`
when omitted), but this remains genuinely untested: every GetValue call in
the full corpus and all 8 real scripts supplies both arguments explicitly.
`SetConfig` is assumed symmetric with `SetCommand` (Index in the middle) on
the same unconfirmed basis.

**A second, unrelated bug surfaced while validating against the real
scripts** (not from batch 6 itself): `PumpcontrollerR0-3-LowCurrentPressure.mbs`'s
`print("\n\rStarting (R0-3-LowCurrentPressure)")` (36 chars) was previously
documented above (the "36 chars... compiles to a 32-byte chunk immediately
followed by a second chunk" note) as simply two adjacent `0x24` STRING
chunks. Diffing the real compiled `.hex` against this compiler's output on
that exact file showed the earlier note was incomplete: there's a
synthesized `Wait(1)` (`02 01 23`) inserted **between** the two chunks. This
lines up exactly with the manual's "32 characters per 1ms time slot"
wording taken literally -- each chunk gets its own 1ms time slot, so the
compiler waits 1ms between slots (but not after the last chunk, since
nothing follows). `codegen.py`'s `_emit_print` now inserts this wait
between consecutive chunks of a single split string literal. Whether this
generalizes to cumulative multi-argument `Print` totals exceeding 32 chars
(as opposed to one single long literal) is untested and unconfirmed.

With both fixes in place, all 8 real `PumpController*.mbs` production
scripts compile byte-for-byte identical to their actual RoborunPlus `.hex`
output (`tests/test_real_scripts.py`), alongside 47/47 passing on the
synthetic corpus (`tests/test_codegen.py`, `tests/test_lexer.py`) -- 55/55
total.

### Full command-code table sourced from the official Roboteq Controllers manual

Scott provided the *Roboteq Controllers User Manual*, V2.1 -- a much larger
document than the MicroBasic scripting manual, covering the full serial
protocol. It turns out to document a `HexCode:` field for essentially every
command, runtime query, and configuration parameter Roboteq supports --
exactly the numeric codes `commands.py` had only been able to fill in one
at a time by reverse-engineering.

**Cross-check first.** All 10 of the bytecode-confirmed codes (batches 4
and 6, above) appear in this manual and match exactly -- `_BATAMPS` = `0x0C`,
`_MS` = `0x10`, `_GO` = `0x00`, etc., zero discrepancies. That's strong
evidence the manual's `HexCode:` field is the literal wire value, so the
remaining ~300 codes it documents (which have not each been individually
diffed against compiled `.hex` output) are trusted on that basis.

**Extraction found real problems in the manual itself, not just data to
copy.** Parsing every `Alias: XXX ... HexCode: YY` and `Syntax Scripting:
result = getvalue(_XXX, ...)`-style line across the document surfaced:

- **Three confirmed copy-paste typos**, each a later section reusing an
  earlier section's example without updating the `_XXX` name to its own
  declared alias:
  - `CU - Raw Redirect Send`'s example says `setcommand(_CS, ee, nn)`
    (should be `_CU`) -- copied from the preceding `CS - CAN Send` section.
  - `BMS - Read BMS switch states`'s example says `getvalue(_BMC, cc)`
    (should be `_BMS`) -- copied from the preceding `BMC` section.
  - `MGX - Read MagSensor Tape Cross Detection`'s example says
    `getvalue(_MGY, cc)` (should be `_MGX`) -- copied from the preceding
    `MGY` section.

  `_CU`, `_BMS`, and `_MGX` never appear correctly documented anywhere in
  the manual, so none of the three are in `commands.py` -- there's no
  confirmed code for any of them under their own name.

- **The DS402 (CANopen motion-profile) sections were excluded entirely.**
  DS402 is a separate CANopen operating profile, not the plain serial mode
  a MicroBasic script runs in, and several DS402 aliases collide with
  unrelated classic-mode codes for the same short name -- e.g. a DS402-only
  `S16 - Target Velocity` section reuses the alias `MOTVEL` with `HexCode:
  61`, while the classic `S - Set Motor Speed` command's own `_MOTVEL` alias
  is `HexCode: 03`. Since a MicroBasic script isn't running in DS402 mode,
  the classic definition is the one that applies, and including the DS402
  duplicate would just be wrong. `ROM`, `TSL`, and `MSL` turned out to be
  DS402-only concepts with no classic-mode definition at all, so they
  aren't in `commands.py`.

- **Two aliases genuinely mean different things depending on the verb**,
  once the typos and DS402 duplicates above are excluded: `_BRUN` is
  `HexCode: 0x0C` as a `SetCommand` (start/stop/restart the running script)
  but `HexCode: 0x48` as a `SetConfig` (enable auto-start on power-up) --
  unrelated parameters sharing an alias. `_CSS` is `0x6C` to `SetCommand`
  (load an SSI sensor counter) and `0x6E` to `GetValue` (read it back) --
  related but still separately numbered. `codegen.py`'s `emit_call` now
  resolves the `Item` argument with the calling verb
  (`resolve_command_code(name, verb=...)`), and `commands.py`'s
  `VERB_SPECIFIC_CODES` holds these two overrides, checked before the flat
  table.

Net result: `commands.py` went from 10 entries to 313 flat entries plus 4
verb-specific ones (317 total), covering essentially every command, query,
and config parameter in the manual. `tests/test_commands.py` pins down the
resolution logic (all 10 bytecode-confirmed values, both verb-specific
aliases, the unknown-command error path). All 73 tests pass, and the 8 real
scripts still compile byte-for-byte identical to their `.hex` files.

## Legal note

Reverse-engineering for interoperability is generally on solid legal footing
in most jurisdictions, but Roboteq's license terms for RoborunPlus/RoboRun+
should be checked before this work goes further, particularly around
decompiling or extracting logic from their PC utility itself (as opposed to
just observing its file outputs, which is the approach above).
