v-TP3-STRCAT — Session summary ============================== Tag: v-TP3-STRCAT Commit: (the commit that adds this file) Covers: v-TP3-STRCAT This is the resume point for the session that completed "Next work" item 1c — string concatenation `+` — in full, with the test harness extensions that the change forced. Every claim below is pinned by fixtures in `shell/tests/fixtures/expected.tsv` and by the gate suite. What changed ------------ 1. String concatenation — `a + b`, `writeln(a + b)`, `s := a + b` (`v-TP3-STRCAT`). TP3's `xconcat` (TPSRC2:963) builds the result in a scratch string, copies the left operand into it, then appends the right (`addstr`/`addch`). The compiler now does the same: - `StrConcat` allocates a fresh 256-byte global scratch STRING per `+` in the source (`NewScratchStr`: `@@SC` of length 256, created at `dc`; `dc += 256`). The scratch is a `KVar` symbol of class `TString` (size 256). - The result `ERes` becomes a **string variable** (kind 1), pointing at that scratch (`res.idx := dest`). This matches how `Assignment`, `IoCall`, `ParseCmp` already handle string operands; no new `ERes` kind was added. - For the left operand: `EmPushStrOperand (left)` → for `TString` calls `EmPushStrAddr`; for a CHAR (one-character literal, `char` variable, or `s[i]` element) it builds a two-byte `` on the stack in a per-operand 2-byte scratch slot (`dc += 2`) and pushes its address. This is how TP3 allows `s + 'a'` to be legal while `s = 'a'` is refused. - Emit: `strstore(dest, left, 255)` (existing selector 17) copies left into the scratch. Then `strcat(dest, right, 255)` (new selector 19) appends right. Both share the same argument order as `strstore` (dest at `[BP+4]`, src at `[BP+6]`, CX = maximum). - Overflow: when the new length > 255, `strcat` branches to `concerr` and leaves through `INT 21h AH=4Ch` with `AL=16` (TP3's "string too long"). The exit code is now asserted by the execution harness via a per-fixture `.exit` sidecar (default 0). 2. The type rule for string `+`. The hook at the **head of `BinOpEmit`** was originally: ``` IF (left.cls = TString) OR (right.cls = TString) THEN StrConcat (...) END ``` That sent `'a' + 'b'` down the integer path (both are `TScalar`+`chr`, `cls != TString`) producing `CHR(195)` — a silently wrong program, not an error. It now tests the same `StrOperand` predicate that `StrConcat` guards with: ``` IF (StrOperand (left) OR StrOperand (right)) THEN StrConcat (...) END ``` where `StrOperand(r) := (r.cls = TString) OR (r.cls = TChar) OR ((r.cls = TScalar) AND r.chr)`. This is a **forward-declared** function next to `StrConcat` (added at line 1916). With this hook, two CHARs, or a string plus a number, reach `StrConcat` and are rejected by its guard as `ETypeErr 56`. `t53_strcat_type` (`'a' + 'b'`) pins the refusal; it was **red before the hook change** (ran through integer path) and is green after. `StrConcat`'s error path now leaves a **well-formed non-string placeholder** (`res.kind = 0`, `res.cls = TScalar`, `res.imm = 0`) rather than a half-built kind-2/TString with an uninitialised `idx`. That is the invariant: if Err() was called (and abortFac set), the returned `ERes` must not claim to be a string, because string consumers dereference `symtab[res.idx]` for any `TString`. 3. Guard against using failed expression results. The compiler's idiom is: after `ParseExpr`, check `IF NOT OK () THEN RETURN END` before dereferencing any symbol from the returned `ERes`. That guard was missing in `IoCall`'s argument loop: when a failed concat returned (kind-2/ TString, idx garbage) the write arm did `EmPushVarAddr (symtab [arg.idx].local, symtab [arg.idx].off)`. With the hook now routing error cases into `StrConcat`, `writeln ('a' + 'b')` became a compiler segfault. Adding the `OK ()` guard after `ParseExpr (arg)` in `IoCall` (mirroring `ParseSub`) stops consuming a failed argument and lets the statement loop terminate via `abortFac`. With the safe placeholder in `StrConcat`, nested cases such as `('a' + 'b') + s` also report the first error cleanly. 4. Runtime: `strcat` (selector 19). `Runtime.mod` gains `EmitStrCat` which appends the source string to the destination. The implementation preserves **AX,BX,CX,DX,SI,DI** (pushes all six) because the compiler may hold a string element address in `DI` across the call (e.g. `s := s + s[i]` in effect, or expressions that push next operands after a string operation). The path is: - check `CX` (max) <= 255 - load `lenD` from `[BP+4]` (destination length byte), `lenS` from `[BP+6]` - `newLen = lenD + lenS`; if `newLen > CX` → `concerr` (exit 16) - else copy `lenS` chars from `src[1]..` to `dst[1 + lenD]..`, set `dst[0] = newLen`, return - `concerr`: `MOV AL,16H; MOV AH,4CH; INT 21H` (6 bytes) The runtime's entry table grows to 20 entries; `RT_Entry`/`RT_Build` register entry 19 as `strcat`. `RtProbe.mod` extends the name table to index 19. `check_runtime.py` and `runtime.golden` were reblessed accordingly. 5. Safety guards: HALT on full label/fixup tables. `Runtime.mod`'s `MaxLbl` was 64 → **96**, `MaxFix` 400 → **512**. The procedures `M` (add a label) and `AddFix` (add a fixup) now call `HALT` when full instead of silently dropping, so a blown table produces a clean abort rather than a subtly wrong runtime blob. 6. Boot sector output: use SI as cursor. `bootcom.s`'s `INT 21h AH=09h` handler kept its cursor at `0702h` (the first byte of the loaded program image at `0602h` in the load window), so writing a CRLF overwrote the start of the loaded code (turning `E8 1A` into a wild jump). The handler now uses `SI` as its cursor and only writes to the video buffer via ES/DI; this removes the code-window limit and fixes the hang seen during bring-up. 7. Execution harness: per-fixture exit codes. `tests/run_com_exec.py` now reads a `.exit` sidecar (decimal integer, default 0) per fixture (`stem + ".exit"`) via `cases()`, and asserts `exit_code_of(rc) == c["exit"]` after the output comparison. The module docstring documents the convention. This is how `t55_strcat_overflow` asserts exit code 16 while still checking its partial output (`read\r\n`). Tests ----- New fixtures: - `t51_strcat.pas` — all operand pairings (var+var, literal+var, var+literal, var+char, char+var, nested concatenation in arguments/assignment), covering multiple consumers (`writeln`, assignment, comparison context). Output committed byte-for-byte. - `t52_strcat_nest.pas` — nested `a + b + '!'`, concatenation compared to a string, and a string **element** as a `+` operand. Output committed. - `t53_strcat_type.pas` — error: `'a' + 'b'` (two CHARs). Expected `ERR 56 663`. - `t54_strcat_strnum.pas` — error: `s + 1` (string + number). Expected `ERR 56 463`. - `t55_strcat_overflow.pas` — `readln` 128 characters, then `s := s + s` (length becomes 256, >255) → runtime exit 16. Input 128×`x`+LF in `.in`, output `read\r\n` in `.out`, `.exit` `16`. `expected.tsv` rows for t51–t55 with their verdicts, code/data bytes, and error positions (the `pos` is the 0-based offset of the lookahead token when `Err` fires). Gates: `run_compile_tests.sh` 56/56; `run_com_tests.sh` 48/48; `run_com_exec.py` 48/48 (t55 has exit 16 asserted); `rt_exec.py` 56/56; `check_runtime.py` PASS; `check_8086.py` PASS; `nonvacuity.sh` 78 ok/0 failed; `run_all.sh` OVERALL ALL PASS. How to verify ------------- cd shell && make # tpshell rebuilt, clean cd shell && tests/run_compile_tests.sh # 56 passed, 0 failed cd shell && tests/run_com_exec.py # 48 passed, 0 failed cd shell && tests/rt_exec.py # 56 passed, 0 failed cd shell && tests/check_runtime.py # PASS cd shell && tests/check_8086.py # PASS cd shell && tests/nonvacuity.sh # 78 ok, 0 failed cd shell && tests/run_all.sh # OVERALL: ALL PASS At this tag the suite reports: compile matrix: 56 passed, 0 failed (of 56) independent .COM check: 48 checked, 0 failed execution: 48 passed, 0 failed (of 48) (t55 asserts exit 16 via .exit) exec86: 48 passed, 0 failed (of 48), cross-checked against qemu runtime entries: 56 passed, 0 failed (of 56) (55 cases + BP pre-flight) runtime: 770 bytes, code 0..738 (739), 20 entries, 65 branches checked check_8086: runtime 770/420, program code 48/48 (9692 bytes), clause H 11/11 nonvacuity: 78 ok, 0 failed OVERALL: ALL PASS Notes ----- - The `-fgen-module-list ... || true` link step prints a roll-up error from `Compiler.mod` during the final link of `tpshell`; this is the expected pass-3 summary and does **not** indicate a compilation failure (`Compiler.o` was built successfully). - `compiletest` is rebuilt when its inputs are newer than the binary (the harness checks staleness). After modifying `Compiler.mod`, `run_compile_tests.sh` will rebuild `compiletest`. - The commit message for the code+test changes should name: the `bootcom.s` AH=09h cursor fix (use SI), the `Runtime.mod` `MaxLbl`/`MaxFix` HALT guards and the table increases, the `BinOpEmit` hook change to use `StrOperand` (fixing the `'a' + 'b'` silent wrong program), the `IoCall` `OK()` guard and the `StrConcat` safe-placeholder fix (eliminating the compiler segfault), the new runtime entry `strcat` (selector 19) and register preservation, the `run_com_exec.py` `.exit` sidecar convention, and the new fixtures t51–t55.