ソースを参照

compiler: variable string subscripts, s[i] with a run-time index

v-TP3-STRINDEX accepted a constant index only (it folds into boff at
compile time); a variable one was ENoLib.  A run-time index needs an
address register, so ParseSub now forms the element's address:

    LoadAtom (index) ;              (* AX := i *)
    EmLeaDi (local, off+boff) ;     (* LEA DI,[base] *)
    EmAddDiAx () ;                  (* DI := &s[i] *)
    r.kind := 5 ;                   (* the address is in DI *)
    r.cls  := TChar ;

kind 5 is the new invariant (0 const / 1 var / 2 value in AX / 3 string
literal / 4 on the stack / 5 element, address in DI).  The consumers:

  * LoadAtom       MOV AL,[DI] (8A 05), AH := 0, -> kind 2;
  * SaveLeft       materialises and pushes a kind-5 LEFT operand before
                   the right side is parsed -- DI cannot survive that
                   parse (a nested subscript reuses it, a call clobbers
                   it) -- and converges on the kind-2-then-push path;
  * Assignment     PUSH DI across the right-hand side, POP DI, then
                   MOV [DI],AL (88 05), so g[i] := h[j] stores where it
                   read; this is TPSRC8 asnscal2/asnscal3's flgpshdi;
  * IoCall         PUSHes DI (57) instead of LEA AX,[off] for the
                   address; readln (s[i]) still classifies as TChar.

Four one-line emitters (AddDiAx 01 C7, LdAlDi 8A 05, StDiAl 88 05,
PushDi 57, PopDi 5F) plus the parameterised LeaDi (8D 7E / 8D BE /
8D 3E).  EmBpDisp gained a regf parameter for LeaDi to reuse; all five
old call sites pass regf = 0, emitting byte-identical code, so the
frame-displacement check is unaffected.  DI is safe because a .COM has
DS=SS=CS=ES and neither the compiler nor the runtime loads a segment
register.  No runtime entry: the arithmetic is inline, so the runtime
image is unchanged (616 bytes, 18 entries, 51 branches).

No type check and no range check on the index, matching the
constant-index path.

t46_strindex_var covers a global and a local string, a computed index
(i+1), a subscript as an argument and the both-indexed g[i] := h[j];
t47_strindex_var_read is readln (g[i]).  Both were ERR 102 (ENoLib)
before the change and green after.

Also: .in fixtures must be LF-terminated.  rdln stops at the first
CR/LF/^Z and consumes one byte, so a CRLF .in leaves the LF pending and
the next read picks up a stray 0x0A.  Not a compiler fault.

Gates: compile matrix 48/48, run_com_exec 43/43, exec86 43/43,
audit_helpers 110/110, runtime entries 51/51, OVERALL: ALL PASS.
tpshell clean build 191272 bytes.

Tag: v-TP3-STRIDX-VAR.
Eric Streit 1 日 前
親
コミット
ac8c1fc856

+ 33 - 22
SUMMARY.md

@@ -34,6 +34,7 @@ manual, not guessed.
 | Multi-name declarations, both spellings — `var a, b, c : integer`, `procedure p (x, y : integer ; z : integer)` | `v-TP3-MULTIDECL` | done, **definitions and formal parameters read a comma-separated list of NAMES and then one `: type`, exactly TPSRC7 `rdvarlst`/`rdvloop` (VAR) and `prfcnt`/`ccomma` (parameters), with `;` between parameter groups. The older `name : type, name : type` spelling still parses, because both reach the same symbols; the names are created as placeholders and given the group's one type afterwards. One `GetWord` that did not `Skip` first attached the space after a comma to the next name (`" B"`), which is why the first name worked and the second did not; `t42_multidecl` prints each name's value to prove every one landed on its own slot (43/43 compile, 39/39 executed)** |
 | The 4 KiB code window enforced — TP3 error 98 instead of writing over the data | `v-TP3-CODEWINDOW` | done, **`dataBase` is `rtSz + 1000H`, so code and data share one 64 KiB `.COM` segment and code may not reach it. `Ebyte` now refuses past `dataBase` (guarded by `imgReady`, since `dataBase` is 0 until `Compile` measures it) and reports TP3's error 98, "Memory overflow" — the same number `DefVar` already uses when `dc` overflows. `t43_codewindow` emits more than 4 KiB of inline literal bytes and must report `ERR 98`; disabling the check turns the fixture green-as-`OK`, which is the bug (a silently corrupt image), so the matrix row is the proof (44/44 compile)** |
 | String subscripts — `s[i]` read, write, and `readln (s[i])` | `v-TP3-STRINDEX` | done, **a STRING is a length byte at `s[0]` followed by the characters, so `s[i]` is the single byte at `off+i`: reading it yields a `CHAR`, assigning to it stores one byte, and `readln (s[i])` reads one byte at `off+i`. A constant index folds into `boff` as an array's does and `r.cls` drops to `TChar`, so `LoadAtom`, `Assignment` and `IoCall` all see one byte. Only a constant index is accepted — a variable index is `ENoLib`, exactly as it already was for an array. The fix uncovered a latent bug: `ParseAtom` did `r.boff := 0` *after* `ParseVar`/`ParseSub`, discarding every subscript, so `s[i]` addressed `off+0` (the length byte). `ParseVar` already zeroes `boff`, so the reset was redundant and was removed. `t44_strindex` prints every indexed byte and `t45_strindex_read` feeds `readln (s[i])`; the indexed read is red before the parse fix and green after (46/46 compile, 41/41 executed)** |
+| Variable string subscripts — `s[i]` with a *computed* index | `v-TP3-STRIDX-VAR` | done, **the address of `s[i]` is now computed at run time, so a variable index, a computed one (`i+1`) and a nested one all work — `v-TP3-STRINDEX` accepted a constant only. `ParseSub` evaluates the index into `AX`, then `LEA DI,[base]` / `ADD DI,AX` puts the element's address in `DI` and `r.kind` drops to `5`; `LoadAtom` reads `[DI]`, `Assignment` `PUSH DI`es the destination across its right-hand side (so `g[i] := h[j]` stores where it read — TPSRC8 `asnscal2`/`asnscal3`'s `flgpshdi`, and `SaveLeft` materialises a `DI`-addressed left operand *before* parsing the right), and `IoCall` pushes `DI` for `readln (s[i])`. No runtime entry: the arithmetic is compile-time emitted, so the runtime image is unchanged. Five new one-line emitters are name-audited (`AddDiAx`, `LdAlDi`, `StDiAl`, `PushDi`, `PopDi`) and `LeaDi` is the one documented computed emitter. `t46_strindex_var` reads, writes, compares, passes as an argument and assigns both-indexed (`g[i] := h[j]`), globally and locally; `t47_strindex_var_read` is `readln (g[i])` (48/48 compile, 43/43 executed)** |
 
 Every row that names a tag has one, and every tag points at a commit on
 `master`; verified by diffing the rows against `git tag -l`, which is how
