Преглед изворни кода

Runtime: assemble the 8086 library, and find that Unicorn can't check it

The runtime gates everything downstream: without it the TU_* entries have
to land somewhere real, and no compiled image can ever run.  Written as
Modula-2 emitters rather than a checked-in binary, so the tree stays
self-contained and the entry offsets are derived from the assembled bytes
instead of guessed.

shell/Runtime.def/.mod  366-byte blob, 13 entries, following the TPSRC7
  copyrt shape (runtime copied to the front of the code buffer, pc
  initialised past it) -- so TU_* offsets are runtime-relative and the
  CALL displacement arithmetic needs no rebasing.  initmem zeroes the
  data area using the bounds in the header words; progend and halt share
  one address, since the compiler already zeroes AX for progend and
  discards the HALT argument at compile time; stackchk is a bare RET
  because range checking isn't compiled in and its call site sits
  mid-expression.  rel8, rel16 and runtime-data addresses are all fixed
  up after the blob is placed, so nothing depends on a hand-computed
  displacement.

shell/tests/RtProbe.mod  dumps the size, the entry offsets and an
  offset-annotated hex dump, so the blob can be checked before anything
  depends on it.

shell/tests/rt_exec.py  calls each entry with a known argument and
  compares the bytes it sends to INT 21h.  It currently FAILS, and the
  cause is the executor, not the library: Unicorn 2.1.4 UC_MODE_16
  mis-decodes 16-bit ModRM memory operands -- rm=5 and rm=6 decode
  correctly, rm=0,1,2,3,7 do not, and only base-register-free disp16
  addressing is trustworthy.  Generated code is almost entirely BP/SI
  relative, so that makes Unicorn useless as an oracle: it cannot tell
  wrong codegen from wrong emulation.  pip --upgrade resolves to the
  same 2.1.4.  qemu-system-i386 is installed and is the next candidate.
  The measurement is written up in SUMMARY.md so nobody re-derives the rm
  table by hand again.

Running the code under even a broken emulator was worth it: a wrong
encoding executes rather than failing to assemble.  Four bugs, none of
which a compiler diagnostic would report.  B() masked a two-byte opcode
down to one byte, so MOV BP,SP was missing from every frame; MOV BP,SP
was encoded 8B E4, which is MOV SP,SP; initmem read its argument from
[SP] (the return address) instead of AX; and EmPushVarAddr emits 8D 46
= LEA AX,[SI+disp8] where it means 8D 45 = [BP+disp8], so read into a
local has always addressed the wrong cell.  That last one is pre-existing
and is left unfixed here, recorded in the summary as the next thing to do.

No compiler behaviour changed, so the matrix is unchanged and was
re-verified: 17 of 23 fixtures compile, uitest 10/10, tpshell 120792
bytes.
Eric Streit пре 2 недеља
родитељ
комит
2bef202bdd
6 измењених фајлова са 1146 додато и 16 уклоњено
  1. 1 0
      .gitignore
  2. 187 16
      SUMMARY.md
  3. 43 0
      shell/Runtime.def
  4. 622 0
      shell/Runtime.mod
  5. 106 0
      shell/tests/RtProbe.mod
  6. 187 0
      shell/tests/rt_exec.py

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@ shell/tpshell.lst
 shell/compiletest
 shell/tests/ct.lst
 __pycache__/
+shell/tests/rtprobe

+ 187 - 16
SUMMARY.md

@@ -16,7 +16,8 @@ manual, not guessed.
 | Compiler skeleton + build recipe | `v-TP3-SHELL-COMPILES` | done |
 | Parser `Skip` bug class (9 sites) | `v-TP3-PARSER-FIXES` | done |
 | Standard procedures + `rel16` fix | `v-TP3-STDPROCS` | done |
-| Runtime blob, linker, `CmdRun` interpreter | — | **not started** |
+| Runtime library + 8086 execution harness | `v-TP3-RUNTIME-BLOB` | assembled, **never run** |
+| Linker + `CmdRun` | — | **not started** |
 
 ## Build
 
@@ -93,6 +94,62 @@ changing `Run`'s signature, so `Editor.def` stays additive.
 feature: the latter move as the compiler grows, and a test whose
 expectations drift with it stops being a test.
 
+## The executor problem (blocking everything downstream)
+
+The whole point of a Pascal→8086 compiler is that the output *runs*. Until this
+milestone no compiled image had ever been executed, so the plan was: get a real
+CPU emulator, run the image, assert the exact stdout bytes.
+
+**Unicorn 2.1.4 cannot be used for this.** `UC_MODE_16` mis-decodes 16-bit
+ModRM memory operands. The measurement, by loading a byte-pattern image so a
+load reveals its own effective address, then executing a single
+`LEA AX,[r+disp8]` and reading AX back with every register set to a distinct
+value:
+
+```
+mod=01, disp8=4            got        8086 says
+  8D 40 04  LEA AX,[BX+4]  AX=0d04    0x504   (= BX+SI+4)   WRONG
+  8D 41 04  LEA AX,[BX+SI+4] AX=0e04  0xd04                WRONG
+  8D 42 04  LEA AX,[BX+DI+4] AX=0f04  0xe04                WRONG
+  8D 43 04  LEA AX,[BP+4]  AX=1004    0x704                WRONG
+  8D 45 04  LEA AX,[DI+4]  AX=0904    0x904                right
+  8D 46 04  LEA AX,[BP+4]  AX=0704    0x704                right
+  8D 47 04  LEA AX,[DI+4]  AX=0504    0x904                WRONG
+mod=00 / mod=10 direct disp16
+  8B 1E 00 20  MOV BX,[2000]  BX=1234                     right
+  8D 06 34 12  LEA AX,[1234]  AX=1234                     right
+```
+
+So `rm=5` and `rm=6` decode correctly but `rm=0,1,2,3,7` do not, and only
+*base-register-free* addressing (direct `disp16`) is trustworthy. That rules
+Unicorn out as an oracle for exactly the instruction forms generated code is
+made of — `LEA AX,[BP+d]`, `MOV AX,[SI+d]`, `LODSW`-style loops, everything
+with a frame pointer. It cannot distinguish "my codegen is wrong" from "the
+emulator is wrong", which makes it worse than no emulator at all.
+
+`pip install --upgrade unicorn` resolves to the same 2.1.4, so this is not
+avoidable by upgrading.
+
+Also ruled out, for the record:
+
+- **DOSBox-X 2025.02.01** (installed, `/usr/bin/dosbox-x`, plain root-owned
+  ELF, not a snap) starts cleanly headless under
+  `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy`, but its `-c`/autoexec
+  commands never observably execute: a `md` never appeared on the host, and a
+  `.COM` that creates `OUT.TXT` via `INT 21h AH=3Dh/40h` never produced the
+  file. Shell `>` is intercepted by dosbox-x's own wrapper
+  (`SHELL:Redirect output to out.txt`). A `.BAT` route timed out.
+- **qemu-system-i386 is installed** (`/usr/bin/qemu-system-i386`) and is the
+  next candidate: a 512-byte boot sector can load the image and a `INT 21h`
+  shim can hand the output back. Not attempted yet.
+
+`tests/rt_exec.py` is the harness, written against Unicorn, and it is written
+to survive the switch: it loads the runtime, calls each entry with a known
+argument, and compares the bytes sent to `INT 21h` against expectations. It
+currently **fails**, and the failures are the emulator's, not the library's —
+`wrint` prints `-` for every value because `MOV AX,[BP+4]` reads the wrong
+address. Do not read those results as a verdict on the runtime.
+
 ## Components
 
 ### Shell — `shell/Shell.mod`, `Term.mod`, `Posix.c`, `TextBuf.mod`
