neser 0.1.1

NESER - NES Emulator in Rust - is a NES emulator written in Rust. It aims to be a high-quality, hardware-accurate emulator that is also easy to use and extend. It supports a wide range of NES games and features, including various mappers, audio processing, and input handling. NESER is designed to be modular and extensible, allowing developers to easily add new features or support for additional hardware. It can be run using one of two frontends: a native desktop application using SDL2, or a web application using WebAssembly. The desktop application provides a high-performance, feature-rich experience with support for various input devices and display options, while the web application allows users to play NES games directly in their browsers without needing to install any software in a BYOR manner (Bring Your Own Roms).
Documentation
use crate::cpu;

use super::types::{CpuDisasmLineSnapshot, CpuDisasmWindowState};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisasmWindowConfig {
    pub before: usize,
    pub after: usize,
    pub top_margin: usize,
    pub bottom_margin: usize,
}

impl Default for DisasmWindowConfig {
    fn default() -> Self {
        // Total window height is before + 1 + after.
        // 10 + 1 + 9 = 20 lines.
        Self {
            before: 10,
            after: 9,
            top_margin: 3,
            bottom_margin: 3,
        }
    }
}

pub fn disassemble_window<F: Fn(u16) -> u8>(
    read: F,
    pc: u16,
    config: DisasmWindowConfig,
) -> Vec<CpuDisasmLineSnapshot> {
    let mut start = pc;
    for _ in 0..config.before {
        let Some(prev) = prev_instruction_start(&read, start) else {
            break;
        };
        start = prev;
    }

    let target_lines = config.before + 1 + config.after;
    disassemble_from_start(&read, start, pc, target_lines)
}

pub fn disassemble_window_with_state<F: Fn(u16) -> u8>(
    read: F,
    pc: u16,
    state: &mut CpuDisasmWindowState,
    config: DisasmWindowConfig,
) -> Vec<CpuDisasmLineSnapshot> {
    let target_lines = config.before + 1 + config.after;

    let mut lines = if let Some(start) = state.start {
        disassemble_from_start(&read, start, pc, target_lines)
    } else {
        disassemble_window(&read, pc, config)
    };

    let current_index = lines.iter().position(|l| l.is_current);

    if let Some(idx) = current_index {
        let last_two_start = lines.len().saturating_sub(2);
        if idx >= last_two_start {
            lines = disassemble_window(&read, pc, config);
            state.start = lines.first().map(|l| l.addr);
            return lines;
        }

        // Keep the existing start when the current line is safely within the window.
        state.start = lines.first().map(|l| l.addr);
        return lines;
    }

    // PC not found (e.g., jumped). Re-center using the original logic.
    lines = disassemble_window(&read, pc, config);
    state.start = lines.first().map(|l| l.addr);
    lines
}

fn disassemble_from_start<F: Fn(u16) -> u8>(
    read: &F,
    start: u16,
    pc: u16,
    target_lines: usize,
) -> Vec<CpuDisasmLineSnapshot> {
    let mut lines = Vec::with_capacity(target_lines);

    let mut addr = start;
    for _ in 0..target_lines {
        let line = disassemble_one(read, addr, pc);
        let step = (line.bytes.len() as u16).max(1);
        addr = addr.wrapping_add(step);
        lines.push(line);

        if addr == 0 {
            break;
        }
    }

    lines
}

fn prev_instruction_start<F: Fn(u16) -> u8>(read: &F, pc: u16) -> Option<u16> {
    for len in (1u16..=3u16).rev() {
        let start = pc.wrapping_sub(len);
        let op = read(start);
        let meta = cpu::lookup(op);

        if meta.bytes() as u16 == len {
            return Some(start);
        }
    }

    None
}

fn disassemble_one<F: Fn(u16) -> u8>(read: &F, addr: u16, pc: u16) -> CpuDisasmLineSnapshot {
    let op = read(addr);
    let meta = cpu::lookup(op);
    let len = meta.bytes() as usize;

    let mut bytes = Vec::with_capacity(len);
    for i in 0..len {
        bytes.push(read(addr.wrapping_add(i as u16)));
    }

    let text = format_instruction(meta, addr, &bytes);

    CpuDisasmLineSnapshot {
        addr,
        bytes,
        text,
        is_current: addr == pc,
    }
}

