Bladeren bron

compiler: string subscripts, s[i] (constant index)

A STRING is a length byte at s[0] then the characters, so s[i] is the
one byte at off+i.  ParseSub folds a constant index into boff (the field
an array already uses) and drops r.cls to TChar, so:

  * LoadAtom loads one byte (A0 off / 8A 46 disp) and zeroes AH;
  * Assignment stores one byte when the symbol is TString but r.cls is
    TChar (the element) rather than TString (the whole string);
  * IoCall already classified on arg.cls, so writeln (s[i]) -> wrchar;
  * the reader now classifies read (s[i]) the same way and pushes
    off+boff.

Only a constant index is accepted; a variable index is ENoLib, exactly
as it already was for an array (the runtime address arithmetic TP3 does
in indstr/ialoop is still to come).

This uncovered a latent bug: ParseAtom did `r.boff := 0' AFTER
ParseVar/ParseSub, discarding every subscript.  ParseVar already zeroes
boff before ParseSub, so the reset was pure loss - s[i] addressed off+0,
the length byte.  Nothing caught it because an array cannot be declared
yet, so no fixture had ever exercised the fold.  t44_strindex is red
with the reset restored (reads the length byte) and green without it.

Gates: compile matrix 46/46, run_com_exec 41/41, exec86 41/41, runtime
entries 51/51, OVERALL: ALL PASS.  tpshell clean build 191104 bytes.

Tag: v-TP3-STRINDEX.
Eric Streit 19 uur geleden
bovenliggende
commit
171f6e69a6

+ 19 - 14
SUMMARY.md

@@ -33,6 +33,7 @@ manual, not guessed.
 | String *reads* and string-to-string assignment — `readln(s)`, `s := t` | `v-TP3-STRREAD-ASSIGN` | done, **`readln(s)`/`read(s)` and `s := t` on string variables. Two runtime entries carry selectors 16/17 (`rdstr`, `strstore`); the caller sets the limit in `CX` with a full `MOV CX,imm16`. `rdstr` (TPSRC4 `xrdstr`) reads a line, stops at CR/LF/^Z, leaves the terminator pending for the existing `rdln` wrapper, truncates at the limit, and stores the length last; `strstore` (TPSRC2 `strstore` + TPSRC8 `asnvar`) copies `min(len(t), size(s)-1)` characters, never writes the source, and leaves the destination tail past the new length untouched. The milestone also exposed a pre-existing frame-layout bug: a local `string` was packed with the *integer* rule, so its characters grew UP over the saved BP at `[BP]` and the return address at `[BP+2]` — `readln` into such a local then ran the string's own bytes as its return address. Locals are now packed top-anchored, which keeps every integer offset byte-for-byte and puts a `string[5]` at `BP-6..BP-1`, inside the frame (38/38 executed, 51/51 entry checks)** |
 | String *reads* and string-to-string assignment — `readln(s)`, `s := t` | `v-TP3-STRREAD-ASSIGN` | done, **`readln(s)`/`read(s)` and `s := t` on string variables. Two runtime entries carry selectors 16/17 (`rdstr`, `strstore`); the caller sets the limit in `CX` with a full `MOV CX,imm16`. `rdstr` (TPSRC4 `xrdstr`) reads a line, stops at CR/LF/^Z, leaves the terminator pending for the existing `rdln` wrapper, truncates at the limit, and stores the length last; `strstore` (TPSRC2 `strstore` + TPSRC8 `asnvar`) copies `min(len(t), size(s)-1)` characters, never writes the source, and leaves the destination tail past the new length untouched. The milestone also exposed a pre-existing frame-layout bug: a local `string` was packed with the *integer* rule, so its characters grew UP over the saved BP at `[BP]` and the return address at `[BP+2]` — `readln` into such a local then ran the string's own bytes as its return address. Locals are now packed top-anchored, which keeps every integer offset byte-for-byte and puts a `string[5]` at `BP-6..BP-1`, inside the frame (38/38 executed, 51/51 entry checks)** |
 | 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)** |
 
 
 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
@@ -126,11 +127,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.
 
 
-**39 of 44 fixtures compile clean**, up from 1 (the empty program) when the
+**41 of 46 fixtures compile clean**, up from 1 (the empty program) when the
 direct harness was first built.
 direct harness was first built.
 
 
 ```
 ```
-compile matrix: 44 passed, 0 failed (of 44)
+compile matrix: 46 passed, 0 failed (of 46)
 ```
 ```
 
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -153,7 +154,9 @@ inside a procedure, so both address paths run ·
 `t40` `readln` into a global *and* a local `string` ·
 `t40` `readln` into a global *and* a local `string` ·
 `t41` `s := t` between two strings, with the truncation both ways ·
 `t41` `s := t` between two strings, with the truncation both ways ·
 `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 ·
