1
0

2 İşlemeler 5b8e928c6c ... 4ba05b34bd

Yazar SHA1 Mesaj Tarih
  Eric Streit 4ba05b34bd tests: one definition of the .COM image layout, and a sweep region that is measured 1 hafta önce
  Eric Streit 842dcff3a8 TP3-CMDRUN: the R key runs the image in-process, with a second oracle 1 hafta önce

+ 8 - 0
.gitignore

@@ -12,3 +12,11 @@ shell/tests/rtprobe
 # at different times, which is the same drift as a duplicated table.
 shell/rtprobe.lst
 shell/rtp.lst
+shell/tests/e86.lst
+# the second execution oracle's driver binary: built by tests/run_exec86.py
+# from tests/Exec86Run.mod, exactly like comtest is built by the matrix.
+shell/exec86run
+# scratch: ad-hoc logs, probe sources and the backups nonvacuity.sh takes of
+# the UNTRACKED files it mutates, so a run that dies mid-mutation can still
+# put them back.
+tmp/

+ 305 - 67
SUMMARY.md

@@ -24,7 +24,8 @@ manual, not guessed.
 | Execute the nine fixtures that only compiled | `v-TP3-DEAD-FIXTURES` | done, **30/30 run; four operator/scoping bugs found** |
 | Runtime entries under qemu, and the register contract stated | `v-TP3-BP-CONTRACT` | done, **36/36 entry checks; `wrchar`/`wrbool` no longer destroy BP** |
 | 8086-legal conditional branches and `SETcc` | `v-TP3-8086-LOWERING` | done, **the emitted code no longer contains an opcode the 8086 lacks** |
-| `CmdRun` (the `R` key), in-process 8086 interpreter | — | **not started** |
+| `CmdRun` (the `R` key) + `Exec86`, the in-process 8086 interpreter | `v-TP3-CMDRUN` | done, **a second execution oracle: 33/33 fixtures agree with qemu byte for byte, and `R` runs one inside the shell** |
+| Image layout in one file; a check's sweep region measured, not restated | `v-TP3-IMAGELAYOUT` | done, **every reader of a linked `.COM` imports `tests/comimage.py`, and the region `check_framedisp` sweeps is two readings of the file required to agree** |
 
 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,6 +72,15 @@ 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.
 
+**Scratch and logs live in `TP3-comp/tmp/`, beside the tree that produced
+them** — never in `/tmp`. Every test that writes a build log, a saved copy of a
+mutated source, a probe binary or a corpus puts it there (`../tmp/`, since the
+shell scripts `cd` into `shell/` first), and the folder is gitignored, so a
+failed run's evidence sits next to the code it describes and cannot be
+committed by accident. What still touches the system temp area is only the
+per-run working directory that `tempfile.mkdtemp` / `mktemp -d` creates — an
+anonymous tree, not a named file anyone goes back and reads.
+
 **`shell/build_tpshell.sh` is now a three-line wrapper around `make`**, and
 that is a fix, not a refactor. It used to be a second hand-maintained copy of
 the recipe, and a copy of a build recipe drifts — this one was wrong twice
@@ -85,6 +95,8 @@ knowledge of its own.
 ## What is verified, and how
 
 Nothing here is "it compiles clean" — each claim below comes from a run.
+`tests/run_all.sh` runs every check below in one pass and prints
+`OVERALL: ALL PASS`, or it prints which one did not.
 
 ### Compiler front end — `shell/tests/run_compile_tests.sh`
 
@@ -104,13 +116,14 @@ pins the verdict *and the numbers* per fixture — verdict plus code size plus
 data size, or error number plus position — and the runner compares. A wrong
 error position or a program that lost six bytes now fails the suite instead of
 needing a squint. It was checked for vacuousness by reverting the string
-scanner fix: 19/23 and exit 1, restored: 23/23 and exit 0.
+scanner fix: the suite went red with exit 1, and came back green only when the
+fix was restored.
 
-**30 of 33 fixtures compile clean**, up from 1 (the empty program) when the
+**33 of 36 fixtures compile clean**, up from 1 (the empty program) when the
 direct harness was first built.
 
 ```
-compile matrix: 33 passed, 0 failed (of 33)
+compile matrix: 36 passed, 0 failed (of 36)
 ```
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -123,17 +136,20 @@ Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
 `t27` five locals · `t28` a 70-parameter declaration · `t29` `readln` from a
 supplied input file · `t30` a counted `for` whose bounds come from an
 expression · `t31` a value parameter *and* a call across statements ·
-`t32` `EXIT` out of a `for` body.
+`t32` `EXIT` out of a `for` body · `t33` all six comparisons · `t34` the
+operators nothing else uses (`div mod and or`, unary `-`, a variable `*`) ·
+`t35` `not`, both halves of TPSRC9's `neglevel` split.
 
 Failing, all deliberately: `t14` `array [1..5] of integer` at its point of use
 and `t25` a string literal used as a *value* (`s := 'hi'`) both → `ENoLib`
 (102), the original's "not implemented" path; `uierror` is a deliberate syntax
 error (41) used by the UI test.
 
-`run_com_tests.sh` additionally links **all 30 that compile** to a real `.COM`
+`run_com_tests.sh` additionally links **all 33 that compile** to a real `.COM`
 and re-verifies the bytes with an independent Python checker that *measures*
-the layout instead of restating it: `30 checked, 0 failed`. That last part was
-itself a bug fix — see "the two restated constants" below.
+the layout instead of restating it: `independent .COM check: 33 checked, 0
+failed`. That last part was itself a bug fix — see "the two restated
+constants" below.
 
 ### Shell + editor UI — `shell/tests/uitest.py`
 
@@ -156,7 +172,7 @@ changing `Run`'s signature, so `Editor.def` stays additive.
 feature: the latter move as the compiler grows, and a test whose
 expectations drift with it stops being a test.
 
-### The encodings — six checks that can each go red
+### The encodings — checks that can each go red
 
 Nothing above looks at *machine code*. It all stops at "the compiler produced
 what it intended to produce", which is exactly where the bugs in this project
@@ -170,10 +186,12 @@ proves each one can go red.
 |---|---|---|
 | `probe/run_modrm19.py` | the mod=00/01/10 effective addresses, by **executing** 23 cases on a real 8086 under qemu and scanning for where the marker landed | mod=11 — see below |
 | `probe/modrm11.py` | the mod=11 register identities, by **encoding** with GNU `as` and decoding with FCML, against hard-coded bytes | the table agreeing with itself |
-| `audit_helpers.py` | every one-line emitter in **both** `Runtime.mod` and `Compiler.mod` decodes to what its *name* says — **100/100**, 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 — **101/101**, 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 (436 bytes, code ends at 405) 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, 33/33 | 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, 35 cases plus a pre-flight, 36/36 — 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
@@ -231,17 +249,33 @@ entire 16-bit range as a signed value. The check also asserts the *rule* rather
 than one encoding — always-disp16 is accepted, and `nonvacuity.sh` proves that
 by building it and requiring the check to stay green.
 
+It had a **second, unrelated blind spot of its own**: the region it swept came
+from `RT_SZ = 391`, a runtime size that had drifted, so the sweep began inside
+the runtime, part way through an instruction — and the check passed anyway,
+because the byte patterns it searches for are in the image wherever they happen
+to be and a decode starting mid-instruction happened to reach the same offsets.
+Nothing in the check could see where its own region began, which is a wrong
+input that produces the right answers until the layout moves. The region is now
+**two independent readings of the file** — where the entry jump says execution
+starts, where the program header says the runtime ends — required to agree
+before a single byte is swept, with `tests/comimage.py` supplying both readings
+and `tests/check_comimage.py` asserting there is only one copy of each.
+
 ### Execution under qemu — `tests/run_com_exec.py`
 
-The sixth check is the one that cannot be written as a byte comparison, so it
-is also the one that finds the most: 31 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`.
+The check that cannot be written as a byte comparison, so also the one that
+finds the most: 33 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: 31 passed, 0 failed (of 31)
+execution: 33 passed, 0 failed (of 33)
 ```
 
+This is no longer the only execution oracle: `run_exec86.py` below runs the
+same 33 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
 *every* program — while the compile matrix and the `.COM` layout check were both
 perfectly happy. That is the third time in a row that a fault passed every
@@ -251,7 +285,8 @@ The two fixtures it found nothing in are the interesting ones: `t31_procparam`
 and `t32_forexit` were the two most expensive bugs in the project, and neither
 was visible as a wrong byte count. See `overProc` below.
 
-**The `.COM` layout constants are measured, not restated.** `run_com_tests.sh`
+**The `.COM` layout constants are measured, not restated — and now live in one
+file.** `run_com_tests.sh`
 and `comtest.py` both used to hard-code `RT_SZ = 391` against a runtime that
 had since grown to 432 bytes at the time, so they read the program header 41 bytes early
 and reported **30 false failures** — a red suite that meant nothing, which is
@@ -266,20 +301,30 @@ measured runtime size: 436 bytes (header at image offset 439)
 
 A restated constant that has drifted is worse than a derived one, and the two
 checkers had drifted from each other as well as from the runtime — which is why
-there are two of them and why both were wrong in the same way.
+there were two of them and why both were wrong in the same way.
+
+So the layout itself moved into **`tests/comimage.py`**, and the third reader
+joined the first two. Four places now read a linked image — `comtest.py`, the
+independent checker inside `run_com_tests.sh`, `check_framedisp.py` and
+`check_8086.py` — and none of them says what a `.COM` looks like; they import
+it. `tests/check_comimage.py` is the assertion that this stays true: one
+definition of each part, imported by every reader, no second `find_header`
+anywhere in `tests/`. Its scope is stated in its own docstring, because it
+cannot see a check that re-derives the layout from inline numbers — that is what
+the readers do instead, each stating the layout twice from two different
+sources and refusing to proceed when the two disagree.
 
 ### 8086 legality — `tests/check_8086.py`
 
-The twelfth and newest check, and the only one that asks a question about the
-*target* rather than about the compiler: **does the 8086 have this instruction
-at all?**
+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: 26 comparison sites, 22 lowered to a Boolean value, 13 lowered to a branch
-            value conditions  : = x2  <> x2  < x5  >= x3  <= x2  > x8
+8086 check: 35 comparison sites, 31 lowered to a Boolean value, 13 lowered to a branch
+            value conditions  : = x6  <> x2  < x5  >= x3  <= x2  > x13
             branch conditions : IF / REPEAT x9  CASE x2  FOR downto x1  FOR to x3
             runtime: 436 bytes, 219 swept, 0 0F-prefixed
-            program code: 28 of 31 fixtures swept end to end, 2257 bytes
+            program code: 30 of 33 fixtures swept end to end, 2897 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
 ```
@@ -320,22 +365,105 @@ through were the same hole. **H** pins, per fixture, the conditions its branch
 sites declare, read off the `.pas` sources; its need was *measured* (mutation M5
 came back green before H existed) rather than anticipated.
 
-**What it does not do.** It cannot assert on the CASE arm's label immediate, and
-it never executes anything: it proves the opcodes are 8086 and that conditions
-are attached to the right constructs, but the control flow those bytes produce
-is still only checked by qemu on a 486. An in-process 8086 interpreter is the
-only thing that would close that, which is why `CmdRun` is next.
+**What it does not do.** It cannot assert on the CASE arm's label immediate,
+and it never executes anything: it proves the opcodes are 8086 and that
+conditions are attached to the right constructs, but the control flow those
+bytes produce is a question for execution — and until this milestone it could
+only be asked of qemu, on a machine with no 8086 model. It is now asked twice.
+
+### The second execution oracle — `tests/run_exec86.py`
+
+`shell/Exec86.mod` is a flat 8086 interpreter over the linked image, and this
+check runs every fixture through it as well as through qemu and requires the
+two to agree **byte for byte**:
+
+```
+exec86: 33 passed, 0 failed (of 33), cross-checked against qemu
+```
+
+Three assertions per fixture, in order of how much they are worth: the output
+matches the hand-derived `.out` exactly; the output matches what qemu printed
+for the same bytes; and the guest left through `INT 21h AH=4Ch` with code 0.
+The `.out` files are written from Pascal's semantics and are never blessed
+from a machine's output — `run_exec86.py` has no `--rebless` at all — so
+agreeing with qemu is a **third** opinion, not a second vote on the same one.
+
+It exists because the 8086-legality check could not close its own gap: qemu's
+lowest CPU model is a 486, where `0F 84` is an ordinary `JZ`, so an oracle
+built on qemu is structurally blind to that class of fault in *either*
+direction. This one was written against the 8086's own reference instead, and
+`nonvacuity.sh` proves it can go red on its own: inverting `JE` inside its
+`Cond` swaps the two arms of every `=` in every program, while the emitted
+bytes, the sizes and every byte-level check stay green — and qemu, executing
+the unchanged image, still agrees with itself.
+
+What it does not do: `FLAGS` are never written back, `PF`/`AF` are not
+maintained (`JP`/`JNP` fault saying so rather than answering a value nobody
+computed), no string instruction exists, a non-zero segment register faults,
+and execution outside the loaded image faults. Each is deliberate; `Exec86.mod`'s
+header states the measured instruction set it implements and refuses to guess
+past it, because an interpreter that guesses does not fail, it answers.
+
+### The `R` key, end to end — `tests/runtest.py`
+
+The only check that exercises `CmdRun`, and the only one whose evidence comes
+from a program nobody here has read: drive the shell through a pty, `W` to load
+`t34_arith`, `R`, and compare the **guest's own output** against that fixture's
+hand-derived `.out`.
+
+```
+UI TEST (R): t34_arith.pas
+------------------------------------------------------------
+R reported a successful compile                            PASS
+R reported poking the image at 0100h                       PASS
+guest ran to a clean AH=4Ch exit with code 0               PASS
+R reported a non-zero step count                           PASS
+guest output matches the hand-derived .out                 PASS
+ESC after the run returned to the main menu                PASS
+shell exited cleanly (status 0)                            PASS
+R wrote no .COM (it runs the image where it already is)    PASS
+------------------------------------------------------------
+child: EXIT 0
+RESULT: ALL PASS
+```
+
+The last assertion is the one no other check can make: `R` writes **no file**,
+so the `.COM` on disk must be exactly as it was before — nothing else in this
+project observes `R` at all. It is proved able to fail by neutering the poke
+loop's count: with `n = 0` nothing is copied in, `Run86` faults on its first
+step (*execution left the loaded image*), and the guest's `AH=4Ch` assertion
+goes red before a single instruction has run.
 
 ### Non-vacuity — `tests/nonvacuity.sh`
 
-Every assertion in this file is proved able to fail: **44 deliberate
+Every assertion in this file is proved able to fail: **62 deliberate
 breakages, each asserted to turn exactly one named check red for the stated
-reason, then restored and re-asserted green.** Six break the runtime, five
-attack the mod=11 table (including restoring the exact wrong table this
-project once shipped), three target `EmBpDisp` — the truncation, the
-always-disp16 over-encoding that must *stay* green, and the restored source —
-six attack the helper audit, five attack the `.COM` layout checker, and five
-attack the 8086 lowering.
+reason, then restored and re-asserted green** — `non-vacuity: 62 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
+`Compiler.mod`, the BP contract, the `.COM` layout checker, the image-layout
+helper and the three checks that read it, the 8086 lowering, the interpreter
+itself, and the `R` key.
+
+Eight arrived with the previous milestone (`v-TP3-CMDRUN`), and each one is the
+same shape: code that compiled clean and passed every byte-level check, until it
+was **run**. Two are behavioural (below), two come from the new interpreter, two
+pin the two new grammar rows the helper audit gained for `EmXorAl01`, and two
+are the `R` key's mutation and its restored green.
+
+Ten arrived with this one, and they are a different shape: **a check whose own
+input had never been verified.** `check_framedisp` swept a region built from a
+literal that had drifted, and the region is now two independent readings of the
+file required to agree — so the first three cases break each reading in turn:
+the entry jump answered with the old literal, and the header's own equation one
+byte out, seen red by **two different readers** with two different sentences.
+The next four break the single copy itself — a `find_header` copied out of
+`comimage.py`, a layout constant copied out of it, the definition deleted (which
+must be a red report rather than a silent green one, because a rule that matches
+nothing looks exactly like a rule that passes), and a caller that reaches the
+helper through another consumer instead of through `comimage`. The last three
+are restored greens, one per reader.
 
 That last group is the newest and the least optional. Two of its five
 (`M4`, `M5`) invert the branch polarity, which is a *legal* 8086 opcode
@@ -391,7 +519,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 30/30 now.
+that tag and 33/33 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
@@ -607,12 +735,60 @@ audit reads the *name*. The emitters are now `MovAlArg2`/`MovAlArg4` and
 it: a helper called `CmpArg4W0` that emitted the `+2` form fails the run with
 *"step 2: displacement is 2, name says 4"*.
 
-### Still missing: `CmdRun`
+### The in-process interpreter — `shell/Exec86.mod`, and the `R` key
 
-The `R` key of the shell is still unimplemented. TP3's `R` runs a `.COM`
-*in place*, without the DOS loader, from the same 64 KB of memory; the
-honest way to do that is a small in-process 8086 interpreter over the
-image, cross-validated against qemu on the same bytes.
+TP3's `R` runs a `.COM` *in place*, without the DOS loader, from the same
+64 KB DOS would give it. `Exec86` is that machine: a flat 8086 over
+`ARRAY [0..65535] OF CARDINAL` of bytes (one element per byte), with three
+entry points and nothing else:
+
+```
+Clear86                        wipe all 64 KB; AX..DI := 0; SP := 0FFFEh;
+                               IP := 0100h; segments := 0; flags cleared;
+                               loadHi := 0100h
+Poke86 (addr, value)           mem[addr] := value MOD 256, and raise loadHi
+Run86 (VAR exitCode : CARDINAL;
+       VAR steps : LONGCARD) : CARDINAL
+                               0 = the guest halted through INT 21h AH=4Ch,
+                               1 = fault, 2 = the step limit was reached
+```
+
+`CmdRun` does what the original's `krungo` does with no loader: `Compile` →
+`Clear86` → poke `LinkSize()` bytes at `0100h` → `Run86` → report the status
+and the step count → `WaitEsc`. **No file is written**, so `R` is identical
+with Destination = Memory and Destination = `.COM`, and the guest's own
+`AH=4Ch` exit code is printed rather than discarded.
+
+The guest writes to this process's `fd 1` through the runtime's `INT 21h`
+`AH=02`/`AH=09`/`AH=08`, so its output lands between the two status lines
+instead of being collected and replayed: `Term` writes one byte per `write(2)`
+and buffers nothing, which is why the two streams stay in order.
+
+**Why `Clear86`/`Poke86`/`Run86` rather than `Clear`/`Poke`/`Run`.** ISO
+Modula-2 has no import renaming (`FROM M IMPORT x AS y` is rejected) and no
+procedure-local import, and the shell's flat namespace already contains
+`TextBuf.Clear` and `Editor.Run`. The `86` suffix is the fix, applied
+identically in `Exec86.def`, `Exec86.mod`, `tests/Exec86Run.mod` and the
+module-level import in `Shell.mod`.
+
+**What it deliberately does not do**, each stated in `Exec86.mod`'s own
+header: `FLAGS` are never written back, so `INT 21h` "preserves the flags" by
+construction (a real `INT` pushes them and `IRET` pops them, so the handler's
+`CLC`/`STC` are discarded — `bootcom.s` relies on that); `PF`/`AF` are not
+maintained, so `JP`/`JNP` fault instead of answering a value nobody computed;
+`DF`/`IF`/`TF` are not maintained and no string instruction exists, so nothing
+can read `DF`; a non-zero segment register faults **before every step**,
+because a non-zero segment would silently *alias* onto the same 64 KB instead
+of faulting; an unknown `INT 21h` function faults; and `MaxSteps = 2000000000`
+catches a runaway. Execution outside `[0100h, loadHi)` faults, which is what
+makes the `R` non-vacuity case fail on its very first step.
+
+The instruction set is not "the 8086" but the measured union of (a) every byte
+`Runtime.mod` emits — 219 instructions, swept by `check_8086.py` — and (b)
+every byte `Compiler.mod`'s `Em*` procedures can write (the generated region
+cannot be swept: inline string literals desynchronise a sweep, so the authority
+there is the source). Everything outside that union faults rather than
+guessing, because an interpreter that guesses does not fail — it answers.
 
 ## Components
 
@@ -627,7 +803,9 @@ old file to `.BAK` (unlink+rename, TP3's order); `D` is a DOS `*.*` glob
 listing with `k bytes free`; `O` is the options submenu; `Q` confirms and
 prompts to save when the text changed.
 
-`E` and `C` are wired. **`R` is a stub** — it prints "Interpreter pending".
+`E`, `C` and `R` are wired. `R` compiles, pokes the linked image into the
+in-process interpreter at `0100h`, runs it and reports the guest's exit status
+and the step count — see *The in-process interpreter* above.
 
 ### Editor — `shell/Editor.mod`
 
@@ -876,14 +1054,21 @@ Details that are deliberate, not incidental:
 
 ## Honest limitations
 
-- **`CmdRun` — the `R` key — is still a stub.** The compiler's *output* now
-  executes (30 fixtures, exact output, exit codes), but the shell cannot run a
-  `.COM` in place. The linker writes a real `.COM` and the boot sector runs one
-  under qemu; nothing in the host program yet interprets 8086 code. So the
-  user's route to seeing output is "compile, then run under qemu", not "press
-  `R`". TP3's `R` runs in the same 64 KB with no DOS loader, which is why this
-  is an interpreter and not a `system()` call.
-- **Every fixture that compiles is now also run.** 30 of 33 execute; the 3 that
+- **A multi-argument call whose earlier argument is a computed value is still
+  wrong, and is not claimed to be fixed.** `SaveLeft` parks a kind-2 operand
+  (a value that exists only in `AX`) across the parse of the *other* operand of
+  a binary operator, which is what fixed `(p > q) or (q > p)`. The three call
+  parsers never park an argument that has already been parsed, so in
+  `f (a > b, x)` the parse of `x` overwrites `a > b`'s value before the call is
+  emitted, and `f (a > b, c > d)` passes the second comparison twice.
+  Reproduced exactly as written here; the fix belongs to the call path and was
+  out of scope for the operator fix.
+- **`runtest.py` runs one fixture through `R`, not 33.** It drives `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 32 are covered by
+  `run_exec86.py`, which calls the same interpreter directly.
+- **Every fixture that compiles is now also run.** 33 of 36 execute; the 3 that
   do not are `t14` and `t25` (`ENoLib`, by design) and `uierror` (a deliberate
   syntax error). The gap this replaces was nine fixtures that compiled and were
   *never executed* — `t08` const, `t09` if/then/else, `t10` while, `t11` for/to,
@@ -938,7 +1123,7 @@ 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.** 436 bytes, 14 entries, 100
+  invariants are stated rather than implied.** 436 bytes, 14 entries, 101
   emitter helpers decoded against their own names across both modules, the
   whole code region golden-pinned, all branch targets on instruction
   boundaries — and 35 cases that call the entries one at a time under qemu
@@ -953,9 +1138,11 @@ 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.
-- **30 fixtures is a small sample of Pascal.** They cover `var`, `const`,
+- **33 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 and `readln`. They do **not**
+  parameters, `goto`/`label`, string literals, `readln`, all six comparisons,
+  and the arithmetic and logical operators `* + - div mod and or not` on
+  **variables**. They do **not**
   cover nested procedures, recursion, `var` parameters, `with`, records, sets,
   files, reals, or any type wider than 2 bytes — all still `ENoLib`. A green
   execution matrix says nothing about those.
@@ -1299,8 +1486,8 @@ fault than the previous thirty-one and is worth setting out at length.
 
     What makes this worth a section rather than a bullet is that **everything
     was green while it was true.** The compile matrix passed. The `.COM` layout
-    checker passed. The runtime golden passed. The emitter audit passed. Thirty
-    fixtures booted in qemu and printed exactly their hand-derived expected
+    checker passed. The runtime golden passed. The emitter audit passed. Every
+    fixture booted in qemu and printed exactly its hand-derived expected
     bytes. Two reasons, and the second is the one to remember:
 
     - **qemu-system-i386 has no 8086 model.** Its lowest is 486, where
@@ -1384,15 +1571,65 @@ its branch sites declare, **read off the `.pas` sources** and written out with
 the reasoning beside each row — a table measured from the image would agree with
 any behaviour including a wrong one.
 
-Five mutations are now permanent cases in `tests/nonvacuity.sh` (44 ok, 0
-failed, up from 39): M1 and M2 restore each original defect, M3 swaps `>`/`>=`,
+Five mutations are now permanent cases in `tests/nonvacuity.sh` (its final
+`non-vacuity:` line reports the total across every section; these five were
+`44 ok` when added):
+M1 and M2 restore each original defect, M3 swaps `>`/`>=`,
 M4 inverts the branch polarity everywhere, M5 inverts it for IF and CASE only.
 M5's first run was the one that came back green, and that is the case's whole
 reason for existing.
 
+### Then two fixtures were written for the untested operators, and two more appeared
+
+34. **A computed left operand was destroyed while the right one was parsed.**
+    `t34_arith` exists because `div`, `mod`, `and`, `or`, unary `-` and a
+    *variable* `*` had never been executed by anything — `t08` was the only
+    fixture that multiplied, and it multiplied two **constants**, which
+    `BinOpEmit` folds away without emitting an instruction at all. Its
+    `and`/`or` lines are a second first: `(p > q) and (q > p)` puts a value
+    that exists only in `AX` on *both* sides of an operator, and nothing kept
+    the left one alive while the right one was parsed. The `or` line printed
+    `FALSE` where Pascal says `TRUE`.
+
+    `SaveLeft` pushes it the moment the operator is recognised (`kind := 4`)
+    and `LoadPair` then materialises `AX = left`, `CX = right` in whichever of
+    three shapes the situation needs; the third is the original path and is
+    still correct for its case. Both are named in `Compiler.mod` because the
+    shape table *is* the fix — an inline "just reload it" at one call site
+    would not survive the next operator.
+
+    This bug is also why the fix has a stated boundary: the **call** path has
+    the same hole and does not have it fixed (`f (a > b, x)`). A fix that
+    claims "computed operands" while only covering binary operators is worse
+    than one that writes its edge down; see Honest limitations.
+
+35. **`not` was lowered identically for booleans and integers, so every
+    boolean negation was wrong.** TPSRC9's `neglevel` picks the instruction
+    from the operand's *type* before it emits anything — `NOT AX` (`F7 D0`)
+    for an integer, `XOR AL,#01` (`34 01`) for a boolean, error 47 for
+    anything else — and this compiler emitted `NOT AX` for both. So
+    `not (a = 17)` computed `0FFFEh`, and the runtime's `wrbool` tests
+    `[BP+4] <> 0`, which reads `0FFFEh` as TRUE. Pascal says FALSE.
+
+    **Exec86 and qemu agreed with each other and both disagreed with Pascal**,
+    which is what pins the fault on the compiler rather than on either
+    interpreter: two machines built independently cannot share a bug in an
+    emitter neither of them ever reads. It also made the new oracle worth
+    having — the same disagreement would have been invisible in a suite whose
+    only second opinion was a different execution of the same bytes.
+
+    The fix is `neglevel`'s own split, not a special case: `ParseNeg` dispatches
+    on `r.cls`, so `t35_not` carries *both* arms — `not a` has to stay `NOT AX`
+    and must not be dragged to `XOR AL,#01` by a fix aimed at booleans. A
+    narrow row in the helper audit pins the new emitter by its name: a helper
+    called `XorAl01` that emits an immediate of `02` fails with *"immediate is
+    2, name says 1"*, and the matching opcode row refuses a byte no name
+    claims — moving the `34H` to `35H` fails with *"no name pattern accepts
+    it"*, so the emitter cannot silently become a different instruction.
+
 ## The bug family, stated once
 
-Nine of the thirty-two are the *same* bug in different clothes: **loading the
+Nine of the thirty-five are the *same* bug in different clothes: **loading the
 address where the value was wanted, or picking the register one byte or one
 letter away from the right one.** `EmPushVarAddr` had the right bytes for the
 wrong register. `LdAlBx` and `MovAlBl` are one letter apart. `MovAh0` and
@@ -1469,12 +1706,11 @@ independently-scanned inventory at all.
 
 ## Next steps
 
-1. **`CmdRun`** as an in-process 8086 interpreter — the `R` menu key, and a
-   fallback executor for environments with no DOS. Validate it against qemu on
-   the *same images*, so the two oracles check each other. Cross-validation is
-   the point: an interpreter that agrees with qemu on 31 fixtures is far more
-   evidence than either alone. It is also the only candidate for an **8086**
-   execution oracle, since qemu cannot be one.
+1. **The multi-argument kind-2 clobber.** `f (a > b, x)` passes a wrong first
+   value, and `f (a > b, c > d)` passes the second comparison twice.
+   `SaveLeft` parks a computed operand across the parse of the *other* operand
+   of a binary operator; the three call parsers park nothing. Fixture first, so
+   the bug is red before the fix (see "Honest limitations").
 2. **String *variables*** — `s : string`, `s := 'hi'`, `writeln(s)`. The
    encoding blocker is gone (`EmBpDisp`); what is left is a length word, an
    assignment path, and a `WrStr` entry (TPSRC4 `xwrtstr`). `IoCall` currently
@@ -1497,6 +1733,8 @@ independently-scanned inventory at all.
    name `Search` finds at any level), which Pascal allows.
 7. Harden the program-header parameter loop against non-advancing input
    (`program p(1;)`) with a `BOOLEAN` flag — **not** `EXIT`, which ICEs gm2.
