From d48e1e447f848c5f2eccc7b0291f7228abb1b0f5 Mon Sep 17 00:00:00 2001 From: JusticePS Date: Wed, 22 Mar 2023 07:25:55 -0700 Subject: [PATCH] Adventure: implement new game (#1531) Adds Adventure for the Atari 2600, NTSC version. New randomizer, not based on prior works. Somewhat atypical of current AP rom patch games; The generator does not require the adventure rom, but writes some data to an .apadvn APContainer file that the client uses along with a base bsdiff patch to generate a final rom file. --- .gitignore | 1 + AdventureClient.py | 516 +++++++++++++ README.md | 1 + Utils.py | 8 +- data/adventure_basepatch.bsdiff4 | Bin 0 -> 1059 bytes data/lua/ADVENTURE/adventure_connector.lua | 851 +++++++++++++++++++++ data/lua/ADVENTURE/json.lua | 380 +++++++++ data/lua/ADVENTURE/socket.lua | 132 ++++ host.yaml | 19 + inno_setup.iss | 54 +- worlds/adventure/Items.py | 53 ++ worlds/adventure/Locations.py | 214 ++++++ worlds/adventure/Offsets.py | 46 ++ worlds/adventure/Options.py | 244 ++++++ worlds/adventure/Regions.py | 160 ++++ worlds/adventure/Rom.py | 321 ++++++++ worlds/adventure/Rules.py | 98 +++ worlds/adventure/__init__.py | 391 ++++++++++ worlds/adventure/docs/en_Adventure.md | 62 ++ worlds/adventure/docs/setup_en.md | 70 ++ 20 files changed, 3619 insertions(+), 2 deletions(-) create mode 100644 AdventureClient.py create mode 100644 data/adventure_basepatch.bsdiff4 create mode 100644 data/lua/ADVENTURE/adventure_connector.lua create mode 100644 data/lua/ADVENTURE/json.lua create mode 100644 data/lua/ADVENTURE/socket.lua create mode 100644 worlds/adventure/Items.py create mode 100644 worlds/adventure/Locations.py create mode 100644 worlds/adventure/Offsets.py create mode 100644 worlds/adventure/Options.py create mode 100644 worlds/adventure/Regions.py create mode 100644 worlds/adventure/Rom.py create mode 100644 worlds/adventure/Rules.py create mode 100644 worlds/adventure/__init__.py create mode 100644 worlds/adventure/docs/en_Adventure.md create mode 100644 worlds/adventure/docs/setup_en.md diff --git a/.gitignore b/.gitignore index f0df6e6f..5f8ad6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ *multisave *.archipelago *.apsave +*.BIN build bundle/components.wxs diff --git a/AdventureClient.py b/AdventureClient.py new file mode 100644 index 00000000..4c2cb92c --- /dev/null +++ b/AdventureClient.py @@ -0,0 +1,516 @@ +import asyncio +import hashlib +import json +import time +import os +import bsdiff4 +import subprocess +import zipfile +from asyncio import StreamReader, StreamWriter, CancelledError +from typing import List + + +import Utils +from NetUtils import ClientStatus +from Utils import async_start +from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ + get_base_parser +from worlds.adventure import AdventureDeltaPatch + +from worlds.adventure.Locations import base_location_id +from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation +from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table +from worlds.adventure.Offsets import static_item_element_size, connector_port_offset + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = \ + "Connection timing out. Please restart your emulator, then restart adventure_connector.lua" +CONNECTION_REFUSED_STATUS = \ + "Connection Refused. Please start your emulator and make sure adventure_connector.lua is running" +CONNECTION_RESET_STATUS = \ + "Connection was reset. Please restart your emulator, then restart adventure_connector.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" + +SCRIPT_VERSION = 1 + + +class AdventureCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx: CommonContext): + super().__init__(ctx) + + def _cmd_2600(self): + """Check 2600 Connection State""" + if isinstance(self.ctx, AdventureContext): + logger.info(f"2600 Status: {self.ctx.atari_status}") + + def _cmd_aconnect(self): + """Discard current atari 2600 connection state""" + if isinstance(self.ctx, AdventureContext): + self.ctx.atari_sync_task.cancel() + + +class AdventureContext(CommonContext): + command_processor = AdventureCommandProcessor + game = 'Adventure' + lua_connector_port: int = 17242 + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.freeincarnates_used: int = -1 + self.freeincarnate_pending: int = 0 + self.foreign_items: [AdventureForeignItemInfo] = [] + self.autocollect_items: [AdventureAutoCollectLocation] = [] + self.atari_streams: (StreamReader, StreamWriter) = None + self.atari_sync_task = None + self.messages = {} + self.locations_array = None + self.atari_status = CONNECTION_INITIAL_STATUS + self.awaiting_rom = False + self.display_msgs = True + self.deathlink_pending = False + self.set_deathlink = False + self.client_compatibility_mode = 0 + self.items_handling = 0b111 + self.checked_locations_sent: bool = False + self.port_offset = 0 + self.bat_no_touch_locations: [BatNoTouchLocation] = [] + self.local_item_locations = {} + self.dragon_speed_info = {} + + options = Utils.get_options() + self.display_msgs = options["adventure_options"]["display_msgs"] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(AdventureContext, self).server_auth(password_requested) + if not self.auth: + self.auth = self.player_name + if not self.auth: + self.awaiting_rom = True + logger.info('Awaiting connection to adventure_connector to get Player information') + return + + await self.send_connect() + + def _set_message(self, msg: str, msg_id: int): + if self.display_msgs: + self.messages[(time.time(), msg_id)] = msg + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.locations_array = None + if Utils.get_options()["adventure_options"].get("death_link", False): + self.set_deathlink = True + async_start(self.get_freeincarnates_used()) + elif cmd == "RoomInfo": + self.seed_name = args['seed_name'] + elif cmd == 'Print': + msg = args['text'] + if ': !' not in msg: + self._set_message(msg, SYSTEM_MESSAGE_ID) + elif cmd == "ReceivedItems": + msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" + self._set_message(msg, SYSTEM_MESSAGE_ID) + elif cmd == "Retrieved": + self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() + elif cmd == "SetReply": + if args["key"] == f"adventure_{self.auth}_freeincarnates_used": + self.freeincarnates_used = args["value"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() + + def on_deathlink(self, data: dict): + self.deathlink_pending = True + super().on_deathlink(data) + + def run_gui(self): + from kvui import GameManager + + class AdventureManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Adventure Client" + + self.ui = AdventureManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + async def get_freeincarnates_used(self): + if self.server and not self.server.socket.closed: + await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}]) + await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}]) + + def send_pending_freeincarnates(self): + if self.freeincarnate_pending > 0: + async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending)) + self.freeincarnate_pending = 0 + + async def send_pending_freeincarnates_impl(self, send_val: int) -> None: + await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used", + "default": 0, "want_reply": False, + "operations": [{"operation": "add", "value": send_val}]}]) + + async def used_freeincarnate(self) -> None: + if self.server and not self.server.socket.closed: + await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used", + "default": 0, "want_reply": True, + "operations": [{"operation": "add", "value": 1}]}]) + else: + self.freeincarnate_pending = self.freeincarnate_pending + 1 + + +def convert_item_id(ap_item_id: int): + static_item_index = ap_item_id - base_adventure_item_id + return static_item_index * static_item_element_size + + +def get_payload(ctx: AdventureContext): + current_time = time.time() + items = [] + dragon_speed_update = {} + diff_a_locked = ctx.diff_a_mode > 0 + diff_b_locked = ctx.diff_b_mode > 0 + freeincarnate_count = 0 + for item in ctx.items_received: + item_id_str = str(item.item) + if base_adventure_item_id < item.item <= standard_item_max: + items.append(convert_item_id(item.item)) + elif item_id_str in ctx.dragon_speed_info: + if item.item in dragon_speed_update: + last_index = len(ctx.dragon_speed_info[item_id_str]) - 1 + dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index] + else: + dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0] + elif item.item == item_table["Left Difficulty Switch"].id: + diff_a_locked = False + elif item.item == item_table["Right Difficulty Switch"].id: + diff_b_locked = False + elif item.item == item_table["Freeincarnate"].id: + freeincarnate_count = freeincarnate_count + 1 + freeincarnates_available = 0 + + if ctx.freeincarnates_used >= 0: + freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending) + ret = json.dumps( + { + "items": items, + "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() + if key[0] > current_time - 10}, + "deathlink": ctx.deathlink_pending, + "dragon_speeds": dragon_speed_update, + "difficulty_a_locked": diff_a_locked, + "difficulty_b_locked": diff_b_locked, + "freeincarnates_available": freeincarnates_available, + "bat_logic": ctx.bat_logic + } + ) + ctx.deathlink_pending = False + return ret + + +async def parse_locations(data: List, ctx: AdventureContext): + locations = data + + # for loc_name, loc_data in location_table.items(): + + # if flags["EventFlag"][280] & 1 and not ctx.finished_game: + # await ctx.send_msgs([ + # {"cmd": "StatusUpdate", + # "status": 30} + # ]) + # ctx.finished_game = True + if locations == ctx.locations_array: + return + ctx.locations_array = locations + if locations is not None: + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}]) + + +def send_ap_foreign_items(adventure_context): + foreign_item_json_list = [] + autocollect_item_json_list = [] + bat_no_touch_locations_json_list = [] + for fi in adventure_context.foreign_items: + foreign_item_json_list.append(fi.get_dict()) + for fi in adventure_context.autocollect_items: + autocollect_item_json_list.append(fi.get_dict()) + for ntl in adventure_context.bat_no_touch_locations: + bat_no_touch_locations_json_list.append(ntl.get_dict()) + payload = json.dumps( + { + "foreign_items": foreign_item_json_list, + "autocollect_items": autocollect_item_json_list, + "local_item_locations": adventure_context.local_item_locations, + "bat_no_touch_locations": bat_no_touch_locations_json_list + } + ) + print("sending foreign items") + msg = payload.encode() + (reader, writer) = adventure_context.atari_streams + writer.write(msg) + writer.write(b'\n') + + +def send_checked_locations_if_needed(adventure_context): + if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None: + if len(adventure_context.checked_locations) == 0: + return + checked_short_ids = [] + for location in adventure_context.checked_locations: + checked_short_ids.append(location - base_location_id) + print("Sending checked locations") + payload = json.dumps( + { + "checked_locations": checked_short_ids, + } + ) + msg = payload.encode() + (reader, writer) = adventure_context.atari_streams + writer.write(msg) + writer.write(b'\n') + adventure_context.checked_locations_sent = True + + +async def atari_sync_task(ctx: AdventureContext): + logger.info("Starting Atari 2600 connector. Use /2600 for status information") + while not ctx.exit_event.is_set(): + try: + error_status = None + if ctx.atari_streams: + (reader, writer) = ctx.atari_streams + msg = get_payload(ctx).encode() + writer.write(msg) + writer.write(b'\n') + try: + await asyncio.wait_for(writer.drain(), timeout=1.5) + try: + # Data will return a dict with 1+ fields + # 1. A keepalive response of the Players Name (always) + # 2. romhash field with sha256 hash of the ROM memory region + # 3. locations, messages, and deathLink + # 4. freeincarnate, to indicate a freeincarnate was used + data = await asyncio.wait_for(reader.readline(), timeout=5) + data_decoded = json.loads(data.decode()) + if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION: + msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \ + "Lua and AdventureClient are from the same Archipelago installation." + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data: + msg = "The server is running a different multiworld than your client is. " \ + "(invalid seed_name)" + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if 'romhash' in data_decoded: + if ctx.rom_hash.upper() != data_decoded['romhash'].upper(): + msg = "The rom hash does not match the client rom hash data" + print("got " + data_decoded['romhash']) + print("expected " + str(ctx.rom_hash)) + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if ctx.auth is None: + ctx.auth = ctx.player_name + if ctx.awaiting_rom: + await ctx.server_auth(False) + if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \ + and not error_status and ctx.auth: + # Not just a keep alive ping, parse + async_start(parse_locations(data_decoded['locations'], ctx)) + if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags: + dragon_name = "a dragon" + if data_decoded['deathLink'] == 1: + dragon_name = "Rhindle" + elif data_decoded['deathLink'] == 2: + dragon_name = "Yorgle" + elif data_decoded['deathLink'] == 3: + dragon_name = "Grundle" + print (ctx.auth + " has been eaten by " + dragon_name ) + await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name) + # TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by ' + if 'victory' in data_decoded and not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + if 'freeincarnate' in data_decoded: + await ctx.used_freeincarnate() + if ctx.set_deathlink: + await ctx.update_death_link(True) + send_checked_locations_if_needed(ctx) + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.atari_streams = None + except ConnectionResetError as e: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.atari_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + except CancelledError: + logger.debug("Connection Cancelled, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + pass + except Exception as e: + print("unknown exception " + e) + raise + if ctx.atari_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to 2600") + ctx.atari_status = CONNECTION_CONNECTED_STATUS + ctx.checked_locations_sent = False + send_ap_foreign_items(ctx) + send_checked_locations_if_needed(ctx) + else: + ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}" + elif error_status: + ctx.atari_status = error_status + logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates") + else: + try: + port = ctx.lua_connector_port + ctx.port_offset + logger.debug(f"Attempting to connect to 2600 on port {port}") + print(f"Attempting to connect to 2600 on port {port}") + ctx.atari_streams = await asyncio.wait_for( + asyncio.open_connection("localhost", + port), + timeout=10) + ctx.atari_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.atari_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.atari_status = CONNECTION_REFUSED_STATUS + continue + except CancelledError: + pass + except CancelledError: + pass + print("exiting atari sync task") + + +async def run_game(romfile): + auto_start = Utils.get_options()["adventure_options"].get("rom_start", True) + rom_args = Utils.get_options()["adventure_options"].get("rom_args") + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif os.path.isfile(auto_start): + open_args = [auto_start, romfile] + if rom_args is not None: + open_args.insert(1, rom_args) + subprocess.Popen(open_args, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +async def patch_and_run_game(patch_file, ctx): + base_name = os.path.splitext(patch_file)[0] + comp_path = base_name + '.a26' + try: + base_rom = AdventureDeltaPatch.get_source_data() + except Exception as msg: + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + + with open("data/adventure_basepatch.bsdiff4", "rb") as file: + basepatch = bytes(file.read()) + + base_patched_rom_data = bsdiff4.patch(base_rom, basepatch) + + with zipfile.ZipFile(patch_file, 'r') as patch_archive: + if not AdventureDeltaPatch.check_version(patch_archive): + logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same") + raise Exception("apadvn version doesn't match this client.") + + ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive) + ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive) + ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive) + ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive) + ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive) + ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive) + ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive) + ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive) + ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive) + ctx.auth = ctx.player_name + + patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas) + rom_hash = hashlib.sha256() + rom_hash.update(patched_rom_data) + ctx.rom_hash = rom_hash.hexdigest() + ctx.port_offset = patched_rom_data[connector_port_offset] + + with open(comp_path, "wb") as patched_rom_file: + patched_rom_file.write(patched_rom_data) + + async_start(run_game(comp_path)) + + +if __name__ == '__main__': + + Utils.init_logging("AdventureClient") + + async def main(): + parser = get_base_parser() + parser.add_argument('patch_file', default="", type=str, nargs="?", + help='Path to an ADVNTURE.BIN rom file') + parser.add_argument('port', default=17242, type=int, nargs="?", + help='port for adventure_connector connection') + args = parser.parse_args() + + ctx = AdventureContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync") + + if args.patch_file: + ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower() + if ext == "apadvn": + logger.info("apadvn file supplied, beginning patching process...") + async_start(patch_and_run_game(args.patch_file, ctx)) + else: + logger.warning(f"Unknown patch file extension {ext}") + if args.port is int: + ctx.lua_connector_port = args.port + + await ctx.exit_event.wait() + ctx.server_address = None + + await ctx.shutdown() + + if ctx.atari_sync_task: + await ctx.atari_sync_task + print("finished atari_sync_task (main)") + + + import colorama + + colorama.init() + + asyncio.run(main()) + colorama.deinit() diff --git a/README.md b/README.md index 2c49aba9..9454d0f1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Currently, the following games are supported: * Kingdom Hearts 2 * The Legend of Zelda: Link's Awakening DX * Clique +* Adventure For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/Utils.py b/Utils.py index 23acb9f1..e2ff1931 100644 --- a/Utils.py +++ b/Utils.py @@ -332,7 +332,13 @@ def get_default_options() -> OptionsType: }, "wargroove_options": { "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" - } + }, + "adventure_options": { + "rom_file": "ADVNTURE.BIN", + "display_msgs": True, + "rom_start": True, + "rom_args": "" + }, } return options diff --git a/data/adventure_basepatch.bsdiff4 b/data/adventure_basepatch.bsdiff4 new file mode 100644 index 0000000000000000000000000000000000000000..038dff1e6416d339c1f60f348a8419c6f0475e9c GIT binary patch literal 1059 zcmV+;1l;>VQ$$HdMl>*d000000000v0RR91000005C8xG0000&T4*^jL0KkKS@lBc z9smG7es+BCBLUF>00a;K2mn9;00aOa2mk-X5IfN8IjFhL4TCYl_Zra>c&fTu~w0Uo&c^`(Q@dlycj(w>sHs`J7MYI=DIn z!gYQ+MMOXH;Mi3BOT%1|80M)*PFG})eZZE;fWVT3q+%J(jVDq>NdS~U5JUw5KgHaU zP81|92riHSLRx4!F+o`-Q&}cw?KA)Y=>Px!|Ns8xU?BoQsF{FozGI1dAR0<$;~!*_ zdBEey^g!OQ0fm8rDUcIT(Vzk90qQg~2Afgp00Te(8Z-fs>Ne6(QRtd_Pc)&E(rKec zp|k|i=mg28h7%EuOaLYX#AIoLVHgt-VK4!q2vO-z)XfCOr>cI2iakToph4;Y0qO(V zpfu3L(@iwU8hS^H21U_EW5yH)WZ@a^F^ENd6Z?D6V(e^6B19nwoFH&cPH*|xjIz|U z*^G*<2>7EIj~&ZrG;{07h^jb(k&}fRz!eU-b=cB|Y}+tM5}(>16Ig^4P~xCJ0O)2< zKTH6IG3W;-p=1al-3kE(Bw|vVAy5$rTfDKG2%LYTMCBzow*k;k23lNN$SIvq=hqev z)TrQw^i*f$=Q!PgHFF?jQ8LGF5Yjs&OU)zf2|Ka(phhpkjAKd-u98MuiFjB4R|3-6 zaG)x;mmy52pB=V@Gv3OxSje0(Lxve_ieDFa2$eLzoc9seon;xwp&ffN1)1M6#|BN2 ztsW|n)~JnyGJQZJo>Z*2>2_Ef9!N&M3+id^yYkGG&~lJ)Vd>g*vAN(iYX}a8gaVYI z(6Qg4ZOP5Z6!qL6tE|M*5~d{cDzBvju`*j`5SD4IZ4Vqct0#b+VuDT_e4}m8glG?K z87d!qAipwXaV{d$$Ct1O24fQ>>A-MNEZ3|2saYW93Qo~@``reop$wxNZvL3z#elCC dnvYd7K-Mep1Rqc=8>|0|xgwk>NE0*mngDaC#S#Dj literal 0 HcmV?d00001 diff --git a/data/lua/ADVENTURE/adventure_connector.lua b/data/lua/ADVENTURE/adventure_connector.lua new file mode 100644 index 00000000..598d6d74 --- /dev/null +++ b/data/lua/ADVENTURE/adventure_connector.lua @@ -0,0 +1,851 @@ +local socket = require("socket") +local json = require('json') +local math = require('math') + +local STATE_OK = "Ok" +local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" +local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" +local STATE_UNINITIALIZED = "Uninitialized" + +local SCRIPT_VERSION = 1 + +local APItemValue = 0xA2 +local APItemRam = 0xE7 +local BatAPItemValue = 0xAB +local BatAPItemRam = 0xEA +local PlayerRoomAddr = 0x8A -- if in number room, we're not in play mode +local WinAddr = 0xDE -- if not 0 (I think if 0xff specifically), we won (and should update once, immediately) + +-- If any of these are 2, that dragon ate the player (should send update immediately +-- once, and reset that when none of them are 2 again) + +local DragonState = {0xA8, 0xAD, 0xB2} +local last_dragon_state = {0, 0, 0} +local carryAddress = 0x9D -- uses rom object table +local batRoomAddr = 0xCB +local batCarryAddress = 0xD0 -- uses ram object location +local batInvalidCarryItem = 0x78 +local batItemCheckAddr = 0xf69f +local batMatrixLen = 11 -- number of pairs +local last_carry_item = 0xB4 +local frames_with_no_item = 0 +local ItemTableStart = 0xfe9d +local PlayerSlotAddress = 0xfff9 + +local itemMessages = {} + +local nullObjectId = 0xB4 +local ItemsReceived = nil +local sha256hash = nil +local foreign_items = nil +local foreign_items_by_room = {} +local bat_no_touch_locations_by_room = {} +local bat_no_touch_items = {} +local autocollect_items = {} +local localItemLocations = {} + +local prev_bat_room = 0xff +local prev_player_room = 0 +local prev_ap_room_index = nil + +local pending_foreign_items_collected = {} +local pending_local_items_collected = {} +local rendering_foreign_item = nil +local skip_inventory_items = {} + +local inventory = {} +local next_inventory_item = nil + +local input_button_address = 0xD7 + +local deathlink_rec = nil +local deathlink_send = 0 + +local deathlink_sent = false + +local prevstate = "" +local curstate = STATE_UNINITIALIZED +local atariSocket = nil +local frame = 0 + +local ItemIndex = 0 + +local yorgle_speed_address = 0xf725 +local grundle_speed_address = 0xf740 +local rhindle_speed_address = 0xf70A + +local read_switch_a = 0xf780 +local read_switch_b = 0xf764 + +local yorgle_speed = nil +local grundle_speed = nil +local rhindle_speed = nil + +local slow_yorgle_id = tostring(118000000 + 0x103) +local slow_grundle_id = tostring(118000000 + 0x104) +local slow_rhindle_id = tostring(118000000 + 0x105) + +local yorgle_dead = false +local grundle_dead = false +local rhindle_dead = false + +local diff_a_locked = false +local diff_b_locked = false + +local bat_logic = 0 + +local is_dead = 0 +local freeincarnates_available = 0 +local send_freeincarnate_used = false +local current_bat_ap_item = nil + +local was_in_number_room = false + +local u8 = nil +local wU8 = nil +local u16 + +local bizhawk_version = client.getversion() +local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") +local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8") + +u8 = memory.read_u8 +wU8 = memory.write_u8 +u16 = memory.read_u16_le +function uRangeRam(address, bytes) + data = memory.read_bytes_as_array(address, bytes, "Main RAM") + return data +end +function uRangeRom(address, bytes) + data = memory.read_bytes_as_array(address+0xf000, bytes, "System Bus") + return data +end +function uRangeAddress(address, bytes) + data = memory.read_bytes_as_array(address, bytes, "System Bus") + return data +end + + +function table.empty (self) + for _, _ in pairs(self) do + return false + end + return true +end + +function slice (tbl, s, e) + local pos, new = 1, {} + for i = s + 1, e do + new[pos] = tbl[i] + pos = pos + 1 + end + return new +end + +local function createForeignItemsByRoom() + foreign_items_by_room = {} + if foreign_items == nil then + return + end + for _, foreign_item in pairs(foreign_items) do + if foreign_items_by_room[foreign_item.room_id] == nil then + foreign_items_by_room[foreign_item.room_id] = {} + end + new_foreign_item = {} + new_foreign_item.room_id = foreign_item.room_id + new_foreign_item.room_x = foreign_item.room_x + new_foreign_item.room_y = foreign_item.room_y + new_foreign_item.short_location_id = foreign_item.short_location_id + + table.insert(foreign_items_by_room[foreign_item.room_id], new_foreign_item) + end +end + +function debugPrintNoTouchLocations() + for room_id, list in pairs(bat_no_touch_locations_by_room) do + for index, notouch_location in ipairs(list) do + print("ROOM "..tostring(room_id).. "["..tostring(index).."]: "..tostring(notouch_location.short_location_id)) + end + end +end + +function processBlock(block) + if block == nil then + return + end + local block_identified = 0 + local msgBlock = block['messages'] + if msgBlock ~= nil then + block_identified = 1 + for i, v in pairs(msgBlock) do + if itemMessages[i] == nil then + local msg = {TTL=450, message=v, color=0xFFFF0000} + itemMessages[i] = msg + end + end + end + local itemsBlock = block["items"] + if itemsBlock ~= nil then + block_identified = 1 + ItemsReceived = itemsBlock + end + local apItemsBlock = block["foreign_items"] + if apItemsBlock ~= nil then + block_identified = 1 + print("got foreign items block") + foreign_items = apItemsBlock + createForeignItemsByRoom() + end + local autocollectItems = block["autocollect_items"] + if autocollectItems ~= nil then + block_identified = 1 + autocollect_items = {} + for _, acitem in pairs(autocollectItems) do + if autocollect_items[acitem.room_id] == nil then + autocollect_items[acitem.room_id] = {} + end + table.insert(autocollect_items[acitem.room_id], acitem) + end + end + local localLocalItemLocations = block["local_item_locations"] + if localLocalItemLocations ~= nil then + block_identified = 1 + localItemLocations = localLocalItemLocations + print("got local item locations") + end + local checkedLocationsBlock = block["checked_locations"] + if checkedLocationsBlock ~= nil then + block_identified = 1 + for room_id, foreign_item_list in pairs(foreign_items_by_room) do + for i, foreign_item in pairs(foreign_item_list) do + short_id = foreign_item.short_location_id + for j, checked_id in pairs(checkedLocationsBlock) do + if checked_id == short_id then + table.remove(foreign_item_list, i) + break + end + end + end + end + if foreign_items ~= nil then + for i, foreign_item in pairs(foreign_items) do + short_id = foreign_item.short_location_id + for j, checked_id in pairs(checkedLocationsBlock) do + if checked_id == short_id then + foreign_items[i] = nil + break + end + end + end + end + end + local dragon_speeds_block = block["dragon_speeds"] + if dragon_speeds_block ~= nil then + block_identified = 1 + yorgle_speed = dragon_speeds_block[slow_yorgle_id] + grundle_speed = dragon_speeds_block[slow_grundle_id] + rhindle_speed = dragon_speeds_block[slow_rhindle_id] + end + local diff_a_block = block["difficulty_a_locked"] + if diff_a_block ~= nil then + block_identified = 1 + diff_a_locked = diff_a_block + end + local diff_b_block = block["difficulty_b_locked"] + if diff_b_block ~= nil then + block_identified = 1 + diff_b_locked = diff_b_block + end + local freeincarnates_available_block = block["freeincarnates_available"] + if freeincarnates_available_block ~= nil then + block_identified = 1 + if freeincarnates_available ~= freeincarnates_available_block then + freeincarnates_available = freeincarnates_available_block + local msg = {TTL=450, message="freeincarnates: "..tostring(freeincarnates_available), color=0xFFFF0000} + itemMessages[-2] = msg + end + end + local bat_logic_block = block["bat_logic"] + if bat_logic_block ~= nil then + block_identified = 1 + bat_logic = bat_logic_block + end + local bat_no_touch_locations_block = block["bat_no_touch_locations"] + if bat_no_touch_locations_block ~= nil then + block_identified = 1 + for _, notouch_location in pairs(bat_no_touch_locations_block) do + local room_id = tonumber(notouch_location.room_id) + if bat_no_touch_locations_by_room[room_id] == nil then + bat_no_touch_locations_by_room[room_id] = {} + end + table.insert(bat_no_touch_locations_by_room[room_id], notouch_location) + + if notouch_location.local_item ~= nil and notouch_location.local_item ~= 255 then + bat_no_touch_items[tonumber(notouch_location.local_item)] = true + -- print("no touch: "..tostring(notouch_location.local_item)) + end + end + -- debugPrintNoTouchLocations() + end + deathlink_rec = deathlink_rec or block["deathlink"] + if( block_identified == 0 ) then + print("unidentified block") + print(block) + end +end + +local function clearScreen() + if is23Or24Or25 then + return + elseif is26To28 then + drawText(0, 0, "", "black") + end +end + +local function getMaxMessageLength() + if is23Or24Or25 then + return client.screenwidth()/11 + elseif is26To28 then + return client.screenwidth()/12 + end +end + +function drawText(x, y, message, color) + if is23Or24Or25 then + gui.addmessage(message) + elseif is26To28 then + gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client") + end +end + +local function drawMessages() + if table.empty(itemMessages) then + clearScreen() + return + end + local y = 10 + found = false + maxMessageLength = getMaxMessageLength() + for k, v in pairs(itemMessages) do + if v["TTL"] > 0 then + message = v["message"] + while true do + drawText(5, y, message:sub(1, maxMessageLength), v["color"]) + y = y + 16 + + message = message:sub(maxMessageLength + 1, message:len()) + if message:len() == 0 then + break + end + end + newTTL = 0 + if is26To28 then + newTTL = itemMessages[k]["TTL"] - 1 + end + itemMessages[k]["TTL"] = newTTL + found = true + end + end + if found == false then + clearScreen() + end +end + +function difference(a, b) + local aa = {} + for k,v in pairs(a) do aa[v]=true end + for k,v in pairs(b) do aa[v]=nil end + local ret = {} + local n = 0 + for k,v in pairs(a) do + if aa[v] then n=n+1 ret[n]=v end + end + return ret +end + +function getAllRam() + uRangeRAM(0,128); + return data +end + +local function arrayEqual(a1, a2) + if #a1 ~= #a2 then + return false + end + + for i, v in ipairs(a1) do + if v ~= a2[i] then + return false + end + end + + return true +end + +local function alive_mode() + return (u8(PlayerRoomAddr) ~= 0x00 and u8(WinAddr) == 0x00) +end + +local function generateLocationsChecked() + list_of_locations = {} + for s, f in pairs(pending_foreign_items_collected) do + table.insert(list_of_locations, f.short_location_id + 118000000) + end + for s, f in pairs(pending_local_items_collected) do + table.insert(list_of_locations, f + 118000000) + end + return list_of_locations +end + +function receive() + l, e = atariSocket:receive() + if e == 'closed' then + if curstate == STATE_OK then + print("Connection closed") + end + curstate = STATE_UNINITIALIZED + return + elseif e == 'timeout' then + return + elseif e ~= nil then + print(e) + curstate = STATE_UNINITIALIZED + return + end + if l ~= nil then + processBlock(json.decode(l)) + end + -- Determine Message to send back + + newSha256 = memory.hash_region(0xF000, 0x1000, "System Bus") + if (sha256hash ~= nil and sha256hash ~= newSha256) then + print("ROM changed, quitting") + curstate = STATE_UNINITIALIZED + return + end + sha256hash = newSha256 + local retTable = {} + retTable["scriptVersion"] = SCRIPT_VERSION + retTable["romhash"] = sha256hash + if (alive_mode()) then + retTable["locations"] = generateLocationsChecked() + end + if (u8(WinAddr) ~= 0x00) then + retTable["victory"] = 1 + end + if( deathlink_sent or deathlink_send == 0 ) then + retTable["deathLink"] = 0 + else + print("Sending deathlink "..tostring(deathlink_send)) + retTable["deathLink"] = deathlink_send + deathlink_sent = true + end + deathlink_send = 0 + + if send_freeincarnate_used == true then + print("Sending freeincarnate used") + retTable["freeincarnate"] = true + send_freeincarnate_used = false + end + + msg = json.encode(retTable).."\n" + local ret, error = atariSocket:send(msg) + if ret == nil then + print(error) + elseif curstate == STATE_INITIAL_CONNECTION_MADE then + curstate = STATE_TENTATIVELY_CONNECTED + elseif curstate == STATE_TENTATIVELY_CONNECTED then + print("Connected!") + curstate = STATE_OK + end +end + +function AutocollectFromRoom() + if autocollect_items ~= nil and autocollect_items[prev_player_room] ~= nil then + for _, item in pairs(autocollect_items[prev_player_room]) do + pending_foreign_items_collected[item.short_location_id] = item + end + end +end + +function SetYorgleSpeed() + if yorgle_speed ~= nil then + emu.setregister("A", yorgle_speed); + end +end + +function SetGrundleSpeed() + if grundle_speed ~= nil then + emu.setregister("A", grundle_speed); + end +end + +function SetRhindleSpeed() + if rhindle_speed ~= nil then + emu.setregister("A", rhindle_speed); + end +end + +function SetDifficultySwitchB() + if diff_b_locked then + local a = emu.getregister("A") + if a < 128 then + emu.setregister("A", a + 128) + end + end +end + +function SetDifficultySwitchA() + if diff_a_locked then + local a = emu.getregister("A") + if (a > 128 and a < 128 + 64) or (a < 64) then + emu.setregister("A", a + 64) + end + end +end + +function TryFreeincarnate() + if freeincarnates_available > 0 then + freeincarnates_available = freeincarnates_available - 1 + for index, state_addr in pairs(DragonState) do + if last_dragon_state[index] == 1 then + send_freeincarnate_used = true + memory.write_u8(state_addr, 1, "System Bus") + local msg = {TTL=450, message="used freeincarnate", color=0xFF00FF00} + itemMessages[-1] = msg + end + end + + end +end + +function GetLinkedObject() + if emu.getregister("X") == batRoomAddr then + bat_interest_item = emu.getregister("A") + -- if the bat can't touch that item, we'll switch it to the number item, which should never be + -- in the same room as the bat. + if bat_no_touch_items[bat_interest_item] ~= nil then + emu.setregister("A", 0xDD ) + emu.setregister("Y", 0xDD ) + end + end +end + +function CheckCollectAPItem(carry_item, target_item_value, target_item_ram, rendering_foreign_item) + if( carry_item == target_item_value and rendering_foreign_item ~= nil ) then + memory.write_u8(carryAddress, nullObjectId, "System Bus") + memory.write_u8(target_item_ram, 0xFF, "System Bus") + pending_foreign_items_collected[rendering_foreign_item.short_location_id] = rendering_foreign_item + for index, fi in pairs(foreign_items_by_room[rendering_foreign_item.room_id]) do + if( fi.short_location_id == rendering_foreign_item.short_location_id ) then + table.remove(foreign_items_by_room[rendering_foreign_item.room_id], index) + break + end + end + for index, fi in pairs(foreign_items) do + if( fi.short_location_id == rendering_foreign_item.short_location_id ) then + foreign_items[index] = nil + break + end + end + prev_ap_room_index = 0 + return true + end + return false +end + +function BatCanTouchForeign(foreign_item, bat_room) + if bat_no_touch_locations_by_room[bat_room] == nil or bat_no_touch_locations_by_room[bat_room][1] == nil then + return true + end + + for index, location in ipairs(bat_no_touch_locations_by_room[bat_room]) do + if location.short_location_id == foreign_item.short_location_id then + return false + end + end + return true; +end + +function main() + memory.usememorydomain("System Bus") + if (is23Or24Or25 or is26To28) == false then + print("Must use a version of bizhawk 2.3.1 or higher") + return + end + local playerSlot = memory.read_u8(PlayerSlotAddress) + local port = 17242 + playerSlot + print("Using port"..tostring(port)) + server, error = socket.bind('localhost', port) + if( error ~= nil ) then + print(error) + end + event.onmemoryexecute(SetYorgleSpeed, yorgle_speed_address); + event.onmemoryexecute(SetGrundleSpeed, grundle_speed_address); + event.onmemoryexecute(SetRhindleSpeed, rhindle_speed_address); + event.onmemoryexecute(SetDifficultySwitchA, read_switch_a) + event.onmemoryexecute(SetDifficultySwitchB, read_switch_b) + event.onmemoryexecute(GetLinkedObject, batItemCheckAddr) + -- TODO: Add an onmemoryexecute event to intercept the bat reading item rooms, and don't 'see' an item in the + -- room if it is in bat_no_touch_locations_by_room. Although realistically, I may have to handle this in the rom + -- for it to be totally reliable, because it won't work before the script connects (I might have to reset them?) + -- TODO: Also remove those items from the bat_no_touch_locations_by_room if they have been collected + while true do + frame = frame + 1 + drawMessages() + if not (curstate == prevstate) then + print("Current state: "..curstate) + prevstate = curstate + end + + local current_player_room = u8(PlayerRoomAddr) + local bat_room = u8(batRoomAddr) + local bat_carrying_item = u8(batCarryAddress) + local bat_carrying_ap_item = (BatAPItemRam == bat_carrying_item) + + if current_player_room == 0x1E then + if u8(PlayerRoomAddr + 1) > 0x4B then + memory.write_u8(PlayerRoomAddr + 1, 0x4B) + end + end + + if current_player_room == 0x00 then + if not was_in_number_room then + print("reset "..tostring(bat_carrying_ap_item).." "..tostring(bat_carrying_item)) + memory.write_u8(batCarryAddress, batInvalidCarryItem) + memory.write_u8(batCarryAddress+ 1, 0) + createForeignItemsByRoom() + memory.write_u8(BatAPItemRam, 0xff) + memory.write_u8(APItemRam, 0xff) + prev_ap_room_index = 0 + prev_player_room = 0 + rendering_foreign_item = nil + was_in_number_room = true + end + else + was_in_number_room = false + end + + if bat_room ~= prev_bat_room then + if bat_carrying_ap_item then + if foreign_items_by_room[prev_bat_room] ~= nil then + for r,f in pairs(foreign_items_by_room[prev_bat_room]) do + if f.short_location_id == current_bat_ap_item.short_location_id then + -- print("removing item from "..tostring(r).." in "..tostring(prev_bat_room)) + table.remove(foreign_items_by_room[prev_bat_room], r) + break + end + end + end + if foreign_items_by_room[bat_room] == nil then + foreign_items_by_room[bat_room] = {} + end + -- print("adding item to "..tostring(bat_room)) + table.insert(foreign_items_by_room[bat_room], current_bat_ap_item) + else + -- set AP item room and position for new room, or to invalid room + if foreign_items_by_room[bat_room] ~= nil and foreign_items_by_room[bat_room][1] ~= nil + and BatCanTouchForeign(foreign_items_by_room[bat_room][1], bat_room) then + if current_bat_ap_item ~= foreign_items_by_room[bat_room][1] then + current_bat_ap_item = foreign_items_by_room[bat_room][1] + -- print("Changing bat item to "..tostring(current_bat_ap_item.short_location_id)) + end + memory.write_u8(BatAPItemRam, bat_room) + memory.write_u8(BatAPItemRam + 1, current_bat_ap_item.room_x) + memory.write_u8(BatAPItemRam + 2, current_bat_ap_item.room_y) + else + memory.write_u8(BatAPItemRam, 0xff) + if current_bat_ap_item ~= nil then + -- print("clearing bat item") + end + current_bat_ap_item = nil + end + end + end + prev_bat_room = bat_room + + -- update foreign_items_by_room position and room id for bat item if bat carrying an item + if bat_carrying_ap_item then + -- this is setting the item using the bat's position, which is somewhat wrong, but I think + -- there will be more problems with the room not matching sometimes if I use the actual item position + current_bat_ap_item.room_id = bat_room + current_bat_ap_item.room_x = u8(batRoomAddr + 1) + current_bat_ap_item.room_y = u8(batRoomAddr + 2) + end + + if (alive_mode()) then + if (current_player_room ~= prev_player_room) then + memory.write_u8(APItemRam, 0xFF, "System Bus") + prev_ap_room_index = 0 + prev_player_room = current_player_room + AutocollectFromRoom() + end + local carry_item = memory.read_u8(carryAddress, "System Bus") + bat_no_touch_items[carry_item] = nil + if (next_inventory_item ~= nil) then + if ( carry_item == nullObjectId and last_carry_item == nullObjectId ) then + frames_with_no_item = frames_with_no_item + 1 + if (frames_with_no_item > 10) then + frames_with_no_item = 10 + local input_value = memory.read_u8(input_button_address, "System Bus") + if( input_value >= 64 and input_value < 128 ) then -- high bit clear, second highest bit set + memory.write_u8(carryAddress, next_inventory_item) + local item_ram_location = memory.read_u8(ItemTableStart + next_inventory_item) + if( memory.read_u8(batCarryAddress) ~= 0x78 and + memory.read_u8(batCarryAddress) == item_ram_location) then + memory.write_u8(batCarryAddress, batInvalidCarryItem) + memory.write_u8(batCarryAddress+ 1, 0) + memory.write_u8(item_ram_location, current_player_room) + memory.write_u8(item_ram_location + 1, memory.read_u8(PlayerRoomAddr + 1)) + memory.write_u8(item_ram_location + 2, memory.read_u8(PlayerRoomAddr + 2)) + end + ItemIndex = ItemIndex + 1 + next_inventory_item = nil + end + end + else + frames_with_no_item = 0 + end + end + if( carry_item ~= last_carry_item ) then + if ( localItemLocations ~= nil and localItemLocations[tostring(carry_item)] ~= nil ) then + pending_local_items_collected[localItemLocations[tostring(carry_item)]] = + localItemLocations[tostring(carry_item)] + table.remove(localItemLocations, tostring(carry_item)) + skip_inventory_items[carry_item] = carry_item + end + end + last_carry_item = carry_item + + CheckCollectAPItem(carry_item, APItemValue, APItemRam, rendering_foreign_item) + if CheckCollectAPItem(carry_item, BatAPItemValue, BatAPItemRam, current_bat_ap_item) and bat_carrying_ap_item then + memory.write_u8(batCarryAddress, batInvalidCarryItem) + memory.write_u8(batCarryAddress+ 1, 0) + end + + + rendering_foreign_item = nil + if( foreign_items_by_room[current_player_room] ~= nil ) then + if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil ) and memory.read_u8(APItemRam) ~= 0xff then + foreign_items_by_room[current_player_room][prev_ap_room_index].room_x = memory.read_u8(APItemRam + 1) + foreign_items_by_room[current_player_room][prev_ap_room_index].room_y = memory.read_u8(APItemRam + 2) + end + prev_ap_room_index = prev_ap_room_index + 1 + local invalid_index = -1 + if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then + prev_ap_room_index = 1 + end + if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and current_bat_ap_item ~= nil and + foreign_items_by_room[current_player_room][prev_ap_room_index].short_location_id == current_bat_ap_item.short_location_id) then + invalid_index = prev_ap_room_index + prev_ap_room_index = prev_ap_room_index + 1 + if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then + prev_ap_room_index = 1 + end + end + + if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and prev_ap_room_index ~= invalid_index ) then + memory.write_u8(APItemRam, current_player_room) + rendering_foreign_item = foreign_items_by_room[current_player_room][prev_ap_room_index] + memory.write_u8(APItemRam + 1, rendering_foreign_item.room_x) + memory.write_u8(APItemRam + 2, rendering_foreign_item.room_y) + else + memory.write_u8(APItemRam, 0xFF, "System Bus") + end + end + if is_dead == 0 then + dragons_revived = false + player_dead = false + new_dragon_state = {0,0,0} + for index, dragon_state_addr in pairs(DragonState) do + new_dragon_state[index] = memory.read_u8(dragon_state_addr, "System Bus" ) + if last_dragon_state[index] == 1 and new_dragon_state[index] ~= 1 then + dragons_revived = true + elseif last_dragon_state[index] ~= 1 and new_dragon_state[index] == 1 then + dragon_real_index = index - 1 + print("Killed dragon: "..tostring(dragon_real_index)) + local dragon_item = {} + dragon_item["short_location_id"] = 0xD0 + dragon_real_index + pending_foreign_items_collected[dragon_item.short_location_id] = dragon_item + end + if new_dragon_state[index] == 2 then + player_dead = true + end + end + if dragons_revived and player_dead == false then + TryFreeincarnate() + end + last_dragon_state = new_dragon_state + end + elseif (u8(PlayerRoomAddr) == 0x00) then -- not alive mode, in number room + ItemIndex = 0 -- reset our inventory + next_inventory_item = nil + skip_inventory_items = {} + end + if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then + if (frame % 5 == 0) then + receive() + if alive_mode() then + local was_dead = is_dead + is_dead = 0 + for index, dragonStateAddr in pairs(DragonState) do + local dragonstateval = memory.read_u8(dragonStateAddr, "System Bus") + if ( dragonstateval == 2) then + is_dead = index + end + end + if was_dead ~= 0 and is_dead == 0 then + TryFreeincarnate() + end + if deathlink_rec == true and is_dead == 0 then + print("setting dead from deathlink") + deathlink_rec = false + deathlink_sent = true + is_dead = 1 + memory.write_u8(carryAddress, nullObjectId, "System Bus") + memory.write_u8(DragonState[1], 2, "System Bus") + end + if (is_dead > 0 and deathlink_send == 0 and not deathlink_sent) then + deathlink_send = is_dead + print("setting deathlink_send to "..tostring(is_dead)) + elseif (is_dead == 0) then + deathlink_send = 0 + deathlink_sent = false + end + if ItemsReceived ~= nil and ItemsReceived[ItemIndex + 1] ~= nil then + while ItemsReceived[ItemIndex + 1] ~= nil and skip_inventory_items[ItemsReceived[ItemIndex + 1]] ~= nil do + print("skip") + ItemIndex = ItemIndex + 1 + end + local static_id = ItemsReceived[ItemIndex + 1] + if static_id ~= nil then + inventory[static_id] = 1 + if next_inventory_item == nil then + next_inventory_item = static_id + end + end + end + end + end + elseif (curstate == STATE_UNINITIALIZED) then + if (frame % 60 == 0) then + + print("Waiting for client.") + + emu.frameadvance() + server:settimeout(2) + print("Attempting to connect") + local client, timeout = server:accept() + if timeout == nil then + print("Initial connection made") + curstate = STATE_INITIAL_CONNECTION_MADE + atariSocket = client + atariSocket:settimeout(0) + end + end + end + emu.frameadvance() + end +end + +main() diff --git a/data/lua/ADVENTURE/json.lua b/data/lua/ADVENTURE/json.lua new file mode 100644 index 00000000..0833bf6f --- /dev/null +++ b/data/lua/ADVENTURE/json.lua @@ -0,0 +1,380 @@ +-- +-- json.lua +-- +-- Copyright (c) 2015 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +local json = { _version = "0.1.0" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\\\", + [ "\"" ] = "\\\"", + [ "\b" ] = "\\b", + [ "\f" ] = "\\f", + [ "\n" ] = "\\n", + [ "\r" ] = "\\r", + [ "\t" ] = "\\t", +} + +local escape_char_map_inv = { [ "\\/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return escape_char_map[c] or string.format("\\u%04x", c:byte()) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if val[1] ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + --local line_count = 1 + --local col_count = 1 + --for i = 1, idx - 1 do + -- col_count = col_count + 1 + -- if str:sub(i, i) == "\n" then + -- line_count = line_count + 1 + -- col_count = 1 + -- end + -- end + -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(3, 6), 16 ) + local n2 = tonumber( s:sub(9, 12), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local has_unicode_escape = false + local has_surrogate_escape = false + local has_escape = false + local last + for j = i + 1, #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + end + + if last == 92 then -- "\\" (escape char) + if x == 117 then -- "u" (unicode escape sequence) + local hex = str:sub(j + 1, j + 5) + if not hex:find("%x%x%x%x") then + decode_error(str, j, "invalid unicode escape in string") + end + if hex:find("^[dD][89aAbB]") then + has_surrogate_escape = true + else + has_unicode_escape = true + end + else + local c = string.char(x) + if not escape_chars[c] then + decode_error(str, j, "invalid escape char '" .. c .. "' in string") + end + has_escape = true + end + last = nil + + elseif x == 34 then -- '"' (end of string) + local s = str:sub(i + 1, j - 1) + if has_surrogate_escape then + s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) + end + if has_unicode_escape then + s = s:gsub("\\u....", parse_unicode_escape) + end + if has_escape then + s = s:gsub("\\.", escape_char_map_inv) + end + return s, j + 1 + + else + last = x + end + end + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + return ( parse(str, next_char(str, 1, space_chars, true)) ) +end + + +return json \ No newline at end of file diff --git a/data/lua/ADVENTURE/socket.lua b/data/lua/ADVENTURE/socket.lua new file mode 100644 index 00000000..a98e9521 --- /dev/null +++ b/data/lua/ADVENTURE/socket.lua @@ -0,0 +1,132 @@ +----------------------------------------------------------------------------- +-- LuaSocket helper module +-- Author: Diego Nehab +-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ +----------------------------------------------------------------------------- + +----------------------------------------------------------------------------- +-- Declare module and import dependencies +----------------------------------------------------------------------------- +local base = _G +local string = require("string") +local math = require("math") +local socket = require("socket.core") +module("socket") + +----------------------------------------------------------------------------- +-- Exported auxiliar functions +----------------------------------------------------------------------------- +function connect(address, port, laddress, lport) + local sock, err = socket.tcp() + if not sock then return nil, err end + if laddress then + local res, err = sock:bind(laddress, lport, -1) + if not res then return nil, err end + end + local res, err = sock:connect(address, port) + if not res then return nil, err end + return sock +end + +function bind(host, port, backlog) + local sock, err = socket.tcp() + if not sock then return nil, err end + sock:setoption("reuseaddr", true) + local res, err = sock:bind(host, port) + if not res then return nil, err end + res, err = sock:listen(backlog) + if not res then return nil, err end + return sock +end + +try = newtry() + +function choose(table) + return function(name, opt1, opt2) + if base.type(name) ~= "string" then + name, opt1, opt2 = "default", name, opt1 + end + local f = table[name or "nil"] + if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) + else return f(opt1, opt2) end + end +end + +----------------------------------------------------------------------------- +-- Socket sources and sinks, conforming to LTN12 +----------------------------------------------------------------------------- +-- create namespaces inside LuaSocket namespace +sourcet = {} +sinkt = {} + +BLOCKSIZE = 2048 + +sinkt["close-when-done"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if not chunk then + sock:close() + return 1 + else return sock:send(chunk) end + end + }) +end + +sinkt["keep-open"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if chunk then return sock:send(chunk) + else return 1 end + end + }) +end + +sinkt["default"] = sinkt["keep-open"] + +sink = choose(sinkt) + +sourcet["by-length"] = function(sock, length) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if length <= 0 then return nil end + local size = math.min(socket.BLOCKSIZE, length) + local chunk, err = sock:receive(size) + if err then return nil, err end + length = length - string.len(chunk) + return chunk + end + }) +end + +sourcet["until-closed"] = function(sock) + local done + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if done then return nil end + local chunk, err, partial = sock:receive(socket.BLOCKSIZE) + if not err then return chunk + elseif err == "closed" then + sock:close() + done = 1 + return partial + else return nil, err end + end + }) +end + + +sourcet["default"] = sourcet["until-closed"] + +source = choose(sourcet) diff --git a/host.yaml b/host.yaml index 83266271..4a86d054 100644 --- a/host.yaml +++ b/host.yaml @@ -168,3 +168,22 @@ zillion_options: # You have to know the path to the emulator core library on the user's computer. rom_start: "retroarch" +adventure_options: + # File name of the standard NTSC Adventure rom. + # The licensed "The 80 Classic Games" CD-ROM contains this. + # It may also have a .a26 extension + rom_file: "ADVNTURE.BIN" + # Set this to false to never autostart a rom (such as after patching) + # True for operating system default program for '.a26' + # Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld) + rom_start: true + # Optional, additional args passed into rom_start before the .bin file + # For example, this can be used to autoload the connector script in BizHawk + # (see BizHawk --lua= option) + rom_args: " " + # Set this to true to display item received messages in Emuhawk + display_msgs: true + + + + diff --git a/inno_setup.iss b/inno_setup.iss index 4a4c8927..57e48b38 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -87,6 +87,7 @@ Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing Name: "client/wargroove"; Description: "Wargroove"; Types: full playing Name: "client/zl"; Description: "Zillion"; Types: full playing Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing +Name: "client/advn"; Description: "Adventure"; Types: full playing Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing [Dirs] @@ -105,6 +106,7 @@ Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz +Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp @@ -128,6 +130,7 @@ Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flag Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2 +Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall [Icons] @@ -145,6 +148,7 @@ Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoCh Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2 Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2 +Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server @@ -160,6 +164,7 @@ Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\Archip Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2 +Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn [Run] @@ -247,6 +252,11 @@ Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; Components: client/tloz Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/tloz +Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/advn +Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/advn +Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; Components: client/advn +Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/advn + Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server @@ -320,6 +330,9 @@ var LADXROMFilePage: TInputFileWizardPage; var tlozrom: string; var TLoZROMFilePage: TInputFileWizardPage; +var advnrom: string; +var AdvnROMFilePage: TInputFileWizardPage; + function GetSNESMD5OfFile(const rom: string): string; var data: AnsiString; begin @@ -490,6 +503,21 @@ begin '.z64'); end; +function AddA26Page(name: string): TInputFileWizardPage; +begin + Result := + CreateInputFilePage( + wpSelectComponents, + 'Select ROM File', + 'Where is your ' + name + ' located?', + 'Select the file, then click Next.'); + + Result.Add( + 'Location of ROM file:', + 'A2600 ROM files|*.BIN;*.a26|All files|*.*', + '.BIN'); +end; + function NextButtonClick(CurPageID: Integer): Boolean; begin if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then @@ -516,6 +544,8 @@ begin Result := not (LADXROMFilePage.Values[0] = '') else if (assigned(TLoZROMFilePage)) and (CurPageID = TLoZROMFilePage.ID) then Result := not (TLoZROMFilePage.Values[0] = '') + else if (assigned(AdvnROMFilePage)) and (CurPageID = AdvnROMFilePage.ID) then + Result := not (AdvnROMFilePage.Values[0] = '') else Result := True; end; @@ -712,6 +742,22 @@ begin Result := ''; end; +function GetAdvnROMPath(Param: string): string; +begin + if Length(advnrom) > 0 then + Result := advnrom + else if Assigned(AdvnROMFilePage) then + begin + R := CompareStr(GetMD5OfFile(AdvnROMFilePage.Values[0]), '157bddb7192754a45372be196797f284'); + if R <> 0 then + MsgBox('Adventure ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); + + Result := AdvnROMFilePage.Values[0] + end + else + Result := ''; +end; + procedure InitializeWizard(); begin AddOoTRomPage(); @@ -755,10 +801,14 @@ begin l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d'); if Length(l2acrom) = 0 then L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc'); - + tlozrom := CheckNESROM('Legend of Zelda, The (U) (PRG0) [!].nes', '337bd6f1a1163df31bf2633665589ab0'); if Length(tlozrom) = 0 then TLoZROMFilePage:= AddNESRomPage('Legend of Zelda, The (U) (PRG0) [!].nes'); + + advnrom := CheckSMSRom('ADVNTURE.BIN', '157bddb7192754a45372be196797f284'); + if Length(advnrom) = 0 then + AdvnROMFilePage:= AddA26Page('ADVNTURE.BIN'); end; @@ -789,4 +839,6 @@ begin Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx')); if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then Result := not (WizardIsComponentSelected('generator/tloz') or WizardIsComponentSelected('client/tloz')); + if (assigned(AdvnROMFilePage)) and (PageID = AdvnROMFilePage.ID) then + Result := not (WizardIsComponentSelected('client/advn')); end; diff --git a/worlds/adventure/Items.py b/worlds/adventure/Items.py new file mode 100644 index 00000000..76d7d6fd --- /dev/null +++ b/worlds/adventure/Items.py @@ -0,0 +1,53 @@ +from typing import Optional +from BaseClasses import ItemClassification, Item + +base_adventure_item_id = 118000000 + + +class AdventureItem(Item): + def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int): + super().__init__(name, classification, code, player) + + +class ItemData: + def __init__(self, id: int, classification: ItemClassification): + self.classification = classification + self.id = None if id is None else id + base_adventure_item_id + self.table_index = id + + +nothing_item_id = base_adventure_item_id + +# base IDs are the index in the static item data table, which is +# not the same order as the items in RAM (but offset 0 is a 16-bit address of +# location of room and position data) +item_table = { + "Yellow Key": ItemData(0xB, ItemClassification.progression_skip_balancing), + "White Key": ItemData(0xC, ItemClassification.progression), + "Black Key": ItemData(0xD, ItemClassification.progression), + "Bridge": ItemData(0xA, ItemClassification.progression), + "Magnet": ItemData(0x11, ItemClassification.progression), + "Sword": ItemData(0x9, ItemClassification.progression), + "Chalice": ItemData(0x10, ItemClassification.progression_skip_balancing), + # Non-ROM Adventure items, managed by lua + "Left Difficulty Switch": ItemData(0x100, ItemClassification.filler), + "Right Difficulty Switch": ItemData(0x101, ItemClassification.filler), + # Can use these instead of 'nothing' + "Freeincarnate": ItemData(0x102, ItemClassification.filler), + # These should only be enabled if fast dragons is on? + "Slow Yorgle": ItemData(0x103, ItemClassification.filler), + "Slow Grundle": ItemData(0x104, ItemClassification.filler), + "Slow Rhindle": ItemData(0x105, ItemClassification.filler), + # this should only be enabled if opted into? For now, I'll just exclude them + "Revive Dragons": ItemData(0x106, ItemClassification.trap), + "nothing": ItemData(0x0, ItemClassification.filler) + # Bat Trap + # Bat Time Out + # "Revive Dragons": ItemData(0x110, ItemClassification.trap) +} + +standard_item_max = item_table["Magnet"].id + + +event_table = { +} \ No newline at end of file diff --git a/worlds/adventure/Locations.py b/worlds/adventure/Locations.py new file mode 100644 index 00000000..2ef561b1 --- /dev/null +++ b/worlds/adventure/Locations.py @@ -0,0 +1,214 @@ +from BaseClasses import Location + +base_location_id = 118000000 + + +class AdventureLocation(Location): + game: str = "Adventure" + + +class WorldPosition: + room_id: int + room_x: int + room_y: int + + def __init__(self, room_id: int, room_x: int = None, room_y: int = None): + self.room_id = room_id + self.room_x = room_x + self.room_y = room_y + + def get_position(self, random): + if self.room_x is None or self.room_y is None: + return random.choice(standard_positions) + else: + return self.room_x, self.room_y + + +class LocationData: + def __init__(self, region, name, location_id, world_positions: [WorldPosition] = None, event=False, + needs_bat_logic: bool = False): + self.region: str = region + self.name: str = name + self.world_positions: [WorldPosition] = world_positions + self.room_id: int = None + self.room_x: int = None + self.room_y: int = None + self.location_id: int = location_id + if location_id is None: + self.short_location_id: int = None + self.location_id: int = None + else: + self.short_location_id: int = location_id + self.location_id: int = location_id + base_location_id + self.event: bool = event + if world_positions is None and not event: + self.room_id: int = self.short_location_id + self.needs_bat_logic: int = needs_bat_logic + self.local_item: int = None + + def get_position(self, random): + if self.world_positions is None or len(self.world_positions) == 0: + if self.room_id is None: + return None + self.room_x, self.room_y = random.choice(standard_positions) + if self.room_id is None: + selected_pos = random.choice(self.world_positions) + self.room_id = selected_pos.room_id + self.room_x, self.room_y = selected_pos.get_position(random) + return self.room_x, self.room_y + + def get_room_id(self, random): + if self.world_positions is None or len(self.world_positions) == 0: + return None + if self.room_id is None: + selected_pos = random.choice(self.world_positions) + self.room_id = selected_pos.room_id + self.room_x, self.room_y = selected_pos.get_position(random) + return self.room_id + + +standard_positions = [ + (0x80, 0x20), + (0x20, 0x20), + (0x20, 0x40), + (0x20, 0x40), + (0x30, 0x20) +] + + +# Gives the most difficult region the dragon can reach and get stuck in from the provided room without the +# player unlocking something for it +def dragon_room_to_region(room: int) -> str: + if room <= 0x11: + return "Overworld" + elif room <= 0x12: + return "YellowCastle" + elif room <= 0x16 or room == 0x1B: + return "BlackCastle" + elif room <= 0x1A: + return "WhiteCastleVault" + elif room <= 0x1D: + return "Overworld" + elif room <= 0x1E: + return "CreditsRoom" + + +def get_random_room_in_regions(regions: [str], random) -> int: + possible_rooms = {} + for locname in location_table: + if location_table[locname].region in regions: + room = location_table[locname].get_room_id(random) + if room is not None: + possible_rooms[room] = location_table[locname].room_id + return random.choice(list(possible_rooms.keys())) + + +location_table = { + "Blue Labyrinth 0": LocationData("Overworld", "Blue Labyrinth 0", 0x4, + [WorldPosition(0x4, 0x83, 0x47), # exit upper right + WorldPosition(0x4, 0x12, 0x47), # exit upper left + WorldPosition(0x4, 0x65, 0x20), # exit bottom right + WorldPosition(0x4, 0x2A, 0x20), # exit bottom left + WorldPosition(0x5, 0x4B, 0x60), # T room, top + WorldPosition(0x5, 0x28, 0x1F), # T room, bottom left + WorldPosition(0x5, 0x70, 0x1F), # T room, bottom right + ]), + "Blue Labyrinth 1": LocationData("Overworld", "Blue Labyrinth 1", 0x6, + [WorldPosition(0x6, 0x8C, 0x20), # final turn bottom right + WorldPosition(0x6, 0x03, 0x20), # final turn bottom left + WorldPosition(0x6, 0x4B, 0x30), # final turn center + WorldPosition(0x7, 0x4B, 0x40), # straightaway center + WorldPosition(0x8, 0x40, 0x40), # entrance middle loop + WorldPosition(0x8, 0x4B, 0x60), # entrance upper loop + WorldPosition(0x8, 0x8C, 0x5E), # entrance right loop + ]), + "Catacombs": LocationData("Overworld", "Catacombs", 0x9, + [WorldPosition(0x9, 0x49, 0x40), + WorldPosition(0x9, 0x4b, 0x20), + WorldPosition(0xA), + WorldPosition(0xA), + WorldPosition(0xB, 0x40, 0x40), + WorldPosition(0xB, 0x22, 0x1f), + WorldPosition(0xB, 0x70, 0x1f)]), + "Adjacent to Catacombs": LocationData("Overworld", "Adjacent to Catacombs", 0xC, + [WorldPosition(0xC), + WorldPosition(0xD)]), + "Southwest of Catacombs": LocationData("Overworld", "Southwest of Catacombs", 0xE), + "White Castle Gate": LocationData("Overworld", "White Castle Gate", 0xF), + "Black Castle Gate": LocationData("Overworld", "Black Castle Gate", 0x10), + "Yellow Castle Gate": LocationData("Overworld", "Yellow Castle Gate", 0x11), + "Inside Yellow Castle": LocationData("YellowCastle", "Inside Yellow Castle", 0x12), + "Dungeon0": LocationData("BlackCastle", "Dungeon0", 0x13, + [WorldPosition(0x13), + WorldPosition(0x14)]), + "Dungeon Vault": LocationData("BlackCastleVault", "Dungeon Vault", 0xB5, + [WorldPosition(0x15, 0x46, 0x1B)], + needs_bat_logic=True), + "Dungeon1": LocationData("BlackCastle", "Dungeon1", 0x15, + [WorldPosition(0x15), + WorldPosition(0x16)]), + "RedMaze0": LocationData("WhiteCastle", "RedMaze0", 0x17, + [WorldPosition(0x17, 0x70, 0x40), # right side third room + WorldPosition(0x17, 0x18, 0x40), # left side third room + WorldPosition(0x18, 0x20, 0x40), + WorldPosition(0x18, 0x1A, 0x3F), # left side second room + WorldPosition(0x18, 0x70, 0x3F), # right side second room + ]), + "Red Maze Vault Entrance": LocationData("WhiteCastlePreVaultPeek", "Red Maze Vault Entrance", 0xB7, + [WorldPosition(0x17, 0x50, 0x60)], + needs_bat_logic=True), + "Red Maze Vault": LocationData("WhiteCastleVault", "Red Maze Vault", 0x19, + [WorldPosition(0x19, 0x4E, 0x35)], + needs_bat_logic=True), + "RedMaze1": LocationData("WhiteCastle", "RedMaze1", 0x1A), # entrance + "Black Castle Foyer": LocationData("BlackCastle", "Black Castle Foyer", 0x1B), + "Northeast of Catacombs": LocationData("Overworld", "Northeast of Catacombs", 0x1C), + "Southeast of Catacombs": LocationData("Overworld", "Southeast of Catacombs", 0x1D), + "Credits Left Side": LocationData("CreditsRoom", "Credits Left Side", 0x1E, + [WorldPosition(0x1E, 0x25, 0x50)]), + "Credits Right Side": LocationData("CreditsRoomFarSide", "Credits Right Side", 0xBE, + [WorldPosition(0x1E, 0x70, 0x40)], + needs_bat_logic=True), + "Chalice Home": LocationData("YellowCastle", "Chalice Home", None, event=True), + "Slay Yorgle": LocationData("Varies", "Slay Yorgle", 0xD1, event=False), + "Slay Grundle": LocationData("Varies", "Slay Grundle", 0xD2, event=False), + "Slay Rhindle": LocationData("Varies", "Slay Rhindle", 0xD0, event=False), +} + +# the old location table, for reference +location_table_old = { + "Blue Labyrinth 0": LocationData("Overworld", "Blue Labyrinth 0", 0x4), + "Blue Labyrinth 1": LocationData("Overworld", "Blue Labyrinth 1", 0x5), + "Blue Labyrinth 2": LocationData("Overworld", "Blue Labyrinth 2", 0x6), + "Blue Labyrinth 3": LocationData("Overworld", "Blue Labyrinth 3", 0x7), + "Blue Labyrinth 4": LocationData("Overworld", "Blue Labyrinth 4", 0x8), + "Catacombs0": LocationData("Overworld", "Catacombs0", 0x9), + "Catacombs1": LocationData("Overworld", "Catacombs1", 0xA), + "Catacombs2": LocationData("Overworld", "Catacombs2", 0xB), + "East of Catacombs": LocationData("Overworld", "East of Catacombs", 0xC), + "West of Catacombs": LocationData("Overworld", "West of Catacombs", 0xD), + "Southwest of Catacombs": LocationData("Overworld", "Southwest of Catacombs", 0xE), + "White Castle Gate": LocationData("Overworld", "White Castle Gate", 0xF), + "Black Castle Gate": LocationData("Overworld", "Black Castle Gate", 0x10), + "Yellow Castle Gate": LocationData("Overworld", "Yellow Castle Gate", 0x11), + "Inside Yellow Castle": LocationData("YellowCastle", "Inside Yellow Castle", 0x12), + "Dungeon0": LocationData("BlackCastle", "Dungeon0", 0x13), + "Dungeon1": LocationData("BlackCastle", "Dungeon1", 0x14), + "Dungeon Vault": LocationData("BlackCastleVault", "Dungeon Vault", 0x15, + [WorldPosition(0xB5, 0x46, 0x1B)]), + "Dungeon2": LocationData("BlackCastle", "Dungeon2", 0x15), + "Dungeon3": LocationData("BlackCastle", "Dungeon3", 0x16), + "RedMaze0": LocationData("WhiteCastle", "RedMaze0", 0x17, [WorldPosition(0x17, 0x70, 0x40)]), + "RedMaze1": LocationData("WhiteCastle", "RedMaze1", 0x18, [WorldPosition(0x18, 0x20, 0x40)]), + "Red Maze Vault Entrance": LocationData("WhiteCastlePreVaultPeek", "Red Maze Vault Entrance", + 0x17, [WorldPosition(0xB7, 0x50, 0x60)]), + "Red Maze Vault": LocationData("WhiteCastleVault", "Red Maze Vault", 0x19, [WorldPosition(0x19, 0x4E, 0x35)]), + "RedMaze3": LocationData("WhiteCastle", "RedMaze3", 0x1A), + "Black Castle Foyer": LocationData("BlackCastle", "Black Castle Foyer", 0x1B), + "Northeast of Catacombs": LocationData("Overworld", "Northeast of Catacombs", 0x1C), + "Southeast of Catacombs": LocationData("Overworld", "Southeast of Catacombs", 0x1D), + "Credits Left Side": LocationData("CreditsRoom", "Credits Left Side", 0x1E, [WorldPosition(0x1E, 0x25, 0x50)]), + "Credits Right Side": LocationData("CreditsRoomFarSide", "Credits Right Side", 0x1E, + [WorldPosition(0xBE, 0x70, 0x40)]), + "Chalice Home": LocationData("YellowCastle", "Chalice Home", None, event=True) +} diff --git a/worlds/adventure/Offsets.py b/worlds/adventure/Offsets.py new file mode 100644 index 00000000..c1e74aee --- /dev/null +++ b/worlds/adventure/Offsets.py @@ -0,0 +1,46 @@ +# probably I should generate this from the list file + +static_item_data_location = 0xe9d +static_item_element_size = 9 +static_first_dragon_index = 6 +item_position_table = 0x402 +items_ram_start = 0xa1 +connector_port_offset = 0xff9 +# dragon speeds are hardcoded directly in their respective movement subroutines, not in their item table or state data +# so this is the second byte of an LDA immediate instruction +yorgle_speed_data_location = 0x724 +grundle_speed_data_location = 0x73f +rhindle_speed_data_location = 0x709 + + +# in case I need to place a rom address in the rom +rom_address_space_start = 0xf000 + +start_castle_offset = 0x39c +start_castle_values = [0x11, 0x10, 0x0F] +"""yellow, black, white castle gate rooms""" + +# indexed by static item table index. 0x00 indicates the position data is in ROM and is irrelevant to the randomizer +item_ram_addresses = [ + 0xD9, # lamp + 0x00, # portcullis 1 + 0x00, # portcullis 2 + 0x00, # portcullis 3 + 0x00, # author name + 0x00, # GO object + 0xA4, # Rhindle + 0xA9, # Yorgle + 0xAE, # Grundle + 0xB6, # Sword + 0xBC, # Bridge + 0xBF, # Yellow Key + 0xC2, # White key + 0xC5, # Black key + 0xCB, # Bat + 0xA1, # Dot + 0xB9, # Chalice + 0xB3, # Magnet + 0xE7, # AP object 1 + 0xEA, # AP bat object + 0xBC, # NULL object (end of table) +] diff --git a/worlds/adventure/Options.py b/worlds/adventure/Options.py new file mode 100644 index 00000000..a8016fc2 --- /dev/null +++ b/worlds/adventure/Options.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from typing import Dict + +from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle + + +class FreeincarnateMax(Range): + """How many maximum freeincarnate items to allow + + When done generating items, any remaining item slots will be filled + with freeincarnates, up to this maximum amount. Any remaining item + slots after that will be 'nothing' items placed locally, so in multigame + multiworlds, keeping this value high will allow more items from other games + into Adventure. + """ + display_name = "Freeincarnate Maximum" + range_start = 0 + range_end = 17 + default = 17 + + +class ItemRandoType(Choice): + """Choose how items are placed in the game + + Not yet implemented. Currently only traditional supported + Traditional: Adventure items are not in the map until + they are collected (except local items) and are dropped + on the player when collected. Adventure items are not checks. + Inactive: Every item is placed, but is inactive until collected. + Each item touched is a check. The bat ignores inactive items. + + Supported values: traditional, inactive + Default value: traditional + """ + + display_name = "Item type" + option_traditional = 0x00 + option_inactive = 0x01 + default = option_traditional + + +class DragonSlayCheck(DefaultOnToggle): + """If true, slaying each dragon for the first time is a check + """ + display_name = "Slay Dragon Checks" + + +class TrapBatCheck(Choice): + """ + Locking the bat inside a castle may be a check + + Not yet implemented + If set to yes, the bat will not start inside a castle. + Setting with_key requires the matching castle key to also be + in the castle with the bat, achieved by dropping the key in the + path of the portcullis as it falls. This setting is not recommended with the bat use_logic setting + + Supported values: no, yes, with_key + Default value: yes + """ + display_name = "Trap bat check" + option_no_check = 0x0 + option_yes_key_optional = 0x1 + option_with_key = 0x2 + default = option_yes_key_optional + + +class DragonRandoType(Choice): + """ + How to randomize the dragon starting locations + + normal: Grundle is in the overworld, Yorgle in the white castle, and Rhindle in the black castle + shuffle: A random dragon is placed in the overworld, one in the white castle, and one in the black castle + overworldplus: Dragons can be placed anywhere, but at least one will be in the overworld + randomized: Dragons can be anywhere except the credits room + + + Supported values: normal, shuffle, overworldplus, randomized + Default value: shuffle + """ + display_name = "Dragon Randomization" + option_normal = 0x0 + option_shuffle = 0x1 + option_overworldplus = 0x2 + option_randomized = 0x3 + default = option_shuffle + + +class BatLogic(Choice): + """How the bat is considered for logic + + With cannot_break, the bat cannot pick up an item that starts out-of-logic until the player touches it + With can_break, the bat is free to pick up any items, even if they are out-of-logic + With use_logic, the bat can pick up anything just like can_break, and locations are no longer considered to require + the magnet or bridge to collect, since the bat can retrieve these. + A future option may allow the bat itself to be placed as an item. + + Supported values: cannot_break, can_break, use_logic + Default value: can_break + """ + display_name = "Bat Logic" + option_cannot_break = 0x0 + option_can_break = 0x1 + option_use_logic = 0x2 + default = option_can_break + + +class YorgleStartingSpeed(Range): + """ + Sets Yorgle's initial speed. Yorgle has a speed of 2 in the original game + Default value: 2 + """ + display_name = "Yorgle MaxSpeed" + range_start = 1 + range_end = 9 + default = 2 + + +class YorgleMinimumSpeed(Range): + """ + Sets Yorgle's speed when all speed reducers are found. Yorgle has a speed of 2 in the original game + Default value: 2 + """ + display_name = "Yorgle Min Speed" + range_start = 1 + range_end = 9 + default = 1 + + +class GrundleStartingSpeed(Range): + """ + Sets Grundle's initial speed. Grundle has a speed of 2 in the original game + Default value: 2 + """ + display_name = "Grundle MaxSpeed" + range_start = 1 + range_end = 9 + default = 2 + + +class GrundleMinimumSpeed(Range): + """ + Sets Grundle's speed when all speed reducers are found. Grundle has a speed of 2 in the original game + Default value: 2 + """ + display_name = "Grundle Min Speed" + range_start = 1 + range_end = 9 + default = 1 + + +class RhindleStartingSpeed(Range): + """ + Sets Rhindle's initial speed. Rhindle has a speed of 3 in the original game + Default value: 3 + """ + display_name = "Rhindle MaxSpeed" + range_start = 1 + range_end = 9 + default = 3 + + +class RhindleMinimumSpeed(Range): + """ + Sets Rhindle's speed when all speed reducers are found. Rhindle has a speed of 3 in the original game + Default value: 2 + """ + display_name = "Rhindle Min Speed" + range_start = 1 + range_end = 9 + default = 2 + + +class ConnectorMultiSlot(Toggle): + """If true, the client and lua connector will add lowest 8 bits of the player slot + to the port number used to connect to each other, to simplify connecting multiple local + clients to local BizHawks. + Set in the yaml, since the connector has to read this out of the rom file before connecting. + """ + display_name = "Connector Multi-Slot" + + +class DifficultySwitchA(Choice): + """Set availability of left difficulty switch + This controls the speed of the dragons' bite animation + + """ + display_name = "Left Difficulty Switch" + option_normal = 0x0 + option_locked_hard = 0x1 + option_hard_with_unlock_item = 0x2 + default = option_hard_with_unlock_item + + +class DifficultySwitchB(Choice): + """Set availability of right difficulty switch + On hard, dragons will run away from the sword + + """ + display_name = "Right Difficulty Switch" + option_normal = 0x0 + option_locked_hard = 0x1 + option_hard_with_unlock_item = 0x2 + default = option_hard_with_unlock_item + + +class StartCastle(Choice): + """Choose or randomize which castle to start in front of. + + This affects both normal start and reincarnation. Starting + at the black castle may give easy dot runs, while starting + at the white castle may make them more dangerous! Also, not + starting at the yellow castle can make delivering the chalice + with a full inventory slightly less trivial. + + This doesn't affect logic since all the castles are reachable + from each other. + """ + display_name = "Start Castle" + option_yellow = 0 + option_black = 1 + option_white = 2 + default = option_yellow + + +adventure_option_definitions: Dict[str, type(Option)] = { + "dragon_slay_check": DragonSlayCheck, + "death_link": DeathLink, + "bat_logic": BatLogic, + "freeincarnate_max": FreeincarnateMax, + "dragon_rando_type": DragonRandoType, + "connector_multi_slot": ConnectorMultiSlot, + "yorgle_speed": YorgleStartingSpeed, + "yorgle_min_speed": YorgleMinimumSpeed, + "grundle_speed": GrundleStartingSpeed, + "grundle_min_speed": GrundleMinimumSpeed, + "rhindle_speed": RhindleStartingSpeed, + "rhindle_min_speed": RhindleMinimumSpeed, + "difficulty_switch_a": DifficultySwitchA, + "difficulty_switch_b": DifficultySwitchB, + "start_castle": StartCastle, + +} \ No newline at end of file diff --git a/worlds/adventure/Regions.py b/worlds/adventure/Regions.py new file mode 100644 index 00000000..4a62518f --- /dev/null +++ b/worlds/adventure/Regions.py @@ -0,0 +1,160 @@ +from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType +from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region + + +def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True, + one_way=False, name=None): + source_region = world.get_region(source, player) + target_region = world.get_region(target, player) + + if name is None: + name = source + " to " + target + + connection = Entrance( + player, + name, + source_region + ) + + connection.access_rule = rule + + source_region.exits.append(connection) + connection.connect(target_region) + if not one_way: + connect(world, player, target, source, rule, True) + + +def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None: + for name, locdata in location_table.items(): + locdata.get_position(multiworld.random) + + menu = Region("Menu", player, multiworld) + + menu.exits.append(Entrance(player, "GameStart", menu)) + multiworld.regions.append(menu) + + overworld = Region("Overworld", player, multiworld) + overworld.exits.append(Entrance(player, "YellowCastlePort", overworld)) + overworld.exits.append(Entrance(player, "WhiteCastlePort", overworld)) + overworld.exits.append(Entrance(player, "BlackCastlePort", overworld)) + overworld.exits.append(Entrance(player, "CreditsWall", overworld)) + multiworld.regions.append(overworld) + + yellow_castle = Region("YellowCastle", player, multiworld, "Yellow Castle") + yellow_castle.exits.append(Entrance(player, "YellowCastleExit", yellow_castle)) + multiworld.regions.append(yellow_castle) + + white_castle = Region("WhiteCastle", player, multiworld, "White Castle") + white_castle.exits.append(Entrance(player, "WhiteCastleExit", white_castle)) + white_castle.exits.append(Entrance(player, "WhiteCastleSecretPassage", white_castle)) + white_castle.exits.append(Entrance(player, "WhiteCastlePeekPassage", white_castle)) + multiworld.regions.append(white_castle) + + white_castle_pre_vault_peek = Region("WhiteCastlePreVaultPeek", player, multiworld, "White Castle Secret Peek") + white_castle_pre_vault_peek.exits.append(Entrance(player, "WhiteCastleFromPeek", white_castle_pre_vault_peek)) + multiworld.regions.append(white_castle_pre_vault_peek) + + white_castle_secret_room = Region("WhiteCastleVault", player, multiworld, "White Castle Vault",) + white_castle_secret_room.exits.append(Entrance(player, "WhiteCastleReturnPassage", white_castle_secret_room)) + multiworld.regions.append(white_castle_secret_room) + + black_castle = Region("BlackCastle", player, multiworld, "Black Castle") + black_castle.exits.append(Entrance(player, "BlackCastleExit", black_castle)) + black_castle.exits.append(Entrance(player, "BlackCastleVaultEntrance", black_castle)) + multiworld.regions.append(black_castle) + + black_castle_secret_room = Region("BlackCastleVault", player, multiworld, "Black Castle Vault") + black_castle_secret_room.exits.append(Entrance(player, "BlackCastleReturnPassage", black_castle_secret_room)) + multiworld.regions.append(black_castle_secret_room) + + credits_room = Region("CreditsRoom", player, multiworld, "Credits Room") + credits_room.exits.append(Entrance(player, "CreditsExit", credits_room)) + credits_room.exits.append(Entrance(player, "CreditsToFarSide", credits_room)) + multiworld.regions.append(credits_room) + + credits_room_far_side = Region("CreditsRoomFarSide", player, multiworld, "Credits Far Side") + credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side)) + multiworld.regions.append(credits_room_far_side) + + dragon_slay_check = multiworld.dragon_slay_check[player].value + priority_locations = determine_priority_locations(multiworld, dragon_slay_check) + + for name, location_data in location_table.items(): + require_sword = False + if location_data.region == "Varies": + if location_data.name == "Slay Yorgle": + if not dragon_slay_check: + continue + region_name = dragon_room_to_region(dragon_rooms[0]) + elif location_data.name == "Slay Grundle": + if not dragon_slay_check: + continue + region_name = dragon_room_to_region(dragon_rooms[1]) + elif location_data.name == "Slay Rhindle": + if not dragon_slay_check: + continue + region_name = dragon_room_to_region(dragon_rooms[2]) + else: + raise Exception(f"Unknown location region for {location_data.name}") + r = multiworld.get_region(region_name, player) + else: + r = multiworld.get_region(location_data.region, player) + + adventure_loc = AdventureLocation(player, location_data.name, location_data.location_id, r) + if adventure_loc.name in priority_locations: + adventure_loc.progress_type = LocationProgressType.PRIORITY + r.locations.append(adventure_loc) + + # In a tracker and plando-free world, I'd determine unused locations here and not add them. + # But that would cause problems with both plandos and trackers. So I guess I'll stick + # with filling in with 'nothing' in pre_fill. + + # in the future, I may randomize the map some, and that will require moving + # connections to later, probably + + multiworld.get_entrance("GameStart", player) \ + .connect(multiworld.get_region("Overworld", player)) + + multiworld.get_entrance("YellowCastlePort", player) \ + .connect(multiworld.get_region("YellowCastle", player)) + multiworld.get_entrance("YellowCastleExit", player) \ + .connect(multiworld.get_region("Overworld", player)) + + multiworld.get_entrance("WhiteCastlePort", player) \ + .connect(multiworld.get_region("WhiteCastle", player)) + multiworld.get_entrance("WhiteCastleExit", player) \ + .connect(multiworld.get_region("Overworld", player)) + + multiworld.get_entrance("WhiteCastleSecretPassage", player) \ + .connect(multiworld.get_region("WhiteCastleVault", player)) + multiworld.get_entrance("WhiteCastleReturnPassage", player) \ + .connect(multiworld.get_region("WhiteCastle", player)) + multiworld.get_entrance("WhiteCastlePeekPassage", player) \ + .connect(multiworld.get_region("WhiteCastlePreVaultPeek", player)) + multiworld.get_entrance("WhiteCastleFromPeek", player) \ + .connect(multiworld.get_region("WhiteCastle", player)) + + multiworld.get_entrance("BlackCastlePort", player) \ + .connect(multiworld.get_region("BlackCastle", player)) + multiworld.get_entrance("BlackCastleExit", player) \ + .connect(multiworld.get_region("Overworld", player)) + multiworld.get_entrance("BlackCastleVaultEntrance", player) \ + .connect(multiworld.get_region("BlackCastleVault", player)) + multiworld.get_entrance("BlackCastleReturnPassage", player) \ + .connect(multiworld.get_region("BlackCastle", player)) + + multiworld.get_entrance("CreditsWall", player) \ + .connect(multiworld.get_region("CreditsRoom", player)) + multiworld.get_entrance("CreditsExit", player) \ + .connect(multiworld.get_region("Overworld", player)) + + multiworld.get_entrance("CreditsToFarSide", player) \ + .connect(multiworld.get_region("CreditsRoomFarSide", player)) + multiworld.get_entrance("CreditsFromFarSide", player) \ + .connect(multiworld.get_region("CreditsRoom", player)) + + +# Placeholder for adding sets of priority locations at generation, possibly as an option in the future +def determine_priority_locations(world: MultiWorld, dragon_slay_check: bool) -> {}: + priority_locations = {} + return priority_locations diff --git a/worlds/adventure/Rom.py b/worlds/adventure/Rom.py new file mode 100644 index 00000000..62c40197 --- /dev/null +++ b/worlds/adventure/Rom.py @@ -0,0 +1,321 @@ +import hashlib +import json +import os +import zipfile +from typing import Optional, Any + +import Utils +from .Locations import AdventureLocation, LocationData +from Utils import OptionsType +from worlds.Files import APDeltaPatch, AutoPatchRegister, APContainer +from itertools import chain + +import bsdiff4 + +ADVENTUREHASH: str = "157bddb7192754a45372be196797f284" + + +class AdventureAutoCollectLocation: + short_location_id: int = 0 + room_id: int = 0 + + def __init__(self, short_location_id: int, room_id: int): + self.short_location_id = short_location_id + self.room_id = room_id + + def get_dict(self): + return { + "short_location_id": self.short_location_id, + "room_id": self.room_id, + } + + +class AdventureForeignItemInfo: + short_location_id: int = 0 + room_id: int = 0 + room_x: int = 0 + room_y: int = 0 + + def __init__(self, short_location_id: int, room_id: int, room_x: int, room_y: int): + self.short_location_id = short_location_id + self.room_id = room_id + self.room_x = room_x + self.room_y = room_y + + def get_dict(self): + return { + "short_location_id": self.short_location_id, + "room_id": self.room_id, + "room_x": self.room_x, + "room_y": self.room_y, + } + + +class BatNoTouchLocation: + short_location_id: int + room_id: int + room_x: int + room_y: int + local_item: int + + def __init__(self, short_location_id: int, room_id: int, room_x: int, room_y: int, local_item: int = None): + self.short_location_id = short_location_id + self.room_id = room_id + self.room_x = room_x + self.room_y = room_y + self.local_item = local_item + + def get_dict(self): + ret_dict = { + "short_location_id": self.short_location_id, + "room_id": self.room_id, + "room_x": self.room_x, + "room_y": self.room_y, + } + if self.local_item is not None: + ret_dict["local_item"] = self.local_item + else: + ret_dict["local_item"] = 255 + return ret_dict + + +class AdventureDeltaPatch(APContainer, metaclass=AutoPatchRegister): + hash = ADVENTUREHASH + game = "Adventure" + patch_file_ending = ".apadvn" + zip_version: int = 2 + + # locations: [], autocollect: [], seed_name: bytes, + def __init__(self, *args: Any, **kwargs: Any) -> None: + patch_only = True + if "autocollect" in kwargs: + patch_only = False + self.foreign_items: [AdventureForeignItemInfo] = [AdventureForeignItemInfo(loc.short_location_id, loc.room_id, loc.room_x, loc.room_y) + for loc in kwargs["locations"]] + + self.autocollect_items: [AdventureAutoCollectLocation] = kwargs["autocollect"] + self.seedName: bytes = kwargs["seed_name"] + self.local_item_locations: {} = kwargs["local_item_locations"] + self.dragon_speed_reducer_info: {} = kwargs["dragon_speed_reducer_info"] + self.diff_a_mode: int = kwargs["diff_a_mode"] + self.diff_b_mode: int = kwargs["diff_b_mode"] + self.bat_logic: int = kwargs["bat_logic"] + self.bat_no_touch_locations: [LocationData] = kwargs["bat_no_touch_locations"] + self.rom_deltas: {int, int} = kwargs["rom_deltas"] + del kwargs["locations"] + del kwargs["autocollect"] + del kwargs["seed_name"] + del kwargs["local_item_locations"] + del kwargs["dragon_speed_reducer_info"] + del kwargs["diff_a_mode"] + del kwargs["diff_b_mode"] + del kwargs["bat_logic"] + del kwargs["bat_no_touch_locations"] + del kwargs["rom_deltas"] + super(AdventureDeltaPatch, self).__init__(*args, **kwargs) + + def write_contents(self, opened_zipfile: zipfile.ZipFile): + super(AdventureDeltaPatch, self).write_contents(opened_zipfile) + # write Delta + opened_zipfile.writestr("zip_version", + self.zip_version.to_bytes(1, "little"), + compress_type=zipfile.ZIP_STORED) + if self.foreign_items is not None: + loc_bytes = [] + for foreign_item in self.foreign_items: + loc_bytes.append(foreign_item.short_location_id) + loc_bytes.append(foreign_item.room_id) + loc_bytes.append(foreign_item.room_x) + loc_bytes.append(foreign_item.room_y) + opened_zipfile.writestr("adventure_locations", + bytes(loc_bytes), + compress_type=zipfile.ZIP_LZMA) + if self.autocollect_items is not None: + loc_bytes = [] + for item in self.autocollect_items: + loc_bytes.append(item.short_location_id) + loc_bytes.append(item.room_id) + opened_zipfile.writestr("adventure_autocollect", + bytes(loc_bytes), + compress_type=zipfile.ZIP_LZMA) + if self.player_name is not None: + opened_zipfile.writestr("player", + self.player_name, # UTF-8 + compress_type=zipfile.ZIP_STORED) + if self.seedName is not None: + opened_zipfile.writestr("seedName", + self.seedName, + compress_type=zipfile.ZIP_STORED) + if self.local_item_locations is not None: + opened_zipfile.writestr("local_item_locations", + json.dumps(self.local_item_locations), + compress_type=zipfile.ZIP_LZMA) + if self.dragon_speed_reducer_info is not None: + opened_zipfile.writestr("dragon_speed_reducer_info", + json.dumps(self.dragon_speed_reducer_info), + compress_type=zipfile.ZIP_LZMA) + if self.diff_a_mode is not None: + opened_zipfile.writestr("diff_a_mode", + self.diff_a_mode.to_bytes(1, "little"), + compress_type=zipfile.ZIP_STORED) + if self.diff_b_mode is not None: + opened_zipfile.writestr("diff_b_mode", + self.diff_b_mode.to_bytes(1, "little"), + compress_type=zipfile.ZIP_STORED) + if self.bat_logic is not None: + opened_zipfile.writestr("bat_logic", + self.bat_logic.to_bytes(1, "little"), + compress_type=zipfile.ZIP_STORED) + if self.bat_no_touch_locations is not None: + loc_bytes = [] + for loc in self.bat_no_touch_locations: + loc_bytes.append(loc.short_location_id) # used for AP items managed by script + loc_bytes.append(loc.room_id) # used for local items placed in rom + loc_bytes.append(loc.room_x) + loc_bytes.append(loc.room_y) + loc_bytes.append(0xff if loc.local_item is None else loc.local_item) + opened_zipfile.writestr("bat_no_touch_locations", + bytes(loc_bytes), + compress_type=zipfile.ZIP_LZMA) + if self.rom_deltas is not None: + # this is not an efficient way to do this AT ALL, but Adventure's data is so tiny it shouldn't matter + # if you're looking at doing something like this for another game, consider encoding your rom changes + # in a more efficient way + opened_zipfile.writestr("rom_deltas", + json.dumps(self.rom_deltas), + compress_type=zipfile.ZIP_LZMA) + + def read_contents(self, opened_zipfile: zipfile.ZipFile): + super(AdventureDeltaPatch, self).read_contents(opened_zipfile) + self.foreign_items = AdventureDeltaPatch.read_foreign_items(opened_zipfile) + self.autocollect_items = AdventureDeltaPatch.read_autocollect_items(opened_zipfile) + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + @classmethod + def check_version(cls, opened_zipfile: zipfile.ZipFile) -> bool: + version_bytes = opened_zipfile.read("zip_version") + version = 0 + if version_bytes is not None: + version = int.from_bytes(version_bytes, "little") + if version != cls.zip_version: + return False + return True + + @classmethod + def read_rom_info(cls, opened_zipfile: zipfile.ZipFile) -> (bytes, bytes, str): + seedbytes: bytes = opened_zipfile.read("seedName") + namebytes: bytes = opened_zipfile.read("player") + namestr: str = namebytes.decode("utf-8") + return seedbytes, namestr + + @classmethod + def read_difficulty_switch_info(cls, opened_zipfile: zipfile.ZipFile) -> (int, int): + diff_a_bytes = opened_zipfile.read("diff_a_mode") + diff_b_bytes = opened_zipfile.read("diff_b_mode") + diff_a = 0 + diff_b = 0 + if diff_a_bytes is not None: + diff_a = int.from_bytes(diff_a_bytes, "little") + if diff_b_bytes is not None: + diff_b = int.from_bytes(diff_b_bytes, "little") + return diff_a, diff_b + + @classmethod + def read_bat_logic(cls, opened_zipfile: zipfile.ZipFile) -> int: + bat_logic = opened_zipfile.read("bat_logic") + if bat_logic is None: + return 0 + return int.from_bytes(bat_logic, "little") + + @classmethod + def read_foreign_items(cls, opened_zipfile: zipfile.ZipFile) -> [AdventureForeignItemInfo]: + foreign_items = [] + readbytes: bytes = opened_zipfile.read("adventure_locations") + bytelist = list(readbytes) + for i in range(round(len(bytelist) / 4)): + offset = i * 4 + foreign_items.append(AdventureForeignItemInfo(bytelist[offset], + bytelist[offset + 1], + bytelist[offset + 2], + bytelist[offset + 3])) + return foreign_items + + @classmethod + def read_bat_no_touch(cls, opened_zipfile: zipfile.ZipFile) -> [BatNoTouchLocation]: + locations = [] + readbytes: bytes = opened_zipfile.read("bat_no_touch_locations") + bytelist = list(readbytes) + for i in range(round(len(bytelist) / 5)): + offset = i * 5 + locations.append(BatNoTouchLocation(bytelist[offset], + bytelist[offset + 1], + bytelist[offset + 2], + bytelist[offset + 3], + bytelist[offset + 4])) + return locations + + @classmethod + def read_autocollect_items(cls, opened_zipfile: zipfile.ZipFile) -> [AdventureForeignItemInfo]: + autocollect_items = [] + readbytes: bytes = opened_zipfile.read("adventure_autocollect") + bytelist = list(readbytes) + for i in range(round(len(bytelist) / 2)): + offset = i * 2 + autocollect_items.append(AdventureAutoCollectLocation(bytelist[offset], bytelist[offset + 1])) + return autocollect_items + + @classmethod + def read_local_item_locations(cls, opened_zipfile: zipfile.ZipFile) -> [AdventureForeignItemInfo]: + readbytes: bytes = opened_zipfile.read("local_item_locations") + readstr: str = readbytes.decode() + return json.loads(readstr) + + @classmethod + def read_dragon_speed_info(cls, opened_zipfile: zipfile.ZipFile) -> {}: + readbytes: bytes = opened_zipfile.read("dragon_speed_reducer_info") + readstr: str = readbytes.decode() + return json.loads(readstr) + + @classmethod + def read_rom_deltas(cls, opened_zipfile: zipfile.ZipFile) -> {int, int}: + readbytes: bytes = opened_zipfile.read("rom_deltas") + readstr: str = readbytes.decode() + return json.loads(readstr) + + @classmethod + def apply_rom_deltas(cls, base_bytes: bytes, rom_deltas: {int, int}) -> bytearray: + rom_bytes = bytearray(base_bytes) + for offset, value in rom_deltas.items(): + int_offset = int(offset) + rom_bytes[int_offset:int_offset+1] = int.to_bytes(value, 1, "little") + return rom_bytes + + +def apply_basepatch(base_rom_bytes: bytes) -> bytes: + with open(os.path.join(os.path.dirname(__file__), "../../data/adventure_basepatch.bsdiff4"), "rb") as basepatch: + delta: bytes = basepatch.read() + return bsdiff4.patch(base_rom_bytes, delta) + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + file_name = get_base_rom_path(file_name) + with open(file_name, "rb") as file: + base_rom_bytes = bytes(file.read()) + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if ADVENTUREHASH != basemd5.hexdigest(): + raise Exception(f"Supplied Base Rom does not match known MD5 for Adventure. " + "Get the correct game and version, then dump it") + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + options: OptionsType = Utils.get_options() + if not file_name: + file_name = options["adventure_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name diff --git a/worlds/adventure/Rules.py b/worlds/adventure/Rules.py new file mode 100644 index 00000000..6f4b53fa --- /dev/null +++ b/worlds/adventure/Rules.py @@ -0,0 +1,98 @@ +from worlds.adventure import location_table +from worlds.adventure.Options import BatLogic, DifficultySwitchB, DifficultySwitchA +from worlds.generic.Rules import add_rule, set_rule, forbid_item +from BaseClasses import LocationProgressType + + +def set_rules(self) -> None: + world = self.multiworld + use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic + + set_rule(world.get_entrance("YellowCastlePort", self.player), + lambda state: state.has("Yellow Key", self.player)) + set_rule(world.get_entrance("BlackCastlePort", self.player), + lambda state: state.has("Black Key", self.player)) + set_rule(world.get_entrance("WhiteCastlePort", self.player), + lambda state: state.has("White Key", self.player)) + + # a future thing would be to make the bat an actual item, or at least allow it to + # be placed in a castle, which would require some additions to the rules when + # use_bat_logic is true + if not use_bat_logic: + set_rule(world.get_entrance("WhiteCastleSecretPassage", self.player), + lambda state: state.has("Bridge", self.player)) + set_rule(world.get_entrance("WhiteCastlePeekPassage", self.player), + lambda state: state.has("Bridge", self.player) or + state.has("Magnet", self.player)) + set_rule(world.get_entrance("BlackCastleVaultEntrance", self.player), + lambda state: state.has("Bridge", self.player) or + state.has("Magnet", self.player)) + + dragon_slay_check = world.dragon_slay_check[self.player].value + if dragon_slay_check: + if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item: + set_rule(world.get_location("Slay Yorgle", self.player), + lambda state: state.has("Sword", self.player) and + state.has("Right Difficulty Switch", self.player)) + set_rule(world.get_location("Slay Grundle", self.player), + lambda state: state.has("Sword", self.player) and + state.has("Right Difficulty Switch", self.player)) + set_rule(world.get_location("Slay Rhindle", self.player), + lambda state: state.has("Sword", self.player) and + state.has("Right Difficulty Switch", self.player)) + else: + set_rule(world.get_location("Slay Yorgle", self.player), + lambda state: state.has("Sword", self.player)) + set_rule(world.get_location("Slay Grundle", self.player), + lambda state: state.has("Sword", self.player)) + set_rule(world.get_location("Slay Rhindle", self.player), + lambda state: state.has("Sword", self.player)) + + # really this requires getting the dot item, and having another item or enemy + # in the room, but the dot would be *super evil* + # to actually make randomized, since it is invisible. May add some options + # for how that works in the distant future, but for now, just say you need + # the bridge and black key to get to it, as that simplifies things a lot + set_rule(world.get_entrance("CreditsWall", self.player), + lambda state: state.has("Bridge", self.player) and + state.has("Black Key", self.player)) + + if not use_bat_logic: + set_rule(world.get_entrance("CreditsToFarSide", self.player), + lambda state: state.has("Magnet", self.player)) + + # bridge literally does not fit in this space, I think. I'll just exclude it + forbid_item(world.get_location("Dungeon Vault", self.player), "Bridge", self.player) + # don't put magnet in locations that can pull in-logic items out of reach unless the bat is in play + if not use_bat_logic: + forbid_item(world.get_location("Dungeon Vault", self.player), "Magnet", self.player) + forbid_item(world.get_location("Red Maze Vault Entrance", self.player), "Magnet", self.player) + forbid_item(world.get_location("Credits Right Side", self.player), "Magnet", self.player) + + # and obviously we don't want to start with the game already won + forbid_item(world.get_location("Inside Yellow Castle", self.player), "Chalice", self.player) + overworld = world.get_region("Overworld", self.player) + + for loc in overworld.locations: + forbid_item(loc, "Chalice", self.player) + + add_rule(world.get_location("Chalice Home", self.player), + lambda state: state.has("Chalice", self.player) and state.has("Yellow Key", self.player)) + + # world.random.choice(overworld.locations).progress_type = LocationProgressType.PRIORITY + + # all_locations = world.get_locations(self.player).copy() + # while priority_count < get_num_items(): + # loc = world.random.choice(all_locations) + # if loc.progress_type == LocationProgressType.DEFAULT: + # loc.progress_type = LocationProgressType.PRIORITY + # priority_count += 1 + # all_locations.remove(loc) + + # TODO: Add events for dragon_slay_check and trap_bat_check. Here? Elsewhere? + # if self.dragon_slay_check == 1: + # TODO - Randomize bat and dragon start rooms and use those to determine rules + # TODO - for the requirements for the slay event (since we have to get to the + # TODO - dragons and sword to kill them). Unless the dragons are set to be items, + # TODO - which might be a funny option, then they can just be randoed like normal + # TODO - just forbidden from the vaults and all credits room locations diff --git a/worlds/adventure/__init__.py b/worlds/adventure/__init__.py new file mode 100644 index 00000000..b9d9d5f1 --- /dev/null +++ b/worlds/adventure/__init__.py @@ -0,0 +1,391 @@ +import base64 +import copy +import itertools +import math +import os +from enum import IntFlag +from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple + +from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \ + LocationProgressType +from Main import __version__ +from Options import AssembleOptions +from worlds.AutoWorld import WebWorld, World +from Fill import fill_restrictive +from worlds.generic.Rules import add_rule, set_rule +from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB +from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \ + AdventureAutoCollectLocation +from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max +from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions +from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \ + static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \ + rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset +from .Regions import create_regions +from .Rules import set_rules + + +from worlds.LauncherComponents import Component, components, SuffixIdentifier + +# Adventure +components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn'))) + + +class AdventureWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Guide", + "A guide to setting up Adventure for MultiWorld.", + "English", + "setup_en.md", + "setup/en", + ["JusticePS"] + )] + theme = "dirt" + + +def get_item_position_data_start(table_index: int): + item_ram_address = item_ram_addresses[table_index]; + return item_position_table + item_ram_address - items_ram_start + + +class AdventureWorld(World): + """ + Adventure for the Atari 2600 is an early graphical adventure game. + Find the enchanted chalice and return it to the yellow castle, + using magic items to enter hidden rooms, retrieve out of + reach items, or defeat the three dragons. Beware the bat + who likes to steal your equipment! + """ + game: ClassVar[str] = "Adventure" + web: ClassVar[WebWorld] = AdventureWeb() + + option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions + item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()} + location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()} + data_version: ClassVar[int] = 1 + required_client_version: Tuple[int, int, int] = (0, 3, 9) + + def __init__(self, world: MultiWorld, player: int): + super().__init__(world, player) + self.rom_name: Optional[bytearray] = bytearray("", "utf8" ) + self.dragon_rooms: [int] = [0x14, 0x19, 0x4] + self.dragon_slay_check: Optional[int] = 0 + self.connector_multi_slot: Optional[int] = 0 + self.dragon_rando_type: Optional[int] = 0 + self.yorgle_speed: Optional[int] = 2 + self.yorgle_min_speed: Optional[int] = 2 + self.grundle_speed: Optional[int] = 2 + self.grundle_min_speed: Optional[int] = 2 + self.rhindle_speed: Optional[int] = 3 + self.rhindle_min_speed: Optional[int] = 3 + self.difficulty_switch_a: Optional[int] = 0 + self.difficulty_switch_b: Optional[int] = 0 + self.start_castle: Optional[int] = 0 + # dict of item names -> list of speed deltas + self.dragon_speed_reducer_info: {} = {} + self.created_items: int = 0 + + @classmethod + def stage_assert_generate(cls, _multiworld: MultiWorld) -> None: + # don't need rom anymore + pass + + def place_random_dragon(self, dragon_index: int): + region_list = ["Overworld", "YellowCastle", "BlackCastle", "WhiteCastle"] + self.dragon_rooms[dragon_index] = get_random_room_in_regions(region_list, self.multiworld.random) + + def generate_early(self) -> None: + self.rom_name = \ + bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21] + self.rom_name.extend([0] * (21 - len(self.rom_name))) + + self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value + self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value + self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value + self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value + self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value + self.grundle_speed = self.multiworld.grundle_speed[self.player].value + self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value + self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value + self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value + self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value + self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value + self.start_castle = self.multiworld.start_castle[self.player].value + self.created_items = 0 + + if self.dragon_slay_check == 0: + item_table["Sword"].classification = ItemClassification.useful + else: + item_table["Sword"].classification = ItemClassification.progression + if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item: + item_table["Right Difficulty Switch"].classification = ItemClassification.progression + + if self.dragon_rando_type == DragonRandoType.option_shuffle: + self.multiworld.random.shuffle(self.dragon_rooms) + elif self.dragon_rando_type == DragonRandoType.option_overworldplus: + dragon_indices = [0, 1, 2] + overworld_forced_index = self.multiworld.random.choice(dragon_indices) + dragon_indices.remove(overworld_forced_index) + region_list = ["Overworld"] + self.dragon_rooms[overworld_forced_index] = get_random_room_in_regions(region_list, self.multiworld.random) + self.place_random_dragon(dragon_indices[0]) + self.place_random_dragon(dragon_indices[1]) + elif self.dragon_rando_type == DragonRandoType.option_randomized: + self.place_random_dragon(0) + self.place_random_dragon(1) + self.place_random_dragon(2) + + def create_items(self) -> None: + for event in map(self.create_item, event_table): + self.multiworld.itempool.append(event) + exclude = [item for item in self.multiworld.precollected_items[self.player]] + self.created_items = 0 + for item in map(self.create_item, item_table): + if item.code == nothing_item_id: + continue + if item in exclude and item.code <= standard_item_max: + exclude.remove(item) # this is destructive. create unique list above + else: + if item.code <= standard_item_max: + self.multiworld.itempool.append(item) + self.created_items += 1 + num_locations = len(location_table) - 1 # subtract out the chalice location + if self.dragon_slay_check == 0: + num_locations -= 3 + + if self.difficulty_switch_a == DifficultySwitchA.option_hard_with_unlock_item: + self.multiworld.itempool.append(self.create_item("Left Difficulty Switch")) + self.created_items += 1 + if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item: + self.multiworld.itempool.append(self.create_item("Right Difficulty Switch")) + self.created_items += 1 + + extra_filler_count = num_locations - self.created_items + self.dragon_speed_reducer_info = {} + # make sure yorgle doesn't take 2 if there's not enough for the others to get at least one + if extra_filler_count <= 4: + extra_filler_count = 1 + self.create_dragon_slow_items(self.yorgle_min_speed, self.yorgle_speed, "Slow Yorgle", extra_filler_count) + extra_filler_count = num_locations - self.created_items + + if extra_filler_count <= 3: + extra_filler_count = 1 + self.create_dragon_slow_items(self.grundle_min_speed, self.grundle_speed, "Slow Grundle", extra_filler_count) + extra_filler_count = num_locations - self.created_items + + self.create_dragon_slow_items(self.rhindle_min_speed, self.rhindle_speed, "Slow Rhindle", extra_filler_count) + extra_filler_count = num_locations - self.created_items + + # traps would probably go here, if enabled + freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value + actual_freeincarnates = min(extra_filler_count, freeincarnate_max) + self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)] + self.created_items += actual_freeincarnates + + def create_dragon_slow_items(self, min_speed: int, speed: int, item_name: str, maximum_items: int): + if min_speed < speed: + delta = speed - min_speed + if delta > 2 and maximum_items >= 2: + self.multiworld.itempool.append(self.create_item(item_name)) + self.multiworld.itempool.append(self.create_item(item_name)) + speed_with_one = speed - math.floor(delta / 2) + self.dragon_speed_reducer_info[item_table[item_name].id] = [speed_with_one, min_speed] + self.created_items += 2 + elif maximum_items >= 1: + self.multiworld.itempool.append(self.create_item(item_name)) + self.dragon_speed_reducer_info[item_table[item_name].id] = [min_speed] + self.created_items += 1 + + def create_regions(self) -> None: + create_regions(self.multiworld, self.player, self.dragon_rooms) + + set_rules = set_rules + + def generate_basic(self) -> None: + self.multiworld.get_location("Chalice Home", self.player).place_locked_item( + self.create_event("Victory", ItemClassification.progression)) + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + def pre_fill(self): + # Place empty items in filler locations here, to limit + # the number of exported empty items and the density of stuff in overworld. + max_location_count = len(location_table) - 1 + if self.dragon_slay_check == 0: + max_location_count -= 3 + + force_empty_item_count = (max_location_count - self.created_items) + if force_empty_item_count <= 0: + return + overworld = self.multiworld.get_region("Overworld", self.player) + overworld_locations_copy = overworld.locations.copy() + all_locations = self.multiworld.get_locations(self.player) + + locations_copy = all_locations.copy() + for loc in all_locations: + if loc.item is not None or loc.progress_type != LocationProgressType.DEFAULT: + locations_copy.remove(loc) + if loc in overworld_locations_copy: + overworld_locations_copy.remove(loc) + + # guarantee at least one overworld location, so we can for sure get a key somewhere + # if too much stuff is plando'd though, just let it go + if len(overworld_locations_copy) >= 3: + saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy) + locations_copy.remove(saved_overworld_loc) + overworld_locations_copy.remove(saved_overworld_loc) + + # if we have few items, enforce another overworld slot, fill a hard slot, and ensure we have + # at least one hard slot available + if self.created_items < 15: + hard_locations = [] + for loc in locations_copy: + if "Vault" in loc.name or "Credits" in loc.name: + hard_locations.append(loc) + force_empty_item_count -= 1 + loc = self.multiworld.random.choice(hard_locations) + loc.place_locked_item(self.create_item('nothing')) + hard_locations.remove(loc) + locations_copy.remove(loc) + + loc = self.multiworld.random.choice(hard_locations) + locations_copy.remove(loc) + hard_locations.remove(loc) + + saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy) + locations_copy.remove(saved_overworld_loc) + overworld_locations_copy.remove(saved_overworld_loc) + + # if we have very few items, fill another two difficult slots + if self.created_items < 10: + for i in range(2): + force_empty_item_count -= 1 + loc = self.multiworld.random.choice(hard_locations) + loc.place_locked_item(self.create_item('nothing')) + hard_locations.remove(loc) + locations_copy.remove(loc) + + # for the absolute minimum number of items, enforce a third overworld slot + if self.created_items <= 7: + saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy) + locations_copy.remove(saved_overworld_loc) + overworld_locations_copy.remove(saved_overworld_loc) + + # finally, place nothing items + while force_empty_item_count > 0 and locations_copy: + force_empty_item_count -= 1 + # prefer somewhat to thin out the overworld. + if len(overworld_locations_copy) > 0 and self.multiworld.random.randint(0, 10) < 4: + loc = self.multiworld.random.choice(overworld_locations_copy) + else: + loc = self.multiworld.random.choice(locations_copy) + loc.place_locked_item(self.create_item('nothing')) + locations_copy.remove(loc) + if loc in overworld_locations_copy: + overworld_locations_copy.remove(loc) + + def place_dragons(self, rom_deltas: {int, int}): + for i in range(3): + table_index = static_first_dragon_index + i + item_position_data_start = get_item_position_data_start(table_index) + rom_deltas[item_position_data_start] = self.dragon_rooms[i] + + def set_dragon_speeds(self, rom_deltas: {int, int}): + rom_deltas[yorgle_speed_data_location] = self.yorgle_speed + rom_deltas[grundle_speed_data_location] = self.grundle_speed + rom_deltas[rhindle_speed_data_location] = self.rhindle_speed + + def set_start_castle(self, rom_deltas): + start_castle_value = start_castle_values[self.start_castle] + rom_deltas[start_castle_offset] = start_castle_value + + def generate_output(self, output_directory: str) -> None: + rom_path: str = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.bin") + foreign_item_locations: [LocationData] = [] + auto_collect_locations: [AdventureAutoCollectLocation] = [] + local_item_to_location: {int, int} = {} + bat_no_touch_locs: [LocationData] = [] + bat_logic: int = self.multiworld.bat_logic[self.player].value + try: + rom_deltas: { int, int } = {} + self.place_dragons(rom_deltas) + self.set_dragon_speeds(rom_deltas) + self.set_start_castle(rom_deltas) + # start and stop indices are offsets in the ROM file, not Adventure ROM addresses (which start at f000) + + # This places the local items (I still need to make it easy to inject the offset data) + unplaced_local_items = dict(filter(lambda x: nothing_item_id < x[1].id <= standard_item_max, + item_table.items())) + for location in self.multiworld.get_locations(self.player): + # 'nothing' items, which are autocollected when the room is entered + if location.item.player == self.player and \ + location.item.name == "nothing": + location_data = location_table[location.name] + auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id, + location_data.room_id)) + # standard Adventure items, which are placed in the rom + elif location.item.player == self.player and \ + location.item.name != "nothing" and \ + location.item.code is not None and \ + location.item.code <= standard_item_max: + # I need many of the intermediate values here. + item_table_offset = item_table[location.item.name].table_index * static_item_element_size + item_ram_address = item_ram_addresses[item_table[location.item.name].table_index] + item_position_data_start = item_position_table + item_ram_address - items_ram_start + location_data = location_table[location.name] + room_x, room_y = location_data.get_position(self.multiworld.per_slot_randoms[self.player]) + if location_data.needs_bat_logic and bat_logic == 0x0: + copied_location = copy.copy(location_data) + copied_location.local_item = item_ram_address + bat_no_touch_locs.append(copied_location) + del unplaced_local_items[location.item.name] + + rom_deltas[item_position_data_start] = location_data.room_id + rom_deltas[item_position_data_start + 1] = room_x + rom_deltas[item_position_data_start + 2] = room_y + local_item_to_location[item_table_offset] = self.location_name_to_id[location.name] \ + - base_location_id + # items from other worlds, and non-standard Adventure items handled by script, like difficulty switches + elif location.item.code is not None: + if location.item.code != nothing_item_id: + location_data = location_table[location.name] + foreign_item_locations.append(location_data) + if location_data.needs_bat_logic and bat_logic == 0x0: + bat_no_touch_locs.append(location_data) + else: + location_data = location_table[location.name] + auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id, + location_data.room_id)) + # Adventure items that are in another world get put in an invalid room until needed + for unplaced_item_name, unplaced_item in unplaced_local_items.items(): + item_position_data_start = get_item_position_data_start(unplaced_item.table_index) + rom_deltas[item_position_data_start] = 0xff + + if self.multiworld.connector_multi_slot[self.player].value: + rom_deltas[connector_port_offset] = (self.player & 0xff) + else: + rom_deltas[connector_port_offset] = 0 + except Exception as e: + raise e + else: + patch = AdventureDeltaPatch(os.path.splitext(rom_path)[0] + AdventureDeltaPatch.patch_file_ending, + player=self.player, player_name=self.multiworld.player_name[self.player], + locations=foreign_item_locations, + autocollect=auto_collect_locations, local_item_locations=local_item_to_location, + dragon_speed_reducer_info=self.dragon_speed_reducer_info, + diff_a_mode=self.difficulty_switch_a, diff_b_mode=self.difficulty_switch_b, + bat_logic=bat_logic, bat_no_touch_locations=bat_no_touch_locs, + rom_deltas=rom_deltas, + seed_name=bytes(self.multiworld.seed_name, encoding="ascii")) + patch.write() + finally: + if os.path.exists(rom_path): + os.unlink(rom_path) + + # end of ordered Main.py calls + + def create_item(self, name: str) -> Item: + item_data: ItemData = item_table.get(name) + return AdventureItem(name, item_data.classification, item_data.id, self.player) + + def create_event(self, name: str, classification: ItemClassification) -> Item: + return AdventureItem(name, classification, None, self.player) diff --git a/worlds/adventure/docs/en_Adventure.md b/worlds/adventure/docs/en_Adventure.md new file mode 100644 index 00000000..c39e0f7d --- /dev/null +++ b/worlds/adventure/docs/en_Adventure.md @@ -0,0 +1,62 @@ +# Adventure + +## Where is the settings page? +The [player settings page for Adventure](../player-settings) contains all the options you need to configure and export a config file. + +## What does randomization do to this game? +Adventure items may be distributed into additional locations not possible in the vanilla Adventure randomizer. All +Adventure items are added to the multiworld item pool. Depending on the settings, dragon locations may be randomized, +slaying dragons may award items, difficulty switches may require items to unlock, and limited use 'freeincarnates' +can allow reincarnation without resurrecting dragons. Dragon speeds may also be randomized, and items may exist +to reduce their speeds. + +## What is the goal of Adventure when randomized? +Same as vanilla; Find the Enchanted Chalice and return it to the Yellow Castle + +## Which items can be in another player's world? +All three keys, the chalice, the sword, the magnet, and the bridge can be found in another player's world. Depending on +settings, dragon slowdowns, difficulty switch unlocks, and freeincarnates may also be found. + +## What is considered a location check in Adventure? +Most areas in Adventure have one or more locations which can contain an Adventure item or an Archipelago item. +A few rooms have two potential locaions. If the location contains a 'nothing' Adventure item, it will send a check when +that is seen. If it contains an item from another Adventure or other game, it will show a rough approximation of the +Archipelago logo that can be touched for a check. Touching a local Adventure item also 'checks' it, allowing it to be +retrieved after a select-reset or hard reset. + +## Why isn't my item where the spoiler says it should be? +If something isn't where the spoiler says, most likely the bat carried it somewhere else. The bat's ability to shuffle +items around makes it somewhat unique in Archipelago. Touching the item, wherever it is, will award the location check +for wherever the item was originally placed. + +## Which notable items are not randomized? +The bat, dot, and map are not yet randomized. If the chalice is local, it is randomized, but is always in either a +castle or the credits screen. Forcing the chalice local in the yaml is recommended. + +## What does another world's item look like in Adventure? +It looks vaguely like a flashing Archipelago logo. + +## When the player receives an item, what happens? +A message is shown in the client log. While empty handed, the player can press the fire button to retrieve items in the +order they were received. Once an item is retrieved this way, it cannot be retrieved again until pressing select to +return to the 'GO' screen or doing a hard reset, either one of which will reset all items to their original positions. + +## What are recommended settings to tweak for beginners to the rando? +Setting difficulty_switch_a and lowering the dragons' speeds makes the dragons easier to avoid. Adding Chalice to +local_items guarantees you'll visit at least one of the interesting castles, as it can only be placed in a castle or +the credits room. + +## My yellow key is stuck in a wall! Am I softlocked? +Maybe! That's all part of Adventure. If you have access to the magnet, bridge, or bat, you might be able to retrieve +it. In general, since the bat always starts outside of castles, you should always be able to find it unless you lock +it in a castle yourself. This mod's inventory system allows you to quickly recover all the items +you've collected after a hard reset or select-reset (except for the dot), so usually it's not as bad as in vanilla. + +## How do I get into the credits room? There's a item I need in there. +Searching for 'Adventure dot map' should bring up an AtariAge map with a good walkthrough, but here's the basics. +Bring the bridge into the black castle. Find the small room in the dungeon that cannot be reached without the bridge, +enter it, and push yourself into the bottom right corner to pick up the dot. The dot color matches the background, +so you won't be able to see it if it isn't in a wall, so be careful not to drop it. Bring it to the room one south and +one east of the yellow castle and drop it there. Bring 2-3 more objects (the bat and dragons also count for this) until +it lets you walk through the right wall. +If the item is on the right side, you'll need the magnet to get it. \ No newline at end of file diff --git a/worlds/adventure/docs/setup_en.md b/worlds/adventure/docs/setup_en.md new file mode 100644 index 00000000..038e87e7 --- /dev/null +++ b/worlds/adventure/docs/setup_en.md @@ -0,0 +1,70 @@ +# Setup Guide for Adventure: Archipelago + +## Important + +As we are using Bizhawk, this guide is only applicable to Windows and Linux systems. + +## Required Software + +- Bizhawk: [Bizhawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory) + - Version 2.3.1 and later are supported. Version 2.7 is recommended for stability. + - Detailed installation instructions for Bizhawk can be found at the above link. + - Windows users must run the prereq installer first, which can also be found at the above link. +- The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases) + (select `Adventure Client` during installation). +- An Adventure NTSC ROM file. The Archipelago community cannot provide these. + +## Configuring Bizhawk + +Once Bizhawk has been installed, open Bizhawk and change the following settings: + +- Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to + "Lua+LuaInterface". Then restart Bizhawk. This is required for the Lua script to function correctly. + **NOTE: Even if "Lua+LuaInterface" is already selected, toggle between the two options and reselect it. Fresh installs** + **of newer versions of Bizhawk have a tendency to show "Lua+LuaInterface" as the default selected option but still load** + **"NLua+KopiLua" until this step is done.** +- Under Config > Customize, check the "Run in background" box. This will prevent disconnecting from the client while +BizHawk is running in the background. + +- It is recommended that you provide a path to BizHawk in your host.yaml for Adventure so the client can start it automatically + +## Configuring your YAML file + +### What is a YAML file and why do I need one? + +Your YAML file contains a set of configuration options which provide the generator with information about how it should +generate your game. Each player of a multiworld will provide their own YAML file. This setup allows each player to enjoy +an experience customized for their taste, and different players in the same multiworld can all have different options. + +### Where do I get a YAML file? + +You can generate a yaml or download a template by visiting the [Adventure Settings Page](/games/Adventure/player-settings) + +### What are recommended settings to tweak for beginners to the rando? +Setting difficulty_switch_a and lowering the dragons' speeds makes the dragons easier to avoid. Adding Chalice to +local_items guarantees you'll visit at least one of the interesting castles, as it can only be placed in a castle or +the credits room. + +## Joining a MultiWorld Game + +### Obtain your Adventure patch file + +When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done, +the host will provide you with either a link to download your data file, or with a zip file containing everyone's data +files. Your data file should have a `.apadvn` extension. + +Drag your patch file to the AdventureClient.exe to start your client and start the ROM patch process. Once the process +is finished (this can take a while), the client and the emulator will be started automatically (if you set the emulator +path as recommended). + +### Connect to the Multiserver + +Once both the client and the emulator are started, you must connect them. Within the emulator click on the "Tools" +menu and select "Lua Console". Click the folder button or press Ctrl+O to open a Lua script. + +Navigate to your Archipelago install folder and open `data/lua/ADVENTURE/adventure_connector.lua`. + +To connect the client to the multiserver simply put `
:` on the textfield on top and press enter (if the +server uses password, type in the bottom textfield `/connect
: [password]`) + +Press Reset and begin playing