The First Opcode: JP

With the CPU structure defined and memory in place, we can implement the fetch-decode-execute cycle. The first opcode to implement is JP — it is simple enough to understand the full cycle, and it exercises fetch, bitwise decoding, and the program counter.

The cycle

Every CPU works by repeating three steps:

1. Fetch   — read the next instruction from memory
2. Decode  — figure out what it says
3. Execute — do it

In CHIP-8, every instruction is exactly 2 bytes. The fetch step reads both bytes and combines them into a single 16-bit value.

Fetch

#![allow(unused)]
fn main() {
pub fn step(&mut self) {
    // read two bytes from memory at the current PC
    let high = self.memory.read(self.pc) as u16;
    let low  = self.memory.read(self.pc + 1) as u16;
    let opcode = (high << 8) | low;

    // advance PC before executing
    self.pc += 2;

    self.execute(opcode);
}
}

high is shifted 8 bits left to occupy the upper byte, then OR'd with low to fill the lower byte. The result is the full 16-bit opcode.

PC advances by 2 before execute. This means when execute runs, PC already points to the next instruction. Jump opcodes simply overwrite PC with a new address.

Opcode 1NNN — JP addr

JP jumps to address NNN. The next instruction executed will be whatever lives at that address.

1NNN
│└──┘
│  └─ NNN: 12-bit target address
└──── opcode category 1

Example: 0x1300 means "jump to address 0x300".

Decode and execute

#![allow(unused)]
fn main() {
fn execute(&mut self, opcode: u16) {
    let nibble = (opcode & 0xF000) >> 12;  // first nibble: category

    match nibble {
        0x1 => {
            let nnn = opcode & 0x0FFF;  // lower 12 bits: address
            self.pc = nnn;
        }
        _ => println!("unknown opcode: {:#06X}", opcode),
    }
}
}

Mask 0xF000 isolates the first nibble. The shift moves it down from bits 15–12 to bits 3–0, producing a value from 0 to 15. That value drives the match.

Mask 0x0FFF isolates the lower 12 bits — the address. No shift needed because the address goes directly into pc.

Test

#![allow(unused)]
fn main() {
#[test]
fn test_jp() {
    let mut cpu = Cpu::new();
    // 0x1300 = JP 0x300
    cpu.memory.load_rom(&[0x13, 0x00]);
    cpu.step();
    assert_eq!(cpu.pc, 0x300);
}
}

Rust concepts used here: Bitwise masking and shifting are covered in Bitwise Operations. The match on the first nibble is the pattern used for every opcode — see Pattern Matching. The as u16 casts in fetch are explained in Type Casting. The {:#06X} format specifier prints hex values with 0x prefix, padded to 6 characters.