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 DI`es 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 DI`es 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 `CALL`ed (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.