From 80c41723ebb330275f691d653d1b4df92de81170 Mon Sep 17 00:00:00 2001 From: spyroot Date: Thu, 13 Oct 2022 12:42:27 +0400 Subject: [PATCH 01/13] Fixed new gym API. --- nes_py/nes_env.py | 57 +++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/nes_py/nes_env.py b/nes_py/nes_env.py index 35333f5..110bc8a 100644 --- a/nes_py/nes_env.py +++ b/nes_py/nes_env.py @@ -4,13 +4,27 @@ import itertools import os import sys + import gym +from gym.core import ObsType, RenderFrame from gym.spaces import Box from gym.spaces import Discrete import numpy as np from ._rom import ROM from ._image_viewer import ImageViewer +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generic, + List, + Optional, + SupportsFloat, + Tuple, + TypeVar, + Union, +) # the path to the directory this file is in _MODULE_PATH = os.path.dirname(__file__) @@ -24,7 +38,6 @@ except IndexError: raise OSError('missing static lib_nes_env*.so library!') - # setup the argument and return types for Width _LIB.Width.argtypes = None _LIB.Width.restype = ctypes.c_uint @@ -59,7 +72,6 @@ _LIB.Close.argtypes = [ctypes.c_void_p] _LIB.Close.restype = None - # height in pixels of the NES screen SCREEN_HEIGHT = _LIB.Height() # width in pixels of the NES screen @@ -71,11 +83,9 @@ # create a type for the screen tensor matrix from C++ SCREEN_TENSOR = ctypes.c_byte * int(np.prod(SCREEN_SHAPE_32_BIT)) - # create a type for the RAM vector from C++ RAM_VECTOR = ctypes.c_byte * 0x800 - # create a type for the controller buffers from C++ CONTROLLER_VECTOR = ctypes.c_byte * 1 @@ -94,10 +104,10 @@ class NESEnv(gym.Env): # observation space for the environment is static across all instances observation_space = Box( - low=0, - high=255, - shape=SCREEN_SHAPE_24_BIT, - dtype=np.uint8 + low=0, + high=255, + shape=SCREEN_SHAPE_24_BIT, + dtype=np.uint8 ) # action space is a bitmap of button press values for the 8 NES buttons @@ -145,6 +155,8 @@ def __init__(self, rom_path): self._has_backup = False # setup a done flag self.done = True + # truncated + self.truncated = False # setup the controllers, screen, and RAM buffers self.controllers = [self._controller_buffer(port) for port in range(2)] self.screen = self._screen_buffer() @@ -243,7 +255,7 @@ def seed(self, seed=None): # return the list of seeds used by RNG(s) in the environment return [seed] - def reset(self, seed=None, options=None, return_info=None): + def reset(self, seed=None, options=None, return_info=None) -> Tuple[ObsType, dict]: """ Reset the state of the environment and returns an initial observation. @@ -253,7 +265,9 @@ def reset(self, seed=None, options=None, return_info=None): return_info (any): unused Returns: - state (np.ndarray): next frame as a result of the given action + a tuple + state (np.ndarray): next frame as a result of the given action + info dict: Return the info after a step occurs """ # Set the seed. @@ -270,13 +284,13 @@ def reset(self, seed=None, options=None, return_info=None): # set the done flag to false self.done = False # return the screen from the emulator - return self.screen + return self.screen, self._get_info() def _did_reset(self): """Handle any RAM hacking after a reset occurs.""" pass - def step(self, action): + def step(self, action) -> Tuple[ObsType, float, bool, bool, dict]: """ Run one frame of the NES and return the relevant observation data. @@ -304,6 +318,7 @@ def step(self, action): self.done = bool(self._get_done()) # get the info for this step info = self._get_info() + self.truncated = self._get_truncated() # call the after step callback self._did_step(self.done) # bound the reward in [min, max] @@ -312,7 +327,7 @@ def step(self, action): elif reward > self.reward_range[1]: reward = self.reward_range[1] # return the screen from the emulator and other relevant data - return self.screen, reward, self.done, info + return self.screen, reward, self.done, self.truncated, info def _get_reward(self): """Return the reward after a step occurs.""" @@ -322,6 +337,10 @@ def _get_done(self): """Return True if the episode is over, False otherwise.""" return False + def _get_truncated(self): + """Return True if truncated """ + return False + def _get_info(self): """Return the info after a step occurs.""" return {} @@ -352,7 +371,7 @@ def close(self): if self.viewer is not None: self.viewer.close() - def render(self, mode='human'): + def render(self, mode='human') -> Optional[Union[RenderFrame, List[RenderFrame]]]: """ Render the environment. @@ -378,9 +397,9 @@ def render(self, mode='human'): caption = self.spec.id # create the ImageViewer to display frames self.viewer = ImageViewer( - caption=caption, - height=SCREEN_HEIGHT, - width=SCREEN_WIDTH, + caption=caption, + height=SCREEN_HEIGHT, + width=SCREEN_WIDTH, ) # show the screen on the image viewer self.viewer.show(self.screen) @@ -401,7 +420,7 @@ def get_keys_to_action(self): ord('a'), # left ord('s'), # down ord('w'), # up - ord('\r'), # start + ord('\r'), # start ord(' '), # select ord('p'), # B ord('o'), # A @@ -427,4 +446,4 @@ def get_action_meanings(self): # explicitly define the outward facing API of this module -__all__ = [NESEnv.__name__] +__all__ = [NESEnv.__name__] \ No newline at end of file From 654da0d395b90f8a684aa00a69d21a1f350e5b8d Mon Sep 17 00:00:00 2001 From: spyroot Date: Thu, 13 Oct 2022 12:43:29 +0400 Subject: [PATCH 02/13] fixed issue with new API related to truncated --- scripts/run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/run.py b/scripts/run.py index 90abfae..ad8f94e 100644 --- a/scripts/run.py +++ b/scripts/run.py @@ -7,9 +7,9 @@ try: for _ in tqdm.tqdm(range(5000)): if done: - state = env.reset() + state, _ = env.reset() done = False else: - state, reward, done, info = env.step(env.action_space.sample()) + state, reward, done, truncated, info = env.step(env.action_space.sample()) except KeyboardInterrupt: pass From 198b058956d6b67ce24a6f82965a4eb3cb4736a9 Mon Sep 17 00:00:00 2001 From: spyroot Date: Thu, 13 Oct 2022 12:47:54 +0400 Subject: [PATCH 03/13] fixed unit test --- nes_py/tests/test_multiple_makes.py | 10 +++++----- nes_py/tests/test_nes_env.py | 15 ++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/nes_py/tests/test_multiple_makes.py b/nes_py/tests/test_multiple_makes.py index 8764e48..2e578cb 100755 --- a/nes_py/tests/test_multiple_makes.py +++ b/nes_py/tests/test_multiple_makes.py @@ -24,9 +24,9 @@ def play(steps): done = True for _ in range(steps): if done: - _ = env.reset() + _, _ = env.reset() action = env.action_space.sample() - _, _, done, _ = env.step(action) + _, _, done, _, _ = env.step(action) # close the environment env.close() @@ -45,7 +45,7 @@ class ShouldMakeMultipleEnvironmentsParallel(object): def test(self): procs = [None] * self.num_execs - args = (self.steps, ) + args = (self.steps,) # spawn the parallel instances for idx in range(self.num_execs): procs[idx] = self.parallel_initializer(target=play, args=args) @@ -82,6 +82,6 @@ def test(self): for _ in range(self.steps): for idx in range(self.num_envs): if dones[idx]: - _ = envs[idx].reset() + _, _ = envs[idx].reset() action = envs[idx].action_space.sample() - _, _, dones[idx], _ = envs[idx].step(action) + _, _, dones[idx], _, _ = envs[idx].step(action) diff --git a/nes_py/tests/test_nes_env.py b/nes_py/tests/test_nes_env.py index 3e24c6e..b5995af 100644 --- a/nes_py/tests/test_nes_env.py +++ b/nes_py/tests/test_nes_env.py @@ -79,7 +79,7 @@ def test(self): for _ in range(500): if done: # reset the environment and check the output value - state = env.reset() + state, _ = env.reset() self.assertIsInstance(state, np.ndarray) # sample a random action and check it action = env.action_space.sample() @@ -87,12 +87,13 @@ def test(self): # take a step and check the outputs output = env.step(action) self.assertIsInstance(output, tuple) - self.assertEqual(4, len(output)) + self.assertEqual(5, len(output)) # check each output - state, reward, done, info = output + state, reward, done, truncated, info = output self.assertIsInstance(state, np.ndarray) self.assertIsInstance(reward, float) self.assertIsInstance(done, bool) + self.assertIsInstance(truncated, bool) self.assertIsInstance(info, dict) # check the render output render = env.render('rgb_array') @@ -108,9 +109,9 @@ def test(self): for _ in range(250): if done: - state = env.reset() + state, _ = env.reset() done = False - state, _, done, _ = env.step(0) + state, _, done, _, _ = env.step(0) backup = state.copy() @@ -120,9 +121,9 @@ def test(self): if done: state = env.reset() done = False - state, _, done, _ = env.step(0) + state, _, done, _, _ = env.step(0) self.assertFalse(np.array_equal(backup, state)) env._restore() self.assertTrue(np.array_equal(backup, env.screen)) - env.close() + env.close() \ No newline at end of file From 134d5abd99d0787098642b082962fa7d52671048 Mon Sep 17 00:00:00 2001 From: spyroot Date: Thu, 13 Oct 2022 12:50:35 +0400 Subject: [PATCH 04/13] added flags --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d50a5f7..5f178d8 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ setup( name='nes_py', - version='8.2.1', + version='8.2.2', description='An NES Emulator and OpenAI Gym interface', long_description=README, long_description_content_type='text/markdown', From 099260a915323712f470267b60ea5cef1f608075 Mon Sep 17 00:00:00 2001 From: spyroot Date: Thu, 13 Oct 2022 13:07:37 +0400 Subject: [PATCH 05/13] fixed issue in app --- nes_py/app/play_human.py | 6 +++--- nes_py/app/play_random.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nes_py/app/play_human.py b/nes_py/app/play_human.py index 6ca1657..db58e83 100644 --- a/nes_py/app/play_human.py +++ b/nes_py/app/play_human.py @@ -62,15 +62,15 @@ def play_human(env: gym.Env, callback=None): # reset if the environment is done if done: done = False - state = env.reset() + state, _ = env.reset() viewer.show(env.unwrapped.screen) # unwrap the action based on pressed relevant keys action = keys_to_action.get(viewer.pressed_keys, _NOP) - next_state, reward, done, _ = env.step(action) + next_state, reward, done, truncated, _ = env.step(action) viewer.show(env.unwrapped.screen) # pass the observation data through the callback if callback is not None: - callback(state, action, reward, done, next_state) + callback(state, action, reward, done, truncated, next_state) state = next_state # shutdown if the escape key is pressed if viewer.is_escape_pressed: diff --git a/nes_py/app/play_random.py b/nes_py/app/play_random.py index d2fc4d8..1d3e5cd 100644 --- a/nes_py/app/play_random.py +++ b/nes_py/app/play_random.py @@ -19,9 +19,9 @@ def play_random(env, steps): progress = tqdm(range(steps)) for _ in progress: if done: - _ = env.reset() + _, _ = env.reset() action = env.action_space.sample() - _, reward, done, info = env.step(action) + _, reward, done, _, info = env.step(action) progress.set_postfix(reward=reward, info=info) env.render() except KeyboardInterrupt: From 339cadadc179408cb43166d9864de419a4a3de47 Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Mon, 17 Jul 2023 21:25:20 +0300 Subject: [PATCH 06/13] Added support for gymnasium --- backup_restore.py | 3 ++- nes_py/app/play_human.py | 5 +++-- nes_py/app/play_random.py | 3 ++- nes_py/nes_env.py | 28 +++++++++++++--------------- nes_py/tests/test_multiple_makes.py | 6 ++++-- nes_py/tests/test_nes_env.py | 22 +++++++++++++--------- nes_py/wrappers/joypad_space.py | 10 +++------- requirements.txt | 4 ++-- scripts/run.py | 3 ++- setup.py | 6 ++++-- speedtest.py | 2 +- 11 files changed, 49 insertions(+), 43 deletions(-) diff --git a/backup_restore.py b/backup_restore.py index 839dcee..292619d 100644 --- a/backup_restore.py +++ b/backup_restore.py @@ -10,7 +10,8 @@ state = env.reset() done = False else: - state, reward, done, info = env.step(env.action_space.sample()) + state, reward, terminated, truncated, info = env.step(env.action_space.sample()) + done = terminated or truncated if (i + 1) % 12: env._backup() if (i + 1) % 27: diff --git a/nes_py/app/play_human.py b/nes_py/app/play_human.py index db58e83..377ebc6 100644 --- a/nes_py/app/play_human.py +++ b/nes_py/app/play_human.py @@ -1,5 +1,5 @@ """A method to play gym environments using human IO inputs.""" -import gym +import gymnasium as gym import time from pyglet import clock from .._image_viewer import ImageViewer @@ -66,7 +66,8 @@ def play_human(env: gym.Env, callback=None): viewer.show(env.unwrapped.screen) # unwrap the action based on pressed relevant keys action = keys_to_action.get(viewer.pressed_keys, _NOP) - next_state, reward, done, truncated, _ = env.step(action) + next_state, reward, terminated, truncated, _ = env.step(action) + done = terminated or truncated viewer.show(env.unwrapped.screen) # pass the observation data through the callback if callback is not None: diff --git a/nes_py/app/play_random.py b/nes_py/app/play_random.py index 1d3e5cd..8fcceff 100644 --- a/nes_py/app/play_random.py +++ b/nes_py/app/play_random.py @@ -21,7 +21,8 @@ def play_random(env, steps): if done: _, _ = env.reset() action = env.action_space.sample() - _, reward, done, _, info = env.step(action) + _, reward, terminated, truncated, info = env.step(action) + done = terminated or truncated progress.set_postfix(reward=reward, info=info) env.render() except KeyboardInterrupt: diff --git a/nes_py/nes_env.py b/nes_py/nes_env.py index 110bc8a..7b00ba3 100644 --- a/nes_py/nes_env.py +++ b/nes_py/nes_env.py @@ -5,10 +5,10 @@ import os import sys -import gym -from gym.core import ObsType, RenderFrame -from gym.spaces import Box -from gym.spaces import Discrete +import gymnasium as gym +from gymnasium.core import ObsType, RenderFrame +from gymnasium.spaces import Box +from gymnasium.spaces import Discrete import numpy as np from ._rom import ROM from ._image_viewer import ImageViewer @@ -113,12 +113,13 @@ class NESEnv(gym.Env): # action space is a bitmap of button press values for the 8 NES buttons action_space = Discrete(256) - def __init__(self, rom_path): + def __init__(self, rom_path, render_mode='human'): """ Create a new NES environment. Args: rom_path (str): the path to the ROM for the environment + render_mode (str): the mode to render the environment Returns: None @@ -151,6 +152,7 @@ def __init__(self, rom_path): self._env = _LIB.Initialize(self._rom_path) # setup a placeholder for a 'human' render mode viewer self.viewer = None + self.render_mode = render_mode # setup a placeholder for a pointer to a backup state self._has_backup = False # setup a done flag @@ -255,14 +257,13 @@ def seed(self, seed=None): # return the list of seeds used by RNG(s) in the environment return [seed] - def reset(self, seed=None, options=None, return_info=None) -> Tuple[ObsType, dict]: + def reset(self, seed=None, options=None) -> Tuple[ObsType, dict]: """ Reset the state of the environment and returns an initial observation. Args: seed (int): an optional random number seed for the next episode options (any): unused - return_info (any): unused Returns: a tuple @@ -371,21 +372,18 @@ def close(self): if self.viewer is not None: self.viewer.close() - def render(self, mode='human') -> Optional[Union[RenderFrame, List[RenderFrame]]]: + def render(self) -> Optional[Union[RenderFrame, List[RenderFrame]]]: """ Render the environment. Args: - mode (str): the mode to render with: - - human: render to the current display - - rgb_array: Return an numpy.ndarray with shape (x, y, 3), - representing RGB values for an x-by-y pixel image + None Returns: - a numpy array if mode is 'rgb_array', None otherwise + a numpy array if environment was initialized with render_mode='rgb_array', None otherwise """ - if mode == 'human': + if self.render_mode == 'human': # if the viewer isn't setup, import it and create one if self.viewer is None: # get the caption for the ImageViewer @@ -403,7 +401,7 @@ def render(self, mode='human') -> Optional[Union[RenderFrame, List[RenderFrame]] ) # show the screen on the image viewer self.viewer.show(self.screen) - elif mode == 'rgb_array': + elif self.render_mode == 'rgb_array': return self.screen else: # unpack the modes as comma delineated strings ('a', 'b', ...) diff --git a/nes_py/tests/test_multiple_makes.py b/nes_py/tests/test_multiple_makes.py index 2e578cb..6f7d650 100755 --- a/nes_py/tests/test_multiple_makes.py +++ b/nes_py/tests/test_multiple_makes.py @@ -26,7 +26,8 @@ def play(steps): if done: _, _ = env.reset() action = env.action_space.sample() - _, _, done, _, _ = env.step(action) + _, _, terminated, truncated, _ = env.step(action) + done = terminated or truncated # close the environment env.close() @@ -84,4 +85,5 @@ def test(self): if dones[idx]: _, _ = envs[idx].reset() action = envs[idx].action_space.sample() - _, _, dones[idx], _, _ = envs[idx].step(action) + _, _, terminated, truncated, _ = envs[idx].step(action) + dones[idx] = terminated or truncated diff --git a/nes_py/tests/test_nes_env.py b/nes_py/tests/test_nes_env.py index b5995af..1ec3012 100644 --- a/nes_py/tests/test_nes_env.py +++ b/nes_py/tests/test_nes_env.py @@ -1,6 +1,6 @@ """Test cases for the NESEnv class.""" from unittest import TestCase -import gym +import gymnasium as gym import numpy as np from .rom_file_abs_path import rom_file_abs_path from nes_py.nes_env import NESEnv @@ -45,9 +45,9 @@ def test(self): env.close() -def create_smb1_instance(): +def create_smb1_instance(render_mode='human'): """Return a new SMB1 instance.""" - return NESEnv(rom_file_abs_path('super-mario-bros-1.nes')) + return NESEnv(rom_file_abs_path('super-mario-bros-1.nes'), render_mode=render_mode) class ShouldReadAndWriteMemory(TestCase): @@ -74,7 +74,7 @@ def test(self): class ShouldStepEnv(TestCase): def test(self): - env = create_smb1_instance() + env = create_smb1_instance(render_mode='rgb_array') done = True for _ in range(500): if done: @@ -89,14 +89,16 @@ def test(self): self.assertIsInstance(output, tuple) self.assertEqual(5, len(output)) # check each output - state, reward, done, truncated, info = output + state, reward, terminated, truncated, info = output + done = terminated or truncated self.assertIsInstance(state, np.ndarray) self.assertIsInstance(reward, float) - self.assertIsInstance(done, bool) + self.assertIsInstance(terminated, bool) self.assertIsInstance(truncated, bool) + self.assertIsInstance(done, bool) self.assertIsInstance(info, dict) # check the render output - render = env.render('rgb_array') + render = env.render() self.assertIsInstance(render, np.ndarray) env.reset() env.close() @@ -111,7 +113,8 @@ def test(self): if done: state, _ = env.reset() done = False - state, _, done, _, _ = env.step(0) + state, _, terminated, truncated, _ = env.step(0) + done = terminated or truncated backup = state.copy() @@ -121,7 +124,8 @@ def test(self): if done: state = env.reset() done = False - state, _, done, _, _ = env.step(0) + state, _, terminated, truncated, _ = env.step(0) + done = terminated or truncated self.assertFalse(np.array_equal(backup, state)) env._restore() diff --git a/nes_py/wrappers/joypad_space.py b/nes_py/wrappers/joypad_space.py index 893ea6f..120f9cf 100644 --- a/nes_py/wrappers/joypad_space.py +++ b/nes_py/wrappers/joypad_space.py @@ -1,7 +1,7 @@ """An environment wrapper to convert binary to discrete action space.""" -import gym -from gym import Env -from gym import Wrapper +import gymnasium as gym +from gymnasium import Env +from gymnasium import Wrapper class JoypadSpace(Wrapper): @@ -73,10 +73,6 @@ def step(self, action): # take the step and record the output return self.env.step(self._action_map[action]) - def reset(self): - """Reset the environment and return the initial observation.""" - return self.env.reset() - def get_keys_to_action(self): """Return the dictionary of keyboard keys to actions.""" # get the old mapping of keys to actions diff --git a/requirements.txt b/requirements.txt index 371f5e8..e171c1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -gym>=0.17.2 +gymnasium>=0.26.0 numpy>=1.18.5 -pyglet<=1.5.21,>=1.4.0 +pyglet<=1.5.27,>=1.4.0 tqdm>=4.48.2 twine>=1.11.0 diff --git a/scripts/run.py b/scripts/run.py index ad8f94e..9ea3946 100644 --- a/scripts/run.py +++ b/scripts/run.py @@ -10,6 +10,7 @@ state, _ = env.reset() done = False else: - state, reward, done, truncated, info = env.step(env.action_space.sample()) + state, reward, terminated, truncated, info = env.step(env.action_space.sample()) + done = terminated or truncated except KeyboardInterrupt: pass diff --git a/setup.py b/setup.py index 5f178d8..10cc38d 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ setup( name='nes_py', - version='8.2.2', + version='8.3.0', description='An NES Emulator and OpenAI Gym interface', long_description=README, long_description_content_type='text/markdown', @@ -57,6 +57,8 @@ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Topic :: Games/Entertainment', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Emulators', @@ -69,7 +71,7 @@ ext_modules=[LIB_NES_ENV], zip_safe=False, install_requires=[ - 'gym>=0.17.2', + 'gymnasium>=0.26.0', 'numpy>=1.18.5', 'pyglet<=1.5.21,>=1.4.0', 'tqdm>=4.48.2', diff --git a/speedtest.py b/speedtest.py index 90abfae..f2f69f3 100644 --- a/speedtest.py +++ b/speedtest.py @@ -10,6 +10,6 @@ state = env.reset() done = False else: - state, reward, done, info = env.step(env.action_space.sample()) + state, reward, terminated, truncated, info = env.step(env.action_space.sample()) except KeyboardInterrupt: pass From 943d1783f6884240fb6003bf11f40581e7a93fbe Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Mon, 17 Jul 2023 21:42:13 +0300 Subject: [PATCH 07/13] fixed unit test, requires gymnasium>=0.27 gymnasium 0.27 Discrete space returns np.int64 instead of int --- nes_py/tests/test_nes_env.py | 2 +- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nes_py/tests/test_nes_env.py b/nes_py/tests/test_nes_env.py index 1ec3012..dc904d5 100644 --- a/nes_py/tests/test_nes_env.py +++ b/nes_py/tests/test_nes_env.py @@ -83,7 +83,7 @@ def test(self): self.assertIsInstance(state, np.ndarray) # sample a random action and check it action = env.action_space.sample() - self.assertIsInstance(action, int) + self.assertIsInstance(action, np.int64) # take a step and check the outputs output = env.step(action) self.assertIsInstance(output, tuple) diff --git a/requirements.txt b/requirements.txt index e171c1a..47b095f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -gymnasium>=0.26.0 +gymnasium>=0.27.0 numpy>=1.18.5 pyglet<=1.5.27,>=1.4.0 tqdm>=4.48.2 diff --git a/setup.py b/setup.py index 10cc38d..460c2af 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ ext_modules=[LIB_NES_ENV], zip_safe=False, install_requires=[ - 'gymnasium>=0.26.0', + 'gymnasium>=0.27.0', 'numpy>=1.18.5', 'pyglet<=1.5.21,>=1.4.0', 'tqdm>=4.48.2', From 23f7b185154c14033f240cd6cef6acd0643250e8 Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Mon, 17 Jul 2023 23:57:19 +0300 Subject: [PATCH 08/13] small fix --- nes_py/app/play_human.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nes_py/app/play_human.py b/nes_py/app/play_human.py index 377ebc6..04054b3 100644 --- a/nes_py/app/play_human.py +++ b/nes_py/app/play_human.py @@ -71,7 +71,7 @@ def play_human(env: gym.Env, callback=None): viewer.show(env.unwrapped.screen) # pass the observation data through the callback if callback is not None: - callback(state, action, reward, done, truncated, next_state) + callback(state, action, reward, terminated, truncated, next_state) state = next_state # shutdown if the escape key is pressed if viewer.is_escape_pressed: From 4e9b116c9c50ffbe860bf57ef2b5dc9827427cd1 Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Tue, 18 Jul 2023 23:08:26 +0300 Subject: [PATCH 09/13] viewer initialization during init Also added None as a default option for render_mode. Also separated between terminated and truncated instead of a single done internally. --- nes_py/app/cli.py | 6 ++-- nes_py/nes_env.py | 87 +++++++++++++++++++++++++++-------------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/nes_py/app/cli.py b/nes_py/app/cli.py index 762ea2d..34c3db1 100644 --- a/nes_py/app/cli.py +++ b/nes_py/app/cli.py @@ -34,12 +34,14 @@ def main(): """The main entry point for the command line interface.""" # get arguments from the command line args = _get_args() - # create the environment - env = NESEnv(args.rom) # play the environment with the given mode if args.mode == 'human': + # environment is initialized without a rendering mode, as play_human creates its own + env = NESEnv(args.rom) play_human(env) else: + # create the environment + env = NESEnv(args.rom, render_mode='human') play_random(env, args.steps) diff --git a/nes_py/nes_env.py b/nes_py/nes_env.py index 7b00ba3..f623061 100644 --- a/nes_py/nes_env.py +++ b/nes_py/nes_env.py @@ -95,7 +95,7 @@ class NESEnv(gym.Env): # relevant meta-data about the environment metadata = { - 'render.modes': ['rgb_array', 'human'], + 'render.modes': ['rgb_array', 'human', None], 'video.frames_per_second': 60 } @@ -113,7 +113,7 @@ class NESEnv(gym.Env): # action space is a bitmap of button press values for the 8 NES buttons action_space = Discrete(256) - def __init__(self, rom_path, render_mode='human'): + def __init__(self, rom_path, render_mode: Optional[str] = None): """ Create a new NES environment. @@ -150,13 +150,31 @@ def __init__(self, rom_path, render_mode='human'): self._rom_path = rom_path # initialize the C++ object for running the environment self._env = _LIB.Initialize(self._rom_path) - # setup a placeholder for a 'human' render mode viewer - self.viewer = None + if render_mode not in self.metadata['render.modes']: + # unpack the modes as comma delineated strings ('a', 'b', ...) + render_modes = [repr(x) if x is not None else x for x in self.metadata['render.modes']] + msg = 'valid render modes are: {}'.format(', '.join(render_modes)) + raise NotImplementedError(msg) + # setup a 'human' render mode viewer + if render_mode == 'human': + # get the caption for the ImageViewer + if self.spec is None: + # if there is no spec, just use the .nes filename + caption = self._rom_path.split('/')[-1] + else: + # set the caption to the OpenAI Gym id + caption = self.spec.id + # create the ImageViewer to display frames + self.viewer = ImageViewer( + caption=caption, + height=SCREEN_HEIGHT, + width=SCREEN_WIDTH, + ) self.render_mode = render_mode # setup a placeholder for a pointer to a backup state self._has_backup = False - # setup a done flag - self.done = True + # setup a terminated flag + self.terminated = True # truncated self.truncated = False # setup the controllers, screen, and RAM buffers @@ -282,8 +300,12 @@ def reset(self, seed=None, options=None) -> Tuple[ObsType, dict]: _LIB.Reset(self._env) # call the after reset callback self._did_reset() - # set the done flag to false - self.done = False + # set the terminated and truncated flags to false + self.terminated = False + self.truncated = False + # automatically render the environment if in human mode + if self.render_mode == 'human': + self.render() # return the screen from the emulator return self.screen, self._get_info() @@ -302,39 +324,43 @@ def step(self, action) -> Tuple[ObsType, float, bool, bool, dict]: a tuple of: - state (np.ndarray): next frame as a result of the given action - reward (float) : amount of reward returned after given action - - done (boolean): whether the episode has ended + - terminated (boolean): whether the episode has ended + - truncated (boolean): whether the step limit has been reached - info (dict): contains auxiliary diagnostic information """ # if the environment is done, raise an error - if self.done: - raise ValueError('cannot step in a done environment! call `reset`') + if self.terminated or self.truncated: + raise ValueError('cannot step in a terminated or truncated environment! call `reset`') # set the action on the controller self.controllers[0][:] = action # pass the action to the emulator as an unsigned byte _LIB.Step(self._env) # get the reward for this step reward = float(self._get_reward()) - # get the done flag for this step - self.done = bool(self._get_done()) + # get the terminated and truncated flags for this step + self.terminated = bool(self._get_terminated()) + self.truncated = bool(self._get_truncated()) # get the info for this step info = self._get_info() - self.truncated = self._get_truncated() # call the after step callback - self._did_step(self.done) + self._did_step(self.terminated, self.truncated) # bound the reward in [min, max] if reward < self.reward_range[0]: reward = self.reward_range[0] elif reward > self.reward_range[1]: reward = self.reward_range[1] + # automatically render the environment if in human mode + if self.render_mode == 'human': + self.render() # return the screen from the emulator and other relevant data - return self.screen, reward, self.done, self.truncated, info + return self.screen, reward, self.terminated, self.truncated, info def _get_reward(self): """Return the reward after a step occurs.""" return 0 - def _get_done(self): + def _get_terminated(self): """Return True if the episode is over, False otherwise.""" return False @@ -346,12 +372,13 @@ def _get_info(self): """Return the info after a step occurs.""" return {} - def _did_step(self, done): + def _did_step(self, terminated, truncated): """ Handle any RAM hacking after a step occurs. Args: - done (bool): whether the done flag is set to true + terminated (bool): whether the terminated flag is set to true + truncated (bool): whether the truncated flag is set to true Returns: None @@ -369,7 +396,7 @@ def close(self): # deallocate the object locally self._env = None # if there is an image viewer open, delete it - if self.viewer is not None: + if self.render_mode == 'human': self.viewer.close() def render(self) -> Optional[Union[RenderFrame, List[RenderFrame]]]: @@ -383,24 +410,12 @@ def render(self) -> Optional[Union[RenderFrame, List[RenderFrame]]]: a numpy array if environment was initialized with render_mode='rgb_array', None otherwise """ - if self.render_mode == 'human': - # if the viewer isn't setup, import it and create one - if self.viewer is None: - # get the caption for the ImageViewer - if self.spec is None: - # if there is no spec, just use the .nes filename - caption = self._rom_path.split('/')[-1] - else: - # set the caption to the OpenAI Gym id - caption = self.spec.id - # create the ImageViewer to display frames - self.viewer = ImageViewer( - caption=caption, - height=SCREEN_HEIGHT, - width=SCREEN_WIDTH, - ) + if self.render_mode is None: + return None + elif self.render_mode == 'human': # show the screen on the image viewer self.viewer.show(self.screen) + return None elif self.render_mode == 'rgb_array': return self.screen else: From 9dd44a1d85b829efeb6ca9e25e4b82cf7fcbd73c Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Wed, 19 Jul 2023 00:17:19 +0300 Subject: [PATCH 10/13] removed seed() as seeding is done by calling reset() --- nes_py/nes_env.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/nes_py/nes_env.py b/nes_py/nes_env.py index f623061..b5ac1c7 100644 --- a/nes_py/nes_env.py +++ b/nes_py/nes_env.py @@ -144,8 +144,6 @@ def __init__(self, rom_path, render_mode: Optional[str] = None): elif rom.mapper not in {0, 1, 2, 3}: msg = 'ROM has an unsupported mapper number {}. please see https://github.com/Kautenja/nes-py/issues/28 for more information.' raise ValueError(msg.format(rom.mapper)) - # create a dedicated random number generator for the environment - self.np_random = np.random.RandomState() # store the ROM path self._rom_path = rom_path # initialize the C++ object for running the environment @@ -255,26 +253,6 @@ def _will_reset(self): """Handle any RAM hacking after a reset occurs.""" pass - def seed(self, seed=None): - """ - Set the seed for this environment's random number generator. - - Returns: - list: Returns the list of seeds used in this env's random - number generators. The first value in the list should be the - "main" seed, or the value which a reproducer should pass to - 'seed'. Often, the main seed equals the provided 'seed', but - this won't be true if seed=None, for example. - - """ - # if there is no seed, return an empty list - if seed is None: - return [] - # set the random number seed for the NumPy random number generator - self.np_random.seed(seed) - # return the list of seeds used by RNG(s) in the environment - return [seed] - def reset(self, seed=None, options=None) -> Tuple[ObsType, dict]: """ Reset the state of the environment and returns an initial observation. @@ -290,7 +268,7 @@ def reset(self, seed=None, options=None) -> Tuple[ObsType, dict]: """ # Set the seed. - self.seed(seed) + super().reset(seed=seed) # call the before reset callback self._will_reset() # reset the emulator From 4134afd9f96159a35e5bbb6febf2ca7d60fac16c Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Wed, 19 Jul 2023 01:28:21 +0300 Subject: [PATCH 11/13] Added option to set seed when running from cli --- nes_py/app/cli.py | 8 ++++++-- nes_py/app/play_human.py | 8 ++++++-- nes_py/app/play_random.py | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/nes_py/app/cli.py b/nes_py/app/cli.py index 34c3db1..d49ffa4 100644 --- a/nes_py/app/cli.py +++ b/nes_py/app/cli.py @@ -21,6 +21,10 @@ def _get_args(): choices=['human', 'random'], help='The execution mode for the emulation.', ) + parser.add_argument('--seed', '-S', + type=int, + help='the random number seed to use' + ) # add the argument for the number of steps to take in random mode parser.add_argument('--steps', '-s', type=int, @@ -38,11 +42,11 @@ def main(): if args.mode == 'human': # environment is initialized without a rendering mode, as play_human creates its own env = NESEnv(args.rom) - play_human(env) + play_human(env, seed=args.seed) else: # create the environment env = NESEnv(args.rom, render_mode='human') - play_random(env, args.steps) + play_random(env, args.steps, seed=args.seed) # explicitly define the outward facing API of this module diff --git a/nes_py/app/play_human.py b/nes_py/app/play_human.py index 04054b3..9e60058 100644 --- a/nes_py/app/play_human.py +++ b/nes_py/app/play_human.py @@ -9,7 +9,7 @@ _NOP = 0 -def play_human(env: gym.Env, callback=None): +def play_human(env: gym.Env, callback=None, seed=None): """ Play the environment using keyboard as a human. @@ -44,7 +44,11 @@ def play_human(env: gym.Env, callback=None): relevant_keys=set(sum(map(list, keys_to_action.keys()), [])) ) # create a done flag for the environment - done = True + done = False + # reset the environment with the given seed + state, _ = env.reset(seed=seed) + # render the initial state + viewer.show(env.unwrapped.screen) # prepare frame rate limiting target_frame_duration = 1 / env.metadata['video.frames_per_second'] last_frame_time = 0 diff --git a/nes_py/app/play_random.py b/nes_py/app/play_random.py index 8fcceff..3287639 100644 --- a/nes_py/app/play_random.py +++ b/nes_py/app/play_random.py @@ -2,7 +2,7 @@ from tqdm import tqdm -def play_random(env, steps): +def play_random(env, steps, seed=None): """ Play the environment making uniformly random decisions. @@ -15,7 +15,8 @@ def play_random(env, steps): """ try: - done = True + done = False + _, _ = env.reset(seed=seed) progress = tqdm(range(steps)) for _ in progress: if done: From 35557c34d4469b6e19e8a8139cb93d48233cc2e3 Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Sun, 23 Jul 2023 20:52:16 +0300 Subject: [PATCH 12/13] Update joypad_space.py --- nes_py/wrappers/joypad_space.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nes_py/wrappers/joypad_space.py b/nes_py/wrappers/joypad_space.py index 120f9cf..a27752e 100644 --- a/nes_py/wrappers/joypad_space.py +++ b/nes_py/wrappers/joypad_space.py @@ -4,7 +4,7 @@ from gymnasium import Wrapper -class JoypadSpace(Wrapper): +class JoypadSpace(Wrapper, gym.utils.RecordConstructorArgs): """An environment wrapper to convert binary to discrete action space.""" # a mapping of buttons to binary values @@ -38,6 +38,7 @@ def __init__(self, env: Env, actions: list): None """ + gym.utils.RecordConstructorArgs.__init__(self, actions=actions) super().__init__(env) # create the new action space self.action_space = gym.spaces.Discrete(len(actions)) From bf202fc4b70ad22e834f5bbb3f1d8b893983015d Mon Sep 17 00:00:00 2001 From: Itai Bear Date: Thu, 9 Nov 2023 21:05:05 +0200 Subject: [PATCH 13/13] fixed metadata --- nes_py/nes_env.py | 12 +++++------- nes_py/tests/games/Tetris.nes | Bin 0 -> 49168 bytes 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 nes_py/tests/games/Tetris.nes diff --git a/nes_py/nes_env.py b/nes_py/nes_env.py index b5ac1c7..2d2ba06 100644 --- a/nes_py/nes_env.py +++ b/nes_py/nes_env.py @@ -95,7 +95,8 @@ class NESEnv(gym.Env): # relevant meta-data about the environment metadata = { - 'render.modes': ['rgb_array', 'human', None], + 'render_modes': ['rgb_array', 'human'], + 'render_fps': 60, 'video.frames_per_second': 60 } @@ -148,11 +149,8 @@ def __init__(self, rom_path, render_mode: Optional[str] = None): self._rom_path = rom_path # initialize the C++ object for running the environment self._env = _LIB.Initialize(self._rom_path) - if render_mode not in self.metadata['render.modes']: - # unpack the modes as comma delineated strings ('a', 'b', ...) - render_modes = [repr(x) if x is not None else x for x in self.metadata['render.modes']] - msg = 'valid render modes are: {}'.format(', '.join(render_modes)) - raise NotImplementedError(msg) + assert render_mode is None or render_mode in self.metadata["render_modes"] + # setup a 'human' render mode viewer if render_mode == 'human': # get the caption for the ImageViewer @@ -398,7 +396,7 @@ def render(self) -> Optional[Union[RenderFrame, List[RenderFrame]]]: return self.screen else: # unpack the modes as comma delineated strings ('a', 'b', ...) - render_modes = [repr(x) for x in self.metadata['render.modes']] + render_modes = [repr(x) for x in self.metadata['render_modes']] msg = 'valid render modes are: {}'.format(', '.join(render_modes)) raise NotImplementedError(msg) diff --git a/nes_py/tests/games/Tetris.nes b/nes_py/tests/games/Tetris.nes new file mode 100644 index 0000000000000000000000000000000000000000..5f460b1a0943690f6cedda6c2a0c53f1497744a5 GIT binary patch literal 49168 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 literal 0 HcmV?d00001