Processor guides / Introduced 1979

Intel 8088

Guide and executable specificationView source ↗
Explore the model’s stored state
Intel 8088 stored-state schema: registers, arrays, flags, and control fields grouped by type.
Generated by Sauvignon from this processor’s compiled state declarations. Labels are stored field names; register aliases and physical wiring are not shown. Select the diagram to view it at full size.

This executable guide defines Dromaios's original Intel 8088: every documented instruction, stored state, register views, reset, execution, external interrupt acceptance, and public interface. The generated emulator and its integration metadata come from the cpu blocks; the surrounding prose explains the chip, its host contracts, evidence, and instruction-level limitations.

CPU tests · Public type checks · Coverage · Hardware and reference comparisons

Historical background

Intel introduced the 8088 in 1979, following the 8086 in 1978. It retains the 8086's sixteen-bit programmer's architecture while using an eight-bit external data bus. IBM chose the 8088 for the IBM PC, introduced in August 1981. Intel's processor chronology and account of the IBM PC provide the historical context; its family manual describes the architecture and instruction encodings used here.

That eight-bit bus does not make AX an eight-bit register. A word calculation still operates on sixteen bits; transferring a word to memory takes two byte transfers. The physical processor separates instruction execution from bus activity and uses a four-byte instruction queue. Dromaios models ordered instruction effects, with optional nominal instruction clocks for a declared subset and no prefetch queue. Its recorded accesses describe the emulator's observations, not a cycle trace.

The programmer's machine

Registers Role
AX, BX, CX, DX Word registers with independently addressable low/high bytes
SP, BP, SI, DI Word registers used for stacks, addressing, and string operations
CS, DS, SS, ES Segment registers supplying the base of a 64 KiB address window
IP Offset of the next instruction within CS
FLAGS Arithmetic results and the TF/IF/DF control flags

AL and AH are views of AX's low and high bytes. If AX is $1234, AL is $34 and AH is $12. Writing $56 to AL gives AX=$1256; there is no separate AL value to synchronize. The same relationship holds for BX, CX, and DX. SP, BP, SI, and DI do not have separately addressable byte halves on this chip.

A memory address combines a segment and offset: shift the segment left four bits, add the sixteen-bit offset, and keep the low twenty bits. Thus 1234:0010 and 1235:0000 both select physical $12350. At the top of the address space, FFFF:0010 wraps to zero. IP wraps within its segment; advancing it does not carry into CS. The snapshot's pc is a derived physical CS:IP view; the execution contract below selects segmented instruction fetching.

Immediate words and memory words are little-endian: MOV AX,1234H is encoded $B8 $34 $12. Here $ introduces hexadecimal, while binary opcode patterns run from most to least significant bit. Register selectors follow the hardware encoding order, which differs from the stored-state listing.

Stored state

The schema owns the public fields and validation rules used by construction, snapshots, and .machine parsing. Registers and flags use uppercase names in the formal language and lowercase fields in TypeScript. FLAGS is a group of Boolean fields, not a second stored integer.

STOPPED and WAITING distinguish HLT from a WAIT instruction held by TEST. INTRDEFERRED delays maskable recognition; RECOGNITIONDEFERRED inhibits all recognition at a boundary; TRAPPENDING records an owed single-step trap. These latches represent model bookkeeping, not extra architectural registers. The caller supplies initial values; construction is separate from reset.

cpu "8088" boundary segmented
state {
  register AX: 16
  register BX: 16
  register CX: 16
  register DX: 16
  register SP: 16
  register BP: 16
  register SI: 16
  register DI: 16
  register CS: 16
  register DS: 16
  register SS: 16
  register ES: 16
  register IP: 16
  latch STOPPED = halted
  latch WAITING = waiting
  latch INTRDEFERRED = interruptDeferred
  latch RECOGNITIONDEFERRED = recognitionDeferred
  latch TRAPPENDING = trapPending
  flag CF
  flag PF
  flag AF
  flag ZF
  flag SF
  flag TF
  flag IF
  flag DF
  flag OF
}

Byte views and writes

Each read captures its stored word once. A write captures the live word again at writeback, preserving its other half even if a fetch or flag update has changed that word since the earlier operand read. Actions own their local captures, so several byte writes can occur in one instruction without sharing temporary values. Byte-operand definitions use these same views and actions.

view AL "low byte of AX": 8 {
  word = register AX
  return lowByte(word)
}
action setAL "write AL, preserving its other half" (byte: 8) {
  preservedWord = register AX
  AX <- withBits(preservedWord, 7, 0, byte)
}
view CL "low byte of CX": 8 {
  word = register CX
  return lowByte(word)
}
action setCL "write CL, preserving its other half" (byte: 8) {
  preservedWord = register CX
  CX <- withBits(preservedWord, 7, 0, byte)
}
view DL "low byte of DX": 8 {
  word = register DX
  return lowByte(word)
}
action setDL "write DL, preserving its other half" (byte: 8) {
  preservedWord = register DX
  DX <- withBits(preservedWord, 7, 0, byte)
}
view BL "low byte of BX": 8 {
  word = register BX
  return lowByte(word)
}
action setBL "write BL, preserving its other half" (byte: 8) {
  preservedWord = register BX
  BX <- withBits(preservedWord, 7, 0, byte)
}
view AH "high byte of AX": 8 {
  word = register AX
  return highByte(word)
}
action setAH "write AH, preserving its other half" (byte: 8) {
  preservedWord = register AX
  AX <- withBits(preservedWord, 15, 8, byte)
}
view CH "high byte of CX": 8 {
  word = register CX
  return highByte(word)
}
action setCH "write CH, preserving its other half" (byte: 8) {
  preservedWord = register CX
  CX <- withBits(preservedWord, 15, 8, byte)
}
view DH "high byte of DX": 8 {
  word = register DX
  return highByte(word)
}
action setDH "write DH, preserving its other half" (byte: 8) {
  preservedWord = register DX
  DX <- withBits(preservedWord, 15, 8, byte)
}
view BH "high byte of BX": 8 {
  word = register BX
  return highByte(word)
}
action setBH "write BH, preserving its other half" (byte: 8) {
  preservedWord = register BX
  BX <- withBits(preservedWord, 15, 8, byte)
}
view PC "physical CS:IP": 32 {
  segment = register CS
  offset = register IP
  return and(add(shiftBits(extend(segment, 32), left, 4), extend(offset, 32)), u32($FFFFF))
}

The language uses a thirty-two-bit result for PC and explicitly masks it to the twenty-bit bus. Stored CS and IP remain sixteen-bit values.

FLAGS as a packed value

CF records carry or borrow, PF even parity of the low result byte, AF the bit-3 carry or borrow, ZF zero, SF the result sign bit, and OF signed overflow. TF requests single-stepping, IF permits maskable interrupts, and DF selects the string-operation direction. An arithmetic instruction changes only the flags specified by its policy; loading a register does not recompute flags.

The low byte is SF ZF 0 AF 0 PF 1 CF. The full original-chip FLAGS word adds TF/IF/DF/OF in bits 8–11 and sets bits 12–15. Packing reads CF/PF/AF/ZF/SF, then TF/IF/DF/OF for the word. Reserved bits are ignored on restoration. SAHF updates five fields of the existing flags object. Full restoration replaces all nine fields, and is shared by POPF and IRET. Their interrupt-deferral and stack rules remain with those instructions.

view LOWFLAGS "packed low FLAGS": 8 {
  cf = flag CF
  pf = flag PF
  af = flag AF
  zf = flag ZF
  sf = flag SF
  return pack<8>(sf, zf, 0, af, 0, pf, 1, cf)
}
policy RESTORELOWFLAGS "restore LOWFLAGS" (status: 8) {
  CF = bit(status, 0)
  PF = bit(status, 2)
  AF = bit(status, 4)
  ZF = bit(status, 6)
  SF = bit(status, 7)
}
view FLAGS "packed FLAGS word": 16 {
  cf = flag CF
  pf = flag PF
  af = flag AF
  zf = flag ZF
  sf = flag SF
  tf = flag TF
  if = flag IF
  df = flag DF
  of = flag OF
  return pack<16>(1, 1, 1, 1, of, df, if, tf, sf, zf, 0, af, 0, pf, 1, cf)
}
policy RESTOREFLAGS "restore FLAGS" (status: 16) {
  CF = bit(status, 0)
  PF = bit(status, 2)
  AF = bit(status, 4)
  ZF = bit(status, 6)
  SF = bit(status, 7)
  TF = bit(status, 8)
  IF = bit(status, 9)
  DF = bit(status, 10)
  OF = bit(status, 11)
}
action restoreFlags "replace all FLAGS" (status: 16) {
  replace RESTOREFLAGS(status)
}

1001 111d selects SAHF (d=0) or LAHF (d=1). SAHF captures AH before updating CF/PF/AF/ZF/SF in layout order; OF/TF/IF/DF and all registers survive. LAHF packs the low flags before writing AH, preserving live AL at writeback and leaving all flags unchanged.

family SAHF "1001 1110" {
  clocks u16(4)
  status = source AH
  apply RESTORELOWFLAGS(status)
}
family LAHF "1001 1111" {
  clocks u16(4)
  status = source LOWFLAGS
  perform setAH(status)
}

Register selectors and immediate operands

The rrr field selects byte or word operands according to the instruction's width bit. The common order is explicit in these catalogues; SP is not AH, even though both occupy selector 100 in their respective widths.

operands bytes {
  000 "AL" = view AL write setAL
  001 "CL" = view CL write setCL
  010 "DL" = view DL write setDL
  011 "BL" = view BL write setBL
  100 "AH" = view AH write setAH
  101 "CH" = view CH write setCH
  110 "DH" = view DH write setDH
  111 "BH" = view BH write setBH
}
operands words {
  000 "AX" = register AX
  001 "CX" = register CX
  010 "DX" = register DX
  011 "BX" = register BX
  100 "SP" = register SP
  101 "BP" = register BP
  110 "SI" = register SI
  111 "DI" = register DI
}
source immediateByte "immediate byte": 8 {
  byte = fetch
  return byte
}
source immediateWord "immediate word, low byte first": 16 {
  low = fetch
  high = fetch
  return concat(high, low)
}

Instruction clocks

Timed execution uses Intel’s nominal instruction times (table 2-21), including four extra clocks for each word data transfer on the 8088’s byte-wide bus. Instruction bytes are assumed available; there is no prefetch queue, READY wait, DMA contention, or bus-cycle schedule. This is a deterministic instruction-clock model, not cycle-accurate hardware. A machine advances its devices after each completed CPU boundary; port accesses occur before that advancement.

A family declares initial clocks before operand effects. Later clocks + statements add the cost of an executed path without moving its state reads. Timing may depend on the encoding and branch decision. clocks ... when condition covers only those forms: timed execution rejects other forms before any data access or architectural write. Instruction fetches remain read-committed and IP is restored to the start. Families without a timing declaration remain available through ordinary step().

Effective-address calculation adds 6 clocks for a direct offset, 5 for a single base/index register, 7 for BX+SI or BP+DI, and 8 for BX+DI or BP+SI. A displacement adds 4 to a register-based address. Register operands have no EA cost. Segment, LOCK, and repeat prefixes add 2 clocks each in the execution contract, independently of these address calculations. WAIT, ESC, IMUL, IDIV, and other families without a declaration remain outside this timed subset. Shifts and rotates cost 2 clocks for a register by one, or 8 + 4×CL by CL; memory forms cost 15 + EA or 20 + EA + 4×CL, plus eight clocks for a word read/write. Counts use the original 8088's full CL byte, including zero and values greater than 31.

An indirect near CALL costs 20 clocks through a register or 29 + EA through memory. Near JMP costs 11 or 22 + EA respectively. A far pointer requires two word reads. Far JMP costs 32 + EA, and far CALL costs 53 + EA including its two stack writes. LEA takes 2 + EA to calculate the offset. LDS/LES take 24 + EA because they also read the pointer. Indirect PUSH costs 15 clocks from a register or 24 + EA from memory, including the stack write and, for memory, the source word read. POP r/m16 costs 12 clocks to a register or 25 + EA to memory, including the stack read and destination word write. NOT/NEG cost 3 clocks for a register, 16 + EA for a memory byte, or 24 + EA for a memory word. XLAT costs 11 clocks before any segment prefix.

Unsigned MUL uses the upper end of Intel's published range: 77 clocks for a byte register, 83 + EA for a memory byte, 133 for a word register, and 143 + EA for a memory word (including its four extra transfer clocks). The real microcoded multiply varies with its operands; this model deliberately charges the same bound for every value. This is a deterministic timing approximation, not an emulation of the internal multiply loop or a bound on total hardware time with prefetch, waits, and contention. It also leaves memory reads in their ordinary execution order, after timing availability has been checked.

Unsigned DIV uses the same range-ceiling policy: 90 clocks for a byte register, 96 + EA for a memory byte, 162 for a word register, and 172 + EA for a memory word (including its word transfer). These are nominal division costs, not a microcode simulation. The same cost is charged when division delivers a type-0 exception; separate exception-entry timing for that path is not modeled.

String timing follows this model's restartable element boundary. An ordinary string instruction uses Intel's standalone cost. Each repeated step is a fresh one-element REP entry: nine setup clocks plus one element's cost (and word transfer costs). CX=0 charges nine clocks with no operand transfers. These body costs exclude the separately tabulated two clocks per prefix, so an empty single-prefix REP step costs eleven. MOVS/CMPS/STOS/LODS/SCAS cost 17/22/10/13/15 clocks per repeated byte element, with four additional clocks per word transfer.

On hardware, an uninterrupted REP pays its nine-clock setup once for the whole string. This emulator refetches the instruction on every step, deliberately allowing changed code and interrupt offers between elements, and charges setup again. Long uninterrupted repeats therefore take more modeled time. Continuous repeat microcode, prefetch, and exact REP throughput remain unmodeled; do not use these records as measurements of the chip's bulk-copy speed. The explicit re-entry cost requires no hidden or unsaved timing cache.

source effectiveAddressClocks "8088 effective-address calculation" (postbyte: 8): 16 {
  mode = and(postbyte, u8($C0))
  code = and(postbyte, u8(7))
  paired: flag = lessThan(code, u8(4), unsigned)
  slowerPair: flag = or(equal(code, u8(1)), equal(code, u8(2)))
  base = select(paired, select(slowerPair, u16(8), u16(7)), u16(5))
  displaced = add(base, select(zero(mode), u16(0), u16(4)))
  direct: flag = and(zero(mode), equal(code, u8(6)))
  return select(direct, u16(6), displaced)
}
source rmClocks "register cost or memory cost plus EA" (postbyte: 8, registerCost: 16, memoryCost: 16): 16 {
  ea = source effectiveAddressClocks(postbyte)
  return select(equal(and(postbyte, u8($C0)), u8($C0)), registerCost, add(memoryCost, ea))
}

Immediate register loads

1011 w rrr selects MOV: w=0 takes one immediate byte, w=1 a low-first word. Fetch the complete immediate before writing the destination. Byte writes retain the current other half of the stored word. Preserve every flag; a failed fetch leaves the destination untouched, while completed fetches and IP advances remain.

family MOVbyte "1011 0 rrr" for r in bytes named "MOV {r},n" {
  clocks u16(4)
  result = source immediateByte
  operand r <- result
}
family MOVword "1011 1 rrr" for r in words named "MOV {r},n" {
  clocks u16(4)
  result = source immediateWord
  operand r <- result
}

Arithmetic and logic

ADD/ADC wrap a sum to the operand width; SUB/SBB/CMP wrap a difference. ADC adds incoming CF and SBB subtracts it as a borrow. ADD, SUB, and CMP ignore incoming CF. CMP sets subtraction flags without changing either operand. Arithmetic sets CF for unsigned carry/borrow, AF for low-nibble carry/borrow, OF for signed overflow, SF for the result's sign, and ZF for zero. PF uses only the low result byte, even for word operations. INC/DEC set the same flags as adding/subtracting one, but preserve CF. Every operation preserves TF/IF/DF.

OR/AND/XOR store their logical result. TEST sets flags from AND without writing the result. Logic clears CF/OF and sets SF/ZF/PF from the result. Intel leaves AF undefined; the model clears AF deterministically, matching the pinned hardware fixtures rather than promising portable software behavior for that bit. Byte operations preserve the other half of their stored word register.

NOT flips every bit within the operand width and preserves all flags. NEG computes zero minus the operand: CF is set for every nonzero operand, OF only for the most negative value (80 or 8000), and the other arithmetic flags follow subtraction. INC/DEC memory forms share the register rules, including preserving CF. Immediate TEST reads its operand without writing it; NOT, NEG, INC, and DEC read then write, even when the value is unchanged.

Arithmetic and logical flags

Addition and subtraction update CF, AF, OF, ZF, SF, and PF in that order. CF describes unsigned overflow or borrow; OF describes signed overflow. For example, byte $7F + 1 produces $80 with OF set and CF clear, while $FF + 1 produces zero with CF set and OF clear. Both cross a nibble boundary. Word parity still considers only the low byte, unlike word zero and sign.

Logical operations clear OF, CF, and AF in that order, then set ZF/SF/PF. Hardware leaves AF undefined for these operations; clearing it is this model's deterministic choice. Every policy preserves TF/IF/DF. Right-hand expressions use captured operands and are evaluated before the first flag write. These policies serve the specification’s arithmetic and logical families, each retaining its own operand decoding and effect order. A width parameter shares each policy between byte and word operations. Parity always uses the low byte.

policy ADDFLAGS<bits: 8, 16> "8088 ADD flags" (left: bits, right: bits, result: bits, incoming: flag) {
  CF = carry(left, right, incoming)
  AF = halfCarry(left, right, incoming)
  OF = addOverflow(left, right, incoming)
  ZF = zero(result)
  SF = negative(result)
  PF = evenParity(lowByte(result))
}
policy SUBFLAGS<bits: 8, 16> "8088 SUB flags" (left: bits, right: bits, result: bits, incoming: flag) {
  CF = borrow(left, right, incoming)
  AF = halfBorrow(left, right, incoming)
  OF = overflow(left, right, incoming)
  ZF = zero(result)
  SF = negative(result)
  PF = evenParity(lowByte(result))
}
policy LOGIC<bits: 8, 16> "8088 logical flags" (result: bits) {
  OF = 0
  CF = 0
  AF = 0
  ZF = zero(result)
  SF = negative(result)
  PF = evenParity(lowByte(result))
}
policy CARRY "restore captured carry" (incoming: flag) {
  CF = incoming
}