+`t45` `readln (s[i])` storing one character at position `i`.
 
 
 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
@@ -165,9 +168,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 39 that compile** to a real `.COM`
+`run_com_tests.sh` additionally links **all 41 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: 39 checked, 0
+the layout instead of restating it: `independent .COM check: 41 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.
 
 
@@ -211,7 +214,7 @@ proves each one can go red.
 | `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, 39/39 | 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, 41/41 | 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
@@ -284,7 +287,7 @@ and `tests/check_comimage.py` asserting there is only one copy of each.
 ### Execution under qemu — `tests/run_com_exec.py`
 ### Execution under qemu — `tests/run_com_exec.py`
 
 
 The check that cannot be written as a byte comparison, so also the one that
 The check that cannot be written as a byte comparison, so also the one that
-finds the most: 39 fixtures are compiled to `.COM`, put on a floppy, booted,
+finds the most: 41 fixtures are compiled to `.COM`, put on a floppy, booted,
 and their serial output compared to a committed `.out` file **exactly** — CRLF
 and their serial output compared to a committed `.out` file **exactly** — CRLF
 included — plus the exit code passed to `INT 21h AH=4Ch`.
 included — plus the exit code passed to `INT 21h AH=4Ch`.
 
 
@@ -344,7 +347,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: 39 of 39 fixtures swept end to end, 4603 bytes
+            program code: 41 of 41 fixtures swept end to end, 4963 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
 ```
 ```
@@ -587,7 +590,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 39/39 now.
+that tag and 41/41 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
@@ -1172,7 +1175,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.** 39 of 44 execute; the 5 that
+- **Every fixture that compiles is now also run.** 41 of 46 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
@@ -1990,10 +1993,12 @@ independently-scanned inventory at all.
 
 
 ## Next steps
 ## Next steps
 
 
-1. The rest of the string language. `v-TP3-STRVAR` covers `s := 'literal'`
-   and `writeln(s)`; `v-TP3-STRREAD-ASSIGN` covers `readln(s)`/`read(s)` (TP3's
-   `xrdstr`) and `s := t` between two string variables. Still `ENoLib`:
-   `s [i]` indexing and the `+` / comparison operators.
+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).
 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

+ 27 - 3
TP3-COMPILER.md

@@ -146,6 +146,30 @@ 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
+
+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
+`boff` (the same field an array uses) and drops `r.cls` to `TChar`, and then:
+
+- `LoadAtom` loads one byte (`A0 off` / `8A 46 disp`) and zeroes `AH`;
+- `Assignment` stores one byte when the symbol is a `TString` but `r.cls` is
+  `TChar` (the element) rather than `TString` (the whole string);
+- `IoCall` already classified on `arg.cls`, so `writeln (s[i])` picked `wrchar`;
+- the reader now classifies `read (s[i])` the same way and pushes `off+boff`.
+
+The bug this uncovered had been in the tree the whole time: `ParseAtom` ended
+its variable arm with
+
+    ParseVar (r) ;
+    r.boff := 0 ;       (* discard the subscript ParseSub just folded in *)
+
+`ParseVar` already sets `boff := 0` before calling `ParseSub`, so the reset was
+pure loss — `s[i]` (and any constant array subscript) addressed `off+0`, the
+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.
+
 ## 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
