Procházet zdrojové kódy

Analysis: porting CALC.PAS to GNU Modula-2, with the measurements kept

Asked how hard it would be to make a GNU Modula-2 version of the MicroCalc
spreadsheet that shipped with TP3 (Resources/TP3/CALC.{PAS,INC,HLP}).  Nothing
is ported; this records the feasibility work so the next session starts from
measurements instead of re-deriving them.  Deliberately kept out of
SUMMARY.md's milestone table -- it is not a milestone of the compiler -- but
SUMMARY.md now points at it so it is not orphaned.

SIZE, measured by stripping both comment styles:  ~1265 non-blank code lines
across 47 routines.  Largest are Factor (174, the parser core), UpdateCells
(136), GotoX (101), GetPages (78).  Only six dependencies beyond plain Pascal,
all Turbo Pascal's CRT unit, so Term.mod covers the platform layer almost
entirely: ClrScr, GotoXY, PutCh/PutStr/PutCard, LowVideo/NormVideo (mapped to
Marked/Normal -- and CALC never calls HighVideo, so that is exact rather than
approximate), KeyPressed -> Avail, Read(Kbd,Ch) -> GetCh/GetKey, Delay -> Beep.
Editor.mod already decodes ESC sequences; only the mapping to ^E/^X/^D/^S is
new.

