diff --git a/pyboy/plugins/game_wrapper_pac_man.py b/pyboy/plugins/game_wrapper_pac_man.py new file mode 100644 index 000000000..a8c29fbd9 --- /dev/null +++ b/pyboy/plugins/game_wrapper_pac_man.py @@ -0,0 +1,112 @@ +# +# License: See LICENSE.md file +# GitHub: https://github.com/Baekalfen/PyBoy +# +__pdoc__ = { + "GameWrapperPacMan.cartridge_title": False, + "GameWrapperPacMan.post_tick": False, +} + +import pyboy +from pyboy.utils import PyBoyException + +from .base_plugin import PyBoyGameWrapper + +logger = pyboy.logging.get_logger(__name__) + +def enabled(self): + import hashlib + + try: + with open(self.pyboy.gamerom_file, "rb") as f: + rom_hash = hashlib.md5(f.read()).hexdigest() + return rom_hash == "cd9027e147f4605f26ee261c537441b3" + except Exception as e: + logger.error(f"Error occurred while checking ROM hash: {e}") + return False + + +ADDR_SCORE_LO = 0xD637 # tens + ones +ADDR_SCORE_MID = 0xD638 # thousands + hundreds +ADDR_SCORE_HI = 0xD639 # hundred-thousands + ten-thousands + +ADDR_LIVES = 0xD641 +ADDR_LEVEL = 0xD643 + + +def _bcd_score(lo, mid, hi): + + def bcd(b): + return (b >> 4) * 10 + (b & 0x0F) + + return bcd(hi) * 10_000 + bcd(mid) * 100 + bcd(lo) + + +class GameWrapperPacMan(PyBoyGameWrapper): + + + cartridge_title = "PAC-MAN" + + def __init__(self, *args, **kwargs): + self.score = 0 + self.lives_left = 0 + self.level = 0 + self._game_over = False + + super().__init__(*args, game_area_section=(0, 2, 20, 16), game_area_follow_scxy=True, **kwargs) + + + def post_tick(self): + self._tile_cache_invalid = True + self._sprite_cache_invalid = True + + self.score = _bcd_score( + self.pyboy.memory[ADDR_SCORE_LO], + self.pyboy.memory[ADDR_SCORE_MID], + self.pyboy.memory[ADDR_SCORE_HI], + ) + + prev_lives = self.lives_left + self.lives_left = self.pyboy.memory[ADDR_LIVES] + #Calculating game over based on the previous lives + if prev_lives > 0 and self.lives_left == 0: + self._game_over = True + + self.level = self.pyboy.memory[ADDR_LEVEL] + + def game_over(self): + return self._game_over + + def start_game(self, timer_div=None): + if self.game_has_started: + raise PyBoyException("Game already started. Call reset_game() to restart.") + + # Tick until the title screen score row shows the "1UP" label tile (287). + # That tile only appears once the title-screen HUD has been drawn, which + # means the attract loop has completed its boot sequence. + while self.tilemap_background[0, 0] != 287: + self.pyboy.tick(1, False) + + self.pyboy.button("start") + self.pyboy.tick(1, False) + + # Wait until LIVES register is populated — that confirms Pac-Man has + # spawned and the first playable frame is ready. + while self.pyboy.memory[ADDR_LIVES] == 0: + self.pyboy.tick(1, False) + + PyBoyGameWrapper.start_game(self, timer_div=timer_div) + + def reset_game(self, timer_div=None): + + PyBoyGameWrapper.reset_game(self, timer_div=timer_div) + self._game_over = False + + def __repr__(self): + return ( + f"Pac-Man\n" + f"Score: {self.score}\n" + f"Lives left: {self.lives_left}\n" + f"Level: {self.level}\n" + f"Game over: {self._game_over}\n" + ) + super().__repr__() \ No newline at end of file diff --git a/pyboy/plugins/manager.pxd b/pyboy/plugins/manager.pxd index c5eebca48..4c5ed5208 100644 --- a/pyboy/plugins/manager.pxd +++ b/pyboy/plugins/manager.pxd @@ -23,6 +23,7 @@ from pyboy.plugins.game_wrapper_tetris cimport GameWrapperTetris from pyboy.plugins.game_wrapper_kirby_dream_land cimport GameWrapperKirbyDreamLand from pyboy.plugins.game_wrapper_pokemon_gen1 cimport GameWrapperPokemonGen1 from pyboy.plugins.game_wrapper_pokemon_pinball cimport GameWrapperPokemonPinball +from pyboy.plugins.game_wrapper_pac_man cimport GameWrapperPacMan # imports end @@ -48,6 +49,7 @@ cdef class PluginManager: cdef public GameWrapperKirbyDreamLand game_wrapper_kirby_dream_land cdef public GameWrapperPokemonGen1 game_wrapper_pokemon_gen1 cdef public GameWrapperPokemonPinball game_wrapper_pokemon_pinball + cdef public GameWrapperPacman game_wrapper_pacman cdef bint window_sdl2_enabled cdef bint window_open_gl_enabled cdef bint window_glfw_enabled @@ -64,6 +66,7 @@ cdef class PluginManager: cdef bint game_wrapper_kirby_dream_land_enabled cdef bint game_wrapper_pokemon_gen1_enabled cdef bint game_wrapper_pokemon_pinball_enabled + cdef bint game_wrapper_pacman_enabled # plugin_cdef end cdef list handle_events(self, list) diff --git a/pyboy/plugins/manager.py b/pyboy/plugins/manager.py index c2389a4fc..2c46b008c 100644 --- a/pyboy/plugins/manager.py +++ b/pyboy/plugins/manager.py @@ -22,6 +22,7 @@ from pyboy.plugins.game_wrapper_kirby_dream_land import GameWrapperKirbyDreamLand # noqa from pyboy.plugins.game_wrapper_pokemon_gen1 import GameWrapperPokemonGen1 # noqa from pyboy.plugins.game_wrapper_pokemon_pinball import GameWrapperPokemonPinball # noqa +from pyboy.plugins.game_wrapper_pac_man import GameWrapperPacMan # noqa # imports end @@ -43,6 +44,7 @@ def parser_arguments(): yield GameWrapperKirbyDreamLand.argv yield GameWrapperPokemonGen1.argv yield GameWrapperPokemonPinball.argv + yield GameWrapperPacMan.argv # yield_plugins end pass @@ -86,6 +88,8 @@ def __init__(self, pyboy, mb, pyboy_argv): self.game_wrapper_pokemon_gen1_enabled = self.game_wrapper_pokemon_gen1.enabled() self.game_wrapper_pokemon_pinball = GameWrapperPokemonPinball(pyboy, mb, pyboy_argv) self.game_wrapper_pokemon_pinball_enabled = self.game_wrapper_pokemon_pinball.enabled() + self.game_wrapper_pacman = GameWrapperPacMan(pyboy, mb, pyboy_argv) + self.game_wrapper_pacman_enabled = self.game_wrapper_pacman.enabled() # plugins_enabled end def gamewrapper(self): @@ -95,6 +99,7 @@ def gamewrapper(self): if self.game_wrapper_kirby_dream_land_enabled: return self.game_wrapper_kirby_dream_land if self.game_wrapper_pokemon_gen1_enabled: return self.game_wrapper_pokemon_gen1 if self.game_wrapper_pokemon_pinball_enabled: return self.game_wrapper_pokemon_pinball + if self.game_wrapper_pacman_enabled: return self.game_wrapper_pacman # gamewrapper end self.generic_game_wrapper_enabled = True return self.generic_game_wrapper @@ -135,6 +140,8 @@ def handle_events(self, events): events = self.game_wrapper_pokemon_gen1.handle_events(events) if self.game_wrapper_pokemon_pinball_enabled: events = self.game_wrapper_pokemon_pinball.handle_events(events) + if self.game_wrapper_pacman_enabled: + events = self.game_wrapper_pacman.handle_events(events) # foreach end if self.generic_game_wrapper_enabled: events = self.generic_game_wrapper.handle_events(events) @@ -164,6 +171,8 @@ def post_tick(self): self.game_wrapper_pokemon_gen1.post_tick() if self.game_wrapper_pokemon_pinball_enabled: self.game_wrapper_pokemon_pinball.post_tick() + if self.game_wrapper_pacman_enabled: + self.game_wrapper_pacman.post_tick() # foreach end if self.generic_game_wrapper_enabled: self.generic_game_wrapper.post_tick() @@ -273,6 +282,8 @@ def window_title(self): title += self.game_wrapper_pokemon_gen1.window_title() if self.game_wrapper_pokemon_pinball_enabled: title += self.game_wrapper_pokemon_pinball.window_title() + if self.game_wrapper_pacman_enabled: + title += self.game_wrapper_pacman.window_title() # foreach end return title @@ -312,6 +323,8 @@ def stop(self): self.game_wrapper_pokemon_gen1.stop() if self.game_wrapper_pokemon_pinball_enabled: self.game_wrapper_pokemon_pinball.stop() + if self.game_wrapper_pacman_enabled: + self.game_wrapper_pacman.stop() # foreach end if self.generic_game_wrapper_enabled: self.generic_game_wrapper.stop() diff --git a/pyboy/plugins/manager_gen.py b/pyboy/plugins/manager_gen.py index a3a712436..43e208b55 100644 --- a/pyboy/plugins/manager_gen.py +++ b/pyboy/plugins/manager_gen.py @@ -22,6 +22,7 @@ "GameWrapperKirbyDreamLand", "GameWrapperPokemonGen1", "GameWrapperPokemonPinball", + "GameWrapperPacman" ] plugins = [ "AutoPause", diff --git a/setup.py b/setup.py index 59681b9c8..122387592 100644 --- a/setup.py +++ b/setup.py @@ -118,4 +118,4 @@ def prep_pxd_py_files(): "build_ext": build_ext, }, ext_modules=[Extension("", [""])], # Added to trigger a binary wheel -) +) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index aa022865e..fdfe0f2a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,6 +126,10 @@ def supermarioland_rom(secrets): def kirby_rom(secrets): return locate_sha256(b"0f6dba94fae248d419083001c42c02a78be6bd3dff679c895517559e72c98d58") +@pytest.fixture(scope="session") +def pac_man_rom(secrets): + return locate_sha256(b"4a43f491e4c5cef282960b06d23133ff3cb234e228fd21fe1e99958eee8c4971") + @pytest.fixture(scope="session") def any_rom(secrets, tetris_rom): diff --git a/tests/test_pac_man.py b/tests/test_pac_man.py new file mode 100644 index 000000000..b6d72a1df --- /dev/null +++ b/tests/test_pac_man.py @@ -0,0 +1,155 @@ +# +# License: See LICENSE.md file +# GitHub: https://github.com/Baekalfen/PyBoy +# + +import pytest +from pyboy import PyBoy + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _boot(rom): + """Start the emulator and advance to the first playable frame.""" + pyboy = PyBoy(rom, window="null") + pyboy.set_emulation_speed(0) + for _ in range(700): + pyboy.tick(1, False) + pyboy.button("start") + pyboy.tick(1, False) + for _ in range(1000): + pyboy.tick(1, False) + return pyboy + + +def _read_score(pyboy): + def bcd(b): + return (b >> 4) * 10 + (b & 0xF) + return ( + bcd(pyboy.memory[0xD639]) * 10_000 + + bcd(pyboy.memory[0xD638]) * 100 + + bcd(pyboy.memory[0xD637]) + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_pac_man_score_starts_nonzero(pac_man_rom): + """ + Score at the first playable frame is 50 because Pac-Man spawns on a dot. + Verifies the BCD decoder and that the three score addresses are readable. + """ + pyboy = _boot(pac_man_rom) + assert _read_score(pyboy) == 50 + pyboy.stop() + + +def test_pac_man_score_increases_on_movement(pac_man_rom): + """ + Holding UP for 250 frames moves Pac-Man through the maze and eats dots. + Score must be strictly greater than the starting value afterwards. + """ + pyboy = _boot(pac_man_rom) + score_before = _read_score(pyboy) + + for _ in range(250): + pyboy.button_press("up") + pyboy.tick(1, False) + pyboy.button_release("up") + pyboy.tick(20, False) + + assert _read_score(pyboy) > score_before + pyboy.stop() + + +def test_pac_man_score_never_decreases(pac_man_rom): + """ + Score is monotonically non-decreasing throughout a session. + Runs until game over or 5000 ticks, whichever comes first. + """ + pyboy = _boot(pac_man_rom) + prev = _read_score(pyboy) + + for _ in range(5000): + pyboy.tick(1, False) + score = _read_score(pyboy) + assert score >= prev, f"Score decreased: {prev} -> {score}" + prev = score + if pyboy.memory[0xD641] == 0: + break + + pyboy.stop() + + +def test_pac_man_lives_start_at_two(pac_man_rom): + """ + The lives register (0xD641) must equal 2 at the start of a new game. + Pac-Man has 3 lives total; this register counts the extra two shown as icons. + """ + pyboy = _boot(pac_man_rom) + assert pyboy.memory[0xD641] == 2 + pyboy.stop() + + +def test_pac_man_lives_decrease_on_death(pac_man_rom): + """ + Running long enough guarantees ghosts will catch Pac-Man. + Lives must drop below the starting value of 2 within 8000 ticks. + """ + pyboy = _boot(pac_man_rom) + starting_lives = pyboy.memory[0xD641] + + for _ in range(8000): + pyboy.tick(1, False) + if pyboy.memory[0xD641] < starting_lives: + break + else: + pytest.fail("Lives never decreased within 8000 ticks") + + pyboy.stop() + + +def test_pac_man_game_over_when_lives_zero(pac_man_rom): + """ + When lives reach 0 the game is over. + Runs until lives == 0, then asserts the score is a valid BCD value (no + corruption) and that the run actually ended. + """ + pyboy = _boot(pac_man_rom) + + for _ in range(2000): + pyboy.tick(1, False) + if pyboy.memory[0xD641] == 0: + break + else: + pytest.fail("Game never reached game over within 20000 ticks") + + # Score must still be a valid, non-negative integer + assert _read_score(pyboy) >= 0 + pyboy.stop() + + +def test_pac_man_level_starts_at_one(pac_man_rom): + """Level register (0xD643) must be 1 at the start of the first game.""" + pyboy = _boot(pac_man_rom) + assert pyboy.memory[0xD643] == 1 + pyboy.stop() + + +def test_pac_man_level_stable_during_normal_play(pac_man_rom): + """ + Level should not change during a short session on the first maze. + Runs 3000 ticks and asserts level stays at 1. + """ + pyboy = _boot(pac_man_rom) + + for _ in range(3000): + pyboy.tick(1, False) + assert pyboy.memory[0xD643] == 1, \ + f"Level changed unexpectedly at tick {_}" + + pyboy.stop() \ No newline at end of file