@@ -196,13 +220,13 @@ byte is a perfectly well-formed instruction that does something else, so no
 amount of inspecting the compiler's intent will catch it.  This harness closes
 amount of inspecting the compiler's intent will catch it.  This harness closes
 that gap the only way it can be closed -- by running the thing.
 that gap the only way it can be closed -- by running the thing.
 
 
-For each of 39 fixtures it compiles a `.COM`, writes a 1.44 MB floppy with the
+For each of 41 fixtures it compiles a `.COM`, writes a 1.44 MB floppy with the
 image in it, boots `qemu-system-i386 -fda disk.img -serial out.txt`, and
 image in it, boots `qemu-system-i386 -fda disk.img -serial out.txt`, and
 compares `out.txt` to the committed `tests/fixtures/tNN.out` **exactly** --
 compares `out.txt` to the committed `tests/fixtures/tNN.out` **exactly** --
 CRLF included -- plus the exit code the program hands to `INT 21h AH=4Ch`.
 CRLF included -- plus the exit code the program hands to `INT 21h AH=4Ch`.
 
 
 ```
 ```
-cd shell && python3 tests/run_com_exec.py            # all 39
+cd shell && python3 tests/run_com_exec.py            # all 41
 cd shell && python3 tests/run_com_exec.py --show     # print the serial file
 cd shell && python3 tests/run_com_exec.py --show     # print the serial file
 cd shell && python3 tests/run_com_exec.py --rebless  # rewrite the .out files
 cd shell && python3 tests/run_com_exec.py --rebless  # rewrite the .out files
 ```
 ```
