Explorar o código

terminal: escapes from a TEXT config at startup, plus tp3cfg (TINST, unpatched)

The shell used to carry ANSI escapes compiled into Term.mod, the way TINST
carries them by patching TURBO.COM.  Now it reads TP3.TRM at startup - a text
file it never writes.  No binary poke, and the file is editable/copyable/
versionable.

  - TermCfg (new): the format and its decoder.  KEY=value lines, ';' comments,
    keys NAME/CLEAR/HOME/GOTO/NORMAL/MARKED/BEEP.  Values decode \e, \r, \n,
    \t, \\ and ^X; GOTO's %r/%c are decimal row/column (1-based), %R/%C are
    raw row+31/col+31 (VT52), %% is '%'.  Missing file => the ANSI defaults;
    a malformed line leaves the defaults and everything read before it.
  - Term loads TP3.TRM in Open and uses the sequences in ClrScr, GotoXY,
    Marked, Normal, Close and Beep.  GotoXY substitutes the placeholders.
  - tp3cfg (new, built by make): TINST's role without the patch.  Lists the
    terminals it knows (ANSI, VT100, VT52), asks, writes TP3.TRM.  Add a
    terminal with one Build case.
  - shell/TP3.TRM is the committed ANSI default, so the file is exercised.
  - tests/term_config.sh + TermCfgTest: feed a fixture and compare every
    decoded byte.  Proved RED first by corrupting one expected byte (FAIL),
    then restored (PASS).  Wired into run_all.sh as "terminal config".

Honest status of "configurations extracted from TINST": the MECHANISM is here
and tested; the full TINST table is not.  TINST.COM embeds it, TINST.MSG is
only UI text, and the disassembly we have is TURBO.  TERMINAL.md records the
measurement method: run TINST in dosemu once per terminal, diff the patched
TURBO.COM against the original, convert each diff into a Build case.  The
current table is the curated subset the shell needs.

Key remapping (the other half of what TINST configures) is deliberately NOT
here: same design, extended to Editor.mod, as a second step.

run_all.sh: ALL PASS - compile matrix 71/71, terminal config 2/2, execution
60/60 under qemu and Exec86, helper audit PASS.
Eric Streit hai 2 días
pai
achega
f24b5cadb9

+ 5 - 0
.gitignore

@@ -1,5 +1,7 @@
 *.o
 shell/tpshell
+shell/tp3cfg
+shell/tp3cfg.lst
 shell/tpshell.lst
 shell/compiletest
 shell/comtest
@@ -20,3 +22,6 @@ shell/exec86run
 # the UNTRACKED files it mutates, so a run that dies mid-mutation can still
 # put them back.
 tmp/
+
+# terminal-config test harness (tests/term_config.sh)
+shell/tests/termcfgtest

+ 6 - 0
SUMMARY.md

@@ -2072,6 +2072,12 @@ change. Items 1-4 below are independent of both.
    and `Exec86` deliberately implements only the three functions the runtime
    calls.
 
+5. Terminal configuration is a **text file** now, not a patched binary:
+   `shell/TP3.TRM` is read by `tpshell` at startup, and `tp3cfg` writes it
+   (ANSI/VT100/VT52 so far). See `TERMINAL.md`. `tests/term_config.sh` asserts
+   the loader/decoder byte for byte. Key remapping (the rest of TINST) is a
+   separate step.
+
 The 4 KiB code window is no longer a documented limitation but an enforced
 one (`v-TP3-CODEWINDOW`): `Ebyte` reports TP3's error 98 when the code
 reaches `dataBase`, and `t43_codewindow` is the fixture that makes it red

+ 103 - 0
TERMINAL.md

