Skip to content
Lesson 13.10Physical Memory, Machines, and Hardware

Emulator Timing and State

A toy console makes instruction costs, frame timing, save states, and register races concrete inside a Rust emulator.

Lesson 13.10
Read or listenPrint chapter or book
About 20 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 13.10Key ideas, reference and questions

A one-page summary of “Emulator Timing and State” you can keep beside your notes. Plain Markdown.

Lesson 13.10 — Emulator Timing and State: cheatsheet

  • Remember: The opcode chooses the operation. The operand supplies a number or an address. An address operand needs another memory read.
  • Also useful: An instruction finishes as a whole. A frame budget can be exceeded by the final instruction's cost.

Key terms

  • Opcode::decode: Opcode::decode is a match on the byte that returns None for anything that is not an instruction, and the ? turns that into an IllegalOpcode error that names the address.
  • match: Opcode::decode is a match on the byte that returns None for anything that is not an instruction, and the ? turns that into an IllegalOpcode error that names the address.
  • BUTTONS: DISPLAY and BUTTONS are constants holding 0xF0 and 0xF1.
  • mirroring: This is called mirroring.
  • read: An NES emulator's read is a bigger version of the one above.

Review

  • Q: The toy console's frame budget is 18 cycles. A frame stops at 19 cycles, because the instruction that crossed the budget could not stop halfway. Why does the emulator give the next frame 17 cycles instead of 18? A: Taking off the 1-cycle overshoot keeps the average at exactly 18 per frame, so speed stays right
  • Q: A loop ends with DEX (X = X - 1, and Z = 1 if X becomes 0) followed by JNZ (jump while Z is 0). X was 1 before DEX. Why does JNZ not jump this time? A: DEX made X zero, so the zero flag Z became 1
Download cheatsheet (.md)

Lesson 13.9 explained how an emulator works and ran one instruction of a made-up 8-bit console through the fetch–decode–execute loop. This lesson builds the whole console, follows a complete program through it cycle by cycle, and ends with a lab that runs it.

The CPU is made up, but it is built like the 8-bit processors in 1980s consoles. It has four registers:

  • PC, 8 bits, the program counter, so the whole machine has the 256 addresses, 0x00 to 0xFF, of Lesson 13.9’s memory map.
  • A, 8 bits, the accumulator, where arithmetic happens.
  • X, 8 bits, a second register, used here as a loop counter.
  • Z, 1 bit, the zero flag: 1 when the last result was zero, 0 otherwise.

It understands seven instructions. Each begins with its opcode, and some carry an operand byte:

The toy CPU’s instructions

Bytes Written as Length What it does Cycles
01 n LDX #n 2 X = n; Z = 1 if X is 0 2
02 a LDA a 2 A = the byte at address a; Z = 1 if A is 0 3
03 n ADD #n 2 A = A + n, wrapping past 255 back to 0; Z = 1 if A is 0 2
04 a STA a 2 store A at address a 3
05 DEX 1 X = X − 1, wrapping below 0 to 255; Z = 1 if X is 0 1
06 a JNZ a 2 if Z is 0, jump: PC = a 2, or 3 if it jumps
FF HLT 1 stop 1

A # marks an immediate: #5 means the number 5 itself, while an operand without # is an address, and the instruction uses the byte stored there. STA and JNZ leave Z alone. Every other byte value is not an instruction, and the lab reports it as an error instead of guessing.

Every number in the last column comes from one rule: the CPU spends one cycle on each memory access, meaning each instruction byte it fetches and each data byte it reads or writes, plus one extra cycle when a jump is taken, to load the new address into PC. So LDX #n and ADD #n each fetch 2 bytes, 2 cycles; LDA a fetches 2 and reads 1, 3 cycles; STA a fetches 2 and writes 1, 3 cycles; DEX and HLT each fetch 1, 1 cycle; and JNZ a fetches 2, plus 1 if it jumps. Real 8-bit CPUs, such as the 6502 inside the NES, have similar costs for similar reasons.

The program adds 5 gold twice and then shows the total on the console’s display. Gold lives at address 0x80 and starts at 10:

address bytes instruction meaning
0x00 01 02 LDX #2 X = 2: go round the loop twice
0x02 02 80 LDA 0x80 A = gold
0x04 03 05 ADD #5 A = A + 5
0x06 04 80 STA 0x80 gold = A
0x08 05 DEX X = X - 1
0x09 06 02 JNZ 0x02 back to 0x02 while X is not 0
0x0B 04 F0 STA 0xF0 show A on the display
0x0D FF HLT stop

Each address is the one before it plus that instruction’s length: 0x00 + 2 = 0x02, and so on down to 0x08 + 1 = 0x09, 0x09 + 2 = 0x0B, and 0x0B + 2 = 0x0D. The last byte sits at 0x0D, which is 13, so the program fills addresses 0 to 13, which is 14 bytes:

0x0001
02
0x0202
80
0x0403
05
0x0604
80
LDX #2
LDA 0x80
ADD #5
STA 0x80
Addresses 0x00 to 0x07, one byte per box: the first four instructions.
0x0805
0x0906
02
0x0B04
F0
0x0DFF
DEX
JNZ 0x02
STA 0xF0
HLT
Addresses 0x08 to 0x0D: the last four. The earlier ADD #5 at 0x04 adds five to the accumulator on each loop pass.