@@ -161,17 +218,83 @@ image-base space (`TU_InitMem=8H`, `TU_ProgEnd=10H`, `TU_StackChk=18H`).
 
 Detail: `TP3-COMPILER.md`.
 
+### Runtime library — `shell/Runtime.mod`, `shell/Runtime.def`
+
+The 8086 runtime, **assembled byte by byte from Modula-2** — no external
+assembler and no checked-in binary, so the tree stays self-contained and the
+entry offsets are *derived* rather than guessed. Each emitter is one
+instruction with its ModRM byte spelled out in a comment so the encoding can
+be checked by hand against an 8086 table. This is the same approach
+`Compiler.mod` already takes (`Ebyte`/`Eword`/`EmCall`).
+
+It mirrors the original's own mechanism: TPSRC7 `copyrt` copies the runtime
+into the front of the code buffer (`SI=DI=0`, `REPZ MOVSB`) and `pc` is then
+initialised past it (`MOV pc,#$2D7C`). Same shape here — the compiler is meant
+to copy the blob to the front of `cbuf` and start `pc`/`dc` past it. Runtime
+data therefore lives at fixed low offsets and needs no relocation, and because
+both sides of every `CALL` shift by the same amount, `EmCall`'s displacement
+arithmetic is unaffected by the runtime being prepended.
+
+Current blob: **366 bytes**, 13 entries, offsets read back out of the
+assembled bytes by `tests/RtProbe.mod`:
+
+| entry | offset | entry | offset | entry | offset |
+|---|---|---|---|---|---|
+| `initmem` | 0 | `wrint` | 36 | `rdint` | 142 |
+| `progend` | 28 | `wrchar` | 97 | `rdchar` | 243 |
+| `stackchk` | 35 | `wrbool` | 106 | `rdbool` | 264 |
+| `halt` | 28 | `wrreal` | 126 | `rdln` | 310 |
+| | | `wrln` | 134 | | |
+
+`progend` and `halt` deliberately share one address (`XOR AX,AX / MOV AH,4C /
+INT 21h / RET`): the compiler already zeroes AX before `progend` and discards
+the `HALT` argument at compile time, so both leave with exit code 0.
+
+Conventions, matching `Compiler.IoCall` exactly:
+
+| entry | argument | notes |
+|---|---|---|
+| `WrInt/WrChar/WrBool/WrReal` | one 16-bit **value** on the stack | caller pops |
+| `RdInt/RdChar/RdBool` | one **address** on the stack | caller pops |
+| `WrLn/RdLn/StackChk` | nothing | |
+| `InitMem` | `AX` = offset of the program header | a *register*, not a stack word |
+| `ProgEnd/Halt` | nothing | exits, code 0 |
+
+`InitMem` reads the data base and end out of the header words at `+2`/`+6` and
+zeroes that range, because Pascal leaves globals undefined. `StackChk` is a
+bare `RET` — range and stack checking aren't compiled in yet, and the call site
+sits mid-expression, so it must not touch a register.
+
+Two label-name plus fixup list: `rel8`, `rel16` and runtime-data addresses are
+all patched after the blob is placed, so nothing depends on a hand-computed
+displacement.
+
+**It has never executed.** See the emulator finding below.
+
+
 ## Honest limitations
 
-- **A compiled image still cannot be executed.** `CmdRun` is a stub and the
-  runtime blob does not exist. The emitted code is verified *byte by byte*
-  against the offsets the compiler intends, but nothing has ever run it.
-- **The `TU_*` entry offsets are placeholders under an assumed model** — a
-  runtime blob prepended to the image, with every `pc` rebased by its size.
-  That model is assumed, not implemented; the linker is unwritten. Note the
+- **A compiled image still cannot be executed.** `CmdRun` is a stub, the
+  linker is unwritten, and no 8086 executor on this machine has yet proved
+  trustworthy (above). The emitted code is verified *byte by byte* against the
+  offsets the compiler intends, but nothing has ever run it.
+- **The runtime is written but unproven.** `Runtime.mod` assembles to 366 bytes
+  and its entry offsets are derived from the emitted bytes, but it has never
+  been executed on a correct CPU, so treat every encoding in it as unverified
+  even though three of its own bugs were caught by running it under a broken
+  emulator.
+- **The compiler is not yet wired to the runtime.** `Compiler.mod` still
+  carries the hardcoded placeholder `TU_*` constants (`TU_InitMem=8H`,
+  `TU_ProgEnd=10H`, …) and still starts `pc` at 0, so emitted images do not
+  contain the runtime and those offsets are still wrong. `Runtime.mod` is not
+  in `make` or `run_compile_tests.sh` yet for the same reason. Note the
   prologue's `CALL TU_InitMem` targets offset 8, which is currently the
-  `hdrMax` header word, so the scheme is only coherent once the runtime is
-  actually prepended.
+  `hdrMax` header word — coherent only once the blob is really prepended.
+  *(Correction to the earlier note in this file: the `TU_InitMem=8` "collision"
+  was a false alarm. Per TPSRC7 the runtime is copied to the *front* of the
+  code buffer and `pc` starts past it, so `TU_*` offsets are runtime-relative,
+  not image-absolute, and no rebasing of the displacement arithmetic is
+  needed.)*
 - **No string runtime.** `RdConst` gives a 1-character literal as `TScalar`
   (its char code) and only longer literals as `TString`, so multi-char
   literals raise `ENoLib`. `writeln('a')` works via a `chr` flag on `ERes`;
@@ -211,6 +334,33 @@ hand — code sizes looked perfectly plausible throughout.
    too large (`0010` shown as `00000100`). A misleading tool is worse than
    none — it corrupts any offset arithmetic done from its output.
 
+## Bugs found by actually running code
+
+Executing the hand-assembled runtime — even under a broken emulator — paid for
+itself immediately, because a wrong encoding *executes* rather than failing to
+assemble. Four, none of which a compiler diagnostic would ever have reported.
+
+1. **`B()` silently truncated multi-byte opcodes.** `PROCEDURE B` emits exactly
+   one byte and masks with `MOD 100H`, so `B (8BE4H)` — a two-byte opcode passed
+   as one literal — emitted just `E4`. `MOV BP,SP` was missing from every frame
+   in the runtime, so `BP` stayed 0 and *every* `BP`-relative access read
+   address 4. Found by decoding the hex dump; the byte is gone, not wrong.
+2. **`MOV BP,SP` encoded as `8B E4`, which is `MOV SP,SP`** — a no-op. The
+   ModRM byte is `mod·64 + reg·8 + rm`, and I mis-derived it. `Compiler.mod`'s
+   `EmMovBpSp` had it right all along (`8B 0CH`); only the new module was wrong.
+   This is why the encoding is now written as three explicit `B` calls with the
+   arithmetic in a comment rather than as one hex literal.
+3. **`InitMem` read its argument from `[SP]`** — the return address. The
+   convention is a register (`AX`), unlike the per-argument I/O entries which
+   do take a stack word. Caught because the data area was never cleared.
+4. **`EmPushVarAddr` computes the wrong base register for locals** (pre-existing,
+   **not yet fixed**). It emits `8D 46 disp`, which is `LEA AX,[SI+disp8]`, but
+   intends `LEA AX,[BP+disp8]` = `8D 45 disp`. So `read` into a *local* variable
+   has always addressed the wrong cell, silently. It also truncates the offset
+   with `off MOD 100H`, losing displacements above 255. The global form
+   `8D 06 off` (`LEA AX,[disp16]`) is correct. Found while writing the runtime's
+   read entries, which needed the same encoding to be right.
+
 ## gm2 / ISO Modula-2 pitfalls hit along the way
 
 - **Two-phase link** (above) — a single whole-program pass 3 caps