Accumulator arithmetic and tests

The overall pattern is 00 ooo 10 w: ooo selects the operation below, while w=0/1 selects AL/AX and an immediate byte/word. TEST instead uses 1010 100 w, applying AND's flags without replacing the accumulator.

ooo Operation Incoming CF Write result
000 ADD Ignored Yes
001 OR Ignored Yes
010 ADC Added Yes
011 SBB Subtracted Yes
100 AND Ignored Yes
101 SUB Ignored Yes
110 XOR Ignored Yes
111 CMP Ignored No

Fetch the complete immediate low byte first, then read AL/AX. Only ADC and SBB read incoming CF, after both operands. Apply flags before accumulator writeback; byte writes preserve the live upper half of AX. CMP and TEST never write or reread AX. Failed operand fetches prevent all body effects; completed fetches and IP changes remain.

ADD

Do not read incoming flags. Set CF/AF/OF, then ZF/SF/PF from the wrapped result. Write AL/AX after flags and preserve TF/IF/DF.

family ADD8 "00 000 10 0" named "ADD AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = add(left, right)
  apply ADDFLAGS<8>(left, right, result, 0)
  perform setAL(result)
}
family ADD16 "00 000 10 1" named "ADD AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = add(left, right)
  apply ADDFLAGS<16>(left, right, result, 0)
  AX <- result
}

OR

Do not read incoming flags. Clear OF/CF/AF, then set ZF/SF/PF. Write AL/AX after flags and preserve TF/IF/DF.

family OR8 "00 001 10 0" named "OR AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = or(left, right)
  apply LOGIC<8>(result)
  perform setAL(result)
}
family OR16 "00 001 10 1" named "OR AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = or(left, right)
  apply LOGIC<16>(result)
  AX <- result
}

ADC

Capture incoming CF after both operands. Set CF/AF/OF, then ZF/SF/PF from the wrapped result. Write AL/AX after flags and preserve TF/IF/DF.

family ADC8 "00 010 10 0" named "ADC AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  incoming = flag CF
  result = add(left, right, incoming)
  apply ADDFLAGS<8>(left, right, result, incoming)
  perform setAL(result)
}
family ADC16 "00 010 10 1" named "ADC AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  incoming = flag CF
  result = add(left, right, incoming)
  apply ADDFLAGS<16>(left, right, result, incoming)
  AX <- result
}

SBB

Capture incoming CF after both operands. Set CF/AF/OF, then ZF/SF/PF from the wrapped result. Write AL/AX after flags and preserve TF/IF/DF.

family SBB8 "00 011 10 0" named "SBB AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  incoming = flag CF
  result = subtract(left, right, incoming)
  apply SUBFLAGS<8>(left, right, result, incoming)
  perform setAL(result)
}
family SBB16 "00 011 10 1" named "SBB AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  incoming = flag CF
  result = subtract(left, right, incoming)
  apply SUBFLAGS<16>(left, right, result, incoming)
  AX <- result
}

AND

Do not read incoming flags. Clear OF/CF/AF, then set ZF/SF/PF. Write AL/AX after flags and preserve TF/IF/DF.

family AND8 "00 100 10 0" named "AND AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = and(left, right)
  apply LOGIC<8>(result)
  perform setAL(result)
}
family AND16 "00 100 10 1" named "AND AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = and(left, right)
  apply LOGIC<16>(result)
  AX <- result
}

SUB

Do not read incoming flags. Set CF/AF/OF, then ZF/SF/PF from the wrapped result. Write AL/AX after flags and preserve TF/IF/DF.

family SUB8 "00 101 10 0" named "SUB AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = subtract(left, right)
  apply SUBFLAGS<8>(left, right, result, 0)
  perform setAL(result)
}
family SUB16 "00 101 10 1" named "SUB AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
  AX <- result
}

XOR

Do not read incoming flags. Clear OF/CF/AF, then set ZF/SF/PF. Write AL/AX after flags and preserve TF/IF/DF.

family XOR8 "00 110 10 0" named "XOR AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = xor(left, right)
  apply LOGIC<8>(result)
  perform setAL(result)
}
family XOR16 "00 110 10 1" named "XOR AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = xor(left, right)
  apply LOGIC<16>(result)
  AX <- result
}

CMP

Do not read incoming flags. Set CF/AF/OF, then ZF/SF/PF from the wrapped result. Preserve AL/AX and TF/IF/DF.

family CMP8 "00 111 10 0" named "CMP AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = subtract(left, right)
  apply SUBFLAGS<8>(left, right, result, 0)
}
family CMP16 "00 111 10 1" named "CMP AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
}

TEST

Do not read incoming flags. Clear OF/CF/AF, then set ZF/SF/PF. Preserve AL/AX and TF/IF/DF.

family TEST8 "1010 100 0" named "TEST AL,n" {
  clocks u16(4)
  right = source immediateByte
  left = source AL
  result = and(left, right)
  apply LOGIC<8>(result)
}
family TEST16 "1010 100 1" named "TEST AX,n" {
  clocks u16(4)
  right = source immediateWord
  left = register AX
  result = and(left, right)
  apply LOGIC<16>(result)
}

Word increment and decrement

0100 d rrr selects INC (d=0) or DEC (d=1) of a word register. Capture the register, then CF. Add or subtract one with sixteen-bit wrapping, update arithmetic flags, restore captured CF, and only then write the register. Preserve TF/IF/DF. This deliberately retains the model's flag-write ordering, including its transient CF update if a host observer interrupts execution.

family INCword "0100 0 rrr" for r in words named "INC {r}" {
  clocks u16(2)
  left = operand r
  preservedCarry = flag CF
  right = u16(1)
  result = add(left, right)
  apply ADDFLAGS<16>(left, right, result, 0)
  apply CARRY(preservedCarry)
  operand r <- result
}
family DECword "0100 1 rrr" for r in words named "DEC {r}" {
  clocks u16(2)
  left = operand r
  preservedCarry = flag CF
  right = u16(1)
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
  apply CARRY(preservedCarry)
  operand r <- result
}

Logical and physical addresses

A logical address consists of a segment value and a 16-bit offset. Its physical address is (segment × 16 + offset) modulo 100000 in hexadecimal. Carries past the 8088's twenty address lines are discarded. Different logical addresses can refer to the same byte: 1000:2345 and 1234:0005 both address 12345.

Instruction bytes are fetched through CS:IP. IP advances after each fetched byte and wraps from FFFF to 0000, leaving CS unchanged. Each new IP is translated separately. For example, a fetch at 1234:FFFF reads 2233F, then the next fetch reads 12340. At FFFF:000F, consecutive fetches read FFFFF and 00000 because the physical address itself wraps.

Direct memory MOV forms default to DS:offset; stack operations always use SS:SP. ModR/M operands select DS or SS as described below. Words are low byte first. Each byte's offset wraps within its segment before translation to a 20-bit physical address. A word at 1234:FFFF uses physical 2233F and 12340; a word at FFFF:000F uses FFFFF and 00000. Odd word addresses are valid. Data transfers leave IP alone; instruction fetching advances it after each byte.

The hardware POP fixture at SS:FFFF and the hardware-test author's word-bus routines supply the boundary evidence.

RAM accesses and instruction.address contain physical addresses. record.before.cs and record.before.ip retain the instruction's logical address; record.before.pc equals instruction.address. The shared runner's completion address is also physical. It stops at any CS:IP alias of that address; it does not require a particular segment value.

Segmented memory and ModR/M operands

ModR/M has the pattern mm ggg rrr. In register/memory ALU, MOV, TEST, and XCHG, ggg selects a register. In immediate, unary, and shift groups it selects the operation instead. Register codes are AL/CL/DL/BL/AH/CH/DH/BH for bytes and AX/CX/DX/BX/SP/BP/SI/DI for words. Register self-operations and byte halves sharing a word use the operands' original values.

mm rrr interpretation Displacement
00 Memory base below, except rrr=110 is direct DS:offset None, or a word for the direct exception
01 Memory base below Signed byte
10 Memory base below Word, added modulo 65536
11 Register selected by rrr None
rrr Memory base Default segment
000 BX+SI DS
001 BX+DI DS
010 BP+SI SS
011 BP+DI SS
100 SI DS
101 DI DS
110 BP (direct offset when mm=00) SS (DS for the direct exception)
111 BX DS

Base and displacement are added modulo 65536 before segment translation. The operand retains that segment and offset for its reads and writes; changing a base register cannot change the already resolved address. For example, MOV BX,[BX+SI] uses the original BX to find its source. Likewise, XCHG BX,[BX+SI] reads and writes the address computed from the original BX. XCHG reads both operands before writing either, including AL/AH exchanges and self exchanges. 90 is the AX-with-AX encoding, also named NOP. Memory exchanges record operand reads followed by writes; bus locking and arbitration remain outside this instruction-level model.

The immediate group uses 1000 00 s w. 80 is byte and 81 is word; 82 is the documented alternate byte arithmetic encoding; 83 sign-extends a byte to a word. ggg=000/010/011/101/111 selects ADD/ADC/SBB/SUB/CMP for every group byte. OR/AND/XOR (001/100/110) are documented only for 80/81. The model also supports the hardware's sign-extended AND and XOR encodings, described in the immediate group. All documented register and memory choices are supported. Segment overrides replace the default DS/SS choice for explicit memory operands.

LEA computes the effective offset without a data read. LES/LDS read the offset word and segment word from the original resolved address before assigning either destination; loading DS cannot redirect the second word. All four pointer-byte offsets wrap inside that original segment. XLAT captures BX+AL modulo 65536, reads one byte through DS or the override, and replaces only AL.

The declared execution boundary scans prefixes. It passes overridden=0/1 and the captured segmentOverride word to these families. Those are transient inputs, not stored CPU fields. A segment prefix captures its register when encountered; a later prefix replaces it. For ModR/M memory, capture the default segment and base registers before fetching displacement bytes, even when overridden. Absolute accumulator MOV instead fetches its offset before reading DS, and never reads DS when an override supplies the segment.

A captured logical pointer concatenates segment and offset into thirty-two bits. It is not a physical address. This preserves the offset boundary when a word crosses $FFFF: wrap the second offset first, then project each byte onto the twenty-bit bus. Sources take explicit numeric inputs, own their local captures, and return one value. They do not retain a live address callback.

Capturing the base and displacement

Each base source reads the default segment first, then the registers in the order written in the table. The direct form captures DS before fetching the low-first offset. The two catalogues make its mm=00, rrr=110 exception explicit.

source baseBXSI "DS:BX+SI": 32 {
  segment = register DS
  first = register BX
  second = register SI
  return concat(segment, add(first, second))
}
source baseBXDI "DS:BX+DI": 32 {
  segment = register DS
  first = register BX
  second = register DI
  return concat(segment, add(first, second))
}
source baseBPSI "SS:BP+SI": 32 {
  segment = register SS
  first = register BP
  second = register SI
  return concat(segment, add(first, second))
}
source baseBPDI "SS:BP+DI": 32 {
  segment = register SS
  first = register BP
  second = register DI
  return concat(segment, add(first, second))
}
source baseSI "DS:SI": 32 {
  segment = register DS
  first = register SI
  return concat(segment, first)
}
source baseDI "DS:DI": 32 {
  segment = register DS
  first = register DI
  return concat(segment, first)
}
source baseBP "SS:BP": 32 {
  segment = register SS
  first = register BP
  return concat(segment, first)
}
source baseBX "DS:BX": 32 {
  segment = register DS
  first = register BX
  return concat(segment, first)
}
source directPointer "DS and a direct offset": 32 {
  segment = register DS
  offset = source immediateWord
  return concat(segment, offset)
}
operands memoryBases {
  000 "BX+SI" = value baseBXSI
  001 "BX+DI" = value baseBXDI
  010 "BP+SI" = value baseBPSI
  011 "BP+DI" = value baseBPDI
  100 "SI" = value baseSI
  101 "DI" = value baseDI
  110 "BP" = value baseBP
  111 "BX" = value baseBX
}
operands directBases {
  000 "BX+SI" = value baseBXSI
  001 "BX+DI" = value baseBXDI
  010 "BP+SI" = value baseBPSI
  011 "BP+DI" = value baseBPDI
  100 "SI" = value baseSI
  101 "DI" = value baseDI
  110 "direct" = value directPointer
  111 "BX" = value baseBX
}
source effectiveAddress "capture a logical memory address" (postbyte: 8, overridden: 8, segmentOverride: 16): 32 {
  pointer = match postbyte: 32 {
    case "00 xxx rrr" for r in directBases {
      base = operand r
      return base
    }
    case "01 xxx rrr" for r in memoryBases {
      base = operand r
      displacement = fetch
      segment = bits(base, 31, 16)
      offset = add(truncate(base, 16), signExtend(displacement, 16))
      return concat(segment, offset)
    }
    case "10 xxx rrr" for r in memoryBases {
      base = operand r
      displacement = source immediateWord
      segment = bits(base, 31, 16)
      offset = add(truncate(base, 16), displacement)
      return concat(segment, offset)
    }
    otherwise unsupported
  }
  segment = bits(pointer, 31, 16)
  return concat(select(zero(overridden), segment, segmentOverride), truncate(pointer, 16))
}
source resolvedRM "resolve memory; defer register reads" (postbyte: 8, overridden: 8, segmentOverride: 16): 32 {
  pointer = match postbyte: 32 {
    case "0x xxx xxx" {
      address = source effectiveAddress(postbyte, overridden, segmentOverride)
      return address
    }
    case "10 xxx xxx" {
      address = source effectiveAddress(postbyte, overridden, segmentOverride)
      return address
    }
    case "11 xxx xxx" {
      return u32(0)
    }
    otherwise unsupported
  }
  return pointer
}

The zero pointer for a register form is unused. The original ModR/M byte still determines whether an operand is a register or memory; no address is reserved as a sentinel. Ordinary memory operands and ESC share effectiveAddress. Failed displacement fetching retains completed fetches and prevents operand reads, flag changes, and destination writes.

Reading and writing a captured pointer

projectAddress(segment, offset, 4, 20) expresses the physical projection: shift the word segment by four, add the word offset, and keep twenty bits. Word transfers read/write the low byte first. Each uses the captured pointer for both bytes, regardless of changes during a memory callback.

source memory8 "read a captured 8-bit memory operand" (pointer: 32): 8 {
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  low = memory(projectAddress(segment, offset, 4, 20))
  return low
}
action store8 "write a captured 8-bit memory operand" (pointer: 32, contents: 8) using memory {
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  memory(projectAddress(segment, offset, 4, 20)) <- contents
}
source memory16 "read a captured 16-bit memory operand" (pointer: 32): 16 {
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  low = memory(projectAddress(segment, offset, 4, 20))
  high = memory(projectAddress(segment, add(offset, u16(1)), 4, 20))
  return concat(high, low)
}
action store16 "write a captured 16-bit memory operand" (pointer: 32, contents: 16) using memory {
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  memory(projectAddress(segment, offset, 4, 20)) <- lowByte(contents)
  memory(projectAddress(segment, add(offset, u16(1)), 4, 20)) <- highByte(contents)
}

The operand helpers interpret 11 xxx rrr as a register and every other mode as the already captured pointer. They never resolve the address again. A synthetic 11 000 rrr selects the instruction's ggg register through the same helper. Thus both sides share one read/write vocabulary without freezing register contents during address resolution.

source readRM8 "read a resolved 8-bit operand" (postbyte: 8, pointer: 32): 8 {
  result = match postbyte: 8 {
    case "11 xxx rrr" for r in bytes {
      contents = operand r
      return contents
    }
    case "0x xxx xxx" {
      contents = source memory8(pointer)
      return contents
    }
    case "10 xxx xxx" {
      contents = source memory8(pointer)
      return contents
    }
    otherwise unsupported
  }
  return result
}
action writeRM8 "write a resolved 8-bit operand" (postbyte: 8, pointer: 32, contents: 8) using memory {
  match postbyte {
    case "11 xxx rrr" for r in bytes {
      operand r <- contents
    }
    case "0x xxx xxx" {
      perform store8(pointer, contents)
    }
    case "10 xxx xxx" {
      perform store8(pointer, contents)
    }
    otherwise unsupported
  }
}
source readRM16 "read a resolved 16-bit operand" (postbyte: 8, pointer: 32): 16 {
  result = match postbyte: 16 {
    case "11 xxx rrr" for r in words {
      contents = operand r
      return contents
    }
    case "0x xxx xxx" {
      contents = source memory16(pointer)
      return contents
    }
    case "10 xxx xxx" {
      contents = source memory16(pointer)
      return contents
    }
    otherwise unsupported
  }
  return result
}
action writeRM16 "write a resolved 16-bit operand" (postbyte: 8, pointer: 32, contents: 16) using memory {
  match postbyte {
    case "11 xxx rrr" for r in words {
      operand r <- contents
    }
    case "0x xxx xxx" {
      perform store16(pointer, contents)
    }
    case "10 xxx xxx" {
      perform store16(pointer, contents)
    }
    otherwise unsupported
  }
}
codes directions: 8 {
  0 "r/m,r" = 0
  1 "r,r/m" = 1
}

Register/memory transfers

MOV reads only its source before writing its destination. XCHG reads the r/m operand before the register, captures both originals, and writes r/m before the register. This order also applies when two byte operands alias one word. Each byte write preserves the live other half; memory words retain their captured pointer throughout. Neither operation reads or changes flags.

action move8 "move a resolved 8-bit operand" (destination: 8, source: 8, pointer: 32) using memory {
  contents = source readRM8(source, pointer)
  perform writeRM8(destination, pointer, contents)
}
action exchange8 "exchange resolved 8-bit operands" (left: 8, right: 8, pointer: 32) using memory {
  originalLeft = source readRM8(left, pointer)
  originalRight = source readRM8(right, pointer)
  perform writeRM8(left, pointer, originalRight)
  perform writeRM8(right, pointer, originalLeft)
}
action move16 "move a resolved 16-bit operand" (destination: 8, source: 8, pointer: 32) using memory {
  contents = source readRM16(source, pointer)
  perform writeRM16(destination, pointer, contents)
}
action exchange16 "exchange resolved 16-bit operands" (left: 8, right: 8, pointer: 32) using memory {
  originalLeft = source readRM16(left, pointer)
  originalRight = source readRM16(right, pointer)
  perform writeRM16(left, pointer, originalRight)
  perform writeRM16(right, pointer, originalLeft)
}

