Bläddra i källkod

compiler: STRING VALUE parameters, whole-slot copy (step 3)

The ladder moves: CALC.PAS went from ERR 102 at 10350 to ERR 41 at 10405.
`procedure Center(S : AnyString)' is accepted, its body compiles, and the next
blocker is `Length(S)' - a string builtin, the first rung that is a standard
FUNCTION rather than a type.

  - ProcFunc now allows size > 2 for a TString parameter; the slot is n+1 bytes
    (TP3's `tstscal': a value parameter's whole size goes on the stack).
  - EmStrBlob is the string-literal blob factored out of EmPushStrAddr, so a
    literal argument can be copied from too.  (Renamed from StrLitBlob because
    audit_helpers strips two characters unconditionally for Compiler.mod and
    reported it as `rLitBlob'.)
  - EmPushStrSlot (r, reserve, copyN) pushes the source address via
    EmPushStrAddr (so a caller's LOCAL, a global and a literal all work),
    POP SI, reserves `reserve' bytes, and copies `copyN' of <len><chars> with a
    DS-relative byte loop.  NOT REP MOVSB: MOVS/STOS address through ES, and
    nothing here establishes ES = DS.
  - Both call sites push the whole slot when ParmSlot > 2 and add it to the
    cleanup total.  `copyN' is at most the slot, so a shorter actual than the
    formal leaves the tail untouched (harmless: the length byte bounds reads).

Fixture t67_strparam proves VALUE semantics, not merely "it compiles":
`Show (t)' prints hello, sets S[1] := 'X', and the caller's `t' still prints
hello; `Show ('world')' copies a literal too.  .out is hello/hello/world/hello,
executed under qemu AND Exec86 (57/57).  A by-address implementation would print
`Xello'.

nonvacuity.sh gains the matching case: collapse SlotSize's fallthrough to 2 and
t67 goes red while the compile matrix stays green.  87 ok, 0 failed.

run_all.sh: ALL PASS - matrix 68/68, ladder 1/1 at ERR 41 10405, 59 .COM
linked, 57 executed under qemu and Exec86; helper audit PASS (122 helpers).
Eric Streit 2 dagar sedan
förälder
incheckning
2b90a8decd

+ 29 - 28
CALC-COMPILE.md

@@ -97,7 +97,7 @@ Current rung (measured today, normalised position):
 
 | position | blocker |
 |---|---|
-| **10350** | `procedure Center(S : AnyString)` — a **string value parameter**, INSIDE `CALC.INC` |
+| **10405** | `Length(S)` inside `CALC.INC`'s `Center` — a **string builtin** (`ERR 41`), the first rung that is a standard FUNCTION |
 
 Rungs behind it (now passed):
 
@@ -108,14 +108,15 @@ Rungs behind it (now passed):
 | 7972 | `Numbers : set of Char = ['0'..'9']` — set typed constant | sets, increment 1 |
 | 8098 | `Attributes = (Constant,…)` — enum; and `SetOfAttri = Set of Attributes` | Stage 5 (enums) |
 | 8362 | `CellRec = record … end` — record type | Stage 7 (record types) |
-| 8705 | `Cells = array['A'..'G',1..FYMax]`, `Sheet : Cells`, `XPOS : array[…] = (…)` | Stage 8 (declarations) |
-| 9202 | `MCFile : file of CellRec` — the file type, the whole `var` block, and the `{$I}` expansion | Stage 13 (file types) |
+| 8705 | `Cells = array['A'..'G',1..FYMax]`, `Sheet : Cells`, `XPOS` | Stage 8 (declarations) |
+| 9202 | `MCFile : file of CellRec` — file type, whole `var` block, `{$I}` expansion | Stage 13 (file types) |
+| 10350 | `procedure Center(S : AnyString)` — a **string value parameter** | calling convention, steps 1-3 |
 
 Rungs ahead:
 
 | position | blocker |
 |---|---|
-| 10350 | a string value parameter, then the rest of `CALC.INC`: file operations, indexing, `with`, pointers, `var` params, reals, string builtins, CRT, recursion |
+| 10405 | the string builtins (`Length`, `Copy`, `Delete`, `Insert`, `Str`, `Val`), then the rest of `CALC.INC` |
 
 Corrections the measurements force, worth stating plainly because the stage list
 below was written before them:
@@ -159,6 +160,7 @@ stripped.
 | array **element** access with a VARIABLE index `Sheet[fx,fy]`, `with` | 13 `with` sites | `ENoLib` — scaled addressing next |
 | array indexed by an **enum type** | `CALC.INC:773,927` | `ERR 41` |
 | pointers `^Pages`, `New`, `Dispose` | `CALC.INC:579,587,639,690` | `ERR 41` |
+| string **value** parameters (`S : AnyString`) | `CALC.INC:17`, … | **done** — whole-slot copy; `t67_strparam` |
 | `var` parameters | 10 routines, 14 occurrences (e.g. `CALC.INC:765,979,995,1036`) | **silently by value** |
 | self-recursion | `Fact` — `Fact := I * Fact(I - 1)`, `CALC.INC:747,754` | `ERR 1` at the `(` |
 | mutual recursion via `forward` | `Expression` fwd `CALC.INC:742` ↔ `Factor` calls it at `820`,`932` | `forward` parses; definition untested |
@@ -455,8 +457,16 @@ Exec86):
   **10350**, where the first blocker is a string VALUE parameter in a nested
   procedure. File OPERATIONS (`Assign`/`Reset`/…, read/write with a file
   argument) are the next increment and are loud until then.
-- **The ladder now stops at `ERR 102` at `10350`** — `procedure Center(S :
-  AnyString)` in `CALC.INC`, a value parameter wider than a word.
+- **String VALUE parameters are landed** (TP3's whole-slot copy).  Three
+  commits: slot sizes (`v-TP3-CALC-PARAMSLOTS`), the parameter list on the proc
+  symbol (`v-TP3-CALC-PARAMLIST`), and the copy + `size > 2` for strings
+  (`v-TP3-CALC-STRPARAM`).  Fixture `t67_strparam` proves value semantics: the
+  callee mutates `S[1]`, the caller's string is unchanged.  This moved the rung
+  from 10350 to **10405**.
+- **The ladder now stops at `ERR 41` at `10405`** — `Length(S)`, inside
+  `Center`'s body.  The string builtins (`Length`, `Copy`, `Delete`, `Insert`,
+  `Str`, `Val`) are the next increment, and the first rung that is a standard
+  FUNCTION rather than a type.
 - **One surprise on resuming:** the working tree was found holding the
   nonvacuity script's *frame-rule* mutation of `shell/Compiler.mod` (the
   `locFree := 0FFFEH` / pre-decrement-base revert), i.e. a mutation from a
@@ -466,8 +476,8 @@ Exec86):
   byte-for-byte the same for INTEGER locals, so the compile matrix would have
   been green and only `t40_readln` (and later CALC) would have noticed.
 - **Verified baseline, run on this commit:** `./tests/run_all.sh` →
-  **OVERALL: ALL PASS**, compile matrix **67/67**, CALC ladder **1/1** at
-  `ERR 102 10350`, 58 `.COM` linked and 56 executed under both qemu and Exec86
+  **OVERALL: ALL PASS**, compile matrix **68/68**, CALC ladder **1/1** at
+  `ERR 41 10405`, 59 `.COM` linked and 57 executed under both qemu and Exec86
   (`t64_arrays` is compile-only: its image is past the 8 KB boot-sector read).
 - **`tests/nonvacuity.sh`:** re-run after this commit's cases (the set
   membership bit among them); see the commit message for the count. It is not in
@@ -510,26 +520,17 @@ ordered by what the ladder will hit next, not by the original stage numbers
 shape as this session's nine: one focused change, one fixture that is red before
 and green after, an execution proof where the image fits, and a commit.
 
-1. **A string VALUE parameter** — the current rung:
-   `procedure Center(S : AnyString)` (`CALC.INC:17`).  `ProcFunc` refuses a
-   parameter wider than a word; a `string[n]` parameter is `n+1` bytes.  TP3
-   passes a value parameter's whole size on the stack (`TPSRC7` `tstscal`), so
-   the caller must copy the string into the callee's slot.
-   **Step 1 is DONE** (`v-TP3-CALC-PARAMSLOTS`): parameter *slots* are
-   generalised — `SlotSize (cls, size)` is TP3's "scalar-like ⇒ at least two
-   bytes, larger ⇒ its own size", and the callee's reverse layout is the
-   general `off := 8 + frameTotal − off − slot`, which reduces exactly to the
-   old `parmOff + 2 − off` while every slot is 2.  Nothing observable moved
-   (matrix 67/67, ladder unchanged, framedisp PASS, 56/56 executed).
-   **Next:** (3) push a string's whole slot (`SUB SP,size / MOV DI,SP / MOV
-   SI,src / MOV CX,size / CLD / REP MOVSB`, or a `TU_PushStr` entry) and allow
-   `size > 2` for string parameters; (4) a fixture where the callee **mutates**
-   `S` and the caller's variable is unchanged — the value semantics TP3
-   promises.  `var` parameters proper are item 6.
-   **Step 2 is DONE too:** the procedure symbol carries `parmFirst`/`parmCount`,
-   `ParmSlot (idx, k)` sizes each argument from it, and `EmCallMost` cleans up
-   the sum of slot sizes.  Still behaviour-identical (every slot 2), and a
-   slot > 2 is a loud `ENoLib` until step 3 pushes it.
+1. **String VALUE parameters — DONE** (the rung moved 10350 → 10405; the
+   blocker is now `Length(S)`, a builtin).  TP3 puts a value parameter's whole
+   size on the stack (`TPSRC7` `tstscal`), so a `string[n]` parameter is `n+1`
+   bytes.  The three steps landed as separate commits/tags:
+   `v-TP3-CALC-PARAMSLOTS` (slot sizes + reverse layout),
+   `v-TP3-CALC-PARAMLIST` (the procedure symbol carries `parmFirst`/`parmCount`;
+   call sites size each argument), and `v-TP3-CALC-STRPARAM` (the caller copies
+   `<len><chars>` into the slot; `EmStrBlob` + `EmPushStrSlot`, a DS-relative
+   byte loop, no ES dependency).  Fixture `t67_strparam`: the callee mutates
+   `S[1]` and the caller's string is unchanged, so it is a copy and not an
+   address.  `var` parameters proper are item 6.
 2. **The rest of the declaration part**, if the rung is still there: local
    `type`/`const`/`var` inside a nested function (`CALC.INC:767-778`, the
    enum-indexed array of strings), `Succ`/`Pred`/`Ord`/`Chr`.

+ 8 - 7
SUMMARY.md

@@ -2037,15 +2037,16 @@ census of what `CALC.PAS` actually needs and the reference measurements from
 the original `TURBO.COM`. Two tracks run in parallel: the ladder (front-end,
 verifiable on small fixtures) and the layout (§3, the only thing that stops
 the compiler *emitting* a 16,560-byte program). The ratchet and front-end
-features through **file types** are landed: the ladder
+features through **string value parameters** are landed: the ladder
 (`tests/calc_ladder.sh` + `tests/fixtures/calc_ladder.tsv`, run by `run_all.sh`;
-CALC.PAS now stops at `ERR 102` at pos 10350, a string value parameter INSIDE
+CALC.PAS now stops at `ERR 41` at pos 10405, the `Length` builtin inside
 `CALC.INC`), the `{$I}` include, `^X`/`#nn`, scalar typed constants, sets,
-enums, records (CellRec = 80), subranges, arrays, element/field access, and file
-types (`text`, `file of T`; fixtures through `t66_files`). The next rungs are
-file operations, a variable-index subscript (`Sheet[FX,FY]`) + `with`, CRT,
-reals, the string builtins, pointers, `var` params, recursion, and the layout
-change. Items 1-4 below are independent of both.
+enums, records (CellRec = 80), subranges, arrays, element/field access, file
+types, and string value parameters (`t67_strparam`; fixtures through `t67`).
+The next rungs are the string builtins (`Length`/`Copy`/`Delete`/`Insert`/
+`Str`/`Val`), file operations, variable-index subscripts + `with`, pointers,
+`var` params, reals, CRT, recursion, and the layout change. Items 1-4 below are
+independent of both.
 
 1. The rest of the string language. `v-TP3-STRVAR`/`v-TP3-STRREAD-ASSIGN` cover
    string variables, `s := 'literal'`, `writeln (s)`, `readln (s)`/`read (s)`

+ 124 - 38
shell/Compiler.mod

@@ -974,6 +974,11 @@ BEGIN
    Ebyte (5AH)
 END EmPopDx ;
 
+PROCEDURE EmPopSi () ;
+BEGIN
+   Ebyte (5EH)                                   (* POP SI *)
+END EmPopSi ;
+
 (* 91 = XCHG AX,CX, and NOT 93.  LoadPair hands the operator the left
    operand in CX and the right in AX (in the original, and still the
    commonest, shape: it pushes the left, loads the right, then pops the
@@ -2528,6 +2533,36 @@ BEGIN
    END
 END LoadPair ;
 
+PROCEDURE EmStrBlob (r : ERes) : CARDINAL ;
+(* Emit a string literal's <len><chars> in the code stream behind a JMP and
+   return the image offset of the LENGTH byte.  Shared by EmPushStrAddr (which
+   pushes that address) and EmPushStrSlot (which copies the bytes onto the
+   stack for a string value parameter). *)
+VAR n, len, here : CARDINAL ;
+BEGIN
+   len := strLen [r.strx] ;
+   IF len > 255 THEN
+      (* As in IoCall: the length is one byte, so a longer literal would wrap
+         and silently carry only its first (length MOD 256) characters.
+         TP3 strings are at most 255 characters. *)
+      Err (EConstRange) ;
+      RETURN 0
+   END ;
+   IF len + 1 <= 127 THEN
+      Ebyte (0EBH) ; Ebyte (VAL (BYTE, len + 1))    (* JMP rel8 over it *)
+   ELSE
+      Ebyte (0E9H) ; Eword (len + 1)                (* JMP rel16 over it *)
+   END ;
+   here := pc ;                 (* the length byte: the literal's address *)
+   Ebyte (VAL (BYTE, len)) ;
+   n := 0 ;
+   WHILE n < len DO
+      Ebyte (VAL (BYTE, ORD (strPool [strOff [r.strx] + n]))) ;
+      INC (n)
+   END ;
+   RETURN here
+END EmStrBlob ;
+
 PROCEDURE EmPushStrAddr (r : ERes) ;
 (* Push the ADDRESS of a whole-string operand - the argument shape cmpstr
    takes, one per operand.
@@ -2561,38 +2596,52 @@ PROCEDURE EmPushStrAddr (r : ERes) ;
    (its character code), so `s = 'a'` compares a string with a character and
    is a type error rather than a length-1 string compare.  That is deliberate
    - it is the same rule `c := 'a'` relies on - and the caller rejects it. *)
-VAR n, len, here : CARDINAL ;
 BEGIN
    IF r.kind = 1 THEN
       EmPushVarAddr (symtab [r.idx].local,
                      (symtab [r.idx].off + r.boff) MOD 10000H)
    ELSIF r.kind = 3 THEN
-      len := strLen [r.strx] ;
-      IF len > 255 THEN
-         (* As in IoCall: the length is one byte, so a longer literal would
-            wrap and silently compare only its first (length MOD 256)
-            characters.  TP3 strings are at most 255 characters. *)
-         Err (EConstRange) ;
-         RETURN
-      END ;
-      IF len + 1 <= 127 THEN
-         Ebyte (0EBH) ; Ebyte (VAL (BYTE, len + 1))    (* JMP rel8 over it *)
-      ELSE
-         Ebyte (0E9H) ; Eword (len + 1)                (* JMP rel16 over it *)
-      END ;
-      here := pc ;                 (* the length byte: the literal's address *)
-      Ebyte (VAL (BYTE, len)) ;
-      n := 0 ;
-      WHILE n < len DO
-         Ebyte (VAL (BYTE, ORD (strPool [strOff [r.strx] + n]))) ;
-         INC (n)
-      END ;
-      EmPushVarAddr (FALSE, here)  (* LEA AX,[here]; PUSH AX *)
+      EmPushVarAddr (FALSE, EmStrBlob (r))  (* the literal travels inline *)
    ELSE
       Err (ENoLib)                 (* a computed string is not a value *)
    END
 END EmPushStrAddr ;
 
+PROCEDURE EmPushStrSlot (r : ERes ; reserve, copyN : CARDINAL) ;
+(* Push a STRING VALUE argument's whole slot: reserve `reserve' bytes on the
+   stack and copy `copyN' of the source string's <len><chars> into them, so
+   that after the CALL the callee's BP+4 is the length byte and the characters
+   follow it.
+
+     <push the source address, then POP SI>   ; local/global/literal alike
+     SUB SP,reserve ; MOV DI,SP ; MOV CX,copyN
+     loop: MOV AL,[SI] ; MOV [DI],AL ; INC SI ; INC DI ; LOOP loop
+
+   The address comes from EmPushStrAddr, which already knows how to form it for
+   a local ([BP+off]), a global (absolute) or a literal (inline blob) - so a
+   caller's LOCAL string can be passed too, not just a global.
+
+   A byte loop, not REP MOVSB, on purpose: MOVS/STOS address through ES, and a
+   .COM's ES happens to equal DS but nothing in this compiler establishes that
+   contract.  Every access here is DS-relative, which is the one segment the
+   image already guarantees.  `copyN' is at most `reserve' (a shorter actual
+   string than the formal leaves the tail of the slot untouched, harmless
+   because the length byte bounds every read). *)
+BEGIN
+   EmPushStrAddr (r) ;                   (* push the source address *)
+   EmPopSi () ;                          (* SI := source *)
+   EmSubSp (reserve) ;
+   Ebyte (89H) ; Ebyte (0E7H) ;          (* MOV DI,SP *)
+   EmMovCxi (copyN) ;
+   IF copyN > 0 THEN
+      Ebyte (8AH) ; Ebyte (04H) ;        (* MOV AL,[SI] *)
+      Ebyte (88H) ; Ebyte (05H) ;        (* MOV [DI],AL *)
+      Ebyte (46H) ;                      (* INC SI *)
+      Ebyte (47H) ;                      (* INC DI *)
+      Ebyte (0E2H) ; Ebyte (0F8H)        (* LOOP -8: back to MOV AL,[SI] *)
+   END
+END EmPushStrSlot ;
+
 (* ---------------------------------------------------------------- *)
 (*  sets - type sizing, literals, compile-time membership            *)
 (* ---------------------------------------------------------------- *)
@@ -3252,7 +3301,7 @@ PROCEDURE ParseCallArgs (idx : CARDINAL) ;
    first argument goes on first and so ends up farthest from BP, and the LAST
    declared parameter is the one at BP+4 (RESUME-TP3.md 3.11). *)
 VAR arg : ERes ;
-    nArgs, cleanup, slot : CARDINAL ;
+    nArgs, cleanup, slot, nCopy : CARDINAL ;
 BEGIN
    nArgs := 0 ;
    cleanup := 0 ;
@@ -3267,15 +3316,32 @@ BEGIN
          ParseExpr (arg) ;
          slot := ParmSlot (idx, nArgs) ;
          IF slot > 2 THEN
-            (* A value parameter wider than a word (a string): its whole slot
-               must be pushed, which is step 3 of the calling-convention work.
-               Loud, because pushing two bytes of a 71-byte argument would be
-               a silent miscompile. *)
-            Err (ENoLib) ;
-            EXIT
+            (* A STRING VALUE parameter: push its whole slot, copied.
+               `copyN' is at most the slot: an actual shorter than the formal
+               leaves the tail untouched, which is harmless because the length
+               byte bounds every read. *)
+            IF arg.cls # TString THEN
+               Err (ETypeErr) ;
+               EXIT
+            END ;
+            nCopy := slot ;
+            IF arg.kind = 1 THEN
+               IF VarSize (arg) < nCopy THEN
+                  nCopy := VarSize (arg)
+               END
+            ELSIF arg.kind = 3 THEN
+               IF strLen [arg.strx] + 1 < nCopy THEN
+                  nCopy := strLen [arg.strx] + 1
+               END
+            ELSE
+               Err (ENoLib) ;          (* a computed string is not a value *)
+               EXIT
+            END ;
+            EmPushStrSlot (arg, slot, nCopy)
+         ELSE
+            LoadAtom (arg) ;
+            EmPushAx ()
          END ;
-         LoadAtom (arg) ;
-         EmPushAx () ;
          cleanup := cleanup + slot ;
          INC (nArgs) ;
          IF NOT MatchDelim (',') THEN
@@ -3291,7 +3357,7 @@ PROCEDURE ParseCall (idx : CARDINAL) ;
 (* procedure/function call; '(' optional - same parse-then-push as above,
    reached from ParseAtom when a FUNCTION is called in an expression *)
 VAR arg : ERes ;
-    nArgs, cleanup, slot : CARDINAL ;
+    nArgs, cleanup, slot, nCopy : CARDINAL ;
 BEGIN
    nArgs := 0 ;
    cleanup := 0 ;
@@ -3305,12 +3371,29 @@ BEGIN
             ParseExpr (arg) ;
             slot := ParmSlot (idx, nArgs) ;
             IF slot > 2 THEN
-               (* see ParseCallArgs: a string slot needs its whole width *)
-               Err (ENoLib) ;
-               EXIT
+               (* see ParseCallArgs: push the whole string slot *)
+               IF arg.cls # TString THEN
+                  Err (ETypeErr) ;
+                  EXIT
+               END ;
+               nCopy := slot ;
+               IF arg.kind = 1 THEN
+                  IF VarSize (arg) < nCopy THEN
+                     nCopy := VarSize (arg)
+                  END
+               ELSIF arg.kind = 3 THEN
+                  IF strLen [arg.strx] + 1 < nCopy THEN
+                     nCopy := strLen [arg.strx] + 1
+                  END
+               ELSE
+                  Err (ENoLib) ;
+                  EXIT
+               END ;
+               EmPushStrSlot (arg, slot, nCopy)
+            ELSE
+               LoadAtom (arg) ;
+               EmPushAx ()
             END ;
-            LoadAtom (arg) ;
-            EmPushAx () ;
             cleanup := cleanup + slot ;
             INC (nArgs) ;
             IF NOT MatchDelim (',') THEN
@@ -4732,7 +4815,10 @@ BEGIN
             END ;
             ExpectDelim (':', ENoSemi) ;   (* formal is 'name : type' *)
             ParseType (cls, size, elem) ;
-            IF size > 2 THEN
+            IF (size > 2) AND (cls # TString) THEN
+               (* A string VALUE parameter is a whole-slot copy (n+1 bytes on
+                  the stack); any other type wider than a word - a record, an
+                  array, a set - is still refused. *)
                Err (ENoLib) ;
                RETURN
             END ;

+ 8 - 0
shell/tests/audit_helpers.py

@@ -132,6 +132,14 @@ NON_ONE_LINE = {
         # single sequence to disassemble; the LAYOUT it produces is pinned by
         # t61_setconst's .out instead.
         "SetBlob": COMP,
+        # StrBlob is the string-literal blob on its own (factored out of
+        # PushStrAddr so a string value parameter can copy from it); PushStrSlot
+        # reserves a slot and copies a string value into it.  Both emit data
+        # behind a jump / a data-dependent byte loop, so there is no single
+        # sequence to disassemble.  The layouts they produce are pinned by the
+        # string and string-parameter fixtures instead.
+        "StrBlob": COMP,
+        "PushStrSlot": COMP,
     },
 }
 # The Vx family is NOT here: those four are audited by audit_vx_helpers,

+ 6 - 8
shell/tests/fixtures/calc_ladder.tsv

@@ -25,11 +25,9 @@
 # positions shift — which is exactly why this must be a measured number, not a
 # remembered one.
 #
-# Current rung (2026-10-19): INSIDE CALC.INC — `procedure Center(S : AnyString)',
-# a STRING VALUE parameter.  The rung moved from 9202 (MCFile : file of
-# CellRec) when FILE TYPES landed (`text' and `file of T'), which let the whole
-# `var' block pass and the `{$I CALC.INC}' at 9404 expand, so the compiler is
-# now compiling the include's body.  This is a big step: past the include is
-# where all of CALC's own code lives.  The immediate blocker is a value
-# parameter whose type is bigger than a word (string[70]).
-CALC.PAS	ERR	102	10350
+# Current rung (2026-10-20): `Length(S)' inside CALC.INC's Center - a string
+# BUILTIN, not a declaration.  The rung moved from 10350 (`procedure Center(S :
+# AnyString)') when STRING VALUE PARAMETERS landed: the whole slot is copied
+# onto the stack, length byte at BP+4, and the callee mutates only its copy.
+# This is the first rung that is a standard FUNCTION rather than a type.
+CALC.PAS	ERR	41	10405

+ 9 - 0
shell/tests/fixtures/expected.tsv

@@ -519,3 +519,12 @@ t65_array_access	OK	101	16
 #                   data=12 = two file variables (4 each) + the two @@T
 #                   temporaries.  File OPERATIONS are the next increment.
 t66_files	OK	50	12
+#   t67_strparam           a STRING VALUE parameter - CALC.INC:17.  TP3 puts a
+#                   value parameter's whole size on the stack, so a string[20]
+#                   parameter is 21 bytes and the caller COPIES <len><chars>
+#                   into the callee's slot (length byte at BP+4).  The .out is
+#                   `hello / hello / world / hello': Show mutates S[1], and the
+#                   caller's t still prints `hello', so it is a copy and not an
+#                   address; a literal argument is copied too.  data=25 = t (21)
+#                   + the @@T temporaries; the slot lives on the stack.
+t67_strparam	OK	189	25

+ 4 - 0
shell/tests/fixtures/t67_strparam.out

@@ -0,0 +1,4 @@
+hello
+hello
+world
+hello

+ 35 - 0
shell/tests/fixtures/t67_strparam.pas

@@ -0,0 +1,35 @@
+program t67_strparam;
+
+{ t67 -- a STRING VALUE parameter.  CALC.INC:17 is
+
+      procedure Center(S : AnyString);
+
+  TP3 puts a value parameter's whole SIZE on the stack (TPSRC7 tstscal), so a
+  string[20] parameter is 21 bytes: the caller copies <len><chars> into the
+  callee's slot, with the length byte at the lowest address (BP+4).  That is
+  what makes it a VALUE: the callee has its own copy, and mutating S must not
+  touch the caller's variable.
+
+  The fixture proves both halves:
+    - `Show (t)' then `writeln (t)' prints `hello' again, so S[1] := 'X'
+      changed only the copy;
+    - `Show ('world')' shows a LITERAL argument being copied too.
+  A by-address implementation would print `Xello' after the first call.
+
+  The bytes come from a DS-relative byte loop, not REP MOVSB: MOVS addresses
+  through ES, and nothing here establishes ES = DS. }
+
+var
+   t : string[20];
+procedure Show (S : string[20]);
+begin
+   writeln (S);
+   S[1] := 'X'
+end;
+begin
+   t := 'hello';
+   Show (t);
+   writeln (t);
+   Show ('world');
+   writeln (t)
+end.

+ 27 - 0
shell/tests/nonvacuity.sh

@@ -1643,6 +1643,33 @@ else
 fi
 cp "$SAVED_C" Compiler.mod
 
+# --- a string parameter's slot --------------------------------------------
+# A string VALUE parameter's slot is its size (n+1 bytes).  Collapse SlotSize
+# to 2 for it and BOTH sides agree on 2 - so the compiler still compiles and
+# the matrix stays green - but the callee's "string" is just a length byte and
+# garbage; t67 prints something other than `hello'.
+cp "$SAVED_C" Compiler.mod
+if python3 - <<'PYX'
+p = 'Compiler.mod'
+s = open(p).read()
+old = '   RETURN size\nEND SlotSize ;'
+assert s.count(old) == 1, 'the SlotSize fallthrough found %d times -- update this mutation' % s.count(old)
+open(p, 'w').write(s.replace(old, '   RETURN 2\nEND SlotSize ;'))
+PYX
+then
+    if rebuild_compiler; then
+        expect_red "t67 red when a string parameter's slot collapses to a word" \
+            "t67_strparam" python3 tests/run_com_exec.py t67_strparam
+    else
+        echo "  FAIL: the compiler would not rebuild with SlotSize collapsed"
+        fail=$((fail + 1))
+    fi
+else
+    echo "  BROKEN CASE: the string-slot mutation did not apply"
+    fail=$((fail + 1))
+fi
+cp "$SAVED_C" Compiler.mod
+
 if rebuild_compiler; then
     if python3 tests/run_com_exec.py >/dev/null 2>&1; then
         echo "  ok: every executed fixture passes on the restored sources"