# 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).