Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions pyboy/plugins/game_wrapper_pac_man.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +17 to +26

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed. Unused.



ADDR_SCORE_LO = 0xD637 # tens + ones
ADDR_SCORE_MID = 0xD638 # thousands + hundreds
ADDR_SCORE_HI = 0xD639 # hundred-thousands + ten-thousands
Comment on lines +29 to +31

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good


ADDR_LIVES = 0xD641
ADDR_LEVEL = 0xD643


def _bcd_score(lo, mid, hi):

def bcd(b):
return (b >> 4) * 10 + (b & 0x0F)
Comment on lines +39 to +40

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can find bcd->dec and dec->bcd functions in utils:

from pyboy.utils import dec_to_bcd, bcd_to_dec


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
Comment on lines +60 to +61

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should call super().post_tick() instead


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__()
3 changes: 3 additions & 0 deletions pyboy/plugins/manager.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions pyboy/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -43,6 +44,7 @@ def parser_arguments():
yield GameWrapperKirbyDreamLand.argv
yield GameWrapperPokemonGen1.argv
yield GameWrapperPokemonPinball.argv
yield GameWrapperPacMan.argv
# yield_plugins end
pass

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pyboy/plugins/manager_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"GameWrapperKirbyDreamLand",
"GameWrapperPokemonGen1",
"GameWrapperPokemonPinball",
"GameWrapperPacman"
]
plugins = [
"AutoPause",
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,4 @@ def prep_pxd_py_files():
"build_ext": build_ext,
},
ext_modules=[Extension("", [""])], # Added to trigger a binary wheel
)
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated formating

4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
155 changes: 155 additions & 0 deletions tests/test_pac_man.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +15 to +24

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be handled by start_game in your wrapper



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])
)
Comment on lines +27 to +34

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use:

from pyboy.utils import dec_to_bcd, bcd_to_dec



# ---------------------------------------------------------------------------
# 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:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this address? It's used in multiple tests, so best declare it globally.

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()
Loading