v-TP3-STRIDX-VAR.md 9.7 KB

v-TP3-STRIDX-VAR — Session summary

Tag: v-TP3-STRIDX-VAR Commit: (the commit that adds this file) Covers: v-TP3-STRIDX-VAR

This is the resume point for the session that did "Next work" item 1a — the variable string subscript s [i] — in full. It is written so the next session can pick up without re-deriving anything: every claim below is pinned by a fixture in shell/tests/fixtures/expected.tsv and by the gate suite.

What changed

  1. Variable string subscripts — s [i] where i is a run-time value (v-TP3-STRIDX-VAR).

v-TP3-STRINDEX accepted a constant index only: it folds into boff (off+i) at compile time, and anything else was ENoLib. A variable index is a run-time address, so it needs a register. TP3 forms it in TPSRC9 indstr / ialoop (1100-1111, 1200-1229); ours does the same, in ParseSub:

   LoadAtom (index) ;              (* AX := i *)
   EmLeaDi (local, off+boff) ;     (* LEA DI,[base] -- the base's byte 0 *)
   EmAddDiAx () ;                  (* DI := &s[i]  (01 C7: add di,ax) *)
   r.kind := 5 ;                   (* the address lives in DI *)
   r.cls  := TChar ;               (* one byte, exactly as a constant index *)

kind = 5 is the new invariant. ERes.kind already meant: 0 const · 1 var · 2 value in AX · 3 string literal · 4 on the stack. 5 slots in as "a string element whose address is in DI", avoiding a new boolean field that could go stale. The five consumers:

  • LoadAtom (kind 5) emits MOV AL,[DI] (8A 05) and zeroes AH, i.e. the same single byte the A0 off form loads, then drops to kind 2.
  • SaveLeft materialises and pushes a kind-5 left operand the moment the operator is recognised, before the right-hand side is parsed. DI cannot survive that parse: the right operand may contain a subscript of its own (which reuses DI) or a call (which may clobber it). Turning kind 5 into kind 2 → push → kind 4 makes a kind-5 left operand converge on the path a kind-2 one already takes.
  • Assignment needs the destination address after the right-hand side, so it PUSH DIes it first, restores it with POP DI, and stores MOV [DI],AL (88 05). g[i] := h[j] therefore stores where it read. This is exactly TPSRC8 asnscal2/asnscal3 (758-760, 786-789), which set flgpshdi around the expression for the same reason.
  • IoCall needs only the address, so for a kind-5 argument it PUSH DIes it (57) instead of LEA AX,[off]; readln (s[i]) still classifies as TChar and picks rdchar, reading one byte.

No new runtime entry: the arithmetic is emitted inline, so the runtime image is byte-for-byte unchanged (616 bytes, 18 entries, 51 branches).

There is no type check and no range check on the index, matching the constant-index path (which also does neither). {$R+} / {$I+} are a separate milestone.

Five new emitters

Four one-line emitters and one parameterised:

AddDiAx   01 C7      add di, ax
LdAlDi    8A 05      mov al, byte ptr [di]
StDiAl    88 05      mov byte ptr [di], al
PushDi    57         push di
PopDi     5F         pop di
LeaDi     8D 7E d8   lea di, [bp + disp8]
          8D BE d16  lea di, [bp + disp16]
          8D 3E off  lea di, [off]

EmLeaDi reuses the address forms EmPushVarAddr already had, minus the push. Every opcode was checked with objdump -b binary -m i8086 and the project's own tests/disasm16.py before it was emitted. The four one-liners are audited by name by audit_helpers.py; LeaDi has a parameter list, so it is the one entry that must be listed in NON_ONE_LINE (as COMP) rather than name-audited.

DI is a safe place to keep the address: a .COM has DS=SS=CS=ES and neither the compiler nor the runtime ever loads a segment register. The runtime routines that use DI are all CALLed (and some already save it — rdstr does PushDi/PopDi), and Assignment saves it across the only other thing that runs between the two uses.

One refactor, no behaviour change

EmBpDisp gained a regf parameter (the register field of the ModR/M byte), so LeaDi can reuse it. All five of its old call sites pass regf = 0, which emits the identical bytes, so check_framedisp.py (which examines t27/t28) is unaffected. This is the only edit to a previously-working path.

Gotcha found: .in fixtures must be LF-terminated

Not a compiler bug, but a fixture-authoring trap worth writing down. rdln (EmitRdLn) exits the moment it reads a CR, an LF or a ^Z, consuming that one byte and nothing more. An .in file written with CRLF terminators therefore leaves the LF pending, and the next character read picks up a stray 0x0A. That is what made t47's second readln (g[i]) store a newline: the first readln ate only the CR.

The convention is therefore: .in is LF-terminated (the runtime's own comment says "LF because our input files are LF-terminated"), .out is CRLF, because the guest emits CRLF. t45_strindex_read.in already followed this; t47 was written CRLF first and was wrong. The compiler was right both times.

Fixtures added

t46_strindex_var        OK   578  31   s[i] with a variable index, both
                                        directions; global + local, a computed
                                        index (i+1), a subscript as an
                                        argument, and both-indexed g[i]:=h[j]
t47_strindex_var_read   OK   188  15   readln (g[i]) with the index in a
                                        variable  (input: 'X' then 'Y')

Both were red before the change — ERR 102 (ENoLib), no expected.tsv row — and green after. Data sizes: t46 = 31 is g + h (string[10], 11 each) + i + j (4) + c (1) + two @@T temporaries (4); t47 = 15 is the global string[8] (9) + i (2) + two @@T (4).

Scope decisions and findings

  • DI, not BX. The sketch in v-TP3-DECL-WINDOW-STRIDX.md item 1a proposed LEA BX and parking the index ERes on r until LoadAtom / Assignment runs, which would have needed ERes to carry a nested index. Storing the address in DI instead is simpler and matches TP3's own shape more closely: there is no index to park, because ParseSub spends it on the spot, and the only thing that must outlive the parse is one register, which PUSH DI/POP DI handle. kind = 5 is the whole change to ERes — no new field.
  • Materialise the left operand early. SaveLeft is the one place the address must be spent before the right side is parsed, for the reason in the list above. A subscript on the right is fine: it sets DI after SaveLeft and LoadPair reads it immediately, with no parse in between.
  • The audit's own rule. An emitter with a parameter list is excluded from the by-name audit, so it must be listed in NON_ONE_LINE[mod] or the coverage check fails. LeaDi is COMP; the other five are audited by name. This is the second time the rule has bitten, and it is documented in audit_helpers.py's header.

Next work

Item 1 (the rest of the string language), in the order that keeps each step self-contained:

b. String comparison = <> < <= > >= (cmpstr, TPSRC2:890-924, and

 `vcompare`, TPSRC9:133).  A new runtime entry taking two addresses and
 leaving flags set is enough because `EmSetcc` reads live flags; the open
 question is the SIGNEDNESS and the prefix rule (is "ab" < "abc"?) — read it
 off `cmpstr` before trusting the `SETL`/`SETG` opcodes.  This adds a
 runtime entry, so `runtime.golden`, `check_runtime.py`, `rt_exec.py` (next
 selector is 18) and the branch count all move with it.

c. Concatenation + (xconcat, TPSRC2; addstr -> xconcat,

 TPSRC9:194).  Hardest: TP3 pushes strings inline on the stack, this model
 addresses them, so a result needs a scratch/result string and a length rule
 (TP3 caps at 255).

Item 2 (the large one) is untouched: nested procedures / recursion, var parameters (the SEG:OFF push, RESUME-TP3.md 3.11), range/index checks (TU_RANGE_CHECK, TU_INDEX_CHECK), typed constants (3.14), array at its point of use (t14), case with subrange labels.

Also still open from before this session:

d. Harden the program-header parameter loop against non-advancing input

 (`program p(1;)`) with a `BOOLEAN` flag — NOT `EXIT`, which ICEs gm2.

e. FreeDOS (freedos.qcow2, FD14-LiveCD) is still untried; it is the only

 third opinion on the `INT 21h` shim.

How to verify this session's work

cd shell && make clean && make                 # tpshell 191272 bytes, rc=0
cd shell && tests/run_all.sh                   # expect: OVERALL: ALL PASS

At this tag the suite reports:

compile matrix: 48 passed, 0 failed (of 48)
independent .COM check: 43 checked, 0 failed
execution: 43 passed, 0 failed (of 43)
exec86:    43 passed, 0 failed (of 43), cross-checked against qemu
audit_helpers: 110/110 agree (30 in Compiler.mod, was 25)
runtime entries: 51 passed, 0 failed (of 51)
check_8086: 43 of 43 fixtures swept end to end, 5697 bytes
runtime: 616 bytes, 18 entries, 51 branches   (unchanged)
OVERALL: ALL PASS

Each new behaviour was proven red before green: t46 and t47 both report ERR 102 (ENoLib, "not implemented") with the old ParseSub, and a stray 0x0A — not a compiler fault — is the only thing a CRLF .in puts wrong.