@@ -0,0 +1,103 @@
+# TERMINAL — text configuration instead of a patched binary
+
+TINST installs terminal escape sequences by writing them into `TURBO.COM`.
+This port does the same job with a **text file**, `TP3.TRM`, that `tpshell`
+reads at startup. The shell never modifies itself; the file can be edited,
+copied, versioned or generated, and a bad file is a data error rather than a
+corrupted executable.
+
+## The file
+
+Plain text, one `KEY=value` per line. `;` at the start of a line is a comment.
+Blanks around the key and value are ignored; the key is case-insensitive.
+Unknown keys are ignored (so files can carry fields another tool needs).
+
+```
+; TP3.TRM
+NAME=ANSI
+CLEAR=\e[2J\e[H
+HOME=\e[H
+GOTO=\e[%r;%cH
+NORMAL=\e[0m
+MARKED=\e[1;7m
+BEEP=^G
+```
+
+| key | meaning |
+|---|---|
+| `NAME` | a label, not interpreted |
+| `CLEAR` | clear the screen (and usually home the cursor) |
+| `HOME` | home the cursor |
+| `GOTO` | cursor addressing, with the placeholders below |
+| `NORMAL` | normal text attribute |
+| `MARKED` | highlighted attribute (the shell's command letters) |
+| `BEEP` | the bell |
+
+### Values
+
+Values are **decoded**, so the file stays readable:
+
+| written | byte |
+|---|---|
+| `\e` or `\E` | ESC (27) |
+| `\r` | CR (13) |
+| `\n` | LF (10) |
+| `\t` | TAB (9) |
+| `\\` | a backslash |
+| `^X` | control-X (X uppercased, minus 64): `^G` = BEL (7) |
+| anything else | itself |
+
+`GOTO` placeholders:
+
+| placeholder | output |
+|---|---|
+| `%r` | the row in decimal (1-based) |
+| `%c` | the column in decimal (1-based) |
+| `%R` | one byte, `row + 31` (VT52-style) |
+| `%C` | one byte, `col + 31` |
+| `%%` | a literal `%` |
+
+A missing `TP3.TRM` means the built-in ANSI defaults (the sequences the shell
+had before there was a config). A malformed line leaves the defaults (and
+anything read before it) in place; it never silently half-configure the screen.
+
+## `tp3cfg`
+
+The configurator - TINST's role, without the patch. It lists the terminals it
+knows, asks for one, and writes `TP3.TRM`.
+
+```
+cd shell
+./tp3cfg            # list, choose, write TP3.TRM
+```
+
+A terminal is added by one `Build` case in `Tp3Cfg.mod` and one bump of
+`NumTerms`; the sequences are written in the same readable spelling as the
+file. `tp3cfg` is built by `make` alongside `tpshell`.
+
+Current table: **ANSI**, **VT100** (ANSI-compatible for what the shell emits),
+**VT52** (raw-byte cursor addressing, `%R`/`%C`).
+
+## Extracting TINST's full terminal table (next measurement)
+
+`TINST.COM` embeds its table; `TINST.MSG` is only its UI text, and the
+disassembly in `Resources/turbopascal3source` is `TURBO`, not `TINST`. The
+method that measures rather than guesses:
+
+1. Run the original `TINST` in dosemu once per terminal, selecting each entry
+   from its menu.
+2. Save the patched `TURBO.COM` each time and diff it against the untouched
+   copy. TINST patches known offsets, so every terminal's exact escape strings
+   fall out at the offsets TINST itself uses.
+3. Convert each diff into a `tp3cfg` `Build` case.
+
+That is deliberately not done by transcription: the sequences are what the
+original writes, and the diff is the evidence. Until it is done, the table
+above is the curated subset the shell needs.
+
+## Not in this step: key remapping
+
+TINST also remaps the **editing keys** (the cursor/insert/delete/block command
+table visible in `TINST.MSG`). The shell's editor hard-codes its keys today.
+The same design extends to it - a `KEYS` section in the config read by
+`Editor.mod` - and is a separate step.

+ 17 - 5
shell/Makefile

@@ -6,21 +6,33 @@ FLAGS = -fiso
 # pass-3 rollup on a compiler-size program ("too many errors in pass 3"), so
 # the import closure is generated first and the link then consumes it.  Phase 1
 # exiting 1 is the expected rollup, hence the `|| true`.
-MODS  = Shell Term Posix TextBuf Editor Compiler Runtime Linker Exec86
-LINK  = Shell.mod Term.o Posix.o TextBuf.o Editor.o Compiler.o Runtime.o Linker.o Exec86.o
+MODS  = Shell Term TermCfg Posix TextBuf Editor Compiler Runtime Linker Exec86
+LINK  = Shell.mod Term.o TermCfg.o Posix.o TextBuf.o Editor.o Compiler.o Runtime.o Linker.o Exec86.o
 
-all: tpshell
+all: tpshell tp3cfg
 
 tpshell: $(MODS:%=%.o) Shell.mod Makefile
 	$(GM2) $(FLAGS) -fgen-module-list=tpshell.lst -o /dev/null $(LINK) || true
 	$(GM2) $(FLAGS) -fuse-list=tpshell.lst -o $@ $(LINK)
 
+# The terminal configurator: writes the TEXT file TP3.TRM, never patches
+# tpshell.  Same two-phase link (same pass-3 rollup).
+tp3cfg: Tp3Cfg.o TermCfg.o Posix.o Tp3Cfg.mod Makefile
+	$(GM2) $(FLAGS) -fgen-module-list=tp3cfg.lst -o /dev/null Tp3Cfg.mod TermCfg.o Posix.o || true
+	$(GM2) $(FLAGS) -fuse-list=tp3cfg.lst -o $@ Tp3Cfg.mod TermCfg.o Posix.o
+
+Tp3Cfg.o: Tp3Cfg.mod TermCfg.def Posix.def
+	$(GM2) $(FLAGS) -c Tp3Cfg.mod
+
 Shell.o: Shell.mod Term.def Posix.def Editor.def TextBuf.def Compiler.def Linker.def
 	$(GM2) $(FLAGS) -c Shell.mod
 
-Term.o: Term.mod Term.def Posix.def
+Term.o: Term.mod Term.def TermCfg.def Posix.def
 	$(GM2) $(FLAGS) -c Term.mod
 
+TermCfg.o: TermCfg.mod TermCfg.def Posix.def
+	$(GM2) $(FLAGS) -c TermCfg.mod
+
 TextBuf.o: TextBuf.mod TextBuf.def
 	$(GM2) $(FLAGS) -c TextBuf.mod
 
@@ -46,5 +58,5 @@ Exec86.o: Exec86.mod Exec86.def
 	$(GM2) $(FLAGS) -c Exec86.mod
 
 clean:
-	rm -f *.o tpshell tpshell.lst
+	rm -f *.o tpshell tpshell.lst tp3cfg tp3cfg.lst
 .PHONY: all clean

+ 17 - 0
shell/TP3.TRM

@@ -0,0 +1,17 @@
+; TP3 terminal configuration -- read by tpshell at startup (TermCfg.Load).
+;
+; This is a TEXT file, not a patched binary: TINST's job is done here by
+; reading this file.  Run `tp3cfg` to regenerate it for another terminal, or
+; edit it by hand.
+;
+; Values are decoded:  \e = ESC (27), \r, \n, \t, \\ = backslash,
+; ^X = control-X (^G = BEL).  GOTO may use %r/%c (decimal row/column, 1-based)
+; or %R/%C (raw byte, row/column + 31, for VT52-style terminals); %% = '%'.
+; See TERMINAL.md.
+NAME=ANSI
+CLEAR=\e[2J\e[H
+HOME=\e[H
+GOTO=\e[%r;%cH
+NORMAL=\e[0m
+MARKED=\e[1;7m
+BEEP=^G

+ 46 - 14
shell/Term.mod

@@ -4,6 +4,7 @@ IMPLEMENTATION MODULE Term ;
 
 FROM Posix IMPORT read, write, tcgetattr, tcsetattr, cfmakeraw, poll ;
 FROM SYSTEM IMPORT ADR, ADDRESS, BYTE ;
+FROM TermCfg IMPORT Cfg, Default, Load ;
 
 CONST
    TCSANOW = 0 ;
@@ -14,6 +15,7 @@ VAR
    saved      : ARRAY [0..127] OF BYTE ;
    rawstate   : ARRAY [0..127] OF BYTE ;
    haveSaved  : BOOLEAN ;
+   cfg        : Cfg ;
 
 PROCEDURE Out (s : ARRAY OF CHAR) ;
 VAR i : CARDINAL ;
@@ -26,15 +28,28 @@ BEGIN
    END
 END Out ;
 
+PROCEDURE LoadCfg ;
+(* One fixed name, in the directory the shell is started from.  A text file,
+   read once, never written: the shell does not patch itself. *)
+VAR ok : BOOLEAN ;
+BEGIN
+   Load ("TP3.TRM", cfg, ok) ;
+   IF NOT ok THEN
+      (* missing or malformed: Load has already left the ANSI defaults in
+         place for anything it did not read *)
+   END
+END LoadCfg ;
+
 PROCEDURE Open ;
 VAR r : INTEGER ;
 BEGIN
+   LoadCfg ;
    r := tcgetattr (STDIN, ADR (saved)) ;
    rawstate := saved ;
    cfmakeraw (ADR (rawstate)) ;
    r := tcsetattr (STDIN, TCSANOW, ADR (rawstate)) ;
    haveSaved := TRUE ;
-   Out ("")          (* clear screen, home *)
+   Out (cfg.clear)                    (* clear screen, home *)
 END Open ;
 
 PROCEDURE Close ;
@@ -44,21 +59,41 @@ BEGIN
       r := tcsetattr (STDIN, TCSANOW, ADR (saved)) ;
       haveSaved := FALSE
    END ;
-   Out ("")              (* normal attribute *)
+   Out (cfg.normal)                   (* normal attribute *)
 END Close ;
 
 PROCEDURE ClrScr ;
 BEGIN
-   Out ("")
+   Out (cfg.clear)
 END ClrScr ;
 
 PROCEDURE GotoXY (row, col : CARDINAL) ;
+(* cfg.moveTo with %r replaced by the row and %c by the column, both decimal
+   and 1-based (TP3's GotoXY is 1-based); %% is a literal percent. *)
+VAR i : CARDINAL ;
 BEGIN
-   Out ("[") ;
-   PutCard (row) ;
-   Out (";") ;
-   PutCard (col) ;
-   Out ("H")
+   i := 0 ;
+   WHILE (i <= HIGH (cfg.moveTo)) AND (cfg.moveTo [i] # 0C) DO
+      IF (cfg.moveTo [i] = '%') AND (i + 1 <= HIGH (cfg.moveTo)) THEN
+         IF cfg.moveTo [i + 1] = 'r' THEN
+            PutCard (row) ; INC (i, 2)
+         ELSIF cfg.moveTo [i + 1] = 'c' THEN
+            PutCard (col) ; INC (i, 2)
+         ELSIF cfg.moveTo [i + 1] = 'R' THEN
+            (* a single byte, row + 31: VT52's `ESC Y <row+32><col+32>' with a
+               1-based row/column *)
+            PutCh (CHR (row + 31)) ; INC (i, 2)
+         ELSIF cfg.moveTo [i + 1] = 'C' THEN
+            PutCh (CHR (col + 31)) ; INC (i, 2)
+         ELSIF cfg.moveTo [i + 1] = '%' THEN
+            PutCh ('%') ; INC (i, 2)
+         ELSE
+            PutCh (cfg.moveTo [i]) ; INC (i)
+         END
+      ELSE
+         PutCh (cfg.moveTo [i]) ; INC (i)
+      END
+   END
 END GotoXY ;
 
 PROCEDURE PutCh (ch : CHAR) ;
@@ -131,12 +166,12 @@ END ScrnWide ;
 
 PROCEDURE Marked ;
 BEGIN
-   Out ("")            (* bold + reverse video *);
+   Out (cfg.marked)                  (* highlighted attribute *)
 END Marked ;
 
 PROCEDURE Normal ;
 BEGIN
-   Out ("")
+   Out (cfg.normal)
 END Normal ;
 
 PROCEDURE GetCh (VAR ch : CHAR) ;
@@ -179,11 +214,8 @@ BEGIN
 END GetKey ;
 
 PROCEDURE Beep ;
-VAR n : LONGINT ;
-    b  : CHAR ;
 BEGIN
-   b := CHR (7) ;
-   n := write (STDOUT, ADR (b), 1)
+   Out (cfg.beep)
 END Beep ;
 
 BEGIN

+ 60 - 0
shell/TermCfg.def

@@ -0,0 +1,60 @@
+DEFINITION MODULE TermCfg ;
+
+(* Terminal escape sequences, loaded from a TEXT file.  This is the whole
+   point of the module: the shell never patches its own binary (TINST's
+   approach); it reads a configuration at startup.
+
+   FILE FORMAT - plain text, one KEY=value per line:
+
+      ; a comment
+      NAME=ANSI
+      CLEAR=\e[2J\e[H
+      HOME=\e[H
+      GOTO=\e[%r;%cH
+      NORMAL=\e[0m
+      MARKED=\e[1;7m
+      BEEP=^G
+
+   `;' at the start of a line starts a comment.  Blanks around the key and
+   value are ignored.  The key is case-insensitive.
+
+   Values are DECODED, so the file stays readable and editable:
+
+      \e  or \E   ESC (27)        \r  CR (13)      \n  LF (10)
+      \t  TAB (9)                 \\  a backslash
+      ^X          control-X (X uppercased, minus 64), e.g. ^G = BEL (7)
+
+   GOTO may contain %r and %c; the shell substitutes the decimal row and
+   column there (1-based, as TP3's GotoXY is).  %% is a literal percent.
+
+   A missing file, or a line with no '=', sets ok to FALSE and leaves the
+   defaults (and anything read before the bad line) in place - never a
+   silently half-written screen. *)
+
+CONST
+   MaxSeq  = 63 ;
+   MaxName = 31 ;
+
+TYPE
+   Cfg = RECORD
+      name   : ARRAY [0..MaxName] OF CHAR ;
+      clear  : ARRAY [0..MaxSeq] OF CHAR ;
+      home   : ARRAY [0..MaxSeq] OF CHAR ;
+      moveTo : ARRAY [0..MaxSeq] OF CHAR ;   (* GOTO: %r, %c *)
+      normal : ARRAY [0..MaxSeq] OF CHAR ;
+      marked : ARRAY [0..MaxSeq] OF CHAR ;
+      beep   : ARRAY [0..MaxSeq] OF CHAR ;
+   END ;
+
+PROCEDURE ParseValue (src : ARRAY OF CHAR ; VAR dst : ARRAY OF CHAR) ;
+(* decode one value's escapes into dst: the same rules as a file's value, so
+   a caller building a Cfg in memory spells it exactly as the file would. *)
+
+PROCEDURE Default (VAR c : Cfg) ;
+(* the ANSI set - what the shell used before there was a config file *)
+
+PROCEDURE Load (path : ARRAY OF CHAR ; VAR c : Cfg ; VAR ok : BOOLEAN) ;
+(* Default(c), then every KEY=value in path.  ok is FALSE if the file could
+   not be opened or a line is malformed. *)
+
+END TermCfg.

+ 214 - 0
shell/TermCfg.mod

@@ -0,0 +1,214 @@
+IMPLEMENTATION MODULE TermCfg ;
+
+FROM Posix IMPORT open, read, close ;
+FROM SYSTEM IMPORT ADR, BYTE ;
+
+CONST
+   ESC = 27 ;
+   CR  = 13 ;
+   LF  = 10 ;
+   BS  = 92 ;        (* backslash, as a number: Modula-2 string escaping is
+                        not something to rely on for a single character *)
+   MaxFile = 4095 ;
+
+PROCEDURE Clear (VAR s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i <= HIGH (s) DO
+      s [i] := 0C ;
+      INC (i)
+   END
+END Clear ;
+
+PROCEDURE Copy (VAR dst : ARRAY OF CHAR ; src : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (dst)) AND (i <= HIGH (src)) AND (src [i] # 0C) DO
+      dst [i] := src [i] ;
+      INC (i)
+   END ;
+   IF i <= HIGH (dst) THEN
+      dst [i] := 0C
+   END
+END Copy ;
+
+PROCEDURE Up (ch : CHAR) : CHAR ;
+BEGIN
+   IF (ch >= 'a') AND (ch <= 'z') THEN
+      RETURN CHR (ORD (ch) - 32)
+   END ;
+   RETURN ch
+END Up ;
+
+PROCEDURE KeyIs (key : ARRAY OF CHAR ; want : ARRAY OF CHAR) : BOOLEAN ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   LOOP
+      IF (i > HIGH (key)) OR (i > HIGH (want)) THEN
+         RETURN FALSE
+      END ;
+      IF Up (key [i]) # Up (want [i]) THEN
+         RETURN FALSE
+      END ;
+      IF want [i] = 0C THEN
+         RETURN TRUE
+      END ;
+      INC (i)
+   END
+END KeyIs ;
+
+PROCEDURE ParseValue (src : ARRAY OF CHAR ; VAR dst : ARRAY OF CHAR) ;
+(* \e, \r, \n, \t, \\, ^X; everything else is a literal character. *)
+VAR i, o, c : CARDINAL ; ch : CHAR ;
+BEGIN
+   i := 0 ; o := 0 ;
+   WHILE (i <= HIGH (src)) AND (src [i] # 0C) DO
+      ch := src [i] ;
+      IF (ORD (ch) = BS) AND (i + 1 <= HIGH (src)) AND (src [i + 1] # 0C) THEN
+         INC (i) ;
+         ch := src [i] ;
+         IF (ch = 'e') OR (ch = 'E') THEN
+            c := ESC
+         ELSIF ch = 'r' THEN
+            c := CR
+         ELSIF ch = 'n' THEN
+            c := LF
+         ELSIF ch = 't' THEN
+            c := 9
+         ELSIF ORD (ch) = BS THEN
+            c := BS
+         ELSE
+            c := ORD (ch)
+         END
+      ELSIF (ch = '^') AND (i + 1 <= HIGH (src)) AND (src [i + 1] # 0C) THEN
+         INC (i) ;
+         c := ORD (Up (src [i])) - 64
+      ELSE
+         c := ORD (ch)
+      END ;
+      IF o <= HIGH (dst) THEN
+         dst [o] := CHR (c) ;
+         INC (o)
+      END ;
+      INC (i)
+   END ;
+   IF o <= HIGH (dst) THEN
+      dst [o] := 0C
+   END
+END ParseValue ;
+
+PROCEDURE Apply (VAR c : Cfg ; key, val : ARRAY OF CHAR) ;
+BEGIN
+   IF KeyIs (key, "NAME") THEN
+      Copy (c.name, val)
+   ELSIF KeyIs (key, "CLEAR") THEN
+      ParseValue (val, c.clear)
+   ELSIF KeyIs (key, "HOME") THEN
+      ParseValue (val, c.home)
+   ELSIF KeyIs (key, "GOTO") OR KeyIs (key, "MOVETO") THEN
+      ParseValue (val, c.moveTo)
+   ELSIF KeyIs (key, "NORMAL") THEN
+      ParseValue (val, c.normal)
+   ELSIF KeyIs (key, "MARKED") THEN
+      ParseValue (val, c.marked)
+   ELSIF KeyIs (key, "BEEP") THEN
+      ParseValue (val, c.beep)
+   END
+   (* an unknown key is ignored on purpose: TINST's files and ours can then
+      share a file without one rejecting the other's fields *)
+END Apply ;
+
+PROCEDURE Default (VAR c : Cfg) ;
+BEGIN
+   Copy (c.name, "ANSI") ;
+   ParseValue ("\e[2J\e[H", c.clear) ;
+   ParseValue ("\e[H", c.home) ;
+   ParseValue ("\e[%r;%cH", c.moveTo) ;
+   ParseValue ("\e[0m", c.normal) ;
+   ParseValue ("\e[1;7m", c.marked) ;
+   ParseValue ("^G", c.beep)
+END Default ;
+
+PROCEDURE Load (path : ARRAY OF CHAR ; VAR c : Cfg ; VAR ok : BOOLEAN) ;
+VAR fd : INTEGER ;
+    k : LONGINT ;
+    b : BYTE ;
+    buf : ARRAY [0..MaxFile] OF CHAR ;
+    n, i, ls, le, eq, j : CARDINAL ;
+    key : ARRAY [0..31] OF CHAR ;
+    val : ARRAY [0..255] OF CHAR ;
+    bad : BOOLEAN ;
+BEGIN
+   Default (c) ;
+   ok := FALSE ;
+   fd := open (ADR (path), 0, 0) ;
+   IF fd < 0 THEN
+      RETURN
+   END ;
+   n := 0 ;
+   LOOP
+      k := read (fd, ADR (b), 1) ;
+      IF k # 1 THEN
+         EXIT
+      END ;
+      IF b = 26 THEN                       (* ^Z ends the text *)
+         EXIT
+      END ;
+      IF n <= HIGH (buf) THEN
+         buf [n] := CHR (ORD (b)) ;
+         INC (n)
+      END
+   END ;
+   k := close (fd) ;
+   bad := FALSE ;
+   i := 0 ;
+   WHILE i < n DO
+      ls := i ;
+      WHILE (i < n) AND (buf [i] # CHR (CR)) AND (buf [i] # CHR (LF)) DO
+         INC (i)
+      END ;
+      le := i ;
+      WHILE (i < n) AND ((buf [i] = CHR (CR)) OR (buf [i] = CHR (LF))) DO
+         INC (i)
+      END ;
+      (* trim blanks *)
+      WHILE (ls < le) AND ((buf [ls] = ' ') OR (buf [ls] = CHR (9))) DO
+         INC (ls)
+      END ;
+      WHILE (le > ls) AND ((buf [le - 1] = ' ') OR (buf [le - 1] = CHR (9))) DO
+         DEC (le)
+      END ;
+      IF (ls < le) AND (buf [ls] # ';') THEN
+         eq := ls ;
+         WHILE (eq < le) AND (buf [eq] # '=') DO
+            INC (eq)
+         END ;
+         IF eq = le THEN
+            bad := TRUE                       (* a line with no '=' *)
+         ELSE
+            j := 0 ;
+            WHILE (ls < eq) AND (j <= HIGH (key)) DO
+               key [j] := buf [ls] ;
+               INC (j) ;
+               INC (ls)
+            END ;
+            key [j] := 0C ;
+            INC (eq) ;                        (* skip '=' *)
+            j := 0 ;
+            WHILE (eq < le) AND (j <= HIGH (val)) DO
+               val [j] := buf [eq] ;
+               INC (j) ;
+               INC (eq)
+            END ;
+            val [j] := 0C ;
+            Apply (c, key, val)
+         END
+      END
+   END ;
+   ok := NOT bad
+END Load ;
+
+END TermCfg.

+ 247 - 0
shell/Tp3Cfg.mod

@@ -0,0 +1,247 @@
+MODULE Tp3Cfg ;
+
+(* The terminal CONFIGURATOR - the TINST role, without the binary patch.
+
+   TINST installs terminal escape sequences by writing them into TURBO.COM.
+   This program writes a TEXT file, TP3.TRM, that the shell reads at startup
+   (TermCfg.Load).  Nothing here touches tpshell: edit the file by hand, copy
+   it, version it, generate it - the shell will read whatever is there.
+
+   Usage:
+      tp3cfg                 list the terminals, pick one, write TP3.TRM
+   (The sequence table below is the place to add a terminal: one Build case,
+   one NumTerms increment.  A CLI with --list/--show is a later convenience;
+   the file it writes is plain text and can be edited or copied by hand.)
+
+   The sequences are entered here as readable text and DECODED by TermCfg, so
+   the emitted file contains the same `\e...' spelling a person can edit. *)
+
+FROM Posix IMPORT read, write, open, close ;
+FROM SYSTEM IMPORT ADR ;
+FROM TermCfg IMPORT Cfg, Default, ParseValue ;
+
+CONST
+   STDOUT = 1 ;
+   NumTerms = 3 ;
+
+VAR
+   terms : ARRAY [0..NumTerms - 1] OF Cfg ;
+   nout  : LONGINT ;
+   chbuf : CHAR ;
+   outFd : INTEGER ;          (* STDOUT normally, the file while writing it *)
+
+PROCEDURE NL ;
+BEGIN
+   chbuf := CHR (10) ;
+   nout := write (outFd, ADR (chbuf), 1)
+END NL ;
+
+PROCEDURE Out (s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      chbuf := s [i] ;
+      nout := write (outFd, ADR (chbuf), 1) ;
+      INC (i)
+   END
+END Out ;
+
+PROCEDURE OutCh (ch : CHAR) ;
+BEGIN
+   chbuf := ch ;
+   nout := write (outFd, ADR (chbuf), 1)
+END OutCh ;
+
+PROCEDURE Copy (VAR dst : ARRAY OF CHAR ; src : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (dst)) AND (i <= HIGH (src)) AND (src [i] # 0C) DO
+      dst [i] := src [i] ;
+      INC (i)
+   END ;
+   IF i <= HIGH (dst) THEN
+      dst [i] := 0C
+   END
+END Copy ;
+
+PROCEDURE Build (i : CARDINAL ; VAR c : Cfg) ;
+BEGIN
+   Default (c) ;
+   IF i = 0 THEN
+      Copy (c.name, "ANSI")
+      (* ANSI is the default in TermCfg *)
+   ELSIF i = 1 THEN
+      Copy (c.name, "VT100")
+      (* VT100 is ANSI-compatible for everything the shell emits *)
+   ELSE
+      Copy (c.name, "VT52") ;
+      ParseValue ("\eH\eJ", c.clear) ;
+      ParseValue ("\eH", c.home) ;
+      ParseValue ("\eY%R%C", c.moveTo) ;
+      ParseValue ("\eA", c.normal) ;
+      ParseValue ("\eB", c.marked) ;
+      ParseValue ("^G", c.beep)
+   END
+END Build ;
+
+PROCEDURE Init ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i < NumTerms DO
+      Build (i, terms [i]) ;
+      INC (i)
+   END
+END Init ;
+
+PROCEDURE Find (name : ARRAY OF CHAR) : CARDINAL ;
+(* 0..NumTerms-1, or NumTerms when not found *)
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE i < NumTerms DO
+      IF NameEq (terms [i].name, name) THEN
+         RETURN i
+      END ;
+      INC (i)
+   END ;
+   RETURN NumTerms
+END Find ;
+
+PROCEDURE NameEq (a, b : ARRAY OF CHAR) : BOOLEAN ;
+VAR i : CARDINAL ; ca, cb : CHAR ;
+BEGIN
+   i := 0 ;
+   LOOP
+      IF (i > HIGH (a)) OR (i > HIGH (b)) THEN
+         RETURN FALSE
+      END ;
+      ca := a [i] ; cb := b [i] ;
+      IF (ca >= 'a') AND (ca <= 'z') THEN ca := CHR (ORD (ca) - 32) END ;
+      IF (cb >= 'a') AND (cb <= 'z') THEN cb := CHR (ORD (cb) - 32) END ;
+      IF ca # cb THEN
+         RETURN FALSE
+      END ;
+      IF ca = 0C THEN
+         RETURN TRUE
+      END ;
+      INC (i)
+   END
+END NameEq ;
+
+PROCEDURE EncCh (ch : CHAR) ;
+(* the inverse of TermCfg.Decode, so the file stays readable *)
+VAR o : CARDINAL ;
+BEGIN
+   o := ORD (ch) ;
+   IF o = 27 THEN
+      Out ("\e")
+   ELSIF o = 13 THEN
+      Out ("\r")
+   ELSIF o = 10 THEN
+      NL
+   ELSIF o = 9 THEN
+      Out ("\t")
+   ELSIF o = 92 THEN
+      Out ("\\")
+   ELSIF (o > 0) AND (o < 27) THEN
+      Out ("^") ;
+      OutCh (CHR (o + 64))
+   ELSE
+      OutCh (ch)
+   END
+END EncCh ;
+
+PROCEDURE Enc (label : ARRAY OF CHAR ; s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   Out (label) ;
+   Out ("=") ;
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      EncCh (s [i]) ;
+      INC (i)
+   END ;
+   NL
+END Enc ;
+
+PROCEDURE WriteCfg (path : ARRAY OF CHAR ; c : Cfg) : BOOLEAN ;
+VAR fd : INTEGER ;
+BEGIN
+   fd := open (ADR (path), 0241H, 420) ;   (* O_WRONLY|O_CREAT|O_TRUNC, 0644 *)
+   IF fd < 0 THEN
+      Out ("tp3cfg: cannot write ") ; Out (path) ; NL ;
+      RETURN FALSE
+   END ;
+   outFd := fd ;
+   Out ("; TP3 terminal configuration - written by tp3cfg") ; NL ;
+   Out ("; the shell reads it as TP3.TRM; edit freely") ; NL ;
+   Enc ("NAME", c.name) ;
+   Enc ("CLEAR", c.clear) ;
+   Enc ("HOME", c.home) ;
+   Enc ("GOTO", c.moveTo) ;
+   Enc ("NORMAL", c.normal) ;
+   Enc ("MARKED", c.marked) ;
+   Enc ("BEEP", c.beep) ;
+   outFd := STDOUT ;
+   nout := close (fd) ;
+   RETURN TRUE
+END WriteCfg ;
+
+PROCEDURE Show (i : CARDINAL) ;
+BEGIN
+   Out ("NAME=") ; Out (terms [i].name) ; NL ;
+   Enc ("CLEAR", terms [i].clear) ;
+   Enc ("HOME", terms [i].home) ;
+   Enc ("GOTO", terms [i].moveTo) ;
+   Enc ("NORMAL", terms [i].normal) ;
+   Enc ("MARKED", terms [i].marked) ;
+   Enc ("BEEP", terms [i].beep)
+END Show ;
+
+(* ------------------------------------------------------------------ main *)
+
+PROCEDURE Main ;
+VAR i : CARDINAL ;
+    pick : ARRAY [0..31] OF CHAR ;
+    line : ARRAY [0..127] OF CHAR ;
+    n : LONGINT ;
+    k : CARDINAL ;
+BEGIN
+   Init ;
+   Out ("Terminals:") ;
+   NL ;
+   i := 0 ;
+   WHILE i < NumTerms DO
+      Out ("  ") ; Out (terms [i].name) ; NL ;
+      INC (i)
+   END ;
+   Out ("Write which one to TP3.TRM? ") ;
+   k := 0 ;
+   LOOP
+      n := read (0, ADR (chbuf), 1) ;
+      IF (n # 1) OR (chbuf = CHR (10)) OR (chbuf = CHR (13)) THEN
+         EXIT
+      END ;
+      IF k <= HIGH (line) THEN
+         line [k] := chbuf ;
+         INC (k)
+      END
+   END ;
+   line [k] := 0C ;
+   i := Find (line) ;
+   IF i = NumTerms THEN
+      Out ("unknown terminal") ; NL
+   ELSE
+      IF WriteCfg ("TP3.TRM", terms [i]) THEN
+         Out ("wrote TP3.TRM (") ; Out (terms [i].name) ; Out (")") ; NL
+      END
+   END
+END Main ;
+
+BEGIN
+   outFd := STDOUT ;
+   Main
+END Tp3Cfg.

+ 72 - 0
shell/tests/TermCfgTest.mod

@@ -0,0 +1,72 @@
+MODULE TermCfgTest ;
+
+(* Read a terminal config and print it as hex, so the loader and the escape
+   decoder are asserted byte for byte (tests/term_config.sh compares this with
+   an expected file).  The path is fixed because this is a harness program, not
+   a configurable tool: the script copies the fixture there. *)
+
+FROM TermCfg IMPORT Cfg, Load ;
+FROM Posix IMPORT write ;
+FROM SYSTEM IMPORT ADR ;
+
+VAR
+   c : Cfg ;
+   ok : BOOLEAN ;
+   n : LONGINT ;
+   ch : CHAR ;
+
+PROCEDURE Out (s : ARRAY OF CHAR) ;
+VAR i : CARDINAL ;
+BEGIN
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      ch := s [i] ;
+      n := write (1, ADR (ch), 1) ;
+      INC (i)
+   END
+END Out ;
+
+PROCEDURE NL ;
+BEGIN
+   ch := CHR (10) ;
+   n := write (1, ADR (ch), 1)
+END NL ;
+
+PROCEDURE HexDig (d : CARDINAL) ;
+BEGIN
+   IF d < 10 THEN
+      ch := CHR (ORD ('0') + d)
+   ELSE
+      ch := CHR (ORD ('A') + d - 10)
+   END ;
+   n := write (1, ADR (ch), 1)
+END HexDig ;
+
+PROCEDURE Hex (label : ARRAY OF CHAR ; s : ARRAY OF CHAR) ;
+VAR i, o : CARDINAL ;
+BEGIN
+   Out (label) ;
+   Out ("=") ;
+   i := 0 ;
+   WHILE (i <= HIGH (s)) AND (s [i] # 0C) DO
+      o := ORD (s [i]) ;
+      HexDig (o DIV 16) ;
+      HexDig (o MOD 16) ;
+      Out (" ") ;
+      INC (i)
+   END ;
+   NL
+END Hex ;
+
+BEGIN
+   Load ("../tmp/term_test.trm", c, ok) ;
+   IF ok THEN Out ("ok=1") ELSE Out ("ok=0") END ;
+   NL ;
+   Out ("NAME=") ; Out (c.name) ; NL ;
+   Hex ("CLEAR", c.clear) ;
+   Hex ("HOME", c.home) ;
+   Hex ("GOTO", c.moveTo) ;
+   Hex ("NORMAL", c.normal) ;
+   Hex ("MARKED", c.marked) ;
+   Hex ("BEEP", c.beep)
+END TermCfgTest.

+ 8 - 0
shell/tests/fixtures/term_ansi.hex

@@ -0,0 +1,8 @@
+ok=1
+NAME=ANSI
+CLEAR=1B 5B 32 4A 1B 5B 48 
+HOME=1B 5B 48 
+GOTO=1B 5B 25 72 3B 25 63 48 
+NORMAL=1B 5B 30 6D 
+MARKED=1B 5B 31 3B 37 6D 
+BEEP=07 

+ 8 - 0
shell/tests/fixtures/term_ansi.trm

@@ -0,0 +1,8 @@
+; a fixture for tests/term_config.sh: the ANSI default, as tp3cfg writes it
+NAME=ANSI
+CLEAR=\e[2J\e[H
+HOME=\e[H
+GOTO=\e[%r;%cH
+NORMAL=\e[0m
+MARKED=\e[1;7m
+BEEP=^G

+ 8 - 0
shell/tests/fixtures/term_vt52.hex

@@ -0,0 +1,8 @@
+ok=1
+NAME=VT52
+CLEAR=1B 48 1B 4A 
+HOME=1B 48 
+GOTO=1B 59 25 52 25 43 
+NORMAL=1B 41 
+MARKED=1B 42 
+BEEP=07 

+ 8 - 0
shell/tests/fixtures/term_vt52.trm

@@ -0,0 +1,8 @@
+; a fixture for tests/term_config.sh: VT52's raw-byte cursor addressing
+NAME=VT52
+CLEAR=\eH\eJ
+HOME=\eH
+GOTO=\eY%R%C
+NORMAL=\eA
+MARKED=\eB
+BEEP=^G

+ 3 - 0
shell/tests/run_all.sh

@@ -169,6 +169,9 @@ run "layout helper"   python3 tests/check_comimage.py
 run "mod=11 table"     python3 tests/probe/modrm11.py
 run "runtime image"   python3 tests/check_runtime.py
 run "compile matrix"  tests/run_compile_tests.sh
+# The shell reads its terminal escapes from a text file (TP3.TRM).  This is
+# that loader's oracle: config in, decoded bytes out, compared exactly.
+run "terminal config" tests/term_config.sh
 # The compile matrix proves the compiler agrees with tests/fixtures.  This one
 # proves it gets a stated distance into the REAL program the CALC work targets
 # (CALC-COMPILE.md §1): one row of tests/fixtures/calc_ladder.tsv, moved

+ 57 - 0
shell/tests/term_config.sh

@@ -0,0 +1,57 @@
+#!/bin/bash
+# tests/term_config.sh -- assert the terminal CONFIG loader and decoder.
+#
+# The shell reads TP3.TRM at startup (TermCfg.Load) instead of having escape
+# sequences patched into it.  This suite is that loader's oracle: it feeds a
+# fixture config to a tiny harness and compares every decoded sequence byte for
+# byte.  The interesting cases are the ones an eyeball misses:
+#
+#   * \e must be ONE ESC byte, not a backslash and an 'e';
+#   * ^G must be BEL (07), not '^' and 'G';
+#   * the ANSI default must survive when no keys are given;
+#   * VT52's GOTO uses %R/%C (raw bytes), which are still just text here - the
+#     shell substitutes them, and their presence is pinned so the substitution
+#     site cannot silently lose them.
+#
+# It is a separate suite, not part of the shell UI tests, because the loader is
+# pure data: no pty, no screen, no timing.
+set -u
+D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
+GM2=/home/eric/Modula2/Gm2/bin/gm2
+[ -x "$GM2" ] || GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
+cd "$D" || exit 9
+mkdir -p ../tmp
+
+if [ ! -f Posix.o ] || [ Posix.c -nt Posix.o ]; then
+    cc -c Posix.c >../tmp/termcfg_c_posix 2>&1 || {
+        echo "COMPILE_FAIL Posix"; grep -m5 "error:" ../tmp/termcfg_c_posix; exit 1; }
+fi
+if [ ! -f TermCfg.o ] || [ TermCfg.mod -nt TermCfg.o ]; then
+    $GM2 -fiso -c TermCfg.mod >../tmp/termcfg_c 2>&1 || {
+        echo "COMPILE_FAIL TermCfg"; grep -m5 "error:" ../tmp/termcfg_c; exit 1; }
+fi
+if [ ! -x tests/termcfgtest ] || [ tests/TermCfgTest.mod -nt tests/termcfgtest ] \
+   || [ TermCfg.o -nt tests/termcfgtest ] || [ Posix.o -nt tests/termcfgtest ]; then
+    rm -f tests/termcfgtest
+    $GM2 -fiso -o tests/termcfgtest tests/TermCfgTest.mod TermCfg.o Posix.o \
+        >../tmp/termcfg_link 2>&1 || {
+        echo "LINK_FAIL"; grep -m5 -E "error:|undefined" ../tmp/termcfg_link; exit 1; }
+fi
+
+pass=0; fail=0
+for name in term_ansi term_vt52; do
+    cp "tests/fixtures/$name.trm" ../tmp/term_test.trm || exit 1
+    ./tests/termcfgtest > "../tmp/$name.got" 2>&1
+    if diff -u "tests/fixtures/$name.hex" "../tmp/$name.got" > "../tmp/$name.diff"; then
+        printf '  %-20s PASS\n' "$name"
+        pass=$((pass + 1))
+    else
+        printf '  %-20s FAIL\n' "$name"
+        head -20 "../tmp/$name.diff"
+        fail=$((fail + 1))
+    fi
+done
+
+echo "----------------------------------------------------------------"
+echo "terminal config: $pass passed, $fail failed (of $((pass + fail)))"
+[ "$fail" -eq 0 ] || exit 1