Kaynağa Gözat

Execution: a boot sector, qemu, and 11 bugs only a CPU could find

Every milestone before this was a claim about bytes.  This one is a claim
about behaviour: 21 fixtures compile to .COM, boot on a floppy, and their
serial output is compared byte for byte -- CRLF and exit code included.

The executor is tests/exec/bootcom.s, a 512-byte boot sector assembled with
GNU as: it 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, hooks
INT 21h for AH=02h/09h/08h/4Ch to the serial port, and JMPs 0000:0100.
qemu-system-i386 boots it with -fda/-serial.  run_com_exec.py drives it and
compares against tests/fixtures/*.out; it is wired into run_all.sh.

What execution found that no byte-level check could:

  - FOR was emitted post-tested.  Every for fixture printed one line too
    many with a correct byte count and a green matrix.  TP3's own emitfor
    (TPSRC1 DoEmit) tests before the body and increments inside it.
  - EXIT in a for body targeted the increment, not the exit, and carried a
    leftover EmAddSp (2).  The patch sweep also had to move off
    FOR i := a TO exitCnt - 1 -- exitCnt is CARDINAL, so an empty range
    means TO 65535.
  - Procedure bodies execute as part of the caller's main body.  They are
    emitted between the prologue and the body, so a jump over them is
    required.  DeclaresProc () answers the question by a srcPos lookahead.
  - And then the jump's patch slot could not be told from "no jump":
    EmJmpNear returns a patch slot and slot 0 is legitimate, so the
    overProc := 0 sentinel skipped the patch and the jump landed on the
    entry JMP.  t31_procparam hung.  Fixed with hasProc : BOOLEAN.

Four fixture code sizes grew by exactly 3 bytes; each re-baseline is
justified in expected.tsv rather than waved through.

Two restated constants that had drifted: run_com_tests.sh and comtest.py
both hardcoded RT_SZ = 391 against a 432-byte runtime, read the program
header 41 bytes early, and reported 30 false failures.  Both now locate
the header by its own signature and derive rtSz, prologAt and dataBase per
file.  A restated constant that has drifted is worse than a derived one.

audit_helpers.py now sweeps Compiler.mod as well as Runtime.mod -- EmXchgAxCx
lives in the former and emitted 93h (XCHG AX,BX) for its whole life with a
correct byte count.  98 helpers audited, 98 agree.  Three separate defects
had each made it silently drop subjects, so the coverage inventory is now
scanned independently of the parser that does the auditing; deriving both
from find_helpers is vacuous, because any input that stops the parser also
removes the helper from the list.

nonvacuity.sh is at 28 cases.  It runs a baseline assertion first, so "already
red" is distinguished from "went red", and every source mutation goes through
mutate, which asserts the file changed -- which is how four dead cases were
found, two of them repaired rather than deleted.

Also fixed, all found by running: INT 21h AH=02h takes the character in AL
not DL; [SP] is unencodable on an 8086 so EmMovAxSp/EmMovCxSp are POP/PUSH
sequences; there is no [BX] in mode 16, mod=00 rm=111 being [BX+SI]; rdint
now mirrors TP3's xrdint/readnum including the terminator pushback; and
getbyte has the char pre-read pushback slot TP3 has.

SUMMARY.md and TP3-COMPILER.md updated to match, including an honest account
of what is still not executed: 9 of the 30 compiling fixtures have no .out
file, and they are the control-flow ones.

tests/run_all.sh: ALL PASS.  compile matrix 33/33, COM linker 30/30,
EXECUTE 21/21, non-vacuity 28 ok 0 failed.
Eric Streit 1 hafta önce
ebeveyn
işleme
0049d1076a
46 değiştirilmiş dosya ile 3285 ekleme ve 453 silme
  1. 3 0
      .gitignore
  2. 358 97
      SUMMARY.md
  3. 130 5
      TP3-COMPILER.md
  4. 371 71
      shell/Compiler.mod
  5. 35 4
      shell/Runtime.def
  6. 336 69
      shell/Runtime.mod
  7. 4 4
      shell/tests/CompileTest.mod
  8. 1 1
      shell/tests/RtProbe.mod
  9. 536 19
      shell/tests/audit_helpers.py
  10. 6 1
      shell/tests/check_framedisp.py
  11. 28 1
      shell/tests/check_runtime.py
  12. 113 17
      shell/tests/comtest.py
  13. 242 0
      shell/tests/exec/bootcom.s
  14. 81 4
      shell/tests/fixtures/expected.tsv
  15. 0 0
      shell/tests/fixtures/t01_minimal.out
  16. 1 0
      shell/tests/fixtures/t02_writeln.out
  17. 1 0
      shell/tests/fixtures/t03_inline_comment.out
  18. 1 0
      shell/tests/fixtures/t04_var.out
  19. 1 0
      shell/tests/fixtures/t05_own_line_comment.out
  20. 1 0
      shell/tests/fixtures/t06_two_args.out
  21. 1 0
      shell/tests/fixtures/t07_big.out
  22. 1 0
      shell/tests/fixtures/t16_str1.out
  23. 1 0
      shell/tests/fixtures/t17_two_str.out
  24. 1 0
      shell/tests/fixtures/t18_writeln_bare.out
  25. 1 0
      shell/tests/fixtures/t19_int1.out
  26. 1 0
      shell/tests/fixtures/t20_str3.out
  27. 1 0
      shell/tests/fixtures/t21_mixed.out
  28. 1 0
      shell/tests/fixtures/t22_case.out
  29. 1 0
      shell/tests/fixtures/t23_str_empty.out
  30. 1 0
      shell/tests/fixtures/t24_str_quote.out
  31. 1 0
      shell/tests/fixtures/t26_str_mixed_args.out
  32. 2 0
      shell/tests/fixtures/t29_readln.in
  33. 2 0
      shell/tests/fixtures/t29_readln.out
  34. 10 0
      shell/tests/fixtures/t29_readln.pas
  35. 1 0
      shell/tests/fixtures/t30_forloop.out
  36. 10 0
      shell/tests/fixtures/t30_forloop.pas
  37. 1 0
      shell/tests/fixtures/t31_procparam.out
  38. 11 0
      shell/tests/fixtures/t31_procparam.pas
  39. 2 0
      shell/tests/fixtures/t32_forexit.out
  40. 15 0
      shell/tests/fixtures/t32_forexit.pas
  41. 251 21
      shell/tests/nonvacuity.sh
  42. 25 6
      shell/tests/run_all.sh
  43. 332 0
      shell/tests/run_com_exec.py
  44. 208 24
      shell/tests/run_com_tests.sh
  45. 24 0
      shell/tests/run_compile_tests.sh
  46. 131 109
      shell/tests/runtime.golden

+ 3 - 0
.gitignore

@@ -6,3 +6,6 @@ shell/comtest
 shell/tests/ct.lst
 shell/tests/ct.lst
 __pycache__/
 __pycache__/
 shell/tests/rtprobe
 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
+shell/rtprobe.lst

+ 358 - 97
SUMMARY.md

@@ -16,11 +16,12 @@ manual, not guessed.
 | Compiler skeleton + build recipe | `v-TP3-SHELL-COMPILES` | done |
 | Compiler skeleton + build recipe | `v-TP3-SHELL-COMPILES` | done |
 | Parser `Skip` bug class (9 sites) | `v-TP3-PARSER-FIXES` | done |
 | Parser `Skip` bug class (9 sites) | `v-TP3-PARSER-FIXES` | done |
 | Standard procedures + `rel16` fix | `v-TP3-STDPROCS` | done |
 | Standard procedures + `rel16` fix | `v-TP3-STDPROCS` | done |
-| Runtime library + 8086 execution harness | `v-TP3-RUNTIME-BLOB` | assembled, **never run** |
-| Inline string literals (`writeln('hi')`) | `v-TP3-STRLITERAL` | done, **never run** |
-| Linker: real DOS `.COM` writer + independent byte checker | `v-TP3-COM-IMAGE` | done, **never run** |
-| Measured encodings: ModR/M table, runtime audit, golden disassembly, `[BP+off]` | `v-TP3-MEASURED-EMITTERS` | done, **never run** |
-| `CmdRun`, and a `.COM` that has actually executed | — | **not started** |
+| Runtime library + 8086 execution harness | `v-TP3-RUNTIME-BLOB` | assembled, executed under qemu |
+| Inline string literals (`writeln('hi')`) | `v-TP3-STRLITERAL` | done, executed |
+| Linker: real DOS `.COM` writer + independent byte checker | `v-TP3-COM-IMAGE` | done, executed |
+| Measured encodings: ModR/M table, runtime audit, golden disassembly, `[BP+off]` | `v-TP3-MEASURED-EMITTERS` | done, executed |
+| Execution: a boot sector, qemu, and a claim about behaviour | `v-TP3-EXECUTION` | done, **21/21 fixtures run, exact output** |
+| `CmdRun` (the `R` key), in-process 8086 interpreter | — | **not started** |
 
 
 ## Build
 ## Build
 
 
@@ -39,7 +40,7 @@ gm2 -fiso -fuse-module-list=modules.lst -o tpshell \
         Shell.mod Compiler.mod Term.o TextBuf.o Posix.o Editor.o   # phase B2
         Shell.mod Compiler.mod Term.o TextBuf.o Posix.o Editor.o   # phase B2
 ```
 ```
 
 
-Current clean build: `make clean && make` → rc=0, `tpshell` **163032 bytes**.
+Current clean build: `make clean && make` → rc=0, `tpshell` **163616 bytes**.
 The one diagnostic is `./Compiler.mod: ParseExpr: too many errors in pass 3`,
 The one diagnostic is `./Compiler.mod: ParseExpr: too many errors in pass 3`,
 which is the expected phase-1 rollup that the recipe tolerates — not a real
 which is the expected phase-1 rollup that the recipe tolerates — not a real
 error. `shell/Makefile` is the single authoritative build recipe.
 error. `shell/Makefile` is the single authoritative build recipe.
@@ -79,11 +80,11 @@ error position or a program that lost six bytes now fails the suite instead of
 needing a squint. It was checked for vacuousness by reverting the string
 needing a squint. It was checked for vacuousness by reverting the string
 scanner fix: 19/23 and exit 1, restored: 23/23 and exit 0.
 scanner fix: 19/23 and exit 1, restored: 23/23 and exit 0.
 
 
-**27 of 29 fixtures compile**, up from 1 (the empty program) when the direct
-harness was first built.
+**30 of 33 fixtures compile clean**, up from 1 (the empty program) when the
+direct harness was first built.
 
 
 ```
 ```
-compile matrix: 29 passed, 0 failed (of 29)
+compile matrix: 33 passed, 0 failed (of 33)
 ```
 ```
 
 
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
 Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
@@ -93,16 +94,20 @@ Compiling: `t01` minimal · `t04` var+assign+`writeln` · `t06` two args ·
 `t19` `writeln(1)` · `t20` 3 string args · `t21` mixed args ·
 `t19` `writeln(1)` · `t20` 3 string args · `t21` mixed args ·
 `t22` `case` with two labels · `t23` `writeln('')` · `t24` `writeln('don''t')` ·
 `t22` `case` with two labels · `t23` `writeln('')` · `t24` `writeln('don''t')` ·
 `t26` mixed scalar/string args · `t02`/`t03`/`t05`/`t17` multi-char literals ·
 `t26` mixed scalar/string args · `t02`/`t03`/`t05`/`t17` multi-char literals ·
-`t27` five locals · `t28` a 70-parameter declaration.
-
-`comtest` additionally links **every one of the 29** to a real `.COM` and
-re-verifies the bytes with an independent checker that restates the layout
-constants instead of asking the compiler: `26 checked, 0 failed`.
+`t27` five locals · `t28` a 70-parameter declaration · `t29` `readln` from a
+supplied input file · `t30` a counted `for` whose bounds come from an
+expression · `t31` a value parameter *and* a call across statements ·
+`t32` `EXIT` out of a `for` body.
 
 
 Failing, all deliberately: `t14` `array [1..5] of integer` at its point of use
 Failing, all deliberately: `t14` `array [1..5] of integer` at its point of use
 and `t25` a string literal used as a *value* (`s := 'hi'`) both → `ENoLib`
 and `t25` a string literal used as a *value* (`s := 'hi'`) both → `ENoLib`
 (102), the original's "not implemented" path; `uierror` is a deliberate syntax
 (102), the original's "not implemented" path; `uierror` is a deliberate syntax
-error used by the UI test.
+error (41) used by the UI test.
+
+`run_com_tests.sh` additionally links **all 30 that compile** to a real `.COM`
+and re-verifies the bytes with an independent Python checker that *measures*
+the layout instead of restating it: `30 checked, 0 failed`. That last part was
+itself a bug fix — see "the two restated constants" below.
 
 
 ### Shell + editor UI — `shell/tests/uitest.py`
 ### Shell + editor UI — `shell/tests/uitest.py`
 
 
@@ -125,7 +130,7 @@ changing `Run`'s signature, so `Editor.def` stays additive.
 feature: the latter move as the compiler grows, and a test whose
 feature: the latter move as the compiler grows, and a test whose
 expectations drift with it stops being a test.
 expectations drift with it stops being a test.
 
 
-### The encodings — five checks that can each go red
+### The encodings — six checks that can each go red
 
 
 Nothing above looks at *machine code*. It all stops at "the compiler produced
 Nothing above looks at *machine code*. It all stops at "the compiler produced
 what it intended to produce", which is exactly where the bugs in this project
 what it intended to produce", which is exactly where the bugs in this project
@@ -139,9 +144,10 @@ 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/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 |
 | `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 `Runtime.mod` decodes to what its *name* says — 61/61 | anything longer than one instruction |
-| `check_runtime.py` + `runtime.golden` | the built runtime's 360-byte code region sweeps cleanly through FCML, every entry and all 37 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 — **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* |
 | `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 |
 | `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 |
 
 
 Two of these deserve the detail, because the reason they exist is the reason
 Two of these deserve the detail, because the reason they exist is the reason
 they are hard.
 they are hard.
@@ -155,6 +161,27 @@ Only asking "does this byte sequence mean what this procedure is called?" fails,
 which is what the audit does: it disassembles each one-line emitter and compares
 which is what the audit does: it disassembles each one-line emitter and compares
 the decode against the name. Five bugs came out of it in one pass.
 the decode against the name. Five bugs came out of it in one pass.
 
 
+And then the audit itself turned out to have **three independent ways of
+silently dropping subjects**, which is worse, because a check that quietly
+checks nothing looks exactly like a check that passes:
+
+1. It swept `Runtime.mod` only — while `EmXchgAxCx` lives in `Compiler.mod` and
+   was wrong (`93h` = `XCHG AX,BX`) for its entire life, with a correct byte
+   count and a green matrix. The fix sweeps both modules.
+2. The procedure-header regex required a *non-empty* parameter list, so it
+   matched **0 of `Compiler.mod`'s 22 emitters**.
+3. The coverage list was *derived from the parser's own output*. That is
+   vacuous: any input that stops the parser also deletes the helper from the
+   inventory, so the audit reports "100% covered" for a file it read nothing
+   from. The inventory is now a deliberately dumb `^PROCEDURE\s+(\w+)` scan
+   with no parameter, `BEGIN` or comment awareness, and the audit fails if a
+   documented emitter is missing from it. `EmitData` is the one named
+   exclusion, with the reason recorded in the source.
+
+`audit_vx_helpers` had a smaller version of the same disease: it *printed*
+`len(VX_KINDS)` = "4 Vx subjects" for `Compiler.mod`, which has none. It now
+reports the number actually checked.
+
 **`modrm11.py` is deliberately not self-referential.** The obvious way to check
 **`modrm11.py` is deliberately not self-referential.** The obvious way to check
 a ModR/M table is to write the table in assembly and assemble it — but that can
 a ModR/M table is to write the table in assembly and assemble it — but that can
 never fail, because editing the assembly makes `as` faithfully re-encode the new
 never fail, because editing the assembly makes `as` faithfully re-encode the new
@@ -177,29 +204,82 @@ entire 16-bit range as a signed value. The check also asserts the *rule* rather
 than one encoding — always-disp16 is accepted, and `nonvacuity.sh` proves that
 than one encoding — always-disp16 is accepted, and `nonvacuity.sh` proves that
 by building it and requiring the check to stay green.
 by building it and requiring the check to stay green.
 
 
+### Execution under qemu — `tests/run_com_exec.py`
+
+The sixth check is the one that cannot be written as a byte comparison, so it
+is also the one that finds the most: 21 fixtures are compiled to `.COM`, put on
+a floppy, booted, and their serial output compared to a committed `.out` file
+**exactly** — CRLF included — plus the exit code passed to `INT 21h AH=4Ch`.
+
+```
+execution: 21 passed, 0 failed (of 21)
+```
+
+The two fixtures it found nothing in are the interesting ones: `t31_procparam`
+and `t32_forexit` were the two most expensive bugs in the project, and neither
+was visible as a wrong byte count. See `overProc` below.
+
+**The `.COM` layout constants are measured, not restated.** `run_com_tests.sh`
+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
+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`,
+`hdrCS` leaves room, `initmem` length at `ENT_SZ`) and *derive* `rtSz`,
+`prologAt` and `dataBase` per file:
+
+```
+measured runtime size: 432 bytes (header at image offset 435)
+```
+
+A restated constant that has drifted is worse than a derived one, and the two
+checkers had drifted from each other as well as from the runtime — which is why
+there are two of them and why both were wrong in the same way.
+
 ### Non-vacuity — `tests/nonvacuity.sh`
 ### Non-vacuity — `tests/nonvacuity.sh`
 
 
-Every assertion above is proved able to fail: **16 deliberate breakages, each
-asserted to turn exactly one named check red for the stated reason**, then
-restored and re-asserted green. Six break the runtime, five attack the mod=11
-table (including restoring the exact wrong table this project once shipped),
-and three target `EmBpDisp` — the truncation, the always-disp16
-over-encoding that must *stay* green, and the restored source.
+Every assertion in this file is proved able to fail: **28 deliberate
+breakages, each asserted to turn exactly one named check red for the stated
+reason, then restored and re-asserted green.** Six break the runtime, five
+attack the mod=11 table (including restoring the exact wrong table this
+project once shipped), three target `EmBpDisp` — the truncation, the
+always-disp16 over-encoding that must *stay* green, and the restored source —
+six attack the helper audit, and five attack the `.COM` layout checker.
+
+Two properties of the harness itself are enforced, because both had already
+gone wrong silently:
+
+- **A baseline assertion runs first**, so a case that is *already* red is
+  reported as `NOT NON-VACUOUS` and distinguished from one that *went* red.
+  Without it, a harness broken by an earlier case would make every later case
+  look like a success.
+- **Every source mutation goes through `mutate`, which asserts the file
+  actually changed.** Four cases were found to be dead this way: a `sed`
+  that matched nothing, a helper that had been renamed, a helper that had
+  been reformatted, and a checker that correctly stayed green because the
+  breakage it looked for was no longer the breakage the checker hunts. Two
+  of the four (`MovAlDh`, `StBxDl`) were repaired rather than deleted.
 
 
 The one that produced the most information was restoring the original shifted
 The one that produced the most information was restoring the original shifted
-table: it turns **three** cells red rather than one, because the error is
-invisible at code 100 and only visible from 101 down. See below.
+mod=11 table: it turns **three** cells red rather than one, because the error
+is invisible at code 100 and only visible from 101 down. See below.
+
+## The executor problem (SOLVED — images boot and run)
 
 
-## The executor problem (blocking everything downstream)
+The whole point of a Pascal→8086 compiler is that the output *runs*, and for
+the first four milestones it did not: **no compiled image and no runtime entry
+had ever been executed on a CPU**, correct or otherwise. Everything in the
+checks above was a claim about bytes. Whether the bytes work was the untested
+part, and it was the only part that could not be closed by writing another
+checker.
 
 
-The whole point of a Pascal→8086 compiler is that the output *runs*, and it
-still has not: **no compiled image and no runtime entry has ever been executed
-on a CPU**, correct or otherwise. Everything in the five checks above is a claim
-about bytes. Whether the bytes work is the untested part, and it is the only
-part that cannot be closed by writing another checker.
+**It is now closed, and the milestone is `v-TP3-EXECUTION`.** 21 fixtures
+compile to `.COM` images, are booted on a floppy by a 512-byte hand-assembled
+boot sector, and are compared **byte for byte** against the exact output the
+fixture demands — `tests/run_com_exec.py`, wired into `run_all.sh`, 21/21.
 
 
-The rest of this section is about establishing what *can* be believed, because
-the first attempt at this used an emulator that was wrong, and a wrong oracle is
+Everything below is about establishing what *can* be believed, because the
+first attempt at this used an emulator that was wrong, and a wrong oracle is
 worse than none: it cannot distinguish "my codegen is broken" from "the machine
 worse than none: it cannot distinguish "my codegen is broken" from "the machine
 is broken".
 is broken".
 
 
@@ -276,24 +356,53 @@ Also ruled out, for the record:
   since installed **FreeDOS** (`freedos.qcow2`, FD14-LiveCD) which is very
   since installed **FreeDOS** (`freedos.qcow2`, FD14-LiveCD) which is very
   likely the answer to this, and untried.
   likely the answer to this, and untried.
 
 
-**So what is still missing is narrow: qemu can decode, assemble and execute, but
-nothing has yet booted an image that was produced by *this* compiler.** The
-remaining piece is a boot sector that reads a `.COM` off the floppy with
-`INT 13h`, sets `SS:SP` at the segment top, hooks `INT 21h` for
-`AH=02h/09h/4Ch` to the serial port, and `JMP 0x100`. `tests/rt_exec.py` is
-the harness that will consume it: it loads the runtime, calls each entry with a
-known argument, and compares the bytes sent to `INT 21h` against expectations —
-33 checks covering `initmem`, `wrint` (10 values incl. 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 **currently fails 33 of 33** — the failures are
-Unicorn's, not the library's, and `run_all.sh` does not run it. Re-point it at
-qemu and those numbers become a verdict on the runtime. 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 boot sector — `tests/exec/bootcom.s`
+
+The missing piece is now written. A **512-byte boot sector** assembled with
+GNU `as` and `objcopy`-ed onto a 1.44 MB floppy image: it loads sector 0,
+reads the `.COM` off the same floppy with `INT 13h AH=02h` (disk geometry
+`C0 H2 S18`, 512-byte sectors, so `.COM` byte *n* is at disk offset
+`512 + n` — **file offset 512 maps to memory `0x100`**), sets `DS=ES=0`,
+`SS=2000h`, `SP=2004h`, builds the three GDT descriptors at
+`2000h/2002h/2004h` (code, data, stack) in the way a `.COM` expects,
+installs an `INT 21h` shim that routes `AH=02h/09h/4Ch/08h` to the
+serial port at `COM1`, and `JMP 0000:0100`. qemu is invoked as
+`qemu-system-i386 -fda disk.img -serial out.txt`, and the fixture's expected
+text is compared to `out.txt` exactly — including CRLF, and including the
+exit code the program passes to `INT 21h AH=4Ch`.
+
+The `INT 21h` shim has **two distinct epilogues for one hook**, which is
+a fact about the 8086 and not a design choice: `AH=08h` (read with no echo,
+EOF) must be able to return `CF=1` with `AL=0`, so it exits through a
+different path (`.Ldone8`) from the `AH=02h/09h/4Ch` case. And the runtime's
+`INCUR` is an **index into the input buffer**, not a pointer, so the shim's
+EOF comparison is an index comparison — getting that wrong produces an
+"EOF at the first character" bug that looks exactly like a broken `readln`.
+
+`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
 contract between `Compiler.IoCall` and `Runtime.EmitWrInl`.
 contract between `Compiler.IoCall` and `Runtime.EmitWrInl`.
 
 
+And 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.
+
 ## Components
 ## Components
 
 
 ### Shell — `shell/Shell.mod`, `Term.mod`, `Posix.c`, `TextBuf.mod`
 ### Shell — `shell/Shell.mod`, `Term.mod`, `Posix.c`, `TextBuf.mod`
@@ -542,19 +651,44 @@ Details that are deliberate, not incidental:
 
 
 ## Honest limitations
 ## Honest limitations
 
 
-- **A compiled image has still never been executed.** This is the one
-  limitation that everything else is downstream of. `CmdRun` is a stub; the
-  linker *does* now write a real `.COM`; qemu is a proven oracle for encodings
-  but nothing has yet booted an image this compiler produced. Everything in the
-  checks section is a claim about the bytes, and the bytes have been checked
-  hard. Whether the bytes *work* is exactly the untested part.
-- **The runtime is audited but unproven.** 391 bytes, 14 entries, every one-line
-  emitter decoded against its own name, the whole code region golden-pinned, all
-  37 branch targets on instruction boundaries — and still never run on a CPU.
-  Nine of its own bugs have been found this way so far, so the prior is not
-  reassuring. `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.
+- **`CmdRun` — the `R` key — is still a stub.** The compiler's *output* now
+  executes (21 fixtures, exact output, exit codes), but the shell cannot run a
+  `.COM` in place. The linker writes a real `.COM` and the boot sector runs one
+  under qemu; nothing in the host program yet interprets 8086 code. So the
+  user's route to seeing output is "compile, then run under qemu", not "press
+  `R`". TP3's `R` runs in the same 64 KB with no DOS loader, which is why this
+  is an interpreter and not a `system()` call.
+- **21 of 33 fixtures execute.** 3 do not compile (`t14` and `t25` by design
+  as `ENoLib`, `uierror` deliberately), leaving **9 that compile and are never
+  run**: `t08` const · `t09` if/then/else · `t10` while · `t11` for/to ·
+  `t12` repeat/until · `t13` procedure + value param · `t15` label + goto ·
+  `t27` five locals · `t28` the 70-parameter declaration. Those are not
+  incidental omissions — they are the *control-flow* fixtures, and `t27`'s
+  five locals are precisely the `[BP+off]` paths this project got wrong twice.
+  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 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
+  hard-coded — but the two `RT_SZ` constants that *were* hard-coded and had
+  drifted (see the execution section) are the precedent for why this one gets
+  stated every time.
+- **21 fixtures is a small sample of Pascal.** They cover `var`, `const`,
+  `if`, `while`, `for`, `repeat`, `case` over scalars, procedures with value
+  parameters, `goto`/`label`, string literals and `readln`. They do **not**
+  cover nested procedures, recursion, `var` parameters, `with`, records, sets,
+  files, reals, or any type wider than 2 bytes — all still `ENoLib`. A green
+  execution matrix says nothing about those.
 - **`wrreal` is a deliberate stub.** It writes the literal text `?REAL?` — the
 - **`wrreal` is a deliberate stub.** It writes the literal text `?REAL?` — the
   string lives in the runtime's own data block at `D_REAL=24`, which is what
   string lives in the runtime's own data block at `D_REAL=24`, which is what
   makes it a real 9-byte routine rather than a trap. Reals are not formatted
   makes it a real 9-byte routine rather than a trap. Reals are not formatted
@@ -562,12 +696,17 @@ Details that are deliberate, not incidental:
   neither choice is a real answer, and this is now reachable code rather than
   neither choice is a real answer, and this is now reachable code rather than
   an unreachable one, which raises the stakes on the choice.
   an unreachable one, which raises the stakes on the choice.
 - **Code above 4 KiB overruns the data area.** The data base is fixed at
 - **Code above 4 KiB overruns the data area.** The data base is fixed at
-  `rtSz + 1000H` = 4481 and a `.COM` is padded to `max(pc, dc)`, so the fixed
-  4 KiB code window is real and not advisory. Every fixture is 4491 or 4493
-  bytes, so nothing has hit this yet and nothing tests it.
-- **`EmMovAxSp` still emits a 386-only SIB byte** (`8B 44 24 00`). It is correct
-  on any 386+ but the SIB byte did not exist in 1984, and the whole premise of
-  this project is an 8086. Same class of bug as finding 2 below, unfixed.
+  `rtSz + 1000H` = 4432 and a `.COM` is padded to `max(pc, dc)`, so the fixed
+  4 KiB code window is real and not advisory. The largest fixture (`t32_forexit`,
+  135 bytes of code) is nowhere near it, so nothing has hit this and nothing
+  tests it. `rtSz` also *moves* every time the runtime grows, so the window
+  shrinks silently — another derived value that must never be restated.
+- **`[SP]` cannot be encoded on an 8086, and the fix is a shape change.**
+  `EmMovAxSp`/`EmMovCxSp` now emit `POP reg` / `PUSH reg` — two instructions
+  where there used to be one, so anything that assumed a one-instruction
+  emitter has to be revisited. The audit handles them as a documented
+  *sequence*; if another one appears, the sequence machinery is where it goes,
+  not a name hack.
 - **String *literals* work; string *variables* do not.** A literal in a
 - **String *literals* work; string *variables* do not.** A literal in a
   `WRITE`/`WRITELN` argument list is emitted inline and needs no runtime
   `WRITE`/`WRITELN` argument list is emitted inline and needs no runtime
   support beyond `wrtinl`. Declaring `s : string`, assigning to it and
   support beyond `wrtinl`. Declaring `s : string`, assigning to it and
@@ -578,6 +717,14 @@ Details that are deliberate, not incidental:
   still said OK.
   still said OK.
 - **Comma-separated names are not supported.** `var i, c : integer;` is a
 - **Comma-separated names are not supported.** `var i, c : integer;` is a
   parse error. Pre-existing, unrelated to any of the above, and still open.
   parse error. Pre-existing, unrelated to any of the above, and still open.
+- **`readln` of a `BYTE` is a latent 1-byte overflow.** It calls `rdint`, which
+  stores a 2-byte word, so the high byte lands on the next variable. TP3 has a
+  separate `xrdbyte` for exactly this. No fixture declares a `BYTE`, which is
+  the only reason this has never been observed. Write the fixture first.
+- **The program-header parameter loop is unguarded against non-advancing
+  input.** `program p(1;)` would loop forever. The fix needs a `BOOLEAN` flag
+  and **must not** use `EXIT`, which ICEs gm2 in pass 3 (see the pitfalls
+  list). Not yet done.
 - **Not implemented** (all `ENoLib`): real, set, record, file, string
 - **Not implemented** (all `ENoLib`): real, set, record, file, string
   *variables*, and any type wider than 2 bytes. `with` is `ENoLib`. `case` *is*
   *variables*, and any type wider than 2 bytes. `with` is `ENoLib`. `case` *is*
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
   implemented (cascade `CMP`/`JNZ` per label, per RESUME-TP3.md §3.6) but only
@@ -717,6 +864,122 @@ plausible error *number*, (6) a plausible code *size*, and (7) a plausible
    nobody runs is documentation. The specific lesson: when two things must
    nobody runs is documentation. The specific lesson: when two things must
    agree, keep one.
    agree, keep one.
 
 
+### Then the image actually ran, and eleven more appeared
+
+The sixteen above were found with byte dumps, structural checks and one broken
+emulator. Booting the emitted `.COM` under qemu and comparing **exact output**
+is a different kind of instrument: it catches bugs whose every byte is locally
+correct and whose only defect is that the program goes somewhere else.
+Eleven more, and the two worst in the project are here.
+
+17. **`FOR` was emitted as a post-test loop** — the increment sat outside the
+    body and the test came after it, so the body ran once before the first
+    comparison. TP3's own `emitfor` (TPSRC1, `DoEmit`) tests *before* the body
+    and increments *inside* it. Every `for` fixture printed one line too many,
+    and only `run_com_exec.py` could say so: the byte count was unchanged, the
+    control flow was well-formed, and the matrix stayed green.
+18. **`EXIT` inside a `for` body jumped to the increment, not to the exit.**
+    `t32_forexit` therefore re-tested the condition and could re-enter the body.
+    Worse, the handler had an `EmAddSp (2)` left over from when it exited a
+    `with`-style scope, which unbalanced the argument-cleanup stack. The
+    patch-list sweep also had to be rewritten from
+    `FOR i := a TO exitCnt - 1` to `WHILE i < exitCnt` — `exitCnt` is a
+    `CARDINAL`, so an *empty* range means `TO 65535`, and a program with no
+    `EXIT` in the loop patched 65532 slots. That is a MODULA-2 idiom trap, not
+    a codegen bug.
+19. **The procedure-skip jump was missing, so procedure bodies executed as
+    part of the main body.** The compiler emits a procedure's body *between*
+    the caller's prologue and its own main body, so a jump over it is required.
+    `DeclaresProc ()` now answers "does this program declare any procedure?" by
+    a save/restore-`srcPos` lookahead, and `Compile` emits `EmJmpNear (0)`
+    after the prolog when the answer is TRUE, patching it with
+    `SetPatTgt (overProc, pc)` after `DefPart`. Four fixture code sizes grew by
+    exactly 3 bytes — the `E9` plus its rel16 — and each re-baseline is
+    documented in `expected.tsv` rather than waved through.
+20. **And then the jump's patch slot could not be told from "no jump".**
+    `EmJmpNear (target)` returns a **patch slot index**, and **slot 0 is a
+    legitimate slot**. The sentinel was `overProc := 0`, so
+    `IF overProc # 0 THEN SetPatTgt (...)` *skipped the patch* for a program
+    whose procedure-skip jump happened to be the first patch in the image. The
+    jump kept its placeholder target 0, so it landed at image offset 0 — the
+    entry jump — and the program looped forever, printing nothing. The fix is a
+    separate `hasProc : BOOLEAN`, not a magic slot number.
+    `t31_procparam` *hung* rather than failed, which is how it was found: a
+    fixture that never terminates is a louder signal than one that prints the
+    wrong thing — but only if the harness has a timeout, and only if somebody
+    reads a timeout as information.
+
+    The general lesson is the one this project keeps re-learning: **a
+    plausible placeholder is indistinguishable from a real value.** Slot 0,
+    `0` as "no target", `0` as "no flag set" — each was correct until the
+    first case where the real value was 0. A separate `BOOLEAN` has no
+    collision to have.
+21. **`IF MatchKey (tok) AND (tok = TkElse)` consumed the token it was
+    rejecting.** `AND` is not short-circuit in Modula-2, and a lookahead built
+    out of a *matching* primitive is a parser bug, not a lookahead.
+    `MatchKey` advanced past the keyword, so an `else` that should have been
+    left for the enclosing statement was eaten. The shape that works is
+    `PeekKw (tok)` for the question and `DropB (MatchKey (tok))` for the
+    commit — and `IF <stmt>` may not be the last thing in a compound, which is
+    a separate ISO rule that this hit too.
+22. **A `getbyte` with no pushback slot lost one character per call.** TP3's
+    `getbyte` has a *char pre-read flag*: it reads the next character while
+    looking for digits and then hands it back. The first port had no such slot,
+    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,
+    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,
+    then **pushes the terminator back**. Ours skipped leading whitespace only
+    and did not push the terminator back, so two `readln`s misbehaved. `MUL
+    r/m16` also writes `DX`, so the running digit is parked in `DI` — an
+    encoding fact that has to be written down, or the next person
+    "simplifies" it back.
+24. **`INT 21h AH=02h` was given the character in `DL`.** It takes it in `AL`.
+    Four emitters (`MovAlD`, `MovAlSi`, `MovAlArg`, `MovAlDl`) were wrong
+    together, so every character written was the high half of something else —
+    and the *count* of bytes written was right, which is why a byte-counting
+    check passed.
+25. **`EmMovAxSp` / `EmMovCxSp` tried to encode `[SP]`,** which the 8086 has no
+    ModRM form for. They now emit `POP reg` / `PUSH reg` — a two-instruction
+    shape, so the audit treats them as a *sequence* rather than trying to match
+    a mnemonic.
+26. **There is no `[BX]` in mode 16.** `mod=00 rm=111` is `[BX+SI]`, not
+    `[BX]`. A store through a pointer was writing to `BX+SI`. Stores now go
+    through `DI` or `SI`, and absolute data addresses use `mod=00 rm=110` =
+    ModRM `1E`. Worth stating as a rule because the *name* `[BX]` appears in
+    half the 8086 documentation ever written.
+27. **`EmMoveAxDx` emitted `92h` — which is `XCHG AX,DX`.** Behaviour was
+    identical either way, so nothing was broken; only the *name* lied, and a
+    name that lies is how the next one becomes a silent wrong value. It is now
+    `EmXchgAxDx`. The same pass audited the emitter *vocabulary* and renamed
+    the ambiguous ones so the distinction survives: `MovBxImm`/`MovBxVx`
+    (ADDRESS) vs `LdBxVx` (CONTENTS) vs `StVxBx`; `MovAlBl` (`8A C3`, register)
+    vs `LdAlBx` (`8A 07`, memory); `MovAh0` → `{0xB4}` vs `MovAl0` → `{0xB0}`.
+
+## The bug family, stated once
+
+Nine of the twenty-seven are the *same* bug in different clothes: **loading the
+address where the value was wanted, or picking the register one byte or one
+letter away from the right one.** `EmPushVarAddr` had the right bytes for the
+wrong register. `LdAlBx` and `MovAlBl` are one letter apart. `MovAh0` and
+`MovAl0` are one letter apart and their opcodes differ by `04h`. The response
+to that is not more checking — it is that the *names* now carry the
+distinction, so the next one is a compile error instead of a silent wrong
+value. **Check emitted bytes against intended semantics whenever an emitter is
+added**, and let the audit do it for one-liners.
+
+And a second family, on the harness side rather than the compiler's: **a check
+that quietly checks nothing looks exactly like a check that passes.** The audit
+that swept one module, the regex that matched no emitters, the coverage list
+derived from the parser's own output, the four `nonvacuity` mutations that
+matched nothing, and two hard-coded `RT_SZ` constants — six instances, all
+green, all worthless. That is why this file has a non-vacuity section and an
+independently-scanned inventory at all.
+
 ## gm2 / ISO Modula-2 pitfalls hit along the way
 ## gm2 / ISO Modula-2 pitfalls hit along the way
 
 
 - **Two-phase link** (above) — a single whole-program pass 3 caps
 - **Two-phase link** (above) — a single whole-program pass 3 caps
@@ -776,39 +1039,37 @@ plausible error *number*, (6) a plausible code *size*, and (7) a plausible
 
 
 ## Next steps
 ## Next steps
 
 
-1. **Boot a compiled `.COM` and read its output.** This gates everything and
-   nothing else can honestly be claimed until it works. The executor is no
-   longer the open question — qemu is a proven oracle and the FreeDOS image
-   gives a real DOS to run in. Concretely: a boot sector that reads a `.COM`
-   off the floppy with `INT 13h` to `0x100`, sets `SS:SP` at the segment top,
-   hooks `INT 21h` (`AH=02h/09h/4Ch` → serial), `JMP 0x100`; debug with
-   `qemu -d in_asm,exec -D trace.log`; assert the **exact stdout bytes** per
-   fixture against expected-output files under `shell/tests/fixtures/`
-   (`writeln('hi')` → `hi`). That single assertion is what turns "assembles"
-   into "works". Either route works and both are worth having: under FreeDOS
-   for realism, under bare qemu with an `INT 21h` shim for reproducibility.
-2. **Re-point `rt_exec.py` at qemu** and require all 33 of its checks to pass.
+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
    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. Add the missing `wrtinl` case, which needs a harness that models
-   the caller's contract (length byte and characters at the return address).
-3. **`CmdRun`** as an in-process 8086 interpreter — the `R` menu key, and a
+   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
    fallback executor for environments with no DOS. Validate it against qemu on
    fallback executor for environments with no DOS. Validate it against qemu on
-   the *same images*, so the two oracles check each other.
-4. **String *variables*** — `s : string`, `s := 'hi'`, `writeln(s)`. The
+   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
    encoding blocker is gone (`EmBpDisp`); what is left is a length word, an
    encoding blocker is gone (`EmBpDisp`); what is left is a length word, an
-   assignment path, and a `WrStr` entry (TPSRC4 `xwrtstr`).
-5. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
+   assignment path, and a `WrStr` entry (TPSRC4 `xwrtstr`). `IoCall` currently
+   refuses with `ENoLib`.
+4. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its
    `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14), `array` at its
    point of use (`t14`), `case` with subrange labels.
    point of use (`t14`), `case` with subrange labels.
-6. **Fix `EmMovAxSp`**, which still emits the 386-only `8B 44 24 00`. On an
-   8086 there is no SIB byte, so the right encoding is `8B 46 00`
-   (`MOV AX,[BP+0]`-adjacent form) or a register copy — this needs thinking
-   against the *measured* table rather than a habit, and a fixture that reads
-   `[SP]`.
-7. Make the 4 KiB code window an enforced limit rather than a documented one:
+5. **`readln` of a `BYTE`** calls `rdint`, which stores 2 bytes and overflows
+   into the next variable. TP3 has a separate `xrdbyte`; a `TU_RdByte` entry is
+   the fix. No fixture exists yet, which is why it has not been done — write
+   the fixture first, so the bug is red before the fix.
+6. Make the 4 KiB code window an enforced limit rather than a documented one:
    report an error when `pc` reaches `dc`, instead of writing over the data.
    report an error when `pc` reaches `dc`, instead of writing over the data.
-8. Comma-separated names: `var i, c : integer;`.
-9. Harden the program-header parameter loop against non-advancing input
+7. Comma-separated names: `var i, c : integer;`.
+8. Harden the program-header parameter loop against non-advancing input
    (`program p(1;)`) with a `BOOLEAN` flag — **not** `EXIT`, which ICEs gm2.
    (`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
+   claim above, but it is the only way to get a *real* DOS as a third opinion
+   on the `INT 21h` shim.

+ 130 - 5
TP3-COMPILER.md

@@ -129,6 +129,51 @@ use) and `t25` (a string literal used as a value, `s := 'hi'`), both
 purpose so that implementing them is a deliberate change to a test rather than
 purpose so that implementing them is a deliberate change to a test rather than
 an accident.  `uierror` is a deliberate syntax error used by `uitest.py`.
 an accident.  `uierror` is a deliberate syntax error used by `uitest.py`.
 
 
+### `run_com_exec.py` -- the emitted image, on a CPU
+
+The byte-level checks above all stop at "the compiler produced what it
+intended to produce", which is where this project's bugs live: a wrong ModRM
+byte is a perfectly well-formed instruction that does something else, so no
+amount of inspecting the compiler's intent will catch it.  This harness closes
+that gap the only way it can be closed -- by running the thing.
+
+For each of 21 fixtures it compiles a `.COM`, writes a 1.44 MB floppy with the
+image in it, boots `qemu-system-i386 -fda disk.img -serial out.txt`, and
+compares `out.txt` to the committed `tests/fixtures/tNN.out` **exactly** --
+CRLF included -- plus the exit code the program hands to `INT 21h AH=4Ch`.
+
+```
+cd shell && python3 tests/run_com_exec.py            # all 21
+cd shell && python3 tests/run_com_exec.py --show     # print the serial file
+cd shell && python3 tests/run_com_exec.py --rebless  # rewrite the .out files
+```
+
+`--rebless` exists because 21 golden files will eventually need re-basing, and
+re-basing by hand is how a red test is quietly made green.  The rule this
+project follows instead: **re-baseline deliberately, never to turn a red test
+green.**  If a fixture's expected output changes, the reason goes in
+`expected.tsv` or in `SUMMARY.md` first.
+
+`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.
+
+**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`
+if, `t10` while, `t11` for, `t12` repeat, `t13` procedure, `t15` label, `t27`
+five locals, `t28` 70 parameters).  They have byte-level checks and no
+behavioural check.  That is the next gap to close, and it is a gap in the
+*tests*, not in the compiler.
+
+What execution found that no byte-level check could: the `FOR` loop was
+post-tested, `EXIT` targeted the increment, procedure bodies executed as part
+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.
+
 ### Hex-dumping the emitted image
 ### Hex-dumping the emitted image
 
 
 A line reading `@dump` on stdin switches on a hex dump of the emitted 8086
 A line reading `@dump` on stdin switches on a hex dump of the emitted 8086
@@ -244,10 +289,59 @@ resolved-immediately branch and jump landed 2 bytes past its target.
 (`target - (place + 2)`), which is why forward gotos looked fine and
 (`target - (place + 2)`), which is why forward gotos looked fine and
 backward ones did not.  All three now use `pc + 2`.
 backward ones did not.  All three now use `pc + 2`.
 
 
-This was silently harmless so far only because nothing executes the image
-yet.  Verified on `t10_while`: the `JZ` forward patch lands on the
-instruction after the loop, and the `JMP` back-edge now lands exactly on the
-loop head instead of 2 bytes into it.
+This was silently harmless while nothing executed the image, which is exactly
+why it survived so long.  Verified on `t10_while`: the `JZ` forward patch lands
+on the instruction after the loop, and the `JMP` back-edge now lands exactly on
+the loop head instead of 2 bytes into it -- and `t10_while` is one of the nine
+fixtures that *still* do not execute, so the claim is byte-level only.
+
+## Codegen bug class: a patch slot of 0 is not "no patch"
+
+`EmJmpNear (target)` does not emit a branch -- it emits one and returns the
+**index of a patch slot** in the image's fixup list, because the target is not
+known yet when the branch is emitted.  `Compile` stores that index in
+`overProc` and patches it later with `SetPatTgt (overProc, pc)`.
+
+The first version used `overProc := 0` as the "there was no such jump"
+sentinel, and **patch slot 0 is a perfectly valid slot**.  So
+
+```
+IF overProc # 0 THEN SetPatTgt (overProc, pc) END ;
+```
+
+skipped the patch for any program whose procedure-skip jump happened to be the
+*first* patch in the image.  The jump kept its placeholder target of 0, landed
+at image offset 0 -- which is the entry `JMP` -- and looped forever.  `t31_procparam`
+did not fail, it **hung**.
+
+The fix is a separate `hasProc : BOOLEAN`, and the rule generalises: **a
+plausible placeholder is indistinguishable from a real value.**  `0` as "no
+target", `-1` as "unbounded", `0` as "flag not set" are all correct until the
+first real value is 0.  Reach for a `BOOLEAN`; it has no collision to have.
+
+## Codegen: procedure bodies are emitted inside the caller's main body
+
+The compiler emits a procedure's body *between* the caller program's prologue
+and its own main body, so a jump over it is mandatory -- without it the main
+body runs straight into the procedure's code.  `DeclaresProc ()` answers "does
+this program declare any procedure at all?" with a save/restore-`srcPos`
+lookahead that steps over `:` and `;` and stops at `srcLen`; `Compile` then
+emits `EmJmpNear (0)` after the prolog when the answer is TRUE, and patches it
+once `DefPart` has emitted the bodies.
+
+This is why four fixture code sizes grew by exactly 3 bytes (the `E9` plus its
+rel16): `t13_proc` 53->56, `t27_localvar` 122->125, `t28_farparam` 123->126,
+`t31_procparam` 66->69.  A size change in a pinned matrix is a claim that has to
+be justified, and each of these is written into `expected.tsv`.
+
+`DeclaresProc` reads lookahead characters with
+
+```
+ch : CHAR ; ch := GetCh ()
+```
+
+rather than a bare `GetCh ()`, because `gm2 -fiso` rejects an ignored function
+result -- a rule that has bitten this project in several unrelated places.
 
 
 
 
 ## gm2 pitfall: `EXIT` inside the program-header `WHILE` crashes pass 3
 ## gm2 pitfall: `EXIT` inside the program-header `WHILE` crashes pass 3
@@ -264,4 +358,35 @@ Extracting the loop into its own procedure did **not** help.  So the header
 fix is deliberately exactly one added `Skip ()` and no `EXIT`.  If that loop
 fix is deliberately exactly one added `Skip ()` and no `EXIT`.  If that loop
 must be hardened, use a `BOOLEAN` "advanced" flag, never `EXIT`.
 must be hardened, use a `BOOLEAN` "advanced" flag, never `EXIT`.
 
 
-*Generated: 2026-09-26*
+## gm2 / ISO trap: `AND` is not short-circuit, and a lookahead must not match
+
+Two rules that produced the same class of bug:
+
+- `IF MatchKey (tok) AND (tok = TkVar) THEN` -- `MatchKey` **consumes** the
+  word it recognises, and `AND` evaluates both sides.  So this is not a
+  lookahead, it is a parse that eats the very token being tested for.  The
+  shape that works is `PeekKw (tok)` (which save/restores `srcPos`) for the
+  question, and `DropB (MatchKey (tok))` only on the branch that commits.
+  This is the "Alpha test without `Skip`" family above, one level up: there,
+  the cursor was not past whitespace; here, it was past the token.
+- `IF <stmt>` may not be the last thing in a compound statement.  It is a
+  compile error under `-fiso`, and the error points at the `END`, not at the
+  `IF`.
+
+## gm2 trap: an implementation module must not re-declare its own `.def` CONST
+
+`Runtime.def` declares `LoadBias = 100H` because `Compiler.mod` needs it too.
+`Runtime.mod` must then **not** declare it again -- duplicating a CONST that
+the definition module already exported is an error, and the message points at
+the duplicate, not at the `.def`, so it reads like a redeclaration problem
+rather than "this already exists upstream".
+
+## `gm2 -fiso` rejects a dropped function result
+
+`GetCh ()` as a statement, or any call whose result is discarded, is an error.
+Hence the `DropCh`/`DropB`/`DropC` helpers throughout, and hence
+`ch : CHAR ; ch := GetCh ()` in `DeclaresProc`.  There are ~39 such call sites
+and every one of them is a place where a reader might "simplify" the `DropB`
+away.
+
+*Generated: 2026-09-30*

+ 371 - 71
shell/Compiler.mod

@@ -47,10 +47,19 @@ FROM TextBuf IMPORT Length, CharAt ;
 
 
 FROM SYSTEM IMPORT BYTE ;
 FROM SYSTEM IMPORT BYTE ;
 
 
-FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry ;
+FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry, LoadBias ;
 (* The runtime is copied to the front of the code buffer and pc/dc start past
 (* The runtime is copied to the front of the code buffer and pc/dc start past
    it, so every emitted address is image-absolute and no relocation pass is
    it, so every emitted address is image-absolute and no relocation pass is
-   needed.  See Inittur. *)
+   needed.  See Inittur.
+
+   `LoadBias' comes from Runtime because it is the same constant on both
+   sides of the image: Runtime adds it to every data address it bakes into its
+   own code (FixUp, kind 2), and this module adds it to every ABSOLUTE address
+   it bakes into the program's.  It is deliberately ONE constant in ONE place
+   rather than 0100h written out at six sites, because getting it wrong at one
+   site is invisible - see the note on LoadBias in Runtime.mod.  Relative
+   encodings (the entry JMP, every CALL and JMP) must NOT get it: both
+   operands shift together and the +0100h cancels. *)
 
 
 (* ---------------------------------------------------------------- *)
 (* ---------------------------------------------------------------- *)
 (*  constants                                                       *)
 (*  constants                                                       *)
@@ -59,6 +68,11 @@ FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry ;
 CONST
 CONST
    MaxLine   = 128 ;
    MaxLine   = 128 ;
    MaxName   = 31 ;
    MaxName   = 31 ;
+
+   (* Size of the entry jump at image offset 0: E9 lo hi.  The jump's
+      displacement is relative to the END of the jump, so every offset in the
+      image is EntSize further along than it was before the jump existed. *)
+   EntSize   = 3 ;
    MaxCode   = 24000 ;
    MaxCode   = 24000 ;
    MaxSym    = 3000 ;
    MaxSym    = 3000 ;
    MaxPatch  = 2000 ;
    MaxPatch  = 2000 ;
@@ -68,6 +82,15 @@ CONST
    TNone    = 0 ;  TArray  = 1 ;  TRecord = 2 ;  TSet  = 3 ;
    TNone    = 0 ;  TArray  = 1 ;  TRecord = 2 ;  TSet  = 3 ;
    TPtr     = 4 ;  TFile   = 5 ;  TText   = 6 ;  TUntyp = 7 ;
    TPtr     = 4 ;  TFile   = 5 ;  TText   = 6 ;  TUntyp = 7 ;
    TString  = 8 ;  TReal   = 9 ;  TScalar = 10 ; TBool  = 11 ;
    TString  = 8 ;  TReal   = 9 ;  TScalar = 10 ; TBool  = 11 ;
+   (* CHAR needs a class of its own.  It used to be registered as TScalar,
+      which made a CHAR variable indistinguishable from an INTEGER one: the
+      class is all IoCall has to dispatch on, so `write(c)` called wrint (the
+      value 65 became the *address* 65 and it printed whatever lived at 0x41)
+      and `readln(c)` called rdint (which stores a 16-bit result, so it wrote
+      two bytes into a one-byte variable).  TP3 TPSRC8 prdtyped/pwriteln
+      dispatches on the type identifier for exactly this reason.  BYTE stays
+      TScalar: a BYTE is written as an integer, as in TP3. *)
+   TChar    = 12 ;
 
 
    (* symbol kinds *)
    (* symbol kinds *)
    KLabel   = 100H ;  KConst  = 200H ;  KType   = 300H ;
    KLabel   = 100H ;  KConst  = 200H ;  KType   = 300H ;
@@ -205,12 +228,17 @@ VAR
       prepended. *)
       prepended. *)
    rtSz, dataBase : CARDINAL ;
    rtSz, dataBase : CARDINAL ;
 
 
-   (* Runtime entry offsets inside the emitted image, i.e. offsets into the
-      runtime blob, which the linker places at offset 0.  They were a
-      hand-written placeholder ladder until the runtime was wired in; they are
-      now taken from Runtime.RT_Entry, which derives them from where the code
-      actually lands in the assembled blob.  Not a CONST block any more
-      because RT_Entry is a function.
+   (* Image offset of the entry jump's rel16 operand, patched at the end of
+      Compile.  The jump is at image offset 0, so its displacement is simply
+      the program code's end - 3. *)
+   entRel, prologAt : CARDINAL ;
+
+   (* Runtime entry offsets as IMAGE-ABSOLUTE addresses, which is what
+      EmCall and EmJmp want.  They are derived from Runtime.RT_Entry in
+      Inittur (after RT_Build, since RT_Entry only knows where the code
+      landed once the blob is assembled) rather than written down, so a moved
+      entry cannot leave the compiler calling the old address.  Not a CONST
+      block because RT_Entry is a function.
 
 
       Standard-procedure entries: TP3 does NOT pass a descriptor -
       Standard-procedure entries: TP3 does NOT pass a descriptor -
       TPSRC8 pwriteln/pwrloop inspects each argument's class in CL and emits
       TPSRC8 pwriteln/pwrloop inspects each argument's class in CL and emits
@@ -509,18 +537,43 @@ BEGIN
 END EmMovAh0 ;
 END EmMovAh0 ;
 
 
 PROCEDURE EmMovAxSp () ;
 PROCEDURE EmMovAxSp () ;
-(* MOV AX,[SP].  Needs a SIB byte, and [SP] cannot be encoded with mod=00
-   (that would compute BP+SP), so it is mod=01 / SIB=24h / disp8=0.  Emitting
-   just "8B 04" left the SIB slot unfilled, which silently ate the *next*
-   instruction - the case-label CMP - and turned every case comparison into
-   a load from a garbage address. *)
-BEGIN
-   Ebyte (8BH) ; Ebyte (44H) ; Ebyte (24H) ; Ebyte (0)
+(* Read the top of the stack into AX, leaving the stack unchanged.
+
+   The obvious encoding, MOV AX,[SP], DOES NOT EXIST on the 8086.  There is
+   no encoding of [SP] as a memory operand: SIB bytes, which is how [ESP]
+   would be written, did not exist until the 386, and mod=00 / rm=100 is
+   [SI], not [SP].  The first version of this emitted 8B 44 24 00 - mod=01,
+   rm=100, SIB=24h, disp8=0 - which is correct only on a 386 and above.  Two
+   of this project's oracles agree that it is wrong: fcml in 16-bit mode
+   decodes it as MOV AX,[SI+0x24h], and so does qemu executing it, because
+   qemu follows the CPU's rules for the encoding it is given rather than
+   guessing.  It was not the encoder's fault that the bytes were well formed;
+   they were, and they read SI+24h.
+
+   The observable effect was that CASE compiled to no branches at all: each
+   label test loaded a garbage address, every comparison failed, and the
+   program fell straight past the whole statement and exited without printing.
+   A CASE fixture caught it.  Nothing else could have - the encoding is
+   valid, the size is right, and the byte-level checks have no way to know
+   what register was meant.
+
+   So: POP then PUSH the same value.  Two bytes, no SIB, correct on every
+   8086, and observationally identical to peeking - the stack pointer ends
+   where it started, holding the same value. *)
+BEGIN
+   Ebyte (58H) ;                     (* POP AX  *)
+   Ebyte (50H)                      (* PUSH AX *)
 END EmMovAxSp ;
 END EmMovAxSp ;
 
 
 PROCEDURE EmMovCxSp () ;
 PROCEDURE EmMovCxSp () ;
-BEGIN
-   Ebyte (8BH) ; Ebyte (0CH)
+(* The same, for CX - the FOR loop's bound, pushed by the FOR statement and
+   re-read on every iteration.  This one was emitting 8B 0C and nothing else,
+   which is MOV CX,[SI] with the SIB slot missing: the *next* instruction was
+   consumed as the SIB byte and the displacement.  Same root cause, same fix,
+   and it had not been noticed only because no FOR fixture is executed yet. *)
+BEGIN
+   Ebyte (59H) ;                     (* POP CX  *)
+   Ebyte (51H)                      (* PUSH CX *)
 END EmMovCxSp ;
 END EmMovCxSp ;
 
 
 PROCEDURE EmPushAx () ;
 PROCEDURE EmPushAx () ;
@@ -538,9 +591,24 @@ BEGIN
    Ebyte (5AH)
    Ebyte (5AH)
 END EmPopDx ;
 END EmPopDx ;
 
 
+(* 91 = XCHG AX,CX, and NOT 93.  BinOpEmit has the left operand in CX and the
+   right in AX (it pushes the left, loads the right, then pops the left into
+   CX), so the exchange is what puts LEFT in AX for the operation to act on.
+   Without it, `a - b` computes `b - a`; with the wrong register, `a + b`
+   computes `AX' + a` where AX' is whatever BX happened to hold.
+
+   This emitted 93H = XCHG BX,AX for its entire life, which is the same class
+   of mistake as MovSiBx = 89 DC in Runtime.mod: the right opcode, the wrong
+   ModRM, decoding cleanly.  Byte counts were right, the compile matrix was
+   green, and no exec fixture did arithmetic on two variables - the first one
+   to do so, `c := a + b` with a=7 b=5, printed 263 = 0100h+7, where 0100h
+   was the caller's leftover BX.  The name was the only thing wrong, and
+   nothing read the name: audit_helpers.py swept Runtime.mod and not
+   Compiler.mod, which is where most of these emitters live.  It does both
+   modules now. *)
 PROCEDURE EmXchgAxCx () ;
 PROCEDURE EmXchgAxCx () ;
 BEGIN
 BEGIN
-   Ebyte (93H)
+   Ebyte (91H)
 END EmXchgAxCx ;
 END EmXchgAxCx ;
 
 
 PROCEDURE EmXorAxAx () ;
 PROCEDURE EmXorAxAx () ;
@@ -650,18 +718,21 @@ BEGIN
 END EmBpDisp ;
 END EmBpDisp ;
 
 
 PROCEDURE EmLoadVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
 PROCEDURE EmLoadVar (local : BOOLEAN ; off, nbytes : CARDINAL) ;
+(* A local is [BP+off] and `off' is already a frame displacement, so it needs
+   no bias.  A global is [off] with a DIRECT displacement, i.e. an absolute
+   address, and that is the image offset + LoadBias - see Runtime.LoadBias. *)
 BEGIN
 BEGIN
    IF nbytes = 1 THEN
    IF nbytes = 1 THEN
       IF local THEN
       IF local THEN
          Ebyte (8AH) ; EmBpDisp (off)
          Ebyte (8AH) ; EmBpDisp (off)
       ELSE
       ELSE
-         Ebyte (0A0H) ; Eword (off)
+         Ebyte (0A0H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    ELSE
    ELSE
       IF local THEN
       IF local THEN
          Ebyte (8BH) ; EmBpDisp (off)
          Ebyte (8BH) ; EmBpDisp (off)
       ELSE
       ELSE
-         Ebyte (0A1H) ; Eword (off)
+         Ebyte (0A1H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    END
    END
 END EmLoadVar ;
 END EmLoadVar ;
@@ -672,13 +743,13 @@ BEGIN
       IF local THEN
       IF local THEN
          Ebyte (88H) ; EmBpDisp (off)
          Ebyte (88H) ; EmBpDisp (off)
       ELSE
       ELSE
-         Ebyte (0A2H) ; Eword (off)
+         Ebyte (0A2H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    ELSE
    ELSE
       IF local THEN
       IF local THEN
          Ebyte (89H) ; EmBpDisp (off)
          Ebyte (89H) ; EmBpDisp (off)
       ELSE
       ELSE
-         Ebyte (0A3H) ; Eword (off)
+         Ebyte (0A3H) ; Eword ((off + LoadBias) MOD 10000H)
       END
       END
    END
    END
 END EmStoreVar ;
 END EmStoreVar ;
@@ -687,12 +758,14 @@ PROCEDURE EmPushVarAddr (local : BOOLEAN ; off : CARDINAL) ;
 (* LEA AX,[BP+disp] / LEA AX,[off] then PUSH AX - READ passes the address of
 (* LEA AX,[BP+disp] / LEA AX,[off] then PUSH AX - READ passes the address of
    a variable, not its value.  8D 46 disp is LEA AX,[BP+disp8] and
    a variable, not its value.  8D 46 disp is LEA AX,[BP+disp8] and
    8D 86 lo hi is LEA AX,[BP+disp16]; 8D 06 off is LEA AX,[off]
    8D 86 lo hi is LEA AX,[BP+disp16]; 8D 06 off is LEA AX,[off]
-   (mod=00 rm=110 = the direct disp16 form).  All three are 8086-legal. *)
+   (mod=00 rm=110 = the direct disp16 form).  All three are 8086-legal.
+   The [off] form is absolute and so carries LoadBias; the [BP+disp] forms
+   are displacements and so do not. *)
 BEGIN
 BEGIN
    IF local THEN
    IF local THEN
       Ebyte (8DH) ; EmBpDisp (off)
       Ebyte (8DH) ; EmBpDisp (off)
    ELSE
    ELSE
-      Ebyte (8DH) ; Ebyte (06H) ; Eword (off)
+      Ebyte (8DH) ; Ebyte (06H) ; Eword ((off + LoadBias) MOD 10000H)
    END ;
    END ;
    EmPushAx ()
    EmPushAx ()
 END EmPushVarAddr ;
 END EmPushVarAddr ;
@@ -1060,6 +1133,60 @@ BEGIN
    RETURN TkNone
    RETURN TkNone
 END WddTok ;
 END WddTok ;
 
 
+PROCEDURE DeclaresProc () : BOOLEAN ;
+(* Does the REST of the source declare a PROCEDURE or a FUNCTION?
+
+   Needed because the declaration part is compiled BEFORE the main statement
+   part, so a procedure's code lands between the program prologue and the
+   main body - and nothing jumps over it.  A program with a procedure
+   therefore ran off the end of the prologue, straight into the first
+   procedure, which read its argument out of an uninitialised frame and
+   returned to address 0.  Every Pascal program containing a procedure was
+   broken; `t13_proc` compiled and was never executed, so nothing saw it.
+
+   The jump that fixes it has to be emitted BEFORE the declaration part, but
+   whether one is needed is only known AFTER - so the only honest options are
+   to emit it unconditionally (3 dead bytes in every program, and every code
+   size in expected.tsv moves) or to know the answer in advance.  This is the
+   second: it scans ahead and puts srcPos back.
+
+   That is safe because the whole program is already in `src` and `srcPos` is
+   a plain index into it - the same trick PeekKw and KwAhead use.  The scan
+   looks for the keywords anywhere in the remainder rather than tracking the
+   nesting of `begin`s, which is deliberately loose: a program with no
+   procedures that merely mentions the word in a string literal would get a
+   3-byte jump to the next instruction, which is harmless, whereas tracking
+   the main `begin` against a procedure's `begin` would be a second parser
+   to get wrong. *)
+VAR save : CARDINAL ;
+    found : BOOLEAN ;
+    tk : CARDINAL ;
+    ch : CHAR ;
+BEGIN
+   save := srcPos ;
+   found := FALSE ;
+   tk := TkNone ;                 (* so the answer is defined if src is empty *)
+   (* Step over delimiters as well as blanks.  Stopping at the first
+      non-letter looked reasonable and was wrong: `var x : integer ;` is full
+      of ':' and ';', so the scan gave up inside the variable section and
+      never reached the PROCEDURE.  The loop ends at the end of the source,
+      not at the first punctuation. *)
+   WHILE (NOT found) AND (srcPos < srcLen) DO
+      Skip () ;
+      IF Alpha (CurCh ()) THEN
+         GetWord () ;
+         tk := WddTok () ;
+         IF (tk = TkProcedure) OR (tk = TkFunction) THEN
+            found := TRUE
+         END
+      ELSE
+         ch := GetCh ()                (* a ':' or ';' - step over it *)
+      END
+   END ;
+   srcPos := save ;
+   RETURN (tk = TkProcedure) OR (tk = TkFunction)
+END DeclaresProc ;
+
 PROCEDURE KwAhead (word : ARRAY OF CHAR) : BOOLEAN ;
 PROCEDURE KwAhead (word : ARRAY OF CHAR) : BOOLEAN ;
 (* does the next token (past blanks/comments) equal the keyword 'word',
 (* does the next token (past blanks/comments) equal the keyword 'word',
    without consuming it?  srcPos is saved and restored. *)
    without consuming it?  srcPos is saved and restored. *)
@@ -1436,10 +1563,19 @@ BEGIN
    RETURN VAL (LONGINT, W16 (a * b))
    RETURN VAL (LONGINT, W16 (a * b))
 END ConstMul ;
 END ConstMul ;
 
 
-PROCEDURE EmMoveAxDx () ;
+PROCEDURE EmXchgAxDx () ;
+(* 92h = XCHG AX,DX.  Named for what it EMITS, which is the point of the
+   whole naming convention: this used to be called EmMoveAxDx, which is what
+   somebody would expect the opcode to be, and it is not - 89 D8 is
+   MOV AX,DX, 92h is the exchange.  Here the exchange is what is wanted, so
+   the name is the only thing that was wrong, and it was wrong in the exact
+   way this file's names are not allowed to be: reading as "a move" when it
+   is a swap.  After EmIDivAxCx the remainder is in DX and `mod` wants it in
+   AX; an exchange gets it there in one byte where a move also would, so the
+   behaviour is identical either way and only the name lied. *)
 BEGIN
 BEGIN
    Ebyte (92H)
    Ebyte (92H)
-END EmMoveAxDx ;
+END EmXchgAxDx ;
 
 
 PROCEDURE BinOpEmit (op : CARDINAL ; left, right : ERes ; VAR res : ERes) ;
 PROCEDURE BinOpEmit (op : CARDINAL ; left, right : ERes ; VAR res : ERes) ;
 (* binary operation at one precedence level; folds constant operands *)
 (* binary operation at one precedence level; folds constant operands *)
@@ -1505,7 +1641,7 @@ BEGIN
    |  2   : EmSubAxCx ;
    |  2   : EmSubAxCx ;
    |  3   : EmMulAxCx ;
    |  3   : EmMulAxCx ;
    |  TkDiv : EmIDivAxCx ;
    |  TkDiv : EmIDivAxCx ;
-   |  TkMod : EmIDivAxCx ; EmMoveAxDx ;
+   |  TkMod : EmIDivAxCx ; EmXchgAxDx ;
    ELSE
    ELSE
       Err (ETypeErr)
       Err (ETypeErr)
    END ;
    END ;
@@ -2006,10 +2142,13 @@ BEGIN
             ent := TU_RdInt                   (* real reads: not yet *)
             ent := TU_RdInt                   (* real reads: not yet *)
          ELSIF acls = TBool THEN
          ELSIF acls = TBool THEN
             ent := TU_RdBool
             ent := TU_RdBool
+         ELSIF acls = TChar THEN
+            ent := TU_RdChar                  (* one byte, not a word *)
          ELSIF acls = TScalar THEN
          ELSIF acls = TScalar THEN
             ent := TU_RdInt
             ent := TU_RdInt
          ELSE
          ELSE
-            ent := TU_RdChar
+            Err (ETypeErr) ;
+            RETURN
          END
          END
       ELSE
       ELSE
          acls := args [i].cls ;
          acls := args [i].cls ;
@@ -2056,10 +2195,13 @@ BEGIN
                ent := TU_WrReal
                ent := TU_WrReal
             ELSIF acls = TBool THEN
             ELSIF acls = TBool THEN
                ent := TU_WrBool
                ent := TU_WrBool
+            ELSIF acls = TChar THEN
+               ent := TU_WrChar                (* c : char - one char *)
             ELSIF acls = TScalar THEN
             ELSIF acls = TScalar THEN
                ent := TU_WrInt
                ent := TU_WrInt
             ELSE
             ELSE
-               ent := TU_WrChar
+               Err (ETypeErr) ;
+               RETURN
             END
             END
          END
          END
       END ;
       END ;
@@ -2111,7 +2253,17 @@ BEGIN
          Err (ENoSemi)
          Err (ENoSemi)
       END ;
       END ;
       Statmnt () ;
       Statmnt () ;
-      IF MatchKey (tok) AND (tok = TkElse) THEN
+      (* Peek the ELSE, do not match it.  `MatchKey (tok) AND (tok = TkElse)`
+         consumes whatever the next keyword is even when the AND fails, so an
+         `if` that was the LAST statement of a BEGIN..END block ate the block's
+         own END: Compound then found neither ';' nor END and raised ENoSemi.
+         Any `if` as the last statement of a compound was unparseable - not
+         in a loop, not anywhere - and no fixture had one, so nothing noticed.
+         The visible symptom was a parse error at the statement AFTER the
+         block, which points at entirely the wrong piece of source. *)
+      PeekKw (tok) ;
+      IF tok = TkElse THEN
+         DropB (MatchKey (tok)) ;
          exj := EmJmpNear (0) ;
          exj := EmJmpNear (0) ;
          SetPatTgt (zj, pc) ;
          SetPatTgt (zj, pc) ;
          Statmnt () ;
          Statmnt () ;
@@ -2203,16 +2355,20 @@ BEGIN
       brkSave [brkN] := exitCnt ;
       brkSave [brkN] := exitCnt ;
       loopTy [brkN] := 2 ;
       loopTy [brkN] := 2 ;
       INC (brkN) ;
       INC (brkN) ;
-      L1 := pc ;                       (* Ltest *)
-      Statmnt () ;
-      DEC (brkN) ;
-      i := brkSave [brkN] ;
-      WHILE i < exitCnt DO
-         SetPatTgt (exitPatch [i], pc) ;
-         INC (i)
-      END ;
-      exitCnt := brkSave [brkN] ;
-      (* test then step: ax = var ; cx = bound (from [sp]) *)
+      L1 := pc ;                       (* Ltest: the test comes FIRST *)
+      (* The test is emitted before the body, not after it.  It used to be
+         emitted after, which is a post-test loop and runs the body one time
+         too many: with `for i := 1 to 5`, the sequence of i at the test is
+         1,2,3,4,5,6 - the test at i=5 is `5 > 5`, which is false, so the
+         body ran a sixth time with i=6.  `for i := 1 to 5 do s := s + i`
+         printed 21.  The size was right and the shape was right; only the
+         order was wrong, and no byte check can see an order.
+
+         The bound stays on the stack for the whole loop, so EmMovCxSp has to
+         re-read it every iteration - which is also what makes the bound a
+         *variable* rather than a constant.  [SP] cannot be encoded on the
+         8086, so EmMovCxSp is POP CX ; PUSH CX, an observational no-op that
+         leaves the bound in place. *)
       EmMovCxSp () ;
       EmMovCxSp () ;
       EmLoadVar (symtab [idx].local, symtab [idx].off,
       EmLoadVar (symtab [idx].local, symtab [idx].off,
                  symtab [idx].size) ;
                  symtab [idx].size) ;
@@ -2222,6 +2378,22 @@ BEGIN
       ELSE
       ELSE
          zj := EmJcc (8FH, 0)          (* JG -> done *)
          zj := EmJcc (8FH, 0)          (* JG -> done *)
       END ;
       END ;
+      Statmnt () ;
+      DEC (brkN) ;
+      (* A FOR's exits are NOT patched here, even though `done` is not known
+         yet.  For a WHILE or REPEAT, "just after the body" is a correct
+         target: the jump back to the test re-evaluates the condition and
+         leaves.  For a FOR there is a STEP between the body and `done`, so
+         an EXIT that jumped here would increment the control variable and
+         jump back to the test - and if the incremented value still satisfied
+         the bound, it would run the body AGAIN.  `exit` did not exit.
+
+         The fix needs no new bookkeeping: `brkSave [brkN] .. exitCnt` still
+         names exactly this loop's exits, because Statmnt may have added more
+         and nothing has reset exitCnt.  So they are patched at `done`, below.
+         A WHILE nested inside the FOR saves and restores its own range and
+         leaves this one intact. *)
+      (* step *)
       EmLoadVar (symtab [idx].local, symtab [idx].off,
       EmLoadVar (symtab [idx].local, symtab [idx].off,
                  symtab [idx].size) ;
                  symtab [idx].size) ;
       IF dow THEN
       IF dow THEN
@@ -2232,8 +2404,21 @@ BEGIN
       EmStoreVar (symtab [idx].local, symtab [idx].off,
       EmStoreVar (symtab [idx].local, symtab [idx].off,
                   symtab [idx].size) ;
                   symtab [idx].size) ;
       DropC (EmJmpNear (L1)) ;
       DropC (EmJmpNear (L1)) ;
-      SetPatTgt (zj, pc) ;             (* done: drop bound, continue *)
-      EmAddSp (2)
+      SetPatTgt (zj, pc) ;             (* done: *)
+      (* A WHILE loop here, not a FOR over the exit range.  The range is
+         usually EMPTY - most loops have no `exit` - and `exitCnt` is a
+         CARDINAL, so `TO exitCnt - 1` with exitCnt = 0 is `TO 65535`: the
+         loop does not terminate, it wraps, and it walks exitPatch [0..65535]
+         off the end of a 64-element array.  `for i := 1 to 10 do i := i` has
+         no exit, so this is the ORDINARY case, and it faulted with
+         "invalid address referenced" on every FOR loop without an exit. *)
+      i := brkSave [brkN] ;
+      WHILE i < exitCnt DO
+         SetPatTgt (exitPatch [i], pc) ;
+         INC (i)
+      END ;
+      exitCnt := brkSave [brkN] ;
+      EmAddSp (2)                      (* drop the loop bound *)
    ELSIF tok = TkCase THEN
    ELSIF tok = TkCase THEN
       ParseExpr (t) ;
       ParseExpr (t) ;
       LoadAtom (t) ;
       LoadAtom (t) ;
@@ -2332,9 +2517,13 @@ BEGIN
       IF brkN = 0 THEN
       IF brkN = 0 THEN
          Err (EUnknown)
          Err (EUnknown)
       ELSE
       ELSE
-         IF loopTy [brkN - 1] = 2 THEN
-            EmAddSp (2)                (* drop FOR bound *)
-         END ;
+         (* No EmAddSp (2) here, even inside a FOR.  The FOR's `done` label
+            drops the bound, so an EXIT that jumped to `done` would drop it a
+            second time - 4 bytes off a stack that only had 2 to give, which
+            silently corrupts the caller's frame.  It used to do exactly
+            that, and it was doubly wrong: the exits were patched to the STEP
+            rather than to `done`, so the EXIT also incremented the control
+            variable and jumped back into the test. *)
          zj := EmJmpNear (0) ;
          zj := EmJmpNear (0) ;
          IF exitCnt < 64 THEN
          IF exitCnt < 64 THEN
             exitPatch [exitCnt] := zj ;
             exitPatch [exitCnt] := zj ;
@@ -2431,6 +2620,9 @@ BEGIN
       END
       END
    ELSIF tok = TkSet THEN
    ELSIF tok = TkSet THEN
       Err (ENoLib) ;
       Err (ENoLib) ;
+      (* Unreachable, and it would be wrong even if it were reached: a
+         speculative `MatchKey (tok) AND (tok = TkOf)` consumes the token it
+         rejects.  See the note in the IF handler. *)
       IF MatchKey (tok) AND (tok = TkOf) THEN
       IF MatchKey (tok) AND (tok = TkOf) THEN
          ParseType (cls, s2, e2)
          ParseType (cls, s2, e2)
       END
       END
@@ -2896,38 +3088,58 @@ BEGIN
    txerrPos := 0 ;
    txerrPos := 0 ;
    srcPos := 0 ;
    srcPos := 0 ;
    srcLen := Length () ;
    srcLen := Length () ;
-   (* Copy the runtime to the front of the code buffer and start pc past it,
-      which is what the original does: TPSRC7 "copyrt" runs REPZ MOVSB with
-      SI=DI=0 and then "MOV pc,#$2D7C".  The image is therefore
+   (* The image layout is
 
 
-          [runtime][program header][program code]
+          [JMP rel16][runtime][program header][program code]
 
 
-      and because the runtime sits at offset 0, every address the compiler
+      The runtime is copied to the front, which is what the original does:
+      TPSRC7 "copyrt" runs REPZ MOVSB with SI=DI=0 and then "MOV pc,#$2D7C".
+      Because the runtime sits at (near) offset 0, every address the compiler
       emits is already image-absolute - the data symbols' offsets, the TU_*
       emits is already image-absolute - the data symbols' offsets, the TU_*
       call targets and the rel16 displacements all need no relocation pass.
       call targets and the rel16 displacements all need no relocation pass.
       (The base shift would in fact cancel in EmCall's arithmetic, since both
       (The base shift would in fact cancel in EmCall's arithmetic, since both
       sides of a CALL move together; making the offsets absolute just means
       sides of a CALL move together; making the offsets absolute just means
       the linker has nothing to do but copy bytes.)
       the linker has nothing to do but copy bytes.)
 
 
+      The JMP is new, and it is not cosmetic.  A DOS .COM is entered at
+      CS:0100, i.e. FILE offset 0, and for a long time offset 0 held the
+      runtime's first bytes - so a .COM built by this compiler started by
+      executing initmem with AX holding whatever the loader left in it.  Every
+      test up to that point checked bytes and never ran the thing, so it could
+      not see this.  The jump is the program's entry and the runtime is
+      ordinary data to it; keeping the runtime at the front is what preserves
+      the no-relocation property, so the jump goes in front of the runtime
+      rather than the runtime being moved behind the program.
+
       dc is put a fixed 4 KiB above the end of the program so that data cannot
       dc is put a fixed 4 KiB above the end of the program so that data cannot
       collide with code in a single 64 KiB .COM segment.  LIMITATION: a
       collide with code in a single 64 KiB .COM segment.  LIMITATION: a
       program whose code exceeds 4 KiB overruns its own data area.  TP3 had
       program whose code exceeds 4 KiB overruns its own data area.  TP3 had
       overlay segments for this; we do not, and the check belongs where the
       overlay segments for this; we do not, and the check belongs where the
       limit is documented rather than as a silent truncation. *)
       limit is documented rather than as a silent truncation. *)
-   RT_Build () ;
+   RT_Build (EntSize) ;
    rt := RT_Size () ;
    rt := RT_Size () ;
    IF rt >= MaxCode THEN
    IF rt >= MaxCode THEN
-      Err (EMemOvf) ;                 (* cannot happen: rt is 385 *)
+      Err (EMemOvf) ;                 (* cannot happen: rt is 391 *)
       RETURN
       RETURN
    END ;
    END ;
-   i := 0 ;
-   WHILE i < rt DO
-      cbuf [i] := RT_Byte (i) ;
+   (* The entry jump, at image offset 0.  See the layout note above: a DOS
+      .COM is entered at CS:0100, which is file offset 0, so whatever sits
+      at offset 0 is the program's first executed instruction. *)
+   (* The jump's three bytes are written out longhand rather than through
+      Eword, because Eword writes at pc and advances it, and pc is stale at
+      this point -- the operand landed wherever the last compile left pc. *)
+   cbuf [0] := 0E9H ;                  (* JMP rel16 *)
+   entRel := 1 ;
+   cbuf [entRel] := 0 ;
+   cbuf [entRel + 1] := 0 ;
+   i := EntSize ;
+   WHILE i - EntSize < rt DO
+      cbuf [i] := RT_Byte (i - EntSize) ;
       INC (i)
       INC (i)
    END ;
    END ;
-   pc := rt ;
-   rtSz := rt ;
-   dataBase := rt + 1000H ;
+   pc := rt + EntSize ;
+   rtSz := rt + EntSize ;
+   dataBase := rtSz + 1000H ;
    dc := dataBase ;
    dc := dataBase ;
    strTop := 0 ;
    strTop := 0 ;
    strCnt := 0 ;
    strCnt := 0 ;
@@ -2950,7 +3162,7 @@ BEGIN
    InitKeys () ;
    InitKeys () ;
    DropC (NewSym ("INTEGER", KType, TScalar, 2, 2, 0, 0, FALSE)) ;
    DropC (NewSym ("INTEGER", KType, TScalar, 2, 2, 0, 0, FALSE)) ;
    DropC (NewSym ("BYTE"   , KType, TScalar, 1, 1, 0, 0, FALSE)) ;
    DropC (NewSym ("BYTE"   , KType, TScalar, 1, 1, 0, 0, FALSE)) ;
-   DropC (NewSym ("CHAR"   , KType, TScalar, 1, 1, 0, 0, FALSE)) ;
+   DropC (NewSym ("CHAR"   , KType, TChar, 1, 1, 0, 0, FALSE)) ;
    DropC (NewSym ("BOOLEAN", KType, TBool  , 1, 1, 0, 0, FALSE)) ;
    DropC (NewSym ("BOOLEAN", KType, TBool  , 1, 1, 0, 0, FALSE)) ;
    DropC (NewSym ("REAL"   , KType, TReal  , 6, 6, 0, 0, FALSE)) ;
    DropC (NewSym ("REAL"   , KType, TReal  , 6, 6, 0, 0, FALSE)) ;
    DropC (NewSym ("STRING" , KType, TString, 256, 1, 0, 0, FALSE)) ;
    DropC (NewSym ("STRING" , KType, TString, 256, 1, 0, 0, FALSE)) ;
@@ -2963,7 +3175,25 @@ BEGIN
    dc := dc + 2 ;
    dc := dc + 2 ;
    (* Runtime entry offsets, derived from the blob rather than assumed.  This
    (* Runtime entry offsets, derived from the blob rather than assumed.  This
       has to happen after RT_Build, since RT_Entry only knows where the code
       has to happen after RT_Build, since RT_Entry only knows where the code
-      landed once the blob is assembled. *)
+      landed once the blob is assembled.
+
+      RT_Entry returns an IMAGE-ABSOLUTE address, already biased by the base
+      RT_Build was given, so nothing here has to know where the runtime
+      landed.  It used to return a blob-relative offset, and the bias was
+      applied here instead - three bytes' worth, for the entry jump.  With
+      that line missing, every CALL landed three bytes short, in the middle of
+      a neighbouring runtime entry, and a CALL into the middle of wrtin's
+      `INT 21h' behaves perfectly plausibly: the program runs, prints nothing
+      and hangs.  Only running it finds that. *)
+   IF (RT_Entry (13) = 0) OR (RT_Entry (11) = 0) OR (RT_Entry (3) = 0) THEN
+      (* RT_Entry returns 0 for an unknown selector.  initmem sits at 0
+         legitimately, so it cannot appear in this test - but wrtinl, rdln
+         and wrint never can, so catching them is enough to catch a runtime
+         that failed to build or a selector that went stale.  This test is on
+         0 is not a usable address here, since RT_Entry returns an
+         image-absolute address and the base is EntSize. *)
+      Err (EMemOvf)
+   END ;
    TU_InitMem  := RT_Entry (0) ;
    TU_InitMem  := RT_Entry (0) ;
    TU_ProgEnd  := RT_Entry (1) ;
    TU_ProgEnd  := RT_Entry (1) ;
    TU_StackChk := RT_Entry (2) ;
    TU_StackChk := RT_Entry (2) ;
@@ -2978,13 +3208,6 @@ BEGIN
    TU_RdLn     := RT_Entry (11) ;
    TU_RdLn     := RT_Entry (11) ;
    TU_Halt     := RT_Entry (12) ;
    TU_Halt     := RT_Entry (12) ;
    TU_WrInl    := RT_Entry (13) ;      (* inline string literal *)
    TU_WrInl    := RT_Entry (13) ;      (* inline string literal *)
-   IF (TU_WrInl = 0) OR (TU_RdLn = 0) OR (TU_WrInt = 0) THEN
-      (* RT_Entry returns 0 for an unknown selector.  initmem sits at 0
-         legitimately, so it cannot appear in this test - but wrtinl, rdln
-         and wrint never can, so catching them is enough to catch a runtime
-         that failed to build or a selector that went stale. *)
-      Err (EMemOvf)
-   END
 END Inittur ;
 END Inittur ;
 
 
 PROCEDURE HeadWord (VAR slot : CARDINAL) ;
 PROCEDURE HeadWord (VAR slot : CARDINAL) ;
@@ -2995,6 +3218,8 @@ END HeadWord ;
 
 
 PROCEDURE Compile (VAR errNo, errPos : CARDINAL) : BOOLEAN ;
 PROCEDURE Compile (VAR errNo, errPos : CARDINAL) : BOOLEAN ;
 VAR tok : CARDINAL ;
 VAR tok : CARDINAL ;
+    overProc : CARDINAL ;
+    hasProc : BOOLEAN ;
 BEGIN
 BEGIN
    Inittur () ;
    Inittur () ;
    IF OK () THEN
    IF OK () THEN
@@ -3007,12 +3232,17 @@ BEGIN
       Eword (16) ;                  (* max open files *)
       Eword (16) ;                  (* max open files *)
       Eword (0) ;                   (* input buffer word *)
       Eword (0) ;                   (* input buffer word *)
       Eword (0) ;                   (* output buffer word *)
       Eword (0) ;                   (* output buffer word *)
+      (* Here, and not one line earlier, is the first instruction of the
+         program: everything above is the header, which is DATA.  The entry
+         jump has to land exactly here.  Recorded rather than assumed, so that
+         a header that grows a word moves the target with it. *)
+      prologAt := pc ;
       (* TU_InitMem takes the header offset in AX, not on the stack, so the
       (* TU_InitMem takes the header offset in AX, not on the stack, so the
          AX load has to precede the call.  Previously the prologue called
          AX load has to precede the call.  Previously the prologue called
          offset 8 - which in this layout is the hdrMax word - and that was
          offset 8 - which in this layout is the hdrMax word - and that was
          coherent only because the runtime was not there.  Now it is the
          coherent only because the runtime was not there.  Now it is the
          real header. *)
          real header. *)
-      EmMovAxi (rtSz) ;
+      EmMovAxi ((rtSz + LoadBias) MOD 10000H) ;
       DropC (EmCall (TU_InitMem)) ;
       DropC (EmCall (TU_InitMem)) ;
       EmMovBpSp () ;
       EmMovBpSp () ;
       IF MatchKey (tok) AND (tok = TkProgram) THEN
       IF MatchKey (tok) AND (tok = TkProgram) THEN
@@ -3037,8 +3267,30 @@ BEGIN
          IfMatchSemi ()
          IfMatchSemi ()
       END ;
       END ;
       IF OK () THEN
       IF OK () THEN
+         (* Jump over the procedure bodies, if there are any.  DefPart
+            compiles them HERE, between the prologue and the main statement
+            part, and there was no jump - so a program with a procedure fell
+            off the end of the prologue into the first procedure.  See
+            DeclaresProc for why the condition is asked before DefPart runs
+            and not after. *)
+         hasProc := DeclaresProc () ;
+         IF hasProc THEN
+            overProc := EmJmpNear (0)
+         END ;
          DefPart () ;
          DefPart () ;
          IF OK () THEN
          IF OK () THEN
+            (* A separate flag, NOT `overProc # 0`.  EmJmpNear returns a
+               patch SLOT, and slot 0 is a perfectly ordinary slot - the
+               first forward jump in a program is slot 0.  So a zero test
+               cannot tell "no forward jump" from "forward jump in slot 0",
+               it just skips the first patch, and the jump keeps its
+               placeholder target of 0.  The program then jumped to image
+               offset 0, i.e. back to the entry jump, and ran the runtime
+               and the whole program again, forever.  The slot is only
+               valid together with a boolean saying a slot was taken. *)
+            IF hasProc THEN
+               SetPatTgt (overProc, pc)
+            END ;
             IF MatchKey (tok) AND (tok = TkBegin) THEN
             IF MatchKey (tok) AND (tok = TkBegin) THEN
                Compound () ;
                Compound () ;
                IF OK () THEN
                IF OK () THEN
@@ -3055,10 +3307,58 @@ BEGIN
                      bigger and serves a real overlay loader), but
                      bigger and serves a real overlay loader), but
                      Runtime.EmitInitMem reads +4 and +8, so hdrDS and
                      Runtime.EmitInitMem reads +4 and +8, so hdrDS and
                      hdrHeap must be the data base and the data end. *)
                      hdrHeap must be the data base and the data end. *)
+                  (* The entry jump's displacement.  A .COM is entered at
+                     CS:0100 = file offset 0, so the jump is the only thing
+                     that decides where execution starts, and it has to land
+                     on the START of the program code - the prologue, which is
+                     at rtSz - not on pc, which is the END of it.  (Patching
+                     pc - EntSize, i.e. the end, lands one byte past the last
+                     instruction, in the zero-filled code/data gap, where the
+                     CPU slides through `ADD [BX+SI],AL' until it faults.)
+                     The displacement is measured from the END of the jump,
+                     and both addresses are image-absolute, so the load
+                     segment cancels. *)
+                  (* The entry jump's displacement.  A .COM is entered at
+                     CS:0100 = file offset 0, so this jump is the only thing
+                     that decides where execution starts.
+
+                     The target is prologAt - where the prologue ACTUALLY
+                     began, recorded before the header words were emitted,
+                     and the header is 16 bytes long, so this is rtSz + 16 and
+                     NOT rtSz.  Landing on rtSz lands on the HEADER, which is
+                     data, and the CPU then decodes sixteen bytes of it as
+                     instructions.  That failure is spectacularly
+                     non-deterministic across programs: 01 00 is
+                     `ADD [BX+SI],AX' and is harmless, so writeln('hi') ran
+                     fine by sliding through the header into the prologue,
+                     while t07's hdrHeap word 90 12 decodes as a LOCK-prefixed
+                     ADD whose displacement crosses a page and faults, and the
+                     program hung with no output at all.  Both looked like
+                     "the jump is in the right area".  Recording the position
+                     rather than assuming it means a future header that grows
+                     a word cannot silently reintroduce this. *)
+                  PatchWord (entRel, (prologAt - EntSize) MOD 10000H) ;
                   PatchWord (hdrFlag, 1) ;
                   PatchWord (hdrFlag, 1) ;
-                  PatchWord (hdrCS, pc) ;
-                  PatchWord (hdrDS, dataBase) ;
-                  PatchWord (hdrHeap, dc) ;
+                  (* Every OFFSET field in the header is a segment offset,
+                     i.e. an image offset plus LoadBias - one convention for
+                     the whole structure, so that nobody has to remember
+                     which of these five words is numbered which way.
+
+                       hdrFlag  1  set, so a loader can recognise the header
+                       hdrCS     end of the generated code
+                       hdrDS     first byte of the data area   <- read by initmem
+                       hdrHeap   one past the last             <- read by initmem
+                       hdrMax    0 (no overlay loader yet)
+
+                     hdrDS and hdrHeap are the two that are CONSUMED, and
+                     omitting the bias there is a silent no-op: initmem would
+                     clear a range starting 0100h below the data, off the
+                     front of the image, and never reach the globals at the
+                     end.  Nothing crashes, and the globals keep whatever the
+                     loader left in them. *)
+                  PatchWord (hdrCS, pc + LoadBias) ;
+                  PatchWord (hdrDS, dataBase + LoadBias) ;
+                  PatchWord (hdrHeap, dc + LoadBias) ;
                   PatchWord (hdrMax, 0)
                   PatchWord (hdrMax, 0)
                END
                END
             ELSE
             ELSE

+ 35 - 4
shell/Runtime.def

@@ -8,8 +8,9 @@ DEFINITION MODULE Runtime ;
    generated program starts past it; the .COM writer emits the runtime as a
    generated program starts past it; the .COM writer emits the runtime as a
    block ahead of the program).  Same shape here: RT_Build assembles the
    block ahead of the program).  Same shape here: RT_Build assembles the
    library into rt[0..RT_Size-1], the compiler copies it to the front of its
    library into rt[0..RT_Size-1], the compiler copies it to the front of its
-   code buffer and starts pc/dc past it, so the runtime's own data lives at
-   low, link-time-constant addresses and needs no relocation.
+   code buffer (behind the entry JMP) and starts pc/dc past it, so the
+   runtime's own data lives at low, link-time-constant addresses and needs no
+   relocation.  The "front" is now at RT_Build's `base' rather than at 0.
 
 
    Entry offsets are *derived* from where the code actually lands, not
    Entry offsets are *derived* from where the code actually lands, not
    hardcoded - see RT_Entry.  Everything the compiler calls is reached by a
    hardcoded - see RT_Entry.  Everything the compiler calls is reached by a
@@ -27,8 +28,38 @@ CONST
    E_RdInt   = 8 ;  E_RdChar  = 9 ;  E_RdBool  = 10 ;
    E_RdInt   = 8 ;  E_RdChar  = 9 ;  E_RdBool  = 10 ;
    E_RdLn    = 11 ; E_Halt    = 12 ; E_WrInl   = 13 ;
    E_RdLn    = 11 ; E_Halt    = 12 ; E_WrInl   = 13 ;
 
 
-PROCEDURE RT_Build () ;
-(* assemble the runtime; idempotent, called once at Compile time *)
+   (* Where a .COM's first byte ends up.  DOS loads a .COM at CS:0100 (the
+      100 bytes below are the PSP), and CS = DS, so an image offset K is at
+      DS:(K + 0100h).  EVERY address baked into the image as an absolute
+      literal must therefore carry this.  It is here, in Runtime, because
+      FixUp needs it and Compiler needs it, and one constant in one place is
+      the only way a six-site bias can stay consistent - see the long note in
+      Runtime.mod, which also records how the failure looks: the program
+      runs, prints its first field, and then walks a $ that is 100h too low
+      through the runtime's own code. *)
+   LoadBias  = 100H ;
+
+PROCEDURE RT_Build (base : CARDINAL) ;
+(* assemble the runtime; idempotent, called once at Compile time.
+
+   `base' is the IMAGE OFFSET the runtime blob will be copied to.  It is a
+   parameter rather than an assumption because the image begins with a
+   three-byte entry JMP, so the runtime does not sit at image offset 0 - and
+   every address the runtime bakes into its own code (its data block, and the
+   entry offsets RT_Entry hands back) has to be biased by it.  The emitted
+   BYTES are the same for any base, because both are computed after assembly,
+   so the standalone probes and tests/runtime.golden pass 0 and are unaffected.
+
+   NOTE, because it has already cost time: this .def file is HAND-MAINTAINED.
+   gm2 reads X.def to resolve `FROM X IMPORT ...' and checks the
+   implementation against it, but it does NOT rewrite it - a successful
+   compile leaves X.def byte-identical, and deleting it makes gm2 fail with
+   "the file containing the definition module « X » cannot be found" rather
+   than regenerate it.  So an interface change means editing Runtime.def by
+   hand in the same commit, exactly as Editor.def was edited to add
+   Editor.GotoOffset.  Keep such changes ADDITIVE where possible: an
+   unchanged procedure's declaration does not need re-typing, so only the
+   lines that actually moved have to be right. *)
 
 
 PROCEDURE RT_Size () : CARDINAL ;
 PROCEDURE RT_Size () : CARDINAL ;
 (* size of the runtime in bytes - the offset at which the generated program
 (* size of the runtime in bytes - the offset at which the generated program

+ 336 - 69
shell/Runtime.mod

@@ -9,9 +9,20 @@ IMPLEMENTATION MODULE Runtime ;
    known exactly and are not guesses.
    known exactly and are not guesses.
 
 
    Memory model: a .COM image, so CS = DS = ES = SS = 0 and the whole thing
    Memory model: a .COM image, so CS = DS = ES = SS = 0 and the whole thing
-   lives in one 64K segment.  The runtime occupies offsets 0..RT_Size-1, the
-   generated program follows, and the program's globals follow at
-   RT_Size + 1000H.  Runtime data therefore sits at fixed low offsets.
+   lives in one 64K segment.  The runtime occupies image offsets
+   RT_Build's base .. base + RT_Size - 1, the generated program follows, and
+   the program's globals follow at that + 1000H.
+
+   The base is a parameter rather than an assumption, and that is not
+   decoration.  The image begins with a three-byte JMP - a .COM is entered at
+   file offset 0, and without it a .COM built by this compiler starts by
+   executing initmem - so the runtime does NOT sit at image offset 0.  Every
+   address the runtime bakes into its own code (its data block, and the entry
+   offsets it hands back) therefore has to be biased by that base.  Two places
+   do it, and only two: FixUp for the data block, and RT_Entry for the
+   entries.  The emitted BYTES are identical for any base, because both are
+   computed after assembly; tests/check_runtime.py and tests/runtime.golden
+   pass base 0 and are unaffected.
 
 
    Calling conventions, matching Compiler.IoCall:
    Calling conventions, matching Compiler.IoCall:
      WrInt/WrChar/WrBool/WrReal  one 16-bit value on the stack (caller pops)
      WrInt/WrChar/WrBool/WrReal  one 16-bit value on the stack (caller pops)
@@ -30,13 +41,58 @@ CONST
    MaxFix = 400 ;
    MaxFix = 400 ;
    MaxNm  = 15 ;
    MaxNm  = 15 ;
 
 
-   (* offsets inside the runtime's own data block *)
+   (* Offsets inside the runtime's own data block.  These must agree with the
+      bytes EmitData emits, and EmitData ASSERTS that they do - because the
+      only other way to find out is to run a program, and a D_ constant that
+      is one too high does not crash: the $ that terminates the string it
+      points at is simply somewhere else, and a $-string read from the wrong
+      address runs on until it happens to find a 24h.  wrln did exactly that
+      and printed a screenful of memory, which is what found this.  D_TRUE was
+      the only one that was right, and the golden (instructions only) could
+      not see any of it. *)
    D_NUM   = 0 ;      (* 8 bytes, decimal conversion scratch *)
    D_NUM   = 0 ;      (* 8 bytes, decimal conversion scratch *)
-   D_TRUE  = 8 ;      (* "TRUE$" *)
-   D_FALSE = 14 ;     (* "FALSE$" *)
-   D_CRLF  = 21 ;     (* CR LF '$' *)
-   D_REAL  = 24 ;     (* "?REAL?" - reals are not formatted yet *)
-   D_END   = 31 ;
+   D_TRUE  = 8 ;      (* "TRUE$"  - 5 bytes, 8..12 *)
+   D_FALSE = 13 ;     (* "FALSE$" - 6 bytes, 13..18 *)
+   D_CRLF  = 19 ;     (* CR LF '$' - 3 bytes, 19..21 *)
+   D_REAL  = 22 ;     (* "?REAL?" - 6 bytes, 22..27; reals are not formatted yet *)
+   D_PUSH  = 28 ;     (* 2 bytes, 28..29: the one-character pushback.  The high
+                          byte is 1 whenever a character is pending, so the
+                          word is 0 exactly when the slot is empty - a NUL in
+                          the input would otherwise be indistinguishable from
+                          "nothing pushed back".  Byte 30 is unused. *)
+   D_END   = 31 ;     (* the block is padded to here *)
+
+   (* THE LOAD BIAS.  Everything above is an offset into the image as this
+      compiler numbers it: the entry JMP is at image 0.  But a DOS .COM is
+      NOT loaded at that offset - DOS loads it at CS:0100, because 0000..00FF
+      is the PSP - so image offset K lives at CS:(K + 0100h), and since a
+      .COM has CS = DS, every address baked into the image as a literal has
+      to carry that +0100h.
+
+      Getting this wrong is the single most confusing failure this compiler
+      can have, because nothing crashes.  The entry JMP is *relative*, so it
+      still lands on the prologue; every CALL is relative, so every call
+      still lands on the right runtime entry; initmem still runs and still
+      zeroes the data area.  The program therefore starts, runs, and prints
+      its first field correctly - and then a $ that is 0100h too low resolves
+      to code instead of to data, so the string walk runs on through the
+      runtime's own instructions until it happens to hit a 24h.  That is
+      exactly what writeln('hi') did: `hi' out of wrtin's inline data, then
+      249 bytes of runtime machine code, stopping only at the '$' inside
+      "TRUE$".  See tests/fixtures/t02_writeln.out.
+
+      The bias is a CONSTANT, not a variable to be relocated at load time,
+      because the 8086 has no way to relocode an image in place and no way
+      to set a segment register to a sub-paragraph boundary.  It is the same
+      +0100h the original's own output has: TPSRC7 opendest copies the
+      generated image to 100h and runs it there, so the original's addresses
+      are 0100h-relative too.  The difference is only in how the bytes are
+      *numbered* - ours are 0-based in the file, the original's are
+      segment-relative - and this constant is where that difference stops.
+
+   The constant itself is declared in Runtime.def, because that is the only
+   declaration site a compiler can import - an implementation module does not
+   re-declare what its definition module already declared. *)
 
 
 TYPE
 TYPE
    LblRec = RECORD
    LblRec = RECORD
@@ -59,6 +115,7 @@ VAR
    fix    : ARRAY [0..MaxFix - 1] OF FixRec ;
    fix    : ARRAY [0..MaxFix - 1] OF FixRec ;
    nfix   : CARDINAL ;
    nfix   : CARDINAL ;
    dataAt : CARDINAL ;
    dataAt : CARDINAL ;
+   rtBase : CARDINAL ;
    built  : BOOLEAN ;
    built  : BOOLEAN ;
    entNm  : ARRAY [0..13] OF ARRAY [0..MaxNm] OF CHAR ;
    entNm  : ARRAY [0..13] OF ARRAY [0..MaxNm] OF CHAR ;
 
 
@@ -293,6 +350,8 @@ END CmpArgW0 ;
 PROCEDURE CmpSiBx ; BEGIN B (39H) ; B (0DEH) END CmpSiBx ;  (* 39 DE: CMP SI,BX *)
 PROCEDURE CmpSiBx ; BEGIN B (39H) ; B (0DEH) END CmpSiBx ;  (* 39 DE: CMP SI,BX *)
 PROCEDURE CmpCxDx ; BEGIN B (39H) ; B (0D1H) END CmpCxDx ;  (* 11 010 001 *)
 PROCEDURE CmpCxDx ; BEGIN B (39H) ; B (0D1H) END CmpCxDx ;  (* 11 010 001 *)
 PROCEDURE CmpDiCx ; BEGIN B (39H) ; B (0CFH) END CmpDiCx ;  (* 11 001 111 *)
 PROCEDURE CmpDiCx ; BEGIN B (39H) ; B (0CFH) END CmpDiCx ;  (* 11 001 111 *)
+PROCEDURE CmpBx0  ; BEGIN B (83H) ; B (0FBH) ; B (0) END CmpBx0 ;
+PROCEDURE CmpCxV  (v : CARDINAL ) ; BEGIN B (83H) ; B (0F9H) ; B (v) END CmpCxV ;
 
 
 PROCEDURE AddDl (v : CARDINAL ) ; BEGIN B (80H) ; B (0C2H) ; B (v) END AddDl ;
 PROCEDURE AddDl (v : CARDINAL ) ; BEGIN B (80H) ; B (0C2H) ; B (v) END AddDl ;
 PROCEDURE SubAl (v : CARDINAL ) ; BEGIN B (2CH) ; B (v) END SubAl ;
 PROCEDURE SubAl (v : CARDINAL ) ; BEGIN B (2CH) ; B (v) END SubAl ;
@@ -303,11 +362,47 @@ PROCEDURE MulBx ; BEGIN B (0F7H) ; B (0E3H) END MulBx ;   (* 11 100 011 *)
 
 
 PROCEDURE MovAh (v : CARDINAL ) ; BEGIN B (0B4H) ; B (v) END MovAh ;
 PROCEDURE MovAh (v : CARDINAL ) ; BEGIN B (0B4H) ; B (v) END MovAh ;
 PROCEDURE MovDl (v : CARDINAL ) ; BEGIN B (0B2H) ; B (v) END MovDl ;
 PROCEDURE MovDl (v : CARDINAL ) ; BEGIN B (0B2H) ; B (v) END MovDl ;
-PROCEDURE MovBxV (v : CARDINAL ) ; BEGIN B (0BBH) ; W (v) END MovBxV ;
-PROCEDURE MovCxV (v : CARDINAL ) ; BEGIN B (0B9H) ; W (v) END MovCxV ;
-PROCEDURE MovDxV (v : CARDINAL ) ; BEGIN B (0BAH) ; W (v) END MovDxV ;
-PROCEDURE MovBxD (delta : CARDINAL ) ; BEGIN B (0BBH) ; Dd (delta) END MovBxD ;
-PROCEDURE MovDxD (delta : CARDINAL ) ; BEGIN B (0BAH) ; Dd (delta) END MovDxD ;
+(* The runtime's own data block, addressed by absolute image address.  Four
+   emitters cover it, and the difference between them is the whole subject of
+   the mistakes recorded below, so they are named systematically:
+
+       Mov<reg>Vx   MOV reg, Vx      -- reg  := the ADDRESS   (BB / BA + Dd)
+       Ld<reg>Vx    MOV reg, [Vx]    -- reg  := the CONTENTS (8B 1E + Dd)
+       StVx<reg>    MOV [Vx], reg    -- [Vx] := reg          (89 1E + Dd)
+
+   "Vx" is the operand word for "a 16-bit absolute address into the data
+   block", spelled with a V precisely so it cannot be confused with a base
+   register: on the 8086 there is no [BX] memory form, so the address has to
+   go through mod=00 / rm=110, and rm=111 is [BX+SI] - a different, perfectly
+   decodable instruction.  tests/audit_helpers.py checks all four.
+
+   The two traps here, both of which happened:
+
+   - Dd versus W.  Dd turns a D_ offset into a FIXUP, so the address can be
+     placed only once the data block has been given its final position in the
+     image.  W emits the number as it stands.  The two produce the SAME
+     instruction, and getch once used MovBxImm where it wanted the address, so
+     `MOV BX,D_PUSH' came out as `MOV BX,001Ch' - the raw offset 1Ch, inside
+     the entry JMP.  Non-zero, so the pushback slot was never examined, getch
+     served a byte of the runtime's own code forever, and readln hung.  Hence
+     the Imm suffix: the raw-immediate emitters are named for what they do.
+
+   - address versus contents.  LdBxVx and MovBxVx are both `8B`/`BB` + Dd to
+     the eye and completely different instructions.  getch's first cut used
+     MovBxVx where it wanted the contents, so it tested the ADDRESS for zero,
+     found 02AFh every time, and returned the low byte of memory 02AFh - 0,
+     the value it had just stored there - instead of asking DOS. *)
+PROCEDURE MovBxImm (v : CARDINAL ) ; BEGIN B (0BBH) ; W (v) END MovBxImm ;
+PROCEDURE MovCxImm (v : CARDINAL ) ; BEGIN B (0B9H) ; W (v) END MovCxImm ;
+PROCEDURE MovDxImm (v : CARDINAL ) ; BEGIN B (0BAH) ; W (v) END MovDxImm ;
+PROCEDURE MovBxVx (delta : CARDINAL ) ; BEGIN B (0BBH) ; Dd (delta) END MovBxVx ;
+PROCEDURE MovDxVx (delta : CARDINAL ) ; BEGIN B (0BAH) ; Dd (delta) END MovDxVx ;
+PROCEDURE LdBxVx (delta : CARDINAL ) ;
+(* 8B 1E lo hi: BX := WORD PTR [Vx] - mod=00 / rm=110, the only 16-bit form
+   that can carry a bare absolute address. *)
+BEGIN
+   B (8BH) ; B (01EH) ; Dd (delta)
+END LdBxVx ;
 
 
 PROCEDURE MovSiAx  ; BEGIN B (8BH) ; B (0F0H) END MovSiAx ;  (* 8B F0: MOV SI,AX *)
 PROCEDURE MovSiAx  ; BEGIN B (8BH) ; B (0F0H) END MovSiAx ;  (* 8B F0: MOV SI,AX *)
 PROCEDURE MovAxDi  ; BEGIN B (8BH) ; B (0C7H) END MovAxDi ;   (* 11 000 111 *)
 PROCEDURE MovAxDi  ; BEGIN B (8BH) ; B (0C7H) END MovAxDi ;   (* 11 000 111 *)
@@ -335,20 +430,69 @@ PROCEDURE MovAlDh  ; BEGIN B (8AH) ; B (0C6H) END MovAlDh ;  (* 8A C6: AL:=DH *)
 PROCEDURE MovDlSi  ; BEGIN B (8AH) ; B (14H) END MovDlSi ;
 PROCEDURE MovDlSi  ; BEGIN B (8AH) ; B (14H) END MovDlSi ;
 PROCEDURE MovDlArg ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg ;  (* DL:=[BP+2] *)
 PROCEDURE MovDlArg ; BEGIN B (8AH) ; B (56H) ; B (2) END MovDlArg ;  (* 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.
+   DL is the natural register for the digit scratch in wrint and for a stack
+   argument in wrchar, and both of them were loading DL and then calling
+   AH=02h - which printed whatever happened to be left in AL.  For wrint that
+   was the QUOTIENT's low byte from the preceding DIV, so writeln(1) printed a
+   NUL and writeln(2) printed a NUL, and a two-digit number printed its
+   quotient instead of itself.  A wrong register is not a wrong encoding: the
+   golden bytes were right, `mov dl,[si]' is a perfectly good instruction, and
+   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 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 *)
 PROCEDURE StDiAx   ; BEGIN B (89H) ; B (5H) END StDiAx ;    (* 89 05: [DI]:=AX *)
-PROCEDURE StDiBx   ; BEGIN B (89H) ; B (1DH) END StDiBx ;   (* 89 1D: [DI]:=BX *)
-PROCEDURE StBxCx   ; BEGIN B (89H) ; B (0FH) END StBxCx ;   (* 89 0F: [BX]:=CX *)
 PROCEDURE StSiDl   ; BEGIN B (88H) ; B (14H) END StSiDl ;   (* 88 14: [SI]:=DL *)
 PROCEDURE StSiDl   ; BEGIN B (88H) ; B (14H) END StSiDl ;   (* 88 14: [SI]:=DL *)
-PROCEDURE StBxDl   ; BEGIN B (88H) ; B (17H) END StBxDl ;   (* 88 17: [BX]:=DL *)
-PROCEDURE MovDhAl  ; BEGIN B (88H) ; B (0C6H) END MovDhAl ;
+PROCEDURE StDiDl   ; BEGIN B (88H) ; B (15H) END StDiDl ;   (* 88 15: [DI]:=DL *)
+PROCEDURE StDiCx   ; BEGIN B (89H) ; B (0DH) END StDiCx ;   (* 89 0D: [DI]:=CX *)
+PROCEDURE MovDiBp4 ; BEGIN B (8BH) ; B (7EH) ; B (4) END MovDiBp4 ; (* DI:=[BP+4] *)
+
+(* There is NO "store through BX" instruction on the 8086, and writing one
+   anyway is the single most expensive mistake this file has produced, so the
+   reasoning is recorded rather than left in the probe history.
+
+   In 16-bit addressing the r/m column is a LOCATION, not a register list: for
+   mod=00, rm=000..101 are [BX+SI] [BX+DI] [BP+SI] [BP+DI] [SI] [DI], and
+   rm=110 is the only one that means "a direct displacement".  rm=111 is
+   [BX+SI], NOT [BX].  So `89 1D' - the ModR/M the first version of this used
+   for "MOV [BX],CX" - is mod=00 reg=BX r/m=DI, i.e. MOV [DI],BX: the two
+   operands swapped AND [BX] inexpressible.  GNU as and objdump in -m i8086
+   agree, and the bytes are perfectly well formed, which is why nothing
+   objected.  What the program saw was its own address being written over the
+   interrupt vector table, and the variable it was asked to fill left holding
+   whatever the loader put there: readln(n) then printed 0, and readln(c)
+   printed a NUL.  Correct by comparison: the caller puts the address in BX,
+   and the store has to go through DI or SI, so the sequence is
+   MOV DI,[BP+4] / MOV [DI],<value> - see StoreThrough below. *)
 PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C2H) END MovDlAl ;
 PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C2H) END MovDlAl ;
+PROCEDURE XchgAxDi ; BEGIN B (87H) ; B (0C7H) END XchgAxDi ; (* 87 C7: AX<->DI *)
+PROCEDURE AddAxDi  ; BEGIN B (03H) ; B (0C7H) END AddAxDi ;  (* 03 C7: AX:=AX+DI *)
 PROCEDURE MovSiBx  ; BEGIN B (89H) ; B (0DEH) END MovSiBx ;  (* 89 DE: MOV SI,BX *)
 PROCEDURE MovSiBx  ; BEGIN B (89H) ; B (0DEH) END MovSiBx ;  (* 89 DE: MOV SI,BX *)
 PROCEDURE MovDiDx  ; BEGIN B (89H) ; B (0D7H) END MovDiDx ;
 PROCEDURE MovDiDx  ; BEGIN B (89H) ; B (0D7H) END MovDiDx ;
 PROCEDURE MovDiAx  ; BEGIN B (89H) ; B (0C7H) END MovDiAx ;
 PROCEDURE MovDiAx  ; BEGIN B (89H) ; B (0C7H) END MovDiAx ;
 PROCEDURE AddDiAx  ; BEGIN B (1H) ; B (0C7H) END AddDiAx ;
 PROCEDURE AddDiAx  ; BEGIN B (1H) ; B (0C7H) END AddDiAx ;
 PROCEDURE IncBx    ; BEGIN B (43H) END IncBx ;
 PROCEDURE IncBx    ; BEGIN B (43H) END IncBx ;
-PROCEDURE MovAlBx  ; BEGIN B (8AH) ; B (07H) END MovAlBx ;  (* 8A 07: AL:=[BX] *)
+PROCEDURE LdAlBx   ; BEGIN B (8AH) ; B (07H) END LdAlBx ;   (* 8A 07: AL:=[BX] *)
+PROCEDURE MovAlBl  ; BEGIN B (8AH) ; B (0C3H) END MovAlBl ; (* 8A C3: AL:=BL *)
+(* 8A 07 and 8A C3 are one letter apart and do opposite things: the
+   first reads the byte AT the pointer in BX, the second takes the low
+   byte OF BX.  getch's pushback path wanted the second and used the
+   first, so it read memory 010Ah - the address of the character - and
+   returned whatever was there.  Hence the Ld/Mov split. *)
 PROCEDURE MovClBx  ; BEGIN B (8AH) ; B (0FH) END MovClBx ;  (* 8A 0F: CL:=[BX] *)
 PROCEDURE MovClBx  ; BEGIN B (8AH) ; B (0FH) END MovClBx ;  (* 8A 0F: CL:=[BX] *)
+PROCEDURE XorBxBx  ; BEGIN B (31H) ; B (0DBH) END XorBxBx ;  (* 31 DB: BX:=0 *)
+PROCEDURE MovBlDl  ; BEGIN B (8AH) ; B (0DAH) END MovBlDl ;  (* 8A DA: BL:=DL *)
+PROCEDURE StVxBx   (delta : CARDINAL ) ;
+(* 89 1E lo hi: MOV [Vx],BX - the mirror of LdBxVx above, and it must use the
+   same mod=00 / rm=110 and the same Dd fixup kind, or one of the two
+   addresses a different place. *)
+BEGIN
+   B (89H) ; B (01EH) ; Dd (delta)
+END StVxBx ;
 PROCEDURE JmpBx    ; BEGIN B (0FFH) ; B (0E3H) END JmpBx ;  (* FF E3: JMP BX *)
 PROCEDURE JmpBx    ; BEGIN B (0FFH) ; B (0E3H) END JmpBx ;  (* FF E3: JMP BX *)
 (* JE 74  JNE 75  JB 72  JBE 76  JGE 7D *)
 (* JE 74  JNE 75  JB 72  JBE 76  JGE 7D *)
 PROCEDURE Je8  (nm : ARRAY OF CHAR ) ; BEGIN Jcc (74H, nm) END Je8 ;
 PROCEDURE Je8  (nm : ARRAY OF CHAR ) ; BEGIN Jcc (74H, nm) END Je8 ;
@@ -416,14 +560,50 @@ END EmitStackChk ;
 
 
 PROCEDURE EmitGetCh ;
 PROCEDURE EmitGetCh ;
 (* AL = next character, 1Ah at end of input.  INT 21h AH=08h reads without
 (* AL = next character, 1Ah at end of input.  INT 21h AH=08h reads without
-   echoing, so a redirected stdin behaves the same as a keyboard. *)
+   echoing, so a redirected stdin behaves the same as a keyboard.
+
+   This is TP3's `getbyte' (TPSRC4:94), not a bare INT 21h.  getbyte serves a
+   buffered character from the file record without advancing the buffer
+   pointer when the "char pre-read" flag ($02) is set, and readnum sets that
+   flag on the character that ENDS a number - the terminating blank is *not*
+   consumed.  xreadln is then called and it is the xreadln that consumes the
+   newline.
+
+   This matters because `readln(n)' is emitted as xrdint followed by xreadln
+   (TPSRC8 prdrdln emits xreadln whenever rdlnflg is set).  A getch that
+   always consumed its character left xreadln starting on the *following*
+   line, so `readln(n); readln(c)' read the char from line 3 of the input.
+   One byte of pushback reproduces the flag. *)
 BEGIN
 BEGIN
    M ("getch") ;
    M ("getch") ;
+   LdBxVx (D_PUSH) ;             (* BX := the slot's contents *)
+   CmpBx0 ;
+   Je8 ("gc_dos") ;
+   MovAlBl ;                        (* AL := the pending character, i.e. the
+                                      low byte OF BX - not the byte at [BX] *)
+   XorBxBx ;
+   StVxBx (D_PUSH) ;                (* and empty the slot *)
+   RetR ;
+   M ("gc_dos") ;
    MovAh (8) ;
    MovAh (8) ;
    Int21 ;
    Int21 ;
    RetR
    RetR
 END EmitGetCh ;
 END EmitGetCh ;
 
 
+PROCEDURE EmitUnGetCh ;
+(* put AL back, so the next getch returns it again.  TP3 does this by
+   rewinding the buffer pointer; a one-byte slot is the same thing. *)
+BEGIN
+   M ("ungetch") ;
+   MovDlAl ;
+   MovBxImm (1) ;                      (* BH := 1 marks the slot occupied, so
+                                         a pushed-back NUL is still a
+                                         pushed-back NUL *)
+   MovBlDl ;
+   StVxBx (D_PUSH) ;
+   RetR
+END EmitUnGetCh ;
+
 PROCEDURE EmitWrInt ;
 PROCEDURE EmitWrInt ;
 (* one signed 16-bit value on the stack.  Div CX gives the remainder in DX,
 (* one signed 16-bit value on the stack.  Div CX gives the remainder in DX,
    which is turned into a digit and stored backwards from the end of the
    which is turned into a digit and stored backwards from the end of the
@@ -435,12 +615,12 @@ BEGIN
    CmpAx0 ;
    CmpAx0 ;
    Jge8 ("wi_pos") ;
    Jge8 ("wi_pos") ;
    PushAx ;
    PushAx ;
-   MovDl (ORD ("-")) ; MovAh (2) ; Int21 ;
+   MovAlD (ORD ("-")) ; MovAh (2) ; Int21 ;
    PopAx ;
    PopAx ;
    NegAx ;
    NegAx ;
    M ("wi_pos") ;
    M ("wi_pos") ;
-   MovCxV (10) ;
-   MovBxD (D_NUM + 8) ;    (* BX = one past the last digit *)
+   MovCxImm (10) ;
+   MovBxVx (D_NUM + 8) ;    (* BX = one past the last digit *)
    MovSiBx ;
    MovSiBx ;
    M ("wi_dig") ;
    M ("wi_dig") ;
    XorDxDx ;
    XorDxDx ;
@@ -453,7 +633,7 @@ BEGIN
    M ("wi_out") ;
    M ("wi_out") ;
    CmpSiBx ;
    CmpSiBx ;
    Je8 ("wi_done") ;
    Je8 ("wi_done") ;
-   MovDlSi ;
+   MovAlSi ;                  (* the digit: AH=02h wants it in AL *)
    MovAh (2) ; Int21 ;
    MovAh (2) ; Int21 ;
    IncSi ;
    IncSi ;
    J8 ("wi_out") ;
    J8 ("wi_out") ;
@@ -464,11 +644,12 @@ END EmitWrInt ;
 PROCEDURE EmitWrChar ;
 PROCEDURE EmitWrChar ;
 (* the low byte of the one 16-bit argument, which sits above the return
 (* 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
    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. *)
+   the stack pointer and is handed straight back before the RET.
+   AL, not DL: see the AL twins above. *)
 BEGIN
 BEGIN
    M ("wrchar") ;
    M ("wrchar") ;
    MovBpSp ;
    MovBpSp ;
-   MovDlArg ;
+   MovAlArg ;
    MovSpBp ;
    MovSpBp ;
    MovAh (2) ;
    MovAh (2) ;
    Int21 ;
    Int21 ;
@@ -501,7 +682,7 @@ BEGIN
    MovAh (2) ;                       (* INT 21h/02h: put character, AL *)
    MovAh (2) ;                       (* INT 21h/02h: put character, AL *)
    Jcxz8 ("wn_end") ;                (* empty string -> nothing to do *)
    Jcxz8 ("wn_end") ;                (* empty string -> nothing to do *)
    M ("wn_loop") ;
    M ("wn_loop") ;
-   MovAlBx ;                         (* AL := next character *)
+   LdAlBx ;                         (* AL := next character *)
    Int21 ;                           (* (preserves every register but AL) *)
    Int21 ;                           (* (preserves every register but AL) *)
    IncBx ;
    IncBx ;
    Loop8 ("wn_loop") ;
    Loop8 ("wn_loop") ;
@@ -515,10 +696,10 @@ BEGIN
    M ("wrbool") ;
    M ("wrbool") ;
    CmpArgW0 ;
    CmpArgW0 ;
    Jne8 ("wb_t") ;
    Jne8 ("wb_t") ;
-   MovDxD (D_FALSE) ;
+   MovDxVx (D_FALSE) ;
    J8 ("wb_o") ;
    J8 ("wb_o") ;
    M ("wb_t") ;
    M ("wb_t") ;
-   MovDxD (D_TRUE) ;
+   MovDxVx (D_TRUE) ;
    M ("wb_o") ;
    M ("wb_o") ;
    MovAh (9) ;
    MovAh (9) ;
    Int21 ;
    Int21 ;
@@ -528,7 +709,7 @@ END EmitWrBool ;
 PROCEDURE EmitWrLn ;
 PROCEDURE EmitWrLn ;
 BEGIN
 BEGIN
    M ("wrln") ;
    M ("wrln") ;
-   MovDxD (D_CRLF) ;
+   MovDxVx (D_CRLF) ;
    MovAh (9) ;
    MovAh (9) ;
    Int21 ;
    Int21 ;
    RetR
    RetR
@@ -540,26 +721,43 @@ PROCEDURE EmitWrReal ;
    marker beats printing the mantissa as an integer. *)
    marker beats printing the mantissa as an integer. *)
 BEGIN
 BEGIN
    M ("wrreal") ;
    M ("wrreal") ;
-   MovDxD (D_REAL) ;
+   MovDxVx (D_REAL) ;
    MovAh (9) ;
    MovAh (9) ;
    Int21 ;
    Int21 ;
    RetR
    RetR
 END EmitWrReal ;
 END EmitWrReal ;
 
 
 PROCEDURE EmitRdInt ;
 PROCEDURE EmitRdInt ;
-(* address on the stack; skips leading blanks, takes an optional sign, then
-   digits, stopping *before* the delimiter so the following TU_RdLn throws
-   away the rest of the line.  Sign in CX, value in DI. *)
+(* address on the stack.  A transcription of TP3 TPSRC4 xrdint/readnum:
+
+      rnspace:  getbyte ; ^Z ? -> rnend
+                consume ; <= 20h ? -> rnspace
+      rndig:    [buf]=char ; getbyte ; <= 20h ? -> rnend   (NOT consumed)
+                consume ; -> rndig
+      rnend:    buf[0] := 0
+
+   Two behaviours are load-bearing and neither is obvious:
+
+   - the character that ENDS the scan is left pending, because readnum tests
+     it *before* clearing getbyte's pre-read flag.  xreadln, which the
+     compiler emits next for a readln, is what consumes the newline.  See the
+     note on getch.
+
+   - ^Z before any digit reaches rnend with "nothing entered", and xrdint
+     (JZ rdierr) then returns WITHOUT touching the variable.  CX carries both
+     the sign and that state: 0 = positive, 1 = negative, 2 = nothing read.
+
+   Sign in CX, value in DI. *)
 BEGIN
 BEGIN
    M ("rdint") ;
    M ("rdint") ;
    PushBp ; MovBpSp ;
    PushBp ; MovBpSp ;
    PushAx ; PushBx ; PushCx ; PushDx ; PushDi ;
    PushAx ; PushBx ; PushCx ; PushDx ; PushDi ;
-   M ("ri_skip") ;
+   M ("ri_lead") ;
    C8 ("getch") ;
    C8 ("getch") ;
-   CmpAl (ORD (" ")) ; Je8 ("ri_skip") ;
-   CmpAl (9) ;         Je8 ("ri_skip") ;
-   CmpAl (13) ;        Je8 ("ri_skip") ;
-   CmpAl (10) ;        Je8 ("ri_skip") ;
+   CmpAl (26) ;                      (* ^Z: end of input *)
+   Je8 ("ri_none") ;
+   CmpAl (20) ;                      (* every control char and the space *)
+   Jbe8 ("ri_lead") ;
    XorCxCx ;
    XorCxCx ;
    CmpAl (ORD ("-")) ;
    CmpAl (ORD ("-")) ;
    Jne8 ("ri_nos") ;
    Jne8 ("ri_nos") ;
@@ -578,23 +776,43 @@ BEGIN
    CmpAl (ORD ("9")) ;
    CmpAl (ORD ("9")) ;
    Ja8 ("ri_done") ;
    Ja8 ("ri_done") ;
    SubAl (ORD ("0")) ;
    SubAl (ORD ("0")) ;
-   MovDhAl ;                (* keep the digit across the multiply *)
-   MovAxDi ;
-   MovBxV (10) ;
-   MulBx ;                  (* DX:AX := DI * 10 *)
+   MovAh (0) ;              (* AX := the digit, 0..9 *)
+   XchgAxDi ;               (* AX := the value so far, DI := the digit.
+                               The digit has to survive MUL, and the 8-bit
+                               reg field of 8A/88 has only AL/CL/DL/BL/AH/
+                               CH/DH/BH - there is no SI or DI byte to park
+                               it in.  MUL r/m16 writes DX, so the previous
+                               version's "keep the digit across the multiply"
+                               comment was describing a register the multiply
+                               owns.  Every read of an integer therefore
+                               returned 0: the digit was computed, moved to
+                               DH, and multiplied away one instruction later. *)
+   MovBxImm (10) ;
+   MulBx ;                  (* DX:AX := value * 10 *)
+   AddAxDi ;                (* AX := low word + digit.  A carry out of bit 15
+                               is dropped, which is what a 16-bit INTEGER
+                               does anyway - the high word of the product is
+                               never stored. *)
    MovDiAx ;
    MovDiAx ;
-   MovAh (0) ;
-   MovAlDh ;
-   AddDiAx ;
    C8 ("getch") ;
    C8 ("getch") ;
    J8 ("ri_dig") ;
    J8 ("ri_dig") ;
    M ("ri_done") ;
    M ("ri_done") ;
+   C8 ("ungetch") ;                 (* the delimiter stays pending *)
+   CmpCxV (2) ;
+   Je8 ("ri_out") ;
    CmpCx0 ;
    CmpCx0 ;
    Je8 ("ri_st") ;
    Je8 ("ri_st") ;
    NegDi ;
    NegDi ;
    M ("ri_st") ;
    M ("ri_st") ;
-   MovBxBp4 ;
-   StDiBx ;
+   MovAxDi ;                    (* the parsed value out of DI, into AX... *)
+   MovDiBp4 ;                  (* ...the caller's address into DI... *)
+   StDiAx ;                    (* ...and store.  The three are needed
+                                  because there is no [BX] form; see the
+                                  note on the store emitters. *)
+   M ("ri_out") ;
+   PopDi ; PopDx ; PopCx ; PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR ;
+   M ("ri_none") ;               (* ^Z first: leave the variable alone *)
    PopDi ; PopDx ; PopCx ; PopBx ; PopAx ;
    PopDi ; PopDx ; PopCx ; PopBx ; PopAx ;
    MovSpBp ; PopBp ; RetR
    MovSpBp ; PopBp ; RetR
 END EmitRdInt ;
 END EmitRdInt ;
@@ -603,12 +821,12 @@ PROCEDURE EmitRdChar ;
 BEGIN
 BEGIN
    M ("rdchar") ;
    M ("rdchar") ;
    PushBp ; MovBpSp ;
    PushBp ; MovBpSp ;
-   PushAx ; PushBx ;
+   PushAx ; PushBx ; PushDi ;
    C8 ("getch") ;
    C8 ("getch") ;
    MovDlAl ;
    MovDlAl ;
-   MovBxBp4 ;
-   StBxDl ;
-   PopBx ; PopAx ;
+   MovDiBp4 ;                  (* no [BX] form: the address goes in DI *)
+   StDiDl ;
+   PopDi ; PopBx ; PopAx ;
    MovSpBp ; PopBp ; RetR
    MovSpBp ; PopBp ; RetR
 END EmitRdChar ;
 END EmitRdChar ;
 
 
@@ -618,7 +836,7 @@ PROCEDURE EmitRdBool ;
 BEGIN
 BEGIN
    M ("rdbool") ;
    M ("rdbool") ;
    PushBp ; MovBpSp ;
    PushBp ; MovBpSp ;
-   PushAx ; PushBx ; PushCx ;
+   PushAx ; PushBx ; PushCx ; PushDi ;
    C8 ("getch") ;
    C8 ("getch") ;
    XorCxCx ;
    XorCxCx ;
    CmpAl (ORD ("T")) ; Je8 ("rb_t") ;
    CmpAl (ORD ("T")) ; Je8 ("rb_t") ;
@@ -630,9 +848,10 @@ BEGIN
    M ("rb_t") ;
    M ("rb_t") ;
    IncCx ;
    IncCx ;
    M ("rb_s") ;
    M ("rb_s") ;
-   MovBxBp4 ;
-   StBxCx ;
-   PopCx ; PopBx ; PopAx ;
+   MovDiBp4 ;                  (* DI = the caller's address, because there is
+                                  no [BX] to store through *)
+   StDiCx ;
+   PopDi ; PopCx ; PopBx ; PopAx ;
    MovSpBp ; PopBp ; RetR
    MovSpBp ; PopBp ; RetR
 END EmitRdBool ;
 END EmitRdBool ;
 
 
@@ -652,19 +871,61 @@ BEGIN
    RetR
    RetR
 END EmitRdLn ;
 END EmitRdLn ;
 
 
+PROCEDURE Str5 (s : ARRAY OF CHAR ; n : CARDINAL) ;
+(* emit n characters, so the strings below are visibly the same length as the
+   D_ constants claim and an off-by-one in either place is a compile-time
+   mismatch rather than a wrong pointer nobody notices *)
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i < n DO
+      B (ORD (s [i])) ;
+      INC (i)
+   END
+END Str5 ;
+
 PROCEDURE EmitData ;
 PROCEDURE EmitData ;
+(* The runtime's data block.  The D_ constants name the offsets in it and are
+   ASSERTED against what is emitted here, three ways: the starting offset of
+   each string, the offset just past it, and the block's total length.  A D_
+   constant is a 16-bit immediate inside a MOV, so a wrong one is a
+   perfectly well-formed instruction that reads the wrong memory - see the
+   note on the D_ block. *)
 BEGIN
 BEGIN
    dataAt := rpos ;
    dataAt := rpos ;
+   (* Each D_ is checked BEFORE the string it names is emitted, because it
+      names that string's START. *)
+   IF dataAt + D_NUM # rpos THEN
+      HALT
+   END ;
    (* 8 bytes of scratch, never read before written *)
    (* 8 bytes of scratch, never read before written *)
    B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ;
    B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ; B (0) ;
-   B (ORD ("T")) ; B (ORD ("R")) ; B (ORD ("U")) ; B (ORD ("E")) ; B (ORD ("$")) ;
-   B (ORD ("F")) ; B (ORD ("A")) ; B (ORD ("L")) ; B (ORD ("S")) ;
-   B (ORD ("E")) ; B (ORD ("$")) ;
+   IF dataAt + D_TRUE # rpos THEN
+      HALT
+   END ;
+   Str5 ("TRUE$", 5) ;
+   IF dataAt + D_FALSE # rpos THEN
+      HALT
+   END ;
+   Str5 ("FALSE$", 6) ;
+   IF dataAt + D_CRLF # rpos THEN
+      HALT
+   END ;
    B (13) ; B (10) ; B (ORD ("$")) ;
    B (13) ; B (10) ; B (ORD ("$")) ;
-   B (ORD ("?")) ; B (ORD ("R")) ; B (ORD ("E")) ; B (ORD ("A")) ;
-   B (ORD ("L")) ; B (ORD ("?")) ;
+   IF dataAt + D_REAL # rpos THEN
+      HALT
+   END ;
+   Str5 ("?REAL?", 6) ;
+   IF dataAt + D_PUSH # rpos THEN
+      HALT
+   END ;
+   B (0) ; B (0) ;                 (* the pushback slot, empty to begin with *)
    WHILE rpos < dataAt + D_END DO
    WHILE rpos < dataAt + D_END DO
       B (0)
       B (0)
+   END ;
+   (* the total, so D_END is checked too and not just used as a pad target *)
+   IF rpos - dataAt # D_END THEN
+      HALT
    END
    END
 END EmitData ;
 END EmitData ;
 
 
@@ -674,7 +935,12 @@ BEGIN
    i := 0 ;
    i := 0 ;
    WHILE i < nfix DO
    WHILE i < nfix DO
       IF fix [i].kind = 2 THEN
       IF fix [i].kind = 2 THEN
-         t := (dataAt + fix [i].val) MOD 10000H ;
+         (* A data address, and the ONE kind that is absolute rather than
+            relative: rtBase says where the blob sits in the image, val says
+            where inside the blob, and LoadBias says where the image itself
+            sits once DOS has loaded it.  All three are needed; omitting any
+            one produces a readable-looking address into the wrong bytes. *)
+         t := (dataAt + rtBase + fix [i].val + LoadBias) MOD 10000H ;
          rt [fix [i].place] := VAL (BYTE, t MOD 100H) ;
          rt [fix [i].place] := VAL (BYTE, t MOD 100H) ;
          rt [fix [i].place + 1] := VAL (BYTE, (t DIV 100H) MOD 100H)
          rt [fix [i].place + 1] := VAL (BYTE, (t DIV 100H) MOD 100H)
       ELSE
       ELSE
@@ -698,11 +964,12 @@ END FixUp ;
 (*  public interface                                                 *)
 (*  public interface                                                 *)
 (* ---------------------------------------------------------------- *)
 (* ---------------------------------------------------------------- *)
 
 
-PROCEDURE RT_Build ;
+PROCEDURE RT_Build (base : CARDINAL) ;
 BEGIN
 BEGIN
    IF built THEN
    IF built THEN
       RETURN
       RETURN
    END ;
    END ;
+   rtBase := base ;              (* must precede every Emit*, they read it *)
    rpos := 0 ; ltop := 0 ; nfix := 0 ; dataAt := 0 ;
    rpos := 0 ; ltop := 0 ; nfix := 0 ; dataAt := 0 ;
    EmitInitMem ;
    EmitInitMem ;
    EmitEnd ;
    EmitEnd ;
@@ -710,7 +977,7 @@ BEGIN
    EmitWrInt ; EmitWrChar ; EmitWrBool ; EmitWrReal ; EmitWrLn ;
    EmitWrInt ; EmitWrChar ; EmitWrBool ; EmitWrReal ; EmitWrLn ;
    EmitWrInl ;
    EmitWrInl ;
    EmitRdInt ; EmitRdChar ; EmitRdBool ; EmitRdLn ;
    EmitRdInt ; EmitRdChar ; EmitRdBool ; EmitRdLn ;
-   EmitGetCh ;
+   EmitGetCh ; EmitUnGetCh ;
    EmitData ;
    EmitData ;
    FixUp ;
    FixUp ;
    SetStr (entNm [0], "initmem") ;
    SetStr (entNm [0], "initmem") ;
@@ -733,7 +1000,7 @@ END RT_Build ;
 PROCEDURE RT_Size () : CARDINAL ;
 PROCEDURE RT_Size () : CARDINAL ;
 BEGIN
 BEGIN
    IF NOT built THEN
    IF NOT built THEN
-      RT_Build ()
+      RT_Build (0)
    END ;
    END ;
    RETURN rpos
    RETURN rpos
 END RT_Size ;
 END RT_Size ;
@@ -741,7 +1008,7 @@ END RT_Size ;
 PROCEDURE RT_Byte (i : CARDINAL ) : BYTE ;
 PROCEDURE RT_Byte (i : CARDINAL ) : BYTE ;
 BEGIN
 BEGIN
    IF NOT built THEN
    IF NOT built THEN
-      RT_Build ()
+      RT_Build (0)
    END ;
    END ;
    IF i >= rpos THEN
    IF i >= rpos THEN
       RETURN 0
       RETURN 0
@@ -752,18 +1019,18 @@ END RT_Byte ;
 PROCEDURE RT_Entry (i : CARDINAL ) : CARDINAL ;
 PROCEDURE RT_Entry (i : CARDINAL ) : CARDINAL ;
 BEGIN
 BEGIN
    IF NOT built THEN
    IF NOT built THEN
-      RT_Build ()
+      RT_Build (0)
    END ;
    END ;
    IF i > 13 THEN
    IF i > 13 THEN
       RETURN 0
       RETURN 0
    END ;
    END ;
-   RETURN LblOff (entNm [i])
+   RETURN rtBase + LblOff (entNm [i])
 END RT_Entry ;
 END RT_Entry ;
 
 
 PROCEDURE RT_CodeEnd () : CARDINAL ;
 PROCEDURE RT_CodeEnd () : CARDINAL ;
 BEGIN
 BEGIN
    IF NOT built THEN
    IF NOT built THEN
-      RT_Build ()
+      RT_Build (0)
    END ;
    END ;
    RETURN dataAt
    RETURN dataAt
 END RT_CodeEnd ;
 END RT_CodeEnd ;

+ 4 - 4
shell/tests/CompileTest.mod

@@ -265,10 +265,10 @@ BEGIN
 END DumpCode ;
 END DumpCode ;
 
 
 PROCEDURE DumpImage ;
 PROCEDURE DumpImage ;
-(* The whole linked image - [runtime][program header][program code] - which is
-   what a .COM would actually contain.  The runtime's own 391 bytes are shown
-   only at the head and the tail: enough to prove the blob is really in there,
-   without burying the program in 24 lines of library. *)
+(* The whole linked image - [entry JMP][runtime][program header][program code] -
+   which is what a .COM would actually contain.  The runtime's own 391 bytes
+   are shown only at the head and the tail: enough to prove the blob is really
+   in there, without burying the program in 24 lines of library. *)
 VAR i, n, rtSz, total, from, lim : CARDINAL ;
 VAR i, n, rtSz, total, from, lim : CARDINAL ;
     hx : ARRAY [0..1] OF CHAR ;
     hx : ARRAY [0..1] OF CHAR ;
 BEGIN
 BEGIN

+ 1 - 1
shell/tests/RtProbe.mod

@@ -67,7 +67,7 @@ PROCEDURE Main ;
 VAR i, n : CARDINAL ;
 VAR i, n : CARDINAL ;
     names : ARRAY [0..13] OF ARRAY [0..15] OF CHAR ;
     names : ARRAY [0..13] OF ARRAY [0..15] OF CHAR ;
 BEGIN
 BEGIN
-   RT_Build () ;
+   RT_Build (0) ;   (* base 0: the standalone probes report blob-relative *)
    PCARD (RT_Size ()) ; PS (" bytes") ; NL ;
    PCARD (RT_Size ()) ; PS (" bytes") ; NL ;
    PS ("code ends at ") ; PCARD (RT_CodeEnd ()) ; NL ;
    PS ("code ends at ") ; PCARD (RT_CodeEnd ()) ; NL ;
    i := 0 ;
    i := 0 ;

+ 536 - 19
shell/tests/audit_helpers.py

@@ -57,12 +57,135 @@ SHELL = os.path.dirname(HERE)
 sys.path.insert(0, HERE)
 sys.path.insert(0, HERE)
 import disasm16  # noqa: E402  (path set above)
 import disasm16  # noqa: E402  (path set above)
 
 
-RE_HELPER = re.compile(
-    r"^PROCEDURE\s+(\w+)\s*;\s*BEGIN\s+(.*?)\s+END\s+\1\s*;", re.MULTILINE)
+# An emitter may be declared with or without a parameter list, so the
+# parentheses are optional.  This is not cosmetic: the version that required
+# `PROCEDURE Name ;` matched ZERO of Compiler.mod's 22 emitters, because they
+# are all declared `PROCEDURE EmName () ;`.  So the audit reported "everything
+# agrees" for a module where it had examined nothing - a check that cannot fail
+# is not a check.
+RE_PROC_HEAD = re.compile(
+    r"^PROCEDURE\s+(\w+)\s*(?:\(\s*\))?\s*;", re.MULTILINE)
+
+
+RE_ANY_PROC = re.compile(r"^PROCEDURE\s+(\w+)", re.MULTILINE)
+
+# --- what the one-line grammar deliberately does NOT reach ----------------
+#
+# The name audit checks procedures whose body is a list of CONSTANT bytes, so
+# that the bytes can be disassembled and compared against the name.  Three
+# kinds of emitter cannot be checked that way, and all three are listed here
+# with their reason.  A name in this table is a documented exclusion; a name
+# that is merely absent is a finding, and the coverage check below is what
+# tells the two apart.
+#
+# 1. PARAMETERISED operands -- a byte that is a parameter, not a literal.
+#    `SubAl (v)` emits 2C v, which disassembles to "sub al, 0" whatever v is,
+#    so the name can be checked only by also trusting the source order.  The
+#    grammar could learn this (the pattern Int([0-9A-Fa-f]+) already handles
+#    the analogous case for INT); it does not yet, and until it does these are
+#    unchecked BY THE AUDIT, not verified.
+# 2. COMPUTED or DISPATCHED bytes -- a ModRM chosen by a comparison, a
+#    rel16 with a patch slot, a form that depends on which kind of variable it
+#    is.  There is no single byte sequence to disassemble.
+# 3. DATA, not code -- the B(...) calls here emit the D_ block, so running
+#    them through a disassembler yields "add [bx+si],al" and a decode error.
+#    tests/check_runtime.py pins these bytes against runtime.golden, so they
+#    are pinned; only the name-to-opcode correspondence does not apply.
+#
+# Named per module because the same name can be a one-liner in one and not the
+# other.  The Vx family in Runtime.mod is the reason a name is worth listing
+# separately: those four ARE audited, by audit_vx_helpers, because their shape
+# is fixed even though their immediate is a data offset.
+PARAM = "parameterised operand - the byte is an argument, not a literal"
+COMP = "computed or dispatched bytes - no single sequence to disassemble"
+DATA = "emits DATA (the D_ block), not instructions"
+
+NON_ONE_LINE = {
+    "Runtime.mod": {
+        "MovBxImm": PARAM, "MovCxImm": PARAM, "MovDxImm": PARAM,
+        "MovDl": PARAM, "MovAlD": PARAM, "MovAh": PARAM,
+        "SubAl": PARAM, "CmpAl": PARAM, "AddDl": PARAM, "J8": COMP,
+        "Jcc": COMP, "CmpCxV": PARAM,
+        "B": DATA, "C8": DATA,
+    },
+    "Compiler.mod": {
+        "AddSp": PARAM, "SubSp": PARAM, "Setcc": PARAM,
+        "Call": COMP, "Jcc": COMP, "JmpNear": COMP,
+        "BpDisp": COMP, "LoadVar": COMP, "StoreVar": COMP,
+        "PushVarAddr": COMP, "MovAxi": COMP, "CmpAxi": COMP,
+    },
+}
+# The Vx family is NOT here: those four are audited by audit_vx_helpers,
+# because their shape is fixed even though their immediate is a data offset.
+# They are named explicitly rather than filtered out of VX_KINDS so that this
+# table stands on its own and a name added to VX_KINDS later cannot silently
+# become unchecked - it would instead be reported, which is the point.
+_NONLINE_VX = ("LdBxVx", "MovBxVx", "MovDxVx", "StVxBx")
+
+
+
+def scan_emitters(src):
+    """Yield (name, text) for every PROCEDURE, however it is written.
+
+    The INVENTORY side of the coverage check, and deliberately as dumb as it
+    can be: a name at the start of a line, and everything up to the next one.
+    It does not check the parameter list, does not look for BEGIN, does not
+    care where comments are, and cannot be defeated by the same mistake twice.
+
+    The reason it exists at all is that the other list - the one the audit
+    actually reads - is produced by find_helpers, which is a real parser and
+    therefore fails on real inputs (a parameter list it did not expect, a
+    comment in the wrong place).  Comparing a parser against itself finds
+    nothing; comparing it against a scan that cannot parse anything finds
+    exactly the cases where the parser is the thing that is wrong.
+    """
+    heads = list(RE_ANY_PROC.finditer(src))
+    for k, h in enumerate(heads):
+        stop = heads[k + 1].start() if k + 1 < len(heads) else len(src)
+        yield h.group(1), src[h.end():stop]
+
+
+def find_helpers(src):
+    """Yield (name, body) for every PROCEDURE, in source order.
+
+    A documented emitter must be auditable, so text between the header and
+    BEGIN - which is where the explanatory comment belongs, and the only place
+    it can be read next to the code it describes - is allowed through.  A
+    regex that permitted it had nested quantifiers and took exponential time
+    on these files, so this splits on procedure HEADERS instead and takes the
+    text up to the next header.  That is linear, and it also cannot read one
+    procedure's bytes as another's.
+
+    The header regex is still strict about the parameter list (empty only),
+    because that is what distinguishes an emitter from a real routine.
+    """
+    heads = list(RE_PROC_HEAD.finditer(src))
+    for k, h in enumerate(heads):
+        stop = heads[k + 1].start() if k + 1 < len(heads) else len(src)
+        chunk = src[h.end():stop]
+        m = re.search(r"\bBEGIN\b(.*)\bEND\s+%s\s*;" % re.escape(h.group(1)),
+                      chunk, re.DOTALL)
+        if not m:
+            continue
+        yield h.group(1), m.group(1)
 # The H suffix is optional: the source mixes B (8AH) and B (0) for the same
 # The H suffix is optional: the source mixes B (8AH) and B (0) for the same
 # kind of literal, and a byte written without H used to be silently dropped
 # kind of literal, and a byte written without H used to be silently dropped
 # from the audit, which made three helpers look like truncated prefixes.
 # from the audit, which made three helpers look like truncated prefixes.
-RE_BYTE = re.compile(r"B\s*\(\s*([0-9A-Fa-f]+)H?\s*\)")
+#
+# BOTH B (...) and Ebyte (...) are accepted.  Runtime.mod spells a byte B(v)
+# and Compiler.mod spells the same thing Ebyte(v); the Em prefix is on the
+# *procedure* names there, not on the byte call, so the byte regex has to cover
+# both spellings or Compiler.mod's 22 emitters are silently skipped - which is
+# what happened, and how EmXchgAxCx stayed wrong for its whole life with
+# correct byte counts and a green compile matrix.
+RE_BYTE = re.compile(r"(?:\bB|\bEbyte)\s*\(\s*([0-9A-Fa-f]+)H?\s*\)")
+
+# Both modules emit bytes, so both must be swept.  Compiler.mod is where the
+# procedure-skip jump, the FOR test ordering and EmXchgAxCx (93h = XCHG BX,AX
+# where the name says XCHG AX,CX) all live: bugs that break every two-variable
+# arithmetic and comparison in every program, invisible because the audit
+# looked at Runtime.mod and found nothing wrong there.
+MODULES = ["Runtime.mod", "Compiler.mod"]
 
 
 # --- name grammar -----------------------------------------------------
 # --- name grammar -----------------------------------------------------
 #
 #
@@ -136,6 +259,80 @@ PATTERNS = [
     # --- store: name is St<base><reg>, decode puts the register last --
     # --- store: name is St<base><reg>, decode puts the register last --
     (re.compile(r"^St(%s)(%s)$" % (_BASE, _ALT)),
     (re.compile(r"^St(%s)(%s)$" % (_BASE, _ALT)),
      "mov", ["m:%(1)s", "r:%(2)s"], _STORE),
      "mov", ["m:%(1)s", "r:%(2)s"], _STORE),
+    # --- load through a bare base register: Ld<reg><base> ------------
+    # The mirror of the St pattern above, and spelled Ld rather than Mov on
+    # purpose.  `8A 07` and `8A C3` are one byte apart and do opposite
+    # things: LdAlBx reads the byte AT the pointer in BX, MovAlBl takes the
+    # low byte OF BX.  Naming the first "MovAlBx" put the two one letter
+    # apart, and getch's pushback path used the wrong one - it read memory
+    # 010Ah, the address of the character, instead of the character.  So the
+    # grammar is part of the safety: Ld* may only be spelled for a memory
+    # source, which is what distinguishes it from Mov*<reg><lowreg>.
+    (re.compile(r"^Ld(%s)(%s)$" % (_ALT, _BASE)),
+     "mov", ["r:%(1)s", "m:%(2)s"], (0x8A, 0x8B)),
+    # --- XCHG is symmetric, so the name's operand order carries no
+    #     information and must not be checked positionally.  87 /r is
+    #     XCHG r/m16, r16, so FCML always prints the r/m operand first:
+    #     87 C7 - reg=AX, rm=DI - decodes as "xchg di,ax" whichever way the
+    #     author thought about it.  What the audit is really for here is the
+    #     PAIR: XchgAxDi must not come out as XCHG AX,CX.  So the two
+    #     registers are compared as a set (see the "rx:" handling below).
+    (re.compile(r"^Xchg(%s)(%s)$" % (_ALT, _ALT)),
+     "xchg", ["rx:%(1)s|%(2)s"], {0x87, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96,
+                                  0x97}),
+    # --- the accumulator-implicit XCHGs, 91h..97h, join the /r form -----
+    # One byte, no ModRM, both registers fixed by the opcode.  They are in
+    # the same opcode set because the same `rx:` set comparison decides them,
+    # and that comparison is exactly what catches 93h (XCHG BX,AX) under the
+    # name XchgAxCx - the fault that broke every two-variable arithmetic
+    # operation in every program.  90h is deliberately absent: it is NOP, and
+    # FCML decodes it as "nop", not as an XCHG AX,AX.
+    #
+    # --- MOV r8, imm8 : B0+reg, and the AH-specific B4 form -----------
+    # MOV AH,imm8 is B4 imm8, which is not B0+reg.  The name says which
+    # register, so the opcode is checked against it: B4 must be AH and B0+4
+    # must not be.  (The runtime's wrchar used to load the character with the
+    # wrong register, so the store landed in AL-adjacent memory.)
+    # B4 imm8 is MOV AH,imm8 and is NOT B0+reg, so AH and AL are listed
+    # separately with their own opcodes rather than sharing one pattern that
+    # would accept B4 under the name MovAl0.
+    (re.compile(r"^MovAh(0|1)$"),
+     "mov", ["r:Ah", "i:%(1)s"], {0xB4}),
+    (re.compile(r"^MovAl(0|1)$"),
+     "mov", ["r:Al", "i:%(1)s"], {0xB0}),
+    # --- MUL/IMUL r/m16, accumulator implicit, /5 and /4 -----------------
+    # The destination is AX:DX and the operand is the named register, so
+    # "MulAxCx" means CX := CX, i.e. AX:DX := AX * CX.  FCML prints only the
+    # 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 -
+    # 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$"),
+     None, [["mov", ["r:Bp", "r:Sp"]],
+            ["cmp", ["m:Bp+2", "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
+    # with POP and given back with PUSH: an observational no-op that leaves SP
+    # where it found it.  Named MovAxSp because that is the OPERATION, and the
+    # two-step shape is recorded here because it is the only correct encoding.
+    (re.compile(r"^Mov(%s)Sp$" % _ALT),
+     None, [["pop", ["r:%(1)s"]], ["push", ["r:%(1)s"]]], None),
+    # --- IDIV: CWD then IDIV r/m16, sign-extending into DX:AX ---------
+    # A two-instruction shape, so the specs are a list of one list per
+    # instruction.  The CWD is not incidental: F7 /7 divides the 32-bit value
+    # in DX:AX, and a signed dividend is only in DX:AX if CWD ran.  Drop the
+    # 99h and the divisor goes to the same place, so the pair is checked
+    # together - which is the reason the sequence form exists.
+    # FCML prints BOTH ends of a /r divide ("idiv ax, cx"), because the
+    # accumulator is not implicit in the mnemonic the way it is for MUL, so
+    # the spec needs two operands and the AX comes first.
+    (re.compile(r"^I?Div(%s)(%s)$" % (_ALT, _ALT)),
+     None, [["cwd", []], ["idiv", ["r:%(1)s", "r:%(2)s"]]], {0xF7}),
     # --- load a register from memory, with a displacement ------------
     # --- load a register from memory, with a displacement ------------
     # The displacement makes the name unambiguous, so any load opcode works.
     # The displacement makes the name unambiguous, so any load opcode works.
     (re.compile(r"^Mov(%s)(%s)(\d+)$" % (_ALT, _BASE)),
     (re.compile(r"^Mov(%s)(%s)(\d+)$" % (_ALT, _BASE)),
@@ -145,7 +342,16 @@ PATTERNS = [
     # "MovBpSp" means BP := SP and only "MovDlSi" means DL := [SI].  The two
     # "MovBpSp" means BP := SP and only "MovDlSi" means DL := [SI].  The two
     # readings cannot both match, because one demands a register operand
     # readings cannot both match, because one demands a register operand
     # where the other demands a memory operand.
     # where the other demands a memory operand.
-    (re.compile(r"^(Mov|Cmp|Add|Sub|Xor|And|Or|Xchg)(%s)(%s)$" % (_ALT, _ALT)),
+    # Xchg is NOT in this list.  It has its own pattern above, which compares
+    # the two registers as a SET because 87 /r is symmetric and FCML always
+    # prints the r/m operand first - so the name's order carries no
+    # information and must not be checked positionally.  Listing Xchg here as
+    # well gave it a second, ordered reading, and the audit then reported
+    # "2 name readings fit the same bytes" for XchgAxCx: not a real ambiguity
+    # in the code, but two overlapping rows in the grammar saying the same
+    # thing.  A grammar that can be satisfied two ways for one name is a
+    # grammar that can be satisfied the wrong way.
+    (re.compile(r"^(Mov|Cmp|Add|Sub|Xor|And|Or)(%s)(%s)$" % (_ALT, _ALT)),
      None, ["r:%(2)s", "r:%(3)s"], None),
      None, ["r:%(2)s", "r:%(3)s"], None),
     # --- byte move from a bare base register: necessarily memory ------
     # --- byte move from a bare base register: necessarily memory ------
     # Restricted to 8A/88 on purpose: if this ever matched an 8B/89 it would
     # Restricted to 8A/88 on purpose: if this ever matched an 8B/89 it would
@@ -231,20 +437,171 @@ def operands_of(decode):
     return decode[:i].strip(), decode[i + 1:].split(",")
     return decode[:i].strip(), decode[i + 1:].split(",")
 
 
 
 
-def main(argv):
-    verbose = "-v" in argv
-    files = [a for a in argv[1:] if not a.startswith("-")]
-    path = files[0] if files else os.path.join(SHELL, "Runtime.mod")
-    with open(path) as f:
-        src = f.read()
+# --- the Vx helpers, which the one-line grammar cannot see ---------------
+#
+# find_helpers takes a body spanning any number of lines, so a helper written
+# as
+#
+#     PROCEDURE LdBxVx (delta : CARDINAL ) ;
+#     BEGIN
+#        B (8BH) ; B (01EH) ; Dd (delta)
+#     END LdBxVx ;
+#
+# does not reach the one-line pass.  That is a gap rather than a documented
+# exclusion, because these are exactly the helpers that are hard
+# to get right and easy to get subtly wrong:
+#
+#   - the operand is a D_ data-block offset, so the immediate is a FIXUP.
+#     Emitting W (delta) instead of Dd (delta) produces a byte-identical
+#     instruction with the raw offset in it: `MOV BX,001Ch` for
+#     `MOV BX,<D_PUSH>`, pointing into the entry JMP.  That happened, and
+#     the result was a hang rather than a crash.
+#   - in 16-bit mode there is no [BX] memory form, so a data address has to
+#     go through mod=00 / rm=110, which is ModRM 1E.  rm=111 is [BX+SI], and
+#     it decodes cleanly, so a wrong ModRM here is invisible to every other
+#     check in the tree.
+#   - LdBxVx and StVxBx are a load/store pair on the same word and MUST use
+#     the same ModRM and the same fixup kind, or one of them silently
+#     addresses something else.
+#   - LdBxVx and MovBxVx differ only in the ModRM's mod field, i.e. "the word
+#     at the address" versus "the address".  Both mistakes above have been
+#     made here, in this pair.
+#
+# So they get their own check, keyed on "Vx" - the operand word Runtime.mod
+# reserves for "a 16-bit absolute address into the data block" - and every one
+# of them is declared explicitly, so ADDING a Vx helper is a deliberate act:
+# an undeclared one is reported rather than ignored.
+#
+# The header, the body and the closing name are separated by `(.*?)` rather
+# than by `\s*`, because two of these helpers carry an explanatory comment
+# between the parameter list and BEGIN.  A `\s*` there skips them silently -
+# which is the failure this whole check exists to prevent - so the pattern is
+# deliberately loose about whitespace and strict about the `END <name>`.
+RE_VX = re.compile(
+    r"^PROCEDURE\s+(\w*Vx\w*)\s*\([^)]*\)\s*;.*?\bBEGIN\b(.*?)\bEND\s+\1\s*;",
+    re.MULTILINE | re.DOTALL)
+
+# (opcode, shape).  "address" = MOV reg,imm16: the register is in the opcode
+# and there is no ModRM.  "load"/"store" = opcode + ModRM 1E + the
+# Dd-supplied immediate.
+VX_KINDS = {
+    "MovBxVx": (0xBB, "address"),   # MOV BX, Vx
+    "MovDxVx": (0xBA, "address"),   # MOV DX, Vx
+    "LdBxVx":  (0x8B, "load"),      # MOV BX, WORD PTR [Vx]
+    "StVxBx":  (0x89, "store"),     # MOV WORD PTR [Vx], BX
+}
+
+
+def audit_vx_helpers(src, mod, verbose):
+    """Check the multi-line *Vx helpers.  Returns (n_checked, problems)."""
+    found = {}
+    for m in RE_VX.finditer(src):
+        found[m.group(1)] = m.group(2)
+    problems = []
+    # The count reported is the number of helpers ACTUALLY examined, not the
+    # size of VX_KINDS.  Those were the same number when only Runtime.mod was
+    # swept, and printing the constant for Compiler.mod - which defines no Vx
+    # emitter at all - made the report claim four subjects in a module that
+    # has none.  A count that does not change when the module does is a
+    # cosmetic lie, and this report exists to be believed.
+    checked = 0
+    for name in sorted(set(found) | set(VX_KINDS)):
+        # A *Vx helper is a Runtime.mod concept (the V* data word at a fixed
+        # offset).  Compiler.mod has none, so VX_KINDS is only a floor for the
+        # module that is supposed to define them; asking Compiler.mod for four
+        # Vx emitters it should not have produces four phantom failures.
+        if mod != "Runtime.mod" and name in VX_KINDS and name not in found:
+            continue
+        if name not in VX_KINDS:
+            problems.append("%s: a Vx helper with no declared kind - add it "
+                            "to VX_KINDS with its opcode and shape" % name)
+            continue
+        if name not in found:
+            # The module is named rather than hardcoded: these four live in
+            # Runtime.mod, and reporting them as missing from Compiler.mod
+            # would be a false alarm about a module that has no business
+            # defining them.
+            problems.append("%s: declared in VX_KINDS but not defined in %s"
+                            % (name, mod))
+            continue
+        checked += 1
+        body = found[name]
+        want_op, shape = VX_KINDS[name]
+        by = [int(b, 16) for b in RE_BYTE.findall(body)]
+        hexs = " ".join("%02X" % b for b in by[:2])
+        if not by or by[0] != want_op:
+            problems.append("%s: starts %s, expected opcode %02X (%s)"
+                            % (name, hexs or "<nothing>", want_op, shape))
+            continue
+        # The two shapes need different numbers of literal bytes, and asking
+        # for a ModRM on an address move is a false alarm: MOV reg,imm16
+        # carries the register in the opcode and has no ModRM at all.
+        if shape == "address":
+            if len(by) != 1:
+                problems.append("%s: emits %s, expected just the opcode - an "
+                                "address move is `MOV reg,imm16` and has no "
+                                "ModRM" % (name, hexs))
+                continue
+        elif len(by) < 2:
+            problems.append("%s: emits %s, expected opcode and ModRM"
+                            % (name, hexs))
+            continue
+        elif by[1] != 0x1E:
+            problems.append(
+                "%s: ModRM is %02X, expected 1E.  16-bit mode has no [BX] "
+                "form, so a data address must use mod=00 / rm=110; rm=111 "
+                "is [BX+SI] and decodes cleanly, so nothing else would "
+                "notice" % (name, by[1]))
+            continue
+        if "Dd" not in body:
+            problems.append(
+                "%s: does not call Dd, so the D_ offset is emitted raw "
+                "instead of as a fixup - the instruction will be "
+                "byte-correct and point at the wrong place" % name)
+            continue
+        if re.search(r"\bW\s*\(", body):
+            problems.append("%s: calls W as well as Dd - the offset must be "
+                            "placed by Dd alone" % name)
+            continue
+        if verbose:
+            print("%-20s %-16s %s (%s, Dd fixup)"
+                  % (name, hexs, shape,
+                     "no ModRM" if shape == "address" else "mod=00 rm=110"))
+    return checked, problems
 
 
+
+def sweep(src, mod, verbose):
+    """Audit every one-line emitter helper in one module.  Returns
+    (n_ok, n_unparsed, n_bad, problems)."""
     n_ok = n_unparsed = n_bad = 0
     n_ok = n_unparsed = n_bad = 0
     problems = []
     problems = []
+    seen = set()
+    # EmitData is a documented EXCLUSION, not a gap: its B(...) calls are DATA
+    # (the D_ block), not instructions, so running the code buffer through a
+    # disassembler produces nonsense like "add [bx+si],al" and a DECODE ERROR.
+    # It is listed here rather than left implicit, because an exclusion that is
+    # not written down is indistinguishable from a helper nobody audited.
+    #
+    # Its D_ offsets are asserted against this block by check_runtime.py's
+    # golden, so "excluded from the name audit" is not "unchecked": the bytes
+    # are pinned, only the name-to-opcode correspondence does not apply.
+    excluded = {"EmitData"}
     if verbose:
     if verbose:
+        print("-- %s" % mod)
         print("name                 bytes            FCML decode")
         print("name                 bytes            FCML decode")
 
 
-    for m in RE_HELPER.finditer(src):
-        name, body = m.group(1), m.group(2)
+    for name, body in find_helpers(src):
+        # Compiler.mod prefixes its emitters with Em.  The name grammar is
+        # shared with Runtime.mod and has no Em entries, so the prefix is
+        # stripped here and the module name only ever appears in output.  It
+        # is stripped for PATTERNS matching and for the Vx pass alike, which
+        # is why a single RE_BYTE and a single PATTERNS table can serve both
+        # modules instead of two drifting copies.
+        if mod != "Runtime.mod" and name.startswith("Em"):
+            name = name[2:]
+        seen.add(name)
+        if name in excluded:
+            continue
         by = [int(b, 16) for b in RE_BYTE.findall(body)]
         by = [int(b, 16) for b in RE_BYTE.findall(body)]
         if not by:
         if not by:
             continue
             continue
@@ -277,25 +634,96 @@ def main(argv):
             mm = rx.match(name)
             mm = rx.match(name)
             if not mm:
             if not mm:
                 continue
                 continue
-            if ops is not None and by[0] not in ops:
+            # The opcode gate applies to the FIRST byte.  That is right for a
+            # one-instruction helper, and wrong for a sequence, where the first
+            # byte belongs to the setup step: IDivAxCx starts with 99h (CWD)
+            # and its F7h /7 is the second instruction, so a first-byte gate
+            # rejected the one helper whose opcode matters most.  Skipping the
+            # gate for a sequence loses nothing, because the sequence check
+            # below compares every decoded mnemonic against its own spec.
+            is_seq = (isinstance(specs, list) and specs
+                      and isinstance(specs[0], list))
+            if ops is not None and not is_seq and by[0] not in ops:
                 continue
                 continue
             nopattern = False
             nopattern = False
             gmap = {str(i): g for i, g in enumerate(mm.groups(), 1)}
             gmap = {str(i): g for i, g in enumerate(mm.groups(), 1)}
             mn = pmnem
             mn = pmnem
             if mn is None:
             if mn is None:
+                # A pattern with no capture group has no mnemonic to infer
+                # from the name, and a pattern with none either names the
+                # mnemonic outright (a sequence) or is checked by operands
+                # alone.  A group-less pattern with pmnem None is a grammar
+                # bug, not a helper bug, so say so instead of crashing.
+                if not mm.re.groups:
+                    reads.append((mm, None, specs,
+                                  "pattern %r has no capture group and names "
+                                  "no mnemonic" % rx.pattern))
+                    continue
                 mn = mm.group(1).lower()
                 mn = mm.group(1).lower()
             elif "%(" in mn:
             elif "%(" in mn:
                 mn = mn % gmap
                 mn = mn % gmap
             if len(decodes) != 1:
             if len(decodes) != 1:
-                reads.append((mm, mn, specs,
-                              "emits %d instructions, name describes one"
-                              % len(decodes)))
+                # A pattern may describe a fixed SEQUENCE of instructions by
+                # passing a list of specs, one per instruction - IDIV is the
+                # only user (CWD, then IDIV r/m16), and without this it was
+                # reported as "emits 2 instructions, name describes one",
+                # which is a true observation and a useless one: the two
+                # instructions are one operation and the pair is the thing
+                # that has to be checked, because a lone F7 /7 divides by an
+                # un-sign-extended dividend.
+                if not is_seq or len(specs) != len(decodes):
+                    reads.append((mm, mn, specs,
+                                  "emits %d instructions, name describes %s"
+                                  % (len(decodes),
+                                     len(specs) if is_seq else "one")))
+                    continue
+                why = []
+                for k, sub in enumerate(specs):
+                    smn, sspecs = sub
+                    gmnem, got = decodes[k]
+                    if gmnem != smn:
+                        why.append("step %d is %r, name says %s"
+                                   % (k + 1, gmnem, smn))
+                        break
+                    if len(got) != len(sspecs):
+                        why.append("step %d: name asserts %d operand(s), "
+                                   "FCML decoded %d"
+                                   % (k + 1, len(sspecs), len(got)))
+                        break
+                    w = [x for x in
+                         (match_operand(s % gmap if "%(" in s else s, t)
+                          for s, t in zip(sspecs, got)) if x]
+                    if w:
+                        why.append("step %d: %s" % (k + 1, "; ".join(w)))
+                        break
+                reads.append((mm, mn, specs, "; ".join(why) if why else None))
                 continue
                 continue
             gmnem, got = decodes[0]
             gmnem, got = decodes[0]
             if gmnem != mn:
             if gmnem != mn:
                 reads.append((mm, mn, specs,
                 reads.append((mm, mn, specs,
                               "FCML decodes %r, name says %s" % (gmnem, mn)))
                               "FCML decodes %r, name says %s" % (gmnem, mn)))
                 continue
                 continue
+            if len(specs) == 1 and specs[0].startswith("rx:"):
+                # XCHG: compare the two registers as a set, not in order.
+                # This has to come before the operand-COUNT check, because
+                # the "rx:" spec packs both registers into one token while
+                # the decode has two operands - which is the whole point of
+                # it.
+                want = set((specs[0] % gmap)[3:].split("|"))
+                got_regs = [register_word(t) for t in got]
+                if len(got_regs) != 2 or None in got_regs:
+                    reads.append((mm, mn, specs,
+                                  "XCHG needs two bare registers, decoded %s"
+                                  % ", ".join(got)))
+                    continue
+                if set(got_regs) != want:
+                    reads.append((mm, mn, specs,
+                                  "exchanges %s, name says %s"
+                                  % (" and ".join(sorted(got_regs)),
+                                     " and ".join(sorted(want)))))
+                    continue
+                reads.append((mm, mn, specs, None))
+                continue
             if len(got) != len(specs):
             if len(got) != len(specs):
                 reads.append((mm, mn, specs,
                 reads.append((mm, mn, specs,
                               "name asserts %d operand(s), FCML decoded %d"
                               "name asserts %d operand(s), FCML decoded %d"
@@ -327,10 +755,99 @@ def main(argv):
             continue
             continue
         n_ok += 1
         n_ok += 1
 
 
-    total = n_ok + n_unparsed + n_bad
-    print("\n%d one-line emitter helpers audited: %d agree with their name, "
+    n_vx, vx_problems = audit_vx_helpers(src, mod, verbose)
+    problems.extend(vx_problems)
+    n_vx_ok = n_vx - len(vx_problems)
+
+    # Every PROCEDURE that EMITS BYTES must have been looked at.  A helper
+    # whose body the parser skipped is the failure mode that has bitten this
+    # file three times now: the Em prefix, the missing parameter list, and a
+    # comment in the wrong place - each time the suite stayed green while the
+    # helper went unexamined and the reported subject count quietly dropped.
+    #
+    # The inventory is scanned INDEPENDENTLY of find_helpers, and that is the
+    # whole design of this check.  Deriving both lists from the same parser
+    # makes it vacuous: any input that stops find_helpers from matching also
+    # removes the helper from the inventory, so the two lists agree and the
+    # check reports nothing - which is exactly the failure it exists to catch.
+    # So the inventory is a separate, deliberately dumb scan: every
+    # `PROCEDURE <name>` at the start of a line, with everything up to the
+    # next one as its text.  It makes no attempt to parse Modula-2, so it
+    # cannot fail in the same way twice.  A helper the audit cannot reach is
+    # therefore in `declared` and not in `seen`, which is a finding.
+    #
+    # The filter is the byte-call regex, so a parser, a lexer or a symbol
+    # lookup that emits nothing is not expected to be audited - only helpers
+    # that actually put bytes in the code buffer.  A helper that emitted bytes
+    # under some spelling RE_BYTE does not know would be missed here too, which
+    # is why RE_BYTE accepts both B(...) and Ebyte(...) and why a new spelling
+    # has to be added there rather than relied on to be found by accident.
+    strip = (lambda s: s[2:]) if mod != "Runtime.mod" else (lambda s: s)
+    declared = {strip(nm) for nm, txt in scan_emitters(src)
+                if RE_BYTE.search(txt)}
+    # EmitData is a documented EXCLUSION, not a gap: its B(...) calls are DATA
+    # (the D_ block), not instructions, so running the code buffer through a
+    # disassembler produces nonsense like "add [bx+si],al" and a DECODE ERROR.
+    # It is listed here rather than left implicit, because an exclusion that is
+    # not written down is indistinguishable from a helper nobody audited.
+    #
+    # Its D_ offsets are asserted against this block by check_runtime.py's
+    # golden, so "excluded from the name audit" is not "unchecked": the bytes
+    # are pinned, only the name-to-opcode correspondence does not apply.
+    excluded = {"EmitData"}
+    strip = (lambda s: s[2:]) if mod != "Runtime.mod" else (lambda s: s)
+    # The table is written in the names the audit uses (Runtime.mod's own
+    # names, Compiler.mod's with the Em prefix already removed), so it is NOT
+    # stripped again here.  Stripping it twice turns "AddSp" into "dSp", which
+    # matches nothing - and a wrong match here is the dangerous direction: the
+    # entries would stop suppressing their helpers and the report would look
+    # like a genuine finding rather than like a broken table.
+    documented = set(NON_ONE_LINE.get(mod, {}))
+    audited_elsewhere = ({strip(n) for n in _NONLINE_VX}
+                         if mod == "Runtime.mod" else set())
+    unexamined = (declared - seen - {strip(e) for e in excluded}
+                  - documented - audited_elsewhere)
+    for n in sorted(unexamined):
+        problems.append("%s: emits bytes but the audit never examined it - "
+                        "the report would be green and the helper unchecked"
+                        % n)
+    print("\n  %s: %d one-line helpers audited, %d agree with their name, "
           "%d disagree, %d outside the grammar"
           "%d disagree, %d outside the grammar"
-          % (total, n_ok, n_bad, n_unparsed))
+          % (mod, n_ok + n_unparsed + n_bad, n_ok, n_bad, n_unparsed))
+    print("  %s: %d multi-line *Vx helpers audited, %d agree, %d problem(s)"
+          % (mod, n_vx, n_vx_ok, len(vx_problems)))
+    return n_ok + n_vx_ok, n_unparsed, n_bad, problems
+
+
+def main(argv):
+    verbose = "-v" in argv
+    files = [a for a in argv[1:] if not a.startswith("-")]
+    # No argument means "audit every module that emits bytes", not
+    # "audit Runtime.mod".  The single-module default is why the audit was
+    # green while EmXchgAxCx encoded XCHG BX,AX under a name that says
+    # XCHG AX,CX - a fault that broke every two-variable arithmetic operation
+    # in every program the compiler could produce.
+    mods = [os.path.basename(f) for f in files] if files else list(MODULES)
+
+    tot_ok = tot_unparsed = tot_bad = 0
+    problems = []
+    for mod in mods:
+        path = os.path.join(SHELL, mod)
+        if not os.path.exists(path):
+            print("FAIL: no such module: %s" % path)
+            return 1
+        with open(path) as f:
+            src = f.read()
+        n_ok, n_unp, n_bad, pr = sweep(src, mod, verbose)
+        tot_ok += n_ok
+        tot_unparsed += n_unp
+        tot_bad += n_bad
+        problems.extend("%s: %s" % (mod, p) for p in pr)
+
+    total = tot_ok + tot_unparsed + tot_bad
+    print("\nAUDITED %d emitter helpers across %d module(s): %d agree with "
+          "their name, %d disagree, %d outside the grammar"
+          % (total, len(mods), tot_ok, tot_bad, tot_unparsed))
     if problems:
     if problems:
         print("FAIL: %d problem(s)" % len(problems))
         print("FAIL: %d problem(s)" % len(problems))
         for p in problems:
         for p in problems:

+ 6 - 1
shell/tests/check_framedisp.py

@@ -78,7 +78,12 @@ SHELL = os.path.dirname(HERE)
 sys.path.insert(0, HERE)
 sys.path.insert(0, HERE)
 import disasm16  # noqa: E402
 import disasm16  # noqa: E402
 
 
-RTSZ = 391              # re-stated, not asked of the code under test
+# Re-stated, not asked of the code under test.  The image starts with a
+# three-byte entry JMP (see Compiler.Inittur), so the program code begins at
+# ENT_SZ + RT_SZ rather than at the runtime size alone.
+RT_SZ = 391              # Runtime.RT_Size()
+ENT_SZ = 3               # E9 lo hi
+RTSZ = ENT_SZ + RT_SZ
 COMTEST = os.path.join(SHELL, "comtest")
 COMTEST = os.path.join(SHELL, "comtest")
 
 
 # (fixture, opcode, [signed offsets])
 # (fixture, opcode, [signed offsets])

+ 28 - 1
shell/tests/check_runtime.py

@@ -105,15 +105,42 @@ def describe_golden_diff(have, want):
 # This list is the *explanation* for the bytes; runtime.golden is the
 # This list is the *explanation* for the bytes; runtime.golden is the
 # backstop.  Keep the two consistent -- they read the same measured ModRM
 # backstop.  Keep the two consistent -- they read the same measured ModRM
 # table that audit_helpers.py enforces on the source.
 # table that audit_helpers.py enforces on the source.
+#
+# The four read entries are here for the same reason the three earlier 8086
+# ModRM traps are: each of them is a hand-written sequence whose bytes are
+# well formed whether or not they mean anything, and each has in fact been
+# wrong while decoding cleanly.  See the notes in Runtime.mod on each.
 GOLDEN = {
 GOLDEN = {
     "initmem": "8B F0 8B 54 04 8B 4C 06",   # SI:=AX(header); DX:=[SI+4]; CX:=[SI+6]
     "initmem": "8B F0 8B 54 04 8B 4C 06",   # SI:=AX(header); DX:=[SI+4]; CX:=[SI+6]
     "stackchk": "C3",                       # the no-op: a bare RET, by design
     "stackchk": "C3",                       # the no-op: a bare RET, by design
     "progend": "31 C0 B4 4C CD 21 C3",      # XOR AX,AX; AH:=$4C; INT 21h; RET
     "progend": "31 C0 B4 4C CD 21 C3",      # XOR AX,AX; AH:=$4C; INT 21h; RET
     "wrint": "55 8B EC 8B 46 04",           # PUSH BP; MOV BP,SP; AX:=[BP+4]
     "wrint": "55 8B EC 8B 46 04",           # PUSH BP; MOV BP,SP; AX:=[BP+4]
-    "wrchar": "8B EC 8A 56 02 89 EC",       # BP:=SP; DL:=[BP+2]; SP:=BP
+    # wrchar: AL, not DL.  DOS INT 21h AH=02h takes the character in AL, and
+    # DL is the *other* 8086 convention (BIOS teletype).  This read DL, so
+    # every write of a character printed whatever the *last* character read
+    # had been - usually nothing at all, since DL starts undefined.  The
+    # bytes are `8A 46 02` = MOV AL,[BP+2]; the old golden said `8A 56 02`
+    # = 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
     "wrbool": "8B EC 83 7E 02 00 89 EC",    # BP:=SP; CMP [BP+2],0; SP:=BP
     "wrtinl": "5B 31 C9 8A 0F 43 B4 02",    # POP BX; XOR CX,CX; CL:=[BX]; INC BX
     "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
     "rdln": "50",                           # PUSH AX, to keep the caller's
+    # rdint: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DX,DI - five saved registers,
+    # because the digit has to survive MUL (which owns DX) and be parked in
+    # DI across it.  A three-register version was not possible.
+    "rdint": "55 8B EC 50 53 51 52 57",
+    # rdchar: PUSH BP; MOV BP,SP; PUSH AX,BX,DI - three, no CX and no DX.
+    # "DI" is how the caller's address gets in (there is no [BX] form), and
+    # the store is `88 15` = MOV [DI],DL - a BYTE store, so readln of a CHAR
+    # touches one byte and not the two rdint would have written over it.
+    "rdchar": "55 8B EC 50 53 57",
+    # rdbool: PUSH BP; MOV BP,SP; PUSH AX,BX,CX,DI - four.  It needs no DX
+    # (rdint's MUL is what forces DX to be saved) and saves nothing else; the
+    # answer lives in CX, from "T/t/Y/y/1" test, and has to reach the
+    # caller's address in DI.
+    "rdbool": "55 8B EC 50 53 51 57",
 }
 }
 
 
 
 

+ 113 - 17
shell/tests/comtest.py

@@ -35,14 +35,34 @@ FIXTURE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(
 COM = os.path.splitext(FIXTURE)[0] + ".COM"
 COM = os.path.splitext(FIXTURE)[0] + ".COM"
 
 
 # Restated here on purpose - the checker must not ask the code under test.
 # Restated here on purpose - the checker must not ask the code under test.
-# RTSZ was re-baselined 385 -> 391 together with the runtime bug fixes; see
-# the rationale in tests/run_com_tests.sh and tests/runtime.golden.  HEAD is
-# initmem's prologue, whose two SI displacements are the header words this
-# checker goes on to verify, so the two are tied together by assertion rather
-# than by coincidence (that is how initmem came to read hdrMax instead of
-# hdrHeap and silently zero nothing).
-RTSZ = 391                    # Runtime.RT_Size()
-DATAB = RTSZ + 0x1000         # Compiler: data base = rtSz + 1000H
+# ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
+# checker made on its own account; those stay written down.
+#
+# The RUNTIME'S SIZE is not restated, and that is the correction.  It used to
+# be a literal (385, then 391) beside a comment saying it tracks
+# Runtime.RT_Size().  It did not: the runtime is 432 bytes, so this checker
+# read the program header 41 bytes early, out of the middle of the code, and
+# reported a .COM full of nonsense - hdrFlag=61579 - as a compiler fault.  A
+# duplicated constant that has drifted is not an independent check, it is a
+# second source of truth that lies, and it lies in a way that looks like the
+# thing under test is broken.  The size is now MEASURED, by the same
+# self-consistent-header argument documented in find_header below.
+ENT_SZ = 3                    # E9 lo hi, the entry jump
+HDR_SZ = 16                   # 5 header words + 3 buffer words
+# RTSZ, PROLOG and DATAB are derived per .COM from find_header(d).
+# The image starts with a three-byte JMP - see Compiler.Inittur.  A .COM is
+# entered at file offset 0, so before that jump existed this checker ASSERTED
+# that the runtime was at offset 0, which was precisely the bug: every .COM
+# began by executing initmem with whatever the loader left in AX.  A checker
+# that pins a wrong invariant is worse than none, because it makes the wrong
+# thing look tested.
+# The load bias.  A DOS .COM's first byte is at CS:0100 and CS = DS, so an
+# image offset K is at DS:(K + 0100h); every absolute address the image
+# contains has to carry it, or it points 0100h low and - since the code region
+# and the runtime are all below the bias - almost always lands inside the
+# runtime instead of inside the data.  Relative encodings must not carry it.
+# Restated, not asked of the code under test.  See Runtime.LoadBias.
+LOAD_BIAS = 0x100
 HEAD = "8B F0 8B 54 04 8B 4C 06"   # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
 HEAD = "8B F0 8B 54 04 8B 4C 06"   # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
 HDR_DS_WORD = 4               # header word holding the data base
 HDR_DS_WORD = 4               # header word holding the data base
 HDR_HEAP_WORD = 6             # header word holding the data end
 HDR_HEAP_WORD = 6             # header word holding the data end
@@ -51,15 +71,86 @@ assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
        "initmem no longer reads the two header words this checker verifies"
        "initmem no longer reads the two header words this checker verifies"
 
 
 
 
+def find_header(d):
+    """Return the image offset of the program header, or None.
+
+    The header is eight words, and hdrDS ties it to its OWN offset: the data
+    base is header offset + 1000h, and hdrDS is the data base with the load
+    bias added, so header offset = hdrDS - 1000h - bias.  That makes the
+    offset recoverable from the file with no remembered runtime size, which is
+    the whole point - see the note on RT_SZ above.
+
+    A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
+    equation, hdrHeap is above hdrDS, and hdrCS leaves room for the header
+    itself.  initmem is the only code in the image that reads the header, so
+    its bytes are pinned at ENT_SZ and a match that also has them is not a
+    coincidence in the code stream.
+
+    Measuring is not the same as asking the compiler: this reads the emitted
+    file, so it cannot be satisfied by the code under test agreeing with
+    itself.  tests/check_runtime.py is where the runtime's size is pinned on
+    purpose, and tests/run_com_tests.sh measures and prints it on every run.
+    """
+    head = bytes(int(x, 16) for x in HEAD.split())
+    if d[ENT_SZ:ENT_SZ + len(head)] != head:
+        return None
+    for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
+        w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
+        if w(0) != 1:
+            continue
+        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:
+            continue
+        if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):
+            continue
+        if w(2) - LOAD_BIAS < off + HDR_SZ:
+            continue
+        return off
+    return None
+
+
 def check_com(path, expected_src_len):
 def check_com(path, expected_src_len):
     """Return a list of problems (empty = the .COM is correct)."""
     """Return a list of problems (empty = the .COM is correct)."""
     errs = []
     errs = []
     if not os.path.exists(path):
     if not os.path.exists(path):
         return ["no .COM file was written"]
         return ["no .COM file was written"]
     d = open(path, "rb").read()
     d = open(path, "rb").read()
-    if d[:len(HEAD.split())].hex(" ").upper() != HEAD:
-        errs.append("runtime not at offset 0: first bytes %s, want %s"
-                    % (d[:len(HEAD.split())].hex(" ").upper(), HEAD))
+    # Everything below is in terms of where the header actually is in THIS
+    # file, measured, rather than where a literal once said it should be.
+    hdr_off = find_header(d)
+    if hdr_off is None:
+        return ["no program header found: the layout this checker knows how to "
+                "look for is not the one in the file, so every offset below "
+                "would be a guess - reporting the guesses individually would "
+                "be worse than saying so once"]
+    RTSZ = hdr_off
+    PROLOG = hdr_off + HDR_SZ
+    DATAB = hdr_off + 0x1000
+    # The entry jump.  This is the only assertion in the project that can see
+    # where execution STARTS, because it is the only one that cares.  It has
+    # caught three real bugs in these same three bytes: no jump at all; a jump
+    # to `pc` (the END of the code, in the zero gap); and a jump to RTSZ,
+    # which is the program HEADER - data, which the CPU then decodes as
+    # instructions.  That last one is why the target is PROLOG and not RTSZ:
+    # whether it works depends on how the header happens to decode, so
+    # writeln('hi') slid through it unharmed while t07 hung on a LOCK-prefixed
+    # ADD.  Every other check here reads bytes at an offset the compiler chose
+    # for itself.
+    if len(d) >= ENT_SZ:
+        if d[0] != 0xE9:
+            errs.append("byte 0 is %02X, not the E9 of the entry jump" % d[0])
+        want_rel = PROLOG - ENT_SZ
+        got_rel = int.from_bytes(d[1:ENT_SZ], "little")
+        if got_rel != want_rel:
+            errs.append("entry jump rel16=%d, want %d; lands on image offset "
+                        "%d, want %d (the first instruction, %d bytes past the "
+                        "header - not the header at %d)"
+                        % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG,
+                           HDR_SZ, RTSZ))
+    if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper() != HEAD:
+        errs.append("runtime not at offset %d: bytes there %s, want %s"
+                    % (ENT_SZ,
+                       d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper(),
+                       HEAD))
     if len(d) < DATAB:
     if len(d) < DATAB:
         errs.append("file is %d bytes, shorter than the data base %d - the "
         errs.append("file is %d bytes, shorter than the data base %d - the "
                     "globals would be outside the file" % (len(d), DATAB))
                     "globals would be outside the file" % (len(d), DATAB))
@@ -72,16 +163,21 @@ def check_com(path, expected_src_len):
     if len(d) >= RTSZ + 10:
     if len(d) >= RTSZ + 10:
         ds = int.from_bytes(d[RTSZ + HDR_DS_WORD:RTSZ + HDR_DS_WORD + 2], "little")
         ds = int.from_bytes(d[RTSZ + HDR_DS_WORD:RTSZ + HDR_DS_WORD + 2], "little")
         heap = int.from_bytes(d[RTSZ + HDR_HEAP_WORD:RTSZ + HDR_HEAP_WORD + 2], "little")
         heap = int.from_bytes(d[RTSZ + HDR_HEAP_WORD:RTSZ + HDR_HEAP_WORD + 2], "little")
-        if ds != DATAB:
-            errs.append("hdrDS=%d, want %d" % (ds, DATAB))
+        if ds != DATAB + LOAD_BIAS:
+            errs.append("hdrDS=%d, want %d (= data base %d + load bias %d)"
+                        % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS))
         if heap < ds:
         if heap < ds:
             errs.append("hdrHeap=%d < hdrDS=%d" % (heap, ds))
             errs.append("hdrHeap=%d < hdrDS=%d" % (heap, ds))
-        if [d[4], d[7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
+        if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
             errs.append("initmem reads header words +%d/+%d, but the data base "
             errs.append("initmem reads header words +%d/+%d, but the data base "
                         "and data end are at +%d/+%d"
                         "and data end are at +%d/+%d"
-                        % (d[4], d[7], HDR_DS_WORD, HDR_HEAP_WORD))
-    # the gap between the end of the code and the data area must be all zero
-    cs = int.from_bytes(d[RTSZ + 2:RTSZ + 4], "little") if len(d) >= RTSZ + 4 else 0
+                        % (d[ENT_SZ + 4], d[ENT_SZ + 7],
+                           HDR_DS_WORD, HDR_HEAP_WORD))
+    # The gap between the end of the code and the data area must be all zero.
+    # hdrCS is a SEGMENT offset, like every other offset in the header, so
+    # LOAD_BIAS comes back off before it is used to index the file.
+    cs = (int.from_bytes(d[RTSZ + 2:RTSZ + 4], "little") - LOAD_BIAS) \
+        if len(d) >= RTSZ + 4 else 0
     if cs and cs < DATAB:
     if cs and cs < DATAB:
         nz = sum(1 for b in d[cs:DATAB] if b)
         nz = sum(1 for b in d[cs:DATAB] if b)
         if nz:
         if nz:

+ 242 - 0
shell/tests/exec/bootcom.s

@@ -0,0 +1,242 @@
+# bootcom.s -- load a DOS .COM from the boot floppy and run it, with INT 21h
+# wired to the serial port instead of a screen.
+#
+# This is the piece that lets a .COM produced by this compiler actually run.
+# It is deliberately not DOS: it is a 512-byte boot sector that does the four
+# things a DOS .COM loader does, and sends everything the program prints out
+# the serial port so a test harness can read it.
+#
+#   1. install an INT 21h vector pointing at a handler below
+#   2. INT 13h AH=02h: read the .COM off drive A: to 0000:0100
+#   3. SS:SP = 0000:FFFE, the segment top -- where DOS puts a .COM's stack
+#   4. JMP 0000:0100
+#
+# The INT 21h handler implements the four functions this runtime actually
+# calls, and nothing else:
+#
+#   AH=02h  display character in AL
+#   AH=09h  display the $-terminated string at DS:DX
+#   AH=08h  read a character without echo; 1Ah at end of input
+#   AH=4Ch  terminate
+#
+# Those four are the complete set.  See Runtime.mod: every Int21 in it is one
+# of them (the "MovAh (0)" in EmitRdInt is not an INT 21h call, it is the
+# MOV AH,0 that loads a digit into AL before an ADD -- an easy thing to
+# misread as a fourth function).
+#
+# Two properties are load-bearing:
+#
+#   * The handler NEVER writes to the serial port as a marker.  The program's
+#     output is arbitrary bytes, so any sentinel could collide with real
+#     output.  Termination travels out of band instead, through the
+#     isa-debug-exit device at port 0501h: the program exits with code 0 and
+#     qemu exits (value<<1)|1 = 1.  A boot failure exits with value 7Fh, i.e.
+#     qemu exit code 255, which is unambiguous.  Every byte on the serial port
+#     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.
+#
+# Built and driven by tests/run_com_exec.py; see tests/exec/README.md.
+
+	.code16
+	.text
+	.globl	_start
+
+_start:
+	cli
+	xorw	%ax, %ax
+	movw	%ax, %ds
+	movw	%ax, %es
+	movw	%ax, %ss
+	movw	$0xfffe, %sp
+	sti
+
+	# IVT entry 21h lives at 0000:0084 (21h * 4): offset word then segment.
+	# `handler' is a section-relative offset because the section starts at 0;
+	# the segment this sector was loaded at is 7C00h.
+	movw	$handler, %ax
+	addw	$0x7c00, %ax
+	movw	%ax, 0x84
+	movw	$0, %ax
+	movw	%ax, 0x86
+
+	# INT 13h AH=00h: reset the drive controller.  A floppy that has just
+	# been attached needs this before a read will succeed.
+	movb	$0x00, %ah
+	int	$0x13
+
+	# Retry the read: qemu's floppy is a file image and a read can fail while
+	# it settles.  Three attempts, then give up loudly.
+	movw	$3, %cx
+.Lretry:
+	movw	$0x0100, %bx		# ES:BX = 0000:0100
+	movb	$0x02, %ah		# read sectors
+	movb	$16, %al		# 16 * 512 = 8192 bytes, far more than any fixture
+	movb	$0x00, %ch		# cylinder 0
+	movb	$0x02, %cl		# sector 2 -- the .COM, 1-based
+	movb	$0x00, %dh		# head 0
+	movb	$0x00, %dl		# drive A
+	int	$0x13
+	jnc	.Lok
+	decw	%cx
+	jnz	.Lretry
+.Lbootfail:
+	movb	$0x7f, %al
+	movw	$0x0501, %dx		# isa-debug-exit
+	outb	%al, %dx
+	cli
+	hlt
+.Lok:
+	.byte	0xEA, 0x00, 0x01, 0x00, 0x00	# jmp 0000:0100
+
+# ---------------------------------------------------------------- INT 21h
+# Called with the program's registers.  DS is the caller's data segment
+# (0000h here) and is preserved, so AH=09h can reach DS:DX the way DOS does.
+# Every path ends at .Ldone, which restores everything and IRETs -- except
+# AH=4Ch, which does not return at all.
+handler:
+	pushw	%ax
+	pushw	%bx
+	pushw	%cx
+	pushw	%dx
+	pushw	%si
+	pushw	%di
+	pushw	%ds
+	pushw	%es
+
+	cmpb	$0x02, %ah
+	je	.Lh02
+	cmpb	$0x09, %ah
+	je	.Lh09
+	cmpb	$0x08, %ah
+	je	.Lh08
+	cmpb	$0x4c, %ah
+	je	.Lh4c
+	stc				# unknown function
+	jmp	.Ldone
+
+.Lh02:					# display character in AL
+	call	ser_put
+	clc
+	jmp	.Ldone
+
+.Lh09:					# display the $-terminated string at DS:DX
+	movw	%dx, 0x0702		# ser_put clobbers DX, so keep the pointer
+.Lh09next:
+	movw	0x0702, %si		# lodsb: AL = [DS:SI].  Register-indirect
+	lodsb				# addressing with no displacement is not
+	cmpb	$0x24, %al		# expressible in gas .code16 syntax, and
+	je	.Lh09done		# keeping the cursor in memory means the
+	call	ser_put		# pointer survives ser_put's use of DX.
+	incw	0x0702
+	jmp	.Lh09next
+.Lh09done:
+	clc
+	jmp	.Ldone
+
+.Lh08:					# read a character, no echo, 1Ah at EOF
+	# NOTE: this is the one function whose RESULT is in AL, so it must return
+	# through .Ldone8 and not .Ldone - see there.
+	movw	INLEN, %ax		# inlen
+	movw	INCUR, %bx		# input cursor
+	# EOF is when the cursor has REACHED the length, i.e. INCUR >= INLEN.
+	# `cmpw %bx, %ax / jbe' tests AX <= BX, that is INLEN <= INCUR, which is
+	# true on the FIRST character: it returned 1Ah immediately, so readln saw
+	# an empty input and then looped for the line terminator that never came.
+	# t29_readln hung with no output at all.  (This was a bug in the harness,
+	# not in the compiler - but a harness that feeds the program nothing can
+	# never tell you whether the program handles input, so it is a bug that
+	# hides bugs.)
+	cmpw	%ax, %bx		# INCUR vs INLEN
+	jae	.Lh08eof
+	# INCUR is an index INTO the buffer, not an address: the bytes live at
+	# INBUF, and SI = INCUR alone reads the interrupt vector table at 0000:0000
+	# - so AH=08h returned IVT[0] (a low timer vector byte) as the first
+	# character of input.  INCUR is 0 on the first call, which is the one
+	# address in segment 0 that is guaranteed to be wrong.
+	movw	%bx, %si
+	addw	$INBUF, %si
+	lodsb
+	incw	INCUR
+	clc
+	jmp	.Ldone8		# NOT .Ldone: AL is the result here
+.Lh08eof:
+	movb	$0x1a, %al
+	clc
+	jmp	.Ldone8
+
+.Lh4c:					# terminate: exit with AL as the code
+	movw	$0x0501, %dx		# isa-debug-exit
+	outb	%al, %dx
+	cli
+	hlt
+	jmp	.Lh4c
+
+.Ldone:
+	popw	%es
+	popw	%ds
+	popw	%di
+	popw	%si
+	popw	%dx
+	popw	%cx
+	popw	%bx
+	popw	%ax
+	iret
+
+# The same, but for the one function that RETURNS something in AL.  DOS
+# AH=08h hands the character back in AL, so restoring AX on the way out
+# throws the answer away and the caller reads whatever AX held on entry.
+#
+# This was silent in a way that is worth recording: the shim's read path was
+# structurally correct - it found the buffer, advanced the cursor, cleared
+# the carry - and the character was plainly in AL, one instruction before the
+# return.  The discard happened in the epilogue, which every OTHER function
+# needs.  So t29_readln did not see a wrong byte; it saw the *uninitialised*
+# AX the runtime had at the call, which is the first byte of a pointer it was
+# about to overwrite with the parsed value.  readln compared that against 0Dh,
+# 0Ah and 1Ah, rejected it, and looped forever - a hang with no output, from
+# a read that demonstrably worked.
+#
+# AX is therefore popped only on the paths where it is not a result, and
+# AH=02h/09h (which also leave AL alone in DOS) keep using .Ldone.
+.Ldone8:
+	popw	%es
+	popw	%ds
+	popw	%di
+	popw	%si
+	popw	%dx
+	popw	%cx
+	popw	%bx
+	addw	$2, %sp		# drop the saved AX, keep AL
+	iret
+
+# ser_put: send AL to the serial port, leaving AL and DX alone.
+# No line-status polling: qemu's 16550 always accepts a byte, and a poll
+# that never goes ready would hang the harness rather than fail it.
+ser_put:
+	pushw	%ax
+	movw	$0x03f8, %dx
+	outb	%al, %dx
+	popw	%ax
+	ret
+
+# --------------------------------------------------------------- layout
+# The input descriptor lives at 2000h, which the 16-sector read 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.
+	.set	INLEN, 0x2000		# word: number of input bytes
+	.set	INCUR, 0x2002		# word: cursor, starts at INBUF
+	.set	INBUF, 0x2004		# the bytes themselves
+	.set	INMAX, 0x00fc		# 2100h - 2004h, the most that fits
+
+	.org	510
+	.byte	0x55, 0xAA

+ 81 - 4
shell/tests/fixtures/expected.tsv

@@ -63,6 +63,79 @@
 #                 a claim that a 70-argument call works.  Its 123 bytes are
 #                 a claim that a 70-argument call works.  Its 123 bytes are
 #                 1 more than t27's because the body is one ADD and one
 #                 1 more than t27's because the body is one ADD and one
 #                 store rather than five of each.
 #                 store rather than five of each.
+#
+# RE-BASELINED for the case-label fix, and this is the one re-baseline in this
+# file that is a CORRECTION rather than a new feature.  EmMovAxSp used to emit
+#
+#     8B 44 24 00      MOV AX,[SP]          <- 386 encoding, SIB byte
+#
+# which is 4 bytes.  [SP] is not encodable on the 8086 at all: mod=00/rm=100
+# is [SI], and the SIB byte that 8B 44 needs did not exist until the 386.  The
+# bytes were well formed, so every byte-level check passed, and fcml and qemu
+# both DECODED it - as MOV AX,[SI+0x24h], because that is what it is.  Every
+# CASE label test therefore loaded a garbage address, every comparison failed,
+# and a case statement fell straight past its body.  It now emits
+#
+#     58 50            POP AX ; PUSH AX     <- 2 bytes
+#
+# which is observationally a peek.  t22 has two labels, so its code is exactly
+# 4 bytes smaller: 96 -> 92.  The number moving is the point - the old size was
+# the size of a wrong encoding, and a case fixture that is not executed cannot
+# tell you that from its size alone.  t22 is executed (tests/run_com_exec.py).
+#
+# ADDED t29_readln: the first fixture that reads.  It needs the CHAR class to
+# exist at all - see the note on TChar in Compiler.mod - so 7 data bytes where
+# t19 has 4 (n is 2, c is 1, and the two are 2-byte aligned, so 4 + 2 + 1
+# rounds up to 7) and 82 code bytes for the four runtime calls plus a literal
+# char argument.  Its .out is compared byte for byte against qemu.
+#
+# RE-BASELINED for the procedure-skip jump.  t13, t27 and t28 gained exactly
+# +3 code bytes: `E9 rel16`.  These are the only three fixtures that declare a
+# PROCEDURE, and they are the only three rows that moved.
+#
+# The reason is a bug that only execution could find.  The declaration part is
+# compiled BEFORE the main statement part, so a procedure's code lands between
+# the program prologue and the main body - and nothing jumped over it.  A
+# program with a procedure ran off the end of the prologue straight into the
+# first procedure, which read its argument out of an uninitialised frame and
+# returned to address 0000h.  t13_proc compiled, produced a plausible 53 bytes,
+# and was never run, so the suite was green over a program that could not
+# execute at all.
+#
+# The jump is emitted ONLY when a procedure or function is declared, which is
+# why the other 27 OK rows did not move.  That condition is a lookahead over
+# the source buffer, because the jump must be emitted before the declaration
+# part but whether one is needed is only known after - see DeclaresProc in
+# Compiler.mod for why the alternative (always emit it) was rejected.
+#
+#   t13_proc        53 -> 56
+#   t27_localvar   122 -> 125
+#   t28_farparam   123 -> 126
+#   t31_procparam  66 -> 69
+#
+# t31_procparam is the fixture that proves the fix by running: it declares a
+# procedure, calls it, and prints what the procedure wrote.  Before the fix it
+# never halted at all.
+#
+# ADDED t30/t31/t32, all three now EXECUTED (not just compiled):
+#
+#   t30_forloop   `for i := 1 to 5 do s := s + i` printed 21, not 15.  The loop
+#                 test was emitted AFTER the body, making it a post-test loop:
+#                 the body ran a sixth time with i = 6.  The code size and the
+#                 instruction bytes were both already correct - only the ORDER
+#                 was wrong, and no byte-level check can observe an order.  The
+#                 test now precedes the body, so 96 bytes both before and after
+#                 this fix, which is the whole point: the number never moved and
+#                 the behaviour did.
+#   t31_procparam procedure + one parameter.  The non-termination above.
+#   t32_forexit   `for` with `exit` in the body.  Two faults, both invisible to
+#                 a size check: the loop's exits were patched to the position
+#                 just past the body, which in a FOR is the STEP, so `exit`
+#                 incremented the control variable and jumped back into the test
+#                 - it did not exit; and the EXIT handler also emitted ADD SP,2
+#                 while the loop's `done` label emitted it again, dropping four
+#                 bytes off a stack that had two to give.  Exits are now patched
+#                 at `done`, and the handler no longer touches the stack.
 
 
 t01_minimal	OK	29	4
 t01_minimal	OK	29	4
 t02_writeln	OK	38	4
 t02_writeln	OK	38	4
@@ -76,7 +149,7 @@ t09_if	OK	67	6
 t10_while	OK	75	6
 t10_while	OK	75	6
 t11_for	OK	69	6
 t11_for	OK	69	6
 t12_repeat	OK	72	6
 t12_repeat	OK	72	6
-t13_proc	OK	53	6
+t13_proc	OK	56	6
 t14_types	ERR	102	83
 t14_types	ERR	102	83
 t15_label	OK	38	6
 t15_label	OK	38	6
 t16_str1	OK	42	4
 t16_str1	OK	42	4
@@ -85,11 +158,15 @@ t18_writeln_bare	OK	32	4
 t19_int1	OK	42	4
 t19_int1	OK	42	4
 t20_str3	OK	62	4
 t20_str3	OK	62	4
 t21_mixed	OK	62	4
 t21_mixed	OK	62	4
-t22_case	OK	96	6
+t22_case	OK	92	6
 t23_str_empty	OK	36	4
 t23_str_empty	OK	36	4
 t24_str_quote	OK	41	4
 t24_str_quote	OK	41	4
 t25_str_as_value	ERR	102	48
 t25_str_as_value	ERR	102	48
 t26_str_mixed_args	OK	58	4
 t26_str_mixed_args	OK	58	4
-t27_localvar	OK	122	6
-t28_farparam	OK	123	6
+t27_localvar	OK	125	6
+t28_farparam	OK	126	6
+t29_readln	OK	82	7
+t30_forloop	OK	96	8
+t31_procparam	OK	69	6
+t32_forexit	OK	135	8
 uierror	ERR	41	331
 uierror	ERR	41	331

+ 0 - 0
shell/tests/fixtures/t01_minimal.out


+ 1 - 0
shell/tests/fixtures/t02_writeln.out

@@ -0,0 +1 @@
+hi

+ 1 - 0
shell/tests/fixtures/t03_inline_comment.out

@@ -0,0 +1 @@
+hi

+ 1 - 0
shell/tests/fixtures/t04_var.out

@@ -0,0 +1 @@
+1

+ 1 - 0
shell/tests/fixtures/t05_own_line_comment.out

@@ -0,0 +1 @@
+hi

+ 1 - 0
shell/tests/fixtures/t06_two_args.out

@@ -0,0 +1 @@
+ab

+ 1 - 0
shell/tests/fixtures/t07_big.out

@@ -0,0 +1 @@
+9

+ 1 - 0
shell/tests/fixtures/t16_str1.out

@@ -0,0 +1 @@
+a

+ 1 - 0
shell/tests/fixtures/t17_two_str.out

@@ -0,0 +1 @@
+abcde

+ 1 - 0
shell/tests/fixtures/t18_writeln_bare.out

@@ -0,0 +1 @@
+

+ 1 - 0
shell/tests/fixtures/t19_int1.out

@@ -0,0 +1 @@
+1

+ 1 - 0
shell/tests/fixtures/t20_str3.out

@@ -0,0 +1 @@
+abc

+ 1 - 0
shell/tests/fixtures/t21_mixed.out

@@ -0,0 +1 @@
+1a2

+ 1 - 0
shell/tests/fixtures/t22_case.out

@@ -0,0 +1 @@
+a

+ 1 - 0
shell/tests/fixtures/t23_str_empty.out

@@ -0,0 +1 @@
+

+ 1 - 0
shell/tests/fixtures/t24_str_quote.out

@@ -0,0 +1 @@
+don't

+ 1 - 0
shell/tests/fixtures/t26_str_mixed_args.out

@@ -0,0 +1 @@
+1hi2

+ 2 - 0
shell/tests/fixtures/t29_readln.in

@@ -0,0 +1,2 @@
+42
+Z

+ 2 - 0
shell/tests/fixtures/t29_readln.out

@@ -0,0 +1,2 @@
+42
+Z

+ 10 - 0
shell/tests/fixtures/t29_readln.pas

@@ -0,0 +1,10 @@
+program t29;
+var
+  n : integer ;
+  c : char ;
+begin
+  readln (n) ;
+  writeln (n) ;
+  readln (c) ;
+  write (c)
+end.

+ 1 - 0
shell/tests/fixtures/t30_forloop.out

@@ -0,0 +1 @@
+15

+ 10 - 0
shell/tests/fixtures/t30_forloop.pas

@@ -0,0 +1,10 @@
+program t30;
+var
+  i : integer ;
+  s : integer ;
+begin
+  s := 0 ;
+  for i := 1 to 5 do
+    s := s + i ;
+  writeln (s)
+end.

+ 1 - 0
shell/tests/fixtures/t31_procparam.out

@@ -0,0 +1 @@
+3

+ 11 - 0
shell/tests/fixtures/t31_procparam.pas

@@ -0,0 +1,11 @@
+program t31;
+var
+  x : integer ;
+procedure show (a : integer) ;
+begin
+  x := a
+end ;
+begin
+  show (3) ;
+  writeln (x)
+end.

+ 2 - 0
shell/tests/fixtures/t32_forexit.out

@@ -0,0 +1,2 @@
+21
+6

+ 15 - 0
shell/tests/fixtures/t32_forexit.pas

@@ -0,0 +1,15 @@
+program t32;
+var
+  i : integer ;
+  s : integer ;
+begin
+  s := 0 ;
+  for i := 1 to 10 do
+  begin
+    s := s + i ;
+    if s > 20 then
+      exit
+  end ;
+  writeln (s) ;
+  writeln (i)
+end.

+ 251 - 21
shell/tests/nonvacuity.sh

@@ -12,6 +12,12 @@
 #
 #
 #   audit_helpers.py   name-versus-decode: catches a wrong ModRM that still
 #   audit_helpers.py   name-versus-decode: catches a wrong ModRM that still
 #                      decodes cleanly
 #                      decodes cleanly
+#   audit_helpers.py   coverage:           catches a helper that has silently
+#                      dropped OUT of the audit, which is a green report about
+#                      a subject nobody looked at
+#   run_com_tests.sh   the .COM layout:    catches a header that cannot be
+#                      located, a runtime size that disagrees with the image,
+#                      and an entry jump that starts in the wrong place
 #   check_runtime.py   golden:            catches the same thing in the built
 #   check_runtime.py   golden:            catches the same thing in the built
 #                                           image
 #                                           image
 #   check_runtime.py   decode sweep:      catches a wrong instruction LENGTH
 #   check_runtime.py   decode sweep:      catches a wrong instruction LENGTH
@@ -44,6 +50,36 @@ trap 'cp "$SAVED" Runtime.mod; "$GM2" -fiso -c Runtime.mod >/dev/null 2>&1' EXIT
 pass=0
 pass=0
 fail=0
 fail=0
 
 
+# mutate <file> <sed-expr> -- apply a deliberate breakage and INSIST it landed.
+#
+# Four cases in this file were already dead when first run, all the same way:
+# the helper they name had been renamed or reformatted since the case was
+# written, the sed matched nothing, the source was unchanged, and the check
+# correctly passed - so the harness reported "NOT NON-VACUOUS" and, worse, a
+# reader skimming the output could take "the check still passed" for a passing
+# test.  A case that cannot fire is worse than no case: it is a claim of
+# coverage that was never tested.
+#
+# So the mutation is verified, not assumed.  If the file is byte-identical
+# afterwards, that is reported as a FAILURE of the harness, naming the sed, and
+# the case is not run - because running it would only produce a meaningless
+# green.  The message says what to do (fix the sed) rather than what it found.
+mutate () {
+    mf=$1
+    msed=$2
+    cp "$mf" /tmp/opencode/nonvacuity.mut.bak
+    sed -i "$msed" "$mf"
+    if cmp -s "$mf" /tmp/opencode/nonvacuity.mut.bak; then
+        echo "  BROKEN CASE: the mutation did not change $mf"
+        echo "       sed: $msed"
+        echo "       the named code has probably been renamed or reformatted -"
+        echo "       fix this case, it is asserting nothing"
+        fail=$((fail + 1))
+        return 1
+    fi
+    return 0
+}
+
 # rebuild <label> -- re-emit the runtime and dump it
 # rebuild <label> -- re-emit the runtime and dump it
 rebuild () {
 rebuild () {
     "$GM2" -fiso -c Runtime.mod >/dev/null 2>&1 || return 1
     "$GM2" -fiso -c Runtime.mod >/dev/null 2>&1 || return 1
@@ -81,23 +117,25 @@ echo
 # MovSiBx was `89 DC`, which is MOV SP,BX.  Two bytes either way, decodes
 # MovSiBx was `89 DC`, which is MOV SP,BX.  Two bytes either way, decodes
 # cleanly, and no structural check can see it.
 # cleanly, and no structural check can see it.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|B (0DEH) END MovSiBx|B (0DCH) END MovSiBx|' Runtime.mod
+mutate Runtime.mod 's|B (0DEH) END MovSiBx|B (0DCH) END MovSiBx|'
 expect_red "audit_helpers catches MovSiBx emitting MOV SP,BX" \
 expect_red "audit_helpers catches MovSiBx emitting MOV SP,BX" \
     "MovSiBx" python3 tests/audit_helpers.py
     "MovSiBx" python3 tests/audit_helpers.py
 
 
 # CmpSiBx had the identical mistake, which is how you know a single fix is
 # CmpSiBx had the identical mistake, which is how you know a single fix is
 # not enough -- the same misreading was written twice.
 # not enough -- the same misreading was written twice.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|B (39H) ; B (0DEH) END CmpSiBx|B (39H) ; B (0DCH) END CmpSiBx|' Runtime.mod
+mutate Runtime.mod 's|B (39H) ; B (0DEH) END CmpSiBx|B (39H) ; B (0DCH) END CmpSiBx|'
 expect_red "audit_helpers catches CmpSiBx emitting CMP SP,BX" \
 expect_red "audit_helpers catches CmpSiBx emitting CMP SP,BX" \
     "CmpSiBx" python3 tests/audit_helpers.py
     "CmpSiBx" python3 tests/audit_helpers.py
 
 
 # --- 2. golden, and entry goldens ------------------------------------
 # --- 2. golden, and entry goldens ------------------------------------
-# MovAlDh was `8A C0` = MOV AL,AL instead of MOV AL,DH.  This is the case
-# that motivated runtime.golden: the sweep stayed in sync, every branch
-# target stayed on a boundary, and no entry's first bytes moved.
+# MovDlAl was `88 C0` = MOV AL,AL instead of MOV DL,AL.  This is the case that
+# motivated runtime.golden: the sweep stayed in sync, every branch target
+# stayed on a boundary, no entry's first bytes moved, and the size did not
+# change.  The target helper was MovAlDh when this case was written, which is
+# the fourth way a case here can rot - see the note on `mutate` below.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|B (0C6H) END MovAlDh|B (0C0H) END MovAlDh|' Runtime.mod
+mutate Runtime.mod 's|PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C2H)|PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C0H)|'
 rebuild
 rebuild
 expect_red "runtime.golden catches MOV AL,AL" \
 expect_red "runtime.golden catches MOV AL,AL" \
     "mov al,al" python3 tests/check_runtime.py "$DUMP"
     "mov al,al" python3 tests/check_runtime.py "$DUMP"
@@ -105,19 +143,19 @@ expect_red "runtime.golden catches MOV AL,AL" \
 # initmem opened with the mis-emitted MovSiAx, so its entry golden was the
 # initmem opened with the mis-emitted MovSiAx, so its entry golden was the
 # thing that noticed the prologue was a no-op.
 # thing that noticed the prologue was a no-op.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|B (0F0H) END MovSiAx|B (0C0H) END MovSiAx|' Runtime.mod
+mutate Runtime.mod 's|B (0F0H) END MovSiAx|B (0C0H) END MovSiAx|'
 rebuild
 rebuild
 expect_red "check_runtime catches a broken initmem prologue" \
 expect_red "check_runtime catches a broken initmem prologue" \
     "mov ax,ax" python3 tests/check_runtime.py "$DUMP"
     "mov ax,ax" python3 tests/check_runtime.py "$DUMP"
 
 
 # --- 3. decode sweep / length ----------------------------------------
 # --- 3. decode sweep / length ----------------------------------------
-# StBxDl was `88 97` = [BX],DL with mod=10, so the instruction needs a
-# disp16 and the sweep loses sync two bytes later.
+# StDiDl was `88 97` = [BX+disp16],DL: mod=10, so the instruction needs a
+# disp16 it was not given, and the sweep loses sync two bytes later.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|B (88H) ; B (17H) END StBxDl|B (88H) ; B (97H) END StBxDl|' Runtime.mod
+mutate Runtime.mod 's|PROCEDURE StDiDl   ; BEGIN B (88H) ; B (15H)|PROCEDURE StDiDl   ; BEGIN B (88H) ; B (97H)|'
 rebuild
 rebuild
 expect_red "decode sweep catches a mod=10 byte move with no displacement" \
 expect_red "decode sweep catches a mod=10 byte move with no displacement" \
-    "585Bh" python3 tests/check_runtime.py "$DUMP"
+    "mov byte ptr \[bx+5b5fh\],dl" python3 tests/check_runtime.py "$DUMP"
 
 
 # --- 4. branch targets ------------------------------------------------
 # --- 4. branch targets ------------------------------------------------
 # FixUp measures a rel8 from the end of the instruction, one byte past the
 # FixUp measures a rel8 from the end of the instruction, one byte past the
@@ -127,8 +165,7 @@ expect_red "decode sweep catches a mod=10 byte move with no displacement" \
 # wrong -- so this is the one failure mode the golden cannot be expected to
 # wrong -- so this is the one failure mode the golden cannot be expected to
 # catch on its own.
 # catch on its own.
 cp "$SAVED" Runtime.mod
 cp "$SAVED" Runtime.mod
-sed -i 's|rel := (t + 100H - (fix \[i\].place + 1)) MOD 100H|rel := (t + 100H - fix [i].place) MOD 100H|' \
-    Runtime.mod
+mutate Runtime.mod 's|rel := (t + 100H - (fix \[i\].place + 1)) MOD 100H|rel := (t + 100H - fix [i].place) MOD 100H|'
 rebuild
 rebuild
 expect_red "branch check catches rel8 fixups measured from the wrong byte" \
 expect_red "branch check catches rel8 fixups measured from the wrong byte" \
     "not an instruction boundary" \
     "not an instruction boundary" \
@@ -167,15 +204,14 @@ restore_probe () {
 }
 }
 
 
 # 1. one cell of the table moved
 # 1. one cell of the table moved
-sed -i 's|"Si", "Di"\]$|"Bp", "Di"]|' tests/probe/modrm11.py
+mutate tests/probe/modrm11.py 's|"Si", "Di"\]$|"Bp", "Di"]|'
 expect_red "anchor pins a moved table cell" \
 expect_red "anchor pins a moved table cell" \
     "anchor ADD SI, 2" $M11
     "anchor ADD SI, 2" $M11
 restore_probe
 restore_probe
 
 
 # 2. the table this project actually shipped: AX dropped off the front and a
 # 2. the table this project actually shipped: AX dropped off the front and a
 #    duplicate BX invented at the end, which shifts every code down by one
 #    duplicate BX invented at the end, which shifts every code down by one
-sed -i 's|^REG = .*$|REG = ["Cx", "Dx", "Bx", "Sp", "Bp", "Si", "Di", "Bx"]|' \
-    tests/probe/modrm11.py
+mutate tests/probe/modrm11.py 's|^REG = .*$|REG = ["Cx", "Dx", "Bx", "Sp", "Bp", "Si", "Di", "Bx"]|'
 expect_red "the table shifted by one (AX dropped, BX duplicated)" \
 expect_red "the table shifted by one (AX dropped, BX duplicated)" \
     "anchor MOV SP, BP" $M11
     "anchor MOV SP, BP" $M11
 restore_probe
 restore_probe
@@ -183,22 +219,20 @@ restore_probe
 # 3. the .s edited to contradict the table.  This is the case that shows why
 # 3. the .s edited to contradict the table.  This is the case that shows why
 #    the hard-coded EXPECT bytes exist: the assembler encodes the new claim
 #    the hard-coded EXPECT bytes exist: the assembler encodes the new claim
 #    correctly, so comparing the .s against `as` alone can never fail here.
 #    correctly, so comparing the .s against `as` alone can never fail here.
-sed -i 's|movw    %sp, %di         # reg 100|movw    %bp, %di         # reg 100|' \
-    tests/probe/modrm11.s
+mutate tests/probe/modrm11.s 's|movw    %sp, %di         # reg 100|movw    %bp, %di         # reg 100|'
 expect_red "probe source edited away from the recorded bytes" \
 expect_red "probe source edited away from the recorded bytes" \
     "expected 89 E7" $M11
     "expected 89 E7" $M11
 restore_probe
 restore_probe
 
 
 # 4. the 8-bit list edited, which is a different table from the word one
 # 4. the 8-bit list edited, which is a different table from the word one
-sed -i 's|movb    %al, %dl         # 88 C2  ->  DL := AL|movb    %al, %bl         # was DL|' \
-    tests/probe/modrm11.s
+mutate tests/probe/modrm11.s 's|movb    %al, %dl         # 88 C2  ->  DL := AL|movb    %al, %bl         # was DL|'
 expect_red "the 8-bit register list edited" \
 expect_red "the 8-bit register list edited" \
     "expected 88 C2" $M11
     "expected 88 C2" $M11
 restore_probe
 restore_probe
 
 
 # 5. an anchor's recorded byte corrupted, so the anchor can no longer
 # 5. an anchor's recorded byte corrupted, so the anchor can no longer
 #    corroborate itself
 #    corroborate itself
-sed -i 's|"8B EC", "8B E5"|"8B ED", "8B E5"|' tests/probe/modrm11.py
+mutate tests/probe/modrm11.py 's|"8B EC", "8B E5"|"8B ED", "8B E5"|'
 expect_red "anchor byte no longer matches the emitted code" \
 expect_red "anchor byte no longer matches the emitted code" \
     "expected 8B ED" $M11
     "expected 8B ED" $M11
 restore_probe
 restore_probe
@@ -308,6 +342,202 @@ else
     fail=$((fail + 1))
     fail=$((fail + 1))
 fi
 fi
 
 
+echo
+echo "== the emitter-name audit of Compiler.mod (audit_helpers.py)"
+# These need no rebuild: the audit reads the SOURCE, not the built object, so
+# they are the cheapest cases here and they cover the module the audit used
+# not to look at at all.  That is the point of the section: the audit reported
+# "every helper agrees with its name" for a module it had never examined, and
+# EmXchgAxCx was `93` (XCHG BX,AX) under a name that says XCHG AX,CX for the
+# whole life of the project.  Two of these five are for faults that were real.
+SAVED_C2=/tmp/opencode/nonvacuity.Compiler.mod.2
+SAVED_R2=/tmp/opencode/nonvacuity.Runtime.mod.2
+cp Compiler.mod "$SAVED_C2" || exit 1
+cp Runtime.mod "$SAVED_R2" || exit 1
+restore_audit_sources () {
+    cp "$SAVED_C2" Compiler.mod
+    cp "$SAVED_R2" Runtime.mod
+}
+
+AUD="python3 tests/audit_helpers.py"
+
+# 1. THE fault.  91h is XCHG AX,CX; 93h is XCHG BX,AX.  Both are one byte, so
+#    the compile matrix never moved and the byte counts never moved.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|^   Ebyte (91H)$|   Ebyte (93H)|'
+expect_red "audit catches XchgAxCx emitting XCHG BX,AX" \
+    "exchanges Ax and Bx" $AUD
+restore_audit_sources
+
+# 2. the coverage check itself.  A parameter list that find_helpers does not
+#    accept is exactly how the real emitter was missed, and the inventory is
+#    scanned separately on purpose so this can be caught.  Without the
+#    independent scan this case is silent, because both lists would come from
+#    the same parser and agree that the helper does not exist.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|^PROCEDURE EmXchgAxCx () ;$|PROCEDURE EmXchgAxCx (why : CARDINAL) ;|'
+expect_red "audit reports an emitter it cannot reach, rather than skipping it" \
+    "never examined it" $AUD
+restore_audit_sources
+
+# 3. EmXchgAxDx was named EmMoveAxDx, which said MOV where the bytes say XCHG.
+#    93h here is XCHG AX,BX - one letter away, the exact class of mistake the
+#    name is supposed to make impossible.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|^   Ebyte (92H)$|   Ebyte (93H)|'
+expect_red "audit catches XchgAxDx emitting XCHG BX,AX" \
+    "XchgAxDx" $AUD
+restore_audit_sources
+
+# 4. CmpArgW0's [BP+2] written as the 386 SIB form, which decodes on a 8086 as
+#    [SI+24h].  A real bug: the runtime was clearing the wrong memory.
+cp "$SAVED_R2" Runtime.mod
+mutate Runtime.mod 's|   B (83H) ; B (7EH) ; B (2) ; B (0) ;|   B (83H) ; B (7CH) ; B (24) ; B (0) ; B (0) ;|'
+expect_red "audit catches the [SI+24h] encoding of [BP+2]" \
+    "memory base is 'si" $AUD
+restore_audit_sources
+
+# 5. MovAxSp is POP then PUSH, because MOV AX,[SP] does not exist on an 8086.
+#    Dropping the POP leaves the stack one word short - a fault in the shape,
+#    not in a byte value.
+cp "$SAVED_C2" Compiler.mod
+python3 - <<'PYEOF'
+p='Compiler.mod'; s=open(p).read()
+a="   Ebyte (58H) ;                     (* POP AX  *)\n"
+assert s.count(a)==1, "EmMovAxSp POP line not found -- update this mutation"
+open(p,'w').write(s.replace(a, ""))
+PYEOF
+expect_red "audit catches MovAxSp with its POP missing" \
+    "MovAxSp" $AUD
+restore_audit_sources
+
+# 6. The two-instruction shape: IDIV is CWD then IDIV, and dropping the CWD
+#    leaves an un-sign-extended dividend in DX:AX.  Both are still present as
+#    a two-step spec, so a missing step has to be visible.
+cp "$SAVED_C2" Compiler.mod
+mutate Compiler.mod 's|   Ebyte (99H) ; Ebyte (0F7H) ; Ebyte (0F9H)|   Ebyte (0F7H) ; Ebyte (0F9H)|'
+expect_red "audit catches IDiv without the CWD that extends the dividend" \
+    "IDivAxCx" $AUD
+restore_audit_sources
+
+if $AUD >/dev/null 2>&1; then
+    echo "  ok: the audit passes on both restored sources"
+    pass=$((pass + 1))
+else
+    echo "NOT RESTORED: the audit is red after restoring the sources"
+    $AUD 2>&1 | sed 's/^/       /'
+    fail=$((fail + 1))
+fi
+
+echo
+echo "== the .COM layout check, and the runtime size it now measures"
+# The checker used to RESTATE the runtime's size as a literal.  It was wrong
+# by 41 bytes for an unknown time, and every one of the 30 .COM files "failed"
+# on a header read out of the code stream.  A duplicated constant that has
+# drifted does not fail loudly; it re-reports the same falsehood, in which the
+# real failures hide.  The size is now MEASURED from the image.
+#
+# These cases corrupt a real emitted .COM and require the checker to notice.
+# They need the images, so they are built once and copied; the checker has a
+# --check-only mode for exactly this, because its scratch directory is normally
+# deleted on exit and a check that has only ever seen the truth is not a check.
+KEEPDIR=/tmp/opencode/nonvacuity.com
+rm -rf "$KEEPDIR"
+TP_COM_KEEP=1 tests/run_com_tests.sh >/tmp/opencode/nonvacuity.com.log 2>&1
+KEEP=$(sed -n 's/^TP_COM_KEEP=1: images left in //p' \
+       /tmp/opencode/nonvacuity.com.log | tail -1)
+if [ -z "$KEEP" ] || [ ! -d "$KEEP" ]; then
+    echo "  FAIL: could not obtain emitted .COM images for the layout cases"
+    fail=$((fail + 1))
+else
+    COMCHK="tests/run_com_tests.sh --check-only"
+
+    # 0. The baseline.  Every case below is a claim that a specific assertion
+    #    turns red, and none of them means anything if the copies of untouched
+    #    images already fail.  (The stale RT_SZ produced exactly that: 30
+    #    failures that were not findings.)  So this is asserted first, and a
+    #    failure here is reported as a broken baseline rather than a red test.
+    rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
+    cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
+    if $COMCHK "$KEEPDIR" >/dev/null 2>&1; 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/^/       /'
+        fail=$((fail + 1))
+    fi
+
+    # 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
+    #    fell back to a remembered offset would report a confident number here,
+    #    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'
+import sys
+p = sys.argv[1]
+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
+    expect_red "a header that cannot be located is reported, not assumed" \
+        "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.
+    rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
+    cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
+    python3 - "$KEEPDIR/t01_minimal.COM" <<'PYEOF'
+import sys
+p = sys.argv[1]
+d = bytearray(open(p, 'rb').read())
+off = 439
+w = [1, 464 + 0x100, off + 0x1000 + 0x100, off + 0x1000 + 0x100 + 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"
+
+    # 3. The entry jump's opcode.  One byte, and the only assertion in the
+    #    project that can see where execution STARTS.
+    rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
+    cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
+    python3 - "$KEEPDIR/t01_minimal.COM" <<'PYEOF'
+import sys
+p = sys.argv[1]
+d = bytearray(open(p, 'rb').read())
+d[0] = 0xEA
+open(p, 'wb').write(bytes(d))
+PYEOF
+    expect_red "the entry jump must be E9, not a near JMP" \
+        "not the E9 of the entry jump" $COMCHK "$KEEPDIR"
+
+    # 4. The entry jump's target, moved one instruction earlier.  A .COM that
+    #    lands in the middle of the prologue runs, prints something and exits
+    #    cleanly, so no size or structure check can see this.
+    rm -rf "$KEEPDIR"; mkdir -p "$KEEPDIR"
+    cp "$KEEP"/*.COM "$KEEP"/raw.txt "$KEEPDIR"/
+    python3 - "$KEEPDIR/t01_minimal.COM" <<'PYEOF'
+import sys
+p = sys.argv[1]
+d = bytearray(open(p, 'rb').read())
+d[1:3] = (100).to_bytes(2, 'little')
+open(p, 'wb').write(bytes(d))
+PYEOF
+    expect_red "the entry jump must land on the first instruction" \
+        "entry jump rel16=100" $COMCHK "$KEEPDIR"
+
+    rm -rf "$KEEPDIR"
+fi
+
 echo
 echo
 echo "non-vacuity: $pass ok, $fail failed"
 echo "non-vacuity: $pass ok, $fail failed"
 [ "$fail" -eq 0 ]
 [ "$fail" -eq 0 ]

+ 25 - 6
shell/tests/run_all.sh

@@ -27,12 +27,24 @@
 # Three runtime suites run before all of those, because everything else
 # Three runtime suites run before all of those, because everything else
 # trusts the runtime's bytes:
 # trusts the runtime's bytes:
 #
 #
-#   audit_helpers       disassembles every one-line emitter in Runtime.mod
-#                       and compares the DECODE against the procedure's NAME.
-#                       This is the only check that can see a wrong ModRM
-#                       that still decodes cleanly, which is the mistake this
-#                       file actually makes: MovSiBx and CmpSiBx were both
-#                       `DC` (= MOV SP,BX / CMP SP,BX) for a long time.
+#   audit_helpers       disassembles every one-line emitter in BOTH Runtime.mod
+#                       and Compiler.mod and compares the DECODE against the
+#                       procedure's NAME.  This is the only check that can see
+#                       a wrong ModRM that still decodes cleanly, which is the
+#                       mistake this project actually makes: MovSiBx and
+#                       CmpSiBx were both `DC` (= MOV SP,BX / CMP SP,BX) for a
+#                       long time, and EmXchgAxCx was `93` (XCHG BX,AX) under a
+#                       name that says XCHG AX,CX.
+#
+#                       It sweeps Compiler.mod because that is where the
+#                       emitter was.  Auditing one module said "every helper
+#                       agrees with its name" about a module it had not
+#                       examined, which is why the XCHG fault survived a green
+#                       run.  It also cross-checks its own coverage: a
+#                       procedure that emits bytes but that the audit never
+#                       matched is itself a failure, because a helper that
+#                       drops out of the audit when you add a comment is worse
+#                       than a helper that is checked and found wanting.
 #   check_runtime       sweeps the built runtime's code region with FCML: no
 #   check_runtime       sweeps the built runtime's code region with FCML: no
 #                       desync, every entry and every branch target on an
 #                       desync, every entry and every branch target on an
 #                       instruction boundary, and the whole disassembly equal
 #                       instruction boundary, and the whole disassembly equal
@@ -113,6 +125,13 @@ run "mod=11 table"     python3 tests/probe/modrm11.py
 run "runtime image"   python3 tests/check_runtime.py
 run "runtime image"   python3 tests/check_runtime.py
 run "compile matrix"  tests/run_compile_tests.sh
 run "compile matrix"  tests/run_compile_tests.sh
 run "COM linker"      tests/run_com_tests.sh
 run "COM linker"      tests/run_com_tests.sh
+# The byte checks above can only ask "are these bytes well formed".  This one
+# asks "does the program do the right thing": every fixture with a .out is
+# compiled, booted in qemu, and its output compared byte for byte, together
+# with the DOS exit code.  It is the step that found eleven runtime bugs, the
+# 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
 run "frame displ"     python3 tests/check_framedisp.py
 run "frame displ"     python3 tests/check_framedisp.py
 run "UI error path"   python3 tests/uitest.py
 run "UI error path"   python3 tests/uitest.py
 run "UI success path" python3 tests/comtest.py
 run "UI success path" python3 tests/comtest.py

+ 332 - 0
shell/tests/run_com_exec.py

@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+"""run_com_exec.py -- execute a TP3-compiled .COM and assert its exact output.
+
+This is the test the project could not write until now.  Everything before it
+was a claim about the *image*; this is a claim about the *program*.  A .COM
+that this compiler produced is booted on a real 8086 under qemu-system-i386,
+and the exact bytes it writes are asserted against a file.
+
+    .pas --Compiler.mod--> image --Linker.mod--> .COM
+    .COM --bootcom.s--> floppy image --qemu--> serial bytes --assert-->
+
+Nothing here trusts the compiler, the linker, or the boot sector:
+
+  * the floppy image is built here, byte by byte;
+  * the load address and the input descriptor's offset are *restated* from
+    bootcom.s's own layout block, and this file refuses to run if they have
+    drifted out of what the boot sector actually reads;
+  * "the program exited" is not a marker in the output stream -- it is qemu's
+    exit code, via the isa-debug-exit device.  So no sentinel byte can ever
+    collide with real program output, and every byte on the serial port is
+    program output with nothing to strip;
+  * the expected output is a file per fixture, next to the fixture, so a
+    wrong expectation shows up as a diff instead of an inline string that
+    gets quietly edited to match.
+
+Usage:
+
+    tests/run_com_exec.py                 # every fixture with a .out file
+    tests/run_com_exec.py t02_writeln     # one, by name
+    tests/run_com_exec.py --list
+    tests/run_com_exec.py --show t02      # print what came out, assert nothing
+
+`--rebless` regenerates the .out files from what the machine actually did.
+It exists because a project that cannot regenerate its expectations cannot
+tell the difference between a bug and a stale file -- but it is never the way
+to fix a red test, and the summary it prints says how many files it rewrote.
+"""
+
+import os
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SHELL = os.path.dirname(HERE)
+GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2"
+QEMU = "/usr/bin/qemu-system-i386"
+TIMEOUT = 15
+
+# --------------------------------------------------------------- the layout
+# Restated from tests/exec/bootcom.s on purpose.  A test that asks the code
+# under test where things are proves only that the code agrees with itself,
+# so these are written out here and checked against each other; if the
+# assembly's layout changes, the mismatch shows up as a loud failure rather
+# than as a program loaded somewhere harmless.
+LOAD_SEG = 0x0000        # the .COM's load segment
+LOAD_OFF = 0x0100        # ...and offset; DOS puts a .COM at CS:0100
+FLAT_LOAD = LOAD_SEG * 16 + LOAD_OFF
+INLEN_OFF = 0x2000       # word: how many input bytes follow
+INBUF_OFF = 0x2004       # the input bytes
+INBUF_MAX = 0x00FC       # 2100h - 2004h
+SECTORS = 16             # what bootcom.s reads off the disk
+FLOPPY_SECTORS = 2880    # 1.44M, 18 sectors per track
+FLOPPY_BYTES = FLOPPY_SECTORS * 512
+COM_MAX = SECTORS * 512  # a .COM the boot sector could not load in full
+
+# The descriptor has to be inside the region the boot sector reads, or the
+# boot sector reads whatever the BIOS left there.  This is a check on the two
+# constant blocks agreeing, and it is the reason they are both written down.
+READ_END = FLAT_LOAD + SECTORS * 512
+if INBUF_OFF + INBUF_MAX > READ_END:
+    sys.exit("run_com_exec.py: the input descriptor at %04X..%04X runs past the "
+             "%d sectors bootcom.s reads (which end at %04X)"
+             % (INBUF_OFF, INBUF_OFF + INBUF_MAX, SECTORS, READ_END))
+# ...and past the end of any .COM, or it would land inside the program.
+if INBUF_OFF < COM_MAX:
+    sys.exit("run_com_exec.py: the input descriptor at %04X is inside the %d "
+             "bytes the boot sector reads, so it could overlap a .COM"
+             % (INBUF_OFF, COM_MAX))
+
+# isa-debug-exit at port 0501h.  qemu exits with (value<<1)|1, so a program
+# that exits 0 gives rc=1 and a program that exits n gives (n<<1)|1.  Both
+# mean "ran to completion"; anything else means it never got there.
+EXIT_BASE = 1
+
+
+def build_boot_sector():
+    """Assemble tests/exec/bootcom.s and return its 512 bytes."""
+    with tempfile.TemporaryDirectory() as td:
+        obj, raw = os.path.join(td, "b.o"), os.path.join(td, "b.bin")
+        r = subprocess.run(["as", "--32", "-o", obj,
+                            os.path.join(HERE, "exec", "bootcom.s")],
+                           capture_output=True)
+        if r.returncode != 0:
+            sys.exit("as failed:\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"))
+        data = open(raw, "rb").read()
+    if len(data) != 512:
+        sys.exit("the boot sector is %d bytes, not 512" % len(data))
+    if data[510:512] != b"\x55\xaa":
+        sys.exit("the boot sector has no 55 AA signature")
+    return data
+
+
+def to_file(mem_off):
+    """Where a SEGMENT-relative address ends up in the disk image.
+
+    bootcom.s reads disk sector 2 (1-based, so file offset 512) into
+    0000:0100.  Memory at segment offset M therefore comes from file offset
+
+        512 + (M - 0100h)
+
+    which is M + 100h for every M in the loaded region.  Getting this wrong
+    is silent: the image is built, qemu boots it, and the descriptor simply
+    sits at the wrong place.  t29_readln then read an INLEN of 0 - the
+    descriptor was never where the shim looks - so AH=08h reported end of
+    input on the very first call and readln waited forever for a line
+    terminator that could not arrive.  So the conversion is in one named
+    function rather than open-coded, and the two constants that define it are
+    checked against each other above.
+    """
+    return mem_off - LOAD_OFF + 512
+
+
+def build_floppy(boot, com, stdin=b""):
+    """Assemble the 1.44M image: boot sector, then the .COM, then the input."""
+    if len(com) > COM_MAX:
+        sys.exit("the .COM is %d bytes and the boot sector reads only %d"
+                 % (len(com), COM_MAX))
+    if len(stdin) > INBUF_MAX:
+        sys.exit("%d bytes of input, at most %d fit" % (len(stdin), INBUF_MAX))
+    img = bytearray(FLOPPY_BYTES)
+    img[0:512] = boot
+    # The .COM goes at disk sector 2, which is where bootcom.s reads from.
+    # to_file(LOAD_OFF) == 512, so this agrees with the mapping above.
+    img[512:512 + len(com)] = com
+    inlen_f, inbuf_f = to_file(INLEN_OFF), to_file(INBUF_OFF)
+    img[inlen_f:inlen_f + 2] = len(stdin).to_bytes(2, "little")
+    img[inbuf_f:inbuf_f + len(stdin)] = stdin
+    return bytes(img)
+
+
+def run_qemu(floppy):
+    """Boot the image.  Return (output bytes, exit code or None, note)."""
+    with tempfile.TemporaryDirectory() as td:
+        ser = os.path.join(td, "serial.bin")
+        cmd = [QEMU,
+               "-drive", "file=%s,format=raw,if=floppy,index=0" % floppy,
+               "-chardev", "file,id=s0,path=" + ser,
+               "-serial", "chardev:s0",
+               "-device", "isa-debug-exit,iobase=0x501,iosize=1",
+               "-display", "none",
+               "-no-reboot"]
+        try:
+            r = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT)
+        except subprocess.TimeoutExpired:
+            # Whatever reached the serial port is still evidence, so keep it
+            # and report the hang rather than throwing the output away.
+            out = open(ser, "rb").read() if os.path.exists(ser) else b""
+            return out, None, "the machine never halted (timeout %ds)" % TIMEOUT
+        out = open(ser, "rb").read() if os.path.exists(ser) else b""
+    # qemu's OWN code, which is the only record of whether the program reached
+    # the isa-debug-exit port.  It used to be dropped and 0 returned, which
+    # exit_code_of then had to read as "unknown" - so a program that ran to
+    # completion and one that hung were indistinguishable here, and every
+    # fixture reported exit=None.  Passing the real code through is what makes
+    # "it terminated" an assertion rather than an assumption.
+    return out, r.returncode, ""
+
+
+def exit_code_of(rc):
+    """The DOS exit code the program passed to INT 21h AH=4Ch, or None."""
+    if rc is None or rc == 0 or (rc - EXIT_BASE) % 2 != 0:
+        return None
+    return (rc - EXIT_BASE) // 2
+
+
+def ensure_comtest():
+    """Return the path to comtest, rebuilding it if any source is newer.
+
+    Timestamp-checked rather than assumed, because this project has already
+    lost a session to a stale `comtest` disagreeing with a freshly built
+    `compiletest`.  See the mtime comparison in tests/uitest.py's siblings.
+    """
+    exe = os.path.join(SHELL, "comtest")
+    # The freshness list must be the files actually LINKED - the .o files - not
+    # the .mod files they came from.  Watching the sources instead left a real
+    # hole: `make` relinks Compiler.o from an edited Compiler.mod, and if that
+    # happened after comtest was last built, comtest was older than every
+    # .mod and so looked current while containing the old compiler.  The
+    # failure mode is the worst kind: the .COM under test is generated by the
+    # stale compiler, so a fix is invisible and a test that should have gone
+    # red stays green.  The .mod files are kept in the list anyway, so editing a
+    # source without running make also forces a relink.
+    objs = ["TextBuf.o", "Posix.o", "Compiler.o", "Runtime.o", "Linker.o"]
+    newer = objs + ["Compiler.mod", "Runtime.mod", "Linker.mod", "TextBuf.mod",
+                    "Posix.c", os.path.join("tests", "ComTest.mod")]
+    if os.path.exists(exe):
+        t = os.path.getmtime(exe)
+        if all(os.path.exists(os.path.join(SHELL, f))
+               and os.path.getmtime(os.path.join(SHELL, f)) <= t
+               for f in newer):
+            return exe
+    lst = os.path.join(SHELL, "tests", "ct.lst")
+    link = ([os.path.join(SHELL, "tests", "ComTest.mod")]
+            + [os.path.join(SHELL, o) for o in objs])
+    subprocess.run([GM2, "-fiso", "-fgen-module-list=" + lst, "-o", "/dev/null"]
+                   + link, capture_output=True, cwd=SHELL)
+    r = subprocess.run([GM2, "-fiso", "-fuse-list=" + lst, "-o", exe] + link,
+                       capture_output=True, cwd=SHELL)
+    if r.returncode != 0:
+        sys.exit("linking comtest failed:\n" + r.stderr.decode(errors="replace"))
+    return exe
+
+
+def emit_com(exe, pas, workdir):
+    """Compile one .pas to workdir/NAME.COM.  Return (path or None, output)."""
+    # ComTest reads paths from stdin and writes the .COM into its own cwd,
+    # named after the fixture's base name.  So cwd is the workdir.
+    r = subprocess.run([exe], input=pas.encode() + b"\n",
+                       capture_output=True, cwd=workdir)
+    com = os.path.join(workdir,
+                       os.path.basename(pas)[:-4] + ".COM")
+    if not os.path.exists(com):
+        return None, r.stdout.decode(errors="replace").strip()
+    return com, r.stdout.decode(errors="replace").strip()
+
+
+def visible_output(raw):
+    """Turn a repr-able byte string into something a terminal shows."""
+    return raw.decode("latin-1").replace("\r", "\\r").replace("\n", "\\n")
+
+
+def cases():
+    """Every fixture that has a .out file, with its optional input."""
+    fx = os.path.join(HERE, "fixtures")
+    for name in sorted(os.listdir(fx)):
+        if not name.endswith(".out"):
+            continue
+        stem = name[:-4]
+        stdin = b""
+        inpath = os.path.join(fx, stem + ".in")
+        if os.path.exists(inpath):
+            stdin = open(inpath, "rb").read()
+        yield {"name": stem,
+               "out": os.path.join(fx, name),
+               "in": inpath if os.path.exists(inpath) else None,
+               "stdin": stdin}
+
+
+def main(argv):
+    if "--list" in argv:
+        for c in cases():
+            print(c["name"])
+        return 0
+    show = "--show" in argv
+    rebless = "--rebless" in argv
+    names = [a for a in argv if not a.startswith("-")]
+    all_cases = list(cases())
+    if names:
+        sel = [c for c in all_cases if c["name"] in names]
+        missing = set(names) - {c["name"] for c in all_cases}
+        if missing:
+            sys.exit("no such fixture: " + ", ".join(sorted(missing)))
+    else:
+        sel = all_cases
+
+    boot = build_boot_sector()
+    exe = ensure_comtest()
+    workdir = tempfile.mkdtemp(prefix="tpexec-")
+    passed = failed = blessed = 0
+    for c in sel:
+        pas = os.path.join(HERE, "fixtures", c["name"] + ".pas")
+        com, log = emit_com(exe, pas, workdir)
+        if com is None:
+            print("  %-22s FAIL  comtest wrote no .COM" % c["name"])
+            if log:
+                print("       %s" % log)
+            failed += 1
+            continue
+        data = open(com, "rb").read()
+        img = os.path.join(workdir, c["name"] + ".img")
+        open(img, "wb").write(build_floppy(boot, data, c["stdin"]))
+        got, rc, note = run_qemu(img)
+        want = open(c["out"], "rb").read()
+
+        if show:
+            print("  %-22s exit=%s  %s" % (c["name"], exit_code_of(rc),
+                                           visible_output(got)))
+            continue
+        if rc is None:
+            print("  %-22s FAIL  %s" % (c["name"], note))
+            print("       output so far: %s" % visible_output(got))
+            failed += 1
+            continue
+        code = exit_code_of(rc)
+        if code is None:
+            print("  %-22s FAIL  qemu rc=%d, which no .COM exit can produce "
+                  "(the program never called INT 21h AH=4Ch)" % (c["name"], rc))
+            failed += 1
+            continue
+        if got != want:
+            if rebless:
+                open(c["out"], "wb").write(got)
+                print("  %-22s REBLESSED  %s" % (c["name"], visible_output(got)))
+                blessed += 1
+                continue
+            print("  %-22s FAIL" % c["name"])
+            print("       want  %s" % visible_output(want))
+            print("       got   %s" % visible_output(got))
+            failed += 1
+            continue
+        print("  %-22s PASS  %d bytes, exit %d"
+              % (c["name"], len(got), code))
+        passed += 1
+    if show:
+        return 0
+    print()
+    if rebless and blessed:
+        print("REBLESSED %d expectation file(s).  Check the diff: a .out that "
+              "changed is a claim that was wrong, or a program that is."
+              % blessed)
+    print("execution: %d passed, %d failed (of %d)"
+          % (passed, failed, len(sel)))
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 208 - 24
shell/tests/run_com_tests.sh

@@ -13,7 +13,29 @@ GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
 cd "$D" || exit 9
 cd "$D" || exit 9
 FLAGS="-fiso"
 FLAGS="-fiso"
 
 
+# --check-only DIR  --  skip the build and the link, and run only the
+# independent Python checker over the .COM files already in DIR (plus a
+# hand-written raw.txt in the same shape the linker emits).
+#
+# This exists for non-vacuity, and it is the only reason to make it.  The
+# checker normally runs over a scratch directory that the EXIT trap deletes,
+# so there is no way to hand it a DELIBERATELY WRONG .COM and see whether it
+# notices.  A check that has only ever been shown the truth is not a check:
+# it could be reporting the truth about every file because it says nothing at
+# all.  tests/nonvacuity.sh uses this to feed it a corrupted image and
+# require the named assertion to go red.
+CHECK_ONLY=""
+if [ "${1:-}" = "--check-only" ]; then
+    CHECK_ONLY=${2:-}
+    shift 2
+fi
+
 echo "== support modules =="
 echo "== support modules =="
+if [ -n "$CHECK_ONLY" ]; then
+    echo "check-only mode: not rebuilding, not linking"
+    OUT="$CHECK_ONLY"
+    [ -d "$OUT" ] || { echo "RESULT: FAIL (no such directory: $OUT)"; exit 1; }
+else
 [ -f Posix.o ] || cc -c Posix.c || exit 1
 [ -f Posix.o ] || cc -c Posix.c || exit 1
 for m in TextBuf Compiler Runtime Linker; do
 for m in TextBuf Compiler Runtime Linker; do
    $GM2 $FLAGS -c $m.mod >/tmp/cm_c_$m 2>&1 \
    $GM2 $FLAGS -c $m.mod >/tmp/cm_c_$m 2>&1 \
@@ -40,7 +62,16 @@ echo "comtest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)"
 
 
 # .COM files are written beside the harness, so run it in a scratch dir
 # .COM files are written beside the harness, so run it in a scratch dir
 OUT=$(mktemp -d) || exit 9
 OUT=$(mktemp -d) || exit 9
-trap 'rm -rf "$OUT"' EXIT
+# TP_COM_KEEP=1 leaves the scratch dir behind, for tests/nonvacuity.sh to
+# corrupt a copy of a real image and hand it back through --check-only.  The
+# alternative - rebuilding the whole toolchain inside the non-vacuity script
+# to produce one throwaway byte - is slow for no benefit, and the point of
+# the case is the CHECKER's sensitivity, not the compiler's.
+if [ "${TP_COM_KEEP:-0}" = "1" ]; then
+    echo "TP_COM_KEEP=1: images left in $OUT"
+else
+    trap 'rm -rf "$OUT"' EXIT
+fi
 cd "$OUT" || exit 9
 cd "$OUT" || exit 9
 
 
 ls "$D"/tests/fixtures/*.pas | "$D"/comtest > "$OUT/raw.txt" 2>&1
 ls "$D"/tests/fixtures/*.pas | "$D"/comtest > "$OUT/raw.txt" 2>&1
@@ -56,22 +87,68 @@ if [ "$nbad" -ne 0 ]; then
    echo "RESULT: FAIL (harness could not link every compiling fixture)"
    echo "RESULT: FAIL (harness could not link every compiling fixture)"
    exit 1
    exit 1
 fi
 fi
+fi
 
 
 # ---- independent verification of the bytes on disk ------------------------
 # ---- independent verification of the bytes on disk ------------------------
 python3 - "$OUT" <<'PYEOF'
 python3 - "$OUT" <<'PYEOF'
 import sys, os, re, glob
 import sys, os, re, glob
 out = sys.argv[1]
 out = sys.argv[1]
 
 
-# Compiler layout constants, restated here on purpose: the checker must not
-# ask the code under test what the answer is.
+# ENT_SZ and HDR_SZ are the layout constants, and they are RESTATED here on
+# purpose: the checker must not ask the code under test what the answer is.
+#
+# RT_SZ is different, and it is NOT restated.  It used to be a literal, and it
+# was WRONG - 391, against a runtime of 432 bytes - so the header was read at
+# offset 394 instead of 435 and every one of the 30 .COM files "failed" on a
+# header full of code bytes.  A duplicated constant that has silently drifted
+# is not an independent check; it is a second source of truth that lies, and
+# it lies in the direction of looking like the compiler is broken.
+#
+# So the runtime's size is MEASURED, from the .COM itself: the runtime is the
+# region between the entry jump and the program header, and the header is
+# found by its own signature rather than by an assumed offset (hdrFlag = 1,
+# with the code END and the data base where the layout says they are).  If
+# the runtime ever changes size, this follows automatically; if the LAYOUT
+# changes, the header stops being found and the checker says so instead of
+# quietly measuring the wrong thing.
+#
+# The measurement is still independent of the compiler - it reads the emitted
+# file, not a Modula-2 variable - so it cannot be satisfied by the code under
+# test agreeing with itself.  tests/check_runtime.py pins the size explicitly,
+# which is where a deliberate size change should be noticed.
+RT_SZ = None                    # measured per .COM by find_header, below
+
+# The image starts with a three-byte JMP at offset 0 -- see the layout comment
+# in Compiler.Inittur.  It has to be there: a .COM is entered at file offset
+# 0, and until the jump existed this checker ASSERTED that the runtime was at
+# offset 0, which is precisely the bug.  A checker that pins a wrong invariant
+# is worse than no checker, because it makes the wrong thing look tested.
+ENT_SZ = 3                     # E9 lo hi
+HDR_SZ = 16                   # 5 header words + 3 buffer words, see Compiler
+# RTSZ (image offset of the program header), PROLOG (RTSZ + HDR_SZ, where the
+# entry jump must land) and DATAB (RTSZ + 1000h, the compiler's data base) are
+# all DERIVED PER FILE by find_header below, not written down here.  They used
+# to be module constants built on the restated RT_SZ, which is the bug this
+# whole block exists to remove: every one of them was wrong by 41 bytes, and
+# a checker that is consistently wrong in a simple direction does not fail -
+# it re-reports the same false 30 failures, in which the real ones hide.
+
+# The load bias: DOS puts a .COM's first byte at CS:0100, and CS = DS, so an
+# image offset K lives at DS:(K + 0100h).  Every ABSOLUTE address in the
+# image must carry it; relative encodings (the entry jump, every CALL) must
+# not, since both operands shift together.  Restated here so that the header
+# checks below compare against the addresses the program will actually use,
+# and so that the +0100h in them is a decision this checker made rather than
+# an accident of the compiler's.  See Runtime.LoadBias.
 #
 #
-# Re-baselined 385 -> 391 with the runtime bug fixes (see the re-baseline
-# rationale in tests/runtime.golden and the ModRM table in Runtime.mod).  The
-# size is what a change to the runtime shows up in first; if this constant
-# ever needs changing again, find out why the runtime moved rather than just
-# editing the number, and check tests/check_runtime.py for the reason.
-RTSZ  = 391                    # Runtime.RT_Size()
-DATAB = RTSZ + 0x1000         # Compiler: data base = rtSz + 1000H
+# This is the THIRD bias of the same family in this file, and the subtlest:
+# the entry jump (a jump that landed on the end of the code), the RT_Entry
+# offsets (CALLs that landed inside a neighbouring runtime entry) and this
+# one (absolute addresses that landed 0100h low, inside the runtime) all
+# produce a program that STARTS, RUNS and PRINTS something.  Only running it
+# finds this one; the byte checks are all satisfied by an address that is
+# consistently 0100h wrong.
+LOAD_BIAS = 0x100
 
 
 # initmem's prologue, which is the runtime's only reader of the program
 # initmem's prologue, which is the runtime's only reader of the program
 # header.  The displacements +4 and +6 below are the whole point of this
 # header.  The displacements +4 and +6 below are the whole point of this
@@ -81,6 +158,9 @@ DATAB = RTSZ + 0x1000         # Compiler: data base = rtSz + 1000H
 # zeroed nothing at all, and nothing here noticed -- the emitted loop was
 # zeroed nothing at all, and nothing here noticed -- the emitted loop was
 # perfectly well formed, it just never ran.  Asserting the bytes and the
 # perfectly well formed, it just never ran.  Asserting the bytes and the
 # header offsets together is what closes that gap.
 # header offsets together is what closes that gap.
+#
+# It is the FIRST ELEVEN BYTES OF THE RUNTIME, so it sits at image offset
+# ENT_SZ, not 0.
 HEAD  = '8B F0 8B 54 04 8B 4C 06'   # 11 bytes of initmem, see below
 HEAD  = '8B F0 8B 54 04 8B 4C 06'   # 11 bytes of initmem, see below
 HDR_DS_WORD   = 4             # header word holding the data base
 HDR_DS_WORD   = 4             # header word holding the data base
 HDR_HEAP_WORD = 6             # header word holding the data end
 HDR_HEAP_WORD = 6             # header word holding the data end
@@ -99,7 +179,54 @@ if not rows:
     print('RESULT: FAIL (no linked fixtures found in output)')
     print('RESULT: FAIL (no linked fixtures found in output)')
     sys.exit(1)
     sys.exit(1)
 
 
+
+def find_header(d):
+    """Locate the program header by its own signature.  Returns its image
+    offset, or None.
+
+    The header is eight words at image offset ENT_SZ + rtSz, and the layout
+    says what they are (offsets here are BYTES into the header, which is why
+    HDR_DS_WORD is 4 and not 2 - the words are 2 bytes each and 1-based by
+    two, not by one):
+
+        +0  1                 hdrFlag, always 1
+        +2  code end + bias   hdrCS
+        +4  data base + bias  hdrDS, where data base = hdrOff + 1000h
+        +6  data end  + bias  hdrHeap, which is hdrDS + dataBytes
+
+    hdrDS ties the header to its OWN offset, so the offset is recoverable from
+    the file without assuming a runtime size: hdrOff = hdrDS - 1000h - bias.
+    A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
+    equation, hdrHeap is above hdrDS (a heap below its own base is not a
+    layout, it is a coincidence), and the runtime's known first bytes are
+    where they belong.  initmem is the ONLY code in the image that reads the
+    header, so its bytes cannot themselves move: they are at ENT_SZ always.
+
+    Measuring beats restating the size, and it is not a loss of independence:
+    it reads the EMITTED FILE, so the compiler cannot satisfy it by agreeing
+    with itself.  tests/check_runtime.py is where the runtime's size is pinned
+    deliberately, and this checker reports the size it measured on every run,
+    so a change there is visible rather than absorbed.
+    """
+    head = bytes(int(x, 16) for x in HEAD.split())
+    if d[ENT_SZ:ENT_SZ + len(head)] != head:
+        return None                      # no runtime: nothing to measure
+    for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
+        w = (lambda b: int.from_bytes(d[off + b:off + b + 2], 'little'))
+        if w(0) != 1:                                    # hdrFlag
+            continue
+        if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:    # hdrDS
+            continue
+        if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):            # hdrHeap
+            continue
+        if w(2) - LOAD_BIAS < off + HDR_SZ:               # hdrCS
+            continue
+        return off
+    return None
+
+
 bad = 0
 bad = 0
+last_rt = None
 for name, com, image, data, nzg in rows:
 for name, com, image, data, nzg in rows:
     com, image, data, nzg = int(com), int(image), int(data), int(nzg)
     com, image, data, nzg = int(com), int(image), int(data), int(nzg)
     # ComTest writes the .COM by BASENAME beside itself (it cannot graft a
     # ComTest writes the .COM by BASENAME beside itself (it cannot graft a
@@ -113,9 +240,60 @@ for name, com, image, data, nzg in rows:
     else:
     else:
         d = open(path, 'rb').read()
         d = open(path, 'rb').read()
 
 
-    if d[:len(HEAD.split())].hex(' ').upper() != HEAD:
-        errs.append('runtime not at offset 0 (first bytes %s, want %s)'
-                    % (d[:len(HEAD.split())].hex(' ').upper(), HEAD))
+    # Everything below is expressed in terms of where the header actually is,
+    # measured from this file, rather than where a literal says it should be.
+    hdrOff = find_header(d)
+    if hdrOff is None:
+        errs.append('no program header found: the layout this checker knows '
+                    'how to look for is not the one in the file')
+        RTSZ, PROLOG, DATAB = ENT_SZ, ENT_SZ + HDR_SZ, ENT_SZ + 0x1000
+    else:
+        RTSZ = hdrOff
+        PROLOG = hdrOff + HDR_SZ
+        DATAB = hdrOff + 0x1000
+        if RTSZ != last_rt:
+            last_rt = RTSZ
+            print('  measured runtime size: %d bytes (header at image offset '
+                  '%d)' % (RTSZ - ENT_SZ, RTSZ))
+
+    # The entry jump.  This is the one assertion in the whole project that can
+    # see where execution STARTS, because it is the only one that cares.  It
+    # has caught THREE real bugs, all in the same three bytes, and all of them
+    # invisible to every other check here:
+    #
+    #   1. no jump at all, so a .COM began by executing the runtime's initmem
+    #      with whatever the loader left in AX;
+    #   2. a jump to `pc`, one byte past the last instruction, into the
+    #      zero-filled code/data gap, where the CPU slides through
+    #      `ADD [BX+SI],AL` until it faults;
+    #   3. a jump to RTSZ, which is the program HEADER - sixteen bytes of DATA
+    #      that the CPU then decodes as instructions.  This one is the reason
+    #      the target is pinned to PROLOG and not to RTSZ: whether it works
+    #      depends entirely on how those sixteen bytes happen to decode, so
+    #      writeln('hi') ran correctly by sliding through them while t07 hung
+    #      on a LOCK-prefixed ADD with a displacement crossing a page.  The
+    #      correct target is the first instruction, and there is no reason for
+    #      a checker to accept a range.
+    if len(d) >= ENT_SZ:
+        if d[0] != 0xE9:
+            errs.append('byte 0 is %02X, not the E9 of the entry jump' % d[0])
+        # The jump's displacement is measured from the end of the jump.
+        want_rel = PROLOG - ENT_SZ
+        got_rel = int.from_bytes(d[1:ENT_SZ], 'little')
+        if got_rel != want_rel:
+            errs.append('entry jump rel16=%d, want %d; it lands on image '
+                        'offset %d, want %d (the first instruction, %d bytes '
+                        'past the header - not the header at %d, and not the '
+                        'end of the code at %d)'
+                        % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG,
+                           HDR_SZ, RTSZ, image))
+    # The runtime's own first bytes must follow the jump, and the jump must be
+    # the only thing before them.
+    if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper() != HEAD:
+        errs.append('runtime not at offset %d (bytes there %s, want %s)'
+                    % (ENT_SZ,
+                       d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper(),
+                       HEAD))
     if len(d) != com:
     if len(d) != com:
         errs.append('file is %d bytes, harness said %d' % (len(d), com))
         errs.append('file is %d bytes, harness said %d' % (len(d), com))
     if DATAB + data != len(d):
     if DATAB + data != len(d):
@@ -139,23 +317,29 @@ for name, com, image, data, nzg in rows:
         heap = int.from_bytes(hdr[HDR_HEAP_WORD:HDR_HEAP_WORD+2], 'little')
         heap = int.from_bytes(hdr[HDR_HEAP_WORD:HDR_HEAP_WORD+2], 'little')
         if flag != 1:
         if flag != 1:
             errs.append('hdrFlag=%d' % flag)
             errs.append('hdrFlag=%d' % flag)
-        # hdrCS is pc, the end of the code, and the harness's `image` is
-        # rtSz + code ALREADY - so the two are the same number.  Adding
-        # RTSZ here counted the runtime twice.
-        if cs != image:
-            errs.append('hdrCS=%d, want %d (end of image)' % (cs, image))
-        if ds != DATAB:
-            errs.append('hdrDS=%d, want %d' % (ds, DATAB))
-        if heap != DATAB + data:
-            errs.append('hdrHeap=%d, want %d' % (heap, DATAB + data))
+        # hdrCS is the end of the code, as a SEGMENT offset like every other
+        # offset in the header, so the load bias comes off before comparing it
+        # with the harness's `image` (= rtSz + code, already an image offset).
+        # Adding RTSZ here would count the runtime twice.
+        if cs - LOAD_BIAS != image:
+            errs.append('hdrCS=%d, want %d (end of image, as a segment '
+                        'offset)' % (cs, image + LOAD_BIAS))
+        if ds != DATAB + LOAD_BIAS:
+            errs.append('hdrDS=%d, want %d (= data base %d + load bias %d)'
+                        % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS))
+        if heap != DATAB + data + LOAD_BIAS:
+            errs.append('hdrHeap=%d, want %d (= data base %d + %d + load bias %d)'
+                        % (heap, DATAB + data + LOAD_BIAS, DATAB, data,
+                           LOAD_BIAS))
         # initmem must read hdrDS and hdrHeap, not some other pair of header
         # initmem must read hdrDS and hdrHeap, not some other pair of header
         # words.  (It read +8, hdrMax, which the compiler patches to 0, so
         # words.  (It read +8, hdrMax, which the compiler patches to 0, so
         # the range it cleared was empty and every global kept whatever the
         # the range it cleared was empty and every global kept whatever the
         # loader left in it.)
         # loader left in it.)
-        if [d[4], d[7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
+        if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
             errs.append('initmem reads header words +%d/+%d, but the data '
             errs.append('initmem reads header words +%d/+%d, but the data '
                         'base and data end are at +%d/+%d'
                         'base and data end are at +%d/+%d'
-                        % (d[4], d[7], HDR_DS_WORD, HDR_HEAP_WORD))
+                        % (d[ENT_SZ + 4], d[ENT_SZ + 7],
+                           HDR_DS_WORD, HDR_HEAP_WORD))
 
 
     if errs:
     if errs:
         bad += 1
         bad += 1

+ 24 - 0
shell/tests/run_compile_tests.sh

@@ -11,6 +11,30 @@ GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
 cd "$D" || exit 9
 cd "$D" || exit 9
 FLAGS="-fiso"
 FLAGS="-fiso"
 
 
+# Is `compiletest` itself current?  It links against the .o files, and `make`
+# only ever builds `tpshell` - so after an ordinary `make`, compiletest is
+# routinely OLDER than Compiler.o and still contains the previous compiler.
+# The check below used to look only at sources-versus-objects, which is blind
+# to exactly this: it saw "Compiler.mod is not newer than Compiler.o" and
+# skipped the rebuild, and the matrix then reported the previous compiler's
+# verdicts with total confidence.  That is the worst kind of wrong, because a
+# real fix stays invisible and a test that should have gone red stays green.
+# It happened while bisecting the IF-handler bug, and it is the second time
+# this project has lost time to a stale harness.
+#
+# So compiletest is rebuilt whenever it is older than ANY input: the object
+# files it links, the sources those came from, and its own source.
+STALE=no
+for f in compiletest TextBuf.o Posix.o Compiler.o Runtime.o \
+         tests/CompileTest.mod TextBuf.mod Posix.c Posix.def \
+         Compiler.mod Runtime.mod Linker.mod; do
+    if [ -e "$f" ] && [ compiletest -ot "$f" ]; then STALE=yes; break; fi
+done
+if [ "$STALE" = yes ]; then
+    echo "compiletest is older than its inputs - it would report the OLD compiler"
+    rm -f compiletest
+fi
+
 if [ ! -f TextBuf.o ] || [ ! -f Compiler.o ] || [ ! -f Posix.o ] \
 if [ ! -f TextBuf.o ] || [ ! -f Compiler.o ] || [ ! -f Posix.o ] \
    || [ ! -f Runtime.o ] \
    || [ ! -f Runtime.o ] \
    || [ TextBuf.mod -nt TextBuf.o ] || [ Compiler.mod -nt Compiler.o ] \
    || [ TextBuf.mod -nt TextBuf.o ] || [ Compiler.mod -nt Compiler.o ] \

+ 131 - 109
shell/tests/runtime.golden

@@ -35,13 +35,13 @@
 002A  83 F8 00    cmp ax,0h
 002A  83 F8 00    cmp ax,0h
 002D  7D 0A       jnl 39h
 002D  7D 0A       jnl 39h
 002F  50          push ax
 002F  50          push ax
-0030  B2 2D       mov dl,2dh
+0030  B0 2D       mov al,2dh
 0032  B4 02       mov ah,2h
 0032  B4 02       mov ah,2h
 0034  CD 21       int 21h
 0034  CD 21       int 21h
 0036  58          pop ax
 0036  58          pop ax
 0037  F7 D8       neg ax
 0037  F7 D8       neg ax
 0039  B9 0A 00    mov cx,0ah
 0039  B9 0A 00    mov cx,0ah
-003C  BB 70 01    mov bx,170h
+003C  BB 99 02    mov bx,299h
 003F  89 DE       mov si,bx
 003F  89 DE       mov si,bx
 0041  31 D2       xor dx,dx
 0041  31 D2       xor dx,dx
 0043  F7 F1       div ax,cx
 0043  F7 F1       div ax,cx
@@ -52,7 +52,7 @@
 004E  75 F1       jne 41h
 004E  75 F1       jne 41h
 0050  39 DE       cmp si,bx
 0050  39 DE       cmp si,bx
 0052  74 09       je 5dh
 0052  74 09       je 5dh
-0054  8A 14       mov dl,byte ptr [si]
+0054  8A 04       mov al,byte ptr [si]
 0056  B4 02       mov ah,2h
 0056  B4 02       mov ah,2h
 0058  CD 21       int 21h
 0058  CD 21       int 21h
 005A  46          inc si
 005A  46          inc si
@@ -61,7 +61,7 @@
 005F  5D          pop bp
 005F  5D          pop bp
 0060  C3          ret
 0060  C3          ret
 0061  8B EC       mov bp,sp
 0061  8B EC       mov bp,sp
-0063  8A 56 02    mov dl,byte ptr [bp+2h]
+0063  8A 46 02    mov al,byte ptr [bp+2h]
 0066  89 EC       mov sp,bp
 0066  89 EC       mov sp,bp
 0068  B4 02       mov ah,2h
 0068  B4 02       mov ah,2h
 006A  CD 21       int 21h
 006A  CD 21       int 21h
@@ -70,17 +70,17 @@
 006F  83 7E 02 00 cmp word ptr [bp+2h],0h
 006F  83 7E 02 00 cmp word ptr [bp+2h],0h
 0073  89 EC       mov sp,bp
 0073  89 EC       mov sp,bp
 0075  75 05       jne 7ch
 0075  75 05       jne 7ch
-0077  BA 76 01    mov dx,176h
+0077  BA 9E 02    mov dx,29eh
 007A  EB 03       jmp 7fh
 007A  EB 03       jmp 7fh
-007C  BA 70 01    mov dx,170h
+007C  BA 99 02    mov dx,299h
 007F  B4 09       mov ah,9h
 007F  B4 09       mov ah,9h
 0081  CD 21       int 21h
 0081  CD 21       int 21h
 0083  C3          ret
 0083  C3          ret
-0084  BA 80 01    mov dx,180h
+0084  BA A7 02    mov dx,2a7h
 0087  B4 09       mov ah,9h
 0087  B4 09       mov ah,9h
 0089  CD 21       int 21h
 0089  CD 21       int 21h
 008B  C3          ret
 008B  C3          ret
-008C  BA 7D 01    mov dx,17dh
+008C  BA A4 02    mov dx,2a4h
 008F  B4 09       mov ah,9h
 008F  B4 09       mov ah,9h
 0091  CD 21       int 21h
 0091  CD 21       int 21h
 0093  C3          ret
 0093  C3          ret
@@ -102,104 +102,126 @@
 00AC  51          push cx
 00AC  51          push cx
 00AD  52          push dx
 00AD  52          push dx
 00AE  57          push di
 00AE  57          push di
-00AF  E8 B1 00    call 163h
-00B2  3C 20       cmp al,20h
-00B4  74 F9       je 0afh
-00B6  3C 09       cmp al,9h
-00B8  74 F5       je 0afh
-00BA  3C 0D       cmp al,0dh
-00BC  74 F1       je 0afh
-00BE  3C 0A       cmp al,0ah
-00C0  74 ED       je 0afh
-00C2  31 C9       xor cx,cx
-00C4  3C 2D       cmp al,2dh
-00C6  75 06       jne 0ceh
-00C8  41          inc cx
-00C9  E8 97 00    call 163h
-00CC  EB 07       jmp 0d5h
-00CE  3C 2B       cmp al,2bh
-00D0  75 03       jne 0d5h
-00D2  E8 8E 00    call 163h
-00D5  31 FF       xor di,di
-00D7  3C 30       cmp al,30h
-00D9  72 1C       jb 0f7h
-00DB  3C 39       cmp al,39h
-00DD  77 18       jnbe 0f7h
-00DF  2C 30       sub al,30h
-00E1  88 C6       mov dh,al
-00E3  8B C7       mov ax,di
-00E5  BB 0A 00    mov bx,0ah
-00E8  F7 E3       mul bx
-00EA  89 C7       mov di,ax
-00EC  B4 00       mov ah,0h
-00EE  8A C6       mov al,dh
-00F0  01 C7       add di,ax
-00F2  E8 6E 00    call 163h
-00F5  EB E0       jmp 0d7h
-00F7  83 F9 00    cmp cx,0h
-00FA  74 02       je 0feh
-00FC  F7 DF       neg di
-00FE  8B 5E 04    mov bx,word ptr [bp+4h]
-0101  89 1D       mov word ptr [di],bx
-0103  5F          pop di
-0104  5A          pop dx
-0105  59          pop cx
-0106  5B          pop bx
-0107  58          pop ax
-0108  89 EC       mov sp,bp
-010A  5D          pop bp
-010B  C3          ret
-010C  55          push bp
-010D  8B EC       mov bp,sp
-010F  50          push ax
-0110  53          push bx
-0111  E8 4F 00    call 163h
-0114  88 C2       mov dl,al
-0116  8B 5E 04    mov bx,word ptr [bp+4h]
-0119  88 17       mov byte ptr [bx],dl
-011B  5B          pop bx
-011C  58          pop ax
-011D  89 EC       mov sp,bp
-011F  5D          pop bp
-0120  C3          ret
-0121  55          push bp
-0122  8B EC       mov bp,sp
-0124  50          push ax
-0125  53          push bx
-0126  51          push cx
-0127  E8 39 00    call 163h
-012A  31 C9       xor cx,cx
-012C  3C 54       cmp al,54h
-012E  74 12       je 142h
-0130  3C 74       cmp al,74h
-0132  74 0E       je 142h
-0134  3C 59       cmp al,59h
-0136  74 0A       je 142h
-0138  3C 79       cmp al,79h
-013A  74 06       je 142h
-013C  3C 31       cmp al,31h
-013E  74 02       je 142h
-0140  EB 01       jmp 143h
-0142  41          inc cx
-0143  8B 5E 04    mov bx,word ptr [bp+4h]
-0146  89 0F       mov word ptr [bx],cx
-0148  59          pop cx
-0149  5B          pop bx
-014A  58          pop ax
-014B  89 EC       mov sp,bp
-014D  5D          pop bp
-014E  C3          ret
-014F  50          push ax
-0150  E8 10 00    call 163h
-0153  3C 0D       cmp al,0dh
-0155  74 0A       je 161h
-0157  3C 0A       cmp al,0ah
-0159  74 06       je 161h
-015B  3C 1A       cmp al,1ah
-015D  74 02       je 161h
-015F  EB EF       jmp 150h
-0161  58          pop ax
-0162  C3          ret
-0163  B4 08       mov ah,8h
-0165  CD 21       int 21h
-0167  C3          ret
+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