Просмотр исходного кода

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 день назад
Родитель
Сommit
baf4bcdf99

+ 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)** |
 | 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)** |
 | 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)** |
 | 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
 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
 `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
         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`,
 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
 which is the expected phase-1 rollup that the recipe tolerates — not a real
 error. `shell/Makefile` is the single authoritative build recipe.
 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
 scanner fix: the suite went red with exit 1, and came back green only when the
 fix was restored.
 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.
 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 ·
 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)`,
 `t42` `var a, b, c : integer` and `procedure p (x, y : integer ; z : integer)`,
 each name landing on its own slot ·
 each name landing on its own slot ·
 `t44` `s[i]` read and write on a global and a local string ·
 `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
 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
 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
 "Memory overflow", rather than a corrupt image; and
 `uierror` is a deliberate syntax error (41) used by the UI test.
 `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*
 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
 failed`. That last part was itself a bug fix — see "the two restated
 constants" below.
 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/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 |
 | `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_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_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 |
 | `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_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 |
 | `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
 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
             value conditions  : = x6  <> x2  < x5  >= x3  <= x2  > x16
             branch conditions : IF / REPEAT x9  CASE x2  FOR downto x1  FOR to x3
             branch conditions : IF / REPEAT x9  CASE x2  FOR downto x1  FOR to x3
             runtime: 616 bytes, 330 swept, 0 0F-prefixed
             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
             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
             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
 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
 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
 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
 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
 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
 Working subset (v0.5): integer/char/boolean/byte scalars, constants with
 folding, globals, locals, value parameters, procedures and scalar-result
 folding, globals, locals, value parameters, procedures and scalar-result
 functions, `ARRAY[const..const]` with constant indexing, `string [n]`
 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,
 `readln`/`read` into a string, control flow,
 `GOTO`/`EXIT`, the standard procedures `WRITE`, `WRITELN`, `READ`,
 `GOTO`/`EXIT`, the standard procedures `WRITE`, `WRITELN`, `READ`,
 `READLN`, `HALT`, and **inline string literals** as `WRITE`/`WRITELN`
 `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
   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
   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.
   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
 ## Honest limitations