@@ -75,7 +76,7 @@ gm2 -fiso -fuse-module-list=modules.lst -o tpshell \
         Shell.mod Compiler.mod Term.o TextBuf.o Posix.o Editor.o   # phase B2
 ```
 
-Current clean build: `make clean && make` → rc=0, `tpshell` **191104 bytes**.
+Current clean build: `make clean && make` → rc=0, `tpshell` **191272 bytes**.
 The one diagnostic is `./Compiler.mod: ParseExpr: too many errors in pass 3`,
 which is the expected phase-1 rollup that the recipe tolerates — not a real
 error. `shell/Makefile` is the single authoritative build recipe.
@@ -127,11 +128,11 @@ needing a squint. It was checked for vacuousness by reverting the string
 scanner fix: the suite went red with exit 1, and came back green only when the
 fix was restored.
 
-**41 of 46 fixtures compile clean**, up from 1 (the empty program) when the
+**43 of 48 fixtures compile clean**, up from 1 (the empty program) when the
 direct harness was first built.
 
 ```
-compile matrix: 46 passed, 0 failed (of 46)
+compile matrix: 48 passed, 0 failed (of 48)
 ```
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -156,7 +157,10 @@ inside a procedure, so both address paths run ·
 `t42` `var a, b, c : integer` and `procedure p (x, y : integer ; z : integer)`,
 each name landing on its own slot ·
 `t44` `s[i]` read and write on a global and a local string ·
-`t45` `readln (s[i])` storing one character at position `i`.
+`t45` `readln (s[i])` storing one character at position `i` ·
+`t46` `s[i]` with a **variable** index — read, write, compare, an argument and
+`g[i] := h[j]`, globally and locally ·
+`t47` `readln (g[i])` with the index in a variable.
 
 Failing, all deliberately: `t14` `array [1..5] of integer` at its point of use
 and `t25` a string literal assigned to an *integer* (`i := 'ab'`, the reverse
@@ -168,9 +172,9 @@ refuses the whole class because the runtime has no boolean reader at all; and
 "Memory overflow", rather than a corrupt image; and
 `uierror` is a deliberate syntax error (41) used by the UI test.
 
-`run_com_tests.sh` additionally links **all 41 that compile** to a real `.COM`
+`run_com_tests.sh` additionally links **all 43 that compile** to a real `.COM`
 and re-verifies the bytes with an independent Python checker that *measures*
-the layout instead of restating it: `independent .COM check: 41 checked, 0
+the layout instead of restating it: `independent .COM check: 43 checked, 0
 failed`. That last part was itself a bug fix — see "the two restated
 constants" below.
 
@@ -209,12 +213,12 @@ proves each one can go red.
 |---|---|---|
 | `probe/run_modrm19.py` | the mod=00/01/10 effective addresses, by **executing** 23 cases on a real 8086 under qemu and scanning for where the marker landed | mod=11 — see below |
 | `probe/modrm11.py` | the mod=11 register identities, by **encoding** with GNU `as` and decoding with FCML, against hard-coded bytes | the table agreeing with itself |
-| `audit_helpers.py` | every one-line emitter in **both** `Runtime.mod` and `Compiler.mod` decodes to what its *name* says — **105/105**, from an inventory scanned independently of the parser | anything longer than one instruction |
+| `audit_helpers.py` | every one-line emitter in **both** `Runtime.mod` and `Compiler.mod` decodes to what its *name* says — **110/110**, from an inventory scanned independently of the parser | anything longer than one instruction |
 | `check_comimage.py` | a linked image's layout is described in **exactly one file**: `comimage.py` defines each part once, every reader imports it, no second `find_header` exists anywhere in `tests/` | a check that *hand-rolls* the layout from literals (`hdr = 3 + rt_size`), which defines none of those names — the readers catch that a different way: each states the layout twice, from two independent sources, and requires the two to agree |
 | `check_runtime.py` + `runtime.golden` | the built runtime's code region (585 bytes, code 0..584) sweeps cleanly through FCML, every entry and all branch targets land on an instruction boundary, and the whole disassembly is byte-for-byte the committed golden | whether the golden is *right* |
 | `check_framedisp.py` | `[BP+off]` uses disp8 iff `off <= 127`, for locals (negative) and far parameters (>127) | which of the two encodings was chosen, if the other also works |
 | `run_com_exec.py` | the **emitted image executes** and prints exactly the expected bytes | semantics the fixture never exercises |
-| `run_exec86.py` | the same image runs in a **second, independent 8086** and agrees with qemu byte for byte, 41/41 | the `.out` file it shares with `run_com_exec.py` — a wrong expectation fails both at once |
+| `run_exec86.py` | the same image runs in a **second, independent 8086** and agrees with qemu byte for byte, 43/43 | the `.out` file it shares with `run_com_exec.py` — a wrong expectation fails both at once |
 | `rt_exec.py` | the **runtime entries themselves** are called directly, one qemu boot per call, 50 cases plus a pre-flight, 51/51 — plus a `check_bp_contract` pre-flight over the built blob: an entry that borrows BP must open with `55 8B EC` (exact) and contain a `5D` (a screen, because `5D` is also a displacement byte) | whether the *caller's* half of a contract is right, where the only witness is a case that happens to make two calls in a row |
 
 Two of these deserve the detail, because the reason they exist is the reason
@@ -347,7 +351,7 @@ compiler: **does the 8086 have this instruction at all?**
             value conditions  : = x6  <> x2  < x5  >= x3  <= x2  > x16
             branch conditions : IF / REPEAT x9  CASE x2  FOR downto x1  FOR to x3
             runtime: 616 bytes, 330 swept, 0 0F-prefixed
