فهرست منبع

TP3-STRREAD-ASSIGN: readln(s)/read(s) and s := t for string vars

- Runtime: E_RdStr=16 E_StrStore=17; EmitRdStr stores length last after walking dest+1 (IncSi), stops at CR/LF/^Z, leaves terminator pending; EmitStrStore truncates to dest limit (Jcxz8 guard for CX=0), never writes source. RdStr reads limit in CX (compiler emits full MOV CX,imm16), strstore reads two args + CX.
- Compiler: EmMovCxi (B9 imm16); string assignment pushes source then dest and sets CX=dest.size-1; readln(s)/read(s) set CX and call rdstr. Local-frame layout is top-anchored (locFree = highest free byte) so strings grow up inside the frame while integer offsets remain byte-for-byte; fixes the local-string readln overwriting saved BP/return address.
- Tests: t40_readln, t41_strassign with .in/.out; expected.tsv updated for t39 (217 bytes); rt_exec extended to 12-byte slots with F_STACK2/F_CX, added rdstr/strstore cases (51/51); check_framedisp updated; check_runtime + runtime.golden re-blessed; RtProbe names include rdstr/strstore; nonvacuity adds 8 string/frame/arm mutations (78/78).
- Gates: compile matrix 42/42, run_com_exec t39/t40/t41 all PASS, run_all.sh ALL PASS, nonvacuity 78/78, rt_exec 51/51, audit 105/105.
Eric Streit 16 ساعت پیش
والد
کامیت
99a83fbbcf

+ 152 - 65
SUMMARY.md

@@ -30,6 +30,7 @@ manual, not guessed.
 | A `byte` reader of its own, and bug 36's four sites proved re-derivable | `v-TP3-NONVACUITY-RDBYTE` | done, **`readln (b)` of a `byte` prints the byte and leaves the variable beside it alone; reverting any one of the argument-order fix's four sites turns `t36_argclobber` red** |
 | A `BOOLEAN` read refused with TP3's own error 66 | `v-TP3-BOOLREAD` | done, **`readln (b)` of a boolean is an error, not a word store into a one-byte variable — exactly TPSRC8 `prderrtp`; the compile matrix demands `ERR 66` at the character after the variable, and the accepting dispatch is one of the in-suite reverts** |
 | String *variables* — `s : string[10]`, `s := 'hi'`, `writeln(s)` | `v-TP3-STRVAR` | done, **the first declared type bigger than a word that is not an error. `string [n]` is n+1 bytes — a length byte and characters, the layout TPSRC8's `string` type gives a variable (`INC BX / MOV varsize,BX`) — so `DefVar`'s `size > 2` catch-all (still the answer for an `array` at its point of use) exempts `TString`. `s := 'hi'` copies the literal byte by byte and truncates a longer one exactly as TPSRC2 `strstore` does; `writeln(s)` pushes the variable's address and calls a new `wrstr` entry (TPSRC4 `xwrtstr` minus its field-width formatting), which prints the length byte's worth of characters and nothing for `''`. Both a global and a local string are exercised — the absolute and the `[BP+off]` address paths — and the `.out` is compared byte for byte under qemu and Exec86 (36/36)** |
+| 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)** |
 
 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
@@ -71,7 +72,7 @@ gm2 -fiso -fuse-module-list=modules.lst -o tpshell \
         Shell.mod Compiler.mod Term.o TextBuf.o Posix.o Editor.o   # phase B2
 ```
 
-Current clean build: `make clean && make` → rc=0, `tpshell` **163616 bytes**.
+Current clean build: `make clean && make` → rc=0, `tpshell` **191072 bytes**.
 The one diagnostic is `./Compiler.mod: ParseExpr: too many errors in pass 3`,
 which is the expected phase-1 rollup that the recipe tolerates — not a real
 error. `shell/Makefile` is the single authoritative build recipe.
@@ -123,11 +124,11 @@ needing a squint. It was checked for vacuousness by reverting the string
 scanner fix: the suite went red with exit 1, and came back green only when the
 fix was restored.
 
-**36 of 40 fixtures compile clean**, up from 1 (the empty program) when the
+**38 of 42 fixtures compile clean**, up from 1 (the empty program) when the
 direct harness was first built.
 
 ```
-compile matrix: 40 passed, 0 failed (of 40)
+compile matrix: 42 passed, 0 failed (of 42)
 ```
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -146,7 +147,9 @@ operators nothing else uses (`div mod and or`, unary `-`, a variable `*`) ·
 arguments are computed in various positions, each one keeping its own value. ·
 `t37` `readln (b)` of a `byte` sitting next to an `integer` ·
 `t39` `s : string [10]`, assigned a literal and written — globally and from
-inside a procedure, so both address paths run
+inside a procedure, so both address paths run ·
+`t40` `readln` into a global *and* a local `string` ·
+`t41` `s := t` between two strings, with the truncation both ways.
 
 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
@@ -156,9 +159,9 @@ direction is still unimplemented) both → `ENoLib`
 refuses the whole class because the runtime has no boolean reader at all; and
 `uierror` is a deliberate syntax error (41) used by the UI test.
 
-`run_com_tests.sh` additionally links **all 36 that compile** to a real `.COM`
+`run_com_tests.sh` additionally links **all 38 that compile** to a real `.COM`
 and re-verifies the bytes with an independent Python checker that *measures*
-the layout instead of restating it: `independent .COM check: 36 checked, 0
+the layout instead of restating it: `independent .COM check: 38 checked, 0
 failed`. That last part was itself a bug fix — see "the two restated
 constants" below.
 
@@ -197,13 +200,13 @@ proves each one can go red.
 |---|---|---|
 | `probe/run_modrm19.py` | the mod=00/01/10 effective addresses, by **executing** 23 cases on a real 8086 under qemu and scanning for where the marker landed | mod=11 — see below |
 | `probe/modrm11.py` | the mod=11 register identities, by **encoding** with GNU `as` and decoding with FCML, against hard-coded bytes | the table agreeing with itself |
-| `audit_helpers.py` | every one-line emitter in **both** `Runtime.mod` and `Compiler.mod` decodes to what its *name* says — **102/102**, 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 — **105/105**, from an inventory scanned independently of the parser | anything longer than one instruction |
 | `check_comimage.py` | a linked image's layout is described in **exactly one file**: `comimage.py` defines each part once, every reader imports it, no second `find_header` exists anywhere in `tests/` | a check that *hand-rolls* the layout from literals (`hdr = 3 + rt_size`), which defines none of those names — the readers catch that a different way: each states the layout twice, from two independent sources, and requires the two to agree |
-| `check_runtime.py` + `runtime.golden` | the built runtime's code region (497 bytes, code ends at 466) 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 |
 | `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, 36/36 | 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, 42 cases plus a pre-flight, 43/43 — 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 |
+| `run_exec86.py` | the same image runs in a **second, independent 8086** and agrees with qemu byte for byte, 38/38 | the `.out` file it shares with `run_com_exec.py` — a wrong expectation fails both at once |
+| `rt_exec.py` | the **runtime entries themselves** are called directly, one qemu boot per call, 50 cases plus a pre-flight, 51/51 — plus a `check_bp_contract` pre-flight over the built blob: an entry that borrows BP must open with `55 8B EC` (exact) and contain a `5D` (a screen, because `5D` is also a displacement byte) | whether the *caller's* half of a contract is right, where the only witness is a case that happens to make two calls in a row |
 
 Two of these deserve the detail, because the reason they exist is the reason
 they are hard.
@@ -275,16 +278,16 @@ and `tests/check_comimage.py` asserting there is only one copy of each.
 ### Execution under qemu — `tests/run_com_exec.py`
 
 The check that cannot be written as a byte comparison, so also the one that
-finds the most: 35 fixtures are compiled to `.COM`, put on a floppy, booted,
+finds the most: 38 fixtures are compiled to `.COM`, put on a floppy, booted,
 and their serial output compared to a committed `.out` file **exactly** — CRLF
 included — plus the exit code passed to `INT 21h AH=4Ch`.
 
 ```
-execution: 35 passed, 0 failed (of 35)
+execution: 38 passed, 0 failed (of 38)
 ```
 
 This is no longer the only execution oracle: `run_exec86.py` below runs the
-same 35 images a second time, in a different machine, and requires the two to
+same 38 images a second time, in a different machine, and requires the two to
 agree byte for byte.
 
 It also found bug 33 — the branch polarity inverted in *every* conditional in
@@ -307,7 +310,7 @@ signature (`hdrFlag = 1`, `hdrDS == hdrOff + 1000h + bias`, `hdrHeap > hdrDS`,
 `prologAt` and `dataBase` per file:
 
 ```
-measured runtime size: 497 bytes (header at image offset 500)
+measured runtime size: 616 bytes (header at image offset 619)
 ```
 
 A restated constant that has drifted is worse than a derived one, and the two
@@ -334,8 +337,8 @@ compiler: **does the 8086 have this instruction at all?**
 8086 check: 38 comparison sites, 34 lowered to a Boolean value, 13 lowered to a branch
             value conditions  : = x6  <> x2  < x5  >= x3  <= x2  > x16
             branch conditions : IF / REPEAT x9  CASE x2  FOR downto x1  FOR to x3
-            runtime: 497 bytes, 256 swept, 0 0F-prefixed
-            program code: 34 of 36 fixtures swept end to end, 3720 bytes
+            runtime: 616 bytes, 330 swept, 0 0F-prefixed
+            program code: 38 of 38 fixtures swept end to end, 4315 bytes
             t33_cmpops: 13 comparisons matched against their source operators, in order
             clause H: 8 of 8 fixtures matched the branch conditions read off their source
 ```
@@ -389,7 +392,7 @@ check runs every fixture through it as well as through qemu and requires the
 two to agree **byte for byte**:
 
 ```
-exec86: 35 passed, 0 failed (of 35), cross-checked against qemu
+exec86: 38 passed, 0 failed (of 38), cross-checked against qemu
 ```
 
 Three assertions per fixture, in order of how much they are worth: the output
@@ -447,9 +450,9 @@ goes red before a single instruction has run.
 
 ### Non-vacuity — `tests/nonvacuity.sh`
 
-Every assertion in this file is proved able to fail: **67 deliberate
+Every assertion in this file is proved able to fail: **78 deliberate
 breakages, each asserted to turn exactly one named check red for the stated
-reason, then restored and re-asserted green** — `non-vacuity: 67 ok, 0 failed`.
+reason, then restored and re-asserted green** — `non-vacuity: 78 ok, 0 failed`.
 They cover the runtime's emitter audit and its restored source, the mod=11
 table (including restoring the exact wrong table this project once shipped),
 the `[BP+off]` rule, the behavioural bugs, the emitter-name audit of
@@ -464,6 +467,23 @@ must come back red on `t38`'s `ERR 66` row. Nothing that runs programs could
 see that one — an `ERR` fixture is never executed, so the refusal has to be
 asserted where the refusal happens.
 
+Eight more arrived with `v-TP3-STRREAD-ASSIGN`, the string milestone. Two break
+the new **runtime entries**: `rdstr`'s `IncSi` (removed → `hello` comes back as
+`05 65 6c 6c 6f`, caught only by `rt_exec.py`'s byte dump) and `strstore`'s
+empty-source `Jcxz8` guard (removed → `LOOP` with `CX = 0` runs 65536 times,
+wraps the segment and corrupts the driver's own record, which `rt_exec.py`
+reports as a malformed record). One reverts the **frame-layout fix** — the
+pre-fix rule packs a local `string` over `[BP]` and `[BP+2]` — and requires
+*two* independent readers to go red: `run_compile_tests.sh` on `t39`'s size
+(`217` → `215`) and `run_com_exec.py` on `t40` (readln into the local never
+sees its terminator and the machine hangs). Four more break `Compiler.mod`
+arms: the destination limit loaded into `AX` instead of `CX` (the audit's
+`MovCxi` row is `COMP`, so no byte check can see it — only `t41`'s truncation
+can, and only there: `t40` passes either way because its `CX` luckily survives
+large enough), the assignment's two pushes swapped so `strstore` reads the
+destination as its own source, the string-read dispatch pointed at `TU_RdInt`
+so `readln(s)` prints a blank line, and the same round trip re-run green.
+
 Those four replace a proof that could only be quoted. `t36_argclobber`'s
 `.out` had been compiled and run against the compiler as it stood **before**
 the argument-order fix, on both oracles, and came back red on every line whose