fn format_instruction(meta: &cpu::OpCode, addr: u16, bytes: &[u8]) -> String {
    let operand = match meta.mode {
        "IMP" => String::new(),
        "ACC" => "A".to_string(),
        "IMM" => format!("#${:02X}", bytes.get(1).copied().unwrap_or(0)),
        "ZP" => format!("${:02X}", bytes.get(1).copied().unwrap_or(0)),
        "ZPX" => format!("${:02X},X", bytes.get(1).copied().unwrap_or(0)),
        "ZPY" => format!("${:02X},Y", bytes.get(1).copied().unwrap_or(0)),
        "INDX" => format!("(${:02X},X)", bytes.get(1).copied().unwrap_or(0)),
        "INDY" | "INDYW" => format!("(${:02X}),Y", bytes.get(1).copied().unwrap_or(0)),
        "REL" => {
            let off = bytes.get(1).copied().unwrap_or(0) as i8;
            let next = addr.wrapping_add(2);
            let target = next.wrapping_add(off as i16 as u16);
            format!("${:04X}", target)
        }
        "ABS" => {
            let lo = bytes.get(1).copied().unwrap_or(0);
            let hi = bytes.get(2).copied().unwrap_or(0);
            format!("${:04X}", u16::from_le_bytes([lo, hi]))
        }
        "ABSX" | "ABSXW" => {
            let lo = bytes.get(1).copied().unwrap_or(0);
            let hi = bytes.get(2).copied().unwrap_or(0);
            format!("${:04X},X", u16::from_le_bytes([lo, hi]))
        }
        "ABSY" | "ABSYW" => {
            let lo = bytes.get(1).copied().unwrap_or(0);
            let hi = bytes.get(2).copied().unwrap_or(0);
            format!("${:04X},Y", u16::from_le_bytes([lo, hi]))
        }
        "IND" => {
            let lo = bytes.get(1).copied().unwrap_or(0);
            let hi = bytes.get(2).copied().unwrap_or(0);
            format!("(${:04X})", u16::from_le_bytes([lo, hi]))
        }
        _ => String::new(),
    };

    if operand.is_empty() {
        meta.mnemonic.to_string()
    } else {
        format!("{} {}", meta.mnemonic, operand)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_disasm_window_recenters_when_current_reaches_second_last_row() {
        // Use a memory model of all NOPs (0xEA = 1 byte) so instruction boundaries are trivial.
        let read = |_addr: u16| 0xEA;

        let config = DisasmWindowConfig {
            before: 4,
            after: 4,
            top_margin: 3,
            bottom_margin: 3,
        };

        let mut state = CpuDisasmWindowState::default();

        // Initial view is centered around pc (current at index BEFORE).
        let base_pc = 0xC000;
        let lines0 = disassemble_window_with_state(read, base_pc, &mut state, config);
        let idx0 = lines0.iter().position(|l| l.is_current).unwrap();
        assert_eq!(idx0, config.before);

        // Move current to the second-last visible row.
        let target_lines = config.before + 1 + config.after;
        let second_last_index = target_lines - 2;
        let pc_at_second_last = base_pc.wrapping_add((second_last_index - idx0) as u16);

        let lines1 = disassemble_window_with_state(read, pc_at_second_last, &mut state, config);
        let idx1 = lines1.iter().position(|l| l.is_current).unwrap();
        assert_eq!(idx1, config.before);
    }

    #[test]
    fn test_disasm_window_keeps_start_when_current_is_inside_window_and_not_in_last_two_rows() {
        let read = |_addr: u16| 0xEA;

        let config = DisasmWindowConfig {
            before: 8,
            after: 8,
            top_margin: 3,
            bottom_margin: 3,
        };

        let mut state = CpuDisasmWindowState::default();

        let base_pc = 0xC000;
        let lines0 = disassemble_window_with_state(read, base_pc, &mut state, config);
        let start0 = lines0.first().unwrap().addr;

        // Advance by one instruction; current remains comfortably inside window.
        let lines1 =
            disassemble_window_with_state(read, base_pc.wrapping_add(1), &mut state, config);
        let idx1 = lines1.iter().position(|l| l.is_current).unwrap();

        assert_eq!(idx1, config.before + 1);
        assert_eq!(lines1.first().unwrap().addr, start0);
    }

    #[test]
    fn test_disasm_window_recenters_when_current_is_outside_existing_window() {
        let read = |_addr: u16| 0xEA;

        let config = DisasmWindowConfig {
            before: 8,
            after: 8,
            top_margin: 3,
            bottom_margin: 3,
        };

        let mut state = CpuDisasmWindowState::default();

        let base_pc = 0xC000;
        let _ = disassemble_window_with_state(read, base_pc, &mut state, config);

        // Jump far enough so the current PC is outside the existing window.
        let jumped_pc = base_pc.wrapping_add(0x40);
        let lines1 = disassemble_window_with_state(read, jumped_pc, &mut state, config);
        let idx1 = lines1.iter().position(|l| l.is_current).unwrap();

        assert_eq!(idx1, config.before);
    }
}