gameboy_worlds.emulation.emulator
1from enum import Enum 2from typing import Type, Optional, Tuple 3 4 5import os 6import re 7from time import perf_counter 8import sys 9import shutil 10import uuid 11from gameboy_worlds.emulation.parser import StateParser 12from gameboy_worlds.emulation.tracker import StateTracker 13from gameboy_worlds.utils import ( 14 load_parameters, 15 log_error, 16 log_warn, 17 file_makedir, 18 log_info, 19 is_none_str, 20 verify_parameters, 21 log_dict, 22 import_cv2, 23) 24 25 26from pyboy import PyBoy 27from pyboy.utils import WindowEvent 28from matplotlib import pyplot as plt 29from skimage.transform import downscale_local_mean 30import numpy as np 31from tqdm import tqdm 32 33 34class LowLevelActions(Enum): 35 """ 36 Enum for low-level actions that can be performed on the GameBoy emulator. 37 """ 38 39 PRESS_ARROW_DOWN = WindowEvent.PRESS_ARROW_DOWN 40 PRESS_ARROW_LEFT = WindowEvent.PRESS_ARROW_LEFT 41 PRESS_ARROW_RIGHT = WindowEvent.PRESS_ARROW_RIGHT 42 PRESS_ARROW_UP = WindowEvent.PRESS_ARROW_UP 43 PRESS_BUTTON_A = WindowEvent.PRESS_BUTTON_A 44 PRESS_BUTTON_B = WindowEvent.PRESS_BUTTON_B 45 PRESS_BUTTON_START = WindowEvent.PRESS_BUTTON_START 46 # PRESS_BUTTON_SELECT = WindowEvent.PRESS_BUTTON_SELECT 47 48 49class ReleaseActions(Enum): 50 """ 51 Enum for release actions corresponding to low-level actions. 52 """ 53 54 release_actions = { 55 LowLevelActions.PRESS_ARROW_DOWN: WindowEvent.RELEASE_ARROW_DOWN, 56 LowLevelActions.PRESS_ARROW_LEFT: WindowEvent.RELEASE_ARROW_LEFT, 57 LowLevelActions.PRESS_ARROW_RIGHT: WindowEvent.RELEASE_ARROW_RIGHT, 58 LowLevelActions.PRESS_ARROW_UP: WindowEvent.RELEASE_ARROW_UP, 59 LowLevelActions.PRESS_BUTTON_A: WindowEvent.RELEASE_BUTTON_A, 60 LowLevelActions.PRESS_BUTTON_B: WindowEvent.RELEASE_BUTTON_B, 61 LowLevelActions.PRESS_BUTTON_START: WindowEvent.RELEASE_BUTTON_START, 62 # LowLevelActions.PRESS_BUTTON_SELECT: WindowEvent.RELEASE_BUTTON_SELECT, 63 } 64 65 66class IDPathCreator: 67 """ 68 Handles the creation of IDs and paths for saving emulator artifacts. 69 """ 70 71 def __init__(self, parameters: dict): 72 verify_parameters(parameters) 73 self._parameters = parameters 74 75 def _get_numbered_instance_id(self, path: str, instance_id: str = None) -> int: 76 """ 77 Looks at the given path and returns <next_number>_<instance_id> where next_number is 1 + the highest existing numbered instance ID in the path. 78 79 :param path: Path to look for existing instances. 80 :type path: str 81 :param instance_id: Instance ID pattern to match. If None, counts all instances. 82 :type instance_id: str 83 :return: Number of existing instances matching the pattern. 84 """ 85 # instance_id = str(uuid.uuid4())[:8] 86 if not os.path.exists(path): 87 if instance_id is None: 88 instance_id = str(uuid.uuid4())[:8] 89 return f"0_{instance_id}" 90 if instance_id is None: 91 instance_id = str(uuid.uuid4())[:8] 92 n_existing = os.listdir(path) 93 return f"{len(n_existing)}_{instance_id}" 94 # must find the pattern <number>_<instance_id> 95 pattern = re.compile(r"(\d+)_" + re.escape(instance_id)) 96 existing_instances = [d for d in os.listdir(path) if pattern.match(d)] 97 if len(existing_instances) == 0: 98 return f"0_{instance_id}" 99 else: 100 return f"{len(existing_instances)}_{instance_id}" 101 102 def get_session_path( 103 self, 104 session_name: Optional[str], 105 instance_id: Optional[str], 106 environment_variant: str, 107 ) -> str: 108 if session_name is None: 109 session_name = "tmp_sessions" 110 log_warn( 111 f"Saving a temporary session. If you run gameboy_worlds.clear_tmp_sessions(), it will be deleted. To make it permanent, pass in a `session_name` to the emulator constructor kwargs." 112 ) 113 elif not isinstance(session_name, str) or session_name == "": 114 log_error( 115 f"session_name must be a non-empty string. Recieved {session_name}", 116 self._parameters, 117 ) 118 storage_dir = self._parameters["storage_dir"] 119 session_path = os.path.join( 120 storage_dir, "sessions", environment_variant, session_name 121 ) 122 if instance_id is not None: 123 if not isinstance(instance_id, str) or instance_id == "": 124 log_error( 125 f"instance_id must be a non-empty string. Recieved {instance_id}", 126 self._parameters, 127 ) 128 session_path = os.path.join(session_path, "named_instances") 129 instance_id = self._get_numbered_instance_id(session_path, instance_id) 130 full_session_path = os.path.join(session_path, instance_id) 131 os.makedirs(full_session_path, exist_ok=True) 132 return full_session_path 133 134 def clear_tmp_sessions(self): 135 """ 136 Clears the tmp_sessions directory for ALL game variants. 137 """ 138 storage_dir = self._parameters["storage_dir"] 139 session_path = os.path.join(storage_dir, "sessions") 140 if not os.path.exists(session_path): 141 return 142 existing_variants = os.listdir(session_path) 143 cleared_sessions = {} 144 for variant in existing_variants: 145 tmp_sessions_path = os.path.join(session_path, variant, "tmp_sessions") 146 if not os.path.exists(tmp_sessions_path): 147 continue 148 n_sessions = os.listdir(tmp_sessions_path) 149 shutil.rmtree(tmp_sessions_path) 150 cleared_sessions[variant] = len(n_sessions) 151 log_info( 152 f"Cleared temporary sessions, statistics:", 153 self._parameters, 154 ) 155 log_dict(cleared_sessions, parameters=self._parameters) 156 157 158class VideoWriter: 159 def __init__( 160 self, 161 *, 162 session_path: str, 163 output_shape: Tuple[int, int], 164 reduce_resolution: bool, 165 parameters: dict, 166 ): 167 verify_parameters(parameters) 168 self._session_path = session_path 169 self._output_shape = output_shape 170 self._reduce_resolution = reduce_resolution 171 self._parameters = parameters 172 self._frame_writer = None 173 self.video_running = False 174 """ Whether the video writer is currently recording video. """ 175 project_dir = self._parameters["project_root"] 176 cv2 = import_cv2(self._parameters) 177 self._button_images = { 178 None: cv2.imread( 179 os.path.join(project_dir, "assets/buttons/idle.png"), 180 cv2.IMREAD_UNCHANGED, 181 ), 182 LowLevelActions.PRESS_ARROW_DOWN: cv2.imread( 183 os.path.join(project_dir, "assets/buttons/down.png"), 184 cv2.IMREAD_UNCHANGED, 185 ), 186 LowLevelActions.PRESS_ARROW_LEFT: cv2.imread( 187 os.path.join(project_dir, "assets/buttons/left.png"), 188 cv2.IMREAD_UNCHANGED, 189 ), 190 LowLevelActions.PRESS_ARROW_RIGHT: cv2.imread( 191 os.path.join(project_dir, "assets/buttons/right.png"), 192 cv2.IMREAD_UNCHANGED, 193 ), 194 LowLevelActions.PRESS_ARROW_UP: cv2.imread( 195 os.path.join(project_dir, "assets/buttons/up.png"), cv2.IMREAD_UNCHANGED 196 ), 197 LowLevelActions.PRESS_BUTTON_A: cv2.imread( 198 os.path.join(project_dir, "assets/buttons/a.png"), cv2.IMREAD_UNCHANGED 199 ), 200 LowLevelActions.PRESS_BUTTON_B: cv2.imread( 201 os.path.join(project_dir, "assets/buttons/b.png"), cv2.IMREAD_UNCHANGED 202 ), 203 LowLevelActions.PRESS_BUTTON_START: cv2.imread( 204 os.path.join(project_dir, "assets/buttons/start.png"), 205 cv2.IMREAD_UNCHANGED, 206 ), 207 # LowLevelActions.PRESS_BUTTON_SELECT: cv2.imread( 208 # os.path.join(project_dir, "assets/buttons/select.png"), cv2.IMREAD_UNCHANGED 209 # ), 210 } 211 212 def _get_free_video_id(self) -> str: 213 """ 214 Returns a new unique video ID for saving video files. 215 216 Returns: 217 str: A new unique video ID. 218 """ 219 base_dir = os.path.join(self._session_path, "videos") 220 videos = os.listdir(base_dir) if os.path.exists(base_dir) else [] 221 # all will be something.mp4, if its int.mp4, get the int 222 video_ints = [] 223 for video in videos: 224 if video.endswith(".mp4"): 225 video_name = video[:-4] 226 if video_name.isdigit(): 227 video_ints.append(int(video_name)) 228 if len(video_ints) == 0: 229 return "0.mp4" 230 return str(max(video_ints) + 1) + ".mp4" 231 232 def start_video(self, video_id: str = None): 233 """ 234 Starts recording video of the emulator's screen. 235 Args: 236 video_id (str, optional): Name of the video file to save. If None, a new unique name will be generated. 237 """ 238 if video_id is not None: 239 if not isinstance(video_id, str): 240 log_error( 241 "video_id must be a string (not digits) if provided.", 242 self._parameters, 243 ) 244 if not video_id.endswith(".mp4"): 245 log_error("video_id must end with .mp4 if provided.", self._parameters) 246 if os.path.exists(os.path.join(self._session_path, "videos", video_id)): 247 log_warn( 248 f"video_id {video_id} already exists. Overwriting...", 249 self._parameters, 250 ) 251 else: 252 video_id = self._get_free_video_id() 253 base_dir = os.path.join(self._session_path, "videos") 254 os.makedirs(base_dir, exist_ok=True) 255 video_path = os.path.join(base_dir, f"{video_id}") 256 self.close_video() 257 cv2 = import_cv2(self._parameters) 258 self._frame_writer = cv2.VideoWriter( 259 video_path, 260 cv2.VideoWriter_fourcc(*"mp4v"), 261 60, 262 (self._output_shape[0], self._output_shape[1]), 263 isColor=True, 264 ) 265 self.video_running = True 266 log_info(f"\nStarted recording video to: {video_path}\n", self._parameters) 267 268 def _get_reduced(self, frame: np.ndarray) -> np.ndarray: 269 """ 270 Reduces the resolution of the given frame by a factor of 2 using local mean downscaling. 271 Args: 272 frame (np.ndarray): The frame to reduce the resolution of. 273 Returns: 274 np.ndarray: The reduced resolution frame. 275 """ 276 reduced = (downscale_local_mean(frame, (2, 2, 1))).astype(np.uint8) 277 return reduced 278 279 def add_video_frames( 280 self, frames: np.ndarray, pressed_button: Optional[LowLevelActions] = None, show_button: bool = True 281 ): 282 """ 283 Adds a list of frame from the emulator to the video being recorded. 284 285 Args: 286 frames (np.ndarray): A stack of frames to add to the video. Shape is [n_frames, height, width, channels]. 287 pressed_button (LowLevelActions, optional): The button that was pressed during these frames. If None, no button overlay will be added. 288 show_button (bool, optional): Whether to show the button overlay on the video. Defaults to True. 289 """ 290 if show_button: 291 button_image = self._button_images[pressed_button] 292 button_size = 50 293 button_offset = 0 294 button_x = self._output_shape[0] - button_size - button_offset 295 button_y = self._output_shape[1] - button_size - button_offset 296 cv2 = import_cv2(self._parameters) 297 button_image = cv2.resize(button_image, (button_size, button_size)) 298 alphas = button_image[:, :, 3] / 255.0 299 300 for frame in frames: 301 if self._reduce_resolution: 302 frame = self._get_reduced(frame) 303 # frame_size = (current_frame.shape[1], current_frame.shape[0], 1) # Width, Height, should be equal to self.output_shape 304 # expand grayscale frame to 3 channels for video writing 305 treated_frame = np.repeat(frame, 3, axis=2) 306 if show_button: 307 treated_frame[ 308 button_y : button_y + button_size, button_x : button_x + button_size 309 ] = ( 310 treated_frame[ 311 button_y : button_y + button_size, button_x : button_x + button_size 312 ] 313 * (1 - alphas[:, :, np.newaxis]) 314 + button_image[:, :, :3] * alphas[:, :, np.newaxis] 315 ).astype( 316 np.uint8 317 ) 318 else: 319 pass 320 self._frame_writer.write(treated_frame) 321 return 322 323 def close_video(self): 324 """ 325 Closes the video writer and stops recording video. 326 """ 327 if self._frame_writer is not None: 328 self._frame_writer.release() 329 self._frame_writer = None 330 self.video_running = False 331 332 333class Emulator: 334 """ 335 Handles the running of the GameBoy emulator, including loading ROMs, managing state, performing low level actions and calling the tracker. 336 Subclasses will likely only be needed to manually force the execution of specific button sequences when certain states are detected. (e.g. short-circuiting specific menus, etc.) 337 338 Can be used to access the `state_parser` and `state_tracker` instances for the running game instance. 339 """ 340 341 REQUIRED_STATE_PARSER = StateParser 342 """ The minimal functionality StateParser needed for this emulator to run """ 343 344 REQUIRED_STATE_TRACKER = StateTracker 345 """ The minimal functionality StateTracker needed for this emulator to run """ 346 347 def __init__( 348 self, 349 game: str, 350 gb_path: str, 351 state_parser_class: Type[StateParser], 352 state_tracker_class: Type[StateTracker], 353 init_state: str, 354 parameters: dict, 355 *, 356 headless: bool = True, 357 max_steps: int = None, 358 save_video: bool = None, 359 session_name: str = None, 360 instance_id: str = None, 361 wait_ticks: int = None, 362 press_step: int = None, 363 ): 364 """ 365 Start the GameBoy emulator with the given ROM file and initial state. 366 367 Args: 368 game (str): Name of game variant being emulated. 369 gb_path (str): Path to the GameBoy ROM file. 370 state_parser_class (Type[StateParser]): A class that inherits from StateParser to parse game state variables. 371 state_tracker_class (Type[StateTracker]): A class that inherits from StateTracker to track game state metrics. 372 init_state (str): Path to the initial state file to load. 373 parameters (dict): Dictionary of parameters for the environment. 374 headless (bool, optional): Whether to run the environment in headless mode. 375 max_steps (int, optional): Maximum number of steps per episode. 376 save_video (bool, optional): Whether to save video of the episodes. 377 session_name (str, optional): Name of the session. If None, a new session name will be allocated. This is the broad category you want to save files to 378 instance_id (str, optional): Unique identifier for this environment instance. If None, a new UUID will be generated. The instance ID is useful for distinguishing multiple environments running in parallel with the same session name. 379 wait_ticks (int, optional): Number of ticks to wait between actions. 380 press_step (int, optional): Number of steps to press a button for. 381 """ 382 verify_parameters(parameters) 383 self._parameters = parameters 384 if game is None or game == "": 385 log_error( 386 "You must provide a name for the game variant being emulated.", 387 self._parameters, 388 ) 389 if gb_path is None: 390 log_error( 391 "You must provide a path to the GameBoy ROM file.", self._parameters 392 ) 393 if not issubclass(state_parser_class, self.REQUIRED_STATE_PARSER): 394 log_error( 395 f"state_parser_class must be a subclass of {self.REQUIRED_STATE_PARSER.__name__}, got {state_parser_class_.__name__}.", 396 self._parameters, 397 ) 398 if not issubclass(state_tracker_class, self.REQUIRED_STATE_TRACKER): 399 log_error( 400 f"state_tracker_class must be a subclass of {self.REQUIRED_STATE_TRACKER.__name__}, got {state_tracker_class.__name__}.", 401 self._parameters, 402 ) 403 if init_state is None: 404 log_error( 405 "You must provide an initial state file to load.", self._parameters 406 ) 407 if headless not in [True, False]: 408 log_error("headless must be a boolean.", self._parameters) 409 self.game = game 410 """ Name of game variant being emulated. """ 411 self._gb_path = gb_path 412 self._set_init_state(init_state) 413 # validate init_state exists and ends with .state 414 if not os.path.exists(self._gb_path): 415 log_error( 416 f"GameBoy ROM file {self._gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {self._gb_path}", 417 self._parameters, 418 ) 419 if not self._gb_path.endswith(".gb") and not self._gb_path.endswith(".gbc"): 420 log_error( 421 f"GameBoy ROM file {self._gb_path} is not a .gb or .gbc file.", 422 self._parameters, 423 ) 424 self.headless = headless 425 """ Whether to run the environment in headless mode.""" 426 if max_steps is None: 427 max_steps = self._parameters["gameboy_max_steps"] 428 if max_steps > self._parameters["gameboy_hard_max_steps"]: 429 log_warn( 430 f"max_steps {max_steps} exceeds gameboy_hard_max_steps {self._parameters['gameboy_hard_max_steps']}. Setting to hard max.", 431 self._parameters, 432 ) 433 max_steps = self._parameters["gameboy_hard_max_steps"] 434 self.max_steps = max_steps 435 """ Maximum number of steps per episode. """ 436 id_path_creator = IDPathCreator(self._parameters) 437 self.session_path = id_path_creator.get_session_path( 438 session_name=session_name, 439 instance_id=instance_id, 440 environment_variant=self.get_env_variant(), 441 ) 442 """ Path to the session directory. This is where all artifacts for this session are saved. """ 443 444 if wait_ticks is None: 445 wait_ticks = parameters["gameboy_wait_ticks"] 446 self.wait_ticks = wait_ticks 447 """ Number of emulator ticks to wait after an action. Defaults to value specified in config files. """ 448 if press_step is None: 449 press_step = parameters["gameboy_press_step"] 450 self.press_step = press_step 451 """ Number of emulator ticks to hold down a button press. Defaults to value specified in config files. """ 452 self.render_headless = parameters["gameboy_headless_render"] 453 """ Whether to render the emulator screen even in headless mode. This must be true for methods that rely on image observations (e.g. VLMs) to access the screen. Defaults to value specified in config files. """ 454 if not self.render_headless: 455 log_error( 456 "render_headless cannot be set to False. In the Pokemon environments, screen captures are used aggressively to determine state. ", 457 self._parameters, 458 ) 459 460 self.reset_count = 0 461 """ Number of times the environment has been reset. """ 462 self.step_count = 0 463 """ Number of steps taken in the current episode. """ 464 self._reduce_video_resolution = parameters["gameboy_reduce_video_resolution"] 465 frame_size = ( 466 160, 467 144, 468 ) 469 self.screen_shape = (frame_size[0], frame_size[1], 1) 470 """ Resolution of the rendered game screen """ 471 if self._reduce_video_resolution: 472 self.output_shape = (frame_size[0] // 2, frame_size[1] // 2) 473 else: 474 self.output_shape = (frame_size[0], frame_size[1]) 475 """ Shape of the output observations. This is the resolution of the rendered screen. """ 476 477 if save_video is None: 478 save_video = self._parameters["gameboy_default_save_video"] 479 self.save_video = save_video 480 """ Whether to save video of the episodes. """ 481 self.video_writer = VideoWriter( 482 session_path=self.session_path, 483 output_shape=self.output_shape, 484 reduce_resolution=self._reduce_video_resolution, 485 parameters=self._parameters, 486 ) 487 """ Holds the VideoWriter of this Emulator instance """ 488 489 head = "null" if self.headless else "SDL2" 490 491 self._pyboy = PyBoy( 492 self._gb_path, 493 window=head, 494 ) 495 self.state_parser = state_parser_class(self._pyboy, self._parameters) 496 """ Instance of the StateParser to parse game state variables. """ 497 498 self.state_tracker = state_tracker_class( 499 self.state_parser, 500 self._parameters, 501 ) 502 """ Instance of the StateTracker to track game state metrics. """ 503 504 # self.screen = self.pyboy.botsupport_manager().screen() 505 506 if not self.headless: 507 if not is_none_str(self._parameters["gameboy_headed_emulation_speed"]): 508 self._pyboy.set_emulation_speed( 509 int(self._parameters["gameboy_headed_emulation_speed"]) 510 ) 511 self.reset() 512 513 @staticmethod 514 def create_first_state(gb_path: str, state_path: str): 515 """ 516 Creates a basic state for the emulator. This can be used to create an initial, default state file for a new game. 517 518 Warning: This method uses parameter free logging, so if you override the log_file with a command prompt argument, it will be ignored here. 519 520 Args: 521 gb_path (str): Path to the GameBoy ROM file. 522 state_path (str): Path to save the initial state file. 523 """ 524 # error out if gb_path does not exist or is not a .gb or .gbc file 525 if not os.path.exists(gb_path): 526 log_error( 527 f"GameBoy ROM file {gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {gb_path}" 528 ) 529 if not gb_path.endswith(".gb") and not gb_path.endswith(".gbc"): 530 log_error(f"GameBoy ROM file {gb_path} is not a .gb or .gbc file.") 531 if not state_path.endswith(".state"): 532 state_path = state_path + ".state" 533 if os.path.exists(state_path): 534 log_error(f"State file {state_path} already exists. Will not overwrite...") 535 file_makedir(state_path) 536 pyboy = PyBoy( 537 gb_path, 538 window="null", 539 ) 540 with open(state_path, "wb") as f: 541 pyboy.save_state(f) 542 pyboy.stop() 543 log_info(f"Created initial state file at {state_path}") 544 sys.exit(0) 545 546 def set_init_state(self, init_state: str): 547 """Sets a new initial state file for the environment. and resets the environment. 548 549 Args: 550 init_state (str): Path to the new initial state file. 551 """ 552 self._set_init_state(init_state) 553 self.reset() 554 555 def _set_init_state(self, init_state: str): 556 """ 557 Sets a new initial state file for the environment to eventually load. 558 Does not reset the environment. 559 560 Args: 561 init_state (str): Path to the new initial state file. 562 """ 563 if init_state is None: 564 # log_warn(f"No initial state file provided. Using default initial state.", self._parameters) 565 return 566 if not init_state.endswith(".state"): 567 init_state = init_state + ".state" 568 states_path = self._parameters[f"{self.game}_rom_data_path"] + "/states/" 569 if not os.path.exists(init_state): 570 if states_path in init_state: 571 log_error( 572 f"Initial state file {init_state} does not exist.", self._parameters 573 ) 574 # try to resolve. 575 potential_path = os.path.join(states_path, init_state) 576 if os.path.exists(potential_path): 577 init_state = potential_path 578 else: 579 log_error( 580 f"Initial state file {init_state} does not exist.", self._parameters 581 ) 582 self.init_state = init_state 583 log_info(f"Set new initial state file to {self.init_state}", self._parameters) 584 585 def reset(self, new_init_state: str = None): 586 """ 587 Resets the environment to the initial state. Optionally loads a new initial state file. 588 589 Args: 590 new_init_state (str, optional): Path to a new initial state file to load. 591 """ 592 # validate the new_init_state if provided 593 if new_init_state is not None: 594 self._set_init_state(new_init_state) 595 # restart game, skipping to init_state 596 with open(self.init_state, "rb") as f: 597 self._pyboy.load_state(f) 598 599 self.reset_count += 1 600 self.step_count = 0 601 self.state_tracker.reset() 602 self.video_writer.close_video() 603 return 604 605 def get_current_frame(self) -> np.ndarray: 606 """ 607 Renders the currently rendered screen of the emulator and returns it as a numpy array. 608 609 Returns: 610 np.ndarray: The rendered image as a numpy array. 611 """ 612 return self.state_parser.get_current_frame() 613 614 def _update_listeners_after_actions(self, frames: np.ndarray): 615 """ 616 Updates the state tracker after a batch of actions are run on the emulator without step() 617 618 You should *not* call this method when implementing HighLevelActions, instead call step(), track the states at each step, and return the list of transition states. 619 620 Args: 621 frames (np.ndarray): Frames of shape [n_frames, H, W, C] that contain the frames which elapsed during the run of the actions outside step 622 """ 623 self.state_tracker.step(frames) 624 625 def _get_unique_frames(self, frames: np.ndarray) -> np.ndarray: 626 """ 627 Removes duplicate frames from a stack of frames. 628 629 Args: 630 frames (np.ndarray): Stack of frames of shape [n_frames, H, W, C]. 631 Returns: 632 np.ndarray: Stack of frames with duplicates removed. Shape is [n_unique_frames, H, W, C]. 633 """ 634 unique_frames = [] 635 for frame in frames: 636 if len(unique_frames) == 0: 637 unique_frames.append(frame) 638 else: 639 if not np.array_equal(frame, unique_frames[-1]): 640 unique_frames.append(frame) 641 unique_frames = np.stack(unique_frames, axis=0) 642 return unique_frames 643 644 def step(self, action: LowLevelActions = None) -> Tuple[Optional[np.ndarray], bool]: 645 """ 646 Takes a step in the environment by performing the given action on the emulator. If saving video, starts the video recording on the first step. 647 648 :param action: Lowest level action to perform on the emulator. 649 :type action: LowLevelActions 650 :return: 651 - The stack of frames that passed while performing the action, if rendering is enabled. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 652 653 - Is max steps reached. 654 :rtype: Tuple[Optional[np.ndarray], bool] 655 """ 656 if action is not None: 657 if action not in LowLevelActions: 658 log_error( 659 f"Invalid action {action}. Must be one of {list(LowLevelActions)} or None", 660 self._parameters, 661 ) 662 if self.step_count >= self.max_steps: 663 log_warn( 664 "Step called after max_steps reached. Please reset the environment.", 665 self._parameters, 666 ) 667 # This does not exit because some HighLevelActions may call step() multiple times in their execution. 668 # It is not the best practice to allow this to happen, but it is easier to not error out here than check in every HighLevelAction, and this won't advantage the agent too much. 669 # One consequence, however, is that max_steps then becomes a soft limit rather than a hard limit. 670 671 if self.save_video and self.step_count == 0: 672 self.video_writer.start_video() 673 674 frames = self.run_action_on_emulator(action) 675 self.step_count += 1 676 frames = self._get_unique_frames(frames) 677 self._update_listeners_after_actions(frames) 678 return frames, self.check_if_done() 679 680 def get_state_parser(self) -> StateParser: 681 """ 682 Returns the current game state parser instance. 683 684 Returns: 685 StateParser: The current game state parser. 686 """ 687 return self.state_parser 688 689 def run_action_on_emulator( 690 self, action: LowLevelActions = None 691 ) -> Optional[np.ndarray]: 692 """ 693 694 Performs the given action on the emulator by pressing and releasing the corresponding button. 695 696 Args: 697 action (LowLevelActions): Lowest level action to perform on the emulator. 698 Returns: 699 Optional[np.ndarray]: The stack of frames that passed while performing the actions. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 700 """ 701 frames = None 702 if action is not None: 703 frames = [] 704 self._pyboy.send_input(action.value) 705 press_step = self.press_step 706 self._pyboy.tick(press_step, True) 707 frames.append(self.get_current_frame()) 708 self._pyboy.send_input(ReleaseActions.release_actions.value[action]) 709 self._pyboy.tick(self.wait_ticks + press_step + 1, True) 710 frames.append(self.get_current_frame()) 711 # self._pyboy.tick(1, True) 712 # frames.append(self.get_current_frame()) 713 frames = np.stack(frames, axis=0) 714 else: 715 self._pyboy.tick(self.wait_ticks, True) 716 frames = [self.get_current_frame()] 717 frames = np.array(frames) 718 if self.save_video and self.video_writer.video_running: 719 self.video_writer.add_video_frames(frames, pressed_button=action) 720 return frames 721 722 def check_if_done(self): 723 """ 724 Checks if the max_steps limit has been reached. 725 """ 726 done = self.step_count >= self.max_steps - 1 727 return done 728 729 def close(self) -> StateTracker: 730 """ 731 Closes the emulator and any associated resources. 732 If the session directory is empty after closing, it will be deleted. 733 """ 734 self.state_tracker.close() 735 self._pyboy.stop(save=False) 736 self.video_writer.close_video() 737 self.state_tracker.close() 738 # check if session directory is empty, and if so delete it 739 if ( 740 os.path.exists(self.session_path) 741 and len(os.listdir(self.session_path)) == 0 742 ): 743 os.rmdir(self.session_path) 744 return self.state_tracker 745 746 def human_play(self, max_steps: int = None): 747 """ 748 Allows a human to play the emulator using keyboard inputs. 749 Args: 750 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 751 """ 752 if max_steps is None: 753 max_steps = self._parameters["gameboy_hard_max_steps"] 754 log_info( 755 "Starting human play mode. Use arrow keys and A(a)/B(s)/Start(enter) buttons to play. Close the window to exit.", 756 self._parameters, 757 ) 758 if self.headless: 759 log_error( 760 "Human play mode requires headless=False. Change the initialization", 761 self._parameters, 762 ) 763 self.reset() 764 while True: 765 self._pyboy.tick(1, True) 766 self.state_tracker.step() 767 if self.step_count >= max_steps: 768 break 769 self.close() 770 771 def random_play(self, max_steps: int = None): 772 """ 773 Allows the emulator to play itself using (sort of) random actions. 774 Args: 775 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 776 """ 777 if max_steps is None: 778 max_steps = self._parameters["gameboy_random_play_max_steps"] 779 log_info("Starting random play mode.", self._parameters) 780 self.reset() 781 pbar = tqdm(total=max_steps, desc="Random Play Steps") 782 allowed_actions = list(LowLevelActions) 783 # remove the Start and Select actions from allowed actions to avoid menu spamming. 784 allowed_actions.remove(LowLevelActions.PRESS_BUTTON_START) 785 while self.step_count < max_steps: 786 action = np.random.choice(allowed_actions) 787 frames, done = self.step(action) 788 pbar.update(1) 789 if done: 790 break 791 pbar.close() 792 self.close() 793 log_info("Random play mode ended.", self._parameters) 794 795 def _dev_play(self, max_steps: int = None): 796 """ 797 Allows a human to play the emulator using keyboard inputs. Does not route through step function. 798 This function continuously reads from the parameters in the configs directory and if it detects a change in the `gameboy_dev_play_stop` parameter, will enter a breakpoint 799 800 Args: 801 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 802 """ 803 if not hasattr(self.state_parser, "rom_data_path"): 804 log_error( 805 "Development play mode requires a StateParser with rom_data_path attribute.", 806 self._parameters, 807 ) 808 if max_steps is None: 809 max_steps = self._parameters["gameboy_hard_max_steps"] 810 log_info( 811 "Starting human play mode. Use arrow keys and A(a)/B(s)/Start(enter) buttons to play. Close the window to exit. Open configs/gameboy_vars.yaml and set gameboy_dev_play_stop to true to enable development mode.", 812 self._parameters, 813 ) 814 if self.headless: 815 log_error( 816 "Human play mode requires headless=False. Change the initialization", 817 self._parameters, 818 ) 819 self.reset() 820 valid_regions = [] 821 unassigned_regions = [] 822 for region_name, region in self.state_parser.named_screen_regions.items(): 823 valid_regions.append(region_name) 824 if region.multi_targets is None: 825 if region.target is None: 826 unassigned_regions.append(region_name) 827 else: 828 for target_name in region.multi_targets.keys(): 829 if region.multi_targets[target_name] is None: 830 unassigned_regions.append((region_name, target_name)) 831 if len(unassigned_regions) > 0: 832 log_warn( 833 f"Unassigned regions (target array not set) are: {unassigned_regions}", 834 self._parameters, 835 ) 836 if self.save_video: 837 self.video_writer.start_video() 838 while True: 839 self._parameters = load_parameters() 840 if not self._parameters["gameboy_dev_play_stop"]: 841 self._pyboy.tick(1, True) 842 self.state_tracker.step() 843 frames = [self.get_current_frame()] 844 frames = np.array(frames) 845 if self.save_video: 846 self.video_writer.add_video_frames(frames, show_button=False) 847 else: 848 tracker_report = self.state_tracker.report() 849 tracker_report["core"].pop("current_frame", None) 850 tracker_report["core"].pop("passed_frames", None) 851 dev_instructions = f""" 852 In development mode. 853 Enter 'e' to close the emulator. 854 Enter '' to re-enter normal play mode (remember to change gameboy_dev_play_stop back to false in configs or it'll stop again). 855 Enter 'p' to print the current state. 856 Enter 'w' to pass a single tick without any action. 857 Enter 's <state_name>' to save the current state as a .state file. 858 Enter 'l <state_name>' to load a .state file. 859 Enter 'c <region_name> <save_name / None if region.target_path is set>' to capture a named region and save it as a .npy file. To enter a multi-target region use the format "c <region_name>,<target_name> <save_name>" (no spaces in between region and target name) 860 Enter 'd <None / region_name>' to draw a named region and display the current screen with the region drawn. 861 Enter 'b' to enter a breakpoint. 862 Enter 'g <code>' to apply a GameShark code (e.g. g 0101C7C9). 863 Valid region names are: {valid_regions} 864 Initially unassigned regions (target array not set) were: {unassigned_regions}\n\t Note: This list does not update as you assign targets during this session. 865 Current State: 866 """ 867 log_info(dev_instructions, self._parameters) 868 tracker_report.pop("ocr", None) 869 log_dict(tracker_report, parameters=self._parameters) 870 user_input = input("Dev mode input: ") 871 user_input = user_input.lower().strip() 872 first_char = user_input[0] if len(user_input) > 0 else "" 873 allowed_inputs = ["e", "", "p", "w", "c", "s", "l", "d", "b", "g"] 874 if first_char not in allowed_inputs: 875 log_warn( 876 f"Invalid input {user_input}. Valid inputs are: {allowed_inputs}", 877 self._parameters, 878 ) 879 continue 880 if first_char == "e": 881 log_info("Exiting human play mode.", self._parameters) 882 break 883 elif first_char == "": 884 log_info( 885 "Exiting development mode. Resuming normal play.", 886 self._parameters, 887 ) 888 continue 889 elif first_char == "p": 890 log_info( 891 f"Current State:\n{str(self.state_tracker)}", self._parameters 892 ) 893 continue 894 elif first_char == "w": 895 self._pyboy.tick(1, True) 896 self.state_tracker.step() 897 continue 898 elif first_char == "s" or first_char == "l": 899 parts = user_input.split(" ") 900 if len(parts) != 2: 901 log_warn(f"Invalid input {user_input}.", self._parameters) 902 continue 903 state_name = parts[1] 904 if not state_name.endswith(".state"): 905 state_name = state_name + ".state" 906 state_path = os.path.join( 907 self.state_parser.rom_data_path, "states", state_name 908 ) 909 if first_char == "s": 910 if os.path.exists(state_path): 911 confirm_input = input( 912 f"State file {state_path} already exists. Overwrite? (y/n): " 913 ) 914 if confirm_input.lower().strip() != "y": 915 log_info("Aborting save state.", self._parameters) 916 continue 917 self.save_state(state_path) 918 else: 919 if not os.path.exists(state_path): 920 log_warn( 921 f"State file {state_path} does not exist. Cannot load.", 922 self._parameters, 923 ) 924 continue 925 self.set_init_state(state_path) 926 elif first_char == "b": 927 grid_cells = self.state_parser.capture_grid_cells( 928 current_frame=self.get_current_frame(), y_offset=0 929 ) 930 breakpoint() 931 elif first_char == "g": 932 parts = user_input.split(" ") 933 if len(parts) != 2: 934 log_warn( 935 f"Invalid input {user_input}. Usage: g <code>", 936 self._parameters, 937 ) 938 else: 939 code = parts[1].upper() 940 self._pyboy.gameshark.add(code) 941 log_info(f"GameShark code applied: {code}", self._parameters) 942 continue 943 else: 944 current_frame = self.get_current_frame() 945 # draw it even if c, so we can see what we're capturing 946 save_path = None 947 if first_char == "c": 948 parts = user_input.split(" ") 949 if len(parts) != 3: 950 if len(parts) != 2: 951 log_warn( 952 f"Invalid input {user_input}.", self._parameters 953 ) 954 continue 955 else: 956 region_name = parts[1].split(",")[0] 957 region = self.state_parser.named_screen_regions[ 958 region_name 959 ] 960 if region.multi_targets is None: 961 save_path = region.target_path 962 else: 963 if "," not in parts[1]: 964 log_warn( 965 f"Region {region_name} is a multi-target region. Specify target", 966 self._parameters, 967 ) 968 continue 969 target_name = parts[1].split(",")[1] 970 if target_name not in region.multi_target_paths: 971 log_warn( 972 f"Target name {target_name} not found in region {region_name} with targets {region.multi_targets.keys()}.", 973 self._parameters, 974 ) 975 continue 976 save_path = region.multi_target_paths[target_name] 977 if save_path is None: 978 log_warn( 979 f"Region {region_name} does not have a target path specified. Please provide a save name.", 980 self._parameters, 981 ) 982 continue 983 else: 984 save_name = parts[2] 985 if not save_name.endswith(".npy"): 986 save_name = save_name + ".npy" 987 save_path = os.path.join( 988 self.state_parser.rom_data_path, 989 "captures", 990 save_name, 991 ) 992 file_makedir(save_path) 993 elif first_char == "d": 994 parts = user_input.split(" ") 995 if len(parts) == 2: 996 region_name = parts[1].split(",")[ 997 0 998 ] # Shouldn't need but anyway. 999 elif len(parts) == 1: 1000 region_name = "Full Screen" 1001 else: 1002 log_warn(f"Invalid input {user_input}.", self._parameters) 1003 continue 1004 if region_name == "Full Screen": 1005 drawn_frame = current_frame # self.state_parser.draw_grid_overlay(current_frame) 1006 else: 1007 drawn_frame = self.state_parser.draw_named_region( 1008 current_frame, region_name 1009 ) 1010 plt.imshow(drawn_frame[:, :, 0], cmap="gray") 1011 plt.title(f"Region: {region_name}") 1012 plt.show() 1013 if first_char == "c": 1014 captured_region = self.state_parser.capture_named_region( 1015 current_frame, region_name 1016 ) 1017 plt.imshow(captured_region[:, :, 0], cmap="gray") 1018 plt.title(f"Captured Region: {region_name}") 1019 plt.show() 1020 existing_file = os.path.exists(save_path) 1021 existing_str = ( 1022 "" 1023 if not existing_file 1024 else " (will overwrite existing file)" 1025 ) 1026 confirmation_input = input( 1027 f"Save captured region {region_name} to {save_path}? (y/n) {existing_str}: " 1028 ) 1029 if confirmation_input.lower().strip() != "y": 1030 log_info("Aborting capture region.", self._parameters) 1031 continue 1032 np.save(save_path, captured_region) 1033 log_info( 1034 f"Saved captured region {region_name} to {save_path}", 1035 self._parameters, 1036 ) 1037 if self.step_count >= max_steps: 1038 break 1039 tracker = self.close() 1040 log_info("Human play mode ended.", self._parameters) 1041 # log_dict(tracker.report_final(), parameters=self._parameters) 1042 1043 def save_state(self, state_name: str, error_if_exists: bool = False): 1044 """ 1045 Saves the current state of the emulator to a .state file. 1046 Args: 1047 state_name (str): Name of the state file to save (with or without .state extension). 1048 error_if_exists (bool): Whether to raise an error if the state file already exists. 1049 """ 1050 if not state_name.endswith(".state"): 1051 state_name = state_name + ".state" 1052 state_dir = os.path.abspath(self.state_parser.rom_data_path + "/states/") 1053 potential_state_dir = os.path.abspath(os.path.dirname(state_name)) 1054 if state_dir != potential_state_dir: 1055 if potential_state_dir != os.path.abspath(""): 1056 log_error( 1057 f"Tried to save state file to {state_name}, which is outside of the states directory {state_dir}. This is not allowed.", 1058 self._parameters, 1059 ) 1060 state_name = os.path.abspath(os.path.join(state_dir, state_name)) 1061 else: 1062 pass 1063 if os.path.exists(state_name): 1064 if error_if_exists: 1065 log_error( 1066 f"State file {state_name} already exists. Will not overwrite...", 1067 self._parameters, 1068 ) 1069 file_makedir(state_name) 1070 with open(state_name, "wb") as f: 1071 self._pyboy.save_state(f) 1072 log_info(f"Saved state to {state_name}", self._parameters) 1073 1074 def delete_state(self, state_name: str, error_if_not_exists: bool = False): 1075 """ 1076 Deletes a .state file from the states directory. 1077 1078 Args: 1079 state_name (str): Name of the state file to delete (with or without .state extension). 1080 error_if_not_exists (bool): Whether to raise an error if the state file does not exist. 1081 """ 1082 if not state_name.endswith(".state"): 1083 state_name = state_name + ".state" 1084 state_path = os.path.join(self.state_parser.rom_data_path, "states", state_name) 1085 if not os.path.exists(state_path): 1086 if error_if_not_exists: 1087 log_error( 1088 f"State file {state_path} does not exist. Cannot delete.", 1089 self._parameters, 1090 ) 1091 else: 1092 log_warn( 1093 f"State file {state_path} does not exist. Cannot delete.", 1094 self._parameters, 1095 ) 1096 return 1097 # if somehow state_path isn't in the states directory, error out to avoid deleting random files 1098 if os.path.abspath(os.path.dirname(state_path)) != os.path.abspath( 1099 self.state_parser.rom_data_path + "/states/" 1100 ): 1101 log_error( 1102 f"Tried to delete state file at {state_path}, which is outside of the states directory. This is not allowed.", 1103 self._parameters, 1104 ) 1105 os.remove(state_path) 1106 log_info(f"Deleted state file {state_path}", self._parameters) 1107 1108 def _sav_to_state(self, sav_file: Optional[str], state_name: str): 1109 """ 1110 Loads a .sav file into the emulator and saves the corresponding .state file. 1111 Use this if you want to manually create .sav files and convert them to .state files for use as initial states. 1112 Requires `_open_to_first_state` to be implemented in the subclass to get past opening menus. 1113 1114 Args: 1115 save_file (str or None): Path to the .sav file to load. If None, looks for a .sav file in the same directory as the ROM with the same base name. 1116 state_name (str): Name of the state to save. The .state file will be saved in the states directory of the rom_data_path with this name. 1117 """ 1118 log_info( 1119 "Trying to find .sav file and convert to .state file. This is a breaking operation, so the program will terminate after its completion.", 1120 self._parameters, 1121 ) 1122 if sav_file is not None: 1123 expected_sav = sav_file 1124 else: 1125 if ".gbc" in self._gb_path: 1126 expected_sav = self._gb_path.replace(".gbc", ".sav") 1127 else: 1128 expected_sav = self._gb_path.replace(".gb", ".sav") 1129 if not os.path.exists(expected_sav): 1130 log_error( 1131 f"Expected .sav file at {expected_sav} to convert to .state file, but it does not exist.", 1132 self._parameters, 1133 ) 1134 if state_name is None or state_name == "": 1135 log_error( 1136 "You must provide a state_name to save the .state file.", 1137 self._parameters, 1138 ) 1139 # copy the .sav file to self._gb_path.gb.ram file 1140 save_destination = self._gb_path.replace(".gb", ".gb.ram") 1141 shutil.copyfile(expected_sav, save_destination) 1142 self.close() 1143 self._pyboy = PyBoy( 1144 self._gb_path, 1145 window="null", 1146 ) 1147 self._pyboy.set_emulation_speed(0) 1148 self._open_to_first_state() 1149 self.save_state(state_name, error_if_exists=True) 1150 self._pyboy.stop(save=False) 1151 log_info( 1152 "State saved successfully. Exiting now to avoid issues ...", 1153 self._parameters, 1154 ) 1155 # remove the .gb.ram file 1156 if os.path.exists(save_destination): 1157 os.remove(save_destination) 1158 sys.exit(0) 1159 1160 def _open_to_first_state(self): 1161 """ 1162 Presses buttons on the emulator to get past the opening menus and into the game itself. 1163 You don't really need to implement this method, but if you do, you can use _sav_to_state to create state files from mGBA sav files. 1164 """ 1165 raise NotImplementedError 1166 1167 def get_env_variant(self) -> str: 1168 """ 1169 Returns a string identifier for the particular environment variant being used. 1170 1171 :return: string name identifier of the particular env e.g. PokemonRed 1172 """ 1173 return self.game 1174 1175 1176def bytes_to_padded_hex_string(integer_value): 1177 """ 1178 Converts a bytes object into a padded, '0x'-prefixed hexadecimal string. 1179 """ 1180 # 1. Convert the bytes object back into an integer 1181 # Assumes big-endian order for the example '0x00a' -> 10 1182 # 2. Format the integer into a string with padding and the '0x' prefix 1183 # The 'x' specifier for hex, '#' adds '0x', '04' pads to 4 hex characters total 1184 # (not including the '0x' prefix for simple formatters like this, but managing width) 1185 1186 # A robust approach to match your exact output '0x00a': 1187 # You generally want enough width for your bytes. b'\n' is 1 byte, 2 hex chars. 1188 1189 return f"0x{integer_value:04x}" # {0:04x} pads to 4 digits specifically
35class LowLevelActions(Enum): 36 """ 37 Enum for low-level actions that can be performed on the GameBoy emulator. 38 """ 39 40 PRESS_ARROW_DOWN = WindowEvent.PRESS_ARROW_DOWN 41 PRESS_ARROW_LEFT = WindowEvent.PRESS_ARROW_LEFT 42 PRESS_ARROW_RIGHT = WindowEvent.PRESS_ARROW_RIGHT 43 PRESS_ARROW_UP = WindowEvent.PRESS_ARROW_UP 44 PRESS_BUTTON_A = WindowEvent.PRESS_BUTTON_A 45 PRESS_BUTTON_B = WindowEvent.PRESS_BUTTON_B 46 PRESS_BUTTON_START = WindowEvent.PRESS_BUTTON_START 47 # PRESS_BUTTON_SELECT = WindowEvent.PRESS_BUTTON_SELECT
Enum for low-level actions that can be performed on the GameBoy emulator.
50class ReleaseActions(Enum): 51 """ 52 Enum for release actions corresponding to low-level actions. 53 """ 54 55 release_actions = { 56 LowLevelActions.PRESS_ARROW_DOWN: WindowEvent.RELEASE_ARROW_DOWN, 57 LowLevelActions.PRESS_ARROW_LEFT: WindowEvent.RELEASE_ARROW_LEFT, 58 LowLevelActions.PRESS_ARROW_RIGHT: WindowEvent.RELEASE_ARROW_RIGHT, 59 LowLevelActions.PRESS_ARROW_UP: WindowEvent.RELEASE_ARROW_UP, 60 LowLevelActions.PRESS_BUTTON_A: WindowEvent.RELEASE_BUTTON_A, 61 LowLevelActions.PRESS_BUTTON_B: WindowEvent.RELEASE_BUTTON_B, 62 LowLevelActions.PRESS_BUTTON_START: WindowEvent.RELEASE_BUTTON_START, 63 # LowLevelActions.PRESS_BUTTON_SELECT: WindowEvent.RELEASE_BUTTON_SELECT, 64 }
Enum for release actions corresponding to low-level actions.
67class IDPathCreator: 68 """ 69 Handles the creation of IDs and paths for saving emulator artifacts. 70 """ 71 72 def __init__(self, parameters: dict): 73 verify_parameters(parameters) 74 self._parameters = parameters 75 76 def _get_numbered_instance_id(self, path: str, instance_id: str = None) -> int: 77 """ 78 Looks at the given path and returns <next_number>_<instance_id> where next_number is 1 + the highest existing numbered instance ID in the path. 79 80 :param path: Path to look for existing instances. 81 :type path: str 82 :param instance_id: Instance ID pattern to match. If None, counts all instances. 83 :type instance_id: str 84 :return: Number of existing instances matching the pattern. 85 """ 86 # instance_id = str(uuid.uuid4())[:8] 87 if not os.path.exists(path): 88 if instance_id is None: 89 instance_id = str(uuid.uuid4())[:8] 90 return f"0_{instance_id}" 91 if instance_id is None: 92 instance_id = str(uuid.uuid4())[:8] 93 n_existing = os.listdir(path) 94 return f"{len(n_existing)}_{instance_id}" 95 # must find the pattern <number>_<instance_id> 96 pattern = re.compile(r"(\d+)_" + re.escape(instance_id)) 97 existing_instances = [d for d in os.listdir(path) if pattern.match(d)] 98 if len(existing_instances) == 0: 99 return f"0_{instance_id}" 100 else: 101 return f"{len(existing_instances)}_{instance_id}" 102 103 def get_session_path( 104 self, 105 session_name: Optional[str], 106 instance_id: Optional[str], 107 environment_variant: str, 108 ) -> str: 109 if session_name is None: 110 session_name = "tmp_sessions" 111 log_warn( 112 f"Saving a temporary session. If you run gameboy_worlds.clear_tmp_sessions(), it will be deleted. To make it permanent, pass in a `session_name` to the emulator constructor kwargs." 113 ) 114 elif not isinstance(session_name, str) or session_name == "": 115 log_error( 116 f"session_name must be a non-empty string. Recieved {session_name}", 117 self._parameters, 118 ) 119 storage_dir = self._parameters["storage_dir"] 120 session_path = os.path.join( 121 storage_dir, "sessions", environment_variant, session_name 122 ) 123 if instance_id is not None: 124 if not isinstance(instance_id, str) or instance_id == "": 125 log_error( 126 f"instance_id must be a non-empty string. Recieved {instance_id}", 127 self._parameters, 128 ) 129 session_path = os.path.join(session_path, "named_instances") 130 instance_id = self._get_numbered_instance_id(session_path, instance_id) 131 full_session_path = os.path.join(session_path, instance_id) 132 os.makedirs(full_session_path, exist_ok=True) 133 return full_session_path 134 135 def clear_tmp_sessions(self): 136 """ 137 Clears the tmp_sessions directory for ALL game variants. 138 """ 139 storage_dir = self._parameters["storage_dir"] 140 session_path = os.path.join(storage_dir, "sessions") 141 if not os.path.exists(session_path): 142 return 143 existing_variants = os.listdir(session_path) 144 cleared_sessions = {} 145 for variant in existing_variants: 146 tmp_sessions_path = os.path.join(session_path, variant, "tmp_sessions") 147 if not os.path.exists(tmp_sessions_path): 148 continue 149 n_sessions = os.listdir(tmp_sessions_path) 150 shutil.rmtree(tmp_sessions_path) 151 cleared_sessions[variant] = len(n_sessions) 152 log_info( 153 f"Cleared temporary sessions, statistics:", 154 self._parameters, 155 ) 156 log_dict(cleared_sessions, parameters=self._parameters)
Handles the creation of IDs and paths for saving emulator artifacts.
103 def get_session_path( 104 self, 105 session_name: Optional[str], 106 instance_id: Optional[str], 107 environment_variant: str, 108 ) -> str: 109 if session_name is None: 110 session_name = "tmp_sessions" 111 log_warn( 112 f"Saving a temporary session. If you run gameboy_worlds.clear_tmp_sessions(), it will be deleted. To make it permanent, pass in a `session_name` to the emulator constructor kwargs." 113 ) 114 elif not isinstance(session_name, str) or session_name == "": 115 log_error( 116 f"session_name must be a non-empty string. Recieved {session_name}", 117 self._parameters, 118 ) 119 storage_dir = self._parameters["storage_dir"] 120 session_path = os.path.join( 121 storage_dir, "sessions", environment_variant, session_name 122 ) 123 if instance_id is not None: 124 if not isinstance(instance_id, str) or instance_id == "": 125 log_error( 126 f"instance_id must be a non-empty string. Recieved {instance_id}", 127 self._parameters, 128 ) 129 session_path = os.path.join(session_path, "named_instances") 130 instance_id = self._get_numbered_instance_id(session_path, instance_id) 131 full_session_path = os.path.join(session_path, instance_id) 132 os.makedirs(full_session_path, exist_ok=True) 133 return full_session_path
135 def clear_tmp_sessions(self): 136 """ 137 Clears the tmp_sessions directory for ALL game variants. 138 """ 139 storage_dir = self._parameters["storage_dir"] 140 session_path = os.path.join(storage_dir, "sessions") 141 if not os.path.exists(session_path): 142 return 143 existing_variants = os.listdir(session_path) 144 cleared_sessions = {} 145 for variant in existing_variants: 146 tmp_sessions_path = os.path.join(session_path, variant, "tmp_sessions") 147 if not os.path.exists(tmp_sessions_path): 148 continue 149 n_sessions = os.listdir(tmp_sessions_path) 150 shutil.rmtree(tmp_sessions_path) 151 cleared_sessions[variant] = len(n_sessions) 152 log_info( 153 f"Cleared temporary sessions, statistics:", 154 self._parameters, 155 ) 156 log_dict(cleared_sessions, parameters=self._parameters)
Clears the tmp_sessions directory for ALL game variants.
159class VideoWriter: 160 def __init__( 161 self, 162 *, 163 session_path: str, 164 output_shape: Tuple[int, int], 165 reduce_resolution: bool, 166 parameters: dict, 167 ): 168 verify_parameters(parameters) 169 self._session_path = session_path 170 self._output_shape = output_shape 171 self._reduce_resolution = reduce_resolution 172 self._parameters = parameters 173 self._frame_writer = None 174 self.video_running = False 175 """ Whether the video writer is currently recording video. """ 176 project_dir = self._parameters["project_root"] 177 cv2 = import_cv2(self._parameters) 178 self._button_images = { 179 None: cv2.imread( 180 os.path.join(project_dir, "assets/buttons/idle.png"), 181 cv2.IMREAD_UNCHANGED, 182 ), 183 LowLevelActions.PRESS_ARROW_DOWN: cv2.imread( 184 os.path.join(project_dir, "assets/buttons/down.png"), 185 cv2.IMREAD_UNCHANGED, 186 ), 187 LowLevelActions.PRESS_ARROW_LEFT: cv2.imread( 188 os.path.join(project_dir, "assets/buttons/left.png"), 189 cv2.IMREAD_UNCHANGED, 190 ), 191 LowLevelActions.PRESS_ARROW_RIGHT: cv2.imread( 192 os.path.join(project_dir, "assets/buttons/right.png"), 193 cv2.IMREAD_UNCHANGED, 194 ), 195 LowLevelActions.PRESS_ARROW_UP: cv2.imread( 196 os.path.join(project_dir, "assets/buttons/up.png"), cv2.IMREAD_UNCHANGED 197 ), 198 LowLevelActions.PRESS_BUTTON_A: cv2.imread( 199 os.path.join(project_dir, "assets/buttons/a.png"), cv2.IMREAD_UNCHANGED 200 ), 201 LowLevelActions.PRESS_BUTTON_B: cv2.imread( 202 os.path.join(project_dir, "assets/buttons/b.png"), cv2.IMREAD_UNCHANGED 203 ), 204 LowLevelActions.PRESS_BUTTON_START: cv2.imread( 205 os.path.join(project_dir, "assets/buttons/start.png"), 206 cv2.IMREAD_UNCHANGED, 207 ), 208 # LowLevelActions.PRESS_BUTTON_SELECT: cv2.imread( 209 # os.path.join(project_dir, "assets/buttons/select.png"), cv2.IMREAD_UNCHANGED 210 # ), 211 } 212 213 def _get_free_video_id(self) -> str: 214 """ 215 Returns a new unique video ID for saving video files. 216 217 Returns: 218 str: A new unique video ID. 219 """ 220 base_dir = os.path.join(self._session_path, "videos") 221 videos = os.listdir(base_dir) if os.path.exists(base_dir) else [] 222 # all will be something.mp4, if its int.mp4, get the int 223 video_ints = [] 224 for video in videos: 225 if video.endswith(".mp4"): 226 video_name = video[:-4] 227 if video_name.isdigit(): 228 video_ints.append(int(video_name)) 229 if len(video_ints) == 0: 230 return "0.mp4" 231 return str(max(video_ints) + 1) + ".mp4" 232 233 def start_video(self, video_id: str = None): 234 """ 235 Starts recording video of the emulator's screen. 236 Args: 237 video_id (str, optional): Name of the video file to save. If None, a new unique name will be generated. 238 """ 239 if video_id is not None: 240 if not isinstance(video_id, str): 241 log_error( 242 "video_id must be a string (not digits) if provided.", 243 self._parameters, 244 ) 245 if not video_id.endswith(".mp4"): 246 log_error("video_id must end with .mp4 if provided.", self._parameters) 247 if os.path.exists(os.path.join(self._session_path, "videos", video_id)): 248 log_warn( 249 f"video_id {video_id} already exists. Overwriting...", 250 self._parameters, 251 ) 252 else: 253 video_id = self._get_free_video_id() 254 base_dir = os.path.join(self._session_path, "videos") 255 os.makedirs(base_dir, exist_ok=True) 256 video_path = os.path.join(base_dir, f"{video_id}") 257 self.close_video() 258 cv2 = import_cv2(self._parameters) 259 self._frame_writer = cv2.VideoWriter( 260 video_path, 261 cv2.VideoWriter_fourcc(*"mp4v"), 262 60, 263 (self._output_shape[0], self._output_shape[1]), 264 isColor=True, 265 ) 266 self.video_running = True 267 log_info(f"\nStarted recording video to: {video_path}\n", self._parameters) 268 269 def _get_reduced(self, frame: np.ndarray) -> np.ndarray: 270 """ 271 Reduces the resolution of the given frame by a factor of 2 using local mean downscaling. 272 Args: 273 frame (np.ndarray): The frame to reduce the resolution of. 274 Returns: 275 np.ndarray: The reduced resolution frame. 276 """ 277 reduced = (downscale_local_mean(frame, (2, 2, 1))).astype(np.uint8) 278 return reduced 279 280 def add_video_frames( 281 self, frames: np.ndarray, pressed_button: Optional[LowLevelActions] = None, show_button: bool = True 282 ): 283 """ 284 Adds a list of frame from the emulator to the video being recorded. 285 286 Args: 287 frames (np.ndarray): A stack of frames to add to the video. Shape is [n_frames, height, width, channels]. 288 pressed_button (LowLevelActions, optional): The button that was pressed during these frames. If None, no button overlay will be added. 289 show_button (bool, optional): Whether to show the button overlay on the video. Defaults to True. 290 """ 291 if show_button: 292 button_image = self._button_images[pressed_button] 293 button_size = 50 294 button_offset = 0 295 button_x = self._output_shape[0] - button_size - button_offset 296 button_y = self._output_shape[1] - button_size - button_offset 297 cv2 = import_cv2(self._parameters) 298 button_image = cv2.resize(button_image, (button_size, button_size)) 299 alphas = button_image[:, :, 3] / 255.0 300 301 for frame in frames: 302 if self._reduce_resolution: 303 frame = self._get_reduced(frame) 304 # frame_size = (current_frame.shape[1], current_frame.shape[0], 1) # Width, Height, should be equal to self.output_shape 305 # expand grayscale frame to 3 channels for video writing 306 treated_frame = np.repeat(frame, 3, axis=2) 307 if show_button: 308 treated_frame[ 309 button_y : button_y + button_size, button_x : button_x + button_size 310 ] = ( 311 treated_frame[ 312 button_y : button_y + button_size, button_x : button_x + button_size 313 ] 314 * (1 - alphas[:, :, np.newaxis]) 315 + button_image[:, :, :3] * alphas[:, :, np.newaxis] 316 ).astype( 317 np.uint8 318 ) 319 else: 320 pass 321 self._frame_writer.write(treated_frame) 322 return 323 324 def close_video(self): 325 """ 326 Closes the video writer and stops recording video. 327 """ 328 if self._frame_writer is not None: 329 self._frame_writer.release() 330 self._frame_writer = None 331 self.video_running = False
160 def __init__( 161 self, 162 *, 163 session_path: str, 164 output_shape: Tuple[int, int], 165 reduce_resolution: bool, 166 parameters: dict, 167 ): 168 verify_parameters(parameters) 169 self._session_path = session_path 170 self._output_shape = output_shape 171 self._reduce_resolution = reduce_resolution 172 self._parameters = parameters 173 self._frame_writer = None 174 self.video_running = False 175 """ Whether the video writer is currently recording video. """ 176 project_dir = self._parameters["project_root"] 177 cv2 = import_cv2(self._parameters) 178 self._button_images = { 179 None: cv2.imread( 180 os.path.join(project_dir, "assets/buttons/idle.png"), 181 cv2.IMREAD_UNCHANGED, 182 ), 183 LowLevelActions.PRESS_ARROW_DOWN: cv2.imread( 184 os.path.join(project_dir, "assets/buttons/down.png"), 185 cv2.IMREAD_UNCHANGED, 186 ), 187 LowLevelActions.PRESS_ARROW_LEFT: cv2.imread( 188 os.path.join(project_dir, "assets/buttons/left.png"), 189 cv2.IMREAD_UNCHANGED, 190 ), 191 LowLevelActions.PRESS_ARROW_RIGHT: cv2.imread( 192 os.path.join(project_dir, "assets/buttons/right.png"), 193 cv2.IMREAD_UNCHANGED, 194 ), 195 LowLevelActions.PRESS_ARROW_UP: cv2.imread( 196 os.path.join(project_dir, "assets/buttons/up.png"), cv2.IMREAD_UNCHANGED 197 ), 198 LowLevelActions.PRESS_BUTTON_A: cv2.imread( 199 os.path.join(project_dir, "assets/buttons/a.png"), cv2.IMREAD_UNCHANGED 200 ), 201 LowLevelActions.PRESS_BUTTON_B: cv2.imread( 202 os.path.join(project_dir, "assets/buttons/b.png"), cv2.IMREAD_UNCHANGED 203 ), 204 LowLevelActions.PRESS_BUTTON_START: cv2.imread( 205 os.path.join(project_dir, "assets/buttons/start.png"), 206 cv2.IMREAD_UNCHANGED, 207 ), 208 # LowLevelActions.PRESS_BUTTON_SELECT: cv2.imread( 209 # os.path.join(project_dir, "assets/buttons/select.png"), cv2.IMREAD_UNCHANGED 210 # ), 211 }
233 def start_video(self, video_id: str = None): 234 """ 235 Starts recording video of the emulator's screen. 236 Args: 237 video_id (str, optional): Name of the video file to save. If None, a new unique name will be generated. 238 """ 239 if video_id is not None: 240 if not isinstance(video_id, str): 241 log_error( 242 "video_id must be a string (not digits) if provided.", 243 self._parameters, 244 ) 245 if not video_id.endswith(".mp4"): 246 log_error("video_id must end with .mp4 if provided.", self._parameters) 247 if os.path.exists(os.path.join(self._session_path, "videos", video_id)): 248 log_warn( 249 f"video_id {video_id} already exists. Overwriting...", 250 self._parameters, 251 ) 252 else: 253 video_id = self._get_free_video_id() 254 base_dir = os.path.join(self._session_path, "videos") 255 os.makedirs(base_dir, exist_ok=True) 256 video_path = os.path.join(base_dir, f"{video_id}") 257 self.close_video() 258 cv2 = import_cv2(self._parameters) 259 self._frame_writer = cv2.VideoWriter( 260 video_path, 261 cv2.VideoWriter_fourcc(*"mp4v"), 262 60, 263 (self._output_shape[0], self._output_shape[1]), 264 isColor=True, 265 ) 266 self.video_running = True 267 log_info(f"\nStarted recording video to: {video_path}\n", self._parameters)
Starts recording video of the emulator's screen.
Arguments:
- video_id (str, optional): Name of the video file to save. If None, a new unique name will be generated.
280 def add_video_frames( 281 self, frames: np.ndarray, pressed_button: Optional[LowLevelActions] = None, show_button: bool = True 282 ): 283 """ 284 Adds a list of frame from the emulator to the video being recorded. 285 286 Args: 287 frames (np.ndarray): A stack of frames to add to the video. Shape is [n_frames, height, width, channels]. 288 pressed_button (LowLevelActions, optional): The button that was pressed during these frames. If None, no button overlay will be added. 289 show_button (bool, optional): Whether to show the button overlay on the video. Defaults to True. 290 """ 291 if show_button: 292 button_image = self._button_images[pressed_button] 293 button_size = 50 294 button_offset = 0 295 button_x = self._output_shape[0] - button_size - button_offset 296 button_y = self._output_shape[1] - button_size - button_offset 297 cv2 = import_cv2(self._parameters) 298 button_image = cv2.resize(button_image, (button_size, button_size)) 299 alphas = button_image[:, :, 3] / 255.0 300 301 for frame in frames: 302 if self._reduce_resolution: 303 frame = self._get_reduced(frame) 304 # frame_size = (current_frame.shape[1], current_frame.shape[0], 1) # Width, Height, should be equal to self.output_shape 305 # expand grayscale frame to 3 channels for video writing 306 treated_frame = np.repeat(frame, 3, axis=2) 307 if show_button: 308 treated_frame[ 309 button_y : button_y + button_size, button_x : button_x + button_size 310 ] = ( 311 treated_frame[ 312 button_y : button_y + button_size, button_x : button_x + button_size 313 ] 314 * (1 - alphas[:, :, np.newaxis]) 315 + button_image[:, :, :3] * alphas[:, :, np.newaxis] 316 ).astype( 317 np.uint8 318 ) 319 else: 320 pass 321 self._frame_writer.write(treated_frame) 322 return
Adds a list of frame from the emulator to the video being recorded.
Arguments:
- frames (np.ndarray): A stack of frames to add to the video. Shape is [n_frames, height, width, channels].
- pressed_button (LowLevelActions, optional): The button that was pressed during these frames. If None, no button overlay will be added.
- show_button (bool, optional): Whether to show the button overlay on the video. Defaults to True.
324 def close_video(self): 325 """ 326 Closes the video writer and stops recording video. 327 """ 328 if self._frame_writer is not None: 329 self._frame_writer.release() 330 self._frame_writer = None 331 self.video_running = False
Closes the video writer and stops recording video.
334class Emulator: 335 """ 336 Handles the running of the GameBoy emulator, including loading ROMs, managing state, performing low level actions and calling the tracker. 337 Subclasses will likely only be needed to manually force the execution of specific button sequences when certain states are detected. (e.g. short-circuiting specific menus, etc.) 338 339 Can be used to access the `state_parser` and `state_tracker` instances for the running game instance. 340 """ 341 342 REQUIRED_STATE_PARSER = StateParser 343 """ The minimal functionality StateParser needed for this emulator to run """ 344 345 REQUIRED_STATE_TRACKER = StateTracker 346 """ The minimal functionality StateTracker needed for this emulator to run """ 347 348 def __init__( 349 self, 350 game: str, 351 gb_path: str, 352 state_parser_class: Type[StateParser], 353 state_tracker_class: Type[StateTracker], 354 init_state: str, 355 parameters: dict, 356 *, 357 headless: bool = True, 358 max_steps: int = None, 359 save_video: bool = None, 360 session_name: str = None, 361 instance_id: str = None, 362 wait_ticks: int = None, 363 press_step: int = None, 364 ): 365 """ 366 Start the GameBoy emulator with the given ROM file and initial state. 367 368 Args: 369 game (str): Name of game variant being emulated. 370 gb_path (str): Path to the GameBoy ROM file. 371 state_parser_class (Type[StateParser]): A class that inherits from StateParser to parse game state variables. 372 state_tracker_class (Type[StateTracker]): A class that inherits from StateTracker to track game state metrics. 373 init_state (str): Path to the initial state file to load. 374 parameters (dict): Dictionary of parameters for the environment. 375 headless (bool, optional): Whether to run the environment in headless mode. 376 max_steps (int, optional): Maximum number of steps per episode. 377 save_video (bool, optional): Whether to save video of the episodes. 378 session_name (str, optional): Name of the session. If None, a new session name will be allocated. This is the broad category you want to save files to 379 instance_id (str, optional): Unique identifier for this environment instance. If None, a new UUID will be generated. The instance ID is useful for distinguishing multiple environments running in parallel with the same session name. 380 wait_ticks (int, optional): Number of ticks to wait between actions. 381 press_step (int, optional): Number of steps to press a button for. 382 """ 383 verify_parameters(parameters) 384 self._parameters = parameters 385 if game is None or game == "": 386 log_error( 387 "You must provide a name for the game variant being emulated.", 388 self._parameters, 389 ) 390 if gb_path is None: 391 log_error( 392 "You must provide a path to the GameBoy ROM file.", self._parameters 393 ) 394 if not issubclass(state_parser_class, self.REQUIRED_STATE_PARSER): 395 log_error( 396 f"state_parser_class must be a subclass of {self.REQUIRED_STATE_PARSER.__name__}, got {state_parser_class_.__name__}.", 397 self._parameters, 398 ) 399 if not issubclass(state_tracker_class, self.REQUIRED_STATE_TRACKER): 400 log_error( 401 f"state_tracker_class must be a subclass of {self.REQUIRED_STATE_TRACKER.__name__}, got {state_tracker_class.__name__}.", 402 self._parameters, 403 ) 404 if init_state is None: 405 log_error( 406 "You must provide an initial state file to load.", self._parameters 407 ) 408 if headless not in [True, False]: 409 log_error("headless must be a boolean.", self._parameters) 410 self.game = game 411 """ Name of game variant being emulated. """ 412 self._gb_path = gb_path 413 self._set_init_state(init_state) 414 # validate init_state exists and ends with .state 415 if not os.path.exists(self._gb_path): 416 log_error( 417 f"GameBoy ROM file {self._gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {self._gb_path}", 418 self._parameters, 419 ) 420 if not self._gb_path.endswith(".gb") and not self._gb_path.endswith(".gbc"): 421 log_error( 422 f"GameBoy ROM file {self._gb_path} is not a .gb or .gbc file.", 423 self._parameters, 424 ) 425 self.headless = headless 426 """ Whether to run the environment in headless mode.""" 427 if max_steps is None: 428 max_steps = self._parameters["gameboy_max_steps"] 429 if max_steps > self._parameters["gameboy_hard_max_steps"]: 430 log_warn( 431 f"max_steps {max_steps} exceeds gameboy_hard_max_steps {self._parameters['gameboy_hard_max_steps']}. Setting to hard max.", 432 self._parameters, 433 ) 434 max_steps = self._parameters["gameboy_hard_max_steps"] 435 self.max_steps = max_steps 436 """ Maximum number of steps per episode. """ 437 id_path_creator = IDPathCreator(self._parameters) 438 self.session_path = id_path_creator.get_session_path( 439 session_name=session_name, 440 instance_id=instance_id, 441 environment_variant=self.get_env_variant(), 442 ) 443 """ Path to the session directory. This is where all artifacts for this session are saved. """ 444 445 if wait_ticks is None: 446 wait_ticks = parameters["gameboy_wait_ticks"] 447 self.wait_ticks = wait_ticks 448 """ Number of emulator ticks to wait after an action. Defaults to value specified in config files. """ 449 if press_step is None: 450 press_step = parameters["gameboy_press_step"] 451 self.press_step = press_step 452 """ Number of emulator ticks to hold down a button press. Defaults to value specified in config files. """ 453 self.render_headless = parameters["gameboy_headless_render"] 454 """ Whether to render the emulator screen even in headless mode. This must be true for methods that rely on image observations (e.g. VLMs) to access the screen. Defaults to value specified in config files. """ 455 if not self.render_headless: 456 log_error( 457 "render_headless cannot be set to False. In the Pokemon environments, screen captures are used aggressively to determine state. ", 458 self._parameters, 459 ) 460 461 self.reset_count = 0 462 """ Number of times the environment has been reset. """ 463 self.step_count = 0 464 """ Number of steps taken in the current episode. """ 465 self._reduce_video_resolution = parameters["gameboy_reduce_video_resolution"] 466 frame_size = ( 467 160, 468 144, 469 ) 470 self.screen_shape = (frame_size[0], frame_size[1], 1) 471 """ Resolution of the rendered game screen """ 472 if self._reduce_video_resolution: 473 self.output_shape = (frame_size[0] // 2, frame_size[1] // 2) 474 else: 475 self.output_shape = (frame_size[0], frame_size[1]) 476 """ Shape of the output observations. This is the resolution of the rendered screen. """ 477 478 if save_video is None: 479 save_video = self._parameters["gameboy_default_save_video"] 480 self.save_video = save_video 481 """ Whether to save video of the episodes. """ 482 self.video_writer = VideoWriter( 483 session_path=self.session_path, 484 output_shape=self.output_shape, 485 reduce_resolution=self._reduce_video_resolution, 486 parameters=self._parameters, 487 ) 488 """ Holds the VideoWriter of this Emulator instance """ 489 490 head = "null" if self.headless else "SDL2" 491 492 self._pyboy = PyBoy( 493 self._gb_path, 494 window=head, 495 ) 496 self.state_parser = state_parser_class(self._pyboy, self._parameters) 497 """ Instance of the StateParser to parse game state variables. """ 498 499 self.state_tracker = state_tracker_class( 500 self.state_parser, 501 self._parameters, 502 ) 503 """ Instance of the StateTracker to track game state metrics. """ 504 505 # self.screen = self.pyboy.botsupport_manager().screen() 506 507 if not self.headless: 508 if not is_none_str(self._parameters["gameboy_headed_emulation_speed"]): 509 self._pyboy.set_emulation_speed( 510 int(self._parameters["gameboy_headed_emulation_speed"]) 511 ) 512 self.reset() 513 514 @staticmethod 515 def create_first_state(gb_path: str, state_path: str): 516 """ 517 Creates a basic state for the emulator. This can be used to create an initial, default state file for a new game. 518 519 Warning: This method uses parameter free logging, so if you override the log_file with a command prompt argument, it will be ignored here. 520 521 Args: 522 gb_path (str): Path to the GameBoy ROM file. 523 state_path (str): Path to save the initial state file. 524 """ 525 # error out if gb_path does not exist or is not a .gb or .gbc file 526 if not os.path.exists(gb_path): 527 log_error( 528 f"GameBoy ROM file {gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {gb_path}" 529 ) 530 if not gb_path.endswith(".gb") and not gb_path.endswith(".gbc"): 531 log_error(f"GameBoy ROM file {gb_path} is not a .gb or .gbc file.") 532 if not state_path.endswith(".state"): 533 state_path = state_path + ".state" 534 if os.path.exists(state_path): 535 log_error(f"State file {state_path} already exists. Will not overwrite...") 536 file_makedir(state_path) 537 pyboy = PyBoy( 538 gb_path, 539 window="null", 540 ) 541 with open(state_path, "wb") as f: 542 pyboy.save_state(f) 543 pyboy.stop() 544 log_info(f"Created initial state file at {state_path}") 545 sys.exit(0) 546 547 def set_init_state(self, init_state: str): 548 """Sets a new initial state file for the environment. and resets the environment. 549 550 Args: 551 init_state (str): Path to the new initial state file. 552 """ 553 self._set_init_state(init_state) 554 self.reset() 555 556 def _set_init_state(self, init_state: str): 557 """ 558 Sets a new initial state file for the environment to eventually load. 559 Does not reset the environment. 560 561 Args: 562 init_state (str): Path to the new initial state file. 563 """ 564 if init_state is None: 565 # log_warn(f"No initial state file provided. Using default initial state.", self._parameters) 566 return 567 if not init_state.endswith(".state"): 568 init_state = init_state + ".state" 569 states_path = self._parameters[f"{self.game}_rom_data_path"] + "/states/" 570 if not os.path.exists(init_state): 571 if states_path in init_state: 572 log_error( 573 f"Initial state file {init_state} does not exist.", self._parameters 574 ) 575 # try to resolve. 576 potential_path = os.path.join(states_path, init_state) 577 if os.path.exists(potential_path): 578 init_state = potential_path 579 else: 580 log_error( 581 f"Initial state file {init_state} does not exist.", self._parameters 582 ) 583 self.init_state = init_state 584 log_info(f"Set new initial state file to {self.init_state}", self._parameters) 585 586 def reset(self, new_init_state: str = None): 587 """ 588 Resets the environment to the initial state. Optionally loads a new initial state file. 589 590 Args: 591 new_init_state (str, optional): Path to a new initial state file to load. 592 """ 593 # validate the new_init_state if provided 594 if new_init_state is not None: 595 self._set_init_state(new_init_state) 596 # restart game, skipping to init_state 597 with open(self.init_state, "rb") as f: 598 self._pyboy.load_state(f) 599 600 self.reset_count += 1 601 self.step_count = 0 602 self.state_tracker.reset() 603 self.video_writer.close_video() 604 return 605 606 def get_current_frame(self) -> np.ndarray: 607 """ 608 Renders the currently rendered screen of the emulator and returns it as a numpy array. 609 610 Returns: 611 np.ndarray: The rendered image as a numpy array. 612 """ 613 return self.state_parser.get_current_frame() 614 615 def _update_listeners_after_actions(self, frames: np.ndarray): 616 """ 617 Updates the state tracker after a batch of actions are run on the emulator without step() 618 619 You should *not* call this method when implementing HighLevelActions, instead call step(), track the states at each step, and return the list of transition states. 620 621 Args: 622 frames (np.ndarray): Frames of shape [n_frames, H, W, C] that contain the frames which elapsed during the run of the actions outside step 623 """ 624 self.state_tracker.step(frames) 625 626 def _get_unique_frames(self, frames: np.ndarray) -> np.ndarray: 627 """ 628 Removes duplicate frames from a stack of frames. 629 630 Args: 631 frames (np.ndarray): Stack of frames of shape [n_frames, H, W, C]. 632 Returns: 633 np.ndarray: Stack of frames with duplicates removed. Shape is [n_unique_frames, H, W, C]. 634 """ 635 unique_frames = [] 636 for frame in frames: 637 if len(unique_frames) == 0: 638 unique_frames.append(frame) 639 else: 640 if not np.array_equal(frame, unique_frames[-1]): 641 unique_frames.append(frame) 642 unique_frames = np.stack(unique_frames, axis=0) 643 return unique_frames 644 645 def step(self, action: LowLevelActions = None) -> Tuple[Optional[np.ndarray], bool]: 646 """ 647 Takes a step in the environment by performing the given action on the emulator. If saving video, starts the video recording on the first step. 648 649 :param action: Lowest level action to perform on the emulator. 650 :type action: LowLevelActions 651 :return: 652 - The stack of frames that passed while performing the action, if rendering is enabled. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 653 654 - Is max steps reached. 655 :rtype: Tuple[Optional[np.ndarray], bool] 656 """ 657 if action is not None: 658 if action not in LowLevelActions: 659 log_error( 660 f"Invalid action {action}. Must be one of {list(LowLevelActions)} or None", 661 self._parameters, 662 ) 663 if self.step_count >= self.max_steps: 664 log_warn( 665 "Step called after max_steps reached. Please reset the environment.", 666 self._parameters, 667 ) 668 # This does not exit because some HighLevelActions may call step() multiple times in their execution. 669 # It is not the best practice to allow this to happen, but it is easier to not error out here than check in every HighLevelAction, and this won't advantage the agent too much. 670 # One consequence, however, is that max_steps then becomes a soft limit rather than a hard limit. 671 672 if self.save_video and self.step_count == 0: 673 self.video_writer.start_video() 674 675 frames = self.run_action_on_emulator(action) 676 self.step_count += 1 677 frames = self._get_unique_frames(frames) 678 self._update_listeners_after_actions(frames) 679 return frames, self.check_if_done() 680 681 def get_state_parser(self) -> StateParser: 682 """ 683 Returns the current game state parser instance. 684 685 Returns: 686 StateParser: The current game state parser. 687 """ 688 return self.state_parser 689 690 def run_action_on_emulator( 691 self, action: LowLevelActions = None 692 ) -> Optional[np.ndarray]: 693 """ 694 695 Performs the given action on the emulator by pressing and releasing the corresponding button. 696 697 Args: 698 action (LowLevelActions): Lowest level action to perform on the emulator. 699 Returns: 700 Optional[np.ndarray]: The stack of frames that passed while performing the actions. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 701 """ 702 frames = None 703 if action is not None: 704 frames = [] 705 self._pyboy.send_input(action.value) 706 press_step = self.press_step 707 self._pyboy.tick(press_step, True) 708 frames.append(self.get_current_frame()) 709 self._pyboy.send_input(ReleaseActions.release_actions.value[action]) 710 self._pyboy.tick(self.wait_ticks + press_step + 1, True) 711 frames.append(self.get_current_frame()) 712 # self._pyboy.tick(1, True) 713 # frames.append(self.get_current_frame()) 714 frames = np.stack(frames, axis=0) 715 else: 716 self._pyboy.tick(self.wait_ticks, True) 717 frames = [self.get_current_frame()] 718 frames = np.array(frames) 719 if self.save_video and self.video_writer.video_running: 720 self.video_writer.add_video_frames(frames, pressed_button=action) 721 return frames 722 723 def check_if_done(self): 724 """ 725 Checks if the max_steps limit has been reached. 726 """ 727 done = self.step_count >= self.max_steps - 1 728 return done 729 730 def close(self) -> StateTracker: 731 """ 732 Closes the emulator and any associated resources. 733 If the session directory is empty after closing, it will be deleted. 734 """ 735 self.state_tracker.close() 736 self._pyboy.stop(save=False) 737 self.video_writer.close_video() 738 self.state_tracker.close() 739 # check if session directory is empty, and if so delete it 740 if ( 741 os.path.exists(self.session_path) 742 and len(os.listdir(self.session_path)) == 0 743 ): 744 os.rmdir(self.session_path) 745 return self.state_tracker 746 747 def human_play(self, max_steps: int = None): 748 """ 749 Allows a human to play the emulator using keyboard inputs. 750 Args: 751 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 752 """ 753 if max_steps is None: 754 max_steps = self._parameters["gameboy_hard_max_steps"] 755 log_info( 756 "Starting human play mode. Use arrow keys and A(a)/B(s)/Start(enter) buttons to play. Close the window to exit.", 757 self._parameters, 758 ) 759 if self.headless: 760 log_error( 761 "Human play mode requires headless=False. Change the initialization", 762 self._parameters, 763 ) 764 self.reset() 765 while True: 766 self._pyboy.tick(1, True) 767 self.state_tracker.step() 768 if self.step_count >= max_steps: 769 break 770 self.close() 771 772 def random_play(self, max_steps: int = None): 773 """ 774 Allows the emulator to play itself using (sort of) random actions. 775 Args: 776 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 777 """ 778 if max_steps is None: 779 max_steps = self._parameters["gameboy_random_play_max_steps"] 780 log_info("Starting random play mode.", self._parameters) 781 self.reset() 782 pbar = tqdm(total=max_steps, desc="Random Play Steps") 783 allowed_actions = list(LowLevelActions) 784 # remove the Start and Select actions from allowed actions to avoid menu spamming. 785 allowed_actions.remove(LowLevelActions.PRESS_BUTTON_START) 786 while self.step_count < max_steps: 787 action = np.random.choice(allowed_actions) 788 frames, done = self.step(action) 789 pbar.update(1) 790 if done: 791 break 792 pbar.close() 793 self.close() 794 log_info("Random play mode ended.", self._parameters) 795 796 def _dev_play(self, max_steps: int = None): 797 """ 798 Allows a human to play the emulator using keyboard inputs. Does not route through step function. 799 This function continuously reads from the parameters in the configs directory and if it detects a change in the `gameboy_dev_play_stop` parameter, will enter a breakpoint 800 801 Args: 802 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 803 """ 804 if not hasattr(self.state_parser, "rom_data_path"): 805 log_error( 806 "Development play mode requires a StateParser with rom_data_path attribute.", 807 self._parameters, 808 ) 809 if max_steps is None: 810 max_steps = self._parameters["gameboy_hard_max_steps"] 811 log_info( 812 "Starting human play mode. Use arrow keys and A(a)/B(s)/Start(enter) buttons to play. Close the window to exit. Open configs/gameboy_vars.yaml and set gameboy_dev_play_stop to true to enable development mode.", 813 self._parameters, 814 ) 815 if self.headless: 816 log_error( 817 "Human play mode requires headless=False. Change the initialization", 818 self._parameters, 819 ) 820 self.reset() 821 valid_regions = [] 822 unassigned_regions = [] 823 for region_name, region in self.state_parser.named_screen_regions.items(): 824 valid_regions.append(region_name) 825 if region.multi_targets is None: 826 if region.target is None: 827 unassigned_regions.append(region_name) 828 else: 829 for target_name in region.multi_targets.keys(): 830 if region.multi_targets[target_name] is None: 831 unassigned_regions.append((region_name, target_name)) 832 if len(unassigned_regions) > 0: 833 log_warn( 834 f"Unassigned regions (target array not set) are: {unassigned_regions}", 835 self._parameters, 836 ) 837 if self.save_video: 838 self.video_writer.start_video() 839 while True: 840 self._parameters = load_parameters() 841 if not self._parameters["gameboy_dev_play_stop"]: 842 self._pyboy.tick(1, True) 843 self.state_tracker.step() 844 frames = [self.get_current_frame()] 845 frames = np.array(frames) 846 if self.save_video: 847 self.video_writer.add_video_frames(frames, show_button=False) 848 else: 849 tracker_report = self.state_tracker.report() 850 tracker_report["core"].pop("current_frame", None) 851 tracker_report["core"].pop("passed_frames", None) 852 dev_instructions = f""" 853 In development mode. 854 Enter 'e' to close the emulator. 855 Enter '' to re-enter normal play mode (remember to change gameboy_dev_play_stop back to false in configs or it'll stop again). 856 Enter 'p' to print the current state. 857 Enter 'w' to pass a single tick without any action. 858 Enter 's <state_name>' to save the current state as a .state file. 859 Enter 'l <state_name>' to load a .state file. 860 Enter 'c <region_name> <save_name / None if region.target_path is set>' to capture a named region and save it as a .npy file. To enter a multi-target region use the format "c <region_name>,<target_name> <save_name>" (no spaces in between region and target name) 861 Enter 'd <None / region_name>' to draw a named region and display the current screen with the region drawn. 862 Enter 'b' to enter a breakpoint. 863 Enter 'g <code>' to apply a GameShark code (e.g. g 0101C7C9). 864 Valid region names are: {valid_regions} 865 Initially unassigned regions (target array not set) were: {unassigned_regions}\n\t Note: This list does not update as you assign targets during this session. 866 Current State: 867 """ 868 log_info(dev_instructions, self._parameters) 869 tracker_report.pop("ocr", None) 870 log_dict(tracker_report, parameters=self._parameters) 871 user_input = input("Dev mode input: ") 872 user_input = user_input.lower().strip() 873 first_char = user_input[0] if len(user_input) > 0 else "" 874 allowed_inputs = ["e", "", "p", "w", "c", "s", "l", "d", "b", "g"] 875 if first_char not in allowed_inputs: 876 log_warn( 877 f"Invalid input {user_input}. Valid inputs are: {allowed_inputs}", 878 self._parameters, 879 ) 880 continue 881 if first_char == "e": 882 log_info("Exiting human play mode.", self._parameters) 883 break 884 elif first_char == "": 885 log_info( 886 "Exiting development mode. Resuming normal play.", 887 self._parameters, 888 ) 889 continue 890 elif first_char == "p": 891 log_info( 892 f"Current State:\n{str(self.state_tracker)}", self._parameters 893 ) 894 continue 895 elif first_char == "w": 896 self._pyboy.tick(1, True) 897 self.state_tracker.step() 898 continue 899 elif first_char == "s" or first_char == "l": 900 parts = user_input.split(" ") 901 if len(parts) != 2: 902 log_warn(f"Invalid input {user_input}.", self._parameters) 903 continue 904 state_name = parts[1] 905 if not state_name.endswith(".state"): 906 state_name = state_name + ".state" 907 state_path = os.path.join( 908 self.state_parser.rom_data_path, "states", state_name 909 ) 910 if first_char == "s": 911 if os.path.exists(state_path): 912 confirm_input = input( 913 f"State file {state_path} already exists. Overwrite? (y/n): " 914 ) 915 if confirm_input.lower().strip() != "y": 916 log_info("Aborting save state.", self._parameters) 917 continue 918 self.save_state(state_path) 919 else: 920 if not os.path.exists(state_path): 921 log_warn( 922 f"State file {state_path} does not exist. Cannot load.", 923 self._parameters, 924 ) 925 continue 926 self.set_init_state(state_path) 927 elif first_char == "b": 928 grid_cells = self.state_parser.capture_grid_cells( 929 current_frame=self.get_current_frame(), y_offset=0 930 ) 931 breakpoint() 932 elif first_char == "g": 933 parts = user_input.split(" ") 934 if len(parts) != 2: 935 log_warn( 936 f"Invalid input {user_input}. Usage: g <code>", 937 self._parameters, 938 ) 939 else: 940 code = parts[1].upper() 941 self._pyboy.gameshark.add(code) 942 log_info(f"GameShark code applied: {code}", self._parameters) 943 continue 944 else: 945 current_frame = self.get_current_frame() 946 # draw it even if c, so we can see what we're capturing 947 save_path = None 948 if first_char == "c": 949 parts = user_input.split(" ") 950 if len(parts) != 3: 951 if len(parts) != 2: 952 log_warn( 953 f"Invalid input {user_input}.", self._parameters 954 ) 955 continue 956 else: 957 region_name = parts[1].split(",")[0] 958 region = self.state_parser.named_screen_regions[ 959 region_name 960 ] 961 if region.multi_targets is None: 962 save_path = region.target_path 963 else: 964 if "," not in parts[1]: 965 log_warn( 966 f"Region {region_name} is a multi-target region. Specify target", 967 self._parameters, 968 ) 969 continue 970 target_name = parts[1].split(",")[1] 971 if target_name not in region.multi_target_paths: 972 log_warn( 973 f"Target name {target_name} not found in region {region_name} with targets {region.multi_targets.keys()}.", 974 self._parameters, 975 ) 976 continue 977 save_path = region.multi_target_paths[target_name] 978 if save_path is None: 979 log_warn( 980 f"Region {region_name} does not have a target path specified. Please provide a save name.", 981 self._parameters, 982 ) 983 continue 984 else: 985 save_name = parts[2] 986 if not save_name.endswith(".npy"): 987 save_name = save_name + ".npy" 988 save_path = os.path.join( 989 self.state_parser.rom_data_path, 990 "captures", 991 save_name, 992 ) 993 file_makedir(save_path) 994 elif first_char == "d": 995 parts = user_input.split(" ") 996 if len(parts) == 2: 997 region_name = parts[1].split(",")[ 998 0 999 ] # Shouldn't need but anyway. 1000 elif len(parts) == 1: 1001 region_name = "Full Screen" 1002 else: 1003 log_warn(f"Invalid input {user_input}.", self._parameters) 1004 continue 1005 if region_name == "Full Screen": 1006 drawn_frame = current_frame # self.state_parser.draw_grid_overlay(current_frame) 1007 else: 1008 drawn_frame = self.state_parser.draw_named_region( 1009 current_frame, region_name 1010 ) 1011 plt.imshow(drawn_frame[:, :, 0], cmap="gray") 1012 plt.title(f"Region: {region_name}") 1013 plt.show() 1014 if first_char == "c": 1015 captured_region = self.state_parser.capture_named_region( 1016 current_frame, region_name 1017 ) 1018 plt.imshow(captured_region[:, :, 0], cmap="gray") 1019 plt.title(f"Captured Region: {region_name}") 1020 plt.show() 1021 existing_file = os.path.exists(save_path) 1022 existing_str = ( 1023 "" 1024 if not existing_file 1025 else " (will overwrite existing file)" 1026 ) 1027 confirmation_input = input( 1028 f"Save captured region {region_name} to {save_path}? (y/n) {existing_str}: " 1029 ) 1030 if confirmation_input.lower().strip() != "y": 1031 log_info("Aborting capture region.", self._parameters) 1032 continue 1033 np.save(save_path, captured_region) 1034 log_info( 1035 f"Saved captured region {region_name} to {save_path}", 1036 self._parameters, 1037 ) 1038 if self.step_count >= max_steps: 1039 break 1040 tracker = self.close() 1041 log_info("Human play mode ended.", self._parameters) 1042 # log_dict(tracker.report_final(), parameters=self._parameters) 1043 1044 def save_state(self, state_name: str, error_if_exists: bool = False): 1045 """ 1046 Saves the current state of the emulator to a .state file. 1047 Args: 1048 state_name (str): Name of the state file to save (with or without .state extension). 1049 error_if_exists (bool): Whether to raise an error if the state file already exists. 1050 """ 1051 if not state_name.endswith(".state"): 1052 state_name = state_name + ".state" 1053 state_dir = os.path.abspath(self.state_parser.rom_data_path + "/states/") 1054 potential_state_dir = os.path.abspath(os.path.dirname(state_name)) 1055 if state_dir != potential_state_dir: 1056 if potential_state_dir != os.path.abspath(""): 1057 log_error( 1058 f"Tried to save state file to {state_name}, which is outside of the states directory {state_dir}. This is not allowed.", 1059 self._parameters, 1060 ) 1061 state_name = os.path.abspath(os.path.join(state_dir, state_name)) 1062 else: 1063 pass 1064 if os.path.exists(state_name): 1065 if error_if_exists: 1066 log_error( 1067 f"State file {state_name} already exists. Will not overwrite...", 1068 self._parameters, 1069 ) 1070 file_makedir(state_name) 1071 with open(state_name, "wb") as f: 1072 self._pyboy.save_state(f) 1073 log_info(f"Saved state to {state_name}", self._parameters) 1074 1075 def delete_state(self, state_name: str, error_if_not_exists: bool = False): 1076 """ 1077 Deletes a .state file from the states directory. 1078 1079 Args: 1080 state_name (str): Name of the state file to delete (with or without .state extension). 1081 error_if_not_exists (bool): Whether to raise an error if the state file does not exist. 1082 """ 1083 if not state_name.endswith(".state"): 1084 state_name = state_name + ".state" 1085 state_path = os.path.join(self.state_parser.rom_data_path, "states", state_name) 1086 if not os.path.exists(state_path): 1087 if error_if_not_exists: 1088 log_error( 1089 f"State file {state_path} does not exist. Cannot delete.", 1090 self._parameters, 1091 ) 1092 else: 1093 log_warn( 1094 f"State file {state_path} does not exist. Cannot delete.", 1095 self._parameters, 1096 ) 1097 return 1098 # if somehow state_path isn't in the states directory, error out to avoid deleting random files 1099 if os.path.abspath(os.path.dirname(state_path)) != os.path.abspath( 1100 self.state_parser.rom_data_path + "/states/" 1101 ): 1102 log_error( 1103 f"Tried to delete state file at {state_path}, which is outside of the states directory. This is not allowed.", 1104 self._parameters, 1105 ) 1106 os.remove(state_path) 1107 log_info(f"Deleted state file {state_path}", self._parameters) 1108 1109 def _sav_to_state(self, sav_file: Optional[str], state_name: str): 1110 """ 1111 Loads a .sav file into the emulator and saves the corresponding .state file. 1112 Use this if you want to manually create .sav files and convert them to .state files for use as initial states. 1113 Requires `_open_to_first_state` to be implemented in the subclass to get past opening menus. 1114 1115 Args: 1116 save_file (str or None): Path to the .sav file to load. If None, looks for a .sav file in the same directory as the ROM with the same base name. 1117 state_name (str): Name of the state to save. The .state file will be saved in the states directory of the rom_data_path with this name. 1118 """ 1119 log_info( 1120 "Trying to find .sav file and convert to .state file. This is a breaking operation, so the program will terminate after its completion.", 1121 self._parameters, 1122 ) 1123 if sav_file is not None: 1124 expected_sav = sav_file 1125 else: 1126 if ".gbc" in self._gb_path: 1127 expected_sav = self._gb_path.replace(".gbc", ".sav") 1128 else: 1129 expected_sav = self._gb_path.replace(".gb", ".sav") 1130 if not os.path.exists(expected_sav): 1131 log_error( 1132 f"Expected .sav file at {expected_sav} to convert to .state file, but it does not exist.", 1133 self._parameters, 1134 ) 1135 if state_name is None or state_name == "": 1136 log_error( 1137 "You must provide a state_name to save the .state file.", 1138 self._parameters, 1139 ) 1140 # copy the .sav file to self._gb_path.gb.ram file 1141 save_destination = self._gb_path.replace(".gb", ".gb.ram") 1142 shutil.copyfile(expected_sav, save_destination) 1143 self.close() 1144 self._pyboy = PyBoy( 1145 self._gb_path, 1146 window="null", 1147 ) 1148 self._pyboy.set_emulation_speed(0) 1149 self._open_to_first_state() 1150 self.save_state(state_name, error_if_exists=True) 1151 self._pyboy.stop(save=False) 1152 log_info( 1153 "State saved successfully. Exiting now to avoid issues ...", 1154 self._parameters, 1155 ) 1156 # remove the .gb.ram file 1157 if os.path.exists(save_destination): 1158 os.remove(save_destination) 1159 sys.exit(0) 1160 1161 def _open_to_first_state(self): 1162 """ 1163 Presses buttons on the emulator to get past the opening menus and into the game itself. 1164 You don't really need to implement this method, but if you do, you can use _sav_to_state to create state files from mGBA sav files. 1165 """ 1166 raise NotImplementedError 1167 1168 def get_env_variant(self) -> str: 1169 """ 1170 Returns a string identifier for the particular environment variant being used. 1171 1172 :return: string name identifier of the particular env e.g. PokemonRed 1173 """ 1174 return self.game
Handles the running of the GameBoy emulator, including loading ROMs, managing state, performing low level actions and calling the tracker. Subclasses will likely only be needed to manually force the execution of specific button sequences when certain states are detected. (e.g. short-circuiting specific menus, etc.)
Can be used to access the state_parser and state_tracker instances for the running game instance.
348 def __init__( 349 self, 350 game: str, 351 gb_path: str, 352 state_parser_class: Type[StateParser], 353 state_tracker_class: Type[StateTracker], 354 init_state: str, 355 parameters: dict, 356 *, 357 headless: bool = True, 358 max_steps: int = None, 359 save_video: bool = None, 360 session_name: str = None, 361 instance_id: str = None, 362 wait_ticks: int = None, 363 press_step: int = None, 364 ): 365 """ 366 Start the GameBoy emulator with the given ROM file and initial state. 367 368 Args: 369 game (str): Name of game variant being emulated. 370 gb_path (str): Path to the GameBoy ROM file. 371 state_parser_class (Type[StateParser]): A class that inherits from StateParser to parse game state variables. 372 state_tracker_class (Type[StateTracker]): A class that inherits from StateTracker to track game state metrics. 373 init_state (str): Path to the initial state file to load. 374 parameters (dict): Dictionary of parameters for the environment. 375 headless (bool, optional): Whether to run the environment in headless mode. 376 max_steps (int, optional): Maximum number of steps per episode. 377 save_video (bool, optional): Whether to save video of the episodes. 378 session_name (str, optional): Name of the session. If None, a new session name will be allocated. This is the broad category you want to save files to 379 instance_id (str, optional): Unique identifier for this environment instance. If None, a new UUID will be generated. The instance ID is useful for distinguishing multiple environments running in parallel with the same session name. 380 wait_ticks (int, optional): Number of ticks to wait between actions. 381 press_step (int, optional): Number of steps to press a button for. 382 """ 383 verify_parameters(parameters) 384 self._parameters = parameters 385 if game is None or game == "": 386 log_error( 387 "You must provide a name for the game variant being emulated.", 388 self._parameters, 389 ) 390 if gb_path is None: 391 log_error( 392 "You must provide a path to the GameBoy ROM file.", self._parameters 393 ) 394 if not issubclass(state_parser_class, self.REQUIRED_STATE_PARSER): 395 log_error( 396 f"state_parser_class must be a subclass of {self.REQUIRED_STATE_PARSER.__name__}, got {state_parser_class_.__name__}.", 397 self._parameters, 398 ) 399 if not issubclass(state_tracker_class, self.REQUIRED_STATE_TRACKER): 400 log_error( 401 f"state_tracker_class must be a subclass of {self.REQUIRED_STATE_TRACKER.__name__}, got {state_tracker_class.__name__}.", 402 self._parameters, 403 ) 404 if init_state is None: 405 log_error( 406 "You must provide an initial state file to load.", self._parameters 407 ) 408 if headless not in [True, False]: 409 log_error("headless must be a boolean.", self._parameters) 410 self.game = game 411 """ Name of game variant being emulated. """ 412 self._gb_path = gb_path 413 self._set_init_state(init_state) 414 # validate init_state exists and ends with .state 415 if not os.path.exists(self._gb_path): 416 log_error( 417 f"GameBoy ROM file {self._gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {self._gb_path}", 418 self._parameters, 419 ) 420 if not self._gb_path.endswith(".gb") and not self._gb_path.endswith(".gbc"): 421 log_error( 422 f"GameBoy ROM file {self._gb_path} is not a .gb or .gbc file.", 423 self._parameters, 424 ) 425 self.headless = headless 426 """ Whether to run the environment in headless mode.""" 427 if max_steps is None: 428 max_steps = self._parameters["gameboy_max_steps"] 429 if max_steps > self._parameters["gameboy_hard_max_steps"]: 430 log_warn( 431 f"max_steps {max_steps} exceeds gameboy_hard_max_steps {self._parameters['gameboy_hard_max_steps']}. Setting to hard max.", 432 self._parameters, 433 ) 434 max_steps = self._parameters["gameboy_hard_max_steps"] 435 self.max_steps = max_steps 436 """ Maximum number of steps per episode. """ 437 id_path_creator = IDPathCreator(self._parameters) 438 self.session_path = id_path_creator.get_session_path( 439 session_name=session_name, 440 instance_id=instance_id, 441 environment_variant=self.get_env_variant(), 442 ) 443 """ Path to the session directory. This is where all artifacts for this session are saved. """ 444 445 if wait_ticks is None: 446 wait_ticks = parameters["gameboy_wait_ticks"] 447 self.wait_ticks = wait_ticks 448 """ Number of emulator ticks to wait after an action. Defaults to value specified in config files. """ 449 if press_step is None: 450 press_step = parameters["gameboy_press_step"] 451 self.press_step = press_step 452 """ Number of emulator ticks to hold down a button press. Defaults to value specified in config files. """ 453 self.render_headless = parameters["gameboy_headless_render"] 454 """ Whether to render the emulator screen even in headless mode. This must be true for methods that rely on image observations (e.g. VLMs) to access the screen. Defaults to value specified in config files. """ 455 if not self.render_headless: 456 log_error( 457 "render_headless cannot be set to False. In the Pokemon environments, screen captures are used aggressively to determine state. ", 458 self._parameters, 459 ) 460 461 self.reset_count = 0 462 """ Number of times the environment has been reset. """ 463 self.step_count = 0 464 """ Number of steps taken in the current episode. """ 465 self._reduce_video_resolution = parameters["gameboy_reduce_video_resolution"] 466 frame_size = ( 467 160, 468 144, 469 ) 470 self.screen_shape = (frame_size[0], frame_size[1], 1) 471 """ Resolution of the rendered game screen """ 472 if self._reduce_video_resolution: 473 self.output_shape = (frame_size[0] // 2, frame_size[1] // 2) 474 else: 475 self.output_shape = (frame_size[0], frame_size[1]) 476 """ Shape of the output observations. This is the resolution of the rendered screen. """ 477 478 if save_video is None: 479 save_video = self._parameters["gameboy_default_save_video"] 480 self.save_video = save_video 481 """ Whether to save video of the episodes. """ 482 self.video_writer = VideoWriter( 483 session_path=self.session_path, 484 output_shape=self.output_shape, 485 reduce_resolution=self._reduce_video_resolution, 486 parameters=self._parameters, 487 ) 488 """ Holds the VideoWriter of this Emulator instance """ 489 490 head = "null" if self.headless else "SDL2" 491 492 self._pyboy = PyBoy( 493 self._gb_path, 494 window=head, 495 ) 496 self.state_parser = state_parser_class(self._pyboy, self._parameters) 497 """ Instance of the StateParser to parse game state variables. """ 498 499 self.state_tracker = state_tracker_class( 500 self.state_parser, 501 self._parameters, 502 ) 503 """ Instance of the StateTracker to track game state metrics. """ 504 505 # self.screen = self.pyboy.botsupport_manager().screen() 506 507 if not self.headless: 508 if not is_none_str(self._parameters["gameboy_headed_emulation_speed"]): 509 self._pyboy.set_emulation_speed( 510 int(self._parameters["gameboy_headed_emulation_speed"]) 511 ) 512 self.reset()
Start the GameBoy emulator with the given ROM file and initial state.
Arguments:
- game (str): Name of game variant being emulated.
- gb_path (str): Path to the GameBoy ROM file.
- state_parser_class (Type[StateParser]): A class that inherits from StateParser to parse game state variables.
- state_tracker_class (Type[StateTracker]): A class that inherits from StateTracker to track game state metrics.
- init_state (str): Path to the initial state file to load.
- parameters (dict): Dictionary of parameters for the environment.
- headless (bool, optional): Whether to run the environment in headless mode.
- max_steps (int, optional): Maximum number of steps per episode.
- save_video (bool, optional): Whether to save video of the episodes.
- session_name (str, optional): Name of the session. If None, a new session name will be allocated. This is the broad category you want to save files to
- instance_id (str, optional): Unique identifier for this environment instance. If None, a new UUID will be generated. The instance ID is useful for distinguishing multiple environments running in parallel with the same session name.
- wait_ticks (int, optional): Number of ticks to wait between actions.
- press_step (int, optional): Number of steps to press a button for.
The minimal functionality StateParser needed for this emulator to run
The minimal functionality StateTracker needed for this emulator to run
Number of emulator ticks to wait after an action. Defaults to value specified in config files.
Number of emulator ticks to hold down a button press. Defaults to value specified in config files.
Whether to render the emulator screen even in headless mode. This must be true for methods that rely on image observations (e.g. VLMs) to access the screen. Defaults to value specified in config files.
514 @staticmethod 515 def create_first_state(gb_path: str, state_path: str): 516 """ 517 Creates a basic state for the emulator. This can be used to create an initial, default state file for a new game. 518 519 Warning: This method uses parameter free logging, so if you override the log_file with a command prompt argument, it will be ignored here. 520 521 Args: 522 gb_path (str): Path to the GameBoy ROM file. 523 state_path (str): Path to save the initial state file. 524 """ 525 # error out if gb_path does not exist or is not a .gb or .gbc file 526 if not os.path.exists(gb_path): 527 log_error( 528 f"GameBoy ROM file {gb_path} does not exist. You must obtain a ROM through official means, and then place it in the path: {gb_path}" 529 ) 530 if not gb_path.endswith(".gb") and not gb_path.endswith(".gbc"): 531 log_error(f"GameBoy ROM file {gb_path} is not a .gb or .gbc file.") 532 if not state_path.endswith(".state"): 533 state_path = state_path + ".state" 534 if os.path.exists(state_path): 535 log_error(f"State file {state_path} already exists. Will not overwrite...") 536 file_makedir(state_path) 537 pyboy = PyBoy( 538 gb_path, 539 window="null", 540 ) 541 with open(state_path, "wb") as f: 542 pyboy.save_state(f) 543 pyboy.stop() 544 log_info(f"Created initial state file at {state_path}") 545 sys.exit(0)
Creates a basic state for the emulator. This can be used to create an initial, default state file for a new game.
Warning: This method uses parameter free logging, so if you override the log_file with a command prompt argument, it will be ignored here.
Arguments:
- gb_path (str): Path to the GameBoy ROM file.
- state_path (str): Path to save the initial state file.
547 def set_init_state(self, init_state: str): 548 """Sets a new initial state file for the environment. and resets the environment. 549 550 Args: 551 init_state (str): Path to the new initial state file. 552 """ 553 self._set_init_state(init_state) 554 self.reset()
Sets a new initial state file for the environment. and resets the environment.
Arguments:
- init_state (str): Path to the new initial state file.
586 def reset(self, new_init_state: str = None): 587 """ 588 Resets the environment to the initial state. Optionally loads a new initial state file. 589 590 Args: 591 new_init_state (str, optional): Path to a new initial state file to load. 592 """ 593 # validate the new_init_state if provided 594 if new_init_state is not None: 595 self._set_init_state(new_init_state) 596 # restart game, skipping to init_state 597 with open(self.init_state, "rb") as f: 598 self._pyboy.load_state(f) 599 600 self.reset_count += 1 601 self.step_count = 0 602 self.state_tracker.reset() 603 self.video_writer.close_video() 604 return
Resets the environment to the initial state. Optionally loads a new initial state file.
Arguments:
- new_init_state (str, optional): Path to a new initial state file to load.
606 def get_current_frame(self) -> np.ndarray: 607 """ 608 Renders the currently rendered screen of the emulator and returns it as a numpy array. 609 610 Returns: 611 np.ndarray: The rendered image as a numpy array. 612 """ 613 return self.state_parser.get_current_frame()
Renders the currently rendered screen of the emulator and returns it as a numpy array.
Returns:
np.ndarray: The rendered image as a numpy array.
645 def step(self, action: LowLevelActions = None) -> Tuple[Optional[np.ndarray], bool]: 646 """ 647 Takes a step in the environment by performing the given action on the emulator. If saving video, starts the video recording on the first step. 648 649 :param action: Lowest level action to perform on the emulator. 650 :type action: LowLevelActions 651 :return: 652 - The stack of frames that passed while performing the action, if rendering is enabled. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 653 654 - Is max steps reached. 655 :rtype: Tuple[Optional[np.ndarray], bool] 656 """ 657 if action is not None: 658 if action not in LowLevelActions: 659 log_error( 660 f"Invalid action {action}. Must be one of {list(LowLevelActions)} or None", 661 self._parameters, 662 ) 663 if self.step_count >= self.max_steps: 664 log_warn( 665 "Step called after max_steps reached. Please reset the environment.", 666 self._parameters, 667 ) 668 # This does not exit because some HighLevelActions may call step() multiple times in their execution. 669 # It is not the best practice to allow this to happen, but it is easier to not error out here than check in every HighLevelAction, and this won't advantage the agent too much. 670 # One consequence, however, is that max_steps then becomes a soft limit rather than a hard limit. 671 672 if self.save_video and self.step_count == 0: 673 self.video_writer.start_video() 674 675 frames = self.run_action_on_emulator(action) 676 self.step_count += 1 677 frames = self._get_unique_frames(frames) 678 self._update_listeners_after_actions(frames) 679 return frames, self.check_if_done()
Takes a step in the environment by performing the given action on the emulator. If saving video, starts the video recording on the first step.
Parameters
- action: Lowest level action to perform on the emulator.
Returns
- The stack of frames that passed while performing the action, if rendering is enabled. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. - Is max steps reached.
681 def get_state_parser(self) -> StateParser: 682 """ 683 Returns the current game state parser instance. 684 685 Returns: 686 StateParser: The current game state parser. 687 """ 688 return self.state_parser
Returns the current game state parser instance.
Returns:
StateParser: The current game state parser.
690 def run_action_on_emulator( 691 self, action: LowLevelActions = None 692 ) -> Optional[np.ndarray]: 693 """ 694 695 Performs the given action on the emulator by pressing and releasing the corresponding button. 696 697 Args: 698 action (LowLevelActions): Lowest level action to perform on the emulator. 699 Returns: 700 Optional[np.ndarray]: The stack of frames that passed while performing the actions. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None. 701 """ 702 frames = None 703 if action is not None: 704 frames = [] 705 self._pyboy.send_input(action.value) 706 press_step = self.press_step 707 self._pyboy.tick(press_step, True) 708 frames.append(self.get_current_frame()) 709 self._pyboy.send_input(ReleaseActions.release_actions.value[action]) 710 self._pyboy.tick(self.wait_ticks + press_step + 1, True) 711 frames.append(self.get_current_frame()) 712 # self._pyboy.tick(1, True) 713 # frames.append(self.get_current_frame()) 714 frames = np.stack(frames, axis=0) 715 else: 716 self._pyboy.tick(self.wait_ticks, True) 717 frames = [self.get_current_frame()] 718 frames = np.array(frames) 719 if self.save_video and self.video_writer.video_running: 720 self.video_writer.add_video_frames(frames, pressed_button=action) 721 return frames
Performs the given action on the emulator by pressing and releasing the corresponding button.
Arguments:
- action (LowLevelActions): Lowest level action to perform on the emulator.
Returns:
Optional[np.ndarray]: The stack of frames that passed while performing the actions. Is of shape [n_frames (3 right now), height, width, channels]. Otherwise, None.
723 def check_if_done(self): 724 """ 725 Checks if the max_steps limit has been reached. 726 """ 727 done = self.step_count >= self.max_steps - 1 728 return done
Checks if the max_steps limit has been reached.
730 def close(self) -> StateTracker: 731 """ 732 Closes the emulator and any associated resources. 733 If the session directory is empty after closing, it will be deleted. 734 """ 735 self.state_tracker.close() 736 self._pyboy.stop(save=False) 737 self.video_writer.close_video() 738 self.state_tracker.close() 739 # check if session directory is empty, and if so delete it 740 if ( 741 os.path.exists(self.session_path) 742 and len(os.listdir(self.session_path)) == 0 743 ): 744 os.rmdir(self.session_path) 745 return self.state_tracker
Closes the emulator and any associated resources. If the session directory is empty after closing, it will be deleted.
747 def human_play(self, max_steps: int = None): 748 """ 749 Allows a human to play the emulator using keyboard inputs. 750 Args: 751 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 752 """ 753 if max_steps is None: 754 max_steps = self._parameters["gameboy_hard_max_steps"] 755 log_info( 756 "Starting human play mode. Use arrow keys and A(a)/B(s)/Start(enter) buttons to play. Close the window to exit.", 757 self._parameters, 758 ) 759 if self.headless: 760 log_error( 761 "Human play mode requires headless=False. Change the initialization", 762 self._parameters, 763 ) 764 self.reset() 765 while True: 766 self._pyboy.tick(1, True) 767 self.state_tracker.step() 768 if self.step_count >= max_steps: 769 break 770 self.close()
Allows a human to play the emulator using keyboard inputs.
Arguments:
- max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs.
772 def random_play(self, max_steps: int = None): 773 """ 774 Allows the emulator to play itself using (sort of) random actions. 775 Args: 776 max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs. 777 """ 778 if max_steps is None: 779 max_steps = self._parameters["gameboy_random_play_max_steps"] 780 log_info("Starting random play mode.", self._parameters) 781 self.reset() 782 pbar = tqdm(total=max_steps, desc="Random Play Steps") 783 allowed_actions = list(LowLevelActions) 784 # remove the Start and Select actions from allowed actions to avoid menu spamming. 785 allowed_actions.remove(LowLevelActions.PRESS_BUTTON_START) 786 while self.step_count < max_steps: 787 action = np.random.choice(allowed_actions) 788 frames, done = self.step(action) 789 pbar.update(1) 790 if done: 791 break 792 pbar.close() 793 self.close() 794 log_info("Random play mode ended.", self._parameters)
Allows the emulator to play itself using (sort of) random actions.
Arguments:
- max_steps (int, optional): Maximum number of steps to play. Defaults to gameboy_hard_max_steps in configs.
1044 def save_state(self, state_name: str, error_if_exists: bool = False): 1045 """ 1046 Saves the current state of the emulator to a .state file. 1047 Args: 1048 state_name (str): Name of the state file to save (with or without .state extension). 1049 error_if_exists (bool): Whether to raise an error if the state file already exists. 1050 """ 1051 if not state_name.endswith(".state"): 1052 state_name = state_name + ".state" 1053 state_dir = os.path.abspath(self.state_parser.rom_data_path + "/states/") 1054 potential_state_dir = os.path.abspath(os.path.dirname(state_name)) 1055 if state_dir != potential_state_dir: 1056 if potential_state_dir != os.path.abspath(""): 1057 log_error( 1058 f"Tried to save state file to {state_name}, which is outside of the states directory {state_dir}. This is not allowed.", 1059 self._parameters, 1060 ) 1061 state_name = os.path.abspath(os.path.join(state_dir, state_name)) 1062 else: 1063 pass 1064 if os.path.exists(state_name): 1065 if error_if_exists: 1066 log_error( 1067 f"State file {state_name} already exists. Will not overwrite...", 1068 self._parameters, 1069 ) 1070 file_makedir(state_name) 1071 with open(state_name, "wb") as f: 1072 self._pyboy.save_state(f) 1073 log_info(f"Saved state to {state_name}", self._parameters)
Saves the current state of the emulator to a .state file.
Arguments:
- state_name (str): Name of the state file to save (with or without .state extension).
- error_if_exists (bool): Whether to raise an error if the state file already exists.
1075 def delete_state(self, state_name: str, error_if_not_exists: bool = False): 1076 """ 1077 Deletes a .state file from the states directory. 1078 1079 Args: 1080 state_name (str): Name of the state file to delete (with or without .state extension). 1081 error_if_not_exists (bool): Whether to raise an error if the state file does not exist. 1082 """ 1083 if not state_name.endswith(".state"): 1084 state_name = state_name + ".state" 1085 state_path = os.path.join(self.state_parser.rom_data_path, "states", state_name) 1086 if not os.path.exists(state_path): 1087 if error_if_not_exists: 1088 log_error( 1089 f"State file {state_path} does not exist. Cannot delete.", 1090 self._parameters, 1091 ) 1092 else: 1093 log_warn( 1094 f"State file {state_path} does not exist. Cannot delete.", 1095 self._parameters, 1096 ) 1097 return 1098 # if somehow state_path isn't in the states directory, error out to avoid deleting random files 1099 if os.path.abspath(os.path.dirname(state_path)) != os.path.abspath( 1100 self.state_parser.rom_data_path + "/states/" 1101 ): 1102 log_error( 1103 f"Tried to delete state file at {state_path}, which is outside of the states directory. This is not allowed.", 1104 self._parameters, 1105 ) 1106 os.remove(state_path) 1107 log_info(f"Deleted state file {state_path}", self._parameters)
Deletes a .state file from the states directory.
Arguments:
- state_name (str): Name of the state file to delete (with or without .state extension).
- error_if_not_exists (bool): Whether to raise an error if the state file does not exist.
1168 def get_env_variant(self) -> str: 1169 """ 1170 Returns a string identifier for the particular environment variant being used. 1171 1172 :return: string name identifier of the particular env e.g. PokemonRed 1173 """ 1174 return self.game
Returns a string identifier for the particular environment variant being used.
Returns
string name identifier of the particular env e.g. PokemonRed
1177def bytes_to_padded_hex_string(integer_value): 1178 """ 1179 Converts a bytes object into a padded, '0x'-prefixed hexadecimal string. 1180 """ 1181 # 1. Convert the bytes object back into an integer 1182 # Assumes big-endian order for the example '0x00a' -> 10 1183 # 2. Format the integer into a string with padding and the '0x' prefix 1184 # The 'x' specifier for hex, '#' adds '0x', '04' pads to 4 hex characters total 1185 # (not including the '0x' prefix for simple formatters like this, but managing width) 1186 1187 # A robust approach to match your exact output '0x00a': 1188 # You generally want enough width for your bytes. b'\n' is 1 byte, 2 hex chars. 1189 1190 return f"0x{integer_value:04x}" # {0:04x} pads to 4 digits specifically
Converts a bytes object into a padded, '0x'-prefixed hexadecimal string.