Просмотр исходного кода

Run every runtime entry on a real 8086, and state the register contract

The last harness that reported a number nobody could act on now reports a
verdict.  `rt_exec.py` was written against Unicorn 2.1.4, whose 16-bit ModRM
decoding is wrong on this machine, so it failed 33 of 33 -- every failure was
the emulator's, and a wrong oracle is worse than none, because it cannot tell
"my codegen is broken" from "the machine is broken".  It now boots the same
`exec/bootcom.s` the .COM fixtures use, one fresh machine per case, and passes
35 cases plus a pre-flight.  Wired into `run_all.sh`; `ensure_rtprobe()` is now
the only recipe for RtProbe, so a probe can never be older than the runtime it
reports on.

Bugs this found, none of which any byte-level check could see:

* `bootcom.s` DMA'd the image straight to 0000:0100, which is what DOS does and
  which *works*: CF=0, the right sector count, the right length, in the right
  place.  But 0400h-04FFh is the BDA, and SeaBIOS writes part of it back after
  the transfer, so ten bytes of BIOS data ended up on top of the image.  Found
  with a 19-byte probe that read 0440h as its first instruction and got the
  BIOS's own bytes back, then localised by dumping the whole loaded image: one
  10-byte run wrong inside an otherwise byte-perfect sector, which no
  partial-read or sector-count bug can produce.  Now reads to 8000h and
  REP MOVSWs down to 0100h.

* `CmpAl (20)` was decimal twenty.  It emits `3C 14`, not the `3C 20` of the
  hex it reads as, so rdint's lead-in skip never skipped a space.  Every magic
  number in Runtime.mod is now explicit hex.

* `wrchar` and `wrbool` destroyed BP.  Both borrow it to reach their argument
  -- [SP] is unencodable in 16-bit mode -- and neither saved it.  BP is the one
  register an entry may keep, because the driver keeps its cursor into the case
  record there, so wrchar sent the *next* call to a garbage address and the
  machine triple-faulted.

The third is the one worth reading twice, because every existing check called
that shape correct: the bytes were well formed, the size did not change, all
branch targets were on instruction boundaries, the name audit said every helper
emitted what its name said, and check_runtime.py's entry golden had *blessed*
it in five bytes.  A positional golden blesses whatever is there.  So the rule
is now stated in two places that can disagree -- the entry goldens carry the
PUSH BP and POP BP (what the bytes must be), and a `check_bp_contract`
pre-flight checks the same rule over the built blob (what the bytes must mean).
Its two halves are not equally strong and the code says which is which: "must
start with 55 8B EC" is exact, "must contain a 5D" is a screen, because 5D is
also a displacement byte.  Demanding the POP be contiguous is not an
improvement -- wrbool closes its frame after the INT 21h -- since a rule that
fails a correct entry teaches a reader to distrust the check.

It changed the emitters too.  [BP+2] versus [BP+4] is a one-byte difference
that reads as a plausible character, and the first version of the fix passed
the displacement as a Modula-2 *parameter* -- invisible to audit_helpers.py,
because the audit reads the name.  The emitters are MovAlArg2/MovAlArg4 and
CmpArg2W0/CmpArg4W0 now, and the audit's CmpArg rule takes the displacement as
a captured group, so a CmpArg4W0 emitting the +2 form fails with "step 2:
displacement is 2, name says 4".  That is the general lesson: a parameter is
the one thing a name-based audit cannot see, so anything the audit must check
has to be in the name.

Two expectations in the harness were wrong, and in both cases the code was
right.  `rdchar` stores one byte (StDiDl = MOV [DI],DL) and the old check
dumped a word; `rdint` at end of input stores nothing, as TP3's xrdint does and
as EmitRdInt's own comment said, and the old expectation said zero.

nonvacuity.sh: 33 cases, 0 failed.  Four are new (the BP contract's two failure
modes, the golden, and a subject that has drifted), each guarded by a baseline
assertion first.  The .COM layout cases used to carry the header offset as the
literal 435 and 439, which were ENT_SZ + the runtime size at the time; four
bytes of runtime later they were editing the wrong bytes -- 435 was inside the
runtime itself, so case 1 had quietly stopped testing "the header cannot be
found" and case 2 had become a no-op writing the header where it already was.
Both still went red, for reasons the messages did not name, which is the whole
problem.  They now read the offset back out of the checker's own report.

Runtime 436 bytes, code ends at 405, 14 entries, 100 emitter helpers audited
100/100.  run_all.sh: 12 checks, ALL PASS.  nonvacuity.sh: 33 ok.  Compile
matrix 33/33, .COM linker 30/30, execution 21/21, runtime entries 36/36.
Eric Streit 1 неделя назад
Родитель
Сommit
d56860aa74

+ 5 - 2
.gitignore

@@ -6,6 +6,9 @@ shell/comtest
 shell/tests/ct.lst
 __pycache__/
 shell/tests/rtprobe
-# stray gm2 -fgen-module-list output from an earlier probe recipe; the current
-# one writes to /tmp/tp_rtprobe and needs no list file
+# stray gm2 -fgen-module-list output from ad-hoc probe recipes.  The one
+# recipe that still exists is tests/rt_exec.py:ensure_rtprobe(), and it needs
+# no list file.  Both names are here because the stray was spelled both ways
+# at different times, which is the same drift as a duplicated table.
 shell/rtprobe.lst
+shell/rtp.lst

+ 135 - 47
SUMMARY.md

@@ -144,10 +144,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 — **98/98**, from an inventory scanned independently of the parser | anything longer than one instruction |
-| `check_runtime.py` + `runtime.golden` | the built runtime's code region (432 bytes, code ends at 401) 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* |
+| `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 |
+| `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 |
+| `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
 they are hard.
@@ -221,7 +222,7 @@ was visible as a wrong byte count. See `overProc` below.
 
 **The `.COM` layout constants are measured, not restated.** `run_com_tests.sh`
 and `comtest.py` both used to hard-code `RT_SZ = 391` against a runtime that
-had since grown to 432 bytes, so they read the program header 41 bytes early
+had since grown to 432 bytes at the time, so they read the program header 41 bytes early
 and reported **30 false failures** — a red suite that meant nothing, which is
 the most expensive kind of red. Both now locate the header by its own
 signature (`hdrFlag = 1`, `hdrDS == hdrOff + 1000h + bias`, `hdrHeap > hdrDS`,
@@ -229,7 +230,7 @@ signature (`hdrFlag = 1`, `hdrDS == hdrOff + 1000h + bias`, `hdrHeap > hdrDS`,
 `prologAt` and `dataBase` per file:
 
 ```
-measured runtime size: 432 bytes (header at image offset 435)
+measured runtime size: 436 bytes (header at image offset 439)
 ```
 
 A restated constant that has drifted is worse than a derived one, and the two
@@ -382,23 +383,119 @@ EOF comparison is an index comparison — getting that wrong produces an
 `run_com_exec.py --show` prints the serial file, so a failing fixture can be
 told apart from a broken harness without re-deriving anything.
 
-### Still missing: `CmdRun`
-
-`tests/rt_exec.py` is the *other* harness — 33 direct calls into the runtime
-entries (`initmem`, `wrint` ×10 including both `INT16` extremes, `wrchar`,
-`wrbool`, `wrln`, `stackchk`, a composed `writeln(42) writeln TRUE`
-sequence, and the read entries against supplied input including EOF). It was
-written against Unicorn and **fails 33 of 33**; the failures are Unicorn's,
-not the library's, so `run_all.sh` does not run it. Re-pointing it at
-`qemu-system-i386` — reusing the very same `tests/exec/bootcom.s`, so the
-boot machinery is written once — turns those numbers into a verdict on the
-runtime rather than on the emulator. It has **no `wrtinl` case yet**, which
-needs a harness change rather than just a machine change: that entry's
-argument is not a stack word, the caller must place a length byte and the
-characters at the *return address*, so testing it also tests the encoding
+### The runtime, entry by entry — `tests/rt_exec.py`
+
+`run_com_exec.py` proves *emitted programs* behave. This harness proves the
+**runtime entries themselves** do, which is a different question: a program
+never calls `rdint` without a `readln` behind it, never calls `rdbool` twice in
+a row, and never exercises the `INT16` extremes individually.
+
+35 cases, one fresh qemu boot each, plus a pre-flight that runs before any of
+them: `initmem` (data poisoned to `AA` first, so
+a zero is a *result* and not a leftover), `wrint` ×10 including both `INT16`
+extremes, `wrchar` ×2, `wrbool` ×3, `wrln`, `stackchk`, a composed
+`writeln(42) writeln TRUE` sequence, `rdint` ×6 against supplied input,
+`rdchar` ×2, `rdbool` ×6, `rdint` at EOF, and `wrtinl`.
+
+Three properties are load-bearing, and each of them was chosen against the
+easier alternative:
+
+- **One boot per case.** A machine that has already run a case has already run
+  a runtime entry, and if that entry corrupts something the next case inherits
+  it. About a tenth of a second a boot buys a machine that has provably never
+  executed anything, and 35 of them cost under four.
+- **One case record, written twice.** `rt_exec.py` and `exec/rtdrv.s` each
+  state the 40-byte layout, and the cases only pass if the two agree on every
+  offset. A driver that silently read the wrong field would still boot, still
+  run, and still print — so the duplication is the check.
+- **A BP-contract pre-flight, before any machine starts.** See below.
+
+The harness reuses `tests/exec/bootcom.s`, the same 512-byte boot sector
+`run_com_exec.py` uses, so the boot machinery is written once.
+
+#### Two expectations were wrong, and both were wrong in the harness's favour
+
+Recording this because the instinct on a red test is to suspect the code, and
+in both cases the code was right:
+
+- `rdchar` stores **one** byte (`StDiDl` = `MOV [DI],DL`), so the old check
+  dumped a two-byte word and compared it against `ord(c)`. It could never pass.
+  The case now reports two bytes and expects the character followed by the
+  `0xEE` poison still sitting in the second.
+- `rdint` at end of input stores **nothing** — TP3's `xrdint` returns to `rnerr`
+  without touching the variable, and `EmitRdInt`'s own comment says so. The old
+  expectation said the variable was set to zero.
+
+`wrtinl` also needed a harness change rather than a machine change: that
+entry's argument is not a stack word, the caller must place a length byte and
+the characters at the **return address**, so testing it also tests the encoding
 contract between `Compiler.IoCall` and `Runtime.EmitWrInl`.
 
-And the `R` key of the shell is still unimplemented. TP3's `R` runs a `.COM`
+#### What execution found that no byte check could
+
+**A 10-byte corruption of the BIOS data area.** The boot sector DMA'd the image
+straight to `0000:0100`, which is what DOS does — and `0400h-04FFh` is the BDA,
+where SeaBIOS keeps live state. The transfer destroys it, SeaBIOS writes part
+of it back *after* the DMA, and ten bytes of BIOS data end up on top of the
+image. The read sets `CF=0` and returns success. The image is the right length
+in the right place and ten bytes of it are wrong.
+
+It was found by a 19-byte probe that read `0440h` as its first instruction
+after the jump and got the BIOS's own bytes back, and then localised by
+dumping the whole loaded image against a pattern: one 10-byte run wrong inside
+an otherwise byte-perfect sector, which no partial-read or sector-count bug
+can produce. The fix is in `bootcom.s`: read to `8000h` — clear of the IVT, the
+BDA, SeaBIOS's stack at `700h` and the ROM window at `C000h` — and then
+`REP MOVSW` down to `0100h`. The copy is executed code, so it is the last thing
+that touches the image and no BIOS call follows it.
+
+**`CmpAl (20)` was decimal twenty.** `rdint`'s lead-in skip is
+`CmpAl (20H)`, `JBE` — skip everything at or below a space. Written as `20`,
+Modula-2 read it as decimal and emitted `3C 14`, so a leading space was never
+skipped and the scan ended with nothing read. Every other magic number in
+`Runtime.mod` is now written as hex (`MovAh (02H)`, `CmpAl (0DH)`, `CmpAl
+(1AH)`), because a literal that *reads* like hex but is decimal is silent.
+
+**`wrchar` and `wrbool` destroyed BP.** Both borrow BP to reach their argument
+— `[SP]` cannot be encoded in 16-bit mode, so BP stands in — and neither saved
+it. BP is the one register an entry may keep, because the driver keeps its
+cursor into the case record there. So `wrchar` sent the *next* call to a
+garbage address, the machine triple-faulted, SeaBIOS rebooted, and the run
+printed the record header twice and hung. Symptom: `record was never closed
+(EOT)`.
+
+**The interesting part is that every existing check called that shape
+correct.** The bytes were well formed, the size did not change, the golden
+matched, all branch targets were on instruction boundaries, and the name audit
+said every helper emitted what its name said. `check_runtime.py`'s entry golden
+had *blessed* it in five bytes: `"wrchar": "8B EC 8A 46 02 89 EC"`. A positional
+golden blesses whatever is there.
+
+So the rule is now stated, in two places that can disagree:
+
+- `check_runtime.py`'s entry goldens carry the `PUSH BP` and the `POP BP`.
+  What the bytes must be.
+- `rt_exec.py` has a `check_bp_contract` pre-flight over the built blob. What
+  the bytes must *mean*. Its two halves are not equally strong and the code
+  says so: "must start with `55 8B EC`" is exact, and "must contain a `5D`" is
+  a *screen*, because `5D` is also a displacement byte and this code does not
+  disassemble. Requiring the `POP` to be contiguous with anything else is not
+  an improvement — `wrbool` legitimately closes its frame after the `INT 21h`,
+  and a rule insisting on `89 EC 5D` fails a correct entry, which is worse
+  because it teaches a reader to distrust the check.
+
+And a third consequence, in the emitters themselves: reaching the argument at
+`[BP+2]` versus `[BP+4]` is a one-byte difference that reads as a plausible
+character, and the first version of the fix passed that displacement as a
+Modula-2 **parameter** — which is invisible to `audit_helpers.py`, because the
+audit reads the *name*. The emitters are now `MovAlArg2`/`MovAlArg4` and
+`CmpArg2W0`/`CmpArg4W0`, with the displacement in the name, so the audit pins
+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 `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.
@@ -668,15 +765,15 @@ Details that are deliberate, not incidental:
   They have byte-level checks and no behavioural check at all. Writing nine
   `.out` files is cheap and is the highest-value step after `rt_exec.py`; the
   byte counts in `expected.tsv` will then have a behavioural counterpart.
-- **The runtime is audited, executed end to end, but not per entry.** 432
-  bytes, 14 entries, 98 emitter helpers decoded against their own names across
-  both modules, the whole code region golden-pinned, all branch targets on
-  instruction boundaries — and reached only through the 21 fixtures' call
-  sequences. `rt_exec.py` exists to call all 14 directly and currently fails
-  33/33 *because its machine is wrong*, so there is currently no per-entry
-  verdict. `wrtinl` is the newest entry and the only one no check touches even
-  in principle: its argument lives at its own return address, so testing it
-  needs a harness that models the caller's contract.
+- **The runtime's entries are now each called directly, and two of its
+  invariants are stated rather than implied.** 436 bytes, 14 entries, 100
+  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
+  (`rt_exec.py`, 36/36). What is *not* covered is what a direct call cannot
+  see: `wrtinl` is checked, but only from the harness's side of the calling
+  contract, and the nine fixtures that are compiled but never executed
+  (above) are still reached only through the 21 that are.
 - **The pushback slot's address is a moving target.** It sits at
   `rtSz + LoadBias + dataAt + D_PUSH`, so every runtime growth moves it, and
   every address derived from it must be recomputed. It is computed, not
@@ -928,8 +1025,8 @@ Eleven more, and the two worst in the project are here.
     so `rdint` consumed the delimiter and the following statement lost its
     first character. The fix is a one-character pushback slot `D_PUSH` plus
     `ungetch`, and the slot's *address moves whenever the runtime grows*
-    (`rtSz + LoadBias + dataAt + D_PUSH` — at 432 bytes, image `0x1B0`, memory
-    `0x2B0`, one pad byte before the program header at `0x1B3`). It is computed,
+    (`rtSz + LoadBias + dataAt + D_PUSH` — at 436 bytes, image `0x1B4`, memory
+    `0x2B4`, one pad byte before the program header at `0x1B7`). It is computed,
     never hard-coded: the same class of restated-constant bug as `RT_SZ` above.
 23. **`rdint` did not mirror TP3's `xrdint`/`readnum`.** TP3 checks for `^Z`
     *first*, then skips characters `<= 20h`, then an optional sign, then digits,
@@ -1039,37 +1136,28 @@ independently-scanned inventory at all.
 
 ## Next steps
 
-1. **Re-point `rt_exec.py` at qemu** and require all 33 of its checks to pass.
-   They currently fail 33/33 under Unicorn, and those failures are the
-   emulator's, not the runtime's. **The expectations stay; only the machine
-   changes**, and `tests/exec/bootcom.s` is reused so the boot machinery is
-   written once. This is next because it is the last harness that reports a
-   number nobody can act on, and because the 33 checks are a *per-entry* claim
-   about the runtime, which the 21 execution fixtures only cover end to end.
-   Add the missing `wrtinl` case, which needs a harness that models the
-   caller's contract (length byte and characters at the return address).
-2. **`CmdRun`** as an in-process 8086 interpreter — the `R` menu key, and a
+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 21 fixtures is far more
    evidence than either alone.
-3. **String *variables*** — `s : string`, `s := 'hi'`, `writeln(s)`. The
+2. **String *variables*** — `s : string`, `s := 'hi'`, `writeln(s)`. The
    encoding blocker is gone (`EmBpDisp`); what is left is a length word, an
    assignment path, and a `WrStr` entry (TPSRC4 `xwrtstr`). `IoCall` currently
    refuses with `ENoLib`.
-4. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
+3. 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.
-5. **`readln` of a `BYTE`** calls `rdint`, which stores 2 bytes and overflows
+4. **`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.
-6. Make the 4 KiB code window an enforced limit rather than a documented one:
+5. 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.
-7. Comma-separated names: `var i, c : integer;`.
-8. Harden the program-header parameter loop against non-advancing input
+6. Comma-separated names: `var i, c : integer;`.
+7. Harden the program-header parameter loop against non-advancing input
    (`program p(1;)`) with a `BOOLEAN` flag — **not** `EXIT`, which ICEs gm2.
-9. FreeDOS (`freedos.qcow2`, FD14-LiveCD) is still untried. Not needed for any
+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.

+ 95 - 3
TP3-COMPILER.md

@@ -157,9 +157,20 @@ green.**  If a fixture's expected output changes, the reason goes in
 `shell/tests/exec/bootcom.s` is the boot sector: 512 bytes, reads the `.COM`
 off the same floppy with `INT 13h`, sets `DS=ES=0`, `SS=2000h SP=2004h`, builds
 the three GDT descriptors a `.COM` expects, installs an `INT 21h` shim for
-`AH=02h/09h/08h/4Ch` to the serial port, and `JMP 0000:0100`.  It is
-reusable, and `rt_exec.py` is meant to reuse it rather than grow a second boot
-path.
+`AH=02h/09h/08h/4Ch` to the serial port, and `JMP 0000:0100`.  It is reusable
+rather than duplicated, and `rt_exec.py` reuses it.
+
+It reads the sixteen sectors to `8000h` and then `REP MOVSW`s them down to
+`0100h`, which is the whole boot sector's one subtlety.  A direct DMA to
+`0100h` is what DOS does, and it *works* -- sets CF=0, returns the right
+sector count -- but `0400h-04FFh` is the BIOS data area, where SeaBIOS keeps
+live state that it writes back *after* the transfer, and ten bytes of BIOS data
+end up on top of the image.  The image is the right length in the right place
+and ten bytes of it are wrong, which is the worst shape a failure can have: a
+partial read or a wrong sector count would have been visible, this was not.
+`8000h` is clear of the IVT, the BDA, SeaBIOS's stack at `0700h` and the ROM
+window at `C000h`; and the copy is executed code, so it is the last thing
+that touches the image and no BIOS call follows it.
 
 **21 of 33 fixtures execute.**  3 do not compile, and 9 compile without a
 `.out` file -- and those 9 are the control-flow fixtures (`t08` const, `t09`
@@ -174,6 +185,87 @@ of the main body, and the procedure-skip jump's patch slot could not be
 distinguished from "no jump" because slot 0 is legitimate.  All four had
 plausible byte counts.  See `SUMMARY.md` for the full list.
 
+### `rt_exec.py` -- the runtime entries, called one at a time
+
+`run_com_exec.py` asks whether *programs* behave.  This asks whether the
+**runtime entries** do, which is a different question: a compiled program never
+calls `rdint` without a `readln` behind it, never calls `rdbool` twice in a
+row, and never exercises the `INT16` extremes one at a time.  It is in
+`run_all.sh` and **36/36 pass** — 35 cases plus the pre-flight below, which is
+counted as a check because it is one.
+
+```
+cd shell && python3 tests/rt_exec.py             # all 35 cases + the pre-flight
+cd shell && python3 tests/rt_exec.py --probe     # the RtProbe dump alone
+cd shell && python3 tests/rt_exec.py --list      # the case list, with expectations
+cd shell && python3 tests/rt_exec.py --show      # print a case's wire bytes
+cd shell && python3 tests/rt_exec.py 3 --show    # one case
+```
+
+**One boot per case, deliberately.**  A machine that has already run a case has
+already run a runtime entry, and if that entry corrupted something the next
+case inherits it.  About a tenth of a second a boot buys a machine that has provably never
+executed anything, which is the only version of that claim worth making.
+
+**The case record is stated twice on purpose.**  `rt_exec.py` and
+`exec/rtdrv.s` each describe the same 40-byte record independently, and a case
+only passes if the two agree on every offset.  A driver that read the wrong
+field would still boot, still run, and still print, so the duplication *is* the
+check -- the same argument as the `.COM` layout constants in
+`run_com_tests.sh`.
+
+**`--probe` is the single recipe for `RtProbe`.**  `run_all.sh` used to
+hand-build a second copy at `/tmp/tp_rtprobe`; two recipes for one artifact is
+how a stale binary gets believed, and a probe older than the runtime it
+reports on is worse than no probe, because its failure -- a blob with the
+wrong addresses baked into it -- is invisible at the byte level and
+catastrophic at the behaviour level.  `ensure_rtprobe()` mtime-checks
+`Runtime.mod`, `Runtime.def`, `Runtime.o`, `Posix.c` and `RtProbe.mod`.
+
+What it found, that nothing else had:
+
+- **`CmpAl (20)` was decimal twenty.**  `rdint`'s lead-in skip is
+  `CmpAl (20H)`; written `20` it emitted `3C 14`, so a leading space was never
+  skipped and the scan ended having read nothing.  Every magic number in
+  `Runtime.mod` is now hex, because a literal that *reads* like hex and is
+  decimal is silent.
+- **`wrchar` and `wrbool` destroyed BP.**  Both borrow it to reach their
+  argument -- `[SP]` cannot be encoded in 16-bit mode -- and neither saved it.
+  BP is the one register an entry may keep, because the driver keeps its
+  cursor into the case record there.  `wrchar` therefore sent the *next* call
+  to a garbage address, the machine triple-faulted, and the run printed the
+  record header twice and hung.
+
+The second one is the interesting one, because **every byte-level check had
+called that shape correct.**  The bytes were well formed, the size did not
+change, the golden matched, all branch targets were on instruction boundaries,
+and the name audit said every helper emitted what its name said.  Worse,
+`check_runtime.py`'s entry golden had *blessed* it in five bytes.  A
+positional golden blesses whatever is there.
+
+The rule is now stated in two places that can disagree: the entry goldens carry
+the `PUSH BP` and the `POP BP` (what the bytes must be), and `rt_exec.py`'s
+`check_bp_contract` pre-flight checks the same rule over the built blob before
+any machine starts (what the bytes must *mean*).  Its two halves are not
+equally strong and the code says which is which -- "must start with
+`55 8B EC`" is exact, and "must contain a `5D`" is a *screen*, because `5D` is
+also a displacement byte and this code does not disassemble.  Demanding the
+`POP` be contiguous with something else is not an improvement: `wrbool`
+legitimately closes its frame after the `INT 21h`, and a rule insisting on
+`89 EC 5D` fails a correct entry, which is worse here than a miss, because it
+teaches a reader to distrust the check.
+
+And it changed the emitters, not just the tests.  Reaching the argument at
+`[BP+2]` versus `[BP+4]` is a one-byte difference that reads as a plausible
+character, and the first version of the fix passed that displacement as a
+Modula-2 **parameter** -- which is invisible to `audit_helpers.py`, because the
+audit reads the *name*.  The emitters are `MovAlArg2`/`MovAlArg4` and
+`CmpArg2W0`/`CmpArg4W0` now, displacement in the name, so the audit pins it: a
+`CmpArg4W0` that emitted the `+2` form fails with *"step 2: displacement is 2,
+name says 4"*.  That is the general lesson -- **a Modula-2 parameter is the
+one thing a name-based audit cannot see, so a thing the audit must check has
+to be in the name.**
+
 ### Hex-dumping the emitted image
 
 A line reading `@dump` on stdin switches on a hex dump of the emitted 8086

+ 71 - 31
shell/Runtime.mod

@@ -334,18 +334,35 @@ PROCEDURE AddDi2 ; BEGIN B (83H) ; B (0C7H) ; B (2) END AddDi2 ;  (* 11 000 111
 PROCEDURE CmpAl (v : CARDINAL ) ; BEGIN B (3CH) ; B (v) END CmpAl ;
 PROCEDURE CmpAx0 ; BEGIN B (83H) ; B (0F8H) ; B (0) END CmpAx0 ;
 PROCEDURE CmpCx0 ; BEGIN B (83H) ; B (0F9H) ; B (0) END CmpCx0 ;
-PROCEDURE CmpArgW0 ;
-(* CMP WORD PTR [BP+2],0 - the one 16-bit argument of a BOOLEAN entry.
-   [SP] cannot be encoded in 16-bit mode, so borrow BP for the three
-   instructions and hand SP back untouched before the caller pops the
-   argument.  (Was 83 7C 24 00 00, which decodes as CMP WORD [SI+24h],0.)
-   The imm8 of the 83 form is sign-extended to 16 bits, so 0 really is a
-   16-bit zero. *)
+PROCEDURE CmpArg2W0 ;
 BEGIN
-   B (8BH) ; B (0ECH) ;                 (* MOV BP,SP          *)
-   B (83H) ; B (7EH) ; B (2) ; B (0) ;  (* CMP WORD [BP+2],0  *)
-   B (89H) ; B (0ECH)                   (* MOV SP,BP          *)
-END CmpArgW0 ;
+   B (8BH) ; B (0ECH) ;                 (* MOV BP,SP            *)
+   B (83H) ; B (7EH) ; B (2) ; B (0) ;  (* CMP WORD [BP+2],0    *)
+   B (89H) ; B (0ECH)                   (* MOV SP,BP            *)
+END CmpArg2W0 ;
+
+PROCEDURE CmpArg4W0 ;
+(* CMP WORD PTR [BP+4],0 - the same comparison, in the frame where the entry
+   pushed BP first.
+   The 2 and the 4 are the whole point of having two names.  [SP] is not
+   encodable in 16-bit mode, so an entry that takes one 16-bit argument has to
+   borrow BP to reach it, and how far up the argument then sits depends on
+   whether BP was saved first: 2 bytes without the PUSH, 4 with it.  A single
+   emitter taking the displacement as a parameter is the obvious way to write
+   this and the wrong one -- a parameter is invisible to tests/audit_helpers.py,
+   which reads the NAME and compares it against the decode, so `CmpArgW0 (2)'
+   and `CmpArgW0 (4)' would differ by one byte with nothing in the codebase
+   able to tell which was meant.  In the name, the audit pins it: a helper
+   called CmpArg4W0 that emitted 83 7E 02 00 would fail the run.
+   Note the sandwich: MOV BP,SP before and MOV SP,BP after, and no POP BP, so
+   the two forms are the ONLY difference.  (Was 83 7C 24 00 00, which decodes
+   as CMP WORD [SI+24h],0.)  The imm8 of the 83 form is sign-extended to 16
+   bits, so 0 really is a 16-bit zero. *)
+BEGIN
+   B (8BH) ; B (0ECH) ;                 (* MOV BP,SP            *)
+   B (83H) ; B (7EH) ; B (4) ; B (0) ;  (* CMP WORD [BP+4],0    *)
+   B (89H) ; B (0ECH)                   (* MOV SP,BP            *)
+END CmpArg4W0 ;
 
 PROCEDURE CmpSiBx ; BEGIN B (39H) ; B (0DEH) END CmpSiBx ;  (* 39 DE: CMP SI,BX *)
 PROCEDURE CmpCxDx ; BEGIN B (39H) ; B (0D1H) END CmpCxDx ;  (* 11 010 001 *)
@@ -428,7 +445,7 @@ PROCEDURE MovAxBp4 ; BEGIN B (8BH) ; B (46H) ; B (4) END MovAxBp4 ;  (* AX:=[BP+
 PROCEDURE MovBxBp4 ; BEGIN B (8BH) ; B (5EH) ; B (4) END MovBxBp4 ;  (* BX:=[BP+4] *)
 PROCEDURE MovAlDh  ; BEGIN B (8AH) ; B (0C6H) END MovAlDh ;  (* 8A C6: AL:=DH *)
 PROCEDURE MovDlSi  ; BEGIN B (8AH) ; B (14H) END MovDlSi ;
-PROCEDURE MovDlArg ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg ;  (* DL:=[BP+2] *)
+PROCEDURE MovDlArg2 ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg2 ;  (* DL:=[BP+2] *)
 
 (* The AL twins of the three above.  INT 21h AH=02h displays the character in
    AL, not DL, so every character this runtime writes has to arrive in AL.
@@ -442,7 +459,8 @@ PROCEDURE MovDlArg ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg ;  (* DL:=[BP+
    every byte-level check stayed green.  Only running it found this. *)
 PROCEDURE MovAlD (v : CARDINAL ) ; BEGIN B (0B0H) ; B (v) END MovAlD ;  (* MOV AL,imm8 *)
 PROCEDURE MovAlSi  ; BEGIN B (8AH) ; B (04H) END MovAlSi ;  (* 8A 04: AL:=[SI] *)
-PROCEDURE MovAlArg ; BEGIN B (8AH) ; B (46H) ; B (2) END MovAlArg ;  (* AL:=[BP+2] *)
+PROCEDURE MovAlArg2 ; BEGIN B (8AH) ; B (46H) ; B (2) END MovAlArg2 ;  (* AL:=[BP+2] *)
+PROCEDURE MovAlArg4 ; BEGIN B (8AH) ; B (46H) ; B (4) END MovAlArg4 ;  (* AL:=[BP+4] *)
 PROCEDURE MovAlDl  ; BEGIN B (8AH) ; B (0C2H) END MovAlDl ;  (* 8A C2: AL:=DL *)
 
 PROCEDURE StDiAx   ; BEGIN B (89H) ; B (5H) END StDiAx ;    (* 89 05: [DI]:=AX *)
@@ -585,7 +603,7 @@ BEGIN
    StVxBx (D_PUSH) ;                (* and empty the slot *)
    RetR ;
    M ("gc_dos") ;
-   MovAh (8) ;
+   MovAh (08H) ;
    Int21 ;
    RetR
 END EmitGetCh ;
@@ -634,7 +652,7 @@ BEGIN
    CmpSiBx ;
    Je8 ("wi_done") ;
    MovAlSi ;                  (* the digit: AH=02h wants it in AL *)
-   MovAh (2) ; Int21 ;
+   MovAh (02H) ; Int21 ;
    IncSi ;
    J8 ("wi_out") ;
    M ("wi_done") ;
@@ -644,14 +662,21 @@ END EmitWrInt ;
 PROCEDURE EmitWrChar ;
 (* the low byte of the one 16-bit argument, which sits above the return
    address.  There is no [SP] addressing in 16-bit mode, so BP stands in for
-   the stack pointer and is handed straight back before the RET.
-   AL, not DL: see the AL twins above. *)
+   the stack pointer -- and it has to be SAVED, because BP is the one register
+   a caller is entitled to expect back: the test driver keeps its cursor into
+   the case record there, so an entry that borrows BP without returning it
+   sends the next call to a garbage address.  That is not hypothetical: wrchar
+   and wrbool both did it, and "writeln(42) writeln TRUE in one program" hung
+   the machine with the record header printed twice, because the driver had
+   been sent back to the top of its own loop.  Load BP, use it, hand it back.
+   MovAlArg4, not MovAlArg2: BP is pushed first, so the argument is four bytes
+   up, not two.  AL, not DL: see the AL twins above. *)
 BEGIN
    M ("wrchar") ;
-   MovBpSp ;
-   MovAlArg ;
-   MovSpBp ;
-   MovAh (2) ;
+   PushBp ; MovBpSp ;
+   MovAlArg4 ;
+   MovSpBp ; PopBp ;
+   MovAh (02H) ;
    Int21 ;
    RetR
 END EmitWrChar ;
@@ -679,7 +704,7 @@ BEGIN
    XorCxCx ;
    MovClBx ;                         (* CX := length *)
    IncBx ;                           (* BX -> first character *)
-   MovAh (2) ;                       (* INT 21h/02h: put character, AL *)
+   MovAh (02H) ;                       (* INT 21h/02h: put character, AL *)
    Jcxz8 ("wn_end") ;                (* empty string -> nothing to do *)
    M ("wn_loop") ;
    LdAlBx ;                         (* AL := next character *)
@@ -692,17 +717,23 @@ BEGIN
 END EmitWrInl ;
 
 PROCEDURE EmitWrBool ;
+(* 4, not 2, for the reason given on CmpArg4W0: BP is pushed before it is
+   borrowed, so the argument is four bytes up.  POP BP before the RET, and
+   note that CmpArg4W0 has already handed SP back, so the pop lands on the
+   saved BP and not on the argument. *)
 BEGIN
    M ("wrbool") ;
-   CmpArgW0 ;
+   PushBp ;
+   CmpArg4W0 ;
    Jne8 ("wb_t") ;
    MovDxVx (D_FALSE) ;
    J8 ("wb_o") ;
    M ("wb_t") ;
    MovDxVx (D_TRUE) ;
    M ("wb_o") ;
-   MovAh (9) ;
+   MovAh (09H) ;
    Int21 ;
+   PopBp ;
    RetR
 END EmitWrBool ;
 
@@ -710,7 +741,7 @@ PROCEDURE EmitWrLn ;
 BEGIN
    M ("wrln") ;
    MovDxVx (D_CRLF) ;
-   MovAh (9) ;
+   MovAh (09H) ;
    Int21 ;
    RetR
 END EmitWrLn ;
@@ -722,7 +753,7 @@ PROCEDURE EmitWrReal ;
 BEGIN
    M ("wrreal") ;
    MovDxVx (D_REAL) ;
-   MovAh (9) ;
+   MovAh (09H) ;
    Int21 ;
    RetR
 END EmitWrReal ;
@@ -754,9 +785,18 @@ BEGIN
    PushAx ; PushBx ; PushCx ; PushDx ; PushDi ;
    M ("ri_lead") ;
    C8 ("getch") ;
-   CmpAl (26) ;                      (* ^Z: end of input *)
+   CmpAl (1AH) ;                     (* ^Z: end of input *)
    Je8 ("ri_none") ;
-   CmpAl (20) ;                      (* every control char and the space *)
+   CmpAl (20H) ;                     (* every control char and the space.
+                                         20H, not 20: readnum skips
+                                         everything <= #$20, and a literal
+                                         that READS like hex but is
+                                         decimal compares AL against 14h,
+                                         so a leading space is never
+                                         skipped and the scan ends with
+                                         nothing read.  Every other
+                                         literal in this module is
+                                         explicit for the same reason. *)
    Jbe8 ("ri_lead") ;
    XorCxCx ;
    CmpAl (ORD ("-")) ;
@@ -862,9 +902,9 @@ BEGIN
    PushAx ;
    M ("rl_loop") ;
    C8 ("getch") ;
-   CmpAl (13) ; Je8 ("rl_e") ;
-   CmpAl (10) ; Je8 ("rl_e") ;
-   CmpAl (26) ; Je8 ("rl_e") ;          (* ^Z: end of input *)
+   CmpAl (0DH) ; Je8 ("rl_e") ;
+   CmpAl (0AH) ; Je8 ("rl_e") ;
+   CmpAl (1AH) ; Je8 ("rl_e") ;          (* ^Z: end of input *)
    J8 ("rl_loop") ;
    M ("rl_e") ;
    PopAx ;

+ 54 - 4
shell/tests/RtProbe.mod

@@ -2,15 +2,58 @@ MODULE RtProbe ;
 
 (* Dump the assembled runtime: size, entry offsets, hex.  Kept separate from
    CompileTest so the library can be checked on its own, before the compiler
-   is wired to it. *)
+   is wired to it.
 
-FROM Posix IMPORT write ;
+   The IMAGE OFFSET the blob will occupy is read from the first line of stdin,
+   as a decimal number.  "0" - or no input at all, which is what every
+   standalone caller gives it - reproduces the original behaviour: entries and
+   internal data addresses come out blob-relative, which is what
+   check_runtime.py, rt_exec.py and tests/runtime.golden all want.
+
+   The parameter exists because the base is not cosmetic.  FixUp bakes
+   dataAt + rtBase + val + LoadBias into every absolute data address the
+   library contains, so a blob built for base 0 and then copied to image
+   offset 0x200 has every string pointer 0x200 too low - and that failure is
+   silent: wrln would walk a $-terminated string from the wrong place and keep
+   going through whatever bytes it finds.  Emitting the bytes is base
+   independent; only the addresses are not. *)
+
+FROM Posix IMPORT read, write ;
 FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry, RT_CodeEnd ;
 FROM SYSTEM IMPORT ADR, BYTE ;
 
 VAR
    c : CHAR ;
 
+PROCEDURE GetBase () : CARDINAL ;
+(* First line of stdin, decimal.  0 on EOF, on a line that is not a number, or
+   on no input at all - so a caller that passes nothing gets base 0 and the
+   historical output.  One byte at a time, because a line is short and this
+   keeps the module free of buffering that could swallow a caller's stdin. *)
+VAR
+   ch : BYTE ;
+   v : CARDINAL ;
+   r : LONGINT ;
+   more : BOOLEAN ;
+BEGIN
+   v := 0 ;
+   more := TRUE ;
+   WHILE more DO
+      r := read (0, ADR (ch), 1) ;
+      IF r # 1 THEN
+         more := FALSE
+      ELSIF (ch < 48) OR (ch > 57) THEN
+         more := FALSE                (* newline, or junk: the line ends *)
+      ELSE
+         (* VAL, because ISO Modula-2 has no arithmetic between a BYTE and a
+            CARDINAL, and a plain `ch - 48' is exactly the sort of
+            mixed-size expression that silently wraps when it is small. *)
+         v := v * 10 + (VAL (CARDINAL, ch) - 48)
+      END
+   END ;
+   RETURN v
+END GetBase ;
+
 PROCEDURE PC (ch : CHAR ) ;
 VAR n : LONGINT ;
 BEGIN