@@ -222,6 +372,10 @@ hand — code sizes looked perfectly plausible throughout.
 - A bare `HALT` aborts under `-fiso` (SIGABRT, exit 134); `HALT (0)` is
   correct.
 - `CHAR` is not the ZType: `ch = 09H` must be `ORD (ch) = 09H`.
+- gm2's ISO `SYSTEM` exports `ORD` but **not** `Ord` — the import is
+  case-sensitive here despite gm2's usual case-insensitivity, so
+  `FROM SYSTEM IMPORT Ord` fails with "unknown symbol" while plain `ORD (c)`
+  compiles. Write `ORD`, never `Ord`.
 - ISO forbids dropping a function result in a statement → the `DropCh`/
   `DropB`/`DropC` discard helpers wrap ~39 call sites.
 - `AND`/`OR`/`NOT` on 16-bit `CARDINAL` → `BitAnd`/`BitOr`/`BitNot`.
@@ -253,11 +407,28 @@ hand — code sizes looked perfectly plausible throughout.
 
 ## Next steps
 
-1. **Runtime blob + linker.** Everything downstream is gated on it: the
-   `TU_*` entries have to land somewhere real and every `pc` needs rebasing.
-2. **`CmdRun`** — the interpreter. Without it no compiled program has ever
-   run, so the codegen is unproven in the only way that counts.
-3. **String runtime** — unlocks the last 4 real fixture failures.
-4. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
+1. **Get a trustworthy 8086 executor**, because it gates items 2–4 and nothing
+   else can be claimed until a compiled image runs. `qemu-system-i386` with a
+   boot-sector loader and an `INT 21h` shim is the plan; keep `rt_exec.py`'s
+   expectations and change only the machine behind them. (Do *not* reach for
+   Unicorn's 16-bit mode again, and do not re-derive the `rm` table by hand
+   again — check it against a real decoder.)
+2. **Fix `EmPushVarAddr`** — `8D 45`/`8D 85` for locals, full `disp16`, per
+   bug 4 above. Two lines, and `read` into a local is wrong until it is done.
+3. **Wire the runtime in and write the linker**: `pc := RT_Size`,
+   `dc := RT_Size + 1000H` (a fixed 4 KiB code/data gap, so a program's data
+   can't collide with its code in a single 64 K `.COM` segment), take the
+   `TU_*` offsets from `RT_Entry` instead of the hardcoded constants, patch the
+   header words (`hdrDS` = data base, `hdrHeap` = data end, so `InitMem` can
+   zero globals), pad the image to cover the data area, and emit the `.COM`.
+   Add `Runtime` to the `make` and `run_compile_tests.sh` rebuild lists.
+4. **Prove it end to end**: compile a fixture, link, execute, assert the exact
+   stdout bytes (`writeln('hi')` → `hi`). That single assertion is what turns
+   this from "assembles" into "works".
+5. **`CmdRun`** — run the emitted image from the `R` menu key.
+6. **String runtime** — unlocks the last 4 real fixture failures.
+7. Nested procedures / recursion, `var` parameters (the `SEG:OFF` push from
    RESUME-TP3.md §3.11), range/index checks (`TU_RANGE_CHECK`,
-   `TU_INDEX_CHECK`), and typed constants (RESUME-TP3.md §3.14).
+   `TU_INDEX_CHECK`), typed constants (RESUME-TP3.md §3.14).
+8. Harden the program-header parameter loop against non-advancing input
+   (`program p(1;)`) with a `BOOLEAN` flag — **not** `EXIT`, which ICEs gm2.

+ 43 - 0
shell/Runtime.def

