diff --git a/PokemonClient.py b/PokemonClient.py deleted file mode 100644 index 6b43a53b8ff7..000000000000 --- a/PokemonClient.py +++ /dev/null @@ -1,382 +0,0 @@ -import asyncio -import json -import time -import os -import bsdiff4 -import subprocess -import zipfile -from asyncio import StreamReader, StreamWriter -from typing import List - - -import Utils -from Utils import async_start -from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ - get_base_parser - -from worlds.pokemon_rb.locations import location_data -from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch - -location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}} -location_bytes_bits = {} -for location in location_data: - if location.ram_address is not None: - if type(location.ram_address) == list: - location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address - location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit}, - {'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}] - else: - location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address - location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit} - -location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item" - and location.address is not None} - -SYSTEM_MESSAGE_ID = 0 - -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua" -CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua" -CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" -CONNECTION_CONNECTED_STATUS = "Connected" -CONNECTION_INITIAL_STATUS = "Connection has not been initiated" - -DISPLAY_MSGS = True - -SCRIPT_VERSION = 3 - - -class GBCommandProcessor(ClientCommandProcessor): - def __init__(self, ctx: CommonContext): - super().__init__(ctx) - - def _cmd_gb(self): - """Check Gameboy Connection State""" - if isinstance(self.ctx, GBContext): - logger.info(f"Gameboy Status: {self.ctx.gb_status}") - - -class GBContext(CommonContext): - command_processor = GBCommandProcessor - game = 'Pokemon Red and Blue' - - def __init__(self, server_address, password): - super().__init__(server_address, password) - self.gb_streams: (StreamReader, StreamWriter) = None - self.gb_sync_task = None - self.messages = {} - self.locations_array = None - self.gb_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 = 0b001 - self.sent_release = False - self.sent_collect = False - self.auto_hints = set() - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(GBContext, self).server_auth(password_requested) - if not self.auth: - self.awaiting_rom = True - logger.info('Awaiting connection to EmuHawk to get Player information') - return - - await self.send_connect() - - def _set_message(self, msg: str, msg_id: int): - if 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 'death_link' in args['slot_data'] and args['slot_data']['death_link']: - self.set_deathlink = True - 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) - - def on_deathlink(self, data: dict): - self.deathlink_pending = True - super().on_deathlink(data) - - def run_gui(self): - from kvui import GameManager - - class GBManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago Pokémon Client" - - self.ui = GBManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - -def get_payload(ctx: GBContext): - current_time = time.time() - ret = json.dumps( - { - "items": [item.item for item in ctx.items_received], - "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() - if key[0] > current_time - 10}, - "deathlink": ctx.deathlink_pending, - "options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled')) - } - ) - ctx.deathlink_pending = False - return ret - - -async def parse_locations(data: List, ctx: GBContext): - locations = [] - flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20], - "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], - "Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]} - - if len(data) > 0x140 + 0x20 + 0x0E + 0x01: - flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:] - else: - flags["DexSanityFlag"] = [0] * 19 - - for flag_type, loc_map in location_map.items(): - for flag, loc_id in loc_map.items(): - if flag_type == "list": - if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit'] - and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']): - locations.append(loc_id) - elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']: - locations.append(loc_id) - - hints = [] - if flags["EventFlag"][280] & 16: - hints.append("Cerulean Bicycle Shop") - if flags["EventFlag"][280] & 32: - hints.append("Route 2 Gate - Oak's Aide") - if flags["EventFlag"][280] & 64: - hints.append("Route 11 Gate 2F - Oak's Aide") - if flags["EventFlag"][280] & 128: - hints.append("Route 15 Gate 2F - Oak's Aide") - if flags["EventFlag"][281] & 1: - hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2", - "Celadon Prize Corner - Item Prize 3"] - if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"] - not in ctx.checked_locations): - hints.append("Fossil - Choice B") - elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"] - not in ctx.checked_locations): - hints.append("Fossil - Choice A") - hints = [ - location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and - location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked - ] - if hints: - await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}]) - ctx.auto_hints.update(hints) - - 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}]) - - -async def gb_sync_task(ctx: GBContext): - logger.info("Starting GB connector. Use /gb for status information") - while not ctx.exit_event.is_set(): - error_status = None - if ctx.gb_streams: - (reader, writer) = ctx.gb_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 up to two fields: - # 1. A keepalive response of the Players Name (always) - # 2. An array representing the memory values of the locations area (if in game) - 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 PokemonClient are from the same Archipelago installation." - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - error_status = CONNECTION_RESET_STATUS - ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion'] - if ctx.client_compatibility_mode == 0: - ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested - if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]): - 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 - ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]) - if not ctx.auth: - ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) - if ctx.auth == '': - msg = "Invalid ROM detected. No player name built into the ROM." - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - error_status = CONNECTION_RESET_STATUS - if ctx.awaiting_rom: - await ctx.server_auth(False) - if 'locations' in data_decoded and ctx.game and ctx.gb_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'] and 'DeathLink' in ctx.tags: - await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!") - if 'options' in data_decoded: - msgs = [] - if data_decoded['options'] & 4 and not ctx.sent_release: - ctx.sent_release = True - msgs.append({"cmd": "Say", "text": "!release"}) - if data_decoded['options'] & 8 and not ctx.sent_collect: - ctx.sent_collect = True - msgs.append({"cmd": "Say", "text": "!collect"}) - if msgs: - await ctx.send_msgs(msgs) - if ctx.set_deathlink: - await ctx.update_death_link(True) - except asyncio.TimeoutError: - logger.debug("Read Timed Out, Reconnecting") - error_status = CONNECTION_TIMING_OUT_STATUS - writer.close() - ctx.gb_streams = None - except ConnectionResetError as e: - logger.debug("Read failed due to Connection Lost, Reconnecting") - error_status = CONNECTION_RESET_STATUS - writer.close() - ctx.gb_streams = None - except TimeoutError: - logger.debug("Connection Timed Out, Reconnecting") - error_status = CONNECTION_TIMING_OUT_STATUS - writer.close() - ctx.gb_streams = None - except ConnectionResetError: - logger.debug("Connection Lost, Reconnecting") - error_status = CONNECTION_RESET_STATUS - writer.close() - ctx.gb_streams = None - if ctx.gb_status == CONNECTION_TENTATIVE_STATUS: - if not error_status: - logger.info("Successfully Connected to Gameboy") - ctx.gb_status = CONNECTION_CONNECTED_STATUS - else: - ctx.gb_status = f"Was tentatively connected but error occured: {error_status}" - elif error_status: - ctx.gb_status = error_status - logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates") - else: - try: - logger.debug("Attempting to connect to Gameboy") - ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10) - ctx.gb_status = CONNECTION_TENTATIVE_STATUS - except TimeoutError: - logger.debug("Connection Timed Out, Trying Again") - ctx.gb_status = CONNECTION_TIMING_OUT_STATUS - continue - except ConnectionRefusedError: - logger.debug("Connection Refused, Trying Again") - ctx.gb_status = CONNECTION_REFUSED_STATUS - continue - - -async def run_game(romfile): - auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True) - if auto_start is True: - import webbrowser - webbrowser.open(romfile) - elif os.path.isfile(auto_start): - subprocess.Popen([auto_start, romfile], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - -async def patch_and_run_game(game_version, patch_file, ctx): - base_name = os.path.splitext(patch_file)[0] - comp_path = base_name + '.gb' - if game_version == "blue": - delta_patch = BlueDeltaPatch - else: - delta_patch = RedDeltaPatch - - try: - base_rom = delta_patch.get_source_data() - except Exception as msg: - logger.info(msg, extra={'compact_gui': True}) - ctx.gui_error('Error', msg) - - with zipfile.ZipFile(patch_file, 'r') as patch_archive: - with patch_archive.open('delta.bsdiff4', 'r') as stream: - patch = stream.read() - patched_rom_data = bsdiff4.patch(base_rom, patch) - - 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("PokemonClient") - - options = Utils.get_options() - - async def main(): - parser = get_base_parser() - parser.add_argument('patch_file', default="", type=str, nargs="?", - help='Path to an APRED or APBLUE patch file') - args = parser.parse_args() - - ctx = GBContext(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.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync") - - if args.patch_file: - ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower() - if ext == "apred": - logger.info("APRED file supplied, beginning patching process...") - async_start(patch_and_run_game("red", args.patch_file, ctx)) - elif ext == "apblue": - logger.info("APBLUE file supplied, beginning patching process...") - async_start(patch_and_run_game("blue", args.patch_file, ctx)) - else: - logger.warning(f"Unknown patch file extension {ext}") - - await ctx.exit_event.wait() - ctx.server_address = None - - await ctx.shutdown() - - if ctx.gb_sync_task: - await ctx.gb_sync_task - - - import colorama - - colorama.init() - - asyncio.run(main()) - colorama.deinit() diff --git a/data/lua/connector_pkmn_rb.lua b/data/lua/connector_pkmn_rb.lua deleted file mode 100644 index 3f56435bdbee..000000000000 --- a/data/lua/connector_pkmn_rb.lua +++ /dev/null @@ -1,224 +0,0 @@ -local socket = require("socket") -local json = require('json') -local math = require('math') -require("common") -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 = 3 - -local APIndex = 0x1A6E -local APDeathLinkAddress = 0x00FD -local APItemAddress = 0x00FF -local EventFlagAddress = 0x1735 -local MissableAddress = 0x161A -local HiddenItemsAddress = 0x16DE -local RodAddress = 0x1716 -local DexSanityAddress = 0x1A71 -local InGameAddress = 0x1A84 -local ClientCompatibilityAddress = 0xFF00 - -local ItemsReceived = nil -local playerName = nil -local seedName = nil - -local deathlink_rec = nil -local deathlink_send = false - -local prevstate = "" -local curstate = STATE_UNINITIALIZED -local gbSocket = nil -local frame = 0 - -local compat = nil - -local function defineMemoryFunctions() - local memDomain = {} - local domains = memory.getmemorydomainlist() - memDomain["rom"] = function() memory.usememorydomain("ROM") end - memDomain["wram"] = function() memory.usememorydomain("WRAM") end - return memDomain -end - -local memDomain = defineMemoryFunctions() -u8 = memory.read_u8 -wU8 = memory.write_u8 -u16 = memory.read_u16_le -function uRange(address, bytes) - data = memory.readbyterange(address - 1, bytes + 1) - data[0] = nil - return data -end - -function generateLocationsChecked() - memDomain.wram() - events = uRange(EventFlagAddress, 0x140) - missables = uRange(MissableAddress, 0x20) - hiddenitems = uRange(HiddenItemsAddress, 0x0E) - rod = {u8(RodAddress)} - dexsanity = uRange(DexSanityAddress, 19) - - - data = {} - - categories = {events, missables, hiddenitems, rod} - if compat > 1 then - table.insert(categories, dexsanity) - end - for _, category in ipairs(categories) do - for _, v in ipairs(category) do - table.insert(data, v) - end - end - - 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 - -function receive() - l, e = gbSocket: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 - block = json.decode(l) - if block ~= nil then - local itemsBlock = block["items"] - if itemsBlock ~= nil then - ItemsReceived = itemsBlock - end - deathlink_rec = block["deathlink"] - - end - end - -- Determine Message to send back - memDomain.rom() - newPlayerName = uRange(0xFFF0, 0x10) - newSeedName = uRange(0xFFDB, 21) - if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then - print("ROM changed, quitting") - curstate = STATE_UNINITIALIZED - return - end - playerName = newPlayerName - seedName = newSeedName - local retTable = {} - retTable["scriptVersion"] = SCRIPT_VERSION - - if compat == nil then - compat = u8(ClientCompatibilityAddress) - if compat < 2 then - InGameAddress = 0x1A71 - end - end - - retTable["clientCompatibilityVersion"] = compat - retTable["playerName"] = playerName - retTable["seedName"] = seedName - memDomain.wram() - - in_game = u8(InGameAddress) - if in_game == 0x2A or in_game == 0xAC then - retTable["locations"] = generateLocationsChecked() - elseif in_game ~= 0 then - print("Game may have crashed") - curstate = STATE_UNINITIALIZED - return - end - - retTable["deathLink"] = deathlink_send - deathlink_send = false - - msg = json.encode(retTable).."\n" - local ret, error = gbSocket: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 main() - if not checkBizHawkVersion() then - return - end - server, error = socket.bind('localhost', 17242) - - while true do - frame = frame + 1 - if not (curstate == prevstate) then - print("Current state: "..curstate) - prevstate = curstate - end - if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then - if (frame % 5 == 0) then - receive() - in_game = u8(InGameAddress) - if in_game == 0x2A or in_game == 0xAC then - if u8(APItemAddress) == 0x00 then - ItemIndex = u16(APIndex) - if deathlink_rec == true then - wU8(APDeathLinkAddress, 1) - elseif u8(APDeathLinkAddress) == 3 then - wU8(APDeathLinkAddress, 0) - deathlink_send = true - end - if ItemsReceived[ItemIndex + 1] ~= nil then - item_id = ItemsReceived[ItemIndex + 1] - 172000000 - if item_id > 255 then - item_id = item_id - 256 - end - wU8(APItemAddress, item_id) - 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 - curstate = STATE_INITIAL_CONNECTION_MADE - gbSocket = client - gbSocket:settimeout(0) - end - end - end - emu.frameadvance() - end -end - -main() diff --git a/inno_setup.iss b/inno_setup.iss index b4779b1067b7..4744fa2b724d 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -140,13 +140,13 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{ Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; -Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index c3ae2b0495b0..31739bb24606 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -101,8 +101,6 @@ def launch_textclient(): Component('OoT Adjuster', 'OoTAdjuster'), # FF1 Component('FF1 Client', 'FF1Client'), - # Pokémon - Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')), # TLoZ Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')), # ChecksFinder diff --git a/worlds/pokemon_rb/__init__.py b/worlds/pokemon_rb/__init__.py index b2ee0702c91e..d9bd6dde76e5 100644 --- a/worlds/pokemon_rb/__init__.py +++ b/worlds/pokemon_rb/__init__.py @@ -2,9 +2,11 @@ import settings import typing import threading +import base64 from copy import deepcopy from typing import TextIO +from Utils import __version__ from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification, LocationProgressType from Fill import fill_restrictive, FillError, sweep_from_pool from worlds.AutoWorld import World, WebWorld @@ -22,6 +24,7 @@ from .level_scaling import level_scaling from . import logic from . import poke_data +from . import client class PokemonSettings(settings.Group): @@ -36,16 +39,8 @@ class BlueRomFile(settings.UserFilePath): copy_to = "Pokemon Blue (UE) [S][!].gb" md5s = [BlueDeltaPatch.hash] - class RomStart(str): - """ - Set this to false to never autostart a rom (such as after patching) - True for operating system default program - Alternatively, a path to a program to open the .gb file with - """ - red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to) blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to) - rom_start: typing.Union[RomStart, bool] = True class PokemonWebWorld(WebWorld): @@ -141,9 +136,6 @@ def encode_name(name, t): else: self.rival_name = encode_name(self.multiworld.rival_name[self.player].value, "Rival") - if len(self.multiworld.player_name[self.player].encode()) > 16: - raise Exception(f"Player name too long for {self.multiworld.get_player_name(self.player)}. Player name cannot exceed 16 bytes for Pokémon Red and Blue.") - if not self.multiworld.badgesanity[self.player]: self.multiworld.non_local_items[self.player].value -= self.item_name_groups["Badges"] @@ -621,6 +613,13 @@ def stage_generate_output(cls, multiworld, output_directory): def generate_output(self, output_directory: str): generate_output(self, output_directory) + def modify_multidata(self, multidata: dict): + rom_name = bytearray(f'AP{__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed:11}\0', + 'utf8')[:21] + rom_name.extend([0] * (21 - len(rom_name))) + new_name = base64.b64encode(bytes(rom_name)).decode() + multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]] + def write_spoiler_header(self, spoiler_handle: TextIO): spoiler_handle.write(f"Cerulean Cave Total Key Items: {self.multiworld.cerulean_cave_key_items_condition[self.player].total}\n") spoiler_handle.write(f"Elite Four Total Key Items: {self.multiworld.elite_four_key_items_condition[self.player].total}\n") diff --git a/worlds/pokemon_rb/basepatch_blue.bsdiff4 b/worlds/pokemon_rb/basepatch_blue.bsdiff4 index eb4d83360cd8..bee5a8d2f499 100644 Binary files a/worlds/pokemon_rb/basepatch_blue.bsdiff4 and b/worlds/pokemon_rb/basepatch_blue.bsdiff4 differ diff --git a/worlds/pokemon_rb/basepatch_red.bsdiff4 b/worlds/pokemon_rb/basepatch_red.bsdiff4 index cffb0b7e0653..f2db54a84fda 100644 Binary files a/worlds/pokemon_rb/basepatch_red.bsdiff4 and b/worlds/pokemon_rb/basepatch_red.bsdiff4 differ diff --git a/worlds/pokemon_rb/client.py b/worlds/pokemon_rb/client.py new file mode 100644 index 000000000000..fb29045cf4e8 --- /dev/null +++ b/worlds/pokemon_rb/client.py @@ -0,0 +1,277 @@ +import base64 +import logging +import time + +from NetUtils import ClientStatus +from worlds._bizhawk.client import BizHawkClient +from worlds._bizhawk import read, write, guarded_write + +from worlds.pokemon_rb.locations import location_data + +logger = logging.getLogger("Client") + +BANK_EXCHANGE_RATE = 100000000 + +DATA_LOCATIONS = { + "ItemIndex": (0x1A6E, 0x02), + "Deathlink": (0x00FD, 0x01), + "APItem": (0x00FF, 0x01), + "EventFlag": (0x1735, 0x140), + "Missable": (0x161A, 0x20), + "Hidden": (0x16DE, 0x0E), + "Rod": (0x1716, 0x01), + "DexSanityFlag": (0x1A71, 19), + "GameStatus": (0x1A84, 0x01), + "Money": (0x141F, 3), + "ResetCheck": (0x0100, 4), + # First and second Vermilion Gym trash can selection. Second is not used, so should always be 0. + # First should never be above 0x0F. This is just before Event Flags. + "CrashCheck1": (0x1731, 2), + # Unused, should always be 0. This is just before Missables flags. + "CrashCheck2": (0x1617, 1), + # Progressive keys, should never be above 10. Just before Dexsanity flags. + "CrashCheck3": (0x1A70, 1), + # Route 18 script value. Should never be above 2. Just before Hidden items flags. + "CrashCheck4": (0x16DD, 1), +} + +location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}} +location_bytes_bits = {} +for location in location_data: + if location.ram_address is not None: + if type(location.ram_address) == list: + location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address + location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit}, + {'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}] + else: + location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address + location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit} + +location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item" + and location.address is not None} + + +class PokemonRBClient(BizHawkClient): + system = ("GB", "SGB") + patch_suffix = (".apred", ".apblue") + game = "Pokemon Red and Blue" + + def __init__(self): + super().__init__() + self.auto_hints = set() + self.locations_array = None + self.disconnect_pending = False + self.set_deathlink = False + self.banking_command = None + self.game_state = False + self.last_death_link = 0 + + async def validate_rom(self, ctx): + game_name = await read(ctx.bizhawk_ctx, [(0x134, 12, "ROM")]) + game_name = game_name[0].decode("ascii") + if game_name in ("POKEMON RED\00", "POKEMON BLUE"): + ctx.game = self.game + ctx.items_handling = 0b001 + ctx.command_processor.commands["bank"] = cmd_bank + seed_name = await read(ctx.bizhawk_ctx, [(0xFFDB, 21, "ROM")]) + ctx.seed_name = seed_name[0].split(b"\0")[0].decode("ascii") + self.set_deathlink = False + self.banking_command = None + self.locations_array = None + self.disconnect_pending = False + return True + return False + + async def set_auth(self, ctx): + auth_name = await read(ctx.bizhawk_ctx, [(0xFFC6, 21, "ROM")]) + if auth_name[0] == bytes([0] * 21): + # rom was patched before rom names implemented, use player name + auth_name = await read(ctx.bizhawk_ctx, [(0xFFF0, 16, "ROM")]) + auth_name = auth_name[0].decode("ascii").split("\x00")[0] + else: + auth_name = base64.b64encode(auth_name[0]).decode() + ctx.auth = auth_name + + async def game_watcher(self, ctx): + if not ctx.server or not ctx.server.socket.open or ctx.server.socket.closed: + return + + data = await read(ctx.bizhawk_ctx, [(loc_data[0], loc_data[1], "WRAM") + for loc_data in DATA_LOCATIONS.values()]) + data = {data_set_name: data_name for data_set_name, data_name in zip(DATA_LOCATIONS.keys(), data)} + + if self.set_deathlink: + self.set_deathlink = False + await ctx.update_death_link(True) + + if self.disconnect_pending: + self.disconnect_pending = False + await ctx.disconnect() + + if data["GameStatus"][0] == 0 or data["ResetCheck"] == b'\xff\xff\xff\x7f': + # Do not handle anything before game save is loaded + self.game_state = False + return + elif (data["GameStatus"][0] not in (0x2A, 0xAC) + or data["CrashCheck1"][0] & 0xF0 or data["CrashCheck1"][1] & 0xFF + or data["CrashCheck2"][0] + or data["CrashCheck3"][0] > 10 + or data["CrashCheck4"][0] > 2): + # Should mean game crashed + logger.warning("Pokémon Red/Blue game may have crashed. Disconnecting from server.") + self.game_state = False + await ctx.disconnect() + return + self.game_state = True + + # SEND ITEMS TO CLIENT + + if data["APItem"][0] == 0: + item_index = int.from_bytes(data["ItemIndex"], "little") + if len(ctx.items_received) > item_index: + item_code = ctx.items_received[item_index].item - 172000000 + if item_code > 255: + item_code -= 256 + await write(ctx.bizhawk_ctx, [(DATA_LOCATIONS["APItem"][0], + [item_code], "WRAM")]) + + # LOCATION CHECKS + + locations = set() + + for flag_type, loc_map in location_map.items(): + for flag, loc_id in loc_map.items(): + if flag_type == "list": + if (data["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << + location_bytes_bits[loc_id][0]['bit'] + and data["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << + location_bytes_bits[loc_id][1]['bit']): + locations.add(loc_id) + elif data[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']: + locations.add(loc_id) + + if locations != self.locations_array: + if locations: + self.locations_array = locations + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": list(locations)}]) + + # AUTO HINTS + + hints = [] + if data["EventFlag"][280] & 16: + hints.append("Cerulean Bicycle Shop") + if data["EventFlag"][280] & 32: + hints.append("Route 2 Gate - Oak's Aide") + if data["EventFlag"][280] & 64: + hints.append("Route 11 Gate 2F - Oak's Aide") + if data["EventFlag"][280] & 128: + hints.append("Route 15 Gate 2F - Oak's Aide") + if data["EventFlag"][281] & 1: + hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2", + "Celadon Prize Corner - Item Prize 3"] + if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id[ + "Fossil - Choice B"] + not in ctx.checked_locations): + hints.append("Fossil - Choice B") + elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id[ + "Fossil - Choice A"] + not in ctx.checked_locations): + hints.append("Fossil - Choice A") + hints = [ + location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in self.auto_hints and + location_name_to_id[loc] in ctx.missing_locations and + location_name_to_id[loc] not in ctx.locations_checked + ] + if hints: + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}]) + self.auto_hints.update(hints) + + # DEATHLINK + + if "DeathLink" in ctx.tags: + if data["Deathlink"][0] == 3: + await ctx.send_death(ctx.player_names[ctx.slot] + " is out of usable Pokémon! " + + ctx.player_names[ctx.slot] + " blacked out!") + await write(ctx.bizhawk_ctx, [(DATA_LOCATIONS["Deathlink"][0], [0], "WRAM")]) + self.last_death_link = ctx.last_death_link + elif ctx.last_death_link > self.last_death_link: + self.last_death_link = ctx.last_death_link + await write(ctx.bizhawk_ctx, [(DATA_LOCATIONS["Deathlink"][0], [1], "WRAM")]) + + # BANK + + if self.banking_command: + original_money = data["Money"] + # Money is stored as binary-coded decimal. + money = int(original_money.hex()) + if self.banking_command > money: + logger.warning(f"You do not have ${self.banking_command} to deposit!") + elif (-self.banking_command * BANK_EXCHANGE_RATE) > ctx.stored_data[f"EnergyLink{ctx.team}"]: + logger.warning("Not enough money in the EnergyLink storage!") + else: + if self.banking_command + money > 999999: + self.banking_command = 999999 - money + money = str(money - self.banking_command).zfill(6) + money = [int(money[:2], 16), int(money[2:4], 16), int(money[4:], 16)] + money_written = await guarded_write(ctx.bizhawk_ctx, [(0x141F, money, "WRAM")], + [(0x141F, original_money, "WRAM")]) + if money_written: + if self.banking_command >= 0: + deposit = self.banking_command - int(self.banking_command / 4) + tax = self.banking_command - deposit + logger.info(f"Deposited ${deposit}, and charged a tax of ${tax}.") + self.banking_command = deposit + else: + logger.info(f"Withdrew ${-self.banking_command}.") + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "operations": + [{"operation": "add", "value": self.banking_command * BANK_EXCHANGE_RATE}, + {"operation": "max", "value": 0}], + }]) + self.banking_command = None + + # VICTORY + + if data["EventFlag"][280] & 1 and not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + def on_package(self, ctx, cmd, args): + if cmd == 'Connected': + if 'death_link' in args['slot_data'] and args['slot_data']['death_link']: + self.set_deathlink = True + self.last_death_link = time.time() + ctx.set_notify(f"EnergyLink{ctx.team}") + elif cmd == 'RoomInfo': + if ctx.seed_name and ctx.seed_name != args["seed_name"]: + # CommonClient's on_package displays an error to the user in this case, but connection is not cancelled. + self.game_state = False + self.disconnect_pending = True + super().on_package(ctx, cmd, args) + + +def cmd_bank(self, cmd: str = "", amount: str = ""): + """Deposit or withdraw money with the server's EnergyLink storage. + /bank - check server balance. + /bank deposit # - deposit money. One quarter of the amount will be lost to taxation. + /bank withdraw # - withdraw money.""" + if self.ctx.game != "Pokemon Red and Blue": + logger.warning("This command can only be used while playing Pokémon Red and Blue") + return + if not cmd: + logger.info(f"Money available: {int(self.ctx.stored_data[f'EnergyLink{self.ctx.team}'] / BANK_EXCHANGE_RATE)}") + return + elif (not self.ctx.server) or self.ctx.server.socket.closed or not self.ctx.client_handler.game_state: + logger.info(f"Must be connected to server and in game.") + elif not amount: + logger.warning("You must specify an amount.") + elif cmd == "withdraw": + self.ctx.client_handler.banking_command = -int(amount) + elif cmd == "deposit": + if int(amount) < 4: + logger.warning("You must deposit at least $4, for tax purposes.") + return + self.ctx.client_handler.banking_command = int(amount) + else: + logger.warning(f"Invalid bank command {cmd}") + return diff --git a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md index 086ec347f34f..b164d4b0fef6 100644 --- a/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md +++ b/worlds/pokemon_rb/docs/en_Pokemon Red and Blue.md @@ -83,6 +83,9 @@ you until these have ended. ## Unique Local Commands -The following command is only available when using the PokemonClient to play with Archipelago. +You can use `/bank` commands to deposit and withdraw money from the server's EnergyLink storage. This can be accessed by +any players playing games that use the EnergyLink feature. -- `/gb` Check Gameboy Connection State +- `/bank` - check the amount of money available on the server. +- `/bank withdraw #` - withdraw money from the server. +- `/bank deposit #` - deposit money into the server. 25% of the amount will be lost to taxation. \ No newline at end of file diff --git a/worlds/pokemon_rb/docs/setup_en.md b/worlds/pokemon_rb/docs/setup_en.md index 7ba9b3aa09e3..c9344959f6b9 100644 --- a/worlds/pokemon_rb/docs/setup_en.md +++ b/worlds/pokemon_rb/docs/setup_en.md @@ -11,7 +11,6 @@ As we are using BizHawk, this guide is only applicable to Windows and Linux syst - 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 `Pokemon Client` during installation). - Pokémon Red and/or Blue ROM files. The Archipelago community cannot provide these. ## Optional Software @@ -71,28 +70,41 @@ And the following special characters (these each count as one character): ## Joining a MultiWorld Game -### Obtain your Pokémon patch file +### Generating and Patching a Game -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 `.apred` or `.apblue` extension. +1. Create your settings file (YAML). +2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game). +This will generate an output file for you. Your patch file will have a `.apred` or `.apblue` file extension. +3. Open `ArchipelagoLauncher.exe` +4. Select "Open Patch" on the left side and select your patch file. +5. If this is your first time patching, you will be prompted to locate your vanilla ROM. +6. A patched `.gb` file will be created in the same place as the patch file. +7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your +BizHawk install. -Double-click on your patch file 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 associated the extension -to the emulator as recommended). +If you're playing a single-player seed and you don't care about autotracking or hints, you can stop here, close the +client, and load the patched ROM in any emulator. However, for multiworlds and other Archipelago features, continue +below using BizHawk as your emulator. ### Connect to the Multiserver -Once both the client and the emulator are started, you must connect them. Navigate to your Archipelago install folder, -then to `data/lua`, and drag+drop the `connector_pkmn_rb.lua` script onto the main EmuHawk window. (You could instead -open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to `connector_pkmn_rb.lua` with the file -picker.) +By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just +in case you have to close and reopen a window mid-game for some reason. + +1. Pokémon Red and Blue use Archipelago's BizHawk Client. If the client isn't still open from when you patched your +game, you can re-open it from the launcher. +2. Ensure EmuHawk is running the patched ROM. +3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing. +4. In the Lua Console window, go to `Script > Open Script…`. +5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`. +6. The emulator may freeze every few seconds until it manages to connect to the client. This is expected. The BizHawk +Client window should indicate that it connected and recognized Pokémon Red/Blue. +7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the +top text field of the client and click Connect. To connect the client to the multiserver simply put `
: