e-tipsmemo

ごった煮

NES Emulator in Rust : 2 CPU

e-tipsmemo.hatenablog.com
続き

CPU

#[derive(Default, Clone)]
pub struct Cpu {
    pub pc: Cell<u16>,
    pub a: Cell<u8>,
    pub x: Cell<u8>,
    pub y: Cell<u8>,
    pub s: Cell<u8>,
    pub p: Cell<Status>,
    pub nmi: Cell<bool>,
}

レジスタを用意する。Statusはbitflags crateでflag管理する。
メインメモリはNESが持っているので、メモリアクセスはNESメンバ関数にしてある。
ここら辺は参考サイトとほぼ同様

Fetch / Decode / Exec

//Not consume Cpu Cycle
let pc = nes.cpu.pc.get();
let bin = nes.read8(pc);
let opcode = num_traits::FromPrimitive::from_u8(bin)
	.unwrap_or_else(|| panic!("Unknown Opcode 0x{:02X} @0x{:04X}", bin, pc));

let oparg = match opcode {
	////////////////////
	/// LDA Operations
	////////////////////
	Opcode::LdaXind => {
		yield_all! { Xind_load(nes, RegA) }
	}
	Opcode::LdaZpg => {
		yield_all! { Zpg_load(nes, RegA) }
	}
....
	////////////////////
	/// LDY Operations
	////////////////////
	Opcode::LdyImm => {
		yield_all! { Imm_load(nes, RegY) }
	}
	Opcode::LdyZpg => {
		yield_all! { Zpg_load(nes, RegY) }
	}
....

公式命令の動作の説明は、いろいろなサイトで解説されており、基本的な動作をするものばかりであった。
アドレッシングモードのタイミングさえ合わせれば難しいことはない。

いくつかのアドレッシングモードでのアドレス計算ではオーバーフローを無視したりする計算があり、注意。

フラグを更新するときに使いまくっているstd::cell::Cellのupdateメソッドは
feature cell_updateが必要。

    #[inline]
    #[unstable(feature = "cell_update", issue = "50186")]
    pub fn update<F>(&self, f: F) -> T
    where
        F: FnOnce(T) -> T,
    {
        let old = self.get();
        let new = f(old);
        self.set(new);
        new
    }

f:id:katakanan:20210616211408p:plain:w400
実装抜けがないようにチェック(nestestドリブンで実装したほうがよかった気がする)

nestest.nes

命令実装で役に立つのが、nestest.nes

    if cfg!(feature = "nestestlog") {
        nes = nes::Nes::start("../nes-roms/nestest.nes");
        nes.cpu.pc.set(0xC000);
        nes.cpu.p.set(cpu::Status::from_bits_truncate(0x24));
    } else {
        nes = nes::Nes::start("../nes-roms/sample1.nes");
    }

0xC000にジャンプし、フラグを0x24、スタックアドレスは0xFD(デフォルト)とするとPPUなしでCPUの動作テストができる。
その動作結果を既存のlogと比較することで、ミスを発見し、直す。
https://www.qmtpro.com/%7Enes/misc/nestest.log

このlogですこし面倒だったのは、
左側にはあるアドレスで実行されるに命令とオペランドの組み合わせがかかれているが、
右側にあるのはその命令を実行する前のCPUの状態とPPU、CPUサイクルということだった。

C8D1  B8        CLV                             A:00 X:00 Y:00 P:6F SP:FB PPU:  4, 19 CYC:461
C8D2  A9 F8     LDA #$F8                        A:00 X:00 Y:00 P:2F SP:FB PPU:  4, 25 CYC:463
C8D4  29 EF     AND #$EF                        A:F8 X:00 Y:00 P:AD SP:FB PPU:  4, 31 CYC:465

2行目のLDX #$FBが実行された結果、3行目の右側の状態となる。

このエミュレータがGeneratorを使用しているので、
左側の命令の情報がすべて得られている時にはすでに、fetch/decode/execが実行された後となり、(とくにCPUサイクルは進んでいる)
エミュレータ内部で持っているCPUの状態はすでに変わっているので
前の状態をわざわざ保存しておかなければならなかった。

loop {
	match Pin::new(&mut nes_cpu).resume(()) {
		GeneratorState::Yielded(cpu_step @ cpu::CpuStep::Cycle) => {
			cpu_cycle = cpu_cycle + 1;
			yield NesStep::Cpu(cpu_step);
			break;
		}
		GeneratorState::Yielded(operation) => {
			if cfg!(feature = "nestestlog") {
				println!(
					"{:<48}{} PPU:{:>3},{:>3} CYC:{}",
					cpu::debug_op(&operation),
					cpu::debug_reg(&regs_before_exec),
					ppu_line_before_exec,
					ppu_cycle_before_exec,
					cpu_cycle_before_exec
				);
				cpu_cycle_before_exec = cpu_cycle;
				ppu_cycle_before_exec = ppu_cycle;
				ppu_line_before_exec = ppu_line;
				regs_before_exec = self.cpu.clone();
			}

			yield NesStep::Cpu(operation);
		}
	}
}

汚い。

nestestを通しつつある。(WinMergeUを使用)
f:id:katakanan:20210716213858p:plain
f:id:katakanan:20210719075353p:plain
0xC6BCまでが公開されている命令セットのテストとなる。ここまで通っていれば大体のゲームは動くらしい。

Undocumented Instruction

せっかくなのでnestest.logを通すためだけのUndocumented Instructionも実装した。
いくつかのUndocumented ISAは、既存のOfficial ISAが組み合わさったような動作を行う。
https://www.nesdev.com/undocumented_opcodes.txt
例えば、

ISC (ISB) [INS]
=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D
Increase memory by one, then subtract memory from accu-mulator (with
borrow). Status flags: N,V,Z,C

なので INCとSUBが組み合わさっている。

pub struct ISB;
impl MemCalcOperation for ISB {
    #[inline(always)]
    fn calc(&self, nes: &Nes, value: u8) -> u8 {
        let tmp = (value as u16 + 1) as u8; //Increase memory by one
        let c = nes.cpu.p.get().contains(Status::C) as u16;
        let a = nes.cpu.a.get();
        let not_value = !tmp;
        let res = (a as u16) + (not_value as u16) + c; //then subtract memory from accu-mulator
        //A - b- !c = A + (not b) + c
        let mut p = nes.cpu.p.get();
        p.set(Status::Z, res & 0xFF == 0);
        p.set(Status::C, res & 0x100 != 0);
        p.set(Status::N, res & 0x80 != 0);
        p.set(
            Status::V,
            (a ^ res as u8) & (not_value ^ res as u8) & 0x80 != 0,
        );

        nes.cpu.p.set(p);
        nes.cpu.a.set(res as u8);
        tmp
    }
}

アドレス0xC68B以降は入力やAPUなのでとりあえず無視。0xFFを返すようにしておけば問題ない。

この記事を書きながら、nestestにないUndocumented ISAもあることに気づいたが。
それらはやる気が出たら実装する。
HLTとかASR (ASR) [ALR]....

こうして命令実装はかなり順調に進む。
SUBのオーバーフローなどはわかりにくいが検索するとすぐ出てくる。
Determining carry and overflow flag in 6502 emulation in Java? - Stack Overflow


大半のアドレッシングモードを計算してから、オーバーフローを無視するアドレス計算では、wrapping_addといった関数を使えば、かなり楽できたということを知った。
(知らなかったのでがんばって符号拡張したりしている。しかし符号拡張が間違っているところでは実行中にoverflow panicを起こしたりするかもしれない。)
f:id:katakanan:20210821180758p:plain
こういう基礎的なところを知るためにも、NESエミュレータ作成はプログラミング言語入門には最適だと改めて感じた。

またnestestを通せば最低限CPUは間違っていないという安心感があるので、心おきなくPPU実装に移ることができる。
実際、background機能だけのPPUを実装後、nestsetを実行すると一発で通った。

f:id:katakanan:20210804080502p:plain
PPUはバグっているが、nestestは通っていることがわかる。