@@ -231,7 +255,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.
 
 
-**39 of 44 fixtures execute.**  5 do not: `t14` and `t25` (`ENoLib`, by
+**41 of 46 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`

+ 56 - 7
shell/Compiler.mod

@@ -1648,6 +1648,7 @@ PROCEDURE LoadAtom (VAR r : ERes) ;
    a counted string where a 16-bit word is expected.  Reporting it here rather
    a counted string where a 16-bit word is expected.  Reporting it here rather
    than in the parser means writeln('hi') still works - IoCall handles a
    than in the parser means writeln('hi') still works - IoCall handles a
    literal before it ever calls LoadAtom. *)
    literal before it ever calls LoadAtom. *)
+VAR emsz : CARDINAL ;
 BEGIN
 BEGIN
    IF r.cls = TString THEN
    IF r.cls = TString THEN
       Err (ENoLib) ;                    (* string value used as a number *)
       Err (ENoLib) ;                    (* string value used as a number *)
@@ -1658,13 +1659,20 @@ BEGIN
       EmMovAxi (W16 (r.imm)) ;
       EmMovAxi (W16 (r.imm)) ;
       r.kind := 2
       r.kind := 2
    ELSIF r.kind = 1 THEN
    ELSIF r.kind = 1 THEN
-      IF symtab [r.idx].size > 2 THEN
+      IF (symtab [r.idx].size > 2) AND NOT
+         ((r.cls = TChar) AND (symtab [r.idx].cls = TString)) THEN
          Err (ENoLib)
          Err (ENoLib)
       ELSE
       ELSE
+         (* s[i] indexes one byte of a string, not the string, so its width is
+            one whatever the variable's declared width is. *)
+         emsz := symtab [r.idx].size ;
+         IF (r.cls = TChar) AND (symtab [r.idx].cls = TString) THEN
+            emsz := 1
+         END ;
          EmLoadVar (symtab [r.idx].local,
          EmLoadVar (symtab [r.idx].local,
                     (symtab [r.idx].off + r.boff) MOD 10000H,
                     (symtab [r.idx].off + r.boff) MOD 10000H,
-                    symtab [r.idx].size) ;
-         IF symtab [r.idx].size = 1 THEN
+                    emsz) ;
+         IF emsz = 1 THEN
             EmMovAh0 ()
             EmMovAh0 ()
          END ;
          END ;
          r.kind := 2
          r.kind := 2
@@ -1692,6 +1700,21 @@ BEGIN
          IF symtab [r.idx].cls = TArray THEN
          IF symtab [r.idx].cls = TArray THEN
             r.boff := W16 (VAL (LONGINT, r.boff)
             r.boff := W16 (VAL (LONGINT, r.boff)
                            + t.imm * VAL (LONGINT, symtab [r.idx].elem))
                            + t.imm * VAL (LONGINT, symtab [r.idx].elem))
+         ELSIF 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. *)
+            IF r.cls = TChar THEN
+               Err (ESimpType) ;
+               RETURN
+            END ;
+            r.boff := W16 (VAL (LONGINT, r.boff) + t.imm) ;
+            r.cls := TChar
          ELSE
          ELSE
             Err (ESimpType) ;
             Err (ESimpType) ;
             RETURN
             RETURN
@@ -2145,8 +2168,13 @@ BEGIN
       r.cls := symtab [idx].cls ;
       r.cls := symtab [idx].cls ;
       RETURN
       RETURN
    ELSIF (symtab [idx].tag = KVar) OR (symtab [idx].tag = KType) THEN
    ELSIF (symtab [idx].tag = KVar) OR (symtab [idx].tag = KType) THEN
+      (* ParseVar initialises boff to 0 and then ParseSub folds any constant
+         subscript into it.  Do NOT reset boff here: doing so after ParseVar
+         returns threw away the subscript, so s[i] read/wrote off+0 (the
+         length byte) while still reporting itself as a char.  This was
+         latent for arrays because an array cannot be declared yet, so no
+         fixture had ever exercised the fold. *)
       ParseVar (r) ;
       ParseVar (r) ;
-      r.boff := 0 ;
       RETURN
       RETURN
    ELSE
    ELSE
       Err (EUnknown)
       Err (EUnknown)
@@ -2301,7 +2329,7 @@ BEGIN
       Err (EUnknown) ;
       Err (EUnknown) ;
       RETURN
       RETURN
    END ;
    END ;
-   IF symtab [r.idx].cls = TString THEN
+   IF (symtab [r.idx].cls = TString) AND (r.cls = TString) THEN
       (* s := <string literal>.  TPSRC8 asn2 sends a string through the scalar
       (* s := <string literal>.  TPSRC8 asn2 sends a string through the scalar
          store path, and estcpl's string arm emits `MOV CL,max_len' (max_len =
          store path, and estcpl's string arm emits `MOV CL,max_len' (max_len =
          varsize-1) then `CALL strstore', which copies the source - length byte
          varsize-1) then `CALL strstore', which copies the source - length byte
@@ -2356,7 +2384,18 @@ BEGIN
       RETURN
       RETURN
    END ;
    END ;
    IF symtab [r.idx].size > 2 THEN
    IF symtab [r.idx].size > 2 THEN
-      Err (ENoLib) ;
+      IF (r.cls # TChar) OR (symtab [r.idx].cls # TString) THEN
+         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. *)
+      ParseExpr (src) ;
+      LoadAtom (src) ;
+      EmStoreVar (symtab [r.idx].local,
+                  (symtab [r.idx].off + r.boff) MOD 10000H, 1) ;
       RETURN
       RETURN
    END ;
    END ;
    ParseExpr (src) ;
    ParseExpr (src) ;
@@ -2444,7 +2483,17 @@ BEGIN
                   RETURN
                   RETURN
                END ;
                END ;
                acls := symtab [arg.idx].cls ;
                acls := symtab [arg.idx].cls ;
-               EmPushVarAddr (symtab [arg.idx].local, symtab [arg.idx].off) ;
+               (* s[i] is a CHAR variable, so read(s[i]) must read one byte at
+                  off+boff, not the whole string at off.  arg.cls is TChar
+                  (set by ParseSub) while the symbol's own class is still
+                  TString, which is exactly how the element is told from the
+                  whole string; the address push has to carry boff.  For a
+                  plain variable arg.boff is 0, so nothing changes. *)
+               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 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

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

@@ -307,3 +307,20 @@ uierror	ERR	41	331
 #                   writeln cases below embeds 240 bytes inline (wrtinl), so the
 #                   writeln cases below embeds 240 bytes inline (wrtinl), so the
 #                   overflow lands inside one of them at pos 4978.
 #                   overflow lands inside one of them at pos 4978.
 t43_codewindow	ERR	98	4978
 t43_codewindow	ERR	98	4978
+#   t44_strindex    s[i] read and write on a string, as a CHAR.  A STRING is a
+#                   length byte at s[0] then the characters, so s[i] is the one
+#                   byte at off+i; a constant index folds into boff exactly as
+#                   an array's would, and r.cls drops to TChar so LoadAtom,
+#                   Assignment and IoCall all treat it as one byte.  Only a
+#                   constant index is accepted (a variable index is ENoLib, as
+#                   it already was for an array).  data=16 is the global
+#                   string[10] (11 bytes), the char c (1) and two @@T temps.
+#                   The fix also had to drop ParseAtom's `r.boff := 0` after
+#                   ParseVar, which had been discarding every subscript.
+t44_strindex	OK	258	16
+#   t45_strindex_read  readln (s[i]) reads one character into position i; the
+#                   reader classifies by arg.cls (TChar for an indexed string)
+#                   and pushes off+boff.  data=15 is the global string[10]
+#                   (11 bytes) plus two @@T temps.  t45_strindex_read.in feeds
+#                   'X' then 'Y'.
+t45_strindex_read	OK	134	15

+ 5 - 0
shell/tests/fixtures/t44_strindex.out

@@ -0,0 +1,5 @@
+hel
+o
+Hello
+abc
+Hcllo

+ 30 - 0
shell/tests/fixtures/t44_strindex.pas

@@ -0,0 +1,30 @@
+program t44;
+(* String subscripts, s[i], in both directions.
+
+   A STRING is a length byte at s[0] followed by the characters, so s[i]
+   selects the single byte at off+i: reading it yields a CHAR, and assigning to
+   it stores one byte.  Both a global and a local string are exercised, because
+   the address paths differ (absolute vs [BP+off]).  Only a CONSTANT index is
+   covered: a variable index is ENoLib here, exactly as it already is for an
+   array. *)
+var
+  g : string[10] ;
+  c : char ;
+procedure p ;
+var
+  l : string[6] ;
+begin
+  l := 'abc' ;
+  writeln (l[1], l[2], l[3]) ;
+  g[2] := l[3]
+end ;
+begin
+  g := 'hello' ;
+  writeln (g[1], g[2], g[3]) ;
+  c := g[5] ;
+  writeln (c) ;
+  g[1] := 'H' ;
+  writeln (g) ;
+  p ;
+  writeln (g)
+end.

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

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

+ 2 - 0
shell/tests/fixtures/t45_strindex_read.out

@@ -0,0 +1,2 @@
+aXcde
+XY

+ 17 - 0
shell/tests/fixtures/t45_strindex_read.pas

@@ -0,0 +1,17 @@
+program t45;
+(* readln (s[i]) : reading into one character of a string.
+
+   s[i] is a CHAR variable, so the read must store one byte at off+i and the
+   address push has to carry the subscript.  Before this, the reader classified
+   by the SYMBOL's class (TString) and pushed off, so readln(s[i]) read a whole
+   line into s from its start instead.  The two reads here land at two
+   different positions, so either one being wrong changes the output. *)
+var
+  s : string[10] ;
+begin
+  s := 'abcde' ;
+  readln (s[2]) ;
+  writeln (s) ;
+  readln (s[1]) ;
+  writeln (s[2], s[1])
+end.