Emulator Timing and State
A toy console makes instruction costs, frame timing, save states, and register races concrete inside a Rust emulator.
Cheatsheet for Lesson 13.10
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::decodeis amatchon the byte that returnsNonefor anything that is not an instruction, and the?turns that into anIllegalOpcodeerror that names the address.match:Opcode::decodeis amatchon the byte that returnsNonefor anything that is not an instruction, and the?turns that into anIllegalOpcodeerror that names the address.BUTTONS:DISPLAYandBUTTONSare constants holding0xF0and0xF1.- mirroring: This is called mirroring.
read: An NES emulator'sreadis 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 byJNZ(jump while Z is 0). X was 1 beforeDEX. Why doesJNZnot jump this time? A: DEX made X zero, so the zero flag Z became 1
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.
A toy 8-bit CPU
Section titled “A toy 8-bit CPU”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,
0x00to0xFF, 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 in memory
Section titled “The program in memory”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 meaning0x00 01 02 LDX #2 X = 2: go round the loop twice0x02 02 80 LDA 0x80 A = gold0x04 03 05 ADD #5 A = A + 50x06 04 80 STA 0x80 gold = A0x08 05 DEX X = X - 10x09 06 02 JNZ 0x02 back to 0x02 while X is not 00x0B 04 F0 STA 0xF0 show A on the display0x0D FF HLT stopEach 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:
LDX #2LDA 0x80ADD #5STA 0x800x00 to 0x07, one byte per box: the first four instructions.DEXJNZ 0x02STA 0xF0HLT0x08 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:
0x80 holds 10, which is 0x0A.Trace the program
Section titled “Trace the program”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:
1 LDX and the first loop pass: 2 + 12 = 14
Cycles, drawn to scale029 cycles212114Loading X costs 2 cycles; the first pass costs 12 and makes gold 10 + 5 = 15.
2 Second loop pass: 14 + 11 = 25
Cycles, drawn to scale029 cycles212114The second pass is one cycle cheaper and makes gold 15 + 5 = 20.
3 Display and halt: 25 + 4 = 29
Cycles, drawn to scale029 cycles212114The 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.
HLT. PC is 0x0E, one past the HLT at 0x0D, because fetching the opcode moved it on.The interpreter in Rust
Section titled “The interpreter in Rust”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.
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
- 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.
- 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.
- 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.
- Fetch the address operand: read the byte at PC = 0x03, which is 80, and advance PC to 0x04. The operand names RAM address 0x80.
- 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.
- 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.
- Fetch opcode
- Decode
- Fetch address
- Read RAM into A
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
| Action | PC | A | Cycles | What happened |
|---|---|---|---|---|
| Starting state | 0x02 | 0 | 2 | RAM[0x80] holds 10. |
| Fetch opcode | 0x03 | 0 | 3 | Read 02 from program address 0x02. |
| Decode | 0x03 | 0 | 3 | 02 means LDA. Decoding adds no memory read. |
| Fetch address | 0x04 | 0 | 4 | Read operand 80 from program address 0x03. |
| Read RAM into A | 0x04 | 10 | 5 | A becomes 10; Z stays 0; RAM still holds 10. |
What does a rising clock edge do?
Data can change between clock edges. The stored value changes only when the clock goes from 0 to 1.
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
| Time | Clock | Data | Stored value | Reason |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | Initial state |
| 1 | 1 | 1 | 1 | Rising edge captures data |
| 2 | 0 | 0 | 1 | Falling edge keeps the stored value |
| 3 | 1 | 0 | 0 | Rising 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 memory map as code
Section titled “The memory map as code”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.
Frame budgets and overshoot
Section titled “Frame budgets and overshoot”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:
STA at 0x06 runs.A save state, byte by byte
Section titled “A save state, byte by byte”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:
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.
A cheat code racing a register
Section titled “A cheat code racing a register”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:
Run the toy console
Section titled “Run the toy console”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
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:
cargo run --bin toy_emulator_labThe 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.
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?
An instruction always runs to its end, so a frame can pass its budget: here step 8, an ADD worth 2 cycles, started at 17 and finished at 19. If every frame then got a fresh 18, the extra cycles would pile up and the emulated machine would run ahead of real time. Subtracting each overshoot from the next budget cancels it, so over many frames the average is exactly 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.
Plain Markdown, saved in this browser.
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.