• he/him

Chronicling writing a simple NES emulator to learn Swift, macOS APIs, and retro architecture.


Now that there are registers and memory, it'd be nice if they did something, so next up is actually implementing the CPU functionality. The 6502s functionality is pretty basic, with only 56 different operations. Each can have several different addressing modes, but there's only 151 8-bit opcodes defined. The opcodes then have 0-2 extra bytes for operands, depending on the addressing mode. Let's get some definitions for the various addressing modes, using Swift's enum.

    enum AddressingMode: String {
        // zero bytes
        case implied
        case implied_a // same, but uses the accumulator
        
        // one byte - $nn
        case immediate // $#nn
        case zeropage // $00nn
        case zeropage_x // $00nn + x, no carry
        case zeropage_y // $00nn + y, no carry, LDX/STX only
        case indirect_pre_x // ($00xx + x), no carry
        case indirect_post_y // ($00xx) + y
        case relative // PC + signed $nn
        
        // two bytes - $ll $hh
        case absolute // $hhll
        case absolute_x // $hhll + X
        case absolute_y // $hhll + Y
        case indirect_absolute // ($hhll), JMP only
    }
syntax highlighting by codehost

These group the standard addressing modes by the number of additional bytes they take. 0 for the ones where the operand is completely encoded into the opcode (such as CLC for clear carry). 1 for immediates (like LDA #30, loading 30 into the accumulator) or zero page accesses (LDA $30, loading the data from memory location 0x0030) and the various zero page indexed and indirect modes, or for relative branches. And 2 for full 16-bit addresses (LDA $2000).

These aren't all strictly standard nomenclature, but the official terms "indexed indirect" and "indirect indexed" are not as clear as I think they should be, so I've called them "indirect pre x" and "indirect post y" instead.

With that, we're ready to implement some opcodes. Let's gather that information together is a nice struct:

struct Opcode {
    typealias Handler = (Environment, Opcode) -> Void
    typealias Map = [UInt8: AddressingMode]
    
    let name: Name
    let code: UInt8
    let mode: AddressingMode
    let handler: Handler
   
    enum Name: String {
        // Load and store
        case LDA
        case LDX
        case LDY
        case STA
        case STX
        case STY
        ...
    }
}

Here, I'm trying to make the type system my friend. Restrict the list of names using an enum, and define the type of a handler function. I tried to use some fancy features, like Swift's ResultBuilders, but in the end the simplest way to dispatch my opcodes seemed to be to just make a big table mapping bytes to functions (in this case, via their Opcode structs). Using the helper type Opcode.map, I defined some metadata, and a handler, and built a giant table out of them. Some sample opcodes for loading the registers:

        // MARK: Load and Store
        static let lda_map: Opcode.Map = [
            0xA9: .immediate,
            0xA5: .zeropage,
            0xB5: .zeropage_x,
            0xAD: .absolute,
            0xBD: .absolute_x,
            0xB9: .absolute_y,
            0xA1: .indirect_pre_x,
            0xB1: .indirect_post_y
        ]
        static func lda_handler(env: Environment, opcode: Opcode) {
            env.reg.a = loadValue(env: env, mode: opcode.mode)
            env.reg.p.setNZ(fromValue: env.reg.a)
        }
        
        static let ldx_map: Opcode.Map = [
            0xA2: .immediate,
            0xA6: .zeropage,
            0xB6: .zeropage_y,
            0xAE: .absolute,
            0xBE: .absolute_y
        ]
        static func ldx_handler(env: Environment, opcode: Opcode) {
            env.reg.x = loadValue(env: env, mode: opcode.mode)
            env.reg.p.setNZ(fromValue: env.reg.x)
        }
        
        static let ldy_map: Opcode.Map = [
            0xA0: .immediate,
            0xA4: .zeropage,
            0xB4: .zeropage_x,
            0xAC: .absolute,
            0xBC: .absolute_x
        ]
        static func ldy_handler(env: Environment, opcode: Opcode) {
            env.reg.y = loadValue(env: env, mode: opcode.mode)
            env.reg.p.setNZ(fromValue: env.reg.y)
        }

Using some helper functions to abstract away the common features of most opcodes, most of the handlers are very short. For example, loadValue will, based on the addressing mode, read the required amount of bytes to find the relevant value (either an immediate, or calculate the address and fetch the value from there), and setNZ sets the negative and zero flags based on the value fetched.

        private static func loadValue(env: Environment, mode: AddressingMode) -> UInt8 {
            return
                if mode == .immediate { env.loadAndAdvancePC() }
                else { env.load(calculateAddress(env: env, mode: mode)) }
        }

        private static func calculateAddress(env: Environment, mode: AddressingMode) -> Address {
            let low = env.loadAndAdvancePC()
            
            // May read two bytes from environment, advancing PC to calculate the address
            switch(mode) {
            case .implied, .implied_a, .immediate:
                fatalError("Cannot generate an address for \(mode)")
                
            // Zero page address calculations should wrap without carry, so use &+ on the UInt8
            case .zeropage:
                return Address(low)
            case .zeropage_x:
                return Address(low &+ env.reg.x)
            case .zeropage_y:
                return Address(low &+ env.reg.y)
            ...
       }

Swift's enums make it easy to make sure all cases are handled. So far, this does run a simplified model of the 6502; it currently does not count cycles, and it doesn't account for all the false/superfluous memory accesses the real 6502 does. For example, due to its design requiring it to make a memory access every cycle, even if unneeded, and address calculations sometimes taking multiple cycles due to carries, the real 6502 can, when doing indexed addressing (like, LDA $10FF,X, with x = 1), read from the wrong page ($1000) before the carry propagates to the high byte and it reads again from the correct address ($1100). While this makes little difference for standard memory, on memory mapped devices it can cause side effects! A high-accuracy emulator would probably need to account for that.

With all 56 operations and all 151 bytes mapped, and writing a quick dispatcher to read from memory, look up an opcode, and run the handlers, I could run through the CPU test mentioned previously. It found a couple small bugs in my implementation (notably I forgot to change which flag CLV (clear overflow) cleared when copy/pasting), but shortly after I was able to pass everything but the decimal mode tests, which I have not yet implemented yet since the NES does not need them. Most opcodes are only 2 or 3 lines of code after factoring out the commonalities, with only ADC and BRK really taking much effort at all.

I will have to work on the simulator fidelity soon I imagine, but with that, it may be possible to start putting in the skeleton for the NES's PPU.


You must log in to comment.