From 1ac53026817222a4f1087f3b158562e9687c857b Mon Sep 17 00:00:00 2001 From: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com> Date: Sun, 12 May 2024 19:23:51 -0400 Subject: [PATCH] Jak and Daxter: Implement New Game (#1) * Jak 1: Initial commit: Cell Locations, Items, and Regions modeled. * Jak 1: Wrote Regions, Rules, init. Untested. * Jak 1: Fixed mistakes, need better understanding of Entrances. * Jak 1: Fixed bugs, refactored Regions, added missing Special Checks. First spoiler log generated. * Jak 1: Add Scout Fly Locations, code and style cleanup. * Jak 1: Add Scout Flies to Regions. * Jak 1: Add version info. * Jak 1: Reduced code smell. * Jak 1: Fixed UT bugs, added Free The Sages as Locations. * Jak 1: Refactor ID scheme to better fit game's scheme. Add more subregions and rules, but still missing one-way Entrances. * Jak 1: Add some one-ways, adjust scout fly offset. * Jak 1: Found Scout Fly ID's for first 4 maps. * Jak 1: Add more scout fly ID's, refactor game/AP ID translation for easier reading and code reuse. * Jak 1: Fixed a few things. Four maps to go. * Jak 1: Last of the scout flies mapped! * Jak 1: simplify citadel sages logic. * Jak 1: WebWorld setup, some documentation. * Jak 1: Initial checkin of Client. Removed the colon from the game name. * Jak 1: Refactored client into components, working on async communication between the client and the game. * Jak 1: In tandem with new ArchipelaGOAL memory structure, define read_memory. * Jak 1: There's magic in the air... * Jak 1: Fixed bug translating scout fly ID's. * Jak 1: Make the REPL a little more verbose, easier to debug. * Jak 1: Did you know Snowy Mountain had such specific unlock requirements? I didn't. * Jak 1: Update Documentation. * Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops. * Jak 1: Simplified startup process, updated docs, prayed. * Jak 1: quick fix to settings. --- JakAndDaxterClient.py | 9 + worlds/jakanddaxter/Client.py | 200 +++++++++++++++ worlds/jakanddaxter/GameID.py | 5 + worlds/jakanddaxter/Items.py | 7 + worlds/jakanddaxter/JakAndDaxterOptions.py | 20 ++ worlds/jakanddaxter/Locations.py | 46 ++++ worlds/jakanddaxter/Regions.py | 229 +++++++++++++++++ worlds/jakanddaxter/Rules.py | 232 ++++++++++++++++++ worlds/jakanddaxter/__init__.py | 108 ++++++++ worlds/jakanddaxter/client/MemoryReader.py | 133 ++++++++++ worlds/jakanddaxter/client/ReplClient.py | 202 +++++++++++++++ .../en_Jak and Daxter The Precursor Legacy.md | 78 ++++++ worlds/jakanddaxter/docs/setup_en.md | 85 +++++++ worlds/jakanddaxter/locs/CellLocations.py | 188 ++++++++++++++ worlds/jakanddaxter/locs/OrbLocations.py | 101 ++++++++ worlds/jakanddaxter/locs/ScoutLocations.py | 227 +++++++++++++++++ worlds/jakanddaxter/requirements.txt | 1 + 17 files changed, 1871 insertions(+) create mode 100644 JakAndDaxterClient.py create mode 100644 worlds/jakanddaxter/Client.py create mode 100644 worlds/jakanddaxter/GameID.py create mode 100644 worlds/jakanddaxter/Items.py create mode 100644 worlds/jakanddaxter/JakAndDaxterOptions.py create mode 100644 worlds/jakanddaxter/Locations.py create mode 100644 worlds/jakanddaxter/Regions.py create mode 100644 worlds/jakanddaxter/Rules.py create mode 100644 worlds/jakanddaxter/__init__.py create mode 100644 worlds/jakanddaxter/client/MemoryReader.py create mode 100644 worlds/jakanddaxter/client/ReplClient.py create mode 100644 worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md create mode 100644 worlds/jakanddaxter/docs/setup_en.md create mode 100644 worlds/jakanddaxter/locs/CellLocations.py create mode 100644 worlds/jakanddaxter/locs/OrbLocations.py create mode 100644 worlds/jakanddaxter/locs/ScoutLocations.py create mode 100644 worlds/jakanddaxter/requirements.txt diff --git a/JakAndDaxterClient.py b/JakAndDaxterClient.py new file mode 100644 index 0000000000..040f8ff389 --- /dev/null +++ b/JakAndDaxterClient.py @@ -0,0 +1,9 @@ +import Utils +from worlds.jakanddaxter.Client import launch + +import ModuleUpdate +ModuleUpdate.update() + +if __name__ == '__main__': + Utils.init_logging("JakAndDaxterClient", exception_logger="Client") + launch() diff --git a/worlds/jakanddaxter/Client.py b/worlds/jakanddaxter/Client.py new file mode 100644 index 0000000000..b6e982a9e3 --- /dev/null +++ b/worlds/jakanddaxter/Client.py @@ -0,0 +1,200 @@ +import logging +import os +import subprocess +import typing +import asyncio +import colorama + +import Utils +from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled + +from worlds.jakanddaxter.GameID import jak1_name +from worlds.jakanddaxter.client.ReplClient import JakAndDaxterReplClient +from worlds.jakanddaxter.client.MemoryReader import JakAndDaxterMemoryReader + +import ModuleUpdate +ModuleUpdate.update() + + +all_tasks = set() + + +def create_task_log_exception(awaitable: typing.Awaitable) -> asyncio.Task: + async def _log_exception(a): + try: + return await a + except Exception as e: + logger.exception(e) + finally: + all_tasks.remove(task) + task = asyncio.create_task(_log_exception(awaitable)) + all_tasks.add(task) + return task + + +class JakAndDaxterClientCommandProcessor(ClientCommandProcessor): + ctx: "JakAndDaxterContext" + + # The command processor is not async and cannot use async tasks, so long-running operations + # like the /repl connect command (which takes 10-15 seconds to compile the game) have to be requested + # with user-initiated flags. The text client will hang while the operation runs, but at least we can + # inform the user to wait. The flags are checked by the agents every main_tick. + def _cmd_repl(self, *arguments: str): + """Sends a command to the OpenGOAL REPL. Arguments: + - connect : connect the client to the REPL (goalc). + - status : check internal status of the REPL.""" + if arguments: + if arguments[0] == "connect": + logger.info("This may take a bit... Wait for the success audio cue before continuing!") + self.ctx.repl.initiated_connect = True + if arguments[0] == "status": + self.ctx.repl.print_status() + + def _cmd_memr(self, *arguments: str): + """Sends a command to the Memory Reader. Arguments: + - connect : connect the memory reader to the game process (gk). + - status : check the internal status of the Memory Reader.""" + if arguments: + if arguments[0] == "connect": + self.ctx.memr.initiated_connect = True + if arguments[0] == "status": + self.ctx.memr.print_status() + + +class JakAndDaxterContext(CommonContext): + tags = {"AP"} + game = jak1_name + items_handling = 0b111 # Full item handling + command_processor = JakAndDaxterClientCommandProcessor + + # We'll need two agents working in tandem to handle two-way communication with the game. + # The REPL Client will handle the server->game direction by issuing commands directly to the running game. + # But the REPL cannot send information back to us, it only ingests information we send it. + # Luckily OpenGOAL sets up memory addresses to write to, that AutoSplit can read from, for speedrunning. + # We'll piggyback off this system with a Memory Reader, and that will handle the game->server direction. + repl: JakAndDaxterReplClient + memr: JakAndDaxterMemoryReader + + # And two associated tasks, so we have handles on them. + repl_task: asyncio.Task + memr_task: asyncio.Task + + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: + self.repl = JakAndDaxterReplClient() + self.memr = JakAndDaxterMemoryReader() + super().__init__(server_address, password) + + def run_gui(self): + from kvui import GameManager + + class JakAndDaxterManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Jak and Daxter ArchipelaGOAL Client" + + self.ui = JakAndDaxterManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(JakAndDaxterContext, self).server_auth(password_requested) + await self.get_username() + await self.send_connect() + + def on_package(self, cmd: str, args: dict): + if cmd == "ReceivedItems": + for index, item in enumerate(args["items"], start=args["index"]): + logger.info(args) + self.repl.item_inbox[index] = item + + async def ap_inform_location_checks(self, location_ids: typing.List[int]): + message = [{"cmd": "LocationChecks", "locations": location_ids}] + await self.send_msgs(message) + + def on_locations(self, location_ids: typing.List[int]): + create_task_log_exception(self.ap_inform_location_checks(location_ids)) + + async def run_repl_loop(self): + while True: + await self.repl.main_tick() + await asyncio.sleep(0.1) + + async def run_memr_loop(self): + while True: + await self.memr.main_tick(self.on_locations) + await asyncio.sleep(0.1) + + +async def run_game(ctx: JakAndDaxterContext): + exec_directory = "" + try: + exec_directory = Utils.get_settings()["jakanddaxter_options"]["root_directory"] + files_in_path = os.listdir(exec_directory) + if ".git" in files_in_path: + # Indicates the user is running from source, append expected subdirectory appropriately. + exec_directory = os.path.join(exec_directory, "out", "build", "Release", "bin") + else: + # Indicates the user is running from the official launcher, a mod launcher, or otherwise. + # We'll need to handle version numbers in the path somehow... + exec_directory = os.path.join(exec_directory, "versions", "official") + latest_version = list(reversed(os.listdir(exec_directory)))[0] + exec_directory = os.path.join(exec_directory, str(latest_version)) + except FileNotFoundError: + logger.error(f"Unable to locate directory {exec_directory}, " + f"unable to locate game executable.") + return + except KeyError as e: + logger.error(f"Hosts.yaml does not contain {e.args[0]}, " + f"unable to locate game executable.") + return + + gk = os.path.join(exec_directory, "gk.exe") + goalc = os.path.join(exec_directory, "goalc.exe") + + # Don't mind all the arguments, they are exactly what you get when you run "task boot-game" or "task repl". + await asyncio.create_subprocess_exec( + gk, + "-v", "--game jak1", "--", "-boot", "-fakeiso", "-debug", + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + stdin=subprocess.DEVNULL) + + # You MUST launch goalc as a console application, so powershell/cmd/bash/etc is the program + # and goalc is just an argument. It HAS to be this way. + # TODO - Support other OS's. + await asyncio.create_subprocess_exec( + "powershell.exe", + goalc, "--user-auto", "--game jak1") + + # Auto connect the repl and memr agents. Sleep 5 because goalc takes just a little bit of time to load, + # and it's not something we can await. + logger.info("This may take a bit... Wait for the success audio cue before continuing!") + await asyncio.sleep(5) + ctx.repl.initiated_connect = True + ctx.memr.initiated_connect = True + + +async def main(): + Utils.init_logging("JakAndDaxterClient", exception_logger="Client") + + ctx = JakAndDaxterContext(None, None) + + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + ctx.repl_task = create_task_log_exception(ctx.run_repl_loop()) + ctx.memr_task = create_task_log_exception(ctx.run_memr_loop()) + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + # Find and run the game (gk) and compiler/repl (goalc). + await run_game(ctx) + await ctx.exit_event.wait() + await ctx.shutdown() + + +def launch(): + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/worlds/jakanddaxter/GameID.py b/worlds/jakanddaxter/GameID.py new file mode 100644 index 0000000000..555be696af --- /dev/null +++ b/worlds/jakanddaxter/GameID.py @@ -0,0 +1,5 @@ +# All Jak And Daxter Archipelago IDs must be offset by this number. +jak1_id = 741000000 + +# The name of the game. +jak1_name = "Jak and Daxter The Precursor Legacy" diff --git a/worlds/jakanddaxter/Items.py b/worlds/jakanddaxter/Items.py new file mode 100644 index 0000000000..d051f15869 --- /dev/null +++ b/worlds/jakanddaxter/Items.py @@ -0,0 +1,7 @@ +from BaseClasses import Item +from .GameID import jak1_name + + +class JakAndDaxterItem(Item): + game: str = jak1_name + diff --git a/worlds/jakanddaxter/JakAndDaxterOptions.py b/worlds/jakanddaxter/JakAndDaxterOptions.py new file mode 100644 index 0000000000..cfed39815c --- /dev/null +++ b/worlds/jakanddaxter/JakAndDaxterOptions.py @@ -0,0 +1,20 @@ +import os +from dataclasses import dataclass +from Options import Toggle, PerGameCommonOptions + + +# class EnableScoutFlies(Toggle): +# """Enable to include each Scout Fly as a check. Adds 112 checks to the pool.""" +# display_name = "Enable Scout Flies" + + +# class EnablePrecursorOrbs(Toggle): +# """Enable to include each Precursor Orb as a check. Adds 2000 checks to the pool.""" +# display_name = "Enable Precursor Orbs" + + +@dataclass +class JakAndDaxterOptions(PerGameCommonOptions): + # enable_scout_flies: EnableScoutFlies + # enable_precursor_orbs: EnablePrecursorOrbs + pass diff --git a/worlds/jakanddaxter/Locations.py b/worlds/jakanddaxter/Locations.py new file mode 100644 index 0000000000..ef56137cf1 --- /dev/null +++ b/worlds/jakanddaxter/Locations.py @@ -0,0 +1,46 @@ +from BaseClasses import Location +from .GameID import jak1_name +from .locs import CellLocations as Cells, ScoutLocations as Scouts + + +class JakAndDaxterLocation(Location): + game: str = jak1_name + + +# All Locations +# Because all items in Jak And Daxter are unique and do not regenerate, we can use this same table as our item table. +# Each Item ID == its corresponding Location ID. While we're here, do all the ID conversions needed. +location_table = { + **{Cells.to_ap_id(k): Cells.locGR_cellTable[k] for k in Cells.locGR_cellTable}, + **{Cells.to_ap_id(k): Cells.locSV_cellTable[k] for k in Cells.locSV_cellTable}, + **{Cells.to_ap_id(k): Cells.locFJ_cellTable[k] for k in Cells.locFJ_cellTable}, + **{Cells.to_ap_id(k): Cells.locSB_cellTable[k] for k in Cells.locSB_cellTable}, + **{Cells.to_ap_id(k): Cells.locMI_cellTable[k] for k in Cells.locMI_cellTable}, + **{Cells.to_ap_id(k): Cells.locFC_cellTable[k] for k in Cells.locFC_cellTable}, + **{Cells.to_ap_id(k): Cells.locRV_cellTable[k] for k in Cells.locRV_cellTable}, + **{Cells.to_ap_id(k): Cells.locPB_cellTable[k] for k in Cells.locPB_cellTable}, + **{Cells.to_ap_id(k): Cells.locLPC_cellTable[k] for k in Cells.locLPC_cellTable}, + **{Cells.to_ap_id(k): Cells.locBS_cellTable[k] for k in Cells.locBS_cellTable}, + **{Cells.to_ap_id(k): Cells.locMP_cellTable[k] for k in Cells.locMP_cellTable}, + **{Cells.to_ap_id(k): Cells.locVC_cellTable[k] for k in Cells.locVC_cellTable}, + **{Cells.to_ap_id(k): Cells.locSC_cellTable[k] for k in Cells.locSC_cellTable}, + **{Cells.to_ap_id(k): Cells.locSM_cellTable[k] for k in Cells.locSM_cellTable}, + **{Cells.to_ap_id(k): Cells.locLT_cellTable[k] for k in Cells.locLT_cellTable}, + **{Cells.to_ap_id(k): Cells.locGMC_cellTable[k] for k in Cells.locGMC_cellTable}, + **{Scouts.to_ap_id(k): Scouts.locGR_scoutTable[k] for k in Scouts.locGR_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locSV_scoutTable[k] for k in Scouts.locSV_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locFJ_scoutTable[k] for k in Scouts.locFJ_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locSB_scoutTable[k] for k in Scouts.locSB_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locMI_scoutTable[k] for k in Scouts.locMI_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locFC_scoutTable[k] for k in Scouts.locFC_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locRV_scoutTable[k] for k in Scouts.locRV_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locPB_scoutTable[k] for k in Scouts.locPB_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locLPC_scoutTable[k] for k in Scouts.locLPC_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locBS_scoutTable[k] for k in Scouts.locBS_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locMP_scoutTable[k] for k in Scouts.locMP_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locVC_scoutTable[k] for k in Scouts.locVC_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locSC_scoutTable[k] for k in Scouts.locSC_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locSM_scoutTable[k] for k in Scouts.locSM_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locLT_scoutTable[k] for k in Scouts.locLT_scoutTable}, + **{Scouts.to_ap_id(k): Scouts.locGMC_scoutTable[k] for k in Scouts.locGMC_scoutTable} +} diff --git a/worlds/jakanddaxter/Regions.py b/worlds/jakanddaxter/Regions.py new file mode 100644 index 0000000000..fa4a6aa880 --- /dev/null +++ b/worlds/jakanddaxter/Regions.py @@ -0,0 +1,229 @@ +import typing +from enum import Enum, auto +from BaseClasses import MultiWorld, Region +from .GameID import jak1_name +from .JakAndDaxterOptions import JakAndDaxterOptions +from .Locations import JakAndDaxterLocation, location_table +from .locs import CellLocations as Cells, ScoutLocations as Scouts + + +class Jak1Level(int, Enum): + GEYSER_ROCK = auto() + SANDOVER_VILLAGE = auto() + FORBIDDEN_JUNGLE = auto() + SENTINEL_BEACH = auto() + MISTY_ISLAND = auto() + FIRE_CANYON = auto() + ROCK_VILLAGE = auto() + PRECURSOR_BASIN = auto() + LOST_PRECURSOR_CITY = auto() + BOGGY_SWAMP = auto() + MOUNTAIN_PASS = auto() + VOLCANIC_CRATER = auto() + SPIDER_CAVE = auto() + SNOWY_MOUNTAIN = auto() + LAVA_TUBE = auto() + GOL_AND_MAIAS_CITADEL = auto() + + +class Jak1SubLevel(int, Enum): + MAIN_AREA = auto() + FORBIDDEN_JUNGLE_SWITCH_ROOM = auto() + FORBIDDEN_JUNGLE_PLANT_ROOM = auto() + SENTINEL_BEACH_CANNON_TOWER = auto() + PRECURSOR_BASIN_BLUE_RINGS = auto() + LOST_PRECURSOR_CITY_SUNKEN_ROOM = auto() + LOST_PRECURSOR_CITY_HELIX_ROOM = auto() + BOGGY_SWAMP_FLUT_FLUT = auto() + MOUNTAIN_PASS_RACE = auto() + MOUNTAIN_PASS_SHORTCUT = auto() + SNOWY_MOUNTAIN_FLUT_FLUT = auto() + SNOWY_MOUNTAIN_LURKER_FORT = auto() + SNOWY_MOUNTAIN_FROZEN_BOX = auto() + GOL_AND_MAIAS_CITADEL_ROTATING_TOWER = auto() + GOL_AND_MAIAS_CITADEL_FINAL_BOSS = auto() + + +level_table: typing.Dict[Jak1Level, str] = { + Jak1Level.GEYSER_ROCK: "Geyser Rock", + Jak1Level.SANDOVER_VILLAGE: "Sandover Village", + Jak1Level.FORBIDDEN_JUNGLE: "Forbidden Jungle", + Jak1Level.SENTINEL_BEACH: "Sentinel Beach", + Jak1Level.MISTY_ISLAND: "Misty Island", + Jak1Level.FIRE_CANYON: "Fire Canyon", + Jak1Level.ROCK_VILLAGE: "Rock Village", + Jak1Level.PRECURSOR_BASIN: "Precursor Basin", + Jak1Level.LOST_PRECURSOR_CITY: "Lost Precursor City", + Jak1Level.BOGGY_SWAMP: "Boggy Swamp", + Jak1Level.MOUNTAIN_PASS: "Mountain Pass", + Jak1Level.VOLCANIC_CRATER: "Volcanic Crater", + Jak1Level.SPIDER_CAVE: "Spider Cave", + Jak1Level.SNOWY_MOUNTAIN: "Snowy Mountain", + Jak1Level.LAVA_TUBE: "Lava Tube", + Jak1Level.GOL_AND_MAIAS_CITADEL: "Gol and Maia's Citadel" +} + +subLevel_table: typing.Dict[Jak1SubLevel, str] = { + Jak1SubLevel.MAIN_AREA: "Main Area", + Jak1SubLevel.FORBIDDEN_JUNGLE_SWITCH_ROOM: "Forbidden Jungle Switch Room", + Jak1SubLevel.FORBIDDEN_JUNGLE_PLANT_ROOM: "Forbidden Jungle Plant Room", + Jak1SubLevel.SENTINEL_BEACH_CANNON_TOWER: "Sentinel Beach Cannon Tower", + Jak1SubLevel.PRECURSOR_BASIN_BLUE_RINGS: "Precursor Basin Blue Rings", + Jak1SubLevel.LOST_PRECURSOR_CITY_SUNKEN_ROOM: "Lost Precursor City Sunken Room", + Jak1SubLevel.LOST_PRECURSOR_CITY_HELIX_ROOM: "Lost Precursor City Helix Room", + Jak1SubLevel.BOGGY_SWAMP_FLUT_FLUT: "Boggy Swamp Flut Flut", + Jak1SubLevel.MOUNTAIN_PASS_RACE: "Mountain Pass Race", + Jak1SubLevel.MOUNTAIN_PASS_SHORTCUT: "Mountain Pass Shortcut", + Jak1SubLevel.SNOWY_MOUNTAIN_FLUT_FLUT: "Snowy Mountain Flut Flut", + Jak1SubLevel.SNOWY_MOUNTAIN_LURKER_FORT: "Snowy Mountain Lurker Fort", + Jak1SubLevel.SNOWY_MOUNTAIN_FROZEN_BOX: "Snowy Mountain Frozen Box", + Jak1SubLevel.GOL_AND_MAIAS_CITADEL_ROTATING_TOWER: "Gol and Maia's Citadel Rotating Tower", + Jak1SubLevel.GOL_AND_MAIAS_CITADEL_FINAL_BOSS: "Gol and Maia's Citadel Final Boss" +} + + +class JakAndDaxterRegion(Region): + game: str = jak1_name + + +# Use the original game ID's for each item to tell the Region which Locations are available in it. +# You do NOT need to add the item offsets or game ID, that will be handled by create_*_locations. +def create_regions(multiworld: MultiWorld, options: JakAndDaxterOptions, player: int): + create_region(player, multiworld, "Menu") + + region_gr = create_region(player, multiworld, level_table[Jak1Level.GEYSER_ROCK]) + create_cell_locations(region_gr, Cells.locGR_cellTable) + create_fly_locations(region_gr, Scouts.locGR_scoutTable) + + region_sv = create_region(player, multiworld, level_table[Jak1Level.SANDOVER_VILLAGE]) + create_cell_locations(region_sv, Cells.locSV_cellTable) + create_fly_locations(region_sv, Scouts.locSV_scoutTable) + + region_fj = create_region(player, multiworld, level_table[Jak1Level.FORBIDDEN_JUNGLE]) + create_cell_locations(region_fj, {k: Cells.locFJ_cellTable[k] for k in {3, 4, 5, 8, 9, 7}}) + create_fly_locations(region_fj, Scouts.locFJ_scoutTable) + + sub_region_fjsr = create_subregion(region_fj, subLevel_table[Jak1SubLevel.FORBIDDEN_JUNGLE_SWITCH_ROOM]) + create_cell_locations(sub_region_fjsr, {k: Cells.locFJ_cellTable[k] for k in {2}}) + + sub_region_fjpr = create_subregion(sub_region_fjsr, subLevel_table[Jak1SubLevel.FORBIDDEN_JUNGLE_PLANT_ROOM]) + create_cell_locations(sub_region_fjpr, {k: Cells.locFJ_cellTable[k] for k in {6}}) + + region_sb = create_region(player, multiworld, level_table[Jak1Level.SENTINEL_BEACH]) + create_cell_locations(region_sb, {k: Cells.locSB_cellTable[k] for k in {15, 17, 16, 18, 21, 22, 20}}) + create_fly_locations(region_sb, Scouts.locSB_scoutTable) + + sub_region_sbct = create_subregion(region_sb, subLevel_table[Jak1SubLevel.SENTINEL_BEACH_CANNON_TOWER]) + create_cell_locations(sub_region_sbct, {k: Cells.locSB_cellTable[k] for k in {19}}) + + region_mi = create_region(player, multiworld, level_table[Jak1Level.MISTY_ISLAND]) + create_cell_locations(region_mi, Cells.locMI_cellTable) + create_fly_locations(region_mi, Scouts.locMI_scoutTable) + + region_fc = create_region(player, multiworld, level_table[Jak1Level.FIRE_CANYON]) + create_cell_locations(region_fc, Cells.locFC_cellTable) + create_fly_locations(region_fc, Scouts.locFC_scoutTable) + + region_rv = create_region(player, multiworld, level_table[Jak1Level.ROCK_VILLAGE]) + create_cell_locations(region_rv, Cells.locRV_cellTable) + create_fly_locations(region_rv, Scouts.locRV_scoutTable) + + region_pb = create_region(player, multiworld, level_table[Jak1Level.PRECURSOR_BASIN]) + create_cell_locations(region_pb, {k: Cells.locPB_cellTable[k] for k in {54, 53, 52, 56, 55, 58, 57}}) + create_fly_locations(region_pb, Scouts.locPB_scoutTable) + + sub_region_pbbr = create_subregion(region_pb, subLevel_table[Jak1SubLevel.PRECURSOR_BASIN_BLUE_RINGS]) + create_cell_locations(sub_region_pbbr, {k: Cells.locPB_cellTable[k] for k in {59}}) + + region_lpc = create_region(player, multiworld, level_table[Jak1Level.LOST_PRECURSOR_CITY]) + create_cell_locations(region_lpc, {k: Cells.locLPC_cellTable[k] for k in {45, 48, 44, 51}}) + create_fly_locations(region_lpc, {k: Scouts.locLPC_scoutTable[k] + for k in {262193, 131121, 393265, 196657, 49, 65585}}) + + sub_region_lpcsr = create_subregion(region_lpc, subLevel_table[Jak1SubLevel.LOST_PRECURSOR_CITY_SUNKEN_ROOM]) + create_cell_locations(sub_region_lpcsr, {k: Cells.locLPC_cellTable[k] for k in {47, 49}}) + create_fly_locations(region_lpc, {k: Scouts.locLPC_scoutTable[k] for k in {327729}}) + + sub_region_lpchr = create_subregion(region_lpc, subLevel_table[Jak1SubLevel.LOST_PRECURSOR_CITY_HELIX_ROOM]) + create_cell_locations(sub_region_lpchr, {k: Cells.locLPC_cellTable[k] for k in {46, 50}}) + + region_bs = create_region(player, multiworld, level_table[Jak1Level.BOGGY_SWAMP]) + create_cell_locations(region_bs, {k: Cells.locBS_cellTable[k] for k in {36, 38, 39, 40, 41, 42}}) + create_fly_locations(region_bs, {k: Scouts.locBS_scoutTable[k] for k in {43, 393259, 65579, 262187, 196651}}) + + sub_region_bsff = create_subregion(region_bs, subLevel_table[Jak1SubLevel.BOGGY_SWAMP_FLUT_FLUT]) + create_cell_locations(sub_region_bsff, {k: Cells.locBS_cellTable[k] for k in {43, 37}}) + create_fly_locations(sub_region_bsff, {k: Scouts.locBS_scoutTable[k] for k in {327723, 131115}}) + + region_mp = create_region(player, multiworld, level_table[Jak1Level.MOUNTAIN_PASS]) + create_cell_locations(region_mp, {k: Cells.locMP_cellTable[k] for k in {86}}) + + sub_region_mpr = create_subregion(region_mp, subLevel_table[Jak1SubLevel.MOUNTAIN_PASS_RACE]) + create_cell_locations(sub_region_mpr, {k: Cells.locMP_cellTable[k] for k in {87, 88}}) + create_fly_locations(sub_region_mpr, Scouts.locMP_scoutTable) + + sub_region_mps = create_subregion(sub_region_mpr, subLevel_table[Jak1SubLevel.MOUNTAIN_PASS_SHORTCUT]) + create_cell_locations(sub_region_mps, {k: Cells.locMP_cellTable[k] for k in {110}}) + + region_vc = create_region(player, multiworld, level_table[Jak1Level.VOLCANIC_CRATER]) + create_cell_locations(region_vc, Cells.locVC_cellTable) + create_fly_locations(region_vc, Scouts.locVC_scoutTable) + + region_sc = create_region(player, multiworld, level_table[Jak1Level.SPIDER_CAVE]) + create_cell_locations(region_sc, Cells.locSC_cellTable) + create_fly_locations(region_sc, Scouts.locSC_scoutTable) + + region_sm = create_region(player, multiworld, level_table[Jak1Level.SNOWY_MOUNTAIN]) + create_cell_locations(region_sm, {k: Cells.locSM_cellTable[k] for k in {60, 61, 66, 64}}) + create_fly_locations(region_sm, {k: Scouts.locSM_scoutTable[k] for k in {65, 327745, 65601, 131137, 393281}}) + + sub_region_smfb = create_subregion(region_sm, subLevel_table[Jak1SubLevel.SNOWY_MOUNTAIN_FROZEN_BOX]) + create_cell_locations(sub_region_smfb, {k: Cells.locSM_cellTable[k] for k in {67}}) + + sub_region_smff = create_subregion(region_sm, subLevel_table[Jak1SubLevel.SNOWY_MOUNTAIN_FLUT_FLUT]) + create_cell_locations(sub_region_smff, {k: Cells.locSM_cellTable[k] for k in {63}}) + + sub_region_smlf = create_subregion(region_sm, subLevel_table[Jak1SubLevel.SNOWY_MOUNTAIN_LURKER_FORT]) + create_cell_locations(sub_region_smlf, {k: Cells.locSM_cellTable[k] for k in {62, 65}}) + create_fly_locations(sub_region_smlf, {k: Scouts.locSM_scoutTable[k] for k in {196673, 262209}}) + + region_lt = create_region(player, multiworld, level_table[Jak1Level.LAVA_TUBE]) + create_cell_locations(region_lt, Cells.locLT_cellTable) + create_fly_locations(region_lt, Scouts.locLT_scoutTable) + + region_gmc = create_region(player, multiworld, level_table[Jak1Level.GOL_AND_MAIAS_CITADEL]) + create_cell_locations(region_gmc, {k: Cells.locGMC_cellTable[k] for k in {71, 72, 73}}) + create_fly_locations(region_gmc, {k: Scouts.locGMC_scoutTable[k] + for k in {91, 65627, 196699, 262235, 393307, 131163}}) + + sub_region_gmcrt = create_subregion(region_gmc, subLevel_table[Jak1SubLevel.GOL_AND_MAIAS_CITADEL_ROTATING_TOWER]) + create_cell_locations(sub_region_gmcrt, {k: Cells.locGMC_cellTable[k] for k in {70, 91}}) + create_fly_locations(sub_region_gmcrt, {k: Scouts.locGMC_scoutTable[k] for k in {327771}}) + + create_subregion(sub_region_gmcrt, subLevel_table[Jak1SubLevel.GOL_AND_MAIAS_CITADEL_FINAL_BOSS]) + + +def create_region(player: int, multiworld: MultiWorld, name: str) -> JakAndDaxterRegion: + region = JakAndDaxterRegion(name, player, multiworld) + multiworld.regions.append(region) + return region + + +def create_subregion(parent: Region, name: str) -> JakAndDaxterRegion: + region = JakAndDaxterRegion(name, parent.player, parent.multiworld) + parent.multiworld.regions.append(region) + return region + + +def create_cell_locations(region: Region, locations: typing.Dict[int, str]): + region.locations += [JakAndDaxterLocation(region.player, + location_table[Cells.to_ap_id(loc)], + Cells.to_ap_id(loc), + region) for loc in locations] + + +def create_fly_locations(region: Region, locations: typing.Dict[int, str]): + region.locations += [JakAndDaxterLocation(region.player, + location_table[Scouts.to_ap_id(loc)], + Scouts.to_ap_id(loc), + region) for loc in locations] diff --git a/worlds/jakanddaxter/Rules.py b/worlds/jakanddaxter/Rules.py new file mode 100644 index 0000000000..bb355e0e2c --- /dev/null +++ b/worlds/jakanddaxter/Rules.py @@ -0,0 +1,232 @@ +from BaseClasses import MultiWorld, CollectionState +from .JakAndDaxterOptions import JakAndDaxterOptions +from .Regions import Jak1Level, Jak1SubLevel, level_table, subLevel_table +from .Locations import location_table as item_table +from .locs import CellLocations as Cells, ScoutLocations as Scouts + + +# Helper function for a handful of special cases +# where we need "at least any N" number of a specific set of cells. +def has_count_of(cell_list: set, required_count: int, player: int, state: CollectionState) -> bool: + c: int = 0 + for k in cell_list: + if state.has(item_table[k], player): + c += 1 + if c >= required_count: + return True + return False + + +def set_rules(multiworld: MultiWorld, options: JakAndDaxterOptions, player: int): + # Setting up some useful variables here because the offset numbers can get confusing + # for access rules. Feel free to add more variables here to keep the code more readable. + # You DO need to convert the game ID's to AP ID's here. + gr_cells = {Cells.to_ap_id(k) for k in Cells.locGR_cellTable} + fj_temple_top = Cells.to_ap_id(4) + fj_blue_switch = Cells.to_ap_id(2) + fj_plant_boss = Cells.to_ap_id(6) + fj_fisherman = Cells.to_ap_id(5) + sb_flut_flut = Cells.to_ap_id(17) + fc_end = Cells.to_ap_id(69) + pb_purple_rings = Cells.to_ap_id(58) + lpc_sunken = Cells.to_ap_id(47) + lpc_helix = Cells.to_ap_id(50) + mp_klaww = Cells.to_ap_id(86) + mp_end = Cells.to_ap_id(87) + pre_sm_cells = {Cells.to_ap_id(k) for k in {**Cells.locVC_cellTable, **Cells.locSC_cellTable}} + sm_yellow_switch = Cells.to_ap_id(60) + sm_fort_gate = Cells.to_ap_id(63) + lt_end = Cells.to_ap_id(89) + gmc_rby_sages = {Cells.to_ap_id(k) for k in {71, 72, 73}} + gmc_green_sage = Cells.to_ap_id(70) + + # Start connecting regions and set their access rules. + connect_start(multiworld, player, Jak1Level.GEYSER_ROCK) + + connect_regions(multiworld, player, + Jak1Level.GEYSER_ROCK, + Jak1Level.SANDOVER_VILLAGE, + lambda state: has_count_of(gr_cells, 4, player, state)) + + connect_regions(multiworld, player, + Jak1Level.SANDOVER_VILLAGE, + Jak1Level.FORBIDDEN_JUNGLE) + + connect_region_to_sub(multiworld, player, + Jak1Level.FORBIDDEN_JUNGLE, + Jak1SubLevel.FORBIDDEN_JUNGLE_SWITCH_ROOM, + lambda state: state.has(item_table[fj_temple_top], player)) + + connect_subregions(multiworld, player, + Jak1SubLevel.FORBIDDEN_JUNGLE_SWITCH_ROOM, + Jak1SubLevel.FORBIDDEN_JUNGLE_PLANT_ROOM, + lambda state: state.has(item_table[fj_blue_switch], player)) + + connect_sub_to_region(multiworld, player, + Jak1SubLevel.FORBIDDEN_JUNGLE_PLANT_ROOM, + Jak1Level.FORBIDDEN_JUNGLE, + lambda state: state.has(item_table[fj_plant_boss], player)) + + connect_regions(multiworld, player, + Jak1Level.SANDOVER_VILLAGE, + Jak1Level.SENTINEL_BEACH) + + connect_region_to_sub(multiworld, player, + Jak1Level.SENTINEL_BEACH, + Jak1SubLevel.SENTINEL_BEACH_CANNON_TOWER, + lambda state: state.has(item_table[fj_blue_switch], player)) + + connect_regions(multiworld, player, + Jak1Level.SANDOVER_VILLAGE, + Jak1Level.MISTY_ISLAND, + lambda state: state.has(item_table[fj_fisherman], player)) + + connect_regions(multiworld, player, + Jak1Level.SANDOVER_VILLAGE, + Jak1Level.FIRE_CANYON, + lambda state: state.count_group("Power Cell", player) >= 20) + + connect_regions(multiworld, player, + Jak1Level.FIRE_CANYON, + Jak1Level.ROCK_VILLAGE, + lambda state: state.has(item_table[fc_end], player)) + + connect_regions(multiworld, player, + Jak1Level.ROCK_VILLAGE, + Jak1Level.PRECURSOR_BASIN) + + connect_region_to_sub(multiworld, player, + Jak1Level.PRECURSOR_BASIN, + Jak1SubLevel.PRECURSOR_BASIN_BLUE_RINGS, + lambda state: state.has(item_table[pb_purple_rings], player)) + + connect_regions(multiworld, player, + Jak1Level.ROCK_VILLAGE, + Jak1Level.LOST_PRECURSOR_CITY) + + connect_region_to_sub(multiworld, player, + Jak1Level.LOST_PRECURSOR_CITY, + Jak1SubLevel.LOST_PRECURSOR_CITY_SUNKEN_ROOM) + + connect_subregions(multiworld, player, + Jak1SubLevel.LOST_PRECURSOR_CITY_SUNKEN_ROOM, + Jak1SubLevel.LOST_PRECURSOR_CITY_HELIX_ROOM) + + connect_sub_to_region(multiworld, player, + Jak1SubLevel.LOST_PRECURSOR_CITY_HELIX_ROOM, + Jak1Level.LOST_PRECURSOR_CITY, + lambda state: state.has(item_table[lpc_helix], player)) + + connect_sub_to_region(multiworld, player, + Jak1SubLevel.LOST_PRECURSOR_CITY_SUNKEN_ROOM, + Jak1Level.ROCK_VILLAGE, + lambda state: state.has(item_table[lpc_sunken], player)) + + connect_regions(multiworld, player, + Jak1Level.ROCK_VILLAGE, + Jak1Level.BOGGY_SWAMP) + + connect_region_to_sub(multiworld, player, + Jak1Level.BOGGY_SWAMP, + Jak1SubLevel.BOGGY_SWAMP_FLUT_FLUT, + lambda state: state.has(item_table[sb_flut_flut], player)) + + connect_regions(multiworld, player, + Jak1Level.ROCK_VILLAGE, + Jak1Level.MOUNTAIN_PASS, + lambda state: state.count_group("Power Cell", player) >= 45) + + connect_region_to_sub(multiworld, player, + Jak1Level.MOUNTAIN_PASS, + Jak1SubLevel.MOUNTAIN_PASS_RACE, + lambda state: state.has(item_table[mp_klaww], player)) + + connect_subregions(multiworld, player, + Jak1SubLevel.MOUNTAIN_PASS_RACE, + Jak1SubLevel.MOUNTAIN_PASS_SHORTCUT, + lambda state: state.has(item_table[sm_yellow_switch], player)) + + connect_sub_to_region(multiworld, player, + Jak1SubLevel.MOUNTAIN_PASS_RACE, + Jak1Level.VOLCANIC_CRATER, + lambda state: state.has(item_table[mp_end], player)) + + connect_regions(multiworld, player, + Jak1Level.VOLCANIC_CRATER, + Jak1Level.SPIDER_CAVE) + + connect_regions(multiworld, player, + Jak1Level.VOLCANIC_CRATER, + Jak1Level.SNOWY_MOUNTAIN, + lambda state: has_count_of(pre_sm_cells, 2, player, state) + or state.count_group("Power Cell", player) >= 71) # Yeah, this is a weird one. + + connect_region_to_sub(multiworld, player, + Jak1Level.SNOWY_MOUNTAIN, + Jak1SubLevel.SNOWY_MOUNTAIN_FROZEN_BOX, + lambda state: state.has(item_table[sm_yellow_switch], player)) + + connect_region_to_sub(multiworld, player, + Jak1Level.SNOWY_MOUNTAIN, + Jak1SubLevel.SNOWY_MOUNTAIN_FLUT_FLUT, + lambda state: state.has(item_table[sb_flut_flut], player)) + + connect_region_to_sub(multiworld, player, + Jak1Level.SNOWY_MOUNTAIN, + Jak1SubLevel.SNOWY_MOUNTAIN_LURKER_FORT, + lambda state: state.has(item_table[sm_fort_gate], player)) + + connect_regions(multiworld, player, + Jak1Level.VOLCANIC_CRATER, + Jak1Level.LAVA_TUBE, + lambda state: state.count_group("Power Cell", player) >= 72) + + connect_regions(multiworld, player, + Jak1Level.LAVA_TUBE, + Jak1Level.GOL_AND_MAIAS_CITADEL, + lambda state: state.has(item_table[lt_end], player)) + + connect_region_to_sub(multiworld, player, + Jak1Level.GOL_AND_MAIAS_CITADEL, + Jak1SubLevel.GOL_AND_MAIAS_CITADEL_ROTATING_TOWER, + lambda state: has_count_of(gmc_rby_sages, 3, player, state)) + + connect_subregions(multiworld, player, + Jak1SubLevel.GOL_AND_MAIAS_CITADEL_ROTATING_TOWER, + Jak1SubLevel.GOL_AND_MAIAS_CITADEL_FINAL_BOSS, + lambda state: state.has(item_table[gmc_green_sage], player)) + + multiworld.completion_condition[player] = lambda state: state.can_reach( + multiworld.get_region(subLevel_table[Jak1SubLevel.GOL_AND_MAIAS_CITADEL_FINAL_BOSS], player), + "Region", + player) + + +def connect_start(multiworld: MultiWorld, player: int, target: Jak1Level): + menu_region = multiworld.get_region("Menu", player) + start_region = multiworld.get_region(level_table[target], player) + menu_region.connect(start_region) + + +def connect_regions(multiworld: MultiWorld, player: int, source: Jak1Level, target: Jak1Level, rule=None): + source_region = multiworld.get_region(level_table[source], player) + target_region = multiworld.get_region(level_table[target], player) + source_region.connect(target_region, rule=rule) + + +def connect_region_to_sub(multiworld: MultiWorld, player: int, source: Jak1Level, target: Jak1SubLevel, rule=None): + source_region = multiworld.get_region(level_table[source], player) + target_region = multiworld.get_region(subLevel_table[target], player) + source_region.connect(target_region, rule=rule) + + +def connect_sub_to_region(multiworld: MultiWorld, player: int, source: Jak1SubLevel, target: Jak1Level, rule=None): + source_region = multiworld.get_region(subLevel_table[source], player) + target_region = multiworld.get_region(level_table[target], player) + source_region.connect(target_region, rule=rule) + + +def connect_subregions(multiworld: MultiWorld, player: int, source: Jak1SubLevel, target: Jak1SubLevel, rule=None): + source_region = multiworld.get_region(subLevel_table[source], player) + target_region = multiworld.get_region(subLevel_table[target], player) + source_region.connect(target_region, rule=rule) diff --git a/worlds/jakanddaxter/__init__.py b/worlds/jakanddaxter/__init__.py new file mode 100644 index 0000000000..75a87c9dbf --- /dev/null +++ b/worlds/jakanddaxter/__init__.py @@ -0,0 +1,108 @@ +import typing +import settings + +from BaseClasses import Item, ItemClassification, Tutorial +from .GameID import jak1_id, jak1_name +from .JakAndDaxterOptions import JakAndDaxterOptions +from .Items import JakAndDaxterItem +from .Locations import JakAndDaxterLocation, location_table as item_table +from .locs import CellLocations as Cells, ScoutLocations as Scouts, OrbLocations as Orbs +from .Regions import create_regions +from .Rules import set_rules +from ..AutoWorld import World, WebWorld +from ..LauncherComponents import components, Component, launch_subprocess, Type + + +class JakAndDaxterSettings(settings.Group): + class RootDirectory(settings.UserFolderPath): + """Path to folder containing the ArchipelaGOAL mod.""" + description = "ArchipelaGOAL Root Directory" + + root_directory: RootDirectory = RootDirectory("D:/Files/Repositories/ArchipelaGOAL") + + +class JakAndDaxterWebWorld(WebWorld): + setup_en = Tutorial( + "Multiworld Setup Guide", + "A guide to setting up ArchipelaGOAL (Archipelago on OpenGOAL).", + "English", + "setup_en.md", + "setup/en", + ["markustulliuscicero"] + ) + + tutorials = [setup_en] + + +class JakAndDaxterWorld(World): + """ + Jak and Daxter: The Precursor Legacy is a 2001 action platformer developed by Naughty Dog + for the PlayStation 2. The game follows the eponymous protagonists, a young boy named Jak + and his friend Daxter, who has been transformed into an ottsel. With the help of Samos + the Sage of Green Eco and his daughter Keira, the pair travel north in search of a cure for Daxter, + discovering artifacts created by an ancient race known as the Precursors along the way. When the + rogue sages Gol and Maia Acheron plan to flood the world with Dark Eco, they must stop their evil plan + and save the world. + """ + # ID, name, version + game = jak1_name + data_version = 1 + required_client_version = (0, 4, 5) + + # Options + settings: typing.ClassVar[JakAndDaxterSettings] + options_dataclass = JakAndDaxterOptions + options: JakAndDaxterOptions + + # Web world + web = JakAndDaxterWebWorld() + + # Items and Locations + # Stored as {ID: Name} pairs, these must now be swapped to {Name: ID} pairs. + # Remember, the game ID and various offsets for each item type have already been calculated. + item_name_to_id = {item_table[k]: k for k in item_table} + location_name_to_id = {item_table[k]: k for k in item_table} + item_name_groups = { + "Power Cell": {item_table[k]: k for k in item_table + if k in range(jak1_id, jak1_id + Scouts.fly_offset)}, + "Scout Fly": {item_table[k]: k for k in item_table + if k in range(jak1_id + Scouts.fly_offset, jak1_id + Orbs.orb_offset)} + # "Precursor Orb": {} # TODO + } + + def create_regions(self): + create_regions(self.multiworld, self.options, self.player) + + def set_rules(self): + set_rules(self.multiworld, self.options, self.player) + + def create_items(self): + self.multiworld.itempool += [self.create_item(item_table[k]) for k in item_table] + + def create_item(self, name: str) -> Item: + item_id = self.item_name_to_id[name] + if item_id in range(jak1_id, jak1_id + Scouts.fly_offset): + # Power Cell + classification = ItemClassification.progression_skip_balancing + elif item_id in range(jak1_id + Scouts.fly_offset, jak1_id + Orbs.orb_offset): + # Scout Fly + classification = ItemClassification.progression_skip_balancing + elif item_id > jak1_id + Orbs.orb_offset: + # Precursor Orb + classification = ItemClassification.filler # TODO + else: + classification = ItemClassification.filler + + item = JakAndDaxterItem(name, classification, item_id, self.player) + return item + + +def launch_client(): + from .Client import launch + launch_subprocess(launch, name="JakAndDaxterClient") + + +components.append(Component("Jak and Daxter Client", + "JakAndDaxterClient", + func=launch_client, + component_type=Type.CLIENT)) diff --git a/worlds/jakanddaxter/client/MemoryReader.py b/worlds/jakanddaxter/client/MemoryReader.py new file mode 100644 index 0000000000..4373e9676e --- /dev/null +++ b/worlds/jakanddaxter/client/MemoryReader.py @@ -0,0 +1,133 @@ +import typing +import pymem +from pymem import pattern +from pymem.exception import ProcessNotFound, ProcessError, MemoryReadError, WinAPIError + +from CommonClient import logger +from worlds.jakanddaxter.locs import CellLocations as Cells, ScoutLocations as Flies + +# Some helpful constants. +next_cell_index_offset = 0 # Each of these is an uint64, so 8 bytes. +next_buzzer_index_offset = 8 # Each of these is an uint64, so 8 bytes. +cells_offset = 16 +buzzers_offset = 420 # cells_offset + (sizeof uint32 * 101 cells) = 16 + (4 * 101) + + +# buzzers_offset +# + (sizeof uint32 * 112 flies) <-- The buzzers themselves. +# + (sizeof uint8 * 116 tasks) <-- A "cells-received" array for the game to handle new ownership logic. +# = 420 + (4 * 112) + (1 * 116) +end_marker_offset = 984 + + +class JakAndDaxterMemoryReader: + marker: typing.ByteString + goal_address = None + connected: bool = False + initiated_connect: bool = False + + # The memory reader just needs the game running. + gk_process: pymem.process = None + + location_outbox = [] + outbox_index = 0 + + def __init__(self, marker: typing.ByteString = b'UnLiStEdStRaTs_JaK1\x00'): + self.marker = marker + self.connect() + + async def main_tick(self, location_callback: typing.Callable): + if self.initiated_connect: + await self.connect() + self.initiated_connect = False + + if self.connected: + try: + self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive. + except (ProcessError, MemoryReadError, WinAPIError): + logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.") + self.connected = False + else: + return + + # Read the memory address to check the state of the game. + self.read_memory() + location_callback(self.location_outbox) # TODO - I forgot why call this here when it's already down below... + + # Checked Locations in game. Handle the entire outbox every tick until we're up to speed. + if len(self.location_outbox) > self.outbox_index: + location_callback(self.location_outbox) + self.outbox_index += 1 + + async def connect(self): + try: + self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel + logger.info("Found the gk process: " + str(self.gk_process.process_id)) + except ProcessNotFound: + logger.error("Could not find the gk process.") + self.connected = False + return + + # If we don't find the marker in the first loaded module, we've failed. + modules = list(self.gk_process.list_modules()) + marker_address = pattern.pattern_scan_module(self.gk_process.process_handle, modules[0], self.marker) + if marker_address: + # At this address is another address that contains the struct we're looking for: the game's state. + # From here we need to add the length in bytes for the marker and 4 bytes of padding, + # and the struct address is 8 bytes long (it's u64). + goal_pointer = marker_address + len(self.marker) + 4 + self.goal_address = int.from_bytes(self.gk_process.read_bytes(goal_pointer, 8), + byteorder="little", + signed=False) + logger.info("Found the archipelago memory address: " + str(self.goal_address)) + self.connected = True + else: + logger.error("Could not find the archipelago memory address.") + self.connected = False + + if self.connected: + logger.info("The Memory Reader is ready!") + + def print_status(self): + logger.info("Memory Reader Status:") + logger.info(" Game process ID: " + (str(self.gk_process.process_id) if self.gk_process else "None")) + logger.info(" Game state memory address: " + str(self.goal_address)) + logger.info(" Last location checked: " + (str(self.location_outbox[self.outbox_index]) + if self.outbox_index else "None")) + + def read_memory(self) -> typing.List[int]: + try: + next_cell_index = int.from_bytes( + self.gk_process.read_bytes(self.goal_address, 8), + byteorder="little", + signed=False) + next_buzzer_index = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + next_buzzer_index_offset, 8), + byteorder="little", + signed=False) + + for k in range(0, next_cell_index): + next_cell = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + cells_offset + (k * 4), 4), + byteorder="little", + signed=False) + cell_ap_id = Cells.to_ap_id(next_cell) + if cell_ap_id not in self.location_outbox: + self.location_outbox.append(cell_ap_id) + logger.info("Checked power cell: " + str(next_cell)) + + for k in range(0, next_buzzer_index): + next_buzzer = int.from_bytes( + self.gk_process.read_bytes(self.goal_address + buzzers_offset + (k * 4), 4), + byteorder="little", + signed=False) + buzzer_ap_id = Flies.to_ap_id(next_buzzer) + if buzzer_ap_id not in self.location_outbox: + self.location_outbox.append(buzzer_ap_id) + logger.info("Checked scout fly: " + str(next_buzzer)) + + except (ProcessError, MemoryReadError, WinAPIError): + logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.") + self.connected = False + + return self.location_outbox diff --git a/worlds/jakanddaxter/client/ReplClient.py b/worlds/jakanddaxter/client/ReplClient.py new file mode 100644 index 0000000000..c70d633b00 --- /dev/null +++ b/worlds/jakanddaxter/client/ReplClient.py @@ -0,0 +1,202 @@ +import time +import struct +from socket import socket, AF_INET, SOCK_STREAM + +import pymem +from pymem.exception import ProcessNotFound, ProcessError + +from CommonClient import logger +from worlds.jakanddaxter.locs import CellLocations as Cells, ScoutLocations as Flies, OrbLocations as Orbs +from worlds.jakanddaxter.GameID import jak1_id + + +class JakAndDaxterReplClient: + ip: str + port: int + sock: socket + connected: bool = False + initiated_connect: bool = False # Signals when user tells us to try reconnecting. + + # The REPL client needs the REPL/compiler process running, but that process + # also needs the game running. Therefore, the REPL client needs both running. + gk_process: pymem.process = None + goalc_process: pymem.process = None + + item_inbox = {} + inbox_index = 0 + + def __init__(self, ip: str = "127.0.0.1", port: int = 8181): + self.ip = ip + self.port = port + self.connect() + + async def main_tick(self): + if self.initiated_connect: + await self.connect() + self.initiated_connect = False + + if self.connected: + try: + self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive. + except ProcessError: + logger.error("The gk process has died. Restart the game and run \"/repl connect\" again.") + self.connected = False + try: + self.goalc_process.read_bool(self.goalc_process.base_address) # Ping to see if it's alive. + except ProcessError: + logger.error("The goalc process has died. Restart the compiler and run \"/repl connect\" again.") + self.connected = False + else: + return + + # Receive Items from AP. Handle 1 item per tick. + if len(self.item_inbox) > self.inbox_index: + self.receive_item() + self.inbox_index += 1 + + # This helper function formats and sends `form` as a command to the REPL. + # ALL commands to the REPL should be sent using this function. + # TODO - this blocks on receiving an acknowledgement from the REPL server. But it doesn't print + # any log info in the meantime. Is that a problem? + def send_form(self, form: str, print_ok: bool = True) -> bool: + header = struct.pack(" jak1_id + Orbs.orb_offset: + pass # TODO + + def receive_power_cell(self, ap_id: int) -> bool: + cell_id = Cells.to_game_id(ap_id) + ok = self.send_form("(send-event " + "*target* \'get-archipelago " + "(pickup-type fuel-cell) " + "(the float " + str(cell_id) + "))") + if ok: + logger.info(f"Received power cell {cell_id}!") + else: + logger.error(f"Unable to receive power cell {cell_id}!") + return ok + + def receive_scout_fly(self, ap_id: int) -> bool: + fly_id = Flies.to_game_id(ap_id) + ok = self.send_form("(send-event " + "*target* \'get-archipelago " + "(pickup-type buzzer) " + "(the float " + str(fly_id) + "))") + if ok: + logger.info(f"Received scout fly {fly_id}!") + else: + logger.error(f"Unable to receive scout fly {fly_id}!") + return ok diff --git a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md new file mode 100644 index 0000000000..a96d2797f2 --- /dev/null +++ b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md @@ -0,0 +1,78 @@ +# Jak And Daxter (ArchipelaGOAL) + +## Where is the options page? + +The [Player Options Page](../player-options) for this game contains +all the options you need to configure and export a config file. + +At this time, Scout Flies are always randomized, and Precursor Orbs +are never randomized. + +## What does randomization do to this game? +All 101 Power Cells and 112 Scout Flies are now Location Checks +and may contain Items for different games, as well as different Items from within Jak and Daxter. + +## What is the goal of the game once randomized? +To complete the game, you must defeat the Gol and Maia and stop them from opening the Dark Eco silo. + +In order to reach them, you will need at least 72 Power Cells to cross the Lava Tube. In addition, +you will need the four specific Power Cells obtained by freeing the Red, Blue, Yellow, and Green Sages. + +## How do I progress through the game? +You can progress by performing tasks and completing the challenges that would normally give you Power Cells and +Scout Flies in the game. If you are playing with others, those players may find Power Cells and Scout Flies +in their games, and those Items will be automatically sent to your game. + +If you have completed all possible tasks available to you but still cannot progress, you may have to wait for +another player to find enough of your game's Items to allow you to progress. If that does not apply, +double-check your spoiler log to make sure you have all the items you should have. If you don't, +you may have encountered a bug. Please see the options for bug reporting below. + +## What happens when I pick up an item? +Jak and Daxter will perform their victory animation, if applicable. You will not receive that item, and +the Item count for that item will not change. The pause menu will say "Task Completed" below the +picked-up Power Cell, but the icon will remain "dormant." You will see a message in your text client saying +what you found and who it belongs to. + +## What happens when I receive an item? +Jak and Daxter won't perform their victory animation, and gameplay will continue as normal. Your text client will +inform you where you received the Item from, and which one it is. Your Item count for that type of Item will also +tick up. The pause menu will not say "Task Completed" below the selected Power Cell, but the icon will be "activated." + +## I can't reach a certain area within an accessible region, how do I get there? +Some areas are locked behind possession of specific Power Cells. For example, you cannot access Misty Island +until you have the "Catch 200 Pounds of Fish" Power Cell. Keep in mind, your access to Misty Island is determined +_through possession of this specific Power Cell only,_ **not** _by you completing the Fishing minigame._ + +## I got soft-locked and can't leave, how do I get out of here? +As stated before, some areas are locked behind possession of specific Power Cells. But you may already be past +a point-of-no-return preventing you from backtracking. One example is the Forbidden Jungle temple, where +the elevator is locked at the bottom, and if you haven't unlocked the Blue Eco Switch, you cannot access +the Plant Boss's room and escape. + +In this scenario, you will need to open your menu and find the "Warp To Home" option at the bottom of the list. +Selecting this option will instantly teleport you to Geyser Rock. From there, you can teleport back to the nearest +sage's hut to continue your journey. + +## I think I found a bug, where should I report it? +Depending on the nature of the bug, there are a couple of different options. + +* If you found a logical error in the randomizer, please create a new Issue +[here.](https://github.com/ArchipelaGOAL/Archipelago/issues) + * Use this page if: + * You are hard-locked from progressing. For example, you are stuck on Geyser Rock because one of the four + Geyser Rock Power Cells is not on Geyser Rock. + * The randomizer did not respect one of the Options you chose. + * You see a mistake, typo, etc. on this webpage. + * You see an error or stack trace appear on the text client. + * Please upload your config file and spoiler log file in the Issue, so we can troubleshoot the problem. + +* If you encountered an error in OpenGOAL, please create a new Issue +[here.](https://github.com/ArchipelaGOAL/ArchipelaGOAL/issues) + * Use this page if: + * You encounter a crash, freeze, reset, etc. + * You fail to send Items you find in the game to the Archipelago server. + * You fail to receive Items the server sends to you. + * Your game disconnects from the server and cannot reconnect. + * You go looking for a game item that has already disappeared before you could reach it. + * Please upload any log files that may have been generated. \ No newline at end of file diff --git a/worlds/jakanddaxter/docs/setup_en.md b/worlds/jakanddaxter/docs/setup_en.md new file mode 100644 index 0000000000..ce2b193677 --- /dev/null +++ b/worlds/jakanddaxter/docs/setup_en.md @@ -0,0 +1,85 @@ +# Jak And Daxter (ArchipelaGOAL) Setup Guide + +## Required Software + +- A legally purchased copy of *Jak And Daxter: The Precursor Legacy.* +- Python version 3.10 or higher. Make sure this is added to your PATH environment variable. +- [Task](https://taskfile.dev/installation/) (This makes it easier to run commands.) + +## Installation + +### Installation via OpenGOAL Mod Launcher + +At this time, the only supported method of setup is through Manual Compilation. Aside from the legal copy of the game, all tools required to do this are free. + +***Windows Preparations*** + +***Linux Preparations*** + +***Using the Launcher*** + +### Manual Compilation (Linux/Windows) + +***Windows Preparations*** + +- Dump your copy of the game as an ISO file to your PC. +- Download a zipped up copy of the Archipelago Server and Client [here.](https://github.com/ArchipelaGOAL/Archipelago) +- Download a zipped up copy of the modded OpenGOAL game [here.](https://github.com/ArchipelaGOAL/ArchipelaGOAL) +- Unzip the two projects into easily accessible directories. + + +***Linux Preparations*** + +***Compiling*** + +## Starting a Game + +- Open 3 Powershell windows. If you have VSCode, you can run 3 terminals to consolidate this process. + - In the first window, navigate to the Archipelago folder using `cd` and run `python ./Launcher.py --update_settings`. Then run it again without the `--update_settings` flag. + - In the second window, navigate to the ArchipelaGOAL folder and run `task extract`. This will prompt you to tell the mod where to find your ISO file to dump its contents. When that is done, run `task repl`. + - In the third window, navigate to the ArchipelaGOAL folder and run `task boot-game`. At this point, Jak should be standing outside Samos's hut. + - Once you confirm all those tasks succeeded, you can now close all these windows. +- Edit your host.yaml file and ensure these lines exist. And don't forget to specify your ACTUAL install path. If you're on Windows, no backslashes! +``` +jakanddaxter_options: + # Path to folder containing the ArchipelaGOAL mod. + root_directory: "D:/Files/Repositories/ArchipelaGOAL" +``` +- In the Launcher, click Generate to create a new random seed. Save the resulting zip file. +- In the Launcher, click Host to host the Archipelago server. It will prompt you for the location of that zip file. +- Once the server is running, in the Launcher, find the Jak and Daxter Client and click it. You should see the command window begin to compile the game. +- When it completes, you should hear the menu closing sound effect, and you should see the text client indicate that the two agents are ready to communicate with the game. +- Connect the client to the Archipelago server and enter your slot name. Once this is done, the game should be ready to play. Talk to Samos to trigger the cutscene where he sends you to Geyser Rock, and off you go! + +Once you complete the setup steps, you should only need to run the Launcher again to generate a game, host a server, or run the client and connect to a server. +- You never need to download the zip copies of the projects again (unless there are updates). +- You never need to dump your ISO again. +- You never need to extract the ISO assets again. + +### Joining a MultiWorld Game + +MultiWorld games are untested at this time. + +### Playing Offline + +Offline play is untested at this time. + +## Installation and Setup Troubleshooting + +### Compilation Failures + +### Runtime Failures + +- If the client window appears but no sound plays, you will need to enter the following commands into the client to connect it to the game. + - `/repl connect` + - `/memr connect` +- Once these are done, you can enter `/repl status` and `/memr status` to check that everything is connected and ready. + +## Gameplay Troubleshooting + +### Known Issues + +- I've streamlined the process of connecting the client's agents to the game, but it comes at the cost of more granular commands useful for troubleshooting. +- The game needs to run in debug mode in order to allow the repl to connect to it. At some point I want to make sure it can run in retail mode, or at least hide the debug text on screen and play the game's introductory cutscenes properly. +- The client is currently not very robust and doesn't handle failures gracefully. This may result in items not being delivered to the game, or location checks not being delivered to the server. +- The game relates tasks and power cells closely but separately. Some issues may result from having to tell the game to check for the power cells you own, rather than the tasks you completed. \ No newline at end of file diff --git a/worlds/jakanddaxter/locs/CellLocations.py b/worlds/jakanddaxter/locs/CellLocations.py new file mode 100644 index 0000000000..304810af80 --- /dev/null +++ b/worlds/jakanddaxter/locs/CellLocations.py @@ -0,0 +1,188 @@ +from ..GameID import jak1_id + +# Power Cells are given ID's between 0 and 116 by the game. + +# The game tracks all game-tasks as integers. +# 101 of these ID's correspond directly to power cells, but they are not +# necessarily ordered, nor are they the first 101 in the task list. +# The remaining ones are cutscenes and other events. + + +# These helper functions do all the math required to get information about each +# power cell and translate its ID between AP and OpenGOAL. +def to_ap_id(game_id: int) -> int: + assert game_id < jak1_id, f"Attempted to convert {game_id} to an AP ID, but it already is one." + return jak1_id + game_id + + +def to_game_id(ap_id: int) -> int: + assert ap_id >= jak1_id, f"Attempted to convert {ap_id} to a Jak 1 ID, but it already is one." + return ap_id - jak1_id + + +# The ID's you see below correspond directly to that cell's game-task ID. + +# Geyser Rock +locGR_cellTable = { + 92: "GR: Find The Cell On The Path", + 93: "GR: Open The Precursor Door", + 94: "GR: Climb Up The Cliff", + 95: "GR: Free 7 Scout Flies" +} + +# Sandover Village +locSV_cellTable = { + 11: "SV: Bring 90 Orbs To The Mayor", + 12: "SV: Bring 90 Orbs to Your Uncle", + 10: "SV: Herd The Yakows Into The Pen", + 13: "SV: Bring 120 Orbs To The Oracle (1)", + 14: "SV: Bring 120 Orbs To The Oracle (2)", + 75: "SV: Free 7 Scout Flies" +} + +# Forbidden Jungle +locFJ_cellTable = { + 3: "FJ: Connect The Eco Beams", + 4: "FJ: Get To The Top Of The Temple", + 2: "FJ: Find The Blue Vent Switch", + 6: "FJ: Defeat The Dark Eco Plant", + 5: "FJ: Catch 200 Pounds Of Fish", + 8: "FJ: Follow The Canyon To The Sea", + 9: "FJ: Open The Locked Temple Door", + 7: "FJ: Free 7 Scout Flies" +} + +# Sentinel Beach +locSB_cellTable = { + 15: "SB: Unblock The Eco Harvesters", + 17: "SB: Push The Flut Flut Egg Off The Cliff", + 16: "SB: Get The Power Cell From The Pelican", + 18: "SB: Chase The Seagulls", + 19: "SB: Launch Up To The Cannon Tower", + 21: "SB: Explore The Beach", + 22: "SB: Climb The Sentinel", + 20: "SB: Free 7 Scout Flies" +} + +# Misty Island +locMI_cellTable = { + 23: "MI: Catch The Sculptor's Muse", + 24: "MI: Climb The Lurker Ship", + 26: "MI: Stop The Cannon", + 25: "MI: Return To The Dark Eco Pool", + 27: "MI: Destroy the Balloon Lurkers", + 29: "MI: Use Zoomer To Reach Power Cell", + 30: "MI: Use Blue Eco To Reach Power Cell", + 28: "MI: Free 7 Scout Flies" +} + +# Fire Canyon +locFC_cellTable = { + 69: "FC: Reach The End Of Fire Canyon", + 68: "FC: Free 7 Scout Flies" +} + +# Rock Village +locRV_cellTable = { + 31: "RV: Bring 90 Orbs To The Gambler", + 32: "RV: Bring 90 Orbs To The Geologist", + 33: "RV: Bring 90 Orbs To The Warrior", + 34: "RV: Bring 120 Orbs To The Oracle (1)", + 35: "RV: Bring 120 Orbs To The Oracle (2)", + 76: "RV: Free 7 Scout Flies" +} + +# Precursor Basin +locPB_cellTable = { + 54: "PB: Herd The Moles Into Their Hole", + 53: "PB: Catch The Flying Lurkers", + 52: "PB: Beat Record Time On The Gorge", + 56: "PB: Get The Power Cell Over The Lake", + 55: "PB: Cure Dark Eco Infected Plants", + 58: "PB: Navigate The Purple Precursor Rings", + 59: "PB: Navigate The Blue Precursor Rings", + 57: "PB: Free 7 Scout Flies" +} + +# Lost Precursor City +locLPC_cellTable = { + 47: "LPC: Raise The Chamber", + 45: "LPC: Follow The Colored Pipes", + 46: "LPC: Reach The Bottom Of The City", + 48: "LPC: Quickly Cross The Dangerous Pool", + 44: "LPC: Match The Platform Colors", + 50: "LPC: Climb The Slide Tube", + 51: "LPC: Reach The Center Of The Complex", + 49: "LPC: Free 7 Scout Flies" +} + +# Boggy Swamp +locBS_cellTable = { + 37: "BS: Ride The Flut Flut", + 36: "BS: Protect Farthy's Snacks", + 38: "BS: Defeat The Lurker Ambush", + 39: "BS: Break The Tethers To The Zeppelin (1)", + 40: "BS: Break The Tethers To The Zeppelin (2)", + 41: "BS: Break The Tethers To The Zeppelin (3)", + 42: "BS: Break The Tethers To The Zeppelin (4)", + 43: "BS: Free 7 Scout Flies" +} + +# Mountain Pass +locMP_cellTable = { + 86: "MP: Defeat Klaww", + 87: "MP: Reach The End Of The Mountain Pass", + 110: "MP: Find The Hidden Power Cell", + 88: "MP: Free 7 Scout Flies" +} + +# Volcanic Crater +locVC_cellTable = { + 96: "VC: Bring 90 Orbs To The Miners (1)", + 97: "VC: Bring 90 Orbs To The Miners (2)", + 98: "VC: Bring 90 Orbs To The Miners (3)", + 99: "VC: Bring 90 Orbs To The Miners (4)", + 100: "VC: Bring 120 Orbs To The Oracle (1)", + 101: "VC: Bring 120 Orbs To The Oracle (2)", + 74: "VC: Find The Hidden Power Cell", + 77: "VC: Free 7 Scout Flies" +} + +# Spider Cave +locSC_cellTable = { + 78: "SC: Use Your Goggles To Shoot The Gnawing Lurkers", + 79: "SC: Destroy The Dark Eco Crystals", + 80: "SC: Explore The Dark Cave", + 81: "SC: Climb The Giant Robot", + 82: "SC: Launch To The Poles", + 83: "SC: Navigate The Spider Tunnel", + 84: "SC: Climb the Precursor Platforms", + 85: "SC: Free 7 Scout Flies" +} + +# Snowy Mountain +locSM_cellTable = { + 60: "SM: Find The Yellow Vent Switch", + 61: "SM: Stop The 3 Lurker Glacier Troops", + 66: "SM: Deactivate The Precursor Blockers", + 67: "SM: Open The Frozen Crate", + 63: "SM: Open The Lurker Fort Gate", + 62: "SM: Get Through The Lurker Fort", + 64: "SM: Survive The Lurker Infested Cave", + 65: "SM: Free 7 Scout Flies" +} + +# Lava Tube +locLT_cellTable = { + 89: "LT: Cross The Lava Tube", + 90: "LT: Free 7 Scout Flies" +} + +# Gol and Maias Citadel +locGMC_cellTable = { + 71: "GMC: Free The Blue Sage", + 72: "GMC: Free The Red Sage", + 73: "GMC: Free The Yellow Sage", + 70: "GMC: Free The Green Sage", + 91: "GMC: Free 7 Scout Flies" +} diff --git a/worlds/jakanddaxter/locs/OrbLocations.py b/worlds/jakanddaxter/locs/OrbLocations.py new file mode 100644 index 0000000000..4e586a65fb --- /dev/null +++ b/worlds/jakanddaxter/locs/OrbLocations.py @@ -0,0 +1,101 @@ +from ..GameID import jak1_id + +# Precursor Orbs are not necessarily given ID's by the game. + +# Of the 2000 orbs (or "money") you can pick up, only 1233 are standalone ones you find in the overworld. +# We can identify them by Actor ID's, which run from 549 to 24433. Other actors reside in this range, +# so like Power Cells these are not ordered, nor contiguous, nor exclusively orbs. + +# In fact, other ID's in this range belong to actors that spawn orbs when they are activated or when they die, +# like steel crates, orb caches, Spider Cave gnawers, or jumping on the Plant Boss's head. + +# These orbs that spawn from parent actors DON'T have an Actor ID themselves - the parent object keeps +# track of how many of its orbs have been picked up. If you pick up only some of its orbs, it +# will respawn when you leave the area, and only drop the remaining number of orbs when activated/killed. +# Once all the orbs are picked up, the actor will permanently "retire" and never spawn again. +# The maximum number of orbs that any actor can spawn is 30 (the orb caches in citadel). Covering +# these ID-less orbs may need to be a future enhancement. TODO ^^ + +# Standalone orbs need 15 bits to identify themselves by Actor ID, +# so we can use 2^15 to offset them from scout flies, just like we offset +# scout flies from power cells by 2^10. +orb_offset = 32768 + + +# These helper functions do all the math required to get information about each +# precursor orb and translate its ID between AP and OpenGOAL. +def to_ap_id(game_id: int) -> int: + assert game_id < jak1_id, f"Attempted to convert {game_id} to an AP ID, but it already is one." + return jak1_id + orb_offset + game_id # Add the offsets and the orb Actor ID. + + +def to_game_id(ap_id: int) -> int: + assert ap_id >= jak1_id, f"Attempted to convert {ap_id} to a Jak 1 ID, but it already is one." + return ap_id - jak1_id - orb_offset # Reverse process, subtract the offsets. + + +# The ID's you see below correspond directly to that orb's Actor ID in the game. + +# Geyser Rock +locGR_orbTable = { +} + +# Sandover Village +locSV_orbTable = { +} + +# Forbidden Jungle +locFJ_orbTable = { +} + +# Sentinel Beach +locSB_orbTable = { +} + +# Misty Island +locMI_orbTable = { +} + +# Fire Canyon +locFC_orbTable = { +} + +# Rock Village +locRV_orbTable = { +} + +# Precursor Basin +locPB_orbTable = { +} + +# Lost Precursor City +locLPC_orbTable = { +} + +# Boggy Swamp +locBS_orbTable = { +} + +# Mountain Pass +locMP_orbTable = { +} + +# Volcanic Crater +locVC_orbTable = { +} + +# Spider Cave +locSC_orbTable = { +} + +# Snowy Mountain +locSM_orbTable = { +} + +# Lava Tube +locLT_orbTable = { +} + +# Gol and Maias Citadel +locGMC_orbTable = { +} diff --git a/worlds/jakanddaxter/locs/ScoutLocations.py b/worlds/jakanddaxter/locs/ScoutLocations.py new file mode 100644 index 0000000000..c1349d0d63 --- /dev/null +++ b/worlds/jakanddaxter/locs/ScoutLocations.py @@ -0,0 +1,227 @@ +from ..GameID import jak1_id + +# Scout Flies are given ID's between 0 and 393311 by the game, explanation below. + +# Each fly (or "buzzer") is given a unique 32-bit number broken into two 16-bit numbers. +# The lower 16 bits are the game-task ID of the power cell the fly corresponds to. +# The higher 16 bits are the index of the fly itself, from 000 (0) to 110 (6). + +# Ex: The final scout fly on Geyser Rock +# 0000000000000110 0000000001011111 +# ( Index: 6 ) ( Cell: 95 ) + +# Because flies are indexed from 0, each 0th fly's full ID == the power cell's ID. +# So we need to offset all of their ID's in order for Archipelago to separate them +# from their power cells. We can use 1024 (2^10) for this purpose, because scout flies +# only ever need 10 bits to identify themselves (3 for the index, 7 for the cell ID). + +# We're also going to compress the ID by bit-shifting the fly index down to lower bits, +# keeping the scout fly ID range to a smaller set of numbers (1000 -> 2000, instead of 1 -> 400000). +fly_offset = 1024 + + +# These helper functions do all the math required to get information about each +# scout fly and translate its ID between AP and OpenGOAL. +def to_ap_id(game_id: int) -> int: + assert game_id < jak1_id, f"Attempted to convert {game_id} to an AP ID, but it already is one." + cell_id = get_cell_id(game_id) # Get the power cell ID from the lowest 7 bits. + buzzer_index = (game_id - cell_id) >> 9 # Get the index, bit shift it down 9 places. + compressed_id = fly_offset + buzzer_index + cell_id # Add the offset, the bit-shifted index, and the cell ID. + return jak1_id + compressed_id # Last thing: add the game's ID. + + +def to_game_id(ap_id: int) -> int: + assert ap_id >= jak1_id, f"Attempted to convert {ap_id} to a Jak 1 ID, but it already is one." + compressed_id = ap_id - jak1_id # Reverse process. First thing: subtract the game's ID. + cell_id = get_cell_id(compressed_id) # Get the power cell ID from the lowest 7 bits. + buzzer_index = compressed_id - fly_offset - cell_id # Get the bit-shifted index. + return (buzzer_index << 9) + cell_id # Return the index to its normal place, re-add the cell ID. + + +# Get the power cell ID from the lowest 7 bits. +# Make sure to use this function ONLY when the input argument does NOT include jak1_id, +# because that number may flip some of the bottom 7 bits, and that will throw off this bit mask. +def get_cell_id(buzzer_id: int) -> int: + assert buzzer_id < jak1_id, f"Attempted to bit mask {buzzer_id}, but it is polluted by the game's ID {jak1_id}." + return buzzer_id & 0b1111111 + + +# The ID's you see below correspond directly to that fly's 32-bit ID in the game. +# I used the decompiled entity JSON's and Jak's X/Y coordinates in Debug Mode +# to determine which box ID is which location. + +# Geyser Rock +locGR_scoutTable = { + 95: "GR: Scout Fly On Ground, Front", + 327775: "GR: Scout Fly On Ground, Back", + 393311: "GR: Scout Fly On Left Ledge", + 65631: "GR: Scout Fly On Right Ledge", + 262239: "GR: Scout Fly On Middle Ledge, Left", + 131167: "GR: Scout Fly On Middle Ledge, Right", + 196703: "GR: Scout Fly On Top Ledge" +} + +# Sandover Village +locSV_scoutTable = { + 262219: "SV: Scout Fly In Fisherman's House", + 327755: "SV: Scout Fly In Mayor's House", + 131147: "SV: Scout Fly Under Bridge", + 65611: "SV: Scout Fly Behind Sculptor's House", + 75: "SV: Scout Fly Overlooking Farmer's House", + 393291: "SV: Scout Fly Near Oracle", + 196683: "SV: Scout Fly In Farmer's House" +} + +# Forbidden Jungle +locFJ_scoutTable = { + 393223: "FJ: Scout Fly At End Of Path", + 262151: "FJ: Scout Fly On Spiral Of Stumps", + 7: "FJ: Scout Fly Near Dark Eco Boxes", + 196615: "FJ: Scout Fly At End Of River", + 131079: "FJ: Scout Fly Behind Lurker Machine", + 327687: "FJ: Scout Fly Around Temple Spire", + 65543: "FJ: Scout Fly On Top Of Temple" +} + +# Sentinel Beach +locSB_scoutTable = { + 327700: "SB: Scout Fly At Entrance", + 20: "SB: Scout Fly Overlooking Locked Boxes", + 65556: "SB: Scout Fly On Path To Flut Flut", + 262164: "SB: Scout Fly Under Wood Pillars", + 196628: "SB: Scout Fly Overlooking Blue Eco Vent", + 131092: "SB: Scout Fly Overlooking Green Eco Vents", + 393236: "SB: Scout Fly On Sentinel" +} + +# Misty Island +locMI_scoutTable = { + 327708: "MI: Scout Fly Overlooking Entrance", + 65564: "MI: Scout Fly On Ledge Near Arena Entrance", + 262172: "MI: Scout Fly Near Arena Door", + 28: "MI: Scout Fly On Ledge Near Arena Exit", + 131100: "MI: Scout Fly On Ship", + 196636: "MI: Scout Fly On Barrel Ramps", + 393244: "MI: Scout Fly On Zoomer Ramps" +} + +# Fire Canyon +locFC_scoutTable = { + 393284: "FC: Scout Fly 1", + 68: "FC: Scout Fly 2", + 65604: "FC: Scout Fly 3", + 196676: "FC: Scout Fly 4", + 131140: "FC: Scout Fly 5", + 262212: "FC: Scout Fly 6", + 327748: "FC: Scout Fly 7" +} + +# Rock Village +locRV_scoutTable = { + 76: "RV: Scout Fly Behind Sage's Hut", + 131148: "RV: Scout Fly Near Waterfall", + 196684: "RV: Scout Fly Behind Geologist", + 262220: "RV: Scout Fly Behind Fiery Boulder", + 65612: "RV: Scout Fly On Dock", + 327756: "RV: Scout Fly At Pontoon Bridge", + 393292: "RV: Scout Fly At Boggy Swamp Entrance" +} + +# Precursor Basin +locPB_scoutTable = { + 196665: "PB: Scout Fly Overlooking Entrance", + 393273: "PB: Scout Fly Near Mole Hole", + 131129: "PB: Scout Fly At Purple Ring Start", + 65593: "PB: Scout Fly Near Dark Eco Plant, Above", + 57: "PB: Scout Fly At Blue Ring Start", + 262201: "PB: Scout Fly Before Big Jump", + 327737: "PB: Scout Fly Near Dark Eco Plant, Below" +} + +# Lost Precursor City +locLPC_scoutTable = { + 262193: "LPC: Scout Fly First Room", + 131121: "LPC: Scout Fly Before Second Room", + 393265: "LPC: Scout Fly Second Room, Near Orb Vent", + 196657: "LPC: Scout Fly Second Room, On Path To Cell", + 49: "LPC: Scout Fly Second Room, Green Pipe", # Sunken Pipe Game, special cases. See `got-buzzer?` + 65585: "LPC: Scout Fly Second Room, Blue Pipe", # Sunken Pipe Game, special cases. See `got-buzzer?` + 327729: "LPC: Scout Fly Across Steam Vents" +} + +# Boggy Swamp +locBS_scoutTable = { + 43: "BS: Scout Fly Near Entrance", + 393259: "BS: Scout Fly Over First Jump Pad", + 65579: "BS: Scout Fly Over Second Jump Pad", + 262187: "BS: Scout Fly Across Black Swamp", + 327723: "BS: Scout Fly Overlooking Flut Flut", + 131115: "BS: Scout Fly On Flut Flut Platforms", + 196651: "BS: Scout Fly In Field Of Boxes" +} + +# Mountain Pass +locMP_scoutTable = { + 88: "MP: Scout Fly 1", + 65624: "MP: Scout Fly 2", + 131160: "MP: Scout Fly 3", + 196696: "MP: Scout Fly 4", + 262232: "MP: Scout Fly 5", + 327768: "MP: Scout Fly 6", + 393304: "MP: Scout Fly 7" +} + +# Volcanic Crater +locVC_scoutTable = { + 262221: "VC: Scout Fly In Miner's Cave", + 393293: "VC: Scout Fly Near Oracle", + 196685: "VC: Scout Fly On Stone Platforms", + 131149: "VC: Scout Fly Near Lava Tube", + 77: "VC: Scout Fly At Minecart Junction", + 65613: "VC: Scout Fly Near Spider Cave", + 327757: "VC: Scout Fly Near Mountain Pass" +} + +# Spider Cave +locSC_scoutTable = { + 327765: "SC: Scout Fly Near Dark Dave Entrance", + 262229: "SC: Scout Fly In Dark Cave", + 393301: "SC: Scout Fly Main Cave, Overlooking Entrance", + 196693: "SC: Scout Fly Main Cave, Near Dark Crystal", + 131157: "SC: Scout Fly Main Cave, Near Robot Cave Entrance", + 85: "SC: Scout Fly Robot Cave, At Bottom Level", + 65621: "SC: Scout Fly Robot Cave, At Top Level", +} + +# Snowy Mountain +locSM_scoutTable = { + 65: "SM: Scout Fly Near Entrance", + 327745: "SM: Scout Fly Near Frozen Box", + 65601: "SM: Scout Fly Near Yellow Eco Switch", + 131137: "SM: Scout Fly On Cliff near Flut Flut", + 393281: "SM: Scout Fly Under Bridge To Fort", + 196673: "SM: Scout Fly On Top Of Fort Tower", + 262209: "SM: Scout Fly On Top Of Fort" +} + +# Lava Tube +locLT_scoutTable = { + 90: "LT: Scout Fly 1", + 65626: "LT: Scout Fly 2", + 327770: "LT: Scout Fly 3", + 262234: "LT: Scout Fly 4", + 131162: "LT: Scout Fly 5", + 196698: "LT: Scout Fly 6", + 393306: "LT: Scout Fly 7" +} + +# Gol and Maias Citadel +locGMC_scoutTable = { + 91: "GMC: Scout Fly At Entrance", + 65627: "GMC: Scout Fly Main Room, Left of Robot", + 196699: "GMC: Scout Fly Main Room, Right of Robot", + 262235: "GMC: Scout Fly Before Jumping Lurkers", + 393307: "GMC: Scout Fly At Blast Furnace", + 131163: "GMC: Scout Fly At Launch Pad Room", + 327771: "GMC: Scout Fly Top Of Rotating Tower" +} diff --git a/worlds/jakanddaxter/requirements.txt b/worlds/jakanddaxter/requirements.txt new file mode 100644 index 0000000000..fe25267f67 --- /dev/null +++ b/worlds/jakanddaxter/requirements.txt @@ -0,0 +1 @@ +Pymem>=1.13.0 \ No newline at end of file