Tag: v-TP3-STRCMP Commit: (the commit that adds this file) Fixes: the commit immediately before it, "tests: re-anchor three nonvacuity
mutations that item 1a invalidated" (see "Non-vacuity" below)
Covers: v-TP3-STRCMP
This is the resume point for the session that did "Next work" item 1b — string
comparison with = <> < <= > >= — in full, and for the test-harness repair
that running tests/nonvacuity.sh at the start of it turned up. Every claim
below is pinned by a fixture in shell/tests/fixtures/expected.tsv and by the
gate suite.
= <> < <= > >= with both operands strings
(v-TP3-STRCMP).ParseCmp had one arm, the numeric one: both operands are loaded into
AX/CX, CMP AX,CX sets the flags, and a six-entry SETcc table turns
them into a 0/1. A string operand never reached that arm — it fell through
to LoadAtom, which refuses a TString with ENoLib (102). The new arm
is put before the constant-fold arm, because a string literal is not a
kind-0 constant and the numeric arm's own dispatch would have swallowed it:
IF (left.cls = TString) OR (right.cls = TString) THEN
IF (left.cls # TString) OR (right.cls # TString) THEN
Err (ETypeErr) ; (* a string compared with a number/char *)
RETURN
END ;
EmPushStrAddr (left) ;
EmPushStrAddr (right) ;
DropC (EmCall (TU_CmpStr)) ;
EmAddSp (4) ;
EmOrAxAx () ;
EmRelSetcc (op) ;
r.kind := 2 ;
r.cls := TBool
ELSIF (left.kind = 0) AND (right.kind = 0) THEN
... constant fold ...
ELSE
LoadPair (left, right) ;
EmCmpAxCx () ;
EmRelSetcc (op) ;
r.kind := 2 ;
r.cls := TBool
END
The tail is not a second implementation: EmRelSetcc is the same six-arm
table the numeric arm uses, factored out of ParseCmp when the string arm
arrived. The two paths therefore cannot drift, which matters because the
table's arms are two hex digits apart (SETG 9FH vs SETGE 9DH) and were
swapped once already — see EmRelSetcc's comment and tests/nonvacuity.sh's
mutate_cc_swap, which mutates exactly those two lines and now breaks the
string comparison at the same time as the numeric one.
cmpstr, selector 18 (Runtime.mod, Runtime.def
E_CmpStr = 18).It takes two string addresses on the stack (caller-pushed, caller-popped)
and returns AX = -1 / 0 / +1 for left < right / = / >. 83 bytes at image
offset 585 (0249h..029Bh):
0249 55 push bp 024F 31 C9 xor cx,cx
024A 8B EC mov bp,sp 0251 31 D2 xor dx,dx
024C 53 push bx 0253 8B 5E 06 mov bx,[bp+6]
024D 51 push cx 0256 8A 0F mov cl,[bx]
024E 52 push dx 0258 8B 46 04 mov ax,[bp+4]
025B 8B F0 mov si,ax
025D 8A 14 mov dl,[si]
025F 39 D1 cmp cx,dx
0261 76 02 jbe 0265h
0263 8B CA mov cx,dx
0265 E3 0E jcxz 0275h
0267 43 inc bx
0268 46 inc si
0269 8A 07 mov al,[bx]
026B 8A 14 mov dl,[si]
026D 3A C2 cmp al,dl
026F 75 14 jne 0285h
0271 43 inc bx
0272 46 inc si
0273 E2 F4 loop 0269h
0275 8B 5E 06 mov bx,[bp+6]
0278 8A 0F mov cl,[bx]
027A 8B 46 04 mov ax,[bp+4]
027D 8B F0 mov si,ax
027F 31 D2 xor dx,dx
0281 8A 14 mov dl,[si]
0283 39 D1 cmp cx,dx
0285 B0 00 mov al,0
0287 B4 00 mov ah,0
0289 72 06 jb 0291h
028B 74 08 je 0295h
028D B0 01 mov al,1
028F EB 04 jmp 0295h
0291 B0 FF mov al,0FFh
0293 B4 FF mov ah,0FFh
0295 5A pop dx
0296 59 pop cx
0297 5B pop bx
0298 8B E5 mov sp,bp
029A 5D pop bp
029B C3 ret
The layout is deliberate and each part is worth naming:
min(lenL,lenR) bytes are compared in order; the first difference
decides (jne cs_flag). The byte compare is CMP AL,DL, i.e.
unsigned — CF is the borrow out of AL−DL — which is what
"lexicographic" means for characters.cs_len re-reads
them; cmp cx,dx then falls into cs_flag). So a proper prefix is
less than the string that continues it: 'ab' < 'abc', and the empty
string is less than everything.CX is free to be LOOP's count. JCXZ skips the
loop when min = 0: without it, LOOP with CX = 0 runs 65536 times.CmpAlDl is 3A C2, not 38 C2. /r puts the register field where the
left operand is, so 3A C2 is CMP AL,DL and 38 C2 would be
CMP DL,AL, i.e. the same bytes with the flags negated.Harness re-baselines, all in this commit, because the runtime entry moves offsets and the fixtures add files:
tests/RtProbe.mod: the entry table widened from [0..17] to [0..18],
plus names[18].tests/check_runtime.py: GOLDEN["cmpstr"] = "55 8B EC 53 51 52" (the
prologue minus AX, which is the result and is not saved).tests/runtime.golden: re-blessed. The diff is worth reading and is
exactly two things: eight absolute data-address fixups, each +0x53,
because the data area starts after the code and the code grew by 83 bytes
(0x53); and the appended cmpstr block at 0249h. No existing
instruction moved.tests/rt_exec.py: ENTS gains cmpstr, plus a read-only smoke case.tests/check_8086.py: BRANCH_SITES["t48_strcmp"] = [4] * 30 and
["t49_strcmp_lit"] = [4] * 13, with BRANCH_WHY text.tests/audit_helpers.py: "PushStrAddr": COMP in Compiler.mod's
NON_ONE_LINE — see "Scope decisions" below.tests/fixtures/expected.tsv: three new rows (below).tests/check_runtime.py's branch scan learned LOOP. RE_BRANCH was
^(?:j\w+|call|jmp)\s+...$, so it saw every Jcc, JMP and CALL — but
not LOOP. LOOP is a relative branch too, and its target has to land
on an instruction boundary like any other. The runtime already contained
four LOOPs when this was found (they are much older than this
session); cmpstr added a fifth, and only then did the gap come into view.
Widening the regex moved the checked-branch count from 57 to 62 — +5, one
per LOOP.
The first cmpstr epilogue saved and restored AX with the other
registers:
PushAx ; PushBx ; PushCx ; PushDx ... PopDx ; PopCx ; PopBx ; PopAx
That is wrong, and wrong in a way no static check can see: AX is the
result, so there is nothing to save, and popping it back restores the
caller's AX — which, for a < b, is the address of the right operand,
still in AX from its LEA. Every ordering comparison then read a positive
number: = and <> were correct (they test zero), while <, <=, >, >=
over any left < right string came back as FALSE TRUE TRUE TRUE FALSE FALSE.
Nothing about the bytes is malformed, the size is unchanged, every branch
target is still on a boundary, and the whole compile matrix stayed green. It
was caught only by executing t48, and confirmed by hand on qemu. The fix
is the epilogue it has now — PopDx ; PopCx ; PopBx ; MovSpBp ; PopBp ; RetR —
and the comment in EmitCmpStr records it so the next reader does not have to
rediscover why AX is the one register this entry does not preserve.
−1/0/+1, and that test is
SIGNED. cmpstr does the character work with unsigned byte compares
(that is TP3's cmpstr / vcompare, TPSRC2 926-962 / TPSRC9 133), but the
compiler only ever sees the signed result and runs the same signed SETcc
table the numeric arm uses. Signed is load-bearing: −1 is 0FFFFh, so
SETB (unsigned below) would be false on −1 and every a < b would come
back FALSE. SETL/SETG/SETGE/SETLE are the right four.'ab' < 'abc', '' < 'ab'. This is the tiebreak, and t48 pins
both directions as separate blocks.1-character literal is a CHAR, not a string. ISO Modula-2 makes 'a'
a character constant, so s = 'a' is TString vs TScalar and is
ETypeErr (56) — t50. The empty literal '' is different: it is the
zero-length string, and it works (t49 uses it). This is the same rule
c := 'a' already relied on.ETypeErr. The
mixed-operand guard is new; before string comparison existed the program
failed later and with the wrong diagnosis. t50 pins the new one.t48_strcmp OK 1905 24 string = <> < <= > >=, both operands
string variables. Output: 41, 14, 50,
14, 50, then the six as VALUES:
FALSE TRUE TRUE TRUE FALSE FALSE
t49_strcmp_lit OK 1006 15 the same operators with a string
LITERAL on one or both sides, including
the empty literal. Output: 61, 7, 15,
then four as VALUES: TRUE FALSE TRUE TRUE
t50_strcmp_type ERR 56 600 `s = 'a'`: a one-character literal is a
CHAR, so this is the mixed-operand error
t48's output is deliberately not a count of the six operators. Each
block weights them 1,2,4,8,16,32 for =,<>,<,<=,>,>=, so the printed sum is a
signature of which ones held (41 = =,<=,>=; 14 = <>,<,<=;
50 = <>,>,>=). A plain +1 cannot do this: for any unequal pair
[a<b]+[a<=b]+[a>b]+[a>=b] is always 2, so [a<b] and [a>b] cancel and the
first version of this fixture reported 3,3,3,3 while every ordering
comparison was inverted. The weights are what made the AX-restore bug
visible as a wrong number instead of a plausible one.
AX = −1/0/+1, not flags. The sketch in v-TP3-STRIDX-VAR.md said "a
new runtime entry taking two addresses and leaving flags set is enough
because EmSetcc reads live flags". That is not enough here, and the
reason is in EmitCmpStr's comment: this caller pushes two addresses and
must drop them with ADD SP,4, which is in the CPU's arithmetic group and
destroys the flags. TP3 can branch on flags straight after its cmpstr
because TP3's cmpstr removes its own arguments (MOV SP,BX, TPSRC2 960),
so there is no ADD SP to run. Returning a value in AX costs a few
instructions inside the entry and keeps the flag convention entirely inside
the compiler, where the one EmRelSetcc table already lives.OR AX,AX (09 C0), not CMP AX,0 (3D 00 00). Same flags (SF/ZF set,
OF/CF cleared), but tests/check_8086.py reads every 3D 00 00 in the
program region as an IF/WHILE/REPEAT test and requires a branch immediately
after it — and writeln (a = b) is a comparison used as a value, not a
test. TEST AX,AX (85 C0) is equivalent but has no mnemonic in the
audit's name grammar, so EmOrAxAx it is. (A TP3 wrapper is literally
MOV AX,#1 / Jcc / DEC AX / OR AX,AX: the same SETcc our numeric arm emits
followed by the same OR AX,AX. This design moves that tail out of six
per-operator runtime entries and into the one shared table. TP3 needs the
six because its cmpstr is reached through vcompare's vector table by
type; we already have the operator as a number.).COM image carries no initialised data (globals are left to
the loader's zero fill), so EmPushStrAddr emits JMP over the bytes —
EB rel8 (rel8 = len+1) or E9 rel16 when len+1 > 127 — then a length
byte, then the characters, and pushes the address of the length byte
(LEA AX,[here]; PUSH AX, absolute into CS, which is where a .COM's DS
already is). This mirrors TPSRC9 fullvar (993), with one extra byte:
TP3's constant is an ARRAY [1..n] OF CHAR and estr2 (TPSRC10 85) emits it
without a length byte because the length travelled on the stack instead.
Every string in this runtime — variable or literal — is <len><chars>, which
is what rdstr writes and what cmpstr reads, so a literal operand has to
look the same. The jump exists only to keep the CPU from walking into the
characters.[BP+6], right at
[BP+4] — the same order strstore takes its two.Em* naming rule bit again. audit_helpers.py strips the
first two characters of every emitter's name to find it in the grammar, so a
Compiler.mod emitter whose name does not begin Em becomes a name the
grammar has never heard of. PushStrAddr would have stripped to
shStrAddr; it is EmPushStrAddr, a proper sibling of EmPushVarAddr. It
also needed "PushStrAddr": COMP in NON_ONE_LINE["Compiler.mod"] (it has a
parameter list, which the by-name audit excludes) — the same rule that bit
LeaDi last session, documented in audit_helpers.py's header.LOOPtests/run_all.sh says to run tests/nonvacuity.sh directly after touching
any of the checks, and it was red when this session ran it: 75 ok, 3 failed.
All three were pre-existing — they fail at the previous tag too — because
v-TP3-STRIDX-VAR (item 1a, s [i]) changed the code the mutations match.
SUMMARY.md at that same tag already claimed non-vacuity: 78 ok, 0 failed.
The number of cases (78) was right; the "ok, 0 failed" was not, and one of the
three failures (EmBpDisp) was of exactly the kind this file exists to catch —
a mutation that had silently stopped applying, so the check it was supposed
to prove non-vacuous was passing without being disturbed at all:
check_framedisp / EmBpDisp. The mutation restored the original
off MOD 100H, always-disp8 bug by matching the old body verbatim. Item
1a gave EmBpDisp a regf parameter (the DI forms need reg 7), so the
anchor no longer matched; the mutation silently applied nothing, the
compiler rebuilt unmutated, and the check passed — reported as
NOT NON-VACUOUS. Both EmBpDisp mutations are re-anchored, and both
keep regf: the mutation is about the disp8/disp16 choice, and breaking
the DI forms at the same time would be two faults wearing one label.SaveLeft. The mutation made the park unreachable by matching the
old kind-2 body. Item 1a added an ELSIF left.kind = 5 branch, so the
anchor missed and the case was reported BROKEN CASE. It now disables
both guards, asserting each is unique — IF left.kind = 2 reads like
it can only be SaveLeft's, but other routines branch on kind too.size > 2 refusal that follows it. Item 1a added
AND (r.cls = TString) to that guard (so the new s[i] := c element arm
is not swallowed by the whole-string one) and gave ParseSub a look-alike
IF symtab [r.idx].cls = TString THEN. The shorter anchor therefore found
ParseSub's, ~800 lines earlier, and the splice deleted everything
between the two — the compiler would not rebuild. The guard is now
spelled out in full.Each repair was checked by hand: the truncation case again reports its named
message (no 8B access at [BP+128], offsets seen include −128), the
left-operand case again makes t34_arith print FAIL, and the
string-assignment case again makes t39 red. The suite is 78 ok, 0 failed.
cd shell && make # tpshell 195576 bytes, rc=0
cd shell && tests/run_all.sh # expect: OVERALL: ALL PASS
cd shell && tests/nonvacuity.sh # expect: 78 ok, 0 failed
At this tag the suite reports:
compile matrix: 51 passed, 0 failed (of 51)
independent .COM check: 45 checked, 0 failed
execution: 45 passed, 0 failed (of 45)
exec86: 45 passed, 0 failed (of 45), cross-checked against qemu
audit_helpers: 112/112 agree (was 110)
runtime entries: 52 passed, 0 failed (of 52)
check_8086: 39 comparison sites, 35 lowered to a Boolean value, 57 to a
branch; clause H 10 of 10; 45 of 45 fixtures swept, 8576 bytes
runtime: 699 bytes, code 0..667 (668), 19 entries, 62 branches checked
(was 616 bytes, 18 entries, 51 branches: +6 = cmpstr's Jcc/JMP,
+5 = the five LOOPs the widened branch scan now sees)
nonvacuity: 78 ok, 0 failed (was 75 ok, 3 failed)
OVERALL: ALL PASS
Each new behaviour was proven red before green: with the old ParseCmp, t48
and t49 both report ERR 102 (ENoLib, "not implemented") at the first
comparison, and t50 fails with the old (wrong) diagnosis rather than
ETypeErr. The AX-restore bug is the other kind of red — every byte and
every size green, and only t48's weighted sums show it.
Item 1 (the rest of the string language):
c. Concatenation + — the last piece of item 1, and the hardest.
TP3's `addstr` (TPSRC9 194) reaches `xconcat` (TPSRC2 963); `addch` /
`addch2` convert a one-character operand with `MOV AH,AL ; MOV AL,#01`
then `PUSH AX` — *that* is how TP3 makes `s + 'a'` legal, where our
compiler currently treats a one-character literal as a `CHAR` (the same
rule `t50` pins). TP3 pushes strings inline on the stack; this model
addresses them, so a result needs a scratch string and a length rule
(TP3 caps at 255). This adds a runtime entry again, so `runtime.golden`,
`check_runtime.py`, `rt_exec.py` (next selector is 19) and the branch
count all move with it.
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.