CALC-PORT.md 15 KB

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