-8. FreeDOS (`freedos.qcow2`, FD14-LiveCD) is still untried. Not needed for any
-   claim above, but it is the only way to get a *real* DOS as a third opinion
-   on the `INT 21h` shim.
+8. FreeDOS (`freedos.qcow2`, FD14-LiveCD) is still untried. Now that `R` runs
+   the image in-process, a real DOS is no longer needed for any claim above —
+   but it is still the only way to get a third opinion on the `INT 21h` shim,
+   and `Exec86` deliberately implements only the three functions the runtime
+   calls.

+ 116 - 17
shell/Compiler.mod

@@ -660,9 +660,11 @@ BEGIN
    Ebyte (5AH)
 END EmPopDx ;
 
-(* 91 = XCHG AX,CX, and NOT 93.  BinOpEmit has the left operand in CX and the
-   right in AX (it pushes the left, loads the right, then pops the left into
-   CX), so the exchange is what puts LEFT in AX for the operation to act on.
+(* 91 = XCHG AX,CX, and NOT 93.  LoadPair hands the operator the left
+   operand in CX and the right in AX (in the original, and still the
+   commonest, shape: it pushes the left, loads the right, then pops the
+   left into CX), so the exchange is what puts LEFT in AX for the
+   operation to act on.
    Without it, `a - b` computes `b - a`; with the wrong register, `a + b`
    computes `AX' + a` where AX' is whatever BX happened to hold.
 
@@ -725,6 +727,19 @@ BEGIN
    Ebyte (0F7H) ; Ebyte (0D0H)
 END EmNotAx ;
 
+PROCEDURE EmXorAl01 () ;
+(* XOR AL,#01 - TPSRC9 neglevel's *boolean* NOT:
+
+       CALL loadatom ; CALL ecode ; B $02,$34,$01
+
+   neglevel tests the type first (CMP CL,#$0A -> integer -> NOT AX,
+   CMP CL,#$0B -> boolean -> XOR AL,#01) and reports error 47 for anything
+   else.  Emitting NOT AX for a boolean leaves 0FFFEh or 0FFFFh, and the
+   runtime's wrbool tests [BP+4] <> 0, so `not (a = a)' came out TRUE. *)
+BEGIN
+   Ebyte (34H) ; Ebyte (01H)
+END EmXorAl01 ;
+
 PROCEDURE EmCmpAxCx () ;
 BEGIN
    Ebyte (3BH) ; Ebyte (0C1H)
@@ -1718,8 +1733,7 @@ BEGIN
          res.cls := TBool ;
          RETURN
       END ;
-      LoadAtom (left) ; EmPushAx () ;
-      LoadAtom (right) ; EmPopCx () ;
+      LoadPair (left, right) ;
       EmAndAxCx () ;
       res.kind := 2 ; res.cls := TBool ;
       RETURN
@@ -1731,8 +1745,7 @@ BEGIN
          res.cls := TBool ;
          RETURN
       END ;
-      LoadAtom (left) ; EmPushAx () ;
-      LoadAtom (right) ; EmPopCx () ;
+      LoadPair (left, right) ;
       EmOrAxCx () ;
       res.kind := 2 ; res.cls := TBool ;
       RETURN
@@ -1762,9 +1775,7 @@ BEGIN
          RETURN
       END
    END ;
-   LoadAtom (left) ; EmPushAx () ;
-   LoadAtom (right) ; EmPopCx () ;
-   EmXchgAxCx () ;
+   LoadPair (left, right) ;
    CASE op OF
       OpAdd : EmAddAxCx ;
    |  OpSub : EmSubAxCx ;
@@ -1798,6 +1809,75 @@ BEGIN
    RETURN TRUE
 END ConstCmp ;
 
+(* ---------------------------------------------------------------- *)
+(*  keeping an operand alive across the parse of the other one       *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE SaveLeft (VAR left : ERes) ;
+(* Called the moment the operator has been recognised, i.e. *before* the
+   right-hand operand is parsed.
+
+   Every binary level in this parser has the shape
+
+      left := r ; ParseXxx (right) ; BinOpEmit (op, left, right, r) ;
+
+   so the right-hand operand's code is emitted between those two lines, and
+   the left-hand operand has to survive that.  For kind 0 (a constant) and
+   kind 1 (a named variable) there is nothing to survive: LoadAtom re-emits
+   the MOV or the load at the operator site, and no code has been emitted
+   for the left yet.  A kind-2 value is different - its code is already in
+   the image and its value exists only in AX, which the right-hand parse is
+   about to overwrite.  The value had to be parked on the stack instead.
+
+   TPSRC9 does exactly this in the other order: addptr and mulptr CALL
+   pushres (which calls loadatom and sets flgpshax) *before* CALL mullevel,
+   so PUSH AX lands in the image ahead of the right-hand operand's code.
+   Without this, `(p > q) or (q > p)` emitted both comparisons and then
+   ORed the *second* comparison's result with itself - which is why it
+   printed FALSE for TRUE or FALSE. *)
+BEGIN
+   IF left.kind = 2 THEN
+      EmPushAx () ;
+      left.kind := 4                      (* 4 = on the top of the stack *)
+   END
+END SaveLeft ;
+
+PROCEDURE LoadPair (VAR left, right : ERes) ;
+(* Materialise both operands of a binary operator so that, once this
+   returns, AX holds the LEFT one and CX the RIGHT one - which is the state
+   EmAddAxCx/EmSubAxCx/EmMulAxCx/EmIDivAxCx/EmAndAxCx/EmOrAxCx/EmCmpAxCx
+   all expect (see EmXchgAxCx).
+
+   Three shapes, and only three:
+
+     left.kind = 4   SaveLeft pushed it before the right operand was
+                     parsed, so the right value is in AX and the left one
+                     is on the stack: load the right (a no-op for kind 2),
+                     pop the left into CX, then swap.
+     right.kind = 2  The right value is in AX, and loading the left would
+                     destroy it: push the right, load the left, then pop
+                     the right straight into CX.  No swap needed.
+     neither         Both can still be materialised from scratch, and
+                     neither is at risk, so this is the original sequence.
+
+   Whichever shape is taken, exactly one push is matched by exactly one
+   pop. *)
+BEGIN
+   IF left.kind = 4 THEN
+      LoadAtom (right) ;
+      EmPopCx () ;
+      EmXchgAxCx ()
+   ELSIF right.kind = 2 THEN
+      EmPushAx () ;
+      LoadAtom (left) ;
+      EmPopCx ()
+   ELSE
+      LoadAtom (left) ; EmPushAx () ;
+      LoadAtom (right) ; EmPopCx () ;
+      EmXchgAxCx ()
+   END
+END LoadPair ;
+
 PROCEDURE ParseCmp (VAR r : ERes) ;
 (* "=" | "<>" | "<" | "<=" | ">" | ">=" *)
 VAR op : CARDINAL ;
@@ -1829,6 +1909,7 @@ BEGIN
          RETURN
       END ;
       left := r ;
+      SaveLeft (left) ;
       ParseAdd (right) ;
       IF (left.kind = 0) AND (right.kind = 0) THEN
          IF ConstCmp (op, left.imm, right.imm, f) THEN
@@ -1841,9 +1922,7 @@ BEGIN
             r.cls := TBool
          END
       ELSE
-         LoadAtom (left) ; EmPushAx () ;
-         LoadAtom (right) ; EmPopCx () ;
-         EmXchgAxCx () ;
+         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
@@ -1886,6 +1965,7 @@ BEGIN
          RETURN
       END ;
       left := r ;
+      SaveLeft (left) ;
       ParseMul (right) ;
       BinOpEmit (op, left, right, r)
    END
@@ -1916,6 +1996,7 @@ BEGIN
          RETURN
       END ;
       left := r ;
+      SaveLeft (left) ;
       ParseNeg (right) ;
       BinOpEmit (op, left, right, r)
    END
@@ -1942,12 +2023,30 @@ BEGIN
    ELSIF KwAhead ("NOT") THEN
       GetWord () ;
       ParseNeg (r) ;
-      IF r.kind = 0 THEN
-         r.imm := BitNot (r.imm)
-      ELSE
+      (* TPSRC9 neglevel branches on the operand's type before emitting
+         anything: CMP CL,#$0A (integer) -> NOT AX, CMP CL,#$0B (boolean)
+         -> XOR AL,#01, anything else -> error.  This used to emit NOT AX
+         for both, so `not (a = a)' produced 0FFFEh and the runtime's
+         wrbool test ([BP+4] <> 0) read it as TRUE.
+
+         The integer constant fold stays: the 16 bits are the same either
+         way.  A boolean constant must not go through BitNot - 0FFFFH-1 is
+         0FFFEh, which wrbool also reads as TRUE - so it is loaded and
+         xor-ed, exactly as neglevel does it. *)
+      IF r.cls = TBool THEN
          LoadAtom (r) ;
-         EmNotAx () ;
+         EmXorAl01 () ;
          r.kind := 2
+      ELSIF r.cls = TScalar THEN
+         IF r.kind = 0 THEN
+            r.imm := BitNot (r.imm)
+         ELSE
+            LoadAtom (r) ;
+            EmNotAx () ;
+            r.kind := 2
+         END
+      ELSE
+         Err (ETypeErr)
       END ;
       RETURN
    END ;

+ 72 - 0
shell/Exec86.def

@@ -0,0 +1,72 @@
+DEFINITION MODULE Exec86 ;
+
+(* Exec86 -- an in-process 8086 interpreter.
+
+   This is what the shell's `R` key runs: the compiled image is copied into
+   the interpreter's own 64 KB and executed here, with no DOS, no loader and
+   no emulator outside this program.  TP3's `R` does the same thing - it runs
+   the generated code in place in the same 64 KB - so this is also the shape
+   the original had.
+
+   The reason to want one at all is documented in SUMMARY.md: qemu-system-i386
+   cannot be an 8086 oracle, because its lowest CPU model is a 486, where the
+   opcodes that were the `0F 8x' bug are ordinary instructions.  An
+   interpreter written against the 8086's own reference and then required to
+   agree with qemu byte-for-byte on the same images is a second, independent
+   execution oracle - and the one that can actually see an 8086-only fault.
+
+   The interface is deliberately three calls: zero the machine, poke the
+   image in, run it.  Everything the guest can do outside its own code - the
+   INT 21h services, where its input comes from, where its output goes - is
+   inside, because those are part of "running it", not part of setting it up.
+
+   The names carry an 86 suffix, which reads as redundant inside Exec86 and
+   is not.  ISO Modula-2 has no import renaming - gm2 -fiso rejects
+   `FROM M IMPORT x AS y' outright - and an import cannot be scoped to a
+   procedure either, so every module that imports Exec86 shares one flat
+   namespace with everything else it imports.  Shell needs TextBuf.Clear,
+   Editor.Run, Exec86.Clear and Exec86.Run at the same time, and the first
+   two were spoken for by modules that predate this one.  This is the module
+   that moved: Clear86, Poke86, Run86.
+
+   NOTE, because Runtime.def records the same trap: this .def is
+   HAND-MAINTAINED.  gm2 resolves `FROM Exec86 IMPORT ...' against it and
+   checks the implementation against it, but never rewrites it.  An interface
+   change means editing this file by hand in the same commit.  Keep changes
+   ADDITIVE where possible. *)
+
+PROCEDURE Clear86 ;
+(* Reset the machine to the state a DOS .COM starts in: all of memory zeroed,
+   CS = DS = ES = SS = 0, IP = 0100H (where DOS puts a .COM), SP = 0FFFEH
+   (the top of the segment, where DOS puts the stack), every general register
+   zero, DF clear, every other flag clear.
+
+   Zeroing the general registers is a CHOICE, not a fact about DOS: bootcom.s
+   leaves them as the BIOS found them.  All 31 fixtures are deterministic
+   under qemu, so no guest of ours reads an undefined initial register - and
+   this is the first place that would show up if one ever did.  Memory below
+   0100H is left zero rather than holding bootcom's interrupt vector, because
+   the interpreter services INT 21h itself and never looks at the vector. *)
+
+PROCEDURE Poke86 (addr, value : CARDINAL) ;
+(* Store one byte at flat address `addr' (value is taken modulo 256).
+
+   Poking also records how far up memory has been written, and execution
+   outside that region is a fault: a guest that jumps out of its own loaded
+   image has gone somewhere it cannot come back from, and running on into
+   whatever happens to be there is how a wild jump turns into a wild hang. *)
+
+PROCEDURE Run86 (VAR exitCode : CARDINAL; VAR steps : LONGCARD) : CARDINAL ;
+(* Execute until something stops it.  Returns
+
+     0  the program terminated with INT 21h AH=4Ch, and exitCode is its AL
+     1  the interpreter faulted - a diagnostic is already on stderr
+     2  the step limit was reached; steps says how many
+
+   `steps' is always the number of instructions actually executed.
+
+   The step limit is a runaway guard, and it is a limit rather than a
+   solution: a program that idles in a tight loop will reach it.  It exists
+   so that a broken image fails instead of never returning. *)
+
+END Exec86.

+ 1126 - 0
shell/Exec86.mod

@@ -0,0 +1,1126 @@
+IMPLEMENTATION MODULE Exec86 ;
+
+(* Exec86 -- implementation.  See Exec86.def for what this is for.
+
+   WHAT IS IMPLEMENTED, and where the list comes from
+   --------------------------------------------------
+   Not "the 8086": a CPU has no meaning without the programs it must run, and
+   this one has a corpus.  The set below is the union of exactly two measured
+   things:
+
+     (a) every byte the runtime emits.  Runtime.mod's code region is pure
+         code with no inline data, so it can be swept instruction by
+         instruction and the sweep must complete -- tests/check_8086.py does
+         that, and it covers 219 instructions, 121 distinct forms.  That
+         sweep is the authority for the runtime.
+
+     (b) every byte Compiler.mod's emitters can write.  The generated code
+         region CANNOT be swept the same way: inline string literals are
+         emitted into it after the code, and a linear sweep desynchronises
+         on them (t09_if dies at the bytes `FE E9 0D 00', which are ASCII
+         text).  So the authority there is the source -- the Em* procedures
+         and their Ebyte constants -- not a disassembly.
+
+   Everything outside that union faults rather than guessing.  An
+   interpreter that guesses at an unknown opcode does not fail; it produces
+   an answer.
+
+   FLAGS: CF, ZF, SF and OF are maintained, because every condition the
+   corpus uses reads one of them: the compiler emits 4,5,C,D,E,F
+   (JE JNE JL JGE JLE JG) and the runtime adds 2,6,7 (JB JBE JA).
+   PF and AF are NOT maintained, and JP/JNP fault saying so rather than
+   returning a value nobody computed.  DF, IF and TF are not maintained;
+   no string instruction is implemented, so nothing can read DF.  Nothing in
+   the corpus sets a flag that a later instruction in the corpus reads
+   except through those four.
+
+   SEGMENTS are checked every step.  The machine is 64 KB flat, so a
+   non-zero segment register would alias silently rather than fault; it
+   faults instead.
+
+   INT 21h PRESERVES THE FLAGS.  That is not an approximation: a real INT
+   pushes FLAGS, and IRET pops them back, so the handler's own CLC/STC are
+   discarded before the caller can see them.  bootcom.s does `clc' and
+   `stc' in its handler and they change nothing outside it.  This is the
+   one place where matching the machine rather than the visible source is
+   easy to get wrong, so it is written down. *)
+
+FROM Posix IMPORT read, write ;
+FROM SYSTEM IMPORT ADR ;
+
+CONST
+   STDIN    = 0 ;
+   STDOUT   = 1 ;
+   STDERR   = 2 ;
+   LoadAt   = 100H ;          (* where DOS puts a .COM, and where bootcom
+                                 jumps, so the entry point matches qemu *)
+   MaxSteps = 2000000000 ;    (* runaway guard; see Exec86.def *)
+
+VAR
+   mem   : ARRAY [0..65535] OF CARDINAL ;   (* one CARDINAL per byte, 0..255 *)
+   AX, BX, CX, DX, SI, DI, BP, SP, IP : CARDINAL ;
+   CS, DS, ES, SS : CARDINAL ;
+   CF, ZF, SF, OFl : BOOLEAN ;
+   halted   : BOOLEAN ;
+   haltCode : CARDINAL ;
+   isFault  : BOOLEAN ;
+   faultIP  : CARDINAL ;
+   loadHi   : CARDINAL ;      (* one past the highest address poked *)
+   stepCnt  : LONGCARD ;
+
+   (* filled in by DoModRM *)
+   eaAddr   : CARDINAL ;
+   eaReg    : CARDINAL ;
+   eaIsReg  : BOOLEAN ;
+   rmReg    : CARDINAL ;
+
+
+(* ------------------------------------------------------------ diagnostics *)
+
+PROCEDURE WrErr1 (c : CHAR) ;
+VAR n : LONGINT ;
+BEGIN
+   n := write (STDERR, ADR (c), 1)
+END WrErr1 ;
+
+PROCEDURE WrS (s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+    c : CHAR ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      c := s [i] ;
+      WrErr1 (c) ;
+      i := i + 1
+   END
+END WrS ;
+
+PROCEDURE WrHex (v : CARDINAL) ;
+VAR k, d, p, n : CARDINAL ;
+    c : CHAR ;
+BEGIN
+   WrS ("0x") ;
+   FOR k := 0 TO 3 DO
+      p := 1 ;
+      d := 3 - k ;
+      WHILE d > 0 DO
+         p := p * 16 ;
+         d := d - 1
+      END ;
+      n := (v DIV p) MOD 16 ;
+      IF n < 10 THEN
+         c := CHR (ORD ('0') + n)
+      ELSE
+         c := CHR (ORD ('A') + n - 10)
+      END ;
+      WrErr1 (c)
+   END
+END WrHex ;
+
+PROCEDURE WrDec (v : CARDINAL) ;
+VAR buf : ARRAY [0..10] OF CHAR ;
+    k, j, x : CARDINAL ;
+    c : CHAR ;
+BEGIN
+   k := 10 ;
+   buf [10] := 0C ;
+   x := v ;
+   REPEAT
+      k := k - 1 ;
+      buf [k] := CHR (ORD ('0') + x MOD 10) ;
+      x := x DIV 10
+   UNTIL x = 0 ;
+   j := k ;
+   WHILE j <= 9 DO
+      c := buf [j] ;
+      WrErr1 (c) ;
+      j := j + 1
+   END
+END WrDec ;
+
+PROCEDURE Fault (msg : ARRAY OF CHAR) ;
+BEGIN
+   isFault := TRUE ;
+   WrS ("exec86: fault at IP=") ;
+   WrHex (faultIP) ;
+   WrS (": ") ;
+   WrS (msg) ;
+   WrErr1 (CHR (10))
+END Fault ;
+
+
+(* --------------------------------------------------- widths and sign bits *)
+
+PROCEDURE Sz (w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN RETURN 65536 ELSE RETURN 256 END
+END Sz ;
+
+(* The value with only the sign bit set, for a byte (80H) or word (8000H). *)
+PROCEDURE Sg (w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN RETURN 8000H ELSE RETURN 80H END
+END Sg ;
+
+(* A signed byte widened to a 16-bit two's-complement CARDINAL, so that
+   adding it does the right thing modulo 65536.  128 -> 65408 = -128. *)
+PROCEDURE SE8 (v : CARDINAL) : CARDINAL ;
+BEGIN
+   IF v >= 80H THEN RETURN v + 65280 ELSE RETURN v END
+END SE8 ;
+
+(* Signed interpretations, for MUL/IMUL/DIV/IDIV. *)
+PROCEDURE S16 (v : CARDINAL) : LONGINT ;
+BEGIN
+   IF v >= 8000H THEN
+      RETURN VAL (LONGINT, v) - VAL (LONGINT, 65536)
+   ELSE
+      RETURN VAL (LONGINT, v)
+   END
+END S16 ;
+
+PROCEDURE S8 (v : CARDINAL) : LONGINT ;
+BEGIN
+   IF v >= 80H THEN
+      RETURN VAL (LONGINT, v) - VAL (LONGINT, 256)
+   ELSE
+      RETURN VAL (LONGINT, v)
+   END
+END S8 ;
+
+
+(* --------------------------------------------------------- memory and stack *)
+
+PROCEDURE MemWr (a, v, w : CARDINAL) ;
+BEGIN
+   mem [a] := v MOD 256 ;
+   IF w = 1 THEN
+      mem [(a + 1) MOD 65536] := (v DIV 256) MOD 256
+   END
+END MemWr ;
+
+PROCEDURE MemRd (a, w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      RETURN mem [a] + 256 * mem [(a + 1) MOD 65536]
+   ELSE
+      RETURN mem [a]
+   END
+END MemRd ;
+
+PROCEDURE Push (v : CARDINAL) ;
+BEGIN
+   SP := (SP + 65534) MOD 65536 ;        (* SP - 2, wrapped *)
+   MemWr (SP, v, 1)
+END Push ;
+
+PROCEDURE Pop () : CARDINAL ;
+VAR v : CARDINAL ;
+BEGIN
+   v := MemRd (SP, 1) ;
+   SP := (SP + 2) MOD 65536 ;
+   RETURN v
+END Pop ;
+
+PROCEDURE Fetch8 () : CARDINAL ;
+VAR v : CARDINAL ;
+BEGIN
+   v := mem [IP] ;
+   IP := (IP + 1) MOD 65536 ;
+   RETURN v
+END Fetch8 ;
+
+PROCEDURE Fetch16 () : CARDINAL ;
+VAR a, b : CARDINAL ;
+BEGIN
+   a := Fetch8 () ;
+   b := Fetch8 () ;
+   RETURN a + 256 * b
+END Fetch16 ;
+
+
+(* --------------------------------------------------------------- registers *)
+
+PROCEDURE GetReg (r, w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      CASE r OF
+      | 0 : RETURN AX
+      | 1 : RETURN CX
+      | 2 : RETURN DX
+      | 3 : RETURN BX
+      | 4 : RETURN SP
+      | 5 : RETURN BP
+      | 6 : RETURN SI
+      ELSE  RETURN DI
+      END
+   ELSE
+      CASE r OF
+      | 0 : RETURN AX MOD 256          (* AL *)
+      | 1 : RETURN CX MOD 256          (* CL *)
+      | 2 : RETURN DX MOD 256          (* DL *)
+      | 3 : RETURN BX MOD 256          (* BL *)
+      | 4 : RETURN AX DIV 256          (* AH *)
+      | 5 : RETURN CX DIV 256          (* CH *)
+      | 6 : RETURN DX DIV 256          (* DH *)
+      ELSE  RETURN BX DIV 256          (* BH *)
+      END
+   END
+END GetReg ;
+
+PROCEDURE PutReg (r, w, v : CARDINAL) ;
+VAR hi, lo : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      CASE r OF
+      | 0 : AX := v
+      | 1 : CX := v
+      | 2 : DX := v
+      | 3 : BX := v
+      | 4 : SP := v
+      | 5 : BP := v
+      | 6 : SI := v
+      ELSE  DI := v
+      END
+   ELSE
+      lo := v MOD 256 ;
+      CASE r OF
+      | 0 : AX := (AX DIV 256) * 256 + lo          (* AL *)
+      | 1 : CX := (CX DIV 256) * 256 + lo          (* CL *)
+      | 2 : DX := (DX DIV 256) * 256 + lo          (* DL *)
+      | 3 : BX := (BX DIV 256) * 256 + lo          (* BL *)
+      | 4 : hi := lo * 256 ; AX := (AX MOD 256) + hi   (* AH *)
+      | 5 : hi := lo * 256 ; CX := (CX MOD 256) + hi   (* CH *)
+      | 6 : hi := lo * 256 ; DX := (DX MOD 256) + hi   (* DH *)
+      ELSE  hi := lo * 256 ; BX := (BX MOD 256) + hi   (* BH *)
+      END
+   END
+END PutReg ;
+
+
+(* ---------------------------------------------------------- addressing mode *)
+
+PROCEDURE DoModRM (w : CARDINAL) ;
+(* Fetch the ModR/M byte and, if the operand is in memory, its displacement.
+   `w' is only needed so the caller can size the access afterwards; the
+   address is the same either way.
+
+   The 16-bit addressing modes, since this is the part where a wrong table
+   still decodes cleanly and just reads the wrong variable:
+
+      rm  mod=00        mod=01/10
+       0   [BX+SI]      [BX+SI+disp]
+       1   [BX+DI]      [BX+DI+disp]
+       2   [BP+SI]      [BP+SI+disp]
+       3   [BP+DI]      [BP+DI+disp]
+       4   [SI]         [SI+disp]
+       5   [DI]         [DI+disp]
+       6   [disp16]     [BP+disp]     <- mod=00,rm=6 is the ONLY direct form
+       7   [BX]         [BX+disp]
+
+   Displacements are signed, and adding them modulo 65536 is exactly two's
+   complement addition, so no branch is needed for a negative one.  SE8
+   handles the disp8 case by widening it first. *)
+VAR b, md, rg, rm, base, d : CARDINAL ;
+BEGIN
+   b := Fetch8 () ;
+   md := b DIV 64 ;
+   rg := (b DIV 8) MOD 8 ;
+   rm := b MOD 8 ;
+   rmReg := rg ;
+   IF md = 3 THEN
+      eaIsReg := TRUE ;
+      eaReg := rm ;
+      eaAddr := 0
+   ELSE
+      eaIsReg := FALSE ;
+      eaReg := 0 ;
+      CASE rm OF
+      | 0 : base := (BX + SI) MOD 65536
+      | 1 : base := (BX + DI) MOD 65536
+      | 2 : base := (BP + SI) MOD 65536
+      | 3 : base := (BP + DI) MOD 65536
+      | 4 : base := SI
+      | 5 : base := DI
+      | 6 : base := BP
+      ELSE  base := BX
+      END ;
+      IF (md = 0) AND (rm = 6) THEN
+         eaAddr := Fetch16 ()
+      ELSIF md = 0 THEN
+         eaAddr := base
+      ELSIF md = 1 THEN
+         d := Fetch8 () ;
+         eaAddr := (base + SE8 (d)) MOD 65536
+      ELSE
+         d := Fetch16 () ;
+         eaAddr := (base + d) MOD 65536
+      END
+   END
+END DoModRM ;
+
+PROCEDURE RmRd (w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF eaIsReg THEN
+      RETURN GetReg (eaReg, w)
+   ELSE
+      RETURN MemRd (eaAddr, w)
+   END
+END RmRd ;
+
+PROCEDURE RmWr (v, w : CARDINAL) ;
+BEGIN
+   IF eaIsReg THEN
+      PutReg (eaReg, w, v)
+   ELSE
+      MemWr (eaAddr, v, w)
+   END
+END RmWr ;
+
+
+(* ------------------------------------------------------------------- flags *)
+
+PROCEDURE SetZSF (r, w : CARDINAL) ;
+(* ZF and SF from the result.  PF is deliberately absent: see the header. *)
+BEGIN
+   ZF := r = 0 ;
+   SF := r >= Sg (w)
+END SetZSF ;
+
+PROCEDURE DoAdd (a, b, cin, w : CARDINAL) : CARDINAL ;
+(* a + b + cin, setting the flags.  raw is at most 65535+65535+1, so it
+   never overflows the 32-bit CARDINAL and the carry can be read off it
+   directly rather than inferred from a wrapped result. *)
+VAR raw, res, sz : CARDINAL ;
+    ex : LONGINT ;
+BEGIN
+   sz := Sz (w) ;
+   raw := a + b + cin ;
+   res := raw MOD sz ;
+   CF := raw >= sz ;
+   (* OF: the exact mathematical result left the signed range.  Computing it
+      exactly, rather than from the signs of the two operands, is what makes
+      ADC work too: the carry can turn 127+0 into 128, and the two sign bits
+      alone say nothing about that.  S16/S8 are the signed readings of a
+      value at this width, so the whole test is one addition and a range
+      check. *)
+   IF w = 1 THEN
+      ex := S16 (a) + S16 (b) + VAL (LONGINT, cin) ;
+      OFl := (ex < VAL (LONGINT, -32768)) OR (ex > VAL (LONGINT, 32767))
+   ELSE
+      ex := S8 (a) + S8 (b) + VAL (LONGINT, cin) ;
+      OFl := (ex < VAL (LONGINT, -128)) OR (ex > VAL (LONGINT, 127))
+   END ;
+   SetZSF (res, w) ;
+   RETURN res
+END DoAdd ;
+
+PROCEDURE DoSub (a, b, cin, w : CARDINAL) : CARDINAL ;
+(* Subtracts b and a borrow.  Same reason for computing OF exactly: with
+   cin = 1 and b = 127 the subtrahend is really 128, whose sign bit is not
+   the sign bit of b, so the sign-bit rule gets SBB wrong. *)
+VAR sz, res, bb : CARDINAL ;
+    ex : LONGINT ;
+BEGIN
+   sz := Sz (w) ;
+   bb := b + cin ;
+   IF a >= bb THEN
+      res := a - bb ;              (* CARDINAL is 32-bit, so this cannot wrap *)
+      CF := FALSE
+   ELSE
+      res := a + sz - bb ;         (* a + sz >= bb always, so still non-negative *)
+      CF := TRUE
+   END ;
+   res := res MOD sz ;
+   IF w = 1 THEN
+      ex := S16 (a) - S16 (b) - VAL (LONGINT, cin) ;
+      OFl := (ex < VAL (LONGINT, -32768)) OR (ex > VAL (LONGINT, 32767))
+   ELSE
+      ex := S8 (a) - S8 (b) - VAL (LONGINT, cin) ;
+      OFl := (ex < VAL (LONGINT, -128)) OR (ex > VAL (LONGINT, 127))
+   END ;
+   SetZSF (res, w) ;
+   RETURN res
+END DoSub ;
+
+PROCEDURE SetLogic (r, w : CARDINAL) ;
+BEGIN
+   CF := FALSE ;
+   OFl := FALSE ;
+   SetZSF (r, w)
+END SetLogic ;
+
+(* Bitwise operations.  This dialect has NO bitwise operators at all - no
+   BITAND, no BAND, no `&' - so sets stand in for them, which is the same
+   trick Compiler.mod uses for its own constant folding (BitAnd/BitOr/BitNot
+   there are three lines of this).  `+' is union, `*' is intersection,
+   `-` is difference, so XOR is union minus intersection. *)
+PROCEDURE BAnd (a, b, w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      RETURN CARDINAL (VAL (BITSET, a) * VAL (BITSET, b))
+   ELSE
+      RETURN CARDINAL (VAL (BITSET, a MOD 256) * VAL (BITSET, b MOD 256))
+   END
+END BAnd ;
+
+PROCEDURE BOr (a, b, w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      RETURN CARDINAL (VAL (BITSET, a) + VAL (BITSET, b))
+   ELSE
+      RETURN CARDINAL (VAL (BITSET, a MOD 256) + VAL (BITSET, b MOD 256))
+   END
+END BOr ;
+
+PROCEDURE BXor (a, b, w : CARDINAL) : CARDINAL ;
+VAR u, i : BITSET ;
+BEGIN
+   IF w = 1 THEN
+      u := VAL (BITSET, a) + VAL (BITSET, b) ;
+      i := VAL (BITSET, a) * VAL (BITSET, b)
+   ELSE
+      u := VAL (BITSET, a MOD 256) + VAL (BITSET, b MOD 256) ;
+      i := VAL (BITSET, a MOD 256) * VAL (BITSET, b MOD 256)
+   END ;
+   RETURN CARDINAL (u - i)
+END BXor ;
+
+PROCEDURE BNot (a, w : CARDINAL) : CARDINAL ;
+BEGIN
+   IF w = 1 THEN
+      RETURN CARDINAL (VAL (BITSET, 0FFFFH) - VAL (BITSET, a))
+   ELSE
+      RETURN CARDINAL (VAL (BITSET, 0FFH) - VAL (BITSET, a MOD 256))
+   END
+END BNot ;
+
+PROCEDURE Alu (op, a, b, w : CARDINAL) : CARDINAL ;
+(* op is the group's reg field: 0 ADD 1 OR 2 ADC 3 SBB 4 AND 5 SUB 6 XOR
+   7 CMP.  The result is returned for every op, including CMP; the caller
+   decides whether to write it back, which is what the /r bit of the
+   instruction already says. *)
+VAR cin, res : CARDINAL ;
+BEGIN
+   IF (op = 2) OR (op = 3) THEN
+      IF CF THEN cin := 1 ELSE cin := 0 END
+   ELSE
+      cin := 0
+   END ;
+   CASE op OF
+   | 0 : res := DoAdd (a, b, cin, w)
+   | 1 : res := BOr (a, b, w) ;  SetLogic (res, w)
+   | 2 : res := DoAdd (a, b, cin, w)
+   | 3 : res := DoSub (a, b, cin, w)
+   | 4 : res := BAnd (a, b, w) ; SetLogic (res, w)
+   | 5 : res := DoSub (a, b, cin, w)
+   | 6 : res := BXor (a, b, w) ; SetLogic (res, w)
+   ELSE  res := DoSub (a, b, cin, w)
+   END ;
+   RETURN res
+END Alu ;
+
+PROCEDURE Cond (n : CARDINAL) : BOOLEAN ;
+(* The 16 conditions.  0AH and 0BH need PF, which this machine does not
+   maintain, so they fault instead of answering. *)
+VAR r : BOOLEAN ;
+BEGIN
+   CASE n OF
+   | 0H  : r := OFl                       (* JO  *)
+   | 1H  : r := NOT OFl                   (* JNO *)
+   | 2H  : r := CF                        (* JB  *)
+   | 3H  : r := NOT CF                    (* JAE *)
+   | 4H  : r := ZF                        (* JE  *)
+   | 5H  : r := NOT ZF                    (* JNE *)
+   | 6H  : r := CF OR ZF                  (* JBE *)
+   | 7H  : r := NOT (CF OR ZF)            (* JA  *)
+   | 8H  : r := SF                        (* JS  *)
+   | 9H  : r := NOT SF                    (* JNS *)
+   | 0AH : Fault ("parity flag is not maintained, so JP cannot be evaluated")
+          ; r := FALSE
+   | 0BH : Fault ("parity flag is not maintained, so JNP cannot be evaluated")
+          ; r := FALSE
+   | 0CH : r := SF # OFl                  (* JL  *)
+   | 0DH : r := SF = OFl                  (* JGE *)
+   | 0EH : r := ZF OR (SF # OFl)          (* JLE *)
+   ELSE    r := (NOT ZF) AND (SF = OFl)   (* JG  *)
+   END ;
+   RETURN r
+END Cond ;
+
+
+(* ----------------------------------------------------------------- INT 21h *)
+
+PROCEDURE DoInt21 ;
+(* The four services bootcom.s provides and the runtime calls, and nothing
+   else.  Anything else faults, because the alternative is inventing DOS.
+
+   No flag is touched anywhere in here: IRET restores the caller's FLAGS, so
+   neither this code nor bootcom's CLC/STC is visible to the guest. *)
+VAR ah, b, a, n, lim, stop : CARDINAL ;
+    c : CHAR ;
+    running : BOOLEAN ;
+BEGIN
+   ah := AX DIV 256 ;
+   CASE ah OF
+   | 02H :                                   (* display character in AL *)
+      c := CHR (AX MOD 256) ;
+      n := write (STDOUT, ADR (c), 1)
+
+   | 09H :                                   (* $-terminated string at DS:DX *)
+      a := DX ;
+      running := TRUE ;
+      lim := 0 ;
+      WHILE running DO
+         b := mem [a] ;
+         IF b = 24H THEN                     (* '$' *)
+            running := FALSE
+         ELSE
+            c := CHR (b) ;
+            n := write (STDOUT, ADR (c), 1) ;
+            a := (a + 1) MOD 65536 ;
+            lim := lim + 1 ;
+            IF lim > 65536 THEN
+               Fault ("INT 21h AH=09: walked the whole address space with no $") ;
+               running := FALSE
+            END
+         END
+      END
+
+   | 08H :                                   (* read a character, no echo *)
+      n := read (STDIN, ADR (c), 1) ;
+      IF n = 1 THEN
+         b := ORD (c)
+      ELSE
+         b := 1AH                            (* end of input, as bootcom does *)
+      END ;
+      AX := (AX DIV 256) * 256 + b           (* AL only: AH is the function *)
+
+   | 04CH :                                  (* terminate *)
+      halted := TRUE ;
+      haltCode := AX MOD 256
+
+   ELSE
+      stop := ah ;
+      WrS ("exec86: INT 21h function ") ;
+      WrHex (stop) ;
+      WrS (" is not implemented") ;
+      Fault ("unsupported INT 21h function")
+   END
+END DoInt21 ;
+
+
+(* ------------------------------------------------------ F6/F7 unary group *)
+
+PROCEDURE DoUnaryGroup (w : CARDINAL) ;
+(* F6/F7, after DoModRM has run.  reg selects:
+      /0 /1 TEST  /2 NOT  /3 NEG  /4 MUL  /5 IMUL  /6 DIV  /7 IDIV *)
+VAR k, src, res, prod, q, remw : CARDINAL ;
+    ma, mb, mq, mr, ldv, ldd, lq, lr : LONGINT ;
+    neg : BOOLEAN ;
+BEGIN
+   k := rmReg ;
+   CASE k OF
+   | 0, 1 :                                  (* TEST r/m, imm *)
+      IF w = 0 THEN src := Fetch8 () ELSE src := Fetch16 () END ;
+      SetLogic (BAnd (RmRd (w), src, w), w)
+
+   | 2 :                                     (* NOT: no flags at all *)
+      res := BNot (RmRd (w), w) ;
+      RmWr (res, w)
+
+   | 3 :                                     (* NEG *)
+      res := DoSub (0, RmRd (w), 0, w) ;
+      RmWr (res, w)
+
+   | 4 :                                     (* MUL, unsigned *)
+      src := RmRd (w) ;
+      IF w = 1 THEN
+         prod := AX * src ;                  (* max 65535^2 < 2^32 *)
+         DX := prod DIV 65536 ;
+         AX := prod MOD 65536 ;
+         res := AX
+      ELSE
+         (* MUL r8 writes only AX: AL*src, high byte in AH, DX untouched.
+            It is easy to write DX := 0 here out of a false tidiness. *)
+         prod := (AX MOD 256) * src ;
+         AX := prod MOD 65536 ;
+         res := AX
+      END ;
+      (* CF and OF are the documented ones for MUL; SF and ZF are
+         architecturally undefined and are set from the low result rather
+         than left alone, so that they are at least deterministic. *)
+      IF w = 1 THEN
+         CF := DX # 0
+      ELSE
+         CF := prod >= 256
+      END ;
+      OFl := CF ;
+      SetZSF (res, w)
+
+   | 5 :                                     (* IMUL, signed *)
+      src := RmRd (w) ;
+      IF w = 1 THEN
+         ldd := S16 (AX) * S16 (src) ;
+         lq := ldd
+      ELSE
+         ldd := S8 (AX MOD 256) * S8 (src) ;
+         lq := ldd
+      END ;
+      (* The low word of a negative product is exactly lq MOD 65536, because
+         ISO Modula-2's MOD always returns a non-negative remainder, which is
+         the two's complement low word.  The high word is lq DIV 65536 for
+         the same reason: DIV floors, which is an arithmetic shift. *)
+      AX := VAL (CARDINAL, lq MOD VAL (LONGINT, 65536)) ;
+      IF w = 1 THEN
+         DX := VAL (CARDINAL, (lq DIV VAL (LONGINT, 65536))
+                              MOD VAL (LONGINT, 65536)) ;
+         CF := (lq < VAL (LONGINT, -32768))
+               OR (lq > VAL (LONGINT, 32767)) ;
+         res := AX
+      ELSE
+         CF := (lq < VAL (LONGINT, -128))
+               OR (lq > VAL (LONGINT, 127)) ;
+         res := AX
+      END ;
+      OFl := CF ;
+      SetZSF (res, w)
+
+   | 6 :                                     (* DIV, unsigned *)
+      src := RmRd (w) ;
+      IF src = 0 THEN
+         Fault ("divide by zero")
+      ELSE
+         IF w = 1 THEN
+            prod := DX * 65536 + AX ;
+            q := prod DIV src ;
+            remw := prod MOD src ;
+            IF q > 65535 THEN
+               Fault ("DIV: quotient does not fit in AX")
+            ELSE
+               AX := q ;
+               DX := remw ;
+               res := AX
+            END
+         ELSE
+            prod := AX MOD 65536 ;
+            q := prod DIV src ;
+            remw := prod MOD src ;
+            IF q > 255 THEN
+               Fault ("DIV: quotient does not fit in AL")
+            ELSE
+               AX := remw * 256 + q ;
+               res := q
+            END
+         END ;
+         IF NOT isFault THEN
+            CF := FALSE ;
+            OFl := FALSE ;
+            SetZSF (res, w)
+         END
+      END
+
+   ELSE                                      (* /7 IDIV, signed *)
+      src := RmRd (w) ;
+      IF w = 1 THEN
+         ldv := S16 (src) ;
+         ldd := S16 (DX) * VAL (LONGINT, 65536) + VAL (LONGINT, AX)
+      ELSE
+         ldv := S8 (src) ;
+         ldd := S8 (AX MOD 256)
+      END ;
+      IF ldv = VAL (LONGINT, 0) THEN
+         Fault ("divide by zero")
+      ELSE
+         (* x86 IDIV truncates toward zero and gives the remainder the
+            sign of the dividend.  ISO Modula-2's DIV/MOD are Euclidean:
+            the remainder is always non-negative (measured: (-7) MOD 2 = 1,
+            7 MOD (-2) = 1, (-7) DIV 2 = -4).  So the division is redone on
+            magnitudes, where floor and truncation coincide, and the signs
+            are put back afterwards. *)
+         IF ldd < VAL (LONGINT, 0) THEN ma := VAL (LONGINT, 0) - ldd
+         ELSE ma := ldd END ;
+         IF ldv < VAL (LONGINT, 0) THEN mb := VAL (LONGINT, 0) - ldv
+         ELSE mb := ldv END ;
+         mq := ma DIV mb ;
+         mr := ma MOD mb ;
+         neg := (ldd < VAL (LONGINT, 0)) # (ldv < VAL (LONGINT, 0)) ;
+         IF neg THEN lq := VAL (LONGINT, 0) - mq ELSE lq := mq END ;
+         IF ldd < VAL (LONGINT, 0) THEN lr := VAL (LONGINT, 0) - mr
+         ELSE lr := mr END ;
+
+         IF w = 1 THEN
+            IF (lq < VAL (LONGINT, -32768)) OR (lq > VAL (LONGINT, 32767)) THEN
+               Fault ("IDIV: quotient does not fit in AX")
+            ELSE
+               AX := VAL (CARDINAL, lq MOD VAL (LONGINT, 65536)) ;
+               DX := VAL (CARDINAL, lr MOD VAL (LONGINT, 65536)) ;
+               SetZSF (AX, 1)
+            END
+         ELSE
+            IF (lq < VAL (LONGINT, -128)) OR (lq > VAL (LONGINT, 127)) THEN
+               Fault ("IDIV: quotient does not fit in AL")
+            ELSE
+               (* IDIV r8 also overwrites both halves: AL := quotient,
+                  AH := remainder, so the old AH is gone. *)
+               AX := VAL (CARDINAL, lr MOD VAL (LONGINT, 256)) * 256
+                    + VAL (CARDINAL, lq MOD VAL (LONGINT, 256)) ;
+               SetZSF (VAL (CARDINAL, lq MOD VAL (LONGINT, 256)), 0)
+            END
+         END ;
+         IF NOT isFault THEN
+            CF := FALSE ;
+            OFl := FALSE
+         END
+      END
+   END
+END DoUnaryGroup ;
+
+
+(* --------------------------------------------------------- FF group (word) *)
+
+PROCEDURE DoFFGroup ;
+(* FF, after DoModRM has run with w = 1. *)
+VAR k, res : CARDINAL ;
+    cfSave : BOOLEAN ;
+BEGIN
+   k := rmReg ;
+   CASE k OF
+   | 0 :                                     (* INC r/m: CF is preserved *)
+      cfSave := CF ;
+      res := DoAdd (RmRd (1), 1, 0, 1) ;
+      CF := cfSave ;
+      RmWr (res, 1)
+   | 1 :                                     (* DEC r/m: CF is preserved *)
+      cfSave := CF ;
+      res := DoSub (RmRd (1), 1, 0, 1) ;
+      CF := cfSave ;
+      RmWr (res, 1)
+   | 2 :                                     (* CALL near r/m *)
+      Push (IP) ;
+      IP := RmRd (1)
+   | 4 :                                     (* JMP near r/m *)
+      IP := RmRd (1)
+   | 6 :                                     (* PUSH r/m *)
+      Push (RmRd (1))
+   ELSE
+      Fault ("unsupported opcode in the FF group (far call/jump)")
+   END
+END DoFFGroup ;
+
+
+(* ----------------------------------------------------------- one instruction *)
+
+PROCEDURE Step ;
+VAR op, w, f, alu, v, d, imm, res, n, k : CARDINAL ;
+    cfSave : BOOLEAN ;
+BEGIN
+   faultIP := IP ;                           (* what to report if we fault *)
+   op := Fetch8 () ;
+
+   IF (op = 06H) OR (op = 0EH) OR (op = 16H) OR (op = 1EH) THEN
+      (* PUSH ES / CS / SS / DS.  CS and SS are pushed by nothing in the
+         corpus, but the four encodings are one instruction apart and
+         leaving two of them out would be a gap nobody could explain. *)
+      IF op = 06H THEN Push (ES)
+      ELSIF op = 0EH THEN Push (CS)
+      ELSIF op = 16H THEN Push (SS)
+      ELSE Push (DS)
+      END
+
+   ELSIF (op = 07H) OR (op = 17H) OR (op = 1FH) THEN
+      IF op = 07H THEN ES := Pop ()
+      ELSIF op = 17H THEN SS := Pop ()
+      ELSE DS := Pop ()
+      END
+
+   ELSIF (op = 26H) OR (op = 2EH) OR (op = 36H) OR (op = 3EH) THEN
+      Fault ("segment override prefix: this interpreter is 64 KB flat")
+
+   ELSIF op < 40H THEN
+      (* The 00-3D family: eight ALU operations in six encodings each.
+         `alu' is the group number, `f' the form.
+            f = 0,1  op r/m, r        f = 2,3  op r, r/m
+            f = 4    op AL, imm8      f = 5    op AX, imm16
+         f = 6,7 are the prefixes and DAA/DAS/AAA/AAS, all consumed above
+         or unreachable, so reaching here is a real unknown. *)
+      alu := (op DIV 8) MOD 8 ;
+      f := op MOD 8 ;
+      IF f <= 3 THEN
+         w := f MOD 2 ;
+         DoModRM (w) ;
+         IF f <= 1 THEN
+            res := Alu (alu, RmRd (w), GetReg (rmReg, w), w) ;
+            IF alu # 7 THEN RmWr (res, w) END
+         ELSE
+            res := Alu (alu, GetReg (rmReg, w), RmRd (w), w) ;
+            IF alu # 7 THEN PutReg (rmReg, w, res) END
+         END
+      ELSIF f = 4 THEN
+         v := Fetch8 () ;
+         res := Alu (alu, AX MOD 256, v, 0) ;
+         IF alu # 7 THEN PutReg (0, 0, res) END
+      ELSIF f = 5 THEN
+         v := Fetch16 () ;
+         res := Alu (alu, AX, v, 1) ;
+         IF alu # 7 THEN PutReg (0, 1, res) END
+      ELSE
+         Fault ("unknown opcode in the ALU family")
+      END
+
+   ELSIF op < 50H THEN
+      (* INC r16 / DEC r16.  These do NOT affect CF, which is easy to lose:
+         going through DoAdd sets it, so it is saved and restored. *)
+      k := op - 40H ;
+      cfSave := CF ;
+      IF k < 8 THEN
+         res := DoAdd (GetReg (k, 1), 1, 0, 1) ;
+         PutReg (k, 1, res)
+      ELSE
+         res := DoSub (GetReg (k - 8, 1), 1, 0, 1) ;
+         PutReg (k - 8, 1, res)
+      END ;
+      CF := cfSave
+
+   ELSIF op < 60H THEN
+      IF op < 58H THEN
+         Push (GetReg (op - 50H, 1))
+      ELSE
+         PutReg (op - 58H, 1, Pop ())
+      END
+
+   ELSIF op < 70H THEN
+      Fault ("opcode not implemented (60H-6FH is 80186 and later)")
+
+   ELSIF op < 80H THEN
+      d := Fetch8 () ;
+      IF Cond (op - 70H) THEN
+         IP := (IP + SE8 (d)) MOD 65536
+      END
+
+   ELSIF (op = 80H) OR (op = 81H) OR (op = 83H) THEN
+      IF op = 80H THEN w := 0 ELSE w := 1 END ;
+      DoModRM (w) ;
+      k := rmReg ;
+      IF op = 83H THEN
+         imm := SE8 (Fetch8 ())             (* sign-extended to the full width *)
+      ELSIF w = 0 THEN
+         imm := Fetch8 ()
+      ELSE
+         imm := Fetch16 ()
+      END ;
+      res := Alu (k, RmRd (w), imm, w) ;
+      IF k # 7 THEN RmWr (res, w) END
+
+   ELSIF (op = 84H) OR (op = 85H) THEN
+      (* TEST r/m, r.  Same AND and same flag rule as F6/F7 /0; only the
+         encoding differs, and leaving it out while having the other one
+         would be a gap with no reason behind it. *)
+      w := op - 84H ;
+      DoModRM (w) ;
+      SetLogic (BAnd (RmRd (w), GetReg (rmReg, w), w), w)
+
+   ELSIF (op = 86H) OR (op = 87H) THEN
+      w := op - 86H ;
+      DoModRM (w) ;
+      v := RmRd (w) ;
+      RmWr (GetReg (rmReg, w), w) ;
+      PutReg (rmReg, w, v)
+
+   ELSIF (op >= 88H) AND (op <= 8BH) THEN
+      w := op MOD 2 ;
+      DoModRM (w) ;
+      IF op >= 8AH THEN
+         PutReg (rmReg, w, RmRd (w))
+      ELSE
+         RmWr (GetReg (rmReg, w), w)
+      END
+
+   ELSIF op = 8DH THEN
+      DoModRM (1) ;
+      IF eaIsReg THEN
+         Fault ("LEA with a register operand is not an address")
+      ELSE
+         PutReg (rmReg, 1, eaAddr)
+      END
+
+   ELSIF (op >= 90H) AND (op <= 97H) THEN
+      k := op - 90H ;
+      IF k # 0 THEN                          (* 90 is NOP *)
+         v := AX ;
+         AX := GetReg (k, 1) ;
+         PutReg (k, 1, v)
+      END
+
+   ELSIF op = 98H THEN                       (* CBW: sign-extend AL into AX *)
+      IF (AX MOD 256) >= 80H THEN
+         AX := 65280 + (AX MOD 256)
+      ELSE
+         AX := AX MOD 256
+      END
+
+   ELSIF op = 99H THEN                       (* CWD: sign-extend AX into DX *)
+      IF AX >= 8000H THEN DX := 0FFFFH ELSE DX := 0 END
+
+   ELSIF (op >= 0A0H) AND (op <= 0A3H) THEN
+      d := Fetch16 () ;                      (* moffs: DS is 0, checked in Run86 *)
+      CASE op OF
+      | 0A0H : AX := (AX DIV 256) * 256 + mem [d]
+      | 0A1H : AX := mem [d] + 256 * mem [(d + 1) MOD 65536]
+      | 0A2H : mem [d] := AX MOD 256
+      ELSE     mem [d] := AX MOD 256 ;
+               mem [(d + 1) MOD 65536] := (AX DIV 256) MOD 256
+      END
+
+   ELSIF (op >= 0B0H) AND (op <= 0BFH) THEN
+      IF op < 0B8H THEN
+         PutReg (op - 0B0H, 0, Fetch8 ())
+      ELSE
+         PutReg (op - 0B8H, 1, Fetch16 ())
+      END
+
+   ELSIF op = 0C3H THEN                      (* RET *)
+      IP := Pop ()
+
+   ELSIF op = 0C9H THEN                      (* LEAVE: SP := BP; BP := POP *)
+      SP := BP ;
+      BP := Pop ()
+
+   ELSIF op = 0CDH THEN                      (* INT *)
+      n := Fetch8 () ;
+      IF n = 21H THEN
+         DoInt21 ()
+      ELSE
+         Fault ("only INT 21h is provided; this is not a real-mode machine")
+      END
+
+   ELSIF op = 0E2H THEN                      (* LOOP: CX is not a flag *)
+      d := Fetch8 () ;
+      CX := (CX + 65535) MOD 65536 ;
+      IF CX # 0 THEN
+         IP := (IP + SE8 (d)) MOD 65536
+      END
+
+   ELSIF op = 0E3H THEN                      (* JCXZ *)
+      d := Fetch8 () ;
+      IF CX = 0 THEN
+         IP := (IP + SE8 (d)) MOD 65536
+      END
+
+   ELSIF op = 0E8H THEN                      (* CALL rel16 *)
+      d := Fetch16 () ;
+      Push (IP) ;
+      IP := (IP + d) MOD 65536
+
+   ELSIF op = 0E9H THEN                      (* JMP rel16 *)
+      d := Fetch16 () ;
+      IP := (IP + d) MOD 65536
+
+   ELSIF op = 0EBH THEN                      (* JMP rel8 *)
+      d := Fetch8 () ;
+      IP := (IP + SE8 (d)) MOD 65536
+
+   ELSIF (op = 0F6H) OR (op = 0F7H) THEN
+      DoModRM (op - 0F6H) ;
+      DoUnaryGroup (op - 0F6H)
+
+   ELSIF op = 0FFH THEN
+      DoModRM (1) ;
+      DoFFGroup
+
+   ELSE
+      WrS ("exec86: unknown opcode ") ;
+      WrHex (op) ;
+      WrS (" at ") ;
+      WrHex (faultIP) ;
+      WrErr1 (CHR (10)) ;
+      Fault ("unknown opcode")
+   END
+END Step ;
+
+
+(* ------------------------------------------------------------------ public *)
+
+PROCEDURE Clear86 ;
+VAR i : CARDINAL ;
+BEGIN
+   FOR i := 0 TO 65535 DO
+      mem [i] := 0
+   END ;
+   AX := 0 ; BX := 0 ; CX := 0 ; DX := 0 ;
+   SI := 0 ; DI := 0 ; BP := 0 ;
+   SP := 0FFFEH ;                            (* bootcom's SS:SP = 0000:FFFE *)
+   CS := 0 ; DS := 0 ; ES := 0 ; SS := 0 ;
+   IP := LoadAt ;                            (* bootcom's JMP 0000:0100 *)
+   CF := FALSE ; ZF := FALSE ; SF := FALSE ; OFl := FALSE ;
+   halted := FALSE ;
+   haltCode := 0 ;
+   isFault := FALSE ;
+   faultIP := LoadAt ;
+   loadHi := LoadAt ;
+   stepCnt := 0 ;
+   eaAddr := 0 ; eaReg := 0 ; eaIsReg := FALSE ; rmReg := 0
+END Clear86 ;
+
+PROCEDURE Poke86 (addr, value : CARDINAL) ;
+BEGIN
+   IF addr > 65535 THEN
+      faultIP := IP ;
+      Fault ("Poke address is outside the 64 KB machine")
+   ELSE
+      mem [addr] := value MOD 256 ;
+      IF addr >= loadHi THEN
+         loadHi := addr + 1
+      END
+   END
+END Poke86 ;
+
+PROCEDURE Run86 (VAR exitCode : CARDINAL; VAR steps : LONGCARD) : CARDINAL ;
+VAR cap : LONGCARD ;
+BEGIN
+   exitCode := 0 ;
+   steps := 0 ;
+   isFault := FALSE ;
+   halted := FALSE ;
+   cap := VAL (LONGCARD, MaxSteps) ;
+   LOOP
+      IF halted THEN
+         exitCode := haltCode ;
+         steps := stepCnt ;
+         RETURN 0
+      END ;
+      IF isFault THEN
+         steps := stepCnt ;
+         RETURN 1
+      END ;
+      IF stepCnt >= cap THEN
+         faultIP := IP ;
+         WrS ("exec86: step limit ") ;
+         WrDec (MaxSteps) ;                  (* runaway guard, Exec86.def *)
+         WrS (" reached at IP=") ;
+         WrHex (IP) ;
+         WrErr1 (CHR (10)) ;
+         steps := stepCnt ;
+         RETURN 2
+      END ;
+      (* Checked before every step, not once at the start: the machine is
+         64 KB flat, so a non-zero segment would silently alias onto the
+         same memory instead of faulting. *)
+      IF (CS # 0) OR (DS # 0) OR (ES # 0) OR (SS # 0) THEN
+         faultIP := IP ;
+         Fault ("a segment register is not zero; this machine is 64 KB flat") ;
+         steps := stepCnt ;
+         RETURN 1
+      END ;
+      IF (IP < LoadAt) OR (IP >= loadHi) THEN
+         faultIP := IP ;
+         Fault ("execution left the loaded image") ;
+         steps := stepCnt ;
+         RETURN 1
+      END ;
+      Step () ;
+      stepCnt := stepCnt + 1
+   END
+END Run86 ;
+
+END Exec86.

+ 8 - 2
shell/Makefile

@@ -6,8 +6,8 @@ FLAGS = -fiso
 # pass-3 rollup on a compiler-size program ("too many errors in pass 3"), so
 # the import closure is generated first and the link then consumes it.  Phase 1
 # exiting 1 is the expected rollup, hence the `|| true`.
-MODS  = Shell Term Posix TextBuf Editor Compiler Runtime Linker
-LINK  = Shell.mod Term.o Posix.o TextBuf.o Editor.o Compiler.o Runtime.o Linker.o
+MODS  = Shell Term Posix TextBuf Editor Compiler Runtime Linker Exec86
+LINK  = Shell.mod Term.o Posix.o TextBuf.o Editor.o Compiler.o Runtime.o Linker.o Exec86.o
 
 all: tpshell
 
@@ -39,6 +39,12 @@ Runtime.o: Runtime.mod Runtime.def
 Linker.o: Linker.mod Linker.def Compiler.def Posix.def
 	$(GM2) $(FLAGS) -c Linker.mod
 
+# Exec86.def is HAND-MAINTAINED (see its own header): gm2 checks Exec86.mod
+# against it and never rewrites it, so it is a prerequisite on purpose - an
+# interface change that forgets the .def must not link.
+Exec86.o: Exec86.mod Exec86.def
+	$(GM2) $(FLAGS) -c Exec86.mod
+
 clean:
 	rm -f *.o tpshell tpshell.lst
 .PHONY: all clean

+ 80 - 3
shell/Shell.mod

@@ -15,13 +15,20 @@ FROM Posix IMPORT
    getcwd, chdir, opendir, readdir, closedir, statvfs,
    Dir, dirent, statvfsbuf ;
 
-FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes ;
+FROM Compiler IMPORT Compile, CodeBytes, DataBytes, ImageBytes, ImageByteAt ;
 FROM Linker IMPORT LinkSize, WriteCom ;
 FROM Editor IMPORT Run, GotoOffset ;
 
 FROM TextBuf IMPORT
    TextLimit, Clear, Length, CharAt, InsertCh ;
 
+(* The 86 suffix on these three is not decoration: ISO Modula-2 has neither
+   import renaming nor a procedure-local import, so every import in this
+   module shares one flat namespace, and `Clear' and `Run' were already
+   TextBuf's and Editor's.  Exec86.def's header records the same fact from
+   the other side. *)
+FROM Exec86 IMPORT Clear86, Poke86, Run86 ;
+
 FROM SYSTEM IMPORT ADR, ADDRESS, BYTE ;
 
 CONST
@@ -730,12 +737,82 @@ BEGIN
 END CmdCompile ;
 
 PROCEDURE CmdRun ;
+(* TP3's `R': compile, then run what came out.
+
+   The original has no separate loader here - krungo runs the image where it
+   already is, in the same 64 KB, at the address DOS would give a .COM.
+   Exec86 has the same shape: the linked image is poked into the
+   interpreter's own 64 KB at 0100H and executed in this process, with no
+   file written, so R does the same thing whether Destination is Memory or
+   .COM.
+
+   The guest's output goes to this process's fd 1 through the runtime's
+   INT 21h AH=02/09/08, and it lands between our two status lines rather
+   than being collected and replayed: Term writes one byte per write(2) and
+   buffers nothing, so the two streams stay in order.
+
+   Clear86, Poke86 and Run86 are module-level imports like everything else
+   here; the suffix is explained where they are imported. *)
+VAR
+   status, exitCode, i, n : CARDINAL ;
+   steps : LONGCARD ;
 BEGIN
    ClrScr ;
    GotoXY (1, 1) ;
-   PutStr ("Interpreter pending - compiled code is in memory") ;
+   IF NOT Compile (errNo, errPos) THEN
+      PutStr ("TP3-style error ") ;
+      PutCard (errNo) ;
+      PutStr (" at relative pos ") ;
+      PutCard (errPos) ;
+      CrLf ;
+      PutStr ("press ESC, then the editor opens on the error") ;
+      CrLf ;
+      WaitEsc ;
+      GotoOffset (errPos) ;
+      CmdEditor ;
+      RETURN
+   END ;
+   PutStr ("Compiled OK - code ") ;
+   PutCard (CodeBytes ()) ;
+   PutStr (" bytes, data ") ;
+   PutCard (DataBytes ()) ;
+   CrLf ;
+   (* LinkSize, not ImageBytes: the .COM DOS would load is the image padded
+      out to cover the whole data area, and the globals the program is about
+      to use live in that padding.  ImageByteAt answers 0 past the end of the
+      image, which is exactly the zero fill the padding is made of. *)
+   n := LinkSize () ;
+   Clear86 ;
+   i := 0 ;
+   WHILE i < n DO
+      Poke86 (0100H + i, ORD (ImageByteAt (i))) ;
+      INC (i)
+   END ;
+   PutStr ("running ") ;
+   PutCard (n) ;
+   PutStr (" bytes at 0100h") ;
+   CrLf ;
+   CrLf ;
+   status := Run86 (exitCode, steps) ;
+   CrLf ;
+   CASE status OF
+   | 0 :
+        PutStr ("program terminated (INT 21h AH=4Ch, code ") ;
+        PutCard (exitCode) ;
+        PutStr (")")
+   | 1 :
+        PutStr ("interpreter fault - diagnostic above")
+   | 2 :
+        PutStr ("step limit reached - runaway loop?")
+   ELSE
+        PutStr ("unexpected status ") ;
+        PutCard (status)
+   END ;
+   CrLf ;
+   PutStr ("steps executed: ") ;
+   PutLongCard (steps) ;
    CrLf ;
-   PutStr ("(TP3 option R / debugger not yet wired)") ;
+   PutStr ("press ESC to return to the editor") ;
    CrLf ;
    WaitEsc
 END CmdRun ;

+ 227 - 0
shell/tests/Exec86Run.mod

@@ -0,0 +1,227 @@
+MODULE Exec86Run ;
+
+(* Exec86Run -- run one .COM under the Exec86 interpreter and report what
+   happened, so that a Python harness can compare it against qemu.
+
+   stdin is: one line naming the .COM, then everything else is the guest's
+   input.  That line is read a byte at a time through the raw read(), not
+   through any buffered reader, because the guest reads fd 0 itself: a stdio
+   buffer that had already swallowed the first few input bytes would look
+   exactly like a program that parses its input wrongly, and that is the
+   kind of failure that gets blamed on the code under test.
+
+   stdout belongs to the guest and is never written to here.
+
+   stderr carries exactly one line
+
+      exec86: status=<0|1|2> exit=<n> steps=<n>
+
+   where 0 = halted through INT 21h AH=4Ch, 1 = fault (its diagnostic is on
+   stderr just before this line), 2 = the runaway step limit.  The harness
+   asserts on that line rather than on the process exit code: the guest is
+   free to exit with 126, and an exit code that cannot tell "the program
+   returned 126" from "the interpreter broke" would be an assertion with a
+   hole in it.  The process exit code is set to agree anyway - the guest's
+   own code when status is 0, 126 on a fault, 127 on the step limit - so
+   that running this by hand in a shell still says something useful.
+
+   The input is delivered on fd 0 rather than through the memory descriptor
+   bootcom.s uses (INLEN at 2000h, INBUF at 2004h).  The two are the same
+   machine as far as a guest can tell: bootcom returns the descriptor's bytes
+   in order and then 1Ah for ever, and fd 0 returns the same bytes in order
+   and then read() = 0, which is turned into 1Ah for ever by the same
+   clause.  They agree exactly when the pipe holds exactly the fixture's
+   .in file, which is what the harness feeds.  Writing that down because
+   "we feed it differently and it still matched" is precisely the kind of
+   agreement that stops holding the day someone appends a newline. *)
+
+FROM Posix IMPORT read, write, open, close ;
+FROM Exec86 IMPORT Clear86, Poke86, Run86 ;
+FROM SYSTEM IMPORT ADR, BYTE ;
+
+CONST
+   STDIN  = 0 ;
+   STDERR = 2 ;
+   O_RDONLY = 0 ;
+   LoadAt = 100H ;            (* DOS's .COM entry point, same as bootcom's *)
+
+VAR
+   path  : ARRAY [0..511] OF CHAR ;
+   fbuf  : ARRAY [0..65535] OF BYTE ;
+   fsize : CARDINAL ;
+
+
+PROCEDURE PutS (s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+    n : LONGINT ;
+    c : CHAR ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      c := s [i] ;
+      n := write (STDERR, ADR (c), 1) ;
+      i := i + 1
+   END
+END PutS ;
+
+PROCEDURE PutCh (c : CHAR) ;
+VAR n : LONGINT ;
+BEGIN
+   n := write (STDERR, ADR (c), 1)
+END PutCh ;
+
+PROCEDURE PutLong (n : LONGCARD) ;
+(* Long enough for LONGCARD itself, though the only value printed with it is
+   the step count, which the runaway cap already bounds below CARDINAL's
+   maximum.  Printed anyway rather than narrowed with VAL, because narrowing
+   a counter is how a count that has grown past its type stops being a count. *)
+VAR dig : ARRAY [0..20] OF CHAR ;
+    i : CARDINAL ;
+    v : LONGCARD ;
+BEGIN
+   i := 20 ;
+   dig [20] := 0C ;
+   v := n ;
+   REPEAT
+      i := i - 1 ;
+      dig [i] := CHR (ORD ('0') + VAL (CARDINAL, v MOD VAL (LONGCARD, 10))) ;
+      v := v DIV VAL (LONGCARD, 10)
+   UNTIL v = 0 ;
+   (* digits occupy i..19 with the most significant first, and dig[20] was
+      set only so that the arithmetic above cannot walk off the end. *)
+   WHILE i <= 19 DO
+      PutCh (dig [i]) ;
+      i := i + 1
+   END
+END PutLong ;
+
+PROCEDURE PutN (v : CARDINAL) ;
+VAR dig : ARRAY [0..10] OF CHAR ;
+    i, x : CARDINAL ;
+    n : LONGINT ;
+    c : CHAR ;
+BEGIN
+   i := 10 ;
+   dig [10] := 0C ;
+   x := v ;
+   REPEAT
+      i := i - 1 ;
+      dig [i] := CHR (ORD ('0') + x MOD 10) ;
+      x := x DIV 10
+   UNTIL x = 0 ;
+   x := i ;
+   WHILE x <= 9 DO
+      c := dig [x] ;
+      n := write (STDERR, ADR (c), 1) ;
+      x := x + 1
+   END
+END PutN ;
+
+PROCEDURE Terminate (code : CARDINAL) ;
+BEGIN
+   HALT (code)
+END Terminate ;
+
+PROCEDURE Die (msg : ARRAY OF CHAR; code : CARDINAL) ;
+BEGIN
+   PutS ("exec86run: ") ;
+   PutS (msg) ;
+   PutCh (CHR (10)) ;
+   Terminate (code)
+END Die ;
+
+PROCEDURE ReadPathLine () : BOOLEAN ;
+(* Reads the first line of stdin into `path', one byte at a time through
+   read().  Returns FALSE at end of input with nothing read. *)
+VAR ch : CHAR ;
+    n : LONGINT ;
+    i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   LOOP
+      n := read (STDIN, ADR (ch), 1) ;
+      IF n # 1 THEN
+         path [i] := 0C ;
+         RETURN i > 0
+      END ;
+      IF (ch = CHR (10)) OR (ch = CHR (13)) THEN
+         path [i] := 0C ;
+         RETURN TRUE
+      END ;
+      IF i < HIGH (path) - 1 THEN
+         path [i] := ch ;
+         i := i + 1
+      END
+   END
+END ReadPathLine ;
+
+PROCEDURE ReadCom () : BOOLEAN ;
+VAR fd, k : LONGINT ;
+    z : ARRAY [0..511] OF CHAR ;
+    i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (path)) AND (i < HIGH (z)) AND (path [i] # 0C) DO
+      z [i] := path [i] ;
+      i := i + 1
+   END ;
+   z [i] := 0C ;
+   fd := open (ADR (z), O_RDONLY, 0) ;
+   IF fd < 0 THEN
+      RETURN FALSE
+   END ;
+   fsize := 0 ;
+   LOOP
+      IF fsize >= 65536 THEN EXIT END ;
+      k := read (fd, ADR (fbuf [fsize]), VAL (LONGCARD, 65536 - fsize)) ;
+      IF k <= 0 THEN EXIT END ;
+      fsize := fsize + VAL (CARDINAL, k)
+   END ;
+   k := close (fd) ;
+   RETURN TRUE
+END ReadCom ;
+
+VAR
+   exitCode : CARDINAL ;
+   steps    : LONGCARD ;
+   ix, st   : CARDINAL ;
+
+BEGIN
+   IF NOT ReadPathLine () THEN
+      Die ("no .COM path on stdin", 125)
+   END ;
+   IF NOT ReadCom () THEN
+      Die ("cannot open the .COM", 125)
+   END ;
+   IF fsize = 0 THEN
+      Die ("the .COM is empty", 125)
+   END ;
+   IF fsize > 65536 - LoadAt THEN
+      Die ("the .COM does not fit below 10000h", 125)
+   END ;
+
+   Clear86 ;
+   ix := 0 ;
+   WHILE ix < fsize DO
+      Poke86 (LoadAt + ix, ORD (fbuf [ix])) ;
+      ix := ix + 1
+   END ;
+
+   st := Run86 (exitCode, steps) ;
+
+   PutS ("exec86: status=") ;
+   PutN (st) ;
+   PutS (" exit=") ;
+   PutN (exitCode) ;
+   PutS (" steps=") ;
+   PutLong (steps) ;
+   PutCh (CHR (10)) ;
+
+   IF st = 0 THEN
+      Terminate (exitCode)
+   ELSIF st = 1 THEN
+      Terminate (126)
+   ELSE
+      Terminate (127)
+   END
+END Exec86Run.

+ 9 - 0
shell/tests/audit_helpers.py

@@ -377,6 +377,15 @@ PATTERNS = [
     # --- immediate against a register --------------------------------
     (re.compile(r"^(Add|Sub|Cmp|Xor|And|Or)(%s)(\d+)$" % _ALT),
      None, ["r:%(2)s", "i:%(3)s"], {0x83}),
+    # --- XOR AL,#imm8 is `34 ib': no ModRM byte at all, and only the
+    #     accumulator.  The row above is pinned to 83 /r, which is the word
+    #     `op r, imm8' encoding, so `XorAl01' matched it and was rejected on
+    #     the opcode - leaving the helper with NO reading rather than a wrong
+    #     one, which is how the audit reported it.  Narrow on purpose: 34 is
+    #     XOR AL, nothing else, so a name claiming `xor al, 1h' against any
+    #     other byte fails here.  This is TPSRC9 neglevel's boolean NOT.
+    (re.compile(r"^XorAl(\d+)$"),
+     "xor", ["r:Al", "i:%(1)s"], {0x34}),
     # --- one register: INC CX / DEC SI / NOT DX / NEG AX ------------
     (re.compile(r"^(Inc|Dec|Not|Neg|Shl|Shr|Sar)(%s)$" % _ALT),
      None, ["r:%(2)s"], None),

+ 27 - 9
shell/tests/check_8086.py

@@ -100,6 +100,10 @@ HERE = os.path.dirname(os.path.abspath(__file__))
 SHELL = os.path.dirname(HERE)
 sys.path.insert(0, HERE)
 import disasm16  # noqa: E402
+# The image layout: where the header is, how big it is, and the load bias this
+# file used to carry on its own account.  See tests/check_comimage.py, which
+# asserts that every reader of a linked image gets these from one place.
+import comimage  # noqa: E402
 
 COMTEST = os.path.join(SHELL, "comtest")
 
@@ -128,7 +132,6 @@ def negated(nib):
     """The Jcc nibble the image will carry for a site that declares `nib'."""
     return nib ^ 1
 
-LOAD_BIAS = 0x100        # Runtime.def: a .COM's byte 0 lands at DS:0100h
 CMP_AX_CX = b"\x3b\xc1"  # EmCmpAxCx
 CMP_AX_ZERO = b"\x3d\x00\x00"   # EmCmpAxi (0)
 
@@ -280,13 +283,27 @@ def program_code_region(img, rt_size):
     offsets, derived from the image rather than from a restated constant: the
     entry jump's displacement is the program's own answer for where the code
     begins, and hdrCS is `pc + LoadBias' with pc the end of the generated code,
-    so hdrCS - LoadBias is where it stops."""
-    hdr = 3 + rt_size
-    if len(img) < hdr + 16:
+    so hdrCS - LoadBias is where it stops.
+
+    Where the header is gets two INDEPENDENT answers: rt_size is the runtime
+    blob's size as the probe measured it, and comimage.find_header locates the
+    header inside this file by its own self-consistency equation.  They are
+    measurements of two different artefacts, so their agreement is evidence,
+    and a .COM whose two disagree has no code region this checker can state --
+    guessing one of them would be the restated constant this function exists
+    to avoid."""
+    hdr_probe = comimage.ENT_SZ + rt_size
+    hdr_file = comimage.find_header(img)
+    if hdr_file is None or hdr_file != hdr_probe:
         return None
-    start = (3 + int.from_bytes(img[1:3], "little", signed=True)) % 0x10000
-    hdr_cs = int.from_bytes(img[hdr + 2:hdr + 4], "little")
-    end = hdr_cs - LOAD_BIAS
+    if len(img) < hdr_file + comimage.HDR_SZ:
+        return None
+    start = comimage.entry_target(img)
+    if start is None:
+        return None
+    start %= 0x10000
+    hdr_cs = int.from_bytes(img[hdr_file + 2:hdr_file + 4], "little")
+    end = hdr_cs - comimage.LOAD_BIAS
     if not (start <= end <= len(img)):
         return None
     return start, end
@@ -330,7 +347,8 @@ def main(argv):
             region = program_code_region(img, rt_info["size"])
             if region is None:
                 problems.append("%s: could not locate the code region "
-                                "(entry jump and hdrCS disagree)" % name)
+                                "(entry jump, program header and hdrCS do not "
+                                "agree)" % name)
                 continue
             start, end = region
             code = img[start:end]
@@ -412,7 +430,7 @@ def main(argv):
             # whole thing is code -- assert no 0F opcode over it as well.  This
             # is extra coverage, not the backbone, and how much of the suite
             # it reached is printed rather than implied.
-            instrs, stopped = sweep(code, LOAD_BIAS + start)
+            instrs, stopped = sweep(code, comimage.LOAD_BIAS + start)
             if stopped is None:
                 swept_fixtures += 1
                 swept_bytes += len(code)

+ 149 - 0
shell/tests/check_comimage.py

@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""check_comimage.py -- the image layout is described in exactly one file.
+
+Any check that reads a linked .COM has to know where its parts are: the entry
+jump is three bytes, the header is sixteen, initmem's first bytes sit at ENT_SZ
+and the header is findable by its own signature.  Before tests/comimage.py
+existed that knowledge lived in three places - comtest.py had a find_header,
+the independent checker inside run_com_tests.sh had a second copy of it, and
+check_framedisp.py had a third answer of an entirely different kind: a literal
+RT_SZ that had drifted from the runtime it claimed to describe.
+
+Three copies are three chances to drift, and drift is SILENT here, because each
+copy is self-consistent on its own terms: every one of them reports confidently
+and only the world disagrees.  So the layout lives in one file, and this asserts
+that it stays that way:
+
+  A. comimage.py defines each part exactly once, and imports.  Counting rather
+     than merely searching is the point: a rule that matches nothing looks
+     exactly like a rule that passes (the lesson in tests/rt_exec.py's
+     "matched nothing" case), so a deleted definition would otherwise be a green
+     report about a subject nobody examined.
+  B. no other test source defines any of those names.
+  C. every test source that CALLS find_header takes it from comimage rather
+     than from some other consumer: `from comtest import find_header' would be
+     one helper reached through two of them, and the second of those two is a
+     copy waiting to drift.  Definitions quoted inside a mutation string are
+     not calls, so `def find_header(d):' written as a case's target does not
+     count - nonvacuity.sh would otherwise fail this audit for naming the very
+     thing it is breaking.
+
+Scope, stated so this is not read as more than it is
+----------------------------------------------------
+This cannot see a check that HAND-ROLLS the layout from literals - check_8086
+used to write `hdr = 3 + rt_size', which defines none of these names and would
+have passed here untouched.  Numbers written inline are caught a different way:
+check_8086.program_code_region and check_framedisp.main both state the layout
+twice, from two independent sources (the probe-measured runtime size against
+the header's own equation; the entry jump against the header's end), and
+require the two to agree.  A disagreement is reported instead of swept, and
+tests/nonvacuity.sh turns each of those red on purpose.
+
+Usage: check_comimage.py     (from shell/)
+"""
+
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+COMIMAGE = os.path.join(HERE, "comimage.py")
+
+# The names that make up the layout of a linked image.
+LAYOUT = ["ENT_SZ", "HDR_SZ", "LOAD_BIAS", "HEAD", "HDR_DS_WORD",
+          "HDR_HEAP_WORD"]
+
+# rt_exec.py states a load bias of its own, and deliberately so: the address
+# space it works in is the boot image IT builds (see build_image, and
+# exec/rtdrv.s, which carries a .set of the same value for the same reason).
+# Those two are independent statements checked against each other by the cases
+# failing if they disagree - and rtdrv.s cannot import a Python module.  It is a
+# different artefact's bias, not a copy of this one.
+EXEMPT = {("rt_exec.py", "LOAD_BIAS")}
+
+DEF_FN = re.compile(r"^\s*def\s+find_header\b")
+IMPORTS = re.compile(r"^\s*(?:import\s+comimage\b|from\s+comimage\s+import\b)")
+# A CALL, not a definition.  The lookbehind is what lets nonvacuity.sh quote
+# `def find_header(d):' as its mutation target without this audit reporting the
+# harness itself as a second copy of the helper.
+MENTIONS = re.compile(r"(?<!def )\bfind_header\s*\(")
+
+
+def sources():
+    """every test source this audit reads, as (relative path, lines)"""
+    out = []
+    for root, dirs, files in os.walk(HERE):
+        dirs[:] = sorted(d for d in dirs if not d.startswith("."))
+        for f in sorted(files):
+            if f == "comimage.py" or not (f.endswith(".py")
+                                          or f.endswith(".sh")):
+                continue
+            path = os.path.join(root, f)
+            with open(path, "r") as fh:
+                out.append((os.path.relpath(path, HERE),
+                            fh.read().splitlines()))
+    return out
+
+
+def main():
+    problems = []
+
+    # A: the helper exists, is complete, and loads.  The import runs
+    # comimage's own module-level assertion (initmem's displacements against
+    # the header word offsets), so a broken helper is caught here too.
+    if not os.path.exists(COMIMAGE):
+        print("FAIL: comimage.py does not exist")
+        return 1
+    try:
+        sys.path.insert(0, HERE)
+        import comimage  # noqa: F401
+    except Exception as exc:                      # noqa: BLE001
+        print("FAIL: comimage.py does not import: %s" % exc)
+        return 1
+
+    # Each name is matched on its own.  A single alternation would count one
+    # line for every name in it - six definitions each, from six lines total -
+    # which reports the layout as six times duplicated while it is defined once.
+    defs = dict((name, re.compile(r"^\s*%s\s*=" % re.escape(name)))
+                for name in LAYOUT)
+
+    with open(COMIMAGE) as fh:
+        comimage_lines = fh.read().splitlines()
+    for name in LAYOUT:
+        n = sum(1 for ln in comimage_lines if defs[name].match(ln))
+        if n != 1:
+            problems.append("comimage.py defines %s %d times, want exactly 1"
+                            % (name, n))
+    nfn = sum(1 for ln in comimage_lines if DEF_FN.match(ln))
+    if nfn != 1:
+        problems.append("comimage.py defines find_header %d times, want "
+                        "exactly 1" % nfn)
+
+    # B and C: every other test source.
+    for rel, lines in sources():
+        for ln in lines:
+            if DEF_FN.match(ln):
+                problems.append("%s defines its own find_header" % rel)
+            for name in LAYOUT:
+                if (rel, name) in EXEMPT:
+                    continue
+                if defs[name].match(ln):
+                    problems.append("%s defines its own %s" % (rel, name))
+        mentions = [ln for ln in lines if MENTIONS.search(ln)]
+        if mentions and not any(IMPORTS.match(ln) for ln in lines):
+            problems.append("%s calls find_header without importing it from "
+                            "comimage" % rel)
+
+    if problems:
+        print("FAIL: the image layout is not described in exactly one file")
+        for p in problems:
+            print("  - %s" % p)
+        return 1
+    print("image layout: defined once in comimage.py and imported by every "
+          "reader (%d names, %d sources scanned)"
+          % (len(LAYOUT) + 1, len(sources()) + 1))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 67 - 8
shell/tests/check_framedisp.py

@@ -51,6 +51,32 @@ restatement of what the compiler happens to do:
     parameters  parmOff starts at 4 and grows by 2 per parameter, so
                 parameter k is at 4 + 2*(k-1).
 
+The region this check reads
+---------------------------
+A displacement only means something about the instruction that contains it, so
+this check sweeps the program's OWN instructions, from the first one to the
+end of the image.  Two different things in the file say where the first one
+is, and they are read separately:
+
+    the entry jump  `E9 rel16' at file offset 0, so execution begins at
+                    ENT_SZ + rel16 (Compiler.Inittur)
+    the program     found by its own signature (comimage.find_header); the
+    header          program code follows the header immediately
+
+They must agree.  They are not the same measurement -- one is a jump target,
+the other is a self-consistency equation over header words -- so their
+agreement is evidence, and a disagreement means the region is undetermined,
+which this check reports instead of sweeping something anyway.
+
+Until this section was written the region was a literal: RT_SZ = 391, next to
+a comment saying it tracked Runtime.RT_Size().  It had drifted, the runtime
+having grown past it, so the sweep began inside the RUNTIME, part way through
+an instruction -- and the check passed.  It passed because nothing in it could
+see where its own region began: the byte patterns are found wherever they are
+in the image, and a decode sweep that starts mid-instruction happened to reach
+the same offsets.  A wrong input that produces the right answers is still a
+wrong input, and it stops being right the first time the layout moves.
+
 Fixtures
 --------
 t27_localvar.pas  five locals, assigned and read back.  Covers the negative
@@ -82,13 +108,18 @@ HERE = os.path.dirname(os.path.abspath(__file__))
 SHELL = os.path.dirname(HERE)
 sys.path.insert(0, HERE)
 import disasm16  # noqa: E402
-
-# Re-stated, not asked of the code under test.  The image starts with a
-# three-byte entry JMP (see Compiler.Inittur), so the program code begins at
-# ENT_SZ + RT_SZ rather than at the runtime size alone.
-RT_SZ = 391              # Runtime.RT_Size()
-ENT_SZ = 3               # E9 lo hi
-RTSZ = ENT_SZ + RT_SZ
+# Where the program header is, where the entry jump lands, and how big the
+# header is.  This check used to write the runtime's size down as RT_SZ = 391
+# beside a comment claiming it tracked Runtime.RT_Size(), and swept from
+# ENT_SZ + RT_SZ - which is inside the RUNTIME, part way through an
+# instruction, and nowhere near the program's first instruction.  It still
+# passed, for the two reasons a wrong region always passes: the byte patterns
+# below are in the image wherever they happen to be, and the decode sweep
+# reached the same answers from a start point that nothing in the check could
+# tell was wrong.  So the region is now measured from the file AND asserted to
+# agree with itself in main(), because a check whose own input is wrong reports
+# confidently about bytes the program never executes.
+import comimage  # noqa: E402
 COMTEST = os.path.join(SHELL, "comtest")
 
 # (fixture, opcode, [signed offsets])
@@ -180,7 +211,35 @@ def main(argv):
 
     for fixture, want_op, offsets in CASES:
         img = images[fixture]
-        code = img[RTSZ:]
+        # The region this check sweeps, measured twice from the file: where the
+        # entry jump says execution starts, and where the program header says
+        # the runtime ends.  Those are different facts read from different
+        # bytes, so a check that knows only one of them cannot tell that its
+        # region is wrong -- which is exactly what happened while this was a
+        # literal: the sweep began inside the runtime and reported PASS.  The
+        # two must agree or there is no measured place to start, and a guess
+        # would be reported here as a fact about the compiler.
+        entry = comimage.entry_target(img)
+        hdr = comimage.find_header(img)
+        if entry is None:
+            problems.append("%s: no entry jump at file offset 0, so the image "
+                            "says nothing about where the program's code "
+                            "begins and this check would have to guess"
+                            % fixture)
+            continue
+        if hdr is None:
+            problems.append("%s: no program header found, so the end of the "
+                            "runtime is unknown and this check would have to "
+                            "guess where the program's code begins" % fixture)
+            continue
+        if entry != hdr + comimage.HDR_SZ:
+            problems.append("%s: the entry jump lands on %d but the program "
+                            "header ends at %d -- the two measurements of "
+                            "where the program's code begins disagree, so "
+                            "neither region is swept" % (fixture, entry,
+                                                         hdr + comimage.HDR_SZ))
+            continue
+        code = img[entry:]
         op = int(want_op, 16)
         mine = [d for (o, _, d) in bp_operands(code) if o == want_op]
         if verbose:

+ 5 - 1
shell/tests/check_runtime.py

@@ -53,7 +53,10 @@ import sys
 HERE = os.path.dirname(os.path.abspath(__file__))
 SHELL = os.path.dirname(HERE)
 GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2"
-RTPROBE = "/tmp/tp_check_rtprobe"
+# Scratch artifacts live in the repo's own tmp/ folder, beside the tree that
+# produced them, so a failing run's evidence cannot be lost in /tmp.
+TMP = os.path.normpath(os.path.join(SHELL, "..", "tmp"))
+RTPROBE = os.path.join(TMP, "tp_check_rtprobe")
 GOLDEN_FILE = os.path.join(HERE, "runtime.golden")
 
 RE_SIZE = re.compile(r"^(\d+) bytes$")
@@ -170,6 +173,7 @@ GOLDEN = {
 
 
 def build_and_dump():
+    os.makedirs(TMP, exist_ok=True)
     subprocess.run([GM2, "-fiso", "-Wall", "-c", "Runtime.mod"],
                    cwd=SHELL, check=True)
     subprocess.run([GM2, "-fiso", "-o", RTPROBE,

+ 145 - 0
shell/tests/comimage.py

@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""comimage.py -- the layout of a linked .COM, measured from the file itself.
+
+Every check in this project that reads an emitted image has to answer the same
+three questions before it can say anything at all:
+
+    where is the program header?      find_header(d)
+    where does the runtime end?       find_header(d) - HDR_SZ
+    where does execution start?       entry_target(d)
+
+Until now each check answered for itself.  comtest.py carried one find_header,
+the independent checker inside run_com_tests.sh carried a second copy of it,
+and check_framedisp.py carried neither: it wrote RT_SZ = 391 beside a comment
+saying it tracked Runtime.RT_Size(), and swept the region beginning
+ENT_SZ + RT_SZ - which is inside the RUNTIME, part way through an instruction.
+The check passed anyway, for the two reasons a restated offset always passes:
+the byte patterns it searched for are in the image wherever they happen to be,
+and the decode sweep reached the same answers from a start point that nothing
+in the check could tell was wrong.  A region that is wrong in a way its
+assertions cannot see is the same fault as a constant that has drifted, only
+harder to notice, because the report it prints is a clean PASS.
+
+So the layout lives in one file, is measured rather than remembered, and every
+reader imports it.  tests/check_comimage.py asserts that there is exactly one
+definition of each part and that every reader gets it from here, because a
+shared helper that somebody copies again is four sources of truth instead of
+one - and four is how this started.
+
+What is written down here, and what is not
+------------------------------------------
+The FORMAT is written down: the entry jump is three bytes, the header is
+sixteen, initmem's first bytes and the two header words it reads, and the load
+bias.  Those are this project's knowledge of what it emits, and asking the code
+under test for them would let the code under test satisfy every check by
+agreeing with itself.
+
+The SIZES are not written down.  The runtime's size was written down twice
+(385, then 391), beside a comment saying it tracked the runtime; it did not,
+and each stale value sent a checker into the middle of the code, where it
+reported a well-formed image as a compiler fault.  A duplicated constant that
+has silently drifted is not an independent check, it is a second source of
+truth that lies, and it lies in the direction of looking like the thing under
+test is broken.
+
+Measuring is not the same as asking the compiler: everything here reads the
+emitted file.  tests/check_runtime.py is where the runtime's size is pinned on
+purpose, and tests/run_com_tests.sh prints the size it measures on every run.
+
+Usage: this is a library; import it from a check.
+"""
+
+# ---- the format, written down -------------------------------------------
+
+# ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
+# project made about how a DOS .COM is loaded.  Those stay written down: they
+# are knowledge about the format, not sizes that move when code is edited.
+ENT_SZ = 3                    # E9 lo hi, the entry jump (Compiler.Inittur)
+HDR_SZ = 16                   # 5 header words + 3 buffer words (Compiler)
+
+# The load bias.  A DOS .COM's first byte is at CS:0100 and CS = DS, so an image
+# offset K is at DS:(K + 0100h); every ABSOLUTE address the image contains has
+# to carry it, or it points 0100h low and - since the code region and the
+# runtime are both below the bias - almost always lands inside the runtime
+# instead of inside the data.  Relative encodings must not carry it, because
+# both of their operands shift together.
+# Restated, not asked of the code under test.  See Runtime.LoadBias.
+LOAD_BIAS = 0x100
+
+# initmem's prologue, which is the runtime's only reader of the program header.
+# The displacements +4 and +6 below are the whole point of this constant: the
+# header checks in every checker read hdrDS at +4 and hdrHeap at +6, and
+# initmem has to read the SAME two words or it clears the wrong range.  It read
+# +8 (hdrMax, which the compiler patches to 0), so it zeroed nothing at all and
+# nothing here noticed - the emitted loop was perfectly well formed, it just
+# never ran.  Asserting these bytes together with the header offsets is what
+# closes that gap.
+#
+# It is the first eleven bytes of the runtime, so it sits at ENT_SZ, not 0.
+HEAD = "8B F0 8B 54 04 8B 4C 06"   # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
+HDR_DS_WORD = 4               # header word holding the data base
+HDR_HEAP_WORD = 6             # header word holding the data end
+assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
+       [HDR_DS_WORD, HDR_HEAP_WORD], \
+       "initmem no longer reads the two header words the checks verify"
+
+
+# ---- what the file says, measured ---------------------------------------
+
+def find_header(d):
+    """The image offset of the program header in `d`, or None.
+
+    The header is eight words, and the layout says what they are (offsets here
+    are BYTES into the header, which is why HDR_DS_WORD is 4 and not 2 - the
+    words are two bytes each):
+
+        +0  1                 hdrFlag, always 1
+        +2  code end + bias   hdrCS
+        +4  data base + bias  hdrDS, where data base = hdrOff + 1000h
+        +6  data end  + bias  hdrHeap, which is hdrDS + dataBytes
+
+    hdrDS ties the header to its OWN offset: the data base is header offset +
+    1000h, and hdrDS is that with the load bias added, so the offset is
+    recoverable from the file with no remembered runtime size - which is the
+    whole point.  A candidate is accepted only if hdrFlag is 1, hdrDS satisfies
+    that equation, hdrHeap is above hdrDS (a heap below its own base is not a
+    layout, it is a coincidence), and hdrCS leaves room for the header itself.
+
+    initmem is the only code in the image that reads the header, so its bytes
+    are pinned at ENT_SZ: a candidate that also has them there is not a
+    coincidence in the code stream.
+
+    A header that cannot be found is returned as None rather than as the
+    nearest guess, because a caller that guesses reports confidently about the
+    wrong bytes - which is what the restated runtime size used to do.
+    """
+    head = bytes(int(x, 16) for x in HEAD.split())
+    if d[ENT_SZ:ENT_SZ + len(head)] != head:
+        return None                      # no runtime: nothing to measure
+    for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
+        w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
+        if w(0) != 1:                                    # hdrFlag
+            continue
+        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:    # hdrDS
+            continue
+        if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):            # hdrHeap
+            continue
+        if w(2) - LOAD_BIAS < off + HDR_SZ:               # hdrCS
+            continue
+        return off
+    return None
+
+
+def entry_target(d):
+    """The image offset the entry JMP at file offset 0 lands on, or None.
+
+    A .COM is entered at its first byte, so byte 0 is `E9 rel16' and the first
+    instruction of the program is at ENT_SZ + rel16 (Compiler.Inittur).  This
+    is where EXECUTION starts, which is a different statement from where the
+    runtime ends: the program header sits between the two, and a check that
+    measures only one of them cannot tell that it has started its sweep in the
+    wrong place.  A check that measures both is asked to make them agree.
+    """
+    if len(d) < ENT_SZ or d[0] != 0xE9:
+        return None
+    return ENT_SZ + int.from_bytes(d[1:ENT_SZ], "little", signed=True)

+ 20 - 71
shell/tests/comtest.py

@@ -34,78 +34,27 @@ FIXTURE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(
     os.path.dirname(os.path.abspath(__file__)), "fixtures", "t26_str_mixed_args.pas")
 COM = os.path.splitext(FIXTURE)[0] + ".COM"
 
-# Restated here on purpose - the checker must not ask the code under test.
-# ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
-# checker made on its own account; those stay written down.
+# The image layout - the entry jump, the header's size, the load bias,
+# initmem's first bytes - and the function that MEASURES where the header is
+# live in tests/comimage.py and are imported, not copied.  This checker had one
+# copy of both and the independent checker in tests/run_com_tests.sh had a
+# second; the third reader (tests/check_framedisp.py) had neither and wrote
+# down RT_SZ = 391 instead, which had long since drifted from the runtime it
+# claimed to track, so its sweep began inside the runtime rather than at the
+# program's first instruction and reported a clean PASS over bytes the program
+# never executes.  Two copies of a constant that moves are two chances to be
+# confidently wrong, so there is one now, and tests/check_comimage.py asserts
+# that it stays one.
 #
-# The RUNTIME'S SIZE is not restated, and that is the correction.  It used to
-# be a literal (385, then 391) beside a comment saying it tracks
-# Runtime.RT_Size().  It did not: the runtime is 432 bytes, so this checker
-# read the program header 41 bytes early, out of the middle of the code, and
-# reported a .COM full of nonsense - hdrFlag=61579 - as a compiler fault.  A
-# duplicated constant that has drifted is not an independent check, it is a
-# second source of truth that lies, and it lies in a way that looks like the
-# thing under test is broken.  The size is now MEASURED, by the same
-# self-consistent-header argument documented in find_header below.
-ENT_SZ = 3                    # E9 lo hi, the entry jump
-HDR_SZ = 16                   # 5 header words + 3 buffer words
-# RTSZ, PROLOG and DATAB are derived per .COM from find_header(d).
-# The image starts with a three-byte JMP - see Compiler.Inittur.  A .COM is
-# entered at file offset 0, so before that jump existed this checker ASSERTED
-# that the runtime was at offset 0, which was precisely the bug: every .COM
-# began by executing initmem with whatever the loader left in AX.  A checker
-# that pins a wrong invariant is worse than none, because it makes the wrong
-# thing look tested.
-# The load bias.  A DOS .COM's first byte is at CS:0100 and CS = DS, so an
-# image offset K is at DS:(K + 0100h); every absolute address the image
-# contains has to carry it, or it points 0100h low and - since the code region
-# and the runtime are all below the bias - almost always lands inside the
-# runtime instead of inside the data.  Relative encodings must not carry it.
-# Restated, not asked of the code under test.  See Runtime.LoadBias.
-LOAD_BIAS = 0x100
-HEAD = "8B F0 8B 54 04 8B 4C 06"   # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
-HDR_DS_WORD = 4               # header word holding the data base
-HDR_HEAP_WORD = 6             # header word holding the data end
-assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
-       [HDR_DS_WORD, HDR_HEAP_WORD], \
-       "initmem no longer reads the two header words this checker verifies"
-
-
-def find_header(d):
-    """Return the image offset of the program header, or None.
-
-    The header is eight words, and hdrDS ties it to its OWN offset: the data
-    base is header offset + 1000h, and hdrDS is the data base with the load
-    bias added, so header offset = hdrDS - 1000h - bias.  That makes the
-    offset recoverable from the file with no remembered runtime size, which is
-    the whole point - see the note on RT_SZ above.
-
-    A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
-    equation, hdrHeap is above hdrDS, and hdrCS leaves room for the header
-    itself.  initmem is the only code in the image that reads the header, so
-    its bytes are pinned at ENT_SZ and a match that also has them is not a
-    coincidence in the code stream.
-
-    Measuring is not the same as asking the compiler: this reads the emitted
-    file, so it cannot be satisfied by the code under test agreeing with
-    itself.  tests/check_runtime.py is where the runtime's size is pinned on
-    purpose, and tests/run_com_tests.sh measures and prints it on every run.
-    """
-    head = bytes(int(x, 16) for x in HEAD.split())
-    if d[ENT_SZ:ENT_SZ + len(head)] != head:
-        return None
-    for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
-        w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
-        if w(0) != 1:
-            continue
-        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:
-            continue
-        if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):
-            continue
-        if w(2) - LOAD_BIAS < off + HDR_SZ:
-            continue
-        return off
-    return None
+# RTSZ, PROLOG and DATAB below are derived per .COM from find_header(d) - never
+# written down.  The image starts with a three-byte JMP (Compiler.Inittur) and
+# a .COM is entered at file offset 0, so before that jump existed this checker
+# ASSERTED that the runtime was at offset 0, which was precisely the bug: every
+# .COM began by executing initmem with whatever the loader left in AX.  A
+# checker that pins a wrong invariant is worse than none, because it makes the
+# wrong thing look tested.
+from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD,
+                      HDR_HEAP_WORD, find_header)
 
 
 def check_com(path, expected_src_len):

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

@@ -207,4 +207,6 @@ t30_forloop	OK	97	8
 t31_procparam	OK	69	6
 t32_forexit	OK	138	8
 t33_cmpops	OK	404	12
+t34_arith	OK	448	8
+t35_not	OK	224	7
 uierror	ERR	41	331

+ 15 - 0
shell/tests/fixtures/t34_arith.out

@@ -0,0 +1,15 @@
+85
+22
+12
+3
+2
+-17
+FALSE
+TRUE
+-3
+-2
+17
+-3
+2
+3
+-2

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

@@ -0,0 +1,57 @@
+program t34;
+
+{ t34 -- the operators no other fixture uses.
+
+  Every one of `div', `mod', `and', `or', unary `-' and a variable `*' is
+  emitted by this compiler and has never been executed by any test: t08 was
+  the only fixture that multiplied and it multiplied two CONSTANTS, which
+  BinOpEmit folds without emitting anything.  So `EmIDivAxCx', `EmXchgAxDx',
+  `EmAndAxCx', `EmOrAxCx' and `EmNegAx' were all untested, and so is every
+  instruction the Exec86 interpreter implements for them.
+
+  The operands are deliberately VARIABLES, for the same reason: with two
+  constants `p div q' folds at compile time and the runtime never runs.
+
+  The four sign combinations of div and mod are the point.  Pascal's `div'
+  truncates toward zero and `mod' takes the sign of the dividend:
+
+       a div b = truncation of a/b toward zero
+       a mod b = a - b * (a div b)
+
+  so -17 div 5 = -3 and -17 mod 5 = -2, where a floor-division machine would
+  answer -4 and 1.  ISO Modula-2, which Exec86 is written in, has Euclidean
+  DIV/MOD (measured: (-7) MOD 2 = 1, 7 MOD (-2) = 1), so the interpreter has
+  to convert; these eight lines are what says whether it converts the right
+  way.
+
+  `not' is absent here on purpose: it has its own fixture, t35_not, which
+  is where the boolean/int split in TPSRC9 neglevel is checked. }
+
+var
+  p : integer;
+  q : integer;
+begin
+  p := 17;
+  q := 5;
+  writeln (p * q);
+  writeln (p + q);
+  writeln (p - q);
+  writeln (p div q);
+  writeln (p mod q);
+  writeln (-p);
+  writeln ((p > q) and (q > p));
+  writeln ((p > q) or (q > p));
+  p := -17;
+  q := 5;
+  writeln (p div q);
+  writeln (p mod q);
+  writeln (-p);
+  p := 17;
+  q := -5;
+  writeln (p div q);
+  writeln (p mod q);
+  p := -17;
+  q := -5;
+  writeln (p div q);
+  writeln (p mod q)
+end.

+ 7 - 0
shell/tests/fixtures/t35_not.out

@@ -0,0 +1,7 @@
+-18
+FALSE
+TRUE
+TRUE
+FALSE
+TRUE
+-6

+ 47 - 0
shell/tests/fixtures/t35_not.pas

@@ -0,0 +1,47 @@
+program t35;
+
+{ t35 -- `not', on both of the operand types it is defined for.
+
+  TPSRC9 neglevel picks the instruction from the operand's type before it
+  emits anything:
+
+       CMP.B  CL,#$0A      ; integer ?
+       JZ     negnot       ;  -> CALL loadatom ; NOT AX        (F7 D0)
+       CMP.B  CL,#$0B      ; boolean ?
+       CALL   errnz        ;  -> error 47 for anything else
+       CALL   loadatom
+                            ;  -> XOR AL,#01                   (34 01)
+
+  This compiler used to emit NOT AX for both of them, so every boolean
+  came out wrong one way: `not (a = a)' computed 0FFFEh and the runtime's
+  wrbool tests [BP+4] <> 0, which reads 0FFFEh as TRUE.  Pascal says
+  FALSE.  Exec86 and qemu agreed with each other and both disagreed with
+  Pascal, which is what pins the fault on the compiler and not on the
+  interpreter.
+
+  The integer half is here because it is the other arm of the same branch:
+  `not a' has to stay NOT AX, and must not be dragged along to XOR AL,#01
+  by a fix aimed at booleans.
+
+  A BOOLEAN variable is one byte and LoadAtom clears AH after a byte load,
+  so XOR AL,#01 cannot leave a dirty high byte for wrbool to trip over.
+
+  Every expectation below is derived from Pascal's definition - `not' on an
+  integer is the 16-bit one's complement, `not' on a boolean is logical
+  negation - and from nowhere else. }
+
+var
+  a : integer;
+  b : boolean;
+begin
+  a := 17;
+  writeln (not a);
+  writeln (not (a = 17));
+  writeln (not (a = 18));
+  writeln (not (a > 100));
+  b := a = 17;
+  writeln (not b);
+  b := a = 18;
+  writeln (not b);
+  writeln (not 5)
+end.

+ 458 - 36
shell/tests/nonvacuity.sh

@@ -11,7 +11,11 @@
 # with 16-bit ModRM, so these are the ones the checks have to catch.
 #
 #   audit_helpers.py   name-versus-decode: catches a wrong ModRM that still
-#                      decodes cleanly
+#                      decodes cleanly, and both halves of a name that admits
+#                      an operand at all - `34 02' where the name promises
+#                      `34 01', and `35 01' where the row's opcode gate admits
+#                      only 34h.  Both rows are new, and a row nobody has seen
+#                      reject anything accepts whatever it is shown.
 #   audit_helpers.py   coverage:           catches a helper that has silently
 #                      dropped OUT of the audit, which is a green report about
 #                      a subject nobody looked at
@@ -27,6 +31,12 @@
 #                      going wrong, which no amount of decoding will show
 #   check_framedisp.py the BP disp rule:   catches a displacement that reads
 #                      a different address than the symbol table named
+#   tests/comimage.py the image layout:     catches a sweep region built from a
+#                      literal instead of from two independent readings of the
+#                      file - the old RT_SZ went on reporting PASS while its
+#                      region began inside the runtime - and the layout itself
+#                      being written down a second time, now that one file
+#                      defines it and all three readers import it
 #   rt_exec.py        the BP contract:      catches an entry that borrows BP to
 #                      reach its argument and does not hand it back.  The bytes
 #                      are well formed, the golden is satisfied, the audit says
@@ -44,9 +54,23 @@
 #                      shape-based check.
 #   run_com_exec.py   behaviour:            catches a*b that emits an ADD, `>'
 #                      and `>=' swapped, REPEAT..UNTIL that stops after one
-#                      pass, and two procedures whose parameters collide.
-#                      All four sat in fixtures that COMPILED and were never
-#                      RUN, with every byte-level check green.
+#                      pass, two procedures whose parameters collide, a left
+#                      operand overwritten by the right one's code (SaveLeft),
+#                      and a boolean `not' lowered as the integer one.  Every
+#                      one of them sat in a fixture that COMPILED and was
+#                      never RUN, with every byte-level check green.
+#   run_exec86.py     behaviour (Exec86):   catches the interpreter's OWN
+#                      reading of the machine: JE inverted in Exec86's Cond,
+#                      which swaps the two arms of every `=' in every program
+#                      while the emitted bytes, the sizes and every
+#                      byte-level check stay green.  The image is fine; only
+#                      the thing reading it is wrong, so nothing that looks at
+#                      the image can fail this one.
+#   runtest.py        the R key:            catches CmdRun poking nothing into
+#                      the interpreter at all, which faults on the first step
+#                      and prints "interpreter fault" instead of the guest's
+#                      answer.  It is the only case here that needs a rebuilt
+#                      SHELL rather than a rebuilt compiler or runtime.
 #
 # The mod=11 cases do not need a rebuild -- they read the probe sources
 # directly -- so they are cheap, and they are the ones that matter most: the
@@ -60,12 +84,45 @@ set -u
 cd "$(dirname "$0")/.." || exit 1
 
 GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
-SAVED=/tmp/opencode/nonvacuity.Runtime.mod
-PROBE=/tmp/opencode/nonvacuity.rtprobe
-DUMP=/tmp/opencode/nonvacuity.dump
+SAVED=../tmp/nonvacuity.Runtime.mod
+PROBE=../tmp/nonvacuity.rtprobe
+DUMP=../tmp/nonvacuity.dump
+# Exec86.mod is UNTRACKED, so unlike Runtime.mod git cannot put a botched
+# mutation back: the copy taken here is the only correct one in existence.
+# Shell.mod is tracked but is mutated by the R-key case below, and a trap that
+# restored only the sources would leave tpshell BUILT FROM the mutation - so
+# the trap rebuilds too.  All three copies live in the project's own tmp/ and
+# are taken here, before the first mutation, rather than beside each case.
+#
+# comimage.py and comtest.py are the image layout and one of its three readers;
+# both are mutated by cases further down, and neither is recoverable from git
+# once the mutation is staged over - so they join the trap here for the same
+# reason, and an interrupted run cannot leave the layout half-written.
+mkdir -p ../tmp
+SAVED_E=../tmp/nonvacuity.Exec86.mod
+SAVED_SH=../tmp/nonvacuity.Shell.mod
+SAVED_CI=../tmp/nonvacuity.comimage.py
+SAVED_T=../tmp/nonvacuity.comtest.py
 
 cp Runtime.mod "$SAVED" || exit 1
-trap 'cp "$SAVED" Runtime.mod; "$GM2" -fiso -c Runtime.mod >/dev/null 2>&1' EXIT
+cp Exec86.mod "$SAVED_E" || exit 1
+cp Shell.mod "$SAVED_SH" || exit 1
+cp tests/comimage.py "$SAVED_CI" || exit 1
+cp tests/comtest.py "$SAVED_T" || exit 1
+
+restore_all () {
+    cp "$SAVED" Runtime.mod
+    cp "$SAVED_E" Exec86.mod
+    cp "$SAVED_SH" Shell.mod
+    cp "$SAVED_CI" tests/comimage.py
+    cp "$SAVED_T" tests/comtest.py
+    "$GM2" -fiso -c Runtime.mod >/dev/null 2>&1
+    "$GM2" -fiso -c Exec86.mod >/dev/null 2>&1
+    # The shell is rebuilt as well: a test that runs against a binary built
+    # from a half-restored tree is reporting on the mutation, not on the code.
+    make >/dev/null 2>&1
+}
+trap restore_all EXIT
 
 pass=0
 fail=0
@@ -87,9 +144,9 @@ fail=0
 mutate () {
     mf=$1
     msed=$2
-    cp "$mf" /tmp/opencode/nonvacuity.mut.bak
+    cp "$mf" ../tmp/nonvacuity.mut.bak
     sed -i "$msed" "$mf"
-    if cmp -s "$mf" /tmp/opencode/nonvacuity.mut.bak; then
+    if cmp -s "$mf" ../tmp/nonvacuity.mut.bak; then
         echo "  BROKEN CASE: the mutation did not change $mf"
         echo "       sed: $msed"
         echo "       the named code has probably been renamed or reformatted -"
@@ -212,8 +269,8 @@ echo
 echo "== the mod=11 table (probe/modrm11.py)"
 # These mutate the probe's own sources, not the runtime, so there is no
 # rebuild in the loop.  SAVED_PY / SAVED_S are restored after each case.
-SAVED_PY=/tmp/opencode/nonvacuity.modrm11.py
-SAVED_S=/tmp/opencode/nonvacuity.modrm11.s
+SAVED_PY=../tmp/nonvacuity.modrm11.py
+SAVED_S=../tmp/nonvacuity.modrm11.s
 cp tests/probe/modrm11.py "$SAVED_PY" || exit 1
 cp tests/probe/modrm11.s "$SAVED_S" || exit 1
 
@@ -270,7 +327,7 @@ echo
 echo "== the BP displacement rule (check_framedisp.py)"
 # This one is about Compiler.mod rather than the runtime, and it needs the
 # whole toolchain rebuilt (comtest, not rtprobe), so it gets its own rebuild.
-SAVED_C=/tmp/opencode/nonvacuity.Compiler.mod
+SAVED_C=../tmp/nonvacuity.Compiler.mod
 cp Compiler.mod "$SAVED_C" || exit 1
 
 rebuild_compiler () {
@@ -362,15 +419,151 @@ else
     fail=$((fail + 1))
 fi
 
+# --- the region check_framedisp sweeps, and the one file it comes from -----
+echo
+echo "== the image layout, measured once (tests/comimage.py)"
+# check_framedisp.py decodes instructions over a REGION of the image, and a
+# wrong region does not stop it: the byte patterns it searches for are in the
+# image wherever they happen to be, and a decode that starts mid-instruction
+# reached the same offsets anyway.  That is how RT_SZ = 391 - a literal that had
+# drifted from the runtime it described - went on reporting PASS while its sweep
+# began inside the runtime, part way through an instruction.  Nothing in the
+# check could see where its own region began.
+#
+# So the region is two independent readings of the file - where the entry jump
+# says execution starts, and where the program header says the runtime ends -
+# and the two must AGREE or the region is undetermined.  Both readings, and the
+# layout itself, live in tests/comimage.py, which is also where comtest.py and
+# the independent checker in run_com_tests.sh now get the copy each of them
+# used to keep: a duplicated constant that has silently drifted is not an
+# independent check, it is a second source of truth that lies, and it lies in
+# the direction of looking like the code under test is broken.
+#
+# None of this needs a rebuild - comimage.py is Python the checkers import -
+# so these cases are cheap, and they turn the READERS red one at a time.
+
+# 1. entry_target answers with the literal the region used to be built from.
+#    The two readings now disagree, and there is no measured region to sweep.
+python3 - <<'PYEOF'
+p = 'tests/comimage.py'
+s = open(p).read()
+old = '    return ENT_SZ + int.from_bytes(d[1:ENT_SZ], "little", signed=True)'
+new = '    return ENT_SZ + 391                        # the literal RT_SZ was'
+assert s.count(old) == 1, "entry_target's return not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "a sweep region that is not where execution starts is rejected" \
+    "neither region is swept" python3 tests/check_framedisp.py
+cp "$SAVED_CI" tests/comimage.py
+
+# 2 and 3. The header's own equation, one byte out, so the header cannot be
+#    located at all - the case where a checker that fell back on a remembered
+#    offset would report a confident number instead.  Two readers, two
+#    different sentences: each must say in its own words that it does not know
+#    where the program's code begins.
+python3 - <<'PYEOF'
+p = 'tests/comimage.py'
+s = open(p).read()
+old = '        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:    # hdrDS'
+new = '        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS + 1:  # hdrDS'
+assert s.count(old) == 1, "the hdrDS equation not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "check_framedisp reports a header it cannot locate" \
+    "no program header found" python3 tests/check_framedisp.py
+expect_red "check_8086 reports the same unlocatable header" \
+    "could not locate the code region" python3 tests/check_8086.py
+cp "$SAVED_CI" tests/comimage.py
+
+# 4-7. The single copy itself, now that there is one.  A second find_header,
+#      a second HDR_SZ, a caller that reaches the helper through another
+#      consumer instead of through comimage, and - the case that exists
+#      because of rt_exec.py's "matched nothing" lesson - the definition the
+#      audit counts.  A rule that matches nothing looks exactly like a rule
+#      that passes, so a deleted definition must be a red report rather than a
+#      silent green one.
+python3 - <<'PYEOF'
+p = 'tests/comtest.py'
+s = open(p).read()
+old = 'def check_com(path, expected_src_len):'
+new = ('def find_header(d):\n'
+       '    return None                    # a second copy of the helper\n'
+       '\n\n'
+       'def check_com(path, expected_src_len):')
+assert s.count(old) == 1, "check_com not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "the audit reports a find_header copied out of comimage" \
+    "comtest.py defines its own find_header" python3 tests/check_comimage.py
+cp "$SAVED_T" tests/comtest.py
+
+python3 - <<'PYEOF'
+p = 'tests/comtest.py'
+s = open(p).read()
+old = 'from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD,'
+new = ('HDR_SZ = 16                   # a second copy of the layout\n'
+       'from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD,')
+assert s.count(old) == 1, "the comimage import not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "the audit reports a layout constant defined elsewhere" \
+    "comtest.py defines its own HDR_SZ" python3 tests/check_comimage.py
+cp "$SAVED_T" tests/comtest.py
+
+python3 - <<'PYEOF'
+p = 'tests/comimage.py'
+s = open(p).read()
+old = 'def find_header(d):'
+new = 'def _find_header(d):                # the definition is gone'
+assert s.count(old) == 1, "def find_header not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "the audit counts its own subject rather than matching nothing" \
+    "defines find_header 0 times" python3 tests/check_comimage.py
+cp "$SAVED_CI" tests/comimage.py
+
+# 7. The import dropped while the CALLS stay - the shape a refactor makes when
+#    one reader starts taking the helper from another reader instead.  It would
+#    NameError the moment comtest ran, but the audit says so first, and in the
+#    vocabulary of the layout rather than in the vocabulary of Python: the
+#    failure this is guarding is "two sources of truth", not "undefined name".
+python3 - <<'PYEOF'
+p = 'tests/comtest.py'
+s = open(p).read()
+old = ('from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD,\n'
+       '                      HDR_HEAP_WORD, find_header)')
+new = '# MUTATION: the shared import is gone, but the calls remain\n'
+assert s.count(old) == 1, "the comimage import not found -- update this mutation"
+open(p, 'w').write(s.replace(old, new))
+PYEOF
+expect_red "the audit reports a caller that does not take it from comimage" \
+    "comtest.py calls find_header without importing it from comimage" \
+    python3 tests/check_comimage.py
+cp "$SAVED_T" tests/comtest.py
+
+# Green again, on all three readers at once: the case above mutated the one
+# file all of them import, so restoring it has to satisfy each of them, not
+# just the one that was last run.
+for c in tests/check_framedisp.py tests/check_comimage.py tests/check_8086.py; do
+    if python3 "$c" >/dev/null 2>&1; then
+        echo "  ok: $c passes on the restored sources"
+        pass=$((pass + 1))
+    else
+        echo "NOT RESTORED: $c is red after restoring comimage.py and comtest.py"
+        python3 "$c" 2>&1 | tail -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+done
+
 # --- 3. the operator bugs the nine dead fixtures were hiding ------------
 echo
-echo "== the four bugs the nine never-executed fixtures were hiding"
+echo "== the bugs the never-executed fixtures were hiding"
 echo
 # A different KIND of case from everything above.  The others break the code
 # and assert a byte-level check notices; these break the code and assert a
 # BEHAVIOURAL check notices, which is the only kind that could have found them.
-# All four shipped with a green compile matrix, a passing .COM layout check, a
-# passing golden and a passing emitter audit:
+# Each of the original four shipped with a green compile matrix, a passing .COM
+# layout check, a passing golden and a passing emitter audit:
 #
 #   OpMul = 1        `a * b` emitted ADD AX,CX.  `*' and `+' both numbered
 #                    their operator 1, and BinOpEmit cannot see which
@@ -387,12 +580,25 @@ echo
 # They are mutated back to the original defect and run_com_exec.py must go red
 # on the exact fixture that pins the behaviour.  The fourth (HideLocals) is a
 # scoping bug rather than an operator bug; it was hiding in the same place.
+#
+# Two joined them when the operand-lifetime and `not' fixes landed, each run
+# red by hand before it was written down here, and each with the same property
+# as the four above -- green everywhere except execution:
+#
+#   SaveLeft's park `(p > q) or (q > p)' evaluated `(q > p) or (q > p)'.  A
+#                    kind-2 value exists only in AX, and the right operand's
+#                    code is emitted before the two are ever brought together,
+#                    so without the push the left one is simply gone.
+#   neglevel's split `not' on a boolean emitted the INTEGER `not', which left
+#                    0FFFEh where a boolean belongs, and wrbool reads anything
+#                    non-zero as TRUE -- so `not (a = a)' answered TRUE and so
+#                    did every other `not'.
 mutate_compiler () {   # reuse mutate's verified-change discipline on Compiler.mod
     mf=Compiler.mod
     msed=$1
-    cp "$SAVED_C" /tmp/opencode/nonvacuity.mut2.bak
+    cp "$SAVED_C" ../tmp/nonvacuity.mut2.bak
     sed -i "$msed" "$mf"
-    if cmp -s "$mf" /tmp/opencode/nonvacuity.mut2.bak; then
+    if cmp -s "$mf" ../tmp/nonvacuity.mut2.bak; then
         echo "  BROKEN CASE: the mutation did not change Compiler.mod"
         echo "       sed: $msed"
         echo "       the named code has probably been renamed or reformatted -"
@@ -540,11 +746,84 @@ else
     echo "  FAIL: could not remove HideLocals to test the scoping fix"
     fail=$((fail + 1))
 fi
+# M6: the LEFT operand, parked for the right one.  kind 2 means "this value
+# exists in AX and nowhere else", and the right-hand operand's code is emitted
+# between recognizing the operator and using both operands - so without
+# SaveLeft's push the left value is overwritten before it is ever read, and
+# LoadPair's kind-4 shape can never trigger.  `(p > q) or (q > p)' then
+# evaluates `(q > p) or (q > p)'.  Every byte, every size and the whole compile
+# matrix stay green: nothing is malformed, the value is simply the wrong one.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+old = """BEGIN
+   IF left.kind = 2 THEN
+      EmPushAx () ;
+      left.kind := 4                      (* 4 = on the top of the stack *)
+   END
+END SaveLeft ;"""
+new = """BEGIN
+   (* MUTATION: the park is unreachable, so nothing survives the parse *)
+   IF left.kind = 99 THEN
+      EmPushAx () ;
+      left.kind := 4
+   END
+END SaveLeft ;"""
+assert s.count(old) == 1, 'SaveLeft body found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, new))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "execution catches a left operand clobbered by the right one" \
+            "t34_arith" python3 tests/run_com_exec.py t34_arith
+    else
+        echo "  FAIL: the compiler would not rebuild without SaveLeft's push"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the SaveLeft mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
+# M7: the type split in TPSRC9's neglevel, wrong half.  A boolean NOT lowered
+# as the INTEGER one leaves 0FFFEh/0FFFFh, and wrbool tests [BP+4] <> 0 - so
+# `not (a = a)' answers TRUE, and so does every other `not'.  One instruction,
+# same length, same sizes, and the compile matrix cannot see it because both
+# halves emit well-formed code for a type the parser already accepted.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+old = """      IF r.cls = TBool THEN
+         LoadAtom (r) ;
+         EmXorAl01 () ;"""
+new = """      IF r.cls = TBool THEN
+         LoadAtom (r) ;
+         EmNotAx () ;                      (* MUTATION: integer NOT on a boolean *)"""
+assert s.count(old) == 1, 'the TBool arm of ParseNeg found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, new))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "execution catches a boolean NOT lowered as an integer one" \
+            "t35_not" python3 tests/run_com_exec.py t35_not
+    else
+        echo "  FAIL: the compiler would not rebuild with EmNotAx for a boolean"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the neglevel type-split 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: all 31 executed fixtures pass on the restored compiler"
+        # No count: the matrix grows every time a fixture is added, and a
+        # number here would be right only until the next one.
+        echo "  ok: every executed fixture passes on the restored compiler"
         pass=$((pass + 1))
     else
         echo "NOT RESTORED: run_com_exec.py is red after restoring Compiler.mod"
@@ -556,6 +835,124 @@ else
     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
+# SAME image inside shell/Exec86.mod, this project's own in-process 8086
+# interpreter, and requires its output to agree with BOTH the hand-derived .out
+# and qemu, byte for byte.  Two execution checks that could only ever agree
+# with each other would be one check: the point of this one is that it was
+# written against the 8086's own reference rather than against qemu, whose
+# lowest CPU model is a 486 and whose `0F 84' is an ordinary JZ.
+#
+# So the only mutation worth writing here is one qemu cannot make at all - the
+# interpreter's own reading of the machine.  Cond is that reading: nibble 4 is
+# JE, and inverting it swaps the two arms of every `=' in every program.  The
+# emitted bytes, the sizes and the compile matrix are untouched, because
+# nothing is emitted here - the fault is in who interprets it.
+#
+# Exec86.mod is recompiled explicitly rather than left to the harness:
+# ensure_exec86run() notices the source changed and RELINKS, but does not
+# recompile it, so a mutated source with a stale object would link the good
+# interpreter straight back in and stay green - which is the trap its own
+# docstring records, and the reason this case compiles first.
+#
+# SAVED_E itself was taken at the top of this file, next to the EXIT trap that
+# puts it back.
+
+if python3 - <<'PYX'
+p = 'Exec86.mod'
+s = open(p).read()
+old = "| 4H  : r := ZF"
+new = "| 4H  : r := NOT ZF                    (* MUTATION: JE inverted *)"
+assert s.count(old) == 1, 'the JE arm of Cond found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, new))
+PYX
+then
+    if $GM2 -fiso -c Exec86.mod >/dev/null 2>&1; then
+        expect_red "the interpreter's own oracle catches JE inverted" \
+            "t33_cmpops" python3 tests/run_exec86.py t33_cmpops
+    else
+        echo "  FAIL: Exec86.mod would not compile with JE inverted"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the Cond JE mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_E" Exec86.mod
+
+# And the check on the RESTORED interpreter, because a green above could also
+# come from a mutation that never took and an object left mutated by a run
+# that died in between.
+if $GM2 -fiso -c Exec86.mod >/dev/null 2>&1; then
+    if python3 tests/run_exec86.py >/dev/null 2>&1; then
+        echo "  ok: run_exec86 green on the restored interpreter"
+        pass=$((pass + 1))
+    else
+        echo "NOT RESTORED: run_exec86.py is red after restoring Exec86.mod"
+        python3 tests/run_exec86.py 2>&1 | grep -i "fail" | head -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "NOT RESTORED: Exec86.mod would not recompile"
+    fail=$((fail + 1))
+fi
+
+echo
+echo "== the R key: compile, poke, run, report (runtest.py)"
+# The only check in the project that exercises CmdRun.  Everything above looks
+# at bytes, or at what qemu says the image does; this one drives the shell
+# through a pty the way a person would, presses R, and compares the GUEST's
+# output against the fixture's hand-derived .out - so it fails on things no
+# byte check can see and no file records either, because R writes no file at
+# all (which is itself asserted).
+#
+# The mutation is the poke loop's count.  With n = 0 nothing is copied into
+# the interpreter, loadHi stays where Clear86 left it - 0100h - and Run86
+# faults on its very first step, "execution left the loaded image", before the
+# guest has executed a single instruction.  That is fast and deterministic, which
+# matters: the obvious alternative (poking the image somewhere else) leaves the
+# machine executing zeros and buys a step-limit timeout instead of a finding.
+cp "$SAVED_SH" Shell.mod
+if python3 - <<'PYX'
+p = 'Shell.mod'
+s = open(p).read()
+old = "   n := LinkSize () ;"
+new = "   n := 0 ;                           (* MUTATION: nothing is poked *)"
+assert s.count(old) == 1, 'the poke count in CmdRun found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, new))
+PYX
+then
+    if make > ../tmp/nonvacuity.make.log 2>&1; then
+        expect_red "the R check catches an image that was never poked" \
+            "AH=4Ch exit" python3 tests/runtest.py
+    else
+        echo "  FAIL: the shell would not rebuild with the poke loop neutered"
+        tail -5 ../tmp/nonvacuity.make.log | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the CmdRun poke mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_SH" Shell.mod
+
+if make > ../tmp/nonvacuity.make.log 2>&1; then
+    if python3 tests/runtest.py >/dev/null 2>&1; then
+        echo "  ok: runtest green on the restored shell"
+        pass=$((pass + 1))
+    else
+        echo "NOT RESTORED: runtest.py is red after restoring Shell.mod"
+        python3 tests/runtest.py 2>&1 | grep -i "fail" | head -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "NOT RESTORED: the shell would not rebuild"
+    tail -5 ../tmp/nonvacuity.make.log | sed 's/^/       /'
+    fail=$((fail + 1))
+fi
+
 echo
 echo "== 8086 legality of the conditional lowering (check_8086.py)"
 # This whole section exists because of a fault that every other check in the
@@ -566,7 +963,7 @@ echo "== 8086 legality of the conditional lowering (check_8086.py)"
 # and EVERY comparison in EVERY compiled program was an illegal instruction on
 # the machine this compiler targets.  And all of the following were green while
 # it was: the compile matrix, the .COM layout checker, the runtime golden, the
-# emitter audit, and 30 fixtures executing under qemu to the right answers.
+# emitter audit, and the whole execution suite answering correctly under qemu.
 #
 # Two reasons, and the second is the one worth keeping:
 #   1. qemu-system-i386 has no 8086 model.  Its lowest is 486, where `0F 84' is
@@ -738,8 +1135,8 @@ echo "== the emitter-name audit of Compiler.mod (audit_helpers.py)"
 # "every helper agrees with its name" for a module it had never examined, and
 # EmXchgAxCx was `93` (XCHG BX,AX) under a name that says XCHG AX,CX for the
 # whole life of the project.  Two of these five are for faults that were real.
-SAVED_C2=/tmp/opencode/nonvacuity.Compiler.mod.2
-SAVED_R2=/tmp/opencode/nonvacuity.Runtime.mod.2
+SAVED_C2=../tmp/nonvacuity.Compiler.mod.2
+SAVED_R2=../tmp/nonvacuity.Runtime.mod.2
 cp Compiler.mod "$SAVED_C2" || exit 1
 cp Runtime.mod "$SAVED_R2" || exit 1
 restore_audit_sources () {
@@ -808,6 +1205,29 @@ expect_red "audit catches IDiv without the CWD that extends the dividend" \
     "IDivAxCx" $AUD
 restore_audit_sources
 
+# 7. The byte under EmXorAl01's name, and the row in PATTERNS that was added
+#    to admit it.  `34 01' is XOR AL,#01 - the boolean NOT, and the one emitter
+#    in Compiler.mod with no ModRM byte at all.  A grammar row nobody has ever
+#    seen reject anything cannot be trusted to accept only the truth, so here
+#    is the rejection: `34 02' is the same length, decodes just as cleanly, and
+#    `not x' would flip bit 1 instead of bit 0.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|^   Ebyte (34H) ; Ebyte (01H)|   Ebyte (34H) ; Ebyte (02H)|'
+expect_red "audit catches EmXorAl01 emitting xor al,#2 under a name saying #1" \
+    "XorAl01" $AUD
+restore_audit_sources
+
+# 7b. The other half of that row: the opcode gate.  34h is XOR AL,#imm and
+#     35h is XOR AX,#imm - a word, not a byte, under a name that says AL.  The
+#     row is pinned to {034h} on purpose, and a pin that has never been asked
+#     to hold is a pin.  The report is the one thing the row above cannot
+#     produce: with no reading at all, the helper falls out of the grammar.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|^   Ebyte (34H) ; Ebyte (01H)|   Ebyte (35H) ; Ebyte (01H)|'
+expect_red "the opcode gate rejects XOR AX under an XOR AL name" \
+    "no name pattern accepts it" $AUD
+restore_audit_sources
+
 if $AUD >/dev/null 2>&1; then
     echo "  ok: the audit passes on both restored sources"
     pass=$((pass + 1))
@@ -833,7 +1253,7 @@ echo "== the BP contract rt_exec.py checks before it starts a machine"
 # rt_exec.py needs the whole runtime rebuilt and then boots 36 machines, so this
 # section is the slow one.  The baseline comes first and is asserted: a case
 # that mutates a red tree proves nothing.
-SAVED_R3=/tmp/opencode/nonvacuity.Runtime.mod.3
+SAVED_R3=../tmp/nonvacuity.Runtime.mod.3
 cp Runtime.mod "$SAVED_R3" || exit 1
 
 if rebuild; then
@@ -894,7 +1314,7 @@ cp "$SAVED_R3" Runtime.mod
 #    the fault this project keeps making: a check whose SUBJECT has drifted
 #    reports a confident answer about the wrong thing.
 cp "$SAVED_R3" Runtime.mod
-SAVED_X=/tmp/opencode/nonvacuity.rt_exec.py
+SAVED_X=../tmp/nonvacuity.rt_exec.py
 cp tests/rt_exec.py "$SAVED_X" || exit 1
 mutate tests/rt_exec.py 's|^MOV_BP_SP = b"\\x8b\\xec" .*$|MOV_BP_SP = b"\\x8b\\xed"               # MOV BP,DI: never emitted|' \
 expect_red "a check that matched nothing is a failure, not a pass" \
@@ -940,21 +1360,22 @@ cp "$SAVED_R3" Runtime.mod
 
 echo
 echo "== the .COM layout check, and the runtime size it now measures"
-# The checker used to RESTATE the runtime's size as a literal.  It was wrong
-# by 41 bytes for an unknown time, and every one of the 30 .COM files "failed"
-# on a header read out of the code stream.  A duplicated constant that has
-# drifted does not fail loudly; it re-reports the same falsehood, in which the
-# real failures hide.  The size is now MEASURED from the image.
+# The checker used to RESTATE the runtime's size as a literal.  It had drifted
+# from the runtime it described, so the header was read out of the code stream
+# and EVERY linked fixture "failed" on it - a whole file of "failures" that were
+# not findings at all.  A duplicated constant that has drifted does not fail
+# loudly; it re-reports the same falsehood, in which the real failures hide.
+# The size is now MEASURED from the image.
 #
 # These cases corrupt a real emitted .COM and require the checker to notice.
 # They need the images, so they are built once and copied; the checker has a
 # --check-only mode for exactly this, because its scratch directory is normally
 # deleted on exit and a check that has only ever seen the truth is not a check.
-KEEPDIR=/tmp/opencode/nonvacuity.com
+KEEPDIR=../tmp/nonvacuity.com
 rm -rf "$KEEPDIR"
-TP_COM_KEEP=1 tests/run_com_tests.sh >/tmp/opencode/nonvacuity.com.log 2>&1
+TP_COM_KEEP=1 tests/run_com_tests.sh >../tmp/nonvacuity.com.log 2>&1
 KEEP=$(sed -n 's/^TP_COM_KEEP=1: images left in //p' \
-       /tmp/opencode/nonvacuity.com.log | tail -1)
+       ../tmp/nonvacuity.com.log | tail -1)
 if [ -z "$KEEP" ] || [ ! -d "$KEEP" ]; then
     echo "  FAIL: could not obtain emitted .COM images for the layout cases"
     fail=$((fail + 1))
@@ -963,9 +1384,10 @@ else
 
     # 0. The baseline.  Every case below is a claim that a specific assertion
     #    turns red, and none of them means anything if the copies of untouched
-    #    images already fail.  (The stale RT_SZ produced exactly that: 30
-    #    failures that were not findings.)  So this is asserted first, and a
-    #    failure here is reported as a broken baseline rather than a red test.
+    #    images already fail.  (The stale RT_SZ produced exactly that: a whole
+    #    file of "failures" that were not findings.)  So this is asserted first,
+    #    and a failure here is reported as a broken baseline rather than a red
+    #    test.
     #
     #    The baseline is also WHERE THE HEADER OFFSET COMES FROM.  Cases 1 and 2
     #    used to carry it as the literal 435 and 439, which were ENT_SZ + the
@@ -1002,7 +1424,7 @@ else
         fail=$((fail + 1))
         BASE_RT=0
     fi
-    # The layout constants are ENT_SZ 3 and HDR_SZ 16 (run_com_tests.sh), so the
+    # The layout constants are ENT_SZ 3 and HDR_SZ 16 (tests/comimage.py), so the
     # header sits at hdrOff = 3 + rtSz and the entry jump's displacement must be
     # (hdrOff + 16) - 3.  The wanted number is therefore computed from the header
     # OFFSET, not from rtSz: the two differ by 3, and getting that backwards

+ 2 - 2
shell/tests/probe/README.md

@@ -37,8 +37,8 @@ To run it (needs `qemu-system-i386`, `as`, `objcopy`):
 
 ```sh
 cd shell
-as --32 -o /tmp/modrm19.o tests/probe/modrm19.s
-objcopy -O binary -j .text /tmp/modrm19.o /tmp/modrm19.bin
+as --32 -o ../tmp/modrm19.o tests/probe/modrm19.s
+objcopy -O binary -j .text ../tmp/modrm19.o ../tmp/modrm19.bin
 python3 tests/probe/run_modrm19.py
 ```
 

+ 5 - 2
shell/tests/probe/modrm11.py

@@ -96,8 +96,11 @@ RE_INSN = re.compile(r"^\s*([a-z]+)\s+([^,]+),\s*([^#;]+?)\s*(?:#.*|;.*)?$")
 
 def build():
     """assemble and objcopy, returning the .text bytes"""
-    o = "/tmp/modrm11.o"
-    b = "/tmp/modrm11.bin"
+    # scratch beside the tree, not in /tmp; HERE is shell/tests/probe
+    tmp = os.path.normpath(os.path.join(HERE, "..", "..", "..", "tmp"))
+    os.makedirs(tmp, exist_ok=True)
+    o = os.path.join(tmp, "modrm11.o")
+    b = os.path.join(tmp, "modrm11.bin")
     subprocess.run(["as", "--32", "-o", o, SRC], check=True)
     subprocess.run(["objcopy", "-O", "binary", "-j", ".text", o, b], check=True)
     with open(b, "rb") as f:

+ 2 - 1
shell/tests/probe/run_modrm19.py

@@ -28,7 +28,8 @@ import sys
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 SRC = os.path.join(HERE, "modrm19.s")
-WORK = "/tmp/opencode/modrm19"
+# scratch beside the tree (TP3-comp/tmp), not in /tmp
+WORK = os.path.normpath(os.path.join(HERE, "..", "..", "..", "tmp", "modrm19"))
 
 # The probe's register setup, from modrm19.s.  Distinct values, so the offset
 # a marker lands at identifies the effective address by arithmetic alone.

+ 54 - 6
shell/tests/run_all.sh

@@ -1,10 +1,14 @@
 #!/bin/bash
 # Run every check.  Non-zero exit if anything fails.
 #
-# Four suites, in increasing order of "how much could be lying to me":
+# Six suites, in increasing order of "how much could be lying to me":
 #
-#   1. compile matrix   33 fixtures, verdict + code size + data size ASSERTED
-#                       from expected.tsv.  Fast, no pty.
+#   1. compile matrix   every fixture in tests/fixtures/expected.tsv, verdict +
+#                       code size + data size ASSERTED from that file.  Fast,
+#                       no pty.  The count is deliberately not repeated here:
+#                       it changes whenever a fixture is added, and a number
+#                       that is only right until the next edit is how a
+#                       comment starts claiming things it cannot support.
 #   2. .COM linker      links every fixture, then re-verifies the bytes with an
 #                       independent checker that restates the layout constants
 #                       instead of asking the compiler.
@@ -21,6 +25,24 @@
 #                       well-formed and both decode cleanly, and only one of
 #                       them reads the variable the symbol table named.
 #
+#   6. EXECUTE (Exec86) the same fixtures a second time, through
+#                       shell/Exec86.mod - this project's own in-process 8086
+#                       interpreter - and require its output to agree with
+#                       both the hand-derived .out and qemu, byte for byte.
+#                       This is the second, INDEPENDENT execution oracle: it
+#                       was written against the 8086's own reference, where
+#                       qemu-system-i386's lowest CPU model is a 486 (`0F 84'
+#                       is an ordinary JZ there) and FCML's -m16 is a 386.  Two
+#                       checks that merely agreed with each other would be one
+#                       check wearing two hats.
+#
+#   7. UI run (R)      pty: W, then R - the path that produces NO artifact.
+#                       The image is poked into the interpreter at 0100h and
+#                       run in-process, so the only evidence is the guest's
+#                       own output, asserted against the hand-derived .out,
+#                       plus the reported AH=4Ch status and a non-zero step
+#                       count.  Nothing else in this file can observe R.
+#
 #   RtProbe             dumps the runtime size and its 14 entry offsets, so a
 #                       runtime change that moves an entry is visible here.
 #
@@ -45,6 +67,15 @@
 #                       matched is itself a failure, because a helper that
 #                       drops out of the audit when you add a comment is worse
 #                       than a helper that is checked and found wanting.
+#   check_comimage       asserts that a linked .COM's layout is described in
+#                       exactly one file and that every reader imports it.
+#                       Each reader used to keep its own copy: two had their
+#                       own find_header, one a single constant of it, and one
+#                       nothing but a drifted literal for where the runtime
+#                       ends - which let its sweep begin inside the runtime
+#                       and report PASS over bytes the program never executes.
+#                       Silent, because each copy is self-consistent on its
+#                       own terms.
 #   check_runtime       sweeps the built runtime's code region with FCML: no
 #                       desync, every entry and every branch target on an
 #                       instruction boundary, and the whole disassembly equal
@@ -111,11 +142,14 @@ run () {
    fi
 }
 
+# Scratch and logs go in the local tmp/ folder, beside the tree that produced
+# them, so a failing run's evidence can be read next to the code it describes.
+mkdir -p ../tmp
 echo "== build =="
 make clean >/dev/null 2>&1
-if ! make >/tmp/tp_all_mk 2>&1 ; then
+if ! make >../tmp/all.make.log 2>&1 ; then
    echo "BUILD FAIL"
-   grep -m10 "error:" /tmp/tp_all_mk
+   grep -m10 "error:" ../tmp/all.make.log
    exit 1
 fi
 echo "make rc=0, tpshell $(stat -c%s tpshell) bytes"
@@ -131,6 +165,7 @@ echo "make rc=0, tpshell $(stat -c%s tpshell) bytes"
 run "runtime probe"  python3 tests/rt_exec.py --probe
 
 run "helper audit"    python3 tests/audit_helpers.py
+run "layout helper"   python3 tests/check_comimage.py
 run "mod=11 table"     python3 tests/probe/modrm11.py
 run "runtime image"   python3 tests/check_runtime.py
 run "compile matrix"  tests/run_compile_tests.sh
@@ -142,8 +177,15 @@ run "COM linker"      tests/run_com_tests.sh
 # FOR off-by-one, the missing procedure-skip jump and a parser bug - none of
 # which produced a malformed byte.
 run "EXECUTE under qemu" python3 tests/run_com_exec.py
+# The same images a second time, inside this project's own interpreter, which
+# must agree with qemu byte for byte.  This is the only check that can fail on
+# something qemu cannot do at all: an 8086-only fault, or the interpreter
+# itself misreading one.  It has no --rebless by design - the .out files are
+# hand-derived from Pascal's semantics, and an oracle that could bless its own
+# output would pass by construction.
+run "EXECUTE (Exec86)"   python3 tests/run_exec86.py
 # And this one asks whether the runtime ENTRIES do what they claim, one qemu
-# boot per call, 35 cases plus a pre-flight.  It is the only check that can catch
+# boot per case plus a pre-flight.  It is the only check that can catch
 # an entry that is well-formed, decodes cleanly, preserves every register the
 # driver happens to need - and computes the wrong answer: wrchar and wrbool
 # borrowed BP to reach their argument and never gave it back, which a byte check
@@ -160,6 +202,12 @@ run "frame displ"     python3 tests/check_framedisp.py
 run "8086 opcodes"   python3 tests/check_8086.py
 run "UI error path"   python3 tests/uitest.py
 run "UI success path" python3 tests/comtest.py
+# And the path that produces NO artifact: R compiles, pokes the image into the
+# in-process interpreter at 0100h and runs it here.  Its evidence is the
+# guest's own output against the hand-derived .out, plus the reported INT 21h
+# status and step count - there is no file on disk for any other check to
+# read, which is why this one drives the shell itself.
+run "UI run (R)"      python3 tests/runtest.py
 
 echo
 echo "=============================================================="

+ 42 - 128
shell/tests/run_com_tests.sh

@@ -12,6 +12,8 @@ D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
 GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
 cd "$D" || exit 9
 FLAGS="-fiso"
+# build logs go beside the tree (TP3-comp/tmp), never in /tmp
+mkdir -p ../tmp
 
 # --check-only DIR  --  skip the build and the link, and run only the
 # independent Python checker over the .COM files already in DIR (plus a
@@ -38,24 +40,24 @@ if [ -n "$CHECK_ONLY" ]; then
 else
 [ -f Posix.o ] || cc -c Posix.c || exit 1
 for m in TextBuf Compiler Runtime Linker; do
-   $GM2 $FLAGS -c $m.mod >/tmp/cm_c_$m 2>&1 \
-      || { echo "COMPILE_FAIL $m"; grep -m5 "error:" /tmp/cm_c_$m; exit 1; }
+   $GM2 $FLAGS -c $m.mod >../tmp/cm_c_$m 2>&1 \
+      || { echo "COMPILE_FAIL $m"; grep -m5 "error:" ../tmp/cm_c_$m; exit 1; }
 done
-$GM2 $FLAGS -c tests/ComTest.mod >/tmp/cm_c_ComTest 2>&1 \
-   || { echo "COMPILE_FAIL ComTest"; grep -m5 "error:" /tmp/cm_c_ComTest; exit 1; }
+$GM2 $FLAGS -c tests/ComTest.mod >../tmp/cm_c_ComTest 2>&1 \
+   || { echo "COMPILE_FAIL ComTest"; grep -m5 "error:" ../tmp/cm_c_ComTest; exit 1; }
 
 rm -f tests/ct.lst comtest
 $GM2 $FLAGS -fgen-module-list=tests/ct.lst -o /dev/null \
     tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \
-    >/tmp/cm_p1 2>&1
+    >../tmp/cm_p1 2>&1
 p1=$?
 $GM2 $FLAGS -fuse-list=tests/ct.lst -o comtest \
     tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \
-    >/tmp/cm_p2 2>&1
+    >../tmp/cm_p2 2>&1
 p2=$?
 if [ $p2 -ne 0 ]; then
    echo "LINK_FAIL p1_rc=$p1 p2_rc=$p2"
-   grep -E "error:|undefined" /tmp/cm_p2 | head -10
+   grep -E "error:|undefined" ../tmp/cm_p2 | head -10
    exit 1
 fi
 echo "comtest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)"
@@ -90,88 +92,45 @@ fi
 fi
 
 # ---- independent verification of the bytes on disk ------------------------
-python3 - "$OUT" <<'PYEOF'
+python3 - "$OUT" "$D/tests" <<'PYEOF'
 import sys, os, re, glob
 out = sys.argv[1]
 
-# ENT_SZ and HDR_SZ are the layout constants, and they are RESTATED here on
-# purpose: the checker must not ask the code under test what the answer is.
+# The layout of a linked image - ENT_SZ, HDR_SZ, the load bias, initmem's
+# first bytes, and the function that MEASURES where the header is in a given
+# file - comes from tests/comimage.py and is not written down here.  This
+# checker carried its own copy of all of it and tests/comtest.py carried a
+# second; a duplicated constant that has silently drifted is not an
+# independent check, it is a second source of truth that lies, and it lies in
+# the direction of looking like the compiler is broken.  The runtime's SIZE
+# was such a constant (a literal 391 beside a comment saying it tracked
+# Runtime.RT_Size()), so the header was read out of the middle of the code and
+# every linked fixture "failed" on a header full of code bytes.
 #
-# RT_SZ is different, and it is NOT restated.  It used to be a literal, and it
-# was WRONG - 391, against a runtime of 432 bytes - so the header was read at
-# offset 394 instead of 435 and every one of the 30 .COM files "failed" on a
-# header full of code bytes.  A duplicated constant that has silently drifted
-# is not an independent check; it is a second source of truth that lies, and
-# it lies in the direction of looking like the compiler is broken.
+# The measurement is still independent of the compiler: find_header reads the
+# emitted file, not a Modula-2 variable, so the code under test cannot satisfy
+# it by agreeing with itself.  tests/check_runtime.py pins the runtime's size
+# explicitly, which is where a deliberate size change should be noticed.
 #
-# So the runtime's size is MEASURED, from the .COM itself: the runtime is the
-# region between the entry jump and the program header, and the header is
-# found by its own signature rather than by an assumed offset (hdrFlag = 1,
-# with the code END and the data base where the layout says they are).  If
-# the runtime ever changes size, this follows automatically; if the LAYOUT
-# changes, the header stops being found and the checker says so instead of
-# quietly measuring the wrong thing.
+# The image starts with a three-byte JMP at offset 0 (Compiler.Inittur): a
+# .COM is entered at file offset 0, and until that jump existed this checker
+# ASSERTED that the runtime was at offset 0, which was precisely the bug -
+# every .COM began by executing initmem with whatever the loader left in AX.
+# A checker that pins a wrong invariant is worse than no checker, because it
+# makes the wrong thing look tested.  RTSZ (the header's image offset), PROLOG
+# (RTSZ + HDR_SZ, where the entry jump must land) and DATAB (RTSZ + 1000h, the
+# compiler's data base) are all DERIVED PER FILE by find_header below.
 #
-# The measurement is still independent of the compiler - it reads the emitted
-# file, not a Modula-2 variable - so it cannot be satisfied by the code under
-# test agreeing with itself.  tests/check_runtime.py pins the size explicitly,
-# which is where a deliberate size change should be noticed.
-RT_SZ = None                    # measured per .COM by find_header, below
-
-# The image starts with a three-byte JMP at offset 0 -- see the layout comment
-# in Compiler.Inittur.  It has to be there: a .COM is entered at file offset
-# 0, and until the jump existed this checker ASSERTED that the runtime was at
-# offset 0, which is precisely the bug.  A checker that pins a wrong invariant
-# is worse than no checker, because it makes the wrong thing look tested.
-ENT_SZ = 3                     # E9 lo hi
-HDR_SZ = 16                   # 5 header words + 3 buffer words, see Compiler
-# RTSZ (image offset of the program header), PROLOG (RTSZ + HDR_SZ, where the
-# entry jump must land) and DATAB (RTSZ + 1000h, the compiler's data base) are
-# all DERIVED PER FILE by find_header below, not written down here.  They used
-# to be module constants built on the restated RT_SZ, which is the bug this
-# whole block exists to remove: every one of them was wrong by 41 bytes, and
-# a checker that is consistently wrong in a simple direction does not fail -
-# it re-reports the same false 30 failures, in which the real ones hide.
-
-# The load bias: DOS puts a .COM's first byte at CS:0100, and CS = DS, so an
-# image offset K lives at DS:(K + 0100h).  Every ABSOLUTE address in the
-# image must carry it; relative encodings (the entry jump, every CALL) must
-# not, since both operands shift together.  Restated here so that the header
-# checks below compare against the addresses the program will actually use,
-# and so that the +0100h in them is a decision this checker made rather than
-# an accident of the compiler's.  See Runtime.LoadBias.
-#
-# This is the THIRD bias of the same family in this file, and the subtlest:
-# the entry jump (a jump that landed on the end of the code), the RT_Entry
-# offsets (CALLs that landed inside a neighbouring runtime entry) and this
-# one (absolute addresses that landed 0100h low, inside the runtime) all
-# produce a program that STARTS, RUNS and PRINTS something.  Only running it
-# finds this one; the byte checks are all satisfied by an address that is
-# consistently 0100h wrong.
-LOAD_BIAS = 0x100
-
-# initmem's prologue, which is the runtime's only reader of the program
-# header.  The displacements +4 and +6 below are the whole point of this
-# constant: the header word checks further down read hdrDS at +4 and hdrHeap
-# at +6, and initmem has to read the SAME two words or it clears the wrong
-# range.  It used to read +8 (hdrMax, which the compiler patches to 0), so it
-# zeroed nothing at all, and nothing here noticed -- the emitted loop was
-# perfectly well formed, it just never ran.  Asserting the bytes and the
-# header offsets together is what closes that gap.
-#
-# It is the FIRST ELEVEN BYTES OF THE RUNTIME, so it sits at image offset
-# ENT_SZ, not 0.
-HEAD  = '8B F0 8B 54 04 8B 4C 06'   # 11 bytes of initmem, see below
-HDR_DS_WORD   = 4             # header word holding the data base
-HDR_HEAP_WORD = 6             # header word holding the data end
-# Byte 4 of HEAD is the displacement of initmem's MOV DX,[SI+?], and byte 7
-# the displacement of its MOV CX,[SI+?].  The checks below read the header
-# words at HDR_DS_WORD and HDR_HEAP_WORD, so tying those two displacements to
-# the same two constants is what makes the runtime and the compiler agree by
-# construction rather than by coincidence.
-assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
-       [HDR_DS_WORD, HDR_HEAP_WORD], \
-       'initmem no longer reads the two header words this checker verifies'
+# The load bias is the THIRD bias of the same family in this file, and the
+# subtlest: the entry jump (a jump that landed on the end of the code), the
+# RT_Entry offsets (CALLs that landed inside a neighbouring runtime entry) and
+# absolute addresses (which landed 0100h low, inside the runtime) all produce
+# a program that STARTS, RUNS and PRINTS something.  Only running it finds the
+# last one; the byte checks below are all satisfied by an address that is
+# consistently 0100h wrong.  See comimage.LOAD_BIAS and Runtime.LoadBias.
+sys.path.insert(0, sys.argv[2])
+from comimage import (ENT_SZ, HDR_SZ, LOAD_BIAS, HEAD, HDR_DS_WORD,
+                      HDR_HEAP_WORD, find_header)
 
 raw = open(os.path.join(out, 'raw.txt')).read()
 rows = re.findall(r'(\S+\.pas)\s+OK\s+com=(\d+)\s+image=(\d+)\s+data=(\d+)\s+nonzeroInGap=(\d+)', raw)
@@ -180,51 +139,6 @@ if not rows:
     sys.exit(1)
 
 
-def find_header(d):
-    """Locate the program header by its own signature.  Returns its image
-    offset, or None.
-
-    The header is eight words at image offset ENT_SZ + rtSz, and the layout
-    says what they are (offsets here are BYTES into the header, which is why
-    HDR_DS_WORD is 4 and not 2 - the words are 2 bytes each and 1-based by
-    two, not by one):
-
-        +0  1                 hdrFlag, always 1
-        +2  code end + bias   hdrCS
-        +4  data base + bias  hdrDS, where data base = hdrOff + 1000h
-        +6  data end  + bias  hdrHeap, which is hdrDS + dataBytes
-
-    hdrDS ties the header to its OWN offset, so the offset is recoverable from
-    the file without assuming a runtime size: hdrOff = hdrDS - 1000h - bias.
-    A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
-    equation, hdrHeap is above hdrDS (a heap below its own base is not a
-    layout, it is a coincidence), and the runtime's known first bytes are
-    where they belong.  initmem is the ONLY code in the image that reads the
-    header, so its bytes cannot themselves move: they are at ENT_SZ always.
-
-    Measuring beats restating the size, and it is not a loss of independence:
-    it reads the EMITTED FILE, so the compiler cannot satisfy it by agreeing
-    with itself.  tests/check_runtime.py is where the runtime's size is pinned
-    deliberately, and this checker reports the size it measured on every run,
-    so a change there is visible rather than absorbed.
-    """
-    head = bytes(int(x, 16) for x in HEAD.split())
-    if d[ENT_SZ:ENT_SZ + len(head)] != head:
-        return None                      # no runtime: nothing to measure
-    for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
-        w = (lambda b: int.from_bytes(d[off + b:off + b + 2], 'little'))
-        if w(0) != 1:                                    # hdrFlag
-            continue
-        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:    # hdrDS
-            continue
-        if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):            # hdrHeap
-            continue
-        if w(2) - LOAD_BIAS < off + HDR_SZ:               # hdrCS
-            continue
-        return off
-    return None
-
-
 bad = 0
 last_rt = None
 for name, com, image, data, nzg in rows:

+ 9 - 7
shell/tests/run_compile_tests.sh

@@ -10,6 +10,8 @@ D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
 GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
 cd "$D" || exit 9
 FLAGS="-fiso"
+# build logs go beside the tree (TP3-comp/tmp), never in /tmp
+mkdir -p ../tmp
 
 # Is `compiletest` itself current?  It links against the .o files, and `make`
 # only ever builds `tpshell` - so after an ordinary `make`, compiletest is
@@ -48,14 +50,14 @@ if [ ! -f TextBuf.o ] || [ ! -f Compiler.o ] || [ ! -f Posix.o ] \
    # to be built here too - it used to be assumed present, which made the
    # script fail with "cannot find Posix.o" after any clean.
    if [ ! -f Posix.o ] || [ Posix.c -nt Posix.o ] || [ Posix.def -nt Posix.o ]; then
-      cc -c Posix.c >/tmp/ct_c_Posix 2>&1 \
-         || { echo "COMPILE_FAIL Posix"; grep -m5 "error:" /tmp/ct_c_Posix; exit 1; }
+      cc -c Posix.c >../tmp/ct_c_Posix 2>&1 \
+         || { echo "COMPILE_FAIL Posix"; grep -m5 "error:" ../tmp/ct_c_Posix; exit 1; }
    fi
    for m in TextBuf Compiler Runtime; do
-      $GM2 $FLAGS -c $m.mod >/tmp/ct_c_$m 2>&1
+      $GM2 $FLAGS -c $m.mod >../tmp/ct_c_$m 2>&1
       if [ $? -ne 0 ]; then
          echo "COMPILE_FAIL $m"
-         grep -m5 -E "error:|Please submit" /tmp/ct_c_$m
+         grep -m5 -E "error:|Please submit" ../tmp/ct_c_$m
          exit 1
       fi
    done
@@ -64,14 +66,14 @@ fi
 
 rm -f tests/ct.lst compiletest
 $GM2 $FLAGS -fgen-module-list=tests/ct.lst -o /dev/null \
-    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o Runtime.o >/tmp/ct_p1 2>&1
+    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o Runtime.o >../tmp/ct_p1 2>&1
 p1=$?
 $GM2 $FLAGS -fuse-list=tests/ct.lst -o compiletest \
-    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o Runtime.o >/tmp/ct_p2 2>&1
+    tests/CompileTest.mod TextBuf.o Posix.o Compiler.o Runtime.o >../tmp/ct_p2 2>&1
 p2=$?
 if [ $p2 -ne 0 ]; then
    echo "LINK_FAIL p1_rc=$p1 p2_rc=$p2"
-   grep -E "error:|undefined" /tmp/ct_p2 | head -10
+   grep -E "error:|undefined" ../tmp/ct_p2 | head -10
    exit 1
 fi
 echo "compiletest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)"

+ 229 - 0
shell/tests/run_exec86.py

@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+"""run_exec86.py -- run the same .COM under Exec86 and under qemu, and require
+the two machines to agree byte for byte.
+
+    .pas --Compiler.mod--> image --Linker.mod--> .COM
+    .COM --Exec86Run--> our interpreter ----------\
+    .COM --bootcom.s--> floppy --qemu-----------> compare
+
+This is the cross-validation the summary asked for, and it is a claim about a
+*pair*: either one alone would pass while being wrong in the same direction.
+
+  * the expected output is still the fixture's hand-derived .out, so the
+    interpreter is not being compared only against qemu.  If both machines
+    were wrong the same way, .out - which no machine has ever written - would
+    still catch it;
+  * and the two machines are compared against each other directly, so a
+    disagreement is reported as the two byte strings rather than as one test
+    going red for a reason three assertions away;
+  * the exit code is compared too, so "produced the right bytes and then fell
+    over" cannot pass.
+
+Nothing here is copied from run_com_exec.py: the boot sector, the floppy
+layout, the input descriptor's offsets, qemu's exit-code convention and the
+fixture list are all imported from it.  Two copies of that layout would be two
+things that can drift apart, and the layout has already produced two silent
+bugs once (see to_file's docstring there).
+
+Usage:
+
+    tests/run_exec86.py                 # every fixture that has a .out
+    tests/run_exec86.py t02_writeln     # one, by name
+    tests/run_exec86.py --list
+    tests/run_exec86.py --show t02      # print what came out, assert nothing
+    tests/run_exec86.py --no-qemu       # skip the cross-check, for iteration
+
+There is deliberately no --rebless.  The .out files are derived by hand from
+what the Pascal means, and a script that can regenerate them from what a
+machine did is a way to turn a red test into a green one without ever looking
+at the program.  run_com_exec.py still has it, under that project's rules.
+"""
+
+import os
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SHELL = os.path.dirname(HERE)
+GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2"
+
+# run_com_exec.py owns the boot sector, the floppy, the input descriptor and
+# the qemu exit-code convention.  Importing is what keeps this file from
+# becoming a second, subtly different copy of all four.
+sys.path.insert(0, HERE)
+import run_com_exec as rce  # noqa: E402
+
+
+# ------------------------------------------------------------ the interpreter
+def ensure_exec86run():
+    """Build tests/Exec86Run.mod into ./exec86run if it is out of date.
+
+    Freshness is measured against the .o files that get LINKED, not against
+    the sources they came from, for the same reason rce.ensure_comtest does
+    it that way: `make` relinks an .o from an edited .mod, and if only the
+    .mod were watched, a freshly rebuilt Exec86.o could sit next to an
+    exec86run that still contained the old interpreter - so the fix under
+    test would never actually be in the binary being tested.
+    """
+    exe = os.path.join(SHELL, "exec86run")
+    objs = ["Posix.o", "Exec86.o"]
+    newer = objs + ["Exec86.mod", "Posix.c", os.path.join("tests", "Exec86Run.mod")]
+    if os.path.exists(exe):
+        t = os.path.getmtime(exe)
+        if all(os.path.exists(os.path.join(SHELL, f))
+               and os.path.getmtime(os.path.join(SHELL, f)) <= t
+               for f in newer):
+            return exe
+    lst = os.path.join(SHELL, "tests", "e86.lst")
+    link = [os.path.join(SHELL, "tests", "Exec86Run.mod")]
+    objs_abs = [os.path.join(SHELL, o) for o in objs]
+    subprocess.run([GM2, "-fiso", "-fgen-module-list=" + lst, "-o", "/dev/null"]
+                   + link + objs_abs, capture_output=True, cwd=SHELL)
+    r = subprocess.run([GM2, "-fiso", "-fuse-list=" + lst, "-o", exe]
+                       + link + objs_abs, capture_output=True, cwd=SHELL)
+    if r.returncode != 0:
+        sys.exit("linking exec86run failed:\n"
+                 + r.stderr.decode(errors="replace"))
+    return exe
+
+
+def parse_status(err):
+    """The driver's one report line, or None if it is not there.
+
+    Parsed strictly: a driver that printed no line, or a line this does not
+    understand, is a failure to report rather than something to guess at.
+    """
+    for line in err.decode("latin-1").splitlines():
+        if not line.startswith("exec86: status="):
+            continue
+        out = {}
+        for field in line[len("exec86: "):].split():
+            k, _, v = field.partition("=")
+            if not v.isdigit():
+                return None
+            out[k] = int(v)
+        if set(out) != {"status", "exit", "steps"}:
+            return None
+        return out
+    return None
+
+
+def run_exec86(exe, com, guest_in):
+    """Run one .COM under Exec86.  Return (output, report or None, note)."""
+    payload = com.encode() + b"\n" + guest_in
+    try:
+        r = subprocess.run([exe], input=payload, capture_output=True,
+                           timeout=rce.TIMEOUT)
+    except subprocess.TimeoutExpired:
+        return b"", None, "the interpreter never halted (timeout %ds)" % rce.TIMEOUT
+    rep = parse_status(r.stderr)
+    if rep is None:
+        return r.stdout, None, ("no report line; stderr was:\n  "
+                                + r.stderr.decode("latin-1").replace("\n", "\n  "))
+    return r.stdout, rep, ""
+
+
+# ------------------------------------------------------------------- reporting
+def visible(raw):
+    return raw.decode("latin-1").replace("\r", "\\r").replace("\n", "\\n")
+
+
+def main(argv):
+    if "--list" in argv:
+        for c in rce.cases():
+            print(c["name"])
+        return 0
+    show = "--show" in argv
+    cross = "--no-qemu" not in argv
+    names = [a for a in argv if not a.startswith("-")]
+    all_cases = list(rce.cases())
+    if names:
+        sel = [c for c in all_cases if c["name"] in names]
+        missing = set(names) - set(c["name"] for c in sel)
+        if missing:
+            sys.exit("no such fixture: " + ", ".join(sorted(missing)))
+    else:
+        sel = all_cases
+
+    exe = ensure_exec86run()
+    boot = rce.build_boot_sector() if cross else None
+    workdir = tempfile.mkdtemp(prefix="tpexec86-")
+    passed = failed = 0
+
+    for c in sel:
+        pas = os.path.join(HERE, "fixtures", c["name"] + ".pas")
+        com, log = rce.emit_com(rce.ensure_comtest(), pas, workdir)
+        if com is None:
+            print("  %-22s FAIL  comtest wrote no .COM" % c["name"])
+            if log:
+                print("       %s" % log)
+            failed += 1
+            continue
+
+        got, rep, note = run_exec86(exe, com, c["stdin"])
+        want = open(c["out"], "rb").read()
+
+        if show:
+            print("  %-22s %s" % (c["name"], visible(got)))
+            if rep:
+                print("       %s" % rep)
+            continue
+
+        problems = []
+        if rep is None:
+            problems.append(note)
+        elif rep["status"] != 0:
+            problems.append("interpreter status=%d (0 = halted through INT 21h "
+                            "AH=4Ch)" % rep["status"])
+
+        if got != want:
+            problems.append("output differs from the hand-derived .out\n"
+                            "         want  %s\n"
+                            "         got   %s" % (visible(want), visible(got)))
+
+        if cross:
+            img = os.path.join(workdir, c["name"] + ".img")
+            open(img, "wb").write(rce.build_floppy(boot, open(com, "rb").read(),
+                                                   c["stdin"]))
+            qout, rc, qnote = rce.run_qemu(img)
+            if rc is None:
+                problems.append("qemu: " + qnote)
+            else:
+                qcode = rce.exit_code_of(rc)
+                if qcode is None:
+                    problems.append("qemu rc=%d, which no .COM exit can produce"
+                                    % rc)
+                else:
+                    if qout != got:
+                        problems.append("disagrees with qemu\n"
+                                        "         qemu    %s\n"
+                                        "         exec86  %s"
+                                        % (visible(qout), visible(got)))
+                    if rep is not None and rep["status"] == 0 \
+                            and rep["exit"] != qcode:
+                        problems.append("exit code differs: exec86=%d qemu=%d"
+                                        % (rep["exit"], qcode))
+
+        if problems:
+            failed += 1
+            print("  %-22s FAIL" % c["name"])
+            for p in problems:
+                print("       %s" % p)
+        else:
+            passed += 1
+            tail = "" if rep is None else ", exit %d, %d steps" % (rep["exit"],
+                                                                  rep["steps"])
+            print("  %-22s PASS  %d bytes%s" % (c["name"], len(got), tail))
+
+    if show:
+        return 0
+    print()
+    print("exec86: %d passed, %d failed (of %d)%s"
+          % (passed, failed, len(sel),
+             ", cross-checked against qemu" if cross else ", qemu cross-check off"))
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 120 - 0
shell/tests/runtest.py

@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""End-to-end UI test: R compiles and RUNS the image, with no file written.
+
+uitest.py covers the compile error path, comtest.py the artifact path.  This
+covers TP3's `R', which produces no artifact at all: the linked image is poked
+into the interpreter's own 64 KB at 0100h and executed inside this process, so
+the only evidence it ever existed is what the program printed.
+
+  1. W loads a work file (LoadWorkFile ends with a Pause: needs a filler key)
+  2. R compiles, reports the code and data sizes, pokes LinkSize() bytes at
+     0100h, runs them, and reports a status and a step count
+  3. The GUEST'S OWN OUTPUT is compared against the fixture's .out
+
+Step 3 is the point, and it is deliberately not "the shell said OK".  The .out
+files in this project are hand-derived from Pascal's semantics and never
+blessed from a machine's output, so comparing against one checks the
+interpreter against a third thing that the code under test did not produce:
+the compiler chose the bytes, the interpreter executed them, and neither had a
+say in what the answer was supposed to be.
+
+Step 4-5: the reported status must be a clean INT 21h AH=4Ch with code 0, the
+step count must be non-zero (a run that executed nothing is not a run), ESC
+must get back to the main menu, and the .COM on disk must be untouched - R is
+Memory and .COM alike by design, and "it didn't write a file" is a claim about
+behaviour that only this test can make.
+
+Usage: runtest.py [fixture.pas]        (the fixture must have a .out)
+
+Passing a fixture whose .out does not match is expected to be RED - that is
+the test being non-vacuous, not a failure of the shell.
+"""
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from ptyharness import SHELL_DIR, drain, reap, send, spawn, status_str, visible
+
+FIXTURE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(
+    os.path.dirname(os.path.abspath(__file__)), "fixtures", "t34_arith.pas")
+OUT = os.path.splitext(FIXTURE)[0] + ".out"
+COM = os.path.splitext(FIXTURE)[0] + ".COM"
+
+if not os.path.exists(OUT):
+    sys.exit("runtest.py: %s has no .out, so there would be nothing to "
+             "compare the guest's output against" % os.path.basename(OUT))
+
+# Hand-derived expectation, read as the raw CRLF bytes the runtime emits.
+EXPECTED = open(OUT, "rb").read()
+
+
+def steps_of(text):
+    m = re.search(r"steps executed: (\d+)", text)
+    return int(m.group(1)) if m else -1
+
+
+def main():
+    com_before = os.stat(COM).st_mtime_ns if os.path.exists(COM) else None
+    pid, fd = spawn()
+    checks = []
+    run = ""
+    after = ""
+    try:
+        drain(fd, quiet=0.6)
+        send(fd, b"w")
+        send(fd, FIXTURE.encode())
+        send(fd, b"\r", quiet=0.8)
+        send(fd, b" ", quiet=0.5)              # clears LoadWorkFile's Pause
+
+        run = visible(send(fd, b"r", quiet=2.5))
+        checks.append(("R reported a successful compile",
+                       "Compiled OK - code" in run))
+        checks.append(("R reported poking the image at 0100h",
+                       "bytes at 0100h" in run))
+        # The status line is what Run86 answered: 0 is the guest's own
+        # INT 21h AH=4Ch, 1 an interpreter fault, 2 the step limit.  Only 0
+        # means the program finished because it decided to.
+        checks.append(("guest ran to a clean AH=4Ch exit with code 0",
+                       "program terminated (INT 21h AH=4Ch, code 0)" in run))
+        checks.append(("R reported a non-zero step count", steps_of(run) > 0))
+        checks.append(("guest output matches the hand-derived .out",
+                       EXPECTED.decode("latin-1") in run))
+
+        after = visible(send(fd, b"\x1b", quiet=0.8))
+        checks.append(("ESC after the run returned to the main menu",
+                       "Main file" in after))
+        send(fd, b"q", quiet=0.8)
+    finally:
+        status = reap(fd, pid)
+
+    checks.append(("shell exited cleanly (status 0)", os.WIFEXITED(status)
+                   and os.WEXITSTATUS(status) == 0))
+    com_after = os.stat(COM).st_mtime_ns if os.path.exists(COM) else None
+    checks.append(("R wrote no .COM (it runs the image where it already is)",
+                   com_before == com_after))
+
+    print("UI TEST (R): %s" % os.path.basename(FIXTURE))
+    print("-" * 60)
+    ok = True
+    for name, passed in checks:
+        print("%-58s %s" % (name[:58], "PASS" if passed else "FAIL"))
+        ok = ok and passed
+    print("-" * 60)
+    if not ok:
+        # Only when something failed, and only what is needed to say why: the
+        # captured run and the bytes wanted, so a red line is readable without
+        # re-running it by hand.
+        print("wanted: %r" % EXPECTED)
+        print("captured after R:")
+        for line in run.splitlines():
+            if line.strip():
+                print("   | %s" % line)
+        print("steps: %d" % steps_of(run))
+    print("child: %s" % status_str(status))
+    print("RESULT: %s" % ("ALL PASS" if ok else "FAILURES PRESENT"))
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())