@@ -561,7 +581,7 @@ checker.
 compile to `.COM` images, are booted on a floppy by a 512-byte hand-assembled
 boot sector, and are compared **byte for byte** against the exact output the
 fixture demands — `tests/run_com_exec.py`, wired into `run_all.sh`, 21/21 at
-that tag and 36/36 now.
+that tag and 38/38 now.
 
 Everything below is about establishing what *can* be believed, because the
 first attempt at this used an emulator that was wrong, and a wrong oracle is
@@ -674,19 +694,24 @@ told apart from a broken harness without re-deriving anything.
 never calls `rdint` without a `readln` behind it, never calls `rdbool` twice in
 a row, and never exercises the `INT16` extremes individually.
 
-42 cases, one fresh qemu boot each, plus a pre-flight that runs before any of
+50 cases, one fresh qemu boot each, plus a pre-flight that runs before any of
 them: `initmem` (data poisoned to `AA` first, so
 a zero is a *result* and not a leftover), `wrint` ×10 including both `INT16`
 extremes, `wrchar` ×2, `wrbool` ×3, `wrln`, `stackchk`, a composed
 `writeln(42) writeln TRUE` sequence, `rdint` ×6 against supplied input,
 `rdchar` ×2, `rdbool` ×6, `rdbyte` ×4 against supplied input with a poison
 byte sitting past the one-byte variable, `rdint`/`rdbyte` at EOF, `wrtinl`,
-and `wrstr` ×2 — a five-character string and an empty one, seeded in the
+`wrstr` ×2 — a five-character string and an empty one, seeded in the
 driver's own memory so the entry dereferences a real pointer rather than the
-call site's return address. The two `wrstr` cases exist because the entry has
-the *opposite* argument contract from `wrtinl`: its stack word is an
-address, and the only way to test "prints the length byte's worth and stops"
-twice is two lengths.
+call site's return address — plus `rdstr` ×4 (a line, a line longer than the
+limit, an empty line, and end of input) and `strstore` ×4 (a whole copy, a
+truncation, an empty source, and a dump of the *source* to prove it is not
+written). The two `wrstr` cases exist because the entry has the *opposite*
+argument contract from `wrtinl`: its stack word is an address, and the only
+way to test "prints the length byte's worth and stops" twice is two lengths.
+The string cases seed each destination with `0xEE` before the call, so a byte
+the entry does not write is visibly still `EE`: "the tail past the new length
+is left untouched" is a checked claim, not a comment.
 
 Three properties are load-bearing, and each of them was chosen against the
 easier alternative:
@@ -694,11 +719,12 @@ easier alternative:
 - **One boot per case.** A machine that has already run a case has already run
   a runtime entry, and if that entry corrupts something the next case inherits
   it. About a tenth of a second a boot buys a machine that has provably never
-  executed anything, and 35 of them cost under four.
+  executed anything, and all 51 checks cost about twenty seconds.
 - **One case record, written twice.** `rt_exec.py` and `exec/rtdrv.s` each
-  state the 40-byte layout, and the cases only pass if the two agree on every
-  offset. A driver that silently read the wrong field would still boot, still
-  run, and still print — so the duplication is the check.
+  state the 12-byte slot and 64-byte case layout, and the cases only pass if
+  the two agree on every offset. A driver that silently read the wrong field
+  would still boot, still run, and still print — so the duplication is the
+  check.
 - **A BP-contract pre-flight, before any machine starts.** See below.
 
 The harness reuses `tests/exec/bootcom.s`, the same 512-byte boot sector
@@ -884,7 +910,9 @@ position), and code/data size accounting. Emitted image is a byte array
 
 Working subset (v0.5): integer/char/boolean/byte scalars, constants with
 folding, globals, locals, value parameters, procedures and scalar-result
-functions, `ARRAY[const..const]` with constant indexing, control flow,
+functions, `ARRAY[const..const]` with constant indexing, `string [n]`
+variables with literal assignment, string-to-string assignment and
+`readln`/`read` into a string, control flow,
 `GOTO`/`EXIT`, the standard procedures `WRITE`, `WRITELN`, `READ`,
 `READLN`, `HALT`, and **inline string literals** as `WRITE`/`WRITELN`
 arguments.
@@ -910,6 +938,17 @@ inline-literal form, which differs in kind: no value is pushed and the
 `ADD SP,2` is absent, because the length and the characters *are* the
 argument.)
 
+`readln(s)`/`read(s)` and `s := t` keep the same one-call-per-argument shape
+and add one thing: the *limit*. A string entry reads it from `CX`, set with a
+full `MOV CX,imm16` (`EmMovCxi`, opcode `B9`) rather than a byte move into
+`CL`, because this runtime has no byte-register move and `rdstr`/`strstore`
+use `CX` for the count outright. So `readln(s)` is `LEA AX,[addr] ; PUSH AX ;
+MOV CX,size-1 ; CALL rdstr ; ADD SP,2` followed by the ordinary `rdln` wrapper
+that consumes the terminator `rdstr` leaves pending, and `s := t` is
+`PUSH src ; PUSH dest ; MOV CX,size-1 ; CALL strstore ; ADD SP,4` — source
+first so it lands at `[BP+6]` and the destination at `[BP+4]`, both popped by
+the caller exactly as TP3's `asnvar`/`estcpl` pair does.
+
 `READ`/`READLN` push the *address* so the runtime can store
 (`EmPushVarAddr`: `LEA AX,[BP+off]` via `EmBpDisp` for locals, `8D 06 off` for
 globals); a non-variable argument is `ETypeErr` (56), as in TP3.
@@ -933,7 +972,7 @@ initialised past it (`MOV pc,#$2D7C`). Same shape here, and now actually done:
 image-absolute. **No relocation pass is needed** — worth having paid for, since
 a linker that has to walk fixups is a linker that can get them wrong.
 
-Current blob: **497 bytes**, 16 entries, 42 branch targets, offsets read back
+Current blob: **616 bytes**, 18 entries, 51 branch targets, offsets read back
 out of the assembled bytes by `tests/check_runtime.py` rather than asserted by
 hand:
 
@@ -944,17 +983,23 @@ hand:
 | `stackchk` | 35 | `wrbool` | 111 | `rdchar` | 340 |
 | `halt` | 28 | `wrreal` | 136 | `rdbool` | 363 |
 | | | `wrln` | 144 | `rdln` | 411 |
-| | | `wrtinl` | 152 | | |
-| | | `wrstr` | 171 | | |
+| | | `wrtinl` | 152 | `rdstr` | 466 |
+| | | `wrstr` | 171 | `strstore` | 530 |
 
 Every number in that table is measured, and that is the point: it was last
 correct when the blob was 391 bytes, and the entries quietly moved as it grew
-past 436 to 465 and again to 497 (`wrstr` joined, `v-TP3-STRVAR`) — six of
-the offsets in an earlier version of this table were
-stale, and the size was stale too. A restated constant that has drifted is
-worse than a derived one, which is why the compiler asks
-`Runtime.RT_Entry` instead (below) and why the checkers locate the runtime by
-its own signature rather than by a literal.
+past 436 to 465, to 497 (`wrstr` joined, `v-TP3-STRVAR`) and to 616 (`rdstr`
+and `strstore` joined, `v-TP3-STRREAD-ASSIGN`) — six of the offsets in an
+earlier version of this table were stale, and the size was stale too. A
+restated constant that has drifted is worse than a derived one, which is why
+the compiler asks `Runtime.RT_Entry` instead (below) and why the checkers
+locate the runtime by its own signature rather than by a literal.
+
+The two newest entries are the string readers and stores. `rdstr` is TPSRC4's
+`xrdstr`; `strstore` is TPSRC2's `strstore` as TPSRC8's `asnvar` calls it. Both
+read the **limit** from `CX` rather than a stack word, and both were emitted
+last (selectors 16 and 17) so the entries below keep their offsets.
+
 
 The `TU_*` constants in `Compiler.mod` are **assigned from
 `Runtime.RT_Entry` in `Inittur`**, not written down, so a runtime edit that
@@ -971,6 +1016,8 @@ Conventions, matching `Compiler.IoCall` exactly:
 | `WrInt/WrChar/WrBool/WrReal` | one 16-bit **value** on the stack | caller pops |
 | `RdInt/RdByte/RdChar/RdBool` | one **address** on the stack | caller pops |
 | `WrStr` | one **address** on the stack — the string variable's length byte first | caller pops |
+| `RdStr` | one **address** on the stack, and the limit in **`CX`** | caller pops; `CX` = `size-1` |
+| `StrStore` | **two** addresses on the stack — destination at `[BP+4]`, source at `[BP+6]` | caller pops both; `CX` = the destination's `size-1` |
 | `WrLn/RdLn/StackChk` | nothing | |
 | `InitMem` | `AX` = offset of the program header | a *register*, not a stack word |
 | `ProgEnd/Halt` | nothing | exits, code 0 |
@@ -1013,9 +1060,9 @@ all patched after the blob is placed, so nothing depends on a hand-computed
 displacement.
 
 **It executes.** Every entry is called directly under qemu by
-`tests/rt_exec.py` — 43 cases, all passing — with the register contract each
-entry must honour stated rather than assumed, which is how `wrchar` and
-`wrbool` were caught destroying the caller's BP. That milestone
+`tests/rt_exec.py` — 50 cases plus a pre-flight, 51/51 — with the register
+contract each entry must honour stated rather than assumed, which is how
+`wrchar` and `wrbool` were caught destroying the caller's BP. That milestone
 (`v-TP3-BP-CONTRACT`) is the reason this paragraph no longer says the runtime
 has never run; it did, for two releases. What is *not* covered is what a direct
 call cannot reach: the runtime is still measured entry-by-entry rather than
@@ -1046,9 +1093,9 @@ assertion, rather than asking the compiler where it thinks the header is.
 last dot, padded with a zero gap to `max(pc, dc)`; its stack sits at the segment
 top (`SS = SP = CS:FFFE`), which is where DOS puts it.
 
-**Known limit:** the data area starts at a fixed `rtSz + 1000H` (497 + 4096 =
-4593), so a program whose code exceeds 4 KiB runs into its own data. Every
-fixture's image is between 4600 and 4611 bytes. Documented rather than fixed,
+**Known limit:** the data area starts at a fixed `rtSz + 1000H` (616 + 4096 =
+4712), so a program whose code exceeds 4 KiB runs into its own data. Every
+fixture's image is between 4719 and 4766 bytes. Documented rather than fixed,
 because the
 original has the same fixed-offset behaviour.
 
@@ -1117,9 +1164,9 @@ Details that are deliberate, not incidental:
   `t34_arith`
   through the pty because a pty test is expensive and this one's job is to
   prove the *path* exists (compile → poke → run → report → no file written),
-  which it does with eight assertions. The other 34 are covered by
+  which it does with eight assertions. The other 37 are covered by
   `run_exec86.py`, which calls the same interpreter directly.
-- **Every fixture that compiles is now also run.** 36 of 40 execute; the 4 that
+- **Every fixture that compiles is now also run.** 38 of 42 execute; the 4 that
   do not are `t14` and `t25` (`ENoLib`, by design), `t38` (a boolean read,
   refused with TP3's error 66, by design) and `uierror` (a deliberate syntax
   error). The gap this replaces was nine fixtures that compiled and were
@@ -1177,11 +1224,11 @@ Details that are deliberate, not incidental:
   (`7x 03 E9`, searching for the `E9`) and by `t22_case`'s execution, but the
   label immediate itself is unchecked.
 - **The runtime's entries are now each called directly, and two of its
