|
| 1 | +import ModuleUpdate |
| 2 | + |
| 3 | +ModuleUpdate.update() |
| 4 | + |
| 5 | +import Utils |
| 6 | + |
| 7 | +if __name__ == "__main__": |
| 8 | + Utils.init_logging("DK64Context", exception_logger="Client") |
| 9 | + |
| 10 | +import asyncio |
| 11 | +import colorama |
| 12 | +import time |
| 13 | +import typing |
| 14 | +from worlds.dk64.client.common import N64Exception, DK64MemoryMap, create_task_log_exception |
| 15 | +from worlds.dk64.client.pj64 import PJ64Client |
| 16 | +from worlds.dk64.client.id_data import item_ids |
| 17 | + |
| 18 | +from CommonClient import CommonContext, get_base_parser, gui_enabled, logger, server_loop |
| 19 | +from NetUtils import ClientStatus |
| 20 | + |
| 21 | + |
| 22 | +class DK64Client: |
| 23 | + n64_client = PJ64Client() |
| 24 | + tracker = None |
| 25 | + game = None |
| 26 | + auth = None |
| 27 | + recvd_checks: dict = {} |
| 28 | + |
| 29 | + stop_bizhawk_spam = False |
| 30 | + |
| 31 | + async def wait_for_pj64(self): |
| 32 | + clear_waiting_message = True |
| 33 | + if not self.stop_bizhawk_spam: |
| 34 | + logger.info("Waiting on connection to PJ64...") |
| 35 | + self.stop_bizhawk_spam = True |
| 36 | + |
| 37 | + while True: |
| 38 | + try: |
| 39 | + socket_connected = False |
| 40 | + valid_rom = self.n64_client.validate_rom(self.game) |
| 41 | + if self.n64_client.socket is not None and not socket_connected: |
| 42 | + logger.info("Connected to PJ64") |
| 43 | + socket_connected = True |
| 44 | + while not valid_rom: |
| 45 | + if self.n64_client.socket is not None and not socket_connected: |
| 46 | + logger.info("Connected to PJ64") |
| 47 | + socket_connected = True |
| 48 | + if clear_waiting_message: |
| 49 | + logger.info("Waiting on valid ROM...") |
| 50 | + clear_waiting_message = False |
| 51 | + await asyncio.sleep(1.0) |
| 52 | + valid_rom = self.n64_client.validate_rom(self.game) |
| 53 | + self.stop_bizhawk_spam = False |
| 54 | + logger.info("PJ64 Connected to ROM!") |
| 55 | + return |
| 56 | + except (N64Exception, BlockingIOError, TimeoutError, ConnectionResetError): |
| 57 | + await asyncio.sleep(1.0) |
| 58 | + pass |
| 59 | + |
| 60 | + async def reset_auth(self): |
| 61 | + memory_location = self.n64_client.read_u32(DK64MemoryMap.memory_pointer) |
| 62 | + self.n64_client.write_u8(memory_location + DK64MemoryMap.connection, [0xFF]) |
| 63 | + |
| 64 | + async def wait_and_init_tracker(self): |
| 65 | + await self.wait_for_game_ready() |
| 66 | + # self.tracker = LocationTracker(self.n64_client) |
| 67 | + # self.item_tracker = ItemTracker(self.n64_client) |
| 68 | + |
| 69 | + async def recved_item_from_ap(self, item_id, from_player, next_index): |
| 70 | + # Don't allow getting an item until you've got your first check |
| 71 | + if not self.tracker.has_start_item(): |
| 72 | + return |
| 73 | + |
| 74 | + # Spin until we either: |
| 75 | + # get an exception from a bad read (emu shut down or reset) |
| 76 | + # beat the game |
| 77 | + # the client handles the last pending item |
| 78 | + status = self.safe_to_send() |
| 79 | + while not (await self.is_victory()) and status != 0: |
| 80 | + time.sleep(0.1) |
| 81 | + status = self.safe_to_send() |
| 82 | + # TODO: not sure why we need this |
| 83 | + # item_id -= LABaseID |
| 84 | + # The player name table only goes up to 100, so don't go past that |
| 85 | + # Even if it didn't, the remote player _index_ byte is just a byte, so 255 max |
| 86 | + if from_player > 100: |
| 87 | + from_player = 100 |
| 88 | + |
| 89 | + # next_index += 1 |
| 90 | + # self.n64_client.write_memory(LAClientConstants.wLinkGiveItem, [ |
| 91 | + # item_id, from_player]) |
| 92 | + # status |= 1 |
| 93 | + # status = self.n64_client.write_memory(LAClientConstants.wLinkStatusBits, [status]) |
| 94 | + # self.n64_client.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) |
| 95 | + # self.setFlag(item_ids[14041094].get("flag_id")) |
| 96 | + # memory_location = self.client.n64_client.read_u32(DK64MemoryMap.memory_pointer) |
| 97 | + # example_string = "ExampleString Multi Spaced\0" |
| 98 | + # self.client.n64_client.write_bytestring(memory_location + DK64MemoryMap.fed_string, example_string) |
| 99 | + def check_safe_gameplay(self): |
| 100 | + current_gamemode = self.n64_client.read_u8(DK64MemoryMap.CurrentGamemode) |
| 101 | + next_gamemode = self.n64_client.read_u8(DK64MemoryMap.NextGamemode) |
| 102 | + return current_gamemode in [6, 0xD] and next_gamemode in [6, 0xD] |
| 103 | + |
| 104 | + def safe_to_send(self): |
| 105 | + memory_location = self.n64_client.read_u32(DK64MemoryMap.memory_pointer) |
| 106 | + countdown_value = self.n64_client.read_u8(memory_location + DK64MemoryMap.safety_text_timer) |
| 107 | + return countdown_value == 0 |
| 108 | + |
| 109 | + async def readChecks(self, cb): |
| 110 | + new_checks = [] |
| 111 | + for check in self.remaining_checks: |
| 112 | + addresses = [check.address] |
| 113 | + if check.alternateAddress: |
| 114 | + addresses.append(check.alternateAddress) |
| 115 | + bytes = await self.gameboy.read_memory_cache(addresses) |
| 116 | + if not bytes: |
| 117 | + return False |
| 118 | + check.set(list(bytes.values())) |
| 119 | + |
| 120 | + if check.value: |
| 121 | + self.remaining_checks.remove(check) |
| 122 | + new_checks.append(check) |
| 123 | + if new_checks: |
| 124 | + cb(new_checks) |
| 125 | + return True |
| 126 | + |
| 127 | + def has_start_item(self): |
| 128 | + # Checks to see if the file has been started |
| 129 | + return self.readFlag(0) == 1 |
| 130 | + |
| 131 | + should_reset_auth = False |
| 132 | + |
| 133 | + def setFlag(self, index: int) -> int: |
| 134 | + byte_index = index >> 3 |
| 135 | + shift = index & 7 |
| 136 | + offset = DK64MemoryMap.EEPROM + byte_index |
| 137 | + val = self.n64_client.read_u8(offset) |
| 138 | + self.n64_client.write_u8(offset, [val | (1 << shift)]) |
| 139 | + |
| 140 | + def readFlag(self, index: int) -> int: |
| 141 | + byte_index = index >> 3 |
| 142 | + shift = index & 7 |
| 143 | + offset = DK64MemoryMap.EEPROM + byte_index |
| 144 | + val = self.n64_client.read_u8(offset) |
| 145 | + return (val >> shift) & 1 |
| 146 | + |
| 147 | + async def wait_for_game_ready(self): |
| 148 | + logger.info("Waiting on game to be in valid state...") |
| 149 | + while not self.check_safe_gameplay(): |
| 150 | + if self.should_reset_auth: |
| 151 | + self.should_reset_auth = False |
| 152 | + raise N64Exception("Resetting due to wrong archipelago server") |
| 153 | + logger.info("Game connection ready!") |
| 154 | + |
| 155 | + async def is_victory(self): |
| 156 | + memory_location = self.n64_client.read_u32(DK64MemoryMap.memory_pointer) |
| 157 | + return self.n64_client.read_u8(memory_location + DK64MemoryMap.end_credits) == 1 |
| 158 | + |
| 159 | + async def main_tick(self, item_get_cb, win_cb): |
| 160 | + await self.readChecks(item_get_cb) |
| 161 | + # await self.item_tracker.readItems() |
| 162 | + if await self.is_victory(): |
| 163 | + await win_cb() |
| 164 | + |
| 165 | + # recv_index = struct.unpack(">H", await self.n64_client.async_read_memory(LAClientConstants.wRecvIndex, 2))[0] |
| 166 | + |
| 167 | + # # Play back one at a time |
| 168 | + # if recv_index in self.recvd_checks: |
| 169 | + # item = self.recvd_checks[recv_index] |
| 170 | + # await self.recved_item_from_ap(item.item, item.player, recv_index) |
| 171 | + |
| 172 | + |
| 173 | +class DK64Context(CommonContext): |
| 174 | + tags = {"AP"} |
| 175 | + game = "Donkey Kong 64" |
| 176 | + la_task = None |
| 177 | + found_checks = [] |
| 178 | + last_resend = time.time() |
| 179 | + |
| 180 | + won = False |
| 181 | + |
| 182 | + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: |
| 183 | + self.client = DK64Client() |
| 184 | + self.client.game = self.game.upper() |
| 185 | + self.slot_data = {} |
| 186 | + |
| 187 | + super().__init__(server_address, password) |
| 188 | + |
| 189 | + def run_gui(self) -> None: |
| 190 | + from kvui import GameManager |
| 191 | + |
| 192 | + class DK64Manager(GameManager): |
| 193 | + logging_pairs = [ |
| 194 | + ("Client", "Archipelago"), |
| 195 | + ("Tracker", "Tracker"), |
| 196 | + ] |
| 197 | + base_title = "Archipelago Donkey Kong 64 Client" |
| 198 | + |
| 199 | + def build(self): |
| 200 | + b = super().build() |
| 201 | + return b |
| 202 | + |
| 203 | + self.ui = DK64Manager(self) |
| 204 | + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") |
| 205 | + |
| 206 | + async def send_checks(self): |
| 207 | + message = [{"cmd": "LocationChecks", "locations": self.found_checks}] |
| 208 | + await self.send_msgs(message) |
| 209 | + |
| 210 | + had_invalid_slot_data: typing.Optional[bool] = None |
| 211 | + |
| 212 | + def event_invalid_slot(self): |
| 213 | + # The next time we try to connect, reset the game loop for new auth |
| 214 | + self.had_invalid_slot_data = True |
| 215 | + self.auth = None |
| 216 | + # Don't try to autoreconnect, it will just fail |
| 217 | + self.disconnected_intentionally = True |
| 218 | + CommonContext.event_invalid_slot(self) |
| 219 | + |
| 220 | + async def send_victory(self): |
| 221 | + if not self.won: |
| 222 | + message = [{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}] |
| 223 | + logger.info("victory!") |
| 224 | + await self.send_msgs(message) |
| 225 | + self.won = True |
| 226 | + |
| 227 | + def new_checks(self, item_ids): |
| 228 | + self.found_checks += item_ids |
| 229 | + create_task_log_exception(self.send_checks()) |
| 230 | + |
| 231 | + async def server_auth(self, password_requested: bool = False): |
| 232 | + if password_requested and not self.password: |
| 233 | + await super(DK64Context, self).server_auth(password_requested) |
| 234 | + |
| 235 | + if self.had_invalid_slot_data: |
| 236 | + # We are connecting when previously we had the wrong ROM or server - just in case |
| 237 | + # re-read the ROM so that if the user had the correct address but wrong ROM, we |
| 238 | + # allow a successful reconnect |
| 239 | + self.client.should_reset_auth = True |
| 240 | + self.had_invalid_slot_data = False |
| 241 | + |
| 242 | + while self.client.auth == None: |
| 243 | + await asyncio.sleep(0.1) |
| 244 | + |
| 245 | + # Just return if we're closing |
| 246 | + if self.exit_event.is_set(): |
| 247 | + return |
| 248 | + self.auth = self.client.auth |
| 249 | + await self.send_connect() |
| 250 | + |
| 251 | + def on_package(self, cmd: str, args: dict): |
| 252 | + if cmd == "Connected": |
| 253 | + if self.slot is not None: |
| 254 | + self.game = self.slot_info[self.slot].game |
| 255 | + self.slot_data = args.get("slot_data", {}) |
| 256 | + |
| 257 | + if cmd == "ReceivedItems": |
| 258 | + for index, item in enumerate(args["items"], start=args["index"]): |
| 259 | + self.client.recvd_checks[index] = item |
| 260 | + |
| 261 | + async def sync(self): |
| 262 | + sync_msg = [{"cmd": "Sync"}] |
| 263 | + await self.send_msgs(sync_msg) |
| 264 | + |
| 265 | + async def run_game_loop(self): |
| 266 | + async def victory(): |
| 267 | + await self.send_victory() |
| 268 | + |
| 269 | + def on_item_get(dk64_checks): |
| 270 | + return |
| 271 | + # TODO: implement this |
| 272 | + # checks = [item_ids[check.id] for check in dk64_checks] |
| 273 | + # self.new_checks(checks) |
| 274 | + |
| 275 | + # yield to allow UI to start |
| 276 | + await asyncio.sleep(0) |
| 277 | + while True: |
| 278 | + await asyncio.sleep(0.1) |
| 279 | + |
| 280 | + try: |
| 281 | + if not self.client.stop_bizhawk_spam: |
| 282 | + logger.info("(Re)Starting game loop") |
| 283 | + self.found_checks.clear() |
| 284 | + # On restart of game loop, clear all checks, just in case we swapped ROMs |
| 285 | + # this isn't totally neccessary, but is extra safety against cross-ROM contamination |
| 286 | + self.client.recvd_checks.clear() |
| 287 | + await self.client.wait_for_pj64() |
| 288 | + await self.client.reset_auth() |
| 289 | + |
| 290 | + # If we find ourselves with new auth after the reset, reconnect |
| 291 | + if self.auth and self.client.auth != self.auth: |
| 292 | + # It would be neat to reconnect here, but connection needs this loop to be running |
| 293 | + logger.info("Detected new ROM, disconnecting...") |
| 294 | + await self.disconnect() |
| 295 | + continue |
| 296 | + if not self.client.recvd_checks: |
| 297 | + await self.sync() |
| 298 | + |
| 299 | + # await self.client.wait_and_init_tracker() |
| 300 | + await asyncio.sleep(1.0) |
| 301 | + while True: |
| 302 | + await self.client.reset_auth() |
| 303 | + await self.client.main_tick(on_item_get, victory) |
| 304 | + await asyncio.sleep(0.1) |
| 305 | + now = time.time() |
| 306 | + if self.last_resend + 5.0 < now: |
| 307 | + self.last_resend = now |
| 308 | + await self.send_checks() |
| 309 | + if self.client.should_reset_auth: |
| 310 | + self.client.should_reset_auth = False |
| 311 | + raise Exception("Resetting due to wrong archipelago server") |
| 312 | + except (asyncio.TimeoutError, TimeoutError, ConnectionResetError): |
| 313 | + await asyncio.sleep(1.0) |
| 314 | + |
| 315 | + |
| 316 | +async def main(): |
| 317 | + parser = get_base_parser(description="Donkey Kong 64 Client.") |
| 318 | + parser.add_argument("--url", help="Archipelago connection url") |
| 319 | + |
| 320 | + args = parser.parse_args() |
| 321 | + |
| 322 | + ctx = DK64Context(args.connect, args.password) |
| 323 | + |
| 324 | + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") |
| 325 | + |
| 326 | + # TODO: nothing about the lambda about has to be in a lambda |
| 327 | + ctx.la_task = create_task_log_exception(ctx.run_game_loop()) |
| 328 | + if gui_enabled: |
| 329 | + ctx.run_gui() |
| 330 | + ctx.run_cli() |
| 331 | + |
| 332 | + await ctx.exit_event.wait() |
| 333 | + await ctx.shutdown() |
| 334 | + |
| 335 | + |
| 336 | +if __name__ == "__main__": |
| 337 | + colorama.init() |
| 338 | + asyncio.run(main()) |
| 339 | + colorama.deinit() |
0 commit comments