1000 10 d w is MOV r/m,r or r,r/m. Fetch ModR/M and resolve its memory operand before reading either value. d=0 selects r/m as destination; d=1 selects the ggg register. w=0/1 selects byte/word operands. Preserve flags; a failed address or source read prevents writeback.

family MOVrm8 (overridden: 8, segmentOverride: 16) "1000 10 d 0" for d in directions named "MOV 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(2), select(zero(direction), u16(9), u16(8)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  perform move8(destination, source, pointer)
}
family MOVrm16 (overridden: 8, segmentOverride: 16) "1000 10 d 1" for d in directions named "MOV 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(2), select(zero(direction), u16(13), u16(12)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  perform move16(destination, source, pointer)
}

1000 011 w selects XCHG r/m,r. Resolve the pointer first, then capture r/m before the ggg register. Write them in that order from the captured originals, retaining completed writes if a later effect fails. Preserve all flags.

family XCHGrm8 (overridden: 8, segmentOverride: 16) "1000 011 0" named "XCHG 8-bit r/m,r" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), u16(17))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  perform exchange8(postbyte, register, pointer)
}
family XCHGrm16 (overridden: 8, segmentOverride: 16) "1000 011 1" named "XCHG 16-bit r/m,r" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), u16(25))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  perform exchange16(postbyte, register, pointer)
}

1100 011 w permits only ModR/M /0 for immediate MOV. Reject every other operation selector before displacement or immediate fetches. Resolve any memory destination, fetch the complete immediate, then write once without reading the destination. Byte-register writes preserve the live other half.

family MOVrmImmediate8 (overridden: 8, segmentOverride: 16) "1100 011 0" named "MOV 8-bit r/m,n" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), u16(10))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = source immediateByte
      perform writeRM8(postbyte, pointer, contents)
    }
    otherwise unsupported
  }
}
family MOVrmImmediate16 (overridden: 8, segmentOverride: 16) "1100 011 1" named "MOV 16-bit r/m,n" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), u16(14))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = source immediateWord
      perform writeRM16(postbyte, pointer, contents)
    }
    otherwise unsupported
  }
}

1010 00 d w moves between the accumulator and an absolute offset. Fetch the whole offset first. Read DS only if no segment override was captured, then use the same resolved transfer actions. d=0 loads AL/AX; d=1 stores it. Do not read a memory destination or change flags.

source absolutePointer "absolute offset and selected segment" (overridden: 8, segmentOverride: 16): 32 {
  offset = source immediateWord
  segment = match overridden: 16 {
    case "00000000" {
      original = register DS
      return original
    }
    case "00000001" {
      return segmentOverride
    }
    otherwise unsupported
  }
  return concat(segment, offset)
}
family MOVabsolute8load (overridden: 8, segmentOverride: 16) "1010 00 0 0" named "MOV AL,[offset]" {
  clocks u16(10)
  pointer = source absolutePointer(overridden, segmentOverride)
  perform move8(u8($C0), u8($00), pointer)
}
family MOVabsolute8store (overridden: 8, segmentOverride: 16) "1010 00 1 0" named "MOV [offset],AL" {
  clocks u16(10)
  pointer = source absolutePointer(overridden, segmentOverride)
  perform move8(u8($00), u8($C0), pointer)
}
family MOVabsolute16load (overridden: 8, segmentOverride: 16) "1010 00 0 1" named "MOV AX,[offset]" {
  clocks u16(14)
  pointer = source absolutePointer(overridden, segmentOverride)
  perform move16(u8($C0), u8($00), pointer)
}
family MOVabsolute16store (overridden: 8, segmentOverride: 16) "1010 00 1 1" named "MOV [offset],AX" {
  clocks u16(14)
  pointer = source absolutePointer(overridden, segmentOverride)
  perform move16(u8($00), u8($C0), pointer)
}

Arithmetic on resolved operands

The destination is read only after the right operand has been captured. ADC/SBB then read CF. Arithmetic and logical policies are the same ones used by the accumulator forms. Apply flags before writing the destination; CMP and TEST do not write it. The pointer is already fixed, while byte-register writes still preserve the other half at the actual writeback.

action addRM8 "ADD a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = add(left, right)
  apply ADDFLAGS<8>(left, right, result, 0)
  perform writeRM8(destination, pointer, result)
}
action addRM16 "ADD a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = add(left, right)
  apply ADDFLAGS<16>(left, right, result, 0)
  perform writeRM16(destination, pointer, result)
}
action orRM8 "OR a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = or(left, right)
  apply LOGIC<8>(result)
  perform writeRM8(destination, pointer, result)
}
action orRM16 "OR a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = or(left, right)
  apply LOGIC<16>(result)
  perform writeRM16(destination, pointer, result)
}
action adcRM8 "ADC a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  incoming = flag CF
  result = add(left, right, incoming)
  apply ADDFLAGS<8>(left, right, result, incoming)
  perform writeRM8(destination, pointer, result)
}
action adcRM16 "ADC a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  incoming = flag CF
  result = add(left, right, incoming)
  apply ADDFLAGS<16>(left, right, result, incoming)
  perform writeRM16(destination, pointer, result)
}
action sbbRM8 "SBB a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  incoming = flag CF
  result = subtract(left, right, incoming)
  apply SUBFLAGS<8>(left, right, result, incoming)
  perform writeRM8(destination, pointer, result)
}
action sbbRM16 "SBB a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  incoming = flag CF
  result = subtract(left, right, incoming)
  apply SUBFLAGS<16>(left, right, result, incoming)
  perform writeRM16(destination, pointer, result)
}
action andRM8 "AND a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = and(left, right)
  apply LOGIC<8>(result)
  perform writeRM8(destination, pointer, result)
}
action andRM16 "AND a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = and(left, right)
  apply LOGIC<16>(result)
  perform writeRM16(destination, pointer, result)
}
action subRM8 "SUB a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = subtract(left, right)
  apply SUBFLAGS<8>(left, right, result, 0)
  perform writeRM8(destination, pointer, result)
}
action subRM16 "SUB a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
  perform writeRM16(destination, pointer, result)
}
action xorRM8 "XOR a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = xor(left, right)
  apply LOGIC<8>(result)
  perform writeRM8(destination, pointer, result)
}
action xorRM16 "XOR a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = xor(left, right)
  apply LOGIC<16>(result)
  perform writeRM16(destination, pointer, result)
}
action cmpRM8 "CMP a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = subtract(left, right)
  apply SUBFLAGS<8>(left, right, result, 0)
}
action cmpRM16 "CMP a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
}
action testRM8 "TEST a resolved 8-bit destination" (destination: 8, pointer: 32, right: 8) using memory {
  left = source readRM8(destination, pointer)
  result = and(left, right)
  apply LOGIC<8>(result)
}
action testRM16 "TEST a resolved 16-bit destination" (destination: 8, pointer: 32, right: 16) using memory {
  left = source readRM16(destination, pointer)
  result = and(left, right)
  apply LOGIC<16>(result)
}

00 ooo 0 d w selects the same eight operations as the accumulator-immediate encodings. Resolve ModR/M first, capture the source selected by d, then read the destination and any incoming CF. Failed resolution or operand reads prevent later flags and writeback; completed effects remain visible on failure.

family ADDrm8 (overridden: 8, segmentOverride: 16) "00 000 0 d 0" for d in directions named "ADD 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform addRM8(destination, pointer, right)
}
family ADDrm16 (overridden: 8, segmentOverride: 16) "00 000 0 d 1" for d in directions named "ADD 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform addRM16(destination, pointer, right)
}
family ORrm8 (overridden: 8, segmentOverride: 16) "00 001 0 d 0" for d in directions named "OR 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform orRM8(destination, pointer, right)
}
family ORrm16 (overridden: 8, segmentOverride: 16) "00 001 0 d 1" for d in directions named "OR 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform orRM16(destination, pointer, right)
}
family ADCrm8 (overridden: 8, segmentOverride: 16) "00 010 0 d 0" for d in directions named "ADC 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform adcRM8(destination, pointer, right)
}
family ADCrm16 (overridden: 8, segmentOverride: 16) "00 010 0 d 1" for d in directions named "ADC 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform adcRM16(destination, pointer, right)
}
family SBBrm8 (overridden: 8, segmentOverride: 16) "00 011 0 d 0" for d in directions named "SBB 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform sbbRM8(destination, pointer, right)
}
family SBBrm16 (overridden: 8, segmentOverride: 16) "00 011 0 d 1" for d in directions named "SBB 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform sbbRM16(destination, pointer, right)
}
family ANDrm8 (overridden: 8, segmentOverride: 16) "00 100 0 d 0" for d in directions named "AND 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform andRM8(destination, pointer, right)
}
family ANDrm16 (overridden: 8, segmentOverride: 16) "00 100 0 d 1" for d in directions named "AND 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform andRM16(destination, pointer, right)
}
family SUBrm8 (overridden: 8, segmentOverride: 16) "00 101 0 d 0" for d in directions named "SUB 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform subRM8(destination, pointer, right)
}
family SUBrm16 (overridden: 8, segmentOverride: 16) "00 101 0 d 1" for d in directions named "SUB 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform subRM16(destination, pointer, right)
}
family XORrm8 (overridden: 8, segmentOverride: 16) "00 110 0 d 0" for d in directions named "XOR 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(16), u16(9)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform xorRM8(destination, pointer, right)
}
family XORrm16 (overridden: 8, segmentOverride: 16) "00 110 0 d 1" for d in directions named "XOR 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), select(zero(direction), u16(24), u16(13)))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform xorRM16(destination, pointer, right)
}
family CMPrm8 (overridden: 8, segmentOverride: 16) "00 111 0 d 0" for d in directions named "CMP 8-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), u16(9))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM8(source, pointer)
  perform cmpRM8(destination, pointer, right)
}
family CMPrm16 (overridden: 8, segmentOverride: 16) "00 111 0 d 1" for d in directions named "CMP 16-bit {d}" {
  postbyte = fetch
  direction = operand d
  cost = source rmClocks(postbyte, u16(3), u16(13))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  destination = select(zero(direction), postbyte, register)
  source = select(zero(direction), register, postbyte)
  right = source readRM16(source, pointer)
  perform cmpRM16(destination, pointer, right)
}

1000 010 w selects TEST r/m,r. After address resolution, read the ggg register before r/m. Apply logical flags without writing or rereading either operand. Word PF comes from the low result byte; preserve TF/IF/DF.

family TESTrm8 (overridden: 8, segmentOverride: 16) "1000 010 0" named "TEST 8-bit r/m,r" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(3), u16(9))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  right = source readRM8(register, pointer)
  perform testRM8(postbyte, pointer, right)
}
family TESTrm16 (overridden: 8, segmentOverride: 16) "1000 010 1" named "TEST 16-bit r/m,r" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(3), u16(13))
  clocks cost
  pointer = source resolvedRM(postbyte, overridden, segmentOverride)
  register = or(u8($C0), extend(bits(postbyte, 5, 3), 8))
  right = source readRM16(register, pointer)
  perform testRM16(postbyte, pointer, right)
}

Immediate operation groups

1000 00 s w uses ModR/M ggg as the operation field. The documented $80 and $81 forms allow all eight operations. $82 uses a byte operand and $83 sign-extends an immediate byte to a word. Intel's 1979 table marks /1, /4, and /6 unused for these last two opcodes. Hardware also executes 83 /4 as AND and 83 /6 as XOR. For example, 83 E4 FE clears the low bit of SP to align the stack. In 2E 83 36 BC 0F 01, the CS override selects the word at CS:0FBC and XOR toggles its low bit. The model rejects the other unlisted selectors at the ModR/M byte. AND captures · XOR captures.

Capture the pointer, fetch the complete immediate, then read the destination and any incoming CF. This sequence keeps displacement and immediate callbacks from retargeting the memory operand. CMP updates flags without writeback.

family immediateGroup80 (overridden: 8, segmentOverride: 16) "10000000" named "ALU 8-bit r/m,n ($80)" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), select(equal(extend(bits(postbyte, 5, 3), 8), u8(7)), u16(10), u16(17)))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform addRM8(postbyte, pointer, right)
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform orRM8(postbyte, pointer, right)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform adcRM8(postbyte, pointer, right)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform sbbRM8(postbyte, pointer, right)
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform andRM8(postbyte, pointer, right)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform subRM8(postbyte, pointer, right)
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform xorRM8(postbyte, pointer, right)
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform cmpRM8(postbyte, pointer, right)
    }
    otherwise unsupported
  }
}
family immediateGroup81 (overridden: 8, segmentOverride: 16) "10000001" named "ALU 16-bit r/m,n ($81)" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), select(equal(extend(bits(postbyte, 5, 3), 8), u8(7)), u16(14), u16(25)))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform addRM16(postbyte, pointer, right)
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform orRM16(postbyte, pointer, right)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform adcRM16(postbyte, pointer, right)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform sbbRM16(postbyte, pointer, right)
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform andRM16(postbyte, pointer, right)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform subRM16(postbyte, pointer, right)
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform xorRM16(postbyte, pointer, right)
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      right = immediate
      perform cmpRM16(postbyte, pointer, right)
    }
    otherwise unsupported
  }
}
family immediateGroup82 (overridden: 8, segmentOverride: 16) "10000010" named "ALU 8-bit r/m,n ($82)" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), select(equal(extend(bits(postbyte, 5, 3), 8), u8(7)), u16(10), u16(17)))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform addRM8(postbyte, pointer, right)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform adcRM8(postbyte, pointer, right)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform sbbRM8(postbyte, pointer, right)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform subRM8(postbyte, pointer, right)
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = immediate
      perform cmpRM8(postbyte, pointer, right)
    }
    otherwise unsupported
  }
}
family immediateGroup83 (overridden: 8, segmentOverride: 16) "10000011" named "ALU 16-bit r/m,signed byte ($83)" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(4), select(equal(extend(bits(postbyte, 5, 3), 8), u8(7)), u16(14), u16(25)))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform addRM16(postbyte, pointer, right)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform adcRM16(postbyte, pointer, right)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform sbbRM16(postbyte, pointer, right)
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform andRM16(postbyte, pointer, right)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform subRM16(postbyte, pointer, right)
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform xorRM16(postbyte, pointer, right)
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      right = signExtend(immediate, 16)
      perform cmpRM16(postbyte, pointer, right)
    }
    otherwise unsupported
  }
}

Unary arithmetic, shifts, and products

Several instructions use ModR/M mm ooo rrr: mm and rrr choose the operand, while ooo extends the primary opcode. Validate that extension before displacement fetches or operand reads. Capture a memory pointer once; all later reads and writes use it, even if a callback changes a segment.

Increment, decrement, complement, and negation

INC and DEC preserve the incoming CF, captured after reading the operand. NOT complements every bit without reading flags. NEG subtracts the operand from zero, including the usual subtraction flags. For every width, flag updates precede writeback; byte writes preserve the live other half.

action incRM8 "INC a resolved 8-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM8(postbyte, pointer)
  preservedCarry = flag CF
  right = u8(1)
  result = add(left, right)
  apply ADDFLAGS<8>(left, right, result, 0)
  apply CARRY(preservedCarry)
  perform writeRM8(postbyte, pointer, result)
}

action decRM8 "DEC a resolved 8-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM8(postbyte, pointer)
  preservedCarry = flag CF
  right = u8(1)
  result = subtract(left, right)
  apply SUBFLAGS<8>(left, right, result, 0)
  apply CARRY(preservedCarry)
  perform writeRM8(postbyte, pointer, result)
}

action notRM8 "NOT a resolved 8-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM8(postbyte, pointer)
  result = xor(left, u8($FF))
  perform writeRM8(postbyte, pointer, result)
}

action negRM8 "NEG a resolved 8-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM8(postbyte, pointer)
  result = subtract(u8(0), left)
  apply SUBFLAGS<8>(u8(0), left, result, 0)
  perform writeRM8(postbyte, pointer, result)
}

action incRM16 "INC a resolved 16-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM16(postbyte, pointer)
  preservedCarry = flag CF
  right = u16(1)
  result = add(left, right)
  apply ADDFLAGS<16>(left, right, result, 0)
  apply CARRY(preservedCarry)
  perform writeRM16(postbyte, pointer, result)
}

action decRM16 "DEC a resolved 16-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM16(postbyte, pointer)
  preservedCarry = flag CF
  right = u16(1)
  result = subtract(left, right)
  apply SUBFLAGS<16>(left, right, result, 0)
  apply CARRY(preservedCarry)
  perform writeRM16(postbyte, pointer, result)
}

action notRM16 "NOT a resolved 16-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM16(postbyte, pointer)
  result = xor(left, u16($FFFF))
  perform writeRM16(postbyte, pointer, result)
}

action negRM16 "NEG a resolved 16-bit operand" (postbyte: 8, pointer: 32) using memory {
  left = source readRM16(postbyte, pointer)
  result = subtract(u16(0), left)
  apply SUBFLAGS<16>(u16(0), left, result, 0)
  perform writeRM16(postbyte, pointer, result)
}

One bit at a time

The first byte is 1101 00 v w: v=0 uses count one, v=1 uses CL; w=0/1 selects byte/word. ModR/M's operation field selects:

ggg Instruction Direction Bit inserted
000 ROL Left Outgoing high bit
001 ROR Right Outgoing low bit
010 RCL Left Previous CF
011 RCR Right Previous CF
100 SHL / SAL Left Zero; both mnemonics name the same encoding
101 SHR Right Zero
110 Unsupported — Undocumented encoding
111 SAR Right Sign bit

The original 8088 uses all eight bits of CL, permitting 0–255 movements. It does not apply the five-bit mask used by later x86 processors. Each movement sets CF to the outgoing bit; carry rotations feed that flag into the next movement. Counts equal to or larger than the width still execute. The count is captured before changing any destination, including CL, CH, or CX.