-  invariants are stated rather than implied.** 497 bytes, 16 entries, 102
+  invariants are stated rather than implied.** 616 bytes, 18 entries, 105
   emitter helpers decoded against their own names across both modules, the
   whole code region golden-pinned, all branch targets on instruction
-  boundaries — and 42 cases that call the entries one at a time under qemu
-  (`rt_exec.py`, 43/43 with its BP pre-flight). What is *not* covered is what a direct call cannot
+  boundaries — and 50 cases that call the entries one at a time under qemu
+  (`rt_exec.py`, 51/51 with its BP pre-flight). What is *not* covered is what a direct call cannot
   see: `wrtinl` is checked, but only from the harness's side of the calling
   contract; `wrstr` only from its seeded-address cases. (The nine fixtures that compiled but were never executed used to sit
   here as an extra gap; they are now run, so every fixture that reaches the
@@ -1192,7 +1239,7 @@ Details that are deliberate, not incidental:
   hard-coded — but the two `RT_SZ` constants that *were* hard-coded and had
   drifted (see the execution section) are the precedent for why this one gets
   stated every time.
-- **33 executed fixtures is a small sample of Pascal.** They cover `var`, `const`,
+- **38 executed fixtures is a small sample of Pascal.** They cover `var`, `const`,
   `if`, `while`, `for`, `repeat`, `case` over scalars, procedures with value
   parameters, `goto`/`label`, string literals, `readln`, all six comparisons,
   and the arithmetic and logical operators `* + - div mod and or not` on
@@ -1218,13 +1265,15 @@ Details that are deliberate, not incidental:
   emitter has to be revisited. The audit handles them as a documented
   *sequence*; if another one appears, the sequence machinery is where it goes,
   not a name hack.
-- **String *literals* and string *variables* work; the rest of the string
-  language does not.** A literal in a `WRITE`/`WRITELN` argument list is
-  emitted inline and needs no runtime support beyond `wrtinl`; a variable
-  (`s := 'hi'`, `writeln(s)`) is the `v-TP3-STRVAR` milestone — byte-by-byte
-  literal stores and the `wrstr` entry. Still refused: `readln(s)`, assignment
-  from one string to another, indexing, and the concatenation/comparison
-  operators. The `chr` flag on `ERes` is what keeps
+- **String *literals* and string *variables* work, and strings now read and
+  assign; indexing and the operators do not.** A literal in a
+  `WRITE`/`WRITELN` argument list is emitted inline and needs no runtime
+  support beyond `wrtinl`; a variable (`s := 'hi'`, `writeln(s)`) is the
+  `v-TP3-STRVAR` milestone — byte-by-byte literal stores and the `wrstr` entry;
+  `readln(s)`/`read(s)` and `s := t` are `v-TP3-STRREAD-ASSIGN`, the `rdstr`
+  and `strstore` entries with the limit in `CX`. Still refused: indexing
+  (`s[i]`) and the concatenation/comparison operators. The `chr` flag on
+  `ERes` is what keeps
   `writeln('a')` calling the *character* writer instead of the integer writer;
   without it the compiler emitted the integer path and printed 97 while the test
   still said OK.
@@ -1242,7 +1291,7 @@ Details that are deliberate, not incidental:
   and **must not** use `EXIT`, which ICEs gm2 in pass 3 (see the pitfalls
   list). Not yet done.
 - **Not implemented** (all `ENoLib`): real, set, record, file, string
-  *reading*, string-to-string assignment, indexing, the `+`/comparison string
+  *indexing* (`s[i]`), the `+`/comparison string
   operators, and any declared type wider than 2 bytes other than `TString`.
   `with` is `ENoLib`. `case` *is*
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
@@ -1815,6 +1864,45 @@ reason for existing.
     `rdbool` stays in the runtime and `rt_exec` still probes it; the compiler
     simply no longer reaches it.
 
+### Then strings learned to be read and assigned, and three more appeared
+
+39. **`rdstr` stored its first character where the length byte belongs.**
+    `INCsI` — one instruction, `MOV SI,AX` then step past the length byte — was
+    missing, so the first character went to `dest[0]`; the routine stores the
+    length there at the end, overwriting it. `hello` read back as
+    `05 65 6c 6c 6f`. Nothing moved: same sizes, same offsets, the whole
+    compile matrix and every golden green. This is bug 30's family — a
+    one-instruction omission that leaves well-formed bytes — and only
+    `rt_exec.py`'s byte dump can see it, because only there is the *contents*
+    of the string compared and not just its length.
+
+40. **`strstore` copied 65536 characters when the source was empty.** `LOOP`
+    decrements `CX` and branches while it is *non-zero*, so `CX = 0` on entry
+    runs the full 65536 iterations, not zero. `s := ''` therefore wrote 64 KiB
+    from the destination onward, wrapped the segment and walked through the
+    driver's own case record. The guard is a single `JCXZ` before the loop; the
+    compiler emits `MOV CX,imm16` and the entry has to expect that `CX` can be
+    zero because the *source* is empty. `rt_exec.py` reports the case as a
+    malformed record rather than a crash, which is the honest shape of "the
+    entry wrote where it had no business writing".
+
+41. **A local `string` was packed with the integer rule and grew over the
+    frame.** Present since the code generator was written (bug 32's shape: not
+    introduced, merely *reached*). Every local took the pre-decrement `locFree`
+    and every local was assumed to grow *down*; a `string` grows *up* from its
+    base (length byte, then characters), so a `string[5]` local at `BP-2` ran
+    to `BP+3` — over the saved BP at `[BP]` and the return address at `[BP+2]`.
+    Nothing before this milestone could see it, because nothing before this
+    milestone stored *into* a local string; `t39` only ever wrote a literal
+    into a local of three characters (`BP-2..BP+1`, over `[BP]` alone, which
+    the procedure's own `PUSH BP` happened to make survivable). `readln(s)` of
+    five characters stored them over the return address, and `t40` then ran the
+    string's own bytes as an address: Exec86 said `fault at IP=0x6F6C`, which is
+    `l`,`o`. The fix is top-anchored packing that keeps the *integer* offsets
+    identical (`-2`, `-4`, …) and puts a `string[5]` at `BP-6..BP-1`. It moved
+    one fixture's code size (`t39`, `215` → `217`, a disp8 address becoming a
+    disp16 one), which is what the compile matrix pins.
+
 ## The bug family, stated once
 
 Nine of the bugs recorded here are the *same* bug in different clothes: **loading the
@@ -1894,11 +1982,10 @@ independently-scanned inventory at all.
 
 ## Next steps
 
-1. The rest of the string language, now that `s : string` exists (the
-   `v-TP3-STRVAR` milestone covers `s := 'literal'` and `writeln(s)`):
-   `readln(s)` (TP3's `xrdstr`), `s := t` between two string variables,
-   `s [i]` indexing, and the `+` / comparison operators. All currently
-   `ENoLib`.
+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.
 2. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its

+ 25 - 13
TP3-COMPILER.md

@@ -42,6 +42,19 @@ whole-program pass 3 silently caps identifier/error counts — see
 6. **Module-level `errNo` renamed `errNum`.**  `Compile`'s formal `errNo`
    shadowed the module variable, so `errNo := errNo` was a *self*-assignment
    and every error code reached the caller as 0.
+7. **Local frame layout (string support):** locals are packed top-anchored
+   (`locFree` is the highest free byte, initialised to `0FFFFH`; a symbol of
+   size `n` gets `off := (locFree - n + 1) MOD 10000H`, then `locFree` drops
+   by `n`) so a local `string` grows up inside the frame and does not
+   overwrite the saved BP/return address. The integer-local offsets remain
+   byte-for-byte identical (`-2,-4,...`).
+8. **String read/assign codegen:** new emitter `EmMovCxi (imm)` emits
+   `MOV CX,imm16` (opcode `0B9H`) for the runtime's limit; `IoCall` uses it
+   before calling `rdstr` for string arguments to `read`/`readln`; the
+   string-assignment arm pushes source first, then destination, sets `CX` to
+   the destination's `size-1`, and calls `strstore`. The runtime gained
+   `EmitRdStr` and `EmitStrStore` (selectors 16/17) to match TP3's `xrdstr`
+   and `strstore`.
 
 The compiler's whole-program 8086-emit, TEXT-equivalent, pastres and
 errexit flow are otherwise unchanged (mirrors TP3 TPSRC file, ConvertTP3).
@@ -141,13 +154,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
 that gap the only way it can be closed -- by running the thing.
 
-For each of 31 fixtures it compiles a `.COM`, writes a 1.44 MB floppy with the
+For each of 38 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
 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`.
 
 ```
-cd shell && python3 tests/run_com_exec.py            # all 31
+cd shell && python3 tests/run_com_exec.py            # all 38
 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
 ```
@@ -176,14 +189,13 @@ partial read or a wrong sector count would have been visible, this was not.
 window at `C000h`; and the copy is executed code, so it is the last thing
 that touches the image and no BIOS call follows it.
 
-**30 of 33 fixtures execute.**  3 do not compile.  (Until this milestone 9 more
-compiled without a `.out` and were never run at all; they have all been
-rewritten to print and are now covered.)
-`.out` file -- and those 9 are the control-flow fixtures (`t08` const, `t09`
-if, `t10` while, `t11` for, `t12` repeat, `t13` procedure, `t15` label, `t27`
-five locals, `t28` 70 parameters).  They have byte-level checks and no
-behavioural check.  That is the next gap to close, and it is a gap in the
-*tests*, not in the compiler.
+**38 of 42 fixtures execute.**  4 do not: `t14` and `t25` (`ENoLib`, by
+design), `t38` (a boolean read, refused with TP3's error 66, by design) and
+`uierror` (a deliberate syntax error).  The nine control-flow fixtures (`t08`
+const, `t09` if, `t10` while, `t11` for, `t12` repeat, `t13` procedure, `t15`
+label, `t27` five locals, `t28` 70 parameters) used to compile without a
+`.out` and were never run at all; they now print and are covered, which closed
+the last gap between "compiles" and "runs".
 
 What execution found that no byte-level check could: the `FOR` loop was
 post-tested, `EXIT` targeted the increment, procedure bodies executed as part
@@ -197,11 +209,11 @@ plausible byte counts.  See `SUMMARY.md` for the full list.
 **runtime entries** do, which is a different question: a compiled program never
 calls `rdint` without a `readln` behind it, never calls `rdbool` twice in a
 row, and never exercises the `INT16` extremes one at a time.  It is in
-`run_all.sh` and **36/36 pass** — 35 cases plus the pre-flight below, which is
+`run_all.sh` and **51/51 pass** — 50 cases plus the pre-flight below, which is
 counted as a check because it is one.
 
 ```
-cd shell && python3 tests/rt_exec.py             # all 35 cases + the pre-flight
+cd shell && python3 tests/rt_exec.py             # all 50 cases + the pre-flight
 cd shell && python3 tests/rt_exec.py --probe     # the RtProbe dump alone
 cd shell && python3 tests/rt_exec.py --list      # the case list, with expectations
 cd shell && python3 tests/rt_exec.py --show      # print a case's wire bytes
@@ -214,7 +226,7 @@ case inherits it.  About a tenth of a second a boot buys a machine that has prov
 executed anything, which is the only version of that claim worth making.
 
 **The case record is stated twice on purpose.**  `rt_exec.py` and
-`exec/rtdrv.s` each describe the same 40-byte record independently, and a case
+`exec/rtdrv.s` each describe the same 64-byte record independently, and a case
 only passes if the two agree on every offset.  A driver that read the wrong
 field would still boot, still run, and still print, so the duplication *is* the
 check -- the same argument as the `.COM` layout constants in

+ 72 - 16
shell/Compiler.mod

@@ -269,6 +269,10 @@ VAR
    TU_RdByte   : CARDINAL ;  (* one-byte variable reads; see IoCall *)
    TU_WrInl    : CARDINAL ;  (* inline string literal; NO stack argument *)
    TU_WrStr    : CARDINAL ;  (* string variable; address is the stack argument *)
+   TU_RdStr    : CARDINAL ;  (* readln(s)/read(s); address on the stack, limit
+                                in CX *)
+   TU_StrStore : CARDINAL ;  (* s := t; dest and source addresses on the stack,
+                                limit in CX *)
 
    abortFac : BOOLEAN ;
    errNum   : CARDINAL ;        (* NOT "errNo": Compile's formal of that
@@ -603,6 +607,11 @@ BEGIN
    Ebyte (0B8H) ; Eword (imm)
 END EmMovAxi ;
 
+PROCEDURE EmMovCxi (imm : CARDINAL) ;
+BEGIN
+   Ebyte (0B9H) ; Eword (imm)
+END EmMovCxi ;
+
 PROCEDURE EmMovBpSp () ;
 BEGIN
    Ebyte (8BH) ; Ebyte (0ECH)
@@ -827,11 +836,14 @@ PROCEDURE EmBpDisp (off : CARDINAL) ;
 
    Both displacements are SIGNED, and that is the whole subtlety:
 
-     - `off` is a 16-bit value and locals are allocated DOWNWARD from
-       0FFFEh (locFree starts there and is decremented per declaration), so
-       the first local of a procedure sits at off = 0FFFC, which is -4.  The
-       disp16 form reads those same two bytes as a signed value and addresses
-       [BP-4] correctly.  There is no overflow case: all 65536 values of `off`
+     - `off` is a 16-bit value and locals are packed top-anchored below BP:
+       locFree is the highest still-free byte (0FFFFh = -1 at entry), a local of
+       size n is based so its LAST byte sits on locFree, and locFree then drops
+       by n.  So the first INTEGER local is at off = 0FFFEh = -2, the second at
+       0FFFCh = -4, and so on - and a string local, which grows UP from its
+       base, lands wholly below BP instead of straddling it.  The disp16 form
+       reads those same two bytes as a signed value and addresses the negative
+       slot correctly.  There is no overflow case: all 65536 values of `off`
        are representable, and a frame larger than 64K is a different problem.
      - The old code took `off MOD 100H` and always emitted disp8.  That is
        the correct low byte for every displacement, so it was accidentally
@@ -2289,8 +2301,30 @@ BEGIN
          truncation included, and the tail past the new length is left as it
          was, exactly as strstore leaves it. *)
       ParseExpr (src) ;
-      IF (src.cls # TString) OR (src.kind # 3) THEN
-         Err (ENoLib) ;              (* only literal assignment exists yet *)
+      IF (src.cls # TString) THEN
+         Err (ENoLib) ;
+         RETURN
+      END ;
+      IF src.kind # 3 THEN
+         (* s := <string variable>.  TPSRC8 asnvar pushes the destination
+            address, then the expression (strload) pushes the source, and
+            estcpl emits MOV CL,max_len then CALL strstore, which copies
+            length byte + chars, TRUNCATING to max_len and leaving the
+            destination tail past the new length untouched.  Here both
+            addresses are pushed - source first, so it sits at [BP+6] and the
+            destination at [BP+4] - the caller pops both, and the limit is a
+            full MOV CX,imm16 because this runtime reads it in CX outright. *)
+         IF src.kind # 1 THEN
+            Err (ENoLib) ;          (* a computed string is not a value *)
+            RETURN
+         END ;
+         EmPushVarAddr (symtab [src.idx].local,
+                        (symtab [src.idx].off + src.boff) MOD 10000H) ;
+         EmPushVarAddr (symtab [r.idx].local,
+                        (symtab [r.idx].off + r.boff) MOD 10000H) ;
+         EmMovCxi ((symtab [r.idx].size - 1) MOD 10000H) ;
+         DropC (EmCall (TU_StrStore)) ;
+         EmAddSp (4) ;
          RETURN
       END ;
       n := strLen [src.strx] ;
@@ -2398,12 +2432,17 @@ BEGIN
                   RETURN
                END ;
                acls := symtab [arg.idx].cls ;
-               IF acls = TString THEN
-                  Err (ENoLib) ;                    (* string runtime pending *)
-                  RETURN
-               END ;
                EmPushVarAddr (symtab [arg.idx].local, symtab [arg.idx].off) ;
-               IF acls = TReal THEN
+               IF acls = TString THEN
+                  (* readln(s)/read(s) of a string variable: TPSRC8 prdstr
+                     pushes the address, emits MOV CL,max_len and CALL
+                     xrdstr.  Here the limit is a full MOV CX,imm16 (this
+                     runtime has no byte-register CH/CL setup), and rdstr
+                     leaves the terminator pending so the readln wrapper can
+                     consume the newline. *)
+                  EmMovCxi ((symtab [arg.idx].size - 1) MOD 10000H) ;
+                  ent := TU_RdStr
+               ELSIF acls = TReal THEN
                   ent := TU_RdInt                   (* real reads: not yet *)
                ELSIF acls = TBool THEN
                   (* TP3 refuses boolean I/O: TPSRC8 prderrtp is error 66,
@@ -3042,7 +3081,20 @@ BEGIN
                Err (ENoLib) ;
                RETURN
             END ;
-            DropC (NewSym (nm, KVar, cls, size, elem, locFree, 0, TRUE)) ;
+            (* A local occupies [off, off+size-1] and lives BELOW the saved BP,
+               because BP-relative locals grow down from the top of the frame
+               while a STRING grows UP from its base (length byte, then chars).
+               Top-anchored packing keeps both true at once: locFree is the
+               highest still-free byte (-1 at the top), the new symbol's base
+               is placed so that its LAST byte lands on that byte, and locFree
+               then drops by the whole size.  So the first INTEGER (size 2) is
+               at -2 and the second at -4, byte for byte what the old
+               pre-decrement rule produced, while a string[5] local (size 6) is
+               at -6..-1 instead of the old -2..+3 - which straddled the saved
+               BP at [BP] and the return address at [BP+2] and made `readln(s)'
+               into a local store characters over its own return address. *)
+            DropC (NewSym (nm, KVar, cls, size, elem,
+                           (locFree - size + 1) MOD 10000H, 0, TRUE)) ;
             locFree := (locFree - size) MOD 10000H ;
             locBytes := locBytes + size
          END ;
@@ -3181,7 +3233,7 @@ BEGIN
    savePO := parmOff ;
    INC (lexnest) ;
    nestMark := symTop ;          (* after the proc's own name, before its params *)
-   locFree := 0FFFEH ;
+   locFree := 0FFFFH ;
    locBytes := 0 ;
    parmOff := 4 ;
    IF MatchDelim ('(') THEN
@@ -3259,7 +3311,8 @@ BEGIN
       END ;
       symtab [old].cls := cls ;
       symtab [old].size := size ;
-      resultVar := NewSym (nm, KVar, cls, size, elem, locFree, 0, TRUE) ;
+      resultVar := NewSym (nm, KVar, cls, size, elem,
+                           (locFree - size + 1) MOD 10000H, 0, TRUE) ;
       symtab [old].resvar := resultVar ;
       locFree := (locFree - size) MOD 10000H ;
       locBytes := locBytes + size
@@ -3502,7 +3555,7 @@ BEGIN
    lexnest := 0 ;
    curIsFunc := FALSE ;
    resultVar := 0 ;
-   locFree := 0FFFEH ;
+   locFree := 0FFFFH ;
    locBytes := 0 ;
    parmOff := 4 ;
    dirs.rng := TRUE ;
@@ -3559,6 +3612,9 @@ BEGIN
    TU_RdByte   := RT_Entry (14) ;      (* a BYTE read stores one byte *)
    TU_WrStr    := RT_Entry (15) ;      (* a string variable is written through
                                           the address on the stack *)
+   TU_RdStr    := RT_Entry (16) ;      (* readln(s)/read(s): TPSRC8 prdstr,
+                                          TPSRC4 xrdstr *)
+   TU_StrStore := RT_Entry (17) ;      (* s := t: TPSRC8 asnvar, TPSRC2 strstore *)
 END Inittur ;
 
 PROCEDURE HeadWord (VAR slot : CARDINAL) ;

+ 5 - 0
shell/Runtime.def

@@ -31,6 +31,11 @@ CONST
                                arm, TPSRC4 xrdbyte *)
    E_WrStr   = 15 ;         (* a string variable: address on the stack, TPSRC4
                                xwrtstr minus its field-width formatting *)
+   E_RdStr   = 16 ;         (* readln(s)/read(s) of a string variable: address
+                               on the stack, limit in CX; TPSRC4 xrdstr *)
+   E_StrStore = 17 ;        (* s := t for string variables: destination and
+                               source addresses on the stack, limit in CX;
+                               TPSRC2 strstore *)
 
    (* Where a .COM's first byte ends up.  DOS loads a .COM at CS:0100 (the
       100 bytes below are the PSP), and CS = DS, so an image offset K is at

+ 106 - 2
shell/Runtime.mod

@@ -117,7 +117,7 @@ VAR
    dataAt : CARDINAL ;
    rtBase : CARDINAL ;
    built  : BOOLEAN ;
-   entNm  : ARRAY [0..15] OF ARRAY [0..MaxNm] OF CHAR ;
+   entNm  : ARRAY [0..17] OF ARRAY [0..MaxNm] OF CHAR ;
 
 (* ---------------------------------------------------------------- *)
 (*  name helpers                                                     *)
@@ -424,6 +424,7 @@ END LdBxVx ;
 PROCEDURE MovSiAx  ; BEGIN B (8BH) ; B (0F0H) END MovSiAx ;  (* 8B F0: MOV SI,AX *)
 PROCEDURE MovAxDi  ; BEGIN B (8BH) ; B (0C7H) END MovAxDi ;   (* 11 000 111 *)
 PROCEDURE MovCxSi6 ; BEGIN B (8BH) ; B (4CH) ; B (6) END MovCxSi6 ;
+PROCEDURE MovCxDx  ; BEGIN B (8BH) ; B (0CAH) END MovCxDx ;   (* 8B CA: MOV CX,DX *)
 PROCEDURE MovDxSi2 ; BEGIN B (8BH) ; B (54H) ; B (2) END MovDxSi2 ;
 (* The program header is a block of words laid out by the compiler:
       +0  hdrFlag   1 = image is valid
@@ -443,6 +444,7 @@ PROCEDURE MovDxSi2 ; BEGIN B (8BH) ; B (54H) ; B (2) END MovDxSi2 ;
 PROCEDURE MovDxSi4 ; BEGIN B (8BH) ; B (54H) ; B (4) END MovDxSi4 ;
 PROCEDURE MovAxBp4 ; BEGIN B (8BH) ; B (46H) ; B (4) END MovAxBp4 ;  (* AX:=[BP+4] *)
 PROCEDURE MovBxBp4 ; BEGIN B (8BH) ; B (5EH) ; B (4) END MovBxBp4 ;  (* BX:=[BP+4] *)
+PROCEDURE MovBxBp6 ; BEGIN B (8BH) ; B (5EH) ; B (6) END MovBxBp6 ;  (* BX:=[BP+6] *)
 PROCEDURE MovAlDh  ; BEGIN B (8AH) ; B (0C6H) END MovAlDh ;  (* 8A C6: AL:=DH *)
 PROCEDURE MovDlSi  ; BEGIN B (8AH) ; B (14H) END MovDlSi ;
 PROCEDURE MovDlArg2 ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg2 ;  (* DL:=[BP+2] *)
@@ -488,6 +490,7 @@ PROCEDURE MovDiBp4 ; BEGIN B (8BH) ; B (7EH) ; B (4) END MovDiBp4 ; (* DI:=[BP+4
    and the store has to go through DI or SI, so the sequence is
    MOV DI,[BP+4] / MOV [DI],<value> - see StoreThrough below. *)
 PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C2H) END MovDlAl ;
+PROCEDURE MovDlCl  ; BEGIN B (8AH) ; B (0D1H) END MovDlCl ;  (* 8A D1: MOV DL,CL *)
 PROCEDURE XchgAxDi ; BEGIN B (87H) ; B (0C7H) END XchgAxDi ; (* 87 C7: AX<->DI *)
 PROCEDURE AddAxDi  ; BEGIN B (03H) ; B (0C7H) END AddAxDi ;  (* 03 C7: AX:=AX+DI *)
 PROCEDURE MovSiBx  ; BEGIN B (89H) ; B (0DEH) END MovSiBx ;  (* 89 DE: MOV SI,BX *)
@@ -1010,6 +1013,104 @@ BEGIN
    RetR
 END EmitRdLn ;
 
+PROCEDURE EmitRdStr ;
+(* readln(s)/read(s) of a string variable: TPSRC4's xrdstr, the runtime entry
+   behind both prdstr and prdrdln.  The caller pushes the variable's address
+   (one stack word, read here via BP), sets CX = max_len = varsize-1, and pops
+   the word after the call.
+
+   Keyed to ours rather than TP3's exactly: TP3 emits MOV CL,imm8 and clears
+   CH the length load; this runtime has no byte-register move into CH/CL, so
+   the compiler emits a full MOV CX,imm16 and CX *is* the limit (Loop8 / the
+   count arithmetic below rely on it).
+
+   Characters come from getch, which clobbers only AX and BX, so the running
+   count lives in BX behind a push/pop around each call.  Reading stops at CR,
+   LF or ^Z - LF because our input files are LF-terminated - and the terminator
+   is left PENDING via ungetch, exactly as TP3's xrdstr leaves its CR pending
+   through the pre-read flag: the readln wrapper then consumes the newline,
+   while a bare read(s) leaves it for the next read.  The LOOP truncates a line
+   longer than max_len.  dest[0] := length; dest[1..] := chars. *)
+BEGIN
+   M ("rdstr") ;
+   PushBp ; MovBpSp ;
+   PushAx ; PushBx ; PushCx ; PushDx ; PushDi ;
+   MovDiBp4 ;                (* DI := dest base, for the length byte *)
+   MovAxBp4 ; MovSiAx ;      (* SI := dest, walking *)
+   IncSi ;                   (* SI := dest + 1: chars live at dest[1..], the
+                                length byte at dest[0] is stored at the end *)
+   XorBxBx ;                 (* BX := 0: count of characters read *)
+   M ("rs_loop") ;
+   PushBx ;                  (* getch clobbers BX (and AX) *)
+   C8 ("getch") ;
+   PopBx ;
+   CmpAl (0DH) ; Je8 ("rs_term") ;  (* CR *)
+   CmpAl (0AH) ; Je8 ("rs_term") ;  (* LF *)
+   CmpAl (1AH) ; Je8 ("rs_term") ;  (* ^Z *)
+   MovDlAl ;                 (* DL := char *)
+   IncBx ;                   (* count := count + 1 *)
+   StSiDl ;                  (* dest[i] := char *)
+   IncSi ;
+   Loop8 ("rs_loop") ;       (* CX--; loop while CX # 0: truncation *)
+   M ("rs_end") ;
+   MovAlBl ;                 (* AL := count *)
+   StDiAl ;                  (* dest[0] := length *)
+   PopDi ; PopDx ; PopCx ; PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR ;
+   M ("rs_term") ;
+   PushBx ;                  (* ungetch clobbers BX too *)
+   C8 ("ungetch") ;
+   PopBx ;
+   J8 ("rs_end")
+END EmitRdStr ;
+
+PROCEDURE EmitStrStore ;
+(* s := t for string variables: TPSRC2's strstore with TPSRC8 asnvar's stack
+   shape.  The caller pushes BOTH addresses - destination first, source second,
+   so the LAST push (dest) sits at [BP+4] and the source at [BP+6] - sets
+   CX = max_len = varsize-1 of the DESTINATION, calls, and pops both words
+   (this runtime is caller-pops, unlike TP3's strstore which pops itself).
+
+   TP3's strstore rewrites the length byte of the stack copy of the source to
+   min(src[0], max), then copies length byte + chars, truncating at max, with
+   the destination TAIL past the new length left untouched.  Here, with both
+   variables in memory and no stack copy, that becomes: count := min(src[0],
+   max); dest[0] := count; copy count chars src[1..] -> dest[1..].  The source
+   variable is never written - truncation is expressed in the destination's own
+   length byte only.  A source longer than max is truncated; a shorter one is
+   copied whole (CX := src length, count including the empty case where LOOP
+   simply never runs). *)
+BEGIN
+   M ("strstore") ;
+   PushBp ; MovBpSp ;
+   PushAx ; PushBx ; PushCx ; PushDx ;
+   MovAxBp4 ; MovSiAx ;      (* SI := dest, walking *)
+   MovBxBp6 ;                (* BX := source *)
+   XorDxDx ;
+   LdAlBx ; MovDlAl ;        (* DL := src[0], the source length *)
+   IncBx ;                   (* BX -> first source character *)
+   CmpCxDx ;                 (* CMP CX (max), DX (src length) *)
+   Jb8 ("ss_trunc") ;
+   MovCxDx ;                 (* not truncated: CX := src length *)
+   J8 ("ss_len") ;
+   M ("ss_trunc") ;
+   MovDlCl ;                 (* truncated: DL := max, for the length store *)
+   M ("ss_len") ;
+   StSiDl ;                  (* dest[0] := length *)
+   IncSi ;                   (* dest -> first character *)
+   Jcxz8 ("ss_done") ;       (* an empty source copies nothing; LOOP with
+                                CX = 0 would run 65536 times, not zero *)
+   M ("ss_loop") ;
+   LdAlBx ;                  (* AL := src char *)
+   MovDlAl ;                 (* DL := char *)
+   StSiDl ;                  (* dest char := src char *)
+   IncBx ; IncSi ;
+   Loop8 ("ss_loop") ;       (* CX--; loop while CX # 0 *)
+   M ("ss_done") ;
+   PopDx ; PopCx ; PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR
+END EmitStrStore ;
+
 PROCEDURE Str5 (s : ARRAY OF CHAR ; n : CARDINAL) ;
 (* emit n characters, so the strings below are visibly the same length as the
    D_ constants claim and an off-by-one in either place is a compile-time
@@ -1118,6 +1219,7 @@ BEGIN
    EmitRdInt ; EmitRdScan ; EmitRdByte ;
    EmitRdChar ; EmitRdBool ; EmitRdLn ;
    EmitGetCh ; EmitUnGetCh ;
+   EmitRdStr ; EmitStrStore ;
    EmitData ;
    FixUp ;
    SetStr (entNm [0], "initmem") ;
@@ -1136,6 +1238,8 @@ BEGIN
    SetStr (entNm [13], "wrtinl") ;
    SetStr (entNm [14], "rdbyte") ;
    SetStr (entNm [15], "wrstr") ;
+   SetStr (entNm [16], "rdstr") ;
+   SetStr (entNm [17], "strstore") ;
    built := TRUE
 END RT_Build ;
 
@@ -1163,7 +1267,7 @@ BEGIN
    IF NOT built THEN
       RT_Build (0)
    END ;
-   IF i > 15 THEN
+   IF i > 17 THEN
       RETURN 0
    END ;
    RETURN rtBase + LblOff (entNm [i])

+ 3 - 3
shell/tests/RtProbe.mod

@@ -108,7 +108,7 @@ END PENT ;
 
 PROCEDURE Main ;
 VAR i, n, base : CARDINAL ;
-    names : ARRAY [0..15] OF ARRAY [0..15] OF CHAR ;
+    names : ARRAY [0..17] OF ARRAY [0..15] OF CHAR ;
 BEGIN
    base := GetBase () ;
    RT_Build (base) ;
@@ -130,9 +130,9 @@ BEGIN
    names [6] := "wrreal" ;  names [7] := "wrln" ;    names [8] := "rdint" ;
    names [9] := "rdchar" ;  names [10] := "rdbool" ; names [11] := "rdln" ;
    names [12] := "halt" ;   names [13] := "wrtinl" ;  names [14] := "rdbyte" ;
-   names [15] := "wrstr" ;
+   names [15] := "wrstr" ;  names [16] := "rdstr" ;  names [17] := "strstore" ;
    i := 0 ;
-   WHILE i <= 15 DO
+   WHILE i <= 17 DO
       PENT (i, names [i]) ;
       INC (i)
    END ;

+ 1 - 0
shell/tests/audit_helpers.py

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

+ 14 - 8
shell/tests/check_framedisp.py

@@ -18,8 +18,8 @@ EmLoadVar, EmStoreVar and EmPushVarAddr all used to compute
     disp := off MOD 100H
 
 and always emit the disp8 form.  That is the correct LOW BYTE for any
-displacement, and locals are allocated downward from 0FFFEh, so their offsets
-are negative -- and disp8 0FEh is -2, which is right.  The old code was
+displacement, and locals sit below BP, so their offsets are negative -- and
+disp8 0FEh is -2, which is right.  The old code was
 therefore accidentally correct across -32768..+127, which is where almost
 every variable lives, and every existing fixture kept its exact bytes.
 
@@ -47,9 +47,14 @@ The expected offsets are computed here from the frame-layout rules rather
 than copied from the compiler's output, so this is a cross-check and not a
 restatement of what the compiler happens to do:
 
-    locals      locFree starts at 0FFFEh and the NEW SYMBOL IS GIVEN THE
-                PRE-DECREMENT VALUE, so the first local of a procedure is at
-                0FFFEh = -2, the second at 0FFFCh = -4, and so on down.
+    locals      locFree is the highest still-free byte below BP (0FFFFh = -1
+                at entry), a local of size n is based so its LAST byte sits on
+                locFree, and locFree then drops by n.  For the INTEGER locals
+                t27 declares that gives the first at 0FFFEh = -2, the second at
+                0FFFCh = -4, and so on down (a size-1 local would take the top
+                byte, -1, and a string local n bytes at -n..-1 - the point of
+                the rule is that its base is chosen so the whole variable, which
+                grows UP from that base, stays below the saved BP).
     parameters  TP3 lays the list out from BP+4 backwards: the LAST declared
                 parameter is at BP+4 and each earlier one is 2 higher (see
                 RESUME-TP3.md 3.11), so parameter k of n is at 4 + 2*(n-k).
@@ -133,9 +138,10 @@ COMTEST = os.path.join(SHELL, "comtest")
 # 3-byte mod=01 encoding of the same offset must be ABSENT.  The last of those
 # is the one that goes red if `off MOD 100H` ever comes back.
 def expected_local_offsets(count):
-    """locFree starts at 0FFFEh; the symbol takes the pre-decrement value, so
-    the first local is at -2.  Signed, because that is what a displacement
-    is."""
+    """Locals are packed top-anchored below BP: locFree is the highest still-free
+    byte (0FFFFh = -1 at entry) and drops by each local's size.  For the INTEGER
+    locals declared here (size 2 each) that puts the first base at -2, the second
+    at -4, and so on.  Signed, because that is what a displacement is."""
     return [-(2 + 2 * i) for i in range(count)]
 
 

+ 15 - 0
shell/tests/check_runtime.py

@@ -185,6 +185,21 @@ GOLDEN = {
     # caller's string; the print loop behind it is wrtinl's, so this prefix
     # stops at the address load rather than asserting the whole entry.
     "wrstr": "55 8B EC 53 51 50 8B 5E 04",
+    # rdstr: readln(s)/read(s) of a string variable.  The same five-register
+    # frame as rdint/rdbyte - PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - kept
+    # because the loop calls getch (which owns BX) and has to reload its
+    # counter, and DI holds the destination base for the length byte that
+    # closes the entry.  The world in which these bytes could be wrong is the
+    # world this golden exists to catch: a wrong ModRM on the [BP+4] address
+    # load still decodes cleanly and still reads "some address", so the
+    # prefix pins the frame and rt_exec pins the store.
+    "rdstr": "55 8B EC 50 53 51 52 57",
+    # strstore: s := t.  PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX - DI is not
+    # saved because there is no dest base register: both addresses arrive on
+    # the stack, dest at [BP+4] and source at [BP+6].  The payload goldens
+    # for the two stack loads are pinned by the same ModRM table the audit
+    # enforces (8B 46 04 / 8B 5E 06).
+    "strstore": "55 8B EC 50 53 51 52",
 }
 
 

+ 67 - 10
shell/tests/exec/rtdrv.s

@@ -79,11 +79,13 @@
 	.set	OFF_INBUF, 5	# byte  inbuf[8]
 	.set	OFF_CASE, 13	# byte  the index printed in the header
 	.set	OFF_NCALLS, 14	# word  how many call slots follow
-	.set	SLOT0, 16		#      call slots, 6 bytes each:
-					#      +0 word entry, +2 word arg,
-					#      +4 byte flags, +5 pad
-	.set	SLOT_SZ, 6
-	.set	CASE_SZ, 40		# 16 + 4 slots
+	.set	SLOT0, 16		#      call slots, 12 bytes each:
+					#      +0 word entry, +2 word arg A,
+					#      +4 byte flags, +5 pad,
+					#      +6 word arg B, +8 word CX,
+					#      +10 pad
+	.set	SLOT_SZ, 12
+	.set	CASE_SZ, 64		# 16 + 4 slots
 
 # The image layout.  RT_BASE is where the runtime blob goes; everything else is
 # above 0x0800 so that a runtime which grew by a few hundred bytes would not
@@ -97,9 +99,14 @@
 	.set	WSTR, 0x0850		# a string for the wrstr case: length + chars
 	.set	WSTR_EMPTY, 0x0858	# the same, with a length of 0
 	.set	CASE, 0x0860		# the case record
-	.set	D_DUMPLEN, 0x0890		# the driver's own three words
-	.set	D_DUMPADR, 0x0892
-	.set	D_CNT, 0x0894
+	.set	D_DUMPLEN, 0x08A0	# the driver's own three words
+	.set	D_DUMPADR, 0x08A2
+	.set	D_CNT, 0x08A4
+	.set	RDSTR_DST, 0x08B0	# destination for the rdstr cases
+	.set	SST_HELLO, 0x08C0	# a short source string for strstore
+	.set	SST_LONG, 0x08D0	# a source longer than a small max
+	.set	SST_EMPTY, 0x08E0	# an empty source string
+	.set	SST_DST, 0x08F0		# destination for the strstore cases
 
 	.code16
 	.text
@@ -199,32 +206,59 @@ drive:
 	movw	$0x4C00, %ax		# INT 21h AH=4Ch, AL=0
 	int	$0x21
 
-# doslot: call the entry described by the 6 bytes at BP, with the same
-# conventions Compiler.IoCall uses -- one 16-bit argument on the stack, popped
+# doslot: call the entry described by the 12 bytes at BP, with the same
+# conventions Compiler.IoCall uses -- 16-bit argument(s) on the stack, popped
 # by the caller -- except for initmem, which takes the header in AX.
 #
+# Flags (the byte at +4):
+#   1  F_STACK   push the word at +2; the caller pops one word.
+#   2  F_AX      take the word at +2 in AX instead.
+#   4  F_INL     the argument follows the call, not on the stack.
+#   8  F_STACK2  push the word at +6 FIRST and the word at +2 second, so +2
+#                lands at [BP+4] and +6 at [BP+6]; the caller pops two words.
+#                strstore wants exactly that: dest at [BP+4], source at [BP+6].
+#   16 F_CX      load CX from the word at +8 before the call.  rdstr and
+#                strstore both take their maximum length in CX, a register the
+#                record has no other way to reach, and neither may borrow it
+#                from whatever the previous case left behind.
+#
 # The flags are re-read after the call because the entry has just used AX.
 doslot:
 	movb	4(%bp), %al
+	testb	$8, %al
+	jnz	.Ltwostack
 	testb	$1, %al
 	jz	.Lnostack
 	pushw	2(%bp)
+	jmp	.Lnostack
+.Ltwostack:
+	pushw	6(%bp)			# arg B: the source, ends at [BP+6]
+	pushw	2(%bp)			# arg A: the destination, ends at [BP+4]
 .Lnostack:
 	testb	$2, %al
 	jz	.Lnoax
 	movw	2(%bp), %ax
 .Lnoax:
+	testb	$16, %al
+	jz	.Lnocx
+	movw	8(%bp), %cx
+.Lnocx:
 	movw	(%bp), %si		# image-absolute, as RT_Entry reports it
 	addw	$LOAD_BIAS, %si		# so the load bias is added here, not baked in
 	testb	$4, %al
 	jnz	.Linl
 	call	*%si
 	movb	4(%bp), %al
+	testb	$8, %al
+	jnz	.Lcleantwo
 	testb	$1, %al
 	jz	.Lnoclean
 	addw	$2, %sp		# caller-cleaned
 .Lnoclean:
 	ret
+.Lcleantwo:
+	addw	$4, %sp
+	ret
 
 # wrtinl's argument is NOT a stack word: the entry reads a length byte and that
 # many characters from its own return address.  That is the caller's half of
@@ -332,3 +366,26 @@ HDR:
 
 	.org	D_DUMPLEN
 	.zero	6			# D_DUMPLEN, D_DUMPADR, D_CNT
+
+# --- strings for the two string entries -----------------------------------
+# A compiled `string [n]' variable is a length byte and n characters, and that
+# is the layout rdstr writes and strstore reads.  Each DESTINATION is
+# pre-filled with 0xEE so a byte the entry does NOT write is visibly still
+# there -- that is how "the tail past the new length is left untouched" becomes
+# a checked claim rather than a comment.  The sources are distinct addresses so
+# one image can offer a short string, one longer than a small max, and an empty
+# one, without any case depending on a previous case's leftovers.
+	.org	RDSTR_DST
+	.fill	16, 1, 0xEE
+
+	.org	SST_HELLO
+	.byte	5, 'h', 'e', 'l', 'l', 'o'
+
+	.org	SST_LONG
+	.byte	8, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'
+
+	.org	SST_EMPTY
+	.byte	0			# a length of 0: an empty string
+
+	.org	SST_DST
+	.fill	16, 1, 0xEE

+ 24 - 3
shell/tests/fixtures/expected.tsv

@@ -227,8 +227,27 @@
 #                 an empty string.  A global AND a local string are exercised,
 #                 so the absolute and the [BP+off] address paths both have
 #                 coverage, and the .out is compared byte for byte under qemu
-#                 and Exec86.  Still refused: readln(s), s := another string,
-#                 and s [i] indexing - all ENoLib.
+#                 and Exec86.  t40_readln and t41_strassign extend the same
+#                 ground: readln(s)/read(s) routes to a new runtime entry,
+#                 rdstr (TPSRC4 xrdstr), which stops at CR/LF/^Z and truncates
+#                 to the destination size, leaving the terminator pending for
+#                 the readln wrapper; and `s := t' for two string variables
+#                 routes to strstore (TPSRC2), pushed as both addresses (the
+#                 literal path above is a compile-time copy of the same
+#                 operation).  Still refused: s [i] indexing - ENoLib.
+#
+#                 t39's code size moved 215 -> 217 with the local-frame fix
+#                 that t40 forced.  Locals used to take the PRE-decrement
+#                 locFree as their base, so a local string started at BP-2 and
+#                 its characters ran UP through BP, the saved BP and the return
+#                 address: `readln(s)' into a local s stored "hello" over its
+#                 own caller's return address and the program jumped into the
+#                 string.  Locals are now top-anchored (base placed so the last
+#                 byte lands on the top free byte), which leaves every INTEGER
+#                 local byte-for-byte where it was (-2, -4, ...) but moves a
+#                 string local down: t's base is now BP-6, and two of its byte
+#                 stores that used to wrap to [BP+0]/[BP+1] and fit the disp8
+#                 form are now disp16 - two bytes, hence 217.  See t40.
 
 t01_minimal	OK	29	4
 t02_writeln	OK	38	4
@@ -268,5 +287,7 @@ t35_not	OK	224	7
 t36_argclobber	OK	492	12
 t37_readbyte	OK	139	7
 t38_readbool	ERR	66	659
-t39_strvar	OK	215	15
+t39_strvar	OK	217	15
+t40_readln	OK	163	15
+t41_strassign	OK	263	51
 uierror	ERR	41	331

+ 3 - 0
shell/tests/fixtures/t40_readln.in

@@ -0,0 +1,3 @@
+world
+hello
+

+ 4 - 0
shell/tests/fixtures/t40_readln.out

@@ -0,0 +1,4 @@
+world
+hello
+[
+]

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

@@ -0,0 +1,21 @@
+program t40;
+var
+  g : string [10];
+
+procedure p;
+var
+  s : string [5];
+begin
+  readln (s);
+  writeln (s);
+  readln (s);
+  writeln ('[');
+  write (s);
+  writeln (']')
+end;
+
+begin
+  readln (g);
+  writeln (g);
+  p
+end.

+ 6 - 0
shell/tests/fixtures/t41_strassign.out

@@ -0,0 +1,6 @@
+hello
+world
+worl
+worl
+
+end

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

@@ -0,0 +1,21 @@
+program t41;
+var
+  a : string [20];
+  b : string [20];
+  c : string [4];
+
+begin
+  a := 'hello';
+  writeln (a);
+  b := 'world';
+  a := b;
+  writeln (a);
+  c := a;
+  writeln (c);
+  a := c;
+  writeln (a);
+  b := '';
+  a := b;
+  writeln (a);
+  writeln ('end')
+end.

+ 257 - 0
shell/tests/nonvacuity.sh

@@ -44,6 +44,25 @@
 #                      triple-faults on the second call - so nothing short of
 #                      running it, or of stating the register contract
 #                      explicitly, can see it.
+#   rt_exec.py        the string entries: catches rdstr storing its first
+#                      character where the length byte belongs (a missing IncSi
+#                      puts `hello' back as 05 65 6c 6c 6f), and strstore
+#                      running LOOP with CX = 0 when the source is empty - 65536
+#                      iterations that wrap the segment and walk through the
+#                      driver's own case record.  Both are same-size,
+#                      matrix-green slips that only a byte dump can see.
+#   run_com_exec.py   the frame rule:     catches a local STRING packed with the
+#                      INTEGER rule, so its characters grow up over the saved BP
+#                      and the return address; readln into such a local then
+#                      runs the string's own bytes as an address.  The fix moves
+#                      no well-formed instruction, so only execution - or the
+#                      size it pins in the compile matrix - can tell.
+#   run_com_exec.py   the string arms:    catches the destination limit loaded
+#                      into AX instead of CX, the assignment pushes swapped so
+#                      the destination is read as its own source, and a string
+#                      read dispatched to the integer reader.  Every one is the
+#                      right shape reading the wrong thing, and the MovCxi
+#                      helper is COMP in the audit, so no byte check can see it.
 #   check_8086.py    the 8086 opcodes:    catches a conditional branch or a
 #                      SETcc emitted as `0F 8x'/`0F 9x', which are 386-only.
 #                      Nothing else here can: qemu-system-i386's lowest CPU
@@ -1131,6 +1150,244 @@ else
     fail=$((fail + 1))
 fi
 
+echo
+echo "== the string READ and ASSIGNMENT entries (t40, t41)"
+# t39 above gave a STRING variable a frame and printed it.  This milestone puts
+# one on the RECEIVING end of the two operations that move data into a string
+# and between two strings - readln(s)/read(s), and s := t - and each one is
+# three things at once: a compiler arm that chooses a runtime entry, the
+# convention that carries the limit, and the entry itself.  The cases below
+# break each of the six in turn.  Every mutation is a fault, or a plausible
+# neighbour of one, that was run to a red BY HAND before it was written here.
+#
+# The frame rule is the odd one out: it is not a new entry at all, but a
+# pre-existing compiler bug that only this milestone could reach.  A local
+# STRING was packed with the INTEGER rule - the symbol took the pre-decrement
+# locFree, so a string[5] local sat at BP-2 and its characters grew UP into
+# BP+3, over the saved BP at [BP] and the return address at [BP+2].  `readln(s)'
+# into such a local stored 'hello' over its own return address, and t40 then ran
+# the string's own bytes as an address: Exec86 said `fault at IP=0x6F6C', which
+# is 'l','o'.  The fix packs locals top-anchored, so the first INTEGER (size 2)
+# is still at -2 - byte for byte what the old rule produced - while a string[5]
+# (size 6) goes to -6..-1, inside the frame.  t39's size (217) and t40's
+# behaviour are the two independent things that pin it.
+
+# --- the two runtime entries ---------------------------------------------
+# rdstr stored the first character where the length byte belongs, because the
+# IncSi that steps SI past dest[0] was missing.  The length byte then
+# overwrote it: `hello' read back as 05 65 6c 6c 6f.  Same length, same sizes,
+# and the whole compile matrix stays green - only rt_exec.py's byte dump can
+# see it.
+cp "$SAVED" Runtime.mod
+if python3 - <<'PYX'
+p = 'Runtime.mod'
+lines = open(p).read().split('\n')
+out, i, n = [], 0, 0
+while i < len(lines):
+    if lines[i].startswith('   IncSi ;') and 'dest + 1' in lines[i]:
+        out.append('   (* MUTATION: the IncSi that puts chars at dest+1 is gone *)')
+        i += 1
+        while i < len(lines) and not lines[i].rstrip().endswith('*)'):
+            i += 1
+        i += 1
+        n += 1
+        continue
+    out.append(lines[i])
+    i += 1
+assert n == 1, 'the rdstr IncSi found %d times -- update this mutation' % n
+open(p, 'w').write('\n'.join(out))
+PYX
+then
+    if rebuild; then
+        expect_red "rt_exec catches rdstr storing its first character at dest[0]" \
+            "rdstr reads a line into a string" python3 tests/rt_exec.py
+    else
+        echo "  FAIL: the runtime would not rebuild with rdstr's IncSi removed"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the rdstr IncSi mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED" Runtime.mod
+
+# strstore's empty-source guard.  A source of length 0 sets CX := 0, and LOOP
+# with CX = 0 runs 65536 times rather than zero - so `s := ''' copied 64K,
+# wrapped the segment, and walked through the driver's own case record.  That is
+# why the pattern below matches the CASE, not a byte count: rt_exec.py reports
+# this one as a malformed record, which is the honest shape of "the entry wrote
+# where it had no business writing".
+if python3 - <<'PYX'
+p = 'Runtime.mod'
+lines = open(p).read().split('\n')
+out, i, n = [], 0, 0
+while i < len(lines):
+    if lines[i].startswith('   Jcxz8 ("ss_done")'):
+        out.append('   (* MUTATION: the empty-source guard is gone *)')
+        i += 1
+        while i < len(lines) and not lines[i].rstrip().endswith('*)'):
+            i += 1
+        i += 1
+        n += 1
+        continue
+    out.append(lines[i])
+    i += 1
+assert n == 1, 'the strstore Jcxz8 found %d times -- update this mutation' % n
+open(p, 'w').write('\n'.join(out))
+PYX
+then
+    if rebuild; then
+        expect_red "rt_exec catches a strstore that runs LOOP with CX=0" \
+            "strstore copies an empty string" python3 tests/rt_exec.py
+    else
+        echo "  FAIL: the runtime would not rebuild with the empty-source guard removed"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the strstore empty-source guard mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED" Runtime.mod
+# Re-emit the object: `rebuild` above compiled Runtime.o from the MUTATED
+# source, and the compiler rebuild below links that object - without this
+# recompile the restored-compiler check at the end would report on the mutation.
+"$GM2" -fiso -c Runtime.mod >/dev/null 2>&1 || {
+    echo "  FAIL: Runtime.mod would not recompile after the restore"
+    fail=$((fail + 1))
+}
+
+# --- the frame rule -------------------------------------------------------
+# The pre-fix layout, exactly: the symbol took locFree itself instead of its
+# top, and the frame began at 0FFFEh.  For INTEGER locals that is byte-for-byte
+# the same -2, -4, ... the new rule produces, which is why the old rule survived
+# so long unnoticed; for a STRING it puts characters where the return address
+# is.  Two independent readers go red together, so both are asserted: the
+# compile matrix pins the LAYOUT (t39's code size drops 217 -> 215), and t40
+# pins that the program RUNS (with the overlap its readln never sees the line
+# terminator and the machine hangs).
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+a = '(locFree - size + 1) MOD 10000H'
+assert s.count(a) == 2, 'the top-anchored base found %d times -- update this mutation' % s.count(a)
+s = s.replace(a, 'locFree')                     # the old pre-decrement base
+b = 'locFree := 0FFFFH ;'
+assert s.count(b) == 2, 'the frame top found %d times -- update this mutation' % s.count(b)
+s = s.replace(b, 'locFree := 0FFFEH ;')         # the old starting point
+open(p, 'w').write(s)
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "the compile matrix catches a local string packed over the frame" \
+            "expected: OK 217" tests/run_compile_tests.sh
+        expect_red "t40 red when readln into a local string overwrites its return address" \
+            "never halted" python3 tests/run_com_exec.py t40_readln
+    else
+        echo "  FAIL: the compiler would not rebuild with the old frame layout"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the frame-layout revert did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
+# --- the compiler's arms --------------------------------------------------
+# The limit both entries read is MOV CX,imm16.  MOV AX,imm16 is one opcode away,
+# the same length, and the audit's MovCxi row is COMP - "no single sequence to
+# disassemble" - so nothing in the byte-level checks can tell them apart.  Only
+# execution can: t41's `s := t' truncates to 4 under CX and does not under AX.
+# (t40 passes either way, because its CX happens to survive large enough for the
+# five characters it reads - exactly the kind of luck a behavioural case is for.)
+cp "$SAVED_C" Compiler.mod
+if mutate_compiler 's|   Ebyte (0B9H) ; Eword (imm)|   Ebyte (0B8H) ; Eword (imm)|'; then
+    if rebuild_compiler; then
+        expect_red "t41 red when the destination limit is loaded into AX, not CX" \
+            "t41_strassign" python3 tests/run_com_exec.py t41_strassign
+    else
+        echo "  FAIL: the compiler would not rebuild with MovCxi emitting MOV AX"
+        fail=$((fail + 1))
+    fi
+fi
+cp "$SAVED_C" Compiler.mod
+
+# Assignment pushes the source first, then the destination, so the source sits
+# at [BP+6] and the destination at [BP+4].  Swapped, strstore takes the
+# destination as its own source - t41's `worl' rows become copies of what was
+# already in the destination, which looks like it works and prints the wrong
+# bytes.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+src = ('         EmPushVarAddr (symtab [src.idx].local,\n'
+       '                        (symtab [src.idx].off + src.boff) MOD 10000H) ;\n'
+       '         EmPushVarAddr (symtab [r.idx].local,\n'
+       '                        (symtab [r.idx].off + r.boff) MOD 10000H) ;')
+dst = ('         EmPushVarAddr (symtab [r.idx].local,\n'
+       '                        (symtab [r.idx].off + r.boff) MOD 10000H) ;\n'
+       '         EmPushVarAddr (symtab [src.idx].local,\n'
+       '                        (symtab [src.idx].off + src.boff) MOD 10000H) ;')
+assert s.count(src) == 1, 'the assignment pushes found %d times -- update this mutation' % s.count(src)
+open(p, 'w').write(s.replace(src, dst))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "t41 red when the assignment pushes are swapped" \
+            "t41_strassign" python3 tests/run_com_exec.py t41_strassign
+    else
+        echo "  FAIL: the compiler would not rebuild with the pushes swapped"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the assignment push-order mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
+# The read dispatch's string arm.  TU_RdStr and TU_RdInt sit next to each other
+# in the same table and both take one address; TU_RdInt parses the line as a
+# number, which stores nothing on `world', so readln(s) leaves s empty and the
+# program prints a blank line.  A refusal would at least say so; this arm looks
+# like it worked and answers with the wrong bytes.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+old = ('                  EmMovCxi ((symtab [arg.idx].size - 1) MOD 10000H) ;\n'
+       '                  ent := TU_RdStr')
+assert s.count(old) == 1, 'the string-read arm found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, old.replace('TU_RdStr', 'TU_RdInt')))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "t40 red when a string read dispatches to TU_RdInt" \
+            "t40_readln" python3 tests/run_com_exec.py t40_readln
+    else
+        echo "  FAIL: the compiler would not rebuild with the read dispatch on TU_RdInt"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the string-read dispatch mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
+if rebuild_compiler; then
+    if python3 tests/run_com_exec.py >/dev/null 2>&1; then
+        echo "  ok: every executed fixture passes on the restored sources"
+        pass=$((pass + 1))
+    else
+        echo "NOT RESTORED: run_com_exec.py is red after restoring the sources"
+        python3 tests/run_com_exec.py 2>&1 | grep -i fail | head -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "NOT RESTORED: the compiler would not rebuild"
+    fail=$((fail + 1))
+fi
+
 echo
 echo "== the second execution oracle (run_exec86.py)"
 # Everything above runs the image under qemu-system-i386.  This one runs the

+ 87 - 11
shell/tests/rt_exec.py

@@ -13,7 +13,7 @@ So the machine is now qemu-system-i386, driven through the same boot sector the
 except where they were demonstrably wrong -- see EXPECTATION FIXES below.
 
     .pas-free:  RtProbe (base = RT_BASE)  -> blob + entry offsets
-                rt_exec.py                 -> 16-40 byte case record
+                rt_exec.py                 -> 16-64 byte case record
                 exec/rtdrv.s (assembled)   -> a whole .COM image
                 bootcom.s + qemu           -> one framed record per case
                 rt_exec.py                 -> parsed, compared
@@ -69,7 +69,12 @@ STORE = 0x0840
 WSTR = 0x0850
 WSTR_EMPTY = 0x0858
 CASE = 0x0860
-CASE_SZ = 40
+CASE_SZ = 64
+RDSTR_DST = 0x08B0
+SST_HELLO = 0x08C0
+SST_LONG = 0x08D0
+SST_EMPTY = 0x08E0
+SST_DST = 0x08F0
 
 # A .COM is loaded at CS:0100, and every address in the image is an image
 # offset.  The load bias is therefore the difference between the two, and
@@ -94,12 +99,16 @@ SOH, STX, ETX, EOT = 0x01, 0x02, 0x03, 0x04
 # the lookup fail loudly instead of silently testing offset 0.
 ENTS = ["initmem", "progend", "stackchk", "wrint", "wrchar", "wrbool",
         "wrreal", "wrln", "rdint", "rdchar", "rdbool", "rdln", "halt",
-        "wrtinl", "rdbyte", "wrstr"]
+        "wrtinl", "rdbyte", "wrstr", "rdstr", "strstore"]
 
 # A call slot's flags, as rtdrv.s reads them.
 F_STACK = 1     # the argument goes on the stack, popped by the caller
 F_AX = 2        # ...or in AX instead (initmem's convention)
 F_INL = 4       # wrtinl: the argument is at the return address, not on the stack
+F_STACK2 = 8    # two stack words: +6 pushed first (lands at [BP+6]), +2 second
+                # (at [BP+4]) -- strstore's dest/src order
+F_CX = 16       # CX := the word at +8, the maximum length rdstr and strstore
+                # both read from the register
 
 # The byte patterns check_bp_contract looks for, and the rule they express:
 # "an entry that borrows BP opens with PUSH BP; MOV BP,SP and pops it again
@@ -281,7 +290,7 @@ def check_bp_contract(blob, entries, base, code_end):
 
 # ------------------------------------------------------------------ the case
 def case_bytes(c, entries):
-    """One 40 byte case record, exactly as exec/rtdrv.s reads it.
+    """One CASE_SZ byte case record, exactly as exec/rtdrv.s reads it.
 
     The record is a second, independent statement of the same layout rtdrv.s
     carries in its .set block.  The cases only pass if the two agree on every
@@ -302,11 +311,13 @@ def case_bytes(c, entries):
         sys.exit("case %s has %d calls; the record holds 4"
                  % (c["name"], len(calls)))
     struct.pack_into("<H", b, 14, len(calls))           # OFF_NCALLS
-    for i, (ent, arg, mode) in enumerate(calls):
-        o = 16 + 6 * i                                  # SLOT0
+    for i, (ent, arg, mode, arg2, cx) in enumerate(calls):
+        o = 16 + 12 * i                                 # SLOT0, SLOT_SZ = 12
         struct.pack_into("<H", b, o, entries[ent])
-        struct.pack_into("<H", b, o + 2, arg)
+        struct.pack_into("<H", b, o + 2, arg & 0xFFFF)
         b[o + 4] = mode
+        struct.pack_into("<H", b, o + 6, arg2 & 0xFFFF)
+        struct.pack_into("<H", b, o + 8, cx & 0xFFFF)
     return bytes(b)
 
 
@@ -411,13 +422,26 @@ def build_cases():
                    "note": note, "index": len(cs)})
 
     def s(ent, arg):
-        return (ent, arg, F_STACK)
+        return (ent, arg, F_STACK, 0, 0)
 
     def x(ent, arg):
-        return (ent, arg, F_AX)
+        return (ent, arg, F_AX, 0, 0)
 
     def n(ent):
-        return (ent, 0, 0)
+        return (ent, 0, 0, 0, 0)
+
+    def i(ent):
+        return (ent, 0, F_INL, 0, 0)
+
+    def s2cx(ent, arg, arg2, cx):
+        """Two stack words AND CX: arg2 is pushed first (it lands at [BP+6]),
+        arg second (at [BP+4]), and CX carries the maximum length.  strstore is
+        the only entry that wants all three."""
+        return (ent, arg, F_STACK | F_STACK2 | F_CX, arg2, cx)
+
+    def scx(ent, arg, cx):
+        """One stack word and CX, for rdstr: dest at [BP+4], limit in CX."""
+        return (ent, arg, F_STACK | F_CX, 0, cx)
 
     # initmem is handed the header in AX -- and the header's MEMORY address, not
     # its image offset.  Given the image offset it reads the two words it wants
@@ -492,7 +516,7 @@ def build_cases():
         note="rdscan returns CX=2 on the ^Z path, so rdbyte stores nothing")
 
     add("wrtinl reads its length and characters from the return address",
-        [("wrtinl", 0, F_INL)], out=b"hello",
+        [i("wrtinl")], out=b"hello",
         note="the one entry whose argument is not a stack word")
 
     # wrstr is the other way round from wrtinl: its argument IS a stack word,