THE PART THAT DECIDES IT IS WHETHER THE PORT IS MECHANICAL, so that was
measured rather than assumed.  calc-port/gm2-probes.sh compiles one minimal
module per construct and asserts the verdict; 13 probes, currently all
matching.  Gm2 16.0.1 -fiso accepts nested PROCEDURE + FORWARD + a result type
(the parser's shape), ARRAY [0..n] OF CHAR, and a string literal assigned
straight into a char array.  It rejects nested FUNCTION, named and inline
shortsubrange types, CHAR subrange types, enumeration-indexed arrays, and SET
OF CHAR.  Every rejection has a mechanical replacement; none is a redesign.
Cross-check that these are real and not a broken harness: the working project
uses none of them -- 0 named subranges, 0 enum-indexed arrays, 0 SET OF, and
BITSET only in Compiler.mod.

THE FILE FORMAT IS A LUCKY BREAK.  CALCDEMO.MCS is a real worksheet saved by
the original, so the format can be measured instead of guessed: 11760 bytes /
147 cells = exactly 80, laid out as 1 byte SET OF Attributes, 71 bytes
string[70], 6 bytes Real, 1 byte DEC, 1 byte FW.  The 6-byte Real decoded and
was cross-validated against five cells whose contents are known to be 1..5:
value = (1 + mantissa) * 2^(byte0 - 129), mantissa little-endian.  That makes
phase 3 byte-exact against the original -- the only part of this port provable
against an artefact rather than against my own judgement.

TWO CONFLICTS WITH RESUME-TP3.md, where the artefact wins:
  - array iteration order.  The book says the last declared index varies
    fastest; CALC declares array['A'..'G', 1..FYMax], so the book predicts
    A1, A2, ... Reading CALCDEMO.MCS that way is gibberish; reading it as
    A1, B1, ... yields coherent English rows.  The original stores these
    row-major.
  - the 6-byte Real exponent bias measures as 129, the book says 128.  The
    mantissa interpretation agrees exactly, so this may be the OCR the file's
    own header warns about, but it is recorded as unconfirmed.
Also noted: two of the 147 records hold floating-point garbage because their
formulas divide by empty cells (DEC = 255), so a reader must tolerate DEC > 20.

Section 8 of the doc records the method traps that cost the most time here,
so they are not repeated: printf '%s' does not expand \n, which put every
probe module on one line and manufactured a round of false "Gm2 rejects ..."
conclusions; and in Modula-2 'A' is a character while "A" is a string, so
TYPE Col = "A".."G" is a string range and fails in a way that looks exactly
like a missing feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eric Streit před 5 dny
rodič
revize
5b8e928c6c
3 změnil soubory, kde provedl 548 přidání a 0 odebrání
  1. 321 0
      CALC-PORT.md
  2. 14 0
      SUMMARY.md
  3. 213 0
      calc-port/gm2-probes.sh

+ 321 - 0
CALC-PORT.md

@@ -0,0 +1,321 @@
+# CALC-PORT — porting `Resources/TP3/CALC.PAS` to GNU Modula-2
+
+**Status: analysis only. Nothing has been ported. No code written for CALC yet.**
+
+Feasibility study, done 2026-10-05, kept here so the next session starts from
+measurements instead of re-deriving them. Everything below that is labelled
+*measured* was compiled or decoded on this machine; everything labelled
+*unknown* is a gap the next session has to close.
+
+The target is `/home/eric/bin/Modula2/Gm2/bin/gm2 -fiso`, i.e. the same
+toolchain `shell/` builds with, but running natively on Linux — not the 8086
+`.COM` path that `shell/Compiler.mod` implements. So nothing here constrains
+the TP3 compiler work; this is a separate program.
+
+---
+
+## 1. What the program is
+
+`Resources/TP3/CALC.{PAS,INC,HLP}` — MicroCalc 2.0, a full-screen spreadsheet
+with its own formula parser, on-line help, save/load and print. `CALC.PAS:1-108` is the banner comment and the module index; the code starts
+at `CALC.PAS:108`.
+
+Stripping both comment styles (`{ }` and `(* *)`):
+
+| file | raw lines | non-blank code lines |
+|---|---|---|
+| `CALC.PAS` | 183 | 58 |
+| `CALC.INC` | 1582 | 1207 |
+| **total** | 1765 | **~1265** |
+
+47 routines. Largest:
+
+| lines | routine | what it is |
+|---|---|---|
+| 174 | `Factor` (`CALC.INC:765`) | the parser core: numbers, cell refs, ranges, the 10 built-in functions |
+| 136 | `UpdateCells` | redraw |
+| 101 | `GotoX` | horizontal cursor movement |
+| 78 | `GetPages` | `.HLP` paging for the help system |
+| 53 | `Print` | printer output |
+| 51 | `GetInt` | numeric input |
+| 36 | `Recalculate` | dependency walk |
+| 33 | `Expression` | parser, one level up from `Factor` |
+
+Only 6 external dependencies beyond plain Pascal, all from Turbo Pascal's
+`CRT` unit (`GotoXY` ×25, `KeyPressed`, `ClrScr`, `LowVideo`, `NormVideo`,
+`Delay`), plus `Read(Kbd,Ch)` for raw keys. **No inline assembly.**
+
+Two historical constraints in the source, both of which disappear in the port:
+`CALC.INC:749-751` warns that `Fact` will not compile with `TURBO-87.COM`
+without an 8087 coprocessor, and `CALC.INC:937-942` warns that `Sqrt`, `Sin`
+and friends are not implemented in the BCD compiler, so use `TURBO.COM`.
+
+---
+
+## 2. The screen layer already exists
+
+`shell/Term.mod` + `shell/Posix.c` (raw termios keyboard, ANSI screen) cover
+essentially all of CALC's platform needs. Mapping is 1:1:
+
+| Turbo Pascal `CRT` | `Term.mod` (`Term.def`) |
+|---|---|
+| `ClrScr` | `ClrScr` |
+| `GotoXY(row, col)` | `GotoXY(row, col)` — same 1-based convention |
+| `Write` of char / string / integer | `PutCh` / `PutStr` / `PutCard` |
+| `LowVideo`, `NormVideo` | `Marked`, `Normal` |
+| `KeyPressed` | `Avail` |
+| `Read(Kbd, Ch)` | `GetCh` (blocking), `GetKey` (non-blocking) |
+| `Delay` | `Beep` |
+
+`LowVideo`/`NormVideo` are the *only* two intensities CALC uses — it never
+calls `HighVideo` — so `Marked`/`Normal` is an exact match, not an
+approximation.
+
+Arrow keys: `shell/Editor.mod:1388-1410` already decodes `ESC` sequences.
+The only new work is mapping them onto TP3's codes (`^E` up, `^X`/`^J` down,
+`^D`/`^M`/`^F` right, `^S`/`^A` left — see `CALC.PAS:170-175`).
+
+**Estimated cost of the whole platform layer: about an hour.**
+
+---
+
+## 3. Measured Gm2 constraints
+
+This is the part that decides whether the port is mechanical. Run it:
+
+```sh
+sh calc-port/gm2-probes.sh          # 13 probes, asserts the matrix below
+sh calc-port/gm2-probes.sh --bless  # re-baseline, deliberately only
+```
+
+Measured on **gm2 (GCC) 16.0.1 20260325 (experimental)**, `-fiso -Wall`:
+
+| construct | verdict | what CALC needs it for |
+|---|---|---|
+| `c := 'A'` | **accept** | — |
+| `ARRAY [0..70] OF CHAR` | **accept** | `string[70]`, `string[100]`, `string[6]` |
+| `s := "ABS"` into `ARRAY [0..5] OF CHAR` | **accept** | the function-name table |
+| `ARRAY [0..20] OF INTEGER` | **accept** | subrange *bounds* are fine |
+| nested `PROCEDURE` + result type + `; FORWARD ;` | **accept** | **the parser** |
+| nested `FUNCTION` | **reject** | `Expression`, `Factor`, `Fact` |
+| `TYPE Dec = 0..20` (named shortsubrange) | **reject** | `DEC, FW : 0..20` |
+| `VAR d : 0..20` (inline shortsubrange) | **reject** | same |
+| `TYPE Col = 'A'..'G'` (CHAR subrange) | **reject** | `ColumnName`, and `Cells`' first index |
+| `ARRAY [Sf] OF …` (enumeration index) | **reject** | `StandardfunctionNames` |
+| `c IN {'0'..'9'}` (`SET OF CHAR`) | **reject** | `Numbers` |
+| `BITSET` + `INCL` + `n IN b` | **accept** | the replacement for `SET OF` |
+| `(c >= '0') AND (c <= '9')` | **accept** | the replacement for `set of Char` |
+
+Exact diagnostics are in the script's output; the notable ones:
+
+- nested `FUNCTION` → `expecting one of: «FORWARD» «END» «BEGIN» «CONST» «TYPE» «VAR» «PROCEDURE» «MODULE»`
+- shortsubrange → `syntax error, found «integer number»`
+- `TYPE Col = 'A'..'G'` → `syntax error, found «string»` (it lexes `'A'` as a *string*)
+
+**Cross-check that these are real and not a broken harness:** the entire
+working project in `shell/` uses **none** of the rejected constructs — 0 named
+subranges, 0 enumeration-indexed arrays, 0 `SET OF` declarations, 0 CHAR
+subranges, only `ARRAY [0..n] OF` (19× `ARRAY [0..255]`). `BITSET` appears
+only in `shell/Compiler.mod` (6×), where sets would be natural. The existing
+code has been written to live inside exactly these limits, which is consistent
+with them being real.
+
+### Workarounds, all mechanical
+
+| CALC | replacement | cost |
+|---|---|---|
+| `function Factor(VAR C: Char): Real` | `PROCEDURE Factor(VAR C: CHAR; VAR F: LONGREAL)` | ~30 call sites inside 174 lines; CALC already passes all its state through `var` params, so this is close to 1:1 |
+| `DEC, FW : 0..20` | `CARDINAL` | trivial; keep the range checks by hand, which the port arguably wants anyway |
+| `array['A'..'G', 1..21] of CellRec` | `ARRAY [0..6, 0..20] OF CellRec` + a column-letter↔index accessor | trivial, and it isolates every place the letter arithmetic appeared |
+| `StandardfunctionNames : array[Standardfunction] of string[6]` | `ARRAY [0..9] OF ARRAY [0..5] OF CHAR`, one constant per function | trivial |
+| `Numbers : set of Char` | `IsDigit(c)` as two comparisons | trivial, arguably clearer |
+| `CellStatus : set of Attributes` | `BITSET` + `INCL`/`EXCL` | trivial |
+| `with Sheet[i,j] do` (8 blocks) | qualify: `Sheet[i,j].Field` | mechanical, ~350 lines touched |
+| `^M`, `^[`, `^E`, … | `CHR(13)`, `CHR(27)`, … | trivial |
+| `file of CellRec` | 80-byte manual records | see §4 — verifiable |
+| `Real`, `Str(v:w:d)` | `LONGREAL` + a formatter to write | see §5 — the real cost |
+
+None of these is a redesign.
+
+### Open question about Gm2
+
+Whether these constructs are legal in a **definition module** is *unknown*:
+compiling a fresh standalone definition module failed with `the file
+containing the definition module … cannot be found`, even inside `shell/`
+where the toolchain demonstrably works, so the test could not be set up. If
+shortsubranges and enumeration-indexed arrays *are* legal there, phases 0-1
+below get easier. Worth ten minutes to resolve.
+
+---
+
+## 4. The `.MCS` file format — measured, and `CALCDEMO.MCS` is an oracle
+
+`Resources/TP3/CALCDEMO.MCS` is a real worksheet saved by the original. That
+makes the file format *measurable* rather than guessable, which is unusual
+and valuable.
+
+**Record size.** 11760 bytes ÷ 147 cells (`'A'..'G'` × 1..21) = **exactly 80
+bytes**, no remainder.
+
+**Layout**, read off the bytes:
+
+```
++0    1 byte    SET OF Attributes   bit n = nth of
+                                     Constant, Formula, Txt,
+                                     OverWritten, Locked, Calculated
++1   71 bytes   string[70]          length byte, then 70 data bytes
++72   6 bytes   Real                Turbo Pascal short real
++78   1 byte    DEC                 0..20
++79   1 byte    FW                  0..20
+```
+
+Confirmed by the status histogram over all 147 records: 69 × `Txt`, 68 ×
+`Txt+OverWritten`, 2 × `Txt+Locked`, 5 × `Constant+Calculated`, 3 ×
+`Constant+Formula+Calculated`; `DEC` is 2 in 145 records and `FW` is 10 in 124.
+
+**The 6-byte Real format, decoded and cross-validated** against five cells
+whose contents are known to be 1, 2, 3, 4, 5:
+
+| cell | contents | bytes +72..+77 |
+|---|---|---|
+| D4 | `1` | `81 00 00 00 00 00` |
+| E4 | `2` | `82 00 00 00 00 00` |
+| F4 | `3` | `82 00 00 00 00 40` |
+| G4 | `4` | `83 00 00 00 00 00` |
+| A5 | `5` | `83 00 00 00 00 20` |
+
+Fitting those: **value = (1 + mantissa) × 2^(byte0 − 129)**, where the mantissa
+is the 5 bytes after the exponent read **little-endian** as a fraction
+(3.0 → 0.5, 5.0 → 0.25). An all-zero record means 0.0.
+
+### Where the measurement disagrees with the book
+
+`RESUME-TP3.md` documents the same three things, from Colibri. Two agree, two
+conflict, and per this project's rule the measurement wins:
+
+| topic | `RESUME-TP3.md` | measured | verdict |
+|---|---|---|---|
+| `string[n]` | length in byte 0, size `n+1` | `+1` length, `+2..+72` data | **agree** |
+| records | fields juxtaposed, not compacted | contiguous, offsets sum to 80 | **agree** |
+| data files | records back to back | 147 × 80, no padding, no trailer | **agree** |
+| 6-byte Real | 1 exponent byte *"complément-à-128"*, then sign + 39-bit mantissa, LSB first | mantissa interpretation matches exactly; bias measures as **129**, not 128 | **conflict, off by one** — likely OCR (`RESUME-TP3.md:6` warns `OCR fréquemment faux`), but unconfirmed |
+| 2-D arrays | *"le dernier indice déclaré varie le plus vite"* | **the first** index (`'A'..'G'`) varies fastest | **conflict** — see below |
+
+The array-order conflict is the one that matters for a byte-exact writer.
+`CALC.INC` declares `Cells = array['A'..'G', 1..FYMax] of CellRec`, so the
+book predicts the order `A1, A2, …, A21, B1, …`. Reading `CALCDEMO.MCS`
+that way produces gibberish; reading it as `A1, B1, …, G1, A2, …` yields
+coherent English rows:
+
+```
+ 1 |  This is Micr |               |               |  Item one     |  Item Two  …
+ 4 |               |               |               | 1             | 2          …
+ 5 | 5             |               | (B4>B8)       |               |            …
+```
+
+So the original stores these row-major. Trust the file.
+
+### Two records are corrupt — and that is not a layout error
+
+Records 94 (D14) and 114 (C17) hold 6 bytes of high-entropy data
+(`7D 18 5B 48 FF 15`, `87 1D 44 10 F6 C3`) where the Real and `DEC`/`FW`
+should be, giving `DEC = 255`, outside `0..20`. Their formulas are
+`(LN(B6)/B10)` and `(B7/B10*100)`, and the cells they divide by are **empty or
+hold text** — so these are genuine floating-point garbage from dividing by
+zero, left in a demo file, not evidence of a wrong layout. The exact
+provenance is not nailed down. A reader must therefore tolerate `DEC > 20`.
+
+### What this buys the port
+
+Phase 3's oracle: **assert that the Modula-2 reader reproduces all 147 records
+of `CALCDEMO.MCS` byte-for-byte**, including the two garbage records. That is
+the only part of this port that can be proved against the original rather than
+against my own judgement. It also means the port reads and writes the
+original's own files.
+
+---
+
+## 5. Where the effort actually is
+
+Not the parser. Two things:
+
+1. **The string layer.** `Anystring = string[70]`, plus `string[2]`,
+   `string[3]`, `string[6]`, `string[100]`, and every `Copy`, `Pos`, `Length`,
+   comparison and concatenation across 1200 lines. Modula-2 has
+   `ARRAY [0..n] OF CHAR` and no length. Widest blast radius by far.
+   **First move for the next session: check whether Gm2's own PIM modules
+   help** — `StrLib.mod`, `StrIO.mod`, `NumberIO.mod`, `StringConvert.mod`
+   all ship in `lib/gcc/x86_64-pc-linux-gnu/16.0.1/m2/m2pim/`. If they cover
+   it, this phase mostly disappears.
+2. **Real formatting.** `Str(v:FW:DEC)`, `Str(v:FW)` and the matching `Write`
+   forms — 8 sites (`CALC.INC:244, 246, 268, 270, 551, 553, 1103, 1105`).
+   **No real formatter exists anywhere in this project** — `Runtime.mod:57`
+   still emits the placeholder `"?REAL?"`. Turbo Pascal's exact behaviour
+   (rounding mode, and the scientific-notation fallback that the comment at
+   `CALC.INC:244` alludes to) is *unknown* and will need experiments against
+   the original.
+
+Precision note: `LONGREAL` gives more precision than TP3's 6-byte Real ever
+had, so formula results will differ in the last digits. That is a deliberate,
+documented difference, and it is testable by running the same formulas through
+both.
+
+---
+
+## 6. Estimate and phasing
+
+Roughly 1100-1300 lines of Modula-2. Honest guess: **phases 0-1 are an hour or
+two**; **the whole thing is most of a day**, with the risk in §5 and not in
+the parser.
+
+| phase | ~lines | verified by |
+|---|---|---|
+| 0 — string type + real formatter + column↔index mapping | ~250 | hand-derived cases, **not** blessed from output |
+| 1 — `Init` `Clear` `Grid` `DisplayType` `GotoCell` `LeaveCell` `Update` `Move*` `UpdateCells` `GotoX` | ~250 | screen dumps through the existing `shell/tests/ptyharness.py` |
+| 2 — `Factor` `Expression` `NextCh` `Recalculate` | ~250 | hand-derived expected output per operator |
+| 3 — `Commands` `GetCell` `Format` `Save` `Load` | ~150 | **byte-exact against `CALCDEMO.MCS`** |
+| 4 — `Help` `GetPages` `Print` | ~180 | help-page dumps |
+
+### Suggested next step
+
+Spike phases 0 and 3 first. They are the two ends of the difficulty range —
+phase 3 because `CALCDEMO.MCS` makes it *provable*, phase 0 because the real
+formatter is the one genuinely unknown. A spike turns this estimate into a
+measurement, and it produces the first thing that can be asserted.
+
+---
+
+## 7. Open questions
+
+1. **Real formatting** — what exactly does TP3's `Str(v:w:d)` produce, and
+   when does it fall back to scientific notation? Unknown; needs experiments.
+2. **Gm2 definition modules** — are shortsubrange types and enumeration-indexed
+   arrays legal there? If yes, phases 0-1 get easier. See §3.
+3. **`Gm2` PIM string modules** — do `StrLib`/`StrIO`/`NumberIO` remove the
+   need for a hand-written string type?
+4. **Unread routine bodies** — `GotoX` (101 lines), `UpdateCells` (136) and
+   `GetPages` (78) were sized but not read. A surprise would live there.
+5. **`.HLP` handling** — `CALC.INC:577-699` reads the help file through a
+   `^` pointer and `Readln`; needs replacing with an index. Not yet examined
+   in detail.
+6. **`Print`** — writes printer control codes to a file. Not examined.
+
+---
+
+## 8. Method notes (learned the hard way, do not repeat)
+
+- **Writing probe modules.** Use a heredoc with a quoted delimiter, or
+  `printf "$fmt"` where `\n` is in the **format**. `printf '%s' "$src"` does
+  *not* expand `\n`: the module lands on one line and every diagnostic becomes
+  nonsense. This produced a whole round of false "Gm2 rejects…" conclusions.
+- **CHAR vs STRING literals in Modula-2.** `'A'` is a character, `"A"` is a
+  string. Writing `TYPE Col = "A".."G"` is a *string* range and fails — which
+  looks exactly like a missing feature.
+- **Probes must be verified on disk.** `wc -l` the file before compiling.
+  Silent mangling reads as "the toolchain lacks the feature".
+- **Linear byte scans are not an oracle.** Finding a bare `0F` in the runtime
+  blob looks like a 386-only opcode; the decode sweep in
+  `shell/tests/check_8086.py` shows it is inside a displacement. Same trap as
+  the bare `3D` anchor documented in `v-TP3-8086-LOWERING.md`.
+- **When a measurement contradicts the book, say so and trust the artefact.**
+  Two such conflicts turned up here (§4).