For count one, OF records whether the sign bit changed. For larger counts Intel leaves OF undefined; this model preserves its incoming value. Rotations preserve SF/ZF/PF/AF. Nonzero shifts set SF/ZF/PF from the result and clear undefined AF deterministically, as logical operations do. Every form preserves TF/IF/DF. A zero count preserves all flags and the value.

Intel's 8086 Family User's Manual, printed pages 2-39–2-40 and the instruction tables, defines these operations. The reference comparison checks documented encodings and defined flags against hardware cases.

Memory forms fetch the complete encoding, read the operand once, perform all movements internally, then write it once. A zero count also records a read and unchanged write. Word accesses retain the segment-offset wrapping policy. These are explicit instruction-level access rules, without cycle counts or prefetch effects. The specification describes each movement and its ordered flag updates.

policy RESULT<bits: 8, 16> "zero, sign, and parity" (result: bits) {
  ZF = zero(result)
  SF = negative(result)
  PF = evenParity(lowByte(result))
}
policy SHIFT<bits: 8, 16> "shift result" (result: bits) {
  ZF = zero(result)
  SF = negative(result)
  PF = evenParity(lowByte(result))
  AF = 0
}
policy SHIFTOF<bits: 8, 16> "one-bit overflow" (before: bits, after: bits) {
  OF = negative(xor(before, after))
}

action shiftRM8 "shift or rotate a resolved 8-bit operand" (postbyte: 8, pointer: 32, count: 8, operation: 8) using memory {
  originalOperand = source readRM8(postbyte, pointer)
  shifted = iterate(count, originalOperand) {
    original = shifted
    result = match operation: 8 {
      case "00000000" {
        result = shiftLeft(original, negative(original))
        apply CARRY(negative(original))
        return result
      }
      case "00000001" {
        result = shiftRight(original, lowBit(original))
        apply CARRY(lowBit(original))
        return result
      }
      case "00000010" {
        incoming = flag CF
        result = shiftLeft(original, incoming)
        apply CARRY(negative(original))
        return result
      }
      case "00000011" {
        incoming = flag CF
        result = shiftRight(original, incoming)
        apply CARRY(lowBit(original))
        return result
      }
      case "00000100" {
        result = shiftLeft(original, 0)
        apply CARRY(negative(original))
        return result
      }
      case "00000101" {
        result = shiftRight(original, 0)
        apply CARRY(lowBit(original))
        return result
      }
      case "00000111" {
        result = shiftRight(original, negative(original))
        apply CARRY(lowBit(original))
        return result
      }
      otherwise unsupported
    }
    return result
  }
  when equal(count, u8(1)) {
    apply SHIFTOF<8>(originalOperand, shifted)
  }
  when and(not(lessThan(operation, u8(4), unsigned)), not(zero(count))) {
    apply SHIFT<8>(shifted)
  }
  perform writeRM8(postbyte, pointer, shifted)
}

action shiftRM16 "shift or rotate a resolved 16-bit operand" (postbyte: 8, pointer: 32, count: 8, operation: 8) using memory {
  originalOperand = source readRM16(postbyte, pointer)
  shifted = iterate(count, originalOperand) {
    original = shifted
    result = match operation: 16 {
      case "00000000" {
        result = shiftLeft(original, negative(original))
        apply CARRY(negative(original))
        return result
      }
      case "00000001" {
        result = shiftRight(original, lowBit(original))
        apply CARRY(lowBit(original))
        return result
      }
      case "00000010" {
        incoming = flag CF
        result = shiftLeft(original, incoming)
        apply CARRY(negative(original))
        return result
      }
      case "00000011" {
        incoming = flag CF
        result = shiftRight(original, incoming)
        apply CARRY(lowBit(original))
        return result
      }
      case "00000100" {
        result = shiftLeft(original, 0)
        apply CARRY(negative(original))
        return result
      }
      case "00000101" {
        result = shiftRight(original, 0)
        apply CARRY(lowBit(original))
        return result
      }
      case "00000111" {
        result = shiftRight(original, negative(original))
        apply CARRY(lowBit(original))
        return result
      }
      otherwise unsupported
    }
    return result
  }
  when equal(count, u8(1)) {
    apply SHIFTOF<16>(originalOperand, shifted)
  }
  when and(not(lessThan(operation, u8(4), unsigned)), not(zero(count))) {
    apply SHIFT<16>(shifted)
  }
  perform writeRM16(postbyte, pointer, shifted)
}

family shifts8One (overridden: 8, segmentOverride: 16) "1101 00 0 0" named "8-bit shifts/rotates by one" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(2), u16(15))
  clocks cost when not(equal(bits(postbyte, 5, 3), u3(6)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(0))
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(1))
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(2))
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(3))
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(4))
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(5))
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM8(postbyte, pointer, count, u8(7))
    }
    otherwise unsupported
  }
}

family shifts16One (overridden: 8, segmentOverride: 16) "1101 00 0 1" named "16-bit shifts/rotates by one" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(2), u16(23))
  clocks cost when not(equal(bits(postbyte, 5, 3), u3(6)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(0))
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(1))
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(2))
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(3))
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(4))
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(5))
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = u8(1)
      perform shiftRM16(postbyte, pointer, count, u8(7))
    }
    otherwise unsupported
  }
}

family shifts8CL (overridden: 8, segmentOverride: 16) "1101 00 1 0" named "8-bit shifts/rotates by CL" {
  postbyte = fetch
  shiftCount = source CL
  baseClocks = source rmClocks(postbyte, u16(8), u16(20))
  clocks add(baseClocks, multiply(shiftCount, u8(4))) when not(equal(bits(postbyte, 5, 3), u3(6)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(0))
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(1))
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(2))
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(3))
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(4))
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(5))
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM8(postbyte, pointer, count, u8(7))
    }
    otherwise unsupported
  }
}

family shifts16CL (overridden: 8, segmentOverride: 16) "1101 00 1 1" named "16-bit shifts/rotates by CL" {
  postbyte = fetch
  shiftCount = source CL
  baseClocks = source rmClocks(postbyte, u16(8), u16(28))
  clocks add(baseClocks, multiply(shiftCount, u8(4))) when not(equal(bits(postbyte, 5, 3), u3(6)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(0))
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(1))
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(2))
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(3))
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(4))
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(5))
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      count = source CL
      perform shiftRM16(postbyte, pointer, count, u8(7))
    }
    otherwise unsupported
  }
}

Multiply, divide, and the F6/F7 group

1111 011 w uses /0 for immediate TEST, /2 for NOT, /3 for NEG, and /4–/7 for MUL, IMUL, DIV, IDIV. /1 is undocumented. TEST fetches its immediate before reading the operand and updates flags without writeback.

MUL/IMUL multiply AL by a byte into AX, or AX by a word into DX:AX. MUL sets CF/OF when the upper half is nonzero; IMUL sets them when the result does not fit the signed input width. SF/ZF/PF/AF are undefined and preserved. Operands are read before assigning results, including aliases such as MUL AH or IMUL DX.

DIV divides unsigned AX by a byte, placing quotient/remainder in AL/AH, or unsigned DX:AX by a word, placing them in AX/DX. IDIV uses signed values, truncates toward zero, and gives the remainder the dividend's sign. Arithmetic flags are undefined and preserved; successful division preserves control flags. On the original 8088 the signed quotient ranges are −127..127 and −32767..32767: even −128 and −32768 cause divide errors. These limits follow the 1979 manual and pinned hardware fixtures.

A zero divisor or out-of-range quotient delivers interrupt type 0 after fetching the instruction and reading the divisor, leaving AX/DX unchanged. The frame saves the following IP, as on the original 8088, rather than the faulting instruction's address used by later x86. Arithmetic flags remain unchanged under the model's undefined-flag policy; entry saves them and clears IF/TF.

policy UNSIGNEDPRODUCT8 "product overflow" (product: 16) {
  OF = not(zero(bits(product, 15, 8)))
  CF = not(zero(bits(product, 15, 8)))
}

policy SIGNEDPRODUCT8 "product overflow" (product: 16) {
  OF = not(equal(product, signExtend(truncate(product, 8), 16)))
  CF = not(equal(product, signExtend(truncate(product, 8), 16)))
}

family arithmeticGroup8 (overridden: 8, segmentOverride: 16) "1111 011 0" named "8-bit TEST, unary, multiply, and divide group" {
  postbyte = fetch
  operation = bits(postbyte, 5, 3)
  unsignedMultiply: flag = equal(operation, u3(4))
  unsignedDivide: flag = equal(operation, u3(6))
  unary: flag = or(equal(operation, u3(2)), equal(operation, u3(3)))
  registerCost = select(unary, u16(3), select(unsignedDivide, u16(90), select(unsignedMultiply, u16(77), u16(5))))
  memoryCost = select(unary, u16(16), select(unsignedDivide, u16(96), select(unsignedMultiply, u16(83), u16(11))))
  cost = source rmClocks(postbyte, registerCost, memoryCost)
  clocks cost when or(unary, or(zero(operation), or(unsignedMultiply, unsignedDivide)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateByte
      perform testRM8(postbyte, pointer, immediate)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform notRM8(postbyte, pointer)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform negRM8(postbyte, pointer)
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM8(postbyte, pointer)
      accumulator = source AL
      product = multiply(accumulator, originalOperand, unsigned)
      AX <- product
      apply UNSIGNEDPRODUCT8(product)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM8(postbyte, pointer)
      accumulator = source AL
      product = multiply(accumulator, originalOperand, signed)
      AX <- product
      apply SIGNEDPRODUCT8(product)
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM8(postbyte, pointer)
      dividend = register AX
      quotient, remainder = divide(dividend, originalOperand, unsigned) otherwise "divide-error"
      AX <- concat(remainder, quotient)
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM8(postbyte, pointer)
      dividend = register AX
      quotient, remainder = divide(dividend, originalOperand, signed) otherwise "divide-error"
      reject "divide-error" if equal(quotient, u8($80))
      AX <- concat(remainder, quotient)
    }
    otherwise unsupported
  }
}

policy UNSIGNEDPRODUCT16 "product overflow" (product: 32) {
  OF = not(zero(bits(product, 31, 16)))
  CF = not(zero(bits(product, 31, 16)))
}

policy SIGNEDPRODUCT16 "product overflow" (product: 32) {
  OF = not(equal(product, signExtend(truncate(product, 16), 32)))
  CF = not(equal(product, signExtend(truncate(product, 16), 32)))
}

family arithmeticGroup16 (overridden: 8, segmentOverride: 16) "1111 011 1" named "16-bit TEST, unary, multiply, and divide group" {
  postbyte = fetch
  operation = bits(postbyte, 5, 3)
  unsignedMultiply: flag = equal(operation, u3(4))
  unsignedDivide: flag = equal(operation, u3(6))
  unary: flag = or(equal(operation, u3(2)), equal(operation, u3(3)))
  registerCost = select(unary, u16(3), select(unsignedDivide, u16(162), select(unsignedMultiply, u16(133), u16(5))))
  memoryCost = select(unary, u16(24), select(unsignedDivide, u16(172), select(unsignedMultiply, u16(143), u16(15))))
  cost = source rmClocks(postbyte, registerCost, memoryCost)
  clocks cost when or(unary, or(zero(operation), or(unsignedMultiply, unsignedDivide)))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      immediate = source immediateWord
      perform testRM16(postbyte, pointer, immediate)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform notRM16(postbyte, pointer)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform negRM16(postbyte, pointer)
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM16(postbyte, pointer)
      accumulator = register AX
      product = multiply(accumulator, originalOperand, unsigned)
      AX <- truncate(product, 16)
      DX <- bits(product, 31, 16)
      apply UNSIGNEDPRODUCT16(product)
    }
    case "xx 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM16(postbyte, pointer)
      accumulator = register AX
      product = multiply(accumulator, originalOperand, signed)
      AX <- truncate(product, 16)
      DX <- bits(product, 31, 16)
      apply SIGNEDPRODUCT16(product)
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM16(postbyte, pointer)
      high = register DX
      low = register AX
      dividend = concat(high, low)
      quotient, remainder = divide(dividend, originalOperand, unsigned) otherwise "divide-error"
      AX <- quotient
      DX <- remainder
    }
    case "xx 111 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      originalOperand = source readRM16(postbyte, pointer)
      high = register DX
      low = register AX
      dividend = concat(high, low)
      quotient, remainder = divide(dividend, originalOperand, signed) otherwise "divide-error"
      reject "divide-error" if equal(quotient, u16($8000))
      AX <- quotient
      DX <- remainder
    }
    otherwise unsupported
  }
}

Decimal digits and sign extension

DAA/DAS (0010 s111) adjust packed decimal digits after addition/subtraction; AAA/AAS (0011 s111) adjust unpacked digits. Read AL first, then AH for unpacked adjustments. The low-digit decision consults AF only if the nibble is below ten. For packed adjustment, read AF again for the high threshold: original AL above $9F when AF is set, otherwise above $99. Only a value below that threshold needs a CF read. These are ordered decisions, not unconditional reads of all flags.

Packed correction sets AF then CF, writes AL preserving live AH, then sets ZF/SF/PF. Unpacked correction adjusts the captured AH and AL independently, masks AL to four bits, writes AX, then CF and AF. Undefined flags stay intact.

DAA/DAS adjust AL after packed-decimal addition/subtraction. Low-digit correction uses AF or a low nibble above nine; high correction uses CF or original AL above 99 when incoming AF is clear, 9F when it is set. CF records that high correction; DAS does not add a separate low-digit borrow. AF records low correction, SF/ZF/PF describe adjusted AL, and undefined OF is preserved. AH is unchanged. These original-chip details differ from later x86: DAA with AL=9E, AF=1, CF=0 gives A4, CF=0; DAS with AL=0, AF=1, CF=0 gives FA, CF=0. Valid packed-BCD arithmetic is checked independently in decimal.

AAA/AAS adjust unpacked AL and AH independently, then mask AL to its low nibble. A carry/borrow from AL's byte adjustment does not adjust AH a second time. AF/CF report adjustment; undefined OF/SF/ZF/PF are preserved. AAM splits AL into decimal quotient AH and remainder AL; AAD combines AH×10+AL into AL modulo 256 and clears AH. Only the documented fixed second byte 0A is supported. AAM/AAD set SF/ZF/PF from AL and preserve undefined CF/AF/OF. CBW and CWD sign-extend the accumulator without changing flags.

source decimalLow "low digit needs correction" (original: 8): flag {
  aboveNine: flag = not(lessThan(and(original, u8($0F)), u8(10), unsigned))
  decision = choose aboveNine : flag {
    then {
      return 1
    }
    else {
      incoming = flag AF
      return incoming
    }
  }
  return decision
}
source decimalHigh "high digit needs correction" (original: 8): flag {
  auxiliary = flag AF
  aboveLimit: flag = not(lessThan(original, select(auxiliary, u8($A0), u8($9A)), unsigned))
  decision = choose aboveLimit : flag {
    then {
      return 1
    }
    else {
      incoming = flag CF
      return incoming
    }
  }
  return decision
}
policy PACKEDCORRECTION "packed decimal correction" (low: flag, high: flag) {
  AF = low
  CF = high
}
policy UNPACKEDCORRECTION "unpacked decimal correction" (low: flag) {
  CF = low
  AF = low
}

family DAA "001 0 0 111" named "DAA" {
  original = source AL
  low = source decimalLow(original)
  high = source decimalHigh(original)
  result = add(original, add(select(low, u8(6), u8(0)), select(high, u8($60), u8(0))))
  apply PACKEDCORRECTION(low, high)
  perform setAL(result)
  apply RESULT<8>(result)
}

family DAS "001 0 1 111" named "DAS" {
  original = source AL
  low = source decimalLow(original)
  high = source decimalHigh(original)
  result = subtract(original, add(select(low, u8(6), u8(0)), select(high, u8($60), u8(0))))
  apply PACKEDCORRECTION(low, high)
  perform setAL(result)
  apply RESULT<8>(result)
}

family AAA "001 1 0 111" named "AAA" {
  original = source AL
  high = source AH
  low = source decimalLow(original)
  AX <- concat(add(high, select(low, u8(1), u8(0))), and(add(original, select(low, u8(6), u8(0))), u8($0F)))
  apply UNPACKEDCORRECTION(low)
}

family AAS "001 1 1 111" named "AAS" {
  original = source AL
  high = source AH
  low = source decimalLow(original)
  AX <- concat(subtract(high, select(low, u8(1), u8(0))), and(subtract(original, select(low, u8(6), u8(0))), u8($0F)))
  apply UNPACKEDCORRECTION(low)
}

AAM/AAD (1101 010 d) require the documented $0A radix before accessing AX. AAM splits AL into quotient and remainder in 83 clocks. AAD combines AH × 10 + AL into AL and clears AH in 60 clocks. Both set ZF/SF/PF from the new AL. CBW and CWD (1001 100 s) extend AL's sign to AX or AX's sign to DX without flag effects.

family AAM "1101 010 0" named "AAM" {
  radix = fetch
  reject "opcode" if not(equal(radix, u8(10)))
  clocks u16(83)
  low = source AL
  quotient, remainder = divide(extend(low, 16), u8(10), unsigned) otherwise "divide-error"
  AX <- concat(quotient, remainder)
  result = source AL
  apply RESULT<8>(result)
}
family AAD "1101 010 1" named "AAD" {
  radix = fetch
  reject "opcode" if not(equal(radix, u8(10)))
  clocks u16(60)
  low = source AL
  high = source AH
  AX <- extend(truncate(add(multiply(high, u8(10)), extend(low, 16)), 8), 16)
  result = source AL
  apply RESULT<8>(result)
}
family CBW "1001 100 0" named "CBW" {
  clocks u16(2)
  word = register AX
  AX <- signExtend(lowByte(word), 16)
}
family CWD "1001 100 1" named "CWD" {
  clocks u16(5)
  word = register AX
  DX <- select(negative(word), u16($FFFF), u16(0))
}

Indirect control and the FE/FF groups

1111 111 w selects INC /0 and DEC /1. Only the word group $FF also allows near CALL /2, far CALL /3, near JMP /4, far JMP /5, and PUSH /6. /7 and register encodings of far pointers are rejected before address resolution. A far pointer is offset then segment, each low byte first; wrap the logical offset between words before projecting it to the bus.

