Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial client #2

Merged
merged 17 commits into from
Apr 6, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions MetroidPrimeClient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import asyncio
import traceback

from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, logger, server_loop, gui_enabled
from NetUtils import NetworkItem
import Utils
from worlds.metroidprime.DolphinClient import DolphinException
from worlds.metroidprime.MetroidPrimeInterface import InventoryItemData, MetroidPrimeInterface


class MetroidPrimeCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)


class MetroidPrimeContext(CommonContext):
command_processor = MetroidPrimeCommandProcessor
game_interface: MetroidPrimeInterface
game = "Metroid Prime"
items_handling = 0b111
dolphin_sync_task = None

def __init__(self, server_address, password):
super().__init__(server_address, password)
self.game_interface = MetroidPrimeInterface(logger)

def on_deathlink(self, data: Utils.Dict[str, Utils.Any]) -> None:
super().on_deathlink(data)
logger.info()

async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(MetroidPrimeContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()


async def dolphin_sync_task(ctx: MetroidPrimeContext):
logger.info("Starting Dolphin connector")
while not ctx.exit_event.is_set():
try:
if ctx.game_interface.is_connected() and ctx.game_interface.is_in_playable_state():
await _handle_game_ready(ctx)
else:
await _handle_game_not_ready(ctx)
except Exception as e:
if isinstance(e, DolphinException):
logger.error(str(e))
else:
logger.error(traceback.format_exc())

logger.info("Attempting to reconnect to Dolphin")
await ctx.disconnect()
await asyncio.sleep(3)
continue


def inventory_item_by_network_id(network_id: int, current_inventory: dict[str, InventoryItemData]) -> InventoryItemData:
for item in current_inventory.values():
if item.code == network_id:
return item
return None


def get_total_count_of_item_received(network_id: int, items: list[NetworkItem]) -> int:
count = 0
for network_item in items:
if network_item.item == network_id:
count += 1
return count


async def send_checked_locations(ctx: MetroidPrimeContext):
pass


async def handle_receive_items(ctx: MetroidPrimeContext):
current_items = ctx.game_interface.get_current_inventory()

# Handle Single Item Upgrades
for network_item in ctx.items_received:
item_data = inventory_item_by_network_id(
network_item.item, current_items)
if item_data is None:
logger.debug(
f"Item with network id {network_item.item} not found in inventory. {network_item}")
continue
if item_data.max_capacity == 1 and item_data.current_amount == 0:
logger.debug(f"Giving item {item_data.name} to player")
ctx.game_interface.give_item_to_player(item_data.id, 1, 1)

# Handle Missile Expansions
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not in love with the size of this function, but I figured rather than creating an abstraction it'd be more pragmatic to keep them separate since each of these 3 different powerups have a slightly different effect when you receive them (gain 5 missiles, gain 1 bomb, restore health). Open to suggestions here though for sure

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an area where we can also gate the player from receiving any Missile or PB expansions if they haven't received the Missile Launcher or Power Bomb Main items yet.

amount_of_missiles_given_per_item = 5
missile_item = current_items["Missile Expansion"]
num_missile_expansions_received = get_total_count_of_item_received(
missile_item.code, ctx.items_received)
diff = num_missile_expansions_received * \
amount_of_missiles_given_per_item - missile_item.current_capacity
if diff > 0 and missile_item.current_capacity < missile_item.max_capacity:
new_capacity = min(num_missile_expansions_received *
amount_of_missiles_given_per_item, missile_item.max_capacity)
new_amount = min(missile_item.current_amount + diff, new_capacity)
logger.debug(
f"Setting missile expansion to {new_amount}/{new_capacity} from {missile_item.current_amount}/{missile_item.current_capacity}")
ctx.game_interface.give_item_to_player(
missile_item.id, new_amount, new_capacity)

# Handle Power Bomb Expansions
power_bomb_item = current_items["Power Bomb Expansion"]
num_power_bombs_received = get_total_count_of_item_received(
power_bomb_item.code, ctx.items_received)
diff = num_power_bombs_received - power_bomb_item.current_capacity
if diff > 0 and power_bomb_item.current_capacity < power_bomb_item.max_capacity:
new_capacity = min(num_power_bombs_received,
power_bomb_item.max_capacity)
new_amount = min(power_bomb_item.current_amount + diff, new_capacity)
logger.debug(
f"Setting power bomb expansions to {new_capacity} from {power_bomb_item.current_capacity}")
ctx.game_interface.give_item_to_player(
power_bomb_item.id, new_capacity, new_capacity)

# Handle Energy Tanks
energy_tank_item = current_items["Energy Tank"]
num_energy_tanks_received = get_total_count_of_item_received(
energy_tank_item.code, ctx.items_received)
diff = num_energy_tanks_received - energy_tank_item.current_capacity
if diff > 0 and energy_tank_item.current_capacity < energy_tank_item.max_capacity:
new_capacity = min(num_energy_tanks_received,
energy_tank_item.max_capacity)
logger.debug(
f"Setting energy tanks to {new_capacity} from {energy_tank_item.current_capacity}")
ctx.game_interface.give_item_to_player(
energy_tank_item.id, new_capacity, new_capacity)

# Heal player when they receive a new energy tank
# Player starts with 99 health and each energy tank adds 100 additional
ctx.game_interface.set_current_health(new_capacity * 100.0 + 99)

# TODO: Handle setting Artifact flags so that the Artifact Temple State is updated accordingly


async def _handle_game_ready(ctx: MetroidPrimeContext):
if ctx.server:
if not ctx.slot:
await asyncio.sleep(1)
return

await send_checked_locations(ctx)
await handle_receive_items(ctx)

if "DeathLink" in ctx.tags:
logger.debug("DeathLink not implemented")
await asyncio.sleep(0.5)
else:
logger.info("Waiting for player to connect to server")
await asyncio.sleep(1)


async def _handle_game_not_ready(ctx: MetroidPrimeContext):
"""If the game is not connected or not in a playable state, this will attempt to retry connecting to the game."""
if not ctx.game_interface.is_connected():
logger.info("Attempting to connect to Dolphin")
ctx.game_interface.connect_to_game()
elif not ctx.game_interface.is_in_playable_state():
logger.info(
"Waiting for player to load a save file or start a new game")
await asyncio.sleep(3)


def main(connect=None, password=None, name=None):
Utils.init_logging("Metroid Prime Client")

async def _main(connect, password, name):
ctx = MetroidPrimeContext(connect, password)
ctx.auth = name
ctx.server_task = asyncio.create_task(
server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
await asyncio.sleep(1)

ctx.dolphin_sync_task = asyncio.create_task(
dolphin_sync_task(ctx), name="DolphinSync")

await ctx.exit_event.wait()
ctx.server_address = None

await ctx.shutdown()

if ctx.dolphin_sync_task:
await asyncio.sleep(3)
await ctx.dolphin_sync_task

import colorama

colorama.init()
asyncio.run(_main(connect, password, name))
colorama.deinit()


if __name__ == "__main__":
parser = get_base_parser()
parser.add_argument('--name', default=None,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added support for a --name argument so I don't have to type it in every time I test the client connecting to AP. SC2 client does something similar so I figured it wasn't going to break anything upstream 🤣

help="Slot Name to connect as.")
args = parser.parse_args()
main(args.connect, args.password, args.name)
94 changes: 94 additions & 0 deletions worlds/metroidprime/DolphinClient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from logging import Logger
import dolphin_memory_engine

GC_GAME_ID_ADDRESS = 0x80000000


class DolphinException(Exception):
pass


class DolphinClient:
dolphin: dolphin_memory_engine
logger: Logger

def __init__(self, logger):
self.dolphin = dolphin_memory_engine
self.logger = logger

def is_connected(self):
try:
self.__assert_connected()
return True
except Exception:
return False

def connect(self):
if not self.dolphin.is_hooked():
self.dolphin.hook()
if not self.dolphin.is_hooked():
raise DolphinException(
"Could not connect to Dolphin, verify that you have a game running in the emulator")

def disconnect(self):
if self.dolphin.is_hooked():
self.dolphin.un_hook()

def __assert_connected(self):
"""Custom assert function that returns a DolphinException instead of a generic RuntimeError if the connection is lost"""
try:
self.dolphin.assert_hooked()
# For some reason the dolphin_memory_engine.is_hooked() function doesn't recognize when the game is closed, checking if memory is available will assert the connection is alive
self.dolphin.read_bytes(GC_GAME_ID_ADDRESS, 1)
except RuntimeError as e:
self.disconnect()
raise DolphinException(e)

def verify_target_address(self, target_address: int, read_size: int):
"""Ensures that the target address is within the valid range for GC memory"""
if target_address < 0x80000000 or target_address + read_size > 0x81800000:
raise DolphinException(
f"{target_address:x} -> {target_address + read_size:x} is not a valid for GC memory"
)

def read_pointer(self, pointer, offset, byte_count):
self.__assert_connected()

address = None
try:
address = self.dolphin.follow_pointers(pointer, [0])
except RuntimeError:
self.logger.error(f"Could not read pointer at {pointer:x}")
return None

if not self.dolphin.is_hooked():
raise DolphinException("Dolphin no longer connected")

address += offset
return self.read_address(address, byte_count)

def read_address(self, address, bytes_to_read):
self.__assert_connected()
self.verify_target_address(address, bytes_to_read)
result = self.dolphin.read_bytes(address, bytes_to_read)
return result

def write_pointer(self, pointer, offset, data):
self.__assert_connected()
address = None
try:
address = self.dolphin.follow_pointers(pointer, [0])
except RuntimeError:
self.logger.error(f"Could not read pointer at {pointer:x}")
return None

if not self.dolphin.is_hooked():
raise DolphinException("Dolphin no longer connected")

address += offset
return self.write_address(address, data)

def write_address(self, address, data):
self.__assert_connected()
result = self.dolphin.write_bytes(address, data)
return result
4 changes: 2 additions & 2 deletions worlds/metroidprime/Items.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class MetroidPrimeItem(Item):
"Ice Beam": ItemData("Ice Beam", 1, ItemClassification.progression),
"Wave Beam": ItemData("Wave Beam", 2, ItemClassification.progression),
"Plasma Beam": ItemData("Plasma Beam", 3, ItemClassification.progression),
"Missile Expansion": ItemData("Missile Expansion", 4, ItemClassification.useful, 999),
"Missile Expansion": ItemData("Missile Expansion", 4, ItemClassification.useful, 999 ),
"Scan Visor": ItemData("Scan Visor", 5, ItemClassification.progression),
"Morph Ball Bombs": ItemData("Morph Ball Bombs", 6, ItemClassification.progression),
"Power Bomb Expansion": ItemData("Power Bomb Expansion", 7, ItemClassification.useful, 99),
Expand All @@ -47,7 +47,7 @@ class MetroidPrimeItem(Item):
"Gravity Suit": ItemData("Gravity Suit", 21, ItemClassification.progression),
"Varia Suit": ItemData("Varia Suit", 22, ItemClassification.progression),
"Phazon Suit": ItemData("Phazon Suit", 23, ItemClassification.progression),
"Energy Tank": ItemData("Energy Tank", 24, ItemClassification.useful),
"Energy Tank": ItemData("Energy Tank", 24, ItemClassification.useful, 14),
"Spring Ball": ItemData("Spring Ball", 25, ItemClassification.useful),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We may want to look at putting Spring Ball somewhere else (maybe 43?) if we want to use UnknownItem1 for location checks

# item 026 is a health refill
"Ice Trap": ItemData("Ice Trap", 27, ItemClassification.trap),
Expand Down
Loading
Loading