@@ -510,6 +534,58 @@ def build_cases():
         [s("wrstr", WSTR_EMPTY + LOAD_BIAS)], out=b"",
         note="a length byte of 0 must print no characters at all")
 
+    # --- rdstr: the entry behind readln(s)/read(s).  Its argument is a stack
+    # word (the destination address), it walks it with SI, and CX is the limit.
+    # The destination is pre-filled with 0xEE in rtdrv.s, so every byte past the
+    # stored length is a checked claim that the entry did not write it.
+    add("rdstr reads a line into a string",
+        [scx("rdstr", RDSTR_DST + LOAD_BIAS, 10)], out=b"", stdin=b"hello\n",
+        dump=11, dumpbytes=bytes([5]) + b"hello" + b"\xee" * 5,
+        dumpadr=RDSTR_DST,
+        note="length byte then the characters; nothing past the length is written")
+
+    add("rdstr truncates a line longer than CX",
+        [scx("rdstr", RDSTR_DST + LOAD_BIAS, 3)], out=b"", stdin=b"abcdef\n",
+        dump=5, dumpbytes=bytes([3]) + b"abc" + b"\xee",
+        dumpadr=RDSTR_DST,
+        note="CX is the limit; the rest of the line is left unread, not stored")
+
+    add("rdstr stores length 0 for an empty line",
+        [scx("rdstr", RDSTR_DST + LOAD_BIAS, 10)], out=b"", stdin=b"\n",
+        dump=3, dumpbytes=bytes([0]) + b"\xee\xee", dumpadr=RDSTR_DST,
+        note="the terminator is the first character, so only dest[0] is written")
+
+    add("rdstr at end of input stores length 0",
+        [scx("rdstr", RDSTR_DST + LOAD_BIAS, 10)], out=b"", stdin=b"",
+        dump=3, dumpbytes=bytes([0]) + b"\xee\xee", dumpadr=RDSTR_DST,
+        note="getch returns ^Z at end of input, a terminator like CR and LF")
+
+    # --- strstore: `s := t' for two string variables.  dest at [BP+4], source
+    # at [BP+6], maximum in CX.  The count is min(source length, CX), the source
+    # is never written, and the destination tail past the new length is left
+    # exactly as it was.
+    add("strstore copies a whole string",
+        [s2cx("strstore", SST_DST + LOAD_BIAS, SST_HELLO + LOAD_BIAS, 20)],
+        out=b"", dump=8, dumpbytes=bytes([5]) + b"hello" + b"\xee" * 2,
+        dumpadr=SST_DST,
+        note="dest length and characters; the tail past them is still EE")
+
+    add("strstore truncates to the destination maximum",
+        [s2cx("strstore", SST_DST + LOAD_BIAS, SST_LONG + LOAD_BIAS, 4)],
+        out=b"", dump=8, dumpbytes=bytes([4]) + b"abcd" + b"\xee" * 3,
+        dumpadr=SST_DST,
+        note="count := min(source length, CX); the source itself is not shortened")
+
+    add("strstore copies an empty string",
+        [s2cx("strstore", SST_DST + LOAD_BIAS, SST_EMPTY + LOAD_BIAS, 10)],
+        out=b"", dump=4, dumpbytes=bytes([0]) + b"\xee" * 3, dumpadr=SST_DST,
+        note="CX 0 must copy nothing -- LOOP would otherwise run 65536 times")
+
+    add("strstore leaves the source alone",
+        [s2cx("strstore", SST_DST + LOAD_BIAS, SST_LONG + LOAD_BIAS, 4)],
+        out=b"", dump=9, dumpbytes=bytes([8]) + b"abcdefgh", dumpadr=SST_LONG,
+        note="TP3 shortens a stack copy of the source; this entry writes none")
+
     return cs
 
 

