Przeglądaj źródła

TP3-CMDRUN: the R key runs the image in-process, with a second oracle

CmdRun (the R menu key) is implemented: compile, Clear86, poke LinkSize()
bytes at 0100h, Run86, report the guest's AH=4Ch exit code and step count,
then wait for ESC.  No file is written, so R behaves identically with
Destination = Memory and Destination = .COM.

Exec86 is that machine: a flat 8086 over a 64 KB array, CS=DS=ES=SS=0,
IP=0100h, SP=0FFFEh, three entry points.  The 86 suffix on Clear86, Poke86
and Run86 is forced by ISO Modula-2, which has no import renaming, because
the flat namespace already holds TextBuf.Clear and Editor.Run.  INT 21h
AH=02/08/09 are handled directly with a whole-address-space guard; PF/AF,
string instructions and non-zero segment registers fault rather than
guess, and Exec86.mod's header states the measured instruction set it
implements and refuses to answer for anything outside it.

Two bugs, both found by writing fixtures for operators nothing had ever
executed:

  34. A computed left operand was destroyed while the right one was being
      parsed.  SaveLeft and LoadPair fix the binary-operator path.  The
      call path has the same hole - f (a > b, x) - and is documented as a
      known gap, not fixed.
  35. `not` was lowered the same way for booleans and integers, so every
      boolean negation came out wrong.  ParseNeg now dispatches on the
      operand's class, reproducing TPSRC9's neglevel split.

New gates.  tests/run_exec86.py runs every image through the interpreter
as well as through qemu and requires byte-for-byte agreement; it has no
--rebless, so the .out files stay hand-derived and agreeing with qemu is a
third opinion rather than a second vote.  tests/runtest.py drives R
through a pty and asserts eight things, including that no file appeared on
disk.  Both are registered in run_all.sh, which now runs 14 checks and
prints OVERALL: ALL PASS.  nonvacuity.sh reaches 52 ok, 0 failed, with
every new assertion proven red before it was written down.

Named scratch and build logs now go to TP3-comp/tmp/ instead of /tmp, in
every test that writes one.

SUMMARY.md carries the whole account: the milestone row, the interpreter
section, the two new checks, the two new bug narratives, and the gaps that
were deliberately left open.
Eric Streit 3 dni temu
rodzic
commit
842dcff3a8

+ 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/

+ 276 - 71
SUMMARY.md

@@ -24,7 +24,7 @@ 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** |
 
 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 +71,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 +94,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 +115,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 +135,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 +171,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 +185,11 @@ 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_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
@@ -233,15 +249,19 @@ by building it and requiring the check to stay green.
 
 ### 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
@@ -270,16 +290,15 @@ there are two of them and why both were wrong in the same way.
 
 ### 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 +339,91 @@ 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: **52 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: 52 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 8086 lowering,
+the interpreter itself, and the `R` key.
+
+Eight of those 52 arrived with this milestone, 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.
 
 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 +479,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 +695,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
+
+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:
 
-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.
+```
+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 +763,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 +1014,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 +1083,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 +1098,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 +1446,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 +1531,64 @@ 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` (`52 ok, 0
+failed` in 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,34 +1665,43 @@ 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.
-2. **String *variables*** — `s : string`, `s := 'hi'`, `writeln(s)`. The
+1. **Close the third restated `RT_SZ`.** `tests/check_framedisp.py` hard-codes
+   `RT_SZ = 391` where the runtime now measures 436, so `img[RTSZ:]` begins 61
+   bytes inside the runtime tail and the check passes by luck. `run_com_tests.sh`
+   and `comtest.py` carried the same constant and were fixed by *measuring* the
+   header from its own signature; this one needs that shared helper
+   (`tests/comimage.py`) and, being new, its own non-vacuity case — a green
+   nobody has ever seen red is the failure mode this project keeps
+   rediscovering, and this is the last known instance of it.
+2. **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").
+3. **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
    refuses with `ENoLib`.
-3. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
+4. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its
    point of use (`t14`), `case` with subrange labels.
-4. **`readln` of a `BYTE`** calls `rdint`, which stores 2 bytes and overflows
+5. **`readln` of a `BYTE`** calls `rdint`, which stores 2 bytes and overflows
    into the next variable. TP3 has a separate `xrdbyte`; a `TU_RdByte` entry is
    the fix. No fixture exists yet, which is why it has not been done — write
    the fixture first, so the bug is red before the fix.
-5. Make the 4 KiB code window an enforced limit rather than a documented one:
+6. Make the 4 KiB code window an enforced limit rather than a documented one:
    report an error when `pc` reaches `dc`, instead of writing over the data.
-6. Both spellings of a multi-name declaration. `var i, c : integer;` is
+7. Both spellings of a multi-name declaration. `var i, c : integer;` is
    error 1 at the comma and needs two `var` lines; `procedure f (a : integer;
    b : integer)` is error 1 at the semicolon and needs a comma. Neither is
    wrong Pascal, so a program that compiles under one compiler may not under
    another. A parameter may also not shadow a global (`DupTest` rejects any
    name `Search` finds at any level), which Pascal allows.
-7. Harden the program-header parameter loop against non-advancing input
+8. 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.
+9. 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),

+ 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,

+ 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.

+ 294 - 27
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
@@ -44,9 +48,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 +78,34 @@ 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.
+mkdir -p ../tmp
+SAVED_E=../tmp/nonvacuity.Exec86.mod
+SAVED_SH=../tmp/nonvacuity.Shell.mod
 
 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
+
+restore_all () {
+    cp "$SAVED" Runtime.mod
+    cp "$SAVED_E" Exec86.mod
+    cp "$SAVED_SH" Shell.mod
+    "$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 +127,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 +252,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 +310,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 () {
@@ -364,13 +404,13 @@ fi
 
 # --- 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 +427,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 +593,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 +682,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 +810,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 +982,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 +1052,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 +1100,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 +1161,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" \
@@ -950,11 +1217,11 @@ echo "== the .COM layout check, and the runtime size it now measures"
 # 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))

+ 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.

+ 44 - 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.
 #
@@ -111,11 +133,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"
@@ -142,8 +167,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 +192,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 "=============================================================="

+ 9 - 7
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)"

+ 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())