#!/usr/bin/env python3 """audit_helpers.py -- check that Runtime.mod's one-liner emitter procedures emit the instruction their name claims. Why this exists --------------- Runtime.mod hand-assembles 8086 by writing raw bytes. Most of it is wrapped up as one-line helpers: PROCEDURE MovSiBx ; BEGIN B (89H) ; B (0DCH) END MovSiBx ; and those names are the only documentation of what the bytes mean. That is exactly the setup for a silent, expensive mistake: write the right opcode and the wrong ModRM, and nothing complains. It happened twice here, in the same direction both times: MovSiBx emitted 89 DC -- which is MOV SP,BX, not MOV SI,BX CmpSiBx emitted 39 DC -- ditto, CMP SP,BX The comment next to CmpSiBx even spelled the ModRM out as "11 011 100" and still got it wrong, because 11 011 100 in mod=11 means rm=100=SP; SI is rm=100 only in mod=00, where it means [SI]. Both bugs shipped together into EmitWrInt, which then stored decimal digits through an SI register it had never initialised. The structural checks in check_runtime.py could not see any of this: the bytes decoded cleanly, the sweep stayed in sync, the branch targets were all on boundaries and none of the entry prologues moved. Only a *name* versus a *decode* comparison finds it, because only that knows what the author was trying to say. So: for every helper whose body is a literal byte list, disassemble those bytes with FCML and require the decode to match the name. The name grammar is deliberately narrow and mechanical: e.g. MovSiBx, StDiAx, CmpSpBx, MovDlSi e.g. XorAxAx, NegAx, NotDx, IncSi, DecSi with Op in {Mov, Lea, St, Ld, Cmp, Add, Sub, Xor, And, Or, Not, Inc, Dec, Push, Pop, Int, Jmp, Jne, Jge, Jnl, Jle, Jl, Je, Jae, Jbe, Jb, Ja, JaE...} and the register/operand words below. A helper whose name does not parse is reported as UNPARSED rather than silently skipped -- a name the grammar does not understand is a name we are not checking, and that must be visible. This is a source-level check, so it needs no re-baselining: it is a function of the current source, and it gets stricter as more names are added to the grammar. It also runs without building anything. Usage: audit_helpers.py [MODULE.mod] # default ../Runtime.mod audit_helpers.py -v # print every helper, passing or not """ import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) SHELL = os.path.dirname(HERE) sys.path.insert(0, HERE) import disasm16 # noqa: E402 (path set above) RE_HELPER = re.compile( r"^PROCEDURE\s+(\w+)\s*;\s*BEGIN\s+(.*?)\s+END\s+\1\s*;", re.MULTILINE) # The H suffix is optional: the source mixes B (8AH) and B (0) for the same # kind of literal, and a byte written without H used to be silently dropped # from the audit, which made three helpers look like truncated prefixes. RE_BYTE = re.compile(r"B\s*\(\s*([0-9A-Fa-f]+)H?\s*\)") # --- name grammar ----------------------------------------------------- # # An ordered table of (name regex, mnemonic, operand specs, allowed opcodes). # # Why the opcode column exists # ---------------------------- # "MovDlSi" and "MovBpSp" are ambiguous from the name alone: SI and BP are # both in the register list and in the memory-base list. What settles it is # the opcode, and the rule is the asymmetry that makes 16-bit hand-assembly # so error-prone: # # 88 /r MOV r/m8, r8 reg is the SOURCE (store) # 89 /r MOV r/m16, r16 reg is the SOURCE (store) # 8A /r MOV r8, r/m8 reg is the DESTINATION (load) # 8B /r MOV r16, r/m16 reg is the DESTINATION (load) # # A byte move (8A/88) to or from a bare base register can only be a memory # access, because "mov dl, si" is not an instruction. A word move (8B/89) # could be either, so for word moves the register reading is tried first. # Encoding that rule in the table, rather than in pattern order alone, is what # stops a helper being "verified" against the wrong reading of its own name. # # Operand spec tokens: # # "r:Name" a bare register spelled Name (Ax, Al, Dx, Si, Ds, ...) # "i:N" the immediate N; the name spells it in DECIMAL # "ih:N" like i: but the name spells it in HEX (only Int, whose # vector 21h FCML prints as "21h" and which reads as decimal # 33 if you do not notice) # "m:Base" memory at [Base], with no displacement # "m:Base+D" memory at [Base + D] # # "Arg" is the emitter's alias for BP: on entry BP points at the return # address, so a word argument starts at [BP+2]. Naming it "Arg" rather than # "Bp" is what stops these being misread as [BP] accesses, and it is also # how the grammar knows to expect the +2. # # A name matching NO pattern is reported UNPARSED, which counts as a failure # on purpose: a helper the audit cannot read is a helper nobody is checking. REGS = ["Ax", "Bx", "Cx", "Dx", "Si", "Di", "Bp", "Sp", "Al", "Bl", "Cl", "Dl", "Ah", "Bh", "Ch", "Dh"] # Segment registers: the 8086 can only PUSH/POP them, never MOV to or from # one, so they need names of their own. SEGS = ["Ds", "Es", "Cs", "Ss"] JCCS = ["Je", "Jne", "Jz", "Jnz", "Jge", "Jnl", "Jle", "Jl", "Ja", "Jae", "Jb", "Jbe", "Jg", "Jns", "Js", "Jo", "Jno", "Jp", "Jnp", "Jcxz", "Jecxz", "Jrcxz", "Loop", "Loope", "Loopne"] _ALT = "|".join(REGS) _BASE = "Si|Di|Bx|Bp|Sp|Arg|Data" _STORE = (0x88, 0x89) # reg is the source _LOAD = (0x8A, 0x8B) # reg is the destination PATTERNS = [ # --- no-operand and fixed-operand forms ------------------------- (re.compile(r"^RetR?$"), "ret", [], None), (re.compile(r"^LeaveR?$"), "leave", [], None), (re.compile(r"^Int([0-9A-Fa-f]+)$"), "int", ["ih:%(1)s"], {0xCD}), (re.compile(r"^(Push|Pop)(%s)$" % "|".join(SEGS)), None, ["r:%(2)s"], None), (re.compile(r"^(Push|Pop)(%s)$" % _ALT), None, ["r:%(2)s"], None), # --- jumps: the target is a fixup, so only the mnemonic is checked (re.compile(r"^(%s)(8|16)?$" % "|".join(JCCS)), None, [], None), (re.compile(r"^Jmp(%s)$" % _ALT), "jmp", ["r:%(1)s"], {0xFF}), # --- MUL names one operand; 16-bit DIV names two because it always # divides DX:AX, so the decode has an AX the name has no room for (re.compile(r"^Mul(%s)$" % _ALT), "mul", ["r:%(1)s"], {0xF7}), (re.compile(r"^Div(%s)$" % _ALT), "div", ["r:Ax", "r:%(1)s"], {0xF7}), # --- store: name is St, decode puts the register last -- (re.compile(r"^St(%s)(%s)$" % (_BASE, _ALT)), "mov", ["m:%(1)s", "r:%(2)s"], _STORE), # --- load a register from memory, with a displacement ------------ # The displacement makes the name unambiguous, so any load opcode works. (re.compile(r"^Mov(%s)(%s)(\d+)$" % (_ALT, _BASE)), "mov", ["r:%(1)s", "m:%(2)s+%(3)s"], _LOAD), # --- two registers: MOV BP,SP / CMP SI,BX / XOR AX,AX / ADD DI,AX - # Tried before the bare-base reading below, because for a word move # "MovBpSp" means BP := SP and only "MovDlSi" means DL := [SI]. The two # readings cannot both match, because one demands a register operand # where the other demands a memory operand. (re.compile(r"^(Mov|Cmp|Add|Sub|Xor|And|Or|Xchg)(%s)(%s)$" % (_ALT, _ALT)), None, ["r:%(2)s", "r:%(3)s"], None), # --- byte move from a bare base register: necessarily memory ------ # Restricted to 8A/88 on purpose: if this ever matched an 8B/89 it would # be a register move already claimed by the pattern above. (re.compile(r"^Mov(%s)(%s)$" % (_ALT, _BASE)), "mov", ["r:%(1)s", "m:%(2)s"], (0x8A, 0x88)), # --- immediate against a register -------------------------------- (re.compile(r"^(Add|Sub|Cmp|Xor|And|Or)(%s)(\d+)$" % _ALT), None, ["r:%(2)s", "i:%(3)s"], {0x83}), # --- one register: INC CX / DEC SI / NOT DX / NEG AX ------------ (re.compile(r"^(Inc|Dec|Not|Neg|Shl|Shr|Sar)(%s)$" % _ALT), None, ["r:%(2)s"], None), ] def register_word(tok): """Map an FCML operand token to a REGS/SEGS word, or None.""" t = tok.strip().lower() if not t or t.startswith("word ptr ") or t.startswith("byte ptr "): return None t = t.split()[-1] for r in REGS + SEGS: if t == r.lower(): return r return None def match_operand(spec, tok): """Does one FCML operand token satisfy one token spec? Returns None on success, or a human-readable reason on failure.""" kind, arg = spec.split(":", 1) t = tok.strip() if kind in ("i", "ih"): # FCML prints an immediate as hex with an h suffix ("0h", "2h", "21h") raw = t.lower().rstrip("h") try: v = int(raw, 16) except ValueError: return "%r is not an immediate" % t want = int(arg, 16) if kind == "ih" else int(arg, 10) if v != want: return "immediate is %d, name says %d" % (v, want) return None if kind == "r": got = register_word(t) if got != arg: return "%r is not the register %s" % (t, arg) return None if kind == "m": base, _, disp = arg.partition("+") real = {"Arg": "bp", "Data": "si"}.get(base, base.lower()) if base == "Arg" and not disp: disp = "2" # the first word argument lives at [BP+2] u = t.lower() u = re.sub(r"^(word|byte) ptr ", "", u) if not (u.startswith("[") and u.endswith("]")): return "%r is not a memory reference" % t body = u[1:-1] if not body.startswith(real): return "memory base is %r, name says [%s]" % (body, real) if not disp: if body != real: return ("memory is %r, name says [%s] with no displacement" % (body, real)) return None got = body[len(real):].strip() m = re.fullmatch(r"\+\s*([0-9a-f]+)h?", got) if not m: return "displacement %r is not a number" % got if int(m.group(1), 16) != int(disp): return "displacement is %d, name says %s" \ % (int(m.group(1), 16), disp) return None return "bad spec %r" % spec def operands_of(decode): """Split an FCML decode like "mov si,bx" or "cmp word ptr [bp+4h],0h" into operand tokens. Returns (mnemonic, [tokens]).""" i = decode.find(" ") if i < 0: return decode.strip(), [] return decode[:i].strip(), decode[i + 1:].split(",") def main(argv): verbose = "-v" in argv files = [a for a in argv[1:] if not a.startswith("-")] path = files[0] if files else os.path.join(SHELL, "Runtime.mod") with open(path) as f: src = f.read() n_ok = n_unparsed = n_bad = 0 problems = [] if verbose: print("name bytes FCML decode") for m in RE_HELPER.finditer(src): name, body = m.group(1), m.group(2) by = [int(b, 16) for b in RE_BYTE.findall(body)] if not by: continue raw = bytes(by) # Disassemble; helpers are 1-2 bytes so one instruction is the norm, # but StCx / Shl / Div style helpers can be longer, so decode all. decodes = [] off = 0 while off < len(raw): text, length = disasm16.decode(raw[off:], off) if length == 0: decodes.append(("", "")) break decodes.append(operands_of(text or "?")) off += length hexs = " ".join("%02X" % b for b in raw) dec_text = "; ".join(d[0] + " " + ", ".join(d[1]) for d in decodes) if verbose: print("%-20s %-16s %s" % (name, hexs, dec_text)) # A name can have more than one reading (MovBpSp: BP := SP, or # BP := [SP]). Take the first reading whose operand specs the decode # actually satisfies; if two readings both fit, the name is genuinely # ambiguous and that is itself reported, because it would mean the # audit could be satisfied by the wrong one. reads = [] nopattern = True for rx, pmnem, specs, ops in PATTERNS: mm = rx.match(name) if not mm: continue if ops is not None and by[0] not in ops: continue nopattern = False gmap = {str(i): g for i, g in enumerate(mm.groups(), 1)} mn = pmnem if mn is None: mn = mm.group(1).lower() elif "%(" in mn: mn = mn % gmap if len(decodes) != 1: reads.append((mm, mn, specs, "emits %d instructions, name describes one" % len(decodes))) continue gmnem, got = decodes[0] if gmnem != mn: reads.append((mm, mn, specs, "FCML decodes %r, name says %s" % (gmnem, mn))) continue if len(got) != len(specs): reads.append((mm, mn, specs, "name asserts %d operand(s), FCML decoded %d" % (len(specs), len(got)))) continue why = [w for w in (match_operand(sp % gmap if "%(" in sp else sp, tok) for sp, tok in zip(specs, got)) if w] if why: reads.append((mm, mn, specs, "; ".join(why))) continue reads.append((mm, mn, specs, None)) if nopattern: n_unparsed += 1 problems.append("%s: no name pattern accepts it (emits %s %r) - " "extend PATTERNS or fix the name" % (name, hexs, dec_text)) continue fitting = [r for r in reads if r[3] is None] if len(fitting) > 1: n_bad += 1 problems.append("%s: %d name readings fit the same bytes - the " "name is ambiguous" % (name, len(fitting))) continue if not fitting: n_bad += 1 problems.append("%s: %s" % (name, reads[0][3])) continue n_ok += 1 total = n_ok + n_unparsed + n_bad print("\n%d one-line emitter helpers audited: %d agree with their name, " "%d disagree, %d outside the grammar" % (total, n_ok, n_bad, n_unparsed)) if problems: print("FAIL: %d problem(s)" % len(problems)) for p in problems: print(" - %s" % p) return 1 print("PASS: every audited helper emits what its name says") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))