@@ -0,0 +1,43 @@
+DEFINITION MODULE Runtime ;
+
+(* 8086 runtime library for TP3-compiled programs.
+
+   The original embeds its runtime in the compiler's own code segment and
+   copies it to the front of the generated code buffer (TPSRC7 "copyrt":
+   "MOV CX,#start / REPZ MOVS.B" with SI=DI=0, then "MOV pc,#$2D7C" so the
+   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
+   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.
+
+   Entry offsets are *derived* from where the code actually lands, not
+   hardcoded - see RT_Entry.  Everything the compiler calls is reached by a
+   relative CALL, and both sides shift by the same runtime size, so the
+   displacement arithmetic in EmCall is unaffected by the runtime being
+   prepended. *)
+
+FROM SYSTEM IMPORT BYTE ;
+
+CONST
+   (* entry selectors for RT_Entry *)
+   E_InitMem = 0 ;  E_ProgEnd = 1 ;  E_StackChk = 2 ;
+   E_WrInt   = 3 ;  E_WrChar  = 4 ;  E_WrBool  = 5 ;
+   E_WrReal  = 6 ;  E_WrLn    = 7 ;
+   E_RdInt   = 8 ;  E_RdChar  = 9 ;  E_RdBool  = 10 ;
+   E_RdLn    = 11 ; E_Halt    = 12 ;
+
+PROCEDURE RT_Build () ;
+(* assemble the runtime; idempotent, called once at Compile time *)
+
+PROCEDURE RT_Size () : CARDINAL ;
+(* size of the runtime in bytes - the offset at which the generated program
+   starts inside the image *)
+
+PROCEDURE RT_Byte (i : CARDINAL) : BYTE ;
+(* i-th runtime byte, 0 past the end, for hex dumps *)
+
+PROCEDURE RT_Entry (i : CARDINAL) : CARDINAL ;
+(* offset of entry i (an E_* selector) inside the runtime; 0 if unknown *)
+
+END Runtime.

+ 622 - 0
shell/Runtime.mod

@@ -0,0 +1,622 @@
+IMPLEMENTATION MODULE Runtime ;
+
+(* 8086 runtime library, assembled byte by byte.  Each little emitter below is
+   one 8086 instruction; the ModR/M byte is spelled out in the comment so the
+   encoding can be checked by hand against an 8086 table.  This is the same
+   approach the compiler itself takes in Compiler.mod (Ebyte/Eword/EmCall),
+   so the library needs no external assembler and no binary artifact in the
+   tree - RT_Build assembles it every run, which is why the entry offsets are
+   known exactly and are not guesses.
+
+   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.
+
+   Calling conventions, matching Compiler.IoCall:
+     WrInt/WrChar/WrBool/WrReal  one 16-bit value on the stack (caller pops)
+     RdInt/RdChar/RdBool         one address on the stack (caller pops)
+     WrLn/RdLn/StackChk          nothing
+     InitMem                     AX = offset of the program header word block
+     ProgEnd/Halt                nothing; exits with code 0
+   All entries preserve BP and SP, and every register except the documented
+   result, so they can be called from the middle of an expression. *)
+
+FROM SYSTEM IMPORT BYTE ;
+
+CONST
+   MaxRt  = 4096 ;
+   MaxLbl = 64 ;
+   MaxFix = 400 ;
+   MaxNm  = 15 ;
+
+   (* offsets inside the runtime's own data block *)
+   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 ;
+
+TYPE
+   LblRec = RECORD
+      nm  : ARRAY [0..MaxNm] OF CHAR ;
+      off : CARDINAL ;
+   END ;
+
+   FixRec = RECORD
+      kind  : CARDINAL ;     (* 0 = rel8, 1 = rel16, 2 = data address *)
+      place : CARDINAL ;     (* offset of the displacement/address field *)
+      nm    : ARRAY [0..MaxNm] OF CHAR ;
+      val   : CARDINAL ;
+   END ;
+
+VAR
+   rt     : ARRAY [0..MaxRt - 1] OF BYTE ;
+   rpos   : CARDINAL ;
+   lbl    : ARRAY [0..MaxLbl - 1] OF LblRec ;
+   ltop   : CARDINAL ;
+   fix    : ARRAY [0..MaxFix - 1] OF FixRec ;
+   nfix   : CARDINAL ;
+   dataAt : CARDINAL ;
+   built  : BOOLEAN ;
+   entNm  : ARRAY [0..12] OF ARRAY [0..MaxNm] OF CHAR ;
+
+(* ---------------------------------------------------------------- *)
+(*  name helpers                                                     *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE StrEq (a, b : ARRAY OF CHAR ) : BOOLEAN ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (a)) AND (i <= HIGH (b)) DO
+      IF a [i] # b [i] THEN
+         RETURN FALSE
+      END ;
+      INC (i)
+   END ;
+   RETURN TRUE
+END StrEq ;
+
+PROCEDURE SetStr (VAR dst : ARRAY OF CHAR ; src : ARRAY OF CHAR ) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (dst)) AND (i <= HIGH (src)) DO
+      dst [i] := src [i] ;
+      INC (i)
+   END ;
+   IF i <= HIGH (dst) THEN
+      dst [i] := 0C
+   END
+END SetStr ;
+
+(* ---------------------------------------------------------------- *)
+(*  primitive emitters                                               *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE B (b : CARDINAL ) ;
+(* emit exactly ONE byte.  A two-byte opcode must be written as two B calls -
+   B masks to 100H, so B (8BE4H) would silently emit just E4. *)
+BEGIN
+   IF rpos >= MaxRt THEN
+      RETURN                            (* blob is oversized: drop the byte *)
+   END ;
+   rt [rpos] := VAL (BYTE, b MOD 100H) ;
+   INC (rpos)
+END B ;
+
+PROCEDURE W (w : CARDINAL ) ;
+BEGIN
+   B (w MOD 100H) ;
+   B ((w DIV 100H) MOD 100H)           (* little endian *)
+END W ;
+
+PROCEDURE M (nm : ARRAY OF CHAR ) ;
+(* mark: nm is the current offset *)
+BEGIN
+   IF ltop < MaxLbl THEN
+      SetStr (lbl [ltop].nm, nm) ;
+      lbl [ltop].off := rpos ;
+      INC (ltop)
+   END
+END M ;
+
+PROCEDURE AddFix (kind, place : CARDINAL ; nm : ARRAY OF CHAR ; val : CARDINAL ) ;
+BEGIN
+   IF nfix < MaxFix THEN
+      fix [nfix].kind := kind ;
+      fix [nfix].place := place ;
+      fix [nfix].val := val ;
+      SetStr (fix [nfix].nm, nm) ;
+      INC (nfix)
+   END
+END AddFix ;
+
+PROCEDURE LblOff (nm : ARRAY OF CHAR ) : CARDINAL ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i < ltop DO
+      IF StrEq (lbl [i].nm, nm) THEN
+         RETURN lbl [i].off
+      END ;
+      INC (i)
+   END ;
+   RETURN 0
+END LblOff ;
+
+PROCEDURE J8 (nm : ARRAY OF CHAR ) ;
+BEGIN
+   B (0EBH) ;                          (* JMP rel8 *)
+   AddFix (0, rpos, nm, 0) ;
+   B (0)
+END J8 ;
+
+PROCEDURE C8 (nm : ARRAY OF CHAR ) ;
+BEGIN
+   B (0E8H) ;                          (* CALL rel16 *)
+   AddFix (1, rpos, nm, 0) ;
+   W (0)
+END C8 ;
+
+PROCEDURE Jcc (code : CARDINAL ; nm : ARRAY OF CHAR ) ;
+BEGIN
+   B (code) ;                          (* Jcc rel8 *)
+   AddFix (0, rpos, nm, 0) ;
+   B (0)
+END Jcc ;
+
+PROCEDURE Dd (delta : CARDINAL ) ;
+(* emit a 16-bit address into the runtime's data block; the value is only
+   known once the data block has been placed, so it is a fixup *)
+BEGIN
+   AddFix (2, rpos, "", delta) ;
+   W (0)
+END Dd ;
+
+(* --- 8-bit / 16-bit register and memory forms, one instruction each --- *)
+
+PROCEDURE PushBp ; BEGIN B (55H) END PushBp ;
+PROCEDURE PopBp  ; BEGIN B (5DH) END PopBp ;
+PROCEDURE MovBpSp ; BEGIN B (8BH) ; B (0ECH) END MovBpSp ;  (* 8B EC: MOV BP,SP *)
+PROCEDURE MovSpBp ; BEGIN B (89H) ; B (0ECH) END MovSpBp ;  (* 89 EC: MOV SP,BP *)
+PROCEDURE LeaveR ; BEGIN B (0C9H) END LeaveR ;
+PROCEDURE RetR   ; BEGIN B (0C3H) END RetR ;
+PROCEDURE Int21  ; BEGIN B (0CDH) ; B (21H) END Int21 ;
+PROCEDURE PushDs ; BEGIN B (1EH) END PushDs ;
+PROCEDURE PopEs  ; BEGIN B (7H) END PopEs ;
+
+PROCEDURE PushAx ; BEGIN B (50H) END PushAx ;
+PROCEDURE PopAx  ; BEGIN B (58H) END PopAx ;
+PROCEDURE PushBx ; BEGIN B (53H) END PushBx ;
+PROCEDURE PopBx  ; BEGIN B (5BH) END PopBx ;
+PROCEDURE PushCx ; BEGIN B (51H) END PushCx ;
+PROCEDURE PopCx  ; BEGIN B (59H) END PopCx ;
+PROCEDURE PushDx ; BEGIN B (52H) END PushDx ;
+PROCEDURE PopDx  ; BEGIN B (5AH) END PopDx ;
+PROCEDURE PushDi ; BEGIN B (57H) END PushDi ;
+PROCEDURE PopDi  ; BEGIN B (5FH) END PopDi ;
+
+PROCEDURE XorAxAx ; BEGIN B (31H) ; B (0C0H) END XorAxAx ;  (* 11 000 000 *)
+PROCEDURE XorCxCx ; BEGIN B (31H) ; B (0C9H) END XorCxCx ;  (* 11 001 001 *)
+PROCEDURE XorDxDx ; BEGIN B (31H) ; B (0D2H) END XorDxDx ;  (* 11 010 010 *)
+PROCEDURE XorDiDi ; BEGIN B (31H) ; B (0FFH) END XorDiDi ;  (* 11 111 111 *)
+
+PROCEDURE IncCx ; BEGIN B (41H) END IncCx ;
+PROCEDURE IncSi ; BEGIN B (46H) END IncSi ;
+PROCEDURE DecSi ; BEGIN B (4EH) END DecSi ;
+PROCEDURE AddDi2 ; BEGIN B (83H) ; B (0C7H) ; B (2) END AddDi2 ;  (* 11 000 111 *)
+
+PROCEDURE CmpAl (v : CARDINAL ) ; BEGIN B (3CH) ; B (v) END CmpAl ;
+PROCEDURE CmpAx0 ; BEGIN B (83H) ; B (0F8H) ; B (0) END CmpAx0 ;
+PROCEDURE CmpCx0 ; BEGIN B (83H) ; B (0F9H) ; B (0) END CmpCx0 ;
+PROCEDURE CmpSpW0 ; BEGIN B (83H) ; B (7CH) ; B (24H) ; B (0) ; B (0) END CmpSpW0 ;
+PROCEDURE CmpSiBx ; BEGIN B (39H) ; B (0DCH) END CmpSiBx ;  (* 11 011 100 *)
+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 AddDl (v : CARDINAL ) ; BEGIN B (80H) ; B (0C2H) ; B (v) END AddDl ;
+PROCEDURE SubAl (v : CARDINAL ) ; BEGIN B (2CH) ; B (v) END SubAl ;
+PROCEDURE NegAx ; BEGIN B (0F7H) ; B (0D8H) END NegAx ;
+PROCEDURE NegDi ; BEGIN B (0F7H) ; B (0DFH) END NegDi ;
+PROCEDURE DivCx ; BEGIN B (0F7H) ; B (0F1H) END DivCx ;   (* 11 110 001 *)
+PROCEDURE MulBx ; BEGIN B (0F7H) ; B (0E3H) END MulBx ;   (* 11 100 011 *)
+
+PROCEDURE MovAh (v : CARDINAL ) ; BEGIN B (0B4H) ; B (v) END MovAh ;
+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 ;
+
+PROCEDURE MovAxSp  ; BEGIN B (8BH) ; B (44H) ; B (24H) ; B (0) END MovAxSp ;
+PROCEDURE MovSiAx  ; BEGIN B (8BH) ; B (0C0H) END MovSiAx ;
+PROCEDURE MovAxDi  ; BEGIN B (8BH) ; B (0C7H) END MovAxDi ;   (* 11 000 111 *)
+PROCEDURE MovAxDx  ; BEGIN B (8BH) ; B (0D2H) END MovAxDx ;
+PROCEDURE MovCxSi6 ; BEGIN B (8BH) ; B (4CH) ; B (6) END MovCxSi6 ;
+PROCEDURE MovDxSi2 ; BEGIN B (8BH) ; B (54H) ; B (2) END MovDxSi2 ;
+PROCEDURE MovAxBp4 ; BEGIN B (8BH) ; B (45H) ; B (4) END MovAxBp4 ;
+PROCEDURE MovBxBp4 ; BEGIN B (8BH) ; B (5EH) ; B (4) END MovBxBp4 ;
+PROCEDURE MovAlDh  ; BEGIN B (8AH) ; B (0C0H) END MovAlDh ;
+PROCEDURE MovDlSi  ; BEGIN B (8AH) ; B (14H) END MovDlSi ;
+PROCEDURE MovDlSp  ; BEGIN B (8AH) ; B (54H) ; B (24H) ; B (0) END MovDlSp ;
+
+PROCEDURE StDiAx   ; BEGIN B (89H) ; B (7H) END StDiAx ;    (* [DI] := AX *)
+PROCEDURE StDiBx   ; BEGIN B (89H) ; B (1FH) END StDiBx ;   (* [DI] := BX *)
+PROCEDURE StBxCx   ; BEGIN B (89H) ; B (8BH) END StBxCx ;   (* [BX] := CX *)
+PROCEDURE StSiDl   ; BEGIN B (88H) ; B (14H) END StSiDl ;   (* [SI] := DL *)
+PROCEDURE StBxDl   ; BEGIN B (88H) ; B (93H) END StBxDl ;   (* [BX] := DL *)
+PROCEDURE MovDhAl  ; BEGIN B (88H) ; B (0C6H) END MovDhAl ;
+PROCEDURE MovDlAl  ; BEGIN B (88H) ; B (0C2H) END MovDlAl ;
+PROCEDURE MovSiBx  ; BEGIN B (89H) ; B (0DCH) END MovSiBx ;
+PROCEDURE MovDiDx  ; BEGIN B (89H) ; B (0D7H) END MovDiDx ;
+PROCEDURE MovDiAx  ; BEGIN B (89H) ; B (0C7H) END MovDiAx ;
+PROCEDURE AddDiAx  ; BEGIN B (1H) ; B (0C7H) END AddDiAx ;
+(* JE 74  JNE 75  JB 72  JBE 76  JGE 7D *)
+PROCEDURE Je8  (nm : ARRAY OF CHAR ) ; BEGIN Jcc (74H, nm) END Je8 ;
+PROCEDURE Jne8 (nm : ARRAY OF CHAR ) ; BEGIN Jcc (75H, nm) END Jne8 ;
+PROCEDURE Jb8  (nm : ARRAY OF CHAR ) ; BEGIN Jcc (72H, nm) END Jb8 ;
+PROCEDURE Jbe8 (nm : ARRAY OF CHAR ) ; BEGIN Jcc (76H, nm) END Jbe8 ;
+PROCEDURE Jge8 (nm : ARRAY OF CHAR ) ; BEGIN Jcc (7DH, nm) END Jge8 ;
+PROCEDURE Ja8  (nm : ARRAY OF CHAR ) ; BEGIN Jcc (77H, nm) END Ja8 ;
+
+(* ---------------------------------------------------------------- *)
+(*  the entries                                                      *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE EmitInitMem ;
+(* AX = offset of the program header.  The header holds, at +2 the base of
+   the program's data area and at +6 its end, so the globals can be zeroed -
+   Pascal leaves them undefined, TP3's runtime clears them.  Also makes
+   ES = DS so that any string instruction in the library would work. *)
+BEGIN
+   M ("initmem") ;
+   MovSiAx ;               (* SI = AX = the header offset the caller passed *)
+   MovDxSi2 ;              (* DX = [SI+2] = data base *)
+   MovCxSi6 ;              (* CX = [SI+6] = data end *)
+   CmpCxDx ;
+   Jbe8 ("im_done") ;
+   MovDiDx ;               (* DI = data base *)
+   M ("im_zero") ;
+   MovAxDx ;
+   StDiAx ;
+   AddDi2 ;
+   CmpDiCx ;
+   Jb8 ("im_zero") ;
+   M ("im_done") ;
+   PushDs ; PopEs ;
+   RetR
+END EmitInitMem ;
+
+PROCEDURE EmitEnd ;
+(* progend and halt are the same code: the compiler already zeroes AX for
+   progend, and a HALT argument is discarded at compile time, so both leave
+   with exit code 0. *)
+BEGIN
+   M ("progend") ;
+   M ("halt") ;
+   XorAxAx ;
+   MovAh (4CH) ;
+   Int21 ;
+   RetR
+END EmitEnd ;
+
+PROCEDURE EmitStackChk ;
+(* called from every procedure prologue.  Range and stack checking are not
+   compiled in yet, so this must do nothing at all - in particular it must
+   not touch a register, because the call site is in the middle of a
+   partially evaluated expression. *)
+BEGIN
+   M ("stackchk") ;
+   RetR
+END EmitStackChk ;
+
+PROCEDURE EmitGetCh ;
+(* 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. *)
+BEGIN
+   M ("getch") ;
+   MovAh (8) ;
+   Int21 ;
+   RetR
+END EmitGetCh ;
+
+PROCEDURE EmitWrInt ;
+(* 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
+   scratch area, then printed forwards. *)
+BEGIN
+   M ("wrint") ;
+   PushBp ; MovBpSp ;
+   MovAxBp4 ;
+   CmpAx0 ;
+   Jge8 ("wi_pos") ;
+   PushAx ;
+   MovDl (ORD ("-")) ; MovAh (2) ; Int21 ;
+   PopAx ;
+   NegAx ;
+   M ("wi_pos") ;
+   MovCxV (10) ;
+   MovBxD (D_NUM + 8) ;    (* BX = one past the last digit *)
+   MovSiBx ;
+   M ("wi_dig") ;
+   XorDxDx ;
+   DivCx ;
+   AddDl (ORD ("0")) ;
+   DecSi ;
+   StSiDl ;
+   CmpAx0 ;
+   Jne8 ("wi_dig") ;
+   M ("wi_out") ;
+   CmpSiBx ;
+   Je8 ("wi_done") ;
+   MovDlSi ;
+   MovAh (2) ; Int21 ;
+   IncSi ;
+   J8 ("wi_out") ;
+   M ("wi_done") ;
+   MovSpBp ; PopBp ; RetR
+END EmitWrInt ;
+
+PROCEDURE EmitWrChar ;
+(* the low byte of the pushed word *)
+BEGIN
+   M ("wrchar") ;
+   MovDlSp ;
+   MovAh (2) ;
+   Int21 ;
+   RetR
+END EmitWrChar ;
+
+PROCEDURE EmitWrBool ;
+BEGIN
+   M ("wrbool") ;
+   CmpSpW0 ;
+   Jne8 ("wb_t") ;
+   MovDxD (D_FALSE) ;
+   J8 ("wb_o") ;
+   M ("wb_t") ;
+   MovDxD (D_TRUE) ;
+   M ("wb_o") ;
+   MovAh (9) ;
+   Int21 ;
+   RetR
+END EmitWrBool ;
+
+PROCEDURE EmitWrLn ;
+BEGIN
+   M ("wrln") ;
+   MovDxD (D_CRLF) ;
+   MovAh (9) ;
+   Int21 ;
+   RetR
+END EmitWrLn ;
+
+PROCEDURE EmitWrReal ;
+(* the 6-byte real is on the stack but is not formatted: the compiler does
+   not yet load real operands into a form the runtime could read.  A visible
+   marker beats printing the mantissa as an integer. *)
+BEGIN
+   M ("wrreal") ;
+   MovDxD (D_REAL) ;
+   MovAh (9) ;
+   Int21 ;
+   RetR
+END EmitWrReal ;
+
+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. *)
+BEGIN
+   M ("rdint") ;
+   PushBp ; MovBpSp ;
+   PushAx ; PushBx ; PushCx ; PushDx ; PushDi ;
+   M ("ri_skip") ;
+   C8 ("getch") ;
+   CmpAl (ORD (" ")) ; Je8 ("ri_skip") ;
+   CmpAl (9) ;         Je8 ("ri_skip") ;
+   CmpAl (13) ;        Je8 ("ri_skip") ;
+   CmpAl (10) ;        Je8 ("ri_skip") ;
+   XorCxCx ;
+   CmpAl (ORD ("-")) ;
+   Jne8 ("ri_nos") ;
+   IncCx ;
+   C8 ("getch") ;
+   J8 ("ri_dig0") ;
+   M ("ri_nos") ;
+   CmpAl (ORD ("+")) ;
+   Jne8 ("ri_dig0") ;
+   C8 ("getch") ;
+   M ("ri_dig0") ;
+   XorDiDi ;
+   M ("ri_dig") ;
+   CmpAl (ORD ("0")) ;
+   Jb8 ("ri_done") ;
+   CmpAl (ORD ("9")) ;
+   Ja8 ("ri_done") ;
+   SubAl (ORD ("0")) ;
+   MovDhAl ;                (* keep the digit across the multiply *)
+   MovAxDi ;
+   MovBxV (10) ;
+   MulBx ;                  (* DX:AX := DI * 10 *)
+   MovDiAx ;
+   MovAh (0) ;
+   MovAlDh ;
+   AddDiAx ;
+   C8 ("getch") ;
+   J8 ("ri_dig") ;
+   M ("ri_done") ;
+   CmpCx0 ;
+   Je8 ("ri_st") ;
+   NegDi ;
+   M ("ri_st") ;
+   MovBxBp4 ;
+   StDiBx ;
+   PopDi ; PopDx ; PopCx ; PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR
+END EmitRdInt ;
+
+PROCEDURE EmitRdChar ;
+BEGIN
+   M ("rdchar") ;
+   PushBp ; MovBpSp ;
+   PushAx ; PushBx ;
+   C8 ("getch") ;
+   MovDlAl ;
+   MovBxBp4 ;
+   StBxDl ;
+   PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR
+END EmitRdChar ;
+
+PROCEDURE EmitRdBool ;
+(* one character, classified the way TP3 does: T/t/Y/y/1 true, anything else
+   false. *)
+BEGIN
+   M ("rdbool") ;
+   PushBp ; MovBpSp ;
+   PushAx ; PushBx ; PushCx ;
+   C8 ("getch") ;
+   XorCxCx ;
+   CmpAl (ORD ("T")) ; Je8 ("rb_t") ;
+   CmpAl (ORD ("t")) ; Je8 ("rb_t") ;
+   CmpAl (ORD ("Y")) ; Je8 ("rb_t") ;
+   CmpAl (ORD ("y")) ; Je8 ("rb_t") ;
+   CmpAl (ORD ("1")) ; Je8 ("rb_t") ;
+   J8 ("rb_s") ;
+   M ("rb_t") ;
+   IncCx ;
+   M ("rb_s") ;
+   MovBxBp4 ;
+   StBxCx ;
+   PopCx ; PopBx ; PopAx ;
+   MovSpBp ; PopBp ; RetR
+END EmitRdBool ;
+
+PROCEDURE EmitRdLn ;
+(* discard the rest of the line, including the terminator *)
+BEGIN
+   M ("rdln") ;
+   PushAx ;
+   M ("rl_loop") ;
+   C8 ("getch") ;
+   CmpAl (13) ; Je8 ("rl_e") ;
+   CmpAl (10) ; Je8 ("rl_e") ;
+   CmpAl (26) ; Je8 ("rl_e") ;          (* ^Z: end of input *)
+   J8 ("rl_loop") ;
+   M ("rl_e") ;
+   PopAx ;
+   RetR
+END EmitRdLn ;
+
+PROCEDURE EmitData ;
+BEGIN
+   dataAt := rpos ;
+   (* 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 (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 ("$")) ;
+   B (13) ; B (10) ; B (ORD ("$")) ;
+   B (ORD ("?")) ; B (ORD ("R")) ; B (ORD ("E")) ; B (ORD ("A")) ;
+   B (ORD ("L")) ; B (ORD ("?")) ;
+   WHILE rpos < dataAt + D_END DO
+      B (0)
+   END
+END EmitData ;
+
+PROCEDURE FixUp ;
+VAR i, t, rel : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i < nfix DO
+      IF fix [i].kind = 2 THEN
+         t := (dataAt + fix [i].val) MOD 10000H ;
+         rt [fix [i].place] := VAL (BYTE, t MOD 100H) ;
+         rt [fix [i].place + 1] := VAL (BYTE, (t DIV 100H) MOD 100H)
+      ELSE
+         t := LblOff (fix [i].nm) ;
+         IF fix [i].kind = 0 THEN
+            (* rel8 is measured from the end of the instruction, i.e. one
+               byte past the displacement field *)
+            rel := (t + 100H - (fix [i].place + 1)) MOD 100H ;
+            rt [fix [i].place] := VAL (BYTE, rel)
+         ELSE
+            rel := (t + 10000H - (fix [i].place + 2)) MOD 10000H ;
+            rt [fix [i].place] := VAL (BYTE, rel MOD 100H) ;
+            rt [fix [i].place + 1] := VAL (BYTE, (rel DIV 100H) MOD 100H)
+         END
+      END ;
+      INC (i)
+   END
+END FixUp ;
+
+(* ---------------------------------------------------------------- *)
+(*  public interface                                                 *)
+(* ---------------------------------------------------------------- *)
+
+PROCEDURE RT_Build ;
+BEGIN
+   IF built THEN
+      RETURN
+   END ;
+   rpos := 0 ; ltop := 0 ; nfix := 0 ; dataAt := 0 ;
+   EmitInitMem ;
+   EmitEnd ;
+   EmitStackChk ;
+   EmitWrInt ; EmitWrChar ; EmitWrBool ; EmitWrReal ; EmitWrLn ;
+   EmitRdInt ; EmitRdChar ; EmitRdBool ; EmitRdLn ;
+   EmitGetCh ;
+   EmitData ;
+   FixUp ;
+   SetStr (entNm [0], "initmem") ;
+   SetStr (entNm [1], "progend") ;
+   SetStr (entNm [2], "stackchk") ;
+   SetStr (entNm [3], "wrint") ;
+   SetStr (entNm [4], "wrchar") ;
+   SetStr (entNm [5], "wrbool") ;
+   SetStr (entNm [6], "wrreal") ;
+   SetStr (entNm [7], "wrln") ;
+   SetStr (entNm [8], "rdint") ;
+   SetStr (entNm [9], "rdchar") ;
+   SetStr (entNm [10], "rdbool") ;
+   SetStr (entNm [11], "rdln") ;
+   SetStr (entNm [12], "halt") ;
+   built := TRUE
+END RT_Build ;
+
+PROCEDURE RT_Size () : CARDINAL ;
+BEGIN
+   IF NOT built THEN
+      RT_Build ()
+   END ;
+   RETURN rpos
+END RT_Size ;
+
+PROCEDURE RT_Byte (i : CARDINAL ) : BYTE ;
+BEGIN
+   IF NOT built THEN
+      RT_Build ()
+   END ;
+   IF i >= rpos THEN
+      RETURN 0
+   END ;
+   RETURN rt [i]
+END RT_Byte ;
+
+PROCEDURE RT_Entry (i : CARDINAL ) : CARDINAL ;
+BEGIN
+   IF NOT built THEN
+      RT_Build ()
+   END ;
+   IF i > 12 THEN
+      RETURN 0
+   END ;
+   RETURN LblOff (entNm [i])
+END RT_Entry ;
+
+END Runtime.

+ 106 - 0
shell/tests/RtProbe.mod

@@ -0,0 +1,106 @@
+MODULE RtProbe ;
+
+(* Dump the assembled runtime: size, entry offsets, hex.  Kept separate from
+   CompileTest so the library can be checked on its own, before the compiler
+   is wired to it. *)
+
+FROM Posix IMPORT write ;
+FROM Runtime IMPORT RT_Build, RT_Size, RT_Byte, RT_Entry ;
+FROM SYSTEM IMPORT ADR, BYTE ;
+
+VAR
+   c : CHAR ;
+
+PROCEDURE PC (ch : CHAR ) ;
+VAR n : LONGINT ;
+BEGIN
+   n := write (1, ADR (ch), 1)
+END PC ;
+
+PROCEDURE PS (s : ARRAY OF CHAR ) ;
+VAR i : CARDINAL ; n : LONGINT ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      n := write (1, ADR (s [i]), 1) ;
+      INC (i)
+   END
+END PS ;
+
+PROCEDURE PCARD (n : CARDINAL ) ;
+VAR d : ARRAY [0..9] OF CHAR ; i : CARDINAL ;
+BEGIN
+   IF n = 0 THEN
+      PC ("0")
+   ELSE
+      i := 0 ;
+      WHILE n > 0 DO
+         d [i] := CHR (ORD ("0") + (n MOD 10)) ;
+         n := n DIV 10 ;
+         INC (i)
+      END ;
+      WHILE i > 0 DO
+         DEC (i) ;
+         PC (d [i])
+      END
+   END
+END PCARD ;
+
+PROCEDURE NL ; BEGIN PC (CHR (13)) ; PC (CHR (10)) END NL ;
+
+PROCEDURE PHEX (b : CARDINAL ) ;
+VAR d : CARDINAL ;
+BEGIN
+   d := b DIV 16 ;
+   IF d < 10 THEN PC (CHR (ORD ("0") + d)) ELSE PC (CHR (ORD ("A") + d - 10)) END ;
+   d := b MOD 16 ;
+   IF d < 10 THEN PC (CHR (ORD ("0") + d)) ELSE PC (CHR (ORD ("A") + d - 10)) END
+END PHEX ;
+
+PROCEDURE PENT (i : CARDINAL ; name : ARRAY OF CHAR ) ;
+BEGIN
+   PS ("  entry ") ; PCARD (i) ; PS (" = ") ; PCARD (RT_Entry (i)) ;
+   PS ("  (") ; PS (name) ; PS (")") ; NL
+END PENT ;
+
+PROCEDURE Main ;
+VAR i, n : CARDINAL ;
+    names : ARRAY [0..12] OF ARRAY [0..15] OF CHAR ;
+BEGIN
+   RT_Build () ;
+   PCARD (RT_Size ()) ; PS (" bytes") ; NL ;
+   i := 0 ;
+   WHILE i <= 12 DO
+      names [i] := "" ;
+      INC (i)
+   END ;
+   names [0] := "initmem" ;  names [1] := "progend" ; names [2] := "stackchk" ;
+   names [3] := "wrint" ;   names [4] := "wrchar" ;  names [5] := "wrbool" ;
+   names [6] := "wrreal" ;  names [7] := "wrln" ;    names [8] := "rdint" ;
+   names [9] := "rdchar" ;  names [10] := "rdbool" ; names [11] := "rdln" ;
+   names [12] := "halt" ;
+   i := 0 ;
+   WHILE i <= 12 DO
+      PENT (i, names [i]) ;
+      INC (i)
+   END ;
+   PS ("  hex:") ; NL ;
+   i := 0 ;
+   WHILE i < RT_Size () DO
+      PHEX ((i DIV 65536) MOD 16) ; PHEX ((i DIV 4096) MOD 16) ;
+      PHEX ((i DIV 256) MOD 16) ; PHEX (i MOD 16) ;
+      PS ("  ") ;
+      n := 0 ;
+      WHILE (n < 16) AND (i + n < RT_Size ()) DO
+         PHEX (VAL (CARDINAL, RT_Byte (i + n))) ;
+         PC (" ") ;
+         INC (n)
+      END ;
+      NL ;
+      i := i + 16
+   END
+END Main ;
+
+BEGIN
+   Main
+END RtProbe.

+ 187 - 0
shell/tests/rt_exec.py

@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""Execute the assembled 8086 runtime on a real CPU emulator and check it.
+
+The runtime (shell/Runtime.mod) is assembled by RtProbe, which prints its
+bytes as hex; this script loads them at offset 0 of a flat 64K segment - the
+layout a .COM gets - calls each entry with a known argument and compares the
+bytes it sends to INT 21h with what it expects.
+
+That is the whole point of the exercise: the library is hand-assembled 8086,
+so "it built" says nothing.  This says it *runs*.
+"""
+import re
+import subprocess
+import sys
+
+from unicorn import Uc, UC_ARCH_X86, UC_MODE_16
+from unicorn.x86_const import (
+    UC_X86_REG_AX, UC_X86_REG_DX, UC_X86_REG_SP, UC_X86_REG_IP,
+    UC_X86_REG_CS, UC_X86_REG_DS, UC_X86_REG_ES, UC_X86_REG_SS,
+)
+import unicorn
+
+HERE = __file__.rsplit("/", 1)[0]
+PROBE = HERE + "/rtprobe"
+
+HDR = 0x200          # where the fake program header sits
+DATA_BASE = 0x300
+DATA_END = 0x320
+STACK = 0xF000
+SENTINEL = 0xBEEF   # "return address" that tells us an entry came back
+
+
+def load_runtime():
+    """Run RtProbe, parse its hex dump, return (bytes, {entry: offset})."""
+    out = subprocess.run([PROBE], capture_output=True, text=True, check=True).stdout
+    size = int(re.search(r"^(\d+) bytes", out, re.M).group(1))
+    entries = {n: int(v) for v, n in re.findall(r"entry \d+ = (\d+)\s+\((\w+)\)", out)}
+    blob = bytearray()
+    for line in out.splitlines():
+        m = re.match(r"^[0-9A-F]{8}  ((?:[0-9A-F]{2} )+)$", line)
+        if m:
+            blob += bytes.fromhex(m.group(1).replace(" ", ""))
+    assert len(blob) == size, f"parsed {len(blob)} bytes, header says {size}"
+    return bytes(blob), entries
+
+
+class Machine:
+    def __init__(self, blob):
+        self.blob = blob
+        self.out = bytearray()
+        self.input = bytearray()
+        self.uc = Uc(UC_ARCH_X86, UC_MODE_16)
+        self.uc.mem_map(0, 0x110000)
+        self.uc.mem_write(0, blob)
+        # a program header word block: flag, code end, data base, data end
+        self.uc.mem_write(HDR, b"\x01\x00\x34\x02\x00\x03\x20\x03\x00\x00")
+        self.uc.mem_write(DATA_BASE, b"\xAA" * (DATA_END - DATA_BASE))  # poison
+        for r in (UC_X86_REG_CS, UC_X86_REG_DS, UC_X86_REG_ES, UC_X86_REG_SS):
+            self.uc.reg_write(r, 0)
+        self.uc.hook_add(unicorn.UC_HOOK_INTR, self._intr)
+
+
+    def _intr(self, mu, intno, _):
+        if intno != 0x21:
+            return
+        ah = (mu.reg_read(UC_X86_REG_AX) >> 8) & 0xFF
+        if ah == 0x02:                                   # display character
+            self.out.append(mu.reg_read(UC_X86_REG_DX) & 0xFF)
+        elif ah == 0x09:                                 # display $-string
+            a = mu.reg_read(UC_X86_REG_DX)
+            while True:
+                b = mu.mem_read(a, 1)[0]
+                if b == ord("$"):
+                    break
+                self.out.append(b)
+                a += 1
+        elif ah == 0x4C:                                 # exit
+            mu.emu_stop()
+        elif ah == 0x08:                                 # read char, no echo
+            if self.input:
+                mu.reg_write(UC_X86_REG_AX, (mu.reg_read(UC_X86_REG_AX) & 0xFF00) | self.input.pop(0))
+            else:
+                mu.reg_write(UC_X86_REG_AX, (mu.reg_read(UC_X86_REG_AX) & 0xFF00) | 0x1A)
+
+    def call(self, entry, args=(), ax=0):
+        """Call an entry with `args` pushed (caller-cleaned, like the compiler
+        does).  Returns whatever it wrote to stdout."""
+        self.out.clear()
+        uc = self.uc
+        sp = STACK - 4 * len(args) - 2
+        words = [SENTINEL] + list(args)
+        uc.mem_write(sp, b"".join(w.to_bytes(2, "little") for w in words))
+        uc.reg_write(UC_X86_REG_SP, sp)
+        uc.reg_write(UC_X86_REG_AX, ax)
+        uc.reg_write(UC_X86_REG_IP, entry)
+        # end = SENTINEL: the entry stops by RETurning to it.  Using 0 as the
+        # end address would stop instantly for the entry that lives at 0.
+        uc.emu_start(entry, SENTINEL, timeout=2_000_000, count=200000)
+        if uc.reg_read(UC_X86_REG_IP) != SENTINEL:
+            raise AssertionError(
+                f"entry {entry} did not return (IP={uc.reg_read(UC_X86_REG_IP):#06x})")
+        return bytes(self.out)
+
+
+def main():
+    blob, ent = load_runtime()
+    m = Machine(blob)
+    fails = []
+
+    def check(name, got, want):
+        if got == want:
+            print(f"  ok   {name}: {got!r}")
+        else:
+            print(f"  FAIL {name}: got {got!r} want {want!r}")
+            fails.append(name)
+
+    # TU_InitMem must clear [data base, data end) and leave DS alone
+    m.call(ent["initmem"], ax=HDR)
+    cleared = m.uc.mem_read(DATA_BASE, DATA_END - DATA_BASE)
+    check("initmem zeroes globals", cleared, b"\x00" * (DATA_END - DATA_BASE))
+
+    # TU_WrInt: signed 16-bit decimal
+    for v, want in [(0, b"0"), (1, b"1"), (7, b"7"), (10, b"10"), (999, b"999"),
+                    (12345, b"12345"), (32767, b"32767"),
+                    (-1, b"-1"), (-32768, b"-32768"), (-999, b"-999")]:
+        check(f"wrint({v})", m.call(ent["wrint"], (v & 0xFFFF,)), want)
+
+    check("wrchar('A')", m.call(ent["wrchar"], (ord("A"),)), b"A")
+    check("wrchar('!')", m.call(ent["wrchar"], (ord("!"),)), b"!")
+    check("wrbool(0)", m.call(ent["wrbool"], (0,)), b"FALSE")
+    check("wrbool(1)", m.call(ent["wrbool"], (1,)), b"TRUE")
+    check("wrbool(2)", m.call(ent["wrbool"], (2,)), b"TRUE")
+    check("wrln", m.call(ent["wrln"]), b"\r\n")
+    check("stackchk returns", m.call(ent["stackchk"]), b"")
+
+    # a sequence, the way a program actually calls these
+    m.out.clear()
+    m.call(ent["wrint"], (42,))
+    m.call(ent["wrchar"], (ord(" "),))
+    m.call(ent["wrbool"], (1,))
+    m.call(ent["wrln"])
+    check("writeln(42) writeln TRUE", bytes(m.out), b"42 TRUE\r\n")
+
+    # TU_RdInt / RdChar / RdBool / RdLn against supplied input
+    store = 0x400
+    for text, want in [(b"  42abc", 42), (b"-17 x", -17), (b"+5", 5),
+                       (b"0", 0), (b"  007", 7), (b"1234", 1234)]:
+        m.input = bytearray(text)
+        m.uc.mem_write(store, b"\xEE\xEE")
+        m.call(ent["rdint"], (store,))
+        got = int.from_bytes(m.uc.mem_read(store, 2), "little", signed=True)
+        check(f"rdint({text!r})", got, want)
+        # the delimiter must be left for the following rdln
+        rest = bytes(m.input)
+        m.out.clear()
+        m.call(ent["rdln"])
+        check(f"rdln eats {rest!r}", bytes(m.out), b"")
+
+    for text, want in [(b"Q", ord("Q")), (b"7", ord("7"))]:
+        m.input = bytearray(text)
+        m.uc.mem_write(store, b"\xEE\xEE")
+        m.call(ent["rdchar"], (store,))
+        check(f"rdchar({text!r})", int.from_bytes(m.uc.mem_read(store, 2), "little"), want)
+
+    for text, want in [(b"T", 1), (b"y", 1), (b"1", 1), (b"F", 0), (b"n", 0), (b"0", 0)]:
+        m.input = bytearray(text)
+        m.uc.mem_write(store, b"\xEE\xEE")
+        m.call(ent["rdbool"], (store,))
+        check(f"rdbool({text!r})", int.from_bytes(m.uc.mem_read(store, 2), "little"), want)
+
+    # end of input must terminate the read loops rather than spin
+    m.input = bytearray()
+    m.uc.mem_write(store, b"\xEE\xEE")
+    m.call(ent["rdint"], (store,))
+    check("rdint at EOF", int.from_bytes(m.uc.mem_read(store, 2), "little"), 0)
+    m.call(ent["rdln"])
+
+    print()
+    if fails:
+        print(f"RUNTIME: {len(fails)} FAILURE(S): {', '.join(fails)}")
+        return 1
+    print("RUNTIME: all checks passed")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())