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
30 changes: 27 additions & 3 deletions agent_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from dataclasses import dataclass, field
from enum import Enum

from memory_reader import ROUTE_1


class GamePhase(Enum):
"""Different phases of the game."""
Expand Down Expand Up @@ -249,7 +251,7 @@ def suggest_action_for_goal(
# early is a dead end -- runs reached the table row and
# stalled there. Leave and go north until Route 1 has
# been seen; only then approach the table.
if 0x0B not in self.visited_maps:
if ROUTE_1 not in self.visited_maps:
return "DOWN" # Exit the lab (door is at the south)
if y > 4:
return "UP"
Expand All @@ -272,7 +274,7 @@ def suggest_action_for_goal(
return "RIGHT" # Try different direction
return "UP"
return "UP"
elif map_id == 0x0B: # Route 1
elif map_id == ROUTE_1: # Route 1
# Continue north toward Viridian
if pos and len(pos) >= 2:
if self.is_at_boundary(pos, "UP", map_id):
Expand Down Expand Up @@ -315,6 +317,28 @@ def suggest_action_for_goal(

return None

def movement_target(
self, goal: Goal, game_state: str, memory_data: dict | None = None
) -> tuple[int, int] | None:
"""Map coordinate the pathfinder should walk toward, if any.

Only overworld travel has a coordinate. Dialog, menus, and the
doorway-clear maneuver stay on ``suggest_action_for_goal``.
"""
if game_state != "overworld" or not memory_data or self._exit_maneuver_steps > 0:
return None
map_id = (memory_data.get("current_map") or {}).get("map_id")
pos = memory_data.get("player_position") or (0, 0)
x = pos[0] if len(pos) >= 1 else 0

if goal.name == "get_starter" and map_id == 0x00:
return self.OAK_TRIGGER
if goal.name == "reach_viridian" and map_id == 0x00:
return self.OAK_TRIGGER
if goal.name == "reach_viridian" and map_id == ROUTE_1:
return (x, 0)
return None

def add_event(self, event: GameEvent):
"""Add a game event to history.

Expand Down Expand Up @@ -506,7 +530,7 @@ def check_goal_completion(
if "explore_route_1" not in self.completed_goals:
map_info = memory_data.get("current_map", {})
map_id = map_info.get("map_id", 0)
if map_id == 0x0B: # Route 1
if map_id == ROUTE_1: # Route 1
self.mark_goal_complete("explore_route_1")

def get_progress_summary(self) -> dict:
Expand Down
31 changes: 30 additions & 1 deletion game_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pyboy import PyBoy

from memory_reader import MemoryReader, get_map_name
from navigation import downsample_collision
from ocr_enhancer import OCREnhancer

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -90,6 +91,32 @@ def __init__(
else:
self.memory_reader = None

def read_walkable_grid(self) -> list[list[int]] | None:
"""9 by 10 block grid of the visible map. 1 is walkable.

Uses PyBoy's Pokemon collision map (tileset passable list plus the
screen tilemap). Returns None when that wrapper is not available,
so callers can fall back to the coarser direction heuristic.
"""
collision_fn = getattr(self.pyboy, "game_area_collision", None)
if collision_fn is None:
return None
try:
return downsample_collision(collision_fn())
except Exception:
logger.debug("Walkability grid unavailable", exc_info=True)
return None

def read_map_dimensions(self) -> tuple[int, int] | None:
"""Current map size in blocks, or None if memory is not ready."""
if not self.memory_enabled or not self.memory_reader:
return None
try:
return self.memory_reader.read_map_dimensions()
except Exception:
logger.debug("Map dimensions unavailable", exc_info=True)
return None

def get_screen_image(self) -> np.ndarray:
"""Get current screen as numpy array."""
screen = self.pyboy.screen.image
Expand Down Expand Up @@ -706,8 +733,10 @@ def execute_action(self, action: str) -> bool:
try:
# WAIT N is in ticks of 15 frames (~0.25s of game time each);
# raw single-frame waits were far too short to let loading
# screens and transitions play out
# screens and transitions play out. Cap N so a model reply
# of WAIT 999999 cannot stall the emulator.
units = int(action.split()[1]) if len(action.split()) > 1 else 10
units = max(0, min(units, 30))
for _ in range(units * 15):
self.pyboy.tick()
return True
Expand Down
84 changes: 53 additions & 31 deletions memory_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class MemoryAddresses:
PLAYER_Y = 0xD361 # Player Y coordinate on current map
CURRENT_MAP = 0xD35E # Current map ID
MAP_HEADER_BANK = 0xD35F # Map header bank
# pret wram: wCurMapHeight / wCurMapWidth, in 2x2 blocks
MAP_HEIGHT = 0xD368
MAP_WIDTH = 0xD369

# Player info
PLAYER_NAME_START = 0xD158 # Player name (11 bytes, terminated by 0x50)
Expand All @@ -30,15 +33,17 @@ class MemoryAddresses:
PARTY_POKEMON_START = 0xD16B # First Pokemon data structure
POKEMON_DATA_SIZE = 44 # Size of each Pokemon data structure

# Pokemon data structure offsets (relative to party Pokemon start)
# Pokemon data structure offsets (relative to party Pokemon start).
# pret pokered party_struct: species, current HP, box level, status,
# types, ... level at 33, max HP at 34. Offset 3 is box level, not max HP.
POKEMON_SPECIES = 0 # Pokemon species ID
POKEMON_HP_CURRENT = 1 # Current HP (2 bytes, little-endian)
POKEMON_HP_MAX = 3 # Max HP (2 bytes, little-endian)
POKEMON_STATUS = 5 # Status condition (0x00 = normal)
POKEMON_TYPE1 = 6
POKEMON_TYPE2 = 7
POKEMON_STATUS = 4 # Status condition (0x00 = normal)
POKEMON_TYPE1 = 5
POKEMON_TYPE2 = 6
POKEMON_EXP = 14 # Experience (3 bytes)
POKEMON_LEVEL = 33 # Level
POKEMON_EXP = 34 # Experience (3 bytes)
POKEMON_HP_MAX = 34 # Max HP (2 bytes, little-endian)

# Inventory
INVENTORY_COUNT = 0xD31D # Number of items in inventory
Expand Down Expand Up @@ -163,6 +168,18 @@ def read_current_map(self) -> dict[str, int]:
"map_bank": map_bank,
}

def read_map_dimensions(self) -> tuple[int, int] | None:
"""Read the current map size in walkable blocks.

Returns:
``(width, height)``, or None when the header has not loaded.
"""
width = self.read_byte(MemoryAddresses.MAP_WIDTH)
height = self.read_byte(MemoryAddresses.MAP_HEIGHT)
if width == 0 or height == 0:
return None
return width, height

def read_player_name(self) -> str:
"""Read player name from memory.

Expand Down Expand Up @@ -386,33 +403,38 @@ def read_full_game_state(self) -> dict[str, any]:
0x08: "Cinnabar Island",
0x09: "Indigo Plateau",
0x0A: "Saffron City",
0x0B: "Route 1",
0x0C: "Route 2",
0x0D: "Route 3",
0x0E: "Route 4",
0x0F: "Route 5",
0x10: "Route 6",
0x11: "Route 7",
0x12: "Route 8",
0x13: "Route 9",
0x14: "Route 10",
0x15: "Route 11",
0x16: "Route 12",
0x17: "Route 13",
0x18: "Route 14",
0x19: "Route 15",
0x1A: "Route 16",
0x1B: "Route 17",
0x1C: "Route 18",
0x1D: "Route 19",
0x1E: "Route 20",
0x1F: "Route 21",
0x20: "Route 22",
0x21: "Route 23",
0x22: "Route 24",
0x23: "Route 25",
# 0x0B is UNUSED_MAP_0B in pret pokered map_constants.asm.
# Route 1 is 0x0C; Route 25 is 0x24.
0x0C: "Route 1",
0x0D: "Route 2",
0x0E: "Route 3",
0x0F: "Route 4",
0x10: "Route 5",
0x11: "Route 6",
0x12: "Route 7",
0x13: "Route 8",
0x14: "Route 9",
0x15: "Route 10",
0x16: "Route 11",
0x17: "Route 12",
0x18: "Route 13",
0x19: "Route 14",
0x1A: "Route 15",
0x1B: "Route 16",
0x1C: "Route 17",
0x1D: "Route 18",
0x1E: "Route 19",
0x1F: "Route 20",
0x20: "Route 21",
0x21: "Route 22",
0x22: "Route 23",
0x23: "Route 24",
0x24: "Route 25",
}

# pret pokered: Route 1 is map 0x0C (0x0B is unused).
ROUTE_1 = 0x0C


def get_map_name(map_id: int) -> str:
"""Get map name from map ID.
Expand Down
Loading
Loading