@@ -1175,7 +1182,7 @@ Details that are deliberate, not incidental:
   prove the *path* exists (compile → poke → run → report → no file written),
   prove the *path* exists (compile → poke → run → report → no file written),
   which it does with eight assertions. The other 37 are covered by
   which it does with eight assertions. The other 37 are covered by
   `run_exec86.py`, which calls the same interpreter directly.
   `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,
   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
   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
   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
   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
   and **must not** use `EXIT`, which ICEs gm2 in pass 3 (see the pitfalls
   list). Not yet done.
   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`.
   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*
   `with` is `ENoLib`. `case` *is*
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
   over scalar labels — subrange labels and label lists are untested.
   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
 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)`
    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
 2. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its
    `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
    the destination's `size-1`, and calls `strstore`. The runtime gained
    `EmitRdStr` and `EmitStrStore` (selectors 16/17) to match TP3's `xrdstr`
    `EmitRdStr` and `EmitStrStore` (selectors 16/17) to match TP3's `xrdstr`
    and `strstore`.
    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
 The compiler's whole-program 8086-emit, TEXT-equivalent, pastres and
 errexit flow are otherwise unchanged (mirrors TP3 TPSRC file, ConvertTP3).
 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
 disabled the same fixture compiles `OK` -- the corrupt image -- which is the
 proof the test is not vacuous.
 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
 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
 `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
 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.
 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
 ## Tests
 
 
 Several harnesses live in `shell/tests/` -- in the repo on purpose, because
 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
 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.
 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),
 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,
 `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`
 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
       RECORD
          cls  : CARDINAL ;
          cls  : CARDINAL ;
          kind : CARDINAL ;          (* 0 const, 1 var, 2 value in AX,
          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 ;
          imm  : LONGINT ;
          idx  : CARDINAL ;
          idx  : CARDINAL ;
          boff : CARDINAL ;          (* constant fold-in for subscripts *)
          boff : CARDINAL ;          (* constant fold-in for subscripts *)
@@ -834,7 +836,7 @@ BEGIN
    Ebyte (48H)
    Ebyte (48H)
 END EmDecAx ;
 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
 (* 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,
    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
    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
        broken -- a bug nobody had hit yet, which is exactly why it wanted a
        test rather than an argument. *)
        test rather than an argument. *)
 BEGIN
 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
    IF off <= 127 THEN
-      Ebyte (46H) ; Ebyte (VAL (BYTE, off))
+      Ebyte (VAL (BYTE, 40H + regf * 8 + 6H)) ; Ebyte (VAL (BYTE, off))
    ELSE
    ELSE
-      Ebyte (86H) ; Eword (off)
+      Ebyte (VAL (BYTE, 80H + regf * 8 + 6H)) ; Eword (off)
    END
    END
 END EmBpDisp ;
 END EmBpDisp ;
 
 
@@ -879,13 +885,13 @@ PROCEDURE EmLoadVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
 BEGIN
 BEGIN
    IF nbytes = 1 THEN
    IF nbytes = 1 THEN
       IF local THEN
       IF local THEN
-         Ebyte (8AH) ; EmBpDisp (off)
+         Ebyte (8AH) ; EmBpDisp (off, 0)
       ELSE
       ELSE
          Ebyte (0A0H) ; Eword ((off + LoadBias) MOD 10000H)
          Ebyte (0A0H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    ELSE
    ELSE
       IF local THEN
       IF local THEN
-         Ebyte (8BH) ; EmBpDisp (off)
+         Ebyte (8BH) ; EmBpDisp (off, 0)
       ELSE
       ELSE
          Ebyte (0A1H) ; Eword ((off + LoadBias) MOD 10000H)
          Ebyte (0A1H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
@@ -896,13 +902,13 @@ PROCEDURE EmStoreVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
 BEGIN
 BEGIN
    IF nbytes = 1 THEN
    IF nbytes = 1 THEN
       IF local THEN
       IF local THEN
-         Ebyte (88H) ; EmBpDisp (off)
+         Ebyte (88H) ; EmBpDisp (off, 0)
       ELSE
       ELSE
          Ebyte (0A2H) ; Eword ((off + LoadBias) MOD 10000H)
          Ebyte (0A2H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    ELSE
    ELSE
       IF local THEN
       IF local THEN
-         Ebyte (89H) ; EmBpDisp (off)
+         Ebyte (89H) ; EmBpDisp (off, 0)
       ELSE
       ELSE
          Ebyte (0A3H) ; Eword ((off + LoadBias) MOD 10000H)
          Ebyte (0A3H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
@@ -918,13 +924,52 @@ PROCEDURE EmPushVarAddr (local : BOOLEAN ; off : CARDINAL) ;
    are displacements and so do not. *)
    are displacements and so do not. *)
 BEGIN
 BEGIN
    IF local THEN
    IF local THEN
-      Ebyte (8DH) ; EmBpDisp (off)
+      Ebyte (8DH) ; EmBpDisp (off, 0)
    ELSE
    ELSE
       Ebyte (8DH) ; Ebyte (06H) ; Eword ((off + LoadBias) MOD 10000H)
       Ebyte (8DH) ; Ebyte (06H) ; Eword ((off + LoadBias) MOD 10000H)
    END ;
    END ;
    EmPushAx ()
    EmPushAx ()
 END EmPushVarAddr ;
 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) ;
 PROCEDURE EmSubSp (n : CARDINAL) ;
 BEGIN
 BEGIN
    Ebyte (81H) ; Ebyte (0ECH) ; Eword (n MOD 10000H)
    Ebyte (81H) ; Ebyte (0ECH) ; Eword (n MOD 10000H)
@@ -1655,6 +1700,20 @@ BEGIN
       r.kind := 2 ;
       r.kind := 2 ;
       RETURN
       RETURN
    END ;
    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
    IF r.kind = 0 THEN
       EmMovAxi (W16 (r.imm)) ;
       EmMovAxi (W16 (r.imm)) ;
       r.kind := 2
       r.kind := 2
@@ -1681,8 +1740,15 @@ BEGIN
 END LoadAtom ;
 END LoadAtom ;
 
 
 PROCEDURE ParseSub (VAR r : ERes) ;
 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 ;
 VAR t : ERes ;
 BEGIN
 BEGIN
    LOOP
    LOOP
@@ -1693,28 +1759,42 @@ BEGIN
       DropCh (GetCh ()) ;
       DropCh (GetCh ()) ;
       ParseExpr (t) ;
       ParseExpr (t) ;
       IF OK () THEN
       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
             (* 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
             IF r.cls = TChar THEN
                Err (ESimpType) ;
                Err (ESimpType) ;
                RETURN
                RETURN
             END ;
             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
             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
          ELSE
             Err (ESimpType) ;
             Err (ESimpType) ;
             RETURN
             RETURN
@@ -1894,6 +1974,19 @@ BEGIN
    IF left.kind = 2 THEN
    IF left.kind = 2 THEN
       EmPushAx () ;
       EmPushAx () ;
       left.kind := 4                      (* 4 = on the top of the stack *)
       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
 END SaveLeft ;
 END SaveLeft ;
 
 
@@ -1915,6 +2008,10 @@ PROCEDURE LoadPair (VAR left, right : ERes) ;
      neither         Both can still be materialised from scratch, and
      neither         Both can still be materialised from scratch, and
                      neither is at risk, so this is the original sequence.
                      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
    Whichever shape is taken, exactly one push is matched by exactly one
    pop. *)
    pop. *)
 BEGIN
 BEGIN
@@ -2325,7 +2422,7 @@ PROCEDURE Assignment (r : ERes) ;
 VAR src : ERes ;
 VAR src : ERes ;
     k, n : CARDINAL ;
     k, n : CARDINAL ;
 BEGIN
 BEGIN
-   IF r.kind # 1 THEN
+   IF (r.kind # 1) AND (r.kind # 5) THEN
       Err (EUnknown) ;
       Err (EUnknown) ;
       RETURN
       RETURN
    END ;
    END ;
@@ -2388,14 +2485,31 @@ BEGIN
          Err (ENoLib) ;
          Err (ENoLib) ;
          RETURN
          RETURN
       END ;
       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) ;
       ParseExpr (src) ;
       LoadAtom (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
       RETURN
    END ;
    END ;
    ParseExpr (src) ;
    ParseExpr (src) ;
@@ -2478,7 +2592,7 @@ BEGIN
                that an earlier inline string's call had already overwritten. *)
                that an earlier inline string's call had already overwritten. *)
             pushed := TRUE ;      (* default: value is on the stack -> call + pop *)
             pushed := TRUE ;      (* default: value is on the stack -> call + pop *)
             IF reading THEN
             IF reading THEN
-               IF arg.kind # 1 THEN
+               IF (arg.kind # 1) AND (arg.kind # 5) THEN
                   Err (ETypeErr) ;                  (* READ needs a variable *)
                   Err (ETypeErr) ;                  (* READ needs a variable *)
                   RETURN
                   RETURN
                END ;
                END ;
@@ -2492,8 +2606,16 @@ BEGIN
                IF (acls = TString) AND (arg.cls = TChar) THEN
                IF (acls = TString) AND (arg.cls = TChar) THEN
                   acls := TChar
                   acls := TChar
                END ;
                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
                IF acls = TString THEN
                   (* readln(s)/read(s) of a string variable: TPSRC8 prdstr
                   (* readln(s)/read(s) of a string variable: TPSRC8 prdstr
                      pushes the address, emits MOV CL,max_len and CALL
                      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,
         "Call": COMP, "Jcc": COMP, "JmpNear": COMP,
         "BpDisp": COMP, "LoadVar": COMP, "StoreVar": COMP,
         "BpDisp": COMP, "LoadVar": COMP, "StoreVar": COMP,
         "PushVarAddr": COMP, "MovAxi": COMP, "CmpAxi": 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,
 # 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
 #                   (11 bytes) plus two @@T temps.  t45_strindex_read.in feeds
 #                   'X' then 'Y'.
 #                   'X' then 'Y'.
 t45_strindex_read	OK	134	15
 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.