diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..960c103 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + +# Lint only — ruff is a standalone binary, so this job skips the project +# install (PyPy 3.10 + SDL2 headers + Cython) entirely. +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: ruff check + run: uvx ruff@0.16.2 check --output-format=github . + - name: ruff format --check + run: uvx ruff@0.16.2 format --check . diff --git a/Makefile b/Makefile index 88fef41..18cfbdd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean run install test benchmark docs +.PHONY: build clean run install test benchmark docs lint format PY ?= --python pypy@3.10 @@ -38,3 +38,13 @@ profile: build tests: build @echo "Running tests..." uv run ${PY} pytest + +lint: + @echo "Linting PySNES..." + uv run ${PY} ruff check . + uv run ${PY} ruff format --check . + +format: + @echo "Formatting PySNES..." + uv run ${PY} ruff check --fix . + uv run ${PY} ruff format . diff --git a/pyproject.toml b/pyproject.toml index 299f318..6159830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,44 @@ dependencies = [ [project.scripts] pysnes = "pysnes.pysnes:main" + +[dependency-groups] +dev = [ + # Pinned exactly: a floating formatter version makes CI and local + # checkouts disagree about what "formatted" means. + "ruff==0.16.2", +] + +[tool.ruff] +line-length = 80 +target-version = "py310" +# Third-party checkouts (Mesen2, the SingleStepTests suites). Markdown is +# excluded too: ruff formats Python blocks inside .md, which would rewrite +# the hand-tuned examples in AGENTS.md. +extend-exclude = ["submodules", "*.md"] + +[tool.ruff.lint] +# E/W (pycodestyle), F (pyflakes), I (isort), UP (pyupgrade), B (bugbear), +# SIM (flake8-simplify), C4 (comprehensions), PT (pytest-style), +# PIE/RET/G (misc + return + logging), ISC/LOG (ratchets, today clean). +# TID is deliberately out: pysnes is a single package and relative imports +# between its modules are the house style. +select = [ + "E", "W", "F", "I", "UP", "B", + "SIM", "C4", "PT", "PIE", "RET", "G", "ISC", "LOG", +] + +# SIM108 pushes if/else blocks into ternaries, but a ternary long enough to +# exceed 80 columns gets wrapped by the formatter into a multi-line ternary, +# which is harder to read than the if/else it replaced. House rule wins. +ignore = ["SIM108"] + +[tool.ruff.lint.per-file-ignores] +# Package __init__ files exist to re-export their module's public names. +"**/__init__.py" = ["F401", "F403"] +# Hand-aligned hardware tables (the 512-entry Gaussian interpolation table, +# the 256-entry SPC700 opcode table). They are kept in their reference +# layout inside `# fmt: off` blocks, which stops the formatter reflowing +# them but does not exempt them from the line-length rule. +"pysnes/apu/dsp.py" = ["E501"] +"pysnes/apu/spc700/instructions_spc700.py" = ["E501"] diff --git a/pysnes/_ss_cache.py b/pysnes/_ss_cache.py index 143e964..3c3c66b 100644 --- a/pysnes/_ss_cache.py +++ b/pysnes/_ss_cache.py @@ -16,7 +16,6 @@ import os import pickle - CACHE_DIR = ".pytest_cache" @@ -31,7 +30,9 @@ def _dir_fingerprint(tests_path): def _cache_key(suite_name, tests_path, filter_args): - blob = repr((suite_name, filter_args, _dir_fingerprint(tests_path))).encode() + blob = repr( + (suite_name, filter_args, _dir_fingerprint(tests_path)) + ).encode() return hashlib.sha1(blob).hexdigest()[:16] @@ -62,10 +63,11 @@ def _atomic_write(path, key, params, ids): def get_or_build(suite_name, tests_path, filter_args, parse_fn): - """Return (params, ids). params is [(file_path, index), ...]; ids are pytest IDs. + """Return (params, ids), where params is [(file_path, index), ...] and + ids are pytest IDs. - On cache hit: read pickle, return. - On cache miss: acquire exclusive flock, re-check, call parse_fn(), write, return. + On cache hit: read pickle, return. On cache miss: acquire exclusive flock, + re-check, call parse_fn(), write, return. """ os.makedirs(CACHE_DIR, exist_ok=True) key = _cache_key(suite_name, tests_path, filter_args) diff --git a/pysnes/apu/apu.py b/pysnes/apu/apu.py index 6021dae..a93def7 100644 --- a/pysnes/apu/apu.py +++ b/pysnes/apu/apu.py @@ -1,9 +1,8 @@ from typing import Any - -from .spc700.instructions_spc700 import INSTRUCTIONS -from .spc700.disassembler import SPC700Disassembler from .dsp import Dsp +from .spc700.disassembler import SPC700Disassembler +from .spc700.instructions_spc700 import INSTRUCTIONS class InstructionSlot: @@ -15,11 +14,11 @@ class InstructionSlot: """ def __init__(self, func, a0, a1=None, a2=None, a3=None): - self._func = func - self._a0 = a0 - self._a1 = a1 - self._a2 = a2 - self._a3 = a3 + self._func = func + self._a0 = a0 + self._a1 = a1 + self._a2 = a2 + self._a3 = a3 if a3 is not None: self._nargs = 4 elif a2 is not None: @@ -41,7 +40,6 @@ def call(self): class Timer: - def __init__(self, apu: "Apu", frequency: int) -> None: self.apu = apu self.frequency = frequency @@ -53,8 +51,13 @@ def __init__(self, apu: "Apu", frequency: int) -> None: self.target = 0x00 _STATE_FIELDS = ( - "frequency", "stage0", "stage2", "stage3", "stage3_shadow", - "enable", "target", + "frequency", + "stage0", + "stage2", + "stage3", + "stage3_shadow", + "enable", + "target", ) def dump_state(self) -> dict: @@ -84,19 +87,12 @@ def step(self, clocks: int) -> None: class Apu: - # Registers - # Flags - # Timing - # Registers (raw storage) - # Test register ($F0) fields - # Debug - # Memory regions - # Instruction dispatch - # Memory access tracing (None = disabled; set to [] in tests to capture accesses) - # Flat I/O mode: when True, reads/writes to $F0-$FC bypass I/O routing and - # use page_0 directly. Set by the single-step test harness so that CPU unit - # tests see a simple flat-RAM model instead of DSP/port indirection. - # Instruction trace + # Registers Flags Timing Registers (raw storage) Test register ($F0) fields + # Debug Memory regions Instruction dispatch Memory access tracing (None = + # disabled; set to [] in tests to capture accesses) Flat I/O mode: when + # True, reads/writes to $F0-$FC bypass I/O routing and use page_0 directly. + # Set by the single-step test harness so that CPU unit tests see a simple + # flat-RAM model instead of DSP/port indirection. Instruction trace def __init__(self) -> None: self.reset_registers() @@ -134,10 +130,10 @@ def __str__(self) -> str: def reset_registers(self): # Registers self.PC = 0xFFC0 # Program Counter (16 bit) - self.A = 0x00 # Accumulator (8 bit) - self.X = 0x00 # X Index Register (8 bit) - self.Y = 0x00 # Y Index Register (8 bit) - self.S = 0xEF # Stack Pointer (8 bit) - always on page 1 + self.A = 0x00 # Accumulator (8 bit) + self.X = 0x00 # X Index Register (8 bit) + self.Y = 0x00 # Y Index Register (8 bit) + self.S = 0xEF # Stack Pointer (8 bit) - always on page 1 # Flags stored in PSW Register self.NF = False # Negative @@ -146,25 +142,26 @@ def reset_registers(self): self.BF = False # Break self.HF = False # Half carry self.IF = False # Interrupt enabled (unused) - self.ZF = True # Zero + self.ZF = True # Zero self.CF = False # Carry self.timers = [Timer(self, 128), Timer(self, 128), Timer(self, 16)] - # Catchup clock tracking: master clock value at last APU sync. - # APU runs at ~1.024 MHz; 1 APU clock ≈ 21 master clocks (21477272/1024000). - # Start 2 APU bus cycles ahead of master-clock 0 to model the SPC700 reset - # vector fetch that Mesen performs (Spc::Reset -> ReadWord(ResetVector)) - # before any IPL ROM instruction runs. Without this the APU trails by ~42 MC - # and the SMW main-CPU↔APU handshake loop exits one iteration late. + # Catchup clock tracking: master clock value at last APU sync. APU runs + # at ~1.024 MHz; 1 APU clock ≈ 21 master clocks (21477272/1024000). + # Start 2 APU bus cycles ahead of master-clock 0 to model the SPC700 + # reset vector fetch that Mesen performs (Spc::Reset -> + # ReadWord(ResetVector)) before any IPL ROM instruction runs. Without + # this the APU trails by ~42 MC and the SMW main-CPU↔APU handshake loop + # exits one iteration late. self._last_synced_mc: int = -(2 * 21477272 // 1024000) # Fixed-point remainder for the APU cycle budget (in units of MC_DEN). # Avoids lossy MC↔APU-cycle round-trips in sync_to. self._apu_mc_frac: int = 0 # Per-instruction cycle counter. Reset at the top of fetch_and_execute; - # incremented by every read_external, write_external, and idle() call so tests - # can assert the total cycle count matches the reference data. + # incremented by every read_external, write_external, and idle() call so + # tests can assert the total cycle count matches the reference data. self.cycles: int = 0 self._control_register_raw = 0x80 # F1 raw written value (for readback) @@ -172,8 +169,6 @@ def reset_registers(self): self.control_register = 0x80 # F1 (write only) self.dsp_register_address = 0x00 # F2 (r/w) self.dsp_register_data = 0x00 # F3 (r/w) - # self.timers = bytearray(3) # FA/FB/FC (/w) - # self.counters = bytearray(3) # FD/FE/FF (r/) # $F8/$F9 AUXIO4/AUXIO5: general-purpose 8-bit R/W scratch registers self.auxio4 = 0 self.auxio5 = 0 @@ -191,7 +186,7 @@ def reset_registers(self): self.trace_enabled = False self.trace_log = [] - if hasattr(self, 'dsp') and self.dsp is not None: + if hasattr(self, "dsp") and self.dsp is not None: self.dsp = Dsp(self.read_ram) def allocate_memory(self): @@ -208,7 +203,8 @@ def allocate_memory(self): )) # fmt: on - self.page_0 = bytearray(0x0100) # 0x00–0xFF; upper 16 bytes used by _io_flat mode + # 0x00–0xFF; upper 16 bytes used by _io_flat mode + self.page_0 = bytearray(0x0100) self.page_1 = bytearray(0x0100) # The IO Port0-4 registers have separete memory for R/W @@ -216,14 +212,34 @@ def allocate_memory(self): self.ports_w = bytearray(4) # APU writes to _SCALAR_STATE = ( - "PC", "A", "X", "Y", "S", - "NF", "VF", "PF", "BF", "HF", "IF", "ZF", "CF", - "_last_synced_mc", "_apu_mc_frac", "cycles", + "PC", + "A", + "X", + "Y", + "S", + "NF", + "VF", + "PF", + "BF", + "HF", + "IF", + "ZF", + "CF", + "_last_synced_mc", + "_apu_mc_frac", + "cycles", "_control_register_raw", - "dsp_register_address", "dsp_register_data", - "auxio4", "auxio5", - "ipl_rom_enable", "timers_disable", "ram_writable", "ram_disable", - "timers_enable", "external_wait_states", "internal_wait_states", + "dsp_register_address", + "dsp_register_data", + "auxio4", + "auxio5", + "ipl_rom_enable", + "timers_disable", + "ram_writable", + "ram_disable", + "timers_enable", + "external_wait_states", + "internal_wait_states", ) def dump_state(self) -> dict: @@ -239,13 +255,13 @@ def dump_state(self) -> dict: def load_state(self, d: dict) -> None: self.memory[:] = d["memory"] - self.page_0[:len(d["page_0"])] = d["page_0"] + self.page_0[: len(d["page_0"])] = d["page_0"] self.page_1[:] = d["page_1"] self.ports_r[:] = d["ports_r"] self.ports_w[:] = d["ports_w"] for f, v in d["scalars"].items(): setattr(self, f, v) - for t, ts in zip(self.timers, d["timers"]): + for t, ts in zip(self.timers, d["timers"], strict=True): t.load_state(ts) def load_instructions(self): @@ -256,7 +272,9 @@ def load_instructions(self): self.debug_symbols[opcode] = f"{addr_mode.__name__}" if args: if hasattr(args[0], "__name__"): - self.debug_symbols[opcode] += f" {args[0].__name__} {args[1:]}" + self.debug_symbols[opcode] += ( + f" {args[0].__name__} {args[1:]}" + ) else: self.debug_symbols[opcode] += f" {args}" self.debug_symbols[opcode] = self.debug_symbols[opcode].ljust(30) @@ -266,29 +284,31 @@ def idle(self): self.cycles += 1 def generate_audio_frame(self, n_samples: int): - """Generate n_samples of 16-bit stereo audio. Returns numpy array shape (n_samples, 2).""" + """Generate n_samples of 16-bit stereo audio. Returns numpy array shape + (n_samples, 2).""" return self.dsp.generate_samples(n_samples) def read_ram(self, addr: int) -> int: - """Read APU RAM for DSP use — no cycle increment, no I/O side-effects.""" + """Read APU RAM for DSP use — no cycle increment, no I/O + side-effects.""" addr &= 0xFFFF if addr <= 0x00EF: return self.page_0[addr] - elif addr <= 0x00FF: + if addr <= 0x00FF: return 0 # I/O register range — BRR data never lives here - elif addr <= 0x01FF: + if addr <= 0x01FF: return self.page_1[addr - 0x0100] - elif addr <= 0xFFBF: + if addr <= 0xFFBF: return self.memory[addr - 0x0200] - else: - return self.ipl_rom[addr - 0xFFC0] + return self.ipl_rom[addr - 0xFFC0] def load_program(self, data): """Used for testing only""" self.ipl_rom = data def load_spc(self, spc) -> None: - """Load SPC700 state from a parsed SpcFile, bypassing the IPL boot sequence.""" + """Load SPC700 state from a parsed SpcFile, bypassing the IPL boot + sequence.""" ram = spc.ram # Page 0 ($0000-$00EF) — general RAM @@ -302,12 +322,12 @@ def load_spc(self, spc) -> None: self.ipl_rom = bytearray(spc.extra_ram) # CPU registers - self.PC = spc.pc - self.A = spc.a - self.X = spc.x - self.Y = spc.y + self.PC = spc.pc + self.A = spc.a + self.X = spc.x + self.Y = spc.y self.PSW = spc.psw - self.S = spc.sp + self.S = spc.sp # DSP registers for addr, val in enumerate(spc.dsp_regs): @@ -317,25 +337,26 @@ def load_spc(self, spc) -> None: self.auxio5 = ram[0x00F9] self.dsp_register_address = ram[0x00F2] - # Timer targets ($FA-$FC) live in the I/O region skipped above — restore explicitly. + # Timer targets ($FA-$FC) live in the I/O region skipped above — restore + # explicitly. for i in range(3): self.timers[i].target = ram[0x00FA + i] - # Control register enables/disables timers and may reset port latches (bits 4/5). - # Set it before restoring ports_r so the port reset doesn't clobber the saved values. + # Control register enables/disables timers and may reset port latches + # (bits 4/5). Set it before restoring ports_r so the port reset doesn't + # clobber the saved values. self.control_register = ram[0x00F1] self.ipl_rom_enable = False - # Restore ports_r/$F4-$F7 after control_register write (bits 4/5 would clear them). + # Restore ports_r/$F4-$F7 after control_register write (bits 4/5 would + # clear them). for i in range(4): self.ports_r[i] = ram[0x00F4 + i] self.ports_w[i] = ram[0x00F4 + i] def _read(self, addr: int) -> int: self.cycles += 1 - if addr <= 0x00EF: - result = self.page_0[addr] - elif addr <= 0x00FC and self._io_flat: + if addr <= 0x00EF or addr <= 0x00FC and self._io_flat: result = self.page_0[addr] elif addr == 0x00F0: result = self.test_register @@ -373,9 +394,7 @@ def _write(self, addr: int, value: int) -> None: self.cycles += 1 if self._mem_log is not None: self._mem_log.append((addr, value, "write")) - if addr <= 0x00EF: - self.page_0[addr] = value - elif addr <= 0x00FC and self._io_flat: + if addr <= 0x00EF or addr <= 0x00FC and self._io_flat: self.page_0[addr] = value elif addr == 0x00F0: self.test_register = value @@ -387,9 +406,10 @@ def _write(self, addr: int, value: int) -> None: self.dsp_register_data = value self.dsp.write_register(self.dsp_register_address, value) elif addr <= 0x00F7: - # $F4-$F7 from SPC side: writing updates the SPC→CPU latch (ports_w). - # The CPU→SPC latch (ports_r) is separate hardware; do NOT mirror — the - # SPC reads back whatever the main CPU last wrote, not its own writes. + # $F4-$F7 from SPC side: writing updates the SPC→CPU latch + # (ports_w). The CPU→SPC latch (ports_r) is separate hardware; do + # NOT mirror — the SPC reads back whatever the main CPU last wrote, + # not its own writes. self.ports_w[addr - 0x00F4] = value elif addr == 0x00F8: self.auxio4 = value @@ -409,7 +429,9 @@ def _write(self, addr: int, value: int) -> None: if isinstance(self.ipl_rom, bytearray): self.ipl_rom[addr - 0xFFC0] = value else: - raise NotImplementedError(f"Write to unmapped APU address 0x{addr:04X}") + raise NotImplementedError( + f"Write to unmapped APU address 0x{addr:04X}" + ) def write(self, addr: int, data: int) -> None: self._write(addr, data) @@ -448,11 +470,9 @@ def _format_trace(self, pc: int, opcode: int) -> str: + ("Z" if self.ZF else "z") + ("C" if self.CF else "c") ) - return "{:<24} A:{:02X} X:{:02X} Y:{:02X} S:{:02X} PSW:{:02X} {}".format( - disasm, - self.A, self.X, self.Y, self.S, - self.PSW, - flags, + return ( + f"{disasm:<24} A:{self.A:02X} X:{self.X:02X} " + f"Y:{self.Y:02X} S:{self.S:02X} PSW:{self.PSW:02X} {flags}" ) def fetch_and_execute(self): @@ -464,14 +484,16 @@ def fetch_and_execute(self): self.trace_log.append(line) self.trace_log = self.trace_log[-10:] if self.print_debug: - print("\033[93mAPU 0x{:04X} 0x{:02X} {} [{:04X}] [{:02X}] {}\033[0m".format( - self.PC - 1, opcode, self.debug_symbols[opcode], - self.address, self.data, str(self), - )) + print( + f"\033[93mAPU 0x{self.PC - 1:04X} 0x{opcode:02X} " + f"{self.debug_symbols[opcode]} [{self.address:04X}] " + f"[{self.data:02X}] {str(self)}\033[0m" + ) self.instructions[opcode].call() # Approximate master-clock-to-APU-clock ratio (integer division) - _APU_MC_PER_CLOCK: int = 21 # 21477272 / 1024000 ≈ 20.979 (integer-approx; exact ratio used in sync_to) + # 21477272 / 1024000 ≈ 20.979 (integer-approx; exact ratio used in sync_to) + _APU_MC_PER_CLOCK: int = 21 _APU_MC_NUM: int = 21477272 _APU_MC_DEN: int = 1024000 @@ -479,7 +501,8 @@ def sync_to(self, master_clock: int) -> None: """Catch the APU up to the given master clock value. Called lazily whenever the CPU reads or writes an APU I/O port, ensuring - the APU has run up to that point in time before the port value is sampled. + the APU has run up to that point in time before the port value is + sampled. Each call drains the full budget up to master_clock so the CPU observes the port value as of its access time (the last write at or before now). @@ -490,7 +513,8 @@ def sync_to(self, master_clock: int) -> None: self._last_synced_mc = master_clock # Fixed-point accumulator: add elapsed MC scaled by MC_DEN so we never - # lose fractional cycles across calls. One APU cycle costs MC_NUM units. + # lose fractional cycles across calls. One APU cycle costs MC_NUM + # units. self._apu_mc_frac += elapsed * self._APU_MC_DEN while self._apu_mc_frac >= self._APU_MC_NUM: self.fetch_and_execute() @@ -523,7 +547,8 @@ def write_external(self, addr: int, value: int) -> None: @property def PSW(self) -> int: - return (0 + return ( + 0 | self.NF << 7 | self.VF << 6 | self.PF << 5 diff --git a/pysnes/apu/dsp.py b/pysnes/apu/dsp.py index 9a416df..e236dee 100644 --- a/pysnes/apu/dsp.py +++ b/pysnes/apu/dsp.py @@ -1,4 +1,5 @@ import array + import numpy as np ENV_ATTACK, ENV_DECAY, ENV_SUSTAIN, ENV_RELEASE = 0, 1, 2, 3 @@ -6,6 +7,7 @@ # 512-entry Gaussian table from bsnes/Mesen. Two halves of 256: # gauss[255-off], gauss[511-off], gauss[256+off], gauss[off] # are the 4-tap weights for interpolation offset 0..255. +# fmt: off _GAUSS = ( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, @@ -40,9 +42,12 @@ 1282,1283,1284,1286,1287,1288,1290,1291,1292,1293,1294,1295,1296,1297,1297,1298, 1299,1300,1300,1301,1302,1302,1303,1303,1303,1304,1304,1304,1304,1304,1305,1305, ) +# fmt: on -# DSP envelope rate table (32 entries): number of DSP ticks between envelope steps. -# Index 0 = never update. Attack maps AR*2+1, decay maps DR*2+16, sustain maps SR directly. +# DSP envelope rate table (32 entries): number of DSP ticks between envelope +# steps. Index 0 = never update. Attack maps AR*2+1, decay maps DR*2+16, sustain +# maps SR directly. +# fmt: off _RATE_TABLE = [ 0, 2048, 1536, 1280, 1024, 768, 640, 512, @@ -51,6 +56,7 @@ 16, 12, 10, 8, 6, 5, 4, 3, 2, 1, ] +# fmt: on def _s8(v): @@ -60,27 +66,30 @@ def _s8(v): class VoiceState: __slots__ = ( - 'brr_addr', - 'brr_offset', - 'brr_header', - 'brr_buf', - 'loop_addr', - 'pitch_frac', - 'env_state', - 'env_level', - 'key_off', - 'active', - 'prev1', - 'prev2', - 'env_counter', - 'hist0', 'hist1', 'hist2', 'hist3', + "brr_addr", + "brr_offset", + "brr_header", + "brr_buf", + "loop_addr", + "pitch_frac", + "env_state", + "env_level", + "key_off", + "active", + "prev1", + "prev2", + "env_counter", + "hist0", + "hist1", + "hist2", + "hist3", ) def __init__(self): self.brr_addr = 0 self.brr_offset = 0 self.brr_header = 0 - self.brr_buf = array.array('h', [0] * 16) + self.brr_buf = array.array("h", [0] * 16) self.loop_addr = 0 self.pitch_frac = 0 self.env_state = ENV_ATTACK @@ -99,10 +108,10 @@ def __init__(self): class Dsp: def __init__(self, ram_reader): # ram_reader: callable(addr: int) -> int, reads from APU RAM - self.regs = bytearray(128) # DSP registers $00-$7F + self.regs = bytearray(128) # DSP registers $00-$7F self.voices = [VoiceState() for _ in range(8)] self._ram = ram_reader - self._pending_kon = 0 # latched KON value + self._pending_kon = 0 # latched KON value self._echo_ready = False self._echo_buf = None self._echo_pos = 0 @@ -111,13 +120,15 @@ def __init__(self, ram_reader): def write_register(self, addr: int, value: int) -> None: addr &= 0x7F self.regs[addr] = value & 0xFF - if addr == 0x4C: # KON: accumulate bits; DSP reads them asynchronously + if addr == 0x4C: # KON: accumulate bits; DSP reads them asynchronously self._pending_kon |= value & 0xFF - elif addr == 0x5C: # KOFF: immediately request key-off on matching voices + # KOFF: immediately request key-off on matching voices + elif addr == 0x5C: for v in range(8): if value & (1 << v): self.voices[v].key_off = True - elif addr == 0x6C: # FLG: bit5 = soft reset → silence all voices immediately + # FLG: bit5 = soft reset → silence all voices immediately + elif addr == 0x6C: if value & 0x20: for v in self.voices: v.active = False @@ -130,9 +141,9 @@ def read_register(self, addr: int) -> int: addr &= 0x7F voice = (addr >> 4) & 0x7 reg = addr & 0x0F - if reg == 0x08: # VxENVX: current envelope (0-127) + if reg == 0x08: # VxENVX: current envelope (0-127) return (self.voices[voice].env_level >> 4) & 0x7F - elif addr == 0x7C: # ENDX: read and clear + if addr == 0x7C: # ENDX: read and clear val = self.regs[0x7C] self.regs[0x7C] = 0 return val @@ -145,7 +156,7 @@ def _init_voice_for_test(self, voice_idx: int, brr_addr: int) -> None: v.brr_addr = brr_addr v.brr_offset = 0 v.brr_header = 0 - v.brr_buf = array.array('h', [0] * 16) + v.brr_buf = array.array("h", [0] * 16) v.loop_addr = 0 v.pitch_frac = 0 v.env_state = ENV_ATTACK @@ -174,7 +185,7 @@ def _apply_key_on(self) -> None: loop_hi = self._ram(entry_addr + 3) v.brr_addr = start_lo | (start_hi << 8) v.loop_addr = loop_lo | (loop_hi << 8) - v.brr_buf = array.array('h', [0] * 16) + v.brr_buf = array.array("h", [0] * 16) v.brr_offset = 0 v.pitch_frac = 0 v.prev1 = 0 @@ -220,16 +231,27 @@ def _decode_brr_block(self, voice_idx: int) -> None: if filt == 1: s += prev1 + (-prev1 >> 4) elif filt == 2: - s += (prev1 << 1) + (-((prev1 << 1) + prev1) >> 5) - prev2 + (prev2 >> 4) + s += ( + (prev1 << 1) + + (-((prev1 << 1) + prev1) >> 5) + - prev2 + + (prev2 >> 4) + ) elif filt == 3: - s += (prev1 << 1) + (-(prev1 + (prev1 << 2) + (prev1 << 3)) >> 6) - prev2 + (((prev2 << 1) + prev2) >> 4) + s += ( + (prev1 << 1) + + (-(prev1 + (prev1 << 2) + (prev1 << 3)) >> 6) + - prev2 + + (((prev2 << 1) + prev2) >> 4) + ) # Clamp to 16-bit signed s = max(-32768, min(32767, s)) - # Store ×2 (hardware buffer convention; prev stays at 1× = 15-bit) + # Store ×2 (hardware buffer convention; prev stays at 1× = + # 15-bit) v.brr_buf[i] = max(-32768, min(32767, s * 2)) prev2 = prev1 - prev1 = s # 15-bit value used for next filter step + prev1 = s # 15-bit value used for next filter step i += 1 v.prev1 = prev1 @@ -268,7 +290,7 @@ def _step_envelope(self, voice_idx: int) -> None: return v.env_counter = 0 mode = (gain >> 5) & 0x3 - if mode == 0: # linear decrease + if mode == 0: # linear decrease v.env_level = max(0, v.env_level - 32) elif mode == 1: # exponential decrease v.env_level -= ((v.env_level - 1) >> 8) + 1 @@ -276,7 +298,7 @@ def _step_envelope(self, voice_idx: int) -> None: v.env_level = 0 elif mode == 2: # linear increase v.env_level = min(0x7FF, v.env_level + 32) - else: # bent-line increase: +32 until 0x600, then +8 + else: # bent-line increase: +32 until 0x600, then +8 step = 8 if v.env_level >= 0x600 else 32 v.env_level = min(0x7FF, v.env_level + step) return @@ -335,18 +357,20 @@ def _step_envelope(self, voice_idx: int) -> None: v.env_level = 0 def _init_echo(self) -> None: - """Initialise echo ring buffer from SPC RAM (called lazily on first generate).""" + """Initialise echo ring buffer from SPC RAM (called lazily on first + generate).""" esa = self.regs[0x6D] edl = self.regs[0x7D] & 0x0F self._echo_buf_addr = esa << 8 - buf_len = max(edl, 1) * 0x800 // 4 # stereo samples (4 bytes each in RAM) + # stereo samples (4 bytes each in RAM) + buf_len = max(edl, 1) * 0x800 // 4 self._echo_buf = np.zeros((buf_len, 2), dtype=np.int32) for i in range(buf_len): base = (self._echo_buf_addr + i * 4) & 0xFFFF - l = self._ram(base) | (self._ram(base + 1) << 8) - r = self._ram(base + 2) | (self._ram(base + 3) << 8) - self._echo_buf[i, 0] = l if l < 32768 else l - 65536 - self._echo_buf[i, 1] = r if r < 32768 else r - 65536 + left = self._ram(base) | (self._ram(base + 1) << 8) + right = self._ram(base + 2) | (self._ram(base + 3) << 8) + self._echo_buf[i, 0] = left if left < 32768 else left - 65536 + self._echo_buf[i, 1] = right if right < 32768 else right - 65536 self._echo_pos = 0 self._echo_ready = True @@ -360,8 +384,8 @@ def generate_samples(self, n_samples: int): mvolr = _s8(self.regs[0x1C]) evoll = _s8(self.regs[0x2C]) evolr = _s8(self.regs[0x3C]) - efb = _s8(self.regs[0x0D]) - eon = self.regs[0x4D] + efb = _s8(self.regs[0x0D]) + eon = self.regs[0x4D] # FIR coefficients (8 taps, signed) fir = [_s8(self.regs[0x0F + i * 0x10]) for i in range(8)] @@ -371,12 +395,12 @@ def generate_samples(self, n_samples: int): echo_buf = self._echo_buf echo_len = len(echo_buf) - # Pass 1: voice-outer loop — process all n_samples for each voice, then mix. - # Register reads and voice state are cached as locals to minimise attribute - # and array lookups inside the hot per-sample loop. - left_arr = np.zeros(n_samples, dtype=np.int32) + # Pass 1: voice-outer loop — process all n_samples for each voice, then + # mix. Register reads and voice state are cached as locals to minimise + # attribute and array lookups inside the hot per-sample loop. + left_arr = np.zeros(n_samples, dtype=np.int32) right_arr = np.zeros(n_samples, dtype=np.int32) - echo_in = np.zeros((n_samples, 2), dtype=np.int32) + echo_in = np.zeros((n_samples, 2), dtype=np.int32) regs = self.regs # one local reference saves repeated self.regs lookups @@ -386,43 +410,45 @@ def generate_samples(self, n_samples: int): continue # --- cache per-voice registers once --- - base = vi << 4 - pitch = (regs[base | 0x02] | (regs[base | 0x03] << 8)) & 0x3FFF - # TODO: PMON (0x2D) — if bit vi is set, modulate pitch by previous voice's output sample - voll = _s8(regs[base | 0x00]) - volr = _s8(regs[base | 0x01]) - adsr1 = regs[base | 0x05] - adsr2 = regs[base | 0x06] + base = vi << 4 + pitch = (regs[base | 0x02] | (regs[base | 0x03] << 8)) & 0x3FFF + # TODO: PMON (0x2D) — if bit vi is set, modulate pitch by previous + # voice's output sample + voll = _s8(regs[base | 0x00]) + volr = _s8(regs[base | 0x01]) + adsr1 = regs[base | 0x05] + adsr2 = regs[base | 0x06] gain_reg = regs[base | 0x07] - in_echo = bool(eon & (1 << vi)) + in_echo = bool(eon & (1 << vi)) endx_bit = 1 << vi # --- cache voice state as locals --- - pitch_frac = v.pitch_frac - brr_offset = v.brr_offset - brr_buf = v.brr_buf - brr_header = v.brr_header - loop_addr = v.loop_addr - env_level = v.env_level - env_state = v.env_state + pitch_frac = v.pitch_frac + brr_offset = v.brr_offset + brr_buf = v.brr_buf + brr_header = v.brr_header + loop_addr = v.loop_addr + env_level = v.env_level + env_state = v.env_state env_counter = v.env_counter - key_off = v.key_off - prev1 = v.prev1 - prev2 = v.prev2 - hist0 = v.hist0 - hist1 = v.hist1 - hist2 = v.hist2 - hist3 = v.hist3 - active = True - - # pre-decode envelope mode so the inner loop avoids repeated branches - adsr_on = bool(adsr1 & 0x80) + key_off = v.key_off + prev1 = v.prev1 + prev2 = v.prev2 + hist0 = v.hist0 + hist1 = v.hist1 + hist2 = v.hist2 + hist3 = v.hist3 + active = True + + # pre-decode envelope mode so the inner loop avoids repeated + # branches + adsr_on = bool(adsr1 & 0x80) gain_dir = (not adsr_on) and (not (gain_reg & 0x80)) - if gain_dir: - fixed_env = (gain_reg & 0x7F) << 4 + fixed_env = (gain_reg & 0x7F) << 4 if gain_dir else 0 + if not adsr_on and not gain_dir: + gain_mode = (gain_reg >> 5) & 0x3 else: - fixed_env = 0 # unused - gain_mode = (gain_reg >> 5) & 0x3 if (not adsr_on and not gain_dir) else 0 + gain_mode = 0 gain_rate = gain_reg & 0x1F if (not adsr_on and not gain_dir) else 0 sl = (adsr2 >> 5) & 7 ar = adsr1 & 0x0F @@ -434,7 +460,7 @@ def generate_samples(self, n_samples: int): for s in range(n_samples): # --- envelope step (inlined) --- if key_off: - env_state = ENV_RELEASE + env_state = ENV_RELEASE env_counter = 0 if gain_dir: @@ -455,7 +481,7 @@ def generate_samples(self, n_samples: int): if env_level >= 0x7FF: env_level = 0x7FF if env_level >= 0x7E0: - env_state = ENV_DECAY + env_state = ENV_DECAY env_counter = 0 elif env_state == ENV_DECAY: rate_idx = dr * 2 + 16 @@ -467,7 +493,7 @@ def generate_samples(self, n_samples: int): if env_level < 0: env_level = 0 if (env_level >> 8) == sl: - env_state = ENV_SUSTAIN + env_state = ENV_SUSTAIN env_counter = 0 else: # ENV_SUSTAIN period = _RATE_TABLE[sr] @@ -510,7 +536,7 @@ def generate_samples(self, n_samples: int): brr_offset += 1 if brr_offset >= 16: brr_offset = 0 - end_flag = brr_header & 0x01 + end_flag = brr_header & 0x01 loop_flag = brr_header & 0x02 if end_flag: regs[0x7C] |= endx_bit @@ -531,10 +557,10 @@ def generate_samples(self, n_samples: int): v.prev1 = prev1 v.prev2 = prev2 self._decode_brr_block(vi) - brr_buf = v.brr_buf + brr_buf = v.brr_buf brr_header = v.brr_header - prev1 = v.prev1 - prev2 = v.prev2 + prev1 = v.prev1 + prev2 = v.prev2 if active: hist3 = hist2 hist2 = hist1 @@ -544,15 +570,15 @@ def generate_samples(self, n_samples: int): if not active: break - # TODO: NON (0x3D) — if bit vi is set, replace BRR sample with LFSR noise output - # --- Gaussian interpolation --- + # TODO: NON (0x3D) — if bit vi is set, replace BRR sample with + # LFSR noise output --- Gaussian interpolation --- goff = pitch_frac >> 4 sample = ( - (_GAUSS[255 - goff] * hist3 + - _GAUSS[511 - goff] * hist2 + - _GAUSS[256 + goff] * hist1 + - _GAUSS[ goff] * hist0) >> 11 - ) + _GAUSS[255 - goff] * hist3 + + _GAUSS[511 - goff] * hist2 + + _GAUSS[256 + goff] * hist1 + + _GAUSS[goff] * hist0 + ) >> 11 if sample > 32767: sample = 32767 elif sample < -32768: @@ -560,24 +586,24 @@ def generate_samples(self, n_samples: int): voice_out[s] = ((sample & ~1) * env_level) >> 11 # --- write back voice state --- - v.pitch_frac = pitch_frac - v.brr_offset = brr_offset - v.brr_header = brr_header - v.env_level = env_level - v.env_state = env_state + v.pitch_frac = pitch_frac + v.brr_offset = brr_offset + v.brr_header = brr_header + v.env_level = env_level + v.env_state = env_state v.env_counter = env_counter - v.key_off = key_off - v.hist0 = hist0 - v.hist1 = hist1 - v.hist2 = hist2 - v.hist3 = hist3 - v.active = active + v.key_off = key_off + v.hist0 = hist0 + v.hist1 = hist1 + v.hist2 = hist2 + v.hist3 = hist3 + v.active = active # --- apply voice volumes with NumPy --- voice_np = np.array(voice_out, dtype=np.int32) voice_l = (voice_np * voll) >> 7 voice_r = (voice_np * volr) >> 7 - left_arr += voice_l + left_arr += voice_l right_arr += voice_r if in_echo: echo_in[:, 0] += voice_l @@ -586,10 +612,10 @@ def generate_samples(self, n_samples: int): # Pass 2: echo FIR — batched with NumPy across all n_samples. # For sample s, tap t reads echo_buf[(ep + s - t) % echo_len]. # Applying all taps in NumPy avoids a 8×n_samples Python loop. - ep = self._echo_pos - fir_arr = np.array(fir, dtype=np.int32) - fir_out = np.zeros((n_samples, 2), dtype=np.int32) - s_idx = np.arange(n_samples, dtype=np.int64) + ep = self._echo_pos + fir_arr = np.array(fir, dtype=np.int32) + fir_out = np.zeros((n_samples, 2), dtype=np.int32) + s_idx = np.arange(n_samples, dtype=np.int64) for tap in range(8): if fir_arr[tap] == 0: continue @@ -600,18 +626,27 @@ def generate_samples(self, n_samples: int): # Pass 3: write echo buffer and advance position. if not echo_disabled: write_pos = (ep + s_idx) % echo_len - new_echo = np.clip(echo_in + (fir_out * efb >> 7), -32768, 32767).astype(np.int32) & ~1 + new_echo = ( + np.clip(echo_in + (fir_out * efb >> 7), -32768, 32767).astype( + np.int32 + ) + & ~1 + ) echo_buf[write_pos] = new_echo self._echo_pos = int((ep + n_samples) % echo_len) - # Pass 4: master volume mix + echo volume. - # TODO: stereo hard-clipping — SNES clips each voice's L+R independently before summing - # into left_arr/right_arr; current code clips only the final master mix. + # Pass 4: master volume mix + echo volume. TODO: stereo hard-clipping — + # SNES clips each voice's L+R independently before summing into + # left_arr/right_arr; current code clips only the final master mix. if not muted: - out_l = np.clip((left_arr * mvoll) >> 7, -32768, 32767) + out_l = np.clip((left_arr * mvoll) >> 7, -32768, 32767) out_r = np.clip((right_arr * mvolr) >> 7, -32768, 32767) - out_l = np.clip(out_l + ((fir_out[:, 0] * evoll) >> 7), -32768, 32767) - out_r = np.clip(out_r + ((fir_out[:, 1] * evolr) >> 7), -32768, 32767) + out_l = np.clip( + out_l + ((fir_out[:, 0] * evoll) >> 7), -32768, 32767 + ) + out_r = np.clip( + out_r + ((fir_out[:, 1] * evolr) >> 7), -32768, 32767 + ) output[:, 0] = out_l output[:, 1] = out_r diff --git a/pysnes/apu/spc700/addressing_modes.py b/pysnes/apu/spc700/addressing_modes.py index 1e94b57..d23ce1a 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -2,11 +2,12 @@ class SPC700AddressingModes: """Addressing modes implementation (alphabetical order)""" def AbsoluteBitModify(self, mode): - # OR1/AND1/EOR1/NOT1/MOV1 C,m.b — 5 cycles (read-only), 6 cycles (write) + # OR1/AND1/EOR1/NOT1/MOV1 C,m.b — 5 cycles (read-only), 6 cycles + # (write) self.address = self.fetch() self.address |= self.fetch() << 8 bit = self.address >> 13 - self.address &= 0x1fff + self.address &= 0x1FFF self.data = self.read(self.address) if mode == 0: # OR1 C,m.b — 5 cycles @@ -57,7 +58,8 @@ def AbsoluteModify(self, func): self.write(self.address, result) def AbsoluteWrite(self, reg_data): - # MOV !a,A/X/Y — 5 cycles: opcode + fetch_lo + fetch_hi + dummy_read + write + # MOV !a,A/X/Y — 5 cycles: opcode + fetch_lo + fetch_hi + dummy_read + + # write self.address = self.fetch() self.address |= self.fetch() << 8 self.data = getattr(self, reg_data) @@ -74,7 +76,8 @@ def AbsoluteIndexedRead(self, func, reg): self.A = func(self, self.A, self.data) def AbsoluteIndexedWrite(self, index): - # MOV !a+X,A / MOV !a+Y,A — 6 cycles: opcode + lo + hi + idle + dummy_read + write + # MOV !a+X,A / MOV !a+Y,A — 6 cycles: opcode + lo + hi + idle + + # dummy_read + write index = getattr(self, index) assert index >= 0 self.address = self.fetch() @@ -104,7 +107,8 @@ def BranchBit(self, bit: int, match: bool): if bool(self.data & 1 << bit) == match: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + if displacement >= 0x80: + displacement -= 0x100 self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirect(self): @@ -113,10 +117,11 @@ def BranchNotDirect(self): self.data = self.load(self.address) displacement = self.fetch() self.idle() - if self.A != self.data: + if self.data != self.A: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + if displacement >= 0x80: + displacement -= 0x100 self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirectDecrement(self): @@ -129,7 +134,8 @@ def BranchNotDirectDecrement(self): if self.data != 0: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + if displacement >= 0x80: + displacement -= 0x100 self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirectIndexed(self, reg_index): @@ -140,15 +146,16 @@ def BranchNotDirectIndexed(self, reg_index): self.data = self.load(self.address + index) self.idle() displacement = self.fetch() - if self.A != self.data: + if self.data != self.A: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + if displacement >= 0x80: + displacement -= 0x100 self.PC = (displacement + self.PC) & 0xFFFF def BranchNotYDecrement(self): - # DBNZ Y,rel — 4 cycles not taken, 6 cycles taken - # Hardware reads pc+1 twice (ghost + real); model as 2 idles before fetch + # DBNZ Y,rel — 4 cycles not taken, 6 cycles taken Hardware reads pc+1 + # twice (ghost + real); model as 2 idles before fetch self.idle() self.idle() displacement = self.fetch() @@ -156,7 +163,8 @@ def BranchNotYDecrement(self): if self.Y != 0: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + if displacement >= 0x80: + displacement -= 0x100 self.PC = (displacement + self.PC) & 0xFFFF def Break(self): @@ -165,8 +173,8 @@ def Break(self): self.push(self.PC >> 8) self.push(self.PC >> 0) self.push(self.PSW) - self.address = self.read(0xffde + 0) - self.address |= self.read(0xffde + 1) << 8 + self.address = self.read(0xFFDE + 0) + self.address |= self.read(0xFFDE + 1) << 8 self.PC = self.address self.IF = False self.BF = True @@ -189,17 +197,18 @@ def CallPage(self): self.idle() self.push(self.PC >> 8) self.push(self.PC >> 0) - self.address = 0xff00 | self.address + self.address = 0xFF00 | self.address self.idle() self.PC = self.address def CallTable(self, vector): - # TCALL n — 8 cycles: opcode + dummy read(PC) + idle + pushPCH + pushPCL + idle + readLo + readHi + # TCALL n — 8 cycles: opcode + dummy read(PC) + idle + pushPCH + pushPCL + # + idle + readLo + readHi self.read(self.PC) # dummy read of next byte, PC unchanged self.idle() self.push(self.PC >> 8) self.push(self.PC >> 0) - self.address = 0xffde - (vector << 1) + self.address = 0xFFDE - (vector << 1) self.idle() pc = self.read(self.address + 0) pc |= self.read(self.address + 1) << 8 @@ -261,7 +270,8 @@ def DirectWrite(self, reg): self.store(self.address, self.data) def DirectDirectCompare(self, func): - # CMP dp1,dp2 — 6 cycles: opcode + fetch_src + load_src + fetch_tgt + load_tgt + idle + # CMP dp1,dp2 — 6 cycles: opcode + fetch_src + load_src + fetch_tgt + + # load_tgt + idle source = self.fetch() rhs = self.load(source) target = self.fetch() @@ -317,12 +327,15 @@ def DirectCompareWord(self, func): def DirectReadWord(self, func): # ADDW/SUBW YA,dp — 5 cycles self.address = self.fetch() - self.data = self.load(self.address + 0) | self.load(self.address + 1) << 8 + self.data = ( + self.load(self.address + 0) | self.load(self.address + 1) << 8 + ) self.idle() self.YA = func(self, self.YA, self.data) def DirectModifyWord(self, adjust): - # INCW/DECW dp — 6 cycles: opcode + fetch + read_lo + write_lo + read_hi + write_hi + # INCW/DECW dp — 6 cycles: opcode + fetch + read_lo + write_lo + read_hi + # + write_hi self.address = self.fetch() lo = self.load(self.address) lo_new = (lo + adjust) & 0xFF @@ -363,7 +376,8 @@ def DirectIndexedModify(self, func, reg): self.store(self.address, self.data) def DirectIndexedWrite(self, reg_data, reg_index): - # MOV dp+X,A / MOV dp+Y,A — 5 cycles: opcode + fetch_dp + idle + dummy_read + write + # MOV dp+X,A / MOV dp+Y,A — 5 cycles: opcode + fetch_dp + idle + + # dummy_read + write self.data = getattr(self, reg_data) index = getattr(self, reg_index) self.address = (self.fetch() + index) & 0xFF @@ -381,15 +395,11 @@ def Divide(self): self.VF = self.Y >= self.X if self.Y < (self.X << 1): # if quotient is <= 511 (will fit into 9-bit result) - #self.A = ya / self.X - #self.Y = ya % self.X self.A = (ya // self.X) & 0xFF self.Y = (ya % self.X) & 0xFF else: # otherwise, the quotient won't fit into VF + A # this emulates the odd behavior of the S-SMP in this case - #self.A = 255 - (ya - (self.X << 9)) / (256 - self.X) - #self.Y = self.X + (ya - (self.X << 9)) % (256 - self.X) self.A = (255 - (ya - (self.X << 9)) // (256 - self.X)) & 0xFF self.Y = (self.X + (ya - (self.X << 9)) % (256 - self.X)) & 0xFF # result is set based on a (quotient) only @@ -437,7 +447,8 @@ def IndexedIndirectRead(self, func, reg): self.A = func(self, self.A, self.data) def IndexedIndirectWrite(self, reg_data, reg_index): - # MOV (dp+X),A — 7 cycles: opcode + fetch + idle + ptr_lo + ptr_hi + dummy_read + write + # MOV (dp+X),A — 7 cycles: opcode + fetch + idle + ptr_lo + ptr_hi + + # dummy_read + write self.data = getattr(self, reg_data) index = getattr(self, reg_index) indirect = self.fetch() @@ -459,7 +470,8 @@ def IndirectIndexedRead(self, func, reg_index): self.A = func(self, self.A, self.data) def IndirectIndexedWrite(self, data, index): - # MOV (dp)+Y,A — 7 cycles: opcode + fetch + ptr_lo + ptr_hi + idle + dummy_read + write + # MOV (dp)+Y,A — 7 cycles: opcode + fetch + ptr_lo + ptr_hi + idle + + # dummy_read + write data = getattr(self, data) index = getattr(self, index) assert index >= 0 @@ -596,20 +608,27 @@ def ReturnSubroutine(self): self.idle() def Stop(self): - # STOP — loops with ghost reads (null value) and waits; 3 iterations = 6 extra cycles + # STOP — loops with ghost reads (null value) and waits; 3 iterations = 6 + # extra cycles for _ in range(3): - self.idle() # ghost read of PC (null value, not tracked as memory access) + # ghost read of PC (null value, not tracked as memory access) + self.idle() self.idle() # internal wait def TestSetBitsAbsolute(self, bit_set): - # TSET1/TCLR1 !a — 6 cycles: opcode + lo + hi + read + read(dummy) + write + # TSET1/TCLR1 !a — 6 cycles: opcode + lo + hi + read + read(dummy) + + # write self.address = self.fetch() self.address |= self.fetch() << 8 self.data = self.read(self.address) self.ZF = (self.A - self.data) & 0xFF == 0 self.NF = bool((self.A - self.data) & 0x80) - self.read(self.address) # second read (dummy before write, hardware behaviour) - self.write(self.address, self.data | self.A if bit_set else self.data & ~self.A & 0xFF) + # second read (dummy before write, hardware behaviour) + self.read(self.address) + self.write( + self.address, + self.data | self.A if bit_set else self.data & ~self.A & 0xFF, + ) def Transfer(self, src, dst): # MOV reg,reg — 2 cycles @@ -621,7 +640,9 @@ def Transfer(self, src, dst): self.NF = bool(self.data & 0x80) def Wait(self): - # SLEEP — loops with ghost reads (null value) and waits; 3 iterations = 6 extra cycles + # SLEEP — loops with ghost reads (null value) and waits; 3 iterations = + # 6 extra cycles for _ in range(3): - self.idle() # ghost read of PC (null value, not tracked as memory access) + # ghost read of PC (null value, not tracked as memory access) + self.idle() self.idle() # internal wait diff --git a/pysnes/apu/spc700/disassembler.py b/pysnes/apu/spc700/disassembler.py index 51b7835..99a0310 100644 --- a/pysnes/apu/spc700/disassembler.py +++ b/pysnes/apu/spc700/disassembler.py @@ -3,6 +3,7 @@ from .instructions_spc700 import INSTRUCTIONS from .opcodes_spc700 import SPC700Opcodes +# fmt: off _OPNAMES = { SPC700Opcodes.OR: "OR", SPC700Opcodes.AND: "AND", @@ -22,12 +23,16 @@ SPC700Opcodes.CPW: "CMPW", SPC700Opcodes.LDW: "MOVW", } +# fmt: on +# fmt: off _BRANCH_MNEMS = { 0x10: "BPL", 0x30: "BMI", 0x50: "BVC", 0x70: "BVS", - 0x90: "BCC", 0xb0: "BCS", 0xd0: "BNE", 0xf0: "BEQ", 0x2f: "BRA", + 0x90: "BCC", 0xB0: "BCS", 0xD0: "BNE", 0xF0: "BEQ", 0x2F: "BRA", } +# fmt: on +# fmt: off _ABS_BIT_MODS = [ ("OR1", "C,${a:04X}.{b}"), ("OR1", "C,/${a:04X}.{b}"), @@ -38,6 +43,7 @@ ("MOV1", "${a:04X}.{b},C"), ("NOT1", "${a:04X}.{b}"), ] +# fmt: on def _rel(byte, pc_after): @@ -92,14 +98,17 @@ def _build_table(): elif name == "CallTable": n = args[0] - table[opcode] = (f"TCALL", 0, lambda pc, _n=n: f"{_n}") + table[opcode] = ("TCALL", 0, lambda pc, _n=n: f"{_n}") elif name == "FlagSet": flag, val = args mnem = { - ("CF", False): "CLRC", ("CF", True): "SETC", - ("PF", False): "CLRP", ("PF", True): "SETP", - ("IF", True): "EI", ("IF", False): "DI", + ("CF", False): "CLRC", + ("CF", True): "SETC", + ("PF", False): "CLRP", + ("PF", True): "SETP", + ("IF", True): "EI", + ("IF", False): "DI", }[(flag, val)] table[opcode] = (mnem, 0, lambda pc: "") @@ -151,21 +160,41 @@ def _build_table(): table[opcode] = (mnem, 1, lambda pc, b: f"${_rel(b, pc + 2):04X}") elif name == "BranchNotYDecrement": - table[opcode] = ("DBNZ", 1, lambda pc, b: f"Y,${_rel(b, pc + 2):04X}") + table[opcode] = ( + "DBNZ", + 1, + lambda pc, b: f"Y,${_rel(b, pc + 2):04X}", + ) elif name == "BranchNotDirect": - table[opcode] = ("CBNE", 2, lambda pc, d, r: f"${d:02X},${_rel(r, pc + 3):04X}") + table[opcode] = ( + "CBNE", + 2, + lambda pc, d, r: f"${d:02X},${_rel(r, pc + 3):04X}", + ) elif name == "BranchNotDirectDecrement": - table[opcode] = ("DBNZ", 2, lambda pc, d, r: f"${d:02X},${_rel(r, pc + 3):04X}") + table[opcode] = ( + "DBNZ", + 2, + lambda pc, d, r: f"${d:02X},${_rel(r, pc + 3):04X}", + ) elif name == "BranchNotDirectIndexed": - table[opcode] = ("CBNE", 2, lambda pc, d, r: f"${d:02X}+X,${_rel(r, pc + 3):04X}") + table[opcode] = ( + "CBNE", + 2, + lambda pc, d, r: f"${d:02X}+X,${_rel(r, pc + 3):04X}", + ) elif name == "BranchBit": bit, match = args mnem = "BBS" if match else "BBC" - table[opcode] = (mnem, 2, lambda pc, d, r, b=bit: f"${d:02X}.{b},${_rel(r, pc + 3):04X}") + table[opcode] = ( + mnem, + 2, + lambda pc, d, r, b=bit: f"${d:02X}.{b},${_rel(r, pc + 3):04X}", + ) elif name == "AbsoluteBitSet": bit, val = args @@ -175,8 +204,13 @@ def _build_table(): elif name == "AbsoluteBitModify": mode = args[0] mnem, fmt = _ABS_BIT_MODS[mode] - table[opcode] = (mnem, 2, lambda pc, lo, hi, _fmt=fmt, _m=mode: _fmt.format( - a=(hi << 8 | lo) & 0x1fff, b=(hi << 8 | lo) >> 13)) + table[opcode] = ( + mnem, + 2, + lambda pc, lo, hi, _fmt=fmt, _m=mode: _fmt.format( + a=(hi << 8 | lo) & 0x1FFF, b=(hi << 8 | lo) >> 13 + ), + ) elif name == "ImmediateRead": func, reg = args @@ -204,7 +238,11 @@ def _build_table(): elif name == "DirectIndexedRead": func, reg_t, reg_i = args mnem = _OPNAMES[func] - table[opcode] = (mnem, 1, lambda pc, d, rt=reg_t, ri=reg_i: f"{rt},${d:02X}+{ri}") + table[opcode] = ( + mnem, + 1, + lambda pc, d, rt=reg_t, ri=reg_i: f"{rt},${d:02X}+{ri}", + ) elif name == "DirectIndexedModify": func, reg = args @@ -213,7 +251,11 @@ def _build_table(): elif name == "DirectIndexedWrite": reg_d, reg_i = args - table[opcode] = ("MOV", 1, lambda pc, d, rd=reg_d, ri=reg_i: f"${d:02X}+{ri},{rd}") + table[opcode] = ( + "MOV", + 1, + lambda pc, d, rd=reg_d, ri=reg_i: f"${d:02X}+{ri},{rd}", + ) elif name == "IndexedIndirectRead": func, reg = args @@ -227,11 +269,19 @@ def _build_table(): elif name == "IndexedIndirectWrite": reg_d, reg_i = args - table[opcode] = ("MOV", 1, lambda pc, d, rd=reg_d, ri=reg_i: f"(${d:02X}+{ri}),{rd}") + table[opcode] = ( + "MOV", + 1, + lambda pc, d, rd=reg_d, ri=reg_i: f"(${d:02X}+{ri}),{rd}", + ) elif name == "IndirectIndexedWrite": reg_d, reg_i = args - table[opcode] = ("MOV", 1, lambda pc, d, rd=reg_d, ri=reg_i: f"(${d:02X})+{ri},{rd}") + table[opcode] = ( + "MOV", + 1, + lambda pc, d, rd=reg_d, ri=reg_i: f"(${d:02X})+{ri},{rd}", + ) elif name == "DirectReadWord": func = args[0] @@ -244,12 +294,7 @@ def _build_table(): elif name == "DirectCompareWord": table[opcode] = ("CMPW", 1, lambda pc, d: f"YA,${d:02X}") - elif name == "DirectDirectModify": - func = args[0] - mnem = _OPNAMES[func] - table[opcode] = (mnem, 2, lambda pc, s, t: f"${t:02X},${s:02X}") - - elif name == "DirectDirectCompare": + elif name == "DirectDirectModify" or name == "DirectDirectCompare": func = args[0] mnem = _OPNAMES[func] table[opcode] = (mnem, 2, lambda pc, s, t: f"${t:02X},${s:02X}") @@ -271,38 +316,74 @@ def _build_table(): elif name == "AbsoluteRead": func, reg = args mnem = _OPNAMES[func] - table[opcode] = (mnem, 2, lambda pc, lo, hi, r=reg: f"{r},${(hi<<8|lo):04X}") + table[opcode] = ( + mnem, + 2, + lambda pc, lo, hi, r=reg: f"{r},${(hi << 8 | lo):04X}", + ) elif name == "AbsoluteModify": func = args[0] mnem = _OPNAMES[func] - table[opcode] = (mnem, 2, lambda pc, lo, hi: f"${(hi<<8|lo):04X}") + table[opcode] = ( + mnem, + 2, + lambda pc, lo, hi: f"${(hi << 8 | lo):04X}", + ) elif name == "AbsoluteWrite": reg = args[0] - table[opcode] = ("MOV", 2, lambda pc, lo, hi, r=reg: f"${(hi<<8|lo):04X},{r}") + table[opcode] = ( + "MOV", + 2, + lambda pc, lo, hi, r=reg: f"${(hi << 8 | lo):04X},{r}", + ) elif name == "AbsoluteIndexedRead": func, reg = args mnem = _OPNAMES[func] - table[opcode] = (mnem, 2, lambda pc, lo, hi, r=reg: f"A,${(hi<<8|lo):04X}+{r}") + table[opcode] = ( + mnem, + 2, + lambda pc, lo, hi, r=reg: f"A,${(hi << 8 | lo):04X}+{r}", + ) elif name == "AbsoluteIndexedWrite": reg = args[0] - table[opcode] = ("MOV", 2, lambda pc, lo, hi, r=reg: f"${(hi<<8|lo):04X}+{r},A") + table[opcode] = ( + "MOV", + 2, + lambda pc, lo, hi, r=reg: f"${(hi << 8 | lo):04X}+{r},A", + ) elif name == "TestSetBitsAbsolute": mnem = "TSET1" if args[0] else "TCLR1" - table[opcode] = (mnem, 2, lambda pc, lo, hi: f"${(hi<<8|lo):04X}") + table[opcode] = ( + mnem, + 2, + lambda pc, lo, hi: f"${(hi << 8 | lo):04X}", + ) elif name == "JumpAbsolute": - table[opcode] = ("JMP", 2, lambda pc, lo, hi: f"${(hi<<8|lo):04X}") + table[opcode] = ( + "JMP", + 2, + lambda pc, lo, hi: f"${(hi << 8 | lo):04X}", + ) elif name == "JumpIndirectX": - table[opcode] = ("JMP", 2, lambda pc, lo, hi: f"(${(hi<<8|lo):04X}+X)") + table[opcode] = ( + "JMP", + 2, + lambda pc, lo, hi: f"(${(hi << 8 | lo):04X}+X)", + ) elif name == "CallAbsolute": - table[opcode] = ("JSR", 2, lambda pc, lo, hi: f"${(hi<<8|lo):04X}") + table[opcode] = ( + "JSR", + 2, + lambda pc, lo, hi: f"${(hi << 8 | lo):04X}", + ) elif name == "CallPage": table[opcode] = ("PCALL", 1, lambda pc, d: f"${d:02X}") @@ -321,14 +402,13 @@ def _peek(self, addr): addr &= 0xFFFF if addr <= 0x00EF: return self.apu.page_0[addr] - elif addr <= 0x00FF: + if addr <= 0x00FF: return 0 # I/O region — skip side-effect read - elif addr <= 0x01FF: + if addr <= 0x01FF: return self.apu.page_1[addr - 0x0100] - elif addr <= 0xFFBF: + if addr <= 0xFFBF: return self.apu.memory[addr - 0x0200] - else: - return self.apu.ipl_rom[addr - 0xFFC0] + return self.apu.ipl_rom[addr - 0xFFC0] def disassemble(self, pc): opcode = self._peek(pc) diff --git a/pysnes/apu/spc700/instructions_spc700.py b/pysnes/apu/spc700/instructions_spc700.py index 895c8b8..6536bda 100644 --- a/pysnes/apu/spc700/instructions_spc700.py +++ b/pysnes/apu/spc700/instructions_spc700.py @@ -1,7 +1,7 @@ from .addressing_modes import SPC700AddressingModes from .opcodes_spc700 import SPC700Opcodes - +# fmt: off INSTRUCTIONS = ( (0x00, SPC700AddressingModes.NoOperation), (0x01, SPC700AddressingModes.CallTable, 0), @@ -13,12 +13,12 @@ (0x07, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.OR, "X"), (0x08, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.OR, "A"), (0x09, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.OR), - (0x0a, SPC700AddressingModes.AbsoluteBitModify, 0), - (0x0b, SPC700AddressingModes.DirectModify, SPC700Opcodes.ASL), - (0x0c, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ASL), - (0x0d, SPC700AddressingModes.Push, "PSW"), - (0x0e, SPC700AddressingModes.TestSetBitsAbsolute, True), - (0x0f, SPC700AddressingModes.Break), + (0x0A, SPC700AddressingModes.AbsoluteBitModify, 0), + (0x0B, SPC700AddressingModes.DirectModify, SPC700Opcodes.ASL), + (0x0C, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ASL), + (0x0D, SPC700AddressingModes.Push, "PSW"), + (0x0E, SPC700AddressingModes.TestSetBitsAbsolute, True), + (0x0F, SPC700AddressingModes.Break), (0x10, SPC700AddressingModes.Branch, lambda self: not self.NF), (0x11, SPC700AddressingModes.CallTable, 1), (0x12, SPC700AddressingModes.AbsoluteBitSet, 0, False), @@ -29,12 +29,12 @@ (0x17, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.OR, "Y"), (0x18, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.OR), (0x19, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.OR), - (0x1a, SPC700AddressingModes.DirectModifyWord, -1), - (0x1b, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ASL, "X"), - (0x1c, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ASL, "A"), - (0x1d, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "X"), - (0x1e, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.CMP, "X"), - (0x1f, SPC700AddressingModes.JumpIndirectX), + (0x1A, SPC700AddressingModes.DirectModifyWord, -1), + (0x1B, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ASL, "X"), + (0x1C, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ASL, "A"), + (0x1D, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "X"), + (0x1E, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.CMP, "X"), + (0x1F, SPC700AddressingModes.JumpIndirectX), (0x20, SPC700AddressingModes.FlagSet, "PF", False), (0x21, SPC700AddressingModes.CallTable, 2), (0x22, SPC700AddressingModes.AbsoluteBitSet, 1, True), @@ -45,12 +45,12 @@ (0x27, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.AND, "X"), (0x28, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.AND, "A"), (0x29, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.AND), - (0x2a, SPC700AddressingModes.AbsoluteBitModify, 1), - (0x2b, SPC700AddressingModes.DirectModify, SPC700Opcodes.ROL), - (0x2c, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ROL), - (0x2d, SPC700AddressingModes.Push, "A"), - (0x2e, SPC700AddressingModes.BranchNotDirect), - (0x2f, SPC700AddressingModes.Branch, lambda self: True), + (0x2A, SPC700AddressingModes.AbsoluteBitModify, 1), + (0x2B, SPC700AddressingModes.DirectModify, SPC700Opcodes.ROL), + (0x2C, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ROL), + (0x2D, SPC700AddressingModes.Push, "A"), + (0x2E, SPC700AddressingModes.BranchNotDirect), + (0x2F, SPC700AddressingModes.Branch, lambda self: True), (0x30, SPC700AddressingModes.Branch, lambda self: self.NF), (0x31, SPC700AddressingModes.CallTable, 3), (0x32, SPC700AddressingModes.AbsoluteBitSet, 1, False), @@ -61,12 +61,12 @@ (0x37, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.AND, "Y"), (0x38, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.AND), (0x39, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.AND), - (0x3a, SPC700AddressingModes.DirectModifyWord, +1), - (0x3b, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ROL, "X"), - (0x3c, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ROL, "A"), - (0x3d, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "X"), - (0x3e, SPC700AddressingModes.DirectRead, SPC700Opcodes.CMP, "X"), - (0x3f, SPC700AddressingModes.CallAbsolute), + (0x3A, SPC700AddressingModes.DirectModifyWord, +1), + (0x3B, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ROL, "X"), + (0x3C, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ROL, "A"), + (0x3D, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "X"), + (0x3E, SPC700AddressingModes.DirectRead, SPC700Opcodes.CMP, "X"), + (0x3F, SPC700AddressingModes.CallAbsolute), (0x40, SPC700AddressingModes.FlagSet, "PF", True), (0x41, SPC700AddressingModes.CallTable, 4), (0x42, SPC700AddressingModes.AbsoluteBitSet, 2, True), @@ -77,12 +77,12 @@ (0x47, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.EOR, "X"), (0x48, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.EOR, "A"), (0x49, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.EOR), - (0x4a, SPC700AddressingModes.AbsoluteBitModify, 2), - (0x4b, SPC700AddressingModes.DirectModify, SPC700Opcodes.LSR), - (0x4c, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.LSR), - (0x4d, SPC700AddressingModes.Push, "X"), - (0x4e, SPC700AddressingModes.TestSetBitsAbsolute, False), - (0x4f, SPC700AddressingModes.CallPage), + (0x4A, SPC700AddressingModes.AbsoluteBitModify, 2), + (0x4B, SPC700AddressingModes.DirectModify, SPC700Opcodes.LSR), + (0x4C, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.LSR), + (0x4D, SPC700AddressingModes.Push, "X"), + (0x4E, SPC700AddressingModes.TestSetBitsAbsolute, False), + (0x4F, SPC700AddressingModes.CallPage), (0x50, SPC700AddressingModes.Branch, lambda self: not self.VF), (0x51, SPC700AddressingModes.CallTable, 5), (0x52, SPC700AddressingModes.AbsoluteBitSet, 2, False), @@ -93,12 +93,12 @@ (0x57, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.EOR, "Y"), (0x58, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.EOR), (0x59, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.EOR), - (0x5a, SPC700AddressingModes.DirectCompareWord, SPC700Opcodes.CPW), - (0x5b, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.LSR, "X"), - (0x5c, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.LSR, "A"), - (0x5d, SPC700AddressingModes.Transfer, "A", "X"), - (0x5e, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.CMP, "Y"), - (0x5f, SPC700AddressingModes.JumpAbsolute), + (0x5A, SPC700AddressingModes.DirectCompareWord, SPC700Opcodes.CPW), + (0x5B, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.LSR, "X"), + (0x5C, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.LSR, "A"), + (0x5D, SPC700AddressingModes.Transfer, "A", "X"), + (0x5E, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.CMP, "Y"), + (0x5F, SPC700AddressingModes.JumpAbsolute), (0x60, SPC700AddressingModes.FlagSet, "CF", False), (0x61, SPC700AddressingModes.CallTable, 6), (0x62, SPC700AddressingModes.AbsoluteBitSet, 3, True), @@ -109,12 +109,12 @@ (0x67, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.CMP, "X"), (0x68, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.CMP, "A"), (0x69, SPC700AddressingModes.DirectDirectCompare, SPC700Opcodes.CMP), - (0x6a, SPC700AddressingModes.AbsoluteBitModify, 3), - (0x6b, SPC700AddressingModes.DirectModify, SPC700Opcodes.ROR), - (0x6c, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ROR), - (0x6d, SPC700AddressingModes.Push, "Y"), - (0x6e, SPC700AddressingModes.BranchNotDirectDecrement), - (0x6f, SPC700AddressingModes.ReturnSubroutine), + (0x6A, SPC700AddressingModes.AbsoluteBitModify, 3), + (0x6B, SPC700AddressingModes.DirectModify, SPC700Opcodes.ROR), + (0x6C, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.ROR), + (0x6D, SPC700AddressingModes.Push, "Y"), + (0x6E, SPC700AddressingModes.BranchNotDirectDecrement), + (0x6F, SPC700AddressingModes.ReturnSubroutine), (0x70, SPC700AddressingModes.Branch, lambda self: self.VF), (0x71, SPC700AddressingModes.CallTable, 7), (0x72, SPC700AddressingModes.AbsoluteBitSet, 3, False), @@ -125,12 +125,12 @@ (0x77, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.CMP, "Y"), (0x78, SPC700AddressingModes.DirectImmediateCompare, SPC700Opcodes.CMP), (0x79, SPC700AddressingModes.IndirectXCompareIndirectY, SPC700Opcodes.CMP), - (0x7a, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.ADW), - (0x7b, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ROR, "X"), - (0x7c, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ROR, "A"), - (0x7d, SPC700AddressingModes.Transfer, "X", "A"), - (0x7e, SPC700AddressingModes.DirectRead, SPC700Opcodes.CMP, "Y"), - (0x7f, SPC700AddressingModes.ReturnInterrupt), + (0x7A, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.ADW), + (0x7B, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.ROR, "X"), + (0x7C, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.ROR, "A"), + (0x7D, SPC700AddressingModes.Transfer, "X", "A"), + (0x7E, SPC700AddressingModes.DirectRead, SPC700Opcodes.CMP, "Y"), + (0x7F, SPC700AddressingModes.ReturnInterrupt), (0x80, SPC700AddressingModes.FlagSet, "CF", True), (0x81, SPC700AddressingModes.CallTable, 8), (0x82, SPC700AddressingModes.AbsoluteBitSet, 4, True), @@ -141,12 +141,12 @@ (0x87, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.ADC, "X"), (0x88, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.ADC, "A"), (0x89, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.ADC), - (0x8a, SPC700AddressingModes.AbsoluteBitModify, 4), - (0x8b, SPC700AddressingModes.DirectModify, SPC700Opcodes.DEC), - (0x8c, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.DEC), - (0x8d, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "Y"), - (0x8e, SPC700AddressingModes.PullP), - (0x8f, SPC700AddressingModes.DirectImmediateWrite), + (0x8A, SPC700AddressingModes.AbsoluteBitModify, 4), + (0x8B, SPC700AddressingModes.DirectModify, SPC700Opcodes.DEC), + (0x8C, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.DEC), + (0x8D, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "Y"), + (0x8E, SPC700AddressingModes.PullP), + (0x8F, SPC700AddressingModes.DirectImmediateWrite), (0x90, SPC700AddressingModes.Branch, lambda self: not self.CF), (0x91, SPC700AddressingModes.CallTable, 9), (0x92, SPC700AddressingModes.AbsoluteBitSet, 4, False), @@ -157,106 +157,107 @@ (0x97, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.ADC, "Y"), (0x98, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.ADC), (0x99, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.ADC), - (0x9a, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.SBW), - (0x9b, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.DEC, "X"), - (0x9c, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "A"), - (0x9d, SPC700AddressingModes.Transfer, "S", "X"), - (0x9e, SPC700AddressingModes.Divide), - (0x9f, SPC700AddressingModes.ExchangeNibble), + (0x9A, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.SBW), + (0x9B, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.DEC, "X"), + (0x9C, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "A"), + (0x9D, SPC700AddressingModes.Transfer, "S", "X"), + (0x9E, SPC700AddressingModes.Divide), + (0x9F, SPC700AddressingModes.ExchangeNibble), (0xA0, SPC700AddressingModes.FlagSet, "IF", True), - (0xa1, SPC700AddressingModes.CallTable, 10), - (0xa2, SPC700AddressingModes.AbsoluteBitSet, 5, True), - (0xa3, SPC700AddressingModes.BranchBit, 5, True), - (0xa4, SPC700AddressingModes.DirectRead, SPC700Opcodes.SBC, "A"), - (0xa5, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.SBC, "A"), - (0xa6, SPC700AddressingModes.IndirectXRead, SPC700Opcodes.SBC), - (0xa7, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.SBC, "X"), - (0xa8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.SBC, "A"), - (0xa9, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.SBC), - (0xaa, SPC700AddressingModes.AbsoluteBitModify, 5), - (0xab, SPC700AddressingModes.DirectModify, SPC700Opcodes.INC), - (0xac, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.INC), - (0xad, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.CMP, "Y"), - (0xae, SPC700AddressingModes.Pull, "A"), - (0xaf, SPC700AddressingModes.IndirectXIncrementWrite, "A"), + (0xA1, SPC700AddressingModes.CallTable, 10), + (0xA2, SPC700AddressingModes.AbsoluteBitSet, 5, True), + (0xA3, SPC700AddressingModes.BranchBit, 5, True), + (0xA4, SPC700AddressingModes.DirectRead, SPC700Opcodes.SBC, "A"), + (0xA5, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.SBC, "A"), + (0xA6, SPC700AddressingModes.IndirectXRead, SPC700Opcodes.SBC), + (0xA7, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.SBC, "X"), + (0xA8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.SBC, "A"), + (0xA9, SPC700AddressingModes.DirectDirectModify, SPC700Opcodes.SBC), + (0xAA, SPC700AddressingModes.AbsoluteBitModify, 5), + (0xAB, SPC700AddressingModes.DirectModify, SPC700Opcodes.INC), + (0xAC, SPC700AddressingModes.AbsoluteModify, SPC700Opcodes.INC), + (0xAD, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.CMP, "Y"), + (0xAE, SPC700AddressingModes.Pull, "A"), + (0xAF, SPC700AddressingModes.IndirectXIncrementWrite, "A"), (0xB0, SPC700AddressingModes.Branch, lambda self: self.CF), - (0xb1, SPC700AddressingModes.CallTable, 11), - (0xb2, SPC700AddressingModes.AbsoluteBitSet, 5, False), - (0xb3, SPC700AddressingModes.BranchBit, 5, False), - (0xb4, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.SBC, "A", "X"), - (0xb5, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.SBC, "X"), - (0xb6, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.SBC, "Y"), - (0xb7, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.SBC, "Y"), - (0xb8, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.SBC), - (0xb9, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.SBC), - (0xba, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.LDW), - (0xbb, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.INC, "X"), - (0xbc, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "A"), - (0xbd, SPC700AddressingModes.Transfer, "X", "S"), - (0xbe, SPC700AddressingModes.DecimalAdjustSub), - (0xbf, SPC700AddressingModes.IndirectXIncrementRead, "A"), + (0xB1, SPC700AddressingModes.CallTable, 11), + (0xB2, SPC700AddressingModes.AbsoluteBitSet, 5, False), + (0xB3, SPC700AddressingModes.BranchBit, 5, False), + (0xB4, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.SBC, "A", "X"), + (0xB5, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.SBC, "X"), + (0xB6, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.SBC, "Y"), + (0xB7, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.SBC, "Y"), + (0xB8, SPC700AddressingModes.DirectImmediateModify, SPC700Opcodes.SBC), + (0xB9, SPC700AddressingModes.IndirectXWriteIndirectY, SPC700Opcodes.SBC), + (0xBA, SPC700AddressingModes.DirectReadWord, SPC700Opcodes.LDW), + (0xBB, SPC700AddressingModes.DirectIndexedModify, SPC700Opcodes.INC, "X"), + (0xBC, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "A"), + (0xBD, SPC700AddressingModes.Transfer, "X", "S"), + (0xBE, SPC700AddressingModes.DecimalAdjustSub), + (0xBF, SPC700AddressingModes.IndirectXIncrementRead, "A"), (0xC0, SPC700AddressingModes.FlagSet, "IF", False), - (0xc1, SPC700AddressingModes.CallTable, 12), - (0xc2, SPC700AddressingModes.AbsoluteBitSet, 6, True), - (0xc3, SPC700AddressingModes.BranchBit, 6, True), - (0xc4, SPC700AddressingModes.DirectWrite, "A"), - (0xc5, SPC700AddressingModes.AbsoluteWrite, "A"), - (0xc6, SPC700AddressingModes.IndirectXWrite, "A"), - (0xc7, SPC700AddressingModes.IndexedIndirectWrite, "A", "X"), - (0xc8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.CMP, "X"), - (0xc9, SPC700AddressingModes.AbsoluteWrite, "X"), - (0xca, SPC700AddressingModes.AbsoluteBitModify, 6), - (0xcb, SPC700AddressingModes.DirectWrite, "Y"), - (0xcc, SPC700AddressingModes.AbsoluteWrite, "Y"), - (0xcd, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "X"), - (0xce, SPC700AddressingModes.Pull, "X"), - (0xcf, SPC700AddressingModes.Multiply), + (0xC1, SPC700AddressingModes.CallTable, 12), + (0xC2, SPC700AddressingModes.AbsoluteBitSet, 6, True), + (0xC3, SPC700AddressingModes.BranchBit, 6, True), + (0xC4, SPC700AddressingModes.DirectWrite, "A"), + (0xC5, SPC700AddressingModes.AbsoluteWrite, "A"), + (0xC6, SPC700AddressingModes.IndirectXWrite, "A"), + (0xC7, SPC700AddressingModes.IndexedIndirectWrite, "A", "X"), + (0xC8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.CMP, "X"), + (0xC9, SPC700AddressingModes.AbsoluteWrite, "X"), + (0xCA, SPC700AddressingModes.AbsoluteBitModify, 6), + (0xCB, SPC700AddressingModes.DirectWrite, "Y"), + (0xCC, SPC700AddressingModes.AbsoluteWrite, "Y"), + (0xCD, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "X"), + (0xCE, SPC700AddressingModes.Pull, "X"), + (0xCF, SPC700AddressingModes.Multiply), (0xD0, SPC700AddressingModes.Branch, lambda self: not self.ZF), - (0xd1, SPC700AddressingModes.CallTable, 13), - (0xd2, SPC700AddressingModes.AbsoluteBitSet, 6, False), - (0xd3, SPC700AddressingModes.BranchBit, 6, False), - (0xd4, SPC700AddressingModes.DirectIndexedWrite, "A", "X"), - (0xd5, SPC700AddressingModes.AbsoluteIndexedWrite, "X"), - (0xd6, SPC700AddressingModes.AbsoluteIndexedWrite, "Y"), - (0xd7, SPC700AddressingModes.IndirectIndexedWrite, "A", "Y"), - (0xd8, SPC700AddressingModes.DirectWrite, "X"), - (0xd9, SPC700AddressingModes.DirectIndexedWrite, "X", "Y"), - (0xda, SPC700AddressingModes.DirectWriteWord), - (0xdb, SPC700AddressingModes.DirectIndexedWrite, "Y", "X"), - (0xdc, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "Y"), - (0xdd, SPC700AddressingModes.Transfer, "Y", "A"), - (0xde, SPC700AddressingModes.BranchNotDirectIndexed, "X"), - (0xdf, SPC700AddressingModes.DecimalAdjustAdd), - (0xe0, SPC700AddressingModes.OverflowClear), - (0xe1, SPC700AddressingModes.CallTable, 14), - (0xe2, SPC700AddressingModes.AbsoluteBitSet, 7, True), - (0xe3, SPC700AddressingModes.BranchBit, 7, True), - (0xe4, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "A"), - (0xe5, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "A"), - (0xe6, SPC700AddressingModes.IndirectXRead, SPC700Opcodes.LD), - (0xe7, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.LD, "X"), - (0xe8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "A"), - (0xe9, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "X"), - (0xea, SPC700AddressingModes.AbsoluteBitModify, 7), - (0xeb, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "Y"), - (0xec, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "Y"), - (0xed, SPC700AddressingModes.ComplementCarry), - (0xee, SPC700AddressingModes.Pull, "Y"), - (0xef, SPC700AddressingModes.Wait), + (0xD1, SPC700AddressingModes.CallTable, 13), + (0xD2, SPC700AddressingModes.AbsoluteBitSet, 6, False), + (0xD3, SPC700AddressingModes.BranchBit, 6, False), + (0xD4, SPC700AddressingModes.DirectIndexedWrite, "A", "X"), + (0xD5, SPC700AddressingModes.AbsoluteIndexedWrite, "X"), + (0xD6, SPC700AddressingModes.AbsoluteIndexedWrite, "Y"), + (0xD7, SPC700AddressingModes.IndirectIndexedWrite, "A", "Y"), + (0xD8, SPC700AddressingModes.DirectWrite, "X"), + (0xD9, SPC700AddressingModes.DirectIndexedWrite, "X", "Y"), + (0xDA, SPC700AddressingModes.DirectWriteWord), + (0xDB, SPC700AddressingModes.DirectIndexedWrite, "Y", "X"), + (0xDC, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.DEC, "Y"), + (0xDD, SPC700AddressingModes.Transfer, "Y", "A"), + (0xDE, SPC700AddressingModes.BranchNotDirectIndexed, "X"), + (0xDF, SPC700AddressingModes.DecimalAdjustAdd), + (0xE0, SPC700AddressingModes.OverflowClear), + (0xE1, SPC700AddressingModes.CallTable, 14), + (0xE2, SPC700AddressingModes.AbsoluteBitSet, 7, True), + (0xE3, SPC700AddressingModes.BranchBit, 7, True), + (0xE4, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "A"), + (0xE5, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "A"), + (0xE6, SPC700AddressingModes.IndirectXRead, SPC700Opcodes.LD), + (0xE7, SPC700AddressingModes.IndexedIndirectRead, SPC700Opcodes.LD, "X"), + (0xE8, SPC700AddressingModes.ImmediateRead, SPC700Opcodes.LD, "A"), + (0xE9, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "X"), + (0xEA, SPC700AddressingModes.AbsoluteBitModify, 7), + (0xEB, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "Y"), + (0xEC, SPC700AddressingModes.AbsoluteRead, SPC700Opcodes.LD, "Y"), + (0xED, SPC700AddressingModes.ComplementCarry), + (0xEE, SPC700AddressingModes.Pull, "Y"), + (0xEF, SPC700AddressingModes.Wait), (0xF0, SPC700AddressingModes.Branch, lambda self: self.ZF), - (0xf1, SPC700AddressingModes.CallTable, 15), - (0xf2, SPC700AddressingModes.AbsoluteBitSet, 7, False), - (0xf3, SPC700AddressingModes.BranchBit, 7, False), - (0xf4, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "A", "X"), - (0xf5, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.LD, "X"), - (0xf6, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.LD, "Y"), - (0xf7, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.LD, "Y"), - (0xf8, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "X"), - (0xf9, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "X", "Y"), - (0xfa, SPC700AddressingModes.DirectDirectWrite), - (0xfb, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "Y", "X"), - (0xfc, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "Y"), - (0xfd, SPC700AddressingModes.Transfer, "A", "Y"), - (0xfe, SPC700AddressingModes.BranchNotYDecrement), - (0xff, SPC700AddressingModes.Stop), + (0xF1, SPC700AddressingModes.CallTable, 15), + (0xF2, SPC700AddressingModes.AbsoluteBitSet, 7, False), + (0xF3, SPC700AddressingModes.BranchBit, 7, False), + (0xF4, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "A", "X"), + (0xF5, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.LD, "X"), + (0xF6, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.LD, "Y"), + (0xF7, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.LD, "Y"), + (0xF8, SPC700AddressingModes.DirectRead, SPC700Opcodes.LD, "X"), + (0xF9, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "X", "Y"), + (0xFA, SPC700AddressingModes.DirectDirectWrite), + (0xFB, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.LD, "Y", "X"), + (0xFC, SPC700AddressingModes.ImpliedModify, SPC700Opcodes.INC, "Y"), + (0xFD, SPC700AddressingModes.Transfer, "A", "Y"), + (0xFE, SPC700AddressingModes.BranchNotYDecrement), + (0xFF, SPC700AddressingModes.Stop), ) +# fmt: on diff --git a/pysnes/apu/spc700/opcodes_spc700.py b/pysnes/apu/spc700/opcodes_spc700.py index 84387ff..b72ca90 100644 --- a/pysnes/apu/spc700/opcodes_spc700.py +++ b/pysnes/apu/spc700/opcodes_spc700.py @@ -3,12 +3,12 @@ class SPC700Opcodes: Opcodes implementation Notes: - Python stores negative integers as two's complement and that's why bitwise operations won't work correctly + Python stores negative integers as two's complement and that's why + bitwise operations won't work correctly https://stackoverflow.com/questions/46044936/bitwise-and-between-negative-and-positive-numbers """ def ADC(self, x, y): - #assert False result = x + y + int(self.CF) self.CF = bool(result > 0xFF) self.ZF = bool(result & 0xFF == 0) @@ -45,7 +45,6 @@ def CMP(self, x, y): def DEC(self, x): assert x >= 0 - #assert (x - 1) >= 0 # x is 8 bits, it's ok wrap from 0 to 255 x = (x - 1) & 0xFF self.NF = bool(x & 0x80) @@ -113,7 +112,6 @@ def ROR(self, x): return x def SBC(self, x: int, y: int) -> int: - #assert False assert x >= 0 assert y >= 0 return SPC700Opcodes.ADC(self, x & 0xFF, ~y & 0xFF) @@ -138,7 +136,6 @@ def CPW(self, x, y): return x def LDW(self, x, y): - #assert False assert y >= 0 self.ZF = y == 0 self.NF = bool(y & 0x8000) diff --git a/pysnes/apu/spc_file.py b/pysnes/apu/spc_file.py index 21b796b..a0ee3c2 100644 --- a/pysnes/apu/spc_file.py +++ b/pysnes/apu/spc_file.py @@ -1,7 +1,7 @@ HEADER_MAGIC = b"SNES-SPC700 Sound File Data v0.30\x1a\x1a" _HAS_ID666 = 0x1A -_NO_ID666 = 0x1B +_NO_ID666 = 0x1B class SpcFile: @@ -12,30 +12,37 @@ def __init__(self, path: str) -> None: data = f.read() if len(data) < 0x10200: - raise ValueError(f"SPC file too short: {len(data)} bytes (expected >= 0x10200)") + raise ValueError( + f"SPC file too short: {len(data)} bytes (expected >= 0x10200)" + ) if not data.startswith(HEADER_MAGIC): raise ValueError("Not a valid SPC file (bad magic bytes)") self.has_id666 = data[0x23] == _HAS_ID666 - self.pc = data[0x25] | (data[0x26] << 8) - self.a = data[0x27] - self.x = data[0x28] - self.y = data[0x29] + self.pc = data[0x25] | (data[0x26] << 8) + self.a = data[0x27] + self.x = data[0x28] + self.y = data[0x29] self.psw = data[0x2A] - self.sp = data[0x2B] + self.sp = data[0x2B] if self.has_id666: # Text-format ID666 tag: 0x2E-0xAD def _str(b): - return b.split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() - self.song_name = _str(data[0x2E:0x4E]) # 32 bytes - self.game_name = _str(data[0x4E:0x6E]) # 32 bytes - self.artist_name = _str(data[0x6E:0x8E]) # 32 bytes + return ( + b.split(b"\x00", 1)[0] + .decode("ascii", errors="replace") + .strip() + ) + + self.song_name = _str(data[0x2E:0x4E]) # 32 bytes + self.game_name = _str(data[0x4E:0x6E]) # 32 bytes + self.artist_name = _str(data[0x6E:0x8E]) # 32 bytes else: self.song_name = self.game_name = self.artist_name = "" - self.ram = data[0x100:0x10100] # 64 KB SPC700 RAM - self.dsp_regs = data[0x10100:0x10180] # 128 DSP registers + self.ram = data[0x100:0x10100] # 64 KB SPC700 RAM + self.dsp_regs = data[0x10100:0x10180] # 128 DSP registers # Extra RAM overlays the IPL ROM area 0xFFC0-0xFFFF - self.extra_ram = data[0x101C0:0x10200] # 64 bytes + self.extra_ram = data[0x101C0:0x10200] # 64 bytes diff --git a/pysnes/apu/test_apu.py b/pysnes/apu/test_apu.py index 690edb9..c92735d 100644 --- a/pysnes/apu/test_apu.py +++ b/pysnes/apu/test_apu.py @@ -1,12 +1,12 @@ -import pytest import numpy as np +import pytest from .apu import Apu @pytest.fixture def apu(): - yield Apu() + return Apu() """ @@ -124,6 +124,7 @@ def test_d0_dont_take(apu: Apu): # Control register ($F1) port reset — bits 4 and 5 # --------------------------------------------------------------------------- + def test_control_register_bit4_resets_ports_r_01(apu: Apu): """Bit 4 of $F1 resets CPU→APU input ports 0 and 1 (ports_r only).""" apu.ports_r[0] = 0xAA @@ -152,12 +153,13 @@ def test_control_register_bit5_resets_ports_r_23(apu: Apu): def test_dsp_register_write_read_via_apu(apu): """Writing to $F3 routes through DSP; reading $F3 reads back from DSP.""" - apu.write(0x00F2, 0x10) # DSP address = 0x10 (voice 1 VOLL) - apu.write(0x00F3, 0x55) # write value 0x55 to DSP register 0x10 - apu.write(0x00F2, 0x10) # keep address at 0x10 + apu.write(0x00F2, 0x10) # DSP address = 0x10 (voice 1 VOLL) + apu.write(0x00F3, 0x55) # write value 0x55 to DSP register 0x10 + apu.write(0x00F2, 0x10) # keep address at 0x10 result = apu.read(0x00F3) assert result == 0x55 + def test_generate_audio_frame_returns_correct_shape(apu): """generate_audio_frame returns (n, 2) int16 array.""" out = apu.generate_audio_frame(532) diff --git a/pysnes/apu/test_dsp.py b/pysnes/apu/test_dsp.py index 4691884..f8d927b 100644 --- a/pysnes/apu/test_dsp.py +++ b/pysnes/apu/test_dsp.py @@ -1,6 +1,6 @@ import numpy as np -import pytest -from .dsp import Dsp, ENV_ATTACK, ENV_DECAY, ENV_SUSTAIN, ENV_RELEASE + +from .dsp import ENV_ATTACK, ENV_RELEASE, Dsp def make_dsp(ram=None): @@ -16,7 +16,7 @@ def test_register_write_read(): def test_register_masked_to_7f(): d = make_dsp() - d.write_register(0x80, 0x42) # 0x80 & 0x7F = 0x00 + d.write_register(0x80, 0x42) # 0x80 & 0x7F = 0x00 assert d.read_register(0x00) == 0x42 @@ -24,7 +24,7 @@ def test_koff_sets_key_off(): d = make_dsp() d.voices[0].active = True d.voices[2].active = True - d.write_register(0x5C, 0x05) # KOFF voices 0 and 2 + d.write_register(0x5C, 0x05) # KOFF voices 0 and 2 assert d.voices[0].key_off is True assert d.voices[1].key_off is False assert d.voices[2].key_off is True @@ -40,7 +40,7 @@ def test_endx_read_clears(): def test_envx_read(): d = make_dsp() - d.voices[1].env_level = 0x400 # env_level=0x400, >> 4 = 0x40, & 0x7F = 0x40 + d.voices[1].env_level = 0x400 # env_level=0x400, >> 4 = 0x40, & 0x7F = 0x40 assert d.read_register(0x18) == 0x40 # voice 1 ENVX = reg 0x18 @@ -60,12 +60,14 @@ def test_generate_samples_silent_no_voices(): def test_generate_samples_muted(): """FLG bit7=1 mutes all output.""" d = make_dsp() - d.regs[0x6C] = 0x80 # FLG: mute + d.regs[0x6C] = 0x80 # FLG: mute d.voices[0].active = True d.voices[0].brr_buf = [1000] * 16 d.voices[0].env_level = 0x400 - d.regs[0x00] = 0x7F; d.regs[0x01] = 0x7F # voice 0 volumes - d.regs[0x0C] = 0x7F; d.regs[0x1C] = 0x7F # master volumes + d.regs[0x00] = 0x7F + d.regs[0x01] = 0x7F # voice 0 volumes + d.regs[0x0C] = 0x7F + d.regs[0x1C] = 0x7F # master volumes out = d.generate_samples(10) assert (out == 0).all() @@ -73,45 +75,53 @@ def test_generate_samples_muted(): def test_brr_decode_filter0_shift12(): """Filter 0, shift 12: nibble 1 → sample 2048, nibble 0 → sample 0.""" block = bytearray(9) - block[0] = 0xC1 # shift=12, filter=0, loop=0, end=1 - block[1] = 0x10 # upper nibble=1 → 2048, lower nibble=0 → 0 + block[0] = 0xC1 # shift=12, filter=0, loop=0, end=1 + block[1] = 0x10 # upper nibble=1 → 2048, lower nibble=0 → 0 # remaining bytes 0 → samples 0 mem = bytearray(65536) mem[0x1000:0x1009] = block d = make_dsp(mem) d._init_voice_for_test(0, brr_addr=0x1000) d._decode_brr_block(0) - assert d.voices[0].brr_buf[0] == 4096 # stored ×2: nibble=1, shift=12 → (1<<11)*2 + # stored ×2: nibble=1, shift=12 → (1<<11)*2 + assert d.voices[0].brr_buf[0] == 4096 assert d.voices[0].brr_buf[1] == 0 def test_brr_decode_filter0_negative_nibble(): """Nibble 0xF (-1 signed) with shift=12 → sample -2048.""" block = bytearray(9) - block[0] = 0xC1 # shift=12, filter=0, end=1 - block[1] = 0xF0 # upper nibble=0xF (-1 signed) + block[0] = 0xC1 # shift=12, filter=0, end=1 + block[1] = 0xF0 # upper nibble=0xF (-1 signed) mem = bytearray(65536) mem[0x2000:0x2009] = block d = make_dsp(mem) d._init_voice_for_test(0, brr_addr=0x2000) d._decode_brr_block(0) - assert d.voices[0].brr_buf[0] == -4096 # stored ×2: nibble=-1, shift=12 → (-1<<11)*2 + # stored ×2: nibble=-1, shift=12 → (-1<<11)*2 + assert d.voices[0].brr_buf[0] == -4096 def test_brr_end_flag_sets_endx(): """When BRR block has end flag, ENDX bit is set for that voice.""" mem = bytearray(65536) # source dir at 0, entry 0: start=0x0200, loop=0x0200 - mem[0] = 0x00; mem[1] = 0x02; mem[2] = 0x00; mem[3] = 0x02 + mem[0] = 0x00 + mem[1] = 0x02 + mem[2] = 0x00 + mem[3] = 0x02 # BRR at 0x0200: end=1, loop=1, all zero data mem[0x200] = 0x03 d = make_dsp(mem) - d.write_register(0x5D, 0x00) # DIR=0 - d.write_register(0x04, 0x00) # VxSRCN=0 for voice 0 - d.write_register(0x02, 0x00); d.write_register(0x03, 0x10) # pitch=0x1000 (advance 1 sample/tick) - d.write_register(0x00, 0x7F); d.write_register(0x01, 0x7F) - d.write_register(0x0C, 0x7F); d.write_register(0x1C, 0x7F) - d.write_register(0x4C, 0x01) # KON voice 0 + d.write_register(0x5D, 0x00) # DIR=0 + d.write_register(0x04, 0x00) # VxSRCN=0 for voice 0 + d.write_register(0x02, 0x00) + d.write_register(0x03, 0x10) # pitch=0x1000 (advance 1 sample/tick) + d.write_register(0x00, 0x7F) + d.write_register(0x01, 0x7F) + d.write_register(0x0C, 0x7F) + d.write_register(0x1C, 0x7F) + d.write_register(0x4C, 0x01) # KON voice 0 d.generate_samples(20) # ENDX bit 0 should be set at some point (voice hit end block and loops) # After looping, voice stays active; ENDX was set @@ -127,35 +137,43 @@ def test_brr_end_without_loop_zeroes_envelope(): """ mem = bytearray(65536) # source dir at 0, entry 0: start=0x0200 (loop addr unused) - mem[0] = 0x00; mem[1] = 0x02; mem[2] = 0x00; mem[3] = 0x02 - mem[0x200] = 0x01 # BRR header: end=1, loop=0, zero data + mem[0] = 0x00 + mem[1] = 0x02 + mem[2] = 0x00 + mem[3] = 0x02 + mem[0x200] = 0x01 # BRR header: end=1, loop=0, zero data d = make_dsp(mem) - d.write_register(0x5D, 0x00) # DIR=0 - d.write_register(0x04, 0x00) # VxSRCN=0 for voice 0 - d.write_register(0x02, 0x00); d.write_register(0x03, 0x10) # pitch=0x1000 - d.write_register(0x00, 0x7F); d.write_register(0x01, 0x7F) - d.write_register(0x0C, 0x7F); d.write_register(0x1C, 0x7F) + d.write_register(0x5D, 0x00) # DIR=0 + d.write_register(0x04, 0x00) # VxSRCN=0 for voice 0 + d.write_register(0x02, 0x00) + d.write_register(0x03, 0x10) # pitch=0x1000 + d.write_register(0x00, 0x7F) + d.write_register(0x01, 0x7F) + d.write_register(0x0C, 0x7F) + d.write_register(0x1C, 0x7F) # Direct GAIN holding env high so it can't reach 0 on its own. - d.write_register(0x05, 0x00) # ADSR1 bit7=0 → GAIN mode - d.write_register(0x07, 0x7F) # direct GAIN, max level - d.write_register(0x4C, 0x01) # KON voice 0 - d.generate_samples(64) # run until the sample reaches its end block + d.write_register(0x05, 0x00) # ADSR1 bit7=0 → GAIN mode + d.write_register(0x07, 0x7F) # direct GAIN, max level + d.write_register(0x4C, 0x01) # KON voice 0 + d.generate_samples(64) # run until the sample reaches its end block assert d.voices[0].active is False assert d.voices[0].env_level == 0 - assert d.read_register(0x08) == 0 # VxENVX for voice 0 + assert d.read_register(0x08) == 0 # VxENVX for voice 0 def test_key_on_activates_voice(): """KON causes voice to become active and start at BRR start address.""" mem = bytearray(65536) - mem[0] = 0x00; mem[1] = 0x03 # start addr = 0x0300 - mem[2] = 0x00; mem[3] = 0x03 # loop addr = 0x0300 - mem[0x300] = 0x03 # BRR end+loop, zero data + mem[0] = 0x00 + mem[1] = 0x03 # start addr = 0x0300 + mem[2] = 0x00 + mem[3] = 0x03 # loop addr = 0x0300 + mem[0x300] = 0x03 # BRR end+loop, zero data d = make_dsp(mem) d.write_register(0x5D, 0x00) d.write_register(0x04, 0x00) - d.write_register(0x4C, 0x01) # KON voice 0 - d.generate_samples(1) # apply KON + d.write_register(0x4C, 0x01) # KON voice 0 + d.generate_samples(1) # apply KON assert d.voices[0].active is True assert d.voices[0].brr_addr == 0x0300 or d.voices[0].loop_addr == 0x0300 @@ -165,7 +183,7 @@ def test_envelope_attack_increases(): d.voices[0].active = True d.voices[0].env_state = ENV_ATTACK d.voices[0].env_level = 0 - d.write_register(0x05, 0x8F) # ADSR enabled + d.write_register(0x05, 0x8F) # ADSR enabled before = d.voices[0].env_level d._step_envelope(0) assert d.voices[0].env_level > before @@ -185,8 +203,8 @@ def test_envelope_release_deactivates_at_zero(): d = make_dsp() d.voices[0].active = True d.voices[0].env_state = ENV_RELEASE - d.voices[0].env_level = 4 # less than 8, will hit 0 - d.write_register(0x05, 0x80) # ADSR mode so GAIN direct doesn't intercept + d.voices[0].env_level = 4 # less than 8, will hit 0 + d.write_register(0x05, 0x80) # ADSR mode so GAIN direct doesn't intercept for _ in range(10): d._step_envelope(0) assert d.voices[0].active is False @@ -210,17 +228,19 @@ def test_generate_samples_produces_nonzero_with_active_voice(): v.active = True v.brr_buf = [1000] * 16 v.brr_offset = 0 - v.brr_header = 0x00 # no end/loop flags + v.brr_header = 0x00 # no end/loop flags v.env_level = 0x400 v.pitch_frac = 0 # Pre-populate Gaussian history so interpolation produces nonzero output # (Gaussian uses hist0..hist3; without KON they start at zero) v.hist0 = v.hist1 = v.hist2 = v.hist3 = 1000 - d.regs[0x02] = 0x00; d.regs[0x03] = 0x00 # pitch=0 (no advance, stays at offset 0) - d.regs[0x00] = 0x7F # voice 0 left vol = +127 - d.regs[0x01] = 0x7F # voice 0 right vol = +127 - d.regs[0x0C] = 0x7F # master left vol - d.regs[0x1C] = 0x7F # master right vol - d.regs[0x05] = 0x80 # ADSR mode (bit7=1) so GAIN direct doesn't override env_level + d.regs[0x02] = 0x00 + d.regs[0x03] = 0x00 # pitch=0 (no advance, stays at offset 0) + d.regs[0x00] = 0x7F # voice 0 left vol = +127 + d.regs[0x01] = 0x7F # voice 0 right vol = +127 + d.regs[0x0C] = 0x7F # master left vol + d.regs[0x1C] = 0x7F # master right vol + # ADSR mode (bit7=1) so GAIN direct doesn't override env_level + d.regs[0x05] = 0x80 out = d.generate_samples(10) assert (out != 0).any() diff --git a/pysnes/apu/test_spc700.py b/pysnes/apu/test_spc700.py index fe1e4a1..9550cf6 100644 --- a/pysnes/apu/test_spc700.py +++ b/pysnes/apu/test_spc700.py @@ -12,13 +12,14 @@ """ import os +from collections import defaultdict + import ijson import pytest -from collections import defaultdict -from .apu import Apu from pysnes._ss_cache import get_or_build +from .apu import Apu TESTS_PATH = "submodules/SingleStepTests_spc700/v1" @@ -28,9 +29,9 @@ def _load_case(file_path: str, index: int) -> dict: global _FILE_CACHE_KEY, _FILE_CACHE_VAL - if _FILE_CACHE_KEY != file_path: - with open(file_path, 'rb') as f: - _FILE_CACHE_VAL = list(ijson.items(f, 'item')) + if file_path != _FILE_CACHE_KEY: + with open(file_path, "rb") as f: + _FILE_CACHE_VAL = list(ijson.items(f, "item")) _FILE_CACHE_KEY = file_path return _FILE_CACHE_VAL[index] @@ -74,8 +75,8 @@ def _parse_test_index(opcode_filter, max_per_opcode, mode): params, test_ids = [], [] for file_path in onlyfiles: - with open(file_path, 'rb') as f: - for i, name in enumerate(ijson.items(f, 'item.name')): + with open(file_path, "rb") as f: + for i, name in enumerate(ijson.items(f, "item.name")): test_id = name.replace(" ", "_") if limit > 0 and test_counter[test_id[:2]] >= limit: continue @@ -106,7 +107,8 @@ def get_test_cases(opcode_filter=None, max_per_opcode=None, mode=None): lambda: _parse_test_index(opcode_filter, max_per_opcode, mode), ) test_cases = [ - pytest.param(rp, marks=pytest.mark.xdist_group(rp[0])) for rp in raw_params + pytest.param(rp, marks=pytest.mark.xdist_group(rp[0])) + for rp in raw_params ] return test_cases, test_ids @@ -122,18 +124,18 @@ def test_spc700(test_case): _write_mem(apu, addr, value) apu.PC = initial["pc"] - apu.A = initial["a"] - apu.X = initial["x"] - apu.Y = initial["y"] - apu.S = initial["sp"] + apu.A = initial["a"] + apu.X = initial["x"] + apu.Y = initial["y"] + apu.S = initial["sp"] apu.PSW = initial["psw"] # ── Build expected cycle sequence ──────────────────────────────────── - # Cycles: [address, value, "read"/"write"/"wait"] - # "wait" entries are internal cycles (no memory transaction). - # Entries with null value are ghost reads the hardware performs but whose - # value is discarded; they count toward the cycle total but are excluded - # from the memory-access sequence check (same convention as the 65816 tests). + # Cycles: [address, value, "read"/"write"/"wait"] "wait" entries are + # internal cycles (no memory transaction). Entries with null value are ghost + # reads the hardware performs but whose value is discarded; they count + # toward the cycle total but are excluded from the memory-access sequence + # check (same convention as the 65816 tests). expected_cycles = test_case["cycles"] expected_mem = [ (addr, value, kind) @@ -150,12 +152,14 @@ def test_spc700(test_case): # ── Verify final register state ─────────────────────────────────────── final = test_case["final"] - assert apu.PC == final["pc"], f"PC: {hex(apu.PC)} != {hex(final['pc'])}" - assert apu.A == final["a"], f"A: {hex(apu.A)} != {hex(final['a'])}" - assert apu.X == final["x"], f"X: {hex(apu.X)} != {hex(final['x'])}" - assert apu.Y == final["y"], f"Y: {hex(apu.Y)} != {hex(final['y'])}" - assert apu.S == final["sp"], f"SP: {hex(apu.S)} != {hex(final['sp'])}" - assert apu.PSW == final["psw"], f"PSW: {hex(apu.PSW)} != {hex(final['psw'])}" + assert final["pc"] == apu.PC, f"PC: {hex(apu.PC)} != {hex(final['pc'])}" + assert final["a"] == apu.A, f"A: {hex(apu.A)} != {hex(final['a'])}" + assert final["x"] == apu.X, f"X: {hex(apu.X)} != {hex(final['x'])}" + assert final["y"] == apu.Y, f"Y: {hex(apu.Y)} != {hex(final['y'])}" + assert final["sp"] == apu.S, f"SP: {hex(apu.S)} != {hex(final['sp'])}" + assert final["psw"] == apu.PSW, ( + f"PSW: {hex(apu.PSW)} != {hex(final['psw'])}" + ) for addr, value in final["ram"]: # 0xFD-0xFF are timer counters that clear on read; use the shadow @@ -175,5 +179,6 @@ def test_spc700(test_case): # apu.cycles is incremented by every __getitem__, __setitem__, and idle() # call, giving the full instruction cycle count including internal waits. assert actual_cycle_count == expected_cycle_count, ( - f"Cycle count: got {actual_cycle_count}, expected {expected_cycle_count}" + f"Cycle count: got {actual_cycle_count}, expected " + f"{expected_cycle_count}" ) diff --git a/pysnes/apu/test_timers.py b/pysnes/apu/test_timers.py index c25ecbe..fecdf5d 100644 --- a/pysnes/apu/test_timers.py +++ b/pysnes/apu/test_timers.py @@ -25,13 +25,13 @@ import pytest -from .apu import Apu, Timer - +from .apu import Apu # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def apu(): a = Apu() @@ -45,6 +45,7 @@ def apu(): # Timer enable / disable via control register ($F1) # --------------------------------------------------------------------------- + def test_control_register_enables_timer0(apu: Apu): apu.write(0x00F1, 0x01) assert apu.timers[0].enable is True @@ -98,6 +99,7 @@ def test_disable_timer_stops_counting(apu: Apu): # Timer target ($FA/$FB/$FC) # --------------------------------------------------------------------------- + def test_timer0_target_set_via_register(apu: Apu): apu.write(0x00FA, 0x10) assert apu.timers[0].target == 0x10 @@ -117,6 +119,7 @@ def test_timer2_target_set_via_register(apu: Apu): # Counter output ($FD/$FE/$FF) and clear-on-read # --------------------------------------------------------------------------- + def test_counter_read_returns_stage3(apu: Apu): apu.timers[0].stage3 = 0x07 val = apu.read(0x00FD) @@ -154,14 +157,17 @@ def test_counter_wraps_at_4_bits(apu: Apu): # Counting behaviour — stage2 counts to target then stage3 increments # --------------------------------------------------------------------------- + def _tick_until_overflow(apu: Apu, timer_idx: int, expected_hits: int) -> None: - """Step the APU timer enough times to get exactly expected_hits overflows.""" + """Step the APU timer enough times to get exactly expected_hits + overflows.""" t = apu.timers[timer_idx] t.enable = True t.stage2 = 0 t.stage3 = 0 - # Each call passes t.frequency APU cycles so stage0 overflows exactly once per call, - # incrementing stage2 directly. stage3 increments every target stage2 increments. + # Each call passes t.frequency APU cycles so stage0 overflows exactly once + # per call, incrementing stage2 directly. stage3 increments every target + # stage2 increments. steps = t.target * expected_hits + 1 for _ in range(steps): apu.step_timers(t.frequency) @@ -201,6 +207,7 @@ def test_timer2_higher_frequency(apu: Apu): # Global timer gates (test register $F0 bits 0 and 3) # --------------------------------------------------------------------------- + def test_timers_disable_inhibits_all(apu: Apu): apu.timers_disable = True for t in apu.timers: @@ -231,8 +238,10 @@ def test_timers_enable_false_inhibits_all(apu: Apu): # Port reset via control register bits 4 and 5 # --------------------------------------------------------------------------- + def test_control_bit4_resets_ports_r_01(apu: Apu): - """Bit 4 resets CPU→APU input ports (ports_r); APU output ports (ports_w) unchanged.""" + """Bit 4 resets CPU→APU input ports (ports_r); APU output ports (ports_w) + unchanged.""" apu.ports_r[0] = 0xAA apu.ports_r[1] = 0xBB apu.ports_w[0] = 0x11 @@ -245,7 +254,8 @@ def test_control_bit4_resets_ports_r_01(apu: Apu): def test_control_bit5_resets_ports_r_23(apu: Apu): - """Bit 5 resets CPU→APU input ports (ports_r); APU output ports (ports_w) unchanged.""" + """Bit 5 resets CPU→APU input ports (ports_r); APU output ports (ports_w) + unchanged.""" apu.ports_r[2] = 0xCC apu.ports_r[3] = 0xDD apu.ports_w[2] = 0x33 diff --git a/pysnes/audio/audio_sdl2.py b/pysnes/audio/audio_sdl2.py index 241033d..5fff930 100644 --- a/pysnes/audio/audio_sdl2.py +++ b/pysnes/audio/audio_sdl2.py @@ -1,14 +1,16 @@ import ctypes import time + import numpy as np import sdl2 as sdl -# The SNES DSP always generates at this rate. Pitch registers are calibrated for it. +# The SNES DSP always generates at this rate. Pitch registers are calibrated for +# it. DSP_RATE = 32000 CHANNELS = 2 BUFFER_SAMPLES = 1024 BYTES_PER_SAMPLE = CHANNELS * 2 # stereo int16 = 4 bytes per sample -_BUSY_WAIT_HEADROOM_S = 0.001 # busy-wait the last 1 ms for precision +_BUSY_WAIT_HEADROOM_S = 0.001 # busy-wait the last 1 ms for precision class AudioSDL2: @@ -19,7 +21,8 @@ def __init__(self): self._max_queue_bytes = round(DSP_RATE / 60 + 1) * BYTES_PER_SAMPLE * 4 def initialize(self) -> None: - """Open SDL2 audio device in queue mode (SDL_Init already called by video).""" + """Open SDL2 audio device in queue mode (SDL_Init already called by + video).""" spec = sdl.SDL_AudioSpec( DSP_RATE, sdl.AUDIO_S16SYS, @@ -30,8 +33,8 @@ def initialize(self) -> None: obtained = sdl.SDL_AudioSpec(0, 0, 0, 0) dev_id = sdl.SDL_OpenAudioDevice( - None, # default device - 0, # playback (not capture) + None, # default device + 0, # playback (not capture) ctypes.byref(spec), ctypes.byref(obtained), sdl.SDL_AUDIO_ALLOW_FREQUENCY_CHANGE, @@ -42,7 +45,9 @@ def initialize(self) -> None: self._dev_id = dev_id self._device_rate = obtained.freq if obtained.freq > 0 else DSP_RATE self._drain_rate = float(self._device_rate * BYTES_PER_SAMPLE) - self._max_queue_bytes = round(self._device_rate / 60 + 1) * BYTES_PER_SAMPLE * 4 + self._max_queue_bytes = ( + round(self._device_rate / 60 + 1) * BYTES_PER_SAMPLE * 4 + ) # Unpause to start playback sdl.SDL_PauseAudioDevice(dev_id, 0) print( @@ -52,7 +57,8 @@ def initialize(self) -> None: ) def _resample(self, samples: np.ndarray) -> np.ndarray: - """Linear interpolation from DSP_RATE to device rate when they differ.""" + """Linear interpolation from DSP_RATE to device rate when they + differ.""" if self._device_rate == DSP_RATE: return samples n_in = len(samples) @@ -78,10 +84,14 @@ def queue_samples(self, samples: np.ndarray) -> None: data = np.ascontiguousarray(self._resample(samples), dtype=np.int16) queued = sdl.SDL_GetQueuedAudioSize(self._dev_id) if queued > self._max_queue_bytes: - sleep_s = (queued - self._max_queue_bytes) / self._drain_rate - _BUSY_WAIT_HEADROOM_S + sleep_s = ( + queued - self._max_queue_bytes + ) / self._drain_rate - _BUSY_WAIT_HEADROOM_S if sleep_s > 0: time.sleep(sleep_s) - while sdl.SDL_GetQueuedAudioSize(self._dev_id) > self._max_queue_bytes: + while ( + sdl.SDL_GetQueuedAudioSize(self._dev_id) > self._max_queue_bytes + ): pass # busy-wait the last ~1 ms ptr = data.ctypes.data_as(ctypes.c_void_p) sdl.SDL_QueueAudio(self._dev_id, ptr, data.nbytes) diff --git a/pysnes/bus/bus.py b/pysnes/bus/bus.py index d59e18a..2a90833 100644 --- a/pysnes/bus/bus.py +++ b/pysnes/bus/bus.py @@ -1,31 +1,32 @@ import os -from typing import List - -from ..rom import MappingMode, Rom -from ..cpu import Cpu from ..apu import Apu -from ..ppu import Ppu from ..controller import Controller +from ..cpu import Cpu +from ..ppu import Ppu +from ..rom import MappingMode, Rom from ..scheduler import Scheduler class Bus: - def __init__( self, rom: Rom, cpu: Cpu, apu: Apu, ppu: Ppu, - controllers: List[Controller], + controllers: list[Controller], scheduler: Scheduler, ) -> None: self.rom = rom # program memory (LoROM or HiROM, selected below) mapping_mode = rom.snes_header.mapping_mode - self.is_hirom = mapping_mode == MappingMode.HIROM or mapping_mode == MappingMode.HIROM_FAST - # TODO: HiROM mapping is implemented but lightly tested; address mirroring and - # SRAM window placement may diverge from hardware for some titles. + self.is_hirom = ( + mapping_mode == MappingMode.HIROM + or mapping_mode == MappingMode.HIROM_FAST + ) + # TODO: HiROM mapping is implemented but lightly tested; address + # mirroring and SRAM window placement may diverge from hardware for some + # titles. self.cpu = cpu self.apu = apu # Sound system [0x2140-0x217F] self.ppu = ppu @@ -48,15 +49,20 @@ def __init__( self._wmadd = 0 # Math hardware registers ($4202-$4206 write, $4214-$4217 read) - self._wrmpya = 0 # $4202 multiplicand - self._wrdiv = 0 # $4204-$4205 dividend (16-bit) + self._wrmpya = 0 # $4202 multiplicand + self._wrdiv = 0 # $4204-$4205 dividend (16-bit) def dump_state(self) -> dict: + if self.sram_size: + sram = bytes(self.sram[: self.sram_size]) + else: + sram = b"" + return { "low_ram": bytes(self.low_ram), "high_ram": bytes(self.high_ram), "extended_ram": bytes(self.extended_ram), - "sram": bytes(self.sram[: self.sram_size]) if self.sram_size else b"", + "sram": sram, "sram_dirty": bool(self.sram_dirty), "dma_ppu2_hw_registers": bytes(self.dma_ppu2_hw_registers), "hblank": bool(self.hblank), @@ -84,7 +90,8 @@ def load_state(self, d: dict) -> None: self._wrdiv = d["_wrdiv"] def load_sram(self, path: str) -> int: - """Load SRAM bytes from `path`. Returns the number of bytes loaded (0 if no SRAM or file missing).""" + """Load SRAM bytes from `path`. Returns the number of bytes loaded (0 if + no SRAM or file missing).""" if self.sram_size == 0 or not os.path.exists(path): return 0 with open(path, "rb") as f: @@ -96,20 +103,21 @@ def load_sram(self, path: str) -> int: return n def save_sram(self, path: str) -> int: - """Write SRAM bytes to `path` if dirty. Returns bytes written (0 if no SRAM or not dirty).""" + """Write SRAM bytes to `path` if dirty. Returns bytes written (0 if no + SRAM or not dirty).""" if self.sram_size == 0 or not self.sram_dirty: return 0 with open(path, "wb") as f: - f.write(bytes(self.sram[:self.sram_size])) + f.write(bytes(self.sram[: self.sram_size])) self.sram_dirty = False return self.sram_size def raise_nmi(self) -> None: """Called by PPU at V-Blank start (rising NMI edge). - TODO: NMI timing — on real hardware the NMI fires ~2 CPU cycles after V-Blank - starts; we fire it synchronously which may be slightly early for games that - poll $4210 before the NMI handler runs. + TODO: NMI timing — on real hardware the NMI fires ~2 CPU cycles after + V-Blank starts; we fire it synchronously which may be slightly early for + games that poll $4210 before the NMI handler runs. """ self.cpu.status.nmi_line = True if self.cpu.status.auto_joypad_read_enable: @@ -153,25 +161,33 @@ def read(self, abs_addr: int) -> int: if self.is_hirom: # HiROM ROM: banks $00-$3F at $8000-$FFFF, banks $40-$7D full. # rom_addr = (bank & 0x3F) << 16 | addr works for both regions. - if ((0x00 <= bank <= 0x3F) and addr >= 0x8000) or \ - (0x40 <= bank <= 0x7D): + if ((0x00 <= bank <= 0x3F) and addr >= 0x8000) or ( + 0x40 <= bank <= 0x7D + ): rom_addr = ((bank & 0x3F) << 16) | addr return self.rom.read(rom_addr) # HiROM SRAM: banks $20-$3F at $6000-$7FFF, 8KB window per bank. if 0x20 <= bank <= 0x3F and 0x6000 <= addr <= 0x7FFF: if self.sram_size: - sram_addr = (((bank - 0x20) << 13) | (addr - 0x6000)) & self.sram_mask + sram_addr = ( + ((bank - 0x20) << 13) | (addr - 0x6000) + ) & self.sram_mask return self.sram[sram_addr] return 0xFF else: - if ((0x00 <= bank <= 0x6F) and 0x8000 <= addr <= 0xFFFF) or \ - ((0x40 <= bank <= 0x6F) and (0x0000 <= addr <= 0xFFFF)) or \ - ((0x70 <= bank <= 0x7D) and (0x8000 <= addr <= 0xFFFF)): - rom_addr = (bank * 0x8000) + (addr - (0x8000 if addr >= 0x8000 else 0)) + if ( + ((0x00 <= bank <= 0x6F) and 0x8000 <= addr <= 0xFFFF) + or ((0x40 <= bank <= 0x6F) and (0x0000 <= addr <= 0xFFFF)) + or ((0x70 <= bank <= 0x7D) and (0x8000 <= addr <= 0xFFFF)) + ): + rom_addr = (bank * 0x8000) + ( + addr - (0x8000 if addr >= 0x8000 else 0) + ) return self.rom.read(rom_addr) - # SRAM: LoROM banks $70-$7D, addr $0000-$7FFF (mirrored from $F0-$FD) + # SRAM: LoROM banks $70-$7D, addr $0000-$7FFF (mirrored from + # $F0-$FD) if 0x70 <= bank <= 0x7D and addr < 0x8000: if self.sram_size: sram_addr = (((bank - 0x70) << 15) | addr) & self.sram_mask @@ -184,9 +200,10 @@ def read(self, abs_addr: int) -> int: if 0x7E8000 <= abs_addr <= 0x7FFFFF: return self.extended_ram[abs_addr - 0x7E8000] - if (0x00 <= bank <= 0x3F) or bank == 0x7E: - if 0x0000 <= addr <= 0x1FFF: - return self.low_ram[addr & 0xFFFF] # LowRAM, shadowed from bank $7E + bank_has_low_ram = (0x00 <= bank <= 0x3F) or bank == 0x7E + if bank_has_low_ram and 0x0000 <= addr <= 0x1FFF: + # LowRAM, shadowed from bank $7E + return self.low_ram[addr & 0xFFFF] if 0x00 <= bank <= 0x3F: # Hardware registers $2100-$21FF and $4200-$44FF are mirrored @@ -231,7 +248,10 @@ def read(self, abs_addr: int) -> int: if 0x2140 <= addr <= 0x217F: # 0x2140 - 0x204C == 0xF4 [addr of PORT0] if 0x2140 <= addr <= 0x2143: - self.apu.sync_to(self.scheduler.master_clock + (self.cpu.cycles - self.cpu.prev_cycles)) + self.apu.sync_to( + self.scheduler.master_clock + + (self.cpu.cycles - self.cpu.prev_cycles) + ) return self.apu.ports_w[addr - 0x2140] return self.apu.read_external(addr - 0x204C) @@ -246,7 +266,10 @@ def read(self, abs_addr: int) -> int: if addr == 0x4210: # RDNMI - NMI Flag and 5A22 Version data = ( self.cpu.status.nmi_line << 7 - | 1 << 6 # This bit is open bus, I'm setting it to satisfy the PLP test program + | 1 + # This bit is open bus, I'm setting it to satisfy the + # PLP test program + << 6 | 0x02 # 5A22 chip version number [0-3] ) self.cpu.status.nmi_line = False # Reading clears the line @@ -254,11 +277,16 @@ def read(self, abs_addr: int) -> int: return data if addr == 0x4211: # TIMEUP - IRQ flag (read-and-clear) data = self.cpu.status.irq_line << 7 - self.cpu.status.irq_line = False # Reading clears the latched flag + # Reading clears the latched flag + self.cpu.status.irq_line = False return data if addr == 0x4212: # HVBJOY - PPU Status return ( - (1 << 5) # This bit is unmapped but the test program keeps reading it + ( + 1 << 5 + # This bit is unmapped but the test program keeps + # reading it + ) | self.hblank << 6 | self.vblank << 7 ) @@ -284,12 +312,17 @@ def read(self, abs_addr: int) -> int: # than 0. We approximate the MDR with the high byte of the address, # which is the value the CPU last drove on the bus for an absolute # read (`lda $21C2` → 0x21). This is what the SuperNES Test Program - # relies on: it gates its Character Test animation on bit 5 of a read - # from $21C2 (expects 0x21, bit 5 set); returning 0 froze the demo. + # relies on: it gates its Character Test animation on bit 5 of a + # read from $21C2 (expects 0x21, bit 5 set); returning 0 froze the + # demo. if ( - 0x2000 <= addr <= 0x21FF # 0x2100-0x21FF: mapped regs handled above + 0x2000 + <= addr + <= 0x21FF # 0x2100-0x21FF: mapped regs handled above or 0x2200 <= addr <= 0x3FFF - or 0x4000 <= addr <= 0x41FF # 0x4016/0x4017 already handled above + or 0x4000 + <= addr + <= 0x41FF # 0x4016/0x4017 already handled above or 0x4500 <= addr <= 0x7FFF ): return (addr >> 8) & 0xFF @@ -298,7 +331,9 @@ def read(self, abs_addr: int) -> int: f"Reading unmapped memory region: 0x{abs_addr:06X}" ) - raise NotImplementedError(f"Reading unmapped memory region: 0x{abs_addr:06X}") + raise NotImplementedError( + f"Reading unmapped memory region: 0x{abs_addr:06X}" + ) def peek(self, abs_addr: int) -> int: """Read without side effects — safe for debugger/disassembler use. @@ -330,23 +365,29 @@ def write(self, abs_addr: int, data: int) -> None: # but the mask ROM ignores them. Dropping them here matters for # programs whose stack drifts into the bank-0 vector region # ($FFE0-$FFFF) — corrupting ROM would stomp the interrupt vectors. - if ((0x00 <= bank <= 0x3F) and addr >= 0x8000) or \ - (0x40 <= bank <= 0x7D): + if ((0x00 <= bank <= 0x3F) and addr >= 0x8000) or ( + 0x40 <= bank <= 0x7D + ): return if 0x20 <= bank <= 0x3F and 0x6000 <= addr <= 0x7FFF: if self.sram_size: - sram_addr = (((bank - 0x20) << 13) | (addr - 0x6000)) & self.sram_mask + sram_addr = ( + ((bank - 0x20) << 13) | (addr - 0x6000) + ) & self.sram_mask self.sram[sram_addr] = data self.sram_dirty = True return else: - if ((0x00 <= bank <= 0x6F) and 0x8000 <= addr <= 0xFFFF) or \ - ((0x40 <= bank <= 0x6F) and (0x0000 <= addr <= 0xFFFF)) or \ - ((0x70 <= bank <= 0x7D) and (0x8000 <= addr <= 0xFFFF)): + if ( + ((0x00 <= bank <= 0x6F) and 0x8000 <= addr <= 0xFFFF) + or ((0x40 <= bank <= 0x6F) and (0x0000 <= addr <= 0xFFFF)) + or ((0x70 <= bank <= 0x7D) and (0x8000 <= addr <= 0xFFFF)) + ): return - # SRAM: LoROM banks $70-$7D, addr $0000-$7FFF (mirrored from $F0-$FD) + # SRAM: LoROM banks $70-$7D, addr $0000-$7FFF (mirrored from + # $F0-$FD) if 0x70 <= bank <= 0x7D and addr < 0x8000: if self.sram_size: sram_addr = (((bank - 0x70) << 15) | addr) & self.sram_mask @@ -354,10 +395,10 @@ def write(self, abs_addr: int, data: int) -> None: self.sram_dirty = True return - if (0x00 <= bank <= 0x3F) or bank == 0x7E: - if 0x0000 <= addr <= 0x1FFF: - self.low_ram[addr & 0xFFFF] = data - return + bank_has_low_ram = (0x00 <= bank <= 0x3F) or bank == 0x7E + if bank_has_low_ram and 0x0000 <= addr <= 0x1FFF: + self.low_ram[addr & 0xFFFF] = data + return if 0x00 <= bank <= 0x3F: if 0x2100 <= addr <= 0x21FF: @@ -380,8 +421,14 @@ def write(self, abs_addr: int, data: int) -> None: return if addr == 0x2106: # MOSAIC - self.ppu.mosaic_enabled = [bool(data & (1 << i)) for i in range(4)] - self.ppu.mosaic_size = (data >> 4) + 1 # Not sure if I should add 1 here - (0=Smallest/1x1, 0Fh=Largest/16x16) + self.ppu.mosaic_enabled = [ + bool(data & (1 << i)) for i in range(4) + ] + self.ppu.mosaic_size = ( + (data >> 4) + 1 + # Not sure if I should add 1 here - (0=Smallest/1x1, + # 0Fh=Largest/16x16) + ) return if addr == 0x2105: # BGMODE @@ -409,7 +456,11 @@ def write(self, abs_addr: int, data: int) -> None: return if addr == 0x210D: # BG1HOFS - self.ppu.bg1.hoffset = data << 8 | (self.ppu.latch_bgofs_ppu1 & ~7) | (self.ppu.latch_bgofs_ppu2 & 7) + self.ppu.bg1.hoffset = ( + data << 8 + | (self.ppu.latch_bgofs_ppu1 & ~7) + | (self.ppu.latch_bgofs_ppu2 & 7) + ) self.ppu.latch_bgofs_ppu1 = data self.ppu.latch_bgofs_ppu2 = data return @@ -418,7 +469,11 @@ def write(self, abs_addr: int, data: int) -> None: self.ppu.latch_bgofs_ppu1 = data return if addr == 0x210F: # BG2HOFS - self.ppu.bg2.hoffset = data << 8 | (self.ppu.latch_bgofs_ppu1 & ~7) | (self.ppu.latch_bgofs_ppu2 & 7) + self.ppu.bg2.hoffset = ( + data << 8 + | (self.ppu.latch_bgofs_ppu1 & ~7) + | (self.ppu.latch_bgofs_ppu2 & 7) + ) self.ppu.latch_bgofs_ppu1 = data self.ppu.latch_bgofs_ppu2 = data return @@ -427,7 +482,11 @@ def write(self, abs_addr: int, data: int) -> None: self.ppu.latch_bgofs_ppu1 = data return if addr == 0x2111: # BG3HOFS - self.ppu.bg3.hoffset = data << 8 | (self.ppu.latch_bgofs_ppu1 & ~7) | (self.ppu.latch_bgofs_ppu2 & 7) + self.ppu.bg3.hoffset = ( + data << 8 + | (self.ppu.latch_bgofs_ppu1 & ~7) + | (self.ppu.latch_bgofs_ppu2 & 7) + ) self.ppu.latch_bgofs_ppu1 = data self.ppu.latch_bgofs_ppu2 = data return @@ -436,7 +495,11 @@ def write(self, abs_addr: int, data: int) -> None: self.ppu.latch_bgofs_ppu1 = data return if addr == 0x2113: # BG4HOFS - self.ppu.bg4.hoffset = data << 8 | (self.ppu.latch_bgofs_ppu1 & ~7) | (self.ppu.latch_bgofs_ppu2 & 7) + self.ppu.bg4.hoffset = ( + data << 8 + | (self.ppu.latch_bgofs_ppu1 & ~7) + | (self.ppu.latch_bgofs_ppu2 & 7) + ) self.ppu.latch_bgofs_ppu1 = data self.ppu.latch_bgofs_ppu2 = data return @@ -468,8 +531,8 @@ def write(self, abs_addr: int, data: int) -> None: """ 7-6 Screen Over (see below) 5-2 Not used - 1 Screen V-Flip (0=Normal, 1=Flipped) ;\flip 256x256 "screen" - 0 Screen H-Flip (0=Normal, 1=Flipped) ;/ + 1 Screen V-Flip (0=Normal, 1=Flipped) ;\flip the + 0 Screen H-Flip (0=Normal, 1=Flipped) ;/256x256 screen Screen Over (when exceeding the 128x128 tile BG Map size): 0=Wrap within 128x128 tile area 1=Wrap within 128x128 tile area (same as 0) @@ -544,13 +607,15 @@ def write(self, abs_addr: int, data: int) -> None: return if addr == 0x2133: # SETINI — display control (write-only) - # Bit 0: Screen interlace (0=progressive, 1=interlaced 448-line field alternation) - # Bit 1: OBJ interlace (0=normal, 1=split sprite rows across fields) - # Bit 2: Overscan (0=224 visible lines, 1=239 visible lines) - # Bit 3: Pseudo-hires (0=256-wide, 1=512-wide via subscreen half-pixel offset) - # Bits 4-5: unused - # Bit 6: EXTBG (Mode 7 only; enables BG2 as a second Mode 7 layer) - # Bit 7: External sync (genlock to external video; no effect in emulation) + # Bit 0: Screen interlace (0=progressive, 1=interlaced + # 448-line field alternation) Bit 1: OBJ interlace + # (0=normal, 1=split sprite rows across fields) Bit 2: + # Overscan (0=224 visible lines, 1=239 visible + # lines) Bit 3: Pseudo-hires (0=256-wide, + # 1=512-wide via subscreen half-pixel offset) Bits 4-5: + # unused Bit 6: EXTBG (Mode 7 only; enables + # BG2 as a second Mode 7 layer) Bit 7: External sync + # (genlock to external video; no effect in emulation) self.ppu.m7_extbg = (data >> 6) & 1 return @@ -564,11 +629,15 @@ def write(self, abs_addr: int, data: int) -> None: return # Not writable if 0x2140 <= addr <= 0x2143: # APUIO0-APUIO3 (CPU→SPC ports) - self.apu.sync_to(self.scheduler.master_clock + (self.cpu.cycles - self.cpu.prev_cycles)) + self.apu.sync_to( + self.scheduler.master_clock + + (self.cpu.cycles - self.cpu.prev_cycles) + ) self.apu.ports_r[addr - 0x2140] = data return - if addr == 0x2180: # WMDATA - write byte to WRAM at WMADD, increment + # WMDATA - write byte to WRAM at WMADD, increment + if addr == 0x2180: wm_addr = self._wmadd & 0x1FFFF if wm_addr < 0x2000: self.low_ram[wm_addr] = data @@ -607,7 +676,9 @@ def write(self, abs_addr: int, data: int) -> None: self.dma_ppu2_hw_registers[0x4214 - 0x4200] = 0 self.dma_ppu2_hw_registers[0x4215 - 0x4200] = 0 self.dma_ppu2_hw_registers[0x4216 - 0x4200] = product & 0xFF - self.dma_ppu2_hw_registers[0x4217 - 0x4200] = (product >> 8) & 0xFF + self.dma_ppu2_hw_registers[0x4217 - 0x4200] = ( + product >> 8 + ) & 0xFF return elif addr == 0x4204: # WRDIVL - dividend low byte @@ -629,9 +700,13 @@ def write(self, abs_addr: int, data: int) -> None: quotient = self._wrdiv // data remainder = self._wrdiv % data self.dma_ppu2_hw_registers[0x4214 - 0x4200] = quotient & 0xFF - self.dma_ppu2_hw_registers[0x4215 - 0x4200] = (quotient >> 8) & 0xFF + self.dma_ppu2_hw_registers[0x4215 - 0x4200] = ( + quotient >> 8 + ) & 0xFF self.dma_ppu2_hw_registers[0x4216 - 0x4200] = remainder & 0xFF - self.dma_ppu2_hw_registers[0x4217 - 0x4200] = (remainder >> 8) & 0xFF + self.dma_ppu2_hw_registers[0x4217 - 0x4200] = ( + remainder >> 8 + ) & 0xFF return elif addr == 0x420B: # MDMAEN @@ -652,31 +727,42 @@ def write(self, abs_addr: int, data: int) -> None: self.cpu.status.hirq_enable = bool(data & 0x10) self.cpu.status.virq_enable = bool(data & 0x20) self.cpu.status.irq_enable = ( - self.cpu.status.hirq_enable or self.cpu.status.virq_enable + self.cpu.status.hirq_enable + or self.cpu.status.virq_enable ) - # Disabling both H-IRQ and V-IRQ clears any latched IRQ flag. + # Disabling both H-IRQ and V-IRQ clears any latched IRQ + # flag. if not self.cpu.status.irq_enable: self.cpu.status.irq_line = False - # If NMI is being enabled while V-Blank line is already high, trigger immediately. - # nmi_enable must be set first so nmi_rising_edge() sees it as True. + # If NMI is being enabled while V-Blank line is already + # high, trigger immediately. nmi_enable must be set first so + # nmi_rising_edge() sees it as True. was_enabled = self.cpu.status.nmi_enable self.cpu.status.nmi_enable = bool(data & 0x80) - if data & 0x80: - if not was_enabled and self.cpu.status.nmi_line: - self.cpu.nmi_rising_edge() + nmi_now_enabled = data & 0x80 and not was_enabled + if nmi_now_enabled and self.cpu.status.nmi_line: + self.cpu.nmi_rising_edge() return if addr == 0x4207: # HTIMEL - self.cpu.status.htime = (self.cpu.status.htime & 0x100) | data + self.cpu.status.htime = ( + self.cpu.status.htime & 0x100 + ) | data return if addr == 0x4208: # HTIMEH (only bit 0) - self.cpu.status.htime = (self.cpu.status.htime & 0x0FF) | ((data & 0x01) << 8) + self.cpu.status.htime = (self.cpu.status.htime & 0x0FF) | ( + (data & 0x01) << 8 + ) return if addr == 0x4209: # VTIMEL - self.cpu.status.vtime = (self.cpu.status.vtime & 0x100) | data + self.cpu.status.vtime = ( + self.cpu.status.vtime & 0x100 + ) | data return if addr == 0x420A: # VTIMEH (only bit 0) - self.cpu.status.vtime = (self.cpu.status.vtime & 0x0FF) | ((data & 0x01) << 8) + self.cpu.status.vtime = (self.cpu.status.vtime & 0x0FF) | ( + (data & 0x01) << 8 + ) return if 0x4300 <= addr <= 0x43FF: @@ -689,15 +775,20 @@ def write(self, abs_addr: int, data: int) -> None: # TODO: Implement true open-bus side effects/MDR behavior instead # of silently dropping writes in these known system-area holes. if ( - 0x2000 <= addr <= 0x21FF # 0x2100-0x21FF: mapped regs handled above + 0x2000 + <= addr + <= 0x21FF # 0x2100-0x21FF: mapped regs handled above or 0x2200 <= addr <= 0x3FFF - or 0x4000 <= addr <= 0x41FF # 0x4016/0x4017 already handled above + or 0x4000 + <= addr + <= 0x41FF # 0x4016/0x4017 already handled above or 0x4500 <= addr <= 0x7FFF ): return raise NotImplementedError( - f"Writting unmapped memory region: 0x{abs_addr:06X} = 0x{data:02X}" + f"Writting unmapped memory region: 0x{abs_addr:06X} = " + f"0x{data:02X}" ) if 0x7E2000 <= abs_addr <= 0x7E7FFF: @@ -711,4 +802,3 @@ def write(self, abs_addr: int, data: int) -> None: raise NotImplementedError( f"Writting unmapped memory region: 0x{abs_addr:06X} = 0x{data:02X}" ) - diff --git a/pysnes/bus/test_bus.py b/pysnes/bus/test_bus.py index 1a7651a..452933f 100644 --- a/pysnes/bus/test_bus.py +++ b/pysnes/bus/test_bus.py @@ -8,16 +8,13 @@ from types import SimpleNamespace -import pytest - -from ..scheduler import Scheduler -from .bus import Bus -from ..cpu import Cpu from ..apu import Apu -from ..ppu import Ppu from ..controller import Controller +from ..cpu import Cpu +from ..ppu import Ppu from ..rom import HardwareVectors, InterruptVectors, MappingMode - +from ..scheduler import Scheduler +from .bus import Bus # --------------------------------------------------------------------------- # Helpers @@ -29,16 +26,30 @@ class StubRom: """Minimal ROM stub with a writable bytearray backing store.""" - def __init__(self, size=ROM_SIZE, sram_size=0, mapping_mode=MappingMode.LOROM): + def __init__( + self, size=ROM_SIZE, sram_size=0, mapping_mode=MappingMode.LOROM + ): self.rom = bytearray(size) self.sram_size = sram_size self.snes_header = SimpleNamespace(mapping_mode=mapping_mode) # Populate reset vector so Cpu() doesn't choke self.hardware_vectors = HardwareVectors( - native=InterruptVectors(cop=0x8000, brk=0x8000, abort=0x8000, - nmi=0x8000, reset=0, irq=0x8000), - emulation=InterruptVectors(cop=0x8000, brk=0, abort=0x8000, - nmi=0x8000, reset=0x8000, irq=0x8000), + native=InterruptVectors( + cop=0x8000, + brk=0x8000, + abort=0x8000, + nmi=0x8000, + reset=0, + irq=0x8000, + ), + emulation=InterruptVectors( + cop=0x8000, + brk=0, + abort=0x8000, + nmi=0x8000, + reset=0x8000, + irq=0x8000, + ), ) def read(self, addr): @@ -64,6 +75,7 @@ def make_bus(sram_size=0, mapping_mode=MappingMode.LOROM): # Low RAM ($0000–$1FFF, mirrored from bank $7E) # --------------------------------------------------------------------------- + def test_low_ram_write_read_bank00(): bus, *_ = make_bus() bus.write(0x000100, 0xAB) @@ -94,6 +106,7 @@ def test_low_ram_boundary_last_byte(): # High RAM ($7E2000–$7E7FFF) # --------------------------------------------------------------------------- + def test_high_ram_write_read(): bus, *_ = make_bus() bus.write(0x7E2000, 0x11) @@ -110,6 +123,7 @@ def test_high_ram_boundary(): # Extended RAM ($7E8000–$7FFFFF) # --------------------------------------------------------------------------- + def test_extended_ram_write_read(): bus, *_ = make_bus() bus.write(0x7E8000, 0x55) @@ -126,9 +140,10 @@ def test_extended_ram_boundary(): # LoROM (bank $00, $8000–$FFFF → ROM offset 0x0000–0x7FFF) # --------------------------------------------------------------------------- + def test_lorom_read_bank00(): bus, rom, *_ = make_bus() - rom.rom[0x0010] = 0xBE # ROM offset 0x0010 == bus addr 0x008010 + rom.rom[0x0010] = 0xBE # ROM offset 0x0010 == bus addr 0x008010 assert bus.read(0x008010) == 0xBE @@ -148,7 +163,8 @@ def test_lorom_mirror_high_bank(): def test_lorom_write_to_rom_region_stored(): - """Writes to ROM-mapped addresses are silently dropped (ROM is read-only on hardware).""" + """Writes to ROM-mapped addresses are silently dropped (ROM is read-only on + hardware).""" bus, rom, *_ = make_bus() bus.write(0x008100, 0x7F) assert rom.rom[0x0100] == 0 # write ignored; ROM unchanged @@ -158,6 +174,7 @@ def test_lorom_write_to_rom_region_stored(): # APU I/O ports ($2140–$2143) # --------------------------------------------------------------------------- + def test_apu_port_read_returns_ports_w(): bus, _, _, apu, _ = make_bus() apu.ports_w[0] = 0xAA @@ -183,6 +200,7 @@ def test_apu_port_write_all_four(): # PPU registers (spot-checks) # --------------------------------------------------------------------------- + def test_ppu_inidisp_write(): bus, _, _, _, ppu = make_bus() bus.write(0x002100, 0x0F) # display enable, max brightness @@ -199,7 +217,8 @@ def test_ppu_bgmode_write(): # NMITIMEN / RDNMI ($4200 / $4210) # --------------------------------------------------------------------------- -def test_nmitimen_enables_nmi(make_bus=make_bus): + +def test_nmitimen_enables_nmi(): bus, _, cpu, *_ = make_bus() bus.write(0x004200, 0x80) # bit 7 = NMI enable assert cpu.status.nmi_enable @@ -208,7 +227,7 @@ def test_nmitimen_enables_nmi(make_bus=make_bus): def test_rdnmi_clears_nmi_line(): bus, _, cpu, *_ = make_bus() cpu.status.nmi_line = True - bus.read(0x004210) # reading RDNMI clears the line + bus.read(0x004210) # reading RDNMI clears the line assert not cpu.status.nmi_line @@ -216,6 +235,7 @@ def test_rdnmi_clears_nmi_line(): # HVBJOY ($4212) # --------------------------------------------------------------------------- + def test_hvbjoy_vblank_bit(): bus, *_ = make_bus() bus.vblank = True @@ -247,16 +267,17 @@ def test_hvbjoy_no_blanks(): # which crashes CPU opcode fetches that land in the register region. # --------------------------------------------------------------------------- + def test_ophct_read_is_byte_sized(): bus, *_, ppu = make_bus() - ppu.h_counter = 274 # dot count at H-blank start + ppu.h_counter = 274 # dot count at H-blank start val = bus.read(0x00213C) assert 0 <= val <= 0xFF def test_opvct_read_is_byte_sized(): bus, *_, ppu = make_bus() - ppu.v_counter = 261 # last scanline in a non-interlace frame + ppu.v_counter = 261 # last scanline in a non-interlace frame val = bus.read(0x00213D) assert 0 <= val <= 0xFF @@ -265,6 +286,7 @@ def test_opvct_read_is_byte_sized(): # Low RAM boundary — first byte # --------------------------------------------------------------------------- + def test_low_ram_boundary_first_byte(): bus, *_ = make_bus() bus.write(0x000000, 0x01) @@ -275,20 +297,23 @@ def test_low_ram_boundary_first_byte(): # LoROM Region 2 — banks $40–$6F, full address range ($0000–$FFFF) # --------------------------------------------------------------------------- + def test_lorom_region2_read(): """Banks $40–$6F expose the full 64KB: low half is ROM too.""" bus, rom, *_ = make_bus() - # Bank $40, addr $0010 → ROM offset: 0x40 * 0x8000 + 0x0010 = 0x200010 - # But ROM is only 512 KB (0x80000), so use a small bank number inside range - # Bank $40 = 64 decimal; offset = 64 * 0x8000 + 0x0010 = 0x200010 — beyond 512KB - # Use bank $40 with addr >= 0x8000 to stay in region 1 overlap, OR use addr < 0x8000 - # For region 2 specifically (addr 0x0000-0x7FFF in banks 0x40-0x6F): - # rom_addr = bank * 0x8000 + addr (addr < 0x8000, so no subtraction) - # Bank $40=64, addr $0020 → rom_addr = 64*0x8000 + 0x0020 = 0x200020 (too big) - # Use bank $40 but stub ROM is 512KB=0x80000; 0x200020 > 0x80000 → returns 0 - # Instead use bank $40 addr $0000 with small value to test routing (not OOB crash) + # Bank $40, addr $0010 → ROM offset: 0x40 * 0x8000 + 0x0010 = 0x200010 But + # ROM is only 512 KB (0x80000), so use a small bank number inside range Bank + # $40 = 64 decimal; offset = 64 * 0x8000 + 0x0010 = 0x200010 — beyond 512KB + # Use bank $40 with addr >= 0x8000 to stay in region 1 overlap, OR use addr + # < 0x8000 For region 2 specifically (addr 0x0000-0x7FFF in banks + # 0x40-0x6F): rom_addr = bank * 0x8000 + addr (addr < 0x8000, so no + # subtraction) Bank $40=64, addr $0020 → rom_addr = 64*0x8000 + 0x0020 = + # 0x200020 (too big) Use bank $40 but stub ROM is 512KB=0x80000; 0x200020 > + # 0x80000 → returns 0 Instead use bank $40 addr $0000 with small value to + # test routing (not OOB crash) rom.rom[0] = 0 # ensure clean - val = bus.read(0x400000) # should not crash; ROM is 512KB so addr 0x200000 is OOB → 0 + # should not crash; ROM is 512KB so addr 0x200000 is OOB → 0 + val = bus.read(0x400000) assert val == 0 # StubRom returns 0 for OOB — routing reached ROM, no crash @@ -297,19 +322,22 @@ def test_lorom_region2_boundary_bank40(): bus, rom, *_ = make_bus() # Confirm it doesn't hit low_ram (which would be bank 0x00-0x3F only) bus.write(0x000100, 0xAB) # write low_ram via bank $00 - val = bus.read(0x400100) # read via bank $40 addr $0100 — region 2, goes to ROM - assert val != 0xAB # must NOT return the low_ram value + # read via bank $40 addr $0100 — region 2, goes to ROM + val = bus.read(0x400100) + assert val != 0xAB # must NOT return the low_ram value # --------------------------------------------------------------------------- # LoROM Region 3 — banks $70–$7D, addr $8000–$FFFF # --------------------------------------------------------------------------- + def test_lorom_region3_read(): """Banks $70–$7D, high half map to ROM.""" bus, rom, *_ = make_bus() - # bank $70=112, addr $8010 → rom_addr = 112*0x8000 + (0x8010-0x8000) = 0x380010 - # 0x380010 > 512KB → OOB, StubRom returns 0; test just confirms routing/no crash + # bank $70=112, addr $8010 → rom_addr = 112*0x8000 + (0x8010-0x8000) = + # 0x380010 0x380010 > 512KB → OOB, StubRom returns 0; test just confirms + # routing/no crash val = bus.read(0x708010) assert val == 0 @@ -318,7 +346,7 @@ def test_lorom_region3_not_ram(): """Bank $70 addr $8000 must not hit low_ram or high_ram.""" bus, rom, *_ = make_bus() bus.write(0x7E0100, 0xCC) # write low_ram via bank $7E - val = bus.read(0x708100) # bank $70, addr $8100 → LoROM region 3 + val = bus.read(0x708100) # bank $70, addr $8100 → LoROM region 3 assert val != 0xCC @@ -326,8 +354,10 @@ def test_lorom_region3_not_ram(): # LoROM mirror write (banks $80–$FD → mirrors $00–$7D) # --------------------------------------------------------------------------- + def test_lorom_mirror_write(): - """Write through a mirrored bank ($80+) is silently dropped (ROM is read-only on hardware).""" + """Write through a mirrored bank ($80+) is silently dropped (ROM is + read-only on hardware).""" bus, rom, *_ = make_bus() bus.write(0x808010, 0x55) # bank $80 mirrors bank $00 assert rom.rom[0x0010] == 0 # write ignored; ROM unchanged @@ -337,20 +367,23 @@ def test_lorom_mirror_write(): # Controller — JOYSER0 write ($4016) latches both ports # --------------------------------------------------------------------------- + def test_joyser0_write_latches_controller(): """Writing 1 then 0 to JOYSER0 latches the controller shift register.""" bus, *_ = make_bus() bus.write(0x004016, 0x01) # latch high bus.write(0x004016, 0x00) # latch low — loads shift register - # After latch cycle, reading data() should return 1s (no keys pressed → padding) - val = bus.read(0x004016) # JOYSER0 read - assert val in range(0, 0x100) # sanity: valid byte returned + # After latch cycle, reading data() should return 1s (no keys pressed → + # padding) + val = bus.read(0x004016) # JOYSER0 read + assert val in range(0x100) # sanity: valid byte returned # --------------------------------------------------------------------------- # Joy registers — JOY1L/1H/2L/2H ($4218–$421B) # --------------------------------------------------------------------------- + def test_joy1l_read(): bus, _, _, _, _ = make_bus() bus.controller_port1.joy_l = 0xAB @@ -380,6 +413,7 @@ def test_joy2h_read(): # (the register is write-only; reads fall through to the bytearray) # --------------------------------------------------------------------------- + def test_nmitimen_read_fallthrough(): """Reading $4200 returns the value stored in dma_ppu2_hw_registers.""" bus, *_ = make_bus() @@ -391,6 +425,7 @@ def test_nmitimen_read_fallthrough(): # MEMSEL ($420D) — FastROM speed select (bit 0) # --------------------------------------------------------------------------- + def test_memsel_fastrom_off_by_default(): """Banks $80-$BF/$C0-$FF use slow (8 MC) by default.""" bus, _rom, cpu, *_ = make_bus() @@ -399,7 +434,8 @@ def test_memsel_fastrom_off_by_default(): def test_memsel_fastrom_on_sets_fast_speed(): - """Writing 1 to $420D enables FastROM: banks $80-$BF/$C0-$FF use fast (6 MC).""" + """Writing 1 to $420D enables FastROM: banks $80-$BF/$C0-$FF use fast (6 + MC).""" bus, _rom, cpu, *_ = make_bus() bus.write(0x00420D, 0x01) # MEMSEL: enable FastROM assert cpu.get_clock_cycles(0xC00000) == 6 @@ -431,6 +467,7 @@ def test_memsel_fastrom_toggle(): # SRAM (LoROM) — banks $70-$7D, addr $0000-$7FFF (mirrored at $F0-$FD) # --------------------------------------------------------------------------- + def test_sram_basic_write_read(): """Bank $70 $0000 writes and reads through SRAM.""" bus, *_ = make_bus(sram_size=0x2000) # 8KB @@ -511,14 +548,14 @@ def test_sram_save_noop_after_save(tmp_path): bus.write(0x700000, 0xAB) assert bus.save_sram(str(path)) == 0x2000 mtime = path.stat().st_mtime_ns - assert bus.save_sram(str(path)) == 0 # nothing dirty - assert path.stat().st_mtime_ns == mtime # file untouched + assert bus.save_sram(str(path)) == 0 # nothing dirty + assert path.stat().st_mtime_ns == mtime # file untouched def test_sram_load_then_save_is_noop(tmp_path): """Loading SRAM doesn't make it dirty — a subsequent save does nothing.""" path = tmp_path / "game.srm" - path.write_bytes(b"\xAA" * 0x2000) + path.write_bytes(b"\xaa" * 0x2000) bus, *_ = make_bus(sram_size=0x2000) assert bus.load_sram(str(path)) == 0x2000 # Save to a different path to make the no-op check unambiguous @@ -576,7 +613,8 @@ def test_sram_load_missing_file_noop(tmp_path): def test_sram_load_smaller_file_preserves_tail(tmp_path): - """Loading a file smaller than sram_size only overwrites the leading bytes.""" + """Loading a file smaller than sram_size only overwrites the leading + bytes.""" path = tmp_path / "partial.srm" path.write_bytes(b"\x11\x22") bus, *_ = make_bus(sram_size=0x800) @@ -589,19 +627,21 @@ def test_sram_load_smaller_file_preserves_tail(tmp_path): def test_sram_upper_half_still_rom(): - """Bank $70 $8000-$FFFF is ROM, not SRAM — reads must not return SRAM data.""" + """Bank $70 $8000-$FFFF is ROM, not SRAM — reads must not return SRAM + data.""" bus, rom, *_ = make_bus(sram_size=0x2000) bus.write(0x700000, 0xCC) # SRAM at offset 0 # Bank $00 $8000 -> ROM offset 0; set it so we can verify $70 $8000 hits ROM # (bank $70 $8000 -> ROM offset 0x380000 which is OOB on 512KB StubRom -> 0) - assert bus.read(0x708000) != 0xCC # not SRAM - assert bus.read(0x708000) == 0 # OOB ROM read returns 0 from StubRom + assert bus.read(0x708000) != 0xCC # not SRAM + assert bus.read(0x708000) == 0 # OOB ROM read returns 0 from StubRom # --------------------------------------------------------------------------- # RDVRAML / RDVRAMH ($2139 / $213A) bus dispatch # --------------------------------------------------------------------------- + def test_rdvraml_routes_to_ppu(): """$2139 returns the low byte of the PPU's VRAM prefetch buffer.""" bus, _, _, _, ppu = make_bus() @@ -628,14 +668,17 @@ def test_rdvramh_routes_to_ppu(): # Open-bus behavior for known system-area holes # --------------------------------------------------------------------------- + def test_open_bus_unmapped_2000_range(): - """Reads in $2000-$20FF return open bus, approximated by the address high byte.""" + """Reads in $2000-$20FF return open bus, approximated by the address high + byte.""" bus, *_ = make_bus() assert bus.read(0x0020F1) == 0x20 def test_open_bus_unmapped_2200_range(): - """Reads in $2200-$3FFF return open bus, approximated by the address high byte.""" + """Reads in $2200-$3FFF return open bus, approximated by the address high + byte.""" bus, *_ = make_bus() assert bus.read(0x0027A8) == 0x27 @@ -651,6 +694,7 @@ def test_open_bus_unmapped_mirrored_bank(): # WRAM mirror: banks $FE-$FF shadow $7E-$7F # --------------------------------------------------------------------------- + def test_wram_mirror_bank_fe_low_ram(): """Bank $FE addr $0000-$1FFF mirrors Low RAM.""" bus, *_ = make_bus() @@ -676,6 +720,7 @@ def test_wram_mirror_bank_ff_extended_ram(): # Open-bus writes for known system-area holes # --------------------------------------------------------------------------- + def test_write_unmapped_2000_range_is_dropped(): """Writes to $2000-$20FF use the temporary open-bus fallback.""" bus, *_ = make_bus() @@ -699,6 +744,7 @@ def test_write_unmapped_stack_region_dropped(): # (mirror $80-$BF). Formula: rom_addr = (bank & 0x3F) << 16 | addr. # --------------------------------------------------------------------------- + def test_hirom_read_bank_c0_low_half(): """Bank $C0 addr $0000 → rom_addr 0x000000 (first byte of ROM image).""" bus, rom, *_ = make_bus(mapping_mode=MappingMode.HIROM) @@ -723,7 +769,8 @@ def test_hirom_read_bank_c1_spans_full_64kb(): def test_hirom_read_bank_00_upper_half(): - """Bank $00 addr $8000 is the upper half of HiROM bank $C0 → rom_addr 0x008000.""" + """Bank $00 addr $8000 is the upper half of HiROM bank $C0 → rom_addr + 0x008000.""" bus, rom, *_ = make_bus(mapping_mode=MappingMode.HIROM) rom.rom[0x008000] = 0xCD assert bus.read(0x008000) == 0xCD @@ -751,14 +798,16 @@ def test_hirom_read_bank_80_mirrors_bank_00_upper_half(): def test_hirom_write_to_rom_region(): - """Writes to HiROM-mapped addresses are silently dropped (ROM is read-only on hardware).""" + """Writes to HiROM-mapped addresses are silently dropped (ROM is read-only + on hardware).""" bus, rom, *_ = make_bus(mapping_mode=MappingMode.HIROM) bus.write(0xC00001, 0x7F) assert rom.rom[0x000001] == 0 # write ignored; ROM unchanged def test_hirom_bank_00_low_half_is_lowram_not_rom(): - """HiROM banks $00-$3F below $8000 are system area (LowRAM / I/O), NOT ROM.""" + """HiROM banks $00-$3F below $8000 are system area (LowRAM / I/O), NOT + ROM.""" bus, rom, *_ = make_bus(mapping_mode=MappingMode.HIROM) bus.write(0x000100, 0xFA) # Must go to low_ram, not rom.rom @@ -780,6 +829,7 @@ def test_hirom_bank_40_full_not_lowram(): # HiROM SRAM — banks $20-$3F / $A0-$BF at $6000-$7FFF, 8KB window per bank # --------------------------------------------------------------------------- + def test_hirom_sram_bank_20(): """HiROM SRAM: bank $20 addr $6000 → SRAM offset 0.""" bus, *_ = make_bus(sram_size=0x2000, mapping_mode=MappingMode.HIROM) @@ -847,15 +897,17 @@ def test_hirom_bank_00_hw_registers_still_work(): # Math hardware ($4202-$4206 write, $4214-$4217 read) # --------------------------------------------------------------------------- + def test_multiply_basic(): - """Writing WRMPYB ($4203) triggers 8x8 unsigned multiply; result in $4216-$4217.""" + """Writing WRMPYB ($4203) triggers 8x8 unsigned multiply; result in + $4216-$4217.""" bus, *_ = make_bus() bus.write(0x004202, 5) # WRMPYA = 5 bus.write(0x004203, 3) # WRMPYB = 3 → product = 15 - assert bus.read(0x004216) == 15 # RDMPYL low byte - assert bus.read(0x004217) == 0 # RDMPYH high byte - assert bus.read(0x004214) == 0 # RDDIVL cleared after multiply - assert bus.read(0x004215) == 0 # RDDIVH cleared after multiply + assert bus.read(0x004216) == 15 # RDMPYL low byte + assert bus.read(0x004217) == 0 # RDMPYH high byte + assert bus.read(0x004214) == 0 # RDDIVL cleared after multiply + assert bus.read(0x004215) == 0 # RDDIVH cleared after multiply def test_multiply_overflow(): @@ -863,20 +915,21 @@ def test_multiply_overflow(): bus, *_ = make_bus() bus.write(0x004202, 255) bus.write(0x004203, 255) - assert bus.read(0x004216) == 0x01 # low byte - assert bus.read(0x004217) == 0xFE # high byte + assert bus.read(0x004216) == 0x01 # low byte + assert bus.read(0x004217) == 0xFE # high byte def test_divide_basic(): - """Writing WRDIVB ($4206) triggers 16÷8 unsigned divide; quotient in $4214-$4215, remainder in $4216-$4217.""" + """Writing WRDIVB ($4206) triggers 16÷8 unsigned divide; quotient in + $4214-$4215, remainder in $4216-$4217.""" bus, *_ = make_bus() bus.write(0x004204, 100 & 0xFF) # WRDIVL low byte of 100 bus.write(0x004205, 100 >> 8) # WRDIVH high byte of 100 bus.write(0x004206, 7) # WRDIVB = 7 → 100 / 7 = 14 rem 2 - assert bus.read(0x004214) == 14 # RDDIVL quotient low - assert bus.read(0x004215) == 0 # RDDIVH quotient high - assert bus.read(0x004216) == 2 # RDMPYL remainder low - assert bus.read(0x004217) == 0 # RDMPYH remainder high + assert bus.read(0x004214) == 14 # RDDIVL quotient low + assert bus.read(0x004215) == 0 # RDDIVH quotient high + assert bus.read(0x004216) == 2 # RDMPYL remainder low + assert bus.read(0x004217) == 0 # RDMPYH remainder high def test_divide_high_dividend(): diff --git a/pysnes/bus/test_interrupts.py b/pysnes/bus/test_interrupts.py index e1dac3d..a370f3e 100644 --- a/pysnes/bus/test_interrupts.py +++ b/pysnes/bus/test_interrupts.py @@ -10,17 +10,15 @@ - IRQ enable flags stored correctly """ -import pytest +from types import SimpleNamespace -from ..scheduler import Scheduler -from .bus import Bus -from ..cpu import Cpu from ..apu import Apu -from ..ppu import Ppu from ..controller import Controller +from ..cpu import Cpu +from ..ppu import Ppu from ..rom import HardwareVectors, InterruptVectors, MappingMode -from types import SimpleNamespace - +from ..scheduler import Scheduler +from .bus import Bus # --------------------------------------------------------------------------- # Helpers (shared with other test modules) @@ -34,10 +32,22 @@ def __init__(self, size=ROM_SIZE): self.rom = bytearray(size) self.snes_header = SimpleNamespace(mapping_mode=MappingMode.LOROM) self.hardware_vectors = HardwareVectors( - native=InterruptVectors(cop=0x8000, brk=0x8000, abort=0x8000, - nmi=0x8000, reset=0, irq=0x8000), - emulation=InterruptVectors(cop=0x8000, brk=0, abort=0x8000, - nmi=0x8000, reset=0x8000, irq=0x8000), + native=InterruptVectors( + cop=0x8000, + brk=0x8000, + abort=0x8000, + nmi=0x8000, + reset=0, + irq=0x8000, + ), + emulation=InterruptVectors( + cop=0x8000, + brk=0, + abort=0x8000, + nmi=0x8000, + reset=0x8000, + irq=0x8000, + ), ) def read(self, addr): @@ -63,6 +73,7 @@ def make_bus(): # NMITIMEN ($4200) — NMI enable / disable # --------------------------------------------------------------------------- + def test_nmitimen_enables_nmi(): bus, cpu = make_bus() bus.write(0x004200, 0x80) @@ -113,6 +124,7 @@ def test_nmitimen_auto_joypad_read_enable(): # NMI rising-edge behaviour # --------------------------------------------------------------------------- + def test_nmi_rising_edge_sets_pending_when_enabled(): bus, cpu = make_bus() bus.write(0x004200, 0x80) # enable NMI @@ -128,7 +140,8 @@ def test_nmi_rising_edge_does_not_set_pending_when_disabled(): def test_nmi_enable_with_line_already_high_triggers_immediately(): - """Enabling NMI while nmi_line is already asserted triggers a rising edge.""" + """Enabling NMI while nmi_line is already asserted triggers a rising + edge.""" bus, cpu = make_bus() cpu.status.nmi_line = True # Writing 0x80 to NMITIMEN while nmi_line is high should trigger immediately @@ -166,6 +179,7 @@ def test_lower_nmi_does_not_clear_nmi_line(): # RDNMI ($4210) # --------------------------------------------------------------------------- + def test_rdnmi_read_clears_nmi_line(): bus, cpu = make_bus() cpu.status.nmi_line = True @@ -198,12 +212,13 @@ def test_rdnmi_bit7_persists_through_vblank_end(): """Per SNES spec, $4210 bit 7 is set at V-Blank start and cleared ONLY by a read of $4210. V-Blank end does NOT auto-clear it.""" bus, cpu = make_bus() - bus.raise_nmi() # simulate V-Blank start + bus.raise_nmi() # simulate V-Blank start assert cpu.status.nmi_line is True - bus.lower_nmi() # simulate V-Blank end + bus.lower_nmi() # simulate V-Blank end # Hardware: bit 7 should still be set until the game reads $4210. assert cpu.status.nmi_line is True, ( - "RDNMI bit 7 should persist through V-Blank end; only a $4210 read clears it" + "RDNMI bit 7 should persist through V-Blank end; only a $4210 read " + "clears it" ) # Now reading $4210 clears it. _ = bus.read(0x004210) @@ -214,6 +229,7 @@ def test_rdnmi_bit7_persists_through_vblank_end(): # TIMEUP / IRQ state # --------------------------------------------------------------------------- + def test_irq_flags_independent_of_nmi(): """Setting HIRQ enable must not disturb NMI enable and vice versa.""" bus, cpu = make_bus() @@ -232,29 +248,32 @@ def test_irq_flags_independent_of_nmi(): # interrupt() cycle counting # --------------------------------------------------------------------------- + def test_interrupt_advances_cycles_native_mode(): """interrupt() must count bus cycles (not return a flat 1 MC).""" bus, cpu = make_bus() - cpu.EF = False # native mode: 8 bus cycles (2 idle + push PBR/PCH/PCL/P + 2 vector reads) + # native mode: 8 bus cycles (2 idle + push PBR/PCH/PCL/P + 2 vector reads) + cpu.EF = False cpu.S.w = 0x01FF cycles_before = cpu.cycles cpu.interrupt(0xFFEA) # native NMI vector elapsed_mc = cpu.cycles - cycles_before - # 2 idles (6 MC each) + 4 writes to stack in slow RAM (8 MC each) + 2 reads from ROM $FFxx (8 MC each) - # = 12 + 32 + 16 = 60 MC + # 2 idles (6 MC each) + 4 writes to stack in slow RAM (8 MC each) + 2 reads + # from ROM $FFxx (8 MC each) = 12 + 32 + 16 = 60 MC assert elapsed_mc == 60, f"Expected 60 MC, got {elapsed_mc}" def test_interrupt_advances_cycles_emulation_mode(): """Emulation mode skips PBR push: 7 bus cycles total.""" bus, cpu = make_bus() - cpu.EF = True # emulation mode: 7 bus cycles (2 idle + push PCH/PCL/P + 2 vector reads) + # emulation mode: 7 bus cycles (2 idle + push PCH/PCL/P + 2 vector reads) + cpu.EF = True cpu.S.w = 0x01FF cycles_before = cpu.cycles cpu.interrupt(0xFFFA) # emulation NMI vector elapsed_mc = cpu.cycles - cycles_before - # 2 idles (6 MC each) + 3 writes to stack in slow RAM (8 MC each) + 2 reads from ROM $FFxx (8 MC each) - # = 12 + 24 + 16 = 52 MC + # 2 idles (6 MC each) + 3 writes to stack in slow RAM (8 MC each) + 2 reads + # from ROM $FFxx (8 MC each) = 12 + 24 + 16 = 52 MC assert elapsed_mc == 52, f"Expected 52 MC, got {elapsed_mc}" @@ -279,14 +298,19 @@ def test_interrupt_resets_pb_to_zero_native(): cpu.PC.b = 0x04 cpu.PC.w = 0xDD85 cpu.S.w = 0x01FF - # Place vector low/high at ROM offset $7FEA-$7FEB (LoROM map of $00:FFEA-FFEB). + # Place vector low/high at ROM offset $7FEA-$7FEB (LoROM map of + # $00:FFEA-FFEB). bus.rom.rom[0x7FEA] = 0x6A bus.rom.rom[0x7FEB] = 0x81 cpu.interrupt(0xFFEA) - assert cpu.PC.b == 0x00, f"PB must be 0 after interrupt, got ${cpu.PC.b:02X}" + assert cpu.PC.b == 0x00, ( + f"PB must be 0 after interrupt, got ${cpu.PC.b:02X}" + ) assert cpu.PC.w == 0x816A # The original PBR ($04) must have been pushed before being cleared. - assert bus.read(0x0001FF) == 0x04, f"PBR not pushed; stack top = ${bus.read(0x0001FF):02X}" + assert bus.read(0x0001FF) == 0x04, ( + f"PBR not pushed; stack top = ${bus.read(0x0001FF):02X}" + ) def test_interrupt_pb_is_zero_in_emulation_mode(): @@ -307,6 +331,7 @@ def test_interrupt_pb_is_zero_in_emulation_mode(): # H/V IRQ — $4207-$420A target registers # --------------------------------------------------------------------------- + def test_htime_low_write(): bus, cpu = make_bus() bus.write(0x004208, 0x00) # clear high bit first @@ -346,6 +371,7 @@ def test_vtime_high_write_only_bit0(): # $4211 TIMEUP — read-and-clear IRQ flag # --------------------------------------------------------------------------- + def test_timeup_read_clears_irq_line(): bus, cpu = make_bus() cpu.status.irq_line = True @@ -371,6 +397,7 @@ def test_timeup_bit7_clear_when_line_low(): # IRQ dispatch in CPU._step # --------------------------------------------------------------------------- + def test_irq_pending_fires_interrupt_when_i_flag_clear(): """_step() must fire IRQ when irq_line is high and IFlag is clear.""" bus, cpu = make_bus() @@ -432,13 +459,6 @@ def test_nmi_takes_priority_over_irq(): # PPU → bus IRQ trigger at H/V match # --------------------------------------------------------------------------- -def _make_bus_with_ppu(): - """Variant of make_bus that also starts the PPU event loop.""" - bus, cpu = make_bus() - bus.ppu.start() - cpu.start(bus.scheduler) - return bus, cpu - def test_vrq_fires_at_vtime_scanline(): """V-only IRQ: raise line when v_counter reaches VTIME.""" diff --git a/pysnes/conftest.py b/pysnes/conftest.py index a20e8df..01f734c 100644 --- a/pysnes/conftest.py +++ b/pysnes/conftest.py @@ -13,7 +13,10 @@ def pytest_addoption(parser): default=100_000, type=int, metavar="N", - help="Number of CPU instructions for instruction-level trace (default: 100000)", + help=( + "Number of CPU instructions for instruction-level trace " + "(default: 100000)" + ), ) parser.addoption( "--no-cache", @@ -26,7 +29,10 @@ def pytest_addoption(parser): action="store", default=None, metavar="HEX", - help="Run only tests for the given opcode prefix, e.g. --opcode 29 or --opcode ea", + help=( + "Run only tests for the given opcode prefix, " + "e.g. --opcode 29 or --opcode ea" + ), ) parser.addoption( "--max-per-opcode", @@ -34,7 +40,10 @@ def pytest_addoption(parser): default=None, type=int, metavar="N", - help="Limit to N test cases per opcode variant (default: 1; 0 = unlimited)", + help=( + "Limit to N test cases per opcode variant " + "(default: 1; 0 = unlimited)" + ), ) parser.addoption( "--mode", diff --git a/pysnes/controller/controller.py b/pysnes/controller/controller.py index 1f76d49..e003055 100644 --- a/pysnes/controller/controller.py +++ b/pysnes/controller/controller.py @@ -4,7 +4,7 @@ class Controller: def __init__(self, *, disabled: bool = False) -> None: self.pressed_keys = set() - self.shift_register = list() + self.shift_register = [] self.latched = 0 self.joy_h = 0 self.joy_l = 0 diff --git a/pysnes/cpu/cpu.py b/pysnes/cpu/cpu.py index e2b94d4..b7f011a 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -1,26 +1,26 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING -from rich import print +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from ..bus import Bus - + from ..rom import HardwareVectors class Reg: - def __init__(self, bits: int, value: int) -> None: self.bits = bits self.value = value + # `l`/`h` mirror the 65816's low/high byte accessor names; E743 objects + # to `l` as ambiguous, but renaming would break the register vocabulary. @property - def l(self) -> int: + def l(self) -> int: # noqa: E743 """Low byte getter""" return self.value & 0xFF @l.setter - def l(self, value: int) -> None: + def l(self, value: int) -> None: # noqa: E743 """Low byte setter""" self.value &= 0xFFFF00 self.value |= value & 0xFF @@ -70,11 +70,11 @@ def d(self, value: int) -> None: class CpuStatus: - # nmi_line: set to True at V-Blank start by bus.raise_nmi(); read by bus at $4210 - # irq_line: set by PPU when H/V match condition is satisfied; cleared by - # reading $4211 (TIMEUP) or by disabling both H-IRQ and V-IRQ via $4200. - # H/V IRQ target registers ($4207-$420A). 9-bit each. - # MEMSEL ($420D) bit 0: 1 = FastROM (banks $80-$BF and $C0-$FF use 6 MC instead of 8) + # nmi_line: set to True at V-Blank start by bus.raise_nmi(); read by bus at + # $4210 irq_line: set by PPU when H/V match condition is satisfied; cleared + # by reading $4211 (TIMEUP) or by disabling both H-IRQ and V-IRQ via $4200. + # H/V IRQ target registers ($4207-$420A). 9-bit each. MEMSEL ($420D) bit 0: + # 1 = FastROM (banks $80-$BF and $C0-$FF use 6 MC instead of 8) def __init__(self): self.hirq_enable = False @@ -137,22 +137,25 @@ def call(self, cpu: Cpu): class Cpu: - - - def __init__(self, hardware_vectors: "HardwareVectors") -> None: + def __init__(self, hardware_vectors: HardwareVectors) -> None: self.reset_registers() self.load_instructions() self.status = CpuStatus() from .wdc65816.disassembler import Disassembler + self.disassembler = Disassembler(self) self.trace_log = [] self.trace_enabled = False # set True to populate trace_log (reads bus) self.scheduler = None def __str__(self) -> str: - return f"A:{self.A.w:04X} X:{self.X.w:04X} Y:{self.Y.w:04X} D:{self.D.w:04X} S:{self.S.w:04X} P:{self.P:02X} DB:{self.DB.l:02X} PB:{self.PC.b:02X} PC:{self.PC.w:06X}" + return ( + f"A:{self.A.w:04X} X:{self.X.w:04X} Y:{self.Y.w:04X} " + f"D:{self.D.w:04X} S:{self.S.w:04X} P:{self.P:02X} " + f"DB:{self.DB.l:02X} PB:{self.PC.b:02X} PC:{self.PC.w:06X}" + ) def reset_registers(self): # Registers @@ -161,10 +164,10 @@ def reset_registers(self): self.Y = Reg(16, 0x0000) # Y Index Register self.D = Reg(16, 0x0000) # Direct Page Register self.S = Reg(16, 0x01FF) # Stack Pointer - self.P = 0x34 # Status register - # self.PB = Reg(8, 0x00) # Program Bank Register (removed in favor of PC.b) - self.DB = Reg(8, 0x00) # Data Bank Register - self.PC = Reg(24, 0x00) # self.hardware_vectors.emulation.reset + self.P = 0x34 # Status register + # The Program Bank Register lives in PC.b, not a separate Reg. + self.DB = Reg(8, 0x00) # Data Bank Register + self.PC = Reg(24, 0x00) # self.hardware_vectors.emulation.reset # bsnes # r.vector = 0xfffc; //reset vector address @@ -175,18 +178,20 @@ def reset_registers(self): self.V = Reg(24, 0x00) self.W = Reg(24, 0x00) - self.Z = Reg(16, 0x0000) # this only exists in bsnes but not in actual hardware + # this only exists in bsnes but not in actual hardware + self.Z = Reg(16, 0x0000) # Emulation flag self.EF: bool = True # Starts enabled # other regs used by bsnes self.irq: bool = False # IRQ pin (0 = low, 1 = trigger) - self.wai: bool = False # raised during wai, cleared after interrupt triggered + # raised during wai, cleared after interrupt triggered + self.wai: bool = False self.stp: bool = False # raised during stp, never cleared - # reg to count cpu clock cycles - # snes9x: CPU.Cycles = 182; // Or 188. This is the cycle count just after the jump to the Reset Vector. + # reg to count cpu clock cycles snes9x: CPU.Cycles = 182; // Or 188. + # This is the cycle count just after the jump to the Reset Vector. self.cycles: int = 182 self.prev_cycles = self.cycles @@ -266,13 +271,14 @@ def load_instructions(self): self.instructions: Any = [None] * 256 - # build_instructions binds the opcode table to this CPU's live Reg objects - # (cpu.X, cpu.A, ...) so the hot addressing-mode functions need no per-call - # getattr(cpu, name). The reset path rebuilds the table after - # reset_registers() makes new Reg objects. + # build_instructions binds the opcode table to this CPU's live Reg + # objects (cpu.X, cpu.A, ...) so the hot addressing-mode functions need + # no per-call getattr(cpu, name). The reset path rebuilds the table + # after reset_registers() makes new Reg objects. for opcode, addr_mode, *args in build_instructions(self): - # InstructionSlot stores addr_mode + extra args; cpu is passed at call time. - # This replaces functools.partial — see InstructionSlot.call(). + # InstructionSlot stores addr_mode + extra args; cpu is passed at + # call time. This replaces functools.partial — see + # InstructionSlot.call(). if len(args) == 0: slot = InstructionSlot(addr_mode, nargs=0) elif len(args) == 1: @@ -292,7 +298,8 @@ def attach(self, bus: Bus) -> None: # ------------------------------------------------------------------ def start(self, scheduler) -> None: - """Register the CPU with the scheduler. Call once before the main loop.""" + """Register the CPU with the scheduler. Call once before the main + loop.""" self.scheduler = scheduler self.scheduler.add(0, self._step) @@ -308,7 +315,10 @@ def _step(self) -> None: peeked = self.scheduler.peek() # When no other events are scheduled (e.g. unit tests without a PPU), # set next_event = master_clock so the loop exits after one instruction. - next_event = peeked if peeked != 0xFFFFFFFFFFFFFFFF else self.scheduler.master_clock + if peeked != 0xFFFFFFFFFFFFFFFF: + next_event = peeked + else: + next_event = self.scheduler.master_clock while True: if self._nmi_pending: self._nmi_pending = False @@ -344,7 +354,7 @@ def idle(self): self.icycles += 1 def idle2(self): - if (self.D.l): + if self.D.l: self.idle() def idle4(self, x: int, y: int) -> None: @@ -381,43 +391,40 @@ def read(self, addr: int) -> int: return data def readDirect(self, address: int) -> int: - # this is not part of bsnes implementation but it seems - # tests expect the page to wrap around when in emulation mode - # even if self.D.l is not zero - # NOTE commenting because of test for instruction 46 DirectModify LSR - # if self.EF: - # addr = (self.D.h << 8) | ((self.D.l + address) & 0xff) - # return self.read(addr) + # Not part of the bsnes implementation, but the tests expect the page + # to wrap in emulation mode even when D.l is non-zero. Wrapping + # unconditionally breaks the DirectModify LSR test for opcode $46, so + # the wrap stays gated on D.l == 0. if self.EF and self.D.l == 0: - return self.read(self.D.w | address & 0xff) - return self.read(self.D.w + address & 0xffff) + return self.read(self.D.w | address & 0xFF) + return self.read(self.D.w + address & 0xFFFF) def writeDirect(self, address: int, data: int) -> None: if self.EF and self.D.l == 0: - self.write(self.D.w | address & 0xff, data) + self.write(self.D.w | address & 0xFF, data) else: - self.write(self.D.w + address & 0xffff, data) + self.write(self.D.w + address & 0xFFFF, data) def readDirectN(self, address: int) -> int: - return self.read(self.D.w + address & 0xffff) + return self.read(self.D.w + address & 0xFFFF) def readBank(self, address: int) -> int: - return self.read((self.DB.l << 16) + address & 0xffffff) + return self.read((self.DB.l << 16) + address & 0xFFFFFF) def writeBank(self, address: int, data: int) -> None: - self.write((self.DB.l << 16) + address & 0xffffff, data) + self.write((self.DB.l << 16) + address & 0xFFFFFF, data) def readLong(self, address: int) -> int: - return self.read(address & 0xffffff) + return self.read(address & 0xFFFFFF) def writeLong(self, address: int, data: int) -> None: - self.write(address & 0xffffff, data) + self.write(address & 0xFFFFFF, data) def readStack(self, address: int) -> int: - return self.read(self.S.w + address & 0xffff) + return self.read(self.S.w + address & 0xFFFF) def writeStack(self, address: int, data: int) -> None: - self.write(self.S.w + address & 0xffff, data) + self.write(self.S.w + address & 0xFFFF, data) def fetch(self) -> int: data = self.read(self.PC.d) @@ -466,38 +473,44 @@ def fetch_and_execute(self) -> int: # https://wiki.superfamicom.org/timing#clocks-and-refresh-10 - # A CPU internal operation (an IO cycle) takes 6 master cycles. - # A memory access cycle takes 6, 8, or 12 master cycles, - # depending on the memory region accessed and bit 0 of CPU register $420D. + # A CPU internal operation (an IO cycle) takes 6 master cycles. A memory + # access cycle takes 6, 8, or 12 master cycles, depending on the memory + # region accessed and bit 0 of CPU register $420D. - # The SNES runs 1 scanline every 1364 master cycles, except in non-interlace mode scanline - # $F0 of every other frame (those with $213F.7=1) is only 1360 cycles. Frames are 262 scanlines - # in non-interlace mode, while in interlace mode frames with $213F.7=0 are 263 scanlines. - # "V-Blank" runs from either scanline $E1 or $F0 until the end of the frame. + # The SNES runs 1 scanline every 1364 master cycles, except in + # non-interlace mode scanline $F0 of every other frame (those with + # $213F.7=1) is only 1360 cycles. Frames are 262 scanlines in + # non-interlace mode, while in interlace mode frames with $213F.7=0 are + # 263 scanlines. "V-Blank" runs from either scanline $E1 or $F0 until + # the end of the frame. - # The CPU is paused for 40 cycles beginning about 536 cycles after the start of each scanline. - # Current theory is that this is used for WRAM Refresh. The exact timing is that the refresh pause - # begins at 538 cycles into the first scanline of the first frame, and thereafter some multiple of - # 8 cycles after the previous pause that comes closest to 536. + # The CPU is paused for 40 cycles beginning about 536 cycles after the + # start of each scanline. Current theory is that this is used for WRAM + # Refresh. The exact timing is that the refresh pause begins at 538 + # cycles into the first scanline of the first frame, and thereafter some + # multiple of 8 cycles after the previous pause that comes closest to + # 536. return self.cycles - self.prev_cycles def get_clock_cycles(self, addr: int) -> int: """Returns the number of clock cycles to perform IO on a given address - The 'Speed' column indicates the memory access speed for that area of memory. - The SNES master clock runs at about 21MHz (probably as close to 1.89e9/88 Hz as possible). - Internal operation CPU cycles always take 6 master cycles. Fast memory access cycles also - take 6 master cycles, Slow memory access cycles take 8 master cycles, and XSlow memory access cycles take 12 master cycles. + The 'Speed' column indicates the memory access speed for that area of + memory. The SNES master clock runs at about 21MHz (probably as close to + 1.89e9/88 Hz as possible). Internal operation CPU cycles always take 6 + master cycles. Fast memory access cycles also take 6 master cycles, Slow + memory access cycles take 8 master cycles, and XSlow memory access + cycles take 12 master cycles. Banks | Addresses | Speed | Mapping --------+-------------+-------+--------- - $00-$3F | $0000-$1FFF | Slow | Address Bus A + /WRAM (mirror $7E:0000-$1FFF) + $00-$3F | $0000-$1FFF | Slow | Address Bus A + /WRAM (mirror of $7E) | $2000-$20FF | Fast | Address Bus A | $2100-$21FF | Fast | Address Bus B | $2200-$3FFF | Fast | Address Bus A - | $4000-$41FF | XSlow | Internal CPU registers (see Note 1 below) - | $4200-$43FF | Fast | Internal CPU registers (see Note 1 below) + | $4000-$41FF | XSlow | Internal CPU registers (Note 1) + | $4200-$43FF | Fast | Internal CPU registers (Note 1) | $4400-$5FFF | Fast | Address Bus A | $6000-$7FFF | Slow | Address Bus A | $8000-$FFFF | Slow | Address Bus A + /CART @@ -506,32 +519,35 @@ def get_clock_cycles(self, addr: int) -> int: --------+-------------+-------+--------- $7E-$7F | $0000-$FFFF | Slow | Address Bus A + /WRAM --------+-------------+-------+--------- - $80-$BF | $0000-$1FFF | Slow | Address Bus A + /WRAM (mirror $7E:0000-$1FFF) + $80-$BF | $0000-$1FFF | Slow | Address Bus A + /WRAM (mirror of $7E) | $2000-$20FF | Fast | Address Bus A | $2100-$21FF | Fast | Address Bus B | $2200-$3FFF | Fast | Address Bus A - | $4000-$41FF | XSlow | Internal CPU registers (see Note 1 below) - | $4200-$43FF | Fast | Internal CPU registers (see Note 1 below) + | $4000-$41FF | XSlow | Internal CPU registers (Note 1) + | $4200-$43FF | Fast | Internal CPU registers (Note 1) | $4400-$5FFF | Fast | Address Bus A | $6000-$7FFF | Slow | Address Bus A | $8000-$FFFF | Note2 | Address Bus A + /CART --------+-------------+-------+--------- $C0-$FF | $0000-$FFFF | Note2 | Address Bus A + /CART - Note 2: If bit 1 of CPU register $420D is set, the speed is Fast, otherwise it is Slow. + Note 2: If bit 1 of CPU register $420D is set, the speed is Fast, + otherwise it is Slow. """ """ https://board.zsnes.com/phpBB3/viewtopic.php?t=12711 Hard to say exactly. The core clocks runs at 21.477MHz. - Each cycle can take 6, 8 or 12 clocks, let's assume 8 on average (12 is very rare.) + Each cycle can take 6, 8 or 12 clocks, let's assume 8 on + average (12 is very rare.) Each opcode takes 2-6 cycles, so let's say 4 on average. 21,477,272/32=~671,164 opcodes/second. https://forums.nesdev.org/viewtopic.php?p=175515&sid=e26b9af85c521bb4c8fa1e905af2157c#p175515 - Right. Every CPU instruction takes some number of CPU cycles; each CPU cycle in turn takes - 6, 8, or 12 master clock cycles depending on which memory it's accessing. + Right. Every CPU instruction takes some number of CPU cycles; + each CPU cycle in turn takes 6, 8, or 12 master clock cycles + depending on which memory it's accessing. """ fast, slow, xslow = 6, 8, 12 @@ -567,11 +583,19 @@ def get_clock_cycles(self, addr: int) -> int: @property def P(self) -> int: - return self.CFlag << 0 | self.ZFlag << 1 | self.IFlag << 2 | self.DFlag << 3 | self.XFlag << 4 | self.MFlag << 5 | self.VFlag << 6 | self.NFlag << 7 + return ( + self.CFlag << 0 + | self.ZFlag << 1 + | self.IFlag << 2 + | self.DFlag << 3 + | self.XFlag << 4 + | self.MFlag << 5 + | self.VFlag << 6 + | self.NFlag << 7 + ) @P.setter def P(self, data: int) -> None: - # assert 0 <= data <= 0xFF, f"Invalid value for P register: {hex(data)}" self.CFlag = data & 0x01 > 0 self.ZFlag = data & 0x02 > 0 self.IFlag = data & 0x04 > 0 @@ -582,7 +606,8 @@ def P(self, data: int) -> None: self.NFlag = data & 0x80 > 0 def interrupt(self, vector: int) -> int: - """NMI/IRQ handler. Returns elapsed master-clock cycles for the scheduler.""" + """NMI/IRQ handler. Returns elapsed master-clock cycles for the + scheduler.""" self.prev_cycles = self.cycles self.idle() self.idle() diff --git a/pysnes/cpu/dma.py b/pysnes/cpu/dma.py index 00b08e5..831c611 100644 --- a/pysnes/cpu/dma.py +++ b/pysnes/cpu/dma.py @@ -1,6 +1,5 @@ - -from typing import TYPE_CHECKING from dataclasses import dataclass +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..bus import Bus @@ -11,14 +10,14 @@ # Register offset written per byte within a unit for each transfer mode. # Each sublist has one entry per byte in the unit. _HDMA_TARGET_OFFSETS = [ - [0], # mode 0: 1 byte → $21xx - [0, 1], # mode 1: 2 bytes → $21xx, $21xx+1 - [0, 0], # mode 2: 2 bytes → $21xx, $21xx - [0, 0, 1, 1], # mode 3: 4 bytes → $21xx×2, $21xx+1×2 - [0, 1, 2, 3], # mode 4: 4 bytes → $21xx, $21xx+1, $21xx+2, $21xx+3 - [0, 1, 0, 1], # mode 5: 4 bytes → $21xx, $21xx+1 ×2 - [0, 0], # mode 6: same as 2 - [0, 0, 1, 1], # mode 7: same as 3 + [0], # mode 0: 1 byte → $21xx + [0, 1], # mode 1: 2 bytes → $21xx, $21xx+1 + [0, 0], # mode 2: 2 bytes → $21xx, $21xx + [0, 0, 1, 1], # mode 3: 4 bytes → $21xx×2, $21xx+1×2 + [0, 1, 2, 3], # mode 4: 4 bytes → $21xx, $21xx+1, $21xx+2, $21xx+3 + [0, 1, 0, 1], # mode 5: 4 bytes → $21xx, $21xx+1 ×2 + [0, 0], # mode 6: same as 2 + [0, 0, 1, 1], # mode 7: same as 3 ] @@ -66,12 +65,26 @@ class Channel: _hdma_active: bool = False _STATE_FIELDS = ( - "transfer_mode", "fixed_transfer", "reverse_transfer", "unused", - "indirect", "direction", "target_address", "source_address", - "source_bank", "transfer_size", "indirect_bank", "hdma_address", - "line_counter", "unknown", "hdma_enable", - "_hdma_ptr", "_hdma_bank", "_hdma_line_counter_repeat", - "_hdma_do_transfer", "_hdma_active", + "transfer_mode", + "fixed_transfer", + "reverse_transfer", + "unused", + "indirect", + "direction", + "target_address", + "source_address", + "source_bank", + "transfer_size", + "indirect_bank", + "hdma_address", + "line_counter", + "unknown", + "hdma_enable", + "_hdma_ptr", + "_hdma_bank", + "_hdma_line_counter_repeat", + "_hdma_do_transfer", + "_hdma_active", ) def dump_state(self) -> dict: @@ -95,7 +108,9 @@ def do_transfer(self) -> None: for index in range(count): a_bus_addr = (self.source_bank << 16) | self.source_address - b_bus_addr = 0x2100 | ((self.target_address + offsets[index % unit_len]) & 0xFF) + b_bus_addr = 0x2100 | ( + (self.target_address + offsets[index % unit_len]) & 0xFF + ) if self.direction == 0: self.bus.write(b_bus_addr, self.bus.read(a_bus_addr)) @@ -108,11 +123,12 @@ def do_transfer(self) -> None: # overhead. Also sync _last_refresh_scanline so the CPU's post-DMA # DRAM refresh check doesn't add a spurious 40 MC penalty. self.bus.scheduler.master_clock += count * 8 + 24 - self.bus.cpu._last_refresh_scanline = self.bus.scheduler.master_clock // 1364 + self.bus.cpu._last_refresh_scanline = ( + self.bus.scheduler.master_clock // 1364 + ) class DMA: - def __init__(self, bus: "Bus") -> None: self.channels = [Channel(bus) for _ in range(8)] @@ -120,7 +136,7 @@ def dump_state(self) -> dict: return {"channels": [ch.dump_state() for ch in self.channels]} def load_state(self, d: dict) -> None: - for ch, cs in zip(self.channels, d["channels"]): + for ch, cs in zip(self.channels, d["channels"], strict=True): ch.load_state(cs) def write(self, abs_addr: int, data: int) -> None: @@ -186,16 +202,26 @@ def read(self, abs_addr: int) -> int: | ((channel.indirect & 1) << 6) | ((channel.direction & 1) << 7) ) - if addr == 0x4301: return channel.target_address & 0xFF - if addr == 0x4302: return channel.source_address & 0xFF - if addr == 0x4303: return (channel.source_address >> 8) & 0xFF - if addr == 0x4304: return channel.source_bank & 0xFF - if addr == 0x4305: return channel.transfer_size & 0xFF - if addr == 0x4306: return (channel.transfer_size >> 8) & 0xFF - if addr == 0x4307: return channel.indirect_bank & 0xFF - if addr == 0x4308: return channel.hdma_address & 0xFF - if addr == 0x4309: return (channel.hdma_address >> 8) & 0xFF - if addr == 0x430A: return channel.line_counter & 0xFF + if addr == 0x4301: + return channel.target_address & 0xFF + if addr == 0x4302: + return channel.source_address & 0xFF + if addr == 0x4303: + return (channel.source_address >> 8) & 0xFF + if addr == 0x4304: + return channel.source_bank & 0xFF + if addr == 0x4305: + return channel.transfer_size & 0xFF + if addr == 0x4306: + return (channel.transfer_size >> 8) & 0xFF + if addr == 0x4307: + return channel.indirect_bank & 0xFF + if addr == 0x4308: + return channel.hdma_address & 0xFF + if addr == 0x4309: + return (channel.hdma_address >> 8) & 0xFF + if addr == 0x430A: + return channel.line_counter & 0xFF if addr == 0x430B or addr == 0x430F: return channel.unknown & 0xFF return 0 @@ -212,10 +238,10 @@ def hdmaen_set(self, data: int) -> None: def hdma_init(self) -> None: """Initialize all HDMA-enabled channels at the start of each frame. - TODO: DMA/HDMA timing — cycle counts for DMA transfers and HDMA setup are - not deducted from the CPU cycle budget; games that rely on precise DMA - timing (e.g. mid-frame HDMA effects that depend on cycle-accurate firing) - may render incorrectly. + TODO: DMA/HDMA timing — cycle counts for DMA transfers and HDMA setup + are not deducted from the CPU cycle budget; games that rely on precise + DMA timing (e.g. mid-frame HDMA effects that depend on cycle-accurate + firing) may render incorrectly. """ for ch in self.channels: if not ch.hdma_enable: @@ -227,7 +253,8 @@ def hdma_init(self) -> None: self._load_entry(ch) def _load_entry(self, ch: "Channel") -> None: - """Read the count byte at _hdma_ptr and set up channel state for the entry.""" + """Read the count byte at _hdma_ptr and set up channel state for the + entry.""" count = ch.bus.read(ch._hdma_bank << 16 | ch._hdma_ptr) ch._hdma_ptr = (ch._hdma_ptr + 1) & 0xFFFF if count == 0: @@ -239,7 +266,9 @@ def _load_entry(self, ch: "Channel") -> None: if ch.indirect: lo = ch.bus.read(ch._hdma_bank << 16 | ch._hdma_ptr) - hi = ch.bus.read(ch._hdma_bank << 16 | ((ch._hdma_ptr + 1) & 0xFFFF)) + hi = ch.bus.read( + ch._hdma_bank << 16 | ((ch._hdma_ptr + 1) & 0xFFFF) + ) ch.transfer_size = (hi << 8) | lo ch._hdma_ptr = (ch._hdma_ptr + 2) & 0xFFFF @@ -268,19 +297,33 @@ def hdma_scanline(self) -> None: if ch._hdma_do_transfer: if ch.indirect: for i in range(unit_bytes): - byte = ch.bus.read((data_bank << 16) | ((ch.transfer_size + i) & 0xFFFF)) - ch.bus.write(0x2100 | ((ch.target_address + offsets[i]) & 0xFF), byte) + byte = ch.bus.read( + (data_bank << 16) + | ((ch.transfer_size + i) & 0xFFFF) + ) + ch.bus.write( + 0x2100 | ((ch.target_address + offsets[i]) & 0xFF), + byte, + ) ch.transfer_size = (ch.transfer_size + unit_bytes) & 0xFFFF else: for i in range(unit_bytes): - byte = ch.bus.read((data_bank << 16) | ((ch._hdma_ptr + i) & 0xFFFF)) - ch.bus.write(0x2100 | ((ch.target_address + offsets[i]) & 0xFF), byte) + byte = ch.bus.read( + (data_bank << 16) | ((ch._hdma_ptr + i) & 0xFFFF) + ) + ch.bus.write( + 0x2100 | ((ch.target_address + offsets[i]) & 0xFF), + byte, + ) ch._hdma_ptr = (ch._hdma_ptr + unit_bytes) & 0xFFFF # Step 2: decrement the full counter byte. - ch._hdma_line_counter_repeat = (ch._hdma_line_counter_repeat - 1) & 0xFF + ch._hdma_line_counter_repeat = ( + ch._hdma_line_counter_repeat - 1 + ) & 0xFF - # Step 3: DoTransfer for next scanline = bit 7 of decremented counter. + # Step 3: DoTransfer for next scanline = bit 7 of decremented + # counter. ch._hdma_do_transfer = bool(ch._hdma_line_counter_repeat & 0x80) # Step 4: if bits 6:0 reached zero, load next entry. diff --git a/pysnes/cpu/test_cpu.py b/pysnes/cpu/test_cpu.py index 1122144..e77a3cb 100644 --- a/pysnes/cpu/test_cpu.py +++ b/pysnes/cpu/test_cpu.py @@ -1,14 +1,14 @@ -from collections import defaultdict import os -import ijson -import pytest +from collections import defaultdict from unittest.mock import patch +import ijson +import pytest from rich import print -from .cpu import Cpu from pysnes._ss_cache import get_or_build +from .cpu import Cpu TESTS_PATH = "submodules/65816/v1" @@ -19,15 +19,16 @@ def _load_case(file_path: str, index: int) -> dict: global _FILE_CACHE_KEY, _FILE_CACHE_VAL - if _FILE_CACHE_KEY != file_path: - with open(file_path, 'rb') as f: - _FILE_CACHE_VAL = list(ijson.items(f, 'item')) + if file_path != _FILE_CACHE_KEY: + with open(file_path, "rb") as f: + _FILE_CACHE_VAL = list(ijson.items(f, "item")) _FILE_CACHE_KEY = file_path return _FILE_CACHE_VAL[index] def _parse_test_index(opcode_filter, max_per_opcode, mode): - """Stream test names from every matching JSON and build the (file,index) index. + """Stream test names from every matching JSON and build the (file,index) + index. Uses ijson sub-path 'item.name' so only name strings are materialized — the bulky `ram`/`cycles` arrays are never converted to Python objects. @@ -41,20 +42,19 @@ def _include(filename): return False if prefix is not None and not parts[0].upper().startswith(prefix): return False - if mode is not None and parts[1].lower() != mode.lower(): - return False - return True + return mode is None or parts[1].lower() == mode.lower() onlyfiles = sorted( - os.path.join(TESTS_PATH, f) for f in os.listdir(TESTS_PATH) + os.path.join(TESTS_PATH, f) + for f in os.listdir(TESTS_PATH) if os.path.isfile(os.path.join(TESTS_PATH, f)) and _include(f) ) test_counter = defaultdict(int) params, test_ids = [], [] for file_path in onlyfiles: - with open(file_path, 'rb') as f: - for i, name in enumerate(ijson.items(f, 'item.name')): + with open(file_path, "rb") as f: + for i, name in enumerate(ijson.items(f, "item.name")): test_id = name.replace(" ", "_") if limit > 0 and test_counter[test_id[0:4]] >= limit: continue @@ -84,10 +84,11 @@ def get_test_cases(opcode_filter=None, max_per_opcode=None, mode=None): lambda: _parse_test_index(opcode_filter, max_per_opcode, mode), ) # Apply xdist_group markers at collection time (cache stores plain tuples). - # With --dist=loadgroup, all cases from one JSON file route to the same worker, - # so the single-slot _load_case cache stays warm. + # With --dist=loadgroup, all cases from one JSON file route to the same + # worker, so the single-slot _load_case cache stays warm. test_cases = [ - pytest.param(rp, marks=pytest.mark.xdist_group(rp[0])) for rp in raw_params + pytest.param(rp, marks=pytest.mark.xdist_group(rp[0])) + for rp in raw_params ] return test_cases, test_ids @@ -116,9 +117,10 @@ def test_cpu(test_case): cpu.X.w = initial["x"] cpu.Y.w = initial["y"] cpu.EF = bool(initial["e"]) - # In 8-bit mode (Emulation Mode): The stack pointer (S) is restricted to an 8-bit value, meaning it can only point to - # addresses within the range $0100 to $01FF (the first 256 bytes of page 1). - # This effectively limits the stack to 256 bytes in this mode, similar to how the 6502 operates. + # In 8-bit mode (Emulation Mode): The stack pointer (S) is restricted to an + # 8-bit value, meaning it can only point to addresses within the range $0100 + # to $01FF (the first 256 bytes of page 1). This effectively limits the + # stack to 256 bytes in this mode, similar to how the 6502 operates. if cpu.EF: cpu.S.l = initial["s"] else: @@ -135,28 +137,31 @@ def test_cpu(test_case): final = test_case["final"] calls_expected, calls_performed = [], [] for address, value, outputs in test_case["cycles"]: - # The environment used does not activate RAM unless one of VDA, VPA or VPB is active, - # therefore affected bus transactions with the read line set do not produce a value. - # null is recorded in its place. + # The environment used does not activate RAM unless one of VDA, VPA or + # VPB is active, therefore affected bus transactions with the read line + # set do not produce a value. null is recorded in its place. if value is None: continue - if outputs[3] == 'r': + if outputs[3] == "r": calls_expected.append(f"getitem({hex(address)}) -> {hex(value)}") - elif outputs[3] == 'w': + elif outputs[3] == "w": calls_expected.append(f"setitem({hex(address)}, {hex(value)})") class _BudgetExceeded(Exception): pass real_read = FakeBus.read + def read(self, address): if len(calls_performed) >= len(calls_expected): raise _BudgetExceeded() value = real_read(self, address) calls_performed.append(f"getitem({hex(address)}) -> {hex(value)}") return value + real_write = FakeBus.write + def write(self, address, value): if len(calls_performed) >= len(calls_expected): raise _BudgetExceeded() @@ -164,8 +169,10 @@ def write(self, address, value): return real_write(self, address, value) # mocks to track memory access - with patch.object(FakeBus, "read", autospec=True) as mock_getitem, \ - patch.object(FakeBus, "write", autospec=True) as mock_setitem: + with ( + patch.object(FakeBus, "read", autospec=True) as mock_getitem, + patch.object(FakeBus, "write", autospec=True) as mock_setitem, + ): mock_getitem.side_effect = read mock_setitem.side_effect = write try: @@ -182,15 +189,17 @@ def write(self, address, value): assert cpu.PC.w == final["pc"], f"{hex(cpu.PC.w)} != {hex(final['pc'])}" assert cpu.S.w == final["s"], f"{hex(cpu.S.w)} != {hex(final['s'])}" assert cpu.A.w == final["a"], f"{hex(cpu.A.w)} != {hex(final['a'])}" - assert cpu.X.w == final['x'], f"{hex(cpu.X.w)} != {hex(final['x'])}" - assert cpu.Y.w == final['y'], f"{hex(cpu.Y.w)} != {hex(final['y'])}" - assert cpu.EF == bool(final['e']) - assert cpu.P == final["p"], f"{hex(cpu.P)} != {hex(final['p'])}" - assert cpu.D.w == final['d'], f"{hex(cpu.D.w)} != {hex(final['d'])}" - assert cpu.DB.l == final['dbr'], f"{hex(cpu.DB.l)} != {hex(final['dbr'])}" - assert cpu.PC.b == final['pbr'], f"{hex(cpu.PC.b)} != {hex(final['pbr'])}" + assert cpu.X.w == final["x"], f"{hex(cpu.X.w)} != {hex(final['x'])}" + assert cpu.Y.w == final["y"], f"{hex(cpu.Y.w)} != {hex(final['y'])}" + assert bool(final["e"]) == cpu.EF + assert final["p"] == cpu.P, f"{hex(cpu.P)} != {hex(final['p'])}" + assert cpu.D.w == final["d"], f"{hex(cpu.D.w)} != {hex(final['d'])}" + assert cpu.DB.l == final["dbr"], f"{hex(cpu.DB.l)} != {hex(final['dbr'])}" + assert cpu.PC.b == final["pbr"], f"{hex(cpu.PC.b)} != {hex(final['pbr'])}" for addr, value in final["ram"]: - assert cpu.bus.read(addr) == value, f"cpu.bus[{hex(addr)}] = {hex(cpu.bus.read(addr))} != {hex(value)}" + assert cpu.bus.read(addr) == value, ( + f"cpu.bus[{hex(addr)}] = {hex(cpu.bus.read(addr))} != {hex(value)}" + ) # check on read/write cycles assert calls_performed == calls_expected diff --git a/pysnes/cpu/test_dma.py b/pysnes/cpu/test_dma.py index 4298dd6..77201c4 100644 --- a/pysnes/cpu/test_dma.py +++ b/pysnes/cpu/test_dma.py @@ -9,17 +9,15 @@ - Bus integration: DMA writes are visible via bus reads """ -import pytest +from types import SimpleNamespace -from ..scheduler import Scheduler -from ..bus import Bus -from .cpu import Cpu from ..apu import Apu -from ..ppu import Ppu +from ..bus import Bus from ..controller import Controller +from ..ppu import Ppu from ..rom import HardwareVectors, InterruptVectors, MappingMode -from types import SimpleNamespace - +from ..scheduler import Scheduler +from .cpu import Cpu # --------------------------------------------------------------------------- # Helpers @@ -33,10 +31,22 @@ def __init__(self, size=ROM_SIZE): self.rom = bytearray(size) self.snes_header = SimpleNamespace(mapping_mode=MappingMode.LOROM) self.hardware_vectors = HardwareVectors( - native=InterruptVectors(cop=0x8000, brk=0x8000, abort=0x8000, - nmi=0x8000, reset=0, irq=0x8000), - emulation=InterruptVectors(cop=0x8000, brk=0, abort=0x8000, - nmi=0x8000, reset=0x8000, irq=0x8000), + native=InterruptVectors( + cop=0x8000, + brk=0x8000, + abort=0x8000, + nmi=0x8000, + reset=0, + irq=0x8000, + ), + emulation=InterruptVectors( + cop=0x8000, + brk=0, + abort=0x8000, + nmi=0x8000, + reset=0x8000, + irq=0x8000, + ), ) def read(self, addr): @@ -62,6 +72,7 @@ def make_bus(): # Channel register decoding ($43x0–$43xA) # --------------------------------------------------------------------------- + def test_dmap_transfer_mode(): bus, _, cpu = make_bus() bus.write(0x004300, 0b00000011) # channel 0: transfer_mode=3 @@ -123,6 +134,7 @@ def test_channel_select_channel_7(): # MDMA — transfer mode 0 (1 byte to 1 B-bus register) # --------------------------------------------------------------------------- + def test_mdma_mode0_single_byte(): """Mode 0: each byte from A bus written to the single B-bus target.""" bus, rom, cpu = make_bus() @@ -197,6 +209,7 @@ def test_mdma_mode0_fixed_source_does_not_increment(): # MDMA — transfer mode 1 (2 bytes alternating to $21xx / $21xx+1) # --------------------------------------------------------------------------- + def test_mdma_mode1_alternates_target(): """Mode 1 alternates writes between $21xx and $21xx+1.""" bus, rom, cpu = make_bus() @@ -218,11 +231,13 @@ def test_mdma_mode1_alternates_target(): # MDMA — multi-channel # --------------------------------------------------------------------------- + def test_mdma_only_enabled_channels_run(): """MDMAEN bitmask: only channels with their bit set transfer.""" bus, rom, cpu = make_bus() rom.rom[0x0000] = 0x11 - rom.rom[0x8000] = 0x22 # ROM offset for channel 1 (bank 0, offset 0x8000 + 0x8000) + # ROM offset for channel 1 (bank 0, offset 0x8000 + 0x8000) + rom.rom[0x8000] = 0x22 # Channel 0 bus.write(0x004300, 0x00) @@ -252,6 +267,7 @@ def test_mdma_only_enabled_channels_run(): # HDMA — enable register # --------------------------------------------------------------------------- + def test_hdmaen_sets_channel_flags(): bus, _, cpu = make_bus() bus.write(0x00420C, 0b00000101) # enable channels 0 and 2 @@ -272,8 +288,10 @@ def test_hdmaen_clears_when_zero(): # HDMA scanline execution # --------------------------------------------------------------------------- + def _setup_hdma_channel0(bus, rom, table_offset, table_bytes): - """Write an HDMA table at ROM[table_offset] and configure channel 0 (mode 1, target $2126).""" + """Write an HDMA table at ROM[table_offset] and configure channel 0 (mode 1, + target $2126).""" for i, b in enumerate(table_bytes): rom.rom[table_offset + i] = b # source address = 0x8000 + table_offset (LoROM: bank 0 / $8000 region) @@ -287,17 +305,18 @@ def _setup_hdma_channel0(bus, rom, table_offset, table_bytes): def test_hdma_non_repeat_writes_bytes_to_target(): - """Do-repeat entry (bit 7=1): each scanline gets fresh 2-byte data written to $2126/$2127.""" + """Do-repeat entry (bit 7=1): each scanline gets fresh 2-byte data written + to $2126/$2127.""" bus, rom, cpu = make_bus() # Table: count=0x82 (do-repeat, 2 scanlines), data=[10,200], [20,210], end _setup_hdma_channel0(bus, rom, 0x0000, [0x82, 10, 200, 20, 210, 0x00]) cpu.dma.hdma_init() - cpu.dma.hdma_scanline() # scanline 1: WH0=10, WH1=200 + cpu.dma.hdma_scanline() # scanline 1: WH0=10, WH1=200 assert bus.ppu.wh0 == 10 assert bus.ppu.wh1 == 200 - cpu.dma.hdma_scanline() # scanline 2: WH0=20, WH1=210 + cpu.dma.hdma_scanline() # scanline 2: WH0=20, WH1=210 assert bus.ppu.wh0 == 20 assert bus.ppu.wh1 == 210 @@ -311,17 +330,18 @@ def test_hdma_non_repeat_end_of_table_stops(): bus.ppu.wh0 = 0 bus.ppu.wh1 = 0 - cpu.dma.hdma_scanline() # scanline 1: writes 50, 100 + cpu.dma.hdma_scanline() # scanline 1: writes 50, 100 assert bus.ppu.wh0 == 50 assert bus.ppu.wh1 == 100 - cpu.dma.hdma_scanline() # scanline 2: table ended, no write - assert bus.ppu.wh0 == 50 # unchanged + cpu.dma.hdma_scanline() # scanline 2: table ended, no write + assert bus.ppu.wh0 == 50 # unchanged assert bus.ppu.wh1 == 100 def test_hdma_repeat_reuses_same_data(): - """Do-not-repeat entry (bit 7=0): same 2 bytes written for all scanlines in entry.""" + """Do-not-repeat entry (bit 7=0): same 2 bytes written for all scanlines in + entry.""" bus, rom, cpu = make_bus() # Table: count=0x03 (do-not-repeat, 3 scanlines), data=[30, 150], end _setup_hdma_channel0(bus, rom, 0x0000, [0x03, 30, 150, 0x00]) @@ -332,8 +352,8 @@ def test_hdma_repeat_reuses_same_data(): assert bus.ppu.wh0 == 30 assert bus.ppu.wh1 == 150 - cpu.dma.hdma_scanline() # table ended, no write - assert bus.ppu.wh0 == 30 # unchanged + cpu.dma.hdma_scanline() # table ended, no write + assert bus.ppu.wh0 == 30 # unchanged def test_hdma_multiple_entries_sequential(): @@ -345,11 +365,11 @@ def test_hdma_multiple_entries_sequential(): _setup_hdma_channel0(bus, rom, 0x0000, [0x81, 0, 0, 0x01, 64, 192, 0x00]) cpu.dma.hdma_init() - cpu.dma.hdma_scanline() # entry 1: WH0=0, WH1=0 + cpu.dma.hdma_scanline() # entry 1: WH0=0, WH1=0 assert bus.ppu.wh0 == 0 assert bus.ppu.wh1 == 0 - cpu.dma.hdma_scanline() # entry 2: WH0=64, WH1=192 + cpu.dma.hdma_scanline() # entry 2: WH0=64, WH1=192 assert bus.ppu.wh0 == 64 assert bus.ppu.wh1 == 192 @@ -363,7 +383,7 @@ def test_hdma_disabled_channel_does_nothing(): bus.ppu.wh0 = 0 cpu.dma.hdma_scanline() - assert bus.ppu.wh0 == 0 # not written + assert bus.ppu.wh0 == 0 # not written # --------------------------------------------------------------------------- @@ -373,8 +393,15 @@ def test_hdma_disabled_channel_does_nothing(): # --------------------------------------------------------------------------- -def _setup_hdma_indirect_channel0(bus, rom, table_offset, table_bytes, - indirect_bank, indirect_addr, indirect_bytes): +def _setup_hdma_indirect_channel0( + bus, + rom, + table_offset, + table_bytes, + indirect_bank, + indirect_addr, + indirect_bytes, +): """Write an indirect HDMA table plus its data block; configure channel 0.""" for i, b in enumerate(table_bytes): rom.rom[table_offset + i] = b @@ -394,11 +421,13 @@ def _setup_hdma_indirect_channel0(bus, rom, table_offset, table_bytes, def test_hdma_indirect_non_repeat_reads_from_pointer(): - """Indirect + do-not-repeat: count byte + 2-byte pointer → read data at pointer.""" + """Indirect + do-not-repeat: count byte + 2-byte pointer → read data at + pointer.""" bus, rom, cpu = make_bus() # Table: count=0x02 (do-not-repeat, 2 scanlines), ptr=$1234, end _setup_hdma_indirect_channel0( - bus, rom, + bus, + rom, table_offset=0x0000, table_bytes=[0x02, 0x34, 0x12, 0x00], indirect_bank=0x7E, @@ -418,7 +447,8 @@ def test_hdma_indirect_repeat_advances_pointer_per_scanline(): bus, rom, cpu = make_bus() # Table: count=0x83 (do-repeat, 3 scanlines), ptr=$0200, end _setup_hdma_indirect_channel0( - bus, rom, + bus, + rom, table_offset=0x0000, table_bytes=[0x83, 0x00, 0x02, 0x00], indirect_bank=0x7E, @@ -442,7 +472,8 @@ def test_hdma_indirect_multiple_entries(): # Entry 2: non-repeat, 1 scanline, ptr=$0400 # End _setup_hdma_indirect_channel0( - bus, rom, + bus, + rom, table_offset=0x0000, table_bytes=[0x01, 0x00, 0x03, 0x01, 0x00, 0x04, 0x00], indirect_bank=0x7E, @@ -519,7 +550,9 @@ def _run_mode_transfer(mode, source_bytes, target=0x26, size=None): writes = _record_writes(bus) bus.write(0x00420B, 0x01) # trigger - seq = [(a & 0xFF, v) for (a, v) in writes if 0x2100 <= (a & 0xFFFF) <= 0x21FF] + seq = [ + (a & 0xFF, v) for (a, v) in writes if 0x2100 <= (a & 0xFFFF) <= 0x21FF + ] return seq, cpu @@ -563,11 +596,15 @@ def test_mdma_mode_pattern_repeats_for_size_greater_than_unit(): """When size > unit_bytes the per-unit offset pattern repeats.""" # Mode 1 unit = [0, 1]; 6 source bytes → 3 repeats of the pattern. seq, _ = _run_mode_transfer( - mode=1, source_bytes=[0x10, 0x11, 0x12, 0x13, 0x14, 0x15]) + mode=1, source_bytes=[0x10, 0x11, 0x12, 0x13, 0x14, 0x15] + ) assert seq == [ - (0x26, 0x10), (0x27, 0x11), - (0x26, 0x12), (0x27, 0x13), - (0x26, 0x14), (0x27, 0x15), + (0x26, 0x10), + (0x27, 0x11), + (0x26, 0x12), + (0x27, 0x13), + (0x26, 0x14), + (0x27, 0x15), ] @@ -594,7 +631,9 @@ def test_mdma_reverse_transfer_decrements_source(): writes = _record_writes(bus) bus.write(0x00420B, 0x01) - seq = [(a & 0xFF, v) for (a, v) in writes if 0x2100 <= (a & 0xFFFF) <= 0x21FF] + seq = [ + (a & 0xFF, v) for (a, v) in writes if 0x2100 <= (a & 0xFFFF) <= 0x21FF + ] # Source walks 0x8005 → 0x8004 → 0x8003 → bytes copied in that order. assert seq == [(0x26, 0x55), (0x26, 0x44), (0x26, 0x33)] assert cpu.dma.channels[0].source_address == 0x8002 diff --git a/pysnes/cpu/wdc65816/addressing_modes/__init__.py b/pysnes/cpu/wdc65816/addressing_modes/__init__.py index b2861bc..2f88869 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/__init__.py +++ b/pysnes/cpu/wdc65816/addressing_modes/__init__.py @@ -1,9 +1,9 @@ from .decorator import decorator_mode_8bit -from .read import * -from .write import * from .modify import * from .other import * from .pc import * +from .read import * +from .write import * # TODO remove once all modes are implemented @@ -11,6 +11,7 @@ def __getattr__(name: str): @decorator_mode_8bit def not_implemented(*args, **kwargs): raise NotImplementedError(name) + try: return globals()[name] except KeyError: diff --git a/pysnes/cpu/wdc65816/addressing_modes/decorator.py b/pysnes/cpu/wdc65816/addressing_modes/decorator.py index 09b45a5..d429fc2 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/decorator.py +++ b/pysnes/cpu/wdc65816/addressing_modes/decorator.py @@ -1,5 +1,5 @@ -from typing import TYPE_CHECKING import functools +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cpu import Cpu @@ -7,14 +7,17 @@ def decorator_mode_8bit(func): """Creates function variants for 8/16 bit modes based on M/X flag""" + @functools.wraps(func) def mf_wrapper(cpu: "Cpu", *args): return func(cpu, cpu.MFlag, *args) + func.MF = mf_wrapper @functools.wraps(func) def xf_wrapper(cpu: "Cpu", *args): return func(cpu, cpu.XFlag, *args) + func.XF = xf_wrapper return func diff --git a/pysnes/cpu/wdc65816/addressing_modes/modify.py b/pysnes/cpu/wdc65816/addressing_modes/modify.py index 1fcefab..69d815c 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/modify.py +++ b/pysnes/cpu/wdc65816/addressing_modes/modify.py @@ -1,7 +1,6 @@ -from typing import Callable +from collections.abc import Callable from ...cpu import Cpu, Reg - from .decorator import decorator_mode_8bit diff --git a/pysnes/cpu/wdc65816/addressing_modes/other.py b/pysnes/cpu/wdc65816/addressing_modes/other.py index e5ec55f..cf3272e 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/other.py +++ b/pysnes/cpu/wdc65816/addressing_modes/other.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from ctypes import c_int16 from ...cpu import Cpu, Reg @@ -27,11 +27,11 @@ def Prefix(cpu: Cpu): def ExchangeBA(cpu: Cpu): - cpu.idle() - cpu.idle() - cpu.A.w = cpu.A.w >> 8 | cpu.A.w << 8 - cpu.ZFlag = cpu.A.l == 0 - cpu.NFlag = bool(cpu.A.l & 0x80) + cpu.idle() + cpu.idle() + cpu.A.w = cpu.A.w >> 8 | cpu.A.w << 8 + cpu.ZFlag = cpu.A.l == 0 + cpu.NFlag = bool(cpu.A.l & 0x80) @decorator_mode_8bit @@ -55,7 +55,8 @@ def BlockMove(cpu: Cpu, mode_8bit: bool, adjust: int): cpu.A.w -= 1 if not a_old: break - # Loop: hardware re-fetches the opcode and both operand bytes each iteration. + # Loop: hardware re-fetches the opcode and both operand bytes each + # iteration. cpu.PC.w -= 3 cpu.fetch() # opcode re-fetch (value discarded) dest_bank = cpu.fetch() diff --git a/pysnes/cpu/wdc65816/addressing_modes/pc.py b/pysnes/cpu/wdc65816/addressing_modes/pc.py index 2784c8f..5dc4a11 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/pc.py +++ b/pysnes/cpu/wdc65816/addressing_modes/pc.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from ...cpu import Cpu @@ -47,8 +47,8 @@ def JumpLong(cpu: Cpu): def JumpIndirect(cpu: Cpu): cpu.V.l = cpu.fetch() cpu.V.h = cpu.fetch() - cpu.W.l = cpu.read((cpu.V.w + 0) & 0xffff) - cpu.W.h = cpu.read((cpu.V.w + 1) & 0xffff) + cpu.W.l = cpu.read((cpu.V.w + 0) & 0xFFFF) + cpu.W.h = cpu.read((cpu.V.w + 1) & 0xFFFF) cpu.PC.w = cpu.W.w cpu.idleJump() @@ -57,8 +57,8 @@ def JumpIndexedIndirect(cpu: Cpu): cpu.V.l = cpu.fetch() cpu.V.h = cpu.fetch() cpu.idle() - cpu.W.l = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 0) & 0xffff) - cpu.W.h = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 1) & 0xffff) + cpu.W.l = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 0) & 0xFFFF) + cpu.W.h = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 1) & 0xFFFF) cpu.PC.w = cpu.W.w cpu.idleJump() @@ -66,9 +66,9 @@ def JumpIndexedIndirect(cpu: Cpu): def JumpIndirectLong(cpu: Cpu): cpu.U.l = cpu.fetch() cpu.U.h = cpu.fetch() - cpu.V.l = cpu.read((cpu.U.w + 0) & 0xffff) - cpu.V.h = cpu.read((cpu.U.w + 1) & 0xffff) - cpu.V.b = cpu.read((cpu.U.w + 2) & 0xffff) + cpu.V.l = cpu.read((cpu.U.w + 0) & 0xFFFF) + cpu.V.h = cpu.read((cpu.U.w + 1) & 0xFFFF) + cpu.V.b = cpu.read((cpu.U.w + 2) & 0xFFFF) cpu.PC.d = cpu.V.d cpu.idleJump() @@ -105,8 +105,8 @@ def CallIndexedIndirect(cpu: Cpu): cpu.pushN(cpu.PC.l) cpu.V.h = cpu.fetch() cpu.idle() - cpu.W.l = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 0) & 0xffff) - cpu.W.h = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 1) & 0xffff) + cpu.W.l = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 0) & 0xFFFF) + cpu.W.h = cpu.read(cpu.PC.b << 16 | (cpu.V.w + cpu.X.w + 1) & 0xFFFF) cpu.PC.w = cpu.W.w if cpu.EF: cpu.S.h = 0x01 diff --git a/pysnes/cpu/wdc65816/addressing_modes/read.py b/pysnes/cpu/wdc65816/addressing_modes/read.py index adddf13..7b45e9d 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/read.py +++ b/pysnes/cpu/wdc65816/addressing_modes/read.py @@ -1,7 +1,6 @@ -from typing import Callable +from collections.abc import Callable from ...cpu import Cpu, Reg - from .decorator import decorator_mode_8bit # Shared read-only zero register used as the "no index" offset (i.w == 0). @@ -11,10 +10,12 @@ """ -//both the accumulator and index registers can independently be in either 8-bit or 16-bit mode. -//controlled via the M/X flags, this changes the execution details of various instructions. -//rather than implement four instruction tables for all possible combinations of these bits, -//instead use macro abuse to generate all four tables based off of a single template table. +//both the accumulator and index registers can independently be in either +//8-bit or 16-bit mode. controlled via the M/X flags, this changes the +//execution details of various instructions. rather than implement four +//instruction tables for all possible combinations of these bits, instead +//use macro abuse to generate all four tables based off of a single +//template table. auto WDC65816::instruction() -> void { //a = instructions unaffected by M/X flags //m = instructions affected by M flag (1 = 8-bit; 0 = 16-bit) diff --git a/pysnes/cpu/wdc65816/addressing_modes/write.py b/pysnes/cpu/wdc65816/addressing_modes/write.py index 3dc4a00..edd698b 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/write.py +++ b/pysnes/cpu/wdc65816/addressing_modes/write.py @@ -1,5 +1,4 @@ from ...cpu import Cpu, Reg - from .decorator import decorator_mode_8bit # Shared read-only zero register used as the "no index" offset (i.w == 0). diff --git a/pysnes/cpu/wdc65816/disassembler.py b/pysnes/cpu/wdc65816/disassembler.py index 820aef3..52ad0b8 100644 --- a/pysnes/cpu/wdc65816/disassembler.py +++ b/pysnes/cpu/wdc65816/disassembler.py @@ -1,4 +1,5 @@ -from ctypes import c_uint8, c_int8, c_uint16, c_int16 +from ctypes import c_int8, c_int16, c_uint16 + from ..cpu import Cpu @@ -27,21 +28,34 @@ def disassemble(self, address: int) -> str: self.effective = 0 bank = address & 0xFF0000 - self.opcode = self.read(address); address = bank | ((address + 1) & 0xFFFF) - self.operand0 = self.read(address); address = bank | ((address + 1) & 0xFFFF) - self.operand1 = self.read(address); address = bank | ((address + 1) & 0xFFFF) - self.operand2 = self.read(address); address = bank | ((address + 1) & 0xFFFF) + self.opcode = self.read(address) + address = bank | ((address + 1) & 0xFFFF) + self.operand0 = self.read(address) + address = bank | ((address + 1) & 0xFFFF) + self.operand1 = self.read(address) + address = bank | ((address + 1) & 0xFFFF) + self.operand2 = self.read(address) + address = bank | ((address + 1) & 0xFFFF) self.operandByte = self.operand0 << 0 self.operandWord = self.operand0 << 0 | self.operand1 << 8 - self.operandLong = self.operand0 << 0 | self.operand1 << 8 | self.operand2 << 16 + self.operandLong = ( + self.operand0 << 0 | self.operand1 << 8 | self.operand2 << 16 + ) _, name, func = self.TABLE[self.opcode] operand = func() - s = f"{self.pc:06X} {name} {operand.ljust(10, ' ')} [{self.effective:06X}] " + s = ( + f"{self.pc:06X} {name} {operand.ljust(10, ' ')} " + f"[{self.effective:06X}] " + ) - s += f"A:{self.cpu.A.w:04X} X:{self.cpu.X.w:04X} Y:{self.cpu.Y.w:04X} S:{self.cpu.S.w:04X} D:{self.cpu.D.w:04X} DB:{self.cpu.DB.l:02X} " + s += ( + f"A:{self.cpu.A.w:04X} X:{self.cpu.X.w:04X} " + f"Y:{self.cpu.Y.w:04X} S:{self.cpu.S.w:04X} " + f"D:{self.cpu.D.w:04X} DB:{self.cpu.DB.l:02X} " + ) if self.cpu.EF: s += "N" if self.cpu.NFlag else "n" @@ -69,7 +83,7 @@ def absolute(self): return f"${self.operandWord:04X}" def absolutePC(self): - self.effective = self.pc & 0xff0000 | self.operandWord + self.effective = self.pc & 0xFF0000 | self.operandWord return f"${self.operandWord:04X}" def absoluteX(self): @@ -97,7 +111,9 @@ def directX(self): return f"${self.operandByte:02X},x" def directY(self): - self.effective = c_uint16(self.cpu.D.w + self.operandByte + self.cpu.Y.w).value + self.effective = c_uint16( + self.cpu.D.w + self.operandByte + self.cpu.Y.w + ).value return f"${self.operandByte:02X},y" def immediate(self): @@ -117,7 +133,9 @@ def implied(self): return "" def indexedIndirectX(self): - self.effective = c_uint16(self.cpu.D.w + self.operandByte + self.cpu.X.w).value + self.effective = c_uint16( + self.cpu.D.w + self.operandByte + self.cpu.X.w + ).value self.effective = self.cpu.PC.b << 16 | self.readWord(self.effective) return f"(${self.operandByte:02X},x)" @@ -128,18 +146,22 @@ def indirect(self): def indirectPC(self): self.effective = self.operandWord - self.effective = self.pc & 0xff0000 | self.readWord(self.effective) + self.effective = self.pc & 0xFF0000 | self.readWord(self.effective) return f"(${self.operandWord:04X})" def indirectX(self): self.effective = self.operandWord - self.effective = self.pc & 0xff0000 | c_uint16(self.effective + self.cpu.X.w).value - self.effective = self.pc & 0xff0000 | self.readWord(self.effective) + self.effective = ( + self.pc & 0xFF0000 | c_uint16(self.effective + self.cpu.X.w).value + ) + self.effective = self.pc & 0xFF0000 | self.readWord(self.effective) return f"(${self.operandWord:04X},x)" def indirectIndexedY(self): self.effective = c_uint16(self.cpu.D.w + self.operandByte).value - self.effective = (self.cpu.PC.b << 16) + self.readWord(self.effective) + self.cpu.Y.w + self.effective = ( + (self.cpu.PC.b << 16) + self.readWord(self.effective) + self.cpu.Y.w + ) return f"(${self.operandByte:02X}),y" def indirectLong(self): @@ -160,11 +182,17 @@ def move(self): return f"${self.operand0:02X}=${self.operand1:02X}" def relative(self): - self.effective = self.pc & 0xff0000 | c_uint16(self.pc + 2 + c_int8(self.operandByte).value).value + self.effective = ( + self.pc & 0xFF0000 + | c_uint16(self.pc + 2 + c_int8(self.operandByte).value).value + ) return f"${self.effective:04X}" def relativeWord(self): - self.effective = self.pc & 0xff0000 | c_uint16(self.pc + 3 + c_int16(self.operandWord).value).value + self.effective = ( + self.pc & 0xFF0000 + | c_uint16(self.pc + 3 + c_int16(self.operandWord).value).value + ) return f"${self.effective:04X}" def stack(self): @@ -173,7 +201,9 @@ def stack(self): def stackIndirect(self): self.effective = c_uint16(self.operandByte + self.cpu.S.w).value - self.effective = (self.cpu.PC.b << 16) + self.readWord(self.effective) + self.cpu.Y.w + self.effective = ( + (self.cpu.PC.b << 16) + self.readWord(self.effective) + self.cpu.Y.w + ) return f"(${self.operandByte:02X},s),y)" def build_table(self) -> None: diff --git a/pysnes/cpu/wdc65816/instructions.py b/pysnes/cpu/wdc65816/instructions.py index f36fad3..1e81381 100644 --- a/pysnes/cpu/wdc65816/instructions.py +++ b/pysnes/cpu/wdc65816/instructions.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING -from . import addressing_modes as AM, opcodes as OP +from . import addressing_modes as AM +from . import opcodes as OP if TYPE_CHECKING: from ..cpu import Cpu @@ -10,265 +11,265 @@ def build_instructions(cpu: "Cpu") -> tuple: """Opcode table bound to this CPU's live Reg objects. Register operands are the actual Reg instances (cpu.X, cpu.A, ...) so the - addressing-mode functions need no per-call getattr(cpu, name). Operands that - aren't registers (flag set/clear, single-byte pushes) have dedicated handlers - (CLC/SEC/.../PHP/PHK/PHB) and take no operand at all. + addressing-mode functions need no per-call getattr(cpu, name). Operands + that aren't registers (flag set/clear, single-byte pushes) have dedicated + handlers (CLC/SEC/.../PHP/PHK/PHB) and take no operand at all. """ return ( - (0x00, AM.Interrupt, lambda cpu: 0xfffe if cpu.EF else 0xffe6), - (0x01, AM.IndexedIndirectRead.MF, OP.ORA), - (0x02, AM.Interrupt, lambda cpu: 0xfff4 if cpu.EF else 0xffe4), - (0x03, AM.StackRead.MF, OP.ORA), - (0x04, AM.DirectModify.MF, OP.TSB), - (0x05, AM.DirectRead.MF, OP.ORA), - (0x06, AM.DirectModify.MF, OP.ASL), - (0x07, AM.IndirectLongRead.MF, OP.ORA), - (0x08, AM.PHP), - (0x09, AM.ImmediateRead.MF, OP.ORA), - (0x0a, AM.ImpliedModify.MF, OP.ASL, cpu.A), - (0x0b, AM.PushD), - (0x0c, AM.BankModify.MF, OP.TSB), - (0x0d, AM.BankRead.MF, OP.ORA), - (0x0e, AM.BankModify.MF, OP.ASL), - (0x0f, AM.LongRead.MF, OP.ORA), - (0x10, AM.Branch, lambda cpu: not cpu.NFlag), - (0x11, AM.IndirectIndexedRead.MF, OP.ORA), - (0x12, AM.IndirectRead.MF, OP.ORA), - (0x13, AM.IndirectStackRead.MF, OP.ORA), - (0x14, AM.DirectModify.MF, OP.TRB), - (0x15, AM.DirectRead.MF, OP.ORA, cpu.X), - (0x16, AM.DirectIndexedModify.MF, OP.ASL), - (0x17, AM.IndirectLongRead.MF, OP.ORA, cpu.Y), - (0x18, AM.CLC), - (0x19, AM.BankRead.MF, OP.ORA, cpu.Y), - (0x1a, AM.ImpliedModify.MF, OP.INC, cpu.A), - (0x1b, AM.TransferCS), - (0x1c, AM.BankModify.MF, OP.TRB), - (0x1d, AM.BankRead.MF, OP.ORA, cpu.X), - (0x1e, AM.BankIndexedModify.MF, OP.ASL), - (0x1f, AM.LongRead.MF, OP.ORA, cpu.X), - (0x20, AM.CallShort), - (0x21, AM.IndexedIndirectRead.MF, OP.AND), - (0x22, AM.CallLong), - (0x23, AM.StackRead.MF, OP.AND), - (0x24, AM.DirectRead.MF, OP.BIT), - (0x25, AM.DirectRead.MF, OP.AND), - (0x26, AM.DirectModify.MF, OP.ROL), - (0x27, AM.IndirectLongRead.MF, OP.AND), - (0x28, AM.PullP), - (0x29, AM.ImmediateRead.MF, OP.AND), - (0x2a, AM.ImpliedModify.MF, OP.ROL, cpu.A), - (0x2b, AM.PullD), - (0x2c, AM.BankRead.MF, OP.BIT), - (0x2d, AM.BankRead.MF, OP.AND), - (0x2e, AM.BankModify.MF, OP.ROL), - (0x2f, AM.LongRead.MF, OP.AND), - (0x30, AM.Branch, lambda cpu: cpu.NFlag), - (0x31, AM.IndirectIndexedRead.MF, OP.AND), - (0x32, AM.IndirectRead.MF, OP.AND), - (0x33, AM.IndirectStackRead.MF, OP.AND), - (0x34, AM.DirectRead.MF, OP.BIT, cpu.X), - (0x35, AM.DirectRead.MF, OP.AND, cpu.X), - (0x36, AM.DirectIndexedModify.MF, OP.ROL), - (0x37, AM.IndirectLongRead.MF, OP.AND, cpu.Y), - (0x38, AM.SEC), - (0x39, AM.BankRead.MF, OP.AND, cpu.Y), - (0x3a, AM.ImpliedModify.MF, OP.DEC, cpu.A), - (0x3b, AM.Transfer16, cpu.S, cpu.A), - (0x3c, AM.BankRead.MF, OP.BIT, cpu.X), - (0x3d, AM.BankRead.MF, OP.AND, cpu.X), - (0x3e, AM.BankIndexedModify.MF, OP.ROL), - (0x3f, AM.LongRead.MF, OP.AND, cpu.X), - (0x40, AM.ReturnInterrupt), - (0x41, AM.IndexedIndirectRead.MF, OP.EOR), - (0x42, AM.Prefix), - (0x43, AM.StackRead.MF, OP.EOR), - (0x44, AM.BlockMove.XF, -1), - (0x45, AM.DirectRead.MF, OP.EOR), - (0x46, AM.DirectModify.MF, OP.LSR), - (0x47, AM.IndirectLongRead.MF, OP.EOR), - (0x48, AM.Push.MF, cpu.A), - (0x49, AM.ImmediateRead.MF, OP.EOR), - (0x4a, AM.ImpliedModify.MF, OP.LSR, cpu.A), - (0x4b, AM.PHK), - (0x4c, AM.JumpShort), - (0x4d, AM.BankRead.MF, OP.EOR), - (0x4e, AM.BankModify.MF, OP.LSR), - (0x4f, AM.LongRead.MF, OP.EOR), - (0x50, AM.Branch, lambda cpu: not cpu.VFlag), - (0x51, AM.IndirectIndexedRead.MF, OP.EOR), - (0x52, AM.IndirectRead.MF, OP.EOR), - (0x53, AM.IndirectStackRead.MF, OP.EOR), - (0x54, AM.BlockMove.XF, +1), - (0x55, AM.DirectRead.MF, OP.EOR, cpu.X), - (0x56, AM.DirectIndexedModify.MF, OP.LSR), - (0x57, AM.IndirectLongRead.MF, OP.EOR, cpu.Y), - (0x58, AM.CLI), - (0x59, AM.BankRead.MF, OP.EOR, cpu.Y), - (0x5a, AM.Push.XF, cpu.Y), - (0x5b, AM.Transfer16, cpu.A, cpu.D), - (0x5c, AM.JumpLong), - (0x5d, AM.BankRead.MF, OP.EOR, cpu.X), - (0x5e, AM.BankIndexedModify.MF, OP.LSR), - (0x5f, AM.LongRead.MF, OP.EOR, cpu.X), - (0x60, AM.ReturnShort), - (0x61, AM.IndexedIndirectRead.MF, OP.ADC), - (0x62, AM.PushEffectiveRelativeAddress), - (0x63, AM.StackRead.MF, OP.ADC), - (0x64, AM.DirectWrite.MF, cpu.Z), - (0x65, AM.DirectRead.MF, OP.ADC), - (0x66, AM.DirectModify.MF, OP.ROR), - (0x67, AM.IndirectLongRead.MF, OP.ADC), - (0x68, AM.Pull.MF, cpu.A), - (0x69, AM.ImmediateRead.MF, OP.ADC), - (0x6a, AM.ImpliedModify.MF, OP.ROR, cpu.A), - (0x6b, AM.ReturnLong), - (0x6c, AM.JumpIndirect), - (0x6d, AM.BankRead.MF, OP.ADC), - (0x6e, AM.BankModify.MF, OP.ROR), - (0x6f, AM.LongRead.MF, OP.ADC), - (0x70, AM.Branch, lambda cpu: cpu.VFlag), - (0x71, AM.IndirectIndexedRead.MF, OP.ADC), - (0x72, AM.IndirectRead.MF, OP.ADC), - (0x73, AM.IndirectStackRead.MF, OP.ADC), - (0x74, AM.DirectWrite.MF, cpu.Z, cpu.X), - (0x75, AM.DirectRead.MF, OP.ADC, cpu.X), - (0x76, AM.DirectIndexedModify.MF, OP.ROR), - (0x77, AM.IndirectLongRead.MF, OP.ADC, cpu.Y), - (0x78, AM.SEI), - (0x79, AM.BankRead.MF, OP.ADC, cpu.Y), - (0x7a, AM.Pull.XF, cpu.Y), - (0x7b, AM.Transfer16, cpu.D, cpu.A), - (0x7c, AM.JumpIndexedIndirect), - (0x7d, AM.BankRead.MF, OP.ADC, cpu.X), - (0x7e, AM.BankIndexedModify.MF, OP.ROR), - (0x7f, AM.LongRead.MF, OP.ADC, cpu.X), - (0x80, AM.Branch, lambda _: True), - (0x81, AM.IndexedIndirectWrite.MF), - (0x82, AM.BranchLong), - (0x83, AM.StackWrite.MF), - (0x84, AM.DirectWrite.XF, cpu.Y), - (0x85, AM.DirectWrite.MF, cpu.A), - (0x86, AM.DirectWrite.XF, cpu.X), - (0x87, AM.IndirectLongWrite.MF), - (0x88, AM.ImpliedModify.XF, OP.DEC, cpu.Y), - (0x89, AM.BitImmediate.MF), - (0x8a, AM.Transfer.MF, cpu.X, cpu.A), - (0x8b, AM.PHB), - (0x8c, AM.BankWrite.XF, cpu.Y), - (0x8d, AM.BankWrite.MF, cpu.A), - (0x8e, AM.BankWrite.XF, cpu.X), - (0x8f, AM.LongWrite.MF), - (0x90, AM.Branch, lambda cpu: not cpu.CFlag), - (0x91, AM.IndirectIndexedWrite.MF), - (0x92, AM.IndirectWrite.MF), - (0x93, AM.IndirectStackWrite.MF), - (0x94, AM.DirectWrite.XF, cpu.Y, cpu.X), - (0x95, AM.DirectWrite.MF, cpu.A, cpu.X), - (0x96, AM.DirectWrite.XF, cpu.X, cpu.Y), - (0x97, AM.IndirectLongWrite.MF, cpu.Y), - (0x98, AM.Transfer.MF, cpu.Y, cpu.A), - (0x99, AM.BankWrite.MF, cpu.A, cpu.Y), - (0x9a, AM.TransferXS), - (0x9b, AM.Transfer.XF, cpu.X, cpu.Y), - (0x9c, AM.BankWrite.MF, cpu.Z), - (0x9d, AM.BankWrite.MF, cpu.A, cpu.X), - (0x9e, AM.BankWrite.MF, cpu.Z, cpu.X), - (0x9f, AM.LongWrite.MF, cpu.X), - (0xa0, AM.ImmediateRead.XF, OP.LDY), - (0xa1, AM.IndexedIndirectRead.MF, OP.LDA), - (0xa2, AM.ImmediateRead.XF, OP.LDX), - (0xa3, AM.StackRead.MF, OP.LDA), - (0xa4, AM.DirectRead.XF, OP.LDY), - (0xa5, AM.DirectRead.MF, OP.LDA), - (0xa6, AM.DirectRead.XF, OP.LDX), - (0xa7, AM.IndirectLongRead.MF, OP.LDA), - (0xa8, AM.Transfer.XF, cpu.A, cpu.Y), - (0xa9, AM.ImmediateRead.MF, OP.LDA), - (0xaa, AM.Transfer.XF, cpu.A, cpu.X), - (0xab, AM.PullB), - (0xac, AM.BankRead.XF, OP.LDY), - (0xad, AM.BankRead.MF, OP.LDA), - (0xae, AM.BankRead.XF, OP.LDX), - (0xaf, AM.LongRead.MF, OP.LDA), - (0xb0, AM.Branch, lambda cpu: cpu.CFlag), - (0xb1, AM.IndirectIndexedRead.MF, OP.LDA), - (0xb2, AM.IndirectRead.MF, OP.LDA), - (0xb3, AM.IndirectStackRead.MF, OP.LDA), - (0xb4, AM.DirectRead.XF, OP.LDY, cpu.X), - (0xb5, AM.DirectRead.MF, OP.LDA, cpu.X), - (0xb6, AM.DirectRead.XF, OP.LDX, cpu.Y), - (0xb7, AM.IndirectLongRead.MF, OP.LDA, cpu.Y), - (0xb8, AM.CLV), - (0xb9, AM.BankRead.MF, OP.LDA, cpu.Y), - (0xba, AM.TransferSX.XF), - (0xbb, AM.Transfer.XF, cpu.Y, cpu.X), - (0xbc, AM.BankRead.XF, OP.LDY, cpu.X), - (0xbd, AM.BankRead.MF, OP.LDA, cpu.X), - (0xbe, AM.BankRead.XF, OP.LDX, cpu.Y), - (0xbf, AM.LongRead.MF, OP.LDA, cpu.X), - (0xc0, AM.ImmediateRead.XF, OP.CPY), - (0xc1, AM.IndexedIndirectRead.MF, OP.CMP), - (0xc2, AM.ResetP), - (0xc3, AM.StackRead.MF, OP.CMP), - (0xc4, AM.DirectRead.XF, OP.CPY), - (0xc5, AM.DirectRead.MF, OP.CMP), - (0xc6, AM.DirectModify.MF, OP.DEC), - (0xc7, AM.IndirectLongRead.MF, OP.CMP), - (0xc8, AM.ImpliedModify.XF, OP.INC, cpu.Y), - (0xc9, AM.ImmediateRead.MF, OP.CMP), - (0xca, AM.ImpliedModify.XF, OP.DEC, cpu.X), - (0xcb, AM.Wait), - (0xcc, AM.BankRead.XF, OP.CPY), - (0xcd, AM.BankRead.MF, OP.CMP), - (0xce, AM.BankModify.MF, OP.DEC), - (0xcf, AM.LongRead.MF, OP.CMP), - (0xd0, AM.Branch, lambda cpu: not cpu.ZFlag), - (0xd1, AM.IndirectIndexedRead.MF, OP.CMP), - (0xd2, AM.IndirectRead.MF, OP.CMP), - (0xd3, AM.IndirectStackRead.MF, OP.CMP), - (0xd4, AM.PushEffectiveIndirectAddress), - (0xd5, AM.DirectRead.MF, OP.CMP, cpu.X), - (0xd6, AM.DirectIndexedModify.MF, OP.DEC), - (0xd7, AM.IndirectLongRead.MF, OP.CMP, cpu.Y), - (0xd8, AM.CLD), - (0xd9, AM.BankRead.MF, OP.CMP, cpu.Y), - (0xda, AM.Push.XF, cpu.X), - (0xdb, AM.Stop), - (0xdc, AM.JumpIndirectLong), - (0xdd, AM.BankRead.MF, OP.CMP, cpu.X), - (0xde, AM.BankIndexedModify.MF, OP.DEC), - (0xdf, AM.LongRead.MF, OP.CMP, cpu.X), - (0xe0, AM.ImmediateRead.XF, OP.CPX), - (0xe1, AM.IndexedIndirectRead.MF, OP.SBC), - (0xe2, AM.SetP), - (0xe3, AM.StackRead.MF, OP.SBC), - (0xe4, AM.DirectRead.XF, OP.CPX), - (0xe5, AM.DirectRead.MF, OP.SBC), - (0xe6, AM.DirectModify.MF, OP.INC), - (0xe7, AM.IndirectLongRead.MF, OP.SBC), - (0xe8, AM.ImpliedModify.XF, OP.INC, cpu.X), - (0xe9, AM.ImmediateRead.MF, OP.SBC), - (0xea, AM.NoOperation), - (0xeb, AM.ExchangeBA), - (0xec, AM.BankRead.XF, OP.CPX), - (0xed, AM.BankRead.MF, OP.SBC), - (0xee, AM.BankModify.MF, OP.INC), - (0xef, AM.LongRead.MF, OP.SBC), - (0xf0, AM.Branch, lambda cpu: cpu.ZFlag), - (0xf1, AM.IndirectIndexedRead.MF, OP.SBC), - (0xf2, AM.IndirectRead.MF, OP.SBC), - (0xf3, AM.IndirectStackRead.MF, OP.SBC), - (0xf4, AM.PushEffectiveAddress), - (0xf5, AM.DirectRead.MF, OP.SBC, cpu.X), - (0xf6, AM.DirectIndexedModify.MF, OP.INC), - (0xf7, AM.IndirectLongRead.MF, OP.SBC, cpu.Y), - (0xf8, AM.SED), - (0xf9, AM.BankRead.MF, OP.SBC, cpu.Y), - (0xfa, AM.Pull.XF, cpu.X), - (0xfb, AM.ExchangeCE), - (0xfc, AM.CallIndexedIndirect), - (0xfd, AM.BankRead.MF, OP.SBC, cpu.X), - (0xfe, AM.BankIndexedModify.MF, OP.INC), - (0xff, AM.LongRead.MF, OP.SBC, cpu.X), -) + (0x00, AM.Interrupt, lambda cpu: 0xFFFE if cpu.EF else 0xFFE6), + (0x01, AM.IndexedIndirectRead.MF, OP.ORA), + (0x02, AM.Interrupt, lambda cpu: 0xFFF4 if cpu.EF else 0xFFE4), + (0x03, AM.StackRead.MF, OP.ORA), + (0x04, AM.DirectModify.MF, OP.TSB), + (0x05, AM.DirectRead.MF, OP.ORA), + (0x06, AM.DirectModify.MF, OP.ASL), + (0x07, AM.IndirectLongRead.MF, OP.ORA), + (0x08, AM.PHP), + (0x09, AM.ImmediateRead.MF, OP.ORA), + (0x0A, AM.ImpliedModify.MF, OP.ASL, cpu.A), + (0x0B, AM.PushD), + (0x0C, AM.BankModify.MF, OP.TSB), + (0x0D, AM.BankRead.MF, OP.ORA), + (0x0E, AM.BankModify.MF, OP.ASL), + (0x0F, AM.LongRead.MF, OP.ORA), + (0x10, AM.Branch, lambda cpu: not cpu.NFlag), + (0x11, AM.IndirectIndexedRead.MF, OP.ORA), + (0x12, AM.IndirectRead.MF, OP.ORA), + (0x13, AM.IndirectStackRead.MF, OP.ORA), + (0x14, AM.DirectModify.MF, OP.TRB), + (0x15, AM.DirectRead.MF, OP.ORA, cpu.X), + (0x16, AM.DirectIndexedModify.MF, OP.ASL), + (0x17, AM.IndirectLongRead.MF, OP.ORA, cpu.Y), + (0x18, AM.CLC), + (0x19, AM.BankRead.MF, OP.ORA, cpu.Y), + (0x1A, AM.ImpliedModify.MF, OP.INC, cpu.A), + (0x1B, AM.TransferCS), + (0x1C, AM.BankModify.MF, OP.TRB), + (0x1D, AM.BankRead.MF, OP.ORA, cpu.X), + (0x1E, AM.BankIndexedModify.MF, OP.ASL), + (0x1F, AM.LongRead.MF, OP.ORA, cpu.X), + (0x20, AM.CallShort), + (0x21, AM.IndexedIndirectRead.MF, OP.AND), + (0x22, AM.CallLong), + (0x23, AM.StackRead.MF, OP.AND), + (0x24, AM.DirectRead.MF, OP.BIT), + (0x25, AM.DirectRead.MF, OP.AND), + (0x26, AM.DirectModify.MF, OP.ROL), + (0x27, AM.IndirectLongRead.MF, OP.AND), + (0x28, AM.PullP), + (0x29, AM.ImmediateRead.MF, OP.AND), + (0x2A, AM.ImpliedModify.MF, OP.ROL, cpu.A), + (0x2B, AM.PullD), + (0x2C, AM.BankRead.MF, OP.BIT), + (0x2D, AM.BankRead.MF, OP.AND), + (0x2E, AM.BankModify.MF, OP.ROL), + (0x2F, AM.LongRead.MF, OP.AND), + (0x30, AM.Branch, lambda cpu: cpu.NFlag), + (0x31, AM.IndirectIndexedRead.MF, OP.AND), + (0x32, AM.IndirectRead.MF, OP.AND), + (0x33, AM.IndirectStackRead.MF, OP.AND), + (0x34, AM.DirectRead.MF, OP.BIT, cpu.X), + (0x35, AM.DirectRead.MF, OP.AND, cpu.X), + (0x36, AM.DirectIndexedModify.MF, OP.ROL), + (0x37, AM.IndirectLongRead.MF, OP.AND, cpu.Y), + (0x38, AM.SEC), + (0x39, AM.BankRead.MF, OP.AND, cpu.Y), + (0x3A, AM.ImpliedModify.MF, OP.DEC, cpu.A), + (0x3B, AM.Transfer16, cpu.S, cpu.A), + (0x3C, AM.BankRead.MF, OP.BIT, cpu.X), + (0x3D, AM.BankRead.MF, OP.AND, cpu.X), + (0x3E, AM.BankIndexedModify.MF, OP.ROL), + (0x3F, AM.LongRead.MF, OP.AND, cpu.X), + (0x40, AM.ReturnInterrupt), + (0x41, AM.IndexedIndirectRead.MF, OP.EOR), + (0x42, AM.Prefix), + (0x43, AM.StackRead.MF, OP.EOR), + (0x44, AM.BlockMove.XF, -1), + (0x45, AM.DirectRead.MF, OP.EOR), + (0x46, AM.DirectModify.MF, OP.LSR), + (0x47, AM.IndirectLongRead.MF, OP.EOR), + (0x48, AM.Push.MF, cpu.A), + (0x49, AM.ImmediateRead.MF, OP.EOR), + (0x4A, AM.ImpliedModify.MF, OP.LSR, cpu.A), + (0x4B, AM.PHK), + (0x4C, AM.JumpShort), + (0x4D, AM.BankRead.MF, OP.EOR), + (0x4E, AM.BankModify.MF, OP.LSR), + (0x4F, AM.LongRead.MF, OP.EOR), + (0x50, AM.Branch, lambda cpu: not cpu.VFlag), + (0x51, AM.IndirectIndexedRead.MF, OP.EOR), + (0x52, AM.IndirectRead.MF, OP.EOR), + (0x53, AM.IndirectStackRead.MF, OP.EOR), + (0x54, AM.BlockMove.XF, +1), + (0x55, AM.DirectRead.MF, OP.EOR, cpu.X), + (0x56, AM.DirectIndexedModify.MF, OP.LSR), + (0x57, AM.IndirectLongRead.MF, OP.EOR, cpu.Y), + (0x58, AM.CLI), + (0x59, AM.BankRead.MF, OP.EOR, cpu.Y), + (0x5A, AM.Push.XF, cpu.Y), + (0x5B, AM.Transfer16, cpu.A, cpu.D), + (0x5C, AM.JumpLong), + (0x5D, AM.BankRead.MF, OP.EOR, cpu.X), + (0x5E, AM.BankIndexedModify.MF, OP.LSR), + (0x5F, AM.LongRead.MF, OP.EOR, cpu.X), + (0x60, AM.ReturnShort), + (0x61, AM.IndexedIndirectRead.MF, OP.ADC), + (0x62, AM.PushEffectiveRelativeAddress), + (0x63, AM.StackRead.MF, OP.ADC), + (0x64, AM.DirectWrite.MF, cpu.Z), + (0x65, AM.DirectRead.MF, OP.ADC), + (0x66, AM.DirectModify.MF, OP.ROR), + (0x67, AM.IndirectLongRead.MF, OP.ADC), + (0x68, AM.Pull.MF, cpu.A), + (0x69, AM.ImmediateRead.MF, OP.ADC), + (0x6A, AM.ImpliedModify.MF, OP.ROR, cpu.A), + (0x6B, AM.ReturnLong), + (0x6C, AM.JumpIndirect), + (0x6D, AM.BankRead.MF, OP.ADC), + (0x6E, AM.BankModify.MF, OP.ROR), + (0x6F, AM.LongRead.MF, OP.ADC), + (0x70, AM.Branch, lambda cpu: cpu.VFlag), + (0x71, AM.IndirectIndexedRead.MF, OP.ADC), + (0x72, AM.IndirectRead.MF, OP.ADC), + (0x73, AM.IndirectStackRead.MF, OP.ADC), + (0x74, AM.DirectWrite.MF, cpu.Z, cpu.X), + (0x75, AM.DirectRead.MF, OP.ADC, cpu.X), + (0x76, AM.DirectIndexedModify.MF, OP.ROR), + (0x77, AM.IndirectLongRead.MF, OP.ADC, cpu.Y), + (0x78, AM.SEI), + (0x79, AM.BankRead.MF, OP.ADC, cpu.Y), + (0x7A, AM.Pull.XF, cpu.Y), + (0x7B, AM.Transfer16, cpu.D, cpu.A), + (0x7C, AM.JumpIndexedIndirect), + (0x7D, AM.BankRead.MF, OP.ADC, cpu.X), + (0x7E, AM.BankIndexedModify.MF, OP.ROR), + (0x7F, AM.LongRead.MF, OP.ADC, cpu.X), + (0x80, AM.Branch, lambda _: True), + (0x81, AM.IndexedIndirectWrite.MF), + (0x82, AM.BranchLong), + (0x83, AM.StackWrite.MF), + (0x84, AM.DirectWrite.XF, cpu.Y), + (0x85, AM.DirectWrite.MF, cpu.A), + (0x86, AM.DirectWrite.XF, cpu.X), + (0x87, AM.IndirectLongWrite.MF), + (0x88, AM.ImpliedModify.XF, OP.DEC, cpu.Y), + (0x89, AM.BitImmediate.MF), + (0x8A, AM.Transfer.MF, cpu.X, cpu.A), + (0x8B, AM.PHB), + (0x8C, AM.BankWrite.XF, cpu.Y), + (0x8D, AM.BankWrite.MF, cpu.A), + (0x8E, AM.BankWrite.XF, cpu.X), + (0x8F, AM.LongWrite.MF), + (0x90, AM.Branch, lambda cpu: not cpu.CFlag), + (0x91, AM.IndirectIndexedWrite.MF), + (0x92, AM.IndirectWrite.MF), + (0x93, AM.IndirectStackWrite.MF), + (0x94, AM.DirectWrite.XF, cpu.Y, cpu.X), + (0x95, AM.DirectWrite.MF, cpu.A, cpu.X), + (0x96, AM.DirectWrite.XF, cpu.X, cpu.Y), + (0x97, AM.IndirectLongWrite.MF, cpu.Y), + (0x98, AM.Transfer.MF, cpu.Y, cpu.A), + (0x99, AM.BankWrite.MF, cpu.A, cpu.Y), + (0x9A, AM.TransferXS), + (0x9B, AM.Transfer.XF, cpu.X, cpu.Y), + (0x9C, AM.BankWrite.MF, cpu.Z), + (0x9D, AM.BankWrite.MF, cpu.A, cpu.X), + (0x9E, AM.BankWrite.MF, cpu.Z, cpu.X), + (0x9F, AM.LongWrite.MF, cpu.X), + (0xA0, AM.ImmediateRead.XF, OP.LDY), + (0xA1, AM.IndexedIndirectRead.MF, OP.LDA), + (0xA2, AM.ImmediateRead.XF, OP.LDX), + (0xA3, AM.StackRead.MF, OP.LDA), + (0xA4, AM.DirectRead.XF, OP.LDY), + (0xA5, AM.DirectRead.MF, OP.LDA), + (0xA6, AM.DirectRead.XF, OP.LDX), + (0xA7, AM.IndirectLongRead.MF, OP.LDA), + (0xA8, AM.Transfer.XF, cpu.A, cpu.Y), + (0xA9, AM.ImmediateRead.MF, OP.LDA), + (0xAA, AM.Transfer.XF, cpu.A, cpu.X), + (0xAB, AM.PullB), + (0xAC, AM.BankRead.XF, OP.LDY), + (0xAD, AM.BankRead.MF, OP.LDA), + (0xAE, AM.BankRead.XF, OP.LDX), + (0xAF, AM.LongRead.MF, OP.LDA), + (0xB0, AM.Branch, lambda cpu: cpu.CFlag), + (0xB1, AM.IndirectIndexedRead.MF, OP.LDA), + (0xB2, AM.IndirectRead.MF, OP.LDA), + (0xB3, AM.IndirectStackRead.MF, OP.LDA), + (0xB4, AM.DirectRead.XF, OP.LDY, cpu.X), + (0xB5, AM.DirectRead.MF, OP.LDA, cpu.X), + (0xB6, AM.DirectRead.XF, OP.LDX, cpu.Y), + (0xB7, AM.IndirectLongRead.MF, OP.LDA, cpu.Y), + (0xB8, AM.CLV), + (0xB9, AM.BankRead.MF, OP.LDA, cpu.Y), + (0xBA, AM.TransferSX.XF), + (0xBB, AM.Transfer.XF, cpu.Y, cpu.X), + (0xBC, AM.BankRead.XF, OP.LDY, cpu.X), + (0xBD, AM.BankRead.MF, OP.LDA, cpu.X), + (0xBE, AM.BankRead.XF, OP.LDX, cpu.Y), + (0xBF, AM.LongRead.MF, OP.LDA, cpu.X), + (0xC0, AM.ImmediateRead.XF, OP.CPY), + (0xC1, AM.IndexedIndirectRead.MF, OP.CMP), + (0xC2, AM.ResetP), + (0xC3, AM.StackRead.MF, OP.CMP), + (0xC4, AM.DirectRead.XF, OP.CPY), + (0xC5, AM.DirectRead.MF, OP.CMP), + (0xC6, AM.DirectModify.MF, OP.DEC), + (0xC7, AM.IndirectLongRead.MF, OP.CMP), + (0xC8, AM.ImpliedModify.XF, OP.INC, cpu.Y), + (0xC9, AM.ImmediateRead.MF, OP.CMP), + (0xCA, AM.ImpliedModify.XF, OP.DEC, cpu.X), + (0xCB, AM.Wait), + (0xCC, AM.BankRead.XF, OP.CPY), + (0xCD, AM.BankRead.MF, OP.CMP), + (0xCE, AM.BankModify.MF, OP.DEC), + (0xCF, AM.LongRead.MF, OP.CMP), + (0xD0, AM.Branch, lambda cpu: not cpu.ZFlag), + (0xD1, AM.IndirectIndexedRead.MF, OP.CMP), + (0xD2, AM.IndirectRead.MF, OP.CMP), + (0xD3, AM.IndirectStackRead.MF, OP.CMP), + (0xD4, AM.PushEffectiveIndirectAddress), + (0xD5, AM.DirectRead.MF, OP.CMP, cpu.X), + (0xD6, AM.DirectIndexedModify.MF, OP.DEC), + (0xD7, AM.IndirectLongRead.MF, OP.CMP, cpu.Y), + (0xD8, AM.CLD), + (0xD9, AM.BankRead.MF, OP.CMP, cpu.Y), + (0xDA, AM.Push.XF, cpu.X), + (0xDB, AM.Stop), + (0xDC, AM.JumpIndirectLong), + (0xDD, AM.BankRead.MF, OP.CMP, cpu.X), + (0xDE, AM.BankIndexedModify.MF, OP.DEC), + (0xDF, AM.LongRead.MF, OP.CMP, cpu.X), + (0xE0, AM.ImmediateRead.XF, OP.CPX), + (0xE1, AM.IndexedIndirectRead.MF, OP.SBC), + (0xE2, AM.SetP), + (0xE3, AM.StackRead.MF, OP.SBC), + (0xE4, AM.DirectRead.XF, OP.CPX), + (0xE5, AM.DirectRead.MF, OP.SBC), + (0xE6, AM.DirectModify.MF, OP.INC), + (0xE7, AM.IndirectLongRead.MF, OP.SBC), + (0xE8, AM.ImpliedModify.XF, OP.INC, cpu.X), + (0xE9, AM.ImmediateRead.MF, OP.SBC), + (0xEA, AM.NoOperation), + (0xEB, AM.ExchangeBA), + (0xEC, AM.BankRead.XF, OP.CPX), + (0xED, AM.BankRead.MF, OP.SBC), + (0xEE, AM.BankModify.MF, OP.INC), + (0xEF, AM.LongRead.MF, OP.SBC), + (0xF0, AM.Branch, lambda cpu: cpu.ZFlag), + (0xF1, AM.IndirectIndexedRead.MF, OP.SBC), + (0xF2, AM.IndirectRead.MF, OP.SBC), + (0xF3, AM.IndirectStackRead.MF, OP.SBC), + (0xF4, AM.PushEffectiveAddress), + (0xF5, AM.DirectRead.MF, OP.SBC, cpu.X), + (0xF6, AM.DirectIndexedModify.MF, OP.INC), + (0xF7, AM.IndirectLongRead.MF, OP.SBC, cpu.Y), + (0xF8, AM.SED), + (0xF9, AM.BankRead.MF, OP.SBC, cpu.Y), + (0xFA, AM.Pull.XF, cpu.X), + (0xFB, AM.ExchangeCE), + (0xFC, AM.CallIndexedIndirect), + (0xFD, AM.BankRead.MF, OP.SBC, cpu.X), + (0xFE, AM.BankIndexedModify.MF, OP.INC), + (0xFF, AM.LongRead.MF, OP.SBC, cpu.X), + ) diff --git a/pysnes/cpu/wdc65816/opcodes.py b/pysnes/cpu/wdc65816/opcodes.py index 7a4c2ce..b393b75 100644 --- a/pysnes/cpu/wdc65816/opcodes.py +++ b/pysnes/cpu/wdc65816/opcodes.py @@ -6,48 +6,67 @@ def ADC(cpu: Cpu, mode_8bit: bool, data: int): if not cpu.DFlag: result = cpu.A.l + data + cpu.CFlag else: - result = (cpu.A.l & 0x0f) + (data & 0x0f) + (cpu.CFlag << 0) + result = (cpu.A.l & 0x0F) + (data & 0x0F) + (cpu.CFlag << 0) if result > 0x09: result += 0x06 - cpu.CFlag = result > 0x0f - result = (cpu.A.l & 0xf0) + (data & 0xf0) + (cpu.CFlag << 4) + (result & 0x0f) + cpu.CFlag = result > 0x0F + result = ( + (cpu.A.l & 0xF0) + + (data & 0xF0) + + (cpu.CFlag << 4) + + (result & 0x0F) + ) cpu.VFlag = bool(~(cpu.A.l ^ data) & (cpu.A.l ^ result) & 0x80) - if cpu.DFlag and result > 0x9f: + if cpu.DFlag and result > 0x9F: result += 0x60 - cpu.CFlag = result > 0xff - cpu.ZFlag = result & 0xff == 0 + cpu.CFlag = result > 0xFF + cpu.ZFlag = result & 0xFF == 0 cpu.NFlag = bool(result & 0x80) cpu.A.l = result return cpu.A.l + if not cpu.DFlag: + result = cpu.A.w + data + cpu.CFlag else: - if not cpu.DFlag: - result = cpu.A.w + data + cpu.CFlag - else: - result = (cpu.A.w & 0x000f) + (data & 0x000f) + (cpu.CFlag << 0) - if result > 0x0009: - result += 0x0006 - cpu.CFlag = result > 0x000f - result = (cpu.A.w & 0x00f0) + (data & 0x00f0) + (cpu.CFlag << 4) + (result & 0x000f) - if result > 0x009f: - result += 0x0060 - cpu.CFlag = result > 0x00ff - result = (cpu.A.w & 0x0f00) + (data & 0x0f00) + (cpu.CFlag << 8) + (result & 0x00ff) - if result > 0x09ff: - result += 0x0600 - cpu.CFlag = result > 0x0fff - result = (cpu.A.w & 0xf000) + (data & 0xf000) + (cpu.CFlag << 12) + (result & 0x0fff) - - cpu.VFlag = bool(~(cpu.A.w ^ data) & (cpu.A.w ^ result) & 0x8000) - if cpu.DFlag and result > 0x9fff: - result += 0x6000 - cpu.CFlag = result > 0xffff - cpu.ZFlag = result & 0xffff == 0 - cpu.NFlag = bool(result & 0x8000) - - cpu.A.w = result - return cpu.A.w + result = (cpu.A.w & 0x000F) + (data & 0x000F) + (cpu.CFlag << 0) + if result > 0x0009: + result += 0x0006 + cpu.CFlag = result > 0x000F + result = ( + (cpu.A.w & 0x00F0) + + (data & 0x00F0) + + (cpu.CFlag << 4) + + (result & 0x000F) + ) + if result > 0x009F: + result += 0x0060 + cpu.CFlag = result > 0x00FF + result = ( + (cpu.A.w & 0x0F00) + + (data & 0x0F00) + + (cpu.CFlag << 8) + + (result & 0x00FF) + ) + if result > 0x09FF: + result += 0x0600 + cpu.CFlag = result > 0x0FFF + result = ( + (cpu.A.w & 0xF000) + + (data & 0xF000) + + (cpu.CFlag << 12) + + (result & 0x0FFF) + ) + + cpu.VFlag = bool(~(cpu.A.w ^ data) & (cpu.A.w ^ result) & 0x8000) + if cpu.DFlag and result > 0x9FFF: + result += 0x6000 + cpu.CFlag = result > 0xFFFF + cpu.ZFlag = result & 0xFFFF == 0 + cpu.NFlag = bool(result & 0x8000) + + cpu.A.w = result + return cpu.A.w def AND(cpu: Cpu, mode_8bit: bool, data: int): @@ -56,26 +75,24 @@ def AND(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = cpu.A.l == 0 cpu.NFlag = bool(cpu.A.l & 0x80) return cpu.A.l - else: - cpu.A.w &= data - cpu.ZFlag = cpu.A.w == 0 - cpu.NFlag = bool(cpu.A.w & 0x8000) - return cpu.A.w + cpu.A.w &= data + cpu.ZFlag = cpu.A.w == 0 + cpu.NFlag = bool(cpu.A.w & 0x8000) + return cpu.A.w def ASL(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: cpu.CFlag = bool(data & 0x80) data <<= 1 - cpu.ZFlag = data & 0xff == 0 + cpu.ZFlag = data & 0xFF == 0 cpu.NFlag = bool(data & 0x80) - return data; - else: - cpu.CFlag = bool(data & 0x8000) - data <<= 1 - cpu.ZFlag = data & 0xffff == 0 - cpu.NFlag = bool(data & 0x8000) return data + cpu.CFlag = bool(data & 0x8000) + data <<= 1 + cpu.ZFlag = data & 0xFFFF == 0 + cpu.NFlag = bool(data & 0x8000) + return data def BIT(cpu: Cpu, mode_8bit: bool, data: int): @@ -84,69 +101,64 @@ def BIT(cpu: Cpu, mode_8bit: bool, data: int): cpu.VFlag = bool(data & 0x40) cpu.NFlag = bool(data & 0x80) return data - else: - cpu.ZFlag = (data & cpu.A.w) == 0 - cpu.VFlag = bool(data & 0x4000) - cpu.NFlag = bool(data & 0x8000) - return data + cpu.ZFlag = (data & cpu.A.w) == 0 + cpu.VFlag = bool(data & 0x4000) + cpu.NFlag = bool(data & 0x8000) + return data def CMP(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: result = cpu.A.l - data cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xff == 0 + cpu.ZFlag = result & 0xFF == 0 cpu.NFlag = bool(result & 0x80) return result - else: - result = cpu.A.w - data - cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xffff == 0 - cpu.NFlag = bool(result & 0x8000) - return result + result = cpu.A.w - data + cpu.CFlag = result >= 0 + cpu.ZFlag = result & 0xFFFF == 0 + cpu.NFlag = bool(result & 0x8000) + return result def CPX(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: result = cpu.X.l - data cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xff == 0 + cpu.ZFlag = result & 0xFF == 0 cpu.NFlag = bool(result & 0x80) return result - else: - result = cpu.X.w - data - cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xffff == 0 - cpu.NFlag = bool(result & 0x8000) - return result + result = cpu.X.w - data + cpu.CFlag = result >= 0 + cpu.ZFlag = result & 0xFFFF == 0 + cpu.NFlag = bool(result & 0x8000) + return result def CPY(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: result = cpu.Y.l - data cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xff == 0 + cpu.ZFlag = result & 0xFF == 0 cpu.NFlag = bool(result & 0x80) return result - else: - result = cpu.Y.w - data - cpu.CFlag = result >= 0 - cpu.ZFlag = result & 0xffff == 0 - cpu.NFlag = bool(result & 0x8000) - return result + result = cpu.Y.w - data + cpu.CFlag = result >= 0 + cpu.ZFlag = result & 0xFFFF == 0 + cpu.NFlag = bool(result & 0x8000) + return result def DEC(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: - data = (data - 1) & 0xff + data = (data - 1) & 0xFF cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x80) return data - else: - data = (data - 1) & 0xffff - cpu.ZFlag = data == 0 - cpu.NFlag = bool(data & 0x8000) - return data + data = (data - 1) & 0xFFFF + cpu.ZFlag = data == 0 + cpu.NFlag = bool(data & 0x8000) + return data def EOR(cpu: Cpu, mode_8bit: bool, data: int): @@ -155,11 +167,10 @@ def EOR(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = cpu.A.l == 0 cpu.NFlag = bool(cpu.A.l & 0x80) return cpu.A.l - else: - cpu.A.w ^= data - cpu.ZFlag = cpu.A.w == 0 - cpu.NFlag = bool(cpu.A.w & 0x8000) - return cpu.A.w + cpu.A.w ^= data + cpu.ZFlag = cpu.A.w == 0 + cpu.NFlag = bool(cpu.A.w & 0x8000) + return cpu.A.w def INC(cpu: Cpu, mode_8bit: bool, data: int): @@ -168,24 +179,22 @@ def INC(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x80) return data - else: - data = (data + 1) & 0xFFFF - cpu.ZFlag = data == 0 - cpu.NFlag = bool(data & 0x8000) - return data + data = (data + 1) & 0xFFFF + cpu.ZFlag = data == 0 + cpu.NFlag = bool(data & 0x8000) + return data def LDA(cpu: Cpu, mode_8bit: bool, data: int): if mode_8bit: - cpu.A.l = data; - cpu.ZFlag = cpu.A.l == 0; + cpu.A.l = data + cpu.ZFlag = cpu.A.l == 0 cpu.NFlag = bool(cpu.A.l & 0x80) - return data; - else: - cpu.A.w = data; - cpu.ZFlag = cpu.A.w == 0; - cpu.NFlag = bool(cpu.A.w & 0x8000) - return data; + return data + cpu.A.w = data + cpu.ZFlag = cpu.A.w == 0 + cpu.NFlag = bool(cpu.A.w & 0x8000) + return data def LDX(cpu: Cpu, mode_8bit: bool, data: int): @@ -194,11 +203,10 @@ def LDX(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = cpu.X.l == 0 cpu.NFlag = bool(cpu.X.l & 0x80) return data - else: - cpu.X.w = data - cpu.ZFlag = cpu.X.w == 0 - cpu.NFlag = bool(cpu.X.w & 0x8000) - return data + cpu.X.w = data + cpu.ZFlag = cpu.X.w == 0 + cpu.NFlag = bool(cpu.X.w & 0x8000) + return data def LDY(cpu: Cpu, mode_8bit: bool, data: int): @@ -207,11 +215,10 @@ def LDY(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = cpu.Y.l == 0 cpu.NFlag = bool(cpu.Y.l & 0x80) return data - else: - cpu.Y.w = data - cpu.ZFlag = cpu.Y.w == 0 - cpu.NFlag = bool(cpu.Y.w & 0x8000) - return data + cpu.Y.w = data + cpu.ZFlag = cpu.Y.w == 0 + cpu.NFlag = bool(cpu.Y.w & 0x8000) + return data def LSR(cpu: Cpu, mode_8bit: bool, data: int): @@ -221,12 +228,11 @@ def LSR(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x80) return data - else: - cpu.CFlag = data & 1 - data = (data >> 1) & 0xFFFF - cpu.ZFlag = data == 0 - cpu.NFlag = bool(data & 0x8000) - return data + cpu.CFlag = data & 1 + data = (data >> 1) & 0xFFFF + cpu.ZFlag = data == 0 + cpu.NFlag = bool(data & 0x8000) + return data def ORA(cpu: Cpu, mode_8bit: bool, data: int): @@ -235,11 +241,10 @@ def ORA(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = cpu.A.l == 0 cpu.NFlag = bool(cpu.A.l & 0x80) return cpu.A.l - else: - cpu.A.w |= data - cpu.ZFlag = cpu.A.w == 0 - cpu.NFlag = bool(cpu.A.w & 0x8000) - return cpu.A.w + cpu.A.w |= data + cpu.ZFlag = cpu.A.w == 0 + cpu.NFlag = bool(cpu.A.w & 0x8000) + return cpu.A.w def ROL(cpu: Cpu, mode_8bit: bool, data: int): @@ -250,13 +255,12 @@ def ROL(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x80) return data - else: - carry = cpu.CFlag - cpu.CFlag = bool(data & 0x8000) - data = (data << 1 | carry) & 0xFFFF - cpu.ZFlag = data == 0 - cpu.NFlag = bool(data & 0x8000) - return data + carry = cpu.CFlag + cpu.CFlag = bool(data & 0x8000) + data = (data << 1 | carry) & 0xFFFF + cpu.ZFlag = data == 0 + cpu.NFlag = bool(data & 0x8000) + return data def ROR(cpu: Cpu, mode_8bit: bool, data: int): @@ -267,68 +271,86 @@ def ROR(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x80) return data - else: - carry = cpu.CFlag - cpu.CFlag = bool(data & 1) - data = carry << 15 | data >> 1 - cpu.ZFlag = data == 0 - cpu.NFlag = bool(data & 0x8000) - return data + carry = cpu.CFlag + cpu.CFlag = bool(data & 1) + data = carry << 15 | data >> 1 + cpu.ZFlag = data == 0 + cpu.NFlag = bool(data & 0x8000) + return data def SBC(cpu: Cpu, mode_8bit: bool, data: int): # bsnes - result is always an int data = ~data # this may come as 8bit or 16bit - masking below if mode_8bit: - data &= 0xff + data &= 0xFF if not cpu.DFlag: result = cpu.A.l + data + cpu.CFlag else: - result = (cpu.A.l & 0x0f) + (data & 0x0f) + (cpu.CFlag << 0) - if result <= 0x0f: + result = (cpu.A.l & 0x0F) + (data & 0x0F) + (cpu.CFlag << 0) + if result <= 0x0F: result -= 0x06 - cpu.CFlag = result > 0x0f - result = (cpu.A.l & 0xf0) + (data & 0xf0) + (cpu.CFlag << 4) + (result & 0x0f) + cpu.CFlag = result > 0x0F + result = ( + (cpu.A.l & 0xF0) + + (data & 0xF0) + + (cpu.CFlag << 4) + + (result & 0x0F) + ) cpu.VFlag = bool(~(cpu.A.l ^ data) & (cpu.A.l ^ result) & 0x80) - if cpu.DFlag and result <= 0xff: + if cpu.DFlag and result <= 0xFF: result -= 0x60 - cpu.CFlag = result > 0xff - cpu.ZFlag = result & 0xff == 0 + cpu.CFlag = result > 0xFF + cpu.ZFlag = result & 0xFF == 0 cpu.NFlag = bool(result & 0x80) cpu.A.l = result return cpu.A.l - else: - data &= 0xffff + data &= 0xFFFF - if not cpu.DFlag: - result = cpu.A.w + data + cpu.CFlag - else: - result = (cpu.A.w & 0x000f) + (data & 0x000f) + (cpu.CFlag << 0) - if result <= 0x000f: - result -= 0x0006 - cpu.CFlag = result > 0x000f - result = (cpu.A.w & 0x00f0) + (data & 0x00f0) + (cpu.CFlag << 4) + (result & 0x000f) - if result <= 0x00ff: - result -= 0x0060 - cpu.CFlag = result > 0x00ff - result = (cpu.A.w & 0x0f00) + (data & 0x0f00) + (cpu.CFlag << 8) + (result & 0x00ff) - if result <= 0x0fff: - result -= 0x0600 - cpu.CFlag = result > 0x0fff - result = (cpu.A.w & 0xf000) + (data & 0xf000) + (cpu.CFlag << 12) + (result & 0x0fff) - - cpu.VFlag = bool(~(cpu.A.w ^ data) & (cpu.A.w ^ result) & 0x8000) - if cpu.DFlag and result <= 0xffff: - result -= 0x6000 - cpu.CFlag = result > 0xffff - cpu.ZFlag = result & 0xffff == 0 - cpu.NFlag = bool(result & 0x8000) - - cpu.A.w = result - return cpu.A.w + if not cpu.DFlag: + result = cpu.A.w + data + cpu.CFlag + else: + result = (cpu.A.w & 0x000F) + (data & 0x000F) + (cpu.CFlag << 0) + if result <= 0x000F: + result -= 0x0006 + cpu.CFlag = result > 0x000F + result = ( + (cpu.A.w & 0x00F0) + + (data & 0x00F0) + + (cpu.CFlag << 4) + + (result & 0x000F) + ) + if result <= 0x00FF: + result -= 0x0060 + cpu.CFlag = result > 0x00FF + result = ( + (cpu.A.w & 0x0F00) + + (data & 0x0F00) + + (cpu.CFlag << 8) + + (result & 0x00FF) + ) + if result <= 0x0FFF: + result -= 0x0600 + cpu.CFlag = result > 0x0FFF + result = ( + (cpu.A.w & 0xF000) + + (data & 0xF000) + + (cpu.CFlag << 12) + + (result & 0x0FFF) + ) + + cpu.VFlag = bool(~(cpu.A.w ^ data) & (cpu.A.w ^ result) & 0x8000) + if cpu.DFlag and result <= 0xFFFF: + result -= 0x6000 + cpu.CFlag = result > 0xFFFF + cpu.ZFlag = result & 0xFFFF == 0 + cpu.NFlag = bool(result & 0x8000) + + cpu.A.w = result + return cpu.A.w def TRB(cpu: Cpu, mode_8bit: bool, data: int): @@ -336,10 +358,9 @@ def TRB(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = (data & cpu.A.l) == 0 data &= ~cpu.A.l return data - else: - cpu.ZFlag = (data & cpu.A.w) == 0 - data &= ~cpu.A.w - return data + cpu.ZFlag = (data & cpu.A.w) == 0 + data &= ~cpu.A.w + return data def TSB(cpu: Cpu, mode_8bit: bool, data: int): @@ -347,7 +368,6 @@ def TSB(cpu: Cpu, mode_8bit: bool, data: int): cpu.ZFlag = (data & cpu.A.l) == 0 data |= cpu.A.l return data - else: - cpu.ZFlag = (data & cpu.A.w) == 0 - data |= cpu.A.w - return data + cpu.ZFlag = (data & cpu.A.w) == 0 + data |= cpu.A.w + return data diff --git a/pysnes/debugger/__init__.py b/pysnes/debugger/__init__.py index 040b4b3..176f8a6 100644 --- a/pysnes/debugger/__init__.py +++ b/pysnes/debugger/__init__.py @@ -1,3 +1,3 @@ -from .debugger import Debugger, BreakpointHit +from .debugger import BreakpointHit, Debugger __all__ = ["Debugger", "BreakpointHit"] diff --git a/pysnes/debugger/debugger.py b/pysnes/debugger/debugger.py index 4c698d0..666d55e 100644 --- a/pysnes/debugger/debugger.py +++ b/pysnes/debugger/debugger.py @@ -2,6 +2,7 @@ PySNES Debugger — hooks into cpu._step to support breakpoints and stepping. Zero overhead while the emulator is running with no breakpoints set. """ + from __future__ import annotations import heapq @@ -16,31 +17,31 @@ # Maps disassembler addressing-mode method names to byte lengths. # Variable-length modes (immediateA, immediateX) are handled separately. _MODE_LENGTHS: dict[str, int] = { - "implied": 1, - "immediate": 2, - "direct": 2, - "directX": 2, - "directY": 2, - "indirect": 2, - "indexedIndirectX": 2, - "indirectIndexedY": 2, - "indirectLong": 2, - "indirectLongY": 2, - "relative": 2, - "stack": 2, - "stackIndirect": 2, - "absolute": 3, - "absoluteX": 3, - "absoluteY": 3, - "absolutePC": 3, - "indirectPC": 3, - "indirectX": 3, - "relativeWord": 3, - "move": 3, - "per": 3, - "absoluteLong": 4, - "absoluteLongX": 4, - "indirectLongPC": 4, + "implied": 1, + "immediate": 2, + "direct": 2, + "directX": 2, + "directY": 2, + "indirect": 2, + "indexedIndirectX": 2, + "indirectIndexedY": 2, + "indirectLong": 2, + "indirectLongY": 2, + "relative": 2, + "stack": 2, + "stackIndirect": 2, + "absolute": 3, + "absoluteX": 3, + "absoluteY": 3, + "absolutePC": 3, + "indirectPC": 3, + "indirectX": 3, + "relativeWord": 3, + "move": 3, + "per": 3, + "absoluteLong": 4, + "absoluteLongX": 4, + "indirectLongPC": 4, } @@ -60,7 +61,7 @@ def __init__(self, pysnes: PySNES) -> None: self._instr_count: int = 0 self._original_step = None - self._cmd_queue: queue.Queue = queue.Queue() # Tkinter → emulator + self._cmd_queue: queue.Queue = queue.Queue() # Tkinter → emulator self._notify_queue: queue.Queue = queue.Queue() # emulator → Tkinter self._window = None @@ -79,7 +80,8 @@ def _install_hooks(self) -> None: def _hooked_step(self) -> None: pc = self._cpu.PC.d if pc in self._breakpoints: - # Execute the instruction, then pause (PC now points to the next instruction) + # Execute the instruction, then pause (PC now points to the next + # instruction) self._original_step() self._pysnes.paused = True self._install_hooks() @@ -114,12 +116,14 @@ def step_one_instruction(self) -> None: self._instr_count += 1 def _notify_paused(self) -> None: - """Signal the Tkinter window to refresh. Thread-safe: puts to a queue.""" + """Signal the Tkinter window to refresh. Thread-safe: puts to a + queue.""" if self._window is not None: self._notify_queue.put(True) def drain_commands(self) -> None: - """Process commands from the Tkinter thread. Called every main loop iteration.""" + """Process commands from the Tkinter thread. Called every main loop + iteration.""" while not self._cmd_queue.empty(): try: cmd = self._cmd_queue.get_nowait() @@ -132,7 +136,8 @@ def drain_commands(self) -> None: elif action == "step": done_event = cmd[1] self.step_one_instruction() - done_event.set() # unblocks Tkinter thread so it can refresh immediately + # unblocks Tkinter thread so it can refresh immediately + done_event.set() elif action == "continue": self._pysnes.paused = False elif action == "pause": @@ -142,7 +147,8 @@ def drain_commands(self) -> None: self._pysnes.reset() def disassemble_forward(self, pc: int, count: int) -> list[str]: - """Return up to `count` disassembled instruction strings starting at `pc`.""" + """Return up to `count` disassembled instruction strings starting at + `pc`.""" disasm = self._cpu.disassembler results = [] for _ in range(count): @@ -155,7 +161,8 @@ def disassemble_forward(self, pc: int, count: int) -> list[str]: return results def _next_pc(self, pc: int) -> int: - """Advance PC past the instruction at `pc` using the addressing mode table.""" + """Advance PC past the instruction at `pc` using the addressing mode + table.""" disasm = self._cpu.disassembler bank = pc & 0xFF0000 addr = pc & 0xFFFF @@ -182,9 +189,10 @@ def open_window(self) -> None: def _run(): win = DebuggerWindow(self._cpu, self._bus, self._ppu, self) self._window = win - self._cpu.trace_enabled = True # populate trace_log for disasm history + # populate trace_log for disasm history + self._cpu.trace_enabled = True win.root.mainloop() - win.root.destroy() # destroy in daemon (Tkinter) thread + win.root.destroy() # destroy in daemon (Tkinter) thread self._cpu.trace_enabled = False self._window = None # Force Tk object teardown on this (Tcl-owning) thread. On PyPy the diff --git a/pysnes/debugger/test_debugger.py b/pysnes/debugger/test_debugger.py index 257efd9..92804c1 100644 --- a/pysnes/debugger/test_debugger.py +++ b/pysnes/debugger/test_debugger.py @@ -4,17 +4,18 @@ Buttons enqueue commands via _cmd_queue; drain_commands() is the consumer. These tests drive drain_commands() directly, so no Tkinter display is needed. """ + import threading import pytest -from .debugger import Debugger, BreakpointHit - +from .debugger import BreakpointHit, Debugger # --------------------------------------------------------------------------- # Minimal stubs # --------------------------------------------------------------------------- + class _PC: def __init__(self, addr: int = 0x008000): self.d = addr @@ -34,6 +35,7 @@ def _step(self): class _Scheduler: """Minimal scheduler: run_one() calls cpu._step() once.""" + def __init__(self, cpu: _Cpu): self._cpu = cpu self.master_clock = 0 @@ -51,6 +53,7 @@ class _RealisticScheduler: scheduled its next step, the event in the queue still holds _original_step, so the first run_one() fires it directly, bypassing the hook. """ + def __init__(self): self._queue: list = [] self.master_clock: int = 0 @@ -64,9 +67,6 @@ def run_one(self) -> None: self.master_clock = t fn() - def peek_fn(self): - return self._queue[0][1] if self._queue else None - class _RealisticCpu: """ @@ -74,6 +74,7 @@ class _RealisticCpu: The key line is `scheduler.add(mc, self._step)` — `self._step` is evaluated at call time and captures the current instance attribute. """ + def __init__(self, scheduler: _RealisticScheduler): self._sched = scheduler self.PC = _PC() @@ -101,6 +102,7 @@ def __init__(self): # Fixture # --------------------------------------------------------------------------- + @pytest.fixture def pysnes(): return _PySNES() @@ -117,6 +119,7 @@ def debugger(pysnes): # toggle_breakpoint # --------------------------------------------------------------------------- + def test_toggle_breakpoint_adds(debugger): debugger.toggle_breakpoint(0x8000) assert 0x8000 in debugger._breakpoints @@ -143,8 +146,10 @@ def test_toggle_breakpoint_removes_hook_when_no_breakpoints(debugger, pysnes): # _hooked_step — normal execution (no breakpoint, no step mode) # --------------------------------------------------------------------------- + def test_hooked_step_normal_calls_original(debugger, pysnes): - debugger.toggle_breakpoint(0x9000) # install hook but breakpoint is elsewhere + # install hook but breakpoint is elsewhere + debugger.toggle_breakpoint(0x9000) pysnes.cpu.PC.d = 0x8000 before = pysnes.cpu._steps debugger._hooked_step() @@ -156,6 +161,7 @@ def test_hooked_step_normal_calls_original(debugger, pysnes): # _hooked_step — breakpoint hit # --------------------------------------------------------------------------- + def test_breakpoint_hit_raises(debugger, pysnes): debugger.toggle_breakpoint(0x8000) pysnes.cpu.PC.d = 0x8000 @@ -194,6 +200,7 @@ def test_breakpoint_hit_notifies_window(debugger): # step_one_instruction # --------------------------------------------------------------------------- + def test_step_one_instruction_advances_instr_count(debugger): before = debugger._instr_count debugger.step_one_instruction() @@ -207,7 +214,8 @@ def test_step_one_instruction_calls_cpu_step(debugger, pysnes): def test_step_one_instruction_does_not_install_hook(debugger, pysnes): - """step_one_instruction no longer uses the hook mechanism — cpu._step stays as original.""" + """step_one_instruction no longer uses the hook mechanism — cpu._step stays + as original.""" debugger.step_one_instruction() assert pysnes.cpu._step == debugger._original_step @@ -216,6 +224,7 @@ def test_step_one_instruction_does_not_install_hook(debugger, pysnes): # drain_commands — "pause" button # --------------------------------------------------------------------------- + def test_drain_pause_sets_paused(debugger, pysnes): debugger._cmd_queue.put(("pause",)) debugger.drain_commands() @@ -233,6 +242,7 @@ def test_drain_pause_notifies(debugger): # drain_commands — "continue" button # --------------------------------------------------------------------------- + def test_drain_continue_clears_paused(debugger, pysnes): pysnes.paused = True debugger._cmd_queue.put(("continue",)) @@ -244,6 +254,7 @@ def test_drain_continue_clears_paused(debugger, pysnes): # drain_commands — "step" button # --------------------------------------------------------------------------- + def test_drain_step_advances_instr_count(debugger): before = debugger._instr_count done = threading.Event() @@ -263,6 +274,7 @@ def test_drain_step_sets_event(debugger): # drain_commands — "toggle_bp" (Break at PC / Remove selected buttons) # --------------------------------------------------------------------------- + def test_drain_toggle_bp_adds_breakpoint(debugger): debugger._cmd_queue.put(("toggle_bp", 0x8010)) debugger.drain_commands() @@ -287,6 +299,7 @@ def test_drain_toggle_bp_notifies(debugger): # drain_commands — multiple commands processed in one call # --------------------------------------------------------------------------- + def test_drain_processes_all_queued_commands(debugger, pysnes): debugger._cmd_queue.put(("pause",)) debugger._cmd_queue.put(("continue",)) @@ -298,8 +311,10 @@ def test_drain_processes_all_queued_commands(debugger, pysnes): # BreakpointHit propagates through a scheduler loop # --------------------------------------------------------------------------- + def test_breakpoint_exits_scheduler_run_to(pysnes): - """Simulate the main loop: BreakpointHit raised inside scheduler.run_to().""" + """Simulate the main loop: BreakpointHit raised inside + scheduler.run_to().""" dbg = Debugger(pysnes) dbg.attach() dbg.toggle_breakpoint(0x8000) @@ -329,7 +344,8 @@ def fake_run_to(target): # one instruction even when the queued event was captured before hook install # --------------------------------------------------------------------------- -def test_step_one_instruction_executes_exactly_one_instruction_with_realistic_scheduler(): + +def test_step_one_instruction_runs_exactly_one_with_real_scheduler(): """ Reproduces the real-world bug: when step_one_instruction() is called, the scheduler queue already holds a reference to _original_step (captured while @@ -360,6 +376,7 @@ def test_step_one_instruction_executes_exactly_one_instruction_with_realistic_sc dbg.step_one_instruction() assert cpu._steps - steps_before == 1, ( - f"step_one_instruction() executed {cpu._steps - steps_before} instructions; " + f"step_one_instruction() executed {cpu._steps - steps_before} " + "instructions; " f"expected exactly 1" ) diff --git a/pysnes/debugger/window.py b/pysnes/debugger/window.py index abd62d8..649d864 100644 --- a/pysnes/debugger/window.py +++ b/pysnes/debugger/window.py @@ -4,6 +4,7 @@ All reads use direct Python attribute access — never bus.read() — to avoid hardware register side effects (e.g. OAM/CGRAM address auto-increment). """ + from __future__ import annotations import threading @@ -12,10 +13,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from . import Debugger - from ..cpu.cpu import Cpu from ..bus.bus import Bus + from ..cpu.cpu import Cpu from ..ppu.ppu import Ppu + from . import Debugger # Memory regions selectable in the memory view _REGIONS = ["WRAM", "VRAM", "CGRAM", "ROM"] @@ -27,20 +28,30 @@ def _cpu_flags_str(cpu) -> str: p = cpu.P names = ("N", "V", "M", "X", "D", "I", "Z", "C") - bits = (0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01) - return "".join(n if p & b else n.lower() for n, b in zip(names, bits)) + bits = (0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01) + return "".join( + n if p & b else n.lower() for n, b in zip(names, bits, strict=True) + ) def _apu_flags_str(apu) -> str: flags = ( - ("N", apu.NF), ("V", apu.VF), ("P", apu.PF), ("B", apu.BF), - ("H", apu.HF), ("I", apu.IF), ("Z", apu.ZF), ("C", apu.CF), + ("N", apu.NF), + ("V", apu.VF), + ("P", apu.PF), + ("B", apu.BF), + ("H", apu.HF), + ("I", apu.IF), + ("Z", apu.ZF), + ("C", apu.CF), ) return "".join(n if v else n.lower() for n, v in flags) class DebuggerWindow: - def __init__(self, cpu: Cpu, bus: Bus, ppu: Ppu, debugger: Debugger) -> None: + def __init__( + self, cpu: Cpu, bus: Bus, ppu: Ppu, debugger: Debugger + ) -> None: self._cpu = cpu self._bus = bus self._ppu = ppu @@ -73,10 +84,12 @@ def _build_ui(self) -> None: reg_notebook = ttk.Notebook(top) reg_notebook.pack(side="left", fill="both", expand=True, padx=(0, 4)) - for tab_name, attr in (("CPU", "_cpu_reg_text"), - ("APU", "_apu_reg_text"), - ("PPU", "_ppu_reg_text"), - ("Stack", "_stack_text")): + for tab_name, attr in ( + ("CPU", "_cpu_reg_text"), + ("APU", "_apu_reg_text"), + ("PPU", "_ppu_reg_text"), + ("Stack", "_stack_text"), + ): frame = ttk.Frame(reg_notebook) reg_notebook.add(frame, text=tab_name) t = self._make_text(frame, width=44, height=12) @@ -96,8 +109,12 @@ def _build_ui(self) -> None: ttk.Label(mem_ctrl, text="Region:").pack(side="left") self._region_buttons: dict[str, ttk.Radiobutton] = {} for r in _REGIONS: - btn = ttk.Radiobutton(mem_ctrl, text=r, value=r, - command=lambda region=r: self._on_mem_region_change(region)) + btn = ttk.Radiobutton( + mem_ctrl, + text=r, + value=r, + command=lambda region=r: self._on_mem_region_change(region), + ) btn.pack(side="left", padx=2) if r == self._mem_region_val: btn.state(["selected"]) @@ -118,9 +135,15 @@ def _build_ui(self) -> None: bp_frame = ttk.LabelFrame(bot, text="Breakpoints") bp_frame.pack(side="left", fill="both", expand=True, padx=(0, 4)) - self._bp_list = tk.Listbox(bp_frame, bg="#252526", fg="#d4d4d4", - selectbackground="#094771", font=("Courier", 10), - height=5, width=12) + self._bp_list = tk.Listbox( + bp_frame, + bg="#252526", + fg="#d4d4d4", + selectbackground="#094771", + font=("Courier", 10), + height=5, + width=12, + ) self._bp_list.pack(side="left", fill="both", expand=True) bp_btns = ttk.Frame(bp_frame) @@ -131,31 +154,50 @@ def _build_ui(self) -> None: self._bp_addr_entry = ttk.Entry(add_row, width=8) self._bp_addr_entry.pack(side="left") self._bp_addr_entry.bind("", lambda _e: self._cmd_add_bp()) - ttk.Button(add_row, text="Add", command=self._cmd_add_bp).pack(side="left", padx=(2, 0)) + ttk.Button(add_row, text="Add", command=self._cmd_add_bp).pack( + side="left", padx=(2, 0) + ) - ttk.Button(bp_btns, text="Break at PC", - command=self._cmd_toggle_bp_at_pc).pack(fill="x", pady=2) - ttk.Button(bp_btns, text="Remove selected", - command=self._cmd_remove_bp).pack(fill="x", pady=2) + ttk.Button( + bp_btns, text="Break at PC", command=self._cmd_toggle_bp_at_pc + ).pack(fill="x", pady=2) + ttk.Button( + bp_btns, text="Remove selected", command=self._cmd_remove_bp + ).pack(fill="x", pady=2) ctrl = ttk.LabelFrame(bot, text="Controls") ctrl.pack(side="left", fill="y") - ttk.Button(ctrl, text="Step (N)", command=self._cmd_step).pack(fill="x", pady=2, padx=6) - ttk.Button(ctrl, text="Continue (C)", command=self._cmd_continue).pack(fill="x", pady=2, padx=6) - ttk.Button(ctrl, text="Pause", command=self._cmd_pause).pack(fill="x", pady=2, padx=6) - ttk.Button(ctrl, text="Reset", command=self._cmd_reset).pack(fill="x", pady=2, padx=6) + ttk.Button(ctrl, text="Step (N)", command=self._cmd_step).pack( + fill="x", pady=2, padx=6 + ) + ttk.Button(ctrl, text="Continue (C)", command=self._cmd_continue).pack( + fill="x", pady=2, padx=6 + ) + ttk.Button(ctrl, text="Pause", command=self._cmd_pause).pack( + fill="x", pady=2, padx=6 + ) + ttk.Button(ctrl, text="Reset", command=self._cmd_reset).pack( + fill="x", pady=2, padx=6 + ) # ── Status bar ──────────────────────────────────────────────── self._status_label = ttk.Label(root, text="Running", anchor="w") self._status_label.pack(fill="x", padx=6, pady=(0, 4)) def _make_text(self, parent, **kwargs) -> tk.Text: - t = tk.Text(parent, bg="#1e1e1e", fg="#d4d4d4", - insertbackground="#d4d4d4", - font=("Courier", 10), state="disabled", - relief="flat", borderwidth=1, **kwargs) - t.tag_configure("pc", foreground="#569cd6", background="#094771") - t.tag_configure("bp", foreground="#f44747") + t = tk.Text( + parent, + bg="#1e1e1e", + fg="#d4d4d4", + insertbackground="#d4d4d4", + font=("Courier", 10), + state="disabled", + relief="flat", + borderwidth=1, + **kwargs, + ) + t.tag_configure("pc", foreground="#569cd6", background="#094771") + t.tag_configure("bp", foreground="#f44747") t.tag_configure("history", foreground="#808080") return t @@ -164,7 +206,8 @@ def _make_text(self, parent, **kwargs) -> tk.Text: # ------------------------------------------------------------------ def _poll(self) -> None: - """Called every 100 ms in the Tkinter thread to process refresh signals.""" + """Called every 100 ms in the Tkinter thread to process refresh + signals.""" while not self._debugger._notify_queue.empty(): self._debugger._notify_queue.get_nowait() self.refresh() @@ -198,7 +241,8 @@ def _refresh_cpu_tab(self, cpu) -> None: st = cpu.status mc = self._debugger._scheduler.master_clock text = ( - f" A:{cpu.A.w:04X} X:{cpu.X.w:04X} Y:{cpu.Y.w:04X} S:{cpu.S.w:04X}\n" + f" A:{cpu.A.w:04X} X:{cpu.X.w:04X} Y:{cpu.Y.w:04X} " + f"S:{cpu.S.w:04X}\n" f" D:{cpu.D.w:04X} DB:{cpu.DB.l:02X} PC:{cpu.PC.d:06X}\n" f" P:{p} EF:{int(cpu.EF)}\n" f"\n" @@ -282,7 +326,9 @@ def _refresh_stack_tab(self, cpu) -> None: byte = self._read_bank0(addr) prefix = "► " if i == 1 else " " tag = "pc" if i == 1 else "" - self._stack_text.insert("end", f"{prefix}{addr:04X}: {byte:02X}\n", tag) + self._stack_text.insert( + "end", f"{prefix}{addr:04X}: {byte:02X}\n", tag + ) self._stack_text.configure(state="disabled") def _read_bank0(self, addr: int) -> int: @@ -312,7 +358,7 @@ def _refresh_disassembly(self, cpu) -> None: for i, line in enumerate(forward): addr_str = line[:6] - is_current = (i == 0) + is_current = i == 0 is_bp = int(addr_str, 16) in bps tag = "pc" if is_current else ("bp" if is_bp else "") prefix = "► " if is_current else " " @@ -329,10 +375,15 @@ def _refresh_memory(self) -> None: lines = [] for row in range(0, len(data), _HEX_COLS): - chunk = data[row:row + _HEX_COLS] + chunk = data[row : row + _HEX_COLS] hex_part = " ".join(f"{b:02X}" for b in chunk) - ascii_part = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in chunk) - lines.append(f"{region}:{base + row:06X} {hex_part:<{_HEX_COLS * 3}} {ascii_part}") + ascii_part = "".join( + chr(b) if 0x20 <= b < 0x7F else "." for b in chunk + ) + lines.append( + f"{region}:{base + row:06X} {hex_part:<{_HEX_COLS * 3}} " + f"{ascii_part}" + ) self._set_text(self._mem_text, "\n".join(lines)) @@ -350,13 +401,13 @@ def _read_memory(self, region: str, base: int, length: int) -> bytes: else: result.append(0) return bytes(result) - elif region == "VRAM": + if region == "VRAM": end = min(base + length, len(self._ppu.vram)) return bytes(self._ppu.vram[base:end]) - elif region == "CGRAM": + if region == "CGRAM": end = min(base + length, len(self._ppu.cgram)) return bytes(self._ppu.cgram[base:end]) - elif region == "ROM": + if region == "ROM": data = self._bus.rom.rom end = min(base + length, len(data)) return bytes(data[base:end]) @@ -424,7 +475,7 @@ def _cmd_remove_bp(self) -> None: def _cmd_step(self) -> None: done = threading.Event() self._debugger._cmd_queue.put(("step", done)) - done.wait() # blocks Tkinter thread until main thread finishes the step + done.wait() # blocks Tkinter thread until main thread finishes the step self.refresh() def _cmd_continue(self) -> None: @@ -438,4 +489,5 @@ def _cmd_reset(self) -> None: self._debugger._cmd_queue.put(("reset",)) def _on_close(self) -> None: - self.root.quit() # stops mainloop; destroy() is called in the daemon thread + # stops mainloop; destroy() is called in the daemon thread + self.root.quit() diff --git a/pysnes/harness/__init__.py b/pysnes/harness/__init__.py index 44e78ca..edb1273 100644 --- a/pysnes/harness/__init__.py +++ b/pysnes/harness/__init__.py @@ -15,10 +15,12 @@ h.screenshot("title.png") print("CGRAM[0..4]:", h.cgram(0, 4).hex()) """ + from __future__ import annotations +from collections.abc import Callable, Iterable, Sequence from pathlib import Path -from typing import Any, Callable, Iterable, Optional, Sequence, Union +from typing import Any, Optional, Union import numpy as np import sdl2 @@ -49,11 +51,11 @@ } # Macro event: (frames_to_run, buttons_held_during_those_frames_or_None) -MacroEvent = tuple[int, Optional[Iterable[str]]] +MacroEvent = tuple[int, Iterable[str] | None] WriteHook = Callable[["Harness", int, int], None] -def _resolve_buttons(buttons: Union[str, Iterable[str], None]) -> set[int]: +def _resolve_buttons(buttons: str | Iterable[str] | None) -> set[int]: if buttons is None: return set() names: Iterable[str] = [buttons] if isinstance(buttons, str) else buttons @@ -72,9 +74,9 @@ class Harness: def __init__( self, - rom_path: Union[str, Path], - sram_path: Optional[Union[str, Path]] = None, - load_state: Optional[Union[str, Path]] = None, + rom_path: str | Path, + sram_path: str | Path | None = None, + load_state: str | Path | None = None, ): # Defer the import so unrelated tools (e.g. the CLI's --help) don't pay # the boot cost of importing the emulator. @@ -118,7 +120,8 @@ def paused(self) -> bool: # Execution # ------------------------------------------------------------------ def run_frames(self, n: int) -> None: - """Advance the scheduler by exactly `n` frames worth of master clocks.""" + """Advance the scheduler by exactly `n` frames worth of master + clocks.""" if n <= 0: return sched = self._pysnes.scheduler @@ -126,7 +129,7 @@ def run_frames(self, n: int) -> None: def run_until( self, - predicate: Callable[["Harness"], bool], + predicate: Callable[[Harness], bool], max_frames: int = 600, ) -> bool: """Step one frame at a time until `predicate(self)` is truthy. @@ -147,10 +150,13 @@ def run_to_scanline(self, v: int) -> None: Errors if the requested scanline has already passed in this frame. """ if not (0 <= v < SCANLINES_PER_FRAME): - raise ValueError(f"scanline {v} out of range [0, {SCANLINES_PER_FRAME})") + raise ValueError( + f"scanline {v} out of range [0, {SCANLINES_PER_FRAME})" + ) if v < self.scanline: raise ValueError( - f"scanline {v} already past in current frame (now at {self.scanline})" + f"scanline {v} already past in current frame (now at " + f"{self.scanline})" ) delta = (v - self.scanline) * MC_PER_SCANLINE sched = self._pysnes.scheduler @@ -159,11 +165,11 @@ def run_to_scanline(self, v: int) -> None: # ------------------------------------------------------------------ # Controller input # ------------------------------------------------------------------ - def press(self, buttons: Union[str, Iterable[str]]) -> None: + def press(self, buttons: str | Iterable[str]) -> None: """Add buttons to the held set without releasing anything else.""" self._pysnes.controllers[0].pressed_keys |= _resolve_buttons(buttons) - def release(self, buttons: Union[str, Iterable[str]]) -> None: + def release(self, buttons: str | Iterable[str]) -> None: """Remove buttons from the held set.""" self._pysnes.controllers[0].pressed_keys -= _resolve_buttons(buttons) @@ -172,7 +178,7 @@ def clear_input(self) -> None: def tap( self, - buttons: Union[str, Iterable[str]], + buttons: str | Iterable[str], hold_frames: int = 2, gap_frames: int = 1, ) -> None: @@ -206,7 +212,7 @@ def play(self, macro: Sequence[MacroEvent]) -> None: # ------------------------------------------------------------------ # Screenshot # ------------------------------------------------------------------ - def screenshot(self, path: Union[str, Path]) -> None: + def screenshot(self, path: str | Path) -> None: """Write the current framebuffer to a 256×224 PNG. Reads `ppu.main_bgs` directly and applies the current INIDISP @@ -385,15 +391,17 @@ def close(self) -> None: # macros did. Callers can call _save_sram() explicitly if needed. self._closed = True - def save_state(self, path: Union[str, Path]) -> None: + def save_state(self, path: str | Path) -> None: from pysnes import savestate # noqa: PLC0415 + savestate.save(self._pysnes, str(path)) - def load_state(self, path: Union[str, Path]) -> None: + def load_state(self, path: str | Path) -> None: from pysnes import savestate # noqa: PLC0415 + savestate.load(self._pysnes, str(path)) - def __enter__(self) -> "Harness": + def __enter__(self) -> Harness: return self def __exit__(self, *exc: Any) -> None: diff --git a/pysnes/harness/_png.py b/pysnes/harness/_png.py index 17e2627..ef89a7c 100644 --- a/pysnes/harness/_png.py +++ b/pysnes/harness/_png.py @@ -3,6 +3,7 @@ Lifted from pysnes/ppu/test_ppu.py so the harness and existing screenshot regression tests share one implementation. """ + import struct import zlib from pathlib import Path @@ -19,13 +20,16 @@ def write_png(path: Path, pixels, width: int, height: int) -> None: flat = bytes(pixels) if len(flat) != width * height * 3: raise ValueError( - f"bytes pixels length {len(flat)} does not match {width}*{height}*3" + f"bytes pixels length {len(flat)} does not match " + f"{width}*{height}*3" ) raw = bytearray(height * (1 + width * 3)) stride = 1 + width * 3 for row in range(height): raw[row * stride] = 0 # filter type None - raw[row * stride + 1 : (row + 1) * stride] = flat[row * width * 3 : (row + 1) * width * 3] + raw[row * stride + 1 : (row + 1) * stride] = flat[ + row * width * 3 : (row + 1) * width * 3 + ] else: raw = bytearray() for row in range(height): @@ -40,7 +44,12 @@ def chunk(tag: bytes, body: bytes) -> bytes: ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) idat = zlib.compress(bytes(raw)) - png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + + chunk(b"IEND", b"") + ) Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).write_bytes(png) diff --git a/pysnes/harness/cli.py b/pysnes/harness/cli.py index ec9ddec..a5a2f03 100644 --- a/pysnes/harness/cli.py +++ b/pysnes/harness/cli.py @@ -4,8 +4,11 @@ uv run python -m pysnes.harness.cli --rom ROM.smc -Each line of stdin is a JSON object: {"method": "run_frames", "args": {"n": 60}}. -Each line of stdout is: {"ok": true, "result": ...} or {"ok": false, "error": "..."}. +Each line of stdin is a JSON object: + {"method": "run_frames", "args": {"n": 60}} +Each line of stdout is one of: + {"ok": true, "result": ...} + {"ok": false, "error": "..."} Methods mirror the `Harness` class. Binary blobs (cgram, vram, oam, wram) come back base64-encoded. @@ -14,6 +17,7 @@ appends to an internal log; `drain_writes()` returns and clears it. This keeps the protocol strictly request/response, no async events. """ + from __future__ import annotations import argparse @@ -54,8 +58,12 @@ def release(self, buttons: Any) -> None: def clear_input(self) -> None: self.h.clear_input() - def tap(self, buttons: Any, hold_frames: int = 2, gap_frames: int = 1) -> dict[str, int]: - self.h.tap(buttons, hold_frames=int(hold_frames), gap_frames=int(gap_frames)) + def tap( + self, buttons: Any, hold_frames: int = 2, gap_frames: int = 1 + ) -> dict[str, int]: + self.h.tap( + buttons, hold_frames=int(hold_frames), gap_frames=int(gap_frames) + ) return self._state() def play(self, macro: list) -> dict[str, int]: @@ -77,7 +85,12 @@ def oam(self) -> dict[str, Any]: return _b64_blob(self.h.oam()) def wram(self, addr: int, length: int) -> dict[str, Any]: - return _b64_blob(self.h.wram(int(addr, 0) if isinstance(addr, str) else int(addr), int(length))) + return _b64_blob( + self.h.wram( + int(addr, 0) if isinstance(addr, str) else int(addr), + int(length), + ) + ) def cpu_state(self) -> dict[str, Any]: return self.h.cpu_state() @@ -86,13 +99,21 @@ def ppu_state(self) -> dict[str, Any]: return self.h.ppu_state() def state(self) -> dict[str, Any]: - return {**self._state(), "cpu": self.h.cpu_state(), "ppu": self.h.ppu_state()} + return { + **self._state(), + "cpu": self.h.cpu_state(), + "ppu": self.h.ppu_state(), + } def set_breakpoint(self, addr: int) -> None: - self.h.set_breakpoint(int(addr, 0) if isinstance(addr, str) else int(addr)) + self.h.set_breakpoint( + int(addr, 0) if isinstance(addr, str) else int(addr) + ) def clear_breakpoint(self, addr: int) -> None: - self.h.clear_breakpoint(int(addr, 0) if isinstance(addr, str) else int(addr)) + self.h.clear_breakpoint( + int(addr, 0) if isinstance(addr, str) else int(addr) + ) def watch_writes(self, lo: int, hi: int) -> None: """Install a write-watch on the [lo, hi] address range. Captured writes @@ -145,7 +166,9 @@ def _b64_blob(data: bytes) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="PySNES harness RPC over NDJSON.") + parser = argparse.ArgumentParser( + description="PySNES harness RPC over NDJSON." + ) parser.add_argument("--rom", required=True, help="Path to ROM file.") parser.add_argument("--sram", help="Optional SRAM path.", default=None) args = parser.parse_args(argv) @@ -154,7 +177,9 @@ def main(argv: list[str] | None = None) -> int: cli = _CLI(harness) # Greet so the caller knows we're ready. - sys.stdout.write(json.dumps({"ok": True, "ready": True, **cli._state()}) + "\n") + sys.stdout.write( + json.dumps({"ok": True, "ready": True, **cli._state()}) + "\n" + ) sys.stdout.flush() try: diff --git a/pysnes/harness/test_harness.py b/pysnes/harness/test_harness.py index 7d336a6..c56cc92 100644 --- a/pysnes/harness/test_harness.py +++ b/pysnes/harness/test_harness.py @@ -1,4 +1,5 @@ """Smoke tests for the Harness. Marked @pytest.mark.harness — opt-in.""" + from pathlib import Path import pytest @@ -73,6 +74,7 @@ def test_on_write_range_fires_for_cgram(): events: list[tuple[int, int, int, int]] = [] with Harness(SMW_ROM) as h: + def hook(harness, addr, value): events.append((harness.frame, harness.scanline, addr, value)) @@ -90,9 +92,10 @@ def hook(harness, addr, value): def test_tap_press_release_edge(): _require_smw() - from pysnes.harness import Harness import sdl2 + from pysnes.harness import Harness + with Harness(SMW_ROM) as h: h.tap("Start", hold_frames=2, gap_frames=1) # After tap, no buttons should still be held diff --git a/pysnes/ppu/bg_renderer.py b/pysnes/ppu/bg_renderer.py index 7a0bf54..891296c 100644 --- a/pysnes/ppu/bg_renderer.py +++ b/pysnes/ppu/bg_renderer.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING - from . import color_math from .constants import SCREEN_WIDTH, _colorcode_table from .data_structures import Background @@ -42,8 +41,8 @@ def draw_mode7_scanline(ppu: Ppu) -> None: # v_counter is the hardware scanline (1 = first visible line). # The affine transform uses the hardware scanline directly; the output # row is 0-indexed so we subtract 1 only for the buffer write. - scan_y = ppu.v_counter # hardware scanline (1-based) for transform - row_y = ppu.v_counter - 1 # 0-based index into main_bgs / sub_bgs + scan_y = ppu.v_counter # hardware scanline (1-based) for transform + row_y = ppu.v_counter - 1 # 0-based index into main_bgs / sub_bgs # Sign-extend 16-bit matrix coefficients (m7_write stores them unsigned) a = ppu.m7a if ppu.m7a < 0x8000 else ppu.m7a - 0x10000 @@ -97,8 +96,10 @@ def draw_mode7_scanline(ppu: Ppu) -> None: window_masked = ppu._window_mask_buf ppu._build_window_mask( window_masked, - w1_enable, w1_invert, - w2_enable, w2_invert, + w1_enable, + w1_invert, + w2_enable, + w2_invert, combine_logic, ) @@ -117,16 +118,14 @@ def draw_mode7_scanline(ppu: Ppu) -> None: if outside: if screen_over == 2: continue # transparent - elif screen_over != 3: + if screen_over != 3: vx &= 0x3FF # wrap to 1024×1024 vy &= 0x3FF outside = False - # Tilemap: low byte of VRAM word at (ty*128+tx) = tile number - if outside: # screen_over == 3: force tile 0 - tile_num = 0 - else: - tile_num = ppu.vram[2 * ((vy >> 3) * 128 + (vx >> 3))] + # Tilemap: low byte of VRAM word at (ty*128+tx) = tile number. + # screen_over == 3 forces tile 0 outside the map. + tile_num = 0 if outside else ppu.vram[2 * ((vy >> 3) * 128 + (vx >> 3))] # Pixel: high byte of VRAM word at tile data offset (8bpp) tile_px = vx & 7 @@ -159,7 +158,9 @@ def draw_mode7_scanline(ppu: Ppu) -> None: ppu.sub_bgs[idx] = color -def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_selector: bool) -> None: +def draw_background_scanline( + ppu: Ppu, bg: Background, bpp: int, priority_selector: bool +) -> None: # If neither main nor sub is enabled, nothing to do at all. if not bg.main_screen_enable and not bg.sub_screen_enable: return @@ -168,15 +169,14 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select write_sub = bg.sub_screen_enable layer_tag = bg.number - # Window masking setup for this BG. - # $212E TMW bit (bg.number-1): window masking enabled for this BG on main screen. - # $2123 W12SEL (for BG1/BG2) / $2124 W34SEL (for BG3/BG4): - # bit pairs per BG: (W1 invert, W1 enable, W2 invert, W2 enable). - # For BG1: W12SEL bits 3:2:1:0 = (W2_enable, W2_invert, W1_enable, W1_invert). - # invert=0: pixels INSIDE [WHx_L,WHx_R] are in the mask zone. - # invert=1: pixels OUTSIDE [WHx_L,WHx_R] are in the mask zone. - # $212A WBGLOG combines the two window outputs per BG: - # bits 2n..2n+1 for BGn: 0=OR, 1=AND, 2=XOR, 3=XNOR. + # Window masking setup for this BG. $212E TMW bit (bg.number-1): window + # masking enabled for this BG on main screen. $2123 W12SEL (for BG1/BG2) / + # $2124 W34SEL (for BG3/BG4): bit pairs per BG: (W1 invert, W1 enable, W2 + # invert, W2 enable). For BG1: W12SEL bits 3:2:1:0 = (W2_enable, W2_invert, + # W1_enable, W1_invert). invert=0: pixels INSIDE [WHx_L,WHx_R] are in the + # mask zone. invert=1: pixels OUTSIDE [WHx_L,WHx_R] are in the mask zone. + # $212A WBGLOG combines the two window outputs per BG: bits 2n..2n+1 for + # BGn: 0=OR, 1=AND, 2=XOR, 3=XNOR. bg_idx = bg.number - 1 window_active = ppu.tmw & (1 << bg_idx) w1_enable = False @@ -237,14 +237,17 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select sub_bgs = ppu.sub_bgs ct = _colorcode_table - # Precompute per-dot window mask once for the scanline (constant window boundaries). + # Precompute per-dot window mask once for the scanline (constant window + # boundaries). window_masked = None if window_active and (w1_enable or w2_enable): window_masked = ppu._window_mask_buf ppu._build_window_mask( window_masked, - w1_enable, w1_invert, - w2_enable, w2_invert, + w1_enable, + w1_invert, + w2_enable, + w2_invert, combine_logic, ) @@ -259,7 +262,7 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select scrx = (scrx + scroll_x) % (8 * bg_size_w) offset = ((scry % 256 if bg_size_w == 64 else scry) // 8) * 32 - offset += ((scrx % 256) // 8) + offset += (scrx % 256) // 8 offset += (scrx // 256) * 0x400 offset += (bg_size_w // 64) * ((scry // 256) * 0x800) tilemap_word_addr = (screen_addr + offset) * 2 & 0xFFFF @@ -278,20 +281,30 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select v_shift = i if not tilemap_v_flip else (7 - i) h_shift = (7 - j) if not tilemap_h_flip else j if bpp == 2: - tile_address = (tiledata_addr + tile_num * 16 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 16 + v_shift * 2 + ) & 0xFFFF b_lo = vram[tile_address] b_hi = vram[(tile_address + 1) & 0xFFFF] v = ((b_lo >> h_shift) & 1) | (((b_hi >> h_shift) & 1) << 1) elif bpp == 4: - tile_address = (tiledata_addr + tile_num * 32 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 32 + v_shift * 2 + ) & 0xFFFF b_1 = vram[tile_address] b_2 = vram[(tile_address + 1) & 0xFFFF] b_3 = vram[(tile_address + 16) & 0xFFFF] b_4 = vram[(tile_address + 17) & 0xFFFF] - v = ((b_1 >> h_shift) & 1) | (((b_2 >> h_shift) & 1) << 1) | \ - (((b_3 >> h_shift) & 1) << 2) | (((b_4 >> h_shift) & 1) << 3) + v = ( + ((b_1 >> h_shift) & 1) + | (((b_2 >> h_shift) & 1) << 1) + | (((b_3 >> h_shift) & 1) << 2) + | (((b_4 >> h_shift) & 1) << 3) + ) elif bpp == 8: - tile_address = (tiledata_addr + tile_num * 64 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 64 + v_shift * 2 + ) & 0xFFFF b_1 = vram[tile_address] b_2 = vram[(tile_address + 1) & 0xFFFF] b_3 = vram[(tile_address + 16) & 0xFFFF] @@ -300,14 +313,22 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select b_6 = vram[(tile_address + 33) & 0xFFFF] b_7 = vram[(tile_address + 48) & 0xFFFF] b_8 = vram[(tile_address + 49) & 0xFFFF] - v = ((b_1 >> h_shift) & 1) | (((b_2 >> h_shift) & 1) << 1) | \ - (((b_3 >> h_shift) & 1) << 2) | (((b_4 >> h_shift) & 1) << 3) | \ - (((b_5 >> h_shift) & 1) << 4) | (((b_6 >> h_shift) & 1) << 5) | \ - (((b_7 >> h_shift) & 1) << 6) | (((b_8 >> h_shift) & 1) << 7) + v = ( + ((b_1 >> h_shift) & 1) + | (((b_2 >> h_shift) & 1) << 1) + | (((b_3 >> h_shift) & 1) << 2) + | (((b_4 >> h_shift) & 1) << 3) + | (((b_5 >> h_shift) & 1) << 4) + | (((b_6 >> h_shift) & 1) << 5) + | (((b_7 >> h_shift) & 1) << 6) + | (((b_8 >> h_shift) & 1) << 7) + ) else: raise NotImplementedError(f"Invalid bpp {bpp}") if v: - u32_color = cgram_cache[tilemap_palette * bpp_mult + v + color_offset] + u32_color = cgram_cache[ + tilemap_palette * bpp_mult + v + color_offset + ] pix_idx = row_base + dot if write_main: main_bgs[pix_idx] = u32_color @@ -322,10 +343,13 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select eff_scry = (scry_base + scroll_y) % (8 * bg_size_h) i = eff_scry & 7 # Precompute the scry-dependent part of the tilemap word offset. - # (bg_size_w >> 6) == bg_size_w // 64: 0 for 32-tile-wide, 1 for 64-tile-wide. + # (bg_size_w >> 6) == bg_size_w // 64: 0 for 32-tile-wide, 1 for + # 64-tile-wide. scry_for_row = eff_scry % 256 if bg_size_w == 64 else eff_scry scry_page = eff_scry >> 8 - scry_offset = (scry_for_row >> 3) * 32 + (bg_size_w >> 6) * (scry_page * 0x800) + scry_offset = (scry_for_row >> 3) * 32 + (bg_size_w >> 6) * ( + scry_page * 0x800 + ) eff_scrx = scroll_x % (8 * bg_size_w) scrx_wrap = 8 * bg_size_w @@ -339,7 +363,9 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select # Tilemap fetch — once per tile column. scrx_page = eff_scrx >> 8 col_offset = ((eff_scrx & 0xFF) >> 3) + scrx_page * 0x400 - tilemap_word_addr = (screen_addr + scry_offset + col_offset) * 2 & 0xFFFF + tilemap_word_addr = ( + screen_addr + scry_offset + col_offset + ) * 2 & 0xFFFF low = vram[tilemap_word_addr] high = vram[tilemap_word_addr + 1] tile_num = (high & 3) << 8 | low @@ -351,21 +377,31 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select if tilemap_priority == priority_selector: v_shift = i if not tilemap_v_flip else (7 - i) - # Tile-data fetch — once per tile column, shared across all 8 pixels. + # Tile-data fetch — once per tile column, shared across all 8 + # pixels. if bpp == 2: - tile_address = (tiledata_addr + tile_num * 16 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 16 + v_shift * 2 + ) & 0xFFFF b_lo = vram[tile_address] b_hi = vram[(tile_address + 1) & 0xFFFF] - row_code = ct[(b_lo & 0xF) | ((b_hi & 0xF) << 4)] | (ct[((b_lo >> 4) & 0xF) | (b_hi & 0xF0)] << 32) + row_code = ct[(b_lo & 0xF) | ((b_hi & 0xF) << 4)] | ( + ct[((b_lo >> 4) & 0xF) | (b_hi & 0xF0)] << 32 + ) for k in range(n_pixels): current_dot = dot + k - if window_masked is not None and window_masked[current_dot]: + if ( + window_masked is not None + and window_masked[current_dot] + ): continue jj = pixel_in_tile + k h_shift = (7 - jj) if not tilemap_h_flip else jj v = (row_code >> (h_shift * 8)) & 0x3 if v: - u32_color = cgram_cache[tilemap_palette * bpp_mult + v + color_offset] + u32_color = cgram_cache[ + tilemap_palette * bpp_mult + v + color_offset + ] pix_idx = row_base + current_dot if write_main: main_bgs[pix_idx] = u32_color @@ -373,23 +409,36 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select if write_sub: sub_bgs[pix_idx] = u32_color elif bpp == 4: - tile_address = (tiledata_addr + tile_num * 32 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 32 + v_shift * 2 + ) & 0xFFFF b_1 = vram[tile_address] b_2 = vram[(tile_address + 1) & 0xFFFF] b_3 = vram[(tile_address + 16) & 0xFFFF] b_4 = vram[(tile_address + 17) & 0xFFFF] - code12 = ct[(b_1 & 0xF) | ((b_2 & 0xF) << 4)] | (ct[((b_1 >> 4) & 0xF) | (b_2 & 0xF0)] << 32) - code34 = ct[(b_3 & 0xF) | ((b_4 & 0xF) << 4)] | (ct[((b_3 >> 4) & 0xF) | (b_4 & 0xF0)] << 32) + code12 = ct[(b_1 & 0xF) | ((b_2 & 0xF) << 4)] | ( + ct[((b_1 >> 4) & 0xF) | (b_2 & 0xF0)] << 32 + ) + code34 = ct[(b_3 & 0xF) | ((b_4 & 0xF) << 4)] | ( + ct[((b_3 >> 4) & 0xF) | (b_4 & 0xF0)] << 32 + ) for k in range(n_pixels): current_dot = dot + k - if window_masked is not None and window_masked[current_dot]: + if ( + window_masked is not None + and window_masked[current_dot] + ): continue jj = pixel_in_tile + k h_shift = (7 - jj) if not tilemap_h_flip else jj sh = h_shift * 8 - v = ((code12 >> sh) & 0x3) | (((code34 >> sh) & 0x3) << 2) + v = ((code12 >> sh) & 0x3) | ( + ((code34 >> sh) & 0x3) << 2 + ) if v: - u32_color = cgram_cache[tilemap_palette * bpp_mult + v + color_offset] + u32_color = cgram_cache[ + tilemap_palette * bpp_mult + v + color_offset + ] pix_idx = row_base + current_dot if write_main: main_bgs[pix_idx] = u32_color @@ -397,7 +446,9 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select if write_sub: sub_bgs[pix_idx] = u32_color elif bpp == 8: - tile_address = (tiledata_addr + tile_num * 64 + v_shift * 2) & 0xFFFF + tile_address = ( + tiledata_addr + tile_num * 64 + v_shift * 2 + ) & 0xFFFF b_1 = vram[tile_address] b_2 = vram[(tile_address + 1) & 0xFFFF] b_3 = vram[(tile_address + 16) & 0xFFFF] @@ -406,21 +457,38 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select b_6 = vram[(tile_address + 33) & 0xFFFF] b_7 = vram[(tile_address + 48) & 0xFFFF] b_8 = vram[(tile_address + 49) & 0xFFFF] - code12 = ct[(b_1 & 0xF) | ((b_2 & 0xF) << 4)] | (ct[((b_1 >> 4) & 0xF) | (b_2 & 0xF0)] << 32) - code34 = ct[(b_3 & 0xF) | ((b_4 & 0xF) << 4)] | (ct[((b_3 >> 4) & 0xF) | (b_4 & 0xF0)] << 32) - code56 = ct[(b_5 & 0xF) | ((b_6 & 0xF) << 4)] | (ct[((b_5 >> 4) & 0xF) | (b_6 & 0xF0)] << 32) - code78 = ct[(b_7 & 0xF) | ((b_8 & 0xF) << 4)] | (ct[((b_7 >> 4) & 0xF) | (b_8 & 0xF0)] << 32) + code12 = ct[(b_1 & 0xF) | ((b_2 & 0xF) << 4)] | ( + ct[((b_1 >> 4) & 0xF) | (b_2 & 0xF0)] << 32 + ) + code34 = ct[(b_3 & 0xF) | ((b_4 & 0xF) << 4)] | ( + ct[((b_3 >> 4) & 0xF) | (b_4 & 0xF0)] << 32 + ) + code56 = ct[(b_5 & 0xF) | ((b_6 & 0xF) << 4)] | ( + ct[((b_5 >> 4) & 0xF) | (b_6 & 0xF0)] << 32 + ) + code78 = ct[(b_7 & 0xF) | ((b_8 & 0xF) << 4)] | ( + ct[((b_7 >> 4) & 0xF) | (b_8 & 0xF0)] << 32 + ) for k in range(n_pixels): current_dot = dot + k - if window_masked is not None and window_masked[current_dot]: + if ( + window_masked is not None + and window_masked[current_dot] + ): continue jj = pixel_in_tile + k h_shift = (7 - jj) if not tilemap_h_flip else jj sh = h_shift * 8 - v = ((code12 >> sh) & 0x3) | (((code34 >> sh) & 0x3) << 2) | \ - (((code56 >> sh) & 0x3) << 4) | (((code78 >> sh) & 0x3) << 6) + v = ( + ((code12 >> sh) & 0x3) + | (((code34 >> sh) & 0x3) << 2) + | (((code56 >> sh) & 0x3) << 4) + | (((code78 >> sh) & 0x3) << 6) + ) if v: - u32_color = cgram_cache[tilemap_palette * bpp_mult + v + color_offset] + u32_color = cgram_cache[ + tilemap_palette * bpp_mult + v + color_offset + ] pix_idx = row_base + current_dot if write_main: main_bgs[pix_idx] = u32_color @@ -437,7 +505,8 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select def _bg_window_config(ppu: Ppu, bg_idx: int): - """Return (window_active, w1_en, w1_inv, w2_en, w2_inv, combine) for a BG.""" + """Return (window_active, w1_en, w1_inv, w2_en, w2_inv, combine) for a + BG.""" window_active = ppu.tmw & (1 << bg_idx) if not window_active: return False, False, False, False, False, 0 @@ -454,18 +523,27 @@ def _bg_window_config(ppu: Ppu, bg_idx: int): w2_invert = (sel >> 2) & 1 w2_enable = (sel >> 3) & 1 combine_logic = (ppu.wbglog >> (bg_idx * 2)) & 0x3 - return window_active, w1_enable, w1_invert, w2_enable, w2_invert, combine_logic + return ( + window_active, + w1_enable, + w1_invert, + w2_enable, + w2_invert, + combine_logic, + ) def draw_hires_background_scanline( ppu: Ppu, bg: Background, bpp: int, priority_selector: bool ) -> None: - """Render one BG scanline for the hi-res modes 5 and 6, downsampled to 256px. + """Render one BG scanline for the hi-res modes 5 and 6, downsampled to + 256px. In modes 5/6 the PPU outputs 512 dots per scanline. Each tilemap entry maps - to a 16-pixel-wide cell built from two horizontally-adjacent name-table tiles - (T for the left 8 dots, T+1 for the right 8); the on-screen 512 dots come - from interleaving the main screen (odd dots) with the sub screen (even dots). + to a 16-pixel-wide cell built from two horizontally-adjacent name-table + tiles (T for the left 8 dots, T+1 for the right 8); the on-screen 512 dots + come from interleaving the main screen (odd dots) with the sub screen (even + dots). Our framebuffer is 256px, so we render only the main screen's 256 dots — each output pixel x samples hi-res dot (2x+1). bg.hoffset is already in @@ -481,8 +559,14 @@ def draw_hires_background_scanline( layer_tag = bg.number bg_idx = bg.number - 1 - (window_active, w1_enable, w1_invert, - w2_enable, w2_invert, combine_logic) = _bg_window_config(ppu, bg_idx) + ( + window_active, + w1_enable, + w1_invert, + w2_enable, + w2_invert, + combine_logic, + ) = _bg_window_config(ppu, bg_idx) if ppu._cgram_dirty: ppu._rebuild_cgram_cache() @@ -491,12 +575,12 @@ def draw_hires_background_scanline( screen_size = bg.screen_size bg_size_w = 32 << (screen_size & 1) bg_size_h = 32 << (screen_size >> 1) - scroll_x = bg.hoffset # hi-res (512-dot) units + scroll_x = bg.hoffset # hi-res (512-dot) units scroll_y = bg.voffset tiledata_addr = bg.tiledata_addr screen_addr = bg.screen_addr & 0xFFFF bpp_mult = 1 << bpp - plane_w = 16 * bg_size_w # plane width in hi-res dots + plane_w = 16 * bg_size_w # plane width in hi-res dots orgy = ppu.v_counter - 1 row_base = orgy * SCREEN_WIDTH @@ -509,15 +593,19 @@ def draw_hires_background_scanline( eff_scry = (ppu.v_counter + scroll_y) % (8 * bg_size_h) i = eff_scry & 7 scry_for_row = eff_scry % 256 if bg_size_w == 64 else eff_scry - scry_offset = (scry_for_row >> 3) * 32 + (bg_size_w >> 6) * ((eff_scry >> 8) * 0x800) + scry_offset = (scry_for_row >> 3) * 32 + (bg_size_w >> 6) * ( + (eff_scry >> 8) * 0x800 + ) window_masked = None if window_active and (w1_enable or w2_enable): window_masked = ppu._window_mask_buf ppu._build_window_mask( window_masked, - w1_enable, w1_invert, - w2_enable, w2_invert, + w1_enable, + w1_invert, + w2_enable, + w2_invert, combine_logic, ) @@ -527,12 +615,14 @@ def draw_hires_background_scanline( # Main screen samples the odd hi-res dots. hp = (2 * dot + 1 + scroll_x) % plane_w - entry = hp >> 4 # which 16-dot tilemap cell - cell = hp & 15 # position within the cell (0..15) + entry = hp >> 4 # which 16-dot tilemap cell + cell = hp & 15 # position within the cell (0..15) col_page = entry >> 5 col = entry & 31 - tilemap_word_addr = (screen_addr + scry_offset + col + col_page * 0x400) * 2 & 0xFFFF + tilemap_word_addr = ( + screen_addr + scry_offset + col + col_page * 0x400 + ) * 2 & 0xFFFF low = vram[tilemap_word_addr] high = vram[tilemap_word_addr + 1] if ((high >> 5) & 1) != priority_selector: @@ -544,7 +634,7 @@ def draw_hires_background_scanline( # h-flip mirrors the whole 16-dot cell (swaps the two sub-tiles too). eff_cell = (15 - cell) if tilemap_h_flip else cell - sub_tile = eff_cell >> 3 # 0 -> tile T, 1 -> tile T+1 + sub_tile = eff_cell >> 3 # 0 -> tile T, 1 -> tile T+1 h_shift = 7 - (eff_cell & 7) tnum = tile_num + sub_tile v_shift = i if not tilemap_v_flip else (7 - i) @@ -560,8 +650,12 @@ def draw_hires_background_scanline( b_2 = vram[(tile_address + 1) & 0xFFFF] b_3 = vram[(tile_address + 16) & 0xFFFF] b_4 = vram[(tile_address + 17) & 0xFFFF] - v = ((b_1 >> h_shift) & 1) | (((b_2 >> h_shift) & 1) << 1) | \ - (((b_3 >> h_shift) & 1) << 2) | (((b_4 >> h_shift) & 1) << 3) + v = ( + ((b_1 >> h_shift) & 1) + | (((b_2 >> h_shift) & 1) << 1) + | (((b_3 >> h_shift) & 1) << 2) + | (((b_4 >> h_shift) & 1) << 3) + ) if v: u32_color = cgram_cache[tilemap_palette * bpp_mult + v] diff --git a/pysnes/ppu/color_math.py b/pysnes/ppu/color_math.py index e4aeea1..87ce01b 100644 --- a/pysnes/ppu/color_math.py +++ b/pysnes/ppu/color_math.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING - from .constants import SCREEN_WIDTH if TYPE_CHECKING: @@ -10,13 +9,13 @@ # CGADSUB ($2131) bit masks _CGADSUB_SUBTRACT = 0x80 # 0=add, 1=subtract -_CGADSUB_HALF = 0x40 # 0=full intensity, 1=half intensity -_CGADSUB_BACK = 0x20 # backdrop participates -_CGADSUB_OBJ = 0x10 # OBJ palettes 4-7 participate -_CGADSUB_BG4 = 0x08 -_CGADSUB_BG3 = 0x04 -_CGADSUB_BG2 = 0x02 -_CGADSUB_BG1 = 0x01 +_CGADSUB_HALF = 0x40 # 0=full intensity, 1=half intensity +_CGADSUB_BACK = 0x20 # backdrop participates +_CGADSUB_OBJ = 0x10 # OBJ palettes 4-7 participate +_CGADSUB_BG4 = 0x08 +_CGADSUB_BG3 = 0x04 +_CGADSUB_BG2 = 0x02 +_CGADSUB_BG1 = 0x01 def draw_scanline_forced_blank(ppu: Ppu) -> None: @@ -85,8 +84,8 @@ def composite_scanline(ppu: Ppu) -> None: enable_obj = cgadsub & _CGADSUB_OBJ enable_back = cgadsub & _CGADSUB_BACK - # Color-window (math window) setup: WOBJSEL bits 4-7, WOBJLOG bits 2-3. - # Per $2125 spec (matching $2123 W12SEL convention): bit 0=invert, bit 1=enable. + # Color-window (math window) setup: WOBJSEL bits 4-7, WOBJLOG bits 2-3. Per + # $2125 spec (matching $2123 W12SEL convention): bit 0=invert, bit 1=enable. # Pairs: bits 0-1 OBJ W1, 2-3 OBJ W2, 4-5 MATH W1, 6-7 MATH W2. math_w1_invert = (ppu.wobjsel >> 4) & 1 math_w1_enable = (ppu.wobjsel >> 5) & 1 @@ -100,8 +99,10 @@ def composite_scanline(ppu: Ppu) -> None: cmath_mask = ppu._window_mask_buf ppu._build_window_mask( cmath_mask, - math_w1_enable, math_w1_invert, - math_w2_enable, math_w2_invert, + math_w1_enable, + math_w1_invert, + math_w2_enable, + math_w2_invert, math_logic, ) @@ -128,12 +129,9 @@ def composite_scanline(ppu: Ppu) -> None: # Color-window gating (CGWSEL bits 5-4). if cmath_mode != 0: - if cmath_mask is not None: - in_window = cmath_mask[x] - else: - # No windows enabled → treat as always inside. Matches Mesen - # semantic where a disabled window acts as full-screen inside. - in_window = True + # No windows enabled → treat as always inside. Matches Mesen + # semantic where a disabled window acts as full-screen inside. + in_window = cmath_mask[x] if cmath_mask is not None else True if cmath_mode == 1 and not in_window: continue # inside only → skip outside if cmath_mode == 2 and in_window: @@ -179,7 +177,9 @@ def composite_scanline(ppu: Ppu) -> None: ppu.main_bgs[idx] = (r << 24) | (g << 16) | (b << 8) | (m & 0xFF) -def get_u32_color(ppu: Ppu, bpp: int, palette: int, color: int, color_offset: int = 0) -> int: +def get_u32_color( + ppu: Ppu, bpp: int, palette: int, color: int, color_offset: int = 0 +) -> int: if ppu._cgram_dirty: ppu._rebuild_cgram_cache() if bpp == 8: diff --git a/pysnes/ppu/constants.py b/pysnes/ppu/constants.py index a7954d8..2c4e730 100644 --- a/pysnes/ppu/constants.py +++ b/pysnes/ppu/constants.py @@ -7,15 +7,15 @@ _TOTAL_SCANLINES: int = 262 SCREEN_WIDTH = 256 -SCREEN_HEIGHT = 224 -# TODO: overscan — PAL uses 239/240 visible lines; SETINI $2133 bit 2 enables NTSC pseudo-overscan to 239 lines +# TODO: overscan — PAL uses 239/240 visible lines; SETINI $2133 bit 2 enables +# NTSC pseudo-overscan to 239 lines # Bit positions for each of the 8 pixels in a tile byte, right-to-left. _PIXEL_SEQUENCE = [7, 6, 5, 4, 3, 2, 1, 0] -# 256-entry LUT: key = (plane0_nibble) | (plane1_nibble << 4). -# Value = 4 per-pixel 2-bit color codes packed one per byte (byte p = h_shift p). -_colorcode_table = array('I', [0] * 256) +# 256-entry LUT: key = (plane0_nibble) | (plane1_nibble << 4). Value = 4 +# per-pixel 2-bit color codes packed one per byte (byte p = h_shift p). +_colorcode_table = array("I", [0] * 256) for _b in range(256): _b1n = _b & 0xF _b2n = (_b >> 4) & 0xF diff --git a/pysnes/ppu/data_structures.py b/pysnes/ppu/data_structures.py index ea26b69..8c88aa9 100644 --- a/pysnes/ppu/data_structures.py +++ b/pysnes/ppu/data_structures.py @@ -1,9 +1,7 @@ from dataclasses import dataclass - class Background: - def __init__( self, number: int = 0, @@ -31,8 +29,14 @@ def __init__( self.color_offset_mode_0 = color_offset_mode_0 _STATE_FIELDS = ( - "screen_size", "screen_addr", "tiledata_addr", "tile_size", - "main_screen_enable", "sub_screen_enable", "hoffset", "voffset", + "screen_size", + "screen_addr", + "tiledata_addr", + "tile_size", + "main_screen_enable", + "sub_screen_enable", + "hoffset", + "voffset", ) def dump_state(self) -> dict: @@ -54,32 +58,3 @@ class Object: priority = 0 palette = 0 size = False - - -@dataclass -class Tilemap: - addr: int - palette: int - priority: int - h_flip: int - v_flip: int - - @classmethod - def from_buffer(cls, data: bytearray, addr: int) -> "Tilemap": - low = data[addr] - high = data[addr + 1] - return cls( - addr=(high & 3) << 8 | low, - palette=(high >> 2) & 7, - priority=(high >> 5) & 1, - h_flip=(high >> 6) & 1, - v_flip=(high >> 7) & 1, - ) - - -@dataclass -class Tile: - tile_map: Tilemap - map_num: int # map number 0-3 - map_position_x: int # x index inside de map 0-31 - map_position_y: int # y index inside de map 0-31 diff --git a/pysnes/ppu/oam.py b/pysnes/ppu/oam.py index 7f72e79..06ed16c 100644 --- a/pysnes/ppu/oam.py +++ b/pysnes/ppu/oam.py @@ -1,10 +1,8 @@ -from typing import Optional - from .data_structures import Object class OAM: - def __init__(self, *, oam_dump: Optional[bytes] = None) -> None: + def __init__(self, *, oam_dump: bytes | None = None) -> None: # Object Attribute Memory self.oam = bytearray(512 + 32) self.objects = [Object() for i in range(128)] @@ -64,9 +62,8 @@ def update_low_table(self, addr: int) -> None: data = self.oam[addr + 3] obj.name_select = data & 0x01 - obj.palette = ( - (data >> 1) & 0x07 - ) + 8 # Objects use the palettes present in the second half of CGRAM + # Objects use the palettes present in the second half of CGRAM + obj.palette = ((data >> 1) & 0x07) + 8 obj.priority = (data >> 4) & 0x03 obj.h_flip = data & 0x40 obj.v_flip = data & 0x80 diff --git a/pysnes/ppu/obj_renderer.py b/pysnes/ppu/obj_renderer.py index c0579da..bee358a 100644 --- a/pysnes/ppu/obj_renderer.py +++ b/pysnes/ppu/obj_renderer.py @@ -1,8 +1,7 @@ from __future__ import annotations from array import array -from typing import Tuple, TYPE_CHECKING - +from typing import TYPE_CHECKING from .constants import SCREEN_WIDTH from .data_structures import Object @@ -26,16 +25,23 @@ def _decode_obj_tile(ppu: Ppu, slot: int, vram_addr: int) -> None: for col in range(8): shift = 7 - col cache[dst + col] = ( - ((b0 >> shift) & 1) | - (((b1 >> shift) & 1) << 1) | - (((b2 >> shift) & 1) << 2) | - (((b3 >> shift) & 1) << 3) + ((b0 >> shift) & 1) + | (((b1 >> shift) & 1) << 1) + | (((b2 >> shift) & 1) << 2) + | (((b3 >> shift) & 1) << 3) ) ppu._obj_tile_dirty[slot] = 0 -def _plot_obj(ppu: Ppu, obj: Object, x_offset: int, tile_base_addr: int, - tile_width: int, tile_height: int, cgram_cache: array) -> None: +def _plot_obj( + ppu: Ppu, + obj: Object, + x_offset: int, + tile_base_addr: int, + tile_width: int, + tile_height: int, + cgram_cache: array, +) -> None: """Plot one object's pixels for the current scanline into the OBJ line buffers, writing only where no lower-index sprite has already claimed the pixel (color_line == 0). Objects are always 4bpp.""" @@ -109,7 +115,11 @@ def _render_obj_line(ppu: Ppu) -> None: cgram_cache = ppu._cgram_cache for obj in ppu.oam.objects: - if obj.y == 240: # TODO: replace with proper Y-bounds check; y=240 is the common hide convention but not the hardware rule + if ( + obj.y == 240 + # TODO: replace with proper Y-bounds check; y=240 is the common hide + # convention but not the hardware rule + ): continue tile_width, tile_height = get_obj_dimensions(ppu, obj.size) @@ -124,10 +134,19 @@ def _render_obj_line(ppu: Ppu) -> None: # table, offset from the first by (oam_nameselect+1)*0x1000 VRAM words. tile_base_word = ppu.oam_tiledata_address if obj.name_select: - tile_base_word = (tile_base_word + (ppu.oam_nameselect + 1) * 0x1000) & 0x7FFF + tile_base_word = ( + tile_base_word + (ppu.oam_nameselect + 1) * 0x1000 + ) & 0x7FFF - _plot_obj(ppu, obj, x_screen, tile_base_word * 2, - tile_width, tile_height, cgram_cache) + _plot_obj( + ppu, + obj, + x_screen, + tile_base_word * 2, + tile_width, + tile_height, + cgram_cache, + ) def copy_obj_pixels_for_priority(ppu: Ppu, priority: int = -1) -> None: @@ -165,7 +184,7 @@ def copy_obj_pixels_for_priority(ppu: Ppu, priority: int = -1) -> None: main_layer[idx] = layer_line[px] -def get_obj_dimensions(ppu: Ppu, obj_size: bool) -> Tuple[int, int]: +def get_obj_dimensions(ppu: Ppu, obj_size: bool) -> tuple[int, int]: """ 000 = 8x8 and 16x16 sprites 001 = 8x8 and 32x32 sprites @@ -190,7 +209,8 @@ def draw_point( x: int, y: int, ) -> None: - """Draw a single pixel from bitplane data at tile_data_index+i, wrapping at len(tile_data). + """Draw a single pixel from bitplane data at tile_data_index+i, wrapping at + len(tile_data). Used by tests to verify VRAM-boundary wrapping in tile fetches. """ diff --git a/pysnes/ppu/ppu.py b/pysnes/ppu/ppu.py index f4d947f..98ca9af 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -1,19 +1,21 @@ from array import array from ctypes import c_uint8 -from typing import Optional, TYPE_CHECKING - +from typing import TYPE_CHECKING from . import bg_renderer, color_math, obj_renderer from .constants import ( - _MC_PER_SCANLINE, _HBLANK_START_MC, _VBLANK_START_LINE, _TOTAL_SCANLINES, - SCREEN_WIDTH, SCREEN_HEIGHT, + _HBLANK_START_MC, + _MC_PER_SCANLINE, + _TOTAL_SCANLINES, + _VBLANK_START_LINE, + SCREEN_WIDTH, ) from .data_structures import Background from .oam import OAM if TYPE_CHECKING: - from ..scheduler import Scheduler from ..bus import Bus + from ..scheduler import Scheduler class Ppu: @@ -33,13 +35,13 @@ class Ppu: def __init__( self, *, - vram_dump: Optional[bytes] = None, - cgram_dump: Optional[bytes] = None, - oam_dump: Optional[bytes] = None, + vram_dump: bytes | None = None, + cgram_dump: bytes | None = None, + oam_dump: bytes | None = None, ) -> None: # Scheduler and bus are attached after construction via attach() - self.scheduler: Optional[Scheduler] = None - self.bus: Optional[Bus] = None + self.scheduler: Scheduler | None = None + self.bus: Bus | None = None # VRAM - Video RAM if vram_dump is None: @@ -64,7 +66,7 @@ def __init__( else: self.cgram = bytearray(cgram_dump) self._cgadd = c_uint8(0x00) - self._cgdata: Optional[c_uint8] = None + self._cgdata: c_uint8 | None = None # OAM self.oam = OAM(oam_dump=oam_dump) @@ -101,7 +103,7 @@ def __init__( self.m7x = 0 self.m7y = 0 self.m7sel = 0 - self.m7_extbg = False # SETINI ($2133) bit 6 + self.m7_extbg = False # SETINI ($2133) bit 6 # Hardware multiplier: product of signed16(m7a) × signed8(m7b_lo). # Updated on every write to $211C (M7B). Read via $2134-$2136. self._mpy_result = 0 @@ -110,10 +112,10 @@ def __init__( self.w12sel = 0 self.w34sel = 0 self.wobjsel = 0 - self.wh0 = 0 # Window 1 left - self.wh1 = 0 # Window 1 right - self.wh2 = 0 # Window 2 left - self.wh3 = 0 # Window 2 right + self.wh0 = 0 # Window 1 left + self.wh1 = 0 # Window 1 right + self.wh2 = 0 # Window 2 left + self.wh3 = 0 # Window 2 right self.wbglog = 0 self.wobjlog = 0 @@ -133,28 +135,30 @@ def __init__( self.mosaic_size = 0 self.field = 0 # 0 for even frames, 1 for odd frames - self.h_counter = 0 # current dot being drawn (updated at H-Blank / scanline start) + # current dot being drawn (updated at H-Blank / scanline start) + self.h_counter = 0 self.v_counter = 0 # current scanline being drawn self.frames = 0 # total frames rendered - self.main_bgs = array('I', [0] * 256 * 262) # 262 was 239 before - self.sub_bgs = array('I', [0] * 256 * 262) - # Per-pixel main-screen layer tag: 0=backdrop, 1-4=BG1-BG4, 5=OBJ. - # Used by the color-math composite pass to know which pixels participate. + self.main_bgs = array("I", [0] * 256 * 262) # 262 was 239 before + self.sub_bgs = array("I", [0] * 256 * 262) + # Per-pixel main-screen layer tag: 0=backdrop, 1-4=BG1-BG4, 5=OBJ. Used + # by the color-math composite pass to know which pixels participate. self.main_layer = bytearray(256 * 262) # CGRAM u32-color cache: 256 entries (one per CGRAM slot), pre-converted # from 15-bit SNES RGB to 32-bit RGBA. Rebuilt lazily when _cgram_dirty. # array.array('I') gives PyPy direct unboxed 32-bit integer access. - self._cgram_cache = array('I', [0] * 256) + self._cgram_cache = array("I", [0] * 256) self._cgram_dirty: bool = True # Reusable 256-byte window mask buffer — avoids per-scanline allocation. self._window_mask_buf = bytearray(256) - # 4bpp sprite tile decode cache. One entry per 32-byte VRAM slot (2048 total). - # Each entry stores 64 pre-decoded color indices: 8 rows × 8 pixels (0-15). - # Invalidated on VRAM writes; rebuilt lazily while plotting objects. + # 4bpp sprite tile decode cache. One entry per 32-byte VRAM slot (2048 + # total). Each entry stores 64 pre-decoded color indices: 8 rows × 8 + # pixels (0-15). Invalidated on VRAM writes; rebuilt lazily while + # plotting objects. _N_OBJ_TILE_SLOTS = 2048 # 65536 VRAM bytes / 32 bytes per 4bpp tile self._obj_tile_cache = bytearray(_N_OBJ_TILE_SLOTS * 64) self._obj_tile_dirty = bytearray([1] * _N_OBJ_TILE_SLOTS) @@ -166,26 +170,67 @@ def __init__( # in _render_layers just blit the pixels whose owning sprite has that # priority. _obj_line_color holds the packed RGBA (0 = transparent), # _obj_line_pri the priority (0-3), _obj_line_layer the color-math layer - # tag (5 or 6). _obj_line_vc marks the scanline the buffers were built for. - self._obj_line_color = array('I', [0] * 256) + # tag (5 or 6). _obj_line_vc marks the scanline the buffers were built + # for. + self._obj_line_color = array("I", [0] * 256) self._obj_line_pri = bytearray(256) self._obj_line_layer = bytearray(256) self._obj_line_vc = -1 _SCALAR_STATE = ( - "vmain", "vmaddl", "vmaddh", "_vmdatal", "_vmdatah", "_vram_prefetch", - "display_brightness", "display_disable", - "_oamadd", "_oamadd_reload", "_oam_priority_activation", "_oamodd", "_oamdata", - "oam_main_screen_enable", "oam_sub_screen_enable", - "oam_tiledata_address", "oam_nameselect", "oam_base_size", - "_bgmode", "_bgpriority", - "latch_bgofs_ppu1", "latch_bgofs_ppu2", - "_m7_latch", "m7a", "m7b", "m7c", "m7d", "m7x", "m7y", "m7sel", "m7_extbg", "_mpy_result", - "w12sel", "w34sel", "wobjsel", "wh0", "wh1", "wh2", "wh3", - "wbglog", "wobjlog", "tmw", "tsw", - "cgwsel", "cgadsub", "coldata_r", "coldata_g", "coldata_b", + "vmain", + "vmaddl", + "vmaddh", + "_vmdatal", + "_vmdatah", + "_vram_prefetch", + "display_brightness", + "display_disable", + "_oamadd", + "_oamadd_reload", + "_oam_priority_activation", + "_oamodd", + "_oamdata", + "oam_main_screen_enable", + "oam_sub_screen_enable", + "oam_tiledata_address", + "oam_nameselect", + "oam_base_size", + "_bgmode", + "_bgpriority", + "latch_bgofs_ppu1", + "latch_bgofs_ppu2", + "_m7_latch", + "m7a", + "m7b", + "m7c", + "m7d", + "m7x", + "m7y", + "m7sel", + "m7_extbg", + "_mpy_result", + "w12sel", + "w34sel", + "wobjsel", + "wh0", + "wh1", + "wh2", + "wh3", + "wbglog", + "wobjlog", + "tmw", + "tsw", + "cgwsel", + "cgadsub", + "coldata_r", + "coldata_g", + "coldata_b", "mosaic_size", - "field", "h_counter", "v_counter", "frames", + "field", + "h_counter", + "v_counter", + "frames", ) def dump_state(self) -> dict: @@ -213,7 +258,10 @@ def load_state(self, d: dict) -> None: for f, v in d["scalars"].items(): setattr(self, f, v) self._cgadd = c_uint8(d["_cgadd"]) - self._cgdata = c_uint8(d["_cgdata"]) if d["_cgdata"] is not None else None + if d["_cgdata"] is not None: + self._cgdata = c_uint8(d["_cgdata"]) + else: + self._cgdata = None self.bg1.load_state(d["bg1"]) self.bg2.load_state(d["bg2"]) self.bg3.load_state(d["bg3"]) @@ -225,10 +273,15 @@ def inidisp_set(self, data: int) -> None: # entry (wired in _vblank_start). Still missing: the same reload when # forced-blank (bit 7) is cleared *during* V-Blank — a rarer case. new_disable = (data >> 7) & 1 - if new_disable and not self.display_disable and 0 < self.v_counter < _VBLANK_START_LINE: - # Forced blank just asserted during active display. Rows 0..v_counter-1 - # have already been rendered with display enabled; retroactively clear - # them so stale pixels don't appear at the top of the frame. + if ( + new_disable + and not self.display_disable + and 0 < self.v_counter < _VBLANK_START_LINE + ): + # Forced blank just asserted during active display. Rows + # 0..v_counter-1 have already been rendered with display enabled; + # retroactively clear them so stale pixels don't appear at the top + # of the frame. black = 0x000000FF limit = self.v_counter * SCREEN_WIDTH for i in range(limit): @@ -307,21 +360,20 @@ def _remap_vram_addr(self, addr: int) -> int: m = self.vmain_addr_remapping if m == 0: return addr - elif m == 1: # aaaaaaaaBBBccccc → aaaaaaaacccccBBB - return (addr & 0xFF00) | ((addr & 0x001F) << 3) | ((addr & 0x00E0) >> 5) - elif m == 2: # aaaaaaaBBBcccccc → aaaaaaaccccccBBB - return (addr & 0xFE00) | ((addr & 0x003F) << 3) | ((addr & 0x01C0) >> 6) - else: # aaaaaaBBBccccccc → aaaaaacccccccBBB - return (addr & 0xFC00) | ((addr & 0x007F) << 3) | ((addr & 0x0380) >> 7) - - def write_vram(self) -> None: - word_addr = (self.vmaddl | self.vmaddh << 8) & 0x7FFF - base_addr = self._remap_vram_addr(word_addr) * 2 - assert base_addr < len(self.vram), f"VRAM write out of bounds: 0x{base_addr:06X}" - self.vram[base_addr + 0] = self._vmdatal - self.vram[base_addr + 1] = self._vmdatah - self._obj_tile_dirty[base_addr >> 5] = 1 - self.increment_vmadd() + if m == 1: # aaaaaaaaBBBccccc → aaaaaaaacccccBBB + return ( + (addr & 0xFF00) + | ((addr & 0x001F) << 3) + | ((addr & 0x00E0) >> 5) + ) + if m == 2: # aaaaaaaBBBcccccc → aaaaaaaccccccBBB + return ( + (addr & 0xFE00) + | ((addr & 0x003F) << 3) + | ((addr & 0x01C0) >> 6) + ) + # aaaaaaBBBccccccc → aaaaaacccccccBBB + return (addr & 0xFC00) | ((addr & 0x007F) << 3) | ((addr & 0x0380) >> 7) def increment_vmadd(self) -> None: addr = ( @@ -333,7 +385,9 @@ def increment_vmadd(self) -> None: def refill_vram_prefetch(self) -> None: word_addr = (self.vmaddl | self.vmaddh << 8) & 0x7FFF base_addr = self._remap_vram_addr(word_addr) * 2 - self._vram_prefetch = self.vram[base_addr] | (self.vram[base_addr + 1] << 8) + self._vram_prefetch = self.vram[base_addr] | ( + self.vram[base_addr + 1] << 8 + ) def rdvraml(self) -> int: data = self._vram_prefetch & 0xFF @@ -388,8 +442,10 @@ def stat78(self) -> int: @property def slhv(self) -> int: - """When read, the H/V counter (as read from $213C and $213D) will be latched to - the current X and Y position if bit 7 of $4201 is set. The data actually read is open bus.""" + """When read, the H/V counter (as read from $213C and $213D) will be + latched to the current X and Y position if bit 7 of $4201 is set. The + data actually + read is open bus.""" return 0 # TODO def obsel_set(self, data: int) -> None: @@ -533,7 +589,8 @@ def coldata_set(self, data: int) -> None: # ------------------------------------------------------------------ def reset_registers(self) -> None: - """Reset all PPU I/O registers to power-on state. Preserves VRAM/CGRAM/OAM.""" + """Reset all PPU I/O registers to power-on state. Preserves + VRAM/CGRAM/OAM.""" self.vmain = 0x00 self.vmaddl = 0 self.vmaddh = 0 @@ -627,7 +684,9 @@ def _hblank(self) -> None: self._irq_check() # Schedule end of scanline / start of next - self.scheduler.add(_MC_PER_SCANLINE - _HBLANK_START_MC, self._scanline_end) + self.scheduler.add( + _MC_PER_SCANLINE - _HBLANK_START_MC, self._scanline_end + ) def _irq_check(self) -> None: """Raise CPU IRQ line if the H/V timer match condition is satisfied. @@ -714,100 +773,141 @@ def _render_layers(self): # Each layer writes only where pixels are non-transparent, so the # last write at a pixel wins (= "in front"). bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg4, 2, False) # BG4 pri 0 + # BG4 pri 0 + bg_renderer.draw_background_scanline(self, self.bg4, 2, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg3, 2, False) # BG3 pri 0 + # BG3 pri 0 + bg_renderer.draw_background_scanline(self, self.bg3, 2, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg4, 2, True) # BG4 pri 1 - bg_renderer.draw_background_scanline(self, self.bg3, 2, True) # BG3 pri 1 + # BG4 pri 1 + bg_renderer.draw_background_scanline(self, self.bg4, 2, True) + # BG3 pri 1 + bg_renderer.draw_background_scanline(self, self.bg3, 2, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg2, 2, False) # BG2 pri 0 - bg_renderer.draw_background_scanline(self, self.bg1, 2, False) # BG1 pri 0 + # BG2 pri 0 + bg_renderer.draw_background_scanline(self, self.bg2, 2, False) + # BG1 pri 0 + bg_renderer.draw_background_scanline(self, self.bg1, 2, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=3) - bg_renderer.draw_background_scanline(self, self.bg2, 2, True) # BG2 pri 1 - bg_renderer.draw_background_scanline(self, self.bg1, 2, True) # BG1 pri 1 + # BG2 pri 1 + bg_renderer.draw_background_scanline(self, self.bg2, 2, True) + # BG1 pri 1 + bg_renderer.draw_background_scanline(self, self.bg1, 2, True) elif self._bgmode == 1: # Mode 1: BG1+BG2 (4bpp), BG3 (2bpp); $2105 bit 3 moves BG3 pri-1 # BG3 pri-1 either to the very top or behind OBJ pri-0/1. bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg3, 2, False) # BG3 pri 0 + # BG3 pri 0 + bg_renderer.draw_background_scanline(self, self.bg3, 2, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) if self._bgpriority == 0: - bg_renderer.draw_background_scanline(self, self.bg3, 2, True) # BG3 pri 1 (low) + # BG3 pri 1 (low) + bg_renderer.draw_background_scanline(self, self.bg3, 2, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 4, False) # BG2 pri 0 - bg_renderer.draw_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + # BG2 pri 0 + bg_renderer.draw_background_scanline(self, self.bg2, 4, False) + # BG1 pri 0 + bg_renderer.draw_background_scanline(self, self.bg1, 4, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg2, 4, True) # BG2 pri 1 - bg_renderer.draw_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + # BG2 pri 1 + bg_renderer.draw_background_scanline(self, self.bg2, 4, True) + # BG1 pri 1 + bg_renderer.draw_background_scanline(self, self.bg1, 4, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=3) if self._bgpriority == 1: - bg_renderer.draw_background_scanline(self, self.bg3, 2, True) # BG3 pri 1 (high) + # BG3 pri 1 (high) + bg_renderer.draw_background_scanline(self, self.bg3, 2, True) elif self._bgmode == 2: # Mode 2: BG1 (4bpp) + BG2 (4bpp) with offset-per-tile via BG3. # OPT is not implemented; we render without per-column offsets, # which gets the layout approximately right (enough to boot games # that probe their own title/menu screens). bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg2, 4, False) # BG2 pri 0 + # BG2 pri 0 + bg_renderer.draw_background_scanline(self, self.bg2, 4, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + # BG1 pri 0 + bg_renderer.draw_background_scanline(self, self.bg1, 4, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 4, True) # BG2 pri 1 + # BG2 pri 1 + bg_renderer.draw_background_scanline(self, self.bg2, 4, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + # BG1 pri 1 + bg_renderer.draw_background_scanline(self, self.bg1, 4, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=3) elif self._bgmode == 3: - # Mode 3: BG1 (8bpp/256-color), BG2 (4bpp); $2130 may enable Direct Color on BG1. + # Mode 3: BG1 (8bpp/256-color), BG2 (4bpp); $2130 may enable Direct + # Color on BG1. bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg2, 4, False) # BG2 pri 0 + # BG2 pri 0 + bg_renderer.draw_background_scanline(self, self.bg2, 4, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 8, False) # BG1 pri 0 + # BG1 pri 0 + bg_renderer.draw_background_scanline(self, self.bg1, 8, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 4, True) # BG2 pri 1 + # BG2 pri 1 + bg_renderer.draw_background_scanline(self, self.bg2, 4, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 8, True) # BG1 pri 1 + # BG1 pri 1 + bg_renderer.draw_background_scanline(self, self.bg1, 8, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=3) elif self._bgmode == 4: # Mode 4: BG1 (4bpp) + BG2 (2bpp) with OPT (offset-per-tile, not # implemented — same simplification as Mode 2). bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg2, 2, False) # BG2 pri 0 + # BG2 pri 0 + bg_renderer.draw_background_scanline(self, self.bg2, 2, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + # BG1 pri 0 + bg_renderer.draw_background_scanline(self, self.bg1, 4, False) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 2, True) # BG2 pri 1 + # BG2 pri 1 + bg_renderer.draw_background_scanline(self, self.bg2, 2, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + # BG1 pri 1 + bg_renderer.draw_background_scanline(self, self.bg1, 4, True) obj_renderer.copy_obj_pixels_for_priority(self, priority=3) elif self._bgmode == 5: - # Mode 5: BG1 (4bpp) + BG2 (2bpp), hi-res. - # Natively 512px/scanline (main screen = odd dots, sub = even dots). - # We render the main screen downsampled to the 256px framebuffer; - # each tilemap entry is a 16-dot cell of two adjacent tiles (T, T+1). - # See bg_renderer.draw_hires_background_scanline. + # Mode 5: BG1 (4bpp) + BG2 (2bpp), hi-res. Natively 512px/scanline + # (main screen = odd dots, sub = even dots). We render the main + # screen downsampled to the 256px framebuffer; each tilemap entry is + # a 16-dot cell of two adjacent tiles (T, T+1). See + # bg_renderer.draw_hires_background_scanline. bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_hires_background_scanline(self, self.bg2, 2, False) # BG2 pri 0 + bg_renderer.draw_hires_background_scanline( + self, self.bg2, 2, False + ) # BG2 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_hires_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + bg_renderer.draw_hires_background_scanline( + self, self.bg1, 4, False + ) # BG1 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_hires_background_scanline(self, self.bg2, 2, True) # BG2 pri 1 + bg_renderer.draw_hires_background_scanline( + self, self.bg2, 2, True + ) # BG2 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_hires_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + bg_renderer.draw_hires_background_scanline( + self, self.bg1, 4, True + ) # BG1 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=3) elif self._bgmode == 6: # Mode 6: BG1 (4bpp) only, hi-res + OPT (OPT not implemented). # Same hi-res handling as Mode 5, main screen only. bg_renderer.draw_scanline_backdrop(self) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_hires_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + bg_renderer.draw_hires_background_scanline( + self, self.bg1, 4, False + ) # BG1 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=1) obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_hires_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + bg_renderer.draw_hires_background_scanline( + self, self.bg1, 4, True + ) # BG1 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=3) else: - # Mode 7: affine-transformed BG1 (8bpp). EXTBG BG2 written by same pass. - # Priority (back→front): backdrop, OBJ0, OBJ1, BG1, BG2(EXTBG), OBJ2, OBJ3 + # Mode 7: affine-transformed BG1 (8bpp). EXTBG BG2 written by same + # pass. Priority (back→front): backdrop, OBJ0, OBJ1, BG1, + # BG2(EXTBG), OBJ2, OBJ3 bg_renderer.draw_scanline_backdrop(self) obj_renderer.copy_obj_pixels_for_priority(self, priority=0) obj_renderer.copy_obj_pixels_for_priority(self, priority=1) @@ -824,7 +924,8 @@ def _build_window_mask( w2_invert: bool, combine_logic: int, ) -> None: - """Fill buf[0..255] with 1 where the pixel is inside the combined window, 0 elsewhere.""" + """Fill buf[0..255] with 1 where the pixel is inside the combined + window, 0 elsewhere.""" wh0 = self.wh0 wh1 = self.wh1 wh2 = self.wh2 diff --git a/pysnes/ppu/test_ppu.py b/pysnes/ppu/test_ppu.py index 547d3d2..74d9ed4 100644 --- a/pysnes/ppu/test_ppu.py +++ b/pysnes/ppu/test_ppu.py @@ -1,8 +1,9 @@ """ PPU screenshot regression tests. -Each test loads a pre-built ROM from submodules/SNES/PPU/ (PeterLemon collection), -runs both PySNES and Mesen headlessly for N frames, and compares the 256×224 +Each test loads a pre-built ROM from submodules/SNES/PPU/ (the PeterLemon +collection), runs both PySNES and Mesen headlessly for N frames, and +compares the 256×224 framebuffers pixel-by-pixel. No static reference PNGs are required; Mesen is the live oracle. @@ -16,7 +17,6 @@ import struct import tempfile import time -import zlib from pathlib import Path import pytest @@ -25,16 +25,16 @@ pytestmark = pytest.mark.ppu -REPO_ROOT = Path(__file__).parent.parent.parent -PPU_ROMS = REPO_ROOT / "submodules" / "SNES" / "PPU" +REPO_ROOT = Path(__file__).parent.parent.parent +PPU_ROMS = REPO_ROOT / "submodules" / "SNES" / "PPU" LIDNARIQ_ROMS = REPO_ROOT / "submodules" / "snes-test-roms" -ACTUALS_DIR = REPO_ROOT / "tests" / "ppu_references" +ACTUALS_DIR = REPO_ROOT / "tests" / "ppu_references" MC_PER_FRAME = 262 * 1364 -SCREEN_W = 256 -SCREEN_H = 224 +SCREEN_W = 256 +SCREEN_H = 224 # Mesen getScreenBuffer() returns 256x239; visible 224 lines start at row 7. -MESEN_BUF_H = 239 +MESEN_BUF_H = 239 MESEN_ROW_OFFSET = 7 @@ -46,8 +46,10 @@ # PySNES framebuffer capture # --------------------------------------------------------------------------- + def _run_pysnes(rom_path: Path, n_frames: int) -> list: - """Run PySNES headlessly for n_frames and return SCREEN_H×SCREEN_W (R,G,B) tuples.""" + """Run PySNES headlessly for n_frames and return SCREEN_H×SCREEN_W (R,G,B) + tuples.""" from pysnes.pysnes import PySNES # noqa: PLC0415 pysnes = PySNES(str(rom_path), settings={"headless": True}) @@ -58,8 +60,9 @@ def _run_pysnes(rom_path: Path, n_frames: int) -> list: frame_end = pysnes.scheduler.master_clock + MC_PER_FRAME pysnes.scheduler.run_to(frame_end) - # Apply INIDISP brightness (0-15) post-VBlank, matching Mesen's getScreenBuffer() behavior. - # main_bgs stores unbrightened 8-bit values; brightness is applied here at read time. + # Apply INIDISP brightness (0-15) post-VBlank, matching Mesen's + # getScreenBuffer() behavior. main_bgs stores unbrightened 8-bit values; + # brightness is applied here at read time. brightness = pysnes.ppu.display_brightness pixels = [] for y in range(SCREEN_H): @@ -82,9 +85,11 @@ def _run_pysnes(rom_path: Path, n_frames: int) -> list: # Mesen oracle # --------------------------------------------------------------------------- + def _mesen_bin(config) -> str: """Return path to Mesen binary, or pytest.skip() if not found.""" from pysnes import settings as s # noqa: PLC0415 + cfg = s.load() candidates = [ os.environ.get("MESEN_BIN"), @@ -94,16 +99,19 @@ def _mesen_bin(config) -> str: if path and Path(path).exists(): return path pytest.skip( - "Mesen binary not found. Set MESEN_BIN env var or 'mesen_bin' in settings.json" + "Mesen binary not found. Set MESEN_BIN env var or 'mesen_bin' in " + "settings.json" ) def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: - """Run Mesen headlessly for n_frames; return SCREEN_H×SCREEN_W (R,G,B) tuples.""" + """Run Mesen headlessly for n_frames; return SCREEN_H×SCREEN_W (R,G,B) + tuples.""" import subprocess # noqa: PLC0415 lua_script = REPO_ROOT / "scripts" / "mesen_screenshot.lua" - # Use a path that doesn't exist yet; the Lua script writes atomically via temp+rename. + # Use a path that doesn't exist yet; the Lua script writes atomically via + # temp+rename. fd, out_bin = tempfile.mkstemp(suffix=".bin", dir="/tmp") os.close(fd) os.unlink(out_bin) @@ -130,22 +138,25 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: pixels_u32 = struct.unpack(f"<{count}I", raw) # Mesen returns 256×239 normally; hi-res modes (5/6) produce 512×478. - # Detect hi-res by checking the total pixel count and downsample if needed. + # Detect hi-res by checking the total pixel count and downsample if + # needed. hires = count == (SCREEN_W * 2) * (MESEN_BUF_H * 2) if not hires and count != SCREEN_W * MESEN_BUF_H: pytest.fail(f"Unexpected Mesen buffer size: {count} pixels") - buf_w = SCREEN_W * 2 if hires else SCREEN_W + buf_w = SCREEN_W * 2 if hires else SCREEN_W row_offset = MESEN_ROW_OFFSET * 2 if hires else MESEN_ROW_OFFSET - col_step = 2 if hires else 1 - row_step = 2 if hires else 1 + col_step = 2 if hires else 1 + row_step = 2 if hires else 1 pixels = [] for row in range(SCREEN_H): for col in range(SCREEN_W): - v = pixels_u32[(row_offset + row * row_step) * buf_w + col * col_step] + v = pixels_u32[ + (row_offset + row * row_step) * buf_w + col * col_step + ] r = (v >> 16) & 0xFF - g = (v >> 8) & 0xFF - b = v & 0xFF + g = (v >> 8) & 0xFF + b = v & 0xFF pixels.append((r, g, b)) return pixels finally: @@ -209,8 +220,10 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: 30, marks=pytest.mark.xfail( reason=( - "59 pixels (0.1%) differ at full brightness: CGRAM palette is loaded " - "by DMA during startup; residual DMA timing inaccuracy produces " + "59 pixels (0.1%) differ at full brightness: CGRAM palette is " + "loaded " + "by DMA during startup; residual DMA timing inaccuracy " + "produces " "slightly different CGRAM values than Mesen. " "Mode 7 rendering formula is correct (RotZoom passes 0%)." ), @@ -232,21 +245,33 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: "ppubusact", LIDNARIQ_ROMS / "lidnariq-ppu-bus-activity" / "ppubusact.sfc", 20, - marks=pytest.mark.xfail(reason="Modes 3/4 (8BPP/OPT) and 5/6 (hi-res) not fully implemented", strict=False), + marks=pytest.mark.xfail( + reason=( + "Modes 3/4 (8BPP/OPT) and 5/6 (hi-res) not fully implemented" + ), + strict=False, + ), id="ppubusact", ), ] @pytest.mark.parametrize( - "test_id,rom_rel,n_frames", + ("test_id", "rom_rel", "n_frames"), PPU_TEST_ROMS, - ids=[t[0] if not hasattr(t, 'id') or t.id is None else t.id for t in PPU_TEST_ROMS], + ids=[ + t[0] if not hasattr(t, "id") or t.id is None else t.id + for t in PPU_TEST_ROMS + ], ) def test_ppu_screenshot(request, test_id, rom_rel, n_frames): - """Compare PySNES framebuffer against Mesen oracle at the same frame count.""" + """Compare PySNES framebuffer against Mesen oracle at the same frame + count.""" rom_rel_path = Path(rom_rel) - rom_path = rom_rel_path if rom_rel_path.is_absolute() else PPU_ROMS / rom_rel + if rom_rel_path.is_absolute(): + rom_path = rom_rel_path + else: + rom_path = PPU_ROMS / rom_rel if not rom_path.exists(): pytest.skip(f"ROM not found: {rom_path}") diff --git a/pysnes/ppu/test_ppu_bg_window.py b/pysnes/ppu/test_ppu_bg_window.py index 626b5b2..870b83e 100644 --- a/pysnes/ppu/test_ppu_bg_window.py +++ b/pysnes/ppu/test_ppu_bg_window.py @@ -2,8 +2,8 @@ Synthetic BG window-masking unit tests for $2123 W12SEL + $2126-$2129 WH0-3 + $212A WBGLOG + $212E TMW. -These bypass ROM loading — write VRAM/CGRAM directly, call draw_background_scanline(), -and assert pixel output. No Mesen, no ROM files. +These bypass ROM loading — write VRAM/CGRAM directly, call +draw_background_scanline(), and assert pixel output. No Mesen, no ROM files. Setup: - BG1 tilemap row 0: all tile 0 (solid RED) @@ -14,15 +14,13 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_bg_window.py -v """ -import pytest - from pysnes.ppu import bg_renderer from pysnes.ppu.ppu import Ppu SCREEN_W = 256 TILEDATA = 0x4000 -RED = (255, 0, 0) +RED = (255, 0, 0) BLACK = (0, 0, 0) @@ -54,9 +52,9 @@ def _pixel(ppu: Ppu, x: int, y: int) -> tuple: def _setup_bg1_solid_red(ppu: Ppu) -> None: - _write_cgram(ppu, 0, 0, 0, 0) # backdrop = BLACK - _write_cgram(ppu, 1, 31, 0, 0) # palette 1 = RED - _write_2bpp_solid_tile(ppu, 0, 1) # tile 0 = solid RED + _write_cgram(ppu, 0, 0, 0, 0) # backdrop = BLACK + _write_cgram(ppu, 1, 31, 0, 0) # palette 1 = RED + _write_2bpp_solid_tile(ppu, 0, 1) # tile 0 = solid RED # Row 0 of tilemap: all tile 0 for col in range(32): ppu.vram[col * 2 + 0] = 0 @@ -78,41 +76,45 @@ def _draw_row_with_backdrop(ppu: Ppu, scanline: int) -> None: """Fill backdrop, then draw BG1 — matches real render_scanline ordering.""" ppu.v_counter = scanline bg_renderer.draw_scanline_backdrop(ppu) - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=2, priority_selector=0) + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=2, priority_selector=0 + ) # --------------------------------------------------------------------------- # W1 only (baseline — previously worked) # --------------------------------------------------------------------------- + class TestW1Only: def test_w1_invert0_masks_inside(self): """W1 enabled, invert=0 → pixels INSIDE [wh0,wh1] are masked (BLACK).""" ppu = _make_ppu() _setup_bg1_solid_red(ppu) - ppu.w12sel = 0b00000010 # BG1 W1 enable, invert=0 + ppu.w12sel = 0b00000010 # BG1 W1 enable, invert=0 ppu.wh0 = 64 ppu.wh1 = 128 _draw_row_with_backdrop(ppu, scanline=1) - assert _pixel(ppu, 60, 0) == RED - assert _pixel(ppu, 64, 0) == BLACK - assert _pixel(ppu, 96, 0) == BLACK + assert _pixel(ppu, 60, 0) == RED + assert _pixel(ppu, 64, 0) == BLACK + assert _pixel(ppu, 96, 0) == BLACK assert _pixel(ppu, 128, 0) == BLACK assert _pixel(ppu, 132, 0) == RED def test_w1_invert1_masks_outside(self): - """W1 enabled, invert=1 → pixels OUTSIDE [wh0,wh1] are masked (BLACK).""" + """W1 enabled, invert=1 → pixels OUTSIDE [wh0,wh1] are masked + (BLACK).""" ppu = _make_ppu() _setup_bg1_solid_red(ppu) - ppu.w12sel = 0b00000011 # BG1 W1 enable + invert + ppu.w12sel = 0b00000011 # BG1 W1 enable + invert ppu.wh0 = 64 ppu.wh1 = 128 _draw_row_with_backdrop(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == BLACK - assert _pixel(ppu, 60, 0) == BLACK - assert _pixel(ppu, 64, 0) == RED + assert _pixel(ppu, 0, 0) == BLACK + assert _pixel(ppu, 60, 0) == BLACK + assert _pixel(ppu, 64, 0) == RED assert _pixel(ppu, 128, 0) == RED assert _pixel(ppu, 130, 0) == BLACK @@ -121,11 +123,12 @@ def test_w1_invert1_masks_outside(self): # W1 + W2 with all four WBGLOG combine modes (regression for WindowMultiHDMA) # --------------------------------------------------------------------------- + class TestW1W2Combine: """BG1 W1=[16,112] invert=1; BG1 W2=[144,240] invert=1. - With invert=1, each window's "mask value" is TRUE when X lies OUTSIDE its range. - WBGLOG per BG1 (bits 1:0): 0=OR, 1=AND, 2=XOR, 3=XNOR. + With invert=1, each window's "mask value" is TRUE when X lies OUTSIDE its + range. WBGLOG per BG1 (bits 1:0): 0=OR, 1=AND, 2=XOR, 3=XNOR. AND case (matches the WindowMultiHDMA ROM): pixel masked iff it's outside both windows — i.e. BG1 is drawn inside at least one of W1 or W2. @@ -138,24 +141,24 @@ def _setup_two_windows(self, ppu: Ppu, logic: int) -> None: ppu.w12sel = self.W12SEL_BG1_BOTH_INVERT ppu.wh0, ppu.wh1 = 16, 112 ppu.wh2, ppu.wh3 = 144, 240 - ppu.wbglog = logic & 0x03 # BG1 logic goes into bits 1:0 + ppu.wbglog = logic & 0x03 # BG1 logic goes into bits 1:0 def test_and_logic_shows_bg_in_either_window(self): """WBGLOG=AND + invert=1 both → BG1 drawn inside W1∪W2.""" ppu = _make_ppu() - self._setup_two_windows(ppu, logic=1) # AND + self._setup_two_windows(ppu, logic=1) # AND _draw_row_with_backdrop(ppu, scanline=1) # Outside both windows → masked (black) - assert _pixel(ppu, 0, 0) == BLACK, "left strip before W1" - assert _pixel(ppu, 15, 0) == BLACK + assert _pixel(ppu, 0, 0) == BLACK, "left strip before W1" + assert _pixel(ppu, 15, 0) == BLACK assert _pixel(ppu, 113, 0) == BLACK, "gap between W1 and W2" assert _pixel(ppu, 143, 0) == BLACK assert _pixel(ppu, 241, 0) == BLACK assert _pixel(ppu, 255, 0) == BLACK # Inside W1 → visible - assert _pixel(ppu, 16, 0) == RED - assert _pixel(ppu, 64, 0) == RED + assert _pixel(ppu, 16, 0) == RED + assert _pixel(ppu, 64, 0) == RED assert _pixel(ppu, 112, 0) == RED # Inside W2 → visible assert _pixel(ppu, 144, 0) == RED @@ -163,9 +166,10 @@ def test_and_logic_shows_bg_in_either_window(self): assert _pixel(ppu, 240, 0) == RED def test_or_logic_masks_whole_row(self): - """WBGLOG=OR + invert=1 both → masked whenever outside either — entire row masked.""" + """WBGLOG=OR + invert=1 both → masked whenever outside either — entire + row masked.""" ppu = _make_ppu() - self._setup_two_windows(ppu, logic=0) # OR + self._setup_two_windows(ppu, logic=0) # OR _draw_row_with_backdrop(ppu, scanline=1) for x in (0, 16, 64, 112, 113, 143, 144, 200, 240, 255): @@ -180,14 +184,14 @@ def test_xor_logic_masks_symmetric_strips(self): - X outside both: W1_val=1, W2_val=1 → XOR=0 → visible """ ppu = _make_ppu() - self._setup_two_windows(ppu, logic=2) # XOR + self._setup_two_windows(ppu, logic=2) # XOR _draw_row_with_backdrop(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == RED, "outside both → visible" - assert _pixel(ppu, 15, 0) == RED - assert _pixel(ppu, 16, 0) == BLACK, "inside W1 only → masked" + assert _pixel(ppu, 0, 0) == RED, "outside both → visible" + assert _pixel(ppu, 15, 0) == RED + assert _pixel(ppu, 16, 0) == BLACK, "inside W1 only → masked" assert _pixel(ppu, 112, 0) == BLACK - assert _pixel(ppu, 113, 0) == RED, "gap → visible" + assert _pixel(ppu, 113, 0) == RED, "gap → visible" assert _pixel(ppu, 144, 0) == BLACK, "inside W2 only → masked" assert _pixel(ppu, 240, 0) == BLACK assert _pixel(ppu, 255, 0) == RED @@ -197,16 +201,17 @@ def test_xor_logic_masks_symmetric_strips(self): # W2-only regression (old code ignored W2 entirely) # --------------------------------------------------------------------------- + class TestW2Only: def test_w2_invert0_masks_inside(self): """Only W2 enabled — masks pixels inside [wh2, wh3].""" ppu = _make_ppu() _setup_bg1_solid_red(ppu) - ppu.w12sel = 0b00001000 # BG1: only W2 enable, invert=0 + ppu.w12sel = 0b00001000 # BG1: only W2 enable, invert=0 ppu.wh2, ppu.wh3 = 144, 240 _draw_row_with_backdrop(ppu, scanline=1) - assert _pixel(ppu, 64, 0) == RED + assert _pixel(ppu, 64, 0) == RED assert _pixel(ppu, 143, 0) == RED assert _pixel(ppu, 144, 0) == BLACK assert _pixel(ppu, 200, 0) == BLACK @@ -218,12 +223,13 @@ def test_w2_invert0_masks_inside(self): # TMW bit gates masking entirely # --------------------------------------------------------------------------- + class TestTmwGate: def test_tmw_disabled_draws_everywhere(self): """With TMW bit 0 clear, even an enabled W1 does nothing for BG1.""" ppu = _make_ppu() _setup_bg1_solid_red(ppu) - ppu.tmw = 0x00 # <- disable BG1 window masking + ppu.tmw = 0x00 # <- disable BG1 window masking ppu.w12sel = 0b00000010 # BG1 W1 enable (would normally mask) ppu.wh0, ppu.wh1 = 64, 128 _draw_row_with_backdrop(ppu, scanline=1) diff --git a/pysnes/ppu/test_ppu_bgmode.py b/pysnes/ppu/test_ppu_bgmode.py index 41a5ac2..d9c2139 100644 --- a/pysnes/ppu/test_ppu_bgmode.py +++ b/pysnes/ppu/test_ppu_bgmode.py @@ -9,16 +9,14 @@ data at addresses where the computed fetch offset spills past 0xFFFF. """ -import pytest - from pysnes.ppu import bg_renderer, obj_renderer from pysnes.ppu.ppu import Ppu SCREEN_W = 256 -RED = (255, 0, 0) +RED = (255, 0, 0) GREEN = (0, 255, 0) -BLUE = (0, 0, 255) +BLUE = (0, 0, 255) # --------------------------------------------------------------------------- @@ -84,8 +82,14 @@ def _write_8bpp_solid_tile_at(ppu: Ppu, addr: int, color_index: int) -> None: ppu.vram[(addr + 48 + row * 2 + 1) & 0xFFFF] = planes[7] -def _write_tilemap_entry(ppu: Ppu, screen_addr: int, col: int, row: int, - tile_index: int, palette: int = 0) -> None: +def _write_tilemap_entry( + ppu: Ppu, + screen_addr: int, + col: int, + row: int, + tile_index: int, + palette: int = 0, +) -> None: """Write a single tilemap word at (col, row) in a 32×32 tilemap.""" word_off = (row * 32 + col) * 2 ppu.vram[(screen_addr + word_off + 0) & 0xFFFF] = tile_index & 0xFF @@ -103,24 +107,25 @@ class TestBgMode2Dispatch: def test_mode2_does_not_raise_not_implemented(self): ppu = _make_ppu(bgmode=2) # No BGs enabled — just verify dispatch path runs. - ppu.render_scanline() # should not raise + ppu.render_scanline() # should not raise def test_mode2_renders_bg1_as_4bpp(self): """BG1 in Mode 2 renders at 4bpp (palette index 1 → color 1).""" ppu = _make_ppu(bgmode=2) # Palette 0, color 1 = RED (4bpp uses 16-color palettes) - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 31, 0, 0) + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 31, 0, 0) # Tile 0 = solid color-1 pixels _write_4bpp_solid_tile_at(ppu, 0x4000, color_index=1) # Tilemap[0, 0] = tile 0, palette 0 - _write_tilemap_entry(ppu, screen_addr=0x0000, col=0, row=0, - tile_index=0, palette=0) + _write_tilemap_entry( + ppu, screen_addr=0x0000, col=0, row=0, tile_index=0, palette=0 + ) - ppu.bg1.screen_addr = 0x0000 - ppu.bg1.tiledata_addr = 0x4000 + ppu.bg1.screen_addr = 0x0000 + ppu.bg1.tiledata_addr = 0x4000 ppu.bg1.main_screen_enable = True ppu.render_scanline() @@ -130,15 +135,16 @@ def test_mode2_renders_bg2_as_4bpp(self): """BG2 in Mode 2 renders at 4bpp (behind BG1).""" ppu = _make_ppu(bgmode=2) - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 0, 0, 31) # color 1 = BLUE + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 0, 0, 31) # color 1 = BLUE _write_4bpp_solid_tile_at(ppu, 0x5000, color_index=1) - _write_tilemap_entry(ppu, screen_addr=0x0800, col=0, row=0, - tile_index=0, palette=0) + _write_tilemap_entry( + ppu, screen_addr=0x0800, col=0, row=0, tile_index=0, palette=0 + ) - ppu.bg2.screen_addr = 0x0800 - ppu.bg2.tiledata_addr = 0x5000 + ppu.bg2.screen_addr = 0x0800 + ppu.bg2.tiledata_addr = 0x5000 ppu.bg2.main_screen_enable = True ppu.render_scanline() @@ -161,8 +167,8 @@ def test_2bpp_tile_fetch_wraps_at_0xffff(self): pixel decodes correctly.""" ppu = _make_ppu(bgmode=1) - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 31, 0, 0) # RED + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 31, 0, 0) # RED # Place 2bpp tile 0 starting at 0xFFF0. Each tile = 16 bytes → tile # occupies 0xFFF0..0xFFFF then wraps to 0x0000..0x000F? No — tile 0 @@ -170,16 +176,20 @@ def test_2bpp_tile_fetch_wraps_at_0xffff(self): # 0xFFFE: row 0 at 0xFFFE/0xFFFF, row 1 at 0x0000/0x0001 (wrapped). _write_2bpp_solid_tile_at(ppu, addr=0xFFFE, color_index=1) - _write_tilemap_entry(ppu, screen_addr=0x0100, col=0, row=0, - tile_index=0, palette=0) + _write_tilemap_entry( + ppu, screen_addr=0x0100, col=0, row=0, tile_index=0, palette=0 + ) - ppu.bg1.screen_addr = 0x0100 - ppu.bg1.tiledata_addr = 0xFFFE + ppu.bg1.screen_addr = 0x0100 + ppu.bg1.tiledata_addr = 0xFFFE ppu.bg1.main_screen_enable = True - # Scanline 2 → v_shift=1 → fetches row 1 = bytes at wrapped 0x0000/0x0001. + # Scanline 2 → v_shift=1 → fetches row 1 = bytes at wrapped + # 0x0000/0x0001. ppu.v_counter = 2 - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=2, priority_selector=0) + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=2, priority_selector=0 + ) assert _pixel(ppu, 0, 1) == RED def test_4bpp_tile_fetch_wraps_at_0xffff(self): @@ -187,44 +197,50 @@ def test_4bpp_tile_fetch_wraps_at_0xffff(self): also wrap.""" ppu = _make_ppu(bgmode=1) - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 0, 31, 0) # GREEN + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 0, 31, 0) # GREEN # Place tile at 0xFFF0: row 0 plane 0/1 at 0xFFF0/0xFFF1 (in range), # plane 2/3 at 0xFFF0+16=0x10000 → wraps to 0x0000. _write_4bpp_solid_tile_at(ppu, addr=0xFFF0, color_index=1) - _write_tilemap_entry(ppu, screen_addr=0x0100, col=0, row=0, - tile_index=0, palette=0) + _write_tilemap_entry( + ppu, screen_addr=0x0100, col=0, row=0, tile_index=0, palette=0 + ) - ppu.bg1.screen_addr = 0x0100 - ppu.bg1.tiledata_addr = 0xFFF0 + ppu.bg1.screen_addr = 0x0100 + ppu.bg1.tiledata_addr = 0xFFF0 ppu.bg1.main_screen_enable = True ppu.v_counter = 1 - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=4, priority_selector=0) + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=4, priority_selector=0 + ) assert _pixel(ppu, 0, 0) == GREEN def test_8bpp_tile_fetch_wraps_at_0xffff(self): """8bpp needs 64 bytes; the +32 and +48 plane pairs must wrap too.""" ppu = _make_ppu(bgmode=1) - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 0, 0, 31) # BLUE (palette idx 1) + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 0, 0, 31) # BLUE (palette idx 1) # Place tile at 0xFFE0: row 0 plane 0/1 in range, planes 2/3 at +16 # (0xFFF0), planes 4/5 at +32 (0x10000 → 0x0000), planes 6/7 at +48. _write_8bpp_solid_tile_at(ppu, addr=0xFFE0, color_index=1) - _write_tilemap_entry(ppu, screen_addr=0x0100, col=0, row=0, - tile_index=0, palette=0) + _write_tilemap_entry( + ppu, screen_addr=0x0100, col=0, row=0, tile_index=0, palette=0 + ) - ppu.bg1.screen_addr = 0x0100 - ppu.bg1.tiledata_addr = 0xFFE0 + ppu.bg1.screen_addr = 0x0100 + ppu.bg1.tiledata_addr = 0xFFE0 ppu.bg1.main_screen_enable = True ppu.v_counter = 1 - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=8, priority_selector=0) + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=8, priority_selector=0 + ) assert _pixel(ppu, 0, 0) == BLUE @@ -239,11 +255,11 @@ def test_draw_point_wraps_tile_data_index_near_vram_top(self): ppu = _make_ppu(bgmode=1) # All four bitplane bytes have bit 7 set → pixel 7 resolves to color 15. - ppu.vram[0xFFFF] = 0x80 # plane 0 - ppu.vram[0x0000] = 0x80 # plane 1 (wrapped) - ppu.vram[0x000F] = 0x80 # plane 2 (wrapped) - ppu.vram[0x0010] = 0x80 # plane 3 (wrapped) - _write_cgram(ppu, 15, 31, 0, 0) # palette 0, color 15 = RED + ppu.vram[0xFFFF] = 0x80 # plane 0 + ppu.vram[0x0000] = 0x80 # plane 1 (wrapped) + ppu.vram[0x000F] = 0x80 # plane 2 (wrapped) + ppu.vram[0x0010] = 0x80 # plane 3 (wrapped) + _write_cgram(ppu, 15, 31, 0, 0) # palette 0, color 15 = RED # Snapshot vram into a bytes object to pass as tile_data. tlen=65536 # so the `% tlen` wrap matches how VRAM indexing works in real fetches. diff --git a/pysnes/ppu/test_ppu_color_math.py b/pysnes/ppu/test_ppu_color_math.py index 6f4aa65..32b79fc 100644 --- a/pysnes/ppu/test_ppu_color_math.py +++ b/pysnes/ppu/test_ppu_color_math.py @@ -22,8 +22,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_color_math.py -v """ -import pytest - from pysnes.ppu.ppu import Ppu SCREEN_W = 256 @@ -34,6 +32,7 @@ # Helpers (mirror of test_ppu_scroll.py — kept private to avoid coupling) # --------------------------------------------------------------------------- + def _make_ppu() -> Ppu: ppu = Ppu() ppu.inidisp_set(0x0F) # full brightness so colors pass through unchanged @@ -51,7 +50,8 @@ def _write_2bpp_solid_tile(ppu: Ppu, tile_index: int, color_index: int) -> None: def _write_4bpp_solid_tile(ppu: Ppu, tile_index: int, color_index: int) -> None: - """4bpp tile = 32 bytes. Bytes 0-15 = bp0/bp1 interleaved, bytes 16-31 = bp2/bp3 interleaved.""" + """4bpp tile = 32 bytes. Bytes 0-15 = bp0/bp1 interleaved, bytes 16-31 = + bp2/bp3 interleaved.""" bp0 = 0xFF if (color_index & 1) else 0x00 bp1 = 0xFF if (color_index & 2) else 0x00 bp2 = 0xFF if (color_index & 4) else 0x00 @@ -92,7 +92,8 @@ def _setup_solid_bg( """Configure BG{bg_index} to render a single solid colour everywhere.""" bg = (None, ppu.bg1, ppu.bg2, ppu.bg3, ppu.bg4)[bg_index] - # Always set CGRAM index 0 = black (backdrop), index = color_index = chosen colour. + # Always set CGRAM index 0 = black (backdrop), index = color_index = chosen + # colour. _write_cgram(ppu, 0, 0, 0, 0) _write_cgram(ppu, color_index, *rgb5) @@ -118,6 +119,7 @@ def _setup_solid_bg( # Sub-screen buffer existence and sizing # --------------------------------------------------------------------------- + class TestSubScreenBuffer: def test_sub_bgs_buffer_exists(self): ppu = _make_ppu() @@ -134,38 +136,54 @@ def test_sub_bgs_same_size_as_main(self): # Per-screen routing: backgrounds land in main, sub, or both # --------------------------------------------------------------------------- + class TestPerScreenRouting: - """Verify each background renders into the right buffer based on its enable flags.""" + """Verify each background renders into the right buffer based on its enable + flags.""" def test_main_only_bg_does_not_touch_sub(self): - """BG1 main=True/sub=False → main_bgs has the BG colour, sub_bgs stays at backdrop.""" + """BG1 main=True/sub=False → main_bgs has the BG colour, sub_bgs stays + at backdrop.""" ppu = _make_ppu() ppu.bg1.screen_addr = 0 - _setup_solid_bg(ppu, 1, color_index=1, rgb5=(31, 0, 0), main=True, sub=False) + _setup_solid_bg( + ppu, 1, color_index=1, rgb5=(31, 0, 0), main=True, sub=False + ) ppu.v_counter = 1 ppu.render_scanline() - assert _main_pixel(ppu, 0, 0) == (255, 0, 0), "BG1 should appear in main" - assert _sub_pixel(ppu, 0, 0) == (0, 0, 0), "sub_bgs should still hold backdrop" + assert _main_pixel(ppu, 0, 0) == (255, 0, 0), ( + "BG1 should appear in main" + ) + assert _sub_pixel(ppu, 0, 0) == (0, 0, 0), ( + "sub_bgs should still hold backdrop" + ) def test_sub_only_bg_does_not_touch_main(self): - """BG2 main=False/sub=True → sub_bgs has the BG colour, main_bgs stays at backdrop.""" + """BG2 main=False/sub=True → sub_bgs has the BG colour, main_bgs stays + at backdrop.""" ppu = _make_ppu() ppu.bg2.screen_addr = 0 - _setup_solid_bg(ppu, 2, color_index=2, rgb5=(0, 0, 31), main=False, sub=True) + _setup_solid_bg( + ppu, 2, color_index=2, rgb5=(0, 0, 31), main=False, sub=True + ) ppu.v_counter = 1 ppu.render_scanline() - assert _main_pixel(ppu, 0, 0) == (0, 0, 0), "main_bgs should still hold backdrop" + assert _main_pixel(ppu, 0, 0) == (0, 0, 0), ( + "main_bgs should still hold backdrop" + ) assert _sub_pixel(ppu, 0, 0) == (0, 0, 255), "BG2 should appear in sub" def test_dual_screen_bg_renders_to_both(self): """A BG with main=True AND sub=True writes both buffers.""" ppu = _make_ppu() ppu.bg1.screen_addr = 0 - _setup_solid_bg(ppu, 1, color_index=1, rgb5=(0, 31, 0), main=True, sub=True) + _setup_solid_bg( + ppu, 1, color_index=1, rgb5=(0, 31, 0), main=True, sub=True + ) ppu.v_counter = 1 ppu.render_scanline() @@ -178,37 +196,46 @@ def test_dual_screen_bg_renders_to_both(self): # Color math compositing (the SMW title-screen case) # --------------------------------------------------------------------------- + class TestColorMathBackdropAdd: - """CGADSUB=0x20 (backdrop participates, ADD) + CGWSEL=0x02 (sub layers enabled). + """CGADSUB=0x20 (backdrop participates, ADD) + CGWSEL=0x02 (sub layers + enabled). - This is what SMW's title screen sets: BG2 is on the sub-screen only, and where - the main screen is just backdrop, the final pixel should be backdrop + sub. + This is what SMW's title screen sets: BG2 is on the sub-screen only, and + where the main screen is just backdrop, the final pixel should be backdrop + + sub. With backdrop = black, that simplifies to just the sub pixel.""" def _setup_smw_title_like(self, ppu: Ppu) -> None: """BG1 on main only (the logo), BG2 on sub only (the sky).""" - # BG1: solid red, but only on the LEFT half of the row (cols 0-15). - # We do this by leaving the right half of the tilemap pointing at a transparent tile. + # BG1: solid red, but only on the LEFT half of the row (cols 0-15). We + # do this by leaving the right half of the tilemap pointing at a + # transparent tile. ppu.bg1.screen_addr = 0 - ppu.bg2.screen_addr = 0x800 # word offset 0x800 → byte 0x1000 (different region) + # word offset 0x800 → byte 0x1000 (different region) + ppu.bg2.screen_addr = 0x800 # Backdrop = black _write_cgram(ppu, 0, 0, 0, 0) - # BG1 palette index 1 = red, index 2 (transparent placeholder) stays 0/0/0 + # BG1 palette index 1 = red, index 2 (transparent placeholder) stays + # 0/0/0 _write_cgram(ppu, 1, 31, 0, 0) # BG2 palette index 2 = blue (the "sky") _write_cgram(ppu, 2, 0, 0, 31) - # BG1/BG2 are 4bpp in mode 1 → use 4bpp tiles. Different tile indices so they - # don't share VRAM bytes (each 4bpp tile = 32 bytes). + # BG1/BG2 are 4bpp in mode 1 → use 4bpp tiles. Different tile indices so + # they don't share VRAM bytes (each 4bpp tile = 32 bytes). _write_4bpp_solid_tile(ppu, 0, 1) _write_4bpp_solid_tile(ppu, 1, 2) - # BG1 tilemap (at byte 0): tile 0 in cols 0-15, transparent (use a tile we never wrote = bp0=bp1=0) in cols 16-31 - # CGRAM index 0 is "transparent" by SNES convention, so a tile with all-zero bitplanes renders nothing. + # BG1 tilemap (at byte 0): tile 0 in cols 0-15, transparent (use a tile + # we never wrote = bp0=bp1=0) in cols 16-31 CGRAM index 0 is + # "transparent" by SNES convention, so a tile with all-zero bitplanes + # renders nothing. for col in range(32): addr = 0 + col * 2 - ppu.vram[addr] = 0 if col < 16 else 0xFE # tile FE we never wrote → bitplanes 0 → transparent + # tile FE we never wrote → bitplanes 0 → transparent + ppu.vram[addr] = 0 if col < 16 else 0xFE ppu.vram[addr + 1] = 0 # BG2 tilemap (at byte 0x1000 = word 0x800): tile 1 (blue) everywhere @@ -217,7 +244,10 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: ppu.vram[addr] = 1 ppu.vram[addr + 1] = 0 - for bg, en_main, en_sub in ((ppu.bg1, True, False), (ppu.bg2, False, True)): + for bg, en_main, en_sub in ( + (ppu.bg1, True, False), + (ppu.bg2, False, True), + ): bg.tiledata_addr = TILEDATA bg.screen_size = 0 bg.tile_size = 0 @@ -226,42 +256,49 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: bg.main_screen_enable = en_main bg.sub_screen_enable = en_sub - # CGWSEL bit 1 = sub-screen layers (not just sub backdrop) participate in math + # CGWSEL bit 1 = sub-screen layers (not just sub backdrop) participate + # in math ppu.cgwsel = 0b00000010 - # CGADSUB bit 5 = backdrop is the main-screen layer that takes part in math - # bit 7 = 0 → ADD (not subtract); bit 6 = 0 → no half + # CGADSUB bit 5 = backdrop is the main-screen layer that takes part in + # math bit 7 = 0 → ADD (not subtract); bit 6 = 0 → no half ppu.cgadsub = 0b00100000 def test_main_backdrop_picks_up_sub_pixel(self): - """Where main is backdrop, the composited pixel equals the sub-screen pixel.""" + """Where main is backdrop, the composited pixel equals the sub-screen + pixel.""" ppu = _make_ppu() self._setup_smw_title_like(ppu) ppu.v_counter = 1 ppu.render_scanline() - # Right half (cols 16-31, pixels 128-255): main = backdrop (black), sub = blue (sky). - # With CGADSUB=0x20 ADD: result = (0,0,0) + (0,0,255) = (0,0,255). + # Right half (cols 16-31, pixels 128-255): main = backdrop (black), sub + # = blue (sky). With CGADSUB=0x20 ADD: result = (0,0,0) + (0,0,255) = + # (0,0,255). assert _main_pixel(ppu, 200, 0) == (0, 0, 255), ( - "Backdrop + sub should equal the sub pixel where main is bare backdrop" + "Backdrop + sub should equal the sub pixel where main is bare " + "backdrop" ) def test_main_bg_pixel_is_unchanged_by_color_math(self): - """Where BG1 is on the main screen, color math leaves it alone (BG1 is not in CGADSUB).""" + """Where BG1 is on the main screen, color math leaves it alone (BG1 is + not in CGADSUB).""" ppu = _make_ppu() self._setup_smw_title_like(ppu) ppu.v_counter = 1 ppu.render_scanline() - # Left half (cols 0-15, pixels 0-127): main = BG1 (red). BG1 bit (bit 0) is NOT - # set in CGADSUB, so BG1 pixels pass through as-is. + # Left half (cols 0-15, pixels 0-127): main = BG1 (red). BG1 bit (bit 0) + # is NOT set in CGADSUB, so BG1 pixels pass through as-is. assert _main_pixel(ppu, 50, 0) == (255, 0, 0), ( - "BG1 pixel should not be modified by color math when its CGADSUB bit is clear" + "BG1 pixel should not be modified by color math when its CGADSUB " + "bit is clear" ) def test_color_math_disabled_produces_main_only(self): - """With CGADSUB=0 no math runs, so backdrop areas stay backdrop even if sub has content.""" + """With CGADSUB=0 no math runs, so backdrop areas stay backdrop even if + sub has content.""" ppu = _make_ppu() self._setup_smw_title_like(ppu) ppu.cgadsub = 0 # disable all color math @@ -269,9 +306,11 @@ def test_color_math_disabled_produces_main_only(self): ppu.v_counter = 1 ppu.render_scanline() - # Right half: main = backdrop, sub = blue. With math off, result stays backdrop. + # Right half: main = backdrop, sub = blue. With math off, result stays + # backdrop. assert _main_pixel(ppu, 200, 0) == (0, 0, 0), ( - "With color math disabled, the main backdrop should be visible (not the sub)" + "With color math disabled, the main backdrop should be visible " + "(not the sub)" ) @@ -279,11 +318,15 @@ def test_color_math_disabled_produces_main_only(self): # Subtract path (CGADSUB bit 7) — used by SMW logo drop-shadow # --------------------------------------------------------------------------- + class TestColorMathSubtract: """CGADSUB bit 7 = 1 → result = main - sub (clamped to 0).""" - def _setup_subtract_scene(self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(15, 15, 15)) -> None: - """BG1 on main = bright, BG2 on sub = mid-gray. Subtract bit targets BG1.""" + def _setup_subtract_scene( + self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(15, 15, 15) + ) -> None: + """BG1 on main = bright, BG2 on sub = mid-gray. Subtract bit targets + BG1.""" ppu.bg1.screen_addr = 0 ppu.bg2.screen_addr = 0x800 @@ -300,7 +343,10 @@ def _setup_subtract_scene(self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(15, ppu.vram[0x1000 + col * 2] = 1 ppu.vram[0x1000 + col * 2 + 1] = 0 - for bg, en_main, en_sub in ((ppu.bg1, True, False), (ppu.bg2, False, True)): + for bg, en_main, en_sub in ( + (ppu.bg1, True, False), + (ppu.bg2, False, True), + ): bg.tiledata_addr = TILEDATA bg.screen_size = 0 bg.tile_size = 0 @@ -320,39 +366,49 @@ def test_subtract_bg1(self): ppu.v_counter = 1 ppu.render_scanline() r, g, b = _main_pixel(ppu, 0, 0) - # 5-bit 31 → 248 when scaled; 5-bit 15 → 120. 248 - 120 = 128 (allow small rounding). - assert 120 <= r <= 140 and 120 <= g <= 140 and 120 <= b <= 140, ( - f"expected ~128, got ({r},{g},{b})" - ) + # 5-bit 31 → 248 when scaled; 5-bit 15 → 120. 248 - 120 = 128 (allow + # small rounding). + expected = f"expected ~128, got ({r},{g},{b})" + assert 120 <= r <= 140, expected + assert 120 <= g <= 140, expected + assert 120 <= b <= 140, expected def test_subtract_clamps_at_zero(self): """main=dim (8) − sub=bright (255) = 0, not negative.""" ppu = _make_ppu() - self._setup_subtract_scene(ppu, main_rgb5=(1, 1, 1), sub_rgb5=(31, 31, 31)) + self._setup_subtract_scene( + ppu, main_rgb5=(1, 1, 1), sub_rgb5=(31, 31, 31) + ) ppu.v_counter = 1 ppu.render_scanline() r, g, b = _main_pixel(ppu, 0, 0) assert (r, g, b) == (0, 0, 0), f"expected clamp to 0, got ({r},{g},{b})" def test_subtract_gating_by_cgadsub_bit(self): - """When BG1 bit is clear in CGADSUB, BG1 pixels are unchanged even with subtract on.""" + """When BG1 bit is clear in CGADSUB, BG1 pixels are unchanged even with + subtract on.""" ppu = _make_ppu() self._setup_subtract_scene(ppu) ppu.cgadsub = 0b10000000 # subtract, but no layer participates ppu.v_counter = 1 ppu.render_scanline() # BG1 pixel should pass through unchanged (white) - assert _main_pixel(ppu, 0, 0) == (255, 255, 255), "gate off → no math applied" + assert _main_pixel(ppu, 0, 0) == (255, 255, 255), ( + "gate off → no math applied" + ) # --------------------------------------------------------------------------- # Half-intensity path (CGADSUB bit 6) # --------------------------------------------------------------------------- + class TestColorMathHalf: """CGADSUB bit 6 = 1 → divide result by 2 after add or subtract.""" - def _setup_half_scene(self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(31, 31, 31)) -> None: + def _setup_half_scene( + self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(31, 31, 31) + ) -> None: ppu.bg1.screen_addr = 0 ppu.bg2.screen_addr = 0x800 @@ -369,7 +425,10 @@ def _setup_half_scene(self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(31, 31, ppu.vram[0x1000 + col * 2] = 1 ppu.vram[0x1000 + col * 2 + 1] = 0 - for bg, en_main, en_sub in ((ppu.bg1, True, False), (ppu.bg2, False, True)): + for bg, en_main, en_sub in ( + (ppu.bg1, True, False), + (ppu.bg2, False, True), + ): bg.tiledata_addr = TILEDATA bg.screen_size = 0 bg.tile_size = 0 @@ -381,7 +440,8 @@ def _setup_half_scene(self, ppu: Ppu, main_rgb5=(31, 31, 31), sub_rgb5=(31, 31, ppu.cgwsel = 0b00000010 def test_half_add(self): - """(main + sub) / 2 with both = white → result ≈ white (no clamp loss).""" + """(main + sub) / 2 with both = white → result ≈ white (no clamp + loss).""" ppu = _make_ppu() self._setup_half_scene(ppu) # bit 7 = 0 (add), bit 6 = 1 (half), bit 0 = 1 (BG1 participates) @@ -390,7 +450,10 @@ def test_half_add(self): ppu.render_scanline() r, g, b = _main_pixel(ppu, 0, 0) # (255 + 255) / 2 = 255 - assert r >= 250 and g >= 250 and b >= 250, f"expected ~255, got ({r},{g},{b})" + expected = f"expected ~255, got ({r},{g},{b})" + assert r >= 250, expected + assert g >= 250, expected + assert b >= 250, expected def test_half_add_produces_average(self): """Half-add of two different colors produces the midpoint.""" @@ -401,9 +464,10 @@ def test_half_add_produces_average(self): ppu.render_scanline() r, g, b = _main_pixel(ppu, 0, 0) # (255,0,0) + (0,0,255) = (255,0,255); half = (127,0,127) - assert 120 <= r <= 130 and g == 0 and 120 <= b <= 130, ( - f"expected ~(127,0,127), got ({r},{g},{b})" - ) + expected = f"expected ~(127,0,127), got ({r},{g},{b})" + assert 120 <= r <= 130, expected + assert g == 0, expected + assert 120 <= b <= 130, expected def test_half_subtract(self): """(main - sub) / 2 with both = white → 0 / 2 = 0.""" diff --git a/pysnes/ppu/test_ppu_color_math_window.py b/pysnes/ppu/test_ppu_color_math_window.py index b10aeb1..9874b72 100644 --- a/pysnes/ppu/test_ppu_color_math_window.py +++ b/pysnes/ppu/test_ppu_color_math_window.py @@ -1,5 +1,6 @@ """ -Color-math windowing ($2130 CGWSEL bits 5-4, $2125 WOBJSEL bits 4-7, $212C WOBJLOG bits 2-3). +Color-math windowing ($2130 CGWSEL bits 5-4, $2125 WOBJSEL bits 4-7, $212C +WOBJLOG bits 2-3). CGWSEL bits 5-4 gate WHEN color math is applied: 00 = Always @@ -18,7 +19,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_color_math_window.py -v """ -import pytest from pysnes.ppu.ppu import Ppu SCREEN_W = 256 @@ -59,7 +59,8 @@ def _main_pixel(ppu: Ppu, x: int, y: int) -> tuple: def _setup_backdrop_add_bg2(ppu: Ppu) -> None: - """Mirror SMW title: BG1 main (sparse), BG2 sub (blue), CGADSUB=0x20 (backdrop ADD). + """Mirror SMW title: BG1 main (sparse), BG2 sub (blue), CGADSUB=0x20 + (backdrop ADD). BG1 has no opaque tiles so the whole main scanline is backdrop. BG2 fills sub-screen with blue. Where color math fires, main = backdrop + blue = blue. @@ -71,9 +72,11 @@ def _setup_backdrop_add_bg2(ppu: Ppu) -> None: _write_cgram(ppu, 33, 0, 0, 31) # BG2 solid blue tile - _write_4bpp_solid_tile(ppu, 0, 1) # tile 0 → color_index=1 → palette color 33 for BG2 pal 2 + # tile 0 → color_index=1 → palette color 33 for BG2 pal 2 + _write_4bpp_solid_tile(ppu, 0, 1) - # Wait — for BG2 we need palette 2. Let's instead put color at palette-0 color-1. + # Wait — for BG2 we need palette 2. Let's instead put color at palette-0 + # color-1. _write_cgram(ppu, 1, 0, 0, 31) # BG2 pal 0 color 1 = blue # BG1 tilemap: transparent (tile with bitplanes = 0 never gets drawn) @@ -138,7 +141,8 @@ def test_backdrop_stays_backdrop(self): # Color math fires nowhere → all backdrop pixels stay black. for x in (0, 64, 128, 192, 255): assert _main_pixel(ppu, x, 0) == (0, 0, 0), ( - f"never mode: pixel ({x},0) should stay backdrop, got {_main_pixel(ppu, x, 0)}" + f"never mode: pixel ({x},0) should stay backdrop, got " + f"{_main_pixel(ppu, x, 0)}" ) @@ -146,7 +150,8 @@ class TestColorMathWindowMode01_Inside: """CGWSEL bits 5:4 = 01 → color math only inside color window.""" def test_empty_math_window_blocks_color_math_everywhere(self): - """Match SMW f310 config: CGWSEL inside + WH0=255/WH1=0 (empty) + math W1 enabled.""" + """Match SMW f310 config: CGWSEL inside + WH0=255/WH1=0 (empty) + math + W1 enabled.""" ppu = _make_ppu() _setup_backdrop_add_bg2(ppu) ppu.cgwsel = (ppu.cgwsel & ~0x30) | 0x10 # inside window only @@ -159,7 +164,8 @@ def test_empty_math_window_blocks_color_math_everywhere(self): # Empty window, "inside only" → color math fires nowhere. for x in (0, 64, 128, 192, 255): assert _main_pixel(ppu, x, 0) == (0, 0, 0), ( - f"empty inside-window: ({x},0) should stay backdrop, got {_main_pixel(ppu, x, 0)}" + f"empty inside-window: ({x},0) should stay backdrop, got " + f"{_main_pixel(ppu, x, 0)}" ) def test_narrow_math_window_limits_color_math_region(self): diff --git a/pysnes/ppu/test_ppu_forced_blank.py b/pysnes/ppu/test_ppu_forced_blank.py index 71ea5f5..278e027 100644 --- a/pysnes/ppu/test_ppu_forced_blank.py +++ b/pysnes/ppu/test_ppu_forced_blank.py @@ -1,4 +1,4 @@ -from pysnes.ppu.ppu import Ppu, SCREEN_WIDTH +from pysnes.ppu.ppu import SCREEN_WIDTH, Ppu def _rgb(u32: int) -> tuple[int, int, int]: @@ -48,8 +48,9 @@ def test_forced_blank_mid_frame_clears_already_rendered_rows(): for row in range(4): base = row * SCREEN_WIDTH for x in (0, 64, 128, 255): - assert _rgb(ppu.main_bgs[base + x]) == (0, 0, 0), \ + assert _rgb(ppu.main_bgs[base + x]) == (0, 0, 0), ( f"row {row} x={x} should be black after mid-frame forced blank" + ) assert _rgb(ppu.sub_bgs[base + x]) == (0, 0, 0) assert ppu.main_layer[base + x] == 0 diff --git a/pysnes/ppu/test_ppu_mosaic.py b/pysnes/ppu/test_ppu_mosaic.py index 6ef16f6..c4c94e7 100644 --- a/pysnes/ppu/test_ppu_mosaic.py +++ b/pysnes/ppu/test_ppu_mosaic.py @@ -16,7 +16,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_mosaic.py -v """ -import pytest from pysnes.ppu.ppu import Ppu SCREEN_W = 256 @@ -36,7 +35,9 @@ def _write_cgram(ppu: Ppu, index: int, r5: int, g5: int, b5: int) -> None: ppu.cgram[index * 2 + 1] = (word >> 8) & 0xFF -def _write_4bpp_tile_horizontal_gradient(ppu: Ppu, tile_index: int, row_colors: list) -> None: +def _write_4bpp_tile_horizontal_gradient( + ppu: Ppu, tile_index: int, row_colors: list +) -> None: """Write a 4bpp tile where ALL 8 rows share the same horizontal gradient.""" addr = TILEDATA + tile_index * 32 bp0 = bp1 = bp2 = bp3 = 0 @@ -54,7 +55,8 @@ def _write_4bpp_tile_horizontal_gradient(ppu: Ppu, tile_index: int, row_colors: def _setup_gradient_bg1(ppu: Ppu) -> None: - """BG1 tile 0 row 0 has 8 distinct color indices (1..8), rest transparent.""" + """BG1 tile 0 row 0 has 8 distinct color indices (1..8), rest + transparent.""" # CGRAM colors 1..8: pure reds of increasing brightness for i in range(1, 9): _write_cgram(ppu, i, i * 3, 0, 0) @@ -84,6 +86,7 @@ def _pixel(ppu: Ppu, x: int, y: int) -> tuple: # Tests # --------------------------------------------------------------------------- + class TestMosaicHorizontal: def test_mosaic_disabled_renders_each_pixel(self): """Without mosaic, each of dots 0..7 shows its own gradient color.""" @@ -101,7 +104,8 @@ def test_mosaic_disabled_renders_each_pixel(self): ) def test_mosaic_size_2_blocks_pairs(self): - """With size=2 mosaic on BG1: dot 1 matches dot 0; dot 3 matches dot 2; etc.""" + """With size=2 mosaic on BG1: dot 1 matches dot 0; dot 3 matches dot 2; + etc.""" ppu = _make_ppu() _setup_gradient_bg1(ppu) ppu.mosaic_enabled = [True, False, False, False] @@ -114,7 +118,8 @@ def test_mosaic_size_2_blocks_pairs(self): expected = _pixel(ppu, anchor, 0) got = _pixel(ppu, anchor + 1, 0) assert got == expected, ( - f"dot {anchor+1} should match anchor dot {anchor}: got {got} vs {expected}" + f"dot {anchor + 1} should match anchor dot {anchor}: got {got} " + f"vs {expected}" ) def test_mosaic_size_4_blocks_groups_of_four(self): @@ -132,11 +137,13 @@ def test_mosaic_size_4_blocks_groups_of_four(self): for offset in range(4): got = _pixel(ppu, anchor + offset, 0) assert got == expected, ( - f"dot {anchor+offset} in block@{anchor}: got {got} vs anchor {expected}" + f"dot {anchor + offset} in block@{anchor}: got {got} vs " + f"anchor {expected}" ) def test_mosaic_disable_bit_gated_per_bg(self): - """Size is 4 but BG1 enable bit is off → BG1 unaffected, renders full gradient.""" + """Size is 4 but BG1 enable bit is off → BG1 unaffected, renders full + gradient.""" ppu = _make_ppu() _setup_gradient_bg1(ppu) ppu.mosaic_enabled = [False, True, True, True] # BG1 disabled diff --git a/pysnes/ppu/test_ppu_registers.py b/pysnes/ppu/test_ppu_registers.py index c3f8b6b..9853cd0 100644 --- a/pysnes/ppu/test_ppu_registers.py +++ b/pysnes/ppu/test_ppu_registers.py @@ -8,8 +8,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_registers.py -v """ -import pytest - from pysnes.ppu.ppu import Ppu @@ -21,6 +19,7 @@ def _make_ppu() -> Ppu: # Item 8: NotImplementedError getter fixes # --------------------------------------------------------------------------- + class TestBgmodeGetter: def test_bgmode_returns_mode_bits(self): ppu = _make_ppu() @@ -76,11 +75,12 @@ def test_oamaddh_both_bits(self): assert ppu.oamaddh == 0x81 def test_oamaddl_preserves_high_bit_across_oamaddh_write(self): - """oamaddl getter only returns low 8 bits even after oamaddh sets bit 8.""" + """oamaddl getter only returns low 8 bits even after oamaddh sets bit + 8.""" ppu = _make_ppu() ppu.oamaddl = 0x55 ppu.oamaddh = 0x01 # set bit 8 of address - assert ppu.oamaddl == 0x55 # low byte unchanged + assert ppu.oamaddl == 0x55 # low byte unchanged assert ppu.oamaddh == 0x01 @@ -88,12 +88,15 @@ def test_oamaddl_preserves_high_bit_across_oamaddh_write(self): # Item 9: VRAM address remapping # --------------------------------------------------------------------------- + def _write_word(ppu: Ppu, word_addr: int, low: int, high: int) -> None: - """Set VRAM word address and write a word (low, high) triggering write_vram.""" + """Set the VRAM word address and write a word (low, high).""" ppu.vmaddl = word_addr & 0xFF ppu.vmaddh = (word_addr >> 8) & 0xFF ppu.vmdatal = low - ppu.vmdatah = high # triggers write_vram (increment mode = 1, default) + # The $2119 setter writes the high byte and, in increment mode 1 + # (the default), advances the VRAM address. + ppu.vmdatah = high class TestVramRemapping: @@ -113,7 +116,8 @@ def test_mode1_swaps_lower_8_bits(self): Remapped: aaaaaaaa=0x00, ccccc=00000, BBB=111 → 0x0007 """ ppu = _make_ppu() - ppu.vmain = 0b10000100 # mode 1 (bits 3:2 = 01), increment on high write + # mode 1 (bits 3:2 = 01), increment on high write + ppu.vmain = 0b10000100 _write_word(ppu, 0x00E0, 0xCC, 0xDD) assert ppu.vram[0x0007 * 2 + 0] == 0xCC assert ppu.vram[0x0007 * 2 + 1] == 0xDD @@ -137,7 +141,8 @@ def test_mode2_swaps_lower_9_bits(self): Remapped: aaaaaaa=0x00, cccccc=000000, BBB=111 → 0x0007 """ ppu = _make_ppu() - ppu.vmain = 0b10001000 # mode 2 (bits 3:2 = 10), increment on high write + # mode 2 (bits 3:2 = 10), increment on high write + ppu.vmain = 0b10001000 _write_word(ppu, 0x01C0, 0x33, 0x44) assert ppu.vram[0x0007 * 2 + 0] == 0x33 assert ppu.vram[0x0007 * 2 + 1] == 0x44 @@ -150,7 +155,8 @@ def test_mode3_swaps_lower_10_bits(self): Remapped: aaaaaa=0x00, ccccccc=0000000, BBB=111 → 0x0007 """ ppu = _make_ppu() - ppu.vmain = 0b10001100 # mode 3 (bits 3:2 = 11), increment on high write + # mode 3 (bits 3:2 = 11), increment on high write + ppu.vmain = 0b10001100 _write_word(ppu, 0x0380, 0x55, 0x66) assert ppu.vram[0x0007 * 2 + 0] == 0x55 assert ppu.vram[0x0007 * 2 + 1] == 0x66 @@ -176,6 +182,7 @@ def test_remapping_does_not_affect_address_counter(self): # VRAM read port: RDVRAML ($2139) / RDVRAMH ($213A) # --------------------------------------------------------------------------- + class TestVramReadPort: def _seed(self, ppu: Ppu, word_addr: int, low: int, high: int) -> None: base = word_addr * 2 @@ -183,7 +190,8 @@ def _seed(self, ppu: Ppu, word_addr: int, low: int, high: int) -> None: ppu.vram[base + 1] = high def test_first_read_returns_prefetch_from_vmadd_write(self): - """Writing VMADDL/VMADDH fills the prefetch buffer; first $2139 read returns that pre-fetched byte.""" + """Writing VMADDL/VMADDH fills the prefetch buffer; first $2139 read + returns that pre-fetched byte.""" ppu = _make_ppu() ppu.vmain = 0x00 # increment on low read, step +1 self._seed(ppu, 0x0010, 0xAA, 0xBB) @@ -206,7 +214,7 @@ def test_read_low_increments_when_vmain_bit7_clear(self): ppu.vmaddh = 0x00 ppu.refill_vram_prefetch() assert ppu.rdvraml() == 0x11 - assert ppu.vmaddl == 0x21 # address advanced + assert ppu.vmaddl == 0x21 # address advanced assert ppu.rdvraml() == 0x11 # still the previous buffer assert ppu.rdvraml() == 0x33 # now the value at $0021 @@ -231,4 +239,4 @@ def test_read_low_does_not_increment_when_vmain_bit7_set(self): ppu.vmaddh = 0x00 ppu.refill_vram_prefetch() ppu.rdvraml() - assert ppu.vmaddl == 0x40 # no increment on low read + assert ppu.vmaddl == 0x40 # no increment on low read diff --git a/pysnes/ppu/test_ppu_scroll.py b/pysnes/ppu/test_ppu_scroll.py index e2dae27..82166f9 100644 --- a/pysnes/ppu/test_ppu_scroll.py +++ b/pysnes/ppu/test_ppu_scroll.py @@ -17,8 +17,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_scroll.py -v """ -import pytest - from pysnes.ppu import bg_renderer from pysnes.ppu.ppu import Ppu @@ -27,7 +25,7 @@ # Fully-saturated 8-bit values for 5-bit (31, 0, 31): # expand: (v << 3) | (v >> 2) → (31<<3)|(31>>2) = 248|7 = 255 -RED = (255, 0, 0) +RED = (255, 0, 0) BLUE = (0, 0, 255) @@ -35,12 +33,13 @@ # Helpers # --------------------------------------------------------------------------- + def _make_ppu() -> Ppu: ppu = Ppu() - ppu.inidisp_set(0x0F) # max brightness so colors pass through unchanged - ppu._bgmode = 1 # mode 1 — BG1 is 4BPP in real hardware, but we call - # draw_background_scanline with bpp=2 directly so the - # mode value only matters for color_offset_mode_0 logic + ppu.inidisp_set(0x0F) # max brightness so colors pass through unchanged + ppu._bgmode = 1 # mode 1 — BG1 is 4BPP in real hardware, but we call + # draw_background_scanline with bpp=2 directly so the + # mode value only matters for color_offset_mode_0 logic return ppu @@ -70,7 +69,7 @@ def _pixel(ppu: Ppu, x: int, y: int) -> tuple: u32 = ppu.main_bgs[y * SCREEN_W + x] r = (u32 >> 24) & 0xFF g = (u32 >> 16) & 0xFF - b = (u32 >> 8) & 0xFF + b = (u32 >> 8) & 0xFF return (r, g, b) @@ -83,9 +82,9 @@ def _setup(ppu: Ppu, hoffset: int = 0, voffset: int = 0) -> None: Row 1: tile1 tile1 tile1 tile1 … (all BLUE) """ # CGRAM: index 0 = transparent, 1 = RED, 2 = BLUE - _write_cgram(ppu, 0, 0, 0, 0) - _write_cgram(ppu, 1, 31, 0, 0) # RED - _write_cgram(ppu, 2, 0, 0, 31) # BLUE + _write_cgram(ppu, 0, 0, 0, 0) + _write_cgram(ppu, 1, 31, 0, 0) # RED + _write_cgram(ppu, 2, 0, 0, 31) # BLUE # Tile data _write_2bpp_solid_tile(ppu, 0, 1) # tile 0 = solid RED @@ -94,44 +93,47 @@ def _setup(ppu: Ppu, hoffset: int = 0, voffset: int = 0) -> None: # Tilemap row 0: alternating tile 0 / tile 1 for col in range(32): addr = col * 2 - ppu.vram[addr] = col & 1 # tile index - ppu.vram[addr + 1] = 0x00 # palette 0, priority 0, no flip + ppu.vram[addr] = col & 1 # tile index + ppu.vram[addr + 1] = 0x00 # palette 0, priority 0, no flip # Tilemap row 1 (starts at word offset 32 = byte 64): all tile 1 for col in range(32): addr = 64 + col * 2 - ppu.vram[addr] = 1 + ppu.vram[addr] = 1 ppu.vram[addr + 1] = 0x00 - ppu.bg1.screen_addr = 0 # tilemap at VRAM byte 0 - ppu.bg1.tiledata_addr = TILEDATA - ppu.bg1.screen_size = 0 # 32×32 tiles - ppu.bg1.tile_size = 0 # 8×8 px tiles - ppu.bg1.hoffset = hoffset - ppu.bg1.voffset = voffset + ppu.bg1.screen_addr = 0 # tilemap at VRAM byte 0 + ppu.bg1.tiledata_addr = TILEDATA + ppu.bg1.screen_size = 0 # 32×32 tiles + ppu.bg1.tile_size = 0 # 8×8 px tiles + ppu.bg1.hoffset = hoffset + ppu.bg1.voffset = voffset ppu.bg1.main_screen_enable = True def _draw_row(ppu: Ppu, scanline: int) -> None: """Draw one scanline. Output lands in main_bgs at row (scanline-1).""" ppu.v_counter = scanline - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=2, priority_selector=0) + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=2, priority_selector=0 + ) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- + class TestHorizontalScroll: def test_no_scroll_alternating_columns(self): """hoffset=0: columns 0,2,4 are RED; columns 1,3,5 are BLUE.""" ppu = _make_ppu() _setup(ppu, hoffset=0) - _draw_row(ppu, scanline=1) # output → row 0 + _draw_row(ppu, scanline=1) # output → row 0 - assert _pixel(ppu, 0, 0) == RED, "col 0 tile 0 should be RED" - assert _pixel(ppu, 8, 0) == BLUE, "col 1 tile 1 should be BLUE" - assert _pixel(ppu, 16, 0) == RED, "col 2 tile 0 should be RED" + assert _pixel(ppu, 0, 0) == RED, "col 0 tile 0 should be RED" + assert _pixel(ppu, 8, 0) == BLUE, "col 1 tile 1 should be BLUE" + assert _pixel(ppu, 16, 0) == RED, "col 2 tile 0 should be RED" assert _pixel(ppu, 24, 0) == BLUE, "col 3 tile 1 should be BLUE" def test_full_tile_shift(self): @@ -140,19 +142,20 @@ def test_full_tile_shift(self): _setup(ppu, hoffset=8) _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == BLUE, "shifted: col 0 should now be BLUE" - assert _pixel(ppu, 8, 0) == RED, "shifted: col 1 should now be RED" + assert _pixel(ppu, 0, 0) == BLUE, "shifted: col 0 should now be BLUE" + assert _pixel(ppu, 8, 0) == RED, "shifted: col 1 should now be RED" assert _pixel(ppu, 16, 0) == BLUE assert _pixel(ppu, 24, 0) == RED def test_sub_tile_shift_boundary(self): - """hoffset=3: pixel (x=4) lands on tile 0, pixel (x=5) lands on tile 1.""" + """hoffset=3: pixel (x=4) lands on tile 0, pixel (x=5) lands on tile + 1.""" ppu = _make_ppu() _setup(ppu, hoffset=3) _draw_row(ppu, scanline=1) # scrx = dot + 3. Tile boundary is at dot+3 = 8, i.e. dot = 5. - assert _pixel(ppu, 4, 0) == RED, "dot 4: scrx=7, tile 0 (RED)" + assert _pixel(ppu, 4, 0) == RED, "dot 4: scrx=7, tile 0 (RED)" assert _pixel(ppu, 5, 0) == BLUE, "dot 5: scrx=8, tile 1 (BLUE)" def test_two_tile_shift(self): @@ -161,7 +164,7 @@ def test_two_tile_shift(self): _setup(ppu, hoffset=16) _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == RED, "two-tile shift restores RED at col 0" + assert _pixel(ppu, 0, 0) == RED, "two-tile shift restores RED at col 0" assert _pixel(ppu, 8, 0) == BLUE def test_horizontal_wrap(self): @@ -171,9 +174,11 @@ def test_horizontal_wrap(self): _draw_row(ppu, scanline=1) # dot 0: scrx = 248 → tile 31 (odd) → BLUE - assert _pixel(ppu, 0, 0) == BLUE, "wrapping: tile 31 (BLUE) at left edge" + assert _pixel(ppu, 0, 0) == BLUE, ( + "wrapping: tile 31 (BLUE) at left edge" + ) # dot 8: scrx = (248+8)%256 = 0 → tile 0 (even) → RED - assert _pixel(ppu, 8, 0) == RED, "wrapping: tile 0 (RED) after wrap" + assert _pixel(ppu, 8, 0) == RED, "wrapping: tile 0 (RED) after wrap" class TestVerticalScroll: @@ -206,7 +211,8 @@ def test_sub_tile_vscroll_boundary(self): assert _pixel(ppu, 0, 0) == BLUE def test_sub_tile_vscroll_stays_row0(self): - """voffset=6: scanline 1 → scry=7 → still tile row 0 (RED/BLUE alternating).""" + """voffset=6: scanline 1 → scry=7 → still tile row 0 (RED/BLUE + alternating).""" ppu = _make_ppu() _setup(ppu, voffset=6) _draw_row(ppu, scanline=1) @@ -216,24 +222,26 @@ def test_sub_tile_vscroll_stays_row0(self): assert _pixel(ppu, 8, 0) == BLUE def test_vertical_wrap(self): - """voffset=248: last tile row wraps — scanline 1 maps to tilemap row 31.""" + """voffset=248: last tile row wraps — scanline 1 maps to tilemap row + 31.""" ppu = _make_ppu() _setup(ppu, voffset=248) # 248 = 31 * 8 # Tilemap row 31: fill with tile 0 (RED) so it's distinguishable for col in range(32): addr = (31 * 32 + col) * 2 - ppu.vram[addr] = 0 # tile 0 (RED) + ppu.vram[addr] = 0 # tile 0 (RED) ppu.vram[addr + 1] = 0x00 _draw_row(ppu, scanline=1) # scry = (1 + 248) % 256 = 249 → tile row 31 (249 // 8 = 31) → all RED - assert _pixel(ppu, 0, 0) == RED, "vertical wrap → tile row 31 (RED)" + assert _pixel(ppu, 0, 0) == RED, "vertical wrap → tile row 31 (RED)" assert _pixel(ppu, 8, 0) == RED class TestTilemapWordBits: - """Verify tilemap high-byte bit extraction: palette (bits 12:10) and priority (bit 13). + """Verify tilemap high-byte bit extraction: palette (bits 12:10) and + priority (bit 13). SNES tilemap word layout (16-bit): bit 15 : V-flip @@ -246,25 +254,25 @@ class TestTilemapWordBits: def _setup_palette_test(self, ppu: "Ppu", palette: int) -> None: """Single solid tile at col 0, using the given 2BPP palette index.""" # CGRAM palette 0, color 1 = RED - _write_cgram(ppu, 0, 0, 0, 0) # transparent + _write_cgram(ppu, 0, 0, 0, 0) # transparent _write_cgram(ppu, 1, 31, 0, 0) # palette 0 color 1 = RED # CGRAM palette 1, color 1 = GREEN (index = 1*4+1 = 5) _write_cgram(ppu, 5, 0, 31, 0) # palette 1 color 1 = GREEN _write_2bpp_solid_tile(ppu, 0, 1) # tile 0: solid color-index 1 - # Tilemap col 0: tile 0, given palette, priority 0 - # high byte: vflip=0, hflip=0, palette in bits 4:2, priority=0, tile_hi=0 + # Tilemap col 0: tile 0, given palette, priority 0 high byte: vflip=0, + # hflip=0, palette in bits 4:2, priority=0, tile_hi=0 high = (palette & 7) << 2 - ppu.vram[0] = 0 # tile index low + ppu.vram[0] = 0 # tile index low ppu.vram[1] = high - ppu.bg1.screen_addr = 0 + ppu.bg1.screen_addr = 0 ppu.bg1.tiledata_addr = TILEDATA - ppu.bg1.screen_size = 0 - ppu.bg1.tile_size = 0 - ppu.bg1.hoffset = 0 - ppu.bg1.voffset = 0 + ppu.bg1.screen_size = 0 + ppu.bg1.tile_size = 0 + ppu.bg1.hoffset = 0 + ppu.bg1.voffset = 0 ppu.bg1.main_screen_enable = True def test_palette0_renders_red(self): @@ -272,7 +280,9 @@ def test_palette0_renders_red(self): ppu = _make_ppu() self._setup_palette_test(ppu, palette=0) _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == (255, 0, 0), "palette 0 color 1 should be RED" + assert _pixel(ppu, 0, 0) == (255, 0, 0), ( + "palette 0 color 1 should be RED" + ) def test_palette1_renders_green(self): """Tilemap high=0x04 → palette 1 → color 1 = GREEN. @@ -283,10 +293,13 @@ def test_palette1_renders_green(self): ppu = _make_ppu() self._setup_palette_test(ppu, palette=1) _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == (0, 255, 0), "palette 1 color 1 should be GREEN" + assert _pixel(ppu, 0, 0) == (0, 255, 0), ( + "palette 1 color 1 should be GREEN" + ) def test_priority0_renders_on_low_pass(self): - """priority=0 tile renders when priority_selector=False (low-priority pass).""" + """priority=0 tile renders when priority_selector=False (low-priority + pass).""" ppu = _make_ppu() _write_cgram(ppu, 0, 0, 0, 0) _write_cgram(ppu, 1, 31, 0, 0) # RED @@ -301,10 +314,13 @@ def test_priority0_renders_on_low_pass(self): ppu.bg1.main_screen_enable = True _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == (255, 0, 0), "priority=0 tile should render on low pass" + assert _pixel(ppu, 0, 0) == (255, 0, 0), ( + "priority=0 tile should render on low pass" + ) def test_priority1_renders_on_high_pass(self): - """priority=1 tile renders only when priority_selector=True (high-priority pass). + """priority=1 tile renders only when priority_selector=True + (high-priority pass). SNES: priority is bit 13, i.e. bit 5 of the high byte. priority=1 → high = 0x20 → (0x20 >> 5) & 1 = 1. @@ -324,11 +340,17 @@ def test_priority1_renders_on_high_pass(self): # Low-priority pass: should NOT render _draw_row(ppu, scanline=1) - assert _pixel(ppu, 0, 0) == (0, 0, 0), "priority=1 tile must not render on low pass" + assert _pixel(ppu, 0, 0) == (0, 0, 0), ( + "priority=1 tile must not render on low pass" + ) # High-priority pass: SHOULD render - bg_renderer.draw_background_scanline(ppu, ppu.bg1, bpp=2, priority_selector=1) - assert _pixel(ppu, 0, 0) == (255, 0, 0), "priority=1 tile should render on high pass" + bg_renderer.draw_background_scanline( + ppu, ppu.bg1, bpp=2, priority_selector=1 + ) + assert _pixel(ppu, 0, 0) == (255, 0, 0), ( + "priority=1 tile should render on high pass" + ) class TestWindowMasking: @@ -350,61 +372,64 @@ def _solid_red_bg1(self, ppu): _write_cgram(ppu, 1, 31, 0, 0) # RED _write_2bpp_solid_tile(ppu, 0, 1) for col in range(32): - ppu.vram[col * 2] = 0 + ppu.vram[col * 2] = 0 ppu.vram[col * 2 + 1] = 0x00 - ppu.bg1.screen_addr = 0 - ppu.bg1.tiledata_addr = TILEDATA - ppu.bg1.screen_size = 0 - ppu.bg1.tile_size = 0 - ppu.bg1.hoffset = 0 - ppu.bg1.voffset = 0 + ppu.bg1.screen_addr = 0 + ppu.bg1.tiledata_addr = TILEDATA + ppu.bg1.screen_size = 0 + ppu.bg1.tile_size = 0 + ppu.bg1.hoffset = 0 + ppu.bg1.voffset = 0 ppu.bg1.main_screen_enable = True def test_no_window_all_pixels_drawn(self): - """TMW=0 (window masking disabled): all BG1 pixels drawn regardless of WH0/WH1.""" + """TMW=0 (window masking disabled): all BG1 pixels drawn regardless of + WH0/WH1.""" ppu = _make_ppu() self._solid_red_bg1(ppu) - ppu.tmw = 0x00 # window masking disabled + ppu.tmw = 0x00 # window masking disabled ppu.w12sel = 0x03 # would be active if tmw enabled - ppu.wh0 = 50 - ppu.wh1 = 100 + ppu.wh0 = 50 + ppu.wh1 = 100 _draw_row(ppu, scanline=1) - assert _pixel(ppu, 40, 0) == RED, "left of window: RED (no masking)" - assert _pixel(ppu, 75, 0) == RED, "inside window: RED (no masking)" + assert _pixel(ppu, 40, 0) == RED, "left of window: RED (no masking)" + assert _pixel(ppu, 75, 0) == RED, "inside window: RED (no masking)" assert _pixel(ppu, 150, 0) == RED, "right of window: RED (no masking)" def test_window_masks_inside_region(self): - """W12SEL=0x02 (enable, no invert): pixels INSIDE [WH0,WH1] are masked (not drawn).""" + """W12SEL=0x02 (enable, no invert): pixels INSIDE [WH0,WH1] are masked + (not drawn).""" ppu = _make_ppu() self._solid_red_bg1(ppu) - ppu.tmw = 0x01 # BG1 window masking on main screen - ppu.w12sel = 0x02 # BG1 Window 1 enabled, invert=0 (inside masked) - ppu.wh0 = 50 - ppu.wh1 = 100 + ppu.tmw = 0x01 # BG1 window masking on main screen + ppu.w12sel = 0x02 # BG1 Window 1 enabled, invert=0 (inside masked) + ppu.wh0 = 50 + ppu.wh1 = 100 _draw_row(ppu, scanline=1) # pixels [50..100] are inside the window → masked → not drawn (stay 0) - assert _pixel(ppu, 49, 0) == RED, "just left of window: drawn" - assert _pixel(ppu, 50, 0) == (0, 0, 0), "window left edge: masked" - assert _pixel(ppu, 75, 0) == (0, 0, 0), "inside window: masked" + assert _pixel(ppu, 49, 0) == RED, "just left of window: drawn" + assert _pixel(ppu, 50, 0) == (0, 0, 0), "window left edge: masked" + assert _pixel(ppu, 75, 0) == (0, 0, 0), "inside window: masked" assert _pixel(ppu, 100, 0) == (0, 0, 0), "window right edge: masked" assert _pixel(ppu, 101, 0) == RED, "just right of window: drawn" def test_window_masks_outside_region(self): - """W12SEL=0x03 (enable + invert): pixels OUTSIDE [WH0,WH1] are masked.""" + """W12SEL=0x03 (enable + invert): pixels OUTSIDE [WH0,WH1] are + masked.""" ppu = _make_ppu() self._solid_red_bg1(ppu) - ppu.tmw = 0x01 # BG1 window masking on main screen - ppu.w12sel = 0x03 # BG1 Window 1 enabled, invert=1 (outside masked) - ppu.wh0 = 50 - ppu.wh1 = 100 + ppu.tmw = 0x01 # BG1 window masking on main screen + ppu.w12sel = 0x03 # BG1 Window 1 enabled, invert=1 (outside masked) + ppu.wh0 = 50 + ppu.wh1 = 100 _draw_row(ppu, scanline=1) # pixels outside [50..100] are masked → not drawn - assert _pixel(ppu, 49, 0) == (0, 0, 0), "left of window: masked" - assert _pixel(ppu, 50, 0) == RED, "window left edge: drawn" - assert _pixel(ppu, 75, 0) == RED, "inside window: drawn" + assert _pixel(ppu, 49, 0) == (0, 0, 0), "left of window: masked" + assert _pixel(ppu, 50, 0) == RED, "window left edge: drawn" + assert _pixel(ppu, 75, 0) == RED, "inside window: drawn" assert _pixel(ppu, 100, 0) == RED, "window right edge: drawn" assert _pixel(ppu, 101, 0) == (0, 0, 0), "right of window: masked" @@ -412,12 +437,12 @@ def test_window_boundary_wh0_equals_wh1(self): """Single-pixel window: only pixel at wh0=wh1 is inside.""" ppu = _make_ppu() self._solid_red_bg1(ppu) - ppu.tmw = 0x01 - ppu.w12sel = 0x02 # inside masked - ppu.wh0 = 80 - ppu.wh1 = 80 + ppu.tmw = 0x01 + ppu.w12sel = 0x02 # inside masked + ppu.wh0 = 80 + ppu.wh1 = 80 _draw_row(ppu, scanline=1) - assert _pixel(ppu, 79, 0) == RED, "pixel before: drawn" - assert _pixel(ppu, 80, 0) == (0, 0, 0), "single-pixel window: masked" - assert _pixel(ppu, 81, 0) == RED, "pixel after: drawn" + assert _pixel(ppu, 79, 0) == RED, "pixel before: drawn" + assert _pixel(ppu, 80, 0) == (0, 0, 0), "single-pixel window: masked" + assert _pixel(ppu, 81, 0) == RED, "pixel after: drawn" diff --git a/pysnes/ppu/test_ppu_sprites.py b/pysnes/ppu/test_ppu_sprites.py index 580101e..1401029 100644 --- a/pysnes/ppu/test_ppu_sprites.py +++ b/pysnes/ppu/test_ppu_sprites.py @@ -1,9 +1,9 @@ """ Synthetic PPU sprite (OAM) unit tests. -These bypass ROM loading entirely — they write VRAM/CGRAM/OAM directly and -call copy_obj_pixels_for_priority() to verify that sprite pixels land in main_bgs correctly. -No Mesen, no ROM files needed. +These bypass ROM loading entirely — they write VRAM/CGRAM/OAM directly and call +copy_obj_pixels_for_priority() to verify that sprite pixels land in main_bgs +correctly. No Mesen, no ROM files needed. Setup: - 4BPP tile at VRAM[0x200..0x21F] (solid green, color index 1) @@ -16,8 +16,6 @@ uv run --python pypy3.10 pytest pysnes/ppu/test_ppu_sprites.py -v """ -import pytest - from pysnes.ppu import bg_renderer, obj_renderer from pysnes.ppu.ppu import Ppu @@ -33,6 +31,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_ppu() -> Ppu: ppu = Ppu() ppu.inidisp_set(0x0F) # max brightness @@ -60,8 +59,8 @@ def _write_4bpp_solid_tile(ppu: Ppu, vram_addr: int, color_index: int) -> None: bp2 = 0xFF if (color_index & 4) else 0x00 bp3 = 0xFF if (color_index & 8) else 0x00 for row in range(8): - ppu.vram[vram_addr + row * 2 + 0] = bp0 - ppu.vram[vram_addr + row * 2 + 1] = bp1 + ppu.vram[vram_addr + row * 2 + 0] = bp0 + ppu.vram[vram_addr + row * 2 + 1] = bp1 ppu.vram[vram_addr + 16 + row * 2 + 0] = bp2 ppu.vram[vram_addr + 16 + row * 2 + 1] = bp3 @@ -71,7 +70,7 @@ def _pixel(ppu: Ppu, x: int, y: int) -> tuple: u32 = ppu.main_bgs[y * SCREEN_W + x] r = (u32 >> 24) & 0xFF g = (u32 >> 16) & 0xFF - b = (u32 >> 8) & 0xFF + b = (u32 >> 8) & 0xFF return (r, g, b) @@ -91,8 +90,8 @@ def _setup_sprite(ppu: Ppu) -> None: # CGRAM sprite palette 8, color 1 = GREEN # palette_index = 8 * 16 = 128; color_index = 129; CGRAM byte addr = 258 - _write_cgram(ppu, 0, 0, 0, 0) # color 0 = transparent / backdrop (black) - _write_cgram(ppu, 129, 0, 31, 0) # sprite palette 8, color 1 = GREEN + _write_cgram(ppu, 0, 0, 0, 0) # color 0 = transparent / backdrop (black) + _write_cgram(ppu, 129, 0, 31, 0) # sprite palette 8, color 1 = GREEN # Configure PPU OAM tile base ppu.oam_tiledata_address = 0x100 @@ -106,11 +105,12 @@ def _setup_sprite(ppu: Ppu) -> None: obj.x = 10 obj.y = 5 obj.character = 0 - obj.palette = 8 # +8 already applied (matches OAM decoder in update_low_table) + # +8 already applied (matches OAM decoder in update_low_table) + obj.palette = 8 obj.priority = 0 obj.h_flip = False obj.v_flip = False - obj.size = False # 8×8 sprite (small size) + obj.size = False # 8×8 sprite (small size) obj.name_select = False @@ -118,6 +118,7 @@ def _setup_sprite(ppu: Ppu) -> None: # Tests # --------------------------------------------------------------------------- + class TestSpriteRendering: def test_sprite_pixels_written_to_framebuffer(self): """ @@ -148,7 +149,7 @@ def test_sprite_pixels_do_not_bleed_outside_tile(self): obj_renderer.copy_obj_pixels_for_priority(ppu) # Columns 9 and 18 are adjacent to the sprite — must be backdrop (black) - assert _pixel(ppu, 9, 5) == BLACK, "Left neighbor should be backdrop" + assert _pixel(ppu, 9, 5) == BLACK, "Left neighbor should be backdrop" assert _pixel(ppu, 18, 5) == BLACK, "Right neighbor should be backdrop" def test_transparent_sprite_pixel_does_not_overwrite_backdrop(self): @@ -173,7 +174,8 @@ def test_transparent_sprite_pixel_does_not_overwrite_backdrop(self): ) def test_sprite_off_screen_left_no_crash(self): - """Sprite partially off-screen to the left must not crash or write OOB.""" + """Sprite partially off-screen to the left must not crash or write + OOB.""" ppu = _make_ppu() _setup_sprite(ppu) @@ -181,10 +183,11 @@ def test_sprite_off_screen_left_no_crash(self): ppu.v_counter = 6 bg_renderer.draw_scanline_backdrop(ppu) - obj_renderer.copy_obj_pixels_for_priority(ppu) # must not raise IndexError + # must not raise IndexError + obj_renderer.copy_obj_pixels_for_priority(ppu) # Only the visible part (x=0..4) should be green - for x in range(0, 5): + for x in range(5): assert _pixel(ppu, x, 5) == GREEN, f"Expected GREEN at ({x}, 5)" def test_sprite_x_9bit_sign_extends_to_negative(self): @@ -203,9 +206,10 @@ def test_sprite_x_9bit_sign_extends_to_negative(self): bg_renderer.draw_scanline_backdrop(ppu) obj_renderer.copy_obj_pixels_for_priority(ppu) - for x in range(0, 5): + for x in range(5): assert _pixel(ppu, x, 5) == GREEN, f"Expected GREEN at ({x}, 5)" - # Pixel at x=5 is outside the sprite's right edge — must still be backdrop. + # Pixel at x=5 is outside the sprite's right edge — must still be + # backdrop. assert _pixel(ppu, 5, 5) == BLACK def test_sprite_scanline_not_rendered_above_or_below(self): @@ -271,10 +275,10 @@ def _setup_16x16_sprite(ppu: Ppu) -> None: _write_4bpp_solid_tile(ppu, TILE_BASE + 17 * TILE_SIZE_4BPP, color_index=1) # CGRAM: sprite palette 8 (color indices 129-131) - _write_cgram(ppu, 0, 0, 0, 0) # backdrop = black - _write_cgram(ppu, 129, 0, 31, 0) # color 1 = GREEN - _write_cgram(ppu, 130, 31, 0, 0) # color 2 = RED - _write_cgram(ppu, 131, 0, 0, 31) # color 3 = BLUE + _write_cgram(ppu, 0, 0, 0, 0) # backdrop = black + _write_cgram(ppu, 129, 0, 31, 0) # color 1 = GREEN + _write_cgram(ppu, 130, 31, 0, 0) # color 2 = RED + _write_cgram(ppu, 131, 0, 0, 31) # color 3 = BLUE ppu.oam_tiledata_address = 0x100 ppu.oam_base_size = 0 # 8x8 and 16x16 @@ -294,10 +298,12 @@ def _setup_16x16_sprite(ppu: Ppu) -> None: class TestMultiTileSprite: - """16x16 sprites must render all four 8x8 sub-tiles, not just the top-left.""" + """16x16 sprites must render all four 8x8 sub-tiles, not just the + top-left.""" def test_16x16_top_left_tile(self): - """Scanline through the top-left tile (y=10, pixels x=20..27) → GREEN.""" + """Scanline through the top-left tile (y=10, pixels x=20..27) → + GREEN.""" ppu = _make_ppu() _setup_16x16_sprite(ppu) @@ -321,7 +327,8 @@ def test_16x16_top_right_tile(self): assert _pixel(ppu, x, 10) == RED, f"top-right at ({x},10)" def test_16x16_bottom_left_tile(self): - """Scanline through the bottom-left tile (y=18, pixels x=20..27) → BLUE.""" + """Scanline through the bottom-left tile (y=18, pixels x=20..27) → + BLUE.""" ppu = _make_ppu() _setup_16x16_sprite(ppu) @@ -333,7 +340,8 @@ def test_16x16_bottom_left_tile(self): assert _pixel(ppu, x, 18) == BLUE, f"bottom-left at ({x},18)" def test_16x16_bottom_right_tile(self): - """Scanline through the bottom-right tile (y=18, pixels x=28..35) → GREEN.""" + """Scanline through the bottom-right tile (y=18, pixels x=28..35) → + GREEN.""" ppu = _make_ppu() _setup_16x16_sprite(ppu) @@ -390,7 +398,9 @@ def test_16x16_no_bleed_below(self): YELLOW = (255, 255, 0) -def _write_bg1_4bpp_solid_tile(ppu: Ppu, tile_index: int, color_index: int) -> None: +def _write_bg1_4bpp_solid_tile( + ppu: Ppu, tile_index: int, color_index: int +) -> None: bp0 = 0xFF if (color_index & 1) else 0x00 bp1 = 0xFF if (color_index & 2) else 0x00 bp2 = 0xFF if (color_index & 4) else 0x00 @@ -403,7 +413,9 @@ def _write_bg1_4bpp_solid_tile(ppu: Ppu, tile_index: int, color_index: int) -> N ppu.vram[addr + 16 + row * 2 + 1] = bp3 -def _setup_bg1_tile_over_sprite(ppu: Ppu, bg_priority_bit: int, sprite_priority: int) -> None: +def _setup_bg1_tile_over_sprite( + ppu: Ppu, bg_priority_bit: int, sprite_priority: int +) -> None: """Place a BG1 4bpp YELLOW tile and a GREEN sprite at the same pixel. BG1 tile's tilemap priority bit controls whether it's "priority 1" in SNES @@ -416,10 +428,10 @@ def _setup_bg1_tile_over_sprite(ppu: Ppu, bg_priority_bit: int, sprite_priority: _write_bg1_4bpp_solid_tile(ppu, 0, color_index=1) # Tilemap: one tile at tilemap row 1, col 2 → covers screen (16..23, 8..15). - # Simpler: put the BG tile at col 2, row 1 so it covers the sprite at (20,10). - # Tilemap entry: word 0 for (col 0, row 0). Each row = 32 words = 64 bytes. - # For (col 2, row 1) → byte addr = 1*64 + 2*2 = 68. - # Low byte = tile index 0; high byte bit 5 = priority bit. + # Simpler: put the BG tile at col 2, row 1 so it covers the sprite at + # (20,10). Tilemap entry: word 0 for (col 0, row 0). Each row = 32 words = + # 64 bytes. For (col 2, row 1) → byte addr = 1*64 + 2*2 = 68. Low byte = + # tile index 0; high byte bit 5 = priority bit. base = 0 # BG1 tilemap base at VRAM byte 0 tilemap_addr = base + (1 * 32 + 2) * 2 ppu.vram[tilemap_addr + 0] = 0 # tile 0 @@ -453,7 +465,8 @@ def test_bg1_high_priority_covers_sprite_priority_2(self): ppu.render_scanline() # Sprite and BG1 both at (20, 10). BG1 pri-1 wins → YELLOW. assert _pixel(ppu, 20, 10) == YELLOW, ( - f"BG1 pri-1 should be in front of sprite pri-2, got {_pixel(ppu, 20, 10)}" + "BG1 pri-1 should be in front of sprite pri-2, got " + f"{_pixel(ppu, 20, 10)}" ) def test_bg1_low_priority_behind_sprite_priority_2(self): @@ -502,7 +515,8 @@ def _setup_two_overlapping_sprites( Sprite 1 (higher OAM index) uses palette 9 → RED. Both share the solid color-index-1 tile (character 0). """ - _setup_sprite(ppu) # sprite 0: palette 8 (GREEN), all others hidden at y=240 + # sprite 0: palette 8 (GREEN), all others hidden at y=240 + _setup_sprite(ppu) # Palette 9, color 1 = RED → CGRAM index 9*16 + 1 = 145 _write_cgram(ppu, 145, 31, 0, 0) @@ -536,7 +550,8 @@ def test_lower_index_wins_same_priority(self): ppu.v_counter = 11 ppu.render_scanline() assert _pixel(ppu, 20, 10) == GREEN, ( - f"Sprite 0 (lowest index) should be on top, got {_pixel(ppu, 20, 10)}" + "Sprite 0 (lowest index) should be on top, got " + f"{_pixel(ppu, 20, 10)}" ) def test_lower_index_wins_despite_lower_priority_field(self): @@ -560,5 +575,6 @@ def test_higher_index_higher_priority_still_loses(self): ppu.v_counter = 11 ppu.render_scanline() assert _pixel(ppu, 20, 10) == GREEN, ( - f"Sprite 0 (lowest index) must win the pixel, got {_pixel(ppu, 20, 10)}" + "Sprite 0 (lowest index) must win the pixel, got " + f"{_pixel(ppu, 20, 10)}" ) diff --git a/pysnes/pysnes.py b/pysnes/pysnes.py index 5fda8f1..cd2769a 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -1,31 +1,34 @@ import argparse -from collections import deque -from ctypes import byref +import contextlib import heapq import pathlib +import platform import signal -import time import sys -import platform +import time +from collections import deque +from ctypes import byref import sdl2 as sdl -from .rom import Rom -from .scheduler import Scheduler +from . import settings as settings_module +from .apu import Apu +from .audio import AudioSDL2 from .bus import Bus +from .controller import Controller from .cpu import Cpu -from .apu import Apu +from .debugger import BreakpointHit, Debugger from .ppu import Ppu -from .controller import Controller -from .video import Video -from .debugger import Debugger, BreakpointHit -from .audio import AudioSDL2 +from .rom import Rom +from .scheduler import Scheduler from .spc_player import SpcPlayer -from . import settings as settings_module +from .video import Video class PySNES: - def __init__(self, rom_file_path: str, settings: dict | None = None) -> None: + def __init__( + self, rom_file_path: str, settings: dict | None = None + ) -> None: if settings is None: settings = settings_module.load() self.settings = settings @@ -38,7 +41,9 @@ def __init__(self, rom_file_path: str, settings: dict | None = None) -> None: self.cpu = Cpu(rom.hardware_vectors) self.ppu = Ppu() self.controllers = [Controller(), Controller(disabled=True)] - bus = Bus(rom, self.cpu, self.apu, self.ppu, self.controllers, self.scheduler) + bus = Bus( + rom, self.cpu, self.apu, self.ppu, self.controllers, self.scheduler + ) self._load_sram(bus) self.cpu.attach(bus) self.cpu.trace_enabled = False @@ -69,7 +74,6 @@ def __init__(self, rom_file_path: str, settings: dict | None = None) -> None: # Emulator state self.running = True self.paused = False - self.frame_time = 0.0 self.frame_fps = 60.0 self._max_frames: int = settings.get("max_frames", 0) self._frame_count: int = 0 @@ -86,7 +90,8 @@ def __init__(self, rom_file_path: str, settings: dict | None = None) -> None: # CPU trace: compare against bsnes reference self._trace_file = None self._trace_ref = None - self._trace_ring: deque | None = None # ring-buffer mode; None = stream to file + # ring-buffer mode; None = stream to file + self._trace_ring: deque | None = None self._trace_count = 0 self._trace_limit = 100_000 self._trace_diverged = False @@ -121,6 +126,7 @@ def _do_screenshot(self): def _do_save_state(self): from . import savestate # noqa: PLC0415 + try: savestate.save(self, str(self.state_path)) print(f"Saved state to {self.state_path}", flush=True) @@ -129,6 +135,7 @@ def _do_save_state(self): def _do_load_state(self): from . import savestate # noqa: PLC0415 + try: savestate.load(self, str(self.state_path)) print(f"Loaded state from {self.state_path}", flush=True) @@ -148,10 +155,15 @@ def _do_memory_dump(self): f.write(cgram) with open("wram_dump.bin", "wb") as f: f.write(wram) - print(f"Memory dumps saved: vram_dump.bin ({len(vram)}B), cgram_dump.bin ({len(cgram)}B), wram_dump.bin ({len(wram)}B)", flush=True) + print( + f"Memory dumps saved: vram_dump.bin ({len(vram)}B), " + f"cgram_dump.bin ({len(cgram)}B), wram_dump.bin ({len(wram)}B)", + flush=True, + ) def reset(self) -> None: - """Soft reset: restore CPU/APU to power-on state and jump to the reset vector.""" + """Soft reset: restore CPU/APU to power-on state and jump to the reset + vector.""" # Drop any queued CPU step events so we can reschedule from scratch. # The debugger's _install_hooks rebinds _hooked_step on every call # (including from inside _hooked_step at breakpoint fire), so the @@ -164,13 +176,15 @@ def reset(self) -> None: q[:] = [e for e in q if not (e[-1] == orig or e[-1] == cur)] heapq.heapify(q) self.cpu.reset_registers() - # reset_registers() makes new Reg objects; rebuild the instruction table so - # its pre-resolved register references point at the live Reg instances. + # reset_registers() makes new Reg objects; rebuild the instruction table + # so its pre-resolved register references point at the live Reg + # instances. self.cpu.load_instructions() self.cpu.PC.w = self._reset_vector self.apu.reset_registers() self.ppu.reset_registers() - # Reschedule CPU step; _step may be the debugger hook if breakpoints are active. + # Reschedule CPU step; _step may be the debugger hook if breakpoints are + # active. self.scheduler.add(0, self.cpu._step) self.paused = True self.debugger._notify_paused() @@ -179,10 +193,12 @@ def start_trace(self, ref_path: str | None = None, last: int = 0): """Start CPU tracing to cpu_trace.log. ref_path: optional bsnes reference log to compare against. - last: if > 0, keep only the last N lines in a ring buffer and write on exit. - if 0, stream every line to the file immediately (unlimited). + last: if > 0, keep only the last N lines in a ring buffer and write + them on exit. If 0, stream every line to the file immediately + (unlimited). """ - self._trace_file = open("cpu_trace.log", "w") + # Trace handles stay open until tracing stops, so no context manager. + self._trace_file = open("cpu_trace.log", "w") # noqa: SIM115 self.cpu.trace_enabled = True if last > 0: self._trace_ring = deque(maxlen=last) @@ -194,10 +210,15 @@ def start_trace(self, ref_path: str | None = None, last: int = 0): mode_str = "unlimited" if ref_path is not None: try: - self._trace_ref = open(ref_path, "r") - print(f"CPU trace started ({mode_str}, ref: {ref_path})", flush=True) + self._trace_ref = open(ref_path) # noqa: SIM115 + print( + f"CPU trace started ({mode_str}, ref: {ref_path})", + flush=True, + ) except FileNotFoundError as e: - print(f"Warning: Could not open trace reference: {e}", flush=True) + print( + f"Warning: Could not open trace reference: {e}", flush=True + ) else: print(f"CPU trace started ({mode_str})", flush=True) @@ -222,7 +243,11 @@ def start_trace_from(self, addr: int, limit: int = 100_000) -> None: self._trace_count = 0 self._trace_active = False self.cpu.trace_enabled = True - print(f"[trace] waiting for PC=0x{addr:06X} (limit {limit} instructions) …", flush=True) + print( + f"[trace] waiting for PC=0x{addr:06X} (limit {limit} " + "instructions) …", + flush=True, + ) pysnes = self original_step = self.cpu._step @@ -232,8 +257,13 @@ def traced_step(): pc = pysnes.cpu.PC.d if not pysnes._trace_active and pc == addr: pysnes._trace_active = True - pysnes._trace_file = open("cpu_trace.log", "w") - print(f"[trace] triggered at PC=0x{addr:06X} → cpu_trace.log", flush=True) + pysnes._trace_file = open( # noqa: SIM115 + "cpu_trace.log", "w" + ) + print( + f"[trace] triggered at PC=0x{addr:06X} → cpu_trace.log", + flush=True, + ) if pysnes._trace_active: pysnes._check_trace() @@ -260,8 +290,10 @@ def _format_trace_line(self) -> str: ) return ( f"{disasm:<30} " - f"A:{self.cpu.A.value:04X} X:{self.cpu.X.value:04X} Y:{self.cpu.Y.value:04X} " - f"S:{self.cpu.S.value:04X} D:{self.cpu.D.value:04X} DB:{self.cpu.DB.value:02X} " + f"A:{self.cpu.A.value:04X} X:{self.cpu.X.value:04X} " + f"Y:{self.cpu.Y.value:04X} " + f"S:{self.cpu.S.value:04X} D:{self.cpu.D.value:04X} " + f"DB:{self.cpu.DB.value:02X} " f"{flags}" ) @@ -270,7 +302,6 @@ def _check_trace(self): if self._trace_limit and self._trace_count >= self._trace_limit: return - pc = self.cpu.PC.d self._trace_count += 1 line = self._format_trace_line() @@ -279,15 +310,24 @@ def _check_trace(self): elif self._trace_file: self._trace_file.write(line + "\n") - # Compare against reference (streaming mode only; up to first divergence) - if self._trace_ring is None and not self._trace_diverged and self._trace_ref: + # Compare against reference (streaming mode only; up to first + # divergence) + if ( + self._trace_ring is None + and not self._trace_diverged + and self._trace_ref + ): ref_line = self._trace_ref.readline() while ref_line and ref_line.startswith(".."): ref_line = self._trace_ref.readline() if ref_line: ref_line = ref_line.rstrip() if line[:6].lower() != ref_line[:6].lower(): - print(f"\n*** TRACE DIVERGENCE at instruction {self._trace_count} ***", flush=True) + print( + "\n*** TRACE DIVERGENCE at instruction " + f"{self._trace_count} ***", + flush=True, + ) print(f" OUR: {line}", flush=True) print(f" REF: {ref_line}", flush=True) self._trace_diverged = True @@ -295,7 +335,10 @@ def _check_trace(self): self._trace_file.flush() if self._trace_limit and self._trace_count >= self._trace_limit: - print(f"Trace limit reached ({self._trace_limit} instructions).", flush=True) + print( + f"Trace limit reached ({self._trace_limit} instructions).", + flush=True, + ) if self._trace_file: self._trace_file.flush() @@ -306,7 +349,8 @@ def start_apu_trace(self, ref_path: str | None = None, last: int = 0): last: if > 0, keep only the last N lines (ring buffer, written on exit). if 0, stream every line to the file immediately. """ - self._apu_trace_file = open("apu_trace.log", "w") + # Trace handles stay open until tracing stops, so no context manager. + self._apu_trace_file = open("apu_trace.log", "w") # noqa: SIM115 self.apu.trace_enabled = True if last > 0: self._apu_trace_ring = deque(maxlen=last) @@ -316,10 +360,16 @@ def start_apu_trace(self, ref_path: str | None = None, last: int = 0): mode_str = "unlimited" if ref_path is not None: try: - self._apu_trace_ref = open(ref_path, "r") - print(f"APU trace started ({mode_str}, ref: {ref_path})", flush=True) + self._apu_trace_ref = open(ref_path) # noqa: SIM115 + print( + f"APU trace started ({mode_str}, ref: {ref_path})", + flush=True, + ) except FileNotFoundError as e: - print(f"Warning: Could not open APU trace reference: {e}", flush=True) + print( + f"Warning: Could not open APU trace reference: {e}", + flush=True, + ) else: print(f"APU trace started ({mode_str})", flush=True) @@ -339,12 +389,16 @@ def _check_apu_trace(self, line: str): elif self._apu_trace_file: self._apu_trace_file.write(line + "\n") - if self._apu_trace_ring is None and not self._apu_trace_diverged and self._apu_trace_ref: + if ( + self._apu_trace_ring is None + and not self._apu_trace_diverged + and self._apu_trace_ref + ): ref_line = self._apu_trace_ref.readline() if ref_line: ref_line = ref_line.rstrip() if line[:4].lower() != ref_line[:4].lower(): - print(f"\n*** APU TRACE DIVERGENCE ***", flush=True) + print("\n*** APU TRACE DIVERGENCE ***", flush=True) print(f" OUR: {line}", flush=True) print(f" REF: {ref_line}", flush=True) self._apu_trace_diverged = True @@ -354,9 +408,11 @@ def _check_apu_trace(self, line: str): def main(self): """Main loop driven by the event scheduler.""" MC_PER_FRAME: int = 262 * 1364 - # Exact NTSC frame period: 21.477272 MHz / (262 lines * 1364 dots) ≈ 16.6836 ms + # Exact NTSC frame period: 21.477272 MHz / (262 lines * 1364 dots) ≈ + # 16.6836 ms FRAME_TIME_S: float = MC_PER_FRAME / 21_477_272.0 - _FRAME_HEADROOM_S: float = 0.001 # busy-wait the last 1 ms for precision + # busy-wait the last 1 ms for precision + _FRAME_HEADROOM_S: float = 0.001 # EMA smoothing coefficient for FPS display (≈30-frame window) _FPS_ALPHA: float = 1.0 / 30.0 @@ -372,31 +428,36 @@ def main(self): try: while self.running: if not self.paused: - # Reset deadline after a pause or any large gap so the emulator - # doesn't try to catch up across many frames at once. + # Reset deadline after a pause or any large gap so the + # emulator doesn't try to catch up across many frames at + # once. now = time.perf_counter() if now - frame_deadline > FRAME_TIME_S * 4: frame_deadline = now frame_tick = now frame_end = self.scheduler.master_clock + MC_PER_FRAME - try: + # On a breakpoint paused=True is already set: skip the + # draw and let the next iteration observe it. + with contextlib.suppress(BreakpointHit): self.scheduler.run_to(frame_end) - except BreakpointHit: - pass # paused=True already set; skip draw, next iteration checks paused if self.audio is not None: - # Ensure the SPC700 has run all its cycles for this frame. - # sync_to is normally called lazily from the bus on APU port access; - # if the CPU went the whole frame without touching APU ports the - # SPC700 would be behind and DSP register state would be stale. + # Ensure the SPC700 has run all its cycles for this + # frame. sync_to is normally called lazily from the bus + # on APU port access; if the CPU went the whole frame + # without touching APU ports the SPC700 would be behind + # and DSP register state would be stale. self.apu.sync_to(self.scheduler.master_clock) - # Fixed-point accumulator matching Apu.sync_to pattern — avoids rounding drift. - # DSP rate = Apu._APU_MC_DEN / 32 = 32000 Hz - # samples per frame ≈ MC_PER_FRAME * _APU_MC_DEN / (_APU_MC_NUM * 32) ≈ 532.48 + # Fixed-point accumulator matching Apu.sync_to pattern — + # avoids rounding drift. DSP rate = Apu._APU_MC_DEN / 32 + # = 32000 Hz samples per frame ≈ MC_PER_FRAME * + # _APU_MC_DEN / (_APU_MC_NUM * 32) ≈ 532.48 self._audio_frac += MC_PER_FRAME * self.apu._APU_MC_DEN - n_samples = self._audio_frac // (self.apu._APU_MC_NUM * 32) - self._audio_frac %= (self.apu._APU_MC_NUM * 32) + n_samples = self._audio_frac // ( + self.apu._APU_MC_NUM * 32 + ) + self._audio_frac %= self.apu._APU_MC_NUM * 32 samples = self.apu.generate_audio_frame(n_samples) self.audio.queue_samples(samples) @@ -404,15 +465,26 @@ def main(self): self.video.update_screen() self._frame_count += 1 - if self._max_frames and self._frame_count >= self._max_frames: + if ( + self._max_frames + and self._frame_count >= self._max_frames + ): elapsed = time.perf_counter() - _bench_start - avg_fps = self._frame_count / elapsed if elapsed > 0 else 0.0 - print(f"\nBenchmark: {self._frame_count} frames in {elapsed:.2f}s = {avg_fps:.2f} FPS", flush=True) + if elapsed > 0: + avg_fps = self._frame_count / elapsed + else: + avg_fps = 0.0 + print( + f"\nBenchmark: {self._frame_count} frames in " + f"{elapsed:.2f}s = {avg_fps:.2f} FPS", + flush=True, + ) self.running = False - # Wall-clock frame limiter: sleep to the next frame deadline so the - # emulator runs at exactly NTSC speed when computation finishes early. - # On slow frames we skip the sleep and start the next frame immediately. + # Wall-clock frame limiter: sleep to the next frame deadline + # so the emulator runs at exactly NTSC speed when + # computation finishes early. On slow frames we skip the + # sleep and start the next frame immediately. frame_deadline += FRAME_TIME_S remaining = frame_deadline - time.perf_counter() if remaining > _FRAME_HEADROOM_S: @@ -424,9 +496,14 @@ def main(self): elapsed = now - frame_tick frame_tick = now actual_fps = 1.0 / elapsed if elapsed > 0 else 0.0 - self.frame_fps = self.frame_fps * (1.0 - _FPS_ALPHA) + actual_fps * _FPS_ALPHA + self.frame_fps = ( + self.frame_fps * (1.0 - _FPS_ALPHA) + + actual_fps * _FPS_ALPHA + ) - self.video.set_window_title(f"PySNES - {self.rom_name} | {self.frame_fps:.1f} FPS") + self.video.set_window_title( + f"PySNES - {self.rom_name} | {self.frame_fps:.1f} FPS" + ) # Handle signal-triggered actions if self._screenshot_requested: @@ -451,7 +528,10 @@ def main(self): if self._trace_ring is not None and self._trace_file: for line in self._trace_ring: self._trace_file.write(line + "\n") - print(f"Trace written ({len(self._trace_ring)} lines).", flush=True) + print( + f"Trace written ({len(self._trace_ring)} lines).", + flush=True, + ) if self._trace_file: self._trace_file.close() if self._trace_ref: @@ -459,7 +539,10 @@ def main(self): if self._apu_trace_ring is not None and self._apu_trace_file: for line in self._apu_trace_ring: self._apu_trace_file.write(line + "\n") - print(f"APU trace written ({len(self._apu_trace_ring)} lines).", flush=True) + print( + f"APU trace written ({len(self._apu_trace_ring)} lines).", + flush=True, + ) if self._apu_trace_file: self._apu_trace_file.close() if self._apu_trace_ref: @@ -475,8 +558,10 @@ def process_inputs(self): if self.event.type == sdl.SDL_QUIT: self.running = False return - elif self.event.type == sdl.SDL_KEYUP: - self.controllers[0].pressed_keys.discard(self.event.key.keysym.sym) + if self.event.type == sdl.SDL_KEYUP: + self.controllers[0].pressed_keys.discard( + self.event.key.keysym.sym + ) elif self.event.type == sdl.SDL_KEYDOWN: self.controllers[0].pressed_keys.add(self.event.key.keysym.sym) if self.event.key.keysym.sym == sdl.SDLK_SPACE: @@ -505,25 +590,68 @@ def main(): print_python_info() parser = argparse.ArgumentParser(description="PySNES - SNES emulator") - parser.add_argument("rom", help="Path to ROM file (.smc/.sfc) or SPC audio file (.spc)") - parser.add_argument("--trace", action="store_true", - help="Write CPU trace to cpu_trace.log (unlimited)") - parser.add_argument("--trace-ref", metavar="REF", - help="Compare CPU trace against REF log (implies --trace)") - parser.add_argument("--trace-limit", metavar="N", type=int, default=0, - help="Keep only the last N trace lines; written to cpu_trace.log on exit") - parser.add_argument("--trace-from", metavar="ADDR", help="Start CPU trace when PC first reaches ADDR (hex, e.g. 0x00A087)") - parser.add_argument("--apu-trace", action="store_true", - help="Write APU trace to apu_trace.log (unlimited)") - parser.add_argument("--apu-trace-ref", metavar="REF", - help="Compare APU trace against REF log (implies --apu-trace)") - parser.add_argument("--apu-trace-limit", metavar="N", type=int, default=0, - help="Keep only the last N APU trace lines; written on exit") - parser.add_argument("--headless", action="store_true", help="Run without opening an SDL2 window") - parser.add_argument("--max-frames", metavar="N", type=int, default=0, - help="Exit after rendering N frames (0 = run forever)") - parser.add_argument("--breakpoint", metavar="ADDR", action="append", - help="Set breakpoint at address (hex, e.g. 0x00A087); may be repeated") + parser.add_argument( + "rom", help="Path to ROM file (.smc/.sfc) or SPC audio file (.spc)" + ) + parser.add_argument( + "--trace", + action="store_true", + help="Write CPU trace to cpu_trace.log (unlimited)", + ) + parser.add_argument( + "--trace-ref", + metavar="REF", + help="Compare CPU trace against REF log (implies --trace)", + ) + parser.add_argument( + "--trace-limit", + metavar="N", + type=int, + default=0, + help=( + "Keep only the last N trace lines; written to cpu_trace.log on exit" + ), + ) + parser.add_argument( + "--trace-from", + metavar="ADDR", + help="Start CPU trace when PC first reaches ADDR (hex, e.g. 0x00A087)", + ) + parser.add_argument( + "--apu-trace", + action="store_true", + help="Write APU trace to apu_trace.log (unlimited)", + ) + parser.add_argument( + "--apu-trace-ref", + metavar="REF", + help="Compare APU trace against REF log (implies --apu-trace)", + ) + parser.add_argument( + "--apu-trace-limit", + metavar="N", + type=int, + default=0, + help="Keep only the last N APU trace lines; written on exit", + ) + parser.add_argument( + "--headless", + action="store_true", + help="Run without opening an SDL2 window", + ) + parser.add_argument( + "--max-frames", + metavar="N", + type=int, + default=0, + help="Exit after rendering N frames (0 = run forever)", + ) + parser.add_argument( + "--breakpoint", + metavar="ADDR", + action="append", + help="Set breakpoint at address (hex, e.g. 0x00A087); may be repeated", + ) args = parser.parse_args() if pathlib.Path(args.rom).suffix.lower() == ".spc": @@ -546,7 +674,10 @@ def main(): if args.breakpoint: for addr_str in args.breakpoint: pysnes.debugger.toggle_breakpoint(int(addr_str, 16)) - print(f"Breakpoints set: {[hex(int(a, 16)) for a in args.breakpoint]}", flush=True) + print( + f"Breakpoints set: {[hex(int(a, 16)) for a in args.breakpoint]}", + flush=True, + ) pysnes.main() diff --git a/pysnes/rom/__init__.py b/pysnes/rom/__init__.py index f447640..2d85186 100644 --- a/pysnes/rom/__init__.py +++ b/pysnes/rom/__init__.py @@ -1,4 +1,5 @@ from .rom import ( + SUPPORTED_MAPPING_MODES, CartridgeType, HardwareVectors, InterruptVectors, @@ -6,5 +7,4 @@ Region, Rom, SnesHeader, - SUPPORTED_MAPPING_MODES, ) diff --git a/pysnes/rom/rom.py b/pysnes/rom/rom.py index 4c695fc..fe1e3af 100644 --- a/pysnes/rom/rom.py +++ b/pysnes/rom/rom.py @@ -1,7 +1,6 @@ import pathlib from dataclasses import dataclass from enum import IntEnum -from typing import Union from rich import print @@ -19,13 +18,15 @@ class MappingMode(IntEnum): EXHIROM_FAST = 0x35 -SUPPORTED_MAPPING_MODES = frozenset({ - MappingMode.TEST_PROGRAM, - MappingMode.LOROM, - MappingMode.LOROM_FAST, - MappingMode.HIROM, - MappingMode.HIROM_FAST, -}) +SUPPORTED_MAPPING_MODES = frozenset( + { + MappingMode.TEST_PROGRAM, + MappingMode.LOROM, + MappingMode.LOROM_FAST, + MappingMode.HIROM, + MappingMode.HIROM_FAST, + } +) class CartridgeType(IntEnum): @@ -64,24 +65,26 @@ class Region(IntEnum): AUSTRALIA = 0x11 -def _enum_or_int(cls: type[IntEnum], value: int) -> Union[IntEnum, int]: +def _enum_or_int(cls: type[IntEnum], value: int) -> IntEnum | int: try: return cls(value) except ValueError: return value -# Scripts to convert SNES ROMs to SNES Classic (.sfrom) format and to read .sfrom headers + +# Scripts to convert SNES ROMs to SNES Classic (.sfrom) format and to read +# .sfrom headers # https://gist.github.com/anpage/4834433944a2875ee6d4cbb5786c6bf7 @dataclass class InterruptVectors: cop: int - brk: int # native-only; 0 in emulation + brk: int # native-only; 0 in emulation abort: int nmi: int - reset: int # emulation-only; 0 in native - irq: int # in emulation this is IRQ/BRK + reset: int # emulation-only; 0 in native + irq: int # in emulation this is IRQ/BRK @dataclass @@ -93,14 +96,14 @@ class HardwareVectors: @dataclass class SnesHeader: game_title: str - mapping_mode: Union[MappingMode, int] - cartridge_type: Union[CartridgeType, int] - rom_size: int # bytes; 0 if header byte is 0 - sram_size: int # bytes; 0 if header byte is 0 - destination_code: Union[Region, int] # $FFD9 — region, NOT developer ID + mapping_mode: MappingMode | int + cartridge_type: CartridgeType | int + rom_size: int # bytes; 0 if header byte is 0 + sram_size: int # bytes; 0 if header byte is 0 + destination_code: Region | int # $FFD9 — region, NOT developer ID version: int checksum_complement: int # 16-bit LE - checksum: int # 16-bit LE + checksum: int # 16-bit LE def _looks_like_title(buf) -> bool: @@ -116,7 +119,6 @@ def _looks_like_map_mode(byte: int, want_hirom: bool) -> bool: class Rom: - def __init__(self, rom_file_path: str) -> None: self.rom_file_path = rom_file_path self.rom_file_name = pathlib.Path(rom_file_path).name @@ -127,11 +129,13 @@ def read(self, addr: int) -> int: def load_rom_file(self): """ - SFC and SMC files are usually identical. It's just a different choice in file extension. - “SMC” comes from Super MagiCom, a floppy-based cart copying device for backup/piracy. - The original .smc files produced by the device contained a 512 byte header. + SFC and SMC files are usually identical. It's just a different choice in + file extension. “SMC” comes from Super MagiCom, a floppy-based cart + copying device for backup/piracy. The original .smc files produced by + the device contained a 512 byte header. """ - self.rom = bytearray(0x400000) # https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map + # https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map + self.rom = bytearray(0x400000) print(f"Loading ROM file: {self.rom_file_path!r}") with open(self.rom_file_path, "rb") as f: @@ -149,7 +153,8 @@ def load_rom_file(self): assert self.snes_header.mapping_mode in SUPPORTED_MAPPING_MODES, ( f"unsupported mapping mode {self.snes_header.mapping_mode:#04x} — " - f"supported: LoROM ({MappingMode.LOROM:#04x} / {MappingMode.LOROM_FAST:#04x}), " + f"supported: LoROM ({MappingMode.LOROM:#04x} / " + f"{MappingMode.LOROM_FAST:#04x}), " f"HiROM ({MappingMode.HIROM:#04x} / {MappingMode.HIROM_FAST:#04x})" ) @@ -189,16 +194,21 @@ def _parse_header(self, page_offset: int) -> SnesHeader: rom_size_byte = rom[page_offset + 0xD7] sram_size_byte = rom[page_offset + 0xD8] + if sram_size_byte: + sram_size = min(0x400 << sram_size_byte, 0x20000) + else: + sram_size = 0 return SnesHeader( game_title=game_title, mapping_mode=_enum_or_int(MappingMode, rom[page_offset + 0xD5]), cartridge_type=_enum_or_int(CartridgeType, rom[page_offset + 0xD6]), rom_size=(0x400 << rom_size_byte) if rom_size_byte else 0, - sram_size=min(0x400 << sram_size_byte, 0x20000) if sram_size_byte else 0, + sram_size=sram_size, destination_code=_enum_or_int(Region, rom[page_offset + 0xD9]), version=rom[page_offset + 0xDB], - checksum_complement=rom[page_offset + 0xDC] | rom[page_offset + 0xDD] << 8, + checksum_complement=rom[page_offset + 0xDC] + | rom[page_offset + 0xDD] << 8, checksum=rom[page_offset + 0xDE] | rom[page_offset + 0xDF] << 8, ) @@ -216,7 +226,10 @@ def _parse_vectors(self, page_offset: int) -> HardwareVectors: IRQ $FFEE-$FFEF IRQ/BRK $FFFE-$FFFF """ rom = self.rom - word = lambda lo: rom[page_offset | lo] | rom[page_offset | (lo + 1)] << 8 + + def word(lo: int) -> int: + return rom[page_offset | lo] | rom[page_offset | (lo + 1)] << 8 + return HardwareVectors( native=InterruptVectors( cop=word(0xE4), diff --git a/pysnes/rom/test_rom.py b/pysnes/rom/test_rom.py index 28319da..2936cc4 100644 --- a/pysnes/rom/test_rom.py +++ b/pysnes/rom/test_rom.py @@ -7,8 +7,6 @@ naming, byte=0 size handling, and unfamiliar mapping_mode bytes. """ -import pathlib - import pytest from .rom import ( @@ -21,7 +19,6 @@ SnesHeader, ) - LOROM_BANK_SIZE = 0x8000 HEADER_BASE = 0x7FC0 # offset within bank 0 of the cartridge header @@ -45,22 +42,32 @@ def _build_lorom( img = bytearray(LOROM_BANK_SIZE) img[HEADER_BASE : HEADER_BASE + 21] = title - img[HEADER_BASE + 0x15] = mapping_mode # $FFD5 - img[HEADER_BASE + 0x16] = cartridge_type # $FFD6 - img[HEADER_BASE + 0x17] = rom_size_byte # $FFD7 - img[HEADER_BASE + 0x18] = sram_size_byte # $FFD8 + img[HEADER_BASE + 0x15] = mapping_mode # $FFD5 + img[HEADER_BASE + 0x16] = cartridge_type # $FFD6 + img[HEADER_BASE + 0x17] = rom_size_byte # $FFD7 + img[HEADER_BASE + 0x18] = sram_size_byte # $FFD8 img[HEADER_BASE + 0x19] = destination_code # $FFD9 - img[HEADER_BASE + 0x1B] = version # $FFDB + img[HEADER_BASE + 0x1B] = version # $FFDB img[HEADER_BASE + 0x1C] = checksum_complement & 0xFF img[HEADER_BASE + 0x1D] = (checksum_complement >> 8) & 0xFF img[HEADER_BASE + 0x1E] = checksum & 0xFF img[HEADER_BASE + 0x1F] = (checksum >> 8) & 0xFF - nv = {"cop": 0x1234, "brk": 0x2345, "abort": 0x3456, - "nmi": 0x4567, "irq": 0x5678} + nv = { + "cop": 0x1234, + "brk": 0x2345, + "abort": 0x3456, + "nmi": 0x4567, + "irq": 0x5678, + } nv.update(native_vectors or {}) - ev = {"cop": 0xA111, "abort": 0xA222, "nmi": 0xA333, - "reset": 0x8000, "irq": 0xA444} + ev = { + "cop": 0xA111, + "abort": 0xA222, + "nmi": 0xA333, + "reset": 0x8000, + "irq": 0xA444, + } ev.update(emulation_vectors or {}) def write_word(offset: int, value: int) -> None: @@ -87,11 +94,13 @@ def _make(image: bytes, name: str = "test.sfc") -> Rom: path = tmp_path / name path.write_bytes(image) return Rom(str(path)) + return _make def test_native_cop_vector_reads_from_FFE4(make_rom): - """Regression: previously read $FFE5/$FFE6 (high byte of COP + low of BRK).""" + """Regression: previously read $FFE5/$FFE6 (high byte of COP + low of + BRK).""" img = _build_lorom( native_vectors={"cop": 0xCAFE, "brk": 0xDEAD}, ) @@ -102,26 +111,44 @@ def test_native_cop_vector_reads_from_FFE4(make_rom): def test_native_vectors_full(make_rom): img = _build_lorom( - native_vectors={"cop": 0x1111, "brk": 0x2222, "abort": 0x3333, - "nmi": 0x4444, "irq": 0x5555}, + native_vectors={ + "cop": 0x1111, + "brk": 0x2222, + "abort": 0x3333, + "nmi": 0x4444, + "irq": 0x5555, + }, ) rom = make_rom(img) nv = rom.hardware_vectors.native assert (nv.cop, nv.brk, nv.abort, nv.nmi, nv.irq) == ( - 0x1111, 0x2222, 0x3333, 0x4444, 0x5555, + 0x1111, + 0x2222, + 0x3333, + 0x4444, + 0x5555, ) assert nv.reset == 0 # reset is emulation-only def test_emulation_vectors_full(make_rom): img = _build_lorom( - emulation_vectors={"cop": 0xAAAA, "abort": 0xBBBB, "nmi": 0xCCCC, - "reset": 0x8123, "irq": 0xDDDD}, + emulation_vectors={ + "cop": 0xAAAA, + "abort": 0xBBBB, + "nmi": 0xCCCC, + "reset": 0x8123, + "irq": 0xDDDD, + }, ) rom = make_rom(img) ev = rom.hardware_vectors.emulation assert (ev.cop, ev.abort, ev.nmi, ev.reset, ev.irq) == ( - 0xAAAA, 0xBBBB, 0xCCCC, 0x8123, 0xDDDD, + 0xAAAA, + 0xBBBB, + 0xCCCC, + 0x8123, + 0xDDDD, ) assert ev.brk == 0 # 65C02 emulation has no separate BRK vector @@ -135,7 +162,8 @@ def test_checksum_and_complement_are_16_bit(make_rom): def test_destination_code_field(make_rom): - """$FFD9 holds the region byte; the field used to be misnamed developer_id.""" + """$FFD9 holds the region byte; the field used to be misnamed + developer_id.""" img = _build_lorom(destination_code=0x07) rom = make_rom(img) assert rom.snes_header.destination_code == 0x07 @@ -160,12 +188,15 @@ def test_mapping_mode_hirom_fast_parses(make_rom): assert rom.snes_header.mapping_mode is MappingMode.HIROM_FAST -@pytest.mark.parametrize("mode", [ - 0x22, # ExLoROM - 0x23, # SA-1 (coprocessor) - 0x25, # ExHiROM - 0x99, # unknown byte -]) +@pytest.mark.parametrize( + "mode", + [ + 0x22, # ExLoROM + 0x23, # SA-1 (coprocessor) + 0x25, # ExHiROM + 0x99, # unknown byte + ], +) def test_unsupported_mapping_mode_raises(make_rom, mode): img = _build_lorom(mapping_mode=mode) with pytest.raises(AssertionError, match="unsupported mapping mode"): @@ -217,7 +248,8 @@ def test_game_title_decoded(make_rom): def test_smc_header_is_stripped(make_rom): - """A 512-byte SMC copier header at the front must not shift the parsed header.""" + """A 512-byte SMC copier header at the front must not shift the parsed + header.""" raw = _build_lorom(checksum=0xFACE) img_with_header = b"\x00" * 0x200 + raw # 512 SMC bytes + 32 KB ROM rom = make_rom(img_with_header, name="test.smc") @@ -227,14 +259,23 @@ def test_smc_header_is_stripped(make_rom): def test_header_dataclasses_are_typed(): h = SnesHeader( - game_title="X", mapping_mode=0x20, cartridge_type=0, - rom_size=0, sram_size=0, destination_code=0, version=0, - checksum_complement=0, checksum=0, + game_title="X", + mapping_mode=0x20, + cartridge_type=0, + rom_size=0, + sram_size=0, + destination_code=0, + version=0, + checksum_complement=0, + checksum=0, ) v = HardwareVectors( native=InterruptVectors(cop=0, brk=0, abort=0, nmi=0, reset=0, irq=0), - emulation=InterruptVectors(cop=0, brk=0, abort=0, nmi=0, reset=0, irq=0), + emulation=InterruptVectors( + cop=0, brk=0, abort=0, nmi=0, reset=0, irq=0 + ), ) # Field access uses attributes, not dict keys assert h.destination_code == 0 - assert v.native.cop == 0 and v.emulation.reset == 0 + assert v.native.cop == 0 + assert v.emulation.reset == 0 diff --git a/pysnes/savestate/__init__.py b/pysnes/savestate/__init__.py index 07d9e43..143060e 100644 --- a/pysnes/savestate/__init__.py +++ b/pysnes/savestate/__init__.py @@ -19,6 +19,7 @@ caller (pysnes.PySNES) services F5/F9 requests after `scheduler.run_to(end)` returns, when the scheduler queue is in a quiescent state. """ + from __future__ import annotations import datetime @@ -107,6 +108,7 @@ def _python_impl() -> str: # Save / load # --------------------------------------------------------------------------- + def save(pysnes, path) -> None: """Snapshot the running emulator to `path`. @@ -115,7 +117,10 @@ def save(pysnes, path) -> None: """ rom_header = pysnes.bus.rom.snes_header state = { - "rom": {"title": rom_header.game_title, "checksum": rom_header.checksum}, + "rom": { + "title": rom_header.game_title, + "checksum": rom_header.checksum, + }, "cpu": pysnes.cpu.dump_state(), "bus": pysnes.bus.dump_state(), "ppu": pysnes.ppu.dump_state(), @@ -176,7 +181,7 @@ def load(pysnes, path) -> None: try: header = json.loads(header_bytes.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as e: - raise IncompatibleStateError(f"{path}: corrupt header — {e}") + raise IncompatibleStateError(f"{path}: corrupt header — {e}") from e expected_hash = _compat_hash() saved_hash = header.get("compat_hash", "") @@ -184,7 +189,8 @@ def load(pysnes, path) -> None: raise IncompatibleStateError( f"{path}: compatibility hash mismatch — refusing to load.\n" f" saved on: {header.get('python_impl', '?')} " - f"(commit {header.get('pysnes_commit') or '?'}, hash {saved_hash[:12]}...)\n" + f"(commit {header.get('pysnes_commit') or '?'}, hash " + f"{saved_hash[:12]}...)\n" f" current: {_python_impl()} " f"(commit {_git_commit() or '?'}, hash {expected_hash[:12]}...)\n" f"State files are tied to the exact Python build and source code " @@ -200,7 +206,8 @@ def load(pysnes, path) -> None: if header.get("rom_checksum") != rom_header.checksum: raise RomMismatchError( f"{path}: state was made for " - f"{header.get('rom_title','?')!r} (checksum 0x{header.get('rom_checksum',0):04X}), " + f"{header.get('rom_title', '?')!r} (checksum " + f"0x{header.get('rom_checksum', 0):04X}), " f"but loaded ROM is {rom_header.game_title!r} " f"(checksum 0x{rom_header.checksum:04X}). Refusing to load." ) @@ -217,7 +224,7 @@ def load(pysnes, path) -> None: pysnes.apu.load_state(state["apu"]) pysnes.cpu.load_state(state["cpu"]) pysnes.cpu.dma.load_state(state["dma"]) - for c, cs in zip(pysnes.controllers, state["controllers"]): + for c, cs in zip(pysnes.controllers, state["controllers"], strict=True): c.load_state(cs) registry = { "cpu": pysnes.cpu, diff --git a/pysnes/savestate/test_savestate.py b/pysnes/savestate/test_savestate.py index f237cb9..e18a13e 100644 --- a/pysnes/savestate/test_savestate.py +++ b/pysnes/savestate/test_savestate.py @@ -1,4 +1,5 @@ """Smoke tests for save state. Marked @pytest.mark.harness — opt-in.""" + from pathlib import Path import pytest @@ -37,7 +38,7 @@ def test_round_trip_identity(tmp_path): } h.save_state(path) - h.run_frames(60) # mutate state + h.run_frames(60) # mutate state assert h.frame == 120 h.load_state(path) assert h.frame == pre["frame"] @@ -108,8 +109,9 @@ def test_file_format_version_mismatch(tmp_path): """Bumping the file_format_version byte makes load refuse.""" _require_smw() import struct + from pysnes.harness import Harness - from pysnes.savestate import IncompatibleStateError, MAGIC + from pysnes.savestate import MAGIC, IncompatibleStateError path = tmp_path / "v.state" with Harness(SMW_ROM) as h: @@ -125,17 +127,18 @@ def test_file_format_version_mismatch(tmp_path): blob[o : o + 4] = struct.pack(" None: self._queue: list = [] self._seq: int = 0 @@ -31,7 +31,8 @@ def add(self, delay: int, handler) -> None: self._seq += 1 def run_to(self, target: int) -> None: - """Fire all events whose time <= target, advancing master_clock to each.""" + """Fire all events whose time <= target, advancing master_clock to + each.""" q = self._queue while q and q[0][0] <= target: t, _, fn = heapq.heappop(q) @@ -59,7 +60,8 @@ def dump_state(self) -> dict: method = getattr(fn, "__func__", None) if owner is None or method is None: raise ValueError( - f"Cannot serialize scheduler entry with non-bound-method handler: {fn!r}" + "Cannot serialize scheduler entry with non-bound-method " + f"handler: {fn!r}" ) owner_kind = owner.__class__.__name__.lower() # "cpu", "ppu", "apu" entries.append((int(t), int(seq), owner_kind, method.__name__)) @@ -71,7 +73,7 @@ def dump_state(self) -> dict: def load_state(self, d: dict, registry: dict) -> None: """Rebuild the queue from a dump. `registry` maps owner_kind → object - instance (e.g. {"cpu": pysnes.cpu, "ppu": pysnes.ppu, "apu": pysnes.apu}). + instance, e.g. {"cpu": pysnes.cpu, "ppu": pysnes.ppu}. """ self.master_clock = d["master_clock"] self._seq = d["seq"] @@ -86,7 +88,8 @@ def load_state(self, d: dict, registry: dict) -> None: fn = getattr(owner, method_name, None) if not callable(fn): raise ValueError( - f"Scheduler load: {owner_kind}.{method_name} is not callable" + f"Scheduler load: {owner_kind}.{method_name} is not " + "callable" ) self._queue.append((t, seq, fn)) heapq.heapify(self._queue) diff --git a/pysnes/scheduler/test_scheduler.py b/pysnes/scheduler/test_scheduler.py index ad26613..c511d7d 100644 --- a/pysnes/scheduler/test_scheduler.py +++ b/pysnes/scheduler/test_scheduler.py @@ -10,6 +10,7 @@ def s(): # --- add / run_to --- + def test_single_event_fires(s): fired = [] s.add(100, lambda: fired.append(1)) @@ -38,11 +39,11 @@ def test_master_clock_does_not_exceed_target(s): def test_multiple_events_fire_in_order(s): order = [] - s.add(30, lambda: order.append('a')) - s.add(10, lambda: order.append('b')) - s.add(20, lambda: order.append('c')) + s.add(30, lambda: order.append("a")) + s.add(10, lambda: order.append("b")) + s.add(20, lambda: order.append("c")) s.run_to(30) - assert order == ['b', 'c', 'a'] + assert order == ["b", "c", "a"] def test_only_events_up_to_target_fire(s): @@ -55,16 +56,17 @@ def test_only_events_up_to_target_fire(s): def test_events_scheduled_by_handlers_fire_in_same_run_to(s): - """A handler that schedules a new event within the same window should fire.""" + """A handler that schedules a new event within the same window should + fire.""" fired = [] def first(): - fired.append('first') - s.add(5, lambda: fired.append('second')) + fired.append("first") + s.add(5, lambda: fired.append("second")) s.add(10, first) s.run_to(15) - assert fired == ['first', 'second'] + assert fired == ["first", "second"] def test_master_clock_is_correct_inside_handler(s): @@ -76,6 +78,7 @@ def test_master_clock_is_correct_inside_handler(s): # --- simultaneous events: FIFO by insertion order --- + def test_simultaneous_events_fire_in_insertion_order(s): order = [] s.add(0, lambda: order.append(1)) @@ -87,6 +90,7 @@ def test_simultaneous_events_fire_in_insertion_order(s): # --- peek --- + def test_peek_returns_next_event_time(s): s.add(50, lambda: None) s.add(10, lambda: None) @@ -106,6 +110,7 @@ def test_peek_does_not_fire_event(s): # --- run_one --- + def test_run_one_fires_earliest_event(s): fired = [] s.add(20, lambda: fired.append(20)) @@ -131,6 +136,7 @@ def test_run_one_leaves_remaining_events(s): # --- delay=0 (schedule for "now") --- + def test_delay_zero_fires_at_current_clock(s): s.master_clock = 100 fired = [] @@ -141,6 +147,7 @@ def test_delay_zero_fires_at_current_clock(s): # --- large event counts --- + def test_many_events_all_fire(s): fired = [] for i in range(1000): diff --git a/pysnes/settings.py b/pysnes/settings.py index c9b98f4..8815cfb 100644 --- a/pysnes/settings.py +++ b/pysnes/settings.py @@ -11,20 +11,24 @@ "rom": "roms/mygame.sfc" } """ + import json from pathlib import Path DEFAULTS = { "headless": False, "rom": None, - "mesen_bin": "submodules/Mesen2/bin/linux-x64/Release/linux-x64/publish/Mesen", + "mesen_bin": ( + "submodules/Mesen2/bin/linux-x64/Release/linux-x64/publish/Mesen" + ), } SETTINGS_FILE = "settings.json" def load(path=None) -> dict: - """Load settings from a JSON file, falling back to defaults for missing keys.""" + """Load settings from a JSON file, falling back to defaults for missing + keys.""" result = dict(DEFAULTS) settings_path = Path(path or SETTINGS_FILE) if settings_path.exists(): diff --git a/pysnes/spc_player.py b/pysnes/spc_player.py index 00dd5bb..cf14235 100644 --- a/pysnes/spc_player.py +++ b/pysnes/spc_player.py @@ -7,13 +7,13 @@ from .audio import AudioSDL2 # APU runs at ~1.024 MHz; DSP generates one sample every 32 APU clocks → 32 kHz. -_APU_HZ = 1_024_000 -_DSP_DIV = 32 # APU clocks per DSP (audio) sample +_APU_HZ = 1_024_000 +_DSP_DIV = 32 # APU clocks per DSP (audio) sample _AUDIO_HZ = _APU_HZ // _DSP_DIV # 32 000 Hz # Target 60 "frames" per second for audio chunking. -_FPS = 60 -_APU_PER_FRAME = _APU_HZ // _FPS # ~17 067 APU clocks per frame +_FPS = 60 +_APU_PER_FRAME = _APU_HZ // _FPS # ~17 067 APU clocks per frame _SAMPLES_PER_FRAME = _APU_PER_FRAME // _DSP_DIV # ~533 samples per frame @@ -27,26 +27,31 @@ def __init__(self, spc_path: str) -> None: def _init_sdl(self) -> None: result = sdl.SDL_Init(sdl.SDL_INIT_AUDIO | sdl.SDL_INIT_VIDEO) if result != 0: - raise RuntimeError(f"SDL_Init failed: {sdl.SDL_GetError().decode()}") + raise RuntimeError( + f"SDL_Init failed: {sdl.SDL_GetError().decode()}" + ) title = self.spc.song_name or "SPC Player" if self.spc.game_name: title = f"{self.spc.game_name} — {title}" self._window = sdl.SDL_CreateWindow( title.encode(), - sdl.SDL_WINDOWPOS_CENTERED, sdl.SDL_WINDOWPOS_CENTERED, - 400, 100, + sdl.SDL_WINDOWPOS_CENTERED, + sdl.SDL_WINDOWPOS_CENTERED, + 400, + 100, sdl.SDL_WINDOW_SHOWN, ) def _process_events(self) -> None: event = sdl.SDL_Event() while sdl.SDL_PollEvent(event): - if event.type == sdl.SDL_QUIT: + escape_pressed = ( + event.type == sdl.SDL_KEYDOWN + and event.key.keysym.sym == sdl.SDLK_ESCAPE + ) + if event.type == sdl.SDL_QUIT or escape_pressed: self.running = False - elif event.type == sdl.SDL_KEYDOWN: - if event.key.keysym.sym == sdl.SDLK_ESCAPE: - self.running = False def run(self) -> None: self._init_sdl() diff --git a/pysnes/test_integration.py b/pysnes/test_integration.py index 9d2b941..24e0f7d 100644 --- a/pysnes/test_integration.py +++ b/pysnes/test_integration.py @@ -1,12 +1,15 @@ """ -Integration tests: compare PySNES emulation state against Mesen 2 (reference oracle). +Integration tests: compare PySNES emulation state against Mesen 2 (reference +oracle). -Tier 1 — frame-level: compare CPU/SPC registers + WRAM CRC32 at each frame boundary. -Tier 2 — instruction-level: compare CPU trace line by line to find the exact diverging instruction. +Tier 1 — frame-level: compare CPU/SPC registers + WRAM CRC32 at each frame +boundary. Tier 2 — instruction-level: compare CPU trace line by line to find the +exact diverging instruction. Run: - uv run --python pypy@3.10 pytest pysnes/test_integration.py::test_frame_divergence -v -s - uv run --python pypy@3.10 pytest pysnes/test_integration.py::test_instruction_divergence -v -s + uv run pytest pysnes/test_integration.py::test_frame_divergence -v -s + uv run pytest pysnes/test_integration.py::test_instruction_divergence \ + -v -s Skip in normal suite: uv run --python pypy@3.10 pytest pysnes/ -m "not integration" @@ -42,6 +45,7 @@ def get_mesen(): 2. settings.json "mesen_bin" value """ from pysnes import settings as s # noqa: PLC0415 + cfg = s.load() candidates = [ @@ -53,7 +57,8 @@ def get_mesen(): return path pytest.skip( - "Mesen binary not found. Download from https://github.com/SourMesen/Mesen2/releases " + "Mesen binary not found. Download from " + "https://github.com/SourMesen/Mesen2/releases " "and set MESEN_BIN env var or 'mesen_bin' in settings.json" ) @@ -68,9 +73,11 @@ def _start_tcp_server(): def _collect_lines(srv, timeout=120): - """Accept one connection and collect all newline-delimited lines. Returns list of strings.""" + """Accept one connection and collect all newline-delimited lines. Returns + list of strings.""" lines = [] error = [] + def _run(): try: srv.settimeout(timeout) @@ -84,6 +91,7 @@ def _run(): conn.close() except Exception as e: error.append(e) + t = threading.Thread(target=_run, daemon=True) t.start() return t, lines, error @@ -105,12 +113,15 @@ def _run_mesen(mesen, rom, lua_script, extra_env, timeout=300): if "DISPLAY" not in env: env["DISPLAY"] = _find_display() cmd = [mesen, "--testrunner", rom, lua_script] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env) + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, env=env + ) return result.stdout, result.stderr, result.returncode # ---- Tier 1 fixtures ---- + @pytest.fixture(scope="session") def oracle_frames(request): """Run Mesen headlessly, collect per-frame state via TCP socket.""" @@ -124,10 +135,15 @@ def oracle_frames(request): collect_t, lines, collect_err = _collect_lines(srv, timeout=120) lua_script = str(SCRIPTS_DIR / "mesen_oracle.lua") - stdout, stderr, rc = _run_mesen(mesen, rom, lua_script, { - "MESEN_PORT": str(port), - "MESEN_FRAMES": str(n_frames), - }) + stdout, stderr, rc = _run_mesen( + mesen, + rom, + lua_script, + { + "MESEN_PORT": str(port), + "MESEN_FRAMES": str(n_frames), + }, + ) collect_t.join(timeout=30) if rc != 0: @@ -135,9 +151,11 @@ def oracle_frames(request): if collect_err: pytest.fail(f"Socket error: {collect_err[0]}") if not lines: - pytest.fail(f"No frames received.\nstdout:\n{stdout}\nstderr:\n{stderr}") + pytest.fail( + f"No frames received.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ) - frames = [json.loads(l) for l in lines] + frames = [json.loads(line) for line in lines] print(f"\nMesen oracle: {len(frames)} frames collected", flush=True) return frames @@ -169,34 +187,42 @@ def pysnes_frames(request): + bytes(pysnes.bus.extended_ram) ) psw = ( - (int(apu.NF) << 7) | (int(apu.VF) << 6) | (int(apu.PF) << 5) | (int(apu.BF) << 4) - | (int(apu.HF) << 3) | (int(apu.IF) << 2) | (int(apu.ZF) << 1) | int(apu.CF) + (int(apu.NF) << 7) + | (int(apu.VF) << 6) + | (int(apu.PF) << 5) + | (int(apu.BF) << 4) + | (int(apu.HF) << 3) + | (int(apu.IF) << 2) + | (int(apu.ZF) << 1) + | int(apu.CF) + ) + frames.append( + { + "frame": i + 1, + "cpu": { + "pc": cpu.PC.d & 0xFFFF, + "a": cpu.A.value, + "x": cpu.X.value, + "y": cpu.Y.value, + "sp": cpu.S.value, + "ps": cpu.P, + "k": (cpu.PC.d >> 16) & 0xFF, + "d": cpu.D.value, + "db": cpu.DB.value, + "e": int(cpu.EF), + }, + "spc": { + "pc": apu.PC, + "a": apu.A, + "x": apu.X, + "y": apu.Y, + "sp": apu.S, + "ps": psw, + }, + "wram_crc32": zlib.crc32(wram) & 0xFFFFFFFF, + "wram_head": list(wram[:16]), + } ) - frames.append({ - "frame": i + 1, - "cpu": { - "pc": cpu.PC.d & 0xFFFF, - "a": cpu.A.value, - "x": cpu.X.value, - "y": cpu.Y.value, - "sp": cpu.S.value, - "ps": cpu.P, - "k": (cpu.PC.d >> 16) & 0xFF, - "d": cpu.D.value, - "db": cpu.DB.value, - "e": int(cpu.EF), - }, - "spc": { - "pc": apu.PC, - "a": apu.A, - "x": apu.X, - "y": apu.Y, - "sp": apu.S, - "ps": psw, - }, - "wram_crc32": zlib.crc32(wram) & 0xFFFFFFFF, - "wram_head": list(wram[:16]), - }) print(f"\nPySNES: {len(frames)} frames captured", flush=True) return frames @@ -204,9 +230,11 @@ def pysnes_frames(request): # ---- Tier 1 test ---- + def test_frame_divergence(oracle_frames, pysnes_frames): """Fail at the first frame where PySNES diverges from Mesen.""" - for ref, got in zip(oracle_frames, pysnes_frames): + # strict=False: the runs are expected to diverge, including in length. + for ref, got in zip(oracle_frames, pysnes_frames, strict=False): frame = ref["frame"] diffs = [] @@ -228,7 +256,8 @@ def test_frame_divergence(oracle_frames, pysnes_frames): if rv != gv: diffs.append(f"spc.{field}: mesen={rv:#x} pysnes={gv:#x}") - # wram_crc32 comparison omitted until we can read WRAM from Mesen fast enough + # wram_crc32 comparison omitted until we can read WRAM from Mesen fast + # enough if diffs: msg = [f"Divergence at frame {frame}:"] @@ -245,19 +274,22 @@ def _cpu_str(c): f"PC={c.get('k', 0):02X}:{c.get('pc', 0):04X} " f"A={c.get('a', 0):04X} X={c.get('x', 0):04X} Y={c.get('y', 0):04X} " f"S={c.get('sp', 0):04X} D={c.get('d', 0):04X} " - f"DB={c.get('db', c.get('dbr', 0)):02X} P={c.get('ps', 0):02X} E={c.get('e', 0)}" + f"DB={c.get('db', c.get('dbr', 0)):02X} P={c.get('ps', 0):02X} " + f"E={c.get('e', 0)}" ) def _spc_str(s): return ( f"PC={s.get('pc', 0):04X} A={s.get('a', 0):02X} X={s.get('x', 0):02X} " - f"Y={s.get('y', 0):02X} SP={s.get('sp', 0):02X} PSW={s.get('ps', 0):02X}" + f"Y={s.get('y', 0):02X} SP={s.get('sp', 0):02X} " + f"PSW={s.get('ps', 0):02X}" ) # ---- Tier 2 fixtures ---- + @pytest.fixture(scope="session") def oracle_trace_lines(request): """Run Mesen exec callback, collect CPU trace lines via TCP socket.""" @@ -271,10 +303,16 @@ def oracle_trace_lines(request): collect_t, lines, collect_err = _collect_lines(srv, timeout=300) lua_script = str(SCRIPTS_DIR / "mesen_trace.lua") - stdout, stderr, rc = _run_mesen(mesen, rom, lua_script, { - "MESEN_PORT": str(port), - "MESEN_INSTRUCTIONS": str(n_instructions), - }, timeout=600) + stdout, stderr, rc = _run_mesen( + mesen, + rom, + lua_script, + { + "MESEN_PORT": str(port), + "MESEN_INSTRUCTIONS": str(n_instructions), + }, + timeout=600, + ) collect_t.join(timeout=60) if rc != 0: @@ -282,7 +320,9 @@ def oracle_trace_lines(request): if collect_err: pytest.fail(f"Socket error: {collect_err[0]}") if not lines: - pytest.fail(f"No trace lines received.\nstdout:\n{stdout}\nstderr:\n{stderr}") + pytest.fail( + f"No trace lines received.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ) print(f"\nMesen trace: {len(lines)} instructions", flush=True) return lines @@ -325,6 +365,7 @@ def traced_step(): class _LineCollector: """File-like object that collects lines written to it.""" + def __init__(self): self.lines = [] @@ -342,9 +383,12 @@ def close(self): # ---- Tier 2 test ---- + def test_instruction_divergence(oracle_trace_lines, pysnes_trace_lines): """Fail at the first CPU instruction where PySNES diverges from Mesen.""" - for i, (ref, got) in enumerate(zip(oracle_trace_lines, pysnes_trace_lines)): + # strict=False: the traces are expected to diverge, including in length. + traces = zip(oracle_trace_lines, pysnes_trace_lines, strict=False) + for i, (ref, got) in enumerate(traces): if ref.startswith("..") or got.startswith(".."): continue if ref[:6].lower() != got[:6].lower(): diff --git a/pysnes/video/video.py b/pysnes/video/video.py index 7d04494..9f64fb6 100644 --- a/pysnes/video/video.py +++ b/pysnes/video/video.py @@ -1,6 +1,8 @@ import sdl2 as sdl + try: from .video_sdl2 import SDL2Renderer + SDL2_AVAILABLE = True except ImportError as e: SDL2Renderer = None @@ -8,10 +10,7 @@ print(f"SDL2 renderer not available: {e}") - - class Video: - WINDOW_WIDTH = 768 WINDOW_HEIGHT = 672 @@ -19,7 +18,6 @@ def __init__(self): self.use_sdl2 = SDL2_AVAILABLE self.window = None self.sdl2_renderer = None - self.sdl2_calls = 0 def initialize(self, headless: bool = False) -> None: if headless: @@ -29,16 +27,23 @@ def initialize(self, headless: bool = False) -> None: result = sdl.SDL_Init(sdl.SDL_INIT_EVERYTHING) if result != 0: - raise RuntimeError(f"Failed to initialize SDL: {sdl.SDL_GetError().decode()}") + raise RuntimeError( + f"Failed to initialize SDL: {sdl.SDL_GetError().decode()}" + ) self.window = sdl.SDL_CreateWindow( b"PySNES", - 0, 0, self.WINDOW_WIDTH, self.WINDOW_HEIGHT, + 0, + 0, + self.WINDOW_WIDTH, + self.WINDOW_HEIGHT, sdl.SDL_WINDOW_SHOWN | sdl.SDL_WINDOW_RESIZABLE, ) if not self.window: - raise RuntimeError(f"Failed to create SDL window: {sdl.SDL_GetError().decode()}") + raise RuntimeError( + f"Failed to create SDL window: {sdl.SDL_GetError().decode()}" + ) if self.use_sdl2 and SDL2Renderer: try: @@ -75,20 +80,5 @@ def draw_textures(self, main_bgs): return if self.use_sdl2 and self.sdl2_renderer: self.sdl2_renderer.draw_frame(main_bgs) - self.sdl2_calls += 1 return raise RuntimeError("No valid renderer available!") - - def get_renderer_info(self) -> dict: - info = { - 'active_renderer': 'SDL2' if self.use_sdl2 else 'None', - 'sdl2_available': SDL2_AVAILABLE, - 'sdl2_calls': getattr(self, 'sdl2_calls', 0), - } - if self.sdl2_renderer: - info.update(self.sdl2_renderer.get_performance_info()) - return info - - def toggle_renderer(self) -> bool: - print("No other renderers available to toggle to") - return False diff --git a/pysnes/video/video_sdl2.py b/pysnes/video/video_sdl2.py index 59dd64e..85d9a8c 100644 --- a/pysnes/video/video_sdl2.py +++ b/pysnes/video/video_sdl2.py @@ -2,10 +2,11 @@ SDL2-based video renderer for PySNES Replaces OpenGL with direct SDL2 2D rendering for better performance """ -import sdl2 as sdl -import numpy as np + import ctypes -from typing import Optional + +import numpy as np +import sdl2 as sdl class SDL2Renderer: @@ -14,11 +15,11 @@ class SDL2Renderer: def __init__(self, width: int = 256, height: int = 224): self.width = width self.height = height - self.renderer: Optional[sdl.SDL_Renderer] = None - self.texture: Optional[sdl.SDL_Texture] = None + self.renderer: sdl.SDL_Renderer | None = None + self.texture: sdl.SDL_Texture | None = None self.window = None - self._last_pixel_data: Optional[np.ndarray] = None - self._pixel_data: Optional[np.ndarray] = None + self._last_pixel_data: np.ndarray | None = None + self._pixel_data: np.ndarray | None = None def initialize(self, window) -> None: """Initialize SDL2 renderer from existing window""" @@ -29,24 +30,20 @@ def initialize(self, window) -> None: self.renderer = sdl.SDL_CreateRenderer( window, -1, # Use first available rendering driver - sdl.SDL_RENDERER_ACCELERATED | sdl.SDL_RENDERER_PRESENTVSYNC + sdl.SDL_RENDERER_ACCELERATED | sdl.SDL_RENDERER_PRESENTVSYNC, ) if not self.renderer: # Fallback to software renderer self.renderer = sdl.SDL_CreateRenderer( - window, - -1, - sdl.SDL_RENDERER_SOFTWARE + window, -1, sdl.SDL_RENDERER_SOFTWARE ) if not self.renderer: - raise RuntimeError(f"Failed to create SDL2 renderer: {sdl.SDL_GetError().decode()}") - - # Get renderer info for debugging (stored, not printed) - renderer_info = sdl.SDL_RendererInfo() - sdl.SDL_GetRendererInfo(self.renderer, renderer_info) - self.renderer_name = renderer_info.name.decode() if renderer_info.name else "Unknown" + raise RuntimeError( + "Failed to create SDL2 renderer: " + f"{sdl.SDL_GetError().decode()}" + ) # Create streaming texture for game screen self.texture = sdl.SDL_CreateTexture( @@ -54,23 +51,24 @@ def initialize(self, window) -> None: sdl.SDL_PIXELFORMAT_RGBA8888, # 32-bit RGBA sdl.SDL_TEXTUREACCESS_STREAMING, self.width, - self.height + self.height, ) if not self.texture: - raise RuntimeError(f"Failed to create SDL2 texture: {sdl.SDL_GetError().decode()}") + raise RuntimeError( + "Failed to create SDL2 texture: " + f"{sdl.SDL_GetError().decode()}" + ) # Set texture blend mode for proper alpha blending sdl.SDL_SetTextureBlendMode(self.texture, sdl.SDL_BLENDMODE_BLEND) # Set renderer clear color (black) sdl.SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255) - - # Disable VSync for maximum performance (can be re-enabled later) - # Note: VSync control is renderer-specific in SDL2 - except Exception as e: - raise RuntimeError(f"Failed to initialize SDL2 renderer: {e}") + raise RuntimeError( + f"Failed to initialize SDL2 renderer: {e}" + ) from e def draw_frame(self, texture_data) -> None: """Draw a frame using SDL2.""" @@ -81,7 +79,9 @@ def draw_frame(self, texture_data) -> None: # a new view object every frame. if self._pixel_data is None: n = self.width * self.height - self._pixel_data = np.frombuffer(texture_data, dtype=np.uint32, count=n).view(np.uint8) + self._pixel_data = np.frombuffer( + texture_data, dtype=np.uint32, count=n + ).view(np.uint8) pixel_data = self._pixel_data # Update texture with pixel data @@ -90,7 +90,7 @@ def draw_frame(self, texture_data) -> None: self.texture, None, # Update entire texture pixel_data.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)), - pitch + pitch, ) if result != 0: @@ -125,8 +125,14 @@ def save_screenshot(self, path: str = "screenshot.bmp") -> None: buf = np.ascontiguousarray(self._last_pixel_data) surface = sdl.SDL_CreateRGBSurfaceFrom( buf.ctypes.data_as(ctypes.c_void_p), - self.width, self.height, 32, self.width * 4, - 0xFF000000, 0x00FF0000, 0x0000FF00, 0x000000FF + self.width, + self.height, + 32, + self.width * 4, + 0xFF000000, + 0x00FF0000, + 0x0000FF00, + 0x000000FF, ) if not surface: return @@ -141,31 +147,3 @@ def cleanup(self) -> None: if self.renderer: sdl.SDL_DestroyRenderer(self.renderer) self.renderer = None - - def set_vsync(self, enabled: bool) -> None: - """Enable/disable VSync (if supported by renderer)""" - # Note: SDL2 VSync is set during renderer creation - # This would require recreating the renderer to change - pass - - def get_performance_info(self) -> dict: - """Get performance-related information""" - if not self.renderer: - return {} - - renderer_info = sdl.SDL_RendererInfo() - if sdl.SDL_GetRendererInfo(self.renderer, renderer_info) == 0: - return { - 'name': getattr(self, 'renderer_name', 'Unknown'), - 'flags': renderer_info.flags, - 'accelerated': bool(renderer_info.flags & sdl.SDL_RENDERER_ACCELERATED), - 'vsync': bool(renderer_info.flags & sdl.SDL_RENDERER_PRESENTVSYNC), - 'texture_size': f"{self.width}x{self.height}", - 'backend': 'SDL2' - } - else: - return { - 'backend': 'SDL2', - 'texture_size': f"{self.width}x{self.height}", - 'name': getattr(self, 'renderer_name', 'Unknown') - } diff --git a/scripts/bench_bus.py b/scripts/bench_bus.py index 700d280..3e1ea04 100644 --- a/scripts/bench_bus.py +++ b/scripts/bench_bus.py @@ -2,28 +2,28 @@ Bus read/write micro-benchmark. Run from the project root: - uv run --python pypy3.10 scripts/bench_bus.py # without Cython build - make build && uv run --python pypy3.10 scripts/bench_bus.py # with Cython build + uv run scripts/bench_bus.py # without Cython build + make build && uv run scripts/bench_bus.py # with Cython build Reports ns/op for each hot-path branch in Bus.__getitem__ / __setitem__. """ -import timeit import sys +import timeit from pathlib import Path # Ensure project root is on path when running as a script sys.path.insert(0, str(Path(__file__).parent.parent)) -from pysnes.scheduler import Scheduler +from types import SimpleNamespace + +from pysnes.apu import Apu from pysnes.bus import Bus +from pysnes.controller import Controller from pysnes.cpu import Cpu -from pysnes.apu import Apu from pysnes.ppu import Ppu -from pysnes.controller import Controller from pysnes.rom import HardwareVectors, InterruptVectors, MappingMode -from types import SimpleNamespace - +from pysnes.scheduler import Scheduler ROM_SIZE = 512 * 1024 @@ -33,10 +33,22 @@ def __init__(self, size=ROM_SIZE): self.rom = bytearray(size) self.snes_header = SimpleNamespace(mapping_mode=MappingMode.LOROM) self.hardware_vectors = HardwareVectors( - native=InterruptVectors(cop=0x8000, brk=0x8000, abort=0x8000, - nmi=0x8000, reset=0, irq=0x8000), - emulation=InterruptVectors(cop=0x8000, brk=0, abort=0x8000, - nmi=0x8000, reset=0x8000, irq=0x8000), + native=InterruptVectors( + cop=0x8000, + brk=0x8000, + abort=0x8000, + nmi=0x8000, + reset=0, + irq=0x8000, + ), + emulation=InterruptVectors( + cop=0x8000, + brk=0, + abort=0x8000, + nmi=0x8000, + reset=0x8000, + irq=0x8000, + ), ) def __getitem__(self, addr): @@ -62,29 +74,31 @@ def make_bus(): REPEAT = 3 CASES = [ - # (label, setup, stmt) - # --- via __getitem__/__setitem__ (Python slot dispatch → thin wrapper → read/write) --- - ("low_ram_read []", "", "bus.read(0x000100)"), - ("low_ram_write []", "", "bus.__setitem__(0x000100, 0xAB)"), - ("rom_read []", "", "bus.read(0x008010)"), - ("high_ram_read []", "", "bus.read(0x7E3000)"), - ("extended_ram_read []", "", "bus.read(0x7E8000)"), - ("rdnmi_read []", "", "bus.read(0x004210)"), - ("hvbjoy_read []", "", "bus.read(0x004212)"), - ("apu_port_read []", "", "bus.read(0x002140)"), - # --- via bus.read()/bus.write() (direct Python call into cfunc; inlined when called from Cython) --- - ("low_ram_read .read", "", "bus.read(0x000100)"), - ("low_ram_write .write", "", "bus.write(0x000100, 0xAB)"), - ("rom_read .read", "", "bus.read(0x008010)"), - ("high_ram_read .read", "", "bus.read(0x7E3000)"), - ("extended_ram .read", "", "bus.read(0x7E8000)"), - ("rdnmi_read .read", "", "bus.read(0x004210)"), - ("hvbjoy_read .read", "", "bus.read(0x004212)"), - ("apu_port_read .read", "", "bus.read(0x002140)"), + # (label, setup, stmt) --- via __getitem__/__setitem__ (Python slot dispatch + # → thin wrapper → read/write) --- + ("low_ram_read []", "", "bus.read(0x000100)"), + ("low_ram_write []", "", "bus.__setitem__(0x000100, 0xAB)"), + ("rom_read []", "", "bus.read(0x008010)"), + ("high_ram_read []", "", "bus.read(0x7E3000)"), + ("extended_ram_read []", "", "bus.read(0x7E8000)"), + ("rdnmi_read []", "", "bus.read(0x004210)"), + ("hvbjoy_read []", "", "bus.read(0x004212)"), + ("apu_port_read []", "", "bus.read(0x002140)"), + # --- via bus.read()/bus.write() (direct Python call into cfunc; inlined + # when called from Cython) --- + ("low_ram_read .read", "", "bus.read(0x000100)"), + ("low_ram_write .write", "", "bus.write(0x000100, 0xAB)"), + ("rom_read .read", "", "bus.read(0x008010)"), + ("high_ram_read .read", "", "bus.read(0x7E3000)"), + ("extended_ram .read", "", "bus.read(0x7E8000)"), + ("rdnmi_read .read", "", "bus.read(0x004210)"), + ("hvbjoy_read .read", "", "bus.read(0x004212)"), + ("apu_port_read .read", "", "bus.read(0x002140)"), ] COL_W = 22 + def run(): bus = make_bus() # Pre-warm diff --git a/scripts/compare_spc_wav.py b/scripts/compare_spc_wav.py index ce76597..1b11e28 100644 --- a/scripts/compare_spc_wav.py +++ b/scripts/compare_spc_wav.py @@ -2,27 +2,27 @@ Compare PySNES SPC audio output against a reference WAV (e.g. from Mesen). Usage: - uv run python scripts/compare_spc_wav.py [--seconds N] + uv run python scripts/compare_spc_wav.py \ + [--seconds N] Dumps our SPC player's output to a temp WAV at 32 kHz, resamples the reference to the same rate, then prints per-second RMS error and writes a difference WAV for further inspection. """ + import argparse +import pathlib import sys import wave -import pathlib -import struct -import tempfile import numpy as np sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) -from pysnes.apu.spc_file import SpcFile from pysnes.apu.apu import Apu +from pysnes.apu.spc_file import SpcFile -_APU_HZ = 1_024_000 +_APU_HZ = 1_024_000 _DSP_DIV = 32 _AUDIO_HZ = _APU_HZ // _DSP_DIV # 32 000 Hz @@ -40,7 +40,10 @@ def dump_pysnes(spc_path: str, duration_s: float) -> np.ndarray: chunks = [] collected = 0 - print(f"Dumping PySNES: {duration_s:.1f}s ({total_samples} samples @ {_AUDIO_HZ} Hz)...") + print( + f"Dumping PySNES: {duration_s:.1f}s ({total_samples} samples @ " + f"{_AUDIO_HZ} Hz)..." + ) while collected < total_samples: clocks_left = apu_per_chunk while clocks_left > 0: @@ -56,7 +59,9 @@ def dump_pysnes(spc_path: str, duration_s: float) -> np.ndarray: return all_samples.astype(np.int16) -def load_reference(wav_path: str, duration_s: float, target_rate: int) -> np.ndarray: +def load_reference( + wav_path: str, duration_s: float, target_rate: int +) -> np.ndarray: """Load reference WAV, trim to duration, resample to target_rate.""" with wave.open(wav_path) as w: src_rate = w.getframerate() @@ -66,10 +71,17 @@ def load_reference(wav_path: str, duration_s: float, target_rate: int) -> np.nda want_frames = min(n_frames, int(duration_s * src_rate)) raw = w.readframes(want_frames) - print(f"Reference: {wav_path} — {src_rate} Hz, {n_channels}ch, {n_frames} frames ({n_frames/src_rate:.1f}s)") + print( + f"Reference: {wav_path} — {src_rate} Hz, {n_channels}ch, {n_frames} " + f"frames ({n_frames / src_rate:.1f}s)" + ) dtype = np.int16 if sampwidth == 2 else np.int32 - data = np.frombuffer(raw, dtype=dtype).reshape(-1, n_channels).astype(np.float32) + data = ( + np.frombuffer(raw, dtype=dtype) + .reshape(-1, n_channels) + .astype(np.float32) + ) if n_channels == 1: data = np.column_stack([data, data]) @@ -80,7 +92,7 @@ def load_reference(wav_path: str, duration_s: float, target_rate: int) -> np.nda n_out = int(n_in * target_rate / src_rate) t_in = np.arange(n_in, dtype=np.float32) t_out = np.linspace(0, n_in - 1, n_out, dtype=np.float32) - left = np.interp(t_out, t_in, data[:, 0]) + left = np.interp(t_out, t_in, data[:, 0]) right = np.interp(t_out, t_in, data[:, 1]) data = np.column_stack([left, right]) print(f" resampled {src_rate} → {target_rate} Hz: {n_out} frames") @@ -99,17 +111,17 @@ def write_wav(path: str, samples: np.ndarray, rate: int) -> None: def compare(ours: np.ndarray, ref: np.ndarray, rate: int) -> None: n = min(len(ours), len(ref)) ours = ours[:n].astype(np.float32) - ref = ref[:n].astype(np.float32) + ref = ref[:n].astype(np.float32) diff = ours - ref - print(f"\n=== Comparison ({n} samples = {n/rate:.2f}s) ===") + print(f"\n=== Comparison ({n} samples = {n / rate:.2f}s) ===") print(f"Our RMS: {np.sqrt(np.mean(ours**2)):.1f}") print(f"Ref RMS: {np.sqrt(np.mean(ref**2)):.1f}") print(f"Diff RMS: {np.sqrt(np.mean(diff**2)):.1f}") print(f"Max abs diff: {np.max(np.abs(diff)):.0f}") # Per-second breakdown - print(f"\nPer-second RMS diff (left channel):") + print("\nPer-second RMS diff (left channel):") for sec in range(int(n / rate)): sl = slice(sec * rate, (sec + 1) * rate) rms = np.sqrt(np.mean(diff[sl, 0] ** 2)) @@ -121,12 +133,18 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("spc", help="SPC file path") parser.add_argument("wav", help="Reference WAV path") - parser.add_argument("--seconds", type=float, default=10.0, help="Duration to compare") - parser.add_argument("--dump-ours", metavar="OUT.wav", help="Write our output to this WAV file") + parser.add_argument( + "--seconds", type=float, default=10.0, help="Duration to compare" + ) + parser.add_argument( + "--dump-ours", + metavar="OUT.wav", + help="Write our output to this WAV file", + ) args = parser.parse_args() ours = dump_pysnes(args.spc, args.seconds) - ref = load_reference(args.wav, args.seconds, _AUDIO_HZ) + ref = load_reference(args.wav, args.seconds, _AUDIO_HZ) if args.dump_ours: write_wav(args.dump_ours, ours, _AUDIO_HZ) diff --git a/scripts/debug_frame.py b/scripts/debug_frame.py index 4909a14..ed96db2 100644 --- a/scripts/debug_frame.py +++ b/scripts/debug_frame.py @@ -1,12 +1,12 @@ -from pysnes.scheduler import Scheduler -from pysnes.rom import Rom +from pysnes.apu import Apu from pysnes.bus import Bus +from pysnes.controller import Controller from pysnes.cpu import Cpu -from pysnes.apu import Apu from pysnes.ppu import Ppu -from pysnes.controller import Controller +from pysnes.rom import Rom +from pysnes.scheduler import Scheduler -rom = Rom('roms/SNES Test Program.sfc') +rom = Rom("roms/SNES Test Program.sfc") scheduler = Scheduler() apu = Apu() cpu = Cpu(rom.hardware_vectors) @@ -25,39 +25,64 @@ # Monkey-patch bus to log APU port accesses _orig_get = bus.__class__.__getitem__ + + def _patched_get(self, abs_addr): val = _orig_get(self, abs_addr) addr = abs_addr & 0xFFFF if 0x2140 <= addr <= 0x2143: - port_log.append(f'R ${addr:04X}={hex(val)} ports_w[{addr-0x2140}] CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} MC={scheduler.master_clock}') + port_log.append( + f"R ${addr:04X}={hex(val)} ports_w[{addr - 0x2140}] " + f"CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} " + f"MC={scheduler.master_clock}" + ) return val + + _orig_set = bus.__class__.__setitem__ + + def _patched_set(self, abs_addr, data): addr = abs_addr & 0xFFFF if 0x2140 <= addr <= 0x2143: - port_log.append(f'W ${addr:04X}={hex(data)} CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} MC={scheduler.master_clock}') + port_log.append( + f"W ${addr:04X}={hex(data)} " + f"CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} " + f"MC={scheduler.master_clock}" + ) _orig_set(self, abs_addr, data) + bus.__class__.__getitem__ = _patched_get bus.__class__.__setitem__ = _patched_set # Run until CPU gets stuck (or 30 frames max) -for frame in range(30): +for _frame in range(30): scheduler.run_to(scheduler.master_clock + MC_PER_FRAME) # Find where the stuck state (A.l=0x0 but port!=0x0) first occurs -print(f'Total APU port accesses: {len(port_log)}') +print(f"Total APU port accesses: {len(port_log)}") stuck = len(port_log) for i, entry in enumerate(port_log): - if 'R $2140=' in entry and 'A.l=0x0 ' in entry and 'R $2140=0x0 ' not in entry: + if ( + "R $2140=" in entry + and "A.l=0x0 " in entry + and "R $2140=0x0 " not in entry + ): stuck = i break -print(f'First stuck read at entry {stuck}') -print(f'\nEntries {max(0,stuck-10)} to {min(len(port_log), stuck+5)}:') -for entry in port_log[max(0,stuck-10):min(len(port_log), stuck+5)]: +print(f"First stuck read at entry {stuck}") +print(f"\nEntries {max(0, stuck - 10)} to {min(len(port_log), stuck + 5)}:") +for entry in port_log[max(0, stuck - 10) : min(len(port_log), stuck + 5)]: print(entry) print() -print(f'CPU PC={hex(cpu.PC.value)} A={hex(cpu.A.value)} A.l={hex(cpu.A.l)} P={hex(cpu.P):4s}') -print(f'apu.ports_r={list(apu.ports_r)} apu.ports_w={list(apu.ports_w)} apu.PC={hex(apu.PC)}') +print( + f"CPU PC={hex(cpu.PC.value)} A={hex(cpu.A.value)} A.l={hex(cpu.A.l)} " + f"P={hex(cpu.P):4s}" +) +print( + f"apu.ports_r={list(apu.ports_r)} apu.ports_w={list(apu.ports_w)} " + f"apu.PC={hex(apu.PC)}" +) diff --git a/scripts/dump_mesen_layers.py b/scripts/dump_mesen_layers.py index 5c0d440..fd21c9a 100644 --- a/scripts/dump_mesen_layers.py +++ b/scripts/dump_mesen_layers.py @@ -18,7 +18,9 @@ mesen_layer_obj.png TM = OBJ only mesen_layer_sub_bg2.png TM=0, TS=BG2 only """ + import argparse +import contextlib import json import os import struct @@ -35,12 +37,12 @@ # name, tm_mask (or None = no override), ts_mask (or None) CONFIGS = [ - ("all", None, None), - ("bg1", 0x01, 0x00), - ("bg2", 0x02, 0x00), - ("bg3", 0x04, 0x00), - ("bg4", 0x08, 0x00), - ("obj", 0x10, 0x00), + ("all", None, None), + ("bg1", 0x01, 0x00), + ("bg2", 0x02, 0x00), + ("bg3", 0x04, 0x00), + ("bg4", 0x08, 0x00), + ("obj", 0x10, 0x00), ("sub_bg2", 0x00, 0x02), ] @@ -48,14 +50,17 @@ def mesen_bin() -> str: candidates = [ os.environ.get("MESEN_BIN"), - str(REPO_ROOT / "submodules/Mesen2/bin/linux-x64/Release/linux-x64/publish/Mesen"), + str( + REPO_ROOT + / "submodules/Mesen2/bin/linux-x64/Release/linux-x64/publish/Mesen" + ), ] cfg_path = REPO_ROOT / "settings.json" if cfg_path.exists(): - try: - candidates.insert(1, json.loads(cfg_path.read_text()).get("mesen_bin")) - except Exception: - pass + with contextlib.suppress(Exception): + candidates.insert( + 1, json.loads(cfg_path.read_text()).get("mesen_bin") + ) for c in candidates: if c and Path(c).is_file() and os.access(c, os.X_OK): return c @@ -64,8 +69,9 @@ def mesen_bin() -> str: ) -def run_mesen(mesen: str, rom: Path, out_bin: Path, frame: int, - tm_mask, ts_mask) -> None: +def run_mesen( + mesen: str, rom: Path, out_bin: Path, frame: int, tm_mask, ts_mask +) -> None: lua_script = REPO_ROOT / "scripts/mesen_layers.lua" env = os.environ.copy() env["MESEN_FRAMES"] = str(frame) @@ -97,7 +103,9 @@ def bin_to_png(bin_path: Path, png_path: Path) -> None: from PIL import Image raw = bin_path.read_bytes() - pixels_u32 = struct.unpack(f"<{SCREEN_W * MESEN_BUF_H}I", raw[:EXPECTED_SIZE]) + pixels_u32 = struct.unpack( + f"<{SCREEN_W * MESEN_BUF_H}I", raw[:EXPECTED_SIZE] + ) img = Image.new("RGB", (SCREEN_W, VISIBLE_H)) out = img.load() for row in range(VISIBLE_H): diff --git a/scripts/pysnes_dump_vram.py b/scripts/pysnes_dump_vram.py index 48e4278..156c2ae 100644 --- a/scripts/pysnes_dump_vram.py +++ b/scripts/pysnes_dump_vram.py @@ -8,6 +8,7 @@ .vram 64KB of VRAM as-is .json selected PPU state (bg scroll, tilemap bases, screen enable) """ + import argparse import json import os @@ -24,11 +25,16 @@ def main() -> int: ap.add_argument("rom") ap.add_argument("--frames", type=int, default=560) ap.add_argument("--out", default="/tmp/mesen_layers/pysnes_state") - ap.add_argument("--force-tmw", type=lambda s: int(s, 0), default=None, - help="Force TMW to this value before last frame's render (diagnostic)") + ap.add_argument( + "--force-tmw", + type=lambda s: int(s, 0), + default=None, + help="Force TMW to this value before last frame's render (diagnostic)", + ) args = ap.parse_args() from pysnes.pysnes import PySNES + pysnes = PySNES(args.rom, settings={"headless": True}) pysnes.cpu.start(pysnes.scheduler) pysnes.ppu.start() @@ -42,31 +48,64 @@ def main() -> int: ppu = pysnes.ppu state = { "bgmode": getattr(ppu, "bgmode", None), - "tm": ppu.bg1.main_screen_enable * 1 | ppu.bg2.main_screen_enable * 2 - | ppu.bg3.main_screen_enable * 4 | ppu.bg4.main_screen_enable * 8, - "ts": ppu.bg1.sub_screen_enable * 1 | ppu.bg2.sub_screen_enable * 2 - | ppu.bg3.sub_screen_enable * 4 | ppu.bg4.sub_screen_enable * 8, - "tmw": ppu.tmw, "tsw": ppu.tsw, - "w12sel": ppu.w12sel, "w34sel": ppu.w34sel, "wobjsel": ppu.wobjsel, - "wh0": ppu.wh0, "wh1": ppu.wh1, "wh2": ppu.wh2, "wh3": ppu.wh3, - "cgwsel": ppu.cgwsel, "cgadsub": ppu.cgadsub, - "coldata_r": ppu.coldata_r, "coldata_g": ppu.coldata_g, "coldata_b": ppu.coldata_b, - "bg1": dict(screen_addr=ppu.bg1.screen_addr, tiledata_addr=ppu.bg1.tiledata_addr, - hoffset=ppu.bg1.hoffset, voffset=ppu.bg1.voffset, - screen_size=getattr(ppu.bg1, "screen_size", None), - main=ppu.bg1.main_screen_enable, sub=ppu.bg1.sub_screen_enable), - "bg2": dict(screen_addr=ppu.bg2.screen_addr, tiledata_addr=ppu.bg2.tiledata_addr, - hoffset=ppu.bg2.hoffset, voffset=ppu.bg2.voffset, - screen_size=getattr(ppu.bg2, "screen_size", None), - main=ppu.bg2.main_screen_enable, sub=ppu.bg2.sub_screen_enable), - "bg3": dict(screen_addr=ppu.bg3.screen_addr, tiledata_addr=ppu.bg3.tiledata_addr, - hoffset=ppu.bg3.hoffset, voffset=ppu.bg3.voffset, - screen_size=getattr(ppu.bg3, "screen_size", None), - main=ppu.bg3.main_screen_enable, sub=ppu.bg3.sub_screen_enable), - "bg4": dict(screen_addr=ppu.bg4.screen_addr, tiledata_addr=ppu.bg4.tiledata_addr, - hoffset=ppu.bg4.hoffset, voffset=ppu.bg4.voffset, - screen_size=getattr(ppu.bg4, "screen_size", None), - main=ppu.bg4.main_screen_enable, sub=ppu.bg4.sub_screen_enable), + "tm": ppu.bg1.main_screen_enable * 1 + | ppu.bg2.main_screen_enable * 2 + | ppu.bg3.main_screen_enable * 4 + | ppu.bg4.main_screen_enable * 8, + "ts": ppu.bg1.sub_screen_enable * 1 + | ppu.bg2.sub_screen_enable * 2 + | ppu.bg3.sub_screen_enable * 4 + | ppu.bg4.sub_screen_enable * 8, + "tmw": ppu.tmw, + "tsw": ppu.tsw, + "w12sel": ppu.w12sel, + "w34sel": ppu.w34sel, + "wobjsel": ppu.wobjsel, + "wh0": ppu.wh0, + "wh1": ppu.wh1, + "wh2": ppu.wh2, + "wh3": ppu.wh3, + "cgwsel": ppu.cgwsel, + "cgadsub": ppu.cgadsub, + "coldata_r": ppu.coldata_r, + "coldata_g": ppu.coldata_g, + "coldata_b": ppu.coldata_b, + "bg1": { + "screen_addr": ppu.bg1.screen_addr, + "tiledata_addr": ppu.bg1.tiledata_addr, + "hoffset": ppu.bg1.hoffset, + "voffset": ppu.bg1.voffset, + "screen_size": getattr(ppu.bg1, "screen_size", None), + "main": ppu.bg1.main_screen_enable, + "sub": ppu.bg1.sub_screen_enable, + }, + "bg2": { + "screen_addr": ppu.bg2.screen_addr, + "tiledata_addr": ppu.bg2.tiledata_addr, + "hoffset": ppu.bg2.hoffset, + "voffset": ppu.bg2.voffset, + "screen_size": getattr(ppu.bg2, "screen_size", None), + "main": ppu.bg2.main_screen_enable, + "sub": ppu.bg2.sub_screen_enable, + }, + "bg3": { + "screen_addr": ppu.bg3.screen_addr, + "tiledata_addr": ppu.bg3.tiledata_addr, + "hoffset": ppu.bg3.hoffset, + "voffset": ppu.bg3.voffset, + "screen_size": getattr(ppu.bg3, "screen_size", None), + "main": ppu.bg3.main_screen_enable, + "sub": ppu.bg3.sub_screen_enable, + }, + "bg4": { + "screen_addr": ppu.bg4.screen_addr, + "tiledata_addr": ppu.bg4.tiledata_addr, + "hoffset": ppu.bg4.hoffset, + "voffset": ppu.bg4.voffset, + "screen_size": getattr(ppu.bg4, "screen_size", None), + "main": ppu.bg4.main_screen_enable, + "sub": ppu.bg4.sub_screen_enable, + }, } base = Path(args.out) @@ -82,13 +121,17 @@ def main() -> int: # order inside u32 as written by PPU: (r<<24)|(g<<16)|(b<<8)|a). # Write as little-endian u32 so a consumer can `np.frombuffer(..., '