-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 8 commits
e4b0b45
6f48543
aad6144
bafb566
3de07db
0420d73
e073378
2aef647
79d6969
84547dd
e27e3de
c51e7cf
899180a
3ed044b
88256c9
0af619d
87861b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added support for a |
||
help="Slot Name to connect as.") | ||
args = parser.parse_args() | ||
main(args.connect, args.password, args.name) |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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), | ||
|
@@ -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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.