+ 14 - 0
SUMMARY.md

@@ -35,6 +35,20 @@ rather than a commit whose tree contains this table's account of it, because
 the documentation for that milestone was not written until three commits later
 and by then it already said "executed". The reasoning is in the tag message.
 
+## Separate piece of work
+
+`CALC-PORT.md` — porting `Resources/TP3/CALC.PAS` (the MicroCalc spreadsheet
+that shipped with TP3) to GNU Modula-2 as a native Linux program, using
+`shell/Term.mod` for the screen. **Analysis only; nothing ported.** It is not a
+milestone of this compiler and is deliberately kept out of the table above. It
+records a measured Gm2 accept/reject matrix
+(`calc-port/gm2-probes.sh`, 13 probes, self-asserting), and the 80-byte
+`.MCS` record layout decoded from the original's own `CALCDEMO.MCS` — which
+also contradicts `RESUME-TP3.md` twice, on the `Real` exponent bias and on
+array iteration order. Two artefacts in this repo are the reason it is worth
+doing: that `.MCS` file makes phase 3 byte-exact against the original, and
+`shell/tests/ptyharness.py` already knows how to assert on a VT100 screen.
+
 ## Build
 
 ```sh

+ 213 - 0
calc-port/gm2-probes.sh

@@ -0,0 +1,213 @@
+#!/bin/sh
+# gm2-probes.sh -- what GNU Modula-2 (Gm2) accepts, measured.
+#
+# WHY THIS EXISTS
+#   Porting Resources/TP3/CALC.PAS to Gnu Modula-2 needs to know which
+#   ISO Modula-2 constructs the toolchain actually supports, because CALC
+#   leans on several of them.  Rather than assume, this script compiles one
+#   minimal module per construct and records accept/reject.
+#
+#   It is an ASSERTION, not a report: EXPECTED below is what was measured on
+#   this machine.  If a future Gm2 gains (or loses) a construct, this goes red
+#   and the difference is printed.  Re-baseline deliberately -- do not edit
+#   EXPECTED to make a failure disappear.
+#
+# MEASURED ON
+#   gm2 (GCC) 16.0.1 20260325 (experimental), invoked -fiso -Wall
+#   /home/eric/bin/Modula2/Gm2/bin/gm2
+#
+# USAGE
+#   sh calc-port/gm2-probes.sh          report, and check against EXPECTED
+#   sh calc-port/gm2-probes.sh --bless  re-baseline EXPECTED from what it sees
+#
+# NOTE ON WRITING PROBE FILES
+#   Use the heredocs below (quoted delimiter, so ' survives).  Do NOT try to
+#   build these with `printf '%s' "$src"` -- %s does not expand \n, so the
+#   module lands on one line and every diagnostic becomes nonsense.  Use
+#   `printf "$fmt"` with \n in the FORMAT, or a heredoc.
+
+GM2=${GM2:-/home/eric/bin/Modula2/Gm2/bin/gm2}
+DIR=$(mktemp -d "${TMPDIR:-/tmp}/gm2probe.XXXXXX") || exit 1
+trap 'rm -rf "$DIR"' EXIT
+BLESS=no
+[ "$1" = "--bless" ] && BLESS=yes
+
+# ---------------------------------------------------------------- probes ----
+# name : expect(accept|reject) : one-line description
+# Each probe is the smallest module that exercises exactly one construct.
+
+emit() { cat > "$DIR/$1.mod"; }
+
+emit char_literal              <<'EOF'
+MODULE char_literal;
+VAR c : CHAR;
+BEGIN c := 'A' END char_literal.
+EOF
+
+emit array_of_char             <<'EOF'
+MODULE array_of_char;
+VAR s : ARRAY [0..70] OF CHAR;
+BEGIN s [0] := 'x' END array_of_char.
+EOF
+
+emit string_into_char_array    <<'EOF'
+MODULE string_into_char_array;
+VAR s : ARRAY [0..5] OF CHAR;
+BEGIN s := "ABS" END string_into_char_array.
+EOF
+
+emit array_subrange_bound      <<'EOF'
+MODULE array_subrange_bound;
+VAR a : ARRAY [0..20] OF INTEGER;
+BEGIN a [0] := 1 END array_subrange_bound.
+EOF
+
+emit nested_proc_forward       <<'EOF'
+MODULE nested_proc_forward;
+PROCEDURE Go (n : INTEGER);
+  PROCEDURE H (x : INTEGER) : BOOLEAN; FORWARD;
+  PROCEDURE O (x : INTEGER);
+  BEGIN IF H (x) THEN END END O;
+  PROCEDURE H (x : INTEGER) : BOOLEAN;
+  BEGIN RETURN x > 0 END H;
+BEGIN O (n) END Go;
+BEGIN Go (4) END nested_proc_forward.
+EOF
+
+emit nested_function           <<'EOF'
+MODULE nested_function;
+PROCEDURE Go (n : INTEGER);
+  FUNCTION F (x : INTEGER) : INTEGER;
+  BEGIN RETURN x END F;
+BEGIN n := F (n) END Go;
+BEGIN Go (4) END nested_function.
+EOF
+
+emit named_shortsubrange       <<'EOF'
+MODULE named_shortsubrange;
+TYPE Dec = 0..20;
+VAR d : Dec;
+BEGIN d := 2 END named_shortsubrange.
+EOF
+
+emit inline_shortsubrange      <<'EOF'
+MODULE inline_shortsubrange;
+VAR d : 0..20;
+BEGIN d := 2 END inline_shortsubrange.
+EOF
+
+emit char_subrange_type        <<'EOF'
+MODULE char_subrange_type;
+TYPE Col = 'A'..'G';
+VAR c : Col;
+BEGIN c := 'A' END char_subrange_type.
+EOF
+
+emit enum_indexed_array        <<'EOF'
+MODULE enum_indexed_array;
+TYPE Sf = (fabs, fsqrt, fsqr);
+     Nm = ARRAY [Sf] OF ARRAY [0..5] OF CHAR;
+VAR c : CHAR;
+BEGIN c := Nm [fsqrt] [1] END enum_indexed_array.
+EOF
+
+emit set_of_char_literal       <<'EOF'
+MODULE set_of_char_literal;
+VAR c : CHAR; b : BOOLEAN;
+BEGIN b := c IN {'0'..'9'} END set_of_char_literal.
+EOF
+
+emit bitset_instead_of_set     <<'EOF'
+MODULE bitset_instead_of_set;
+(* CALC's `CellStatus : set of Attributes` and `Numbers : set of Char`
+   have no SET OF here; BITSET + INCL + membership is the replacement. *)
+VAR b : BITSET;
+BEGIN
+  b := {};
+  INCL (b, 2);
+  IF 2 IN b THEN INCL (b, 3) END
+END bitset_instead_of_set.
+EOF
+
+emit char_range_comparisons    <<'EOF'
+MODULE char_range_comparisons;
+VAR c : CHAR; b : BOOLEAN;
+BEGIN b := (c >= '0') AND (c <= '9') END char_range_comparisons.
+EOF
+
+# ------------------------------------------------------------- expected -----
+# name:expect -- measured on gm2 16.0.1 -fiso, 2026-10-05
+cat > "$DIR/EXPECTED" <<'EOF'
+char_literal:accept
+array_of_char:accept
+string_into_char_array:accept
+array_subrange_bound:accept
+nested_proc_forward:accept
+nested_function:reject
+named_shortsubrange:reject
+inline_shortsubrange:reject
+char_subrange_type:reject
+enum_indexed_array:reject
+set_of_char_literal:reject
+bitset_instead_of_set:accept
+char_range_comparisons:accept
+EOF
+
+# ----------------------------------------------------------------- run ------
+echo "gm2: $("$GM2" --version 2>&1 | head -1)"
+echo
+bad=0
+for spec in \
+  "char_literal:accept" \
+  "array_of_char:accept" \
+  "string_into_char_array:accept" \
+  "array_subrange_bound:accept" \
+  "nested_proc_forward:accept" \
+  "nested_function:reject" \
+  "named_shortsubrange:reject" \
+  "inline_shortsubrange:reject" \
+  "char_subrange_type:reject" \
+  "enum_indexed_array:reject" \
+  "set_of_char_literal:reject" \
+  "bitset_instead_of_set:accept" \
+  "char_range_comparisons:accept"
+do
+  name=${spec%%:*}
+  want=${spec##*:}
+  err=$("$GM2" -fiso -Wall -o "$DIR/$name" "$DIR/$name.mod" 2>&1 |
+        grep ": error" | head -1)
+  if [ -z "$err" ]; then got=accept; else got=reject; fi
+  mark="   "
+  if [ "$got" != "$want" ]; then mark="!! "; bad=$((bad+1)); fi
+  printf '%s%-24s want=%-6s got=%-6s' "$mark" "$name" "$want" "$got"
+  if [ -n "$err" ]; then
+    printf '  %s' "$(printf '%s' "$err" | sed 's/.*error: //')"
+  fi
+  echo
+done
+
+echo
+if [ "$BLESS" = yes ]; then
+  echo "--bless: EXPECTED is written from what was just measured:"
+  for spec in \
+    "char_literal" "array_of_char" "string_into_char_array" \
+    "array_subrange_bound" "nested_proc_forward" "nested_function" \
+    "named_shortsubrange" "inline_shortsubrange" "char_subrange_type" \
+    "enum_indexed_array" "set_of_char_literal" "bitset_instead_of_set" \
+    "char_range_comparisons"
+  do
+    name=$spec
+    err=$("$GM2" -fiso -Wall -o "$DIR/$name" "$DIR/$name.mod" 2>&1 |
+          grep ": error" | head -1)
+    if [ -z "$err" ]; then echo "$name:accept"; else echo "$name:reject"; fi
+  done
+  exit 0
+fi
+
+if [ "$bad" -eq 0 ]; then
+  echo "PASS: all 13 constructs match what this Gm2 was measured to do"
+  exit 0
+fi
+echo "FAIL: $bad construct(s) differ from EXPECTED -- Gm2 may have changed."
+echo "      Re-run with --bless only if the new behaviour is understood."
+exit 1