@@ -64,10 +107,17 @@ BEGIN
 END PENT ;
 
 PROCEDURE Main ;
-VAR i, n : CARDINAL ;
+VAR i, n, base : CARDINAL ;
     names : ARRAY [0..13] OF ARRAY [0..15] OF CHAR ;
 BEGIN
-   RT_Build (0) ;   (* base 0: the standalone probes report blob-relative *)
+   base := GetBase () ;
+   RT_Build (base) ;
+   (* "base=0" is printed even when it is 0, because a caller that forgets to
+      redirect stdin would otherwise get base 0 by accident and no evidence of
+      it.  The entries below are then IMAGE-ABSOLUTE, and the last one line
+      gives the address the one-character pushback slot lives at, so a harness
+      that needs to clear it does not have to recompute FixUp's arithmetic. *)
+   PS ("base=") ; PCARD (base) ; NL ;
    PCARD (RT_Size ()) ; PS (" bytes") ; NL ;
    PS ("code ends at ") ; PCARD (RT_CodeEnd ()) ; NL ;
    i := 0 ;

+ 19 - 3
shell/tests/audit_helpers.py

@@ -224,6 +224,14 @@ MODULES = ["Runtime.mod", "Compiler.mod"]
 # "Bp" is what stops these being misread as [BP] accesses, and it is also
 # how the grammar knows to expect the +2.
 #
+# "Arg" is the frame in which the entry did NOT push BP, and it is the only
+# one where the argument is two bytes up: an entry that pushes BP first has
+# moved it to +4, which is a different name and a different byte
+# (MovAlArg2 / MovAlArg4, CmpArg2W0 / CmpArg4W0).  A name that says only
+# "Arg" always means +2, with no exceptions -- the alternatives to spelling
+# the displacement out were a Modula-2 parameter, which this audit cannot
+# see, and a comment, which nothing checks.
+#
 # A name matching NO pattern is reported UNPARSED, which counts as a failure
 # on purpose: a helper the audit cannot read is a helper nobody is checking.
 
@@ -306,14 +314,22 @@ PATTERNS = [
     # one explicit operand ("imul cx"), which is why the spec has one token.
     (re.compile(r"^Mul(%s)(%s)$" % (_ALT, _ALT)),
      "imul", ["r:%(2)s"], {0xF7}),
-    # --- CmpArgW0 : CMP WORD [BP+2],0 sandwiched by MOV BP,SP / MOV SP,BP -
+    # --- CmpArg<N>W0 : CMP WORD [BP+N],0 between MOV BP,SP / MOV SP,BP -----
     # An odd but exact shape: [SP] is not encodable, so the frame pointer is
     # borrowed for the one comparison and handed back untouched.  The pair of
     # saves at the ends is what makes it correct, so the whole sequence is
     # checked - a missing MOV SP,BP would leave BP clobbered for the caller.
-    (re.compile(r"^CmpArg(W?)0$"),
+    #
+    # The displacement is a CAPTURED GROUP, not a constant, and the name has to
+    # spell it out.  That is the rule that keeps CmpArg2W0 and CmpArg4W0
+    # honest: they differ by one byte, and one byte is the difference between
+    # reading a BOOLEAN and reading a character, so the name -- which is the
+    # only thing this audit can read -- has to carry it.  A single helper
+    # taking the displacement as a Modula-2 parameter would decode correctly
+    # under either name and be unchecked under both.
+    (re.compile(r"^CmpArg(2|4)W0$"),
      None, [["mov", ["r:Bp", "r:Sp"]],
-            ["cmp", ["m:Bp+2", "i:0"]],
+            ["cmp", ["m:Bp+%(1)s", "i:0"]],
             ["mov", ["r:Sp", "r:Bp"]]], {0x8B}),
     # --- a "Mov<reg>Sp" that is a POP/PUSH pair, not a memory access ----
     # MOV AX,[SP] does not exist on the 8086 at all, so the stack top is read

+ 27 - 2
shell/tests/check_runtime.py

@@ -123,8 +123,33 @@ GOLDEN = {
     # = MOV DL,[BP+2] and was RIGHT about what the code did and WRONG about
     # what it should do.  That is the whole reason this check exists: a
     # positional golden blesses whatever is there.
-    "wrchar": "8B EC 8A 46 02 89 EC",       # BP:=SP; AL:=[BP+2]; SP:=BP
-    "wrbool": "8B EC 83 7E 02 00 89 EC",    # BP:=SP; CMP [BP+2],0; SP:=BP
+    # wrchar and wrbool: PUSH BP; MOV BP,SP; ...; MOV SP,BP; POP BP.
+    #
+    # These two goldens were the *shape* of the fault found by execution and
+    # not by any byte check.  They read `8B EC 8A 46 02 89 EC` -- BP:=SP,
+    # read, SP:=BP, no PUSH and no POP -- and they blessed it: the bytes were
+    # well formed, the size did not change, the decode said exactly what it
+    # said, and the entries still worked.  What was wrong was that BP is the
+    # one register an entry may keep, because the driver keeps its cursor into
+    # the case record there, so an entry that borrows BP to reach its argument
+    # and never gives it back sends the NEXT call to a garbage address.  The
+    # machine triple-faulted and restarted, which printed the record header
+    # twice and hung.
+    #
+    # So the PUSH and the POP are now in the golden, which is the only place in
+    # the byte-checking suite where "this entry must hand BP back" was ever
+    # stated.  Note the displacement moved 2 -> 4 with them: BP is pushed
+    # first, so the argument is four bytes up.  rt_exec.py checks the same rule
+    # from the other side, and the two disagreeing is the point -- a golden
+    # says what the bytes must be, and the contract says why.
+    "wrchar": "55 8B EC 8A 46 04 89 EC 5D",  # PUSH BP; BP:=SP; AL:=[BP+4]; SP:=BP; POP BP
+    "wrbool": "55 8B EC 83 7E 04 00 89 EC",  # PUSH BP; BP:=SP; CMP [BP+4],0; SP:=BP
+    # ...and wrbool's POP BP is at 0086, after the INT 21h and not next to its
+    # MOV SP,BP at 0076, so the six bytes above stop short of it on purpose.  A
+    # golden that had to span the whole frame would be asserting a code layout
+    # rather than a prologue.  rt_exec.py's POP_FRAME-free screen is what covers
+    # that one, and it is a screen rather than a proof -- see the note there on
+    # why 5D cannot be located exactly without a disassembler.
     "wrtinl": "5B 31 C9 8A 0F 43 B4 02",    # POP BX; XOR CX,CX; CL:=[BX]; INC BX
     "rdln": "50",                           # PUSH AX, to keep the caller's
     # rdint: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - five saved registers,

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

@@ -35,13 +35,30 @@
 #     is therefore program output, with nothing to strip.
 #
 #   * The program is loaded at 0000:0100, so segment 0 holds the IVT at
-#     0000:0000-00FF and the program from 0100 up.  The two scratch words the
-#     INT 21h handler keeps (0700h input cursor, 0702h output string cursor)
-#     sit inside the region the read covers but are written before they are
-#     read, and the input descriptor at 2000h sits past the end of any
-#     fixture.  See the layout block at the end of this file.
+#     0000:0000-00FF and the program from 0100 up.  The input descriptor at
+#     2000h sits past the end of any fixture and is simply part of the disk
+#     image the harness writes, so the addresses here are assembly constants.
 #
-# Built and driven by tests/run_com_exec.py; see tests/exec/README.md.
+#     The one piece of scratch the handler keeps INSIDE the loaded region is
+#     0702h, the output string cursor.  Since 0100h is image offset 0, that is
+#     image offset 602h -- which is inside every fixture's image, because a
+#     .COM is padded out to the data base at rtSz+1000h = 11B4h.  It is in the
+#     ZERO GAP between the end of the code and that data base, so it is
+#     harmless today, and nothing reads it before writing it.  But it is a
+#     real limit and belongs in writing rather than in a discovery later: an
+#     AH=09h against a program whose code reaches 602h scribbles over that
+#     program's own code.  The code starts at 1C7h and the longest fixture's
+#     ends at 468h, so the margin is 19Ah = 410 bytes of code -- and it is the
+#     same unenforced 4 KiB code window the linker documents, seen from the
+#     other end.  Moving the cursor above everything the read covers would
+#     remove the limit; it stays at 0702h because nothing needs it yet, and
+#     because every address in this file is restated in run_com_exec.py, which
+#     is the thing that would have to change with it.  See the layout block at
+#     the end of this file.
+#
+# Built and driven by tests/run_com_exec.py.  tests/exec/rtdrv.s assembles the
+# other half of this machinery -- the driver that calls runtime entries directly
+# -- and reuses this file rather than growing a second boot path.
 
 	.code16
 	.text
@@ -72,9 +89,28 @@ _start:
 
 	# Retry the read: qemu's floppy is a file image and a read can fail while
 	# it settles.  Three attempts, then give up loudly.
+	#
+	# The read lands at SCRATCH, not at 0100h, and is copied down afterwards.
+	# That is not ceremony.  0400h-04FFh is the BIOS data area, and SeaBIOS
+	# keeps live state in it; a transfer whose destination covers that window
+	# destroys it, and SeaBIOS writes part of it back *after* the DMA, so ten
+	# bytes of BIOS data end up on top of the image once the transfer is over.
+	#
+	# The damage is the worst kind.  It is the right length, in the right
+	# place, and nothing reports an error -- the read sets CF=0, exactly as
+	# the jnc below expects.  It was found by a 19-byte probe that read 0440h
+	# as its first instruction after the jump and got the BIOS's own bytes
+	# back, and then confirmed by dumping the whole loaded image: one 10-byte
+	# run wrong inside an otherwise byte-perfect 512-byte sector, which no
+	# partial-read or sector-count bug can produce.
+	#
+	# 8000h is clear of the IVT (0-3FFh), the BDA (400-4FFh), SeaBIOS's stack
+	# at 700h and the option-ROM window at C000h.  REP MOVSW is executed code,
+	# so the copy is the LAST thing that touches 0100h-20FFh and no BIOS call
+	# follows it.  The copy is what the program then runs out of, unchanged.
 	movw	$3, %cx
 .Lretry:
-	movw	$0x0100, %bx		# ES:BX = 0000:0100
+	movw	$SCRATCH, %bx		# ES:BX = 0000:8000
 	movb	$0x02, %ah		# read sectors
 	movb	$16, %al		# 16 * 512 = 8192 bytes, far more than any fixture
 	movb	$0x00, %ch		# cylinder 0
@@ -82,7 +118,7 @@ _start:
 	movb	$0x00, %dh		# head 0
 	movb	$0x00, %dl		# drive A
 	int	$0x13
-	jnc	.Lok
+	jnc	.Lcopy
 	decw	%cx
 	jnz	.Lretry
 .Lbootfail:
@@ -91,7 +127,12 @@ _start:
 	outb	%al, %dx
 	cli
 	hlt
-.Lok:
+.Lcopy:
+	movw	$0x0100, %di
+	movw	$SCRATCH, %si
+	movw	$4096, %cx		# 8192 bytes = 16 sectors
+	cld
+	rep	movsw
 	.byte	0xEA, 0x00, 0x01, 0x00, 0x00	# jmp 0000:0100
 
 # ---------------------------------------------------------------- INT 21h
@@ -109,6 +150,16 @@ handler:
 	pushw	%ds
 	pushw	%es
 
+	# Direction flag, before anything reads it.  Both lodsb's below walk
+	# FORWARD, and DF belongs to the interrupted program, not to the
+	# handler: a handler that assumes the caller's DF is clear is correct
+	# only for as long as no caller ever sets it.  It has never fired -
+	# nothing in Runtime.mod uses a string instruction, so DF is whatever
+	# the BIOS left, and that is 0 - which is exactly why it is worth
+	# writing down.  DF is not restored, because nothing downstream reads
+	# it and restoring it would mean saving EFLAGS around the whole handler.
+	cld
+
 	cmpb	$0x02, %ah
 	je	.Lh02
 	cmpb	$0x09, %ah
@@ -226,13 +277,19 @@ ser_put:
 	ret
 
 # --------------------------------------------------------------- layout
-# The input descriptor lives at 2000h, which the 16-sector read above covers
+# The input descriptor lives at 2000h, which the read + copy above covers
 # (0100h + 2000h = 2100h) and which is well past the end of any fixture (the
 # largest is 4493 bytes, so the image stops at 1285h).  So the descriptor is
 # simply part of the disk image the harness writes, not something it has to
 # patch into this boot sector afterwards -- which means the addresses here are
 # assembly constants and the harness only has to know where they land in the
 # file.  See run_com_exec.py, FLAT_OFF.
+#
+# SCRATCH is where the sector read parks the bytes before the copy moves them
+# to 0100h.  Nothing may ever be placed there: the region is not reserved by
+# this file, it is merely unused, and a future change that put a table at
+# 8000h would be overwritten by the read and would not notice.
+	.set	SCRATCH, 0x8000		# see the note above: not 0100h
 	.set	INLEN, 0x2000		# word: number of input bytes
 	.set	INCUR, 0x2002		# word: cursor, starts at INBUF
 	.set	INBUF, 0x2004		# the bytes themselves

+ 321 - 0
shell/tests/exec/rtdrv.s

@@ -0,0 +1,321 @@
+# rtdrv.s -- call runtime entries, with known arguments, and report what
+# happened.  Assembled by tests/rt_exec.py into a complete .COM image.
+#
+# This replaces the Unicorn harness.  The expectations did not change; only the
+# machine did.  Everything here exists because qemu, unlike an emulator with a
+# Python API, gives you one thing: a serial port.  So a check has to be
+# expressed as a program that reports on itself.
+#
+# The report is a framed record on the serial port:
+#
+#     01 <case index, 2 hex digits> 02  <whatever the entries printed>  03
+#     <dumplen bytes at dumpaddr, 2 hex digits each>                  04
+#
+# The four control bytes occur in no expected output, and rt_exec.py REFUSES to
+# run a case whose expectation contains one -- so a marker can never be mistaken
+# for output, and a driver that emitted nothing is a parse failure rather than
+# a silent pass.
+#
+# Why one qemu boot per check instead of one boot for all of them: the runtime
+# keeps state that outlives a call -- the one-character pushback slot, and the
+# input cursor the boot sector's INT 21h shim owns.  One boot for everything
+# would make each check depend on the ones before it, so a check could fail
+# because of its neighbour and a green run would prove less than it appears to.
+# A boot costs about a tenth of a second and 35 of them cost under four, which
+# buys every check a machine that has provably never executed anything.  (The
+# input descriptor is still re-armed on every boot, because it lives in the
+# image and the image is the same bytes each time.)
+#
+# Two things about the 8086 shape the code below, and both were found by
+# running it rather than by reading it:
+#
+#   * Every entry preserves BP and SP and NO other register.  So the cursor
+#     into the case record is BP: a loop counter in CX or DI would be destroyed
+#     by the first call.  The entry's own BP frame is pushed and restored, so
+#     our BP comes back.
+#
+#   * `movb n(%bp), %cl' leaves the HIGH byte of CX exactly as it was.  Reading
+#     the input length that way and then writing CX to INLEN handed the boot
+#     sector's INT 21h shim a 16-bit length with an undefined top half.  The
+#     8086 has no memory-to-memory move and no zero-extending byte load, so the
+#     high half has to be cleared by hand, every time.
+#
+#   * `dec' sets the sign flag from the RESULT, so `decw cnt / js done' runs the
+#     body for cnt = 1 (1 -> 0, SF clear) and stops on the wrap (0 -> FFFF).
+#     It reads like an off-by-one and is not; the comment at the loop says so
+#     again because the next person will wonder.
+#
+# The image is assembled in ONE piece, here, so every address is a label or an
+# .org and nothing has to be agreed with Python in two places.  What Python does
+# have to agree about -- the runtime's base, and the input descriptor -- is
+# checked rather than trusted: it assembles this file and then asserts that the
+# blob it got from RtProbe really is at RT_BASE in the bytes that came out.
+
+	.set	LOAD_BIAS, 0x0100	# a .COM lives at CS:0100 (that is the PSP)
+	.set	SOH, 0x01
+	.set	STX, 0x02
+	.set	ETX, 0x03
+	.set	EOT, 0x04
+
+# Restated from tests/exec/bootcom.s's layout block ON PURPOSE.  A harness that
+# asks the boot sector where its input buffer is proves only that the two agree
+# with each other; two independent statements of the same layout have to be
+# written down, and a disagreement is a loud failure rather than a program that
+# is fed nothing.  (Same reasoning, and the same lesson, as the hard-coded
+# RT_SZ that run_com_tests.sh used to carry: the descriptor has to be where the
+# shim looks, and the only way to be sure is to have written both down and
+# checked.)
+	.set	INLEN, 0x2000		# word: how many input bytes follow
+	.set	INCUR, 0x2002		# word: index into INBUF, NOT an address
+	.set	INBUF, 0x2004		# the bytes themselves
+
+# The case record, as rt_exec.py writes it.  These offsets are the contract; the
+# two places that read them (this file, and case_bytes() over there) are
+# separate statements of it on purpose, and the cases only pass if both are
+# right.  SLOT0 is the one that was wrong the first time round.
+	.set	OFF_DUMPLEN, 0	# word  how many bytes to report
+	.set	OFF_DUMPADR, 2	# word  image offset to report them from
+	.set	OFF_INLEN, 4	# byte  bytes of input to hand the shim
+	.set	OFF_INBUF, 5	# byte  inbuf[8]
+	.set	OFF_CASE, 13	# byte  the index printed in the header
+	.set	OFF_NCALLS, 14	# word  how many call slots follow
+	.set	SLOT0, 16		#      call slots, 6 bytes each:
+					#      +0 word entry, +2 word arg,
+					#      +4 byte flags, +5 pad
+	.set	SLOT_SZ, 6
+	.set	CASE_SZ, 40		# 16 + 4 slots
+
+# The image layout.  RT_BASE is where the runtime blob goes; everything else is
+# above 0x0800 so that a runtime which grew by a few hundred bytes would not
+# silently run into the test scaffolding.  rt_exec.py asserts
+# RT_BASE + blob size <= HDR, so the bound is a checked one.
+	.set	RT_BASE, 0x0200
+	.set	HDR, 0x0800
+	.set	DATA, 0x0810
+	.set	DLEN, 32			# bytes of data area for initmem to clear
+	.set	STORE, 0x0840		# where a read entry's result is stored
+	.set	CASE, 0x0860		# the case record
+	.set	D_DUMPLEN, 0x0890		# the driver's own three words
+	.set	D_DUMPADR, 0x0892
+	.set	D_CNT, 0x0894
+
+	.code16
+	.text
+	.globl	_start
+
+# The entry JMP -- the same three bytes every image this compiler writes opens
+# with.  bootcom.s jumps to 0000:0100, which is this instruction.  It is
+# written as raw bytes rather than `jmp drive' because gas would shorten that to
+# a two-byte EB 00 short jump, and the byte it dropped would then be executed as
+# the first instruction of the driver.
+_start:
+	.byte	0xE9			# JMP rel16
+	.word	drive - _start - 3
+
+# --------------------------------------------------------------- the driver
+drive:
+	cld
+
+	# 1. Poison the two places a check is allowed to look.  STORE gets EEEH so
+	#    a read entry that stores nothing is visible as such, and the data
+	#    area gets AAH so initmem has something to clear.  The storage below
+	#    is zeroed; the poison is what turns a zero into a RESULT.
+	movw	$STORE + LOAD_BIAS, %si
+	movw	$0xEEEE, %ax
+	movw	%ax, (%si)
+	movw	$DATA + LOAD_BIAS, %si
+	movw	$DLEN, %cx
+	movb	$0xAA, %al
+.Lpoison:
+	movb	%al, (%si)
+	incw	%si
+	loop	.Lpoison
+
+	# BP is the case-record cursor, and it is the only register that survives a
+	# call into the runtime, so everything the loop needs is read through it
+	# or parked in the driver's own words first.
+	movw	$CASE + LOAD_BIAS, %bp
+
+	# 2. Open the record: SOH, the case index as two hex digits, STX.
+	movb	$SOH, %al
+	call	putc
+	movb	OFF_CASE(%bp), %al
+	call	hexb
+	movb	$STX, %al
+	call	putc
+
+	# 3. Arm the input.  INCUR is an INDEX into INBUF, and zeroing it is what
+	#    makes this boot's input start at its first byte.  CX is cleared
+	#    first: see the note at the top about the high byte of CX.
+	xorw	%cx, %cx
+	movb	OFF_INLEN(%bp), %cl
+	movw	%cx, INLEN
+	movw	$0, INCUR
+	movw	$CASE + OFF_INBUF + LOAD_BIAS, %si
+	movw	$INBUF, %di
+	xorw	%ax, %ax
+	repe	cmpsb
+
+	# 4. The calls.  dumplen and the report address are needed after the loop,
+	#    by which time BP has moved, so they are parked now; so is the count,
+	#    because CX is gone by the second iteration.
+	movw	OFF_DUMPLEN(%bp), %ax
+	movw	%ax, D_DUMPLEN + LOAD_BIAS
+	movw	OFF_DUMPADR(%bp), %ax
+	movw	%ax, D_DUMPADR + LOAD_BIAS
+	movw	OFF_NCALLS(%bp), %ax
+	movw	%ax, D_CNT + LOAD_BIAS
+	addw	$SLOT0, %bp		# BP now points AT the first call slot
+.Lcall:
+	decw	D_CNT + LOAD_BIAS	# 1 -> 0 has SF clear, so the body runs once;
+	jns	.Lgo			# 0 -> FFFF is what ends the loop
+	jmp	.Lcalldone
+.Lgo:
+	call	doslot
+	addw	$SLOT_SZ, %bp
+	jmp	.Lcall
+.Lcalldone:
+
+	# 5. Close the output half and report the memory.  A store that never
+	#    happened is the EEEH left in step 1, which is why it is there.
+	movb	$ETX, %al
+	call	putc
+	movw	D_DUMPLEN + LOAD_BIAS, %cx
+	jcxz	.Lnodump
+	movw	D_DUMPADR + LOAD_BIAS, %si
+	addw	$LOAD_BIAS, %si
+.Ldump:
+	lodsb
+	call	hexb
+	loop	.Ldump
+.Lnodump:
+	movb	$EOT, %al
+	call	putc
+
+	# 6. Exit.  Reaching here IS the assertion that every entry returned: an
+	#    entry that hung produces no EOT, and the harness says so.
+	movw	$0x4C00, %ax		# INT 21h AH=4Ch, AL=0
+	int	$0x21
+
+# doslot: call the entry described by the 6 bytes at BP, with the same
+# conventions Compiler.IoCall uses -- one 16-bit argument on the stack, popped
+# by the caller -- except for initmem, which takes the header in AX.
+#
+# The flags are re-read after the call because the entry has just used AX.
+doslot:
+	movb	4(%bp), %al
+	testb	$1, %al
+	jz	.Lnostack
+	pushw	2(%bp)
+.Lnostack:
+	testb	$2, %al
+	jz	.Lnoax
+	movw	2(%bp), %ax
+.Lnoax:
+	movw	(%bp), %si		# image-absolute, as RT_Entry reports it
+	addw	$LOAD_BIAS, %si		# so the load bias is added here, not baked in
+	testb	$4, %al
+	jnz	.Linl
+	call	*%si
+	movb	4(%bp), %al
+	testb	$1, %al
+	jz	.Lnoclean
+	addw	$2, %sp		# caller-cleaned
+.Lnoclean:
+	ret
+
+# wrtinl's argument is NOT a stack word: the entry reads a length byte and that
+# many characters from its own return address.  That is the caller's half of
+# the contract, so it is written literally here -- the length byte and the
+# characters must be the bytes immediately after the call, which is why this is
+# a second copy of the instruction rather than a jump.  The compiler's own
+# placement of the same three things is what the string-literal fixtures in
+# run_com_exec.py check; duplicating a variable one here would be a second
+# thing to keep right, and a check that cannot fail is not a check.
+.Linl:
+	call	*%si
+	.byte	5
+	.byte	'h', 'e', 'l', 'l', 'o'
+	ret
+
+# putc: AL to the serial port.  No status polling -- qemu's 16550 always accepts,
+# and a poll that never went ready would hang the harness instead of failing it.
+putc:
+	pushw	%ax
+	movw	$0x03f8, %dx
+	outb	%al, %dx
+	popw	%ax
+	ret
+
+# hexb: AL as two uppercase hex digits, high nibble first.
+#
+# The 8086 has no `SHR r/m16, imm8' and no shift-by-four, so the high nibble
+# comes from four one-bit shifts through CL.  Both facts have to be written
+# down: gas assembles `shr $4, %ah' without complaint and rejects `shrw $1, %ah'
+# outright, so the only thing standing between this file and an image that
+# assembles cleanly and dies on a real 8086 is refusing the modern encodings.
+hexb:
+	pushw	%ax
+	pushw	%cx
+	movb	%al, %ah
+	movb	$1, %cl
+	shrw	%cl, %ax
+	shrw	%cl, %ax
+	shrw	%cl, %ax
+	shrw	%cl, %ax
+	popw	%cx
+	call	putdig
+	popw	%ax
+	andb	$0x0f, %al
+	movb	%al, %ah
+	call	putdig
+	ret
+
+# putdig: the nibble in AH as one character, moved into AL so that setting AH for
+# INT 21h AH=02h cannot overwrite the digit it is about to print.
+putdig:
+	movb	%ah, %al
+	cmpb	$10, %al
+	jb	.Ldigit
+	addb	$0x37, %al		# 0Ah + 37h = 'A'
+	jmp	.Lemit
+.Ldigit:
+	addb	$0x30, %al		# '0'
+.Lemit:
+	movb	$2, %ah
+	int	$0x21
+	ret
+
+# --------------------------------------------------------------- the image
+# The runtime blob, from tests/RtProbe.mod built at RT_BASE.  The gap is the
+# checked bound: rt_exec.py asserts that 0x0180..RT_BASE is still all zeros, so
+# a driver that outgrew its space fails the suite instead of overwriting the
+# library it is testing.
+	.org	RT_BASE
+	.incbin	"rtblob.bin"
+
+# The program header initmem is handed: +0 flag, +2 code, +4 data base, +6 data
+# end.  Built HERE, from the same DATA and DLEN the poison loop uses, because
+# the compiler's own header layout is a separate claim with its own check and
+# this harness is not that check.  The two fields initmem READS are memory
+# addresses, so they carry the bias; the caller passes AX the header's memory
+# address too, which is the one thing rt_exec.py gets right only by saying so
+# here and in case_bytes().
+	.org	HDR
+HDR:
+	.word	1			# hdrFlag
+	.word	0			# hdrCS, unused by initmem
+	.word	DATA + LOAD_BIAS		# hdrDS   = data base
+	.word	DATA + DLEN + LOAD_BIAS	# hdrHeap = data end
+
+	.org	DATA
+	.zero	DLEN			# poisoned to AAH by the driver
+
+	.org	STORE
+	.zero	2			# poisoned to EEEH by the driver
+
+	.org	CASE
+	.incbin	"rtcase.bin"
+
+	.org	D_DUMPLEN
+	.zero	6			# D_DUMPLEN, D_DUMPADR, D_CNT

+ 187 - 15
shell/tests/nonvacuity.sh

@@ -27,6 +27,13 @@
 #                      going wrong, which no amount of decoding will show
 #   check_framedisp.py the BP disp rule:   catches a displacement that reads
 #                      a different address than the symbol table named
+#   rt_exec.py        the BP contract:      catches an entry that borrows BP to
+#                      reach its argument and does not hand it back.  The bytes
+#                      are well formed, the golden is satisfied, the audit says
+#                      every helper emits what its name says, and the machine
+#                      triple-faults on the second call - so nothing short of
+#                      running it, or of stating the register contract
+#                      explicitly, can see it.
 #
 # 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
@@ -429,6 +436,127 @@ else
     fail=$((fail + 1))
 fi
 
+echo
+echo "== the BP contract rt_exec.py checks before it starts a machine"
+# wrchar and wrbool both borrowed BP to reach their argument -- [SP] is not
+# encodable in 16-bit mode -- and neither saved it.  The driver's cursor into
+# the case record lives in BP precisely because BP is the one register an entry
+# may keep, so "wrchar borrowed it and did not give it back" sent the second
+# call to a garbage address, the machine triple-faulted, and the run printed the
+# record header twice and hung.  Both the golden and the audit call that shape
+# CORRECT: the bytes are well formed, every branch is on a boundary, the size
+# is unchanged, and the decode says exactly what it says.  So the rule is now
+# checked directly, and these two cases are what make that check more than a
+# claim.
+#
+# 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
+cp Runtime.mod "$SAVED_R3" || exit 1
+
+if rebuild; then
+    if python3 tests/rt_exec.py >/dev/null 2>&1; then
+        echo "  ok: baseline - rt_exec.py passes on the unmutated runtime"
+        pass=$((pass + 1))
+    else
+        echo "  FAIL: the baseline is already red, so the cases below prove"
+        echo "        nothing - fix the baseline before reading them"
+        python3 tests/rt_exec.py 2>&1 | tail -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "  FAIL: could not rebuild the runtime for the baseline"
+    fail=$((fail + 1))
+fi
+
+# 1. THE fault: drop the PUSH, exactly as EmitWrChar was written.  This is the
+#    shape the behavioural case found, so the byte-level check must find it too
+#    -- a check that only the expensive test can trip is a check that has not
+#    been made to earn its place.
+cp "$SAVED_R3" Runtime.mod
+python3 - <<'PYEOF'
+p = 'Runtime.mod'
+s = open(p).read()
+a = '   M ("wrchar") ;\n   PushBp ; MovBpSp ;\n'
+assert s.count(a) == 1, "EmitWrChar prologue not found exactly once"
+open(p, 'w').write(s.replace(a, '   M ("wrchar") ;\n   MovBpSp ;\n'))
+PYEOF
+if rebuild; then
+    expect_red "the BP contract catches wrchar borrowing BP unsaved" \
+        "borrows BP but does not open with" python3 tests/rt_exec.py
+else
+    echo "  FAIL: the runtime would not rebuild with the PUSH removed"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_R3" Runtime.mod
+
+# 2. The mirror image: push it and never pop it.  A different one-instruction
+#    omission with the same consequence for a caller, and the reason the rule
+#    asks for the 5D and not just the 55.
+cp "$SAVED_R3" Runtime.mod
+mutate Runtime.mod 's|^   MovSpBp ; PopBp ;$|   MovSpBp ;|'
+if rebuild; then
+    expect_red "the BP contract catches a BP that is pushed and never popped" \
+        "pushes BP but never pops it" python3 tests/rt_exec.py
+else
+    echo "  FAIL: the runtime would not rebuild with the POP removed"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_R3" Runtime.mod
+
+# 3. The scan's other silent failure: a rule that matches nothing looks exactly
+#    like a rule that passes.  The pattern the check looks for is changed to one
+#    the blob does not contain -- MOV BP,DI, which the runtime has no reason to
+#    emit -- so the check has examined thirteen entries and matched nothing and
+#    MUST say so rather than report a clean sweep.  This is the general shape of
+#    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
+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" \
+    "matched nothing" python3 tests/rt_exec.py
+cp "$SAVED_X" tests/rt_exec.py
+
+if rebuild; then
+    if python3 tests/rt_exec.py >/dev/null 2>&1; then
+        echo "  ok: rt_exec.py passes on the restored source"
+        pass=$((pass + 1))
+    else
+        echo "NOT RESTORED: rt_exec.py is red after restoring Runtime.mod"
+        python3 tests/rt_exec.py 2>&1 | tail -3 | sed 's/^/       /'
+        fail=$((fail + 1))
+    fi
+else
+    echo "NOT RESTORED: the runtime would not rebuild"
+    fail=$((fail + 1))
+fi
+
+# 4. The GOLDEN and the entry goldens, which are the other half of the same
+#    rule.  rt_exec.py states the contract; check_runtime.py pins the bytes.
+#    They are separate mechanisms and the case below is what shows the golden
+#    one works on its own -- a re-baseline of runtime.golden would otherwise
+#    have absorbed the new prologues silently, and the next person to bless it
+#    would have no way to know the PUSH and POP were ever missing.
+cp "$SAVED_R3" Runtime.mod
+python3 - <<'PYEOF'
+p = 'Runtime.mod'
+s = open(p).read()
+a = '   M ("wrchar") ;\n   PushBp ; MovBpSp ;\n   MovAlArg4 ;\n   MovSpBp ; PopBp ;\n'
+assert s.count(a) == 1, "EmitWrChar frame not found exactly once"
+open(p, 'w').write(s.replace(a, '   M ("wrchar") ;\n   MovBpSp ;\n   MovAlArg2 ;\n   MovSpBp ;\n'))
+PYEOF
+if rebuild; then
+    expect_red "the entry golden catches wrchar without its PUSH BP" \
+        "entry wrchar starts 8B EC" python3 tests/check_runtime.py "$DUMP"
+else
+    echo "  FAIL: the runtime would not rebuild with wrchar's frame removed"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_R3" Runtime.mod
+
 echo
 echo "== the .COM layout check, and the runtime size it now measures"
 # The checker used to RESTATE the runtime's size as a literal.  It was wrong
@@ -457,17 +585,54 @@ else
     #    images already fail.  (The stale RT_SZ produced exactly that: 30
     #    failures that were not findings.)  So this is asserted first, and a
     #    failure here is reported as a broken baseline rather than a red test.
+    #
+    #    The baseline is also WHERE THE HEADER OFFSET COMES FROM.  Cases 1 and 2
+    #    used to carry it as the literal 435 and 439, which were ENT_SZ + the
+    #    runtime size at the time -- 432.  Two 4-byte changes to the runtime
+    #    later, both cases were editing the wrong bytes: 435 had become four
+    #    bytes inside the runtime itself, so case 1 had stopped testing "the
+    #    header cannot be found" and had started testing "the checker also
+    #    notices you scribbled on the code", and case 2 had become a no-op
+    #    that wrote the header where it already was.  Both still went red, for
+    #    reasons the messages did not name, which is the whole problem: a
+    #    hard-coded offset is a claim about the world that expires silently.
+    #
+    #    So the offset is read back out of the checker's own report on the
+    #    untouched images, and the expected numbers below are ARITHMETIC ON IT.
+    #    If the runtime grows again, these cases still test what they say.
     rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
     cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
-    if $COMCHK "$KEEPDIR" >/dev/null 2>&1; then
+    BASE_OUT=$($COMCHK "$KEEPDIR" 2>&1)
+    if [ $? -eq 0 ]; then
         echo "  ok: baseline - untouched copies of the real images all pass"
         pass=$((pass + 1))
     else
         echo "  FAIL: the baseline is already red, so the cases below prove"
         echo "        nothing - fix the baseline before reading them"
-        $COMCHK "$KEEPDIR" 2>&1 | grep FAIL | head -3 | sed 's/^/       /'
+        printf '%s\n' "$BASE_OUT" | grep FAIL | head -3 | sed 's/^/       /'
         fail=$((fail + 1))
     fi
+    BASE_RT=$(printf '%s\n' "$BASE_OUT" \
+        | sed -n 's/.*measured runtime size: \([0-9][0-9]*\) bytes.*/\1/p' \
+        | head -1)
+    if [ -z "$BASE_RT" ]; then
+        echo "  FAIL: the checker did not report the runtime size it measured,"
+        echo "        so the cases below cannot find the header to edit"
+        fail=$((fail + 1))
+        BASE_RT=0
+    fi
+    # The layout constants are ENT_SZ 3 and HDR_SZ 16 (run_com_tests.sh), so the
+    # header sits at hdrOff = 3 + rtSz and the entry jump's displacement must be
+    # (hdrOff + 16) - 3.  The wanted number is therefore computed from the header
+    # OFFSET, not from rtSz: the two differ by 3, and getting that backwards
+    # produces a plausible-looking expectation three bytes out, which is how
+    # this case came to expect "want 452" and then be reported as not proving
+    # what it said.
+    BASE_OFF=$((3 + BASE_RT))
+    # Case 2 claims a runtime four bytes LONGER, so the header - and with it the
+    # demanded jump target - moves four bytes further on.
+    SHIFTED_WANT=$((BASE_OFF + 4 + 16 - 3))
+    echo "       (measured runtime size $BASE_RT, header at image offset $BASE_OFF)"
 
     # 1. Break hdrDS so it no longer ties the header to its own offset.  The
     #    header must become UNFINDABLE and be reported as such - a checker that
@@ -475,11 +640,10 @@ else
     #    which is the failure mode the measurement was introduced to remove.
     rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
     cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
-    python3 - "$KEEPDIR/t01_minimal.COM" <<'PYEOF'
+    python3 - "$KEEPDIR/t01_minimal.COM" "$BASE_OFF" <<'PYEOF'
 import sys
-p = sys.argv[1]
+p, off = sys.argv[1], int(sys.argv[2])
 d = bytearray(open(p, 'rb').read())
-off = 435
 d[off + 4:off + 6] = (0x1234).to_bytes(2, 'little')     # hdrDS, no longer self-consistent
 open(p, 'wb').write(bytes(d))
 PYEOF
@@ -487,24 +651,32 @@ PYEOF
         "no program header found" $COMCHK "$KEEPDIR"
 
     # 2. A complete, self-consistent header four bytes later, so the measured
-    #    runtime size becomes 436 instead of 432.  This is the positive half of
-    #    the same check: the derivation must FOLLOW the file, and the entry
-    #    jump assertion - expressed in terms of the measurement - must follow
-    #    it too, demanding 452 rather than 448.
+    #    runtime size becomes $((BASE_RT + 4)) instead of $BASE_RT.  This is the
+    #    positive half of the same check: the derivation must FOLLOW the file,
+    #    and the entry jump assertion - expressed in terms of the measurement -
+    #    must follow it too, demanding $SHIFTED_WANT rather than $((BASE_OFF + 16 - 3)).
+    #
+    #    hdrCS is copied from the header already in the file rather than written
+    #    as a literal.  It used to be the literal 464+100h, which was the image
+    #    length when the image was 720 bytes; four bytes of runtime later it was
+    #    464+100h against a 724-byte image, so this case was ALSO failing on
+    #    hdrCS, for a reason three lines below the one it was written to test.
     rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
     cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
-    python3 - "$KEEPDIR/t01_minimal.COM" <<'PYEOF'
+    python3 - "$KEEPDIR/t01_minimal.COM" "$BASE_OFF" "$SHIFTED_WANT" <<'PYEOF'
 import sys
-p = sys.argv[1]
+p, off, want = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
 d = bytearray(open(p, 'rb').read())
-off = 439
-w = [1, 464 + 0x100, off + 0x1000 + 0x100, off + 0x1000 + 0x100 + 4, 0, 0, 0, 0]
+hdrCS = int.from_bytes(d[off + 2:off + 4], 'little')   # unchanged: still the image end
+off += 4
+ds = off + 0x1000 + 0x100
+w = [1, hdrCS, ds, ds + 4, 0, 0, 0, 0]
 for i, x in enumerate(w):
     d[off + 2 * i:off + 2 * i + 2] = x.to_bytes(2, 'little')
 open(p, 'wb').write(bytes(d))
 PYEOF
-    expect_red "the measured runtime size follows the image (432 -> 436)" \
-        "want 452" $COMCHK "$KEEPDIR"
+    expect_red "the measured runtime size follows the image ($BASE_RT -> $((BASE_RT + 4)))" \
+        "want $SHIFTED_WANT" $COMCHK "$KEEPDIR"
 
     # 3. The entry jump's opcode.  One byte, and the only assertion in the
     #    project that can see where execution STARTS.

+ 533 - 166
shell/tests/rt_exec.py

@@ -1,187 +1,554 @@
 #!/usr/bin/env python3
-"""Execute the assembled 8086 runtime on a real CPU emulator and check it.
+"""rt_exec.py -- call every 8086 runtime entry with a known argument, on qemu,
+and check what it does.
 
-The runtime (shell/Runtime.mod) is assembled by RtProbe, which prints its
-bytes as hex; this script loads them at offset 0 of a flat 64K segment - the
-layout a .COM gets - calls each entry with a known argument and compares the
-bytes it sends to INT 21h with what it expects.
+The runtime (shell/Runtime.mod) is hand-assembled 8086, so "it built" says
+nothing about it.  This says it *runs*.  It used to run it on Unicorn, and
+Unicorn 2.1.4 mis-decodes 16-bit ModRM memory operands, so it failed 33 of 33
+and every one of those failures was the emulator's.  A wrong oracle is worse
+than none: it cannot tell "my codegen is broken" from "the machine is broken".
 
-That is the whole point of the exercise: the library is hand-assembled 8086,
-so "it built" says nothing.  This says it *runs*.
+So the machine is now qemu-system-i386, driven through the same boot sector the
+.COM fixtures use (tests/exec/bootcom.s), and the expectations are unchanged
+except where they were demonstrably wrong -- see EXPECTATION FIXES below.
+
+    .pas-free:  RtProbe (base = RT_BASE)  -> blob + entry offsets
+                rt_exec.py                 -> 16-40 byte case record
+                exec/rtdrv.s (assembled)   -> a whole .COM image
+                bootcom.s + qemu           -> one framed record per case
+                rt_exec.py                 -> parsed, compared
+
+    tests/rt_exec.py --probe   # the standalone runtime dump (base 0)
+    tests/rt_exec.py --list    # the case names
+    tests/rt_exec.py --show    # print what came out, assert nothing
+
+EXPECTATION FIXES -- two expectations were wrong, and both were wrong in the
+direction that made the HARNESS the suspect:
+
+  * rdchar stores ONE byte (Runtime.EmitRdChar: `StDiDl' = MOV [DI], DL), so
+    dumping a two-byte word and comparing it against ord(c) could never pass.
+    The check was broken, the entry was right.  The case now reports two bytes
+    and expects the character followed by the 0xEE poison that is still there.
+
+  * rdint at end of input does NOT store anything.  TP3's xrdint returns to
+    rnerr without touching the variable, and Runtime.EmitRdInt says so in its
+    own comment ("^Z before any digit reaches rnend ... returns WITHOUT
+    touching the variable").  The old expectation said the variable became 0.
+    It stays at the poison, and that is the faithful answer.
+
+Everything else is the original expectation verbatim.  The two `rdln' halves of
+the six rdint cases are merged into the same case as the value they follow: a
+readln that printed anything would change the same compared string, so the
+assertion is unchanged - one qemu boot per check instead of two.
 """
+
+import os
 import re
+import struct
 import subprocess
 import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from run_com_exec import (GM2, QEMU, build_boot_sector, build_floppy,  # noqa: E402
+                          run_qemu, visible_output)
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SHELL = os.path.dirname(HERE)
+
+# --------------------------------------------------------------- the layout
+# RT_BASE is restated from exec/rtdrv.s on purpose: the driver needs the blob at
+# a known offset and Python needs to know where the driver thinks it is, so the
+# two are written down and then CHECKED against each other (see build_image).
+# A restated constant that is never compared is a constant waiting to lie.
+RT_BASE = 0x0200
+HDR = 0x0800
+DLEN = 32
+DATA = 0x0810
+STORE = 0x0840
+CASE = 0x0860
+CASE_SZ = 40
+
+# A .COM is loaded at CS:0100, and every address in the image is an image
+# offset.  The load bias is therefore the difference between the two, and
+# putting it in one place here stops it being applied -- or forgotten -- in each
+# field.  exec/rtdrv.s has its own .set of it, for the same reason it has its
+# own .set of the record offsets: two independent statements that are then
+# checked against each other by the fact that any disagreement stops the cases
+# from passing.
+LOAD_BIAS = 0x0100
+
+# The 8086 has a fixed 16-byte-case-record area in rtdrv.s, and the harness
+# asserts this window is still zeros.  If the driver outgrows its space it
+# starts eating the runtime it is testing, which would be silent; this makes it
+# loud.
+DRIVER_TOP = 0x0180
+
+# The four framing bytes exec/rtdrv.s wraps each case's report in.
+SOH, STX, ETX, EOT = 0x01, 0x02, 0x03, 0x04
+
+# Entry selector order, as Runtime.def declares it.  The names are used only to
+# be looked up in RtProbe's output, so renaming an entry in Runtime.mod makes
+# the lookup fail loudly instead of silently testing offset 0.
+ENTS = ["initmem", "progend", "stackchk", "wrint", "wrchar", "wrbool",
+        "wrreal", "wrln", "rdint", "rdchar", "rdbool", "rdln", "halt",
+        "wrtinl"]
+
+# A call slot's flags, as rtdrv.s reads them.
+F_STACK = 1     # the argument goes on the stack, popped by the caller
+F_AX = 2        # ...or in AX instead (initmem's convention)
+F_INL = 4       # wrtinl: the argument is at the return address, not on the stack
+
+# The byte patterns check_bp_contract looks for, and the rule they express:
+# "an entry that borrows BP opens with PUSH BP; MOV BP,SP and pops it again
+# before it returns" -- so an entry containing 8B EC anywhere must start with
+# 55 8B EC and must contain a 5D.  See that function: the first half of that
+# rule is exact and the second is a screen, and the difference matters.
+PUSH_BP_MOV_BP_SP = b"\x55\x8b\xec"   # PUSH BP ; MOV BP,SP
+MOV_BP_SP = b"\x8b\xec"                # MOV BP,SP
+POP_BP = b"\x5d"                       # POP BP
+
 
-from unicorn import Uc, UC_ARCH_X86, UC_MODE_16
-from unicorn.x86_const import (
-    UC_X86_REG_AX, UC_X86_REG_DX, UC_X86_REG_SP, UC_X86_REG_IP,
-    UC_X86_REG_CS, UC_X86_REG_DS, UC_X86_REG_ES, UC_X86_REG_SS,
-)
-import unicorn
+# --------------------------------------------------------------- RtProbe
+def ensure_rtprobe():
+    """Build tests/RtProbe.mod, return its path.
 
-HERE = __file__.rsplit("/", 1)[0]
-PROBE = HERE + "/rtprobe"
+    Timestamp-checked, for the reason run_com_exec.ensure_comtest documents:
+    a probe older than the runtime it reports on is worse than no probe, and
+    the failure it produces -- a blob with the wrong addresses in it -- is
+    silent at the byte level and catastrophic at the behaviour level.
+    """
+    exe = os.path.join(HERE, "rtprobe")
+    newer = ["Runtime.mod", "Runtime.def", "Runtime.o", "Posix.c",
+             os.path.join("tests", "RtProbe.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
+    r = subprocess.run([GM2, "-fiso", "-o", exe, "tests/RtProbe.mod",
+                        "Runtime.o", "Posix.o"],
+                       capture_output=True, cwd=SHELL)
+    if r.returncode != 0:
+        sys.exit("building RtProbe failed:\n" + r.stderr.decode(errors="replace"))
+    return exe
 
-HDR = 0x200          # where the fake program header sits
-DATA_BASE = 0x300
-DATA_END = 0x320
-STACK = 0xF000
-SENTINEL = 0xBEEF   # "return address" that tells us an entry came back
 
+def load_runtime(base):
+    """Run RtProbe at `base`.
 
-def load_runtime():
-    """Run RtProbe, parse its hex dump, return (bytes, {entry: offset})."""
-    out = subprocess.run([PROBE], capture_output=True, text=True, check=True).stdout
+    Return (blob, {name: image-absolute offset}, code_end).  `code_end` is the
+    blob-relative offset where the code stops and the data area starts, and it
+    is what bounds the last entry's bytes: without it the last entry's "does
+    it contain MOV SP,BP" question would be answered partly by the data area,
+    and a string or an entry-table word that happens to contain the pattern
+    would turn a missing epilogue into a pass.
+    """
+    out = subprocess.run([ensure_rtprobe()], input=b"%d\n" % base,
+                         capture_output=True, check=True).stdout.decode(
+                             errors="replace")
+    # RtProbe ends its lines CR LF, as a program of this vintage would.  Every
+    # pattern below is `$'-anchored, and Python's `$' in MULTILINE stops before
+    # "\n" only -- so without this the first regex fails and the report blames
+    # the probe for it.
+    out = out.replace("\r\n", "\n")
+    m = re.search(r"^base=(\d+)$", out, re.M)
+    if m is None or int(m.group(1)) != base:
+        sys.exit("RtProbe did not confirm base=%d:\n%s" % (base, out[:400]))
     size = int(re.search(r"^(\d+) bytes", out, re.M).group(1))
-    entries = {n: int(v) for v, n in re.findall(r"entry \d+ = (\d+)\s+\((\w+)\)", out)}
+    cm = re.search(r"^code ends at (\d+)$", out, re.M)
+    if cm is None:
+        sys.exit("RtProbe did not say where the code ends:\n%s" % out[:400])
+    code_end = int(cm.group(1))
+    if not 0 < code_end <= size:
+        sys.exit("RtProbe says the code ends at %d, which is not inside a "
+                 "%d byte blob" % (code_end, size))
+    entries = {nm: int(v) for v, nm in
+               re.findall(r"entry \d+ = (\d+)\s+\((\w+)\)", out)}
+    missing = [e for e in ENTS if e not in entries]
+    if missing:
+        sys.exit("RtProbe did not report entries: %s" % ", ".join(missing))
     blob = bytearray()
     for line in out.splitlines():
-        m = re.match(r"^[0-9A-F]{8}  ((?:[0-9A-F]{2} )+)$", line)
-        if m:
-            blob += bytes.fromhex(m.group(1).replace(" ", ""))
-    assert len(blob) == size, f"parsed {len(blob)} bytes, header says {size}"
-    return bytes(blob), entries
-
-
-class Machine:
-    def __init__(self, blob):
-        self.blob = blob
-        self.out = bytearray()
-        self.input = bytearray()
-        self.uc = Uc(UC_ARCH_X86, UC_MODE_16)
-        self.uc.mem_map(0, 0x110000)
-        self.uc.mem_write(0, blob)
-        # a program header word block: flag, code end, data base, data end
-        self.uc.mem_write(HDR, b"\x01\x00\x34\x02\x00\x03\x20\x03\x00\x00")
-        self.uc.mem_write(DATA_BASE, b"\xAA" * (DATA_END - DATA_BASE))  # poison
-        for r in (UC_X86_REG_CS, UC_X86_REG_DS, UC_X86_REG_ES, UC_X86_REG_SS):
-            self.uc.reg_write(r, 0)
-        self.uc.hook_add(unicorn.UC_HOOK_INTR, self._intr)
-
-
-    def _intr(self, mu, intno, _):
-        if intno != 0x21:
-            return
-        ah = (mu.reg_read(UC_X86_REG_AX) >> 8) & 0xFF
-        if ah == 0x02:                                   # display character
-            self.out.append(mu.reg_read(UC_X86_REG_DX) & 0xFF)
-        elif ah == 0x09:                                 # display $-string
-            a = mu.reg_read(UC_X86_REG_DX)
-            while True:
-                b = mu.mem_read(a, 1)[0]
-                if b == ord("$"):
-                    break
-                self.out.append(b)
-                a += 1
-        elif ah == 0x4C:                                 # exit
-            mu.emu_stop()
-        elif ah == 0x08:                                 # read char, no echo
-            if self.input:
-                mu.reg_write(UC_X86_REG_AX, (mu.reg_read(UC_X86_REG_AX) & 0xFF00) | self.input.pop(0))
-            else:
-                mu.reg_write(UC_X86_REG_AX, (mu.reg_read(UC_X86_REG_AX) & 0xFF00) | 0x1A)
-
-    def call(self, entry, args=(), ax=0):
-        """Call an entry with `args` pushed (caller-cleaned, like the compiler
-        does).  Returns whatever it wrote to stdout."""
-        self.out.clear()
-        uc = self.uc
-        sp = STACK - 4 * len(args) - 2
-        words = [SENTINEL] + list(args)
-        uc.mem_write(sp, b"".join(w.to_bytes(2, "little") for w in words))
-        uc.reg_write(UC_X86_REG_SP, sp)
-        uc.reg_write(UC_X86_REG_AX, ax)
-        uc.reg_write(UC_X86_REG_IP, entry)
-        # end = SENTINEL: the entry stops by RETurning to it.  Using 0 as the
-        # end address would stop instantly for the entry that lives at 0.
-        uc.emu_start(entry, SENTINEL, timeout=2_000_000, count=200000)
-        if uc.reg_read(UC_X86_REG_IP) != SENTINEL:
-            raise AssertionError(
-                f"entry {entry} did not return (IP={uc.reg_read(UC_X86_REG_IP):#06x})")
-        return bytes(self.out)
-
-
-def main():
-    blob, ent = load_runtime()
-    m = Machine(blob)
-    fails = []
-
-    def check(name, got, want):
-        if got == want:
-            print(f"  ok   {name}: {got!r}")
+        h = re.match(r"^[0-9A-F]{8}  ((?:[0-9A-F]{2} )+)$", line)
+        if h:
+            blob += bytes.fromhex(h.group(1).replace(" ", ""))
+    if len(blob) != size:
+        sys.exit("parsed %d bytes of runtime, RtProbe says %d"
+                 % (len(blob), size))
+    for nm, off in entries.items():
+        if not base <= off < base + code_end:
+            sys.exit("entry %s is at %d, outside the code area %d..%d -- the "
+                     "entry table and the blob disagree"
+                     % (nm, off, base, base + code_end))
+    return bytes(blob), entries, code_end
+
+
+def probe():
+    """The standalone dump run_all.sh's `runtime probe' step wants: base 0."""
+    raw = subprocess.run([ensure_rtprobe()], input=b"0\n", capture_output=True,
+                         check=True).stdout
+    sys.stdout.buffer.write(raw)
+    return 0
+
+
+def check_bp_contract(blob, entries, base, code_end):
+    """Every entry must hand BP back.
+
+    The driver keeps its cursor into the case record in BP, because an entry is
+    allowed to destroy every register except BP and SP.  Two entries did not
+    honour that: wrchar and wrbool borrowed BP to reach their argument -- [SP]
+    is unencodable in 16-bit mode -- and returned without saving it.  One
+    multi-call case caught it, and only because it made four calls in a row; a
+    new entry that borrowed BP the same way would be caught by nothing.
+
+    So the rule is checked here, over the built blob, before any case runs.
+
+    The rule is the runtime's own convention, in two halves, and the halves are
+    NOT equally strong -- which is worth writing down rather than discovering:
+
+      * "an entry that borrows BP must START with 55 8B EC" is EXACT.  The
+        prologue is the first thing the entry does, so a positional test
+        cannot be fooled by anything.
+
+      * "...and must contain a 5D somewhere" is a SCREEN, not a proof.  5D is
+        POP BP, but it is also a perfectly ordinary displacement byte, and
+        this code does not disassemble.  Requiring the POP to be contiguous
+        with something else does not rescue it either: wrbool legitimately
+        closes its frame with POP BP after the INT 21h, and a rule that
+        insisted on 89 EC 5D would have failed a correct entry -- a false
+        alarm, which is worse here than a miss because it teaches a reader to
+        distrust the check.  So the honest position is: a push with no pop is
+        CAUGHT unless some displacement in the entry happens to be 5Dh, and
+        the argument displacements in this runtime are 2 and 4, so nothing can
+        collide today.  tests/nonvacuity.sh removes the POP as well as the
+        PUSH and requires each to turn this red, so the screen is at least
+        witnessed rather than assumed.
+
+    Counting 55s and 5Ds to check frame BALANCE would be worse than either:
+    55 occurs as an immediate and 5D as a displacement, so the answer would
+    come out wrong without the check ever looking wrong.  For the same reason
+    each entry's bytes stop at the next entry, or at `code_end` for the last
+    one: running on into the data area would let a string satisfy either test.
+
+    The counts it returns are the point as much as the verdict.  A scan that
+    matches nothing is indistinguishable from a scan that passes, and that has
+    happened here before -- so zero borrowers is a failure, not a pass.
+
+    Entries are counted by OFFSET, not by name: `progend' and `halt' are one
+    entry with two names, and counting them twice would make the total here
+    disagree with the number of things that can actually be wrong.
+    """
+    offs = sorted(set(entries.values()))
+    scanned = borrowed = 0
+    bad = []
+    for i, a in enumerate(offs):
+        lo = a - base
+        hi = (offs[i + 1] - base) if i + 1 < len(offs) else code_end
+        code = bytes(blob[lo:hi])
+        scanned += 1
+        if MOV_BP_SP not in code:
+            continue
+        borrowed += 1
+        # The two halves are reported separately, because they are separate
+        # faults with separate fixes and "it does not hand BP back" does not
+        # say which of them happened.
+        if not code.startswith(PUSH_BP_MOV_BP_SP):
+            why = "borrows BP but does not open with 55 8B EC, so the "\
+                  "caller's BP is destroyed"
+        elif POP_BP not in code:
+            why = "pushes BP but never pops it, so the caller gets a "\
+                  "cursor two bytes off"
         else:
-            print(f"  FAIL {name}: got {got!r} want {want!r}")
-            fails.append(name)
-
-    # TU_InitMem must clear [data base, data end) and leave DS alone
-    m.call(ent["initmem"], ax=HDR)
-    cleared = m.uc.mem_read(DATA_BASE, DATA_END - DATA_BASE)
-    check("initmem zeroes globals", cleared, b"\x00" * (DATA_END - DATA_BASE))
-
-    # TU_WrInt: signed 16-bit decimal
-    for v, want in [(0, b"0"), (1, b"1"), (7, b"7"), (10, b"10"), (999, b"999"),
-                    (12345, b"12345"), (32767, b"32767"),
-                    (-1, b"-1"), (-32768, b"-32768"), (-999, b"-999")]:
-        check(f"wrint({v})", m.call(ent["wrint"], (v & 0xFFFF,)), want)
-
-    check("wrchar('A')", m.call(ent["wrchar"], (ord("A"),)), b"A")
-    check("wrchar('!')", m.call(ent["wrchar"], (ord("!"),)), b"!")
-    check("wrbool(0)", m.call(ent["wrbool"], (0,)), b"FALSE")
-    check("wrbool(1)", m.call(ent["wrbool"], (1,)), b"TRUE")
-    check("wrbool(2)", m.call(ent["wrbool"], (2,)), b"TRUE")
-    check("wrln", m.call(ent["wrln"]), b"\r\n")
-    check("stackchk returns", m.call(ent["stackchk"]), b"")
-
-    # a sequence, the way a program actually calls these
-    m.out.clear()
-    m.call(ent["wrint"], (42,))
-    m.call(ent["wrchar"], (ord(" "),))
-    m.call(ent["wrbool"], (1,))
-    m.call(ent["wrln"])
-    check("writeln(42) writeln TRUE", bytes(m.out), b"42 TRUE\r\n")
-
-    # TU_RdInt / RdChar / RdBool / RdLn against supplied input
-    store = 0x400
+            continue
+        names = sorted(n for n, v in entries.items() if v == a)
+        bad.append("%s -- %s (image 0x%04X: %s)"
+                   % (",".join(names), why, a, code[:12].hex(" ")))
+    if bad:
+        sys.exit("these entries do not hand BP back, so a caller that keeps a "
+                 "cursor in BP -- which this driver does, and which is the whole "
+                 "reason BP is the one register an entry may keep -- loses it:\n"
+                 "  %s" % "\n  ".join(bad))
+    if borrowed == 0:
+        sys.exit("no entry borrows BP, so this check examined %d entries and "
+                 "matched nothing: it is not looking at what it claims to "
+                 "look at" % scanned)
+    return scanned, borrowed
+
+
+# ------------------------------------------------------------------ the case
+def case_bytes(c, entries):
+    """One 40 byte case record, exactly as exec/rtdrv.s reads it.
+
+    The record is a second, independent statement of the same layout rtdrv.s
+    carries in its .set block.  The cases only pass if the two agree on every
+    offset, which is the whole reason for writing it twice: a driver that
+    silently read the wrong field would still boot, still run, and still print.
+    """
+    if len(c["in"]) > 8:
+        sys.exit("case %s supplies %d input bytes; the record holds 8"
+                 % (c["name"], len(c["in"])))
+    b = bytearray(CASE_SZ)
+    struct.pack_into("<H", b, 0, c["dump"])             # OFF_DUMPLEN
+    struct.pack_into("<H", b, 2, c["dumpadr"])          # OFF_DUMPADR
+    b[4] = len(c["in"])                                 # OFF_INLEN
+    b[5:5 + len(c["in"])] = c["in"]                     # OFF_INBUF
+    b[13] = c["index"]                                  # OFF_CASE
+    calls = c["calls"]
+    if len(calls) > 4:
+        sys.exit("case %s has %d calls; the record holds 4"
+                 % (c["name"], len(calls)))
+    struct.pack_into("<H", b, 14, len(calls))           # OFF_NCALLS
+    for i, (ent, arg, mode) in enumerate(calls):
+        o = 16 + 6 * i                                  # SLOT0
+        struct.pack_into("<H", b, o, entries[ent])
+        struct.pack_into("<H", b, o + 2, arg)
+        b[o + 4] = mode
+    return bytes(b)
+
+
+def build_image(blob, rec):
+    """Assemble exec/rtdrv.s into a complete .COM, and check it is the one
+    we think it is.
+
+    The three assertions are the point of this function.  An image that is
+    subtly not the image we intended would still boot, still run, and still
+    print something -- the failure would look like a runtime bug.
+    """
+    if RT_BASE + len(blob) > HDR:
+        sys.exit("the runtime is %d bytes: at RT_BASE=%04X it runs into the "
+                 "test scaffolding at %04X.  Move the .org's in rtdrv.s."
+                 % (len(blob), RT_BASE, HDR))
+    with tempfile.TemporaryDirectory() as td:
+        open(os.path.join(td, "rtblob.bin"), "wb").write(blob)
+        open(os.path.join(td, "rtcase.bin"), "wb").write(rec)
+        src = os.path.join(HERE, "exec", "rtdrv.s")
+        obj, raw = os.path.join(td, "r.o"), os.path.join(td, "r.bin")
+        r = subprocess.run(["as", "--32", "-I", td, "-o", obj, src],
+                           capture_output=True)
+        if r.returncode != 0:
+            sys.exit("as failed on rtdrv.s:\n"
+                     + r.stderr.decode(errors="replace"))
+        r = subprocess.run(["objcopy", "-O", "binary", obj, raw],
+                           capture_output=True)
+        if r.returncode != 0:
+            sys.exit("objcopy failed:\n" + r.stderr.decode(errors="replace"))
+        img = open(raw, "rb").read()
+
+    # E9 00 00: a near jump to offset 3 from offset 0, so the displacement is
+    # 3 - (0 + 3) = 0.  The driver is the first thing after the jump, which is
+    # what makes the rel16 zero and therefore useless as a check on the target
+    # -- the check that matters is the one below, that the blob landed where the
+    # driver thinks it did.
+    if img[0:3] != b"\xe9\x00\x00":
+        sys.exit("rtdrv.s does not open with a 3-byte near JMP over itself: %s"
+                 % img[0:3].hex())
+    if img[RT_BASE:RT_BASE + len(blob)] != blob:
+        sys.exit("the blob is not at RT_BASE=%04X in the assembled image -- "
+                 "rtdrv.s and rt_exec.py disagree about the layout"
+                 % RT_BASE)
+    gap = img[DRIVER_TOP:RT_BASE]
+    if gap != b"\x00" * len(gap):
+        sys.exit("the driver has outgrown its space: rtdrv.s no longer fits "
+                 "under %04X, so it is overwriting the runtime" % DRIVER_TOP)
+    return img
+
+
+def parse_record(raw, want_index):
+    """Split the serial output into (case index, printed, dumped) or raise.
+
+    Nothing here is lenient.  A driver that printed nothing, a truncated
+    record, or a record for the wrong case are all failures with a reason, not
+    a comparison against an empty string that happens to pass.
+    """
+    if not raw or raw[0] != SOH:
+        raise AssertionError("no record header: the driver printed %r" % raw[:64])
+    if len(raw) < 5 or raw[3] != STX:
+        raise AssertionError("malformed record header %r" % raw[:16])
+    idx = int(raw[1:3], 16)
+    if idx != want_index:
+        raise AssertionError("record is for case %02X, expected %02X"
+                             % (idx, want_index))
+    if raw[-1] != EOT:
+        raise AssertionError("record was never closed (EOT): the last call did "
+                             "not return, or the machine halted early")
+    end = raw.index(ETX, 4)
+    if end < 0:
+        raise AssertionError("record has no ETX: the calls printed nothing and "
+                             "the driver never got past them")
+    printed, dump = raw[4:end], raw[end + 1:-1]
+    if len(dump) % 2 or not re.fullmatch(b"[0-9A-F]*", dump):
+        raise AssertionError("the memory report %r is not hex" % dump)
+    return idx, printed, bytes.fromhex(dump.decode())
+
+
+# ----------------------------------------------------------------- the cases
+def build_cases():
+    """Every check, in the order the names are printed.
+
+    Each case is a list of calls plus two expectations: the bytes the entry(s)
+    printed, and the bytes that must be at `dumpadr' afterwards.  `dump=0' means
+    the case asserts nothing about memory, which is itself an assertion: the
+    report is empty, so a case cannot quietly acquire a memory check it did not
+    ask for.
+
+    The addresses handed to an entry as its argument are MEMORY addresses, not
+    image offsets -- that is the whole of LoadBias, and it is the difference
+    between a read entry storing where the harness looks and storing one page
+    lower where nothing checks it.  V below is "the memory address of STORE",
+    which is what every read case wants.
+    """
+    cs = []
+    V = STORE + LOAD_BIAS
+
+    def add(name, calls, out=b"", stdin=b"", dump=0, dumpbytes=b"",
+            dumpadr=STORE, note=""):
+        cs.append({"name": name, "calls": calls, "out": out, "in": stdin,
+                   "dump": dump, "dumpbytes": dumpbytes, "dumpadr": dumpadr,
+                   "note": note, "index": len(cs)})
+
+    def s(ent, arg):
+        return (ent, arg, F_STACK)
+
+    def x(ent, arg):
+        return (ent, arg, F_AX)
+
+    def n(ent):
+        return (ent, 0, 0)
+
+    # initmem is handed the header in AX -- and the header's MEMORY address, not
+    # its image offset.  Given the image offset it reads the two words it wants
+    # out of blank space, finds both zero, and clears nothing at all: a silent
+    # no-op, which is the reason this case poisons the data area to AAH first.
+    add("initmem zeroes globals",
+        [x("initmem", HDR + LOAD_BIAS)], dump=DLEN, dumpadr=DATA,
+        dumpbytes=b"\x00" * DLEN,
+        note="the data area is poisoned to AA first, so a zero is a result")
+
+    for v, why in [(0, "the one value where a signed/unsigned slip is invisible"),
+                   (1, ""), (-1, ""), (7, ""), (10, ""), (42, ""),
+                   (100, ""), (12345, ""), (-32768, "the sign bit"), (32767, "")]:
+        add("wrint(%d)" % v, [s("wrint", v & 0xFFFF)],
+            out=str(v).encode(), note=why)
+
+    add("wrchar('A')", [s("wrchar", ord("A"))], out=b"A")
+    add("wrchar('!')", [s("wrchar", ord("!"))], out=b"!")
+
+    add("wrbool(0)", [s("wrbool", 0)], out=b"FALSE")
+    add("wrbool(1)", [s("wrbool", 1)], out=b"TRUE")
+    add("wrbool(2)", [s("wrbool", 2)], out=b"TRUE",
+        note="any non-zero is true, as TP3's xwrb does")
+
+    add("wrln", [n("wrln")], out=b"\r\n")
+
+    add("stackchk returns", [n("stackchk")], out=b"",
+        note="no output: the assertion is that control came back at all")
+
+    add("writeln(42) writeln TRUE in one program",
+        [s("wrint", 42), s("wrchar", ord(" ")), s("wrbool", 1), n("wrln")],
+        out=b"42 TRUE\r\n",
+        note="four calls back to back, so no register leaks between them")
+
+    # rdint: the value, and then the rdln that must eat the rest of the line.
     for text, want in [(b"  42abc", 42), (b"-17 x", -17), (b"+5", 5),
                        (b"0", 0), (b"  007", 7), (b"1234", 1234)]:
-        m.input = bytearray(text)
-        m.uc.mem_write(store, b"\xEE\xEE")
-        m.call(ent["rdint"], (store,))
-        got = int.from_bytes(m.uc.mem_read(store, 2), "little", signed=True)
-        check(f"rdint({text!r})", got, want)
-        # the delimiter must be left for the following rdln
-        rest = bytes(m.input)
-        m.out.clear()
-        m.call(ent["rdln"])
-        check(f"rdln eats {rest!r}", bytes(m.out), b"")
-
-    for text, want in [(b"Q", ord("Q")), (b"7", ord("7"))]:
-        m.input = bytearray(text)
-        m.uc.mem_write(store, b"\xEE\xEE")
-        m.call(ent["rdchar"], (store,))
-        check(f"rdchar({text!r})", int.from_bytes(m.uc.mem_read(store, 2), "little"), want)
-
-    for text, want in [(b"T", 1), (b"y", 1), (b"1", 1), (b"F", 0), (b"n", 0), (b"0", 0)]:
-        m.input = bytearray(text)
-        m.uc.mem_write(store, b"\xEE\xEE")
-        m.call(ent["rdbool"], (store,))
-        check(f"rdbool({text!r})", int.from_bytes(m.uc.mem_read(store, 2), "little"), want)
-
-    # end of input must terminate the read loops rather than spin
-    m.input = bytearray()
-    m.uc.mem_write(store, b"\xEE\xEE")
-    m.call(ent["rdint"], (store,))
-    check("rdint at EOF", int.from_bytes(m.uc.mem_read(store, 2), "little"), 0)
-    m.call(ent["rdln"])
-
-    print()
-    if fails:
-        print(f"RUNTIME: {len(fails)} FAILURE(S): {', '.join(fails)}")
-        return 1
-    print("RUNTIME: all checks passed")
-    return 0
+        add("rdint(%r) then rdln" % text,
+            [s("rdint", V), n("rdln")], out=b"", stdin=text, dump=2,
+            dumpbytes=struct.pack("<h", want),
+            note="the delimiter must be left for rdln, which prints nothing")
+
+    for c in "Q7":
+        add("rdchar(%r)" % c, [s("rdchar", V)], out=b"", stdin=c.encode(),
+            dump=2, dumpbytes=bytes([ord(c), 0xEE]),
+            note="rdchar stores ONE byte, so the poison is still in byte 2")
+
+    for c, want in [("T", 1), ("y", 1), ("1", 1), ("F", 0), ("n", 0), ("0", 0)]:
+        add("rdbool(%r)" % c, [s("rdbool", V)], out=b"", stdin=c.encode(),
+            dump=2, dumpbytes=struct.pack("<H", want))
+
+    add("rdint at end of input leaves the variable alone",
+        [s("rdint", V), n("rdln")], out=b"", stdin=b"", dump=2,
+        dumpbytes=b"\xee\xee",
+        note="TP3's xrdint returns to rnerr without storing; the poison stays")
+
+    add("wrtinl reads its length and characters from the return address",
+        [("wrtinl", 0, F_INL)], out=b"hello",
+        note="the one entry whose argument is not a stack word")
+
+    return cs
+
+
+def run_case(c, blob, entries, boot):
+    rec = case_bytes(c, entries)
+    img = build_image(blob, rec)
+    with tempfile.TemporaryDirectory() as td:
+        floppy = os.path.join(td, "f.img")
+        open(floppy, "wb").write(build_floppy(boot, img, c["in"]))
+        return run_qemu(floppy)
+
+
+def main(argv):
+    if "--probe" in argv:
+        return probe()
+    cases = build_cases()
+    if "--list" in argv:
+        for c in cases:
+            print(c["name"])
+        return 0
+    show = "--show" in argv
+    only = [a for a in argv if not a.startswith("-")]
+
+    blob, entries, code_end = load_runtime(RT_BASE)
+    boot = build_boot_sector()
+
+    # Before any machine is started: the contract the driver depends on.
+    nscan, nborrow = check_bp_contract(blob, entries, RT_BASE, code_end)
+    print("  %-52s PASS   (%d entries scanned, %d borrow BP and hand it "
+          "back)" % ("every entry preserves BP", nscan, nborrow))
+    npass = 1
+    nfail = 0
+    for c in cases:
+        if only and not any(o in c["name"] for o in only):
+            continue
+        # A marker byte inside an expectation would make the framing
+        # ambiguous, so it is refused rather than mis-parsed.
+        for b in (SOH, STX, ETX, EOT):
+            if bytes([b]) in c["out"]:
+                sys.exit("case %s: its expected output contains the framing "
+                         "byte %02X, which would be indistinguishable from the "
+                         "driver's own markers" % (c["name"], b))
+        raw, rc, note = run_case(c, blob, entries, boot)
+        if show:
+            print(c["name"], "->", repr(visible_output(raw)))
+            continue
+        try:
+            _, printed, dumped = parse_record(raw, c["index"])
+        except AssertionError as e:
+            print("  %-52s FAIL  %s" % (c["name"], e))
+            nfail += 1
+            continue
+        if printed != c["out"]:
+            print("  %-52s FAIL  printed %s, want %s"
+                  % (c["name"], visible_output(printed),
+                     visible_output(c["out"])))
+            nfail += 1
+            continue
+        if dumped != c["dumpbytes"]:
+            print("  %-52s FAIL  +%04X = %s, want %s"
+                  % (c["name"], c["dumpadr"], dumped.hex() or "-",
+                     c["dumpbytes"].hex() or "-"))
+            nfail += 1
+            continue
+        print("  %-52s PASS%s" % (c["name"],
+                                  ("   (" + c["note"] + ")") if c["note"] else ""))
+        npass += 1
+    if show:
+        return 0
+    print("\nruntime entries: %d passed, %d failed (of %d)"
+          % (npass, nfail, npass + nfail))
+    return 1 if nfail else 0
 
 
 if __name__ == "__main__":
-    sys.exit(main())
+    sys.exit(main(sys.argv[1:]))

+ 34 - 15
shell/tests/run_all.sh

@@ -3,7 +3,7 @@
 #
 # Four suites, in increasing order of "how much could be lying to me":
 #
-#   1. compile matrix   27 fixtures, verdict + code size + data size ASSERTED
+#   1. compile matrix   33 fixtures, verdict + code size + data size ASSERTED
 #                       from expected.tsv.  Fast, no pty.
 #   2. .COM linker      links every fixture, then re-verifies the bytes with an
 #                       independent checker that restates the layout constants
@@ -49,15 +49,19 @@
 #                       desync, every entry and every branch target on an
 #                       instruction boundary, and the whole disassembly equal
 #                       to tests/runtime.golden.
-#   nonvacuity          breaks the runtime six ways and asserts each named
-#                       check goes red for the stated reason.  Slower, so it
+#   nonvacuity          breaks the runtime and the compiler in the ways each
+#                       named check is meant to catch, and asserts it goes red
+#                       for the STATED reason, not merely red.  Slower, so it
 #                       is not in the default run: run it when a check's
 #                       sensitivity is in question, or after editing a check.
 #
-# nonvacuity.sh is NOT run by default: it rebuilds the runtime six times and
-# it is a meta-check, so it belongs to "is the harness honest" reviews rather
-# than to every change.  Run tests/nonvacuity.sh directly after touching any
-# of the checks above.
+# nonvacuity.sh is NOT run by default: it rebuilds the runtime and the compiler
+# a dozen times over and it is a meta-check, so it belongs to "is the harness
+# honest" reviews rather than to every change.  Run tests/nonvacuity.sh
+# directly after touching any of the checks above.  (The count of rebuilds is
+# not stated here on purpose: it drifts every time a case is added, and a
+# number in a comment that is only right until the next edit is the kind of
+# restated constant that hides a fault here.)
 #
 # Two more checks guard the ModR/M table itself, which is the input every hand
 # written emitter in Runtime.mod depends on:
@@ -79,9 +83,13 @@
 #                       the table in Runtime.mod, which modrm11.py checks.
 #                       See probe/README.md to re-run it.
 #
-# rt_exec.py is NOT run: it needs Unicorn, whose 16-bit ModRM decoding is
-# wrong on this machine (see SUMMARY.md), so its failures would be the
-# emulator's, not the runtime's.  Running it by default would be noise.
+# rt_exec.py DOES run.  It used to run on Unicorn and fail 33 of 33, because
+# Unicorn 2.1.4 mis-decodes 16-bit ModRM memory operands - so every failure was
+# the emulator's, and a wrong oracle is worse than none: it cannot tell "my
+# codegen is broken" from "the machine is broken".  It now boots the same
+# bootcom.s the .COM fixtures use, one fresh machine per case, so every case
+# gets a machine that has provably never run anything.  See its own docstring
+# and the EXPECTATION FIXES section in tests/rt_exec.py.
 set -u
 D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
 GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
@@ -114,11 +122,13 @@ echo "make rc=0, tpshell $(stat -c%s tpshell) bytes"
 # gm2 reports a pass-3 rollup on the compiler during phase 1 of the two-phase
 # link; that is expected and is not a build failure (make rc=0 is the check).
 
-run "runtime probe"  bash -c '
-   GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
-   $GM2 -fiso -o /tmp/tp_rtprobe tests/RtProbe.mod Runtime.o Posix.o \
-       >/tmp/tp_rt 2>&1 || { grep -m5 error: /tmp/tp_rt; exit 1; }
-   /tmp/tp_rtprobe'
+# The probe step asks rt_exec.py for its dump rather than building RtProbe a
+# second time by hand.  Two recipes for one artifact is how a stale binary gets
+# believed: ensure_rtprobe() is the only one that knows Runtime.mod's mtime, and
+# a probe older than the runtime it reports on is worse than no probe, because
+# its failure -- a blob with the wrong addresses baked in -- is invisible at the
+# byte level and catastrophic at the behaviour level.
+run "runtime probe"  python3 tests/rt_exec.py --probe
 
 run "helper audit"    python3 tests/audit_helpers.py
 run "mod=11 table"     python3 tests/probe/modrm11.py
@@ -132,6 +142,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
+# 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
+# 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
+# and a register-convention check both call correct.  One case (four calls back
+# to back) noticed; the BP-contract pre-flight is what stops the next entry from
+# depending on there being such a case.
+run "runtime entries"  python3 tests/rt_exec.py
 run "frame displ"     python3 tests/check_framedisp.py
 run "UI error path"   python3 tests/uitest.py
 run "UI success path" python3 tests/comtest.py

+ 170 - 166
shell/tests/runtime.golden

@@ -41,7 +41,7 @@
 0036  58          pop ax
 0037  F7 D8       neg ax
 0039  B9 0A 00    mov cx,0ah
-003C  BB 99 02    mov bx,299h
+003C  BB 9D 02    mov bx,29dh
 003F  89 DE       mov si,bx
 0041  31 D2       xor dx,dx
 0043  F7 F1       div ax,cx
@@ -60,168 +60,172 @@
 005D  89 EC       mov sp,bp
 005F  5D          pop bp
 0060  C3          ret
-0061  8B EC       mov bp,sp
-0063  8A 46 02    mov al,byte ptr [bp+2h]
-0066  89 EC       mov sp,bp
-0068  B4 02       mov ah,2h
-006A  CD 21       int 21h
-006C  C3          ret
-006D  8B EC       mov bp,sp
-006F  83 7E 02 00 cmp word ptr [bp+2h],0h
-0073  89 EC       mov sp,bp
-0075  75 05       jne 7ch
-0077  BA 9E 02    mov dx,29eh
-007A  EB 03       jmp 7fh
-007C  BA 99 02    mov dx,299h
-007F  B4 09       mov ah,9h
-0081  CD 21       int 21h
-0083  C3          ret
-0084  BA A7 02    mov dx,2a7h
-0087  B4 09       mov ah,9h
-0089  CD 21       int 21h
-008B  C3          ret
-008C  BA A4 02    mov dx,2a4h
-008F  B4 09       mov ah,9h
-0091  CD 21       int 21h
-0093  C3          ret
-0094  5B          pop bx
-0095  31 C9       xor cx,cx
-0097  8A 0F       mov cl,byte ptr [bx]
-0099  43          inc bx
-009A  B4 02       mov ah,2h
-009C  E3 07       jcxz 0a5h
-009E  8A 07       mov al,byte ptr [bx]
-00A0  CD 21       int 21h
-00A2  43          inc bx
-00A3  E2 F9       loop 9eh
-00A5  FF E3       jmp bx
-00A7  55          push bp
-00A8  8B EC       mov bp,sp
-00AA  50          push ax
-00AB  53          push bx
-00AC  51          push cx
-00AD  52          push dx
-00AE  57          push di
-00AF  E8 BC 00    call 16eh
-00B2  3C 1A       cmp al,1ah
-00B4  74 54       je 10ah
-00B6  3C 14       cmp al,14h
-00B8  76 F5       jbe 0afh
-00BA  31 C9       xor cx,cx
-00BC  3C 2D       cmp al,2dh
-00BE  75 06       jne 0c6h
-00C0  41          inc cx
-00C1  E8 AA 00    call 16eh
-00C4  EB 07       jmp 0cdh
-00C6  3C 2B       cmp al,2bh
-00C8  75 03       jne 0cdh
-00CA  E8 A1 00    call 16eh
-00CD  31 FF       xor di,di
-00CF  3C 30       cmp al,30h
-00D1  72 18       jb 0ebh
-00D3  3C 39       cmp al,39h
-00D5  77 14       jnbe 0ebh
-00D7  2C 30       sub al,30h
-00D9  B4 00       mov ah,0h
-00DB  87 C7       xchg di,ax
-00DD  BB 0A 00    mov bx,0ah
-00E0  F7 E3       mul bx
-00E2  03 C7       add ax,di
-00E4  89 C7       mov di,ax
-00E6  E8 85 00    call 16eh
-00E9  EB E4       jmp 0cfh
-00EB  E8 97 00    call 185h
-00EE  83 F9 02    cmp cx,2h
-00F1  74 0E       je 101h
-00F3  83 F9 00    cmp cx,0h
-00F6  74 02       je 0fah
-00F8  F7 DF       neg di
-00FA  8B C7       mov ax,di
-00FC  8B 7E 04    mov di,word ptr [bp+4h]
-00FF  89 05       mov word ptr [di],ax
-0101  5F          pop di
-0102  5A          pop dx
-0103  59          pop cx
-0104  5B          pop bx
-0105  58          pop ax
-0106  89 EC       mov sp,bp
-0108  5D          pop bp
-0109  C3          ret
-010A  5F          pop di
-010B  5A          pop dx
-010C  59          pop cx
-010D  5B          pop bx
-010E  58          pop ax
-010F  89 EC       mov sp,bp
-0111  5D          pop bp
-0112  C3          ret
-0113  55          push bp
-0114  8B EC       mov bp,sp
-0116  50          push ax
-0117  53          push bx
-0118  57          push di
-0119  E8 52 00    call 16eh
-011C  88 C2       mov dl,al
-011E  8B 7E 04    mov di,word ptr [bp+4h]
-0121  88 15       mov byte ptr [di],dl
-0123  5F          pop di
-0124  5B          pop bx
-0125  58          pop ax
-0126  89 EC       mov sp,bp
-0128  5D          pop bp
-0129  C3          ret
-012A  55          push bp
-012B  8B EC       mov bp,sp
-012D  50          push ax
-012E  53          push bx
-012F  51          push cx
-0130  57          push di
-0131  E8 3A 00    call 16eh
-0134  31 C9       xor cx,cx
-0136  3C 54       cmp al,54h
-0138  74 12       je 14ch
-013A  3C 74       cmp al,74h
-013C  74 0E       je 14ch
-013E  3C 59       cmp al,59h
-0140  74 0A       je 14ch
-0142  3C 79       cmp al,79h
-0144  74 06       je 14ch
-0146  3C 31       cmp al,31h
-0148  74 02       je 14ch
-014A  EB 01       jmp 14dh
-014C  41          inc cx
-014D  8B 7E 04    mov di,word ptr [bp+4h]
-0150  89 0D       mov word ptr [di],cx
-0152  5F          pop di
-0153  59          pop cx
-0154  5B          pop bx
-0155  58          pop ax
-0156  89 EC       mov sp,bp
-0158  5D          pop bp
-0159  C3          ret
-015A  50          push ax
-015B  E8 10 00    call 16eh
-015E  3C 0D       cmp al,0dh
-0160  74 0A       je 16ch
-0162  3C 0A       cmp al,0ah
-0164  74 06       je 16ch
-0166  3C 1A       cmp al,1ah
-0168  74 02       je 16ch
-016A  EB EF       jmp 15bh
-016C  58          pop ax
-016D  C3          ret
-016E  8B 1E AD 02 mov bx,word ptr [2adh]
-0172  83 FB 00    cmp bx,0h
-0175  74 09       je 180h
-0177  8A C3       mov al,bl
-0179  31 DB       xor bx,bx
-017B  89 1E AD 02 mov word ptr [2adh],bx
-017F  C3          ret
-0180  B4 08       mov ah,8h
-0182  CD 21       int 21h
-0184  C3          ret
-0185  88 C2       mov dl,al
-0187  BB 01 00    mov bx,1h
-018A  8A DA       mov bl,dl
-018C  89 1E AD 02 mov word ptr [2adh],bx
-0190  C3          ret
+0061  55          push bp
+0062  8B EC       mov bp,sp
+0064  8A 46 04    mov al,byte ptr [bp+4h]
+0067  89 EC       mov sp,bp
+0069  5D          pop bp
+006A  B4 02       mov ah,2h
+006C  CD 21       int 21h
+006E  C3          ret
+006F  55          push bp
+0070  8B EC       mov bp,sp
+0072  83 7E 04 00 cmp word ptr [bp+4h],0h
+0076  89 EC       mov sp,bp
+0078  75 05       jne 7fh
+007A  BA A2 02    mov dx,2a2h
+007D  EB 03       jmp 82h
+007F  BA 9D 02    mov dx,29dh
+0082  B4 09       mov ah,9h
+0084  CD 21       int 21h
+0086  5D          pop bp
+0087  C3          ret
+0088  BA AB 02    mov dx,2abh
+008B  B4 09       mov ah,9h
+008D  CD 21       int 21h
+008F  C3          ret
+0090  BA A8 02    mov dx,2a8h
+0093  B4 09       mov ah,9h
+0095  CD 21       int 21h
+0097  C3          ret
+0098  5B          pop bx
+0099  31 C9       xor cx,cx
+009B  8A 0F       mov cl,byte ptr [bx]
+009D  43          inc bx
+009E  B4 02       mov ah,2h
+00A0  E3 07       jcxz 0a9h
+00A2  8A 07       mov al,byte ptr [bx]
+00A4  CD 21       int 21h
+00A6  43          inc bx
+00A7  E2 F9       loop 0a2h
+00A9  FF E3       jmp bx
+00AB  55          push bp
+00AC  8B EC       mov bp,sp
+00AE  50          push ax
+00AF  53          push bx
+00B0  51          push cx
+00B1  52          push dx
+00B2  57          push di
+00B3  E8 BC 00    call 172h
+00B6  3C 1A       cmp al,1ah
+00B8  74 54       je 10eh
+00BA  3C 20       cmp al,20h
+00BC  76 F5       jbe 0b3h
+00BE  31 C9       xor cx,cx
+00C0  3C 2D       cmp al,2dh
+00C2  75 06       jne 0cah
+00C4  41          inc cx
+00C5  E8 AA 00    call 172h
+00C8  EB 07       jmp 0d1h
+00CA  3C 2B       cmp al,2bh
+00CC  75 03       jne 0d1h
+00CE  E8 A1 00    call 172h
+00D1  31 FF       xor di,di
+00D3  3C 30       cmp al,30h
+00D5  72 18       jb 0efh
+00D7  3C 39       cmp al,39h
+00D9  77 14       jnbe 0efh
+00DB  2C 30       sub al,30h
+00DD  B4 00       mov ah,0h
+00DF  87 C7       xchg di,ax
+00E1  BB 0A 00    mov bx,0ah
+00E4  F7 E3       mul bx
+00E6  03 C7       add ax,di
+00E8  89 C7       mov di,ax
+00EA  E8 85 00    call 172h
+00ED  EB E4       jmp 0d3h
+00EF  E8 97 00    call 189h
+00F2  83 F9 02    cmp cx,2h
+00F5  74 0E       je 105h
+00F7  83 F9 00    cmp cx,0h
+00FA  74 02       je 0feh
+00FC  F7 DF       neg di
+00FE  8B C7       mov ax,di
+0100  8B 7E 04    mov di,word ptr [bp+4h]
+0103  89 05       mov word ptr [di],ax
+0105  5F          pop di
+0106  5A          pop dx
+0107  59          pop cx
+0108  5B          pop bx
+0109  58          pop ax
+010A  89 EC       mov sp,bp
+010C  5D          pop bp
+010D  C3          ret
+010E  5F          pop di
+010F  5A          pop dx
+0110  59          pop cx
+0111  5B          pop bx
+0112  58          pop ax
+0113  89 EC       mov sp,bp
+0115  5D          pop bp
+0116  C3          ret
+0117  55          push bp
+0118  8B EC       mov bp,sp
+011A  50          push ax
+011B  53          push bx
+011C  57          push di
+011D  E8 52 00    call 172h
+0120  88 C2       mov dl,al
+0122  8B 7E 04    mov di,word ptr [bp+4h]
+0125  88 15       mov byte ptr [di],dl
+0127  5F          pop di
+0128  5B          pop bx
+0129  58          pop ax
+012A  89 EC       mov sp,bp
+012C  5D          pop bp
+012D  C3          ret
+012E  55          push bp
+012F  8B EC       mov bp,sp
+0131  50          push ax
+0132  53          push bx
+0133  51          push cx
+0134  57          push di
+0135  E8 3A 00    call 172h
+0138  31 C9       xor cx,cx
+013A  3C 54       cmp al,54h
+013C  74 12       je 150h
+013E  3C 74       cmp al,74h
+0140  74 0E       je 150h
+0142  3C 59       cmp al,59h
+0144  74 0A       je 150h
+0146  3C 79       cmp al,79h
+0148  74 06       je 150h
+014A  3C 31       cmp al,31h
+014C  74 02       je 150h
+014E  EB 01       jmp 151h
+0150  41          inc cx
+0151  8B 7E 04    mov di,word ptr [bp+4h]
+0154  89 0D       mov word ptr [di],cx
+0156  5F          pop di
+0157  59          pop cx
+0158  5B          pop bx
+0159  58          pop ax
+015A  89 EC       mov sp,bp
+015C  5D          pop bp
+015D  C3          ret
+015E  50          push ax
+015F  E8 10 00    call 172h
+0162  3C 0D       cmp al,0dh
+0164  74 0A       je 170h
+0166  3C 0A       cmp al,0ah
+0168  74 06       je 170h
+016A  3C 1A       cmp al,1ah
+016C  74 02       je 170h
+016E  EB EF       jmp 15fh
+0170  58          pop ax
+0171  C3          ret
+0172  8B 1E B1 02 mov bx,word ptr [2b1h]
+0176  83 FB 00    cmp bx,0h
+0179  74 09       je 184h
+017B  8A C3       mov al,bl
+017D  31 DB       xor bx,bx
+017F  89 1E B1 02 mov word ptr [2b1h],bx
+0183  C3          ret
+0184  B4 08       mov ah,8h
+0186  CD 21       int 21h
+0188  C3          ret
+0189  88 C2       mov dl,al
+018B  BB 01 00    mov bx,1h
+018E  8A DA       mov bl,dl
+0190  89 1E B1 02 mov word ptr [2b1h],bx
+0194  C3          ret