瀏覽代碼

compiler: string comparison, = <> < <= > >=

ParseCmp had one arm, the numeric one: both operands load into AX/CX, CMP
AX,CX sets the flags, and a six-entry SETcc table turns them into 0/1.  A
string operand never reached it -- it fell through to LoadAtom, which refuses
a TString with ENoLib.  The string arm goes BEFORE the constant-fold arm,
because a string literal is not a kind-0 constant:

    EmPushStrAddr (left) ;
    EmPushStrAddr (right) ;
    EmCall (TU_CmpStr) ;
    EmAddSp (4) ;
    EmOrAxAx () ;
    EmRelSetcc (op) ;
    r.kind := 2 ; r.cls := TBool ;

The tail is the same SETcc table the numeric arm uses: EmRelSetcc was factored
out of ParseCmp so the two paths cannot drift (the arms are SETG 9F / SETGE
9D, one letter apart, and were swapped once before).

cmpstr is runtime selector 18, 83 bytes (0249..029B): min(lenL,lenR) bytes are
compared in order, UNSIGNED (CMP AL,DL; CF is the borrow out of AL-DL), first
difference decides; if the common prefix is equal the LENGTHS decide, so a
proper prefix is less ('ab' < 'abc', '' < everything).  JCXZ skips the loop
when min is 0 (LOOP with CX=0 runs 65536 times); the lengths are re-read from
the arguments so CX is free to be LOOP's count.

Why a value (AX = -1/0/+1) and not the flags: TP3's cmpstr removes its own
arguments (MOV SP,BX, TPSRC2 960), so its six csteq/cstne/... wrappers
(TPSRC2 890-925) can branch on the flags straight after the CALL.  Our caller
pushes two addresses and drops them with ADD SP,4, which is in the CPU's
arithmetic group and DESTROYS the flags.  So the sign travels in AX, the
caller runs OR AX,AX (09 C0), and the compiler's test is SIGNED (SETL/SETG/
SETGE/SETLE): -1 is 0FFFFh, so SETB would be false and every a < b would come
back FALSE.  OR AX,AX rather than CMP AX,0 (3D 00 00) because check_8086.py
reads every 3D 00 00 in the program region as an IF/WHILE/REPEAT test; and
TEST AX,AX (85 C0) has no mnemonic in the audit's name grammar.

A string literal has no storage and a .COM carries no initialised data, so
EmPushStrAddr emits it inline in the code stream -- JMP over <len><chars> (EB
rel8, or E9 rel16 past 127) -- and the address pushed is the length byte.
This mirrors TPSRC9 fullvar (993) with one extra byte: estr2 (TPSRC10 85)
emits TP3's constant without a length byte because the length travelled on
the stack, and every string here is <len><chars>.

A one-character literal is a CHAR, not a string, so s = 'a' is ETypeErr (56);
the empty literal '' is the zero-length string and works.  The mixed-operand
guard (string vs number/char) is new.

The bug that only execution found: the first epilogue saved AX with the other
registers.  AX is the result -- nothing to save -- and popping it restored the
CALLER's AX (for a < b, the right operand's address from its LEA), so every
ordering comparison read a positive number.  = and <> stayed correct; < <= >
>= over any left < right came back FALSE TRUE TRUE TRUE FALSE FALSE.  No byte,
size, sweep or branch check can see it; executing t48 does.  Fixed to
PopDx/PopCx/PopBx/MovSpBp/PopBp/RetR.

Fixtures: t48_strcmp (both operands variables; each block weights the six
operators 1/2/4/8/16/32 so the printed sum identifies which held -- a plain +1
is degenerate), t49_strcmp_lit (a literal on either side, the empty literal
included), t50_strcmp_type (s = 'a' -> ERR 56).

Harness moves, all here because the entry shifts the data area (+0x53, so
eight absolute data-address fixups) and the fixtures are new: RtProbe [0..18];
check_runtime GOLDEN["cmpstr"]; runtime.golden re-blessed (the diff is the
eight fixups plus the 46-line cmpstr block, no instruction moved); rt_exec
ENTS + a cmpstr case; check_8086 BRANCH_SITES for t48/t49 + BRANCH_WHY;
audit_helpers NON_ONE_LINE["Compiler.mod"]["PushStrAddr"] (PushStrAddr ->
EmPushStrAddr, because the audit strips two characters for Compiler.mod and
the grammar had never heard of "shStrAddr"); expected.tsv rows.

check_runtime.py's branch scan learned LOOP, which it had never seen: the
runtime already contained four LOOPs, cmpstr made five, and a LOOP target is a
relative branch like any other.  Branch count 57 -> 62.

Gates: compile matrix 51/51, run_com_exec 45/45, exec86 45/45, audit_helpers
112/112, rt_exec 52/52, check_8086 clause H 10/10, runtime 699 bytes / code
0..667 / 19 entries / 62 branches, nonvacuity 78 ok 0 failed, OVERALL: ALL
PASS.  tpshell clean build 195576 bytes.

Tag: v-TP3-STRCMP.
Eric Streit 17 小時之前
父節點
當前提交
b58b3deaef

+ 42 - 31
SUMMARY.md

@@ -35,6 +35,7 @@ manual, not guessed.
 | The 4 KiB code window enforced — TP3 error 98 instead of writing over the data | `v-TP3-CODEWINDOW` | done, **`dataBase` is `rtSz + 1000H`, so code and data share one 64 KiB `.COM` segment and code may not reach it. `Ebyte` now refuses past `dataBase` (guarded by `imgReady`, since `dataBase` is 0 until `Compile` measures it) and reports TP3's error 98, "Memory overflow" — the same number `DefVar` already uses when `dc` overflows. `t43_codewindow` emits more than 4 KiB of inline literal bytes and must report `ERR 98`; disabling the check turns the fixture green-as-`OK`, which is the bug (a silently corrupt image), so the matrix row is the proof (44/44 compile)** |
 | String subscripts — `s[i]` read, write, and `readln (s[i])` | `v-TP3-STRINDEX` | done, **a STRING is a length byte at `s[0]` followed by the characters, so `s[i]` is the single byte at `off+i`: reading it yields a `CHAR`, assigning to it stores one byte, and `readln (s[i])` reads one byte at `off+i`. A constant index folds into `boff` as an array's does and `r.cls` drops to `TChar`, so `LoadAtom`, `Assignment` and `IoCall` all see one byte. Only a constant index is accepted — a variable index is `ENoLib`, exactly as it already was for an array. The fix uncovered a latent bug: `ParseAtom` did `r.boff := 0` *after* `ParseVar`/`ParseSub`, discarding every subscript, so `s[i]` addressed `off+0` (the length byte). `ParseVar` already zeroes `boff`, so the reset was redundant and was removed. `t44_strindex` prints every indexed byte and `t45_strindex_read` feeds `readln (s[i])`; the indexed read is red before the parse fix and green after (46/46 compile, 41/41 executed)** |
 | Variable string subscripts — `s[i]` with a *computed* index | `v-TP3-STRIDX-VAR` | done, **the address of `s[i]` is now computed at run time, so a variable index, a computed one (`i+1`) and a nested one all work — `v-TP3-STRINDEX` accepted a constant only. `ParseSub` evaluates the index into `AX`, then `LEA DI,[base]` / `ADD DI,AX` puts the element's address in `DI` and `r.kind` drops to `5`; `LoadAtom` reads `[DI]`, `Assignment` `PUSH DI`es the destination across its right-hand side (so `g[i] := h[j]` stores where it read — TPSRC8 `asnscal2`/`asnscal3`'s `flgpshdi`, and `SaveLeft` materialises a `DI`-addressed left operand *before* parsing the right), and `IoCall` pushes `DI` for `readln (s[i])`. No runtime entry: the arithmetic is compile-time emitted, so the runtime image is unchanged. Five new one-line emitters are name-audited (`AddDiAx`, `LdAlDi`, `StDiAl`, `PushDi`, `PopDi`) and `LeaDi` is the one documented computed emitter. `t46_strindex_var` reads, writes, compares, passes as an argument and assigns both-indexed (`g[i] := h[j]`), globally and locally; `t47_strindex_var_read` is `readln (g[i])` (48/48 compile, 43/43 executed)** |
+| String comparison — `= <> < <= > >=` with both operands strings | `v-TP3-STRCMP` | done, **`ParseCmp` gets a string arm ahead of the constant-fold arm: two addresses are pushed (`EmPushStrAddr`), `cmpstr` — runtime selector 18 — returns `AX = -1/0/+1`, and the shared `SETcc` table turns the sign into a Boolean. `cmpstr` (83 bytes, `0249h`..`029Bh`) compares `min(lenL,lenR)` bytes **unsigned** — first difference wins — and breaks a tie on the lengths, so a proper prefix is *less* (`'ab' < 'abc'`, `'' < everything`); the compiler's test on `-1` is **signed** (`SETL`, not `SETB`: `-1` is `0FFFFh`). It returns a value rather than flags because the caller drops the two addresses with `ADD SP,4`, which destroys the flags (TP3's own `cmpstr` removes its arguments itself and so can leave them). A string literal travels inline in the code stream (`JMP` over `<len><chars>`, address = the length byte) because a `.COM` carries no initialised data; a one-character literal is a `CHAR`, so `s = 'a'` is `ETypeErr` 56 while `''` is the empty string. The first epilogue saved `AX`, which restored the *caller's* `AX` (the right operand's address) and inverted every ordering comparison — only executing `t48` could see it. `t48_strcmp` weights the six operators 1/2/4/8/16/32 so the printed sum identifies which held; `t49_strcmp_lit` is the literal operand; `t50_strcmp_type` is the `CHAR` refusal (51/51 compile, 45/45 executed). Running `tests/nonvacuity.sh` also exposed three mutation anchors `v-TP3-STRIDX-VAR` had silently invalidated, and a `LOOP` that the runtime's branch scan had never checked; both fixed** |
 
 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
@@ -128,11 +129,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.
 
-**43 of 48 fixtures compile clean**, up from 1 (the empty program) when the
+**45 of 51 fixtures compile clean**, up from 1 (the empty program) when the
 direct harness was first built.
 
 ```
-compile matrix: 48 passed, 0 failed (of 48)
+compile matrix: 51 passed, 0 failed (of 51)
 ```
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -160,21 +161,27 @@ each name landing on its own slot ·
 `t45` `readln (s[i])` storing one character at position `i` ·
 `t46` `s[i]` with a **variable** index — read, write, compare, an argument and
 `g[i] := h[j]`, globally and locally ·
-`t47` `readln (g[i])` with the index in a variable.
+`t47` `readln (g[i])` with the index in a variable ·
+`t48` all six comparisons between two string **variables**, as branches and as
+values ·
+`t49` the same six with a string **literal** as one or both operands, the empty
+string included.
 
 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
 direction is still unimplemented) both → `ENoLib`
 (102), the original's "not implemented" path; `t38` `readln (b)` of a
 `boolean` → error 66, TP3's "I/O not allowed here" (TPSRC8 `prderrtp`), which