The program and its data share one memory, as they do in every stored-program computer. At power-on the machine looks like this:

PC0x00
A0
X0
Z0
0x80 · gold10
CPU registers
RAM
Before step 1: every register is 0, and gold at 0x80 holds 10, which is 0x0A.

The first pass goes from power-on through the jump back:

Step PC Instruction What happens A X Z Gold Cycles
1 0x00 LDX #2 X = 2, not 0, so Z = 0 0 2 0 10 0 + 2 = 2
2 0x02 LDA 0x80 A = the byte at 0x80, 10 10 2 0 10 2 + 3 = 5
3 0x04 ADD #5 A = 10 + 5 = 15 15 2 0 10 5 + 2 = 7
4 0x06 STA 0x80 gold = A = 15 15 2 0 15 7 + 3 = 10
5 0x08 DEX X = 2 − 1 = 1, not 0, so Z = 0 15 1 0 15 10 + 1 = 11
6 0x09 JNZ 0x02 Z is 0, so PC = 0x02 15 1 0 15 11 + 3 = 14

The second pass repeats the loop body, and this time the counter runs out:

Step PC Instruction What happens A X Z Gold Cycles
7 0x02 LDA 0x80 A = the byte at 0x80, 15 15 1 0 15 14 + 3 = 17
8 0x04 ADD #5 A = 15 + 5 = 20 20 1 0 15 17 + 2 = 19
9 0x06 STA 0x80 gold = A = 20 20 1 0 20 19 + 3 = 22
10 0x08 DEX X = 1 − 1 = 0, so Z = 1 20 0 1 20 22 + 1 = 23
11 0x09 JNZ 0x02 Z is 1, no jump 20 0 1 20 23 + 2 = 25
12 0x0B STA 0xF0 the display shows 20 20 0 1 20 25 + 3 = 28
13 0x0D HLT stop 20 0 1 20 28 + 1 = 29

In hex, 15 is 0x0F and 20 is 1 × 16 + 4 = 0x14. At step 11, fetching JNZ and its operand has already moved PC from 0x09 to 0x09 + 2 = 0x0B, so not jumping needs no extra work: the next instruction is simply the next one.

The whole run takes 29 cycles: 2 for LDX, then 3 + 2 + 3 + 1 + 3 = 12 for the first pass, 3 + 2 + 3 + 1 + 2 = 11 for the second, and 3 + 1 = 4 for the ending. 2 + 12 + 11 + 4 = 29. When the machine stops, it looks like this:

Where 29 cycles goThe program loads X, loops twice and then shows gold. Each part has a cost in cycles.
  1. 1 LDX and the first loop pass: 2 + 12 = 14

    Cycles, drawn to scale
    029 cycles
    2
    12
    11
    4

    Loading X costs 2 cycles; the first pass costs 12 and makes gold 10 + 5 = 15.

  2. 2 Second loop pass: 14 + 11 = 25

    Cycles, drawn to scale
    029 cycles
    2
    12
    11
    4

    The second pass is one cycle cheaper and makes gold 15 + 5 = 20.

  3. 3 Display and halt: 25 + 4 = 29

    Cycles, drawn to scale
    029 cycles
    2
    12
    11
    4

    The display shows 20 and the program halts after 29 cycles in total.

The program loads X, loops twice and then shows gold. Each part has a cost in cycles.

PC0x0E
A20
X0
Z1
0x80 · gold20
0xF0 · display20
CPU registers
RAM
I/O
After HLT. PC is 0x0E, one past the HLT at 0x0D, because fetching the opcode moved it on.

The lab’s step function is that trace, as code. It fetches the opcode, decodes it, and runs one arm of a match. Each arm’s last value is the instruction’s cost in cycles:

fn step(&mut self) -> Result<u8, EmuError> {
if self.cpu.halted {
return Ok(0);
}
let at = self.cpu.pc;
let opcode = self.fetch();
let instruction = Opcode::decode(opcode).ok_or(EmuError::IllegalOpcode { at, opcode })?;
let cycles = match instruction {
Opcode::Lda => {
let address = self.fetch();
self.cpu.a = self.read(address);
self.cpu.zero = self.cpu.a == 0;
3
}
Opcode::Jnz => {
let target = self.fetch();
if self.cpu.zero {
2
} else {
self.cpu.pc = target;
3
}
}
// The other five arms follow the same pattern.
};
self.cpu.cycles += u64::from(cycles);
Ok(cycles)
}

fetch reads the byte at PC and adds 1 to PC, so each call consumes one byte of the instruction. Opcode::decode is a match on the byte that returns None for anything that is not an instruction, and the ? turns that into an IllegalOpcode error that names the address. A real CPU meeting an unknown opcode might raise an exception, as x86 does, or do something undocumented. An emulator that guessed would hide bugs in the guest program and in itself.

Watch it happenOne LDA instruction turns bytes into CPU state

Program bytes 02 80 sit at addresses 0x02 and 0x03. The CPU fetches 02 and advances PC to 0x03, decodes it as LDA, fetches the operand 80 and advances PC to 0x04, then reads RAM address 0x80, which holds 10, into register A. The cycle count goes from 2 to 5 and PC points at ADD #5.