Capture targets before stack writes. A near CALL then captures and pushes IP; a far CALL captures and pushes CS, then captures live IP and pushes it. Commit the new CS before IP only after the whole frame succeeds. PUSH SP on the original 8088 captures SP minus two as its source, before the stack action's independent update of live SP. All paths preserve flags except INC/DEC.

The stack action decrements live SP before reading SS and the new SP, then writes low byte before high byte. A failed write retains completed effects. This same action serves near/far calls and indirect PUSH.

action pushWord "push a captured word" (word: 16) using memory {
  pointer = register SP
  SP <- subtract(pointer, u16(2))
  segment = register SS
  offset = register SP
  perform store16(concat(segment, offset), word)
}

source farPointer "read offset then segment from a captured pointer" (pointer: 32): 32 {
  targetOffset = source memory16(pointer)
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  targetSegment = source memory16(concat(segment, add(offset, u16(2))))
  return concat(targetSegment, targetOffset)
}
action jumpFar "commit captured CS:IP" (targetOffset: 16, targetSegment: 16) {
  CS <- targetSegment
  IP <- targetOffset
}
action callFar "save live CS:IP then commit the target" (targetOffset: 16, targetSegment: 16) using memory {
  returnCS = register CS
  perform pushWord(returnCS)
  returnIP = register IP
  perform pushWord(returnIP)
  perform jumpFar(targetOffset, targetSegment)
}

family wordGroup8 (overridden: 8, segmentOverride: 16) "1111 111 0" named "INC/DEC byte group" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(3), u16(15))
  clocks cost when lessThan(extend(bits(postbyte, 5, 3), 8), u8(2), unsigned)
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform incRM8(postbyte, pointer)
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform decRM8(postbyte, pointer)
    }
    otherwise unsupported
  }
}

family wordGroup16 (overridden: 8, segmentOverride: 16) "1111 111 1" named "INC/DEC and indirect word group" {
  postbyte = fetch
  cost = match postbyte: 16 {
    case "xx 00x xxx" {
      cost = source rmClocks(postbyte, u16(2), u16(23))
      return cost
    }
    case "xx 010 xxx" {
      cost = source rmClocks(postbyte, u16(20), u16(29))
      return cost
    }
    case "xx 011 xxx" {
      cost = source rmClocks(postbyte, u16(0), u16(53))
      return cost
    }
    case "xx 100 xxx" {
      cost = source rmClocks(postbyte, u16(11), u16(22))
      return cost
    }
    case "xx 101 xxx" {
      cost = source rmClocks(postbyte, u16(0), u16(32))
      return cost
    }
    case "xx 110 xxx" {
      cost = source rmClocks(postbyte, u16(15), u16(24))
      return cost
    }
    otherwise unsupported
  }
  clocks cost when not(zero(cost))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform incRM16(postbyte, pointer)
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      perform decRM16(postbyte, pointer)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source readRM16(postbyte, pointer)
      returnIP = register IP
      perform pushWord(returnIP)
      IP <- target
    }
    case "00 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform callFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "01 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform callFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "10 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform callFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "xx 100 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source readRM16(postbyte, pointer)
      IP <- target
    }
    case "00 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform jumpFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "01 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform jumpFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "10 101 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      perform jumpFar(truncate(target, 16), bits(target, 31, 16))
    }
    case "xx 110 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      captured = source readRM16(postbyte, pointer)
      word = select(equal(postbyte, u8($F4)), subtract(captured, u16(2)), captured)
      perform pushWord(word)
    }
    otherwise unsupported
  }
}

Control flow and stack

Near jumps and calls preserve CS. Short JMP and Jcc add a signed byte to IP after the operand; near JMP/CALL add a signed word. The resulting IP wraps to 16 bits. Jcc always fetches its displacement, including when untaken. No transfer reads or prefetches its target.

Opcodes Condition for first mnemonic First / inverted mnemonic (aliases)
70 / 71 OF JO / JNO
72 / 73 CF JB (JC/JNAE) / JAE (JNC/JNB)
74 / 75 ZF JE (JZ) / JNE (JNZ)
76 / 77 CF or ZF JBE (JNA) / JA (JNBE)
78 / 79 SF JS / JNS
7A / 7B PF JP (JPE) / JNP (JPO)
7C / 7D SF differs from OF JL (JNGE) / JGE (JNL)
7E / 7F ZF or SF differs from OF JLE (JNG) / JG (JNLE)

PUSH decrements SP by two, wrapping to 16 bits, then writes the selected word low byte first at SS:SP and SS:(SP+1). PUSH SP stores the decremented SP on the original 8088. POP reads those bytes, increments SP by two, then assigns the word to its destination. POP SP ends with the popped value, replacing the increment. All register stack forms preserve every flag. Segment PUSH/POP use the original SS for the entire access, including POP SS. POP r/m resolves its destination before the pop; an SP register destination receives the popped word after the increment. FF /6 PUSH SP has the same decremented-value rule as opcode 54.

CALL fetches the complete displacement before pushing the following IP, including when the stack overlaps the fetched encoding. It then branches from that following IP. RET reads IP from the stack without further adjustment to that returned address; RET n subsequently adds its unsigned immediate byte count to SP, with 16-bit wrapping. The count may be odd or zero. Discarded parameters cause no reads. Near calls and returns preserve CS; every call and return preserves flags. Indirect near CALL/JMP read a register or memory target before writing the stack or changing IP. Far immediate and memory forms capture a complete segment:offset pointer; memory forms require a memory ModR/M choice. A far CALL pushes the old CS then the following IP before replacing CS:IP, even when stack writes overlap the pointer or instruction. RETF pops IP then CS, then optionally adds its unsigned parameter byte count to SP.

LOOPNE/LOOPE/LOOP decrement CX modulo 65536 before testing for nonzero. The first two also require ZF clear/set; JCXZ tests original CX for zero and preserves it. Each fetches its signed displacement on either path. All preserve flags and CS; no branch fetches its target as part of the same step.

The control-flow example saves an AX loop counter while two nested calls accumulate a sum in RAM, then restores the counter and compares it before branching. IP, SP, and the physical PC stay distinct.

Conditional branches and loops

A relative displacement is added to the post-fetch IP, wrapping within CS. Every conditional branch fetches its displacement before testing flags, and only a taken branch reads or writes IP. 0111 ttt p selects one of eight positive tests with ttt; p=1 inverts that test.

ttt p=0 p=1 Positive test
000 JO JNO OF
001 JB JAE CF
010 JE JNE ZF
011 JBE JA CF or ZF
100 JS JNS SF
101 JP JNP PF
110 JL JGE SF differs from OF
111 JLE JG ZF or (SF differs from OF)

Compound conditions short-circuit: JBE/JA read CF before ZF, and JLE/JG read ZF before SF and OF. The source returns a Boolean decision; the low polarity bit inverts it without rereading any flag.

source branchDecision "ordered branch test" (condition: 8): flag {
  positive = match shiftBits(condition, right, 1): flag {
    case "00000000" {
      test = flag OF
      return test
    }
    case "00000001" {
      test = flag CF
      return test
    }
    case "00000010" {
      test = flag ZF
      return test
    }
    case "00000011" {
      first = flag CF
      result = choose first : flag {
        then {
          return 1
        }
        else {
          second = flag ZF
          return second
        }
      }
      return result
    }
    case "00000100" {
      test = flag SF
      return test
    }
    case "00000101" {
      test = flag PF
      return test
    }
    case "00000110" {
      sign = flag SF
      overflow = flag OF
      return xor(sign, overflow)
    }
    case "00000111" {
      first = flag ZF
      result = choose first : flag {
        then {
          return 1
        }
        else {
          sign = flag SF
          overflow = flag OF
          return xor(sign, overflow)
        }
      }
      return result
    }
    otherwise unsupported
  }
  return xor(positive, lowBit(condition))
}
action relativeJump "branch from live post-fetch IP" (offset: 16) {
  position = register IP
  IP <- add(position, offset)
}
codes branches: 8 {
  0000 "JO" = 0
  0001 "JNO" = 1
  0010 "JB" = 2
  0011 "JAE" = 3
  0100 "JE" = 4
  0101 "JNE" = 5
  0110 "JBE" = 6
  0111 "JA" = 7
  1000 "JS" = 8
  1001 "JNS" = 9
  1010 "JP" = 10
  1011 "JNP" = 11
  1100 "JL" = 12
  1101 "JGE" = 13
  1110 "JLE" = 14
  1111 "JG" = 15
}
family conditionalBranch "0111 cccc" for c in branches named "{c} rel8" {
  offset = source immediateByte
  condition = operand c
  decision = source branchDecision(condition)
  clocks select(decision, u16(16), u16(4))
  when decision {
    perform relativeJump(signExtend(offset, 16))
  }
}

LOOPNE/LOOPE/LOOP/JCXZ use 1110 00 cc. LOOP decrements CX without changing flags, then rereads live CX. A zero count skips any ZF read; otherwise LOOPNE requires ZF clear and LOOPE requires it set. Their initial costs are 5/6 clocks; a taken branch adds 14/12, giving Intel's 19/18 clocks without moving the ZF read ahead of counter writeback. JCXZ tests CX once and never decrements it. Relative JMP uses 1110 10 s 1, where s=0 takes a word and s=1 sign-extends a byte. All offsets are fetched before these effects.

family LOOPNE "1110 00 00" named "LOOPNE rel8" {
  offset = source immediateByte
  counter = register CX
  clocks u16(5)
  CX <- subtract(counter, u16(1))
  remaining = register CX
  when not(zero(remaining)) {
    equal = flag ZF
    when not(equal) {
      clocks + u16(14)
      perform relativeJump(signExtend(offset, 16))
    }
  }
}

family LOOPE "1110 00 01" named "LOOPE rel8" {
  offset = source immediateByte
  counter = register CX
  clocks u16(6)
  CX <- subtract(counter, u16(1))
  remaining = register CX
  when not(zero(remaining)) {
    equal = flag ZF
    when equal {
      clocks + u16(12)
      perform relativeJump(signExtend(offset, 16))
    }
  }
}

family LOOP "1110 00 10" named "LOOP rel8" {
  offset = source immediateByte
  counter = register CX
  clocks select(equal(counter, u16(1)), u16(5), u16(17))
  CX <- subtract(counter, u16(1))
  remaining = register CX
  when not(zero(remaining)) {
    perform relativeJump(signExtend(offset, 16))
  }
}

family JCXZ "1110 00 11" named "JCXZ rel8" {
  offset = source immediateByte
  remaining = register CX
  clocks select(zero(remaining), u16(18), u16(6))
  when zero(remaining) {
    perform relativeJump(signExtend(offset, 16))
  }
}

family jumpWord "1110 10 0 1" named "JMP rel16" {
  clocks u16(15)
  offset = source immediateWord
  perform relativeJump(offset)
}
family jumpByte "1110 10 1 1" named "JMP rel8" {
  clocks u16(15)
  offset = source immediateByte
  perform relativeJump(signExtend(offset, 16))
}

Direct stacks, calls, and returns

The existing pushWord action owns all word pushes, including native interrupt frames. A pop captures SS:SP before reading the low/high bytes, then reads and increments live SP only after both reads succeed. POP SP subsequently replaces that incremented pointer with the captured word. Memory POP captures its destination before reading the stack and never reads the destination itself.

0101 p rrr selects register PUSH/POP. Original 8088 PUSH SP differs from other registers: its source is the captured SP minus two, before the stack's independent pointer update. The separate source makes that exception visible; all other encoded registers use the ordinary register catalogue.

source popWord "pop segmented word": 16 {
  segment = register SS
  offset = register SP
  word = source memory16(concat(segment, offset))
  pointer = register SP
  SP <- add(pointer, u16(2))
  return word
}
source pushedSP "original 8088 PUSH SP source": 16 {
  pointer = register SP
  return subtract(pointer, u16(2))
}
family pushRegister "0101 0 rrr" for r in words named "PUSH {r}" except "0101 0100" {
  clocks u16(15)
  original = operand r
  perform pushWord(original)
}
family pushSP "0101 0100" named "PUSH SP" {
  clocks u16(15)
  original = source pushedSP
  perform pushWord(original)
}
family popRegister "0101 1 rrr" for r in words named "POP {r}" {
  clocks u16(12)
  result = source popWord
  operand r <- result
}
family popOperand (overridden: 8, segmentOverride: 16) "1000 1111" named "POP r/m16" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(12), u16(25))
  clocks cost when zero(bits(postbyte, 5, 3))
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      word = source popWord
      perform writeRM16(postbyte, pointer, word)
    }
    otherwise unsupported
  }
}

Segment PUSH/POP uses 000 ss 11 p, with ss=00/01/10/11 selecting ES/CS/SS/DS. POP CS is undocumented. After any documented segment pop, request inhibition of all interrupt recognition at successful retirement. The request does not immediately change a stored boundary latch; a later failure leaves it uncommitted. The same rule applies to MOV into a segment.

operands segments {
  00 "ES" = register ES
  01 "CS" = register CS
  10 "SS" = register SS
  11 "DS" = register DS
}
family pushSegment "000 ss 110" for s in segments named "PUSH {s}" {
  clocks u16(14)
  original = operand s
  perform pushWord(original)
}
family popSegment "000 ss 111" for s in segments named "POP {s}" except "000 01 111" {
  clocks u16(12)
  result = source popWord
  operand s <- result
  defer all
}

PUSHF (1001 1100) captures the packed FLAGS word before pushing. POPF (1001 1101) reads the complete word before consulting live IF. A transition from clear IF to set IF requests INTR deferral before replacing the flag object. Reserved bits are ignored by the specification's existing restore policy. IRET uses this same specification action after completing its far return.

family pushFlags "1001 1100" named "PUSHF" {
  clocks u16(14)
  original = source FLAGS
  perform pushWord(original)
}
action restoreStackFlags "restore stacked FLAGS with recognition delay" using memory, boundary {
  status = source popWord
  oldIF = flag IF
  when not(oldIF) {
    when bit(status, 9) {
      defer intr
    }
  }
  perform restoreFlags(status)
}
family popFlags "1001 1101" named "POPF" {
  clocks u16(12)
  perform restoreStackFlags()
}

Near relative CALL (1110 1000) fetches its displacement, captures and pushes return IP, then adds the displacement to live IP after both writes. Immediate far CALL (1001 1010) fetches the complete offset and segment before pushing CS and then live IP. Far JMP (1110 1010) fetches the same pointer and writes CS before IP without a stack access. The far-transfer action also serves the indirect FF forms, after they have read their complete memory pointer.

family callRelative "1110 1000" named "CALL rel16" {
  clocks u16(23)
  displacement = source immediateWord
  returnIP = register IP
  perform pushWord(returnIP)
  perform relativeJump(displacement)
}
family callImmediateFar "1001 1010" named "CALL ptr16:16" {
  clocks u16(36)
  targetOffset = source immediateWord
  targetSegment = source immediateWord
  perform callFar(targetOffset, targetSegment)
}
family jumpImmediateFar "1110 1010" named "JMP ptr16:16" {
  clocks u16(15)
  targetOffset = source immediateWord
  targetSegment = source immediateWord
  perform jumpFar(targetOffset, targetSegment)
}

1100 f 01 n selects returns: f=0/1 means near/far, and n=0 fetches an unsigned discard count while n=1 discards zero. Fetch that count before any stack read. Pop IP and optional CS before committing either target; then read and adjust live SP, including when the discard count is zero. A failed later read leaves completed pops intact. IRET reuses the far-return action before its separate FLAGS pop, retaining the two commit points.

action returnNear "return within CS" (discard: 16) using memory {
  targetIP = source popWord
  IP <- targetIP
  pointer = register SP
  SP <- add(pointer, discard)
}
action returnFar "return to a saved CS:IP" (discard: 16) using memory {
  targetIP = source popWord
  targetCS = source popWord
  IP <- targetIP
  CS <- targetCS
  pointer = register SP
  SP <- add(pointer, discard)
}

family returnNearDiscard "1100 0 01 0" named "RET n" {
  clocks u16(16)
  discard = source immediateWord
  perform returnNear(discard)
}

family returnNearPlain "1100 0 01 1" named "RET" {
  clocks u16(12)
  perform returnNear(u16(0))
}

family returnFarDiscard "1100 1 01 0" named "RETF n" {
  clocks u16(25)
  discard = source immediateWord
  perform returnFar(discard)
}

family returnFarPlain "1100 1 01 1" named "RETF" {
  clocks u16(26)
  perform returnFar(u16(0))
}

Remaining transfers and address loads

1001 0 rrr exchanges AX with the selected word register: capture that register before AX, write AX, then write the selected register. The documented NOP encoding $90 retains the same self-exchange schedule, including both reads and writes; no flags or memory are touched.

family exchangeAX "1001 0 rrr" for r in words named "XCHG AX,{r}" except "1001 0000" {
  clocks u16(3)
  selected = operand r
  accumulator = register AX
  AX <- selected
  operand r <- accumulator
}
family NOP "1001 0000" named "NOP" {
  clocks u16(3)
  selected = register AX
  accumulator = register AX
  AX <- selected
  AX <- accumulator
}

MOV segment forms use 1000 11 d 0 and ModR/M mm 0ss rrr: d=0 stores a segment and d=1 loads one. Reject invalid segment selectors, including a CS destination, before resolving the address. Capture a complete source before the write; only a successful segment load requests recognition deferral. Each memory word uses the captured pointer and low-first accesses.

family storeSegment (overridden: 8, segmentOverride: 16) "1000 11 0 0" named "MOV r/m16,Sreg" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(2), u16(13))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = register ES
      perform writeRM16(postbyte, pointer, contents)
    }
    case "xx 001 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = register CS
      perform writeRM16(postbyte, pointer, contents)
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = register SS
      perform writeRM16(postbyte, pointer, contents)
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = register DS
      perform writeRM16(postbyte, pointer, contents)
    }
    otherwise unsupported
  }
}

family loadSegment (overridden: 8, segmentOverride: 16) "1000 11 1 0" named "MOV Sreg,r/m16" {
  postbyte = fetch
  cost = source rmClocks(postbyte, u16(2), u16(12))
  clocks cost
  match postbyte {
    case "xx 000 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = source readRM16(postbyte, pointer)
      ES <- contents
      defer all
    }
    case "xx 010 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = source readRM16(postbyte, pointer)
      SS <- contents
      defer all
    }
    case "xx 011 xxx" {
      pointer = source resolvedRM(postbyte, overridden, segmentOverride)
      contents = source readRM16(postbyte, pointer)
      DS <- contents
      defer all
    }
    otherwise unsupported
  }
}

LEA (1000 1101) resolves a memory address and writes its sixteen-bit offset without reading data. LES/LDS (1100 010 s) read offset then segment from memory, low byte first, before writing the selected general register and then ES/DS. Neither far load requests interrupt inhibition. All three reject mm=11 before resolving or reading an operand. XLAT (1101 0111) reads BX then AL, wraps their sum, then selects DS or the captured override; it reads one byte and writes AL while preserving live AH.

family LEA (overridden: 8, segmentOverride: 16) "10001101" named "LEA r16,m" {
  postbyte = fetch
  ea = source effectiveAddressClocks(postbyte)
  clocks add(u16(2), ea) when not(equal(and(postbyte, u8($C0)), u8($C0)))
  match postbyte {
    case "0x rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      operand r <- truncate(pointer, 16)
    }
    case "10 rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      operand r <- truncate(pointer, 16)
    }
    otherwise unsupported
  }
}

family LES (overridden: 8, segmentOverride: 16) "11000100" named "LES r16,m" {
  postbyte = fetch
  ea = source effectiveAddressClocks(postbyte)
  clocks add(u16(24), ea) when not(equal(and(postbyte, u8($C0)), u8($C0)))
  match postbyte {
    case "0x rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      operand r <- truncate(target, 16)
      ES <- bits(target, 31, 16)
    }
    case "10 rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      operand r <- truncate(target, 16)
      ES <- bits(target, 31, 16)
    }
    otherwise unsupported
  }
}

family LDS (overridden: 8, segmentOverride: 16) "11000101" named "LDS r16,m" {
  postbyte = fetch
  ea = source effectiveAddressClocks(postbyte)
  clocks add(u16(24), ea) when not(equal(and(postbyte, u8($C0)), u8($C0)))
  match postbyte {
    case "0x rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      operand r <- truncate(target, 16)
      DS <- bits(target, 31, 16)
    }
    case "10 rrr xxx" for r in words {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      target = source farPointer(pointer)
      operand r <- truncate(target, 16)
      DS <- bits(target, 31, 16)
    }
    otherwise unsupported
  }
}

source dataSegment "DS unless a segment override was captured" (overridden: 8, segmentOverride: 16): 16 {
  segment = match overridden: 16 {
    case "00000000" {
      original = register DS
      return original
    }
    case "00000001" {
      return segmentOverride
    }
    otherwise unsupported
  }
  return segment
}
family XLAT (overridden: 8, segmentOverride: 16) "1101 0111" named "XLAT" {
  clocks u16(11)
  base = register BX
  index = source AL
  offset = add(base, extend(index, 16))
  segment = source dataSegment(overridden, segmentOverride)
  contents = source memory8(concat(segment, offset))
  perform setAL(contents)
}

Halt and pending traps

HLT advances IP and sets halted. With no owed trap it returns outcome: "halted"; later steps return the same outcome, instruction: null, and no accesses or changes. Reset or accepted INTR/NMI releases halt. When TF was sampled for HLT, the instruction instead reports executed with halted and trapPending set. The next step delivers the trap and releases halt, so a bounded runner can continue into the trap handler. Restored snapshots preserve both the halt state and any pending trap.

Simple controls

HLT sets the stored stopped latch; the boundary still owns retirement and pending traps. CMC complements live CF. CLC/STC and CLD/STD set their selected flag without reading it. CLI clears IF directly; STI reads IF, requests INTR deferral only when it was clear, then sets it. None of these bodies commits recognition latches itself. These requests remain explicit instruction effects.

policy DIRECTION "set direction" (enabled: flag) {
  DF = enabled
}
policy INTERRUPTENABLE "set interrupt enable" (enabled: flag) {
  IF = enabled
}
family HLT "1111 0100" named "HLT" {
  clocks u16(2)
  STOPPED <- 1
}
family CMC "1111 0101" named "CMC" {
  clocks u16(2)
  incoming = flag CF
  apply CARRY(not(incoming))
}
family CLC "1111 1000" named "CLC" {
  clocks u16(2)
  apply CARRY(0)
}
family STC "1111 1001" named "STC" {
  clocks u16(2)
  apply CARRY(1)
}
family CLD "1111 1100" named "CLD" {
  clocks u16(2)
  apply DIRECTION(0)
}
family STD "1111 1101" named "STD" {
  clocks u16(2)
  apply DIRECTION(1)
}
family CLI "1111 1010" named "CLI" {
  clocks u16(2)
  apply INTERRUPTENABLE(0)
}
family STI "1111 1011" named "STI" {
  clocks u16(2)
  enabled = flag IF
  when not(enabled) {
    defer intr
  }
  apply INTERRUPTENABLE(1)
}

Port input and output

The eight forms share 1110 r 1 d w: r=0 fetches an immediate byte port, r=1 uses all of DX, d=0/1 selects input/output, and w=0/1 selects AL/AX. The immediate is zero-extended to a 16-bit port address. Port space is separate from RAM and unsegmented; no segment register contributes to a port address. See Intel's manual, sections 2.4 and 2.7, I/O addressing on page 2-72, and table 4-13.

Byte IN replaces AL while preserving AH. Word IN reads low then high bytes and replaces AX after both reads succeed. OUT captures AL or AX before calling the device. Word transfers always call the device twice, at the selected port and (port + 1) modulo 10000 in hexadecimal. Thus immediate port FF continues at 0100, and DX=FFFF continues at 0000. Odd word ports are valid. This is the 8088's byte bus behavior, with hardware comparison evidence. All flags and other registers, including DX, are preserved.

Instruction fetching still advances and wraps CS:IP before any port transfer. Segment overrides do not affect port selection. LOCK retains the model's lack of bus-arbitration effects. REP/REPNE remain unsupported for IN/OUT: rejection occurs at the opcode, before an immediate port or device callback is read. Each complete IN/OUT is one step; the runner cannot pause between its two bytes.

An absent device fails only when a port instruction reaches its transfer. Input values must be integers in 0–255; malformed values throw RangeError without coercion. Callback failures propagate without a step record or rollback: fetched IP and completed device effects remain. A failed word IN leaves all of AX unchanged; a failed second output leaves the first output delivered. These are host-failure policies, not hardware exceptions or resumable bus cycles.

RAM and device callbacks may inspect snapshots but cannot recursively call step(), reset(), or interrupt() on the same CPU. The shared execution guard clears after success or failure. Reset, inspection, and already halted steps never call the port connection. Reset preserves the connection and external device state.

Byte input replaces AL while preserving AH as observed after the callback.

source immediatePort "zero-extended immediate port": 16 {
  byte = fetch
  return extend(byte, 16)
}
source dxPort "DX port": 16 {
  selector = register DX
  return selector
}

The paired encodings share a body through their port source. Explicit byte accesses keep partial word failures and device ordering visible.

source immediatePortClocks "immediate port timing": 16 {
  return u16(10)
}
source dxPortClocks "DX port timing": 16 {
  return u16(8)
}
family IN8 {
  encoding "1110 0 1 0 0" with portSource = immediatePort, portCost = immediatePortClocks named "IN AL,n"
  encoding "1110 1 1 0 0" with portSource = dxPort, portCost = dxPortClocks named "IN AL,DX"
  cost = source portCost
  clocks cost
  selector = source portSource
  low = port(selector)
  perform setAL(low)
}

family OUT8 {
  encoding "1110 0 1 1 0" with portSource = immediatePort, portCost = immediatePortClocks named "OUT n,AL"
  encoding "1110 1 1 1 0" with portSource = dxPort, portCost = dxPortClocks named "OUT DX,AL"
  cost = source portCost
  clocks cost
  selector = source portSource
  contents = source AL
  port(selector) <- contents
}

family IN16 {
  encoding "1110 0 1 0 1" with portSource = immediatePort, portCost = immediatePortClocks named "IN AX,n"
  encoding "1110 1 1 0 1" with portSource = dxPort, portCost = dxPortClocks named "IN AX,DX"
  cost = source portCost
  clocks add(cost, u16(4))
  selector = source portSource
  low = port(selector)
  high = port(add(selector, u16(1)))
  AX <- concat(high, low)
}

family OUT16 {
  encoding "1110 0 1 1 1" with portSource = immediatePort, portCost = immediatePortClocks named "OUT n,AX"
  encoding "1110 1 1 1 1" with portSource = dxPort, portCost = dxPortClocks named "OUT DX,AX"
  cost = source portCost
  clocks add(cost, u16(4))
  selector = source portSource
  contents = register AX
  port(selector) <- lowByte(contents)
  port(add(selector, u16(1))) <- highByte(contents)
}

String elements and repetition

1010 ooo w selects MOVS (ooo=010), CMPS (011), STOS (101), LODS (110), or SCAS (111); w=0/1 means byte/word. Each instruction invocation handles one element, preserving an interrupt boundary between repeated elements. The prefix scanner supplies repeatMode=0 for no repeat, 1 for F3 (REP/REPE), and 2 for F2 (REPNE), plus the original prefix-start IP and a captured segment override. REPNE is supported only for CMPS/SCAS.

26/2E/36/3E override the data segment with ES/CS/SS/DS. They apply to explicit memory operands, absolute MOV, XLAT, and string sources. They never redirect instruction fetches, implicit stack accesses, or string destinations in ES. F0 (LOCK) is consumed with its instruction; bus arbitration has no modeled state or effect: this model has no competing bus master. Memory XCHG has the same limitation.

Prefix state belongs to the current attempt and is included in fetched bytes. The last segment prefix and last repeat prefix win independently. A prefix-only 64 KiB code segment rejects after one full scan, preserving state and RAM; there is no later-x86 fifteen-byte instruction limit.

MOVS copies DS:SI to ES:DI; CMPS subtracts ES:DI from DS:SI; STOS stores AL/AX at ES:DI; LODS loads AL/AX from DS:SI; SCAS subtracts ES:DI from AL/AX. CMPS/SCAS set subtraction flags without writing either operand. The other string operations preserve flags. Used indices advance by one/two bytes with DF clear or retreat with DF set, wrapping to 16 bits; unprefixed strings preserve CX.

F3 repeats MOVS/STOS/LODS while CX is nonzero and repeats CMPS/SCAS while CX is nonzero and the new ZF is set. F2 repeats CMPS/SCAS while CX is nonzero and the new ZF is clear. REPNE on other strings and REP on non-string instructions are undocumented and rejected. Incoming ZF does not prevent the first comparison. A zero starting CX completes after instruction fetches, without data accesses, index changes, or flag changes.

One repeated element is one step. After an element, decrement CX; if another is required, restore IP to the first prefix. The next step refetches the complete encoding. The final element leaves IP after the instruction. This makes the runner's step budget meaningful and allows snapshot restoration without hidden iteration state. RAM edits between steps affect the next fetch and operand; self-modifying REP therefore follows this explicit instruction-level policy, without hardware prefetch-queue behavior.

Nonempty operations capture source segment/SI and fixed destination ES/DI before any data access, even if the selected operation uses only one side. This retains the declared model's observable access schedule. Word reads/writes use the same low-first, offset-wrapping memory rules as other instructions.

codes stringWidths: 8 {
  0 "B" = 0
  1 "W" = 1
}
source stringCount "plain operation or remaining repeat count" (repeatMode: 8): 16 {
  count = match repeatMode: 16 {
    case "00000000" {
      return u16(1)
    }
    case "00000001" {
      count = register CX
      return count
    }
    case "00000010" {
      count = register CX
      return count
    }
    otherwise unsupported
  }
  return count
}
source stringRead "byte or word string operand" (pointer: 32, width: 8): 16 {
  contents = match width: 16 {
    case "00000000" {
      byte = source memory8(pointer)
      return extend(byte, 16)
    }
    case "00000001" {
      word = source memory16(pointer)
      return word
    }
    otherwise unsupported
  }
  return contents
}
action stringWrite "store byte or word string operand" (pointer: 32, width: 8, contents: 16) using memory {
  match width {
    case "00000000" {
      perform store8(pointer, lowByte(contents))
    }
    case "00000001" {
      perform store16(pointer, contents)
    }
    otherwise unsupported
  }
}
source stringAccumulator "AL or AX string operand" (width: 8): 16 {
  contents = match width: 16 {
    case "00000000" {
      byte = source AL
      return extend(byte, 16)
    }
    case "00000001" {
      word = register AX
      return word
    }
    otherwise unsupported
  }
  return contents
}
action loadStringAccumulator "load AL or AX from a captured string operand" (width: 8, contents: 16) {
  match width {
    case "00000000" {
      perform setAL(lowByte(contents))
    }
    case "00000001" {
      AX <- contents
    }
    otherwise unsupported
  }
}
action compareStringOperands "subtraction flags without operand writeback" (width: 8, left: 16, right: 16) {
  match width {
    case "00000000" {
      result = subtract(lowByte(left), lowByte(right))
      apply SUBFLAGS<8>(lowByte(left), lowByte(right), result, 0)
    }
    case "00000001" {
      result = subtract(left, right)
      apply SUBFLAGS<16>(left, right, result, 0)
    }
    otherwise unsupported
  }
}

After data and comparison flags, read live DF to select the signed byte/word stride. Advance live SI before DI when both apply. Repeated forms then decrement live CX and reread it; only a nonzero count tests the new ZF for CMPS/SCAS. A continuing operation rewinds IP to the supplied prefix start. The next step refetches prefixes, so code changes, snapshot resumption, and interrupt offers remain observable. The finishing action's numeric selectors describe which indices and repeat condition the calling family uses.

action finishString "advance live indices and decide repetition" (width: 8, useSI: 8, useDI: 8, compares: 8, repeatMode: 8, startIP: 16) {
  backward = flag DF
  size = add(extend(width, 16), u16(1))
  delta = select(backward, subtract(u16(0), size), size)
  when not(zero(useSI)) {
    position = register SI
    SI <- add(position, delta)
  }
  when not(zero(useDI)) {
    position = register DI
    DI <- add(position, delta)
  }
  when not(zero(repeatMode)) {
    count = register CX
    CX <- subtract(count, u16(1))
    remaining = register CX
    when not(zero(remaining)) {
      when zero(compares) {
        IP <- startIP
      }
      when not(zero(compares)) {
        matched = flag ZF
        when xor(matched, equal(repeatMode, u8(2))) {
          IP <- startIP
        }
      }
    }
  }
}

MOVS captures the complete source before writing the destination. CMPS reads source before destination; STOS captures AL/AX; LODS preserves live AH for byte loads; SCAS captures AL/AX before reading the destination. CMPS and SCAS apply the same subtraction flags as ordinary SUB, without writing either operand. Each family keeps these effects between the common count guard and index/repeat action.

family stringMove (overridden: 8, segmentOverride: 16, repeatMode: 8, startIP: 16) "1010 010 w" for w in stringWidths named "MOVS{w}" {
  reject "opcode" if equal(repeatMode, u8(2))
  count = source stringCount(repeatMode)
  width = operand w
  wordClocks = select(zero(width), u16(0), u16(8))
  repeatClocks = add(u16(9), select(zero(count), u16(0), add(u16(17), wordClocks)))
  clocks select(zero(repeatMode), add(u16(18), wordClocks), repeatClocks)
  when not(zero(count)) {
    sourceSegment = source dataSegment(overridden, segmentOverride)
    sourceOffset = register SI
    destinationSegment = register ES
    destinationOffset = register DI
    contents = source stringRead(concat(sourceSegment, sourceOffset), width)
    perform stringWrite(concat(destinationSegment, destinationOffset), width, contents)
    perform finishString(width, u8(1), u8(1), u8(0), repeatMode, startIP)
  }
}

family stringCompare (overridden: 8, segmentOverride: 16, repeatMode: 8, startIP: 16) "1010 011 w" for w in stringWidths named "CMPS{w}" {
  count = source stringCount(repeatMode)
  width = operand w
  wordClocks = select(zero(width), u16(0), u16(8))
  repeatClocks = add(u16(9), select(zero(count), u16(0), add(u16(22), wordClocks)))
  clocks select(zero(repeatMode), add(u16(22), wordClocks), repeatClocks)
  when not(zero(count)) {
    sourceSegment = source dataSegment(overridden, segmentOverride)
    sourceOffset = register SI
    destinationSegment = register ES
    destinationOffset = register DI
    left = source stringRead(concat(sourceSegment, sourceOffset), width)
    right = source stringRead(concat(destinationSegment, destinationOffset), width)
    perform compareStringOperands(width, left, right)
    perform finishString(width, u8(1), u8(1), u8(1), repeatMode, startIP)
  }
}

family stringStore (overridden: 8, segmentOverride: 16, repeatMode: 8, startIP: 16) "1010 101 w" for w in stringWidths named "STOS{w}" {
  reject "opcode" if equal(repeatMode, u8(2))
  count = source stringCount(repeatMode)
  width = operand w
  wordClocks = select(zero(width), u16(0), u16(4))
  repeatClocks = add(u16(9), select(zero(count), u16(0), add(u16(10), wordClocks)))
  clocks select(zero(repeatMode), add(u16(11), wordClocks), repeatClocks)
  when not(zero(count)) {
    sourceSegment = source dataSegment(overridden, segmentOverride)
    sourceOffset = register SI
    destinationSegment = register ES
    destinationOffset = register DI
    contents = source stringAccumulator(width)
    perform stringWrite(concat(destinationSegment, destinationOffset), width, contents)
    perform finishString(width, u8(0), u8(1), u8(0), repeatMode, startIP)
  }
}

family stringLoad (overridden: 8, segmentOverride: 16, repeatMode: 8, startIP: 16) "1010 110 w" for w in stringWidths named "LODS{w}" {
  reject "opcode" if equal(repeatMode, u8(2))
  count = source stringCount(repeatMode)
  width = operand w
  wordClocks = select(zero(width), u16(0), u16(4))
  repeatClocks = add(u16(9), select(zero(count), u16(0), add(u16(13), wordClocks)))
  clocks select(zero(repeatMode), add(u16(12), wordClocks), repeatClocks)
  when not(zero(count)) {
    sourceSegment = source dataSegment(overridden, segmentOverride)
    sourceOffset = register SI
    destinationSegment = register ES
    destinationOffset = register DI
    contents = source stringRead(concat(sourceSegment, sourceOffset), width)
    perform loadStringAccumulator(width, contents)
    perform finishString(width, u8(1), u8(0), u8(0), repeatMode, startIP)
  }
}