+ 82 - 8
shell/tests/runtime.golden

@@ -41,7 +41,7 @@
 0036  58          pop ax
 0037  F7 D8       neg ax
 0039  B9 0A 00    mov cx,0ah
-003C  BB DA 02    mov bx,2dah
+003C  BB 51 03    mov bx,351h
 003F  89 DE       mov si,bx
 0041  31 D2       xor dx,dx
 0043  F7 F1       div ax,cx
@@ -73,18 +73,18 @@
 0072  83 7E 04 00 cmp word ptr [bp+4h],0h
 0076  89 EC       mov sp,bp
 0078  75 05       jne 7fh
-007A  BA DF 02    mov dx,2dfh
+007A  BA 56 03    mov dx,356h
 007D  EB 03       jmp 82h
-007F  BA DA 02    mov dx,2dah
+007F  BA 51 03    mov dx,351h
 0082  B4 09       mov ah,9h
 0084  CD 21       int 21h
 0086  5D          pop bp
 0087  C3          ret
-0088  BA E8 02    mov dx,2e8h
+0088  BA 5F 03    mov dx,35fh
 008B  B4 09       mov ah,9h
 008D  CD 21       int 21h
 008F  C3          ret
-0090  BA E5 02    mov dx,2e5h
+0090  BA 5C 03    mov dx,35ch
 0093  B4 09       mov ah,9h
 0095  CD 21       int 21h
 0097  C3          ret