-            program code: 41 of 41 fixtures swept end to end, 4963 bytes
+            program code: 43 of 43 fixtures swept end to end, 5697 bytes
             t33_cmpops: 13 comparisons matched against their source operators, in order
             clause H: 8 of 8 fixtures matched the branch conditions read off their source
 ```
@@ -590,7 +594,7 @@ checker.
 compile to `.COM` images, are booted on a floppy by a 512-byte hand-assembled
 boot sector, and are compared **byte for byte** against the exact output the
 fixture demands — `tests/run_com_exec.py`, wired into `run_all.sh`, 21/21 at
-that tag and 41/41 now.
+that tag and 43/43 now.
 
 Everything below is about establishing what *can* be believed, because the
 first attempt at this used an emulator that was wrong, and a wrong oracle is
@@ -920,7 +924,8 @@ position), and code/data size accounting. Emitted image is a byte array
 Working subset (v0.5): integer/char/boolean/byte scalars, constants with
 folding, globals, locals, value parameters, procedures and scalar-result
 functions, `ARRAY[const..const]` with constant indexing, `string [n]`
-variables with literal assignment, string-to-string assignment and
+variables with literal assignment, string-to-string assignment, `s[i]`
+subscripting (constant **or** variable index) and
 `readln`/`read` into a string, control flow,
 `GOTO`/`EXIT`, the standard procedures `WRITE`, `WRITELN`, `READ`,
 `READLN`, `HALT`, and **inline string literals** as `WRITE`/`WRITELN`
@@ -1161,10 +1166,12 @@ Details that are deliberate, not incidental:
   The length is one byte, so 300 characters would go out behind a length of
   44 and the runtime would print 44 of them and silently drop the rest. TP3
   strings are at most 255 characters, so refusing is the faithful answer.
-- **A string *variable* is a real store now, not a refusal.** `s := 'hi'` and
-  `writeln(s)` compile (see `v-TP3-STRVAR`); what is still refused is
-  `readln(s)`, `s := another_string`, `s [i]` indexing and a literal assigned
-  to a non-string — all `ENoLib`, because none of them has an encoding yet.
+- **A string *variable* is a real store now, not a refusal.** `s := 'hi'`,
+  `writeln(s)`, `readln(s)`, `s := another_string` and `s[i]` indexing
+  (constant or variable) all compile (see `v-TP3-STRVAR`,
+  `v-TP3-STRREAD-ASSIGN`, `v-TP3-STRINDEX`, `v-TP3-STRIDX-VAR`); what is still
+  refused is the string `+` and comparison operators, and a literal assigned to
+  a non-string — `ENoLib`, because neither has an encoding yet.
 
 
 ## Honest limitations
@@ -1175,7 +1182,7 @@ Details that are deliberate, not incidental:
   prove the *path* exists (compile → poke → run → report → no file written),
   which it does with eight assertions. The other 37 are covered by
   `run_exec86.py`, which calls the same interpreter directly.
-- **Every fixture that compiles is now also run.** 41 of 46 execute; the 5 that
+- **Every fixture that compiles is now also run.** 43 of 48 execute; the 5 that
   do not are `t14` and `t25` (`ENoLib`, by design), `t38` (a boolean read,
   refused with TP3's error 66, by design), `t43` (a program whose code exceeds
   the 4 KiB window, refused with error 98, by design) and `uierror` (a
@@ -1301,9 +1308,11 @@ Details that are deliberate, not incidental:
   input.** `program p(1;)` would loop forever. The fix needs a `BOOLEAN` flag
   and **must not** use `EXIT`, which ICEs gm2 in pass 3 (see the pitfalls
   list). Not yet done.
-- **Not implemented** (all `ENoLib`): real, set, record, file, string
-  *indexing* (`s[i]`), the `+`/comparison string
+- **Not implemented** (all `ENoLib`): real, set, record, file, the string
+  `+`/comparison
   operators, and any declared type wider than 2 bytes other than `TString`.
+  A string *subscript* `s[i]` — constant (`v-TP3-STRINDEX`) or variable
+  (`v-TP3-STRIDX-VAR`) — is implemented.
   `with` is `ENoLib`. `case` *is*
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
   over scalar labels — subrange labels and label lists are untested.
@@ -1995,10 +2004,12 @@ independently-scanned inventory at all.
 
 1. The rest of the string language. `v-TP3-STRVAR`/`v-TP3-STRREAD-ASSIGN` cover
    string variables, `s := 'literal'`, `writeln (s)`, `readln (s)`/`read (s)`
-   and `s := t`; `v-TP3-STRINDEX` covers `s[i]` with a **constant** index —
-   read, write and `readln (s[i])`. Still `ENoLib`: a *variable* index `s [i]`
-   (the runtime address arithmetic TPSRC9 `indstr`/`ialoop` does), and the `+`
-   / comparison operators (`cmpstr`/`xconcat`, TPSRC2).
+   and `s := t`; `v-TP3-STRINDEX` covers `s[i]` with a constant index and
+   `v-TP3-STRIDX-VAR` covers a **variable/computed** one — read, write,
+   `readln (s[i])`, comparison against a char, use as an argument, and
+   `g[i] := h[j]`. Still `ENoLib`: the `+` and the whole-string comparison
+   operators (`cmpstr`/`xconcat`, TPSRC2 — the latter needs a runtime entry,
+   the next selector is 18).
 2. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its

+ 65 - 2
TP3-COMPILER.md

@@ -55,6 +55,19 @@ whole-program pass 3 silently caps identifier/error counts — see
    the destination's `size-1`, and calls `strstore`. The runtime gained
    `EmitRdStr` and `EmitStrStore` (selectors 16/17) to match TP3's `xrdstr`
    and `strstore`.
+9. **Variable string subscripts (the `DI` address register):** `ParseSub`
+   evaluates a non-constant index into `AX` and puts the element's address in
+   `DI` (`LEA DI,[base]` / `ADD DI,AX`), mirroring TP3 `indstr`/`ialoop`
+   (TPSRC9 1100-1111, 1200-1229).  Five one-line emitters were added --
+   `AddDiAx` (`01 C7`), `LdAlDi` (`8A 05`), `StDiAl` (`88 05`), `PushDi`
+   (`57`), `PopDi` (`5F`) -- plus one parameterised, `LeaDi`
+   (`8D 7E disp8` / `8D BE disp16` / `8D 3E off`).  So `EmBpDisp` now takes a
+   `regf` (the register field of the ModR/M byte); all five of its old call
+   sites pass `regf = 0`, emitting byte-identical code, which is why the
+   `[BP+off]` frame-displacement check is unaffected.  `DI` is a safe place to
+   keep the address because a `.COM` loads no segment register: `DS=SS=CS=ES`
+   for the whole run, and neither the compiler nor the runtime ever changes
+   one.
 
 The compiler's whole-program 8086-emit, TEXT-equivalent, pastres and
 errexit flow are otherwise unchanged (mirrors TP3 TPSRC file, ConvertTP3).
@@ -146,7 +159,7 @@ than 4 KiB of inline string bytes and must report `ERR 98`; with the gate
 disabled the same fixture compiles `OK` -- the corrupt image -- which is the
 proof the test is not vacuous.
 
-## String subscripts: `s[i]`, and a latent `boff` clobber
+## String subscripts: `s[i]` with a constant index, and a latent `boff` clobber
 
 A `string [n]` is a length byte at `s[0]` followed by the characters, so
 `s[i]` is the single byte at `off+i`.  `ParseSub` folds a constant index into
@@ -170,6 +183,56 @@ length byte.  Nothing caught it because arrays cannot be declared yet, so no
 fixture had ever exercised the fold.  `t44_strindex` reads `off+0` when the
 reset is restored (wrong output, exit 0) and the right bytes when it is gone.
 
+## Variable string subscripts: the element address in `DI`
+
+A constant index folds into `boff` at compile time, but `s[i]` with `i` a
+variable is a run-time address, so it needs a register to hold it.  TP3 does
+this in TPSRC9 `indstr`/`ialoop` (1100-1111, 1200-1229): it evaluates the
+index, then forms the element's address.  Ours does the same, in `ParseSub`:
+
+    LoadAtom (index) ;              (* AX := i *)
+    EmLeaDi (local, off+boff) ;     (* LEA DI,[base], base's own byte 0 *)
+    EmAddDiAx () ;                  (* DI := &s[i] *)
+    r.kind := 5 ;                   (* the address is in DI, not on the stack *)
+    r.cls  := TChar ;               (* one byte, as for a constant index *)
+
+The address is spent immediately in the same place the constant one is, so the
+three consumers from the section above need no new classification rule -- only
+a new source of the address:
+
+- `LoadAtom` (kind 5) emits `MOV AL,[DI]` and zeroes `AH`, i.e. the same one
+  byte the `A0 off` form loads, and drops the result to kind 2;
+- `SaveLeft` materialises and pushes a kind-5 *left* operand the moment the
+  operator is seen, **before** the right-hand side is parsed -- `DI` cannot
+  survive that parse, because the right operand may hold a subscript of its
+  own (which reuses `DI`) or call a routine that clobbers it.  Converting to
+  kind 4 here is safe and converges on the path a kind-2 left operand takes;
+- `Assignment` needs the destination address *after* the right-hand side, so
+  it `PUSH DI`es it first (`g[i] := h[j]` stores where it read).  This is
+  exactly TPSRC8 `asnscal2`/`asnscal3` (758-760, 786-789), which set
+  `flgpshdi` around the expression for the same reason;
+- `IoCall` needs only the address, so for a kind-5 argument it `PUSH DI`
+  instead of `LEA AX,[off]`; the class is still `TChar`, so `readln (s[i])`
+  picks `rdchar` and reads one byte.
+
+No runtime entry was added: the arithmetic is emitted inline, so the runtime
+image is byte-for-byte unchanged (616 bytes, code 0..584, 18 entries).  There
+is no range check on the index, matching the constant-index path; `{$R+}` /
+`{$I+}` are separate.
+
+`t46_strindex_var` covers a global string and a **local** one (the disp16
+`LEA DI` form), a computed index (`i+1`), a subscript passed as an argument
+and the both-indexed `g[i] := h[j]`; `t47_strindex_var_read` is `readln (g[i])`.
+Both were `ERR 102` (`ENoLib`) before the change.
+
+A fixture-authoring trap surfaced here, in the *input* file rather than the
+compiler: `rdln` (`EmitRdLn`) exits the moment it reads a CR, an LF or a `^Z`,
+consuming that one byte and nothing more.  An `.in` file written with CRLF
+terminators therefore leaves the LF pending, and the *next* character read
+picks up a stray `0x0A`.  That is why `.in` fixtures are LF-terminated (the
+runtime's own comment says so); the `.out` side is CRLF, because the guest
+emits CRLF.
+
 ## Tests
 
 Several harnesses live in `shell/tests/` -- in the repo on purpose, because
@@ -255,7 +318,7 @@ partial read or a wrong sector count would have been visible, this was not.
 window at `C000h`; and the copy is executed code, so it is the last thing
 that touches the image and no BIOS call follows it.
 
-**41 of 46 fixtures execute.**  5 do not: `t14` and `t25` (`ENoLib`, by
+**43 of 48 fixtures execute.**  5 do not: `t14` and `t25` (`ENoLib`, by
 design), `t38` (a boolean read, refused with TP3's error 66, by design),
 `t43` (a program whose code exceeds the 4 KiB window, refused with error 98,
 by design) and `uierror` (a deliberate syntax error).  The nine control-flow fixtures (`t08`

+ 159 - 37
shell/Compiler.mod

@@ -176,7 +176,9 @@ TYPE
       RECORD
          cls  : CARDINAL ;
          kind : CARDINAL ;          (* 0 const, 1 var, 2 value in AX,
-                                       3 string literal (cls = TString) *)
+                                       3 string literal (cls = TString),
+                                       4 value on the stack (SaveLeft),
+                                       5 string element, address in DI *)
          imm  : LONGINT ;
          idx  : CARDINAL ;
          boff : CARDINAL ;          (* constant fold-in for subscripts *)
@@ -834,7 +836,7 @@ BEGIN
    Ebyte (48H)
 END EmDecAx ;
 
-PROCEDURE EmBpDisp (off : CARDINAL) ;
+PROCEDURE EmBpDisp (off, regf : CARDINAL) ;
 (* Emit the ModR/M byte and displacement for a [BP+off] operand, picking the
    encoding from the size of off.  This is the ONE place that choice is made,
    because getting it wrong is invisible: 8B 46 d8 and 8B 86 lo hi are both
@@ -865,10 +867,14 @@ PROCEDURE EmBpDisp (off : CARDINAL) ;
        broken -- a bug nobody had hit yet, which is exactly why it wanted a
        test rather than an argument. *)
 BEGIN
+   (* mod=01 rm=110 -> 40H + regf*8 + 6H, mod=10 rm=110 -> 80H + regf*8 + 6H.
+      regf is the ModR/M reg field: 0 when the opcode's register is the
+      accumulator (every original caller), 7 for the DI forms EmLeaDi needs.
+      rm is 110 ([BP+disp]) in both. *)
    IF off <= 127 THEN
-      Ebyte (46H) ; Ebyte (VAL (BYTE, off))
+      Ebyte (VAL (BYTE, 40H + regf * 8 + 6H)) ; Ebyte (VAL (BYTE, off))
    ELSE
-      Ebyte (86H) ; Eword (off)
+      Ebyte (VAL (BYTE, 80H + regf * 8 + 6H)) ; Eword (off)
    END
 END EmBpDisp ;
 
@@ -879,13 +885,13 @@ PROCEDURE EmLoadVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
 BEGIN
    IF nbytes = 1 THEN
       IF local THEN
-         Ebyte (8AH) ; EmBpDisp (off)
+         Ebyte (8AH) ; EmBpDisp (off, 0)
       ELSE
          Ebyte (0A0H) ; Eword ((off + LoadBias) MOD 10000H)
       END
    ELSE
       IF local THEN
-         Ebyte (8BH) ; EmBpDisp (off)
+         Ebyte (8BH) ; EmBpDisp (off, 0)
       ELSE
          Ebyte (0A1H) ; Eword ((off + LoadBias) MOD 10000H)
       END
@@ -896,13 +902,13 @@ PROCEDURE EmStoreVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
 BEGIN
    IF nbytes = 1 THEN
       IF local THEN
-         Ebyte (88H) ; EmBpDisp (off)
+         Ebyte (88H) ; EmBpDisp (off, 0)
       ELSE
          Ebyte (0A2H) ; Eword ((off + LoadBias) MOD 10000H)
       END
    ELSE
       IF local THEN
-         Ebyte (89H) ; EmBpDisp (off)
+         Ebyte (89H) ; EmBpDisp (off, 0)
       ELSE
          Ebyte (0A3H) ; Eword ((off + LoadBias) MOD 10000H)
       END
@@ -918,13 +924,52 @@ PROCEDURE EmPushVarAddr (local : BOOLEAN ; off : CARDINAL) ;
    are displacements and so do not. *)
 BEGIN
    IF local THEN
-      Ebyte (8DH) ; EmBpDisp (off)
+      Ebyte (8DH) ; EmBpDisp (off, 0)
    ELSE
       Ebyte (8DH) ; Ebyte (06H) ; Eword ((off + LoadBias) MOD 10000H)
    END ;
    EmPushAx ()
 END EmPushVarAddr ;
 
+PROCEDURE EmLeaDi (local : BOOLEAN ; off : CARDINAL) ;
+(* LEA DI,[BP+disp] / LEA DI,[off] - the run-time address of an indexed
+   string's element, computed once in ParseSub and read by LoadAtom,
+   Assignment and IoCall.
+
+   It is the same three encodings EmPushVarAddr uses for AX - 8D 7E disp8,
+   8D BE disp16, 8D 3E off - with the ModR/M reg field set to DI (111)
+   instead of the accumulator, which is why EmBpDisp now takes that field and
+   this is still the one place the disp8/disp16 choice is made.  A local is
+   [BP+disp] with no bias; a global is [off] and carries LoadBias, absolute.
+
+   The later `MOV AL,[DI]` / `MOV [DI],AL` read through DS rather than SS.
+   That is correct here for the same reason TP3's own string ops use DI with
+   the default segment: a .COM loads with DS=SS=CS=ES, and nothing in this
+   compiler or its runtime ever reloads a segment register, so a BP-relative
+   frame address held in DI addresses the same byte. *)
+BEGIN
+   IF local THEN
+      Ebyte (8DH) ; EmBpDisp (off, 7)
+   ELSE
+      Ebyte (8DH) ; Ebyte (3EH) ; Eword ((off + LoadBias) MOD 10000H)
+   END
+END EmLeaDi ;
+
+PROCEDURE EmAddDiAx () ;
+BEGIN Ebyte (01H) ; Ebyte (0C7H) END EmAddDiAx ;  (* 01 C7: DI := DI + AX *)
+
+PROCEDURE EmLdAlDi () ;
+BEGIN Ebyte (8AH) ; Ebyte (05H) END EmLdAlDi ;    (* 8A 05: AL := [DI] *)
+
+PROCEDURE EmStDiAl () ;
+BEGIN Ebyte (88H) ; Ebyte (05H) END EmStDiAl ;    (* 88 05: [DI] := AL *)
+
+PROCEDURE EmPushDi () ;
+BEGIN Ebyte (57H) END EmPushDi ;                  (* PUSH DI *)
+
+PROCEDURE EmPopDi () ;
+BEGIN Ebyte (5FH) END EmPopDi ;                   (* POP DI *)
+
 PROCEDURE EmSubSp (n : CARDINAL) ;
 BEGIN
    Ebyte (81H) ; Ebyte (0ECH) ; Eword (n MOD 10000H)
@@ -1655,6 +1700,20 @@ BEGIN
       r.kind := 2 ;
       RETURN
    END ;
+   IF r.kind = 5 THEN
+      (* A string element with a variable index: ParseSub already computed its
+         address into DI (LEA DI,[base] / ADD DI,AX), so the byte is at [DI].
+         Zeroing AH keeps the value a clean 16-bit char, the same thing the
+         size-1 load below does, because every consumer of a CHAR expects the
+         high half to be zero.  The read must happen HERE, before anything
+         else is emitted: the caller reaches this immediately after parsing
+         the subscript, or - for a binary operator - SaveLeft has already
+         materialised it, so DI is still the element's address. *)
+      EmLdAlDi () ;
+      EmMovAh0 () ;
+      r.kind := 2 ;
+      RETURN
+   END ;
    IF r.kind = 0 THEN
       EmMovAxi (W16 (r.imm)) ;
       r.kind := 2
@@ -1681,8 +1740,15 @@ BEGIN
 END LoadAtom ;
 
 PROCEDURE ParseSub (VAR r : ERes) ;
-(* consume '[' constExpr ']' while present, folding the index into the
-   base offset (constant indexing only) *)
+(* consume '[' expr ']' while present.
+
+   A CONSTANT index folds into the base offset boff, exactly as before.  A
+   VARIABLE index on a STRING cannot fold - off+i is a run-time value - so it
+   is computed once here into DI and r.kind drops to 5, which is how LoadAtom,
+   Assignment and IoCall are told the element's address is in DI.  Arrays
+   still accept only a constant index: a variable array subscript needs the
+   element-size scaling that a one-byte string element does not, and it is
+   item 2. *)
 VAR t : ERes ;
 BEGIN
    LOOP
@@ -1693,28 +1759,42 @@ BEGIN
       DropCh (GetCh ()) ;
       ParseExpr (t) ;
       IF OK () THEN
-         IF t.kind # 0 THEN
-            Err (ENoLib) ;
-            RETURN
-         END ;
-         IF symtab [r.idx].cls = TArray THEN
-            r.boff := W16 (VAL (LONGINT, r.boff)
-                           + t.imm * VAL (LONGINT, symtab [r.idx].elem))
-         ELSIF symtab [r.idx].cls = TString THEN
+         IF symtab [r.idx].cls = TString THEN
             (* s[i]: a STRING is a length byte at s[0] followed by the
-               characters (TPSRC8's `string' type), so index i selects the one
-               byte at off+i.  A constant index folds into boff exactly as an
-               array's does, and r.cls drops to TChar so every consumer below
-               - LoadAtom, Assignment, IoCall - sees a one-byte value instead
-               of the whole string.  A second subscript on a character is not
-               a type.  A variable index reaches here only after failing the
-               `t.kind # 0' test above and is ENoLib, same as an array's. *)
+               characters, so index i selects the one byte at off+i.  A second
+               subscript on a character is not a type. *)
             IF r.cls = TChar THEN
                Err (ESimpType) ;
                RETURN
             END ;
-            r.boff := W16 (VAL (LONGINT, r.boff) + t.imm) ;
+            IF t.kind = 0 THEN
+               (* constant index: fold it into boff, as an array's does, and
+                  r.cls drops to TChar so every consumer below - LoadAtom,
+                  Assignment, IoCall - sees a one-byte value instead of the
+                  whole string. *)
+               r.boff := W16 (VAL (LONGINT, r.boff) + t.imm)
+            ELSE
+               (* Variable index.  Compute the element address once, now:
+                  the index is evaluated into AX (LoadAtom is a no-op for a
+                  kind-2 value and re-reads [DI] for a nested subscript), then
+                  DI := base + AX.  This is TP3's indstr/ialoop shape (TPSRC9
+                  1100-1111, 1200-1229), which likewise holds the address in
+                  DI until the byte is loaded or stored; a register is what
+                  lets a later LoadAtom read [DI] and a later assignment PUSH
+                  DI across its right-hand side. *)
+               LoadAtom (t) ;
+               EmLeaDi (symtab [r.idx].local,
+                        (symtab [r.idx].off + r.boff) MOD 10000H) ;
+               EmAddDiAx () ;
+               r.kind := 5
+            END ;
             r.cls := TChar
+         ELSIF t.kind # 0 THEN
+            Err (ENoLib) ;
+            RETURN
+         ELSIF symtab [r.idx].cls = TArray THEN
+            r.boff := W16 (VAL (LONGINT, r.boff)
+                           + t.imm * VAL (LONGINT, symtab [r.idx].elem))
          ELSE
             Err (ESimpType) ;
             RETURN
@@ -1894,6 +1974,19 @@ BEGIN
    IF left.kind = 2 THEN
       EmPushAx () ;
       left.kind := 4                      (* 4 = on the top of the stack *)
+   ELSIF left.kind = 5 THEN
+      (* A string element whose address is in DI.  This is the one place the
+         address must be spent BEFORE the right-hand operand is parsed: DI
+         cannot survive it, because the right operand may hold a subscript of
+         its own (which reuses DI) or call a routine (which may clobber it).
+         Reading [DI] now - which LoadAtom does, turning kind 5 into kind 2 -
+         and pushing the value is exactly what the kind-2 case above does
+         next, so the two branches converge on kind 4.  A subscript on the
+         RIGHT is fine: it sets DI after this point and LoadPair reads it
+         immediately, with no parse in between. *)
+      LoadAtom (left) ;                   (* AL := [DI], AH := 0; kind := 2 *)
+      EmPushAx () ;
+      left.kind := 4
    END
 END SaveLeft ;
 
@@ -1915,6 +2008,10 @@ PROCEDURE LoadPair (VAR left, right : ERes) ;
      neither         Both can still be materialised from scratch, and
                      neither is at risk, so this is the original sequence.
 
+   A kind-5 right-hand operand takes that last shape, and is safe there: the
+   only instruction emitted before its LoadAtom is the left's, and MOV AX,[var]
+   and MOV AX,imm do not touch DI, so [DI] still holds the element address.
+
    Whichever shape is taken, exactly one push is matched by exactly one
    pop. *)
 BEGIN
@@ -2325,7 +2422,7 @@ PROCEDURE Assignment (r : ERes) ;
 VAR src : ERes ;
     k, n : CARDINAL ;
 BEGIN
-   IF r.kind # 1 THEN
+   IF (r.kind # 1) AND (r.kind # 5) THEN
       Err (EUnknown) ;
       RETURN
    END ;
@@ -2388,14 +2485,31 @@ BEGIN
          Err (ENoLib) ;
          RETURN
       END ;
-      (* s[i] := c : one byte at off+boff.  The whole-string path above was
-         skipped because r.cls is TChar, not TString, so this is the element
-         store; a plain char variable has size 1 and takes the scalar path
-         below instead. *)
+      (* s[i] := c : one byte, either at off+boff (a constant index) or at [DI]
+         (a variable one, r.kind = 5).  The whole-string path above was skipped
+         because r.cls is TChar, not TString, so this is the element store; a
+         plain char variable has size 1 and takes the scalar path below
+         instead.
+
+         For a variable index the destination address is in DI, and the
+         right-hand side is parsed next, so DI has to be saved first - the RHS
+         may hold a subscript of its own, which reuses DI, or call a routine
+         that clobbers it.  This is TPSRC8 asnscal2/asnscal3 (758-760,
+         786-789), which set flgpshdi around the expression for the same
+         reason.  After the save, DI is free for the RHS, and the store reads
+         it back. *)
+      IF r.kind = 5 THEN
+         EmPushDi ()
+      END ;
       ParseExpr (src) ;
       LoadAtom (src) ;
-      EmStoreVar (symtab [r.idx].local,
-                  (symtab [r.idx].off + r.boff) MOD 10000H, 1) ;
+      IF r.kind = 5 THEN
+         EmPopDi () ;                    (* DI := the destination again *)
+         EmStDiAl ()                     (* [DI] := AL *)
+      ELSE
+         EmStoreVar (symtab [r.idx].local,
+                     (symtab [r.idx].off + r.boff) MOD 10000H, 1)
+      END ;
       RETURN
    END ;
    ParseExpr (src) ;
@@ -2478,7 +2592,7 @@ BEGIN
                that an earlier inline string's call had already overwritten. *)
             pushed := TRUE ;      (* default: value is on the stack -> call + pop *)
             IF reading THEN
-               IF arg.kind # 1 THEN
+               IF (arg.kind # 1) AND (arg.kind # 5) THEN
                   Err (ETypeErr) ;                  (* READ needs a variable *)
                   RETURN
                END ;
@@ -2492,8 +2606,16 @@ BEGIN
                IF (acls = TString) AND (arg.cls = TChar) THEN
                   acls := TChar
                END ;
-               EmPushVarAddr (symtab [arg.idx].local,
-                              (symtab [arg.idx].off + arg.boff) MOD 10000H) ;
+               IF arg.kind = 5 THEN
+                  (* A variable index: ParseSub computed the element's address
+                     into DI, and nothing has been emitted since, so that is
+                     the address READ wants.  A constant index folds into boff
+                     and takes the LEA path below. *)
+                  EmPushDi ()
+               ELSE
+                  EmPushVarAddr (symtab [arg.idx].local,
+                                 (symtab [arg.idx].off + arg.boff) MOD 10000H)
+               END ;
                IF acls = TString THEN
                   (* readln(s)/read(s) of a string variable: TPSRC8 prdstr
                      pushes the address, emits MOV CL,max_len and CALL

+ 1 - 1
shell/tests/audit_helpers.py

@@ -113,7 +113,7 @@ NON_ONE_LINE = {
         "Call": COMP, "Jcc": COMP, "JmpNear": COMP,
         "BpDisp": COMP, "LoadVar": COMP, "StoreVar": COMP,
         "PushVarAddr": COMP, "MovAxi": COMP, "CmpAxi": COMP,
-        "MovCxi": COMP,
+        "MovCxi": COMP, "LeaDi": COMP,
     },
 }
 # The Vx family is NOT here: those four are audited by audit_vx_helpers,

+ 22 - 0
shell/tests/fixtures/expected.tsv

@@ -324,3 +324,25 @@ t44_strindex	OK	258	16
 #                   (11 bytes) plus two @@T temps.  t45_strindex_read.in feeds
 #                   'X' then 'Y'.
 t45_strindex_read	OK	134	15
+#   t46_strindex_var  s[i] with a VARIABLE index, both directions and in both
+#                   registers.  ParseSub used to accept a constant only (ENoLib
+#                   otherwise, exactly as for an array), because off+i is a
+#                   run-time value; now it evaluates the index, then puts the
+#                   element's address in DI (LEA DI,[base] / ADD DI,AX), and
+#                   r.kind drops to 5 so LoadAtom reads [DI] and Assignment
+#                   PUSHes DI across its right-hand side.  This fixture was red
+#                   (ERR 102) before that.  It covers a global string, a LOCAL
+#                   string (the 8D BE disp16 LEA form), a computed index (i+1),
+#                   a subscript as a procedure argument, and `g[i] := h[j]`
+#                   where BOTH sides are indexed - the case that proves the
+#                   destination is saved across the source.  data=31 is g and h
+#                   (string[10] = 11 each), i and j (4), c (1), and the two @@T
+#                   temporaries (4).
+t46_strindex_var	OK	578	31
+#   t47_strindex_var_read  readln (g[i]) with a variable i.  The reader needs
+#                   the computed address, not a folded off+boff, so IoCall
+#                   pushes DI (57) instead of LEA AX,[off] in that case.
+#                   data=15 is the global string[8] (9 bytes) plus i (2) and the
+#                   two @@T temporaries (4).  t47_strindex_var_read.in feeds
+#                   'X' into g[3] then 'Y' into g[6].
+t47_strindex_var_read	OK	188	15

+ 11 - 0
shell/tests/fixtures/t46_strindex_var.out

@@ -0,0 +1,11 @@
+h
+l
+Hello
+y
+lw
+l
+lello
+e
+b
+aZcdef
+leZlo

+ 57 - 0
shell/tests/fixtures/t46_strindex_var.pas

@@ -0,0 +1,57 @@
+program t46;
+(* s[i] with a VARIABLE index -- an address the code can only compute at run
+   time.
+
+   Until now a subscript had to fold: ParseSub accepted a constant and
+   reported ENoLib for anything else, because off+i is not known when the
+   symbol is.  TP3 computes it once, in indstr/ialoop, and holds it in DI
+   until the byte is loaded or stored (TPSRC9 1100-1111, 1200-1229); the
+   assignment paths then save it across the right-hand side (TPSRC8 asnscal2
+   758-760, asnscal3 786-789), which is what keeps `g[i] := h[j]` storing
+   where it read from.  This mirrors that: LEA DI,[base] / ADD DI,AX, and a
+   PUSH DI around an assignment's right-hand side.
+
+   A STRING is still a length byte at s[0] then the characters, so s[i] is
+   the one byte at off+i.  A second subscript is not a type (ESimpType), and
+   there is no range check yet -- {$R+} / {$I+} belong to item 2. *)
+var
+  g : string[10] ;
+  h : string[10] ;
+  i, j : integer ;
+  c : char ;
+procedure show (ch : char) ;
+begin
+  write (ch)
+end ;
+procedure p ;
+var
+  l : string[6] ;
+  k : integer ;
+begin
+  l := 'abcdef' ;
+  k := 2 ;
+  writeln (l[k]) ;                (* local string: LEA DI,[BP+disp] *)
+  l[k] := 'Z' ;                   (* local destination, variable index *)
+  writeln (l) ;
+  g[3] := l[k]                    (* constant destination, variable source *)
+end ;
+begin
+  g := 'hello' ;
+  h := 'world' ;
+  i := 1 ;
+  j := 4 ;
+  writeln (g[i]) ;                (* read *)
+  c := g[j] ;
+  writeln (c) ;                   (* read into a char *)
+  g[i] := 'H' ;                   (* write *)
+  writeln (g) ;
+  if g[i] = 'H' then writeln ('y') else writeln ('n') ;
+  writeln (g[j], h[i]) ;          (* two variable reads in one call *)
+  show (g[j]) ;                   (* a subscript as a procedure ARGUMENT *)
+  writeln ;
+  g[i] := h[j] ;                  (* both sides indexed at once *)
+  writeln (g) ;
+  writeln (g[i+1]) ;              (* computed index: ADD DI,AX *)
+  p ;
+  writeln (g)
+end.

+ 2 - 0
shell/tests/fixtures/t47_strindex_var_read.in

@@ -0,0 +1,2 @@
+X
+Y

+ 3 - 0
shell/tests/fixtures/t47_strindex_var_read.out

@@ -0,0 +1,3 @@
+..X.....
+..X..Y..
+XY

+ 21 - 0
shell/tests/fixtures/t47_strindex_var_read.pas

@@ -0,0 +1,21 @@
+program t47;
+(* readln (s[i]) with a VARIABLE index.
+
+   The address of the one byte the reader must store into is off+i, and i is
+   only known at run time, so the reader has to carry a computed address:
+   ParseSub leaves it in DI and IoCall pushes DI instead of a constant
+   off+boff.  t45 covers the same path with a constant index, which folds into
+   boff; the two differ in exactly where the address comes from. *)
+var
+  g : string[8] ;
+  i : integer ;
+begin
+  g := '........' ;
+  i := 3 ;
+  readln (g[i]) ;
+  writeln (g) ;
+  i := 6 ;
+  readln (g[i]) ;
+  writeln (g) ;
+  writeln (g[3], g[6])
+end.

+ 195 - 0
v-TP3-STRIDX-VAR.md

@@ -0,0 +1,195 @@
+v-TP3-STRIDX-VAR — Session summary
+==================================
+
+Tag:    v-TP3-STRIDX-VAR
+Commit: (the commit that adds this file)
+Covers: v-TP3-STRIDX-VAR
+
+This is the resume point for the session that did "Next work" item 1a — the
+variable string subscript `s [i]` — in full.  It is written so the next session
+can pick up without re-deriving anything: every claim below is pinned by a
+fixture in `shell/tests/fixtures/expected.tsv` and by the gate suite.
+
+What changed
+------------
+
+1. Variable string subscripts — `s [i]` where `i` is a run-time value
+   (`v-TP3-STRIDX-VAR`).
+
+   `v-TP3-STRINDEX` accepted a **constant** index only: it folds into `boff`
+   (`off+i`) at compile time, and anything else was `ENoLib`.  A variable index
+   is a run-time address, so it needs a register.  TP3 forms it in TPSRC9
+   `indstr` / `ialoop` (1100-1111, 1200-1229); ours does the same, in
+   `ParseSub`:
+
+       LoadAtom (index) ;              (* AX := i *)
+       EmLeaDi (local, off+boff) ;     (* LEA DI,[base] -- the base's byte 0 *)
+       EmAddDiAx () ;                  (* DI := &s[i]  (01 C7: add di,ax) *)
+       r.kind := 5 ;                   (* the address lives in DI *)
+       r.cls  := TChar ;               (* one byte, exactly as a constant index *)
+
+   `kind = 5` is the new invariant.  `ERes.kind` already meant:
+   `0` const · `1` var · `2` value in `AX` · `3` string literal · `4` on the
+   stack.  `5` slots in as "a string element whose address is in `DI`",
+   avoiding a new boolean field that could go stale.  The five consumers:
+
+   - `LoadAtom` (kind 5) emits `MOV AL,[DI]` (`8A 05`) and zeroes `AH`, i.e.
+     the same single byte the `A0 off` form loads, then drops to kind 2.
+   - `SaveLeft` materialises and **pushes** a kind-5 *left* operand the moment
+     the operator is recognised, **before** the right-hand side is parsed.  `DI`
+     cannot survive that parse: the right operand may contain a subscript of its
+     own (which reuses `DI`) or a call (which may clobber it).  Turning kind 5
+     into kind 2 → push → kind 4 makes a kind-5 left operand converge on the
+     path a kind-2 one already takes.
+   - `Assignment` needs the destination address *after* the right-hand side, so
+     it `PUSH DI`es it first, restores it with `POP DI`, and stores `MOV [DI],AL`
+     (`88 05`).  `g[i] := h[j]` therefore stores where it read.  This is exactly
+     TPSRC8 `asnscal2`/`asnscal3` (758-760, 786-789), which set `flgpshdi`
+     around the expression for the same reason.
+   - `IoCall` needs only the address, so for a kind-5 argument it `PUSH DI`es it
+     (`57`) instead of `LEA AX,[off]`; `readln (s[i])` still classifies as
+     `TChar` and picks `rdchar`, reading one byte.
+
+   No new runtime entry: the arithmetic is emitted inline, so the runtime image
+   is byte-for-byte unchanged (616 bytes, 18 entries, 51 branches).
+
+   There is no type check and no range check on the index, matching the
+   constant-index path (which also does neither).  `{$R+}` / `{$I+}` are a
+   separate milestone.
+
+Five new emitters
+-----------------
+
+Four one-line emitters and one parameterised:
+
+    AddDiAx   01 C7      add di, ax
+    LdAlDi    8A 05      mov al, byte ptr [di]
+    StDiAl    88 05      mov byte ptr [di], al
+    PushDi    57         push di
+    PopDi     5F         pop di
+    LeaDi     8D 7E d8   lea di, [bp + disp8]
+              8D BE d16  lea di, [bp + disp16]
+              8D 3E off  lea di, [off]
+
+`EmLeaDi` reuses the address forms `EmPushVarAddr` already had, minus the push.
+Every opcode was checked with `objdump -b binary -m i8086` **and** the project's
+own `tests/disasm16.py` before it was emitted.  The four one-liners are audited
+by name by `audit_helpers.py`; `LeaDi` has a parameter list, so it is the one
+entry that must be listed in `NON_ONE_LINE` (as `COMP`) rather than name-audited.
+
+`DI` is a safe place to keep the address: a `.COM` has `DS=SS=CS=ES` and neither
+the compiler nor the runtime ever loads a segment register.  The runtime
+routines that use `DI` are all `CALL`ed (and some already save it — `rdstr`
+does `PushDi`/`PopDi`), and `Assignment` saves it across the only other thing
+that runs between the two uses.
+
+One refactor, no behaviour change
+---------------------------------
+
+`EmBpDisp` gained a `regf` parameter (the register field of the ModR/M byte),
+so `LeaDi` can reuse it.  All five of its old call sites pass `regf = 0`, which
+emits the identical bytes, so `check_framedisp.py` (which examines `t27`/`t28`)
+is unaffected.  This is the only edit to a previously-working path.
+
+Gotcha found: `.in` fixtures must be LF-terminated
+--------------------------------------------------
+
+Not a compiler bug, but a fixture-authoring trap worth writing down.  `rdln`
+(`EmitRdLn`) exits the moment it reads a **CR**, an **LF** or a **`^Z`**,
+consuming that one byte and nothing more.  An `.in` file written with CRLF
+terminators therefore leaves the LF pending, and the *next* character read
+picks up a stray `0x0A`.  That is what made `t47`'s second `readln (g[i])` store
+a newline: the first `readln` ate only the CR.
+
+The convention is therefore: `.in` is LF-terminated (the runtime's own comment
+says "LF because our input files are LF-terminated"), `.out` is CRLF, because
+the guest emits CRLF.  `t45_strindex_read.in` already followed this; `t47` was
+written CRLF first and was wrong.  The compiler was right both times.
+
+Fixtures added
+--------------
+
+    t46_strindex_var        OK   578  31   s[i] with a variable index, both
+                                            directions; global + local, a computed
+                                            index (i+1), a subscript as an
+                                            argument, and both-indexed g[i]:=h[j]
+    t47_strindex_var_read   OK   188  15   readln (g[i]) with the index in a
+                                            variable  (input: 'X' then 'Y')
+
+Both were red before the change — `ERR 102` (`ENoLib`), no `expected.tsv` row —
+and green after.  Data sizes: `t46` = 31 is `g` + `h` (`string[10]`, 11 each) +
+`i` + `j` (4) + `c` (1) + two `@@T` temporaries (4); `t47` = 15 is the global
+`string[8]` (9) + `i` (2) + two `@@T` (4).
+
+Scope decisions and findings
+----------------------------
+
+- **`DI`, not `BX`.**  The sketch in `v-TP3-DECL-WINDOW-STRIDX.md` item 1a
+  proposed `LEA BX` and parking the index `ERes` on `r` until `LoadAtom` /
+  `Assignment` runs, which would have needed `ERes` to carry a nested index.
+  Storing the *address* in `DI` instead is simpler and matches TP3's own shape
+  more closely: there is no index to park, because `ParseSub` spends it on the
+  spot, and the only thing that must outlive the parse is one register, which
+  `PUSH DI`/`POP DI` handle.  `kind = 5` is the whole change to `ERes` — no new
+  field.
+- **Materialise the left operand early.**  `SaveLeft` is the one place the
+  address must be spent before the right side is parsed, for the reason in the
+  list above.  A subscript on the *right* is fine: it sets `DI` after `SaveLeft`
+  and `LoadPair` reads it immediately, with no parse in between.
+- **The audit's own rule.**  An emitter with a parameter list is excluded from
+  the by-name audit, so it must be listed in `NON_ONE_LINE[mod]` or the coverage
+  check fails.  `LeaDi` is `COMP`; the other five are audited by name.  This is
+  the second time the rule has bitten, and it is documented in
+  `audit_helpers.py`'s header.
+
+Next work
+---------
+
+Item 1 (the rest of the string language), in the order that keeps each step
+self-contained:
+
+  b. **String comparison `= <> < <= > >=`** (`cmpstr`, TPSRC2:890-924, and
+     `vcompare`, TPSRC9:133).  A new runtime entry taking two addresses and
+     leaving flags set is enough because `EmSetcc` reads live flags; the open
+     question is the SIGNEDNESS and the prefix rule (is "ab" < "abc"?) — read it
+     off `cmpstr` before trusting the `SETL`/`SETG` opcodes.  This adds a
+     runtime entry, so `runtime.golden`, `check_runtime.py`, `rt_exec.py` (next
+     selector is 18) and the branch count all move with it.
+  c. **Concatenation `+`** (`xconcat`, TPSRC2; `addstr` -> `xconcat`,
+     TPSRC9:194).  Hardest: TP3 pushes strings inline on the stack, this model
+     addresses them, so a result needs a scratch/result string and a length rule
+     (TP3 caps at 255).
+
+Item 2 (the large one) is untouched: nested procedures / recursion, `var`
+parameters (the `SEG:OFF` push, RESUME-TP3.md 3.11), range/index checks
+(`TU_RANGE_CHECK`, `TU_INDEX_CHECK`), typed constants (3.14), `array` at its
+point of use (t14), `case` with subrange labels.
+
+Also still open from before this session:
+
+  d. Harden the program-header parameter loop against non-advancing input
+     (`program p(1;)`) with a `BOOLEAN` flag — NOT `EXIT`, which ICEs gm2.
+  e. FreeDOS (`freedos.qcow2`, FD14-LiveCD) is still untried; it is the only
+     third opinion on the `INT 21h` shim.
+
+How to verify this session's work
+---------------------------------
+
+    cd shell && make clean && make                 # tpshell 191272 bytes, rc=0
+    cd shell && tests/run_all.sh                   # expect: OVERALL: ALL PASS
+
+At this tag the suite reports:
+
+    compile matrix: 48 passed, 0 failed (of 48)
+    independent .COM check: 43 checked, 0 failed
+    execution: 43 passed, 0 failed (of 43)
+    exec86:    43 passed, 0 failed (of 43), cross-checked against qemu
+    audit_helpers: 110/110 agree (30 in Compiler.mod, was 25)
+    runtime entries: 51 passed, 0 failed (of 51)
+    check_8086: 43 of 43 fixtures swept end to end, 5697 bytes
+    runtime: 616 bytes, 18 entries, 51 branches   (unchanged)
+    OVERALL: ALL PASS
+
+Each new behaviour was proven red before green: `t46` and `t47` both report
+`ERR 102` (`ENoLib`, "not implemented") with the old `ParseSub`, and a stray
+`0x0A` — not a compiler fault — is the only thing a CRLF `.in` puts wrong.