Read the steps
  1. Before the instruction: PC = 0x02, A = 0, X = 2, Z = 0, and 2 cycles have been spent. RAM address 0x80 holds the gold, 10. The next instruction’s bytes are 02 80.
  2. Fetch the opcode: read the byte at PC = 0x02, which is 02, and advance PC to 0x03. This byte names the operation; it has not loaded the gold yet.
  3. Decode: the opcode 02 maps to LDA. The LDA arm needs one address operand and then a data read. An unknown opcode would stop here with an error.
  4. Fetch the address operand: read the byte at PC = 0x03, which is 80, and advance PC to 0x04. The operand names RAM address 0x80.
  5. Read RAM[0x80]: the byte there is 10, so A becomes 10. Because A is not zero, Z stays 0. X is untouched, and RAM still holds 10, because LDA only reads it.
  6. Finish: LDA costs 3 cycles, one for the opcode, one for the operand, one for the data read. The total is 2 + 3 = 5, and PC = 0x04 now selects ADD #5.

An instruction can use several emulated cycles. A saved register and a saved memory byte are separate copies, so they can disagree.

The same instruction can load a different value. Change the memory byte below, then step through the fetches and the data read. Try zero: the loaded value also changes the zero flag. Each memory read costs one cycle in this toy CPU.

Try a different memory byteOne instruction, four actions
Text
PC points to the opcode at 0x02
opcode = program[PC]; PC = 0x03
decode opcode 02 as LDA
address = program[PC]; PC = 0x04
A = RAM[address]; Z = (A == 0)

Registers and values