-refuses the whole class because the runtime has no boolean reader at all; and
+refuses the whole class because the runtime has no boolean reader at all;
 `t43_codewindow` a program whose code exceeds the 4 KiB window → error 98,
-"Memory overflow", rather than a corrupt image; and
+"Memory overflow", rather than a corrupt image;
+`t50_strcmp_type` `s = 'a'` → error 56, because a one-character literal is a
+`CHAR` and not a string; and
 `uierror` is a deliberate syntax error (41) used by the UI test.
 
-`run_com_tests.sh` additionally links **all 43 that compile** to a real `.COM`
+`run_com_tests.sh` additionally links **all 45 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: 43 checked, 0
+the layout instead of restating it: `independent .COM check: 45 checked, 0
 failed`. That last part was itself a bug fix — see "the two restated
 constants" below.
 
@@ -213,13 +220,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 — **110/110**, 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 — **112/112**, from an inventory scanned independently of the parser | anything longer than one instruction |
 | `check_comimage.py` | a linked image's layout is described in **exactly one file**: `comimage.py` defines each part once, every reader imports it, no second `find_header` exists anywhere in `tests/` | a check that *hand-rolls* the layout from literals (`hdr = 3 + rt_size`), which defines none of those names — the readers catch that a different way: each states the layout twice, from two independent sources, and requires the two to agree |