@@ -251,12 +251,12 @@
 01AB  EB EF       jmp 19ch
 01AD  58          pop ax
 01AE  C3          ret
-01AF  8B 1E EE 02 mov bx,word ptr [2eeh]
+01AF  8B 1E 65 03 mov bx,word ptr [365h]
 01B3  83 FB 00    cmp bx,0h
 01B6  74 09       je 1c1h
 01B8  8A C3       mov al,bl
 01BA  31 DB       xor bx,bx
-01BC  89 1E EE 02 mov word ptr [2eeh],bx
+01BC  89 1E 65 03 mov word ptr [365h],bx
 01C0  C3          ret
 01C1  B4 08       mov ah,8h
 01C3  CD 21       int 21h
@@ -264,5 +264,79 @@
 01C6  88 C2       mov dl,al
 01C8  BB 01 00    mov bx,1h
 01CB  8A DA       mov bl,dl
-01CD  89 1E EE 02 mov word ptr [2eeh],bx
+01CD  89 1E 65 03 mov word ptr [365h],bx
 01D1  C3          ret
+01D2  55          push bp
+01D3  8B EC       mov bp,sp
+01D5  50          push ax
+01D6  53          push bx
+01D7  51          push cx
+01D8  52          push dx
+01D9  57          push di
+01DA  8B 7E 04    mov di,word ptr [bp+4h]
+01DD  8B 46 04    mov ax,word ptr [bp+4h]
+01E0  8B F0       mov si,ax
+01E2  46          inc si
+01E3  31 DB       xor bx,bx
+01E5  53          push bx
+01E6  E8 C6 FF    call 1afh
+01E9  5B          pop bx
+01EA  3C 0D       cmp al,0dh
+01EC  74 1D       je 20bh
+01EE  3C 0A       cmp al,0ah
+01F0  74 19       je 20bh
+01F2  3C 1A       cmp al,1ah
+01F4  74 15       je 20bh
+01F6  88 C2       mov dl,al
+01F8  43          inc bx
+01F9  88 14       mov byte ptr [si],dl
+01FB  46          inc si
+01FC  E2 E7       loop 1e5h
+01FE  8A C3       mov al,bl
+0200  88 05       mov byte ptr [di],al
+0202  5F          pop di
+0203  5A          pop dx
+0204  59          pop cx
+0205  5B          pop bx
+0206  58          pop ax
+0207  89 EC       mov sp,bp
+0209  5D          pop bp
+020A  C3          ret
+020B  53          push bx
+020C  E8 B7 FF    call 1c6h
+020F  5B          pop bx
+0210  EB EC       jmp 1feh
+0212  55          push bp
+0213  8B EC       mov bp,sp
+0215  50          push ax
+0216  53          push bx
+0217  51          push cx
+0218  52          push dx
+0219  8B 46 04    mov ax,word ptr [bp+4h]
+021C  8B F0       mov si,ax
+021E  8B 5E 06    mov bx,word ptr [bp+6h]
+0221  31 D2       xor dx,dx
+0223  8A 07       mov al,byte ptr [bx]
+0225  88 C2       mov dl,al
+0227  43          inc bx
+0228  39 D1       cmp cx,dx
+022A  72 04       jb 230h
+022C  8B CA       mov cx,dx
+022E  EB 02       jmp 232h
+0230  8A D1       mov dl,cl
+0232  88 14       mov byte ptr [si],dl
+0234  46          inc si
+0235  E3 0A       jcxz 241h
+0237  8A 07       mov al,byte ptr [bx]
+0239  88 C2       mov dl,al
+023B  88 14       mov byte ptr [si],dl
+023D  43          inc bx
+023E  46          inc si
+023F  E2 F6       loop 237h
+0241  5A          pop dx
+0242  59          pop cx
+0243  5B          pop bx
+0244  58          pop ax
+0245  89 EC       mov sp,bp
+0247  5D          pop bp
+0248  C3          ret