PC
0x02
A
0
X
2
Z
0
cycles
2
RAM[0x80]
10
buffer
—

    Snapshot 1, source line 1: Starting state. PC selects program address 0x02. Fetch the opcode next.

    Initial recorded state.

    Focus a register name to mark its matching source and inspection references.

    Read every snapshot of the lesson’s example
    1. Snapshot 1, line 1. Starting state. PC selects program address 0x02. Fetch the opcode next.

      PC = 0x02; A = 0; X = 2; Z = 0; cycles = 2; RAM[0x80] = 10; buffer = —.

    2. Snapshot 2, line 2. Fetched opcode 02 from program address 0x02. PC advances to 0x03. One memory read adds one cycle.

      PC = 0x03; A = 0; X = 2; Z = 0; cycles = 3; RAM[0x80] = 10; buffer = 02.

    3. Snapshot 3, line 3. Decoded 02 as LDA. Decoding reads no memory, so the cycle count stays at 3.

      PC = 0x03; A = 0; X = 2; Z = 0; cycles = 3; RAM[0x80] = 10; buffer = 02.

    4. Snapshot 4, line 4. Fetched operand 80 from program address 0x03. PC advances to 0x04. This read adds one cycle.

      PC = 0x04; A = 0; X = 2; Z = 0; cycles = 4; RAM[0x80] = 10; buffer = 80; address = 0x80.

    5. Snapshot 5, line 5. Read RAM[0x80] into A. A becomes 10; Z is 0 because A is not zero. RAM still holds 10. Three reads cost three cycles: 2 + 3 = 5.

      PC = 0x04; A = 10; X = 2; Z = 0; cycles = 5; RAM[0x80] = 10; buffer = 10; address = 0x80.

    1. Fetch opcode
    2. Decode
    3. Fetch address
    4. Read RAM into A
    program bytesRAM at 0x8002800x020x0310CPUread buffer: —PCAXZcycles0x020202opcode not fetched yet

    Underlined action: what Step does next. Outlined byte: the read source. Filled register: written by this action.

    PC is 0x02. A is 0. Change RAM, then fetch the opcode.

    Read all four instruction actions
    ActionPCACyclesWhat happened
    Starting state0x0202RAM[0x80] holds 10.
    Fetch opcode0x0303Read 02 from program address 0x02.
    Decode0x030302 means LDA. Decoding adds no memory read.
    Fetch address0x0404Read operand 80 from program address 0x03.
    Read RAM into A0x04105A becomes 10; Z stays 0; RAM still holds 10.
    What does a rising clock edge do?
    A register captures data on a rising clock edge

    Data can change between clock edges. The stored value changes only when the clock goes from 0 to 1.

    clockdatastored01 ↑2 ↓3 ↑

    At time 1, the rising edge captures 1. At time 2, data drops to 0 while the stored value stays 1. The next rising edge captures 0.

    Read the timing example
    TimeClockDataStored valueReason
    0000Initial state
    1111Rising edge captures data
    2001Falling edge keeps the stored value
    3100Rising edge captures data

    Because step decodes every instruction each time it runs, the loop body from 0x02 to 0x0A is decoded afresh on both passes. That repetition is what the dynamic recompilers of Lesson 13.9 remove.

    The toy console maps RAM from 0x00 to 0xEF, the display at 0xF0, the buttons at 0xF1, and nothing from 0xF2 to 0xFF. In the lab, the map is two functions. Every read the CPU makes, including every fetch, goes through read, and every store goes through write:

    fn read(&self, address: u8) -> u8 {
    match address {
    0x00..=0xEF => self.ram[usize::from(address)],
    DISPLAY => self.display,
    BUTTONS => self.buttons,
    _ => 0, // 0xF2 to 0xFF: nothing is wired there
    }
    }
    fn write(&mut self, address: u8, value: u8) {
    match address {
    0x00..=0xEF => self.ram[usize::from(address)] = value,
    DISPLAY => self.display = value,
    _ => {} // the buttons are read-only, and 0xF2 to 0xFF go nowhere
    }
    }

    0x00..=0xEF is Rust’s range from 0x00 to 0xEF with both ends included. That is 0xF0 = 15 × 16 = 240 addresses, exactly the length of the ram array. DISPLAY and BUTTONS are constants holding 0xF0 and 0xF1. So step 12’s STA 0xF0 calls write with address 0xF0 and value 20, the second arm stores 20 in the display register, and nothing in step has to know that 0xF0 is not RAM.

    Real consoles work the same way on a larger scale. The NES has 2 KiB of RAM at 0x0000 to 0x07FF; 0x0800 is 8 × 256 = 2,048 bytes. Its address decoder ignores the top address bits for RAM, so the same 2,048 bytes answer again at 0x0800, 0x1000, and 0x1800: the range up to 0x2000, which is 2 × 16³ = 2 × 4,096 = 8,192 bytes, holds 8,192 ÷ 2,048 = 4 copies. This is called mirroring. The picture chip’s registers start at 0x2000, and the controllers answer at 0x4016 and 0x4017. An NES emulator’s read is a bigger version of the one above.

    A real frame’s budget, like the one in Lesson 13.9, runs to thousands of cycles, while the whole traced program takes only 29. So the lab uses a tiny budget of 18 cycles, which ends a frame partway through the program. Its run loop steps whole instructions until the budget is used or the CPU halts:

    fn run(&mut self, budget: u64) -> Result<u64, EmuError> {
    let mut used = 0;
    while used < budget && !self.cpu.halted {
    used += u64::from(self.step()?);
    }
    Ok(used)
    }

    The check used < budget happens before each instruction, never during one, because an instruction cannot stop halfway. After step 7 the run has used 17 cycles, which is less than 18, so it runs step 8, an ADD worth 2 cycles, and reaches 19. That is not less than 18, so the frame ends. It overshot by 19 − 18 = 1 cycle, and the emulator gives the next frame 18 − 1 = 17 cycles, which keeps the long-run average at exactly 18 per frame. The overshoot can never exceed 2: the frame’s last instruction starts with at most 17 cycles used and costs at most 3, and 17 + 3 = 20 = 18 + 2.

    Here is the machine at that frame boundary:

    PC0x06
    A20
    X1
    Z0
    0x80 · gold15
    CPU registers
    RAM
    Where the 18-cycle budget stops. The new total, 20, exists only in A; gold in RAM still says 15 until STA at 0x06 runs.

    At that frame boundary the new gold total exists only in A, so a snapshot of RAM alone would restore gold = 15 and lose the 20. The lab’s SaveState copies every part of the machine and converts it to a row of bytes:

    PC0x06
    A20
    X1
    Z0
    halted0
    cycles19
    CPU registers
    emulator bookkeeping
    The first 13 bytes. PC, A, X, Z, and the halted flag take 1 byte each; the cycle count is a 64-bit number, and 64 ÷ 8 = 8 bytes. So 5 + 8 = 13.
    RAM240 bytes
    display0
    buttons0
    all of RAM
    I/O registers
    The other 242 bytes: 240 for RAM and 1 each for the two I/O registers. In all, 13 + 240 + 2 = 255 bytes.

    The halted flag records whether HLT has run, and the cycle count is the emulator’s position in time, so a loaded state resumes at the same point in the frame. Reading a state back is parsing input from outside the program, so it is checked the way Lesson 10.2 checks a save file: the lab refuses anything that is not exactly 255 bytes long, and refuses a Z or halted byte that holds anything other than 0 or 1, because no real run of the machine could have produced it. Real emulators also store a version number, because the layout changes as the emulator does, and a state saved by one version may not load in another.

    The lab’s run_frame runs one frame and then applies every cheat, the way a cheat cartridge wrote its values once per frame:

    fn run_frame(&mut self, budget: u64, cheats: &[Cheat]) -> Result<u64, EmuError> {
    let used = self.run(budget)?;
    for cheat in cheats {
    self.write(cheat.address, cheat.value);
    }
    Ok(used.saturating_sub(budget))
    }

    It returns the overshoot, which the caller takes off the next frame’s budget. saturating_sub subtracts but stops at 0 instead of wrapping around, so a frame that ends early because the CPU halted reports no overshoot.

    Give it the 18-cycle frames and one cheat: write 99 to gold. Frame 1 runs steps 1 to 8, and then the cheat writes 99 to 0x80. But the game had already copied gold into A and added 5. Frame 2, with its budget of 17, starts with step 9, which stores A’s 20 over the 99, and steps 9 to 13 finish the program in 3 + 1 + 2 + 3 + 1 = 10 cycles, well inside the budget. Step 12 shows 20 on the display, and at the end of frame 2 the cheat writes 99 again:

    A20
    0x80 · gold99
    0xF0 · display20
    CPU register
    RAM
    I/O
    After frame 2. RAM holds the cheat's 99, but the game stored and displayed the 20 it had already computed in A.
    Any computer

    The portable lab toy_emulator_lab.rs is the console from this lesson: the CPU, the 240 bytes of RAM, the display and buttons registers, the step interpreter, a run loop with a cycle budget, frames with constant-write cheats, and a SaveState that copies the registers and memory and converts them to and from 255 bytes.

    toy_emulator_lab.rs

    rust-labs/src/bin/ · Rust, 580 lines

    Show the codeHide the code
    //! Toy emulator lab for Lesson 14.11.
    //!
    //! A made-up 8-bit console: a CPU with a program counter, registers `A` and
    //! `X`, and a zero flag; 240 bytes of RAM; and two memory-mapped I/O
    //! registers. The emulator is an interpreter. Each step fetches the byte at
    //! the program counter, decodes it, executes it, and counts the cycles the
    //! real chip would have spent. On top of that sit a run loop with a cycle
    //! budget, video frames with constant-write cheats, and save states.
    
    use std::error::Error;
    use std::fmt;
    
    /// RAM fills addresses 0x00 to 0xEF, which is 0xF0 = 15 x 16 = 240 bytes.
    const RAM_SIZE: usize = 0xF0;
    /// Writing here changes the number on the console's display.
    const DISPLAY: u8 = 0xF0;
    /// Reading here returns the buttons held down, one bit per button.
    const BUTTONS: u8 = 0xF1;
    /// Where the lesson's program keeps the player's gold.
    const GOLD: u8 = 0x80;
    /// The small frame budget the lesson uses to stop the program part way.
    const FRAME_BUDGET: u64 = 18;
    
    /// The lesson's program: add 5 gold twice, then show the total.
    const PROGRAM: [u8; 14] = [
        0x01, 0x02, // 0x00  LDX #2     X = 2
        0x02, 0x80, // 0x02  LDA 0x80   A = gold
        0x03, 0x05, // 0x04  ADD #5     A = A + 5
        0x04, 0x80, // 0x06  STA 0x80   gold = A
        0x05, //       0x08  DEX        X = X - 1
        0x06, 0x02, // 0x09  JNZ 0x02   back to 0x02 while X is not 0
        0x04, 0xF0, // 0x0B  STA 0xF0   show A on the display
        0xFF, //       0x0D  HLT
    ];
    
    /// The instructions this CPU understands. Operands are fetched while the
    /// instruction executes.
    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    enum Opcode {
        /// `0x01 n`: X = n.
        Ldx,
        /// `0x02 a`: A = the byte at address a.
        Lda,
        /// `0x03 n`: A = A + n, wrapping past 255 back to 0.
        Add,
        /// `0x04 a`: store A at address a.
        Sta,
        /// `0x05`: X = X - 1, wrapping below 0 to 255.
        Dex,
        /// `0x06 a`: jump to address a if the zero flag is clear.
        Jnz,
        /// `0xFF`: stop.
        Hlt,
    }
    
    impl Opcode {
        const fn decode(byte: u8) -> Option<Self> {
            match byte {
                0x01 => Some(Self::Ldx),
                0x02 => Some(Self::Lda),
                0x03 => Some(Self::Add),
                0x04 => Some(Self::Sta),
                0x05 => Some(Self::Dex),
                0x06 => Some(Self::Jnz),
                0xFF => Some(Self::Hlt),
                _ => None,
            }
        }
    }
    
    /// The CPU's registers, plus two facts the emulator tracks about it: whether
    /// it has halted, and how many cycles it has run since power-on.
    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
    struct Cpu {
        pc: u8,
        a: u8,
        x: u8,
        zero: bool,
        halted: bool,
        cycles: u64,
    }
    
    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    enum EmuError {
        /// The byte at `at` is not an instruction this CPU knows.
        IllegalOpcode { at: u8, opcode: u8 },
        /// The program does not fit in RAM.
        ProgramTooLarge { length: usize },
        /// A save state has the wrong length or an impossible flag byte.
        BadSaveState,
    }
    
    impl fmt::Display for EmuError {
        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::IllegalOpcode { at, opcode } => {
                    write!(
                        formatter,
                        "byte {opcode:#04X} at {at:#04X} is not an instruction"
                    )
                }
                Self::ProgramTooLarge { length } => write!(
                    formatter,
                    "a {length}-byte program does not fit in {RAM_SIZE} bytes of RAM"
                ),
                Self::BadSaveState => {
                    formatter.write_str("the save state has the wrong length or an impossible flag")
                }
            }
        }
    }
    
    impl Error for EmuError {}
    
    /// A constant-write cheat code: after every frame, write `value` at `address`.
    #[derive(Clone, Copy, Debug)]
    struct Cheat {
        address: u8,
        value: u8,
    }
    
    /// The whole console: CPU, RAM, and the two I/O registers.
    #[derive(Clone, Debug, PartialEq, Eq)]
    struct Console {
        cpu: Cpu,
        ram: [u8; RAM_SIZE],
        display: u8,
        buttons: u8,
    }
    
    impl Console {
        /// A console just powered on, with `program` copied to address 0x00.
        fn with_program(program: &[u8]) -> Result<Self, EmuError> {
            let mut ram = [0; RAM_SIZE];
            ram.get_mut(..program.len())
                .ok_or(EmuError::ProgramTooLarge {
                    length: program.len(),
                })?
                .copy_from_slice(program);
            Ok(Self {
                cpu: Cpu::default(),
                ram,
                display: 0,
                buttons: 0,
            })
        }
    
        /// The memory map. Every read the CPU makes comes through here.
        fn read(&self, address: u8) -> u8 {
            match address {
                0x00..=0xEF => self.ram[usize::from(address)],
                DISPLAY => self.display,
                BUTTONS => self.buttons,
                _ => 0, // 0xF2 to 0xFF: nothing is wired there
            }
        }
    
        /// Every write the CPU makes comes through here too.
        fn write(&mut self, address: u8, value: u8) {
            match address {
                0x00..=0xEF => self.ram[usize::from(address)] = value,
                DISPLAY => self.display = value,
                _ => {} // the buttons are read-only, and 0xF2 to 0xFF go nowhere
            }
        }
    
        /// Reads the byte at the program counter and moves the counter past it.
        fn fetch(&mut self) -> u8 {
            let byte = self.read(self.cpu.pc);
            self.cpu.pc = self.cpu.pc.wrapping_add(1);
            byte
        }
    
        /// Runs one instruction and returns the cycles it took.
        ///
        /// The cycle rule: one cycle for each byte fetched, one for each data
        /// byte read or written, and one more when a jump is taken.
        fn step(&mut self) -> Result<u8, EmuError> {
            if self.cpu.halted {
                return Ok(0);
            }
            let at = self.cpu.pc;
            let opcode = self.fetch();
            let instruction = Opcode::decode(opcode).ok_or(EmuError::IllegalOpcode { at, opcode })?;
            let cycles = match instruction {
                Opcode::Ldx => {
                    self.cpu.x = self.fetch();
                    self.cpu.zero = self.cpu.x == 0;
                    2
                }
                Opcode::Lda => {
                    let address = self.fetch();
                    self.cpu.a = self.read(address);
                    self.cpu.zero = self.cpu.a == 0;
                    3
                }
                Opcode::Add => {
                    let value = self.fetch();
                    self.cpu.a = self.cpu.a.wrapping_add(value);
                    self.cpu.zero = self.cpu.a == 0;
                    2
                }
                Opcode::Sta => {
                    let address = self.fetch();
                    self.write(address, self.cpu.a);
                    3
                }
                Opcode::Dex => {
                    self.cpu.x = self.cpu.x.wrapping_sub(1);
                    self.cpu.zero = self.cpu.x == 0;
                    1
                }
                Opcode::Jnz => {
                    let target = self.fetch();
                    if self.cpu.zero {
                        2
                    } else {
                        self.cpu.pc = target;
                        3
                    }
                }
                Opcode::Hlt => {
                    self.cpu.halted = true;
                    1
                }
            };
            self.cpu.cycles += u64::from(cycles);
            Ok(cycles)
        }
    
        /// Runs whole instructions until `budget` cycles have been used or the
        /// CPU halts, and returns the cycles used. An instruction is never split,
        /// so the result can pass the budget by up to two cycles.
        fn run(&mut self, budget: u64) -> Result<u64, EmuError> {
            let mut used = 0;
            while used < budget && !self.cpu.halted {
                used += u64::from(self.step()?);
            }
            Ok(used)
        }
    
        /// One video frame: run for the frame's budget, then apply every cheat,
        /// the way a cheat cartridge writes its values once per frame. Returns
        /// the overshoot, which the caller takes off the next frame's budget.
        fn run_frame(&mut self, budget: u64, cheats: &[Cheat]) -> Result<u64, EmuError> {
            let used = self.run(budget)?;
            for cheat in cheats {
                self.write(cheat.address, cheat.value);
            }
            Ok(used.saturating_sub(budget))
        }
    
        /// Copies everything the console is.
        fn save_state(&self) -> SaveState {
            SaveState {
                cpu: self.cpu,
                ram: self.ram,
                display: self.display,
                buttons: self.buttons,
            }
        }
    
        /// Puts the console back exactly where the save state was taken.
        fn load_state(&mut self, state: &SaveState) {
            self.cpu = state.cpu;
            self.ram = state.ram;
            self.display = state.display;
            self.buttons = state.buttons;
        }
    }
    
    /// Everything the console is, copied out: registers, all of RAM, and the I/O
    /// registers. Loading it puts the machine back exactly where it was.
    #[derive(Clone, Debug, PartialEq, Eq)]
    struct SaveState {
        cpu: Cpu,
        ram: [u8; RAM_SIZE],
        display: u8,
        buttons: u8,
    }
    
    impl SaveState {
        /// PC, A, X, Z, and halted (5 bytes), the cycle count (8 bytes), RAM
        /// (240 bytes), display and buttons (2 bytes): 5 + 8 + 240 + 2 = 255.
        const LENGTH: usize = 5 + 8 + RAM_SIZE + 2;
    
        fn to_bytes(&self) -> Vec<u8> {
            let cpu = self.cpu;
            let mut bytes = Vec::with_capacity(Self::LENGTH);
            bytes.extend([
                cpu.pc,
                cpu.a,
                cpu.x,
                u8::from(cpu.zero),
                u8::from(cpu.halted),
            ]);
            bytes.extend(cpu.cycles.to_le_bytes());
            bytes.extend(self.ram);
            bytes.extend([self.display, self.buttons]);
            bytes
        }
    
        /// Reads a save state back, refusing anything that is not exactly
        /// `LENGTH` bytes long or has a flag byte other than 0 or 1.
        fn from_bytes(bytes: &[u8]) -> Result<Self, EmuError> {
            let bad = EmuError::BadSaveState;
            let (&[pc, a, x, zero, halted], rest) = bytes.split_first_chunk::<5>().ok_or(bad)?;
            let (cycles, rest) = rest.split_first_chunk::<8>().ok_or(bad)?;
            let (ram, rest) = rest.split_first_chunk::<RAM_SIZE>().ok_or(bad)?;
            let &[display, buttons] = rest else {
                return Err(bad);
            };
            let flag = |byte| match byte {
                0 => Ok(false),
                1 => Ok(true),
                _ => Err(bad),
            };
            Ok(Self {
                cpu: Cpu {
                    pc,
                    a,
                    x,
                    zero: flag(zero)?,
                    halted: flag(halted)?,
                    cycles: u64::from_le_bytes(*cycles),
                },
                ram: *ram,
                display,
                buttons,
            })
        }
    }
    
    /// A console with the lesson's program loaded and 10 gold at 0x80.
    fn lesson_console() -> Result<Console, EmuError> {
        let mut console = Console::with_program(&PROGRAM)?;
        console.write(GOLD, 10);
        Ok(console)
    }
    
    fn main() -> Result<(), Box<dyn Error>> {
        println!("1. The traced program, one instruction at a time");
        let mut console = lesson_console()?;
        println!("  step    at    A    X  Z  gold  cycles");
        let mut step = 0;
        while !console.cpu.halted {
            let at = console.cpu.pc;
            console.step()?;
            step += 1;
            let cpu = console.cpu;
            println!(
                "  {step:>4}  {at:#04X}  {:>3}  {:>3}  {}  {:>4}  {:>6}",
                cpu.a,
                cpu.x,
                u8::from(cpu.zero),
                console.read(GOLD),
                cpu.cycles
            );
        }
        println!("  the display shows {}", console.display);
    
        println!("\n2. A budget of {FRAME_BUDGET} cycles stops the run between two instructions");
        let mut console = lesson_console()?;
        let used = console.run(FRAME_BUDGET)?;
        println!(
            "  used {used} cycles: PC = {:#04X}, A = {}, X = {}, gold in RAM = {}",
            console.cpu.pc,
            console.cpu.a,
            console.cpu.x,
            console.read(GOLD)
        );
        let saved = console.save_state();
        console.run(1_000)?;
        let first_ending = console.clone();
        console.load_state(&saved);
        console.run(1_000)?;
        println!(
            "  loaded the save state and ran on again: same final machine = {}",
            console == first_ending
        );
        let bytes = saved.to_bytes();
        let read_back = SaveState::from_bytes(&bytes)?;
        println!(
            "  the save state is {} bytes, and reads back unchanged = {}",
            bytes.len(),
            read_back == saved
        );
    
        println!("\n3. A constant-write cheat puts 99 in gold after every frame");
        let mut console = lesson_console()?;
        let cheats = [Cheat {
            address: GOLD,
            value: 99,
        }];
        let mut budget = FRAME_BUDGET;
        for frame in 1..=2 {
            let overshoot = console.run_frame(budget, &cheats)?;
            budget = FRAME_BUDGET - overshoot;
            println!(
                "  after frame {frame}: gold = {}, A = {}, display = {}",
                console.read(GOLD),
                console.cpu.a,
                console.display
            );
        }
        Ok(())
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        fn fresh() -> Console {
            lesson_console().unwrap()
        }
    
        /// After each step: (address of the instruction, A, X, Z, gold, total
        /// cycles), exactly as the lesson's trace tables list them.
        const TRACE: [(u8, u8, u8, bool, u8, u64); 13] = [
            (0x00, 0, 2, false, 10, 2),   // LDX #2
            (0x02, 10, 2, false, 10, 5),  // LDA 0x80
            (0x04, 15, 2, false, 10, 7),  // ADD #5
            (0x06, 15, 2, false, 15, 10), // STA 0x80
            (0x08, 15, 1, false, 15, 11), // DEX
            (0x09, 15, 1, false, 15, 14), // JNZ 0x02 jumps
            (0x02, 15, 1, false, 15, 17), // LDA 0x80
            (0x04, 20, 1, false, 15, 19), // ADD #5
            (0x06, 20, 1, false, 20, 22), // STA 0x80
            (0x08, 20, 0, true, 20, 23),  // DEX
            (0x09, 20, 0, true, 20, 25),  // JNZ 0x02 falls through
            (0x0B, 20, 0, true, 20, 28),  // STA 0xF0
            (0x0D, 20, 0, true, 20, 29),  // HLT
        ];
    
        #[test]
        fn every_step_matches_the_lesson_trace() {
            let mut console = fresh();
            for (step, &(at, a, x, zero, gold, cycles)) in (1..).zip(TRACE.iter()) {
                assert_eq!(
                    console.cpu.pc, at,
                    "step {step} starts at the wrong address"
                );
                console.step().unwrap();
                let cpu = console.cpu;
                assert_eq!(
                    (cpu.a, cpu.x, cpu.zero, console.read(GOLD), cpu.cycles),
                    (a, x, zero, gold, cycles),
                    "after step {step}"
                );
            }
            assert!(console.cpu.halted);
            assert_eq!(console.display, 20);
        }
    
        #[test]
        fn a_jump_costs_one_more_cycle_when_it_is_taken() {
            let mut console = Console::with_program(&[0x06, 0x05]).unwrap();
            assert_eq!(console.step(), Ok(3));
            assert_eq!(console.cpu.pc, 0x05);
    
            let mut console = Console::with_program(&[0x06, 0x05]).unwrap();
            console.cpu.zero = true;
            assert_eq!(console.step(), Ok(2));
            assert_eq!(console.cpu.pc, 0x02);
        }
    
        #[test]
        fn addition_wraps_past_255_and_sets_the_zero_flag() {
            let mut console = Console::with_program(&[0x03, 0x06]).unwrap();
            console.cpu.a = 250;
            console.step().unwrap();
            assert_eq!(console.cpu.a, 0);
            assert!(console.cpu.zero);
        }
    
        #[test]
        fn a_budget_stops_between_instructions_never_inside_one() {
            let mut console = fresh();
            assert_eq!(console.run(FRAME_BUDGET), Ok(19));
            assert_eq!(
                (console.cpu.pc, console.cpu.a, console.cpu.x),
                (0x06, 20, 1)
            );
            assert_eq!(console.read(GOLD), 15, "the new total exists only in A");
        }
    
        #[test]
        fn loading_a_save_state_replays_the_same_future() {
            let mut console = fresh();
            console.run(FRAME_BUDGET).unwrap();
            let saved = console.save_state();
    
            console.run(1_000).unwrap();
            let first_ending = console.clone();
            assert!(first_ending.cpu.halted);
    
            console.write(GOLD, 0);
            console.load_state(&saved);
            assert_eq!(console.save_state(), saved);
            console.run(1_000).unwrap();
            assert_eq!(console, first_ending);
        }
    
        #[test]
        fn a_save_state_round_trips_through_255_bytes() {
            let mut console = fresh();
            console.run(FRAME_BUDGET).unwrap();
            let saved = console.save_state();
            let bytes = saved.to_bytes();
            assert_eq!(bytes.len(), 255);
            assert_eq!(bytes[..5], [0x06, 20, 1, 0, 0]);
            assert_eq!(SaveState::from_bytes(&bytes), Ok(saved));
        }
    
        #[test]
        fn a_damaged_save_state_is_rejected() {
            let bytes = fresh().save_state().to_bytes();
            assert_eq!(
                SaveState::from_bytes(&bytes[..254]),
                Err(EmuError::BadSaveState)
            );
            let mut damaged = bytes;
            damaged[3] = 2; // the zero flag can only be 0 or 1
            assert_eq!(SaveState::from_bytes(&damaged), Err(EmuError::BadSaveState));
        }
    
        #[test]
        fn unknown_bytes_are_an_error_not_a_guess() {
            let mut console = Console::with_program(&[]).unwrap();
            assert_eq!(
                console.step(),
                Err(EmuError::IllegalOpcode { at: 0, opcode: 0 })
            );
        }
    
        #[test]
        fn a_program_larger_than_ram_is_rejected() {
            assert_eq!(
                Console::with_program(&[0; 241]).unwrap_err(),
                EmuError::ProgramTooLarge { length: 241 }
            );
        }
    
        #[test]
        fn io_addresses_are_not_ram() {
            let mut console = fresh();
            console.write(DISPLAY, 42);
            assert_eq!(console.read(DISPLAY), 42);
    
            console.buttons = 0b0000_0101;
            console.write(BUTTONS, 0xFF);
            assert_eq!(
                console.read(BUTTONS),
                0b0000_0101,
                "the buttons are read-only"
            );
    
            console.write(0xF5, 7);
            assert_eq!(console.read(0xF5), 0, "nothing is wired at 0xF5");
        }
    
        #[test]
        fn a_cheat_is_rewritten_every_frame_but_loses_to_a_register_copy() {
            let mut console = fresh();
            let cheats = [Cheat {
                address: GOLD,
                value: 99,
            }];
            assert_eq!(console.run_frame(FRAME_BUDGET, &cheats), Ok(1));
            assert_eq!(console.read(GOLD), 99);
    
            assert_eq!(console.run_frame(FRAME_BUDGET - 1, &cheats), Ok(0));
            assert!(console.cpu.halted);
            assert_eq!(console.read(GOLD), 99);
            assert_eq!(
                console.display, 20,
                "the game copied gold into A before the cheat wrote it"
            );
        }
    }

    Build it, then run it from the rust-labs folder:

    Terminal window
    cargo run --bin toy_emulator_lab

    The lab first runs the traced program one instruction at a time and prints the same numbers as the tables above. Then it stops a run at the 18-cycle boundary, takes a save state, runs to the end, loads the state, and runs again, arriving at an identical machine. Finally, it runs two frames with the gold cheat and shows RAM at 99 while the display says 20. The tests check every one of the 13 trace steps, both costs of JNZ, wrapping at 255, the budget stop, the save state’s round trip through 255 bytes, the rejection of damaged save states and unknown opcodes, and the cheat’s race with register A.

    Then give the console a watchpoint: a watch: Option<u8> field, and an if in write that records self.cpu.pc whenever the watched address is written. Before you run the traced program with gold watched, predict which PC values it records and why they are not 0x06, the address of the STA.

    Knowledge check

    Paying back the overshoot

    The toy console's frame budget is 18 cycles. A frame stops at 19 cycles, because the instruction that crossed the budget could not stop halfway. Why does the emulator give the next frame 17 cycles instead of 18?

    Finished Lesson 13.10?

    Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

    Chapter 13: 10 lessons

    This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.