-| `check_runtime.py` + `runtime.golden` | the built runtime's code region (585 bytes, code 0..584) sweeps cleanly through FCML, every entry and all branch targets land on an instruction boundary, and the whole disassembly is byte-for-byte the committed golden | whether the golden is *right* |
+| `check_runtime.py` + `runtime.golden` | the built runtime's code region (668 bytes, code 0..667) sweeps cleanly through FCML, every entry and all branch targets — `Jcc`, `JMP`, `CALL` and `LOOP` — 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, 43/43 | the `.out` file it shares with `run_com_exec.py` — a wrong expectation fails both at once |
-| `rt_exec.py` | the **runtime entries themselves** are called directly, one qemu boot per call, 50 cases plus a pre-flight, 51/51 — plus a `check_bp_contract` pre-flight over the built blob: an entry that borrows BP must open with `55 8B EC` (exact) and contain a `5D` (a screen, because `5D` is also a displacement byte) | whether the *caller's* half of a contract is right, where the only witness is a case that happens to make two calls in a row |
+| `run_exec86.py` | the same image runs in a **second, independent 8086** and agrees with qemu byte for byte, 45/45 | 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, 51 cases plus a pre-flight, 52/52 — 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.
@@ -323,7 +330,7 @@ signature (`hdrFlag = 1`, `hdrDS == hdrOff + 1000h + bias`, `hdrHeap > hdrDS`,
 `prologAt` and `dataBase` per file:
 
 ```
-measured runtime size: 616 bytes (header at image offset 619)
+measured runtime size: 699 bytes (header at image offset 702)
 ```
 
 A restated constant that has drifted is worse than a derived one, and the two
@@ -347,13 +354,13 @@ The only check that asks a question about the *target* rather than about the
 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: 616 bytes, 330 swept, 0 0F-prefixed
-            program code: 43 of 43 fixtures swept end to end, 5697 bytes
+8086 check: 39 comparison sites, 35 lowered to a Boolean value, 57 lowered to a branch
+            value conditions  : = x7  <> x2  < x5  >= x3  <= x2  > x16
+            branch conditions : IF / REPEAT x53  CASE x2  FOR downto x1  FOR to x3
+            runtime: 699 bytes, 376 swept, 0 0F-prefixed
+            program code: 45 of 45 fixtures swept end to end, 8576 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
+            clause H: 10 of 10 fixtures matched the branch conditions read off their source
 ```
 
 It exists because bugs 32 and 33 got past everything else, and because **no
@@ -986,7 +993,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: **616 bytes**, 18 entries, 51 branch targets, offsets read back
+Current blob: **699 bytes**, 19 entries, 62 branch targets, offsets read back
 out of the assembled bytes by `tests/check_runtime.py` rather than asserted by
 hand:
 
@@ -999,11 +1006,13 @@ hand:
 | | | `wrln` | 144 | `rdln` | 411 |
 | | | `wrtinl` | 152 | `rdstr` | 466 |
 | | | `wrstr` | 171 | `strstore` | 530 |
+| | | | | `cmpstr` | 585 |
 
 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, to 497 (`wrstr` joined, `v-TP3-STRVAR`) and to 616 (`rdstr`
-and `strstore` joined, `v-TP3-STRREAD-ASSIGN`) — six of the offsets in an
+and `strstore` joined, `v-TP3-STRREAD-ASSIGN`), and to 699 (`cmpstr` joined,
+`v-TP3-STRCMP`) — 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
@@ -1107,9 +1116,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` (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,
+**Known limit:** the data area starts at a fixed `rtSz + 1000H` (699 + 4096 =
+4795), so a program whose code exceeds 4 KiB runs into its own data. Every
+fixture's image is between 4802 and 4849 bytes. Documented rather than fixed,
 because the
 original has the same fixed-offset behaviour.
 
@@ -1242,11 +1251,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.** 616 bytes, 18 entries, 105
+  invariants are stated rather than implied.** 699 bytes, 19 entries, 112
   emitter helpers decoded against their own names across both modules, the
-  whole code region golden-pinned, all branch targets on instruction
-  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
+  whole code region golden-pinned, every `Jcc`/`JMP`/`CALL`/`LOOP` target on an
+  instruction boundary — and 51 cases that call the entries one at a time under
+  qemu (`rt_exec.py`, 52/52 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
@@ -2004,12 +2013,14 @@ independently-scanned inventory at all.
 
 1. The rest of the string language. `v-TP3-STRVAR`/`v-TP3-STRREAD-ASSIGN` cover
    string variables, `s := 'literal'`, `writeln (s)`, `readln (s)`/`read (s)`
-   and `s := t`; `v-TP3-STRINDEX` covers `s[i]` with a constant index and
+   and `s := t`; `v-TP3-STRINDEX` covers `s[i]` with a constant index,
    `v-TP3-STRIDX-VAR` covers a **variable/computed** one — read, write,
    `readln (s[i])`, comparison against a char, use as an argument, and
-   `g[i] := h[j]`. Still `ENoLib`: the `+` and the whole-string comparison
-   operators (`cmpstr`/`xconcat`, TPSRC2 — the latter needs a runtime entry,
-   the next selector is 18).
+   `g[i] := h[j]` — and `v-TP3-STRCMP` covers the six **whole-string
+   comparison** operators (`cmpstr`, runtime selector 18). Still `ENoLib`:
+   concatenation `+` (`xconcat`/`addstr`, TPSRC2 — needs a runtime entry, the
+   next selector is 19; note TP3 makes `s + 'a'` legal by converting the
+   one-character operand, the very case `t50` rejects for `=`).
 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

+ 85 - 12
TP3-COMPILER.md

@@ -68,6 +68,13 @@ whole-program pass 3 silently caps identifier/error counts — see
    keep the address because a `.COM` loads no segment register: `DS=SS=CS=ES`
    for the whole run, and neither the compiler nor the runtime ever changes
    one.
+10. **String comparison (`cmpstr`):** `ParseCmp` grew a string arm ahead of the
+    constant-fold arm.  Each operand's address is pushed by `EmPushStrAddr` -- a
+    variable's from `symtab`, a literal's emitted inline in the code stream as a
+    `JMP` over `<len><chars>` -- `cmpstr` returns `AX = -1/0/+1`, and `EmOrAxAx`
+    plus the shared `EmRelSetcc` table turn the sign into a Boolean.  The
+    relational `SETcc` table was factored out of `ParseCmp` so the numeric and
+    string paths cannot drift.  The runtime gained `EmitCmpStr` (selector 18).
 
 The compiler's whole-program 8086-emit, TEXT-equivalent, pastres and
 errexit flow are otherwise unchanged (mirrors TP3 TPSRC file, ConvertTP3).
@@ -215,10 +222,10 @@ a new source of the address:
   instead of `LEA AX,[off]`; the class is still `TChar`, so `readln (s[i])`
   picks `rdchar` and reads one byte.
 
-No runtime entry was added: the arithmetic is emitted inline, so the runtime
-image is byte-for-byte unchanged (616 bytes, code 0..584, 18 entries).  There
-is no range check on the index, matching the constant-index path; `{$R+}` /
-`{$I+}` are separate.
+No runtime entry was added: the arithmetic is emitted inline, so this change
+did not touch the runtime image at all — no new selector and no new bytes.
+There is no range check on the index, matching the constant-index path;
+`{$R+}` / `{$I+}` are separate.
 
 `t46_strindex_var` covers a global string and a **local** one (the disp16
 `LEA DI` form), a computed index (`i+1`), a subscript passed as an argument
@@ -233,6 +240,70 @@ picks up a stray `0x0A`.  That is why `.in` fixtures are LF-terminated (the
 runtime's own comment says so); the `.out` side is CRLF, because the guest
 emits CRLF.
 
+## String comparison: `cmpstr`, and a value rather than flags
+
+Six operators, both operands strings.  `ParseCmp` gets a string arm *before*
+the constant-fold arm, because a string literal is not a kind-0 constant:
+
+    EmPushStrAddr (left) ;
+    EmPushStrAddr (right) ;
+    EmCall (TU_CmpStr) ;
+    EmAddSp (4) ;
+    EmOrAxAx () ;
+    EmRelSetcc (op) ;
+
+`EmRelSetcc` is the same six-arm `SETcc` table the numeric arm uses, factored
+out so the two cannot drift; `cmpstr` is runtime selector 18, 83 bytes
+(`0249h`..`029Bh`).  The runtime is now 699 bytes, code 0..667, 19 entries.
+
+**Why the entry returns a value and not the flags.**  TP3's own `cmpstr`
+leaves the flags set -- its `MOV SP,BX` (TPSRC2 960) removes its own arguments,
+so its six `csteq`/`cstne`/`cstge`/`cstle`/`cstg`/`cstl` wrappers (TPSRC2
+890-925) can branch on them immediately.  Our caller pushes two addresses and
+drops them with `ADD SP,4`, which is in the CPU's arithmetic group and
+*destroys* the flags.  So `cmpstr` returns `AX = -1/0/+1`, the caller runs
+`OR AX,AX` to put the sign in the flags, and then runs the same `SETcc` as
+every numeric comparison.  `OR AX,AX` (`09 C0`) rather than `CMP AX,0`
+(`3D 00 00`) because `tests/check_8086.py` reads every `3D 00 00` in the
+program region as an IF/WHILE/REPEAT test that must be followed by a branch --
+and a comparison used as a *value* (`writeln (a = b)`) is not one.
+
+**What it compares.**  `min(lenL,lenR)` bytes in order, unsigned (`CMP AL,DL`;
+CF is the borrow out of AL-DL), first difference decides; if the common prefix
+is equal the lengths decide, so a proper prefix is *less* (`'ab' < 'abc'`, and
+`''` less than everything).  The compiler's test on `-1` is **signed**
+(`SETL`/`SETG`/`SETGE`/`SETLE`): `-1` is `0FFFFh`, so `SETB` would be false and
+every `a < b` would come back FALSE.
+
+**String literals travel inline.**  A `.COM` has no initialised data, so a
+literal operand is emitted in the code stream -- a `JMP` over `<len><chars>`
+(`EB rel8`, or `E9 rel16` when the length exceeds 127) -- and the operand's
+address is the length byte.  This mirrors TPSRC9 `fullvar` (993), with one
+extra byte: `estr2` (TPSRC10 85) emits TP3's constant without a length byte,
+because there the length travelled on the stack, and every string in this
+runtime is `<len><chars>`.
+
+**A one-character literal is a `CHAR`.**  `s = 'a'` is `TString` vs `TScalar`,
+so it is `ETypeErr` (56); the empty literal `''` is the zero-length *string*
+and works.  The mixed-operand guard (a string compared with a number or char)
+is new: before this, such a program failed later and with the wrong diagnosis.
+
+**The bug only execution found.**  The first epilogue saved `AX` with the other
+registers.  `AX` is the *result*, so nothing should be saved, and popping it
+back restored the *caller's* `AX` -- for `a < b`, the right operand's address,
+still in `AX` from its `LEA`.  Every ordering comparison then read a positive
+number: `=` and `<>` stayed correct, and `<`, `<=`, `>`, `>=` over any
+`left < right` came back `FALSE TRUE TRUE TRUE FALSE FALSE`.  No byte, size,
+sweep or branch check can see it; `t48_strcmp` does.  The fix is the epilogue
+`PopDx / PopCx / PopBx / MovSpBp / PopBp / RetR`.
+
+`t48_strcmp` compares two string variables, `t49_strcmp_lit` puts a literal on
+either side (the empty one included) and `t50_strcmp_type` is the `CHAR`
+refusal.  `t48` weights the six operators 1/2/4/8/16/32 so the printed sum is a
+*signature* of which held -- a plain `+1` is degenerate, because
+`[a<b]+[a<=b]+[a>b]+[a>=b]` is 2 for any unequal pair, so the first version
+reported `3,3,3,3` while every ordering comparison was inverted.
+
 ## Tests
 
 Several harnesses live in `shell/tests/` -- in the repo on purpose, because
@@ -270,8 +341,9 @@ purpose so that implementing them is a deliberate change to a test rather than
 an accident; `t38` (`readln (b)` of a `boolean`) → error 66, TP3's "I/O not
 allowed here", for the same reason; `t43_codewindow` emits more than the 4 KiB
 between the runtime and the data base → error 98, "Memory overflow", rather
-than a silently corrupt image; and `uierror` is a deliberate syntax error
-used by `uitest.py`.  (`t39_strvar` is the counter-example: a *string*
+than a silently corrupt image; `t50_strcmp_type` (`s = 'a'`, a `CHAR` against a
+string) → error 56, because a one-character literal is not a string; and
+`uierror` is a deliberate syntax error used by `uitest.py`.  (`t39_strvar` is the counter-example: a *string*
 variable, `s : string [10]`, assigned a literal and written -- the first
 declared type wider than a word that compiles, and run under both oracles.)
 
@@ -283,13 +355,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 41 fixtures it compiles a `.COM`, writes a 1.44 MB floppy with the
+For each of 45 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 41
+cd shell && python3 tests/run_com_exec.py            # all 45
 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
 ```
@@ -318,10 +390,11 @@ 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.
 
-**43 of 48 fixtures execute.**  5 do not: `t14` and `t25` (`ENoLib`, by
+**45 of 51 fixtures execute.**  6 do not: `t14` and `t25` (`ENoLib`, by
 design), `t38` (a boolean read, refused with TP3's error 66, by design),
 `t43` (a program whose code exceeds the 4 KiB window, refused with error 98,
-by design) and `uierror` (a deliberate syntax error).  The nine control-flow fixtures (`t08`
+by design), `t50_strcmp_type` (a `CHAR` compared with a string, error 56, 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
@@ -339,11 +412,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 **51/51 pass** — 50 cases plus the pre-flight below, which is
+`run_all.sh` and **52/52 pass** — 51 cases plus the pre-flight below, which is
 counted as a check because it is one.
 
 ```
-cd shell && python3 tests/rt_exec.py             # all 50 cases + the pre-flight
+cd shell && python3 tests/rt_exec.py             # all 51 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

+ 149 - 17
shell/Compiler.mod

@@ -281,6 +281,8 @@ VAR
                                 in CX *)
    TU_StrStore : CARDINAL ;  (* s := t; dest and source addresses on the stack,
                                 limit in CX *)
+   TU_CmpStr   : CARDINAL ;  (* string comparison; two addresses on the stack,
+                                signed -1/0/+1 in AX *)
 
    abortFac : BOOLEAN ;
    errNum   : CARDINAL ;        (* NOT "errNo": Compile's formal of that
@@ -748,6 +750,21 @@ BEGIN
    Ebyte (0BH) ; Ebyte (0C1H)
 END EmOrAxCx ;
 
+PROCEDURE EmOrAxAx () ;
+(* 09 C0: OR AX,AX - the 8086's flag-only test of AX, and the one the string
+   comparison needs, because cmpstr hands back a signed -1/0/+1 rather than
+   flags.  OR AX,AX sets SF and ZF from the value and clears OF and CF, which
+   is exactly the flag state a CMP AX,0 would leave - see EmSetcc and
+   ParseCmp's EmRelSetcc.  TEST AX,AX (85 C0) does the same thing, but
+   tests/audit_helpers.py's name grammar has no `Test' mnemonic, and an
+   emitter that cannot be name-checked is worse than one that can.
+
+   It is NOT EmXorAxAx: XORing AX with itself zeroes it, which would throw
+   away the very value the flags are supposed to describe. *)
+BEGIN
+   Ebyte (09H) ; Ebyte (0C0H)
+END EmOrAxAx ;
+
 PROCEDURE EmNegAx () ;
 BEGIN
    Ebyte (0F7H) ; Ebyte (0D8H)
@@ -826,6 +843,42 @@ BEGIN
    Ebyte (48H)                                          (* DEC AX *)
 END EmSetcc ;
 
+PROCEDURE EmRelSetcc (op : CARDINAL) ;
+(* The relational operator -> SETcc table, in ONE place, because there are now
+   two callers: ParseCmp's numeric arm, whose flags come from EmCmpAxCx, and
+   its string arm, whose flags come from a cmpstr result put through
+   EmOrAxAx.  It was factored out of ParseCmp when the string arm arrived, and
+   the note it carried is repeated here rather than referenced, because a
+   comment in the caller about a table in another routine is a comment nobody
+   reads.
+
+   The mnemonic is written next to every opcode on purpose.  `op' is a number,
+   so the arm for ">" and the arm for ">=" differed only by two hex digits
+   that are each one letter from the other meaning -- SETG (9FH) and SETGE
+   (9DH).  They were swapped once, which made a > b mean a >= b and a >= b
+   mean a > b.  Only the equality boundary could see it: 6>5, 5<5, -1>-2 and
+   every other case I tried were already right.  An opcode on its own does not
+   say which comparison it is the answer to.
+
+   `op' is ParseCmp's own numbering: 1 "=", 2 "<>", 3 "<", 4 ">", 5 ">=",
+   6 "<=".  The condition nibbles are the SIGNED ones (L/G/GE/LE), and both
+   callers hand it a value whose SIGN is the answer - ParseCmp's numeric arm
+   compares with CMP AX,CX on signed data, and cmpstr answers -1/0/+1 - so
+   the signed test is the right one in both places.  One table also means that
+   swap cannot come back in one arm and not the other: tests/nonvacuity.sh
+   mutates exactly these two lines, and it now breaks the string comparison
+   at the same time as the numeric one. *)
+BEGIN
+   CASE op OF
+      1 : EmSetcc (94H) ;          (* =  SETE  *)
+   |  2 : EmSetcc (95H) ;          (* <> SETNE *)
+   |  3 : EmSetcc (9CH) ;          (* <  SETL  *)
+   |  4 : EmSetcc (9FH) ;          (* >  SETG  *)
+   |  5 : EmSetcc (9DH) ;          (* >= SETGE *)
+   |  6 : EmSetcc (9EH)            (* <= SETLE *)
+   END
+END EmRelSetcc ;
+
 PROCEDURE EmIncAx () ;
 BEGIN
    Ebyte (40H)
@@ -2030,6 +2083,71 @@ BEGIN
    END
 END LoadPair ;
 
+PROCEDURE EmPushStrAddr (r : ERes) ;
+(* Push the ADDRESS of a whole-string operand - the argument shape cmpstr
+   takes, one per operand.
+
+   A string VARIABLE's value already is its address, so this is the same push
+   READ makes (EmPushVarAddr), through symtab's local/off.
+
+   A string LITERAL has no storage, and the image carries no initialised data
+   to put one in (globals are left to the loader's zero fill), so the
+   characters have to travel in the code stream and the operand is the address
+   of those bytes.  TP3 does the same in TPSRC9 fullvar (993): it builds the
+   `JMP' opcode in AL and the length in AH (`MOV AL,#$EB' / `MOV.B AH,CH'),
+   emits that word, and takes the position immediately after it as varofs,
+   with varseg := CS - the instruction stream steps over the constant instead
+   of running into it.
+
+   The difference is one byte, and it is deliberate.  TP3's constant is an
+   ARRAY [1..n] OF CHAR, and `estr2' (TPSRC10 85) emits it WITHOUT a length
+   byte because the length travelled on the stack instead.  Every string in
+   THIS runtime - variable or literal - is a `string': a length byte and then
+   the characters, which is what rdstr writes and what cmpstr reads.  So a
+   literal operand has to look the same, and the jump carries one extra byte:
+   <len><chars>, with the ADDRESS pushed being the length byte itself.  The
+   jump still exists only to keep the CPU from walking into the characters.
+
+   Nothing is added to the data segment and no runtime entry is involved in
+   reading the literal; LEA AX,[that] is absolute into the code segment, which
+   is where a .COM's DS already is.
+
+   A 1-character literal is NOT a TString here: RdConst reports it as TScalar
+   (its character code), so `s = 'a'` compares a string with a character and
+   is a type error rather than a length-1 string compare.  That is deliberate
+   - it is the same rule `c := 'a'` relies on - and the caller rejects it. *)
+VAR n, len, here : CARDINAL ;
+BEGIN
+   IF r.kind = 1 THEN
+      EmPushVarAddr (symtab [r.idx].local,
+                     (symtab [r.idx].off + r.boff) MOD 10000H)
+   ELSIF r.kind = 3 THEN
+      len := strLen [r.strx] ;
+      IF len > 255 THEN
+         (* As in IoCall: the length is one byte, so a longer literal would
+            wrap and silently compare only its first (length MOD 256)
+            characters.  TP3 strings are at most 255 characters. *)
+         Err (EConstRange) ;
+         RETURN
+      END ;
+      IF len + 1 <= 127 THEN
+         Ebyte (0EBH) ; Ebyte (VAL (BYTE, len + 1))    (* JMP rel8 over it *)
+      ELSE
+         Ebyte (0E9H) ; Eword (len + 1)                (* JMP rel16 over it *)
+      END ;
+      here := pc ;                 (* the length byte: the literal's address *)
+      Ebyte (VAL (BYTE, len)) ;
+      n := 0 ;
+      WHILE n < len DO
+         Ebyte (VAL (BYTE, ORD (strPool [strOff [r.strx] + n]))) ;
+         INC (n)
+      END ;
+      EmPushVarAddr (FALSE, here)  (* LEA AX,[here]; PUSH AX *)
+   ELSE
+      Err (ENoLib)                 (* a computed string is not a value *)
+   END
+END EmPushStrAddr ;
+
 PROCEDURE ParseCmp (VAR r : ERes) ;
 (* "=" | "<>" | "<" | "<=" | ">" | ">=" *)
 VAR op : CARDINAL ;
@@ -2063,7 +2181,34 @@ BEGIN
       left := r ;
       SaveLeft (left) ;
       ParseAdd (right) ;
-      IF (left.kind = 0) AND (right.kind = 0) THEN
+      IF (left.cls = TString) OR (right.cls = TString) THEN
+         (* A string comparison: cmpstr, the entry TPSRC9's vcompare (133)
+            reaches through TPSRC2's csteq/cstne/cstge/cstle/cstg/cstl
+            wrappers (890-925).  It is intercepted BEFORE the constant-fold
+            arm, because a string literal is never a kind-0 constant and,
+            without this branch, both operands would fall through to LoadAtom,
+            which refuses a TString (ENoLib) - the symptom this arm cures.
+
+            cmpstr returns AX = -1/0/+1 (see Runtime.mod for why a value and
+            not flags), so the second half is exactly the numeric arm's: put
+            the sign in the flags and let the shared six-arm table answer.
+            EmOrAxAx is OR AX,AX rather than EmCmpAxi(0) on purpose: tests/
+            check_8086.py reads every `CMP AX,0000' in the program region as
+            an IF/WHILE/REPEAT test and requires a branch right after it, and
+            a comparison used as a VALUE (writeln(a = b)) is not one. *)
+         IF (left.cls # TString) OR (right.cls # TString) THEN
+            Err (ETypeErr) ;         (* a string compared with a number/char *)
+            RETURN
+         END ;
+         EmPushStrAddr (left) ;
+         EmPushStrAddr (right) ;
+         DropC (EmCall (TU_CmpStr)) ;
+         EmAddSp (4) ;
+         EmOrAxAx () ;
+         EmRelSetcc (op) ;
+         r.kind := 2 ;
+         r.cls := TBool
+      ELSIF (left.kind = 0) AND (right.kind = 0) THEN
          IF ConstCmp (op, left.imm, right.imm, f) THEN
             r.kind := 0 ;
             r.imm := f ;
@@ -2076,22 +2221,7 @@ BEGIN
       ELSE
          LoadPair (left, right) ;
          EmCmpAxCx () ;
-         (* The mnemonic is written next to every opcode on purpose.  `op' is
-            a number, so the arm for ">" and the arm for ">=" differed only
-            by two hex digits that are each one letter from the other
-            meaning -- SETG (9FH) and SETGE (9DH).  They were swapped, which
-            made a > b mean a >= b and a >= b mean a > b.  Only the equality
-            boundary could see it: 6>5, 5<5, -1>-2 and every other case I
-            tried were already right.  An opcode on its own does not say
-            which comparison it is the answer to. *)
-         CASE op OF
-            1 : EmSetcc (94H) ;          (* =  SETE  *)
-         |  2 : EmSetcc (95H) ;          (* <> SETNE *)
-         |  3 : EmSetcc (9CH) ;          (* <  SETL  *)
-         |  4 : EmSetcc (9FH) ;          (* >  SETG  *)
-         |  5 : EmSetcc (9DH) ;          (* >= SETGE *)
-         |  6 : EmSetcc (9EH)            (* <= SETLE *)
-         END ;
+         EmRelSetcc (op) ;
          r.kind := 2 ;
          r.cls := TBool
       END
@@ -3857,6 +3987,8 @@ BEGIN
    TU_RdStr    := RT_Entry (16) ;      (* readln(s)/read(s): TPSRC8 prdstr,
                                           TPSRC4 xrdstr *)
    TU_StrStore := RT_Entry (17) ;      (* s := t: TPSRC8 asnvar, TPSRC2 strstore *)
+   TU_CmpStr   := RT_Entry (18) ;      (* string compare: TPSRC9 vcompare,
+                                          TPSRC2 cmpstr *)
 END Inittur ;
 
 PROCEDURE HeadWord (VAR slot : CARDINAL) ;

+ 4 - 0
shell/Runtime.def

@@ -36,6 +36,10 @@ CONST
    E_StrStore = 17 ;        (* s := t for string variables: destination and
                                source addresses on the stack, limit in CX;
                                TPSRC2 strstore *)
+   E_CmpStr  = 18 ;         (* string comparison: the two addresses on the
+                               stack, first (left) at [BP+6] and second
+                               (right) at [BP+4]; returns AX = -1/0/+1 for
+                               <, =, >; TPSRC2 cmpstr *)
 
    (* 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

+ 105 - 3
shell/Runtime.mod

@@ -117,7 +117,7 @@ VAR
    dataAt : CARDINAL ;
    rtBase : CARDINAL ;
    built  : BOOLEAN ;
-   entNm  : ARRAY [0..17] OF ARRAY [0..MaxNm] OF CHAR ;
+   entNm  : ARRAY [0..18] OF ARRAY [0..MaxNm] OF CHAR ;
 
 (* ---------------------------------------------------------------- *)
 (*  name helpers                                                     *)
@@ -366,6 +366,15 @@ END CmpArg4W0 ;
 
 PROCEDURE CmpSiBx ; BEGIN B (39H) ; B (0DEH) END CmpSiBx ;  (* 39 DE: CMP SI,BX *)
 PROCEDURE CmpCxDx ; BEGIN B (39H) ; B (0D1H) END CmpCxDx ;  (* 11 010 001 *)
+PROCEDURE CmpAlDl ; BEGIN B (3AH) ; B (0C2H) END CmpAlDl ;  (* 3A C2: CMP AL,DL.
+   The byte-sized comparator cmpstr's loop needs, and the two opcodes are NOT
+   interchangeable: 3A /r is `CMP r8,r/m8' (reg is the TARGET) and 38 /r is
+   `CMP r/m8,r8' (reg is the SOURCE).  AL is reg=000 and DL is r/m=010, so
+   3A C2 is CMP AL,DL and 38 C2 would be CMP DL,AL - the same flags negated,
+   which turns every `<` into a `>'.  And there is no `CMP AL,[SI]' name
+   pattern in tests/audit_helpers.py (only the Ld/St memory names are), so the
+   right-hand character is loaded into DL first rather than compared in
+   memory. *)
 PROCEDURE CmpDiCx ; BEGIN B (39H) ; B (0CFH) END CmpDiCx ;  (* 11 001 111 *)
 PROCEDURE CmpBx0  ; BEGIN B (83H) ; B (0FBH) ; B (0) END CmpBx0 ;
 PROCEDURE CmpCxV  (v : CARDINAL ) ; BEGIN B (83H) ; B (0F9H) ; B (v) END CmpCxV ;
@@ -1111,6 +1120,98 @@ BEGIN
    MovSpBp ; PopBp ; RetR
 END EmitStrStore ;
 
+PROCEDURE EmitCmpStr ;
+(* cmpstr: lexicographic comparison of two strings, TPSRC2's cmpstr with the
+   addresses taken as arguments rather than the strings pushed by value.
+
+   The caller pushes BOTH addresses - the FIRST (left) operand first, so it
+   sits at [BP+6], the second (right) at [BP+4], the same order strstore takes
+   its two - calls, and pops both words (caller-pops, unlike TP3's cmpstr,
+   which pops itself).  It returns AX = -1, 0 or +1 for left < right, left =
+   right, left > right.
+
+   Why AX and not the flags, when TP3's cmpstr leaves them set?  Because TP3's
+   cmpstr also REMOVES its own arguments (`MOV SP,BX', TPSRC2 960), so its six
+   wrappers (csteq/cstne/cstge/cstle/cstg/cstl, TPSRC2 890-925) can branch on
+   the flags immediately after the CALL.  Here the caller pushes the two
+   addresses, and dropping them with ADD SP,4 - the one-instruction way - is
+   in the CPU's arithmetic group and DESTROYS the flags.  (POP does not, but
+   then the flags would have to survive the caller's next emission, which is
+   exactly the invisible coupling the notes in this file keep warning about.)
+   A signed value in AX costs a few instructions inside the entry and keeps the
+   flag convention the compiler's own business, where the six-arm table already
+   lives: the caller runs OR AX,AX and the same EmSetcc it runs after every
+   numeric comparison, so the two paths cannot drift apart.
+
+   That tail is not invented.  A TP3 wrapper is `MOV AX,#1 / Jcc / DEC AX /
+   OR AX,AX' - the same EmSetcc our numeric arm emits, followed by the same
+   OR AX,AX - and the only thing this design moves is WHERE it happens: out of
+   six one-per-operator runtime entries and into the one shared table.  TP3
+   needs the six because its cmpstr is reached through vcompare's vector table
+   (TPSRC9 133) by type; we already have the operator as a number.
+
+   The comparison is TPSRC2's, translated to an explicit byte loop because
+   this runtime has no REPZ CMPS: min(lenL,lenR) bytes are compared in order,
+   the first difference decides, and if the common prefix is equal the LENGTHS
+   decide (a prefix is less than the string that continues it).  The byte
+   compare is unsigned - CF is the borrow out of AL-DL - which is what
+   "lexicographic" means for characters.  The tiebreak re-reads the lengths
+   from the arguments rather than keeping them in registers across the loop,
+   so CX is free to be LOOP's count, and the empty string is the case where
+   that count is 0 and LOOP would run 65536 times instead of none. *)
+BEGIN
+   M ("cmpstr") ;
+   PushBp ; MovBpSp ;
+   PushBx ; PushCx ; PushDx ;   (* AX is NOT saved: it is the result *)
+   XorCxCx ;                 (* CX := 0, to make the CL load below a length *)
+   XorDxDx ;
+   MovBxBp6 ;                (* BX := the first (left) string's address *)
+   MovClBx ;                 (* CL := lenL *)
+   MovAxBp4 ; MovSiAx ;      (* SI := the second (right) string's address *)
+   MovDlSi ;                 (* DL := lenR *)
+   CmpCxDx ;                 (* CMP lenL, lenR *)
+   Jbe8 ("cs_min") ;         (* lenL <= lenR: CX is already the shorter *)
+   MovCxDx ;                 (* CX := lenR *)
+   M ("cs_min") ;            (* CX = min (lenL,lenR) *)
+   Jcxz8 ("cs_len") ;        (* an empty operand: no bytes to compare *)
+   IncBx ; IncSi ;           (* past the length byte, to the characters *)
+   M ("cs_loop") ;
+   LdAlBx ;                  (* AL := left[i] *)
+   MovDlSi ;                 (* DL := right[i] *)
+   CmpAlDl ;                 (* CMP AL,DL - unsigned, so CF is the order *)
+   Jne8 ("cs_flag") ;        (* the first difference decides *)
+   IncBx ; IncSi ;
+   Loop8 ("cs_loop") ;
+   M ("cs_len") ;            (* equal over the common prefix: lengths decide *)
+   MovBxBp6 ; MovClBx ;      (* CX := lenL again - BX walked past it *)
+   MovAxBp4 ; MovSiAx ;
+   XorDxDx ; MovDlSi ;       (* DX := lenR *)
+   CmpCxDx ;
+   M ("cs_flag") ;           (* flags are (left - right): CF=1 less, ZF=1 equal *)
+   MovAlD (0) ; MovAh (0) ;  (* AX := 0.  MOV leaves the flags alone, which
+                                is what lets the two tests below still read
+                                the comparison. *)
+   Jb8 ("cs_less") ;
+   Je8 ("cs_done") ;
+   MovAlD (1) ;              (* greater *)
+   J8 ("cs_done") ;
+   M ("cs_less") ;
+   MovAlD (0FFH) ; MovAh (0FFH) ;   (* AX := -1 *)
+   M ("cs_done") ;
+   PopDx ; PopCx ; PopBx ;      (* but NOT AX: AX is the RESULT.  The first
+                                   version pushed AX with the others and
+                                   popped it back here, which restored the
+                                   CALLER's AX - for `a < b' that is the
+                                   address of the RIGHT operand, still in AX
+                                   from its LEA - and every ordering test read
+                                   a positive number.  `<', `<=', `=', `<>',
+                                   `>', `>=' over any left<right string came
+                                   back FALSE TRUE FALSE FALSE TRUE TRUE,
+                                   independently confirmed on qemu.  Saving AX
+                                   at all is wrong: there is nothing to keep. *)
+   MovSpBp ; PopBp ; RetR
+END EmitCmpStr ;
+
 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
@@ -1219,7 +1320,7 @@ BEGIN
    EmitRdInt ; EmitRdScan ; EmitRdByte ;
    EmitRdChar ; EmitRdBool ; EmitRdLn ;
    EmitGetCh ; EmitUnGetCh ;
-   EmitRdStr ; EmitStrStore ;
+   EmitRdStr ; EmitStrStore ; EmitCmpStr ;
    EmitData ;
    FixUp ;
    SetStr (entNm [0], "initmem") ;
@@ -1240,6 +1341,7 @@ BEGIN
    SetStr (entNm [15], "wrstr") ;
    SetStr (entNm [16], "rdstr") ;
    SetStr (entNm [17], "strstore") ;
+   SetStr (entNm [18], "cmpstr") ;
    built := TRUE
 END RT_Build ;
 
@@ -1267,7 +1369,7 @@ BEGIN
    IF NOT built THEN
       RT_Build (0)
    END ;
-   IF i > 17 THEN
+   IF i > 18 THEN
       RETURN 0
    END ;
    RETURN rtBase + LblOff (entNm [i])

+ 3 - 2
shell/tests/RtProbe.mod

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

+ 11 - 0
shell/tests/audit_helpers.py

@@ -114,6 +114,17 @@ NON_ONE_LINE = {
         "BpDisp": COMP, "LoadVar": COMP, "StoreVar": COMP,
         "PushVarAddr": COMP, "MovAxi": COMP, "CmpAxi": COMP,
         "MovCxi": COMP, "LeaDi": COMP,
+        # PushStrAddr is PushVarAddr's sibling one level up: it pushes the
+        # ADDRESS of a whole-string operand, and a string LITERAL has no storage
+        # to take the address of, so it emits the literal INLINE - a jump over
+        # it, then <len><chars> - and pushes the address of those bytes.  The
+        # emitted sequence therefore depends on the operand: a variable is a
+        # single EmPushVarAddr, a literal is two bytes of jump plus the string
+        # itself.  That is COMP, not PARAM (whose immediate varies but whose
+        # opcode does not).  Listed here rather than left out: an emitter the
+        # name grammar cannot describe has to be written down, or the report is
+        # green and the helper unchecked.
+        "PushStrAddr": COMP,
     },
 }
 # The Vx family is NOT here: those four are audited by audit_vx_helpers,

+ 8 - 0
shell/tests/check_8086.py

@@ -234,6 +234,12 @@ BRANCH_SITES = {
     "t22_case":   [5, 5],        # `case x of' with two arms, both fall to end
     "t30_forloop": [15],         # one `for .. to'
     "t32_forexit": [15, 4],      # one `for .. to' plus one `if .. exit'
+    # String comparisons add branch sites without adding `CMP AX,CX' (their
+    # value path tests with OR AX,AX, not a comparison), so the shape walk is
+    # what finds them.  5*6 = thirty `if <string compare>' in t48, 6+3+4 = the
+    # thirteen in t49; every one is a plain IF, so every declared nibble is 4.
+    "t48_strcmp": [4] * 30,
+    "t49_strcmp_lit": [4] * 13,
 }
 BRANCH_WHY = {
     "t09_if":     "three `if' statements",
@@ -244,6 +250,8 @@ BRANCH_WHY = {
     "t22_case":   "a `case' with two arms, both falling through to `end'",
     "t30_forloop": "a single `for .. to'",
     "t32_forexit": "a `for .. to' and an `if .. exit'",
+    "t48_strcmp": "five blocks of six `if <string compare>'",
+    "t49_strcmp_lit": "three blocks of `if <string literal compare>'",
 }
 
 

+ 19 - 3
shell/tests/check_runtime.py

@@ -10,8 +10,8 @@ cannot produce:
      exactly - a single byte-length mistake desynchronises the sweep, so a
      clean sweep means the lengths are all right;
   2. every RT_Entry offset is an instruction boundary;
-  3. every relative branch and CALL target is an instruction boundary inside
-     the code region;
+  3. every relative branch (Jcc, JMP, LOOP) and CALL target is an instruction
+     boundary inside the code region;
   4. the full disassembly matches tests/runtime.golden byte for byte;
   5. each entry still begins with the instruction sequence it is supposed to
      begin with (semantic goldens, not positional ones, so they survive
@@ -62,7 +62,12 @@ GOLDEN_FILE = os.path.join(HERE, "runtime.golden")
 RE_SIZE = re.compile(r"^(\d+) bytes$")
 RE_CODEEND = re.compile(r"^code ends at (\d+)$")
 RE_ENTRY = re.compile(r"^  entry (\d+) = (\d+)\s+\((\w+)\)$")
-RE_BRANCH = re.compile(r"^(?:j\w+|call|jmp)\s+([0-9a-f]+)h$")
+# j\w+ covers the Jcc family and JMP, `call` the near call, and `loop\w*`
+# LOOP/LOOPE/LOOPNE.  LOOP was absent until cmpstr became the runtime's first
+# user of it: a LOOP target is a relative branch like any other and has to land
+# on an instruction boundary, and without this the one LOOP was the one branch
+# the boundary check did not look at.
+RE_BRANCH = re.compile(r"^(?:j\w+|call|loop\w*)\s+([0-9a-f]+)h$")
 
 GOLDEN_TEXT_HEADER = """\
 # runtime.golden -- full FCML disassembly of the runtime's code region.
@@ -200,6 +205,17 @@ GOLDEN = {
     # 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",
+    # cmpstr: string compare, AX := -1 / 0 / +1 for left <, =, > right.  PUSH
+    # BP; MOV BP,SP; PUSH BX,CX,DX - and deliberately NOT AX.  The first
+    # version saved AX with the others and popped it back in the epilogue, so
+    # every return handed the caller back the CALLER's AX: for `a < b' that is
+    # the address of the RIGHT operand, still in AX from the LEA that pushed
+    # it, i.e. a positive number.  Every ordering test then read `greater' and
+    # `<'/`<='/`='/`<>' came back FALSE TRUE FALSE FALSE TRUE TRUE over any
+    # left<right pair, which is what execution found and no byte check could.
+    # AX is a RESULT here, so there is nothing to save; the three-register
+    # frame is the fix, and the sign is pinned by t48/t49 on qemu.
+    "cmpstr": "55 8B EC 53 51 52",
 }
 
 

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

@@ -346,3 +346,34 @@ t46_strindex_var	OK	578	31
 #                   two @@T temporaries (4).  t47_strindex_var_read.in feeds
 #                   'X' into g[3] then 'Y' into g[6].
 t47_strindex_var_read	OK	188	15
+#   t48_strcmp             string = <> < <= > >=, both operands string
+#                   VARIABLES, i.e. string-vs-string (a string SUBSCRIPT s[i] is
+#                   a CHAR, so s[i] = c would be a scalar compare).  Five
+#                   blocks, and each block weights the six operators
+#                   1,2,4,8,16,32 for =,<>,<,<=,>,>= so the printed sum says
+#                   exactly which ones held: 41 equal, 14 left < right, 50 left
+#                   > right.  The weighting is not decoration: with a plain +1
+#                   every unequal pair sums to 3, because exactly two of the
+#                   four ordering tests hold, so 3,3,3,3 was reported while
+#                   every ordering comparison was INVERTED.  The last block
+#                   sets b := '' - the empty string - which drives cmpstr's
+#                   min(len)=0 arm.  code=1905 is 5*6 branch compares plus the
+#                   6 value compares of the final writeln; data=24 is a and b
+#                   (string[8] = 9 each), n (2), and the two @@T temporaries (4).
+t48_strcmp	OK	1905	24
+#   t49_strcmp_lit         the same operators with a string LITERAL in one or
+#                   both positions.  A literal has no storage and the image
+#                   carries no initialised data, so PushStrAddr emits it inline
+#                   in the code stream (JMP over <len><chars>, then the LEA that
+#                   is its address); this pins that path.  Block 3 has both
+#                   operands literal, including '' on both sides; the last line
+#                   takes the empty literal's relation as a VALUE.  data=15 is s
+#                   (string[8] = 9), n (2), and the two @@T temporaries (4).
+t49_strcmp_lit	OK	1006	15
+#   t50_strcmp_type        s = 'a' where s is a string: a one-character literal
+#                   is a CHAR constant in ISO Modula-2, not a string, so this
+#                   mixes TString with TScalar and ParseCmp rejects it.  (''
+#                   does NOT: the zero-length string is a string, which is why
+#                   t49 may compare against it.)  Pins the new mixed-operand
+#                   guard; ETypeErr at the comparison.
+t50_strcmp_type	ERR	56	600

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

@@ -0,0 +1,6 @@
+41
+14
+50
+14
+50
+FALSE TRUE TRUE TRUE FALSE FALSE

+ 76 - 0
shell/tests/fixtures/t48_strcmp.pas

@@ -0,0 +1,76 @@
+{ t48_strcmp: string comparison, all six operators, string VARIABLES.
+  String subscripts s[i] are TChar, so s[i] = c is a char compare; this
+  pins string-vs-string, which is cmpstr (TPSRC2) / vcompare (TPSRC9).
+
+  Each block weights the six operators 1,2,4,8,16,32 for =,<>,<,<=,>,>= so
+  the printed sum is a SIGNATURE of which ones held:
+
+    41  equal (1+8+32)          14  left < right (2+4+8)
+    50  left > right (2+16+32)
+
+  A plain +1 per operator cannot tell < from >: both come to 3, and so does
+  every other unequal pair, because [a<b]+[a<=b]+[a>b]+[a>=b] is always 2.
+  That is not a hypothetical - it is how the first version of this fixture
+  reported 3,3,3,3 while every ordering comparison was inverted. }
+program t48_strcmp (input, output);
+var
+   a, b : string[8];
+   n    : integer;
+begin
+   a := 'abc';
+   b := 'abc';
+   n := 0;
+   if a =  b then n := n + 1;
+   if a <> b then n := n + 2;
+   if a <  b then n := n + 4;
+   if a <= b then n := n + 8;
+   if a >  b then n := n + 16;
+   if a >= b then n := n + 32;
+   writeln(n);                      { 41: =, <=, >= }
+
+   b := 'abd';                      { same length, differs at byte 3 }
+   n := 0;
+   if a =  b then n := n + 1;
+   if a <> b then n := n + 2;
+   if a <  b then n := n + 4;
+   if a <= b then n := n + 8;
+   if a >  b then n := n + 16;
+   if a >= b then n := n + 32;
+   writeln(n);                      { 14: 'abc' < 'abd' }
+
+   b := 'ab';                       { b is a strict PREFIX of a }
+   n := 0;
+   if a =  b then n := n + 1;
+   if a <> b then n := n + 2;
+   if a <  b then n := n + 4;
+   if a <= b then n := n + 8;
+   if a >  b then n := n + 16;
+   if a >= b then n := n + 32;
+   writeln(n);                      { 50: longer means greater }
+
+   a := 'ab';                       { a is a strict PREFIX of b }
+   b := 'abc';
+   n := 0;
+   if a =  b then n := n + 1;
+   if a <> b then n := n + 2;
+   if a <  b then n := n + 4;
+   if a <= b then n := n + 8;
+   if a >  b then n := n + 16;
+   if a >= b then n := n + 32;
+   writeln(n);                      { 14: shorter means less }
+
+   b := '';                         { the empty string: min(lenL,lenR) = 0,
+                                      the JCXZ arm of cmpstr's loop }
+   n := 0;
+   if a =  b then n := n + 1;
+   if a <> b then n := n + 2;
+   if a <  b then n := n + 4;
+   if a <= b then n := n + 8;
+   if a >  b then n := n + 16;
+   if a >= b then n := n + 32;
+   writeln(n);                      { 50: 'ab' > '' }
+
+   b := 'abc';                      { all six as VALUES, not branches }
+   writeln(a = b, ' ', a <> b, ' ', a < b, ' ', a <= b, ' ',
+                   a > b, ' ', a >= b)
+end.

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

@@ -0,0 +1,4 @@
+61
+7
+15
+TRUE FALSE TRUE TRUE

+ 46 - 0
shell/tests/fixtures/t49_strcmp_lit.pas

@@ -0,0 +1,46 @@
+{ t49_strcmp_lit: string comparison where one or both operands are a LITERAL.
+
+  A string literal has no storage, and the .COM image carries no initialised
+  data (globals are left to the loader's zero fill), so TP3 emits the literal
+  inline in the CODE stream - a JMP over its bytes, then <len><chars> - and
+  takes its address with the LEA that follows.  So this pins PushStrAddr's
+  inline path (the jump, the length byte, the address) as well as the compare
+  itself, in both operand positions and with both operands literal.
+
+  The empty literal '' is the length-0 case of that path, and it also drives
+  cmpstr's JCXZ arm, where min(lenL,lenR) is 0 and the loop never runs. }
+program t49_strcmp_lit (input, output);
+var
+   s : string[8];
+   n : integer;
+begin
+   s := 'abc';
+   n := 0;
+   if s =  'abc' then n := n + 1;   { literal on the RIGHT }
+   if s <> 'abc' then n := n + 2;
+   if s <  'abd' then n := n + 4;
+   if s >  'abb' then n := n + 8;
+   if s <= 'abc' then n := n + 16;
+   if s >= 'abc' then n := n + 32;
+   writeln(n);                      { 61: =, <, >, <=, >= }
+
+   n := 0;                          { literal on the LEFT }
+   if 'abc' =  s then n := n + 1;
+   if 'abd' >  s then n := n + 2;
+   if 'abb' <  s then n := n + 4;
+   writeln(n);                      { 7 }
+
+   n := 0;                          { BOTH operands literal.  Note '' is the
+                                      empty STRING while 'a' would be a CHAR,
+                                      so only string literals may appear here
+                                      (see t50 for the CHAR case). }
+   if 'abc' =  'abc' then n := n + 1;
+   if 'abc' <> 'abd' then n := n + 2;
+   if ''    <  'ab'  then n := n + 4;
+   if ''    =  ''    then n := n + 8;
+   writeln(n);                      { 15 }
+
+   s := 'ab';                       { and all four as VALUES }
+   writeln(s <  'abc', ' ', s >  'abc', ' ',
+           'ab' < 'abc', ' ', '' < 'ab')
+end.

+ 16 - 0
shell/tests/fixtures/t50_strcmp_type.pas

@@ -0,0 +1,16 @@
+{ t50_strcmp_type: comparing a string with a CHAR is a type error.
+
+  A one-character literal is a CHAR constant in ISO Modula-2 - not a string -
+  so `s = 'a'` mixes TString with TScalar and must be rejected.  The empty
+  literal '' is different: it is the zero-length STRING, which is why t49 uses
+  it.  This pins ParseCmp's mixed-operand guard, which is new: before string
+  comparison existed, this program failed later and with the wrong diagnosis.
+  Expected: ETypeErr (56) at the comparison. }
+program t50_strcmp_type (input, output);
+var
+   s : string[8];
+begin
+   s := 'abc';
+   if s = 'a' then
+      writeln('no')
+end.

+ 17 - 1
shell/tests/rt_exec.py

@@ -99,7 +99,7 @@ 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", "rdstr", "strstore"]
+        "wrtinl", "rdbyte", "wrstr", "rdstr", "strstore", "cmpstr"]
 
 # A call slot's flags, as rtdrv.s reads them.
 F_STACK = 1     # the argument goes on the stack, popped by the caller
@@ -586,6 +586,22 @@ def build_cases():
         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")
 
+    # --- cmpstr: string compare, AX := -1 / 0 / +1 for left <, =, > right.
+    # left is at [BP+6] and right at [BP+4] (the same order s2cx gives which
+    # string goes where), so this compares 'hello' against 'abcdefgh'.
+    #
+    # The sign is NOT checked here, and cannot be: a case record dumps MEMORY,
+    # and the driver does not capture AX after a call.  What this case can say
+    # is that the entry runs to RET without touching an operand - it is
+    # read-only - and check_bp_contract separately proves it hands BP back.  The
+    # SIGN is pinned by t48_strcmp and t49_strcmp_lit, which run whole compiled
+    # programs on qemu and print all six relations as booleans; a program is a
+    # better observer of a return value than a record with no field for one.
+    add("cmpstr compares 'hello' with 'abcdefgh' and writes neither",
+        [s2cx("cmpstr", SST_LONG + LOAD_BIAS, SST_HELLO + LOAD_BIAS, 20)],
+        out=b"", dump=6, dumpbytes=bytes([5]) + b"hello", dumpadr=SST_HELLO,
+        note="the dump is the LEFT operand ([BP+6]); it must come back unchanged")
+
     return cs
 
 

+ 54 - 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 51 03    mov bx,351h
+003C  BB A4 03    mov bx,3a4h
 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 56 03    mov dx,356h
+007A  BA A9 03    mov dx,3a9h
 007D  EB 03       jmp 82h
-007F  BA 51 03    mov dx,351h
+007F  BA A4 03    mov dx,3a4h
 0082  B4 09       mov ah,9h
 0084  CD 21       int 21h
 0086  5D          pop bp
 0087  C3          ret
-0088  BA 5F 03    mov dx,35fh
+0088  BA B2 03    mov dx,3b2h
 008B  B4 09       mov ah,9h
 008D  CD 21       int 21h
 008F  C3          ret
-0090  BA 5C 03    mov dx,35ch
+0090  BA AF 03    mov dx,3afh
 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 65 03 mov bx,word ptr [365h]
+01AF  8B 1E B8 03 mov bx,word ptr [3b8h]
 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 65 03 mov word ptr [365h],bx
+01BC  89 1E B8 03 mov word ptr [3b8h],bx
 01C0  C3          ret
 01C1  B4 08       mov ah,8h
 01C3  CD 21       int 21h
@@ -264,7 +264,7 @@
 01C6  88 C2       mov dl,al
 01C8  BB 01 00    mov bx,1h
 01CB  8A DA       mov bl,dl
-01CD  89 1E 65 03 mov word ptr [365h],bx
+01CD  89 1E B8 03 mov word ptr [3b8h],bx
 01D1  C3          ret
 01D2  55          push bp
 01D3  8B EC       mov bp,sp
@@ -340,3 +340,49 @@
 0245  89 EC       mov sp,bp
 0247  5D          pop bp
 0248  C3          ret
+0249  55          push bp
+024A  8B EC       mov bp,sp
+024C  53          push bx
+024D  51          push cx
+024E  52          push dx
+024F  31 C9       xor cx,cx
+0251  31 D2       xor dx,dx
+0253  8B 5E 06    mov bx,word ptr [bp+6h]
+0256  8A 0F       mov cl,byte ptr [bx]
+0258  8B 46 04    mov ax,word ptr [bp+4h]
+025B  8B F0       mov si,ax
+025D  8A 14       mov dl,byte ptr [si]
+025F  39 D1       cmp cx,dx
+0261  76 02       jbe 265h
+0263  8B CA       mov cx,dx
+0265  E3 0E       jcxz 275h
+0267  43          inc bx
+0268  46          inc si
+0269  8A 07       mov al,byte ptr [bx]
+026B  8A 14       mov dl,byte ptr [si]
+026D  3A C2       cmp al,dl
+026F  75 14       jne 285h
+0271  43          inc bx
+0272  46          inc si
+0273  E2 F4       loop 269h
+0275  8B 5E 06    mov bx,word ptr [bp+6h]
+0278  8A 0F       mov cl,byte ptr [bx]
+027A  8B 46 04    mov ax,word ptr [bp+4h]
+027D  8B F0       mov si,ax
+027F  31 D2       xor dx,dx
+0281  8A 14       mov dl,byte ptr [si]
+0283  39 D1       cmp cx,dx
+0285  B0 00       mov al,0h
+0287  B4 00       mov ah,0h
+0289  72 06       jb 291h
+028B  74 08       je 295h
+028D  B0 01       mov al,1h
+028F  EB 04       jmp 295h
+0291  B0 FF       mov al,0ffh
+0293  B4 FF       mov ah,0ffh
+0295  5A          pop dx
+0296  59          pop cx
+0297  5B          pop bx
+0298  89 EC       mov sp,bp
+029A  5D          pop bp
+029B  C3          ret

+ 364 - 0
v-TP3-STRCMP.md

@@ -0,0 +1,364 @@
+v-TP3-STRCMP — Session summary
+==============================
+
+Tag:    v-TP3-STRCMP
+Commit: (the commit that adds this file)
+Fixes:  the commit immediately before it, "tests: re-anchor three nonvacuity
+        mutations that item 1a invalidated" (see "Non-vacuity" below)
+Covers: v-TP3-STRCMP
+
+This is the resume point for the session that did "Next work" item 1b — string
+comparison with `= <> < <= > >=` — in full, and for the test-harness repair
+that running `tests/nonvacuity.sh` at the start of it turned up.  Every claim
+below is pinned by a fixture in `shell/tests/fixtures/expected.tsv` and by the
+gate suite.
+
+What changed
+------------
+
+1. String comparison — `= <> < <= > >=` with both operands strings
+   (`v-TP3-STRCMP`).
+
+   `ParseCmp` had one arm, the numeric one: both operands are loaded into
+   `AX`/`CX`, `CMP AX,CX` sets the flags, and a six-entry `SETcc` table turns
+   them into a 0/1.  A string operand never reached that arm — it fell through
+   to `LoadAtom`, which refuses a `TString` with `ENoLib` (102).  The new arm
+   is put **before** the constant-fold arm, because a string literal is not a
+   kind-0 constant and the numeric arm's own dispatch would have swallowed it:
+
+       IF (left.cls = TString) OR (right.cls = TString) THEN
+          IF (left.cls # TString) OR (right.cls # TString) THEN
+             Err (ETypeErr) ;         (* a string compared with a number/char *)
+             RETURN
+          END ;
+          EmPushStrAddr (left) ;
+          EmPushStrAddr (right) ;
+          DropC (EmCall (TU_CmpStr)) ;
+          EmAddSp (4) ;
+          EmOrAxAx () ;
+          EmRelSetcc (op) ;
+          r.kind := 2 ;
+          r.cls := TBool
+       ELSIF (left.kind = 0) AND (right.kind = 0) THEN
+          ... constant fold ...
+       ELSE
+          LoadPair (left, right) ;
+          EmCmpAxCx () ;
+          EmRelSetcc (op) ;
+          r.kind := 2 ;
+          r.cls := TBool
+       END
+
+   The tail is not a second implementation: `EmRelSetcc` is the same six-arm
+   table the numeric arm uses, factored out of `ParseCmp` when the string arm
+   arrived.  The two paths therefore cannot drift, which matters because the
+   table's arms are two hex digits apart (`SETG` 9FH vs `SETGE` 9DH) and were
+   swapped once already — see `EmRelSetcc`'s comment and `tests/nonvacuity.sh`'s
+   `mutate_cc_swap`, which mutates exactly those two lines and now breaks the
+   string comparison at the same time as the numeric one.
+
+2. A new runtime entry — `cmpstr`, selector 18 (`Runtime.mod`, `Runtime.def`
+   `E_CmpStr = 18`).
+
+   It takes two string *addresses* on the stack (caller-pushed, caller-popped)
+   and returns `AX = -1 / 0 / +1` for left < right / = / >.  83 bytes at image
+   offset 585 (`0249h`..`029Bh`):
+
+       0249  55          push bp            024F  31 C9   xor cx,cx
+       024A  8B EC       mov bp,sp          0251  31 D2   xor dx,dx
+       024C  53          push bx            0253  8B 5E 06 mov bx,[bp+6]
+       024D  51          push cx            0256  8A 0F   mov cl,[bx]
+       024E  52          push dx            0258  8B 46 04 mov ax,[bp+4]
+                                           025B  8B F0   mov si,ax
+                                           025D  8A 14   mov dl,[si]
+                                           025F  39 D1   cmp cx,dx
+                                           0261  76 02   jbe 0265h
+                                           0263  8B CA   mov cx,dx
+                                           0265  E3 0E   jcxz 0275h
+                                           0267  43      inc bx
+                                           0268  46      inc si
+                                           0269  8A 07   mov al,[bx]
+                                           026B  8A 14   mov dl,[si]
+                                           026D  3A C2   cmp al,dl
+                                           026F  75 14   jne 0285h
+                                           0271  43      inc bx
+                                           0272  46      inc si
+                                           0273  E2 F4   loop 0269h
+                                           0275  8B 5E 06 mov bx,[bp+6]
+                                           0278  8A 0F   mov cl,[bx]
+                                           027A  8B 46 04 mov ax,[bp+4]
+                                           027D  8B F0   mov si,ax
+                                           027F  31 D2   xor dx,dx
+                                           0281  8A 14   mov dl,[si]
+                                           0283  39 D1   cmp cx,dx
+                                           0285  B0 00   mov al,0
+                                           0287  B4 00   mov ah,0
+                                           0289  72 06   jb 0291h
+                                           028B  74 08   je 0295h
+                                           028D  B0 01   mov al,1
+                                           028F  EB 04   jmp 0295h
+                                           0291  B0 FF   mov al,0FFh
+                                           0293  B4 FF   mov ah,0FFh
+                                           0295  5A      pop dx
+                                           0296  59      pop cx
+                                           0297  5B      pop bx
+                                           0298  8B E5   mov sp,bp
+                                           029A  5D      pop bp
+                                           029B  C3      ret
+
+   The layout is deliberate and each part is worth naming:
+   - `min(lenL,lenR)` bytes are compared in order; the **first difference
+     decides** (`jne cs_flag`).  The byte compare is `CMP AL,DL`, i.e.
+     **unsigned** — CF is the borrow out of AL−DL — which is what
+     "lexicographic" means for characters.
+   - If the common prefix is equal, the **lengths decide** (`cs_len` re-reads
+     them; `cmp cx,dx` then falls into `cs_flag`).  So a proper prefix is
+     **less** than the string that continues it: `'ab' < 'abc'`, and the empty
+     string is less than everything.
+   - The lengths are re-read from the arguments rather than held in registers
+     across the loop, so `CX` is free to be `LOOP`'s count.  `JCXZ` skips the
+     loop when `min = 0`: without it, `LOOP` with `CX = 0` runs 65536 times.
+   - `CmpAlDl` is `3A C2`, not `38 C2`.  `/r` puts the register field where the
+     *left* operand is, so `3A C2` is `CMP AL,DL` and `38 C2` would be
+     `CMP DL,AL`, i.e. the same bytes with the flags negated.
+
+3. Harness re-baselines, all in this commit, because the runtime entry moves
+   offsets and the fixtures add files:
+
+   - `tests/RtProbe.mod`: the entry table widened from `[0..17]` to `[0..18]`,
+     plus `names[18]`.
+   - `tests/check_runtime.py`: `GOLDEN["cmpstr"] = "55 8B EC 53 51 52"` (the
+     prologue minus `AX`, which is the result and is not saved).
+   - `tests/runtime.golden`: re-blessed.  **The diff is worth reading** and is
+     exactly two things: eight absolute data-address fixups, each `+0x53`,
+     because the data area starts after the code and the code grew by 83 bytes
+     (`0x53`); and the appended `cmpstr` block at `0249h`.  No existing
+     instruction moved.
+   - `tests/rt_exec.py`: `ENTS` gains `cmpstr`, plus a read-only smoke case.
+   - `tests/check_8086.py`: `BRANCH_SITES["t48_strcmp"] = [4] * 30` and
+     `["t49_strcmp_lit"] = [4] * 13`, with `BRANCH_WHY` text.
+   - `tests/audit_helpers.py`: `"PushStrAddr": COMP` in `Compiler.mod`'s
+     `NON_ONE_LINE` — see "Scope decisions" below.
+   - `tests/fixtures/expected.tsv`: three new rows (below).
+
+4. `tests/check_runtime.py`'s branch scan learned `LOOP`.  `RE_BRANCH` was
+   `^(?:j\w+|call|jmp)\s+...$`, so it saw every `Jcc`, `JMP` and `CALL` — but
+   **not** `LOOP`.  `LOOP` is a relative branch too, and its target has to land
+   on an instruction boundary like any other.  The runtime already contained
+   **four** `LOOP`s when this was found (they are much older than this
+   session); `cmpstr` added a fifth, and only then did the gap come into view.
+   Widening the regex moved the checked-branch count from 57 to 62 — +5, one
+   per `LOOP`.
+
+The bug that only execution could see
+-------------------------------------
+
+The first `cmpstr` epilogue saved and restored **`AX`** with the other
+registers:
+
+    PushAx ; PushBx ; PushCx ; PushDx  ...  PopDx ; PopCx ; PopBx ; PopAx
+
+That is wrong, and wrong in a way no static check can see: `AX` is the
+*result*, so there is nothing to save, and popping it back restores the
+**caller's** `AX` — which, for `a < b`, is the address of the *right* operand,
+still in `AX` from its `LEA`.  Every ordering comparison then read a positive
+number: `=` and `<>` were correct (they test zero), while `<`, `<=`, `>`, `>=`
+over any `left < right` string came back as `FALSE TRUE TRUE TRUE FALSE FALSE`.
+
+Nothing about the bytes is malformed, the size is unchanged, every branch
+target is still on a boundary, and the whole compile matrix stayed green.  It
+was caught only by *executing* `t48`, and confirmed by hand on qemu.  The fix
+is the epilogue it has now — `PopDx ; PopCx ; PopBx ; MovSpBp ; PopBp ; RetR` —
+and the comment in `EmitCmpStr` records it so the next reader does not have to
+rediscover why `AX` is the one register this entry does not preserve.
+
+Semantics: what a comparison of strings MEANS here
+--------------------------------------------------
+
+- **What the compiler compares is the sign of `−1/0/+1`, and that test is
+  SIGNED.**  `cmpstr` does the character work with *unsigned* byte compares
+  (that is TP3's `cmpstr` / `vcompare`, TPSRC2 926-962 / TPSRC9 133), but the
+  compiler only ever sees the signed result and runs the same signed `SETcc`
+  table the numeric arm uses.  Signed is load-bearing: `−1` is `0FFFFh`, so
+  `SETB` (unsigned below) would be *false* on `−1` and every `a < b` would come
+  back `FALSE`.  `SETL`/`SETG`/`SETGE`/`SETLE` are the right four.
+- **Prefix rule.**  A shorter string that is a prefix of a longer one is
+  **less**: `'ab' < 'abc'`, `'' < 'ab'`.  This is the tiebreak, and `t48` pins
+  both directions as separate blocks.
+- **`1`-character literal is a `CHAR`, not a string.**  ISO Modula-2 makes `'a'`
+  a character constant, so `s = 'a'` is `TString` vs `TScalar` and is
+  `ETypeErr` (56) — `t50`.  The *empty* literal `''` is different: it is the
+  zero-length **string**, and it works (`t49` uses it).  This is the same rule
+  `c := 'a'` already relied on.
+- **A string compared with a number or a character is `ETypeErr`.**  The
+  mixed-operand guard is new; before string comparison existed the program
+  failed later and with the wrong diagnosis.  `t50` pins the new one.
+
+Fixtures added
+--------------
+
+    t48_strcmp        OK   1905  24   string = <> < <= > >=, both operands
+                                      string variables.  Output: 41, 14, 50,
+                                      14, 50, then the six as VALUES:
+                                      FALSE TRUE TRUE TRUE FALSE FALSE
+    t49_strcmp_lit    OK   1006  15   the same operators with a string
+                                      LITERAL on one or both sides, including
+                                      the empty literal.  Output: 61, 7, 15,
+                                      then four as VALUES: TRUE FALSE TRUE TRUE
+    t50_strcmp_type   ERR  56    600  `s = 'a'`: a one-character literal is a
+                                      CHAR, so this is the mixed-operand error
+
+`t48`'s output is deliberately **not** a count of the six operators.  Each
+block weights them 1,2,4,8,16,32 for `=,<>,<,<=,>,>=`, so the printed sum is a
+signature of *which* ones held (`41` = `=`,`<=`,`>=`; `14` = `<>`,`<`,`<=`;
+`50` = `<>`,`>`,`>=`).  A plain `+1` cannot do this: for any unequal pair
+`[a<b]+[a<=b]+[a>b]+[a>=b]` is always 2, so `[a<b]` and `[a>b]` cancel and the
+first version of this fixture reported `3,3,3,3` while **every** ordering
+comparison was inverted.  The weights are what made the `AX`-restore bug
+visible as a wrong *number* instead of a plausible one.
+
+Scope decisions and findings
+----------------------------
+
+- **`AX = −1/0/+1`, not flags.**  The sketch in `v-TP3-STRIDX-VAR.md` said "a
+  new runtime entry taking two addresses and leaving flags set is enough
+  because `EmSetcc` reads live flags".  That is not enough *here*, and the
+  reason is in `EmitCmpStr`'s comment: this caller pushes two addresses and
+  must drop them with `ADD SP,4`, which is in the CPU's arithmetic group and
+  **destroys the flags**.  TP3 can branch on flags straight after its `cmpstr`
+  because TP3's `cmpstr` removes its own arguments (`MOV SP,BX`, TPSRC2 960),
+  so there is no `ADD SP` to run.  Returning a value in `AX` costs a few
+  instructions inside the entry and keeps the flag convention entirely inside
+  the compiler, where the one `EmRelSetcc` table already lives.
+- **`OR AX,AX` (`09 C0`), not `CMP AX,0` (`3D 00 00`).**  Same flags (SF/ZF set,
+  OF/CF cleared), but `tests/check_8086.py` reads every `3D 00 00` in the
+  program region as an IF/WHILE/REPEAT test and requires a branch immediately
+  after it — and `writeln (a = b)` is a comparison used as a **value**, not a
+  test.  `TEST AX,AX` (`85 C0`) is equivalent but has no mnemonic in the
+  audit's name grammar, so `EmOrAxAx` it is.  (A TP3 wrapper is literally
+  `MOV AX,#1 / Jcc / DEC AX / OR AX,AX`: the same `SETcc` our numeric arm emits
+  followed by the same `OR AX,AX`.  This design moves that tail out of six
+  per-operator runtime entries and into the one shared table.  TP3 needs the
+  six because its `cmpstr` is reached through `vcompare`'s vector table by
+  type; we already have the operator as a number.)
+- **String literals travel inline in the code stream.**  A literal has no
+  storage, and a `.COM` image carries no initialised data (globals are left to
+  the loader's zero fill), so `EmPushStrAddr` emits `JMP` over the bytes —
+  `EB rel8` (rel8 = len+1) or `E9 rel16` when `len+1 > 127` — then a length
+  byte, then the characters, and pushes the address of the **length byte**
+  (`LEA AX,[here]; PUSH AX`, absolute into CS, which is where a `.COM`'s DS
+  already is).  This mirrors TPSRC9 `fullvar` (993), with **one extra byte**:
+  TP3's constant is an `ARRAY [1..n] OF CHAR` and `estr2` (TPSRC10 85) emits it
+  *without* a length byte because the length travelled on the stack instead.
+  Every string in this runtime — variable or literal — is `<len><chars>`, which
+  is what `rdstr` writes and what `cmpstr` reads, so a literal operand has to
+  look the same.  The jump exists only to keep the CPU from walking into the
+  characters.
+- **Argument order is left first, right second** — left at `[BP+6]`, right at
+  `[BP+4]` — the same order `strstore` takes its two.
+- **The audit's `Em*` naming rule bit again.**  `audit_helpers.py` strips the
+  first two characters of every emitter's name to find it in the grammar, so a
+  `Compiler.mod` emitter whose name does not begin `Em` becomes a name the
+  grammar has never heard of.  `PushStrAddr` would have stripped to
+  `shStrAddr`; it is `EmPushStrAddr`, a proper sibling of `EmPushVarAddr`.  It
+  also needed `"PushStrAddr": COMP` in `NON_ONE_LINE["Compiler.mod"]` (it has a
+  parameter list, which the by-name audit excludes) — the same rule that bit
+  `LeaDi` last session, documented in `audit_helpers.py`'s header.
+
+Non-vacuity: three mutations that had rotted, and a fifth `LOOP`
+--------------------------------------------------------------
+
+`tests/run_all.sh` says to run `tests/nonvacuity.sh` directly after touching
+any of the checks, and it was red when this session ran it: **75 ok, 3 failed**.
+All three were pre-existing — they fail at the previous tag too — because
+`v-TP3-STRIDX-VAR` (item 1a, `s [i]`) changed the code the mutations match.
+
+`SUMMARY.md` at that same tag already claimed `non-vacuity: 78 ok, 0 failed`.
+The number of cases (78) was right; the "ok, 0 failed" was not, and one of the
+three failures (`EmBpDisp`) was of exactly the kind this file exists to catch —
+a mutation that had *silently stopped applying*, so the check it was supposed
+to prove non-vacuous was passing without being disturbed at all:
+
+  1. **`check_framedisp` / `EmBpDisp`.**  The mutation restored the original
+     `off MOD 100H`, always-disp8 bug by matching the old body verbatim.  Item
+     1a gave `EmBpDisp` a `regf` parameter (the DI forms need reg 7), so the
+     anchor no longer matched; the mutation silently applied *nothing*, the
+     compiler rebuilt unmutated, and the check passed — reported as
+     `NOT NON-VACUOUS`.  Both `EmBpDisp` mutations are re-anchored, and both
+     keep `regf`: the mutation is about the disp8/disp16 choice, and breaking
+     the DI forms at the same time would be two faults wearing one label.
+  2. **`SaveLeft`.**  The mutation made the park unreachable by matching the
+     old kind-2 body.  Item 1a added an `ELSIF left.kind = 5` branch, so the
+     anchor missed and the case was reported `BROKEN CASE`.  It now disables
+     **both** guards, asserting each is unique — `IF left.kind = 2` reads like
+     it can only be `SaveLeft`'s, but other routines branch on kind too.
+  3. **The string-assignment arm.**  The mutation spliced the arm out between
+     its guard and the `size > 2` refusal that follows it.  Item 1a added
+     `AND (r.cls = TString)` to that guard (so the new `s[i] := c` element arm
+     is not swallowed by the whole-string one) and gave `ParseSub` a look-alike
+     `IF symtab [r.idx].cls = TString THEN`.  The shorter anchor therefore found
+     `ParseSub`'s, ~800 lines earlier, and the splice deleted everything
+     between the two — the compiler would not rebuild.  The guard is now
+     spelled out in full.
+
+Each repair was checked by hand: the truncation case again reports its named
+message (`no 8B access at [BP+128]`, offsets seen include `−128`), the
+left-operand case again makes `t34_arith` print `FAIL`, and the
+string-assignment case again makes `t39` red.  The suite is **78 ok, 0 failed**.
+
+How to verify this session's work
+---------------------------------
+
+    cd shell && make                               # tpshell 195576 bytes, rc=0
+    cd shell && tests/run_all.sh                   # expect: OVERALL: ALL PASS
+    cd shell && tests/nonvacuity.sh                # expect: 78 ok, 0 failed
+
+At this tag the suite reports:
+
+    compile matrix: 51 passed, 0 failed (of 51)
+    independent .COM check: 45 checked, 0 failed
+    execution: 45 passed, 0 failed (of 45)
+    exec86:    45 passed, 0 failed (of 45), cross-checked against qemu
+    audit_helpers: 112/112 agree (was 110)
+    runtime entries: 52 passed, 0 failed (of 52)
+    check_8086: 39 comparison sites, 35 lowered to a Boolean value, 57 to a
+                branch; clause H 10 of 10; 45 of 45 fixtures swept, 8576 bytes
+    runtime: 699 bytes, code 0..667 (668), 19 entries, 62 branches checked
+             (was 616 bytes, 18 entries, 51 branches: +6 = cmpstr's Jcc/JMP,
+              +5 = the five LOOPs the widened branch scan now sees)
+    nonvacuity: 78 ok, 0 failed (was 75 ok, 3 failed)
+    OVERALL: ALL PASS
+
+Each new behaviour was proven red before green: with the old `ParseCmp`, `t48`
+and `t49` both report `ERR 102` (`ENoLib`, "not implemented") at the first
+comparison, and `t50` fails with the *old* (wrong) diagnosis rather than
+`ETypeErr`.  The `AX`-restore bug is the other kind of red — every byte and
+every size green, and only `t48`'s weighted sums show it.
+
+Next work
+---------
+
+Item 1 (the rest of the string language):
+
+  c. **Concatenation `+`** — the last piece of item 1, and the hardest.
+     TP3's `addstr` (TPSRC9 194) reaches `xconcat` (TPSRC2 963); `addch` /
+     `addch2` convert a one-character operand with `MOV AH,AL ; MOV AL,#01`
+     then `PUSH AX` — *that* is how TP3 makes `s + 'a'` legal, where our
+     compiler currently treats a one-character literal as a `CHAR` (the same
+     rule `t50` pins).  TP3 pushes strings inline on the stack; this model
+     addresses them, so a result needs a scratch string and a length rule
+     (TP3 caps at 255).  This adds a runtime entry again, so `runtime.golden`,
+     `check_runtime.py`, `rt_exec.py` (next selector is 19) and the branch
+     count all move with it.
+
+Item 2 (the large one) is untouched: nested procedures / recursion, `var`
+parameters (the `SEG:OFF` push, RESUME-TP3.md 3.11), range/index checks
+(`TU_RANGE_CHECK`, `TU_INDEX_CHECK`), typed constants (3.14), `array` at its
+point of use (t14), `case` with subrange labels.
+
+Also still open from before this session:
+
+  d. Harden the program-header parameter loop against non-advancing input
+     (`program p(1;)`) with a `BOOLEAN` flag — NOT `EXIT`, which ICEs gm2.
+  e. FreeDOS (`freedos.qcow2`, FD14-LiveCD) is still untried; it is the only
+     third opinion on the `INT 21h` shim.