From 2dbc527faf6f321eb1a839974ee5796429b7114f Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Fri, 7 Aug 2026 18:37:44 -0300 Subject: [PATCH 1/9] chore: add ruff linter, make targets and lint CI Configure ruff (lint + format) at 80 columns, targeting py310. The ruleset mirrors the one used in pagueia, minus TID: pysnes is a single package and relative imports between its modules are the house style. CI runs ruff via uvx, so the lint job skips the project install (PyPy 3.10 + SDL2 headers + Cython) entirely. Refs #34 --- .github/workflows/lint.yml | 20 ++++++++++++++++++++ Makefile | 12 +++++++++++- pyproject.toml | 26 ++++++++++++++++++++++++++ uv.lock | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5d6f4c2 --- /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.15.20 check --output-format=github . + - name: ruff format --check + run: uvx ruff@0.15.20 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..09b8232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,29 @@ dependencies = [ [project.scripts] pysnes = "pysnes.pysnes:main" + +[dependency-groups] +dev = [ + "ruff>=0.15.20", +] + +[tool.ruff] +line-length = 80 +target-version = "py310" +# Third-party checkouts (Mesen2, the SingleStepTests suites). +extend-exclude = ["submodules"] + +[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", +] + +[tool.ruff.lint.per-file-ignores] +# Package __init__ files exist to re-export their module's public names. +"**/__init__.py" = ["F401", "F403"] diff --git a/uv.lock b/uv.lock index db39ed9..0764c68 100644 --- a/uv.lock +++ b/uv.lock @@ -262,6 +262,11 @@ dependencies = [ { name = "vmprof" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "cython", specifier = "==3.1.1" }, @@ -279,6 +284,9 @@ requires-dist = [ { name = "vmprof", specifier = ">=0.4.18.1" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.20" }] + [[package]] name = "pytest" version = "8.4.1" @@ -360,6 +368,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" From 9e0b833cc34926143375cdcbedff4fc5e921a5dd Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Fri, 7 Aug 2026 18:38:36 -0300 Subject: [PATCH 2/9] style: apply ruff format across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical reformat only — no behaviour change. Verified by dumping the AST of all 91 modules before and after: the dumps are byte-identical. Refs #34 --- pysnes/_ss_cache.py | 4 +- pysnes/apu/apu.py | 122 ++- pysnes/apu/dsp.py | 790 +++++++++++++++--- pysnes/apu/spc700/addressing_modes.py | 51 +- pysnes/apu/spc700/disassembler.py | 145 +++- pysnes/apu/spc700/instructions_spc700.py | 340 ++++---- pysnes/apu/spc700/opcodes_spc700.py | 8 +- pysnes/apu/spc_file.py | 35 +- pysnes/apu/test_apu.py | 8 +- pysnes/apu/test_dsp.py | 115 ++- pysnes/apu/test_spc700.py | 33 +- pysnes/apu/test_timers.py | 7 + pysnes/audio/audio_sdl2.py | 18 +- pysnes/bus/bus.py | 167 +++- pysnes/bus/test_bus.py | 101 ++- pysnes/bus/test_interrupts.py | 41 +- pysnes/cpu/cpu.py | 60 +- pysnes/cpu/dma.py | 109 ++- pysnes/cpu/test_cpu.py | 43 +- pysnes/cpu/test_dma.py | 90 +- .../cpu/wdc65816/addressing_modes/__init__.py | 1 + .../wdc65816/addressing_modes/decorator.py | 3 + pysnes/cpu/wdc65816/addressing_modes/other.py | 10 +- pysnes/cpu/wdc65816/addressing_modes/pc.py | 18 +- pysnes/cpu/wdc65816/disassembler.py | 52 +- pysnes/cpu/wdc65816/instructions.py | 514 ++++++------ pysnes/cpu/wdc65816/opcodes.py | 154 ++-- pysnes/debugger/debugger.py | 59 +- pysnes/debugger/test_debugger.py | 21 +- pysnes/debugger/window.py | 114 ++- pysnes/harness/__init__.py | 7 +- pysnes/harness/_png.py | 12 +- pysnes/harness/cli.py | 38 +- pysnes/harness/test_harness.py | 2 + pysnes/ppu/bg_renderer.py | 198 +++-- pysnes/ppu/color_math.py | 24 +- pysnes/ppu/constants.py | 2 +- pysnes/ppu/data_structures.py | 12 +- pysnes/ppu/obj_renderer.py | 40 +- pysnes/ppu/ppu.py | 272 ++++-- pysnes/ppu/test_ppu.py | 43 +- pysnes/ppu/test_ppu_bg_window.py | 62 +- pysnes/ppu/test_ppu_bgmode.py | 103 ++- pysnes/ppu/test_ppu_color_math.py | 73 +- pysnes/ppu/test_ppu_color_math_window.py | 4 +- pysnes/ppu/test_ppu_forced_blank.py | 3 +- pysnes/ppu/test_ppu_mosaic.py | 9 +- pysnes/ppu/test_ppu_registers.py | 21 +- pysnes/ppu/test_ppu_scroll.py | 172 ++-- pysnes/ppu/test_ppu_sprites.py | 44 +- pysnes/pysnes.py | 201 ++++- pysnes/rom/rom.py | 47 +- pysnes/rom/test_rom.py | 90 +- pysnes/savestate/__init__.py | 9 +- pysnes/savestate/test_savestate.py | 3 +- pysnes/scheduler/scheduler.py | 2 - pysnes/scheduler/test_scheduler.py | 20 +- pysnes/settings.py | 1 + pysnes/spc_player.py | 18 +- pysnes/test_integration.py | 109 ++- pysnes/video/video.py | 24 +- pysnes/video/video_sdl2.py | 59 +- scripts/bench_bus.py | 53 +- scripts/compare_spc_wav.py | 39 +- scripts/debug_frame.py | 39 +- scripts/dump_mesen_layers.py | 31 +- scripts/pysnes_dump_vram.py | 99 ++- scripts/run_pysnes.py | 14 +- scripts/scrapper.py | 4 +- scripts/sfc2sfrom.py | 44 +- setup.py | 21 +- 71 files changed, 3603 insertions(+), 1698 deletions(-) diff --git a/pysnes/_ss_cache.py b/pysnes/_ss_cache.py index 143e964..550e3dd 100644 --- a/pysnes/_ss_cache.py +++ b/pysnes/_ss_cache.py @@ -31,7 +31,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] diff --git a/pysnes/apu/apu.py b/pysnes/apu/apu.py index 6021dae..f9d8dc7 100644 --- a/pysnes/apu/apu.py +++ b/pysnes/apu/apu.py @@ -15,11 +15,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 +41,6 @@ def call(self): class Timer: - def __init__(self, apu: "Apu", frequency: int) -> None: self.apu = apu self.frequency = frequency @@ -53,8 +52,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: @@ -134,10 +138,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,7 +150,7 @@ 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)] @@ -191,7 +195,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 +212,9 @@ def allocate_memory(self): )) # fmt: on - self.page_0 = bytearray(0x0100) # 0x00–0xFF; upper 16 bytes used by _io_flat mode + self.page_0 = bytearray( + 0x0100 + ) # 0x00–0xFF; upper 16 bytes used by _io_flat mode self.page_1 = bytearray(0x0100) # The IO Port0-4 registers have separete memory for R/W @@ -216,14 +222,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,7 +265,7 @@ 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"] @@ -256,7 +282,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) @@ -302,12 +330,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): @@ -409,7 +437,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 +478,16 @@ 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 ( + "{:<24} A:{:02X} X:{:02X} Y:{:02X} S:{:02X} PSW:{:02X} {}".format( + disasm, + self.A, + self.X, + self.Y, + self.S, + self.PSW, + flags, + ) ) def fetch_and_execute(self): @@ -464,10 +499,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( + "\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), + ) + ) self.instructions[opcode].call() # Approximate master-clock-to-APU-clock ratio (integer division) @@ -523,7 +564,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..7f778bc 100644 --- a/pysnes/apu/dsp.py +++ b/pysnes/apu/dsp.py @@ -7,49 +7,555 @@ # gauss[255-off], gauss[511-off], gauss[256+off], gauss[off] # are the 4-tap weights for interpolation offset 0..255. _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, - 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, - 6, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10, - 11, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 15, 16, 16, 17, 17, - 18, 19, 19, 20, 20, 21, 21, 22, 23, 23, 24, 24, 25, 26, 27, 27, - 28, 29, 29, 30, 31, 32, 32, 33, 34, 35, 36, 36, 37, 38, 39, 40, - 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, - 58, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 73, 74, 76, 77, - 78, 80, 81, 83, 84, 86, 87, 89, 90, 92, 94, 95, 97, 99, 100, 102, - 104, 106, 107, 109, 111, 113, 115, 117, 118, 120, 122, 124, 126, 128, 130, 132, - 134, 137, 139, 141, 143, 145, 147, 150, 152, 154, 156, 159, 161, 163, 166, 168, - 171, 173, 175, 178, 180, 183, 186, 188, 191, 193, 196, 199, 201, 204, 207, 210, - 212, 215, 218, 221, 224, 227, 230, 233, 236, 239, 242, 245, 248, 251, 254, 257, - 260, 263, 267, 270, 273, 276, 280, 283, 286, 290, 293, 297, 300, 304, 307, 311, - 314, 318, 321, 325, 328, 332, 336, 339, 343, 347, 351, 354, 358, 362, 366, 370, - 374, 378, 381, 385, 389, 393, 397, 401, 405, 410, 414, 418, 422, 426, 430, 434, - 439, 443, 447, 451, 456, 460, 464, 469, 473, 477, 482, 486, 491, 495, 499, 504, - 508, 513, 517, 522, 527, 531, 536, 540, 545, 550, 554, 559, 563, 568, 573, 577, - 582, 587, 592, 596, 601, 606, 611, 615, 620, 625, 630, 635, 640, 644, 649, 654, - 659, 664, 669, 674, 678, 683, 688, 693, 698, 703, 708, 713, 718, 723, 728, 732, - 737, 742, 747, 752, 757, 762, 767, 772, 777, 782, 787, 792, 797, 802, 806, 811, - 816, 821, 826, 831, 836, 841, 846, 851, 855, 860, 865, 870, 875, 880, 884, 889, - 894, 899, 904, 908, 913, 918, 923, 927, 932, 937, 941, 946, 951, 955, 960, 965, - 969, 974, 978, 983, 988, 992, 997,1001,1005,1010,1014,1019,1023,1027,1032,1036, - 1040,1045,1049,1053,1057,1061,1066,1070,1074,1078,1082,1086,1090,1094,1098,1102, - 1106,1109,1113,1117,1121,1125,1128,1132,1136,1139,1143,1146,1150,1153,1157,1160, - 1164,1167,1170,1174,1177,1180,1183,1186,1190,1193,1196,1199,1202,1205,1207,1210, - 1213,1216,1219,1221,1224,1227,1229,1232,1234,1237,1239,1241,1244,1246,1248,1251, - 1253,1255,1257,1259,1261,1263,1265,1267,1269,1270,1272,1274,1275,1277,1279,1280, - 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, + 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, + 2, + 2, + 3, + 3, + 3, + 3, + 3, + 4, + 4, + 4, + 4, + 4, + 5, + 5, + 5, + 5, + 6, + 6, + 6, + 6, + 7, + 7, + 7, + 8, + 8, + 8, + 9, + 9, + 9, + 10, + 10, + 10, + 11, + 11, + 11, + 12, + 12, + 13, + 13, + 14, + 14, + 15, + 15, + 15, + 16, + 16, + 17, + 17, + 18, + 19, + 19, + 20, + 20, + 21, + 21, + 22, + 23, + 23, + 24, + 24, + 25, + 26, + 27, + 27, + 28, + 29, + 29, + 30, + 31, + 32, + 32, + 33, + 34, + 35, + 36, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 58, + 59, + 60, + 61, + 62, + 64, + 65, + 66, + 67, + 69, + 70, + 71, + 73, + 74, + 76, + 77, + 78, + 80, + 81, + 83, + 84, + 86, + 87, + 89, + 90, + 92, + 94, + 95, + 97, + 99, + 100, + 102, + 104, + 106, + 107, + 109, + 111, + 113, + 115, + 117, + 118, + 120, + 122, + 124, + 126, + 128, + 130, + 132, + 134, + 137, + 139, + 141, + 143, + 145, + 147, + 150, + 152, + 154, + 156, + 159, + 161, + 163, + 166, + 168, + 171, + 173, + 175, + 178, + 180, + 183, + 186, + 188, + 191, + 193, + 196, + 199, + 201, + 204, + 207, + 210, + 212, + 215, + 218, + 221, + 224, + 227, + 230, + 233, + 236, + 239, + 242, + 245, + 248, + 251, + 254, + 257, + 260, + 263, + 267, + 270, + 273, + 276, + 280, + 283, + 286, + 290, + 293, + 297, + 300, + 304, + 307, + 311, + 314, + 318, + 321, + 325, + 328, + 332, + 336, + 339, + 343, + 347, + 351, + 354, + 358, + 362, + 366, + 370, + 374, + 378, + 381, + 385, + 389, + 393, + 397, + 401, + 405, + 410, + 414, + 418, + 422, + 426, + 430, + 434, + 439, + 443, + 447, + 451, + 456, + 460, + 464, + 469, + 473, + 477, + 482, + 486, + 491, + 495, + 499, + 504, + 508, + 513, + 517, + 522, + 527, + 531, + 536, + 540, + 545, + 550, + 554, + 559, + 563, + 568, + 573, + 577, + 582, + 587, + 592, + 596, + 601, + 606, + 611, + 615, + 620, + 625, + 630, + 635, + 640, + 644, + 649, + 654, + 659, + 664, + 669, + 674, + 678, + 683, + 688, + 693, + 698, + 703, + 708, + 713, + 718, + 723, + 728, + 732, + 737, + 742, + 747, + 752, + 757, + 762, + 767, + 772, + 777, + 782, + 787, + 792, + 797, + 802, + 806, + 811, + 816, + 821, + 826, + 831, + 836, + 841, + 846, + 851, + 855, + 860, + 865, + 870, + 875, + 880, + 884, + 889, + 894, + 899, + 904, + 908, + 913, + 918, + 923, + 927, + 932, + 937, + 941, + 946, + 951, + 955, + 960, + 965, + 969, + 974, + 978, + 983, + 988, + 992, + 997, + 1001, + 1005, + 1010, + 1014, + 1019, + 1023, + 1027, + 1032, + 1036, + 1040, + 1045, + 1049, + 1053, + 1057, + 1061, + 1066, + 1070, + 1074, + 1078, + 1082, + 1086, + 1090, + 1094, + 1098, + 1102, + 1106, + 1109, + 1113, + 1117, + 1121, + 1125, + 1128, + 1132, + 1136, + 1139, + 1143, + 1146, + 1150, + 1153, + 1157, + 1160, + 1164, + 1167, + 1170, + 1174, + 1177, + 1180, + 1183, + 1186, + 1190, + 1193, + 1196, + 1199, + 1202, + 1205, + 1207, + 1210, + 1213, + 1216, + 1219, + 1221, + 1224, + 1227, + 1229, + 1232, + 1234, + 1237, + 1239, + 1241, + 1244, + 1246, + 1248, + 1251, + 1253, + 1255, + 1257, + 1259, + 1261, + 1263, + 1265, + 1267, + 1269, + 1270, + 1272, + 1274, + 1275, + 1277, + 1279, + 1280, + 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, ) # 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. _RATE_TABLE = [ 0, - 2048, 1536, 1280, 1024, 768, 640, 512, - 384, 320, 256, 192, 160, 128, 96, - 80, 64, 48, 40, 32, 24, 20, - 16, 12, 10, 8, 6, 5, 4, - 3, 2, 1, + 2048, + 1536, + 1280, + 1024, + 768, + 640, + 512, + 384, + 320, + 256, + 192, + 160, + 128, + 96, + 80, + 64, + 48, + 40, + 32, + 24, + 20, + 16, + 12, + 10, + 8, + 6, + 5, + 4, + 3, + 2, + 1, ] @@ -60,27 +566,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 +608,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 +620,17 @@ 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 + elif ( + addr == 0x5C + ): # KOFF: immediately request key-off on matching voices 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 + elif ( + addr == 0x6C + ): # FLG: bit5 = soft reset → silence all voices immediately if value & 0x20: for v in self.voices: v.active = False @@ -130,9 +643,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 + elif addr == 0x7C: # ENDX: read and clear val = self.regs[0x7C] self.regs[0x7C] = 0 return val @@ -145,7 +658,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 +687,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 +733,26 @@ 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) 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 +791,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 +799,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 @@ -339,7 +862,9 @@ def _init_echo(self) -> None: 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) + buf_len = ( + max(edl, 1) * 0x800 // 4 + ) # stereo samples (4 bytes each in RAM) 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 @@ -360,8 +885,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)] @@ -374,9 +899,9 @@ def generate_samples(self, n_samples: int): # 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) + 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 +911,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 + 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] + 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 + 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) + adsr_on = bool(adsr1 & 0x80) gain_dir = (not adsr_on) and (not (gain_reg & 0x80)) if gain_dir: fixed_env = (gain_reg & 0x7F) << 4 else: fixed_env = 0 # unused - gain_mode = (gain_reg >> 5) & 0x3 if (not adsr_on and not gain_dir) else 0 + gain_mode = ( + (gain_reg >> 5) & 0x3 if (not adsr_on and not gain_dir) else 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 +961,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 +982,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 +994,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 +1037,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 +1058,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 @@ -548,11 +1075,11 @@ def generate_samples(self, n_samples: int): # --- 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 +1087,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 +1113,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,7 +1127,12 @@ 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) @@ -608,10 +1140,14 @@ def generate_samples(self, n_samples: int): # 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..1efc46d 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -6,7 +6,7 @@ def AbsoluteBitModify(self, mode): 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 @@ -104,7 +104,9 @@ 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 + displacement = ( + displacement if displacement < 0x80 else displacement - 0x100 + ) self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirect(self): @@ -116,7 +118,9 @@ def BranchNotDirect(self): if self.A != self.data: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + displacement = ( + displacement if displacement < 0x80 else displacement - 0x100 + ) self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirectDecrement(self): @@ -129,7 +133,9 @@ def BranchNotDirectDecrement(self): if self.data != 0: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + displacement = ( + displacement if displacement < 0x80 else displacement - 0x100 + ) self.PC = (displacement + self.PC) & 0xFFFF def BranchNotDirectIndexed(self, reg_index): @@ -143,7 +149,9 @@ def BranchNotDirectIndexed(self, reg_index): if self.A != self.data: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + displacement = ( + displacement if displacement < 0x80 else displacement - 0x100 + ) self.PC = (displacement + self.PC) & 0xFFFF def BranchNotYDecrement(self): @@ -156,7 +164,9 @@ def BranchNotYDecrement(self): if self.Y != 0: self.idle() self.idle() - displacement = displacement if displacement < 0x80 else displacement - 0x100 + displacement = ( + displacement if displacement < 0x80 else displacement - 0x100 + ) self.PC = (displacement + self.PC) & 0xFFFF def Break(self): @@ -165,8 +175,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,7 +199,7 @@ 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 @@ -199,7 +209,7 @@ def CallTable(self, vector): 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 @@ -317,7 +327,9 @@ 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) @@ -381,15 +393,15 @@ 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 + # 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) + # 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 @@ -608,8 +620,13 @@ def TestSetBitsAbsolute(self, bit_set): 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) + 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, + ) def Transfer(self, src, dst): # MOV reg,reg — 2 cycles diff --git a/pysnes/apu/spc700/disassembler.py b/pysnes/apu/spc700/disassembler.py index 51b7835..7ebd5b5 100644 --- a/pysnes/apu/spc700/disassembler.py +++ b/pysnes/apu/spc700/disassembler.py @@ -4,13 +4,13 @@ from .opcodes_spc700 import SPC700Opcodes _OPNAMES = { - SPC700Opcodes.OR: "OR", + SPC700Opcodes.OR: "OR", SPC700Opcodes.AND: "AND", SPC700Opcodes.EOR: "EOR", SPC700Opcodes.CMP: "CMP", SPC700Opcodes.ADC: "ADC", SPC700Opcodes.SBC: "SBC", - SPC700Opcodes.LD: "MOV", + SPC700Opcodes.LD: "MOV", SPC700Opcodes.ASL: "ASL", SPC700Opcodes.LSR: "LSR", SPC700Opcodes.ROL: "ROL", @@ -24,13 +24,20 @@ } _BRANCH_MNEMS = { - 0x10: "BPL", 0x30: "BMI", 0x50: "BVC", 0x70: "BVS", - 0x90: "BCC", 0xb0: "BCS", 0xd0: "BNE", 0xf0: "BEQ", 0x2f: "BRA", + 0x10: "BPL", + 0x30: "BMI", + 0x50: "BVC", + 0x70: "BVS", + 0x90: "BCC", + 0xB0: "BCS", + 0xD0: "BNE", + 0xF0: "BEQ", + 0x2F: "BRA", } _ABS_BIT_MODS = [ - ("OR1", "C,${a:04X}.{b}"), - ("OR1", "C,/${a:04X}.{b}"), + ("OR1", "C,${a:04X}.{b}"), + ("OR1", "C,/${a:04X}.{b}"), ("AND1", "C,${a:04X}.{b}"), ("AND1", "C,/${a:04X}.{b}"), ("EOR1", "C,${a:04X}.{b}"), @@ -97,9 +104,12 @@ def _build_table(): 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 +161,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 +205,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 +239,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 +252,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 +270,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] @@ -271,38 +322,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}") diff --git a/pysnes/apu/spc700/instructions_spc700.py b/pysnes/apu/spc700/instructions_spc700.py index 895c8b8..4b394d2 100644 --- a/pysnes/apu/spc700/instructions_spc700.py +++ b/pysnes/apu/spc700/instructions_spc700.py @@ -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,28 +45,34 @@ (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), (0x33, SPC700AddressingModes.BranchBit, 1, False), - (0x34, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.AND, "A", "X"), + ( + 0x34, + SPC700AddressingModes.DirectIndexedRead, + SPC700Opcodes.AND, + "A", + "X", + ), (0x35, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.AND, "X"), (0x36, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.AND, "Y"), (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,28 +83,34 @@ (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), (0x53, SPC700AddressingModes.BranchBit, 2, False), - (0x54, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.EOR, "A", "X"), + ( + 0x54, + SPC700AddressingModes.DirectIndexedRead, + SPC700Opcodes.EOR, + "A", + "X", + ), (0x55, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.EOR, "X"), (0x56, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.EOR, "Y"), (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,28 +121,34 @@ (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), (0x73, SPC700AddressingModes.BranchBit, 3, False), - (0x74, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.CMP, "A", "X"), + ( + 0x74, + SPC700AddressingModes.DirectIndexedRead, + SPC700Opcodes.CMP, + "A", + "X", + ), (0x75, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.CMP, "X"), (0x76, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.CMP, "Y"), (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,122 +159,134 @@ (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), (0x93, SPC700AddressingModes.BranchBit, 4, False), - (0x94, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.ADC, "A", "X"), + ( + 0x94, + SPC700AddressingModes.DirectIndexedRead, + SPC700Opcodes.ADC, + "A", + "X", + ), (0x95, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.ADC, "X"), (0x96, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.ADC, "Y"), (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), ) diff --git a/pysnes/apu/spc700/opcodes_spc700.py b/pysnes/apu/spc700/opcodes_spc700.py index 84387ff..feeef5d 100644 --- a/pysnes/apu/spc700/opcodes_spc700.py +++ b/pysnes/apu/spc700/opcodes_spc700.py @@ -8,7 +8,7 @@ class SPC700Opcodes: """ def ADC(self, x, y): - #assert False + # assert False result = x + y + int(self.CF) self.CF = bool(result > 0xFF) self.ZF = bool(result & 0xFF == 0) @@ -45,7 +45,7 @@ def CMP(self, x, y): def DEC(self, x): assert x >= 0 - #assert (x - 1) >= 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 +113,7 @@ def ROR(self, x): return x def SBC(self, x: int, y: int) -> int: - #assert False + # assert False assert x >= 0 assert y >= 0 return SPC700Opcodes.ADC(self, x & 0xFF, ~y & 0xFF) @@ -138,7 +138,7 @@ def CPW(self, x, y): return x def LDW(self, x, y): - #assert False + # 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..cd66034 100644 --- a/pysnes/apu/test_apu.py +++ b/pysnes/apu/test_apu.py @@ -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..e68b4f4 100644 --- a/pysnes/apu/test_dsp.py +++ b/pysnes/apu/test_dsp.py @@ -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,55 @@ 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 + assert ( + d.voices[0].brr_buf[0] == 4096 + ) # stored ×2: nibble=1, shift=12 → (1<<11)*2 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 + assert ( + d.voices[0].brr_buf[0] == -4096 + ) # stored ×2: nibble=-1, shift=12 → (-1<<11)*2 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 +139,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 +185,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 +205,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 +230,20 @@ 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 + d.regs[0x05] = ( + 0x80 # ADSR mode (bit7=1) so GAIN direct doesn't override env_level + ) 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..e4c4c00 100644 --- a/pysnes/apu/test_spc700.py +++ b/pysnes/apu/test_spc700.py @@ -29,8 +29,8 @@ 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')) + 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 +74,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 +106,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,10 +123,10 @@ 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 ──────────────────────────────────── @@ -150,12 +151,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 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'])}" + ) for addr, value in final["ram"]: # 0xFD-0xFF are timer counters that clear on read; use the shadow diff --git a/pysnes/apu/test_timers.py b/pysnes/apu/test_timers.py index c25ecbe..ef79b78 100644 --- a/pysnes/apu/test_timers.py +++ b/pysnes/apu/test_timers.py @@ -32,6 +32,7 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def apu(): a = Apu() @@ -45,6 +46,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 +100,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 +120,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,6 +158,7 @@ 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.""" t = apu.timers[timer_idx] @@ -201,6 +206,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,6 +237,7 @@ 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.""" apu.ports_r[0] = 0xAA diff --git a/pysnes/audio/audio_sdl2.py b/pysnes/audio/audio_sdl2.py index 241033d..9b35403 100644 --- a/pysnes/audio/audio_sdl2.py +++ b/pysnes/audio/audio_sdl2.py @@ -8,7 +8,7 @@ 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: @@ -30,8 +30,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 +42,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( @@ -78,10 +80,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..a8936cc 100644 --- a/pysnes/bus/bus.py +++ b/pysnes/bus/bus.py @@ -11,7 +11,6 @@ class Bus: - def __init__( self, rom: Rom, @@ -23,7 +22,10 @@ def __init__( ) -> 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 + 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 @@ -48,15 +50,17 @@ 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: 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": bytes(self.sram[: self.sram_size]) + if self.sram_size + else b"", "sram_dirty": bool(self.sram_dirty), "dma_ppu2_hw_registers": bytes(self.dma_ppu2_hw_registers), "hblank": bool(self.hblank), @@ -100,7 +104,7 @@ def save_sram(self, path: str) -> int: 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 @@ -153,22 +157,29 @@ 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) @@ -186,7 +197,9 @@ def read(self, abs_addr: int) -> int: if (0x00 <= bank <= 0x3F) or bank == 0x7E: if 0x0000 <= addr <= 0x1FFF: - return self.low_ram[addr & 0xFFFF] # LowRAM, shadowed from bank $7E + return self.low_ram[ + addr & 0xFFFF + ] # LowRAM, shadowed from bank $7E if 0x00 <= bank <= 0x3F: # Hardware registers $2100-$21FF and $4200-$44FF are mirrored @@ -231,7 +244,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 +262,8 @@ 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 + << 6 # This bit is open bus, I'm setting it to satisfy the PLP test program | 0x02 # 5A22 chip version number [0-3] ) self.cpu.status.nmi_line = False # Reading clears the line @@ -254,11 +271,15 @@ 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 + self.cpu.status.irq_line = ( + False # Reading clears the latched flag + ) 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 ) @@ -287,9 +308,13 @@ def read(self, abs_addr: int) -> int: # 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 +323,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,20 +357,25 @@ 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) @@ -380,8 +412,12 @@ 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 +445,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 +458,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 +471,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 +484,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 @@ -564,11 +616,16 @@ 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 + if ( + addr == 0x2180 + ): # WMDATA - write byte to WRAM at WMADD, increment wm_addr = self._wmadd & 0x1FFFF if wm_addr < 0x2000: self.low_ram[wm_addr] = data @@ -607,7 +664,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 +688,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,7 +715,8 @@ 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. if not self.cpu.status.irq_enable: @@ -667,16 +731,24 @@ def write(self, abs_addr: int, data: int) -> None: 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,9 +761,13 @@ 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 @@ -711,4 +787,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..4bf9091 100644 --- a/pysnes/bus/test_bus.py +++ b/pysnes/bus/test_bus.py @@ -29,16 +29,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 +78,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 +109,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 +126,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 +143,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 @@ -158,6 +176,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 +202,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,6 +219,7 @@ def test_ppu_bgmode_write(): # NMITIMEN / RDNMI ($4200 / $4210) # --------------------------------------------------------------------------- + def test_nmitimen_enables_nmi(make_bus=make_bus): bus, _, cpu, *_ = make_bus() bus.write(0x004200, 0x80) # bit 7 = NMI enable @@ -208,7 +229,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 +237,7 @@ def test_rdnmi_clears_nmi_line(): # HVBJOY ($4212) # --------------------------------------------------------------------------- + def test_hvbjoy_vblank_bit(): bus, *_ = make_bus() bus.vblank = True @@ -247,16 +269,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 +288,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,6 +299,7 @@ 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() @@ -288,7 +313,9 @@ def test_lorom_region2_read(): # 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 + val = bus.read( + 0x400000 + ) # should not crash; ROM is 512KB so addr 0x200000 is OOB → 0 assert val == 0 # StubRom returns 0 for OOB — routing reached ROM, no crash @@ -297,14 +324,17 @@ 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 + 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 # --------------------------------------------------------------------------- # 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() @@ -318,7 +348,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,6 +356,7 @@ 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).""" bus, rom, *_ = make_bus() @@ -337,13 +368,14 @@ 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 + val = bus.read(0x004016) # JOYSER0 read assert val in range(0, 0x100) # sanity: valid byte returned @@ -351,6 +383,7 @@ def test_joyser0_write_latches_controller(): # 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() @@ -431,6 +466,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 +547,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 @@ -594,14 +630,15 @@ def test_sram_upper_half_still_rom(): 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,6 +665,7 @@ 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.""" bus, *_ = make_bus() @@ -651,6 +689,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 +715,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 +739,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) @@ -780,6 +821,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 +889,16 @@ 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.""" 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,8 +906,8 @@ 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(): @@ -873,10 +916,10 @@ def test_divide_basic(): 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..a255da7 100644 --- a/pysnes/bus/test_interrupts.py +++ b/pysnes/bus/test_interrupts.py @@ -34,10 +34,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 +75,7 @@ def make_bus(): # NMITIMEN ($4200) — NMI enable / disable # --------------------------------------------------------------------------- + def test_nmitimen_enables_nmi(): bus, cpu = make_bus() bus.write(0x004200, 0x80) @@ -113,6 +126,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 @@ -166,6 +180,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,9 +213,9 @@ 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" @@ -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,6 +248,7 @@ 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() @@ -283,10 +300,14 @@ def test_interrupt_resets_pb_to_zero_native(): 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 +328,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 +368,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 +394,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,6 +456,7 @@ 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() diff --git a/pysnes/cpu/cpu.py b/pysnes/cpu/cpu.py index e2b94d4..e6e3b6d 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -7,9 +7,7 @@ from ..bus import Bus - class Reg: - def __init__(self, bits: int, value: int) -> None: self.bits = bits self.value = value @@ -137,8 +135,6 @@ def call(self, cpu: Cpu): class Cpu: - - def __init__(self, hardware_vectors: "HardwareVectors") -> None: self.reset_registers() self.load_instructions() @@ -146,6 +142,7 @@ def __init__(self, hardware_vectors: "HardwareVectors") -> None: 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) @@ -161,10 +158,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.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.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,14 +172,18 @@ 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 + self.Z = Reg( + 16, 0x0000 + ) # this only exists in bsnes but not in actual hardware # 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 + self.wai: bool = ( + False # raised during wai, cleared after interrupt triggered + ) self.stp: bool = False # raised during stp, never cleared # reg to count cpu clock cycles @@ -308,7 +309,11 @@ 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 + next_event = ( + peeked + if peeked != 0xFFFFFFFFFFFFFFFF + else self.scheduler.master_clock + ) while True: if self._nmi_pending: self._nmi_pending = False @@ -344,7 +349,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: @@ -389,35 +394,35 @@ def readDirect(self, address: int) -> int: # addr = (self.D.h << 8) | ((self.D.l + address) & 0xff) # return self.read(addr) 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) @@ -567,7 +572,16 @@ 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: diff --git a/pysnes/cpu/dma.py b/pysnes/cpu/dma.py index 00b08e5..e2794f2 100644 --- a/pysnes/cpu/dma.py +++ b/pysnes/cpu/dma.py @@ -1,4 +1,3 @@ - from typing import TYPE_CHECKING from dataclasses import dataclass @@ -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)] @@ -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 @@ -239,7 +265,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,17 +296,30 @@ 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. ch._hdma_do_transfer = bool(ch._hdma_line_counter_repeat & 0x80) diff --git a/pysnes/cpu/test_cpu.py b/pysnes/cpu/test_cpu.py index 1122144..a7c8e32 100644 --- a/pysnes/cpu/test_cpu.py +++ b/pysnes/cpu/test_cpu.py @@ -20,8 +20,8 @@ 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')) + 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] @@ -46,15 +46,16 @@ def _include(filename): return True 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 @@ -87,7 +88,8 @@ def get_test_cases(opcode_filter=None, max_per_opcode=None, mode=None): # 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 @@ -141,22 +143,25 @@ def test_cpu(test_case): 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.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.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..f48a995 100644 --- a/pysnes/cpu/test_dma.py +++ b/pysnes/cpu/test_dma.py @@ -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 read(self, addr): @@ -62,6 +74,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 +136,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 +211,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 +233,14 @@ 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.rom[0x8000] = ( + 0x22 # ROM offset for channel 1 (bank 0, offset 0x8000 + 0x8000) + ) # Channel 0 bus.write(0x004300, 0x00) @@ -252,6 +270,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,6 +291,7 @@ 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).""" for i, b in enumerate(table_bytes): @@ -293,11 +313,11 @@ def test_hdma_non_repeat_writes_bytes_to_target(): _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,12 +331,12 @@ 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 @@ -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 @@ -398,7 +425,8 @@ def test_hdma_indirect_non_repeat_reads_from_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 +446,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 +471,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 +549,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 +595,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 +630,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..e645f4a 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/__init__.py +++ b/pysnes/cpu/wdc65816/addressing_modes/__init__.py @@ -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..8e77e93 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/decorator.py +++ b/pysnes/cpu/wdc65816/addressing_modes/decorator.py @@ -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/other.py b/pysnes/cpu/wdc65816/addressing_modes/other.py index e5ec55f..6ae4e36 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/other.py +++ b/pysnes/cpu/wdc65816/addressing_modes/other.py @@ -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 diff --git a/pysnes/cpu/wdc65816/addressing_modes/pc.py b/pysnes/cpu/wdc65816/addressing_modes/pc.py index 2784c8f..afd6d68 100644 --- a/pysnes/cpu/wdc65816/addressing_modes/pc.py +++ b/pysnes/cpu/wdc65816/addressing_modes/pc.py @@ -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/disassembler.py b/pysnes/cpu/wdc65816/disassembler.py index 820aef3..ee77417 100644 --- a/pysnes/cpu/wdc65816/disassembler.py +++ b/pysnes/cpu/wdc65816/disassembler.py @@ -27,14 +27,20 @@ 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() @@ -69,7 +75,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 +103,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 +125,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 +138,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 +174,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 +193,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..2b9766a 100644 --- a/pysnes/cpu/wdc65816/instructions.py +++ b/pysnes/cpu/wdc65816/instructions.py @@ -15,260 +15,260 @@ def build_instructions(cpu: "Cpu") -> tuple: (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..bd95aeb 100644 --- a/pysnes/cpu/wdc65816/opcodes.py +++ b/pysnes/cpu/wdc65816/opcodes.py @@ -6,17 +6,22 @@ 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 @@ -25,25 +30,40 @@ def ADC(cpu: Cpu, mode_8bit: bool, data: int): if not cpu.DFlag: result = cpu.A.w + data + cpu.CFlag else: - result = (cpu.A.w & 0x000f) + (data & 0x000f) + (cpu.CFlag << 0) + 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: + 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: + 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.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: + if cpu.DFlag and result > 0x9FFF: result += 0x6000 - cpu.CFlag = result > 0xffff - cpu.ZFlag = result & 0xffff == 0 + cpu.CFlag = result > 0xFFFF + cpu.ZFlag = result & 0xFFFF == 0 cpu.NFlag = bool(result & 0x8000) cpu.A.w = result @@ -67,13 +87,13 @@ 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; + return data else: cpu.CFlag = bool(data & 0x8000) data <<= 1 - cpu.ZFlag = data & 0xffff == 0 + cpu.ZFlag = data & 0xFFFF == 0 cpu.NFlag = bool(data & 0x8000) return data @@ -95,13 +115,13 @@ 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.ZFlag = result & 0xFFFF == 0 cpu.NFlag = bool(result & 0x8000) return result @@ -110,13 +130,13 @@ 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.ZFlag = result & 0xFFFF == 0 cpu.NFlag = bool(result & 0x8000) return result @@ -125,25 +145,25 @@ 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.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 + data = (data - 1) & 0xFFFF cpu.ZFlag = data == 0 cpu.NFlag = bool(data & 0x8000) return data @@ -177,15 +197,15 @@ def INC(cpu: Cpu, mode_8bit: bool, data: int): 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; + return data else: - cpu.A.w = data; - cpu.ZFlag = cpu.A.w == 0; + cpu.A.w = data + cpu.ZFlag = cpu.A.w == 0 cpu.NFlag = bool(cpu.A.w & 0x8000) - return data; + return data def LDX(cpu: Cpu, mode_8bit: bool, data: int): @@ -280,51 +300,71 @@ 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 = (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: + 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: + 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.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: + if cpu.DFlag and result <= 0xFFFF: result -= 0x6000 - cpu.CFlag = result > 0xffff - cpu.ZFlag = result & 0xffff == 0 + cpu.CFlag = result > 0xFFFF + cpu.ZFlag = result & 0xFFFF == 0 cpu.NFlag = bool(result & 0x8000) cpu.A.w = result diff --git a/pysnes/debugger/debugger.py b/pysnes/debugger/debugger.py index 4c698d0..a3336c9 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 @@ -182,9 +183,11 @@ 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 + self._cpu.trace_enabled = ( + True # populate trace_log for disasm history + ) 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..40cd283 100644 --- a/pysnes/debugger/test_debugger.py +++ b/pysnes/debugger/test_debugger.py @@ -4,6 +4,7 @@ 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 @@ -15,6 +16,7 @@ # Minimal stubs # --------------------------------------------------------------------------- + class _PC: def __init__(self, addr: int = 0x008000): self.d = addr @@ -34,6 +36,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 +54,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 @@ -74,6 +78,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 +106,7 @@ def __init__(self): # Fixture # --------------------------------------------------------------------------- + @pytest.fixture def pysnes(): return _PySNES() @@ -117,6 +123,7 @@ def debugger(pysnes): # toggle_breakpoint # --------------------------------------------------------------------------- + def test_toggle_breakpoint_adds(debugger): debugger.toggle_breakpoint(0x8000) assert 0x8000 in debugger._breakpoints @@ -143,8 +150,11 @@ 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 + debugger.toggle_breakpoint( + 0x9000 + ) # install hook but breakpoint is elsewhere pysnes.cpu.PC.d = 0x8000 before = pysnes.cpu._steps debugger._hooked_step() @@ -156,6 +166,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 +205,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() @@ -216,6 +228,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 +246,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 +258,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 +278,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 +303,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,6 +315,7 @@ 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().""" dbg = Debugger(pysnes) @@ -329,6 +347,7 @@ 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(): """ Reproduces the real-world bug: when step_one_instruction() is called, the diff --git a/pysnes/debugger/window.py b/pysnes/debugger/window.py index abd62d8..6381583 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 @@ -27,20 +28,28 @@ 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) + 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)) 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 +82,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 +107,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 +133,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 +152,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 @@ -282,7 +322,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 +354,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 +371,14 @@ 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}} {ascii_part}" + ) self._set_text(self._mem_text, "\n".join(lines)) @@ -424,7 +470,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: diff --git a/pysnes/harness/__init__.py b/pysnes/harness/__init__.py index 44e78ca..113c39b 100644 --- a/pysnes/harness/__init__.py +++ b/pysnes/harness/__init__.py @@ -15,6 +15,7 @@ h.screenshot("title.png") print("CGRAM[0..4]:", h.cgram(0, 4).hex()) """ + from __future__ import annotations from pathlib import Path @@ -147,7 +148,9 @@ 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})" @@ -387,10 +390,12 @@ def close(self) -> None: def save_state(self, path: Union[str, Path]) -> None: from pysnes import savestate # noqa: PLC0415 + savestate.save(self._pysnes, str(path)) def load_state(self, path: Union[str, Path]) -> None: from pysnes import savestate # noqa: PLC0415 + savestate.load(self._pysnes, str(path)) def __enter__(self) -> "Harness": diff --git a/pysnes/harness/_png.py b/pysnes/harness/_png.py index 17e2627..e68a932 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 @@ -25,7 +26,9 @@ def write_png(path: Path, pixels, width: int, height: int) -> None: 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 +43,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..663fa49 100644 --- a/pysnes/harness/cli.py +++ b/pysnes/harness/cli.py @@ -14,6 +14,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 +55,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 +82,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 +96,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 +163,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 +174,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..bf29b05 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)) diff --git a/pysnes/ppu/bg_renderer.py b/pysnes/ppu/bg_renderer.py index 7a0bf54..9563897 100644 --- a/pysnes/ppu/bg_renderer.py +++ b/pysnes/ppu/bg_renderer.py @@ -42,8 +42,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 +97,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, ) @@ -159,7 +161,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 @@ -243,8 +247,10 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select 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 +265,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 +284,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 +316,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 @@ -325,7 +349,9 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select # (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 +365,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 @@ -353,19 +381,28 @@ def draw_background_scanline(ppu: Ppu, bg: Background, bpp: int, priority_select # 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 +410,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 +447,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 +458,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 @@ -454,7 +523,14 @@ 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( @@ -481,8 +557,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 +573,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 +591,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 +613,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 +632,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 +648,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..4536231 100644 --- a/pysnes/ppu/color_math.py +++ b/pysnes/ppu/color_math.py @@ -10,13 +10,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: @@ -100,8 +100,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, ) @@ -179,7 +181,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..3079a5b 100644 --- a/pysnes/ppu/constants.py +++ b/pysnes/ppu/constants.py @@ -15,7 +15,7 @@ # 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) +_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..e736380 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: diff --git a/pysnes/ppu/obj_renderer.py b/pysnes/ppu/obj_renderer.py index c0579da..6a32052 100644 --- a/pysnes/ppu/obj_renderer.py +++ b/pysnes/ppu/obj_renderer.py @@ -26,16 +26,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 +116,9 @@ 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 +133,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 - - _plot_obj(ppu, obj, x_screen, tile_base_word * 2, - tile_width, tile_height, cgram_cache) + 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, + ) def copy_obj_pixels_for_priority(ppu: Ppu, priority: int = -1) -> None: diff --git a/pysnes/ppu/ppu.py b/pysnes/ppu/ppu.py index f4d947f..fb0182e 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -5,8 +5,12 @@ 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, + _MC_PER_SCANLINE, + _HBLANK_START_MC, + _VBLANK_START_LINE, + _TOTAL_SCANLINES, + SCREEN_WIDTH, + SCREEN_HEIGHT, ) from .data_structures import Background from .oam import OAM @@ -101,7 +105,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 +114,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,12 +137,14 @@ 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) + self.h_counter = ( + 0 # current dot being drawn (updated at H-Blank / scanline start) + ) 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) + 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) @@ -146,7 +152,7 @@ def __init__( # 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. @@ -167,25 +173,65 @@ def __init__( # 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) + 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 +259,9 @@ 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 + self._cgdata = ( + c_uint8(d["_cgdata"]) if d["_cgdata"] is not None else None + ) self.bg1.load_state(d["bg1"]) self.bg2.load_state(d["bg2"]) self.bg3.load_state(d["bg3"]) @@ -225,7 +273,11 @@ 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: + 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. @@ -307,17 +359,31 @@ 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) + 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}" + 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 @@ -333,7 +399,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 @@ -627,7 +695,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,71 +784,125 @@ 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 + bg_renderer.draw_background_scanline( + self, self.bg4, 2, False + ) # BG4 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg3, 2, False) # BG3 pri 0 + bg_renderer.draw_background_scanline( + self, self.bg3, 2, False + ) # BG3 pri 0 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 + 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 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 + 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 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 + 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 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 + bg_renderer.draw_background_scanline( + self, self.bg3, 2, False + ) # BG3 pri 0 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) + bg_renderer.draw_background_scanline( + self, self.bg3, 2, True + ) # BG3 pri 1 (low) 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 + 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 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 + 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 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) + bg_renderer.draw_background_scanline( + self, self.bg3, 2, True + ) # BG3 pri 1 (high) 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 + bg_renderer.draw_background_scanline( + self, self.bg2, 4, False + ) # BG2 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + bg_renderer.draw_background_scanline( + self, self.bg1, 4, False + ) # BG1 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 4, True) # BG2 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg2, 4, True + ) # BG2 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg1, 4, True + ) # BG1 pri 1 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. bg_renderer.draw_scanline_backdrop(self) - bg_renderer.draw_background_scanline(self, self.bg2, 4, False) # BG2 pri 0 + bg_renderer.draw_background_scanline( + self, self.bg2, 4, False + ) # BG2 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 8, False) # BG1 pri 0 + bg_renderer.draw_background_scanline( + self, self.bg1, 8, False + ) # BG1 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 4, True) # BG2 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg2, 4, True + ) # BG2 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 8, True) # BG1 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg1, 8, True + ) # BG1 pri 1 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 + bg_renderer.draw_background_scanline( + self, self.bg2, 2, False + ) # BG2 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=0) - bg_renderer.draw_background_scanline(self, self.bg1, 4, False) # BG1 pri 0 + bg_renderer.draw_background_scanline( + self, self.bg1, 4, False + ) # BG1 pri 0 obj_renderer.copy_obj_pixels_for_priority(self, priority=1) - bg_renderer.draw_background_scanline(self, self.bg2, 2, True) # BG2 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg2, 2, True + ) # BG2 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=2) - bg_renderer.draw_background_scanline(self, self.bg1, 4, True) # BG1 pri 1 + bg_renderer.draw_background_scanline( + self, self.bg1, 4, True + ) # BG1 pri 1 obj_renderer.copy_obj_pixels_for_priority(self, priority=3) elif self._bgmode == 5: # Mode 5: BG1 (4bpp) + BG2 (2bpp), hi-res. @@ -787,23 +911,35 @@ def _render_layers(self): # 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. diff --git a/pysnes/ppu/test_ppu.py b/pysnes/ppu/test_ppu.py index 547d3d2..1984523 100644 --- a/pysnes/ppu/test_ppu.py +++ b/pysnes/ppu/test_ppu.py @@ -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,6 +46,7 @@ # 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.""" from pysnes.pysnes import PySNES # noqa: PLC0415 @@ -82,9 +83,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"), @@ -134,18 +137,20 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: 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: @@ -232,7 +237,10 @@ 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", ), ] @@ -241,12 +249,17 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: @pytest.mark.parametrize( "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.""" rom_rel_path = Path(rom_rel) - rom_path = rom_rel_path if rom_rel_path.is_absolute() else PPU_ROMS / rom_rel + rom_path = ( + rom_rel_path if rom_rel_path.is_absolute() else 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..e284679 100644 --- a/pysnes/ppu/test_ppu_bg_window.py +++ b/pysnes/ppu/test_ppu_bg_window.py @@ -22,7 +22,7 @@ SCREEN_W = 256 TILEDATA = 0x4000 -RED = (255, 0, 0) +RED = (255, 0, 0) BLACK = (0, 0, 0) @@ -54,9 +54,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,26 +78,29 @@ 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 @@ -105,14 +108,14 @@ def test_w1_invert1_masks_outside(self): """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,6 +124,7 @@ 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. @@ -138,24 +142,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 @@ -165,7 +169,7 @@ def test_and_logic_shows_bg_in_either_window(self): def test_or_logic_masks_whole_row(self): """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..884533b 100644 --- a/pysnes/ppu/test_ppu_bgmode.py +++ b/pysnes/ppu/test_ppu_bgmode.py @@ -16,9 +16,9 @@ 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 +84,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 +109,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 +137,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 +169,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 +178,19 @@ 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. 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 +198,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 +256,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..6bed1e4 100644 --- a/pysnes/ppu/test_ppu_color_math.py +++ b/pysnes/ppu/test_ppu_color_math.py @@ -34,6 +34,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 @@ -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,6 +136,7 @@ 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.""" @@ -141,31 +144,43 @@ 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.""" 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.""" 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,6 +193,7 @@ 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). @@ -190,7 +206,9 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: # 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) + ppu.bg2.screen_addr = ( + 0x800 # word offset 0x800 → byte 0x1000 (different region) + ) # Backdrop = black _write_cgram(ppu, 0, 0, 0, 0) @@ -208,7 +226,9 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: # 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 + ppu.vram[addr] = ( + 0 if col < 16 else 0xFE + ) # tile FE we never wrote → bitplanes 0 → transparent ppu.vram[addr + 1] = 0 # BG2 tilemap (at byte 0x1000 = word 0x800): tile 1 (blue) everywhere @@ -217,7 +237,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 @@ -279,10 +302,13 @@ 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: + 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 +326,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 @@ -328,7 +357,9 @@ def test_subtract_bg1(self): 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) @@ -342,17 +373,22 @@ def test_subtract_gating_by_cgadsub_bit(self): 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 +405,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 @@ -390,7 +429,9 @@ 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})" + assert r >= 250 and g >= 250 and b >= 250, ( + f"expected ~255, got ({r},{g},{b})" + ) def test_half_add_produces_average(self): """Half-add of two different colors produces the midpoint.""" diff --git a/pysnes/ppu/test_ppu_color_math_window.py b/pysnes/ppu/test_ppu_color_math_window.py index b10aeb1..f616c39 100644 --- a/pysnes/ppu/test_ppu_color_math_window.py +++ b/pysnes/ppu/test_ppu_color_math_window.py @@ -71,7 +71,9 @@ 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 + _write_4bpp_solid_tile( + ppu, 0, 1 + ) # tile 0 → color_index=1 → palette color 33 for BG2 pal 2 # 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 diff --git a/pysnes/ppu/test_ppu_forced_blank.py b/pysnes/ppu/test_ppu_forced_blank.py index 71ea5f5..62de851 100644 --- a/pysnes/ppu/test_ppu_forced_blank.py +++ b/pysnes/ppu/test_ppu_forced_blank.py @@ -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..9cc9b28 100644 --- a/pysnes/ppu/test_ppu_mosaic.py +++ b/pysnes/ppu/test_ppu_mosaic.py @@ -36,7 +36,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 @@ -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.""" @@ -114,7 +117,7 @@ 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} vs {expected}" ) def test_mosaic_size_4_blocks_groups_of_four(self): @@ -132,7 +135,7 @@ 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 anchor {expected}" ) def test_mosaic_disable_bit_gated_per_bg(self): diff --git a/pysnes/ppu/test_ppu_registers.py b/pysnes/ppu/test_ppu_registers.py index c3f8b6b..73f0794 100644 --- a/pysnes/ppu/test_ppu_registers.py +++ b/pysnes/ppu/test_ppu_registers.py @@ -21,6 +21,7 @@ def _make_ppu() -> Ppu: # Item 8: NotImplementedError getter fixes # --------------------------------------------------------------------------- + class TestBgmodeGetter: def test_bgmode_returns_mode_bits(self): ppu = _make_ppu() @@ -80,7 +81,7 @@ def test_oamaddl_preserves_high_bit_across_oamaddh_write(self): 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,6 +89,7 @@ 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.""" ppu.vmaddl = word_addr & 0xFF @@ -113,7 +115,9 @@ 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 + ppu.vmain = ( + 0b10000100 # mode 1 (bits 3:2 = 01), increment on high write + ) _write_word(ppu, 0x00E0, 0xCC, 0xDD) assert ppu.vram[0x0007 * 2 + 0] == 0xCC assert ppu.vram[0x0007 * 2 + 1] == 0xDD @@ -137,7 +141,9 @@ 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 + ppu.vmain = ( + 0b10001000 # mode 2 (bits 3:2 = 10), increment on high write + ) _write_word(ppu, 0x01C0, 0x33, 0x44) assert ppu.vram[0x0007 * 2 + 0] == 0x33 assert ppu.vram[0x0007 * 2 + 1] == 0x44 @@ -150,7 +156,9 @@ 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 + ppu.vmain = ( + 0b10001100 # mode 3 (bits 3:2 = 11), increment on high write + ) _write_word(ppu, 0x0380, 0x55, 0x66) assert ppu.vram[0x0007 * 2 + 0] == 0x55 assert ppu.vram[0x0007 * 2 + 1] == 0x66 @@ -176,6 +184,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 @@ -206,7 +215,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 +240,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..0376a43 100644 --- a/pysnes/ppu/test_ppu_scroll.py +++ b/pysnes/ppu/test_ppu_scroll.py @@ -27,7 +27,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 +35,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 +71,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 +84,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 +95,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,8 +144,8 @@ 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 @@ -152,7 +156,7 @@ def test_sub_tile_shift_boundary(self): _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 +165,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 +175,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: @@ -223,12 +229,12 @@ def test_vertical_wrap(self): # 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 @@ -246,7 +252,7 @@ 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 @@ -256,15 +262,15 @@ def _setup_palette_test(self, ppu: "Ppu", palette: int) -> None: # 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 +278,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,7 +291,9 @@ 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).""" @@ -301,7 +311,9 @@ 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). @@ -324,11 +336,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,44 +368,44 @@ 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.""" 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).""" 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" @@ -395,16 +413,16 @@ def test_window_masks_outside_region(self): """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 +430,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..78f8758 100644 --- a/pysnes/ppu/test_ppu_sprites.py +++ b/pysnes/ppu/test_ppu_sprites.py @@ -33,6 +33,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_ppu() -> Ppu: ppu = Ppu() ppu.inidisp_set(0x0F) # max brightness @@ -60,8 +61,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 +72,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 +92,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 +107,13 @@ 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) + obj.palette = ( + 8 # +8 already applied (matches OAM decoder in update_low_table) + ) 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 +121,7 @@ def _setup_sprite(ppu: Ppu) -> None: # Tests # --------------------------------------------------------------------------- + class TestSpriteRendering: def test_sprite_pixels_written_to_framebuffer(self): """ @@ -148,7 +152,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): @@ -181,7 +185,9 @@ 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 + obj_renderer.copy_obj_pixels_for_priority( + ppu + ) # must not raise IndexError # Only the visible part (x=0..4) should be green for x in range(0, 5): @@ -271,10 +277,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 @@ -390,7 +396,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 +411,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 @@ -502,7 +512,9 @@ 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 + _setup_sprite( + ppu + ) # sprite 0: palette 8 (GREEN), all others hidden at y=240 # Palette 9, color 1 = RED → CGRAM index 9*16 + 1 = 145 _write_cgram(ppu, 145, 31, 0, 0) diff --git a/pysnes/pysnes.py b/pysnes/pysnes.py index 5fda8f1..fa21ef7 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -25,7 +25,9 @@ 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 +40,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 @@ -86,7 +90,9 @@ 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 + self._trace_ring: deque | None = ( + None # ring-buffer mode; None = stream to file + ) self._trace_count = 0 self._trace_limit = 100_000 self._trace_diverged = False @@ -121,6 +127,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 +136,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,7 +156,10 @@ 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), 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.""" @@ -195,9 +206,14 @@ def start_trace(self, ref_path: str | None = None, last: int = 0): 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) + 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 +238,10 @@ 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 @@ -233,7 +252,10 @@ def traced_step(): 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) + print( + f"[trace] triggered at PC=0x{addr:06X} → cpu_trace.log", + flush=True, + ) if pysnes._trace_active: pysnes._check_trace() @@ -280,14 +302,21 @@ def _check_trace(self): 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: + 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( + f"\n*** TRACE DIVERGENCE at instruction {self._trace_count} ***", + flush=True, + ) print(f" OUR: {line}", flush=True) print(f" REF: {ref_line}", flush=True) self._trace_diverged = True @@ -295,7 +324,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() @@ -317,9 +349,15 @@ def start_apu_trace(self, ref_path: str | None = None, last: int = 0): 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) + 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,7 +377,11 @@ 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() @@ -356,7 +398,9 @@ def main(self): MC_PER_FRAME: int = 262 * 1364 # 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 + _FRAME_HEADROOM_S: float = ( + 0.001 # busy-wait the last 1 ms for precision + ) # EMA smoothing coefficient for FPS display (≈30-frame window) _FPS_ALPHA: float = 1.0 / 30.0 @@ -395,8 +439,10 @@ def main(self): # 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,10 +450,18 @@ 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) + 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, + ) self.running = False # Wall-clock frame limiter: sleep to the next frame deadline so the @@ -424,9 +478,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 +510,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 +521,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: @@ -476,7 +541,9 @@ def process_inputs(self): self.running = False return elif self.event.type == sdl.SDL_KEYUP: - self.controllers[0].pressed_keys.discard(self.event.key.keysym.sym) + 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 +572,66 @@ 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 +654,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/rom.py b/pysnes/rom/rom.py index 4c695fc..2fd1181 100644 --- a/pysnes/rom/rom.py +++ b/pysnes/rom/rom.py @@ -19,13 +19,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): @@ -70,6 +72,7 @@ def _enum_or_int(cls: type[IntEnum], value: int) -> Union[IntEnum, int]: except ValueError: return value + # Scripts to convert SNES ROMs to SNES Classic (.sfrom) format and to read .sfrom headers # https://gist.github.com/anpage/4834433944a2875ee6d4cbb5786c6bf7 @@ -77,11 +80,11 @@ def _enum_or_int(cls: type[IntEnum], value: int) -> Union[IntEnum, int]: @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 @@ -95,12 +98,12 @@ 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 + 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 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 @@ -131,7 +133,9 @@ def load_rom_file(self): “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 + self.rom = bytearray( + 0x400000 + ) # https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map print(f"Loading ROM file: {self.rom_file_path!r}") with open(self.rom_file_path, "rb") as f: @@ -195,10 +199,13 @@ def _parse_header(self, page_offset: int) -> SnesHeader: 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=min(0x400 << sram_size_byte, 0x20000) + if sram_size_byte + else 0, 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 +223,9 @@ 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 + word = lambda lo: ( + 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..172b65d 100644 --- a/pysnes/rom/test_rom.py +++ b/pysnes/rom/test_rom.py @@ -45,22 +45,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,6 +97,7 @@ def _make(image: bytes, name: str = "test.sfc") -> Rom: path = tmp_path / name path.write_bytes(image) return Rom(str(path)) + return _make @@ -102,26 +113,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 @@ -160,12 +189,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"): @@ -227,13 +259,21 @@ 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 diff --git a/pysnes/savestate/__init__.py b/pysnes/savestate/__init__.py index 07d9e43..9ee4da4 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(), @@ -200,7 +205,7 @@ 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 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." ) diff --git a/pysnes/savestate/test_savestate.py b/pysnes/savestate/test_savestate.py index f237cb9..2071687 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"] diff --git a/pysnes/scheduler/scheduler.py b/pysnes/scheduler/scheduler.py index 49bb57a..02094a6 100644 --- a/pysnes/scheduler/scheduler.py +++ b/pysnes/scheduler/scheduler.py @@ -1,7 +1,6 @@ import heapq - class Scheduler: """ Discrete Event Scheduler (DES) — the single source of time for all components. @@ -18,7 +17,6 @@ class Scheduler: - 1 APU clock ≈ 21 master clocks (exact: 21477272/1024000) """ - def __init__(self) -> None: self._queue: list = [] self._seq: int = 0 diff --git a/pysnes/scheduler/test_scheduler.py b/pysnes/scheduler/test_scheduler.py index ad26613..2b4f9b3 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): @@ -59,12 +60,12 @@ def test_events_scheduled_by_handlers_fire_in_same_run_to(s): 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 +77,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 +89,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 +109,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 +135,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 +146,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..793c9e1 100644 --- a/pysnes/settings.py +++ b/pysnes/settings.py @@ -11,6 +11,7 @@ "rom": "roms/mygame.sfc" } """ + import json from pathlib import Path diff --git a/pysnes/spc_player.py b/pysnes/spc_player.py index 00dd5bb..213a47e 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,15 +27,19 @@ 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, ) diff --git a/pysnes/test_integration.py b/pysnes/test_integration.py index 9d2b941..0f2296f 100644 --- a/pysnes/test_integration.py +++ b/pysnes/test_integration.py @@ -42,6 +42,7 @@ def get_mesen(): 2. settings.json "mesen_bin" value """ from pysnes import settings as s # noqa: PLC0415 + cfg = s.load() candidates = [ @@ -71,6 +72,7 @@ def _collect_lines(srv, timeout=120): """Accept one connection and collect all newline-delimited lines. Returns list of strings.""" lines = [] error = [] + def _run(): try: srv.settimeout(timeout) @@ -84,6 +86,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 +108,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 +130,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,7 +146,9 @@ 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] print(f"\nMesen oracle: {len(frames)} frames collected", flush=True) @@ -169,34 +182,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,6 +225,7 @@ 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): @@ -258,6 +280,7 @@ def _spc_str(s): # ---- 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 +294,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 +311,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 +356,7 @@ def traced_step(): class _LineCollector: """File-like object that collects lines written to it.""" + def __init__(self): self.lines = [] @@ -342,6 +374,7 @@ 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)): diff --git a/pysnes/video/video.py b/pysnes/video/video.py index 7d04494..ec640b7 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 @@ -29,16 +28,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: @@ -81,9 +87,9 @@ def draw_textures(self, main_bgs): 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), + "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()) diff --git a/pysnes/video/video_sdl2.py b/pysnes/video/video_sdl2.py index 59dd64e..f443e9b 100644 --- a/pysnes/video/video_sdl2.py +++ b/pysnes/video/video_sdl2.py @@ -2,6 +2,7 @@ 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 @@ -29,24 +30,26 @@ 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()}") + 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" + self.renderer_name = ( + renderer_info.name.decode() if renderer_info.name else "Unknown" + ) # Create streaming texture for game screen self.texture = sdl.SDL_CreateTexture( @@ -54,11 +57,13 @@ 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( + f"Failed to create SDL2 texture: {sdl.SDL_GetError().decode()}" + ) # Set texture blend mode for proper alpha blending sdl.SDL_SetTextureBlendMode(self.texture, sdl.SDL_BLENDMODE_BLEND) @@ -81,7 +86,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 +97,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 +132,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 @@ -156,16 +169,20 @@ def get_performance_info(self) -> dict: 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' + "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') + "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..26805f1 100644 --- a/scripts/bench_bus.py +++ b/scripts/bench_bus.py @@ -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): @@ -64,27 +76,28 @@ def make_bus(): 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)"), + ("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)"), + ("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..31b74bb 100644 --- a/scripts/compare_spc_wav.py +++ b/scripts/compare_spc_wav.py @@ -8,6 +8,7 @@ reference to the same rate, then prints per-second RMS error and writes a difference WAV for further inspection. """ + import argparse import sys import wave @@ -22,7 +23,7 @@ from pysnes.apu.spc_file import SpcFile from pysnes.apu.apu import Apu -_APU_HZ = 1_024_000 +_APU_HZ = 1_024_000 _DSP_DIV = 32 _AUDIO_HZ = _APU_HZ // _DSP_DIV # 32 000 Hz @@ -40,7 +41,9 @@ 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 @ {_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,16 @@ 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} 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 +91,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,10 +110,10 @@ 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}") @@ -121,12 +132,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..9937f55 100644 --- a/scripts/debug_frame.py +++ b/scripts/debug_frame.py @@ -6,7 +6,7 @@ from pysnes.ppu import Ppu from pysnes.controller import Controller -rom = Rom('roms/SNES Test Program.sfc') +rom = Rom("roms/SNES Test Program.sfc") scheduler = Scheduler() apu = Apu() cpu = Cpu(rom.hardware_vectors) @@ -25,19 +25,30 @@ # 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}] CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} 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)} CPU_PC={hex(cpu.PC.value)} A.l={hex(cpu.A.l)} MC={scheduler.master_clock}" + ) _orig_set(self, abs_addr, data) + bus.__class__.__getitem__ = _patched_get bus.__class__.__setitem__ = _patched_set @@ -46,18 +57,26 @@ def _patched_set(self, abs_addr, data): 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)} 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)}" +) diff --git a/scripts/dump_mesen_layers.py b/scripts/dump_mesen_layers.py index 5c0d440..a0d9a0d 100644 --- a/scripts/dump_mesen_layers.py +++ b/scripts/dump_mesen_layers.py @@ -18,6 +18,7 @@ mesen_layer_obj.png TM = OBJ only mesen_layer_sub_bg2.png TM=0, TS=BG2 only """ + import argparse import json import os @@ -35,12 +36,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,12 +49,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")) + candidates.insert( + 1, json.loads(cfg_path.read_text()).get("mesen_bin") + ) except Exception: pass for c in candidates: @@ -64,8 +70,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 +104,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..166cadd 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": 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, + ), } 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(..., ' Date: Fri, 7 Aug 2026 19:08:14 -0300 Subject: [PATCH 3/9] fix: resolve all ruff violations at 80 columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fixable rules applied by `ruff check --fix`; the rest by hand: - cpu.py annotated `hardware_vectors: HardwareVectors` without importing the name — added it under TYPE_CHECKING (F821). - zip() calls now state intent: strict=True where a length mismatch means a corrupt save state, explicit strict=False where the two runs are expected to diverge (B905). - Exceptions raised inside `except` blocks chain with `from e` (B904). - Long comments, docstrings and string literals rewrapped to 80 columns. String splits use adjacent literals, so the parsed AST is unchanged. Deliberate exemptions, each with a reason at the site: Reg.l/Reg.h keep the 65816's low/high byte names (E743), the trace file handles outlive their function (SIM115), and one nested ternary stays an if/else (SIM108). Verified: 388 unit tests, 512 CPU and 512 SPC700 instruction tests pass. Refs #34 --- pysnes/_ss_cache.py | 8 +- pysnes/apu/apu.py | 118 +++--- pysnes/apu/dsp.py | 49 +-- pysnes/apu/spc700/addressing_modes.py | 50 ++- pysnes/apu/spc700/disassembler.py | 18 +- pysnes/apu/spc700/instructions_spc700.py | 1 - pysnes/apu/spc700/opcodes_spc700.py | 3 +- pysnes/apu/test_apu.py | 4 +- pysnes/apu/test_dsp.py | 4 +- pysnes/apu/test_spc700.py | 32 +- pysnes/apu/test_timers.py | 17 +- pysnes/audio/audio_sdl2.py | 10 +- pysnes/bus/bus.py | 104 +++--- pysnes/bus/test_bus.py | 78 ++-- pysnes/bus/test_interrupts.py | 35 +- pysnes/conftest.py | 15 +- pysnes/controller/controller.py | 2 +- pysnes/cpu/cpu.py | 113 +++--- pysnes/cpu/dma.py | 21 +- pysnes/cpu/test_cpu.py | 38 +- pysnes/cpu/test_dma.py | 24 +- .../cpu/wdc65816/addressing_modes/__init__.py | 4 +- .../wdc65816/addressing_modes/decorator.py | 2 +- .../cpu/wdc65816/addressing_modes/modify.py | 3 +- pysnes/cpu/wdc65816/addressing_modes/other.py | 5 +- pysnes/cpu/wdc65816/addressing_modes/pc.py | 2 +- pysnes/cpu/wdc65816/addressing_modes/read.py | 13 +- pysnes/cpu/wdc65816/addressing_modes/write.py | 1 - pysnes/cpu/wdc65816/disassembler.py | 14 +- pysnes/cpu/wdc65816/instructions.py | 9 +- pysnes/cpu/wdc65816/opcodes.py | 342 +++++++++--------- pysnes/debugger/__init__.py | 2 +- pysnes/debugger/debugger.py | 18 +- pysnes/debugger/test_debugger.py | 14 +- pysnes/debugger/window.py | 26 +- pysnes/harness/__init__.py | 35 +- pysnes/harness/_png.py | 3 +- pysnes/harness/cli.py | 7 +- pysnes/harness/test_harness.py | 3 +- pysnes/ppu/bg_renderer.py | 50 +-- pysnes/ppu/color_math.py | 14 +- pysnes/ppu/constants.py | 7 +- pysnes/ppu/oam.py | 4 +- pysnes/ppu/obj_renderer.py | 12 +- pysnes/ppu/ppu.py | 85 ++--- pysnes/ppu/test_ppu.py | 41 ++- pysnes/ppu/test_ppu_bg_window.py | 16 +- pysnes/ppu/test_ppu_bgmode.py | 5 +- pysnes/ppu/test_ppu_color_math.py | 113 +++--- pysnes/ppu/test_ppu_color_math_window.py | 19 +- pysnes/ppu/test_ppu_forced_blank.py | 2 +- pysnes/ppu/test_ppu_mosaic.py | 16 +- pysnes/ppu/test_ppu_registers.py | 11 +- pysnes/ppu/test_ppu_scroll.py | 33 +- pysnes/ppu/test_ppu_sprites.py | 47 ++- pysnes/pysnes.py | 122 ++++--- pysnes/rom/__init__.py | 2 +- pysnes/rom/rom.py | 29 +- pysnes/rom/test_rom.py | 15 +- pysnes/savestate/__init__.py | 10 +- pysnes/savestate/test_savestate.py | 14 +- pysnes/scheduler/scheduler.py | 17 +- pysnes/scheduler/test_scheduler.py | 3 +- pysnes/settings.py | 7 +- pysnes/spc_player.py | 9 +- pysnes/test_integration.py | 37 +- pysnes/video/video_sdl2.py | 36 +- scripts/bench_bus.py | 23 +- scripts/compare_spc_wav.py | 17 +- scripts/debug_frame.py | 24 +- scripts/dump_mesen_layers.py | 5 +- scripts/pysnes_dump_vram.py | 72 ++-- scripts/run_pysnes.py | 7 +- scripts/sfc2sfrom.py | 7 +- scripts/trace_matcher.py | 33 +- setup.py | 18 +- 76 files changed, 1214 insertions(+), 1015 deletions(-) diff --git a/pysnes/_ss_cache.py b/pysnes/_ss_cache.py index 550e3dd..3c3c66b 100644 --- a/pysnes/_ss_cache.py +++ b/pysnes/_ss_cache.py @@ -16,7 +16,6 @@ import os import pickle - CACHE_DIR = ".pytest_cache" @@ -64,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 f9d8dc7..9cb3c85 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: @@ -88,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() @@ -155,20 +147,21 @@ def reset_registers(self): 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) @@ -271,7 +264,7 @@ def load_state(self, d: dict) -> None: 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): @@ -294,29 +287,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 @@ -345,25 +340,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 @@ -401,9 +397,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 @@ -415,9 +409,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 @@ -479,15 +474,8 @@ def _format_trace(self, pc: int, opcode: int) -> str: + ("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, - ) + 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): @@ -500,19 +488,15 @@ def fetch_and_execute(self): 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), - ) + 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 @@ -520,7 +504,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). @@ -531,7 +516,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() diff --git a/pysnes/apu/dsp.py b/pysnes/apu/dsp.py index 7f778bc..bb34c9a 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 @@ -521,8 +522,9 @@ 1305, ) -# 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. _RATE_TABLE = [ 0, 2048, @@ -645,7 +647,7 @@ def read_register(self, addr: int) -> int: reg = addr & 0x0F 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 @@ -749,7 +751,8 @@ def _decode_brr_block(self, voice_idx: int) -> None: # 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 @@ -858,7 +861,8 @@ 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 @@ -868,10 +872,10 @@ def _init_echo(self) -> None: 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 @@ -896,9 +900,9 @@ 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. + # 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) @@ -913,7 +917,8 @@ def generate_samples(self, n_samples: int): # --- 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 + # 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] @@ -940,13 +945,11 @@ def generate_samples(self, n_samples: int): hist3 = v.hist3 active = True - # pre-decode envelope mode so the inner loop avoids repeated branches + # 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 - else: - fixed_env = 0 # unused + fixed_env = (gain_reg & 0x7F) << 4 if gain_dir else 0 gain_mode = ( (gain_reg >> 5) & 0x3 if (not adsr_on and not gain_dir) else 0 ) @@ -1071,8 +1074,8 @@ 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 @@ -1136,9 +1139,9 @@ def generate_samples(self, n_samples: int): 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_r = np.clip((right_arr * mvolr) >> 7, -32768, 32767) diff --git a/pysnes/apu/spc700/addressing_modes.py b/pysnes/apu/spc700/addressing_modes.py index 1efc46d..de14f81 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -2,7 +2,8 @@ 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 @@ -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() @@ -115,7 +118,7 @@ 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 = ( @@ -146,7 +149,7 @@ 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 = ( @@ -155,8 +158,8 @@ def BranchNotDirectIndexed(self, reg_index): 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() @@ -204,7 +207,8 @@ def CallPage(self): 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) @@ -271,7 +275,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() @@ -334,7 +339,8 @@ def DirectReadWord(self, func): 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 @@ -375,7 +381,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 @@ -449,7 +456,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() @@ -471,7 +479,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 @@ -608,13 +617,16 @@ 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) @@ -638,7 +650,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 7ebd5b5..80fc175 100644 --- a/pysnes/apu/spc700/disassembler.py +++ b/pysnes/apu/spc700/disassembler.py @@ -99,7 +99,7 @@ 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 @@ -295,12 +295,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}") @@ -408,14 +403,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 4b394d2..ed5dd2e 100644 --- a/pysnes/apu/spc700/instructions_spc700.py +++ b/pysnes/apu/spc700/instructions_spc700.py @@ -1,7 +1,6 @@ from .addressing_modes import SPC700AddressingModes from .opcodes_spc700 import SPC700Opcodes - INSTRUCTIONS = ( (0x00, SPC700AddressingModes.NoOperation), (0x01, SPC700AddressingModes.CallTable, 0), diff --git a/pysnes/apu/spc700/opcodes_spc700.py b/pysnes/apu/spc700/opcodes_spc700.py index feeef5d..620d46b 100644 --- a/pysnes/apu/spc700/opcodes_spc700.py +++ b/pysnes/apu/spc700/opcodes_spc700.py @@ -3,7 +3,8 @@ 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 """ diff --git a/pysnes/apu/test_apu.py b/pysnes/apu/test_apu.py index cd66034..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() """ diff --git a/pysnes/apu/test_dsp.py b/pysnes/apu/test_dsp.py index e68b4f4..8809abf 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): diff --git a/pysnes/apu/test_spc700.py b/pysnes/apu/test_spc700.py index e4c4c00..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,7 +29,7 @@ def _load_case(file_path: str, index: int) -> dict: global _FILE_CACHE_KEY, _FILE_CACHE_VAL - if _FILE_CACHE_KEY != file_path: + 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 @@ -130,11 +131,11 @@ def test_spc700(test_case): 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) @@ -151,12 +152,12 @@ 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"], ( + 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'])}" ) @@ -178,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 ef79b78..fecdf5d 100644 --- a/pysnes/apu/test_timers.py +++ b/pysnes/apu/test_timers.py @@ -25,8 +25,7 @@ import pytest -from .apu import Apu, Timer - +from .apu import Apu # --------------------------------------------------------------------------- # Fixtures @@ -160,13 +159,15 @@ def test_counter_wraps_at_4_bits(apu: Apu): 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) @@ -239,7 +240,8 @@ def test_timers_enable_false_inhibits_all(apu: Apu): 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 @@ -252,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 9b35403..5fff930 100644 --- a/pysnes/audio/audio_sdl2.py +++ b/pysnes/audio/audio_sdl2.py @@ -1,9 +1,11 @@ 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 @@ -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, @@ -54,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) diff --git a/pysnes/bus/bus.py b/pysnes/bus/bus.py index a8936cc..61b458c 100644 --- a/pysnes/bus/bus.py +++ b/pysnes/bus/bus.py @@ -1,12 +1,10 @@ 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 @@ -17,7 +15,7 @@ def __init__( 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) @@ -26,8 +24,9 @@ def __init__( 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. + # 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 @@ -88,7 +87,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: @@ -100,7 +100,8 @@ 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: @@ -111,9 +112,9 @@ def save_sram(self, path: str) -> int: 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: @@ -182,7 +183,8 @@ def read(self, abs_addr: int) -> int: ) 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 @@ -195,11 +197,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 @@ -263,7 +264,9 @@ def read(self, abs_addr: int) -> int: data = ( self.cpu.status.nmi_line << 7 | 1 - << 6 # This bit is open bus, I'm setting it to satisfy the PLP test program + # 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 @@ -279,7 +282,9 @@ def read(self, abs_addr: int) -> int: return ( ( 1 << 5 - ) # This bit is unmapped but the test program keeps reading it + # This bit is unmapped but the test program keeps + # reading it + ) | self.hblank << 6 | self.vblank << 7 ) @@ -305,8 +310,9 @@ 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 @@ -378,7 +384,8 @@ def write(self, abs_addr: int, data: int) -> None: ): 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 @@ -386,10 +393,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: @@ -417,7 +424,9 @@ def write(self, abs_addr: int, data: int) -> None: ] self.ppu.mosaic_size = ( (data >> 4) + 1 - ) # Not sure if I should add 1 here - (0=Smallest/1x1, 0Fh=Largest/16x16) + # Not sure if I should add 1 here - (0=Smallest/1x1, + # 0Fh=Largest/16x16) + ) return if addr == 0x2105: # BGMODE @@ -520,8 +529,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) @@ -596,13 +605,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 @@ -718,16 +729,18 @@ def write(self, abs_addr: int, data: int) -> None: 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 @@ -773,7 +786,8 @@ def write(self, abs_addr: int, data: int) -> None: 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: diff --git a/pysnes/bus/test_bus.py b/pysnes/bus/test_bus.py index 4bf9091..9876afd 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 @@ -166,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 @@ -220,7 +218,7 @@ def test_ppu_bgmode_write(): # --------------------------------------------------------------------------- -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 @@ -303,15 +301,16 @@ def test_low_ram_boundary_first_byte(): 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 @@ -338,8 +337,9 @@ def test_lorom_region2_boundary_bank40(): 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 @@ -358,7 +358,8 @@ def test_lorom_region3_not_ram(): 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 @@ -374,9 +375,10 @@ def test_joyser0_write_latches_controller(): 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) + # 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 + assert val in range(0x100) # sanity: valid byte returned # --------------------------------------------------------------------------- @@ -434,7 +436,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 @@ -612,7 +615,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) @@ -625,7 +629,8 @@ 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 @@ -667,13 +672,15 @@ def test_rdvramh_routes_to_ppu(): 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 @@ -764,7 +771,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 @@ -792,14 +800,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 @@ -891,7 +901,8 @@ def test_hirom_bank_00_hw_registers_still_work(): 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 @@ -911,7 +922,8 @@ def test_multiply_overflow(): 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 diff --git a/pysnes/bus/test_interrupts.py b/pysnes/bus/test_interrupts.py index a255da7..a01958c 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) @@ -142,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 @@ -218,7 +217,8 @@ def test_rdnmi_bit7_persists_through_vblank_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) @@ -252,26 +252,28 @@ def test_irq_flags_independent_of_nmi(): 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}" @@ -296,7 +298,8 @@ 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) 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 e6e3b6d..33eca44 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -1,10 +1,10 @@ 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: @@ -12,13 +12,15 @@ 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 @@ -68,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 @@ -135,7 +137,7 @@ 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() @@ -149,7 +151,11 @@ def __init__(self, hardware_vectors: "HardwareVectors") -> None: 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 @@ -159,7 +165,8 @@ def reset_registers(self): 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.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 @@ -186,8 +193,8 @@ def reset_registers(self): ) 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 @@ -267,13 +274,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: @@ -293,7 +301,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) @@ -471,38 +480,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 @@ -511,32 +526,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 @@ -596,7 +614,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 e2794f2..026d692 100644 --- a/pysnes/cpu/dma.py +++ b/pysnes/cpu/dma.py @@ -1,5 +1,5 @@ -from typing import TYPE_CHECKING from dataclasses import dataclass +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..bus import Bus @@ -101,7 +101,8 @@ def do_transfer(self) -> None: count = self.transfer_size if self.transfer_size else 0x10000 offsets = _HDMA_TARGET_OFFSETS[self.transfer_mode] unit_len = len(offsets) - if self.fixed_transfer: + # Left as if/else: the ternary form nests two levels and reads worse. + if self.fixed_transfer: # noqa: SIM108 step = 0 else: step = -1 if self.reverse_transfer else 1 @@ -136,7 +137,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: @@ -238,10 +239,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: @@ -253,7 +254,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: @@ -321,7 +323,8 @@ def hdma_scanline(self) -> None: 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 a7c8e32..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,7 +19,7 @@ def _load_case(file_path: str, index: int) -> dict: global _FILE_CACHE_KEY, _FILE_CACHE_VAL - if _FILE_CACHE_KEY != file_path: + 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 @@ -27,7 +27,8 @@ def _load_case(file_path: str, index: int) -> dict: 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,9 +42,7 @@ 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) @@ -85,8 +84,8 @@ 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 @@ -118,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: @@ -137,9 +137,9 @@ 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 @@ -191,8 +191,8 @@ def write(self, address, value): 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 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'])}" diff --git a/pysnes/cpu/test_dma.py b/pysnes/cpu/test_dma.py index f48a995..3f7344b 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 @@ -293,7 +291,8 @@ def test_hdmaen_clears_when_zero(): 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) @@ -307,7 +306,8 @@ 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]) @@ -341,7 +341,8 @@ def test_hdma_non_repeat_end_of_table_stops(): 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]) @@ -421,7 +422,8 @@ def _setup_hdma_indirect_channel0( 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( diff --git a/pysnes/cpu/wdc65816/addressing_modes/__init__.py b/pysnes/cpu/wdc65816/addressing_modes/__init__.py index e645f4a..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 diff --git a/pysnes/cpu/wdc65816/addressing_modes/decorator.py b/pysnes/cpu/wdc65816/addressing_modes/decorator.py index 8e77e93..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 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 6ae4e36..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 @@ -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 afd6d68..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 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 ee77417..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 @@ -45,9 +46,16 @@ def disassemble(self, address: int) -> str: _, 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" diff --git a/pysnes/cpu/wdc65816/instructions.py b/pysnes/cpu/wdc65816/instructions.py index 2b9766a..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,9 +11,9 @@ 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), diff --git a/pysnes/cpu/wdc65816/opcodes.py b/pysnes/cpu/wdc65816/opcodes.py index bd95aeb..b393b75 100644 --- a/pysnes/cpu/wdc65816/opcodes.py +++ b/pysnes/cpu/wdc65816/opcodes.py @@ -26,48 +26,47 @@ def ADC(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -76,11 +75,10 @@ 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): @@ -90,12 +88,11 @@ def ASL(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -104,11 +101,10 @@ 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): @@ -118,12 +114,11 @@ def CMP(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -133,12 +128,11 @@ def CPX(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -148,12 +142,11 @@ def CPY(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -162,11 +155,10 @@ def DEC(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 EOR(cpu: Cpu, mode_8bit: bool, data: int): @@ -175,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): @@ -188,11 +179,10 @@ 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): @@ -201,11 +191,10 @@ def LDA(cpu: Cpu, mode_8bit: bool, data: int): 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 + 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): @@ -214,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): @@ -227,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): @@ -241,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): @@ -255,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): @@ -270,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): @@ -287,13 +271,12 @@ 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): @@ -325,50 +308,49 @@ def SBC(cpu: Cpu, mode_8bit: bool, data: int): 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): @@ -376,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): @@ -387,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 a3336c9..11bedc1 100644 --- a/pysnes/debugger/debugger.py +++ b/pysnes/debugger/debugger.py @@ -80,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() @@ -115,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() @@ -133,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": @@ -143,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): @@ -156,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 diff --git a/pysnes/debugger/test_debugger.py b/pysnes/debugger/test_debugger.py index 40cd283..f5ff687 100644 --- a/pysnes/debugger/test_debugger.py +++ b/pysnes/debugger/test_debugger.py @@ -9,8 +9,7 @@ import pytest -from .debugger import Debugger, BreakpointHit - +from .debugger import BreakpointHit, Debugger # --------------------------------------------------------------------------- # Minimal stubs @@ -219,7 +218,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 @@ -317,7 +317,8 @@ def test_drain_processes_all_queued_commands(debugger, pysnes): 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) @@ -348,7 +349,7 @@ def fake_run_to(target): # --------------------------------------------------------------------------- -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 @@ -379,6 +380,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 6381583..649d864 100644 --- a/pysnes/debugger/window.py +++ b/pysnes/debugger/window.py @@ -13,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"] @@ -29,7 +29,9 @@ 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)) + return "".join( + n if p & b else n.lower() for n, b in zip(names, bits, strict=True) + ) def _apu_flags_str(apu) -> str: @@ -204,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() @@ -238,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" @@ -377,7 +381,8 @@ def _refresh_memory(self) -> None: 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}" + f"{region}:{base + row:06X} {hex_part:<{_HEX_COLS * 3}} " + f"{ascii_part}" ) self._set_text(self._mem_text, "\n".join(lines)) @@ -396,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]) @@ -484,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 113c39b..edb1273 100644 --- a/pysnes/harness/__init__.py +++ b/pysnes/harness/__init__.py @@ -18,8 +18,9 @@ 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 @@ -50,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 @@ -73,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. @@ -119,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 @@ -127,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. @@ -153,7 +155,8 @@ def run_to_scanline(self, v: int) -> None: ) 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 @@ -162,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) @@ -175,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: @@ -209,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 @@ -388,17 +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 e68a932..ef89a7c 100644 --- a/pysnes/harness/_png.py +++ b/pysnes/harness/_png.py @@ -20,7 +20,8 @@ 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 diff --git a/pysnes/harness/cli.py b/pysnes/harness/cli.py index 663fa49..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. diff --git a/pysnes/harness/test_harness.py b/pysnes/harness/test_harness.py index bf29b05..c56cc92 100644 --- a/pysnes/harness/test_harness.py +++ b/pysnes/harness/test_harness.py @@ -92,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 9563897..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 @@ -119,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 @@ -172,15 +169,14 @@ def draw_background_scanline( 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 @@ -241,7 +237,8 @@ def draw_background_scanline( 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 @@ -346,7 +343,8 @@ def draw_background_scanline( 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) * ( @@ -379,7 +377,8 @@ def draw_background_scanline( 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 @@ -506,7 +505,8 @@ def draw_background_scanline( 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 @@ -536,12 +536,14 @@ def _bg_window_config(ppu: Ppu, bg_idx: int): 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 diff --git a/pysnes/ppu/color_math.py b/pysnes/ppu/color_math.py index 4536231..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: @@ -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 @@ -130,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: diff --git a/pysnes/ppu/constants.py b/pysnes/ppu/constants.py index 3079a5b..07acb83 100644 --- a/pysnes/ppu/constants.py +++ b/pysnes/ppu/constants.py @@ -8,13 +8,14 @@ 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). +# 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 diff --git a/pysnes/ppu/oam.py b/pysnes/ppu/oam.py index 7f72e79..793647e 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)] diff --git a/pysnes/ppu/obj_renderer.py b/pysnes/ppu/obj_renderer.py index 6a32052..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 @@ -118,7 +117,9 @@ def _render_obj_line(ppu: Ppu) -> None: 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 + # 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) @@ -183,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 @@ -208,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 fb0182e..1342453 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -1,23 +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, + _MC_PER_SCANLINE, _TOTAL_SCANLINES, + _VBLANK_START_LINE, SCREEN_WIDTH, - SCREEN_HEIGHT, ) 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: @@ -37,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: @@ -68,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) @@ -145,8 +143,8 @@ def __init__( 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. + # 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 @@ -158,9 +156,10 @@ def __init__( # 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) @@ -172,7 +171,8 @@ 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. + # 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) @@ -278,9 +278,10 @@ def inidisp_set(self, data: int) -> None: 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. + # 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): @@ -359,24 +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 + if m == 1: # aaaaaaaaBBBccccc → aaaaaaaacccccBBB return ( (addr & 0xFF00) | ((addr & 0x001F) << 3) | ((addr & 0x00E0) >> 5) ) - elif m == 2: # aaaaaaaBBBcccccc → aaaaaaaccccccBBB + if 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) - ) + # aaaaaaBBBccccccc → aaaaaacccccccBBB + return (addr & 0xFC00) | ((addr & 0x007F) << 3) | ((addr & 0x0380) >> 7) def write_vram(self) -> None: word_addr = (self.vmaddl | self.vmaddh << 8) & 0x7FFF @@ -456,8 +453,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: @@ -601,7 +600,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 @@ -866,7 +866,8 @@ def _render_layers(self): ) # BG1 pri 1 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 @@ -905,11 +906,11 @@ def _render_layers(self): ) # BG1 pri 1 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 @@ -942,8 +943,9 @@ def _render_layers(self): ) # 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) @@ -960,7 +962,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 1984523..2803cc5 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 @@ -48,7 +48,8 @@ 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}) @@ -59,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): @@ -97,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) @@ -133,7 +138,8 @@ 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") @@ -214,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%)." ), @@ -238,7 +246,9 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: 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", + reason=( + "Modes 3/4 (8BPP/OPT) and 5/6 (hi-res) not fully implemented" + ), strict=False, ), id="ppubusact", @@ -247,7 +257,7 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: @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 @@ -255,7 +265,8 @@ def _run_mesen(mesen: str, rom_path: Path, n_frames: int) -> list: ], ) 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 diff --git a/pysnes/ppu/test_ppu_bg_window.py b/pysnes/ppu/test_ppu_bg_window.py index e284679..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,8 +14,6 @@ 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 @@ -105,7 +103,8 @@ def test_w1_invert0_masks_inside(self): 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 @@ -128,8 +127,8 @@ def test_w1_invert1_masks_outside(self): 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. @@ -167,7 +166,8 @@ 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 _draw_row_with_backdrop(ppu, scanline=1) diff --git a/pysnes/ppu/test_ppu_bgmode.py b/pysnes/ppu/test_ppu_bgmode.py index 884533b..d9c2139 100644 --- a/pysnes/ppu/test_ppu_bgmode.py +++ b/pysnes/ppu/test_ppu_bgmode.py @@ -9,8 +9,6 @@ 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 @@ -186,7 +184,8 @@ def test_2bpp_tile_fetch_wraps_at_0xffff(self): 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 diff --git a/pysnes/ppu/test_ppu_color_math.py b/pysnes/ppu/test_ppu_color_math.py index 6bed1e4..193969b 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 @@ -52,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 @@ -93,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) @@ -138,10 +138,12 @@ def test_sub_bgs_same_size_as_main(self): 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( @@ -159,7 +161,8 @@ def test_main_only_bg_does_not_touch_sub(self): ) 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( @@ -195,16 +198,19 @@ def test_dual_screen_bg_renders_to_both(self): 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) @@ -212,18 +218,21 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: # 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] = ( @@ -249,42 +258,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 @@ -292,9 +308,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)" ) @@ -309,7 +327,8 @@ class TestColorMathSubtract: 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.""" + """BG1 on main = bright, BG2 on sub = mid-gray. Subtract bit targets + BG1.""" ppu.bg1.screen_addr = 0 ppu.bg2.screen_addr = 0x800 @@ -349,10 +368,12 @@ 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.""" @@ -366,7 +387,8 @@ def test_subtract_clamps_at_zero(self): 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 @@ -420,7 +442,8 @@ def _setup_half_scene( 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) @@ -429,9 +452,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.""" @@ -442,9 +466,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 f616c39..edc079f 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. @@ -75,7 +76,8 @@ def _setup_backdrop_add_bg2(ppu: Ppu) -> None: ppu, 0, 1 ) # tile 0 → color_index=1 → palette color 33 for BG2 pal 2 - # 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) @@ -140,7 +142,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)}" ) @@ -148,7 +151,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 @@ -161,7 +165,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 62de851..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]: diff --git a/pysnes/ppu/test_ppu_mosaic.py b/pysnes/ppu/test_ppu_mosaic.py index 9cc9b28..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 @@ -56,7 +55,8 @@ def _write_4bpp_tile_horizontal_gradient( 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) @@ -104,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] @@ -117,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): @@ -135,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 73f0794..6a5df82 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 @@ -77,7 +75,8 @@ 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 @@ -91,7 +90,8 @@ def test_oamaddl_preserves_high_bit_across_oamaddh_write(self): 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 VRAM word address and write a word (low, high) triggering + write_vram.""" ppu.vmaddl = word_addr & 0xFF ppu.vmaddh = (word_addr >> 8) & 0xFF ppu.vmdatal = low @@ -192,7 +192,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) diff --git a/pysnes/ppu/test_ppu_scroll.py b/pysnes/ppu/test_ppu_scroll.py index 0376a43..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 @@ -150,7 +148,8 @@ def test_full_tile_shift(self): 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) @@ -212,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) @@ -222,7 +222,8 @@ 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 @@ -239,7 +240,8 @@ def test_vertical_wrap(self): 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 @@ -259,8 +261,8 @@ def _setup_palette_test(self, ppu: "Ppu", palette: int) -> None: _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[1] = high @@ -296,7 +298,8 @@ def test_palette1_renders_green(self): ) 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 @@ -316,7 +319,8 @@ def test_priority0_renders_on_low_pass(self): ) 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. @@ -379,7 +383,8 @@ def _solid_red_bg1(self, ppu): 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 @@ -393,7 +398,8 @@ def test_no_window_all_pixels_drawn(self): 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 @@ -410,7 +416,8 @@ def test_window_masks_inside_region(self): 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 diff --git a/pysnes/ppu/test_ppu_sprites.py b/pysnes/ppu/test_ppu_sprites.py index 78f8758..68ea091 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 @@ -177,7 +175,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) @@ -190,7 +189,7 @@ def test_sprite_off_screen_left_no_crash(self): ) # must not raise IndexError # 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): @@ -209,9 +208,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): @@ -300,10 +300,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) @@ -327,7 +329,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) @@ -339,7 +342,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) @@ -426,10 +430,10 @@ def _setup_bg1_tile_over_sprite( _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 @@ -463,7 +467,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): @@ -548,7 +553,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): @@ -572,5 +578,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 fa21ef7..aca9fcd 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -1,27 +1,28 @@ 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: @@ -157,12 +158,14 @@ def _do_memory_dump(self): 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)", + 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 @@ -175,13 +178,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() @@ -190,10 +195,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) @@ -205,7 +212,7 @@ 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") + self._trace_ref = open(ref_path) # noqa: SIM115 print( f"CPU trace started ({mode_str}, ref: {ref_path})", flush=True, @@ -239,7 +246,8 @@ def start_trace_from(self, addr: int, limit: int = 100_000) -> None: self._trace_active = False self.cpu.trace_enabled = True print( - f"[trace] waiting for PC=0x{addr:06X} (limit {limit} instructions) …", + f"[trace] waiting for PC=0x{addr:06X} (limit {limit} " + "instructions) …", flush=True, ) @@ -251,7 +259,9 @@ 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") + pysnes._trace_file = open( # noqa: SIM115 + "cpu_trace.log", "w" + ) print( f"[trace] triggered at PC=0x{addr:06X} → cpu_trace.log", flush=True, @@ -282,8 +292,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}" ) @@ -292,7 +304,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() @@ -301,7 +312,8 @@ def _check_trace(self): elif self._trace_file: self._trace_file.write(line + "\n") - # Compare against reference (streaming mode only; up to first divergence) + # Compare against reference (streaming mode only; up to first + # divergence) if ( self._trace_ring is None and not self._trace_diverged @@ -314,7 +326,8 @@ def _check_trace(self): ref_line = ref_line.rstrip() if line[:6].lower() != ref_line[:6].lower(): print( - f"\n*** TRACE DIVERGENCE at instruction {self._trace_count} ***", + "\n*** TRACE DIVERGENCE at instruction " + f"{self._trace_count} ***", flush=True, ) print(f" OUR: {line}", flush=True) @@ -338,7 +351,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) @@ -348,7 +362,7 @@ 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") + self._apu_trace_ref = open(ref_path) # noqa: SIM115 print( f"APU trace started ({mode_str}, ref: {ref_path})", flush=True, @@ -386,7 +400,7 @@ def _check_apu_trace(self, line: str): 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 @@ -396,7 +410,8 @@ 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 @@ -416,28 +431,31 @@ 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 @@ -459,14 +477,16 @@ def main(self): 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", + 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: @@ -540,7 +560,7 @@ def process_inputs(self): if self.event.type == sdl.SDL_QUIT: self.running = False return - elif self.event.type == sdl.SDL_KEYUP: + if self.event.type == sdl.SDL_KEYUP: self.controllers[0].pressed_keys.discard( self.event.key.keysym.sym ) @@ -590,7 +610,9 @@ def main(): metavar="N", type=int, default=0, - help="Keep only the last N trace lines; written to cpu_trace.log on exit", + help=( + "Keep only the last N trace lines; written to cpu_trace.log on exit" + ), ) parser.add_argument( "--trace-from", 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 2fd1181..9db9870 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 @@ -66,14 +65,15 @@ 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 @@ -96,11 +96,11 @@ class HardwareVectors: @dataclass class SnesHeader: game_title: str - mapping_mode: Union[MappingMode, int] - cartridge_type: Union[CartridgeType, int] + 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: Union[Region, int] # $FFD9 — region, NOT developer ID + destination_code: Region | int # $FFD9 — region, NOT developer ID version: int checksum_complement: int # 16-bit LE checksum: int # 16-bit LE @@ -129,9 +129,10 @@ 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 @@ -153,7 +154,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})" ) @@ -223,9 +225,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 172b65d..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 @@ -102,7 +99,8 @@ def _make(image: bytes, name: str = "test.sfc") -> Rom: 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}, ) @@ -164,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 @@ -249,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") @@ -277,4 +277,5 @@ def test_header_dataclasses_are_typed(): ) # 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 9ee4da4..143060e 100644 --- a/pysnes/savestate/__init__.py +++ b/pysnes/savestate/__init__.py @@ -181,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", "") @@ -189,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 " @@ -205,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." ) @@ -222,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 2071687..e18a13e 100644 --- a/pysnes/savestate/test_savestate.py +++ b/pysnes/savestate/test_savestate.py @@ -109,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: @@ -126,17 +127,18 @@ def test_file_format_version_mismatch(tmp_path): blob[o : o + 4] = struct.pack(" None: @@ -29,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) @@ -57,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__)) @@ -69,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"] @@ -84,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 2b4f9b3..c511d7d 100644 --- a/pysnes/scheduler/test_scheduler.py +++ b/pysnes/scheduler/test_scheduler.py @@ -56,7 +56,8 @@ 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(): diff --git a/pysnes/settings.py b/pysnes/settings.py index 793c9e1..8815cfb 100644 --- a/pysnes/settings.py +++ b/pysnes/settings.py @@ -18,14 +18,17 @@ 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 213a47e..cf14235 100644 --- a/pysnes/spc_player.py +++ b/pysnes/spc_player.py @@ -46,11 +46,12 @@ def _init_sdl(self) -> None: 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 0f2296f..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" @@ -54,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" ) @@ -69,7 +73,8 @@ 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 = [] @@ -150,7 +155,7 @@ def oracle_frames(request): 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 @@ -228,7 +233,8 @@ def pysnes_frames(request): 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 = [] @@ -250,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}:"] @@ -267,14 +274,16 @@ 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}" ) @@ -377,7 +386,9 @@ def close(self): 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_sdl2.py b/pysnes/video/video_sdl2.py index f443e9b..c955ea8 100644 --- a/pysnes/video/video_sdl2.py +++ b/pysnes/video/video_sdl2.py @@ -3,10 +3,10 @@ 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: @@ -15,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""" @@ -41,7 +41,8 @@ def initialize(self, window) -> None: if not self.renderer: raise RuntimeError( - f"Failed to create SDL2 renderer: {sdl.SDL_GetError().decode()}" + "Failed to create SDL2 renderer: " + f"{sdl.SDL_GetError().decode()}" ) # Get renderer info for debugging (stored, not printed) @@ -62,7 +63,8 @@ def initialize(self, window) -> None: if not self.texture: raise RuntimeError( - f"Failed to create SDL2 texture: {sdl.SDL_GetError().decode()}" + "Failed to create SDL2 texture: " + f"{sdl.SDL_GetError().decode()}" ) # Set texture blend mode for proper alpha blending @@ -75,7 +77,9 @@ def initialize(self, window) -> None: # 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.""" @@ -159,7 +163,6 @@ 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""" @@ -180,9 +183,8 @@ def get_performance_info(self) -> dict: "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"), - } + 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 26805f1..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 @@ -74,8 +74,8 @@ def make_bus(): REPEAT = 3 CASES = [ - # (label, setup, stmt) - # --- via __getitem__/__setitem__ (Python slot dispatch → thin wrapper → read/write) --- + # (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)"), @@ -84,7 +84,8 @@ def make_bus(): ("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) --- + # --- 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)"), diff --git a/scripts/compare_spc_wav.py b/scripts/compare_spc_wav.py index 31b74bb..1b11e28 100644 --- a/scripts/compare_spc_wav.py +++ b/scripts/compare_spc_wav.py @@ -2,7 +2,8 @@ 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 @@ -10,18 +11,16 @@ """ 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 _DSP_DIV = 32 @@ -42,7 +41,8 @@ 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)..." + f"Dumping PySNES: {duration_s:.1f}s ({total_samples} samples @ " + f"{_AUDIO_HZ} Hz)..." ) while collected < total_samples: clocks_left = apu_per_chunk @@ -72,7 +72,8 @@ def load_reference( 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)" + 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 @@ -120,7 +121,7 @@ def compare(ours: np.ndarray, ref: np.ndarray, rate: int) -> None: 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)) diff --git a/scripts/debug_frame.py b/scripts/debug_frame.py index 9937f55..ed96db2 100644 --- a/scripts/debug_frame.py +++ b/scripts/debug_frame.py @@ -1,10 +1,10 @@ -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") scheduler = Scheduler() @@ -32,7 +32,9 @@ def _patched_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}" + 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 @@ -44,7 +46,9 @@ 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}" + 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) @@ -53,7 +57,7 @@ def _patched_set(self, abs_addr, data): 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 @@ -75,8 +79,10 @@ def _patched_set(self, abs_addr, data): 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}" + 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)} apu.PC={hex(apu.PC)}" + 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 a0d9a0d..fd21c9a 100644 --- a/scripts/dump_mesen_layers.py +++ b/scripts/dump_mesen_layers.py @@ -20,6 +20,7 @@ """ import argparse +import contextlib import json import os import struct @@ -56,12 +57,10 @@ def mesen_bin() -> str: ] cfg_path = REPO_ROOT / "settings.json" if cfg_path.exists(): - try: + with contextlib.suppress(Exception): candidates.insert( 1, json.loads(cfg_path.read_text()).get("mesen_bin") ) - except Exception: - pass for c in candidates: if c and Path(c).is_file() and os.access(c, os.X_OK): return c diff --git a/scripts/pysnes_dump_vram.py b/scripts/pysnes_dump_vram.py index 166cadd..156c2ae 100644 --- a/scripts/pysnes_dump_vram.py +++ b/scripts/pysnes_dump_vram.py @@ -70,42 +70,42 @@ def main() -> int: "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, - ), + "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) diff --git a/scripts/run_pysnes.py b/scripts/run_pysnes.py index a7fadb0..0db2061 100644 --- a/scripts/run_pysnes.py +++ b/scripts/run_pysnes.py @@ -5,15 +5,15 @@ """ import argparse -import sys import os +import sys # Add the project root to Python path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from pysnes.pysnes import main, PySNES import sdl2 as sdl +from pysnes.pysnes import PySNES, main if __name__ == "__main__": parser = argparse.ArgumentParser(description="Python SNES emulator.") @@ -40,8 +40,8 @@ # cProfile.run("main()", sort="cumulative") - import pstats import cProfile + import pstats import pyximport @@ -55,7 +55,6 @@ s = pstats.Stats("Profile.prof") s.sort_stats("time").print_stats(50) - pass elif args.load: rom = args.rom or "roms/Super Mario World (U) [!].smc" pysnes = PySNES(rom) diff --git a/scripts/sfc2sfrom.py b/scripts/sfc2sfrom.py index 8289ea0..1364f7c 100644 --- a/scripts/sfc2sfrom.py +++ b/scripts/sfc2sfrom.py @@ -40,9 +40,9 @@ def get_preset_id(data, header_page): if data[header_page + 0x0FD6] == 0x03: return 0x10BD # DSP-1 Games - elif name == "MEGAMAN X2": + if name == "MEGAMAN X2": return 0x1117 - elif name == "MEGAMAN X3": + if name == "MEGAMAN X3": return 0x113D # Nothing special @@ -59,8 +59,7 @@ def get_super_fx(data, header_page): if data[addr] in SFX_TYPES: return 0x0C - else: - return 0x0 + return 0x0 if __name__ == "__main__": diff --git a/scripts/trace_matcher.py b/scripts/trace_matcher.py index 8ae5d02..360fde6 100644 --- a/scripts/trace_matcher.py +++ b/scripts/trace_matcher.py @@ -12,36 +12,35 @@ def check_trace_line(line: str, cpu: Cpu): assert actual == expected, f"{actual!r} != {expected!r}" PC = int(line[:6], 16) - assert PC == cpu.PC.value, "{:06X} != {:06X}".format(PC, cpu.PC.value) + assert cpu.PC.value == PC, f"{PC:06X} != {cpu.PC.value:06X}" A = int(line[33:37], 16) - assert A == cpu.A.value, "{:04X} != {:04X}".format(A, cpu.A.value) + assert cpu.A.value == A, f"{A:04X} != {cpu.A.value:04X}" X = int(line[40:44], 16) - assert X == cpu.X.value, "{:04X} != {:04X}".format(X, cpu.X.value) + assert cpu.X.value == X, f"{X:04X} != {cpu.X.value:04X}" Y = int(line[47:51], 16) - assert Y == cpu.Y.value, "{:04X} != {:04X}".format(Y, cpu.Y.value) + assert cpu.Y.value == Y, f"{Y:04X} != {cpu.Y.value:04X}" S = int(line[54:59], 16) - assert S == cpu.S.value, "{:04X} != {:04X}".format(S, cpu.S.value) + assert cpu.S.value == S, f"{S:04X} != {cpu.S.value:04X}" NF = line[72] == "N" - assert NF == cpu.NFlag, f"{NF} != {cpu.NFlag} {line=} {disassembled=}" + assert cpu.NFlag == NF, f"{NF} != {cpu.NFlag} {line=} {disassembled=}" VF = line[73] == "V" - assert VF == cpu.VFlag, f"{VF} != {cpu.VFlag} {line=} {disassembled=}" + assert cpu.VFlag == VF, f"{VF} != {cpu.VFlag} {line=} {disassembled=}" IF = line[77] == "I" - assert IF == cpu.IFlag, f"{IF} != {cpu.IFlag} {line=} {disassembled=}" + assert cpu.IFlag == IF, f"{IF} != {cpu.IFlag} {line=} {disassembled=}" ZF = line[78] == "Z" - assert ZF == cpu.ZFlag, f"{ZF} != {cpu.ZFlag} {line=} {disassembled=}" + assert cpu.ZFlag == ZF, f"{ZF} != {cpu.ZFlag} {line=} {disassembled=}" CF = line[79] == "C" - assert CF == cpu.CFlag, f"{CF} != {cpu.CFlag} {line=} {disassembled=}" + assert cpu.CFlag == CF, f"{CF} != {cpu.CFlag} {line=} {disassembled=}" EF = line[74] == "1" assert EF == cpu.EF, f"{EF} != {cpu.EF}" - if cpu.EF: - BF = line[75] == "B" - # TODO: Fix this assertion - # assert BF == bool(cpu.status.), f"{BF} != {cpu.P.B}" - else: + # TODO: in emulation mode (cpu.EF), assert the B flag too: + # BF = line[75] == "B" + # assert BF == bool(cpu.status.), f"{BF} != {cpu.P.B}" + if not cpu.EF: MF = line[74] == "M" - assert MF == cpu.MFlag, f"{MF} != {cpu.MFlag}" + assert cpu.MFlag == MF, f"{MF} != {cpu.MFlag}" XF = line[75] == "X" - assert XF == cpu.XFlag, f"{XF} != {cpu.XFlag}" + assert cpu.XFlag == XF, f"{XF} != {cpu.XFlag}" diff --git a/setup.py b/setup.py index 489f52c..aab3d6f 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,16 @@ -from setuptools import setup, Extension -from Cython.Build import cythonize import glob -import pathlib import multiprocessing +import pathlib + +from Cython.Build import cythonize +from setuptools import Extension, setup -# Modules excluded from Cython compilation: PyPy JIT cannot trace through compiled -# C extension call boundaries. The CPU/bus hot path (cpu.py, bus.py, dma.py, -# wdc65816/*.py) calls across module boundaries on every instruction cycle, so -# compiling them creates JIT trace breaks and causes a severe performance regression. -# These are left as pure Python for PyPy to JIT natively. +# Modules excluded from Cython compilation: PyPy JIT cannot trace through +# compiled C extension call boundaries. The CPU/bus hot path (cpu.py, bus.py, +# dma.py, wdc65816/*.py) calls across module boundaries on every instruction +# cycle, so compiling them creates JIT trace breaks and causes a severe +# performance regression. These are left as pure Python for PyPy to JIT +# natively. EXCLUDE_FROM_CYTHON = { "pysnes/cpu/cpu.py", "pysnes/cpu/dma.py", From 2d236699ed26a57dc01a7d2a4cc8449f46ed35c6 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Fri, 7 Aug 2026 19:09:56 -0300 Subject: [PATCH 4/9] chore: pin ruff exactly and exclude markdown from formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev group resolved to 0.16.2 while CI pinned 0.15.20, so the two disagreed about formatting. Pin one exact version in both places. 0.16 formats Python blocks inside .md files, which rewrites the hand-tuned examples in AGENTS.md — exclude markdown. Refs #34 --- .github/workflows/lint.yml | 4 ++-- pyproject.toml | 10 +++++++--- uv.lock | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5d6f4c2..960c103 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,6 +15,6 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 - name: ruff check - run: uvx ruff@0.15.20 check --output-format=github . + run: uvx ruff@0.16.2 check --output-format=github . - name: ruff format --check - run: uvx ruff@0.15.20 format --check . + run: uvx ruff@0.16.2 format --check . diff --git a/pyproject.toml b/pyproject.toml index 09b8232..86aaba5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,14 +28,18 @@ pysnes = "pysnes.pysnes:main" [dependency-groups] dev = [ - "ruff>=0.15.20", + # 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). -extend-exclude = ["submodules"] +# 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), diff --git a/uv.lock b/uv.lock index 0764c68..9db5783 100644 --- a/uv.lock +++ b/uv.lock @@ -285,7 +285,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.20" }] +dev = [{ name = "ruff", specifier = "==0.16.2" }] [[package]] name = "pytest" From 95029e6f418195bab5352144343f44a41d7e53d5 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Sat, 8 Aug 2026 00:13:13 -0300 Subject: [PATCH 5/9] style: replace multi-line ternaries with if/else blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 80-column reformat wrapped three ternaries across multiple lines. A ternary that doesn't fit on one line reads worse than the if/else it replaced, so convert them back — extracting a named variable where the expression sat inside a dict literal or a call argument. Drop SIM108 from the ruleset for the same reason: it pushes if/else blocks into ternaries, and any ternary long enough to exceed 80 columns gets wrapped straight back into a multi-line one. Refs #34 --- pyproject.toml | 5 +++++ pysnes/bus/bus.py | 9 ++++++--- pysnes/cpu/cpu.py | 9 ++++----- pysnes/cpu/dma.py | 3 +-- pysnes/rom/rom.py | 8 +++++--- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 86aaba5..2a63cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,11 @@ select = [ "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"] diff --git a/pysnes/bus/bus.py b/pysnes/bus/bus.py index 61b458c..bb3ec96 100644 --- a/pysnes/bus/bus.py +++ b/pysnes/bus/bus.py @@ -53,13 +53,16 @@ def __init__( 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), diff --git a/pysnes/cpu/cpu.py b/pysnes/cpu/cpu.py index 33eca44..4dee5e0 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -318,11 +318,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 diff --git a/pysnes/cpu/dma.py b/pysnes/cpu/dma.py index 026d692..831c611 100644 --- a/pysnes/cpu/dma.py +++ b/pysnes/cpu/dma.py @@ -101,8 +101,7 @@ def do_transfer(self) -> None: count = self.transfer_size if self.transfer_size else 0x10000 offsets = _HDMA_TARGET_OFFSETS[self.transfer_mode] unit_len = len(offsets) - # Left as if/else: the ternary form nests two levels and reads worse. - if self.fixed_transfer: # noqa: SIM108 + if self.fixed_transfer: step = 0 else: step = -1 if self.reverse_transfer else 1 diff --git a/pysnes/rom/rom.py b/pysnes/rom/rom.py index 9db9870..3986e72 100644 --- a/pysnes/rom/rom.py +++ b/pysnes/rom/rom.py @@ -195,15 +195,17 @@ 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] From 0e5e57e623aa1bd1b8bf0cccb979d22ad540e261 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Sat, 8 Aug 2026 00:16:11 -0300 Subject: [PATCH 6/9] style: unwrap ternaries left as multi-line assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven more sites where the ternary fits on one line but the formatter parenthesized the right-hand side across three. Converted to if/else, except one where the wrap came from a trailing comment — moving the comment above keeps the ternary on a single line. The five identical SPC700 sign extensions collapse to a plain conditional subtraction, which is what they always meant. Verified: 5120 SPC700 instruction tests and 388 unit tests pass. Refs #34 --- pysnes/apu/dsp.py | 7 ++++--- pysnes/apu/spc700/addressing_modes.py | 25 ++++++++++--------------- pysnes/ppu/ppu.py | 7 ++++--- pysnes/ppu/test_ppu.py | 7 ++++--- pysnes/ppu/test_ppu_color_math.py | 5 ++--- pysnes/pysnes.py | 7 ++++--- pysnes/video/video_sdl2.py | 7 ++++--- 7 files changed, 32 insertions(+), 33 deletions(-) diff --git a/pysnes/apu/dsp.py b/pysnes/apu/dsp.py index bb34c9a..2186911 100644 --- a/pysnes/apu/dsp.py +++ b/pysnes/apu/dsp.py @@ -950,9 +950,10 @@ def generate_samples(self, n_samples: int): adsr_on = bool(adsr1 & 0x80) gain_dir = (not adsr_on) and (not (gain_reg & 0x80)) fixed_env = (gain_reg & 0x7F) << 4 if gain_dir else 0 - gain_mode = ( - (gain_reg >> 5) & 0x3 if (not adsr_on and not gain_dir) else 0 - ) + if not adsr_on and not gain_dir: + gain_mode = (gain_reg >> 5) & 0x3 + else: + 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 diff --git a/pysnes/apu/spc700/addressing_modes.py b/pysnes/apu/spc700/addressing_modes.py index de14f81..547e55f 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -107,9 +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): @@ -121,9 +120,8 @@ def BranchNotDirect(self): 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): @@ -136,9 +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): @@ -152,9 +149,8 @@ def BranchNotDirectIndexed(self, reg_index): 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): @@ -167,9 +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): diff --git a/pysnes/ppu/ppu.py b/pysnes/ppu/ppu.py index 1342453..3a1e748 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -259,9 +259,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"]) diff --git a/pysnes/ppu/test_ppu.py b/pysnes/ppu/test_ppu.py index 2803cc5..74d9ed4 100644 --- a/pysnes/ppu/test_ppu.py +++ b/pysnes/ppu/test_ppu.py @@ -268,9 +268,10 @@ def test_ppu_screenshot(request, test_id, rom_rel, n_frames): """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_color_math.py b/pysnes/ppu/test_ppu_color_math.py index 193969b..7502dc8 100644 --- a/pysnes/ppu/test_ppu_color_math.py +++ b/pysnes/ppu/test_ppu_color_math.py @@ -235,9 +235,8 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: # 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 diff --git a/pysnes/pysnes.py b/pysnes/pysnes.py index aca9fcd..4290810 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -473,9 +473,10 @@ def main(self): 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 - ) + 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", diff --git a/pysnes/video/video_sdl2.py b/pysnes/video/video_sdl2.py index c955ea8..be1dc84 100644 --- a/pysnes/video/video_sdl2.py +++ b/pysnes/video/video_sdl2.py @@ -48,9 +48,10 @@ def initialize(self, window) -> None: # 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" - ) + if renderer_info.name: + self.renderer_name = renderer_info.name.decode() + else: + self.renderer_name = "Unknown" # Create streaming texture for game screen self.texture = sdl.SDL_CreateTexture( From b24fb05c825afff1ab9bcdcfbad427cff08b7833 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Sat, 8 Aug 2026 00:38:22 -0300 Subject: [PATCH 7/9] refactor: delete dead code and commented-out blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unreferenced code, confirmed by cross-checking every candidate by hand — vulture can't see the dynamic dispatch this codebase relies on: - Ppu.write_vram: superseded by the $2118/$2119 setters, which each write their own byte so the high byte survives a mode-0 DMA into $2118 alone. - Video.get_renderer_info / toggle_renderer, SDL2Renderer.set_vsync (an empty body) and get_performance_info. Removing the readers left sdl2_calls and renderer_name write-only, so those went too — including an SDL_GetRendererInfo call whose result went nowhere. - data_structures.Tilemap and Tile: never imported anywhere. - PySNES.frame_time, a test helper, a test stub method, and an unused unpacking target. - scripts/trace_matcher.py: the whole file was unreachable, and pysnes.py already compares CPU traces against the bsnes reference. Commented-out code removed, keeping the reasoning as prose where it explained a decision (the emulation-mode direct-page wrap in readDirect, the Program Bank Register living in PC.b). Kept deliberately: Harness.run_until / on_write are public scripting API, and the harness CLI dispatches RPC methods via getattr, so its "unused" methods are live endpoints. Verified: 388 unit, 512 CPU and 2560 SPC700 instruction tests pass. Refs #34 --- pysnes/apu/apu.py | 2 -- pysnes/apu/spc700/addressing_modes.py | 4 --- pysnes/apu/spc700/opcodes_spc700.py | 1 - pysnes/bus/test_interrupts.py | 8 ----- pysnes/cpu/cpu.py | 15 +++------ pysnes/debugger/test_debugger.py | 3 -- pysnes/ppu/constants.py | 1 - pysnes/ppu/data_structures.py | 29 ----------------- pysnes/ppu/ppu.py | 11 ------- pysnes/ppu/test_ppu_registers.py | 7 ++-- pysnes/pysnes.py | 1 - pysnes/video/video.py | 16 ---------- pysnes/video/video_sdl2.py | 42 ------------------------ scripts/run_pysnes.py | 13 +------- scripts/trace_matcher.py | 46 --------------------------- 15 files changed, 10 insertions(+), 189 deletions(-) delete mode 100644 scripts/trace_matcher.py diff --git a/pysnes/apu/apu.py b/pysnes/apu/apu.py index 9cb3c85..afba9de 100644 --- a/pysnes/apu/apu.py +++ b/pysnes/apu/apu.py @@ -169,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 diff --git a/pysnes/apu/spc700/addressing_modes.py b/pysnes/apu/spc700/addressing_modes.py index 547e55f..52faf27 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -395,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 diff --git a/pysnes/apu/spc700/opcodes_spc700.py b/pysnes/apu/spc700/opcodes_spc700.py index 620d46b..19bec92 100644 --- a/pysnes/apu/spc700/opcodes_spc700.py +++ b/pysnes/apu/spc700/opcodes_spc700.py @@ -46,7 +46,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) diff --git a/pysnes/bus/test_interrupts.py b/pysnes/bus/test_interrupts.py index a01958c..a370f3e 100644 --- a/pysnes/bus/test_interrupts.py +++ b/pysnes/bus/test_interrupts.py @@ -460,14 +460,6 @@ def test_nmi_takes_priority_over_irq(): # --------------------------------------------------------------------------- -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.""" bus, cpu = make_bus() diff --git a/pysnes/cpu/cpu.py b/pysnes/cpu/cpu.py index 4dee5e0..990f4ee 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -165,8 +165,7 @@ def reset_registers(self): 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) + # 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 @@ -394,13 +393,10 @@ 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) @@ -602,7 +598,6 @@ def P(self) -> int: @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 diff --git a/pysnes/debugger/test_debugger.py b/pysnes/debugger/test_debugger.py index f5ff687..111aee7 100644 --- a/pysnes/debugger/test_debugger.py +++ b/pysnes/debugger/test_debugger.py @@ -67,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: """ diff --git a/pysnes/ppu/constants.py b/pysnes/ppu/constants.py index 07acb83..2c4e730 100644 --- a/pysnes/ppu/constants.py +++ b/pysnes/ppu/constants.py @@ -7,7 +7,6 @@ _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 diff --git a/pysnes/ppu/data_structures.py b/pysnes/ppu/data_structures.py index e736380..8c88aa9 100644 --- a/pysnes/ppu/data_structures.py +++ b/pysnes/ppu/data_structures.py @@ -58,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/ppu.py b/pysnes/ppu/ppu.py index 3a1e748..5232359 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -376,17 +376,6 @@ def _remap_vram_addr(self, addr: int) -> int: # 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() - def increment_vmadd(self) -> None: addr = ( self.vmaddl | self.vmaddh << 8 diff --git a/pysnes/ppu/test_ppu_registers.py b/pysnes/ppu/test_ppu_registers.py index 6a5df82..5fce81e 100644 --- a/pysnes/ppu/test_ppu_registers.py +++ b/pysnes/ppu/test_ppu_registers.py @@ -90,12 +90,13 @@ def test_oamaddl_preserves_high_bit_across_oamaddh_write(self): 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: diff --git a/pysnes/pysnes.py b/pysnes/pysnes.py index 4290810..11d4249 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -74,7 +74,6 @@ def __init__( # 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 diff --git a/pysnes/video/video.py b/pysnes/video/video.py index ec640b7..9f64fb6 100644 --- a/pysnes/video/video.py +++ b/pysnes/video/video.py @@ -18,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: @@ -81,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 be1dc84..85d9a8c 100644 --- a/pysnes/video/video_sdl2.py +++ b/pysnes/video/video_sdl2.py @@ -45,14 +45,6 @@ def initialize(self, window) -> None: f"{sdl.SDL_GetError().decode()}" ) - # Get renderer info for debugging (stored, not printed) - renderer_info = sdl.SDL_RendererInfo() - sdl.SDL_GetRendererInfo(self.renderer, renderer_info) - if renderer_info.name: - self.renderer_name = renderer_info.name.decode() - else: - self.renderer_name = "Unknown" - # Create streaming texture for game screen self.texture = sdl.SDL_CreateTexture( self.renderer, @@ -73,10 +65,6 @@ def initialize(self, window) -> None: # 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}" @@ -159,33 +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 - - 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", - } - return { - "backend": "SDL2", - "texture_size": f"{self.width}x{self.height}", - "name": getattr(self, "renderer_name", "Unknown"), - } diff --git a/scripts/run_pysnes.py b/scripts/run_pysnes.py index 0db2061..ca92fcc 100644 --- a/scripts/run_pysnes.py +++ b/scripts/run_pysnes.py @@ -30,16 +30,6 @@ args = parser.parse_args() if args.profile: - # import cProfile - # from line_profiler import LineProfiler - - # lp = LineProfiler() - # lp_wrapper = lp(main) - # lp_wrapper() - # lp.print_stats() - - # cProfile.run("main()", sort="cumulative") - import cProfile import pstats @@ -50,7 +40,6 @@ from pysnes import pysnes cProfile.runctx("main()", globals(), locals(), "Profile.prof") - # cProfile.run("pysnes.main()", "Profile.prof") s = pstats.Stats("Profile.prof") s.sort_stats("time").print_stats(50) @@ -59,7 +48,7 @@ rom = args.rom or "roms/Super Mario World (U) [!].smc" pysnes = PySNES(rom) - rom_file_name, rom_extension = rom.split(".") + rom_file_name = rom.split(".")[0] with open(f"{rom_file_name}-vram.bin", "rb") as f: vram_dump = f.read() diff --git a/scripts/trace_matcher.py b/scripts/trace_matcher.py deleted file mode 100644 index 360fde6..0000000 --- a/scripts/trace_matcher.py +++ /dev/null @@ -1,46 +0,0 @@ -from pysnes.cpu import Cpu - - -def check_trace_line(line: str, cpu: Cpu): - """Compare current state with bsnes trace log""" - disassembled = cpu.disassembler.disassemble(cpu.PC.value) - line = line.strip() - - # compare instruction and operand - actual = disassembled[:20].lower() - expected = line[:20].lower() - assert actual == expected, f"{actual!r} != {expected!r}" - - PC = int(line[:6], 16) - assert cpu.PC.value == PC, f"{PC:06X} != {cpu.PC.value:06X}" - A = int(line[33:37], 16) - assert cpu.A.value == A, f"{A:04X} != {cpu.A.value:04X}" - X = int(line[40:44], 16) - assert cpu.X.value == X, f"{X:04X} != {cpu.X.value:04X}" - Y = int(line[47:51], 16) - assert cpu.Y.value == Y, f"{Y:04X} != {cpu.Y.value:04X}" - S = int(line[54:59], 16) - assert cpu.S.value == S, f"{S:04X} != {cpu.S.value:04X}" - - NF = line[72] == "N" - assert cpu.NFlag == NF, f"{NF} != {cpu.NFlag} {line=} {disassembled=}" - VF = line[73] == "V" - assert cpu.VFlag == VF, f"{VF} != {cpu.VFlag} {line=} {disassembled=}" - IF = line[77] == "I" - assert cpu.IFlag == IF, f"{IF} != {cpu.IFlag} {line=} {disassembled=}" - ZF = line[78] == "Z" - assert cpu.ZFlag == ZF, f"{ZF} != {cpu.ZFlag} {line=} {disassembled=}" - CF = line[79] == "C" - assert cpu.CFlag == CF, f"{CF} != {cpu.CFlag} {line=} {disassembled=}" - - EF = line[74] == "1" - assert EF == cpu.EF, f"{EF} != {cpu.EF}" - - # TODO: in emulation mode (cpu.EF), assert the B flag too: - # BF = line[75] == "B" - # assert BF == bool(cpu.status.), f"{BF} != {cpu.P.B}" - if not cpu.EF: - MF = line[74] == "M" - assert cpu.MFlag == MF, f"{MF} != {cpu.MFlag}" - XF = line[75] == "X" - assert cpu.XFlag == XF, f"{XF} != {cpu.XFlag}" From 66e5fa7278355030d947fe4085d77d7d69b3bde5 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Sat, 8 Aug 2026 09:49:59 -0300 Subject: [PATCH 8/9] refactor: drop leftover '# assert False' stub tripwires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADC, SBC and LDW each opened with a commented-out `assert False` from when they were unimplemented stubs. All three are complete and covered by the SPC700 instruction tests. Ruff's ERA001 does not flag these, which is why the earlier sweep missed them — it leans on a heuristic that skips short statements. Refs #34 --- pysnes/apu/spc700/opcodes_spc700.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pysnes/apu/spc700/opcodes_spc700.py b/pysnes/apu/spc700/opcodes_spc700.py index 19bec92..b72ca90 100644 --- a/pysnes/apu/spc700/opcodes_spc700.py +++ b/pysnes/apu/spc700/opcodes_spc700.py @@ -9,7 +9,6 @@ class SPC700Opcodes: """ def ADC(self, x, y): - # assert False result = x + y + int(self.CF) self.CF = bool(result > 0xFF) self.ZF = bool(result & 0xFF == 0) @@ -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) From d153c0694efceeeeba3374ac9f1b044c3d105817 Mon Sep 17 00:00:00 2001 From: Eduardo Vieira Date: Sat, 8 Aug 2026 10:06:26 -0300 Subject: [PATCH 9/9] style: protect hardware tables from the formatter, lift trailing comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the 80-column reformat. Hand-aligned reference tables were exploded one value per line — dsp.py went from 618 to 1158 lines. The Gaussian interpolation table, the DSP rate table, the 256-entry SPC700 opcode table and the disassembler mnemonic tables are restored to their reference layout inside `# fmt: off` blocks. Two files carry a scoped E501 exemption because `# fmt: off` stops the formatter but not the line-length rule. Where a trailing comment was the only reason a statement didn't fit, the formatter split the code and parked the comment after the closing paren: self.page_0 = bytearray( 0x0100 ) # 0x00-0xFF; upper 16 bytes used by _io_flat mode Moving the comment above lets the statement sit on one line. Applied to 57 sites across both shapes (comment after the paren, and comment parked inside it). `# noqa` comments are left alone — suppression is line-scoped. Verified: AST identical before and after both passes; 388 unit, 512 CPU and 5120 SPC700 instruction tests pass. Refs #34 --- pyproject.toml | 6 + pysnes/apu/apu.py | 5 +- pysnes/apu/dsp.py | 599 ++--------------------- pysnes/apu/spc700/addressing_modes.py | 5 +- pysnes/apu/spc700/disassembler.py | 25 +- pysnes/apu/spc700/instructions_spc700.py | 42 +- pysnes/apu/test_dsp.py | 15 +- pysnes/bus/bus.py | 10 +- pysnes/bus/test_bus.py | 10 +- pysnes/cpu/cpu.py | 10 +- pysnes/cpu/test_dma.py | 5 +- pysnes/debugger/debugger.py | 5 +- pysnes/debugger/test_debugger.py | 5 +- pysnes/ppu/oam.py | 5 +- pysnes/ppu/ppu.py | 140 +++--- pysnes/ppu/test_ppu_color_math.py | 5 +- pysnes/ppu/test_ppu_color_math_window.py | 5 +- pysnes/ppu/test_ppu_registers.py | 15 +- pysnes/ppu/test_ppu_sprites.py | 15 +- pysnes/pysnes.py | 10 +- pysnes/rom/rom.py | 5 +- 21 files changed, 180 insertions(+), 762 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a63cc8..6159830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,3 +60,9 @@ 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/apu/apu.py b/pysnes/apu/apu.py index afba9de..a93def7 100644 --- a/pysnes/apu/apu.py +++ b/pysnes/apu/apu.py @@ -203,9 +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 diff --git a/pysnes/apu/dsp.py b/pysnes/apu/dsp.py index 2186911..e236dee 100644 --- a/pysnes/apu/dsp.py +++ b/pysnes/apu/dsp.py @@ -7,558 +7,56 @@ # 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, - 2, - 2, - 3, - 3, - 3, - 3, - 3, - 4, - 4, - 4, - 4, - 4, - 5, - 5, - 5, - 5, - 6, - 6, - 6, - 6, - 7, - 7, - 7, - 8, - 8, - 8, - 9, - 9, - 9, - 10, - 10, - 10, - 11, - 11, - 11, - 12, - 12, - 13, - 13, - 14, - 14, - 15, - 15, - 15, - 16, - 16, - 17, - 17, - 18, - 19, - 19, - 20, - 20, - 21, - 21, - 22, - 23, - 23, - 24, - 24, - 25, - 26, - 27, - 27, - 28, - 29, - 29, - 30, - 31, - 32, - 32, - 33, - 34, - 35, - 36, - 36, - 37, - 38, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 58, - 59, - 60, - 61, - 62, - 64, - 65, - 66, - 67, - 69, - 70, - 71, - 73, - 74, - 76, - 77, - 78, - 80, - 81, - 83, - 84, - 86, - 87, - 89, - 90, - 92, - 94, - 95, - 97, - 99, - 100, - 102, - 104, - 106, - 107, - 109, - 111, - 113, - 115, - 117, - 118, - 120, - 122, - 124, - 126, - 128, - 130, - 132, - 134, - 137, - 139, - 141, - 143, - 145, - 147, - 150, - 152, - 154, - 156, - 159, - 161, - 163, - 166, - 168, - 171, - 173, - 175, - 178, - 180, - 183, - 186, - 188, - 191, - 193, - 196, - 199, - 201, - 204, - 207, - 210, - 212, - 215, - 218, - 221, - 224, - 227, - 230, - 233, - 236, - 239, - 242, - 245, - 248, - 251, - 254, - 257, - 260, - 263, - 267, - 270, - 273, - 276, - 280, - 283, - 286, - 290, - 293, - 297, - 300, - 304, - 307, - 311, - 314, - 318, - 321, - 325, - 328, - 332, - 336, - 339, - 343, - 347, - 351, - 354, - 358, - 362, - 366, - 370, - 374, - 378, - 381, - 385, - 389, - 393, - 397, - 401, - 405, - 410, - 414, - 418, - 422, - 426, - 430, - 434, - 439, - 443, - 447, - 451, - 456, - 460, - 464, - 469, - 473, - 477, - 482, - 486, - 491, - 495, - 499, - 504, - 508, - 513, - 517, - 522, - 527, - 531, - 536, - 540, - 545, - 550, - 554, - 559, - 563, - 568, - 573, - 577, - 582, - 587, - 592, - 596, - 601, - 606, - 611, - 615, - 620, - 625, - 630, - 635, - 640, - 644, - 649, - 654, - 659, - 664, - 669, - 674, - 678, - 683, - 688, - 693, - 698, - 703, - 708, - 713, - 718, - 723, - 728, - 732, - 737, - 742, - 747, - 752, - 757, - 762, - 767, - 772, - 777, - 782, - 787, - 792, - 797, - 802, - 806, - 811, - 816, - 821, - 826, - 831, - 836, - 841, - 846, - 851, - 855, - 860, - 865, - 870, - 875, - 880, - 884, - 889, - 894, - 899, - 904, - 908, - 913, - 918, - 923, - 927, - 932, - 937, - 941, - 946, - 951, - 955, - 960, - 965, - 969, - 974, - 978, - 983, - 988, - 992, - 997, - 1001, - 1005, - 1010, - 1014, - 1019, - 1023, - 1027, - 1032, - 1036, - 1040, - 1045, - 1049, - 1053, - 1057, - 1061, - 1066, - 1070, - 1074, - 1078, - 1082, - 1086, - 1090, - 1094, - 1098, - 1102, - 1106, - 1109, - 1113, - 1117, - 1121, - 1125, - 1128, - 1132, - 1136, - 1139, - 1143, - 1146, - 1150, - 1153, - 1157, - 1160, - 1164, - 1167, - 1170, - 1174, - 1177, - 1180, - 1183, - 1186, - 1190, - 1193, - 1196, - 1199, - 1202, - 1205, - 1207, - 1210, - 1213, - 1216, - 1219, - 1221, - 1224, - 1227, - 1229, - 1232, - 1234, - 1237, - 1239, - 1241, - 1244, - 1246, - 1248, - 1251, - 1253, - 1255, - 1257, - 1259, - 1261, - 1263, - 1265, - 1267, - 1269, - 1270, - 1272, - 1274, - 1275, - 1277, - 1279, - 1280, - 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, + 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, + 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, + 6, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9, 10, 10, 10, + 11, 11, 11, 12, 12, 13, 13, 14, 14, 15, 15, 15, 16, 16, 17, 17, + 18, 19, 19, 20, 20, 21, 21, 22, 23, 23, 24, 24, 25, 26, 27, 27, + 28, 29, 29, 30, 31, 32, 32, 33, 34, 35, 36, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 58, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 73, 74, 76, 77, + 78, 80, 81, 83, 84, 86, 87, 89, 90, 92, 94, 95, 97, 99, 100, 102, + 104, 106, 107, 109, 111, 113, 115, 117, 118, 120, 122, 124, 126, 128, 130, 132, + 134, 137, 139, 141, 143, 145, 147, 150, 152, 154, 156, 159, 161, 163, 166, 168, + 171, 173, 175, 178, 180, 183, 186, 188, 191, 193, 196, 199, 201, 204, 207, 210, + 212, 215, 218, 221, 224, 227, 230, 233, 236, 239, 242, 245, 248, 251, 254, 257, + 260, 263, 267, 270, 273, 276, 280, 283, 286, 290, 293, 297, 300, 304, 307, 311, + 314, 318, 321, 325, 328, 332, 336, 339, 343, 347, 351, 354, 358, 362, 366, 370, + 374, 378, 381, 385, 389, 393, 397, 401, 405, 410, 414, 418, 422, 426, 430, 434, + 439, 443, 447, 451, 456, 460, 464, 469, 473, 477, 482, 486, 491, 495, 499, 504, + 508, 513, 517, 522, 527, 531, 536, 540, 545, 550, 554, 559, 563, 568, 573, 577, + 582, 587, 592, 596, 601, 606, 611, 615, 620, 625, 630, 635, 640, 644, 649, 654, + 659, 664, 669, 674, 678, 683, 688, 693, 698, 703, 708, 713, 718, 723, 728, 732, + 737, 742, 747, 752, 757, 762, 767, 772, 777, 782, 787, 792, 797, 802, 806, 811, + 816, 821, 826, 831, 836, 841, 846, 851, 855, 860, 865, 870, 875, 880, 884, 889, + 894, 899, 904, 908, 913, 918, 923, 927, 932, 937, 941, 946, 951, 955, 960, 965, + 969, 974, 978, 983, 988, 992, 997,1001,1005,1010,1014,1019,1023,1027,1032,1036, + 1040,1045,1049,1053,1057,1061,1066,1070,1074,1078,1082,1086,1090,1094,1098,1102, + 1106,1109,1113,1117,1121,1125,1128,1132,1136,1139,1143,1146,1150,1153,1157,1160, + 1164,1167,1170,1174,1177,1180,1183,1186,1190,1193,1196,1199,1202,1205,1207,1210, + 1213,1216,1219,1221,1224,1227,1229,1232,1234,1237,1239,1241,1244,1246,1248,1251, + 1253,1255,1257,1259,1261,1263,1265,1267,1269,1270,1272,1274,1275,1277,1279,1280, + 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. +# fmt: off _RATE_TABLE = [ 0, - 2048, - 1536, - 1280, - 1024, - 768, - 640, - 512, - 384, - 320, - 256, - 192, - 160, - 128, - 96, - 80, - 64, - 48, - 40, - 32, - 24, - 20, - 16, - 12, - 10, - 8, - 6, - 5, - 4, - 3, - 2, - 1, + 2048, 1536, 1280, 1024, 768, 640, 512, + 384, 320, 256, 192, 160, 128, 96, + 80, 64, 48, 40, 32, 24, 20, + 16, 12, 10, 8, 6, 5, 4, + 3, 2, 1, ] +# fmt: on def _s8(v): @@ -624,15 +122,13 @@ def write_register(self, addr: int, value: int) -> None: self.regs[addr] = value & 0xFF 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 @@ -866,9 +362,8 @@ def _init_echo(self) -> None: 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 diff --git a/pysnes/apu/spc700/addressing_modes.py b/pysnes/apu/spc700/addressing_modes.py index 52faf27..d23ce1a 100644 --- a/pysnes/apu/spc700/addressing_modes.py +++ b/pysnes/apu/spc700/addressing_modes.py @@ -623,9 +623,8 @@ def TestSetBitsAbsolute(self, bit_set): 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) + # 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, diff --git a/pysnes/apu/spc700/disassembler.py b/pysnes/apu/spc700/disassembler.py index 80fc175..99a0310 100644 --- a/pysnes/apu/spc700/disassembler.py +++ b/pysnes/apu/spc700/disassembler.py @@ -3,14 +3,15 @@ from .instructions_spc700 import INSTRUCTIONS from .opcodes_spc700 import SPC700Opcodes +# fmt: off _OPNAMES = { - SPC700Opcodes.OR: "OR", + SPC700Opcodes.OR: "OR", SPC700Opcodes.AND: "AND", SPC700Opcodes.EOR: "EOR", SPC700Opcodes.CMP: "CMP", SPC700Opcodes.ADC: "ADC", SPC700Opcodes.SBC: "SBC", - SPC700Opcodes.LD: "MOV", + SPC700Opcodes.LD: "MOV", SPC700Opcodes.ASL: "ASL", SPC700Opcodes.LSR: "LSR", SPC700Opcodes.ROL: "ROL", @@ -22,22 +23,19 @@ 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", + 0x10: "BPL", 0x30: "BMI", 0x50: "BVC", 0x70: "BVS", + 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}"), + ("OR1", "C,${a:04X}.{b}"), + ("OR1", "C,/${a:04X}.{b}"), ("AND1", "C,${a:04X}.{b}"), ("AND1", "C,/${a:04X}.{b}"), ("EOR1", "C,${a:04X}.{b}"), @@ -45,6 +43,7 @@ ("MOV1", "${a:04X}.{b},C"), ("NOT1", "${a:04X}.{b}"), ] +# fmt: on def _rel(byte, pc_after): diff --git a/pysnes/apu/spc700/instructions_spc700.py b/pysnes/apu/spc700/instructions_spc700.py index ed5dd2e..6536bda 100644 --- a/pysnes/apu/spc700/instructions_spc700.py +++ b/pysnes/apu/spc700/instructions_spc700.py @@ -1,6 +1,7 @@ from .addressing_modes import SPC700AddressingModes from .opcodes_spc700 import SPC700Opcodes +# fmt: off INSTRUCTIONS = ( (0x00, SPC700AddressingModes.NoOperation), (0x01, SPC700AddressingModes.CallTable, 0), @@ -54,13 +55,7 @@ (0x31, SPC700AddressingModes.CallTable, 3), (0x32, SPC700AddressingModes.AbsoluteBitSet, 1, False), (0x33, SPC700AddressingModes.BranchBit, 1, False), - ( - 0x34, - SPC700AddressingModes.DirectIndexedRead, - SPC700Opcodes.AND, - "A", - "X", - ), + (0x34, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.AND, "A", "X"), (0x35, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.AND, "X"), (0x36, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.AND, "Y"), (0x37, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.AND, "Y"), @@ -92,13 +87,7 @@ (0x51, SPC700AddressingModes.CallTable, 5), (0x52, SPC700AddressingModes.AbsoluteBitSet, 2, False), (0x53, SPC700AddressingModes.BranchBit, 2, False), - ( - 0x54, - SPC700AddressingModes.DirectIndexedRead, - SPC700Opcodes.EOR, - "A", - "X", - ), + (0x54, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.EOR, "A", "X"), (0x55, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.EOR, "X"), (0x56, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.EOR, "Y"), (0x57, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.EOR, "Y"), @@ -130,13 +119,7 @@ (0x71, SPC700AddressingModes.CallTable, 7), (0x72, SPC700AddressingModes.AbsoluteBitSet, 3, False), (0x73, SPC700AddressingModes.BranchBit, 3, False), - ( - 0x74, - SPC700AddressingModes.DirectIndexedRead, - SPC700Opcodes.CMP, - "A", - "X", - ), + (0x74, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.CMP, "A", "X"), (0x75, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.CMP, "X"), (0x76, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.CMP, "Y"), (0x77, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.CMP, "Y"), @@ -168,13 +151,7 @@ (0x91, SPC700AddressingModes.CallTable, 9), (0x92, SPC700AddressingModes.AbsoluteBitSet, 4, False), (0x93, SPC700AddressingModes.BranchBit, 4, False), - ( - 0x94, - SPC700AddressingModes.DirectIndexedRead, - SPC700Opcodes.ADC, - "A", - "X", - ), + (0x94, SPC700AddressingModes.DirectIndexedRead, SPC700Opcodes.ADC, "A", "X"), (0x95, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.ADC, "X"), (0x96, SPC700AddressingModes.AbsoluteIndexedRead, SPC700Opcodes.ADC, "Y"), (0x97, SPC700AddressingModes.IndirectIndexedRead, SPC700Opcodes.ADC, "Y"), @@ -206,13 +183,7 @@ (0xB1, SPC700AddressingModes.CallTable, 11), (0xB2, SPC700AddressingModes.AbsoluteBitSet, 5, False), (0xB3, SPC700AddressingModes.BranchBit, 5, False), - ( - 0xB4, - SPC700AddressingModes.DirectIndexedRead, - SPC700Opcodes.SBC, - "A", - "X", - ), + (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"), @@ -289,3 +260,4 @@ (0xFE, SPC700AddressingModes.BranchNotYDecrement), (0xFF, SPC700AddressingModes.Stop), ) +# fmt: on diff --git a/pysnes/apu/test_dsp.py b/pysnes/apu/test_dsp.py index 8809abf..f8d927b 100644 --- a/pysnes/apu/test_dsp.py +++ b/pysnes/apu/test_dsp.py @@ -83,9 +83,8 @@ def test_brr_decode_filter0_shift12(): 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 @@ -99,9 +98,8 @@ def test_brr_decode_filter0_negative_nibble(): 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(): @@ -242,8 +240,7 @@ def test_generate_samples_produces_nonzero_with_active_voice(): 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 - ) + # 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/bus/bus.py b/pysnes/bus/bus.py index bb3ec96..2a90833 100644 --- a/pysnes/bus/bus.py +++ b/pysnes/bus/bus.py @@ -277,9 +277,8 @@ 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 ( @@ -637,9 +636,8 @@ def write(self, abs_addr: int, data: int) -> None: 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 diff --git a/pysnes/bus/test_bus.py b/pysnes/bus/test_bus.py index 9876afd..452933f 100644 --- a/pysnes/bus/test_bus.py +++ b/pysnes/bus/test_bus.py @@ -312,9 +312,8 @@ def test_lorom_region2_read(): # 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 @@ -323,9 +322,8 @@ 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 + # 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 diff --git a/pysnes/cpu/cpu.py b/pysnes/cpu/cpu.py index 990f4ee..b7f011a 100644 --- a/pysnes/cpu/cpu.py +++ b/pysnes/cpu/cpu.py @@ -178,18 +178,16 @@ 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. diff --git a/pysnes/cpu/test_dma.py b/pysnes/cpu/test_dma.py index 3f7344b..77201c4 100644 --- a/pysnes/cpu/test_dma.py +++ b/pysnes/cpu/test_dma.py @@ -236,9 +236,8 @@ 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) diff --git a/pysnes/debugger/debugger.py b/pysnes/debugger/debugger.py index 11bedc1..666d55e 100644 --- a/pysnes/debugger/debugger.py +++ b/pysnes/debugger/debugger.py @@ -189,9 +189,8 @@ 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 self._cpu.trace_enabled = False diff --git a/pysnes/debugger/test_debugger.py b/pysnes/debugger/test_debugger.py index 111aee7..92804c1 100644 --- a/pysnes/debugger/test_debugger.py +++ b/pysnes/debugger/test_debugger.py @@ -148,9 +148,8 @@ def test_toggle_breakpoint_removes_hook_when_no_breakpoints(debugger, pysnes): 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() diff --git a/pysnes/ppu/oam.py b/pysnes/ppu/oam.py index 793647e..06ed16c 100644 --- a/pysnes/ppu/oam.py +++ b/pysnes/ppu/oam.py @@ -62,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/ppu.py b/pysnes/ppu/ppu.py index 5232359..98ca9af 100644 --- a/pysnes/ppu/ppu.py +++ b/pysnes/ppu/ppu.py @@ -135,9 +135,8 @@ 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 @@ -774,126 +773,99 @@ 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. 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 diff --git a/pysnes/ppu/test_ppu_color_math.py b/pysnes/ppu/test_ppu_color_math.py index 7502dc8..32b79fc 100644 --- a/pysnes/ppu/test_ppu_color_math.py +++ b/pysnes/ppu/test_ppu_color_math.py @@ -212,9 +212,8 @@ def _setup_smw_title_like(self, ppu: Ppu) -> None: # 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) diff --git a/pysnes/ppu/test_ppu_color_math_window.py b/pysnes/ppu/test_ppu_color_math_window.py index edc079f..9874b72 100644 --- a/pysnes/ppu/test_ppu_color_math_window.py +++ b/pysnes/ppu/test_ppu_color_math_window.py @@ -72,9 +72,8 @@ 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. diff --git a/pysnes/ppu/test_ppu_registers.py b/pysnes/ppu/test_ppu_registers.py index 5fce81e..9853cd0 100644 --- a/pysnes/ppu/test_ppu_registers.py +++ b/pysnes/ppu/test_ppu_registers.py @@ -116,9 +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 @@ -142,9 +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 @@ -157,9 +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 diff --git a/pysnes/ppu/test_ppu_sprites.py b/pysnes/ppu/test_ppu_sprites.py index 68ea091..1401029 100644 --- a/pysnes/ppu/test_ppu_sprites.py +++ b/pysnes/ppu/test_ppu_sprites.py @@ -105,9 +105,8 @@ 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 @@ -184,9 +183,8 @@ 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(5): @@ -517,9 +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) diff --git a/pysnes/pysnes.py b/pysnes/pysnes.py index 11d4249..cd2769a 100644 --- a/pysnes/pysnes.py +++ b/pysnes/pysnes.py @@ -90,9 +90,8 @@ def __init__( # 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 @@ -412,9 +411,8 @@ def main(self): # 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 diff --git a/pysnes/rom/rom.py b/pysnes/rom/rom.py index 3986e72..fe1e3af 100644 --- a/pysnes/rom/rom.py +++ b/pysnes/rom/rom.py @@ -134,9 +134,8 @@ def load_rom_file(self): 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: