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:nullfor a register form, or{ segment, offset, address, value }for a memory form.addressis the first byte's physical address andvalueis 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=
81and 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
9Fwith incoming AF, otherwise99; 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.