family stringScan (overridden: 8, segmentOverride: 16, repeatMode: 8, startIP: 16) "1010 111 w" for w in stringWidths named "SCAS{w}" {
  count = source stringCount(repeatMode)
  width = operand w
  wordClocks = select(zero(width), u16(0), u16(4))
  repeatClocks = add(u16(9), select(zero(count), u16(0), add(u16(15), wordClocks)))
  clocks select(zero(repeatMode), add(u16(15), wordClocks), repeatClocks)
  when not(zero(count)) {
    sourceSegment = source dataSegment(overridden, segmentOverride)
    sourceOffset = register SI
    destinationSegment = register ES
    destinationOffset = register DI
    left = source stringAccumulator(width)
    right = source stringRead(concat(destinationSegment, destinationOffset), width)
    perform compareStringOperands(width, left, right)
    perform finishString(width, u8(0), u8(1), u8(1), repeatMode, startIP)
  }
}

Interrupt instructions and shared entry

All sources use the same four-byte vector in physical memory at vector × 4: little-endian IP followed by CS. Entry reads all four bytes before any stack write, so a stack overlapping the vector table cannot redirect the current entry. It captures FLAGS, clears IF/TF, and pushes FLAGS, CS, then return IP through SS:SP. Each push decrements SP by two and writes low byte first, with the ordinary segment-offset wrapping rule. The final stack therefore contains IP at SP, CS at SP+2, and FLAGS at SP+4. Entry does not fetch handler code.

INT3 selects type 3; INT n fetches its type byte; INTO selects type 4 only when OF is set. Untaken INTO performs only its instruction fetch. These software entries and divide errors are not blocked by IF or recognition delays. IRET pops IP, CS, then FLAGS, restoring all nine flag bits and ignoring reserved bits. It uses the same word-pop and flag-restoration helpers as RETF and POPF.

The vector-first order follows the original interrupt microcode described in Ken Shirriff's silicon analysis and is checked against the hardware fixtures.

The formal entry action clears TF then IF and the recognition/wait/halt latches. It pushes FLAGS, live CS, and live IP, then commits target CS and IP after the complete frame succeeds. Software forms use 1100 11 tt: INT3 (tt=00), INT with a fetched type byte (01), INTO (10), and IRET (11). report interrupt records completed software delivery; it performs no vector or stack effects itself.

policy ENTRYFLAGS "disable traps and mask INTR" () {
  TF = 0
  IF = 0
}
action enterInterrupt "interrupt entry" (vector: 8) using memory {
  offset = shiftBits(extend(vector, 16), left, 2)
  targetIP = source memory16(concat(u16(0), offset))
  targetCS = source memory16(concat(u16(0), add(offset, u16(2))))
  savedFlags = source FLAGS
  apply ENTRYFLAGS()
  RECOGNITIONDEFERRED <- 0
  INTRDEFERRED <- 0
  WAITING <- 0
  STOPPED <- 0
  perform pushWord(savedFlags)
  savedCS = register CS
  perform pushWord(savedCS)
  savedIP = register IP
  perform pushWord(savedIP)
  CS <- targetCS
  IP <- targetIP
}
family INT3 "1100 1100" named "INT3" {
  clocks u16(72)
  perform enterInterrupt(u8(3))
  report interrupt(u8(3))
}
family INT "1100 1101" named "INT n" {
  clocks u16(71)
  vector = fetch
  perform enterInterrupt(vector)
  report interrupt(vector)
}
family INTO "1100 1110" named "INTO" {
  overflow = flag OF
  clocks select(overflow, u16(73), u16(4))
  when overflow {
    perform enterInterrupt(u8(4))
    report interrupt(u8(4))
  }
}
family IRET "1100 1111" named "IRET" {
  clocks u16(36)
  perform returnFar(u16(0))
  perform restoreStackFlags()
}

IRET completes the ordinary far return before the separate FLAGS pop. Failed FLAGS reads retain restored IP/CS and completed SP changes. The shared FLAGS action requests INTR delay on a zero-to-one IF transition before replacing flags. The specification retirement policy samples the instruction's original TF, so restoring flags cannot retroactively create or cancel that owed trap.

External interrupt delivery

interrupt("intr", acknowledge) offers a currently asserted INTR request; interrupt("nmi") offers an NMI edge selected by the caller. Neither queues a request. The caller retains asserted INTR and latched NMI requests, offers NMI first when both are pending, and reoffers a deferred request at a later boundary. IF masks INTR only. interruptDeferred delays INTR; recognitionDeferred delays both sources. Ignored offers do not acknowledge, access memory, or release halt/wait.

Accepted INTR releases halt/wait and calls acknowledge() once for an integer type byte in 0–255. This callback abstracts the whole hardware acknowledgement sequence, including its two INTA bus cycles; only the supplied byte is recorded. NMI uses type 2 without a callback. Both enter through the current CS:IP, which already points after HLT, to the first prefix of an unfinished REP, or to an in-progress WAIT opcode.

Cpu8088InterruptRecord has detached before/after snapshots, source, instruction: null, and ordered accesses. An accepted record adds outcome: "accepted" and vector; an ignored record adds outcome: "ignored" and reason: "masked" | "deferred". Deferral takes precedence when both apply. INTR's { kind: "acknowledge", value } precedes vector reads and frame writes. The callback is required for an eligible INTR offer and its byte is validated before any vector access. The API does not model a PIC, pin timing, or an interrupt scheduler.

Recognition delays and single stepping

CLI clears IF. STI sets IF and, when it changes from zero to one, inhibits INTR through the following retired instruction. POPF and IRET apply the same delay when restoring IF from zero to one. Another STI with IF already set consumes an existing delay without renewing it. NMI and traps do not use this IF delay.

On the original chip, MOV/POP into any segment register delays recognition of INTR, NMI, and traps through the following instruction. This includes ES and DS, not only SS; consecutive such loads renew recognitionDeferred. LES/LDS and control-flow changes to CS do not set it. A retired instruction, REP element, or successful WAIT sample consumes a delay. TEST release renews the delay for all sources. Unsupported encodings and failed ordinary instructions do not consume it; partially completed entry follows the failure policy below. Prefixes never form separate retirement boundaries.

TF is sampled before execution. An instruction/REP element or WAIT sample with it set leaves trapPending; the next step() delivers type 1 before fetching another instruction, unless segment inhibition requires another instruction first. Thus POPF/IRET setting TF begins trapping after the following instruction; clearing TF does not cancel the trap owed for the instruction that cleared it. An owed trap survives higher-priority software, divide-error, or external entry and is delivered before that handler's first instruction. Callers offer pending NMI/INTR before step() to preserve their priority over a pending trap.

Each REP element is a boundary in this model. Interruption saves the first prefix's address while repetition is unfinished; IRET resumes with the remaining CX and advanced indices. This retains the existing complete-prefix refetch policy. It does not emulate the original chip's multiple-prefix restart quirks or prefetch-dependent self-modification.

Interrupt connection failures

RAM and acknowledgement callbacks may inspect state, but reentrant mutating calls throw before changing it. Callback errors propagate without a record or rollback. A failing acknowledgement leaves an accepted INTR's halt/wait release in place, with no vector read. A failing vector read preserves CS:IP and SP; IF/TF clear only after the entire vector is read. A failing frame write leaves that flag clearing, each attempted push's SP decrement, and completed writes in place; the target CS:IP commits after all three pushes succeed.

IRET commits each completed pop's SP increment, changes CS:IP after both return words, then restores FLAGS after its complete word is read. A pending trap is consumed when its delivery begins, including on failure. These rules expose host failures; they do not model bus faults, cycle-level register timing, or resumption halfway through an instruction. Reset always clears all recognition latches without reading RAM or calling a device.

WAIT and coprocessor escape

ESC has the pattern 1101 1ooo + mm ppp rrr. Concatenate ooo:ppp to obtain the external opcode (0–63). The remaining ModR/M bits select a source using ordinary segment defaults, overrides, displacements, and offset wrapping. Register forms make no data access and expose no CPU register value. Memory forms always read a word, low byte first, including external operations that will eventually write memory. The CPU itself discards that word and changes no registers or flags except advancing IP and the ordinary recognition latches.

After the read, an attached escape(request) receives a Cpu8088Escape:

  • opcode: the decoded six-bit external opcode.
  • modRM: the complete second byte, preserving the external processor's selectors.
  • memory: null for a register form, or { segment, offset, address, value } for a memory form. address is the first byte's physical address and value is the word read by the CPU.

The callback receives a detached request. The step then records { kind: "escape", ...request } with its own copy, after instruction and data reads. With no callback, ESC still performs the dummy word read and records the event. An external processor owns any further reads, writes, computation, and busy state; those operations are not invented CPU accesses. This interface represents execution of ESC, not instruction-queue snooping or bus arbitration. A complete 8087 implementation is not required.

WAIT calls test() once per step and records { kind: "test", high }. The Boolean represents the physical pin: high waits, low continues. A machine without a busy device can explicitly connect test: () => false. There is no default pin level; a missing TEST connection throws when WAIT is actually sampled. The arithmetic TEST instruction is unrelated.

On a high sample, waiting becomes true and IP identifies the WAIT opcode, including after segment/LOCK prefixes and across IP wrapping. The first step retains the fetched bytes and reports waiting. Later steps sample TEST without fetching RAM; they report instruction: null. A low sample clears waiting, advances IP past WAIT, and reports executed; a continuation also carries continuation: "wait". No following instruction is executed in that same step. The shared runner stops on waiting and can resume when the machine changes TEST. A saved waiting snapshot needs no hidden prefix or continuation object. RAM edits do not replace an in-progress WAIT; interrupt return refetches current RAM.

Each busy sample consumes prior recognition delays and permits INTR/NMI or a single-step trap at the next boundary. IF still masks INTR. An accepted entry clears waiting and saves the WAIT opcode's IP, so IRET refetches it. Prefixes are not restored: they have no operand effect on WAIT. An ignored request leaves waiting state untouched. As with REP, a sample with incoming TF set owes a trap; a busy WAIT with an owed trap reports executed so the runner can reach that entry instead of stopping. An already owed eligible trap is delivered before sampling TEST again.

A low TEST sample inhibits all recognition through the following instruction, including when WAIT never had to pause. It sets recognitionDeferred, the same latch used by segment loads. TF can still owe a trap; delivery waits until the following instruction (normally ESC) retires. Consecutive ready WAIT instructions renew the delay. These rules follow the manual and microcode comparison. The step boundary abstracts wait-test cycles; pin synchronization, clock counts, the interrupted WAIT's hardware refetch before vectoring, and prefetch are not modeled.

Missing/malformed TEST callbacks and thrown device errors propagate without a record. TEST must return a Boolean without coercion. Initial instruction fetches remain reflected in IP; a failed continuation leaves its waiting state and IP unchanged. No recognition latch changes until the sample succeeds. If an ESC operand read fails, no external request is issued. If the ESC callback fails, completed reads, fetched IP, and device effects remain; no rollback is attempted. Callbacks preserve their receiver and may inspect snapshots but cannot reenter step, reset, or interrupt. Reset clears waiting and recognition state while preserving connections and devices; it neither samples TEST nor issues ESC.

action pollWait "sample TEST for initial WAIT or continuation" (resuming: 8) using boundary {
  high = sample test
  WAITING <- high
  when zero(resuming) {
    when high {
      position = register IP
      IP <- subtract(position, u16(1))
    }
  }
  when not(zero(resuming)) {
    when not(high) {
      position = register IP
      IP <- add(position, u16(1))
    }
  }
  when not(high) {
    defer all
  }
}
family WAIT "1001 1011" named "WAIT" {
  perform pollWait(u8(0))
}

ESC uses 1101 1 ooo and ModR/M mm ppp rrr; ooo:ppp forms the six-bit coprocessor opcode. Register forms send the selector without reading CPU registers. Memory forms resolve the ordinary segmented address and read a complete dummy word even if no coprocessor is connected. Only then do they send the captured segment, offset, physical address, and word. The device adapter detaches requests and records only successful delivery. ESC itself leaves CPU state and flags unchanged; no coprocessor arithmetic is emulated.

codes escapes: 8 {
  000 "0" = 0
  001 "1" = 1
  010 "2" = 2
  011 "3" = 3
  100 "4" = 4
  101 "5" = 5
  110 "6" = 6
  111 "7" = 7
}
action escapeMemory "capture dummy word before ESC delivery" (opcode: 8, postbyte: 8, pointer: 32) using memory, boundary {
  segment = bits(pointer, 31, 16)
  offset = truncate(pointer, 16)
  contents = source memory16(pointer)
  send escape(opcode, postbyte) with memory(segment, offset, projectAddress(segment, offset, 4, 20), contents)
}
family ESC (overridden: 8, segmentOverride: 16) "1101 1 ooo" for o in escapes named "ESC {o}" {
  postbyte = fetch
  highOpcode = operand o
  opcode = or(shiftBits(highOpcode, left, 3), extend(bits(postbyte, 5, 3), 8))
  match postbyte {
    case "11 xxx xxx" {
      send escape(opcode, postbyte)
    }
    case "0x xxx xxx" {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      perform escapeMemory(opcode, postbyte, pointer)
    }
    case "10 xxx xxx" {
      pointer = source effectiveAddress(postbyte, overridden, segmentOverride)
      perform escapeMemory(opcode, postbyte, pointer)
    }
    otherwise unsupported
  }
}

Reset and execution lifecycle

Reset selects CS:IP=FFFF:0000, clears DS/SS/ES, all nine flags, halt/wait, and the three recognition latches. It reads no vector: physical $FFFF0 is the next instruction address. General registers and RAM are preserved because Intel leaves those register reset values unspecified. This is a deterministic model policy, not a claim about their power-on values.

policy RESETFLAGS "clear every modeled flag" () {
  CF = 0
  PF = 0
  AF = 0
  ZF = 0
  SF = 0
  TF = 0
  IF = 0
  DF = 0
  OF = 0
}
action resetState "reset control state, preserving general registers and RAM" {
  CS <- u16($FFFF)
  IP <- u16(0)
  WAITING <- 0
  STOPPED <- 0
  TRAPPENDING <- 0
  RECOGNITIONDEFERRED <- 0
  INTRDEFERRED <- 0
  ES <- u16(0)
  SS <- u16(0)
  DS <- u16(0)
  replace RESETFLAGS()
}
action setIP "replace the logical instruction offset" (offset: 16) {
  IP <- offset
}
action advanceIP "advance the live offset after one successful fetch" {
  offset = register IP
  IP <- add(offset, u16(1))
}
action beginTrap "consume an owed trap before attempting entry" {
  TRAPPENDING <- 0
}
action retireInstruction "commit requested delays and the original trap sample" (intr: flag, all: flag, sampledTF: flag, owedTrap: flag) {
  INTRDEFERRED <- intr
  RECOGNITIONDEFERRED <- all
  TRAPPENDING <- or(owedTrap, sampledTF)
}

Each successful instruction-byte read advances live IP, including prefix and operand bytes. The offset wraps without carrying into CS. A failed read does not advance it. The separately declared PC view labels the instruction record; fetching projects live CS:IP onto the twenty-bit bus.

The segment prefixes have encoding 001 ss 110, where ss=00/01/10/11 selects ES/CS/SS/DS. Each captures the selected segment's value when consumed; a later override replaces that capture. F2/F3 similarly replace the repeat mode. LOCK is consumed without bus-arbitration effects. Prefixes belong to one attempt, never retire separately, and are bounded by one full code segment rather than a later-x86 instruction-length limit. Repeat prefixes admit only families with repeat inputs; those families check the mode and execute at most one element, rewinding to startIP when another element remains.

An owed trap runs before halt or WAIT unless recognition is inhibited. Its latch clears before vector reads, even if entry fails. Otherwise a halted step has no accesses; a waiting step calls pollWait(1) without fetching. Ordinary attempts and successful WAIT samples capture TF and the owed-trap latch before effects, then commit requested INTR/all delays and those samples at retirement. POPF/IRET cannot retroactively change the sampled TF. Busy WAIT and HLT report executed when a trap is owed, allowing a runner to reach its entry. A released WAIT reports a continuation with no instruction bytes.

Unsupported encodings restore the original IP and skip retirement; failed host callbacks retain completed effects and propagate without a record. A divide error enters type 0 from the following IP, as on the original 8088, then retires with the original trap sample. Software entry reports its own vector after its explicit effects. Neither kind cancels an owed trap. External INTR/NMI offers share the same reentrancy guard. Their ordered gates below preserve deferral-before-masking and reject without acknowledgement or wake-up. Eligible INTR validates callback presence before releasing WAIT/HLT, then obtains its vector. Both sources use the specification's shared entry action.

action acceptInterrupt "release WAIT and HLT before acknowledgement or entry" {
  WAITING <- 0
  STOPPED <- 0
}

execution segmented {
  timing prefix 2 pending 70 offers "intr" 81, "nmi" 70
  memory 20
  counter IP write setIP
  segment CS shift 4
  record address PC
  fetch action advanceIP
  prefixes limit 65536 {
    segment $26 ES
    segment $2E CS
    segment $36 SS
    segment $3E DS
    ignore $F0
    repeat $F2 2
    repeat $F3 1
  }
  stopped STOPPED
  waiting WAITING with pollWait(1)
  pending "trap" vector 1 when TRAPPENDING unless RECOGNITIONDEFERRED with beginTrap then enterInterrupt
  fault "divide-error" vector 0 with enterInterrupt
  reset action resetState
  retire action retireInstruction sampling flag TF, latch TRAPPENDING
  unsupported restore counter
  failure retain
  interrupt offers {
    source intr acknowledge {
      unless latch RECOGNITIONDEFERRED otherwise "deferred"
      unless latch INTRDEFERRED otherwise "deferred"
      when flag IF otherwise "masked"
      accept action acceptInterrupt
      enter action enterInterrupt
    }
    source nmi vector 2 {
      unless latch RECOGNITIONDEFERRED otherwise "deferred"
      accept action acceptInterrupt
      enter action enterInterrupt
    }
  }
}

Public interface

The generated class accepts RAM, complete initial state, and optional machine-owned port, TEST, and ESC connections. Snapshot byte registers and the physical PC are views over copied state. Restoring a snapshot recreates every CPU-owned boundary latch; the machine restores its memory and device state separately.

interface Cpu8088 "Instruction-level Intel 8088 with a 20-bit memory bus and segmented addresses." {
  snapshot al = AL
  snapshot ah = AH
  snapshot bl = BL
  snapshot bh = BH
  snapshot cl = CL
  snapshot ch = CH
  snapshot dl = DL
  snapshot dh = DH
  snapshot pc = PC
}

Stored state and derived views

Cpu8088State contains stored fields only. .machine definitions conventionally use uppercase register/flag names and camelCase control latches. Snapshots add AL/AH, BL/BH, CL/CH, DL/DH, and physical pc; these cannot be assigned in a .machine definition. There is no public packed FLAGS field or prefetch queue. Pending external requests and NMI edge detection belong to the caller.

Construction and inspection

Memory uses the byte-connection contract, with RAM as the usual connection.

new Cpu8088(ram, initialState, connections?) requires a 1 MiB byte-memory address space. It copies and validates every declared register and flag. Each field is read once, including non-enumerable getters; extra metadata and derived views are ignored. Invalid numeric state or address-space size throws RangeError; non-Boolean flags or control latches throw TypeError. Construction performs neither reset nor RAM/device accesses.

The optional Cpu8088Connections object provides ports, escape, and test. ports is a BytePorts connection with readPort(port) and writePort(port, value): 16-bit addresses and one byte per callback. escape(request) receives decoded external instructions; test() samples the physical TEST level. Each connection is independent. Devices own their state; a reconstructed CPU must reconnect separately restored devices. snapshot() returns detached state and views without accessing RAM. Its TypeScript type is recursively readonly. Bypassing that typing cannot change the CPU, another snapshot, or a retained execution record. A snapshot may initialize a new CPU; its views are recomputed from its stored registers. The CPU retains no execution history.

Instruction steps

step() delivers an owed trap, samples an already waiting TEST input, or attempts one instruction/REP element. A fetched instruction returns a Cpu8088StepRecord with independent before/after snapshots, the instruction's physical start address and fetched bytes, ordered Cpu8088Access entries, and an outcome. Memory read/write entries contain physical address and value; port input/output entries contain port and value. ESC adds a decoded escape event; WAIT adds a test sample. All share one log in transfer order. Cpu8088MemoryAccess remains the memory-only alias. Software and divide-error entry attach interrupt: { source, vector } to the triggering instruction's executed record. An owed single-step trap instead returns instruction: null, outcome: "executed", and interrupt: { source: "trap", vector: 1 }; its step contains vector/frame accesses and no instruction fetch. External offers have their own records, described below.

Lengths follow the encoding: opcode, optional ModR/M and displacement, then any immediate. All instruction bytes are fetched before data accesses; data bytes do not appear in instruction.bytes. Word reads and writes are low byte first. MOV reads only its source and writes only its destination. ALU operations read the original operands before writing a result; CMP and TEST perform no destination write. Writes are recorded even when values are unchanged. If a write overlaps code, it cannot alter the already fetched instruction; later steps read current RAM while retained records keep their earlier bytes.

Timed steps and interrupt offers

stepTimed() returns the ordinary step record plus clocks, the nominal CPU clock count declared by the instruction. Prefix costs are added once per fetched prefix. HLT costs 2 clocks when executed; an already halted CPU reports zero and performs no accesses. An owed trap costs 70. interruptTimed() has the same gates, acknowledgement rules, and records as interrupt(), plus 81 clocks for accepted INTR, 70 for accepted NMI, and zero for an ignored offer. Those entry costs include five word transfers (three frame words and two vector words); INTR additionally includes its two byte-wide acknowledgement cycles. IRET’s 36 clocks include three word reads. There is no cumulative CPU clock field: the machine owns elapsed time and idle progression.

Missing instruction timing, an unavailable group form, unsupported encoding, or waiting continuation throws InstructionTimingUnavailable with the physical start address and the bytes fetched so far. It restores IP, skips retirement, and performs no operand effects. Completed instruction reads and any host callback effects remain; rejection cannot undo a device-backed fetch. Other thrown failures retain the ordinary completed-effect policy and produce no elapsed-clock record. Untimed step() and interrupt() retain their existing record shapes and never require a timing declaration.

Timing tests check Intel’s nominal costs, all ModR/M addressing forms, byte/word transfers, branch decisions, full CL shift counts, bounded string re-entry, prefixes, external entry, and explicit rejection. The XT timer example connects these CPU clocks to device time and original BIOS checks.

Unsupported instructions

Unsupported first bytes produce outcome: "unsupported", reason: "opcode" after their opcode fetch (and any preceding prefixes). Invalid ModR/M operation or register selections reject immediately after ModR/M, before displacements, immediates, or operand accesses. AAM/AAD reject after their second byte when it is not 0A. Invalid repetition combinations reject at the opcode. All preserve complete state and RAM; repeating an attempt repeats its reads. This atomic rejection is a model policy, not an illegal-instruction exception implemented by the original chip. Divide errors use the declared entry action as described above.

All documented opcode forms are implemented. External computation and TEST levels use the connections above; unsupported encodings remain outside the documented instruction inventory.

CPU reset

reset() returns detached before/after snapshots and an empty access list, without a step outcome or instruction. A later step fetches current RAM at FFFF0. Reset does not restore an example's memory image; creating a fresh example performs that restart.

Checks and limits

Independent CPU tests check complete snapshots, fetch/access order, wrapping, arithmetic flags, and failure retention. The register semantics tests exercise the full word range for INC/DEC and byte views, and interrupt every observable effect of the register families. The specification checks verify schema ownership, the exact migrated encoding inventory, and packed status. Transfer and ALU probes exercise all resolved operand choices, live byte halves and flag objects, and failures at every observable effect. These checks reuse specification sources and actions; their expected values and effect schedules remain independent. Resolved arithmetic, unary, and stack probes extract the specification cases after pointer resolution, retaining exhaustive decimal inputs, live register/flag changes, and failures at each observable effect. An integration test edits a copied specification and proves those edits reach public construction, snapshots, machine parsing, ModR/M segment selection, and the byte offsets of word reads across instruction families, as well as shift-loop counts, the signed quotient limit, and stack changes through direct pushes, software interrupt entry, and IRET. They also change branch conditions, relative targets, and segment-load recognition delays, then verify public execution.

Independent string, port, and device tests retain their own effect schedules, including every failed callback, live index/flag changes, and empty repeats. The integration test also changes port stride, string count/indices, WAIT polarity, ESC encoding, and vector spacing, proving those formal edits control public execution and shared continuation/entry consumers.

All 291 documented forms and all six model areas belong to this specification. The lifecycle checks change reset, fetch advancement, segmentation, prefixes, repeat selection, trap/fault vectors, retirement samples, and WAIT continuation. The public integration test verifies those declarations reach the CPU class. Independent CPU checks retain full records, failed-access state, recognition delays, and reentrancy expectations.

The vector-offer checks change recognition gates, vector values, wake-up effects, and public names. A renamed specification retains the same generated connection and execution services. The generated catalogue supplies the class, schema, memory size, and physical-PC bound to machine parsing and factory generation.

Independent instruction checks cover every immediate byte and register selector, word-register boundaries, every immediate AX value, byte views across every word value, every code-segment value, and instruction fetches at every IP. ADD checks all byte operand pairs, every word against carry and signed boundaries, every incoming flag pattern at arithmetic boundaries, and parity examples that distinguish a byte from a word. Memory checks cover every byte, word, and direct word offset, odd addresses, segment-end and physical wrapping, exact access widths, and unchanged-value writes. Complete records and observed RAM calls check access order, state preservation, unsupported attempts, reset, self-modifying code, and detached snapshots.

The arithmetic example checks full initial/final RAM images, three complete records, distinct code/data segments, a physical completion address, pause/resume, reset, and fresh restart. Parser, generator, and type checks keep logical initial state separate from derived views and physical image addresses. The transfer example additionally checks byte writes sharing word storage, byte versus word flags, direct readback, memory sentinels, snapshot restoration, and physical completion through a different CS:IP alias.

CMP tests cover every byte pair and every AX value against signed/unsigned word boundaries. PUSH/POP tests cover all register selectors and flags, segment/bus boundaries, every PUSH SP/POP SP value, and the hardware POP DX regression. Jcc uses independently listed truth sets for every flag pattern, plus every displacement on both paths. Call/return checks cover wrapping, operand overlap, unchanged CS, current stack memory, and optional cleanup. The 39-step control-flow example checks complete records, saved loop counters, full RAM images, nested-call resumption from snapshots, physical completion, reset preservation, fresh restart, and bounded loops.

ModR/M checks cover every register pair, all supported operation groups, every memory base and displacement mode, both widths/directions, BP segment defaults, the direct-address exception, byte aliases, all signed-byte immediates, wrapped instruction and data accesses, unchanged-value writes, and code/data overlap. The masked word-sum example verifies all 76 records, a 32-bit carry, BP stack-frame reads, conditional logic, edited source RAM, bounded loops, full memory images, nested state restoration, reset, and restart.

Unary tests exhaust every byte/word with both incoming carry values. Byte rotates/shifts exhaust every operand, all 256 CL counts, and both carry values; word tests cover every operand for count one and boundary operands at every count. All new groups cover register selectors, memory modes, flag patterns, wrapped instruction/data accesses, code overlap, and atomic rejection. XCHG checks every pair and byte alias; immediate TEST exhausts byte pairs. The signed word transformation checks exact records, carry propagation, two-word negation, both marker paths, byte swaps, segment-end reads, physical wrapping, and complete guarded memory images.

Completion checks additionally cover all new register and memory selectors, segment/FLAGS stack transfers, far pointers and overlapping frames, original BCD and signed-division boundaries, every byte multiplication pair, full-word sign extension/AAD/POPF sweeps, and invalid encodings. Prefix and string tests check both directions and widths, empty repetition, flag-based termination, segment and bus wrapping, exact accesses, bounded running, and snapshot-only resumption. A separate encoding inventory audits all 291 documented forms.

Port checks cover all eight forms, every immediate port and modeled flag combination, distinct byte halves, odd and wrapped word ports, instruction-fetch wrapping, prefixes, exact access order, invalid inputs, failures, and reentrancy. An eleven-instruction runner program combines all eight forms with a memory result and HALT, checking restoration of CPU, full RAM, and device state at every boundary. The supplementary 80,000 hardware cases check modeled state and actual port bus transfers without adding network access to the repository tests.

Interrupt checks cover all 256 vectors, all flag combinations for software entry and restoration, IF transitions, segment inhibition, higher-priority entry with an owed trap, wrapped/overlapping frames, and failures at each vector or frame byte. A halted program enters a port-writing handler through INTR, returns with IRET, and resumes from a snapshot during its restored recognition delay. The 66,444 interrupt hardware cases check native control instructions and divide-error delivery.

The decimal buffer example combines a wrapped three-word copy, a far decimal-formatting routine, unsigned division, reverse string stores, saved FLAGS, and HLT. Its tests specify all 52 records, guarded full RAM images, decimal output at unsigned boundaries, and restoration inside both REP and a far-call frame.

ESC checks cover all 64 external opcodes and every ModR/M source, default and overridden segments, dummy word reads, address wrapping, disconnected execution, request ownership, and failures. WAIT checks cover every flag pattern, both TEST levels, interrupt/trap entry, recognition delays, snapshot restoration, reset, invalid inputs, and reentrancy. The combined program restores CPU/device state, interrupts WAIT, returns to it, then continues through word output. The 80,000 ESC hardware cases verify every modeled register/flag, fetched bytes, fixture RAM, and operand bus accesses. WAIT pin transitions have manual/microcode evidence and local tests, not a hardware-fixture comparison.

A complete coprocessor, pin sampling and scheduling, bus arbitration, and prefetching remain deferred. Nominal instruction clocks cover only the declared subset; complete timing and bus-cycle fidelity remain future work. Mapped devices and scheduling belong to the machine. The instruction-level records are not a cycle trace; self-modifying code observes current RAM without the original chip's prefetch-queue effects.

Reference comparisons

The pinned dromaios-pc CPU and instruction table at a6fb9d1 supplied design comparisons. Their later-x86 aliases, reset choices, and machine hooks are not this model's contract. Intel's 1979 manual and the evidence below establish the original-chip distinctions. Comparisons recorded here are supplementary cross-checks; downloaded corpora are not required by the local regression suite.

Hardware fixture comparisons

Recorded runs used Daniel Balsom's 8088 V2 fixtures at aea8448, generated on an AMD D8088. They checked stored registers, defined flags, fetched bytes, final fixture RAM, and whether recorded accesses addressed fixture bytes. They did not compare instruction prefetch, clock timing, or complete bus traces.

Scope Passing cases Limits and additional observations
Ordinary instructions and prefixes, 267 forms 2,423,129 REP's bounded one-element steps were joined for final-state comparison; no HLT fixture exists
83 /4 AND r/m16, sign-extended byte 10,000 Timed execution matched registers, defined flags, instruction bytes, and fixture RAM, including 7,478 memory cases
83 /6 XOR r/m16, sign-extended byte 10,000 The same checks passed for all cases, including 7,441 memory operands
E4–E7 and EC–EF port transfers 80,000 Ordered port addresses/values extracted by latching ALE and sampling T3; input fixtures supply FF
CC/CD/CE/CF and FA/FB 42,000 Native software entry/return and interrupt-control instructions, including ordered data transfers
Documented DIV/IDIV divide errors 24,444 Type-0 frame/vector delivery and following-instruction return IP
D8–DF ESC forms 80,000 Ordered operand addresses/values from ALE/T3; full-word reads for memory forms, none for register forms; no exclusions

The divide-error row rechecks cases included in the ordinary-instruction comparison, whose original run checked detection with delivery deferred; the rows must not be added as disjoint totals. The ordinary run excluded 51,871 cases: undocumented selectors/aliases, non-0A AAM/AAD radices, undocumented repeat combinations, and REP writes into fetched code where the model's refetch policy differs from hardware prefetch. These exclusions do not remove documented forms.

Undefined flags were normalized to the model's stated policies, including saved frame bits; they do not establish portable hardware values. The ordinary hardware generator does not exercise TF/IF, so local tests independently check their preservation. The interrupt comparisons do not establish external pin sampling or recognition timing. There is no WAIT (9B) fixture. Local tests cover varied input bytes, all modeled flag combinations, failed callbacks, and exact instruction-level access order beyond these corpus comparisons.

Segment boundaries and original-chip behavior

The hardware POP DX fixture 5A, case 3252, hash 445ddb088cd7d3f60bfb27947ee7c2152b3b4e82, starts with SS=4B5A, SP=FFFF. It reads A7 at physical 5B59F, then 11 at 4B5A0, leaving DX=11A7 and SP=0001. The PC reference instead read the second byte at 5B5A0. MartyPC's pinned byte-bus and stack routines also translate each byte after wrapping the logical offset. Local tests retain a conflicting sentinel at the wrong address and cover every direct MOV offset. The earlier unprefixed A1/A3 corpus sample had no offset-FFFF case.

Other source comparisons exposed distinctions now covered locally:

  • PUSH SP stores the decremented pointer; the PC reference stored the original.
  • CL counts use all eight bits. The reference masked them to five bits; with AL=81 and CL=20, SHL produces zero. Its ROL/RCL/RCR overflow handling also differs from the original one-bit sign-change rule.
  • DAA/DAS use threshold 9F with incoming AF, otherwise 99; DAS does not set CF solely for low-digit borrow. AAA/AAS adjust AL and AH independently. MartyPC's decimal routines corroborate these hardware comparisons.
  • IDIV's original signed limits exclude −128 and −32768. Fixture F6.7, case 1828 (AX=C4CC, CH=76), confirms the byte divide error. Local BigInt checks cover both widths. Word DIV must also combine DX:AX as an unsigned value; the PC reference used signed JavaScript bitwise arithmetic.
  • Original FLAGS packs bits 15–12 as ones; the PC reference left them clear. Hardware PUSHF/POPF cases and local packing sweeps check this distinction.

The control-flow example matched the pinned PC core across all 39 steps and final RAM. Its stack avoids the segment boundary and PUSH SP. The 76-step word-sum example matched registers, defined flags, accessed values, and final RAM after normalizing undefined AF. Its final immediate-memory OR differs in access order: the reference reads the destination before fetching the immediate; this model fetches the full encoding first. The other 75 steps agreed in order. Neither comparison is a bus-cycle claim.

Interrupt and coprocessor evidence

Intel's 1979 manual, pages 2-22–2-28 and figure 2-31, supplies interrupt priority, segment-load inhibition, and owed-trap rules. Shirriff's silicon analysis establishes vector reads before frame writes and the initial TF sample. Pinned MartyPC interrupt, stack, and step code provide additional comparisons; the old PC emulator stacked first and polled only IF. This model abstracts INTA into one acknowledgement callback and retains its declared REP refetch policy; pin cycles and original multi-prefix restart quirks remain unmodeled.

The 1979 manual's §§2.5, 2.7 and table 4-13 define ESC and TEST-controlled WAIT. The November 1983 ASM86 manual, page 6-14, specifies WAIT restart and inhibition after TEST release. Jenner's microcode disassembly includes the original 8088 ROM: WAIT at 0F8–0FF and continuation 1EC tests TEST, checks pending interrupts while busy, and decrements corrected IP for restart. Together with Shirriff's interrupt-delay analysis, this supports the declared busy-sample boundaries and all-source delay on release. The model omits the hardware WAIT refetch before interrupt entry and does not simulate pin timing.

The old PC core treated WAIT as a no-op and decoded ESC without operand reads. The pinned MartyPC ESC handler supplies a word-read comparison, but its WAIT handler is empty. WAIT therefore relies on the manual/microcode evidence and independent local tests, not those emulator handlers or a corpus. Device callback records are model interfaces, not hardware-trace events.