diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml new file mode 100644 index 0000000000..ba2660809a --- /dev/null +++ b/.github/workflows/analyze-modified-files.yml @@ -0,0 +1,80 @@ +name: Analyze modified files + +on: + pull_request: + paths: + - "**.py" + push: + paths: + - "**.py" + +env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + +jobs: + flake8-or-mypy: + strategy: + fail-fast: false + matrix: + task: [flake8, mypy] + + name: ${{ matrix.task }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: "Determine modified files (pull_request)" + if: github.event_name == 'pull_request' + run: | + git fetch origin $BASE $HEAD + DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py") + echo "modified files:" + echo "$DIFF" + echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV + + - name: "Determine modified files (push)" + if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000' + run: | + git fetch origin $BEFORE $AFTER + DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py") + echo "modified files:" + echo "$DIFF" + echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV + + - name: "Treat all files as modified (new branch)" + if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000' + run: | + echo "diff=." >> $GITHUB_ENV + + - uses: actions/setup-python@v4 + if: env.diff != '' + with: + python-version: 3.8 + + - name: "Install dependencies" + if: env.diff != '' + run: | + python -m pip install --upgrade pip ${{ matrix.task }} + python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes + + - name: "flake8: Stop the build if there are Python syntax errors or undefined names" + continue-on-error: false + if: env.diff != '' && matrix.task == 'flake8' + run: | + flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} + + - name: "flake8: Lint modified files" + continue-on-error: true + if: env.diff != '' && matrix.task == 'flake8' + run: | + flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} + + - name: "mypy: Type check modified files" + continue-on-error: true + if: env.diff != '' && matrix.task == 'mypy' + run: | + mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 849e752305..a40084b9ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,12 +38,13 @@ jobs: run: | python -m pip install --upgrade pip python setup.py build_exe --yes - $NAME="$(ls build)".Split('.',2)[1] + $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] $ZIP_NAME="Archipelago_$NAME.7z" + echo "$NAME -> $ZIP_NAME" echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV New-Item -Path dist -ItemType Directory -Force cd build - Rename-Item exe.$NAME Archipelago + Rename-Item "exe.$NAME" Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago - name: Store 7z uses: actions/upload-artifact@v3 @@ -65,10 +66,10 @@ jobs: - name: Get a recent python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Install build-time dependencies run: | - echo "PYTHON=python3.9" >> $GITHUB_ENV + echo "PYTHON=python3.11" >> $GITHUB_ENV wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index c20d244ad9..0000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: lint - -on: - push: - paths: - - '**.py' - pull_request: - paths: - - '**.py' - -jobs: - flake8: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - name: Install dependencies - run: | - python -m pip install --upgrade pip wheel - pip install flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42594721d0..cc68a88b76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,10 +44,10 @@ jobs: - name: Get a recent python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Install build-time dependencies run: | - echo "PYTHON=python3.9" >> $GITHUB_ENV + echo "PYTHON=python3.11" >> $GITHUB_ENV wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 254d92dd6f..1a76a7f471 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -36,12 +36,13 @@ jobs: - {version: '3.8'} - {version: '3.9'} - {version: '3.10'} + - {version: '3.11'} include: - python: {version: '3.8'} # win7 compat os: windows-latest - - python: {version: '3.10'} # current + - python: {version: '3.11'} # current os: windows-latest - - python: {version: '3.10'} # current + - python: {version: '3.11'} # current os: macos-latest steps: @@ -53,8 +54,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-subtests + pip install pytest pytest-subtests pytest-xdist python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests run: | - pytest + pytest -n auto diff --git a/.gitignore b/.gitignore index 5f8ad6b917..f4bcd35c32 100644 --- a/.gitignore +++ b/.gitignore @@ -27,15 +27,21 @@ *.archipelago *.apsave *.BIN +*.puml +setups build bundle/components.wxs dist +/prof/ README.html .vs/ EnemizerCLI/ /Players/ /SNI/ +/sni-*/ +/appimagetool* +/host.yaml /options.yaml /config.yaml /logs/ @@ -137,6 +143,7 @@ ipython_config.py .venv* env/ venv/ +/venv*/ ENV/ env.bak/ venv.bak/ @@ -167,6 +174,10 @@ dmypy.json # Cython debug symbols cython_debug/ +# Cython intermediates +_speedups.cpp +_speedups.html + # minecraft server stuff jdk*/ minecraft*/ @@ -176,6 +187,9 @@ minecraft_versions.json # pyenv .python-version +#undertale stuff +/Undertale/ + # OS General Files .DS_Store .AppleDouble diff --git a/AdventureClient.py b/AdventureClient.py index 06eea5215c..d2f4e734ac 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -25,11 +25,11 @@ from worlds.adventure.Offsets import static_item_element_size, connector_port_of SYSTEM_MESSAGE_ID = 0 CONNECTION_TIMING_OUT_STATUS = \ - "Connection timing out. Please restart your emulator, then restart adventure_connector.lua" + "Connection timing out. Please restart your emulator, then restart connector_adventure.lua" CONNECTION_REFUSED_STATUS = \ - "Connection Refused. Please start your emulator and make sure adventure_connector.lua is running" + "Connection Refused. Please start your emulator and make sure connector_adventure.lua is running" CONNECTION_RESET_STATUS = \ - "Connection was reset. Please restart your emulator, then restart adventure_connector.lua" + "Connection was reset. Please restart your emulator, then restart connector_adventure.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -396,7 +396,7 @@ async def atari_sync_task(ctx: AdventureContext): ctx.atari_streams = await asyncio.wait_for( asyncio.open_connection("localhost", port), - timeout=10) + timeout=10) ctx.atari_status = CONNECTION_TENTATIVE_STATUS except TimeoutError: logger.debug("Connection Timed Out, Trying Again") diff --git a/BaseClasses.py b/BaseClasses.py index 7249138bfc..683cc11c2c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -7,9 +7,11 @@ import random import secrets import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace -from collections import OrderedDict, Counter, deque, ChainMap +from collections import ChainMap, Counter, deque +from collections.abc import Collection from enum import IntEnum, IntFlag -from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple +from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ + Type, ClassVar import NetUtils import Options @@ -28,15 +30,15 @@ class Group(TypedDict, total=False): link_replacement: bool -class ThreadBarrierProxy(): +class ThreadBarrierProxy: """Passes through getattr while passthrough is True""" - def __init__(self, obj: Any): + def __init__(self, obj: object) -> None: self.passthrough = True self.obj = obj - def __getattr__(self, item): + def __getattr__(self, name: str) -> Any: if self.passthrough: - return getattr(self.obj, item) + return getattr(self.obj, name) else: raise RuntimeError("You are in a threaded context and global random state was removed for your safety. " "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.") @@ -81,6 +83,7 @@ class MultiWorld(): random: random.Random per_slot_randoms: Dict[int, random.Random] + """Deprecated. Please use `self.random` instead.""" class AttributeProxy(): def __init__(self, rule): @@ -96,7 +99,6 @@ class MultiWorld(): self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids} self.glitch_triforce = False self.algorithm = 'balanced' - self.dungeons: Dict[Tuple[str, int], Dungeon] = {} self.groups = {} self.regions = [] self.shops = [] @@ -113,7 +115,6 @@ class MultiWorld(): self.dark_world_light_cone = False self.rupoor_cost = 10 self.aga_randomness = True - self.lock_aga_door_in_escape = False self.save_and_quit_from_boss = True self.custom = False self.customitemarray = [] @@ -122,6 +123,7 @@ class MultiWorld(): self.early_items = {player: {} for player in self.player_ids} self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} + self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.fix_trock_doors = self.AttributeProxy( lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') self.fix_skullwoods_exit = self.AttributeProxy( @@ -135,7 +137,6 @@ class MultiWorld(): def set_player_attr(attr, val): self.__dict__.setdefault(attr, {})[player] = val - set_player_attr('tech_tree_layout_prerequisites', {}) set_player_attr('_region_cache', {}) set_player_attr('shuffle', "vanilla") set_player_attr('logic', "noglitches") @@ -202,14 +203,7 @@ class MultiWorld(): self.player_types[new_id] = NetUtils.SlotType.group self._region_cache[new_id] = {} world_type = AutoWorld.AutoWorldRegister.world_types[game] - for option_key, option in world_type.option_definitions.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - for option_key, option in Options.per_game_common_options.items(): - getattr(self, option_key)[new_id] = option(option.default) - - self.worlds[new_id] = world_type(self, new_id) + self.worlds[new_id] = world_type.create_group(self, new_id, players) self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id]) self.player_name[new_id] = name @@ -232,24 +226,24 @@ class MultiWorld(): range(1, self.players + 1)} def set_options(self, args: Namespace) -> None: - for option_key in Options.common_options: - setattr(self, option_key, getattr(args, option_key, )) - for option_key in Options.per_game_common_options: - setattr(self, option_key, getattr(args, option_key, {})) - for player in self.player_ids: self.custom_data[player] = {} world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] - for option_key in world_type.option_definitions: - setattr(self, option_key, getattr(args, option_key, {})) - self.worlds[player] = world_type(self, player) + self.worlds[player].random = self.per_slot_randoms[player] + for option_key in world_type.options_dataclass.type_hints: + option_values = getattr(args, option_key, {}) + setattr(self, option_key, option_values) + # TODO - remove this loop once all worlds use options dataclasses + options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass + self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player] + for option_key in options_dataclass.type_hints}) def set_item_links(self): item_links = {} replacement_prio = [False, True, None] for player in self.player_ids: - for item_link in self.item_links[player].value: + for item_link in self.worlds[player].options.item_links.value: if item_link["name"] in item_links: if item_links[item_link["name"]]["game"] != self.game[player]: raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}") @@ -304,14 +298,6 @@ class MultiWorld(): group["non_local_items"] = item_link["non_local_items"] group["link_replacement"] = replacement_prio[item_link["link_replacement"]] - # intended for unittests - def set_default_common_options(self): - for option_key, option in Options.common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) - for option_key, option in Options.per_game_common_options.items(): - setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids}) - self.state = CollectionState(self) - def secure(self): self.random = ThreadBarrierProxy(secrets.SystemRandom()) self.is_race = True @@ -363,7 +349,7 @@ class MultiWorld(): for r_location in region.locations: self._location_cache[r_location.name, player] = r_location - def get_regions(self, player=None): + def get_regions(self, player: Optional[int] = None) -> Collection[Region]: return self.regions if player is None else self._region_cache[player].values() def get_region(self, regionname: str, player: int) -> Region: @@ -387,12 +373,6 @@ class MultiWorld(): self._recache() return self._location_cache[location, player] - def get_dungeon(self, dungeonname: str, player: int) -> Dungeon: - try: - return self.dungeons[dungeonname, player] - except KeyError as e: - raise KeyError('No such dungeon %s for player %d' % (dungeonname, player)) from e - def get_all_state(self, use_cache: bool) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: @@ -445,7 +425,6 @@ class MultiWorld(): self.state.collect(item, True) def push_item(self, location: Location, item: Item, collect: bool = True): - assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}." location.item = item item.location = location if collect: @@ -493,8 +472,10 @@ class MultiWorld(): def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]): for player in players: if not location_names: - location_names = [location.name for location in self.get_unfilled_locations(player)] - for location_name in location_names: + valid_locations = [location.name for location in self.get_unfilled_locations(player)] + else: + valid_locations = location_names + for location_name in valid_locations: location = self._location_cache.get((location_name, player), None) if location is not None and location.item is None: yield location @@ -743,9 +724,11 @@ class CollectionState(): return self.prog_items[item, player] >= count def has_all(self, items: Set[str], player: int) -> bool: + """Returns True if each item name of items is in state at least once.""" return all(self.prog_items[item, player] for item in items) def has_any(self, items: Set[str], player: int) -> bool: + """Returns True if at least one item name of items is in state at least once.""" return any(self.prog_items[item, player] for item in items) def count(self, item: str, player: int) -> int: @@ -794,56 +777,6 @@ class CollectionState(): self.stale[item.player] = True -class Region: - name: str - _hint_text: str - player: int - multiworld: Optional[MultiWorld] - entrances: List[Entrance] - exits: List[Entrance] - locations: List[Location] - dungeon: Optional[Dungeon] = None - - def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): - self.name = name - self.entrances = [] - self.exits = [] - self.locations = [] - self.multiworld = multiworld - self._hint_text = hint - self.player = player - - def can_reach(self, state: CollectionState) -> bool: - if state.stale[self.player]: - state.update_reachable_regions(self.player) - return self in state.reachable_regions[self.player] - - def can_reach_private(self, state: CollectionState) -> bool: - for entrance in self.entrances: - if entrance.can_reach(state): - if not self in state.path: - state.path[self] = (self.name, state.path.get(entrance, None)) - return True - return False - - @property - def hint_text(self) -> str: - return self._hint_text if self._hint_text else self.name - - def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance: - for entrance in self.entrances: - if is_main_entrance(entrance): - return entrance - for entrance in self.entrances: # BFS might be better here, trying DFS for now. - return entrance.parent_region.get_connecting_entrance(is_main_entrance) - - def __repr__(self): - return self.__str__() - - def __str__(self): - return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' - - class Entrance: access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -882,41 +815,92 @@ class Entrance: return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' -class Dungeon(object): - def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item], - dungeon_items: List[Item], player: int): +class Region: + name: str + _hint_text: str + player: int + multiworld: Optional[MultiWorld] + entrances: List[Entrance] + exits: List[Entrance] + locations: List[Location] + entrance_type: ClassVar[Type[Entrance]] = Entrance + + def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name - self.regions = regions - self.big_key = big_key - self.small_keys = small_keys - self.dungeon_items = dungeon_items - self.bosses = dict() + self.entrances = [] + self.exits = [] + self.locations = [] + self.multiworld = multiworld + self._hint_text = hint self.player = player - self.multiworld = None + + def can_reach(self, state: CollectionState) -> bool: + if state.stale[self.player]: + state.update_reachable_regions(self.player) + return self in state.reachable_regions[self.player] @property - def boss(self) -> Optional[Boss]: - return self.bosses.get(None, None) + def hint_text(self) -> str: + return self._hint_text if self._hint_text else self.name - @boss.setter - def boss(self, value: Optional[Boss]): - self.bosses[None] = value + def get_connecting_entrance(self, is_main_entrance: Callable[[Entrance], bool]) -> Entrance: + for entrance in self.entrances: + if is_main_entrance(entrance): + return entrance + for entrance in self.entrances: # BFS might be better here, trying DFS for now. + return entrance.parent_region.get_connecting_entrance(is_main_entrance) - @property - def keys(self) -> List[Item]: - return self.small_keys + ([self.big_key] if self.big_key else []) + def add_locations(self, locations: Dict[str, Optional[int]], + location_type: Optional[Type[Location]] = None) -> None: + """ + Adds locations to the Region object, where location_type is your Location class and locations is a dict of + location names to address. - @property - def all_items(self) -> List[Item]: - return self.dungeon_items + self.keys + :param locations: dictionary of locations to be created and added to this Region `{name: ID}` + :param location_type: Location class to be used to create the locations with""" + if location_type is None: + location_type = Location + for location, address in locations.items(): + self.locations.append(location_type(self.player, location, address, self)) - def is_dungeon_item(self, item: Item) -> bool: - return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items) + def connect(self, connecting_region: Region, name: Optional[str] = None, + rule: Optional[Callable[[CollectionState], bool]] = None) -> None: + """ + Connects this Region to another Region, placing the provided rule on the connection. - def __eq__(self, other: Dungeon) -> bool: - if not other: - return False - return self.name == other.name and self.player == other.player + :param connecting_region: Region object to connect to path is `self -> exiting_region` + :param name: name of the connection being created + :param rule: callable to determine access of this connection to go from self to the exiting_region""" + exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}") + if rule: + exit_.access_rule = rule + exit_.connect(connecting_region) + + def create_exit(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an exit of this region. + + :param name: name of the Entrance being created + """ + exit_ = self.entrance_type(self.player, name, self) + self.exits.append(exit_) + return exit_ + + def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], + rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: + """ + Connects current region to regions in exit dictionary. Passed region names must exist first. + + :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, + created entrances will be named "self.name -> connecting_region" + :param rules: rules for the exits from this region. format is {"connecting_region", rule} + """ + if not isinstance(exits, Dict): + exits = dict.fromkeys(exits) + for connecting_region, name in exits.items(): + self.connect(self.multiworld.get_region(connecting_region, self.player), + name, + rules[connecting_region] if rules and connecting_region in rules else None) def __repr__(self): return self.__str__() @@ -925,20 +909,6 @@ class Dungeon(object): return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' -class Boss(): - def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int): - self.name = name - self.enemizer_name = enemizer_name - self.defeat_rule = defeat_rule - self.player = player - - def can_defeat(self, state) -> bool: - return self.defeat_rule(state, self.player) - - def __repr__(self): - return f"Boss({self.name})" - - class LocationProgressType(IntEnum): DEFAULT = 1 PRIORITY = 2 @@ -1071,15 +1041,19 @@ class Item: def flags(self) -> int: return self.classification.as_flag() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Item): + return NotImplemented return self.name == other.name and self.player == other.player - def __lt__(self, other: Item) -> bool: + def __lt__(self, other: object) -> bool: + if not isinstance(other, Item): + return NotImplemented if other.player != self.player: return other.player < self.player return self.name < other.name - def __hash__(self): + def __hash__(self) -> int: return hash((self.name, self.player)) def __repr__(self) -> str: @@ -1091,33 +1065,44 @@ class Item: return f"{self.name} (Player {self.player})" -class Spoiler(): - multiworld: MultiWorld - unreachables: Set[Location] +class EntranceInfo(TypedDict, total=False): + player: int + entrance: str + exit: str + direction: str - def __init__(self, world): - self.multiworld = world + +class Spoiler: + multiworld: MultiWorld + hashes: Dict[int, str] + entrances: Dict[Tuple[str, str, int], EntranceInfo] + playthrough: Dict[str, Union[List[str], Dict[str, str]]] # sphere "0" is list, others are dict + unreachables: Set[Location] + paths: Dict[str, List[Union[Tuple[str, str], Tuple[str, None]]]] # last step takes no further exits + + def __init__(self, multiworld: MultiWorld) -> None: + self.multiworld = multiworld self.hashes = {} - self.entrances = OrderedDict() + self.entrances = {} self.playthrough = {} self.unreachables = set() self.paths = {} - def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): + def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None: if self.multiworld.players == 1: - self.entrances[(entrance, direction, player)] = OrderedDict( - [('entrance', entrance), ('exit', exit_), ('direction', direction)]) + self.entrances[(entrance, direction, player)] = \ + {"entrance": entrance, "exit": exit_, "direction": direction} else: - self.entrances[(entrance, direction, player)] = OrderedDict( - [('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)]) + self.entrances[(entrance, direction, player)] = \ + {"player": player, "entrance": entrance, "exit": exit_, "direction": direction} - def create_playthrough(self, create_paths: bool = True): + def create_playthrough(self, create_paths: bool = True) -> None: """Destructive to the world while it is run, damage gets repaired afterwards.""" from itertools import chain # get locations containing progress items multiworld = self.multiworld prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement} - state_cache = [None] + state_cache: List[Optional[CollectionState]] = [None] collection_spheres: List[Set[Location]] = [] state = CollectionState(multiworld) sphere_candidates = set(prog_locations) @@ -1226,17 +1211,17 @@ class Spoiler(): for item in removed_precollected: multiworld.push_precollected(item) - def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]): + def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None: from itertools import zip_longest multiworld = self.multiworld - def flist_to_iter(node): - while node: - value, node = node - yield value + def flist_to_iter(path_value: Optional[PathValue]) -> Iterator[str]: + while path_value: + region_or_entrance, path_value = path_value + yield region_or_entrance - def get_path(state, region): - reversed_path_as_flist = state.path.get(region, (region, None)) + def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, str], Tuple[str, None]]]: + reversed_path_as_flist: PathValue = state.path.get(region, (str(region), None)) string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) # Now we combine the flat string list into (region, exit) pairs pathsiter = iter(string_path_flat) @@ -1262,14 +1247,11 @@ class Spoiler(): self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \ get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) - def to_file(self, filename: str): - def write_option(option_key: str, option_obj: type(Options.Option)): - res = getattr(self.multiworld, option_key)[player] + def to_file(self, filename: str) -> None: + def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: + res = getattr(self.multiworld.worlds[player].options, option_key) display_name = getattr(option_obj, "display_name", option_key) - try: - outfile.write(f'{display_name + ":":33}{res.current_option_name}\n') - except: - raise Exception + outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") with open(filename, 'w', encoding="utf-8-sig") as outfile: outfile.write( @@ -1285,8 +1267,7 @@ class Spoiler(): outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions) - for f_option, option in options.items(): + for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): write_option(f_option, option) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) @@ -1302,15 +1283,15 @@ class Spoiler(): AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) locations = [(str(location), str(location.item) if location.item is not None else "Nothing") - for location in self.multiworld.get_locations() if location.show_in_spoiler] + for location in self.multiworld.get_locations() if location.show_in_spoiler] outfile.write('\n\nLocations:\n\n') outfile.write('\n'.join( ['%s: %s' % (location, item) for location, item in locations])) outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( - [' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [ - f' {item}' for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) + [f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else + [f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) if self.unreachables: outfile.write('\n\nUnreachable Items:\n\n') outfile.write( @@ -1371,23 +1352,21 @@ class PlandoOptions(IntFlag): @classmethod def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions: try: - part = cls[part] + return base | cls[part] except Exception as e: raise KeyError(f"{part} is not a recognized name for a plando module. " - f"Known options: {', '.join(flag.name for flag in cls)}") from e - else: - return base | part + f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e def __str__(self) -> str: if self.value: - return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value) + return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value) return "None" seeddigits = 20 -def get_seed(seed=None) -> int: +def get_seed(seed: Optional[int] = None) -> int: if seed is None: random.seed(None) return random.randint(0, pow(10, seeddigits) - 1) diff --git a/BizHawkClient.py b/BizHawkClient.py new file mode 100644 index 0000000000..86c8e5197e --- /dev/null +++ b/BizHawkClient.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import ModuleUpdate +ModuleUpdate.update() + +from worlds._bizhawk.context import launch + +if __name__ == "__main__": + launch() diff --git a/CommonClient.py b/CommonClient.py index 4892f69f06..154b61b1d5 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import copy import logging import asyncio import urllib.parse @@ -23,6 +25,7 @@ from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ from Utils import Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os +import ssl if typing.TYPE_CHECKING: import kvui @@ -33,6 +36,12 @@ logger = logging.getLogger("Client") gui_enabled = not sys.stdout or "--nogui" not in sys.argv +@Utils.cache_argsless +def get_ssl_context(): + import certifi + return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where()) + + class ClientCommandProcessor(CommandProcessor): def __init__(self, ctx: CommonContext): self.ctx = ctx @@ -157,6 +166,7 @@ class CommonContext: disconnected_intentionally: bool = False server: typing.Optional[Endpoint] = None server_version: Version = Version(0, 0, 0) + generator_version: Version = Version(0, 0, 0) current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server last_death_link: float = time.time() # last send/received death link on AP layer @@ -166,6 +176,7 @@ class CommonContext: server_address: typing.Optional[str] password: typing.Optional[str] hint_cost: typing.Optional[int] + hint_points: typing.Optional[int] player_names: typing.Dict[int, str] finished_game: bool @@ -182,6 +193,10 @@ class CommonContext: server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations locations_info: typing.Dict[int, NetworkItem] + # data storage + stored_data: typing.Dict[str, typing.Any] + stored_data_notification_keys: typing.Set[str] + # internals # current message box through kvui _messagebox: typing.Optional["kvui.MessageBox"] = None @@ -217,6 +232,9 @@ class CommonContext: self.server_locations = set() # all locations the server knows of, missing_location | checked_locations self.locations_info = {} + self.stored_data = {} + self.stored_data_notification_keys = set() + self.input_queue = asyncio.Queue() self.input_requests = 0 @@ -226,6 +244,7 @@ class CommonContext: self.watcher_event = asyncio.Event() self.jsontotextparser = JSONtoTextParser(self) + self.rawjsontotextparser = RawJSONtoTextParser(self) self.update_data_package(network_data_package) # execution @@ -259,6 +278,7 @@ class CommonContext: self.items_received = [] self.locations_info = {} self.server_version = Version(0, 0, 0) + self.generator_version = Version(0, 0, 0) self.server = None self.server_task = None self.hint_cost = None @@ -360,10 +380,13 @@ class CommonContext: def on_print_json(self, args: dict): if self.ui: - self.ui.print_json(args["data"]) - else: - text = self.jsontotextparser(args["data"]) - logger.info(text) + # send copy to UI + self.ui.print_json(copy.deepcopy(args["data"])) + + logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])), + extra={"NoStream": True}) + logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])), + extra={"NoFile": True}) def on_package(self, cmd: str, args: dict): """For custom package handling in subclasses.""" @@ -457,6 +480,21 @@ class CommonContext: for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) + # data storage + + def set_notify(self, *keys: str) -> None: + """Subscribe to be notified of changes to selected data storage keys. + + The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the + names of the data storage keys to the latest values received from the server. + """ + if new_keys := (set(keys) - self.stored_data_notification_keys): + self.stored_data_notification_keys.update(new_keys) + async_start(self.send_msgs([{"cmd": "Get", + "keys": list(new_keys)}, + {"cmd": "SetNotify", + "keys": list(new_keys)}])) + # DeathLink hooks def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: @@ -586,7 +624,8 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) logger.info(f'Connecting to Archipelago server at {address}') try: - socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) + socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None, + ssl=get_ssl_context() if address.startswith("wss://") else None) if ctx.ui is not None: ctx.ui.update_address_bar(server_url.netloc) ctx.server = Endpoint(socket) @@ -601,6 +640,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) except websockets.InvalidMessage: # probably encrypted if address.startswith("ws://"): + # try wss await server_loop(ctx, "ws" + address[1:]) else: ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage" @@ -645,11 +685,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict): logger.info('Room Information:') logger.info('--------------------------------') version = args["version"] - ctx.server_version = tuple(version) - version = ".".join(str(item) for item in version) + ctx.server_version = Version(*version) - logger.info(f'Server protocol version: {version}') - logger.info("Server protocol tags: " + ", ".join(args["tags"])) + if "generator_version" in args: + ctx.generator_version = Version(*args["generator_version"]) + logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, ' + f'generator version: {ctx.generator_version.as_simple_string()}, ' + f'tags: {", ".join(args["tags"])}') + else: + logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, ' + f'tags: {", ".join(args["tags"])}') if args['password']: logger.info('Password required') ctx.update_permissions(args.get("permissions", {})) @@ -711,6 +756,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.slot = args["slot"] # int keys get lost in JSON transfer ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} + ctx.hint_points = args.get("hint_points", 0) ctx.consume_players_package(args["players"]) msgs = [] if ctx.locations_checked: @@ -719,6 +765,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict): if ctx.locations_scouted: msgs.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted)}) + if ctx.stored_data_notification_keys: + msgs.append({"cmd": "Get", + "keys": list(ctx.stored_data_notification_keys)}) + msgs.append({"cmd": "SetNotify", + "keys": list(ctx.stored_data_notification_keys)}) if msgs: await ctx.send_msgs(msgs) if ctx.finished_game: @@ -782,8 +833,13 @@ async def process_server_cmd(ctx: CommonContext, args: dict): # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]: ctx.on_deathlink(args["data"]) + + elif cmd == "Retrieved": + ctx.stored_data.update(args["keys"]) + elif cmd == "SetReply": - if args["key"] == "EnergyLink": + ctx.stored_data[args["key"]] = args["value"] + if args["key"].startswith("EnergyLink"): ctx.current_energy_link_value = args["value"] if ctx.ui: ctx.ui.set_new_energy_link_value() @@ -823,10 +879,9 @@ def get_base_parser(description: typing.Optional[str] = None): return parser -if __name__ == '__main__': - # Text Mode to use !hint and such with games that have no text entry - +def run_as_textclient(): class TextContext(CommonContext): + # Text Mode to use !hint and such with games that have no text entry tags = {"AP", "TextOnly"} game = "" # empty matches any game since 0.3.2 items_handling = 0b111 # receive all items for /received @@ -841,12 +896,11 @@ if __name__ == '__main__': def on_package(self, cmd: str, args: dict): if cmd == "Connected": self.game = self.slot_info[self.slot].game - + async def disconnect(self, allow_autoreconnect: bool = False): self.game = "" await super().disconnect(allow_autoreconnect) - async def main(args): ctx = TextContext(args.connect, args.password) ctx.auth = args.name @@ -859,7 +913,6 @@ if __name__ == '__main__': await ctx.exit_event.wait() await ctx.shutdown() - import colorama parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") @@ -879,3 +932,7 @@ if __name__ == '__main__': asyncio.run(main(args)) colorama.deinit() + + +if __name__ == '__main__': + run_as_textclient() diff --git a/FF1Client.py b/FF1Client.py index 83c2484682..b7c58e2061 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -13,9 +13,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP SYSTEM_MESSAGE_ID = 0 -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua" -CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua" +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua" +CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -33,7 +33,7 @@ class FF1CommandProcessor(ClientCommandProcessor): logger.info(f"NES Status: {self.ctx.nes_status}") def _cmd_toggle_msgs(self): - """Toggle displaying messages in bizhawk""" + """Toggle displaying messages in EmuHawk""" global DISPLAY_MSGS DISPLAY_MSGS = not DISPLAY_MSGS logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}") diff --git a/FactorioClient.py b/FactorioClient.py index 9c294c1016..070ca50326 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -1,553 +1,12 @@ from __future__ import annotations -import os -import logging -import json -import string -import copy -import re -import subprocess -import sys -import time -import random -import typing import ModuleUpdate ModuleUpdate.update() -import factorio_rcon -import colorama -import asyncio -from queue import Queue +from worlds.factorio.Client import check_stdin, launch import Utils -def check_stdin() -> None: - if Utils.is_windows and sys.stdin: - print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.") - if __name__ == "__main__": Utils.init_logging("FactorioClient", exception_logger="Client") check_stdin() - -from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser -from MultiServer import mark_raw -from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart -from Utils import async_start - -from worlds.factorio import Factorio - - -class FactorioCommandProcessor(ClientCommandProcessor): - ctx: FactorioContext - - def _cmd_energy_link(self): - """Print the status of the energy link.""" - self.output(f"Energy Link: {self.ctx.energy_link_status}") - - @mark_raw - def _cmd_factorio(self, text: str) -> bool: - """Send the following command to the bound Factorio Server.""" - if self.ctx.rcon_client: - # TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds. - self.ctx.print_to_game(f"/factorio {text}") - result = self.ctx.rcon_client.send_command(text) - if result: - self.output(result) - return True - return False - - def _cmd_resync(self): - """Manually trigger a resync.""" - self.ctx.awaiting_bridge = True - - def _cmd_toggle_send_filter(self): - """Toggle filtering of item sends that get displayed in-game to only those that involve you.""" - self.ctx.toggle_filter_item_sends() - - def _cmd_toggle_chat(self): - """Toggle sending of chat messages from players on the Factorio server to Archipelago.""" - self.ctx.toggle_bridge_chat_out() - -class FactorioContext(CommonContext): - command_processor = FactorioCommandProcessor - game = "Factorio" - items_handling = 0b111 # full remote - - # updated by spinup server - mod_version: Utils.Version = Utils.Version(0, 0, 0) - - def __init__(self, server_address, password): - super(FactorioContext, self).__init__(server_address, password) - self.send_index: int = 0 - self.rcon_client = None - self.awaiting_bridge = False - self.write_data_path = None - self.death_link_tick: int = 0 # last send death link on Factorio layer - self.factorio_json_text_parser = FactorioJSONtoTextParser(self) - self.energy_link_increment = 0 - self.last_deplete = 0 - self.filter_item_sends: bool = False - self.multiplayer: bool = False # whether multiple different players have connected - self.bridge_chat_out: bool = True - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(FactorioContext, self).server_auth(password_requested) - - if self.rcon_client: - await get_info(self, self.rcon_client) # retrieve current auth code - else: - raise Exception("Cannot connect to a server with unknown own identity, " - "bridge to Factorio first.") - - await self.send_connect() - - def on_print(self, args: dict): - super(FactorioContext, self).on_print(args) - if self.rcon_client: - if not args['text'].startswith(self.player_names[self.slot] + ":"): - self.print_to_game(args['text']) - - def on_print_json(self, args: dict): - if self.rcon_client: - if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \ - and not self.is_echoed_chat(args): - text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future. - self.print_to_game(text) - super(FactorioContext, self).on_print_json(args) - - @property - def savegame_name(self) -> str: - return f"AP_{self.seed_name}_{self.auth}_Save.zip" - - def print_to_game(self, text): - self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " - f"{text}") - - @property - def energy_link_status(self) -> str: - if not self.energy_link_increment: - return "Disabled" - elif self.current_energy_link_value is None: - return "Standby" - else: - return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J" - - def on_deathlink(self, data: dict): - if self.rcon_client: - self.rcon_client.send_command(f"/ap-deathlink {data['source']}") - super(FactorioContext, self).on_deathlink(data) - - def on_package(self, cmd: str, args: dict): - if cmd in {"Connected", "RoomUpdate"}: - # catch up sync anything that is already cleared. - if "checked_locations" in args and args["checked_locations"]: - self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for - item_name in args["checked_locations"]}) - if cmd == "Connected" and self.energy_link_increment: - async_start(self.send_msgs([{ - "cmd": "SetNotify", "keys": ["EnergyLink"] - }])) - elif cmd == "SetReply": - if args["key"] == "EnergyLink": - if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete: - # it's our deplete request - gained = int(args["original_value"] - args["value"]) - gained_text = Utils.format_SI_prefix(gained) + "J" - if gained: - logger.debug(f"EnergyLink: Received {gained_text}. " - f"{Utils.format_SI_prefix(args['value'])}J remaining.") - self.rcon_client.send_command(f"/ap-energylink {gained}") - - def on_user_say(self, text: str) -> typing.Optional[str]: - # Mirror chat sent from the UI to the Factorio server. - self.print_to_game(f"{self.player_names[self.slot]}: {text}") - return text - - async def chat_from_factorio(self, user: str, message: str) -> None: - if not self.bridge_chat_out: - return - - # Pass through commands - if message.startswith("!"): - await self.send_msgs([{"cmd": "Say", "text": message}]) - return - - # Omit messages that contain local coordinates - if "[gps=" in message: - return - - prefix = f"({user}) " if self.multiplayer else "" - await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}]) - - def toggle_filter_item_sends(self) -> None: - self.filter_item_sends = not self.filter_item_sends - if self.filter_item_sends: - announcement = "Item sends are now filtered." - else: - announcement = "Item sends are no longer filtered." - logger.info(announcement) - self.print_to_game(announcement) - - def toggle_bridge_chat_out(self) -> None: - self.bridge_chat_out = not self.bridge_chat_out - if self.bridge_chat_out: - announcement = "Chat is now bridged to Archipelago." - else: - announcement = "Chat is no longer bridged to Archipelago." - logger.info(announcement) - self.print_to_game(announcement) - - def run_gui(self): - from kvui import GameManager - - class FactorioManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago"), - ("FactorioServer", "Factorio Server Log"), - ("FactorioWatcher", "Bridge Data Log"), - ] - base_title = "Archipelago Factorio Client" - - self.ui = FactorioManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - -async def game_watcher(ctx: FactorioContext): - bridge_logger = logging.getLogger("FactorioWatcher") - next_bridge = time.perf_counter() + 1 - try: - while not ctx.exit_event.is_set(): - # TODO: restore on-demand refresh - if ctx.rcon_client and time.perf_counter() > next_bridge: - next_bridge = time.perf_counter() + 1 - ctx.awaiting_bridge = False - data = json.loads(ctx.rcon_client.send_command("/ap-sync")) - if not ctx.auth: - pass # auth failed, wait for new attempt - elif data["slot_name"] != ctx.auth: - bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}") - elif data["seed_name"] != ctx.seed_name: - bridge_logger.warning( - f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}") - else: - data = data["info"] - research_data = data["research_done"] - research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} - victory = data["victory"] - await ctx.update_death_link(data["death_link"]) - ctx.multiplayer = data.get("multiplayer", False) - - if not ctx.finished_game and victory: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - - if ctx.locations_checked != research_data: - bridge_logger.debug( - f"New researches done: " - f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}") - ctx.locations_checked = research_data - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) - death_link_tick = data.get("death_link_tick", 0) - if death_link_tick != ctx.death_link_tick: - ctx.death_link_tick = death_link_tick - if "DeathLink" in ctx.tags: - async_start(ctx.send_death()) - if ctx.energy_link_increment: - in_world_bridges = data["energy_bridges"] - if in_world_bridges: - in_world_energy = data["energy"] - if in_world_energy < (ctx.energy_link_increment * in_world_bridges): - # attempt to refill - ctx.last_deplete = time.time() - async_start(ctx.send_msgs([{ - "cmd": "Set", "key": "EnergyLink", "operations": - [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, - {"operation": "max", "value": 0}], - "last_deplete": ctx.last_deplete - }])) - # Above Capacity - (len(Bridges) * ENERGY_INCREMENT) - elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ - ctx.energy_link_increment*in_world_bridges: - value = ctx.energy_link_increment * in_world_bridges - async_start(ctx.send_msgs([{ - "cmd": "Set", "key": "EnergyLink", "operations": - [{"operation": "add", "value": value}] - }])) - ctx.rcon_client.send_command( - f"/ap-energylink -{value}") - logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J") - - await asyncio.sleep(0.1) - - except Exception as e: - logging.exception(e) - logging.error("Aborted Factorio Server Bridge") - - -def stream_factorio_output(pipe, queue, process): - pipe.reconfigure(errors="replace") - - def queuer(): - while process.poll() is None: - text = pipe.readline().strip() - if text: - queue.put_nowait(text) - - from threading import Thread - - thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True) - thread.start() - return thread - - -async def factorio_server_watcher(ctx: FactorioContext): - savegame_name = os.path.abspath(ctx.savegame_name) - if not os.path.exists(savegame_name): - logger.info(f"Creating savegame {savegame_name}") - subprocess.run(( - executable, "--create", savegame_name, "--preset", "archipelago" - )) - factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name, - *(str(elem) for elem in server_args)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - stdin=subprocess.DEVNULL, - encoding="utf-8") - factorio_server_logger.info("Started Factorio Server") - factorio_queue = Queue() - stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process) - stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) - try: - while not ctx.exit_event.is_set(): - if factorio_process.poll() is not None: - factorio_server_logger.info("Factorio server has exited.") - ctx.exit_event.set() - - while not factorio_queue.empty(): - msg = factorio_queue.get() - factorio_queue.task_done() - - if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg: - ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) - if not ctx.server: - logger.info("Established bridge to Factorio Server. " - "Ready to connect to Archipelago via /connect") - check_stdin() - - if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: - ctx.awaiting_bridge = True - factorio_server_logger.debug(msg) - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg): - factorio_server_logger.debug(msg) - ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}") - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg): - factorio_server_logger.debug(msg) - ctx.toggle_filter_item_sends() - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg): - factorio_server_logger.debug(msg) - ctx.toggle_bridge_chat_out() - else: - factorio_server_logger.info(msg) - match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg) - if match: - await ctx.chat_from_factorio(match.group(1), match.group(2)) - if ctx.rcon_client: - commands = {} - while ctx.send_index < len(ctx.items_received): - transfer_item: NetworkItem = ctx.items_received[ctx.send_index] - item_id = transfer_item.item - player_name = ctx.player_names[transfer_item.player] - if item_id not in Factorio.item_id_to_name: - factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}") - else: - item_name = Factorio.item_id_to_name[item_id] - factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.") - commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}' - ctx.send_index += 1 - if commands: - ctx.rcon_client.send_commands(commands) - await asyncio.sleep(0.1) - - except Exception as e: - logging.exception(e) - logging.error("Aborted Factorio Server Bridge") - ctx.exit_event.set() - - finally: - if factorio_process.poll() is not None: - if ctx.rcon_client: - ctx.rcon_client.close() - ctx.rcon_client = None - return - - sent_quit = False - if ctx.rcon_client: - # Attempt clean quit through RCON. - try: - ctx.rcon_client.send_command("/quit") - except factorio_rcon.RCONNetworkError: - pass - else: - sent_quit = True - ctx.rcon_client.close() - ctx.rcon_client = None - if not sent_quit: - # Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.) - factorio_process.terminate() - - try: - factorio_process.wait(10) - except subprocess.TimeoutExpired: - factorio_process.kill() - - -async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): - info = json.loads(rcon_client.send_command("/ap-rcon-info")) - ctx.auth = info["slot_name"] - ctx.seed_name = info["seed_name"] - # 0.2.0 addition, not present earlier - death_link = bool(info.get("death_link", False)) - ctx.energy_link_increment = info.get("energy_link", 0) - logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}") - if ctx.energy_link_increment and ctx.ui: - ctx.ui.enable_energy_link() - await ctx.update_death_link(death_link) - - -async def factorio_spinup_server(ctx: FactorioContext) -> bool: - savegame_name = os.path.abspath("Archipelago.zip") - if not os.path.exists(savegame_name): - logger.info(f"Creating savegame {savegame_name}") - subprocess.run(( - executable, "--create", savegame_name - )) - factorio_process = subprocess.Popen( - (executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - stdin=subprocess.DEVNULL, - encoding="utf-8") - factorio_server_logger.info("Started Information Exchange Factorio Server") - factorio_queue = Queue() - stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process) - stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) - rcon_client = None - try: - while not ctx.auth: - while not factorio_queue.empty(): - msg = factorio_queue.get() - factorio_server_logger.info(msg) - if "Loading mod AP-" in msg and msg.endswith("(data.lua)"): - parts = msg.split() - ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split("."))) - elif "Write data path: " in msg: - ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [") - if "AppData" in ctx.write_data_path: - logger.warning("It appears your mods are loaded from Appdata, " - "this can lead to problems with multiple Factorio instances. " - "If this is the case, you will get a file locked error running Factorio.") - if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: - rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) - if ctx.mod_version == ctx.__class__.mod_version: - raise Exception("No Archipelago mod was loaded. Aborting.") - await get_info(ctx, rcon_client) - await asyncio.sleep(0.01) - - except Exception as e: - logger.exception(e, extra={"compact_gui": True}) - msg = "Aborted Factorio Server Bridge" - logger.error(msg) - ctx.gui_error(msg, e) - ctx.exit_event.set() - - else: - logger.info( - f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}") - return True - finally: - factorio_process.terminate() - factorio_process.wait(5) - return False - - -async def main(args): - ctx = FactorioContext(args.connect, args.password) - ctx.filter_item_sends = initial_filter_item_sends - ctx.bridge_chat_out = initial_bridge_chat_out - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer") - successful_launch = await factorio_server_task - if successful_launch: - factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer") - progression_watcher = asyncio.create_task( - game_watcher(ctx), name="FactorioProgressionWatcher") - - await ctx.exit_event.wait() - ctx.server_address = None - - await progression_watcher - await factorio_server_task - - await ctx.shutdown() - - -class FactorioJSONtoTextParser(JSONtoTextParser): - def _handle_color(self, node: JSONMessagePart): - colors = node["color"].split(";") - for color in colors: - if color in self.color_codes: - node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]" - return self._handle_text(node) - return self._handle_text(node) - - -if __name__ == '__main__': - parser = get_base_parser(description="Optional arguments to FactorioClient follow. " - "Remaining arguments get passed into bound Factorio instance." - "Refer to Factorio --help for those.") - parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') - parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') - parser.add_argument('--server-settings', help='Factorio server settings configuration file.') - - args, rest = parser.parse_known_args() - colorama.init() - rcon_port = args.rcon_port - rcon_password = args.rcon_password if args.rcon_password else ''.join( - random.choice(string.ascii_letters) for x in range(32)) - - factorio_server_logger = logging.getLogger("FactorioServer") - options = Utils.get_options() - executable = options["factorio_options"]["executable"] - server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) - if server_settings: - server_settings = os.path.abspath(server_settings) - if not isinstance(options["factorio_options"]["filter_item_sends"], bool): - logging.warning(f"Warning: Option filter_item_sends should be a bool.") - initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"]) - if not isinstance(options["factorio_options"]["bridge_chat_out"], bool): - logging.warning(f"Warning: Option bridge_chat_out should be a bool.") - initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"]) - - if not os.path.exists(os.path.dirname(executable)): - raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") - if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein - executable = os.path.join(executable, "factorio") - if not os.path.isfile(executable): - if os.path.isfile(executable + ".exe"): - executable = executable + ".exe" - else: - raise FileNotFoundError(f"Path {executable} is not an executable file.") - - if server_settings and os.path.isfile(server_settings): - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest) - else: - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest) - - asyncio.run(main(args)) - colorama.deinit() + launch() diff --git a/Fill.py b/Fill.py index 6fa5ecb00d..600d18ef2a 100644 --- a/Fill.py +++ b/Fill.py @@ -5,6 +5,8 @@ import typing from collections import Counter, deque from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from Options import Accessibility + from worlds.AutoWorld import call_all from worlds.generic.Rules import add_item_rule @@ -39,8 +41,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: """ unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] + cleanup_required = False - swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() + swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter() reachable_items: typing.Dict[int, typing.Deque[Item]] = {} for item in item_pool: reachable_items.setdefault(item.player, deque()).append(item) @@ -50,7 +53,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: items_to_place = [items.pop() for items in reachable_items.values() if items] for item in items_to_place: - item_pool.remove(item) + for p, pool_item in enumerate(item_pool): + if pool_item is item: + item_pool.pop(p) + break maximum_exploration_state = sweep_from_pool( base_state, item_pool + unplaced_items) @@ -66,7 +72,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill: typing.Optional[Location] = None # if minimal accessibility, only check whether location is reachable if game not beatable - if world.accessibility[item_to_place.player] == 'minimal': + if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) \ if single_player_placement else not has_beaten_game @@ -84,25 +90,28 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: else: # we filled all reachable spots. if swap: - # try swapping this item with previously placed items - for (i, location) in enumerate(placements): + # try swapping this item with previously placed items in a safe way then in an unsafe way + swap_attempts = ((i, location, unsafe) + for unsafe in (False, True) + for i, location in enumerate(placements)) + for (i, location, unsafe) in swap_attempts: placed_item = location.item # Unplaceable items can sometimes be swapped infinitely. Limit the # number of times we will swap an individual item to prevent this - swap_count = swapped_items[placed_item.player, - placed_item.name] + swap_count = swapped_items[placed_item.player, placed_item.name, unsafe] if swap_count > 1: continue location.item = None placed_item.location = None - swap_state = sweep_from_pool(base_state, [placed_item]) - # swap_state assumes we can collect placed item before item_to_place + swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else []) + # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place + # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic + # to clean that up later, so there is a chance generation fails. if (not single_player_placement or location.player == item_to_place.player) \ and location.can_fill(swap_state, item_to_place, perform_access_check): - # Verify that placing this item won't reduce available locations, which could happen with rules - # that want to not have both items. Left in until removal is proven useful. + # Verify placing this item won't reduce available locations, which would be a useless swap. prev_state = swap_state.copy() prev_loc_count = len( world.get_reachable_locations(prev_state)) @@ -117,13 +126,15 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill = placements.pop(i) swap_count += 1 - swapped_items[placed_item.player, - placed_item.name] = swap_count + swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count reachable_items[placed_item.player].appendleft( placed_item) item_pool.append(placed_item) + # cleanup at the end to hopefully get better errors + cleanup_required = True + break # Item can't be placed here, restore original item @@ -144,6 +155,16 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if on_place: on_place(spot_to_fill) + if cleanup_required: + # validate all placements and remove invalid ones + state = sweep_from_pool(base_state, []) + for placement in placements: + if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + placement.item.location = None + unplaced_items.append(placement.item) + placement.item = None + locations.append(placement) + if allow_excluded: # check if partial fill is the result of excluded locations, in which case retry excluded_locations = [ @@ -246,7 +267,7 @@ def fast_fill(world: MultiWorld, def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]): maximum_exploration_state = sweep_from_pool(state, pool) - minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"} + minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and not location.can_reach(maximum_exploration_state)] for location in unreachable_locations: @@ -269,7 +290,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] if unreachable_locations: def forbid_important_item_rule(item: Item): - return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal') + return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal') for location in unreachable_locations: add_item_rule(location, forbid_important_item_rule) @@ -512,9 +533,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None: # If other players are below the threshold value, swap progression in this sphere into earlier spheres, # which gives more locations available by this sphere. balanceable_players: typing.Dict[int, float] = { - player: world.progression_balancing[player] / 100 + player: world.worlds[player].options.progression_balancing / 100 for player in world.player_ids - if world.progression_balancing[player] > 0 + if world.worlds[player].options.progression_balancing > 0 } if not balanceable_players: logging.info('Skipping multiworld progression balancing.') @@ -734,8 +755,6 @@ def distribute_planned(world: MultiWorld) -> None: else: # not reachable with swept state non_early_locations[loc.player].append(loc.name) - # TODO: remove. Preferably by implementing key drop - from worlds.alttp.Regions import key_drop_data world_name_lookup = world.world_name_lookup block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str] @@ -821,12 +840,12 @@ def distribute_planned(world: MultiWorld) -> None: if "early_locations" in locations: locations.remove("early_locations") - for player in worlds: - locations += early_locations[player] + for target_player in worlds: + locations += early_locations[target_player] if "non_early_locations" in locations: locations.remove("non_early_locations") - for player in worlds: - locations += non_early_locations[player] + for target_player in worlds: + locations += non_early_locations[target_player] block['locations'] = locations @@ -878,10 +897,6 @@ def distribute_planned(world: MultiWorld) -> None: for item_name in items: item = world.worlds[player].create_item(item_name) for location in reversed(candidates): - if location in key_drop_data: - warn( - f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.") - continue if not location.item: if location.item_rule(item): if location.can_fill(world.state, item, False): diff --git a/Generate.py b/Generate.py index afb34f11c6..08fe2b9083 100644 --- a/Generate.py +++ b/Generate.py @@ -7,55 +7,52 @@ import random import string import urllib.parse import urllib.request -from collections import Counter, ChainMap -from typing import Dict, Tuple, Callable, Any, Union +from collections import ChainMap, Counter +from typing import Any, Callable, Dict, Tuple, Union import ModuleUpdate ModuleUpdate.update() +import copy import Utils -from worlds.alttp import Options as LttPOptions -from worlds.generic import PlandoConnection -from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path -from worlds.alttp.EntranceRandomizer import parse_arguments -from Main import main as ERmain -from BaseClasses import seeddigits, get_seed, PlandoOptions import Options +from BaseClasses import seeddigits, get_seed, PlandoOptions +from Main import main as ERmain +from settings import get_settings +from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path +from worlds.alttp import Options as LttPOptions +from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister -import copy - - - +from worlds.generic import PlandoConnection def mystery_argparse(): - options = get_options() - defaults = options["generator"] - - def resolve_path(path: str, resolver: Callable[[str], str]) -> str: - return path if os.path.isabs(path) else resolver(path) + options = get_settings() + defaults = options.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") - parser.add_argument('--weights_file_path', default=defaults["weights_file_path"], + parser.add_argument('--weights_file_path', default=defaults.weights_file_path, help='Path to the weights file to use for rolling game settings, urls are also valid') parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', action='store_true') - parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path), + parser.add_argument('--player_files_path', default=defaults.player_files_path, help="Input directory for player files.") parser.add_argument('--seed', help='Define seed number to generate.', type=int) - parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) - parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) - parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), + parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) + parser.add_argument('--spoiler', type=int, default=defaults.spoiler) + parser.add_argument('--outputpath', default=options.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd - parser.add_argument('--race', action='store_true', default=defaults["race"]) - parser.add_argument('--meta_file_path', default=defaults["meta_file_path"]) + parser.add_argument('--race', action='store_true', default=defaults.race) + parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--log_level', default='info', help='Sets log level') parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0), help='Output rolled mystery results to yaml up to specified number (made for async multiworld)') - parser.add_argument('--plando', default=defaults["plando_options"], + parser.add_argument('--plando', default=defaults.plando_options, help='List of options that can be set manually. Can be combined, for example "bosses, items"') + parser.add_argument("--skip_prog_balancing", action="store_true", + help="Skip progression balancing step during generation.") args = parser.parse_args() if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) @@ -72,12 +69,16 @@ def get_seed_name(random_source) -> str: def main(args=None, callback=ERmain): if not args: args, options = mystery_argparse() + else: + options = get_settings() seed = get_seed(args.seed) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) if args.race: + logging.info("Race mode enabled. Using non-deterministic random source.") random.seed() # reset to time-based random source weights_cache: Dict[str, Tuple[Any, ...]] = {} @@ -85,16 +86,16 @@ def main(args=None, callback=ERmain): try: weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) except Exception as e: - raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e - print(f"Weights: {args.weights_file_path} >> " - f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}") + raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e + logging.info(f"Weights: {args.weights_file_path} >> " + f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}") if args.meta_file_path and os.path.exists(args.meta_file_path): try: meta_weights = read_weights_yamls(args.meta_file_path)[-1] except Exception as e: - raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e - print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") + raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e + logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file del(meta_weights["meta_description"]) except Exception as e: @@ -113,35 +114,35 @@ def main(args=None, callback=ERmain): try: weights_cache[fname] = read_weights_yamls(path) except Exception as e: - raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e + raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e # sort dict for consistent results across platforms: weights_cache = {key: value for key, value in sorted(weights_cache.items())} for filename, yaml_data in weights_cache.items(): if filename not in {args.meta_file_path, args.weights_file_path}: for yaml in yaml_data: - print(f"P{player_id} Weights: {filename} >> " - f"{get_choice('description', yaml, 'No description specified')}") + logging.info(f"P{player_id} Weights: {filename} >> " + f"{get_choice('description', yaml, 'No description specified')}") player_files[player_id] = filename player_id += 1 args.multi = max(player_id - 1, args.multi) - print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " - f"{args.plando}") + logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, " + f"{seed_name} Seed {seed} with plando: {args.plando}") if not weights_cache: - raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. " + raise Exception(f"No weights found. " + f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"A mix is also permitted.") erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando - erargs.glitch_triforce = options["generator"]["glitch_triforce_room"] + erargs.glitch_triforce = options.generator.glitch_triforce_room erargs.spoiler = args.spoiler erargs.race = args.race erargs.outputname = seed_name erargs.outputpath = args.outputpath - - Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) + erargs.skip_prog_balancing = args.skip_prog_balancing settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) @@ -156,7 +157,8 @@ def main(args=None, callback=ERmain): for yaml in weights_cache[path]: if category_name is None: for category in yaml: - if category in AutoWorldRegister.world_types and key in Options.common_options: + if category in AutoWorldRegister.world_types and \ + key in Options.CommonOptions.type_hints: yaml[category][key] = option elif category_name not in yaml: logging.warning(f"Meta: Category {category_name} is not present in {path}.") @@ -194,7 +196,7 @@ def main(args=None, callback=ERmain): player += 1 except Exception as e: - raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e + raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e else: raise RuntimeError(f'No weights specified for player {player}') @@ -339,7 +341,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: game_world = AutoWorldRegister.world_types[game] - options = ChainMap(game_world.option_definitions, Options.per_game_common_options) + options = game_world.options_dataclass.type_hints if option_key in options: if options[option_key].supports_weighting: return get_choice(option_key, category_dict) @@ -373,7 +375,7 @@ def roll_linked_options(weights: dict) -> dict: else: logging.debug(f"linked option {option_set['name']} skipped.") except Exception as e: - raise ValueError(f"Linked option {option_set['name']} is destroyed. " + raise ValueError(f"Linked option {option_set['name']} is invalid. " f"Please fix your linked option.") from e return weights @@ -403,7 +405,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict: update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) except Exception as e: - raise ValueError(f"Your trigger number {i + 1} is destroyed. " + raise ValueError(f"Your trigger number {i + 1} is invalid. " f"Please fix your triggers.") from e return weights @@ -444,11 +446,16 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b f"which is not enabled.") ret = argparse.Namespace() - for option_key in Options.per_game_common_options: - if option_key in weights and option_key not in Options.common_options: + for option_key in Options.PerGameCommonOptions.type_hints: + if option_key in weights and option_key not in Options.CommonOptions.type_hints: raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") ret.game = get_choice("game", weights) + if ret.game not in AutoWorldRegister.world_types: + picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0] + raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? " + f"Check your spelling or installation of that world.") + if ret.game not in weights: raise Exception(f"No game options for selected game \"{ret.game}\" found.") @@ -460,35 +467,27 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b game_weights = weights[ret.game] ret.name = get_choice('name', weights) - for option_key, option in Options.common_options.items(): + for option_key, option in Options.CommonOptions.type_hints.items(): setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) - if ret.game in AutoWorldRegister.world_types: - for option_key, option in world_type.option_definitions.items(): - handle_option(ret, game_weights, option_key, option, plando_options) - for option_key, option in Options.per_game_common_options.items(): - # skip setting this option if already set from common_options, defaulting to root option - if option_key not in world_type.option_definitions and \ - (option_key not in Options.common_options or option_key in game_weights): - handle_option(ret, game_weights, option_key, option, plando_options) - if PlandoOptions.items in plando_options: - ret.plando_items = game_weights.get("plando_items", []) - if ret.game == "Minecraft" or ret.game == "Ocarina of Time": - # bad hardcoded behavior to make this work for now - ret.plando_connections = [] - if PlandoOptions.connections in plando_options: - options = game_weights.get("plando_connections", []) - for placement in options: - if roll_percentage(get_choice("percentage", placement, 100)): - ret.plando_connections.append(PlandoConnection( - get_choice("entrance", placement), - get_choice("exit", placement), - get_choice("direction", placement) - )) - elif ret.game == "A Link to the Past": - roll_alttp_settings(ret, game_weights, plando_options) - else: - raise Exception(f"Unsupported game {ret.game}") + for option_key, option in world_type.options_dataclass.type_hints.items(): + handle_option(ret, game_weights, option_key, option, plando_options) + if PlandoOptions.items in plando_options: + ret.plando_items = game_weights.get("plando_items", []) + if ret.game == "Minecraft" or ret.game == "Ocarina of Time": + # bad hardcoded behavior to make this work for now + ret.plando_connections = [] + if PlandoOptions.connections in plando_options: + options = game_weights.get("plando_connections", []) + for placement in options: + if roll_percentage(get_choice("percentage", placement, 100)): + ret.plando_connections.append(PlandoConnection( + get_choice("entrance", placement), + get_choice("exit", placement), + get_choice("direction", placement) + )) + elif ret.game == "A Link to the Past": + roll_alttp_settings(ret, game_weights, plando_options) return ret diff --git a/KH2Client.py b/KH2Client.py index 5223d8a111..1134932dc2 100644 --- a/KH2Client.py +++ b/KH2Client.py @@ -53,79 +53,8 @@ class KH2Context(CommonContext): self.collectible_override_flags_address = 0 self.collectible_offsets = {} self.sending = [] - # flag for if the player has gotten their starting inventory from the server - self.hasStartingInvo = False # list used to keep track of locations+items player has. Used for disoneccting - self.kh2seedsave = {"checked_locations": {"0": []}, - "starting_inventory": self.hasStartingInvo, - - # Character: [back of invo, front of invo] - "SoraInvo": [0x25CC, 0x2546], - "DonaldInvo": [0x2678, 0x2658], - "GoofyInvo": [0x278E, 0x276C], - "AmountInvo": { - "ServerItems": { - "Ability": {}, - "Amount": {}, - "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, "Aerial Dodge": 0, - "Glide": 0}, - "Bitmask": [], - "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, - "Equipment": [], - "Magic": {}, - "StatIncrease": {}, - "Boost": {}, - }, - "LocalItems": { - "Ability": {}, - "Amount": {}, - "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, - "Aerial Dodge": 0, "Glide": 0}, - "Bitmask": [], - "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, - "Equipment": [], - "Magic": {}, - "StatIncrease": {}, - "Boost": {}, - }}, - # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked - "worldIdChecks": { - "1": [], # world of darkness (story cutscenes) - "2": [], - "3": [], # destiny island doesn't have checks to ima put tt checks here - "4": [], - "5": [], - "6": [], - "7": [], - "8": [], - "9": [], - "10": [], - "11": [], - # atlantica isn't a supported world. if you go in atlantica it will check dc - "12": [], - "13": [], - "14": [], - "15": [], - # world map, but you only go to the world map while on the way to goa so checking hb - "16": [], - "17": [], - "18": [], - "255": [], # starting screen - }, - "Levels": { - "SoraLevel": 0, - "ValorLevel": 0, - "WisdomLevel": 0, - "LimitLevel": 0, - "MasterLevel": 0, - "FinalLevel": 0, - }, - "SoldEquipment": [], - "SoldBoosts": {"Power Boost": 0, - "Magic Boost": 0, - "Defense Boost": 0, - "AP Boost": 0} - } + self.kh2seedsave = None self.slotDataProgressionNames = {} self.kh2seedname = None self.kh2slotdata = None @@ -202,14 +131,13 @@ class KH2Context(CommonContext): self.boost_set = set(CheckDupingItems["Boosts"]) self.stat_increase_set = set(CheckDupingItems["Stat Increases"]) - self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities} # Growth:[level 1,level 4,slot] - self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25CE], - "Quick Run": [0x62, 0x65, 0x25D0], - "Dodge Roll": [0x234, 0x237, 0x25D2], - "Aerial Dodge": [0x066, 0x069, 0x25D4], - "Glide": [0x6A, 0x6D, 0x25D6]} + self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA], + "Quick Run": [0x62, 0x65, 0x25DC], + "Dodge Roll": [0x234, 0x237, 0x25DE], + "Aerial Dodge": [0x066, 0x069, 0x25E0], + "Glide": [0x6A, 0x6D, 0x25E2]} self.boost_to_anchor_dict = { "Power Boost": 0x24F9, "Magic Boost": 0x24FA, @@ -269,19 +197,66 @@ class KH2Context(CommonContext): if not os.path.exists(self.game_communication_path): os.makedirs(self.game_communication_path) if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): + self.kh2seedsave = {"itemIndex": -1, + # back of soras invo is 0x25E2. Growth should be moved there + # Character: [back of invo, front of invo] + "SoraInvo": [0x25D8, 0x2546], + "DonaldInvo": [0x26F4, 0x2658], + "GoofyInvo": [0x280A, 0x276C], + "AmountInvo": { + "ServerItems": { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, + "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + }, + "LocalItems": { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + }}, + # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked + "LocationsChecked": [], + "Levels": { + "SoraLevel": 0, + "ValorLevel": 0, + "WisdomLevel": 0, + "LimitLevel": 0, + "MasterLevel": 0, + "FinalLevel": 0, + }, + "SoldEquipment": [], + "SoldBoosts": {"Power Boost": 0, + "Magic Boost": 0, + "Defense Boost": 0, + "AP Boost": 0} + } with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), 'wt') as f: pass + self.locations_checked = set() elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f: self.kh2seedsave = json.load(f) + self.locations_checked = set(self.kh2seedsave["LocationsChecked"]) + self.serverconneced = True if cmd in {"Connected"}: - for player in args['players']: - if str(player.slot) not in self.kh2seedsave["checked_locations"]: - self.kh2seedsave["checked_locations"].update({str(player.slot): []}) self.kh2slotdata = args['slot_data'] - self.serverconneced = True self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()} try: self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") @@ -296,21 +271,29 @@ class KH2Context(CommonContext): if cmd in {"ReceivedItems"}: start_index = args["index"] - if start_index != len(self.items_received): + if start_index == 0: + # resetting everything that were sent from the server + self.kh2seedsave["SoraInvo"][0] = 0x25D8 + self.kh2seedsave["DonaldInvo"][0] = 0x26F4 + self.kh2seedsave["GoofyInvo"][0] = 0x280A + self.kh2seedsave["itemIndex"] = - 1 + self.kh2seedsave["AmountInvo"]["ServerItems"] = { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, + "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + } + if start_index > self.kh2seedsave["itemIndex"]: + self.kh2seedsave["itemIndex"] = start_index for item in args['items']: - # starting invo from server - if item.location in {-2}: - if not self.kh2seedsave["starting_inventory"]: - asyncio.create_task(self.give_item(item.item)) - # if location is not already given or is !getitem - elif item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \ - or item.location in {-1}: - asyncio.create_task(self.give_item(item.item)) - if item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \ - and item.location not in {-1, -2}: - self.kh2seedsave["checked_locations"][str(item.player)].append(item.location) - if not self.kh2seedsave["starting_inventory"]: - self.kh2seedsave["starting_inventory"] = True + asyncio.create_task(self.give_item(item.item)) if cmd in {"RoomUpdate"}: if "checked_locations" in args: @@ -326,12 +309,12 @@ class KH2Context(CommonContext): if currentworldint in self.worldid: curworldid = self.worldid[currentworldint] for location, data in curworldid.items(): - if location not in self.locations_checked \ + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ and (int.from_bytes( self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") & 0x1 << data.bitIndex) > 0: - self.locations_checked.add(location) - self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + self.sending = self.sending + [(int(locationId))] except Exception as e: logger.info("Line 285") if self.kh2connected: @@ -344,12 +327,12 @@ class KH2Context(CommonContext): for location, data in SoraLevels.items(): currentLevel = int.from_bytes( self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big") - if location not in self.locations_checked \ + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ and currentLevel >= data.bitIndex: if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel: self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel - self.locations_checked.add(location) - self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + self.sending = self.sending + [(int(locationId))] formDict = { 0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels], 3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]} @@ -357,12 +340,12 @@ class KH2Context(CommonContext): for location, data in formDict[i][1].items(): formlevel = int.from_bytes( self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") - if location not in self.locations_checked \ + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ and formlevel >= data.bitIndex: if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]: self.kh2seedsave["Levels"][formDict[i][0]] = formlevel - self.locations_checked.add(location) - self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + self.sending = self.sending + [(int(locationId))] except Exception as e: logger.info("Line 312") if self.kh2connected: @@ -373,18 +356,20 @@ class KH2Context(CommonContext): async def checkSlots(self): try: for location, data in weaponSlots.items(): - if location not in self.locations_checked: + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked: if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") > 0: - self.locations_checked.add(location) - self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + self.sending = self.sending + [(int(locationId))] for location, data in formSlots.items(): - if location not in self.locations_checked: + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked: if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") & 0x1 << data.bitIndex > 0: - self.locations_checked.add(location) - self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + # self.locations_checked + self.sending = self.sending + [(int(locationId))] + except Exception as e: if self.kh2connected: logger.info("Line 333") @@ -394,8 +379,7 @@ class KH2Context(CommonContext): async def verifyChests(self): try: - currentworld = str(int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")) - for location in self.kh2seedsave["worldIdChecks"][currentworld]: + for location in self.locations_checked: locationName = self.lookup_id_to_Location[location] if locationName in self.chest_set: if locationName in self.location_name_to_worlddata.keys(): @@ -428,24 +412,6 @@ class KH2Context(CommonContext): self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor, (self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1) - def verifyLocation(self, location): - locationData = self.location_name_to_worlddata[location] - locationName = self.lookup_id_to_Location[location] - isChecked = True - - if locationName not in levels_locations: - if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), - "big") & 0x1 << locationData.bitIndex) == 0: - isChecked = False - elif locationName in SoraLevels: - if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), - "big") < locationData.bitIndex: - isChecked = False - elif int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), - "big") < locationData.bitIndex: - isChecked = False - return isChecked - async def give_item(self, item, ItemType="ServerItems"): try: itemname = self.lookup_id_to_item[item] @@ -679,7 +645,21 @@ class KH2Context(CommonContext): current = self.kh2.read_short(self.kh2.base_address + self.Save + slot) ability = current & 0x0FFF if ability | 0x8000 != (0x8000 + itemData.memaddr): - self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr) + if current - 0x8000 > 0: + self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr)) + else: + self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr) + # removes the duped ability if client gave faster than the game. + for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}: + if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \ + self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]: + self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0) + # remove the dummy level 1 growths if they are in these invo slots. + for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}: + current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot) + ability = current & 0x0FFF + if 0x05E <= ability <= 0x06D: + self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0) for itemName in self.master_growth: growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \ @@ -707,6 +687,10 @@ class KH2Context(CommonContext): self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big") if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big") & 0x1 << itemData.bitmask) == 0: + # when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game. + if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}: + self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410, + (0).to_bytes(1, 'big'), 1) self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, (itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1) @@ -753,10 +737,13 @@ class KH2Context(CommonContext): if itemName in server_stat: amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName] + # 0x130293 is Crit_1's location id for touching the computer if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big") != amountOfItems \ and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1), - "big") >= 5: + "big") >= 5 and int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1), + "big") > 0: self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, amountOfItems.to_bytes(1, 'big'), 1) @@ -777,7 +764,8 @@ class KH2Context(CommonContext): if itemName == "AP Boost": amountOfUsedBoosts -= 50 totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts) - if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][itemName] and amountOfBoostsInInvo < 255: + if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][ + itemName] and amountOfBoostsInInvo < 255: self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, (amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1) @@ -859,9 +847,9 @@ async def kh2_watcher(ctx: KH2Context): location_ids = [] location_ids = [location for location in message[0]["locations"] if location not in location_ids] for location in location_ids: - currentWorld = int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + 0x0714DB8, 1), "big") - if location not in ctx.kh2seedsave["worldIdChecks"][str(currentWorld)]: - ctx.kh2seedsave["worldIdChecks"][str(currentWorld)].append(location) + if location not in ctx.locations_checked: + ctx.locations_checked.add(location) + ctx.kh2seedsave["LocationsChecked"].append(location) if location in ctx.kh2LocalItems: item = ctx.kh2slotdata["LocalItems"][str(location)] await asyncio.create_task(ctx.give_item(item, "LocalItems")) diff --git a/Launcher.py b/Launcher.py index be40987e32..9e184bf108 100644 --- a/Launcher.py +++ b/Launcher.py @@ -11,14 +11,19 @@ Scroll down to components= to add components to the launcher as well as setup.py import argparse import itertools +import logging +import multiprocessing import shlex import subprocess import sys +import webbrowser from os.path import isfile from shutil import which from typing import Sequence, Union, Optional -from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier +import Utils +import settings +from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths if __name__ == "__main__": import ModuleUpdate @@ -29,7 +34,8 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename, def open_host_yaml(): - file = user_path('host.yaml') + file = settings.get_settings().filename + assert file, "host.yaml missing" if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') @@ -38,79 +44,103 @@ def open_host_yaml(): exe = which("open") subprocess.Popen([exe, file]) else: - import webbrowser webbrowser.open(file) def open_patch(): suffixes = [] for c in components: - if isfile(get_exe(c)[-1]): - suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \ - isinstance(c.file_identifier, SuffixIdentifier) else [] + if c.type == Type.CLIENT and \ + isinstance(c.file_identifier, SuffixIdentifier) and \ + (c.script_name is None or isfile(get_exe(c)[-1])): + suffixes += c.file_identifier.suffixes try: - filename = open_filename('Select patch', (('Patches', suffixes),)) + filename = open_filename("Select patch", (("Patches", suffixes),)) except Exception as e: - messagebox('Error', str(e), error=True) + messagebox("Error", str(e), error=True) else: - file, _, component = identify(filename) + file, component = identify(filename) if file and component: - launch([*get_exe(component), file], component.cli) + exe = get_exe(component) + if exe is None or not isfile(exe[-1]): + exe = get_exe("Launcher") + + launch([*exe, file], component.cli) + + +def generate_yamls(): + from Options import generate_yaml_templates + + target = Utils.user_path("Players", "Templates") + generate_yaml_templates(target, False) + open_folder(target) def browse_files(): - file = user_path() + open_folder(user_path()) + + +def open_folder(folder_path): if is_linux: exe = which('xdg-open') or which('gnome-open') or which('kde-open') - subprocess.Popen([exe, file]) + subprocess.Popen([exe, folder_path]) elif is_macos: exe = which("open") - subprocess.Popen([exe, file]) + subprocess.Popen([exe, folder_path]) else: - import webbrowser - webbrowser.open(file) + webbrowser.open(folder_path) + + +def update_settings(): + from settings import get_settings + get_settings().save() components.extend([ # Functions - Component('Open host.yaml', func=open_host_yaml), - Component('Open Patch', func=open_patch), - Component('Browse Files', func=browse_files), + Component("Open host.yaml", func=open_host_yaml), + Component("Open Patch", func=open_patch), + Component("Generate Template Settings", func=generate_yamls), + Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), + Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Browse Files", func=browse_files), ]) def identify(path: Union[None, str]): if path is None: - return None, None, None + return None, None for component in components: if component.handles_file(path): - return path, component.script_name, component - return (None, None, None) if '/' in path or '\\' in path else (None, path, None) + return path, component + elif path == component.display_name or path == component.script_name: + return None, component + return None, None def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]: if isinstance(component, str): name = component component = None - if name.startswith('Archipelago'): + if name.startswith("Archipelago"): name = name[11:] - if name.endswith('.exe'): + if name.endswith(".exe"): name = name[:-4] - if name.endswith('.py'): + if name.endswith(".py"): name = name[:-3] if not name: return None for c in components: - if c.script_name == name or c.frozen_name == f'Archipelago{name}': + if c.script_name == name or c.frozen_name == f"Archipelago{name}": component = c break if not component: return None if is_frozen(): - suffix = '.exe' if is_windows else '' - return [local_path(f'{component.frozen_name}{suffix}')] + suffix = ".exe" if is_windows else "" + return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None else: - return [sys.executable, local_path(f'{component.script_name}.py')] + return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None def launch(exe, in_terminal=False): @@ -132,16 +162,18 @@ def launch(exe, in_terminal=False): def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label + from kivy.uix.image import AsyncImage + from kivy.uix.relativelayout import RelativeLayout class Launcher(App): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - _tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])} - _funcs = {c.display_name: c for c in components if c.type == Type.FUNC} + _tools = {c.display_name: c for c in components if c.type == Type.TOOL} + _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} + _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} + _miscs = {c.display_name: c for c in components if c.type == Type.MISC} def __init__(self, ctx=None): self.title = self.base_title @@ -153,24 +185,44 @@ def run_gui(): self.container = ContainerLayout() self.grid = GridLayout(cols=2) self.container.add_widget(self.grid) - + self.grid.add_widget(Label(text="General")) + self.grid.add_widget(Label(text="Clients")) button_layout = self.grid # make buttons fill the window + + def build_button(component: Component): + """ + Builds a button widget for a given component. + + Args: + component (Component): The component associated with the button. + + Returns: + None. The button is added to the parent grid layout. + + """ + button = Button(text=component.display_name) + button.component = component + button.bind(on_release=self.component_action) + if component.icon != "icon": + image = AsyncImage(source=icon_paths[component.icon], + size=(38, 38), size_hint=(None, 1), pos=(5, 0)) + box_layout = RelativeLayout() + box_layout.add_widget(button) + box_layout.add_widget(image) + button_layout.add_widget(box_layout) + else: + button_layout.add_widget(button) + for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()): + self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): # column 1 if tool: - button = Button(text=tool[0]) - button.component = tool[1] - button.bind(on_release=self.component_action) - button_layout.add_widget(button) + build_button(tool[1]) else: button_layout.add_widget(Label()) # column 2 if client: - button = Button(text=client[0]) - button.component = client[1] - button.bind(on_press=self.component_action) - button_layout.add_widget(button) + build_button(client[1]) else: button_layout.add_widget(Label()) @@ -178,14 +230,29 @@ def run_gui(): @staticmethod def component_action(button): - if button.component.type == Type.FUNC: + if button.component.func: button.component.func() else: launch(get_exe(button.component), button.component.cli) + def _stop(self, *largs): + # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. + # Closing the window explicitly cleans it up. + self.root_window.close() + super()._stop(*largs) + Launcher().run() +def run_component(component: Component, *args): + if component.func: + component.func(*args) + elif component.script_name: + subprocess.run([*get_exe(component.script_name), *args]) + else: + logging.warning(f"Component {component} does not appear to be executable.") + + def main(args: Optional[Union[argparse.Namespace, dict]] = None): if isinstance(args, argparse.Namespace): args = {k: v for k, v in args._get_kwargs()} @@ -193,24 +260,40 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): args = {} if "Patch|Game|Component" in args: - file, component, _ = identify(args["Patch|Game|Component"]) + file, component = identify(args["Patch|Game|Component"]) if file: args['file'] = file if component: args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") + if args["update_settings"]: + update_settings() if 'file' in args: - subprocess.run([*get_exe(args['component']), args['file'], *args['args']]) + run_component(args["component"], args["file"], *args["args"]) elif 'component' in args: - subprocess.run([*get_exe(args['component']), *args['args']]) - else: + run_component(args["component"], *args["args"]) + elif not args["update_settings"]: run_gui() if __name__ == '__main__': init_logging('Launcher') + Utils.freeze_support() + multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser(description='Archipelago Launcher') - parser.add_argument('Patch|Game|Component', type=str, nargs='?', - help="Pass either a patch file, a generated game or the name of a component to run.") - parser.add_argument('args', nargs="*", help="Arguments to pass to component.") + run_group = parser.add_argument_group("Run") + run_group.add_argument("--update_settings", action="store_true", + help="Update host.yaml and exit.") + run_group.add_argument("Patch|Game|Component", type=str, nargs="?", + help="Pass either a patch file, a generated game or the name of a component to run.") + run_group.add_argument("args", nargs="*", + help="Arguments to pass to component.") main(parser.parse_args()) + + from worlds.LauncherComponents import processes + for process in processes: + # we await all child processes to close before we tear down the process host + # this makes it feel like each one is its own program, as the Launcher is closed now + process.join() diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index e0557e4af4..f3fc9d2cdb 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -9,15 +9,18 @@ if __name__ == "__main__": import asyncio import base64 import binascii +import colorama import io -import logging +import os +import re import select +import shlex import socket +import struct +import sys +import subprocess import time import typing -import urllib - -import colorama from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, @@ -30,6 +33,7 @@ from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.Locations import get_locations_to_id, meta_to_name from worlds.ladx.Tracker import LocationTracker, MagpieBridge + class GameboyException(Exception): pass @@ -91,7 +95,7 @@ class LAClientConstants: # wLinkSendShopTarget = 0xDDFF - wRecvIndex = 0xDDFE # 0xDB58 + wRecvIndex = 0xDDFD # Two bytes wCheckAddress = 0xC0FF - 0x4 WRamCheckSize = 0x4 WRamSafetyValue = bytearray([0]*WRamCheckSize) @@ -115,17 +119,17 @@ class RAGameboy(): assert (self.socket) self.socket.setblocking(False) - def get_retroarch_version(self): - self.send(b'VERSION\n') - select.select([self.socket], [], []) - response_str, addr = self.socket.recvfrom(16) + async def send_command(self, command, timeout=1.0): + self.send(f'{command}\n') + response_str = await self.async_recv() + self.check_command_response(command, response_str) return response_str.rstrip() - def get_retroarch_status(self, timeout): - self.send(b'GET_STATUS\n') - select.select([self.socket], [], [], timeout) - response_str, addr = self.socket.recvfrom(1000, ) - return response_str.rstrip() + async def get_retroarch_version(self): + return await self.send_command("VERSION") + + async def get_retroarch_status(self): + return await self.send_command("GET_STATUS") def set_cache_limits(self, cache_start, cache_size): self.cache_start = cache_start @@ -141,8 +145,8 @@ class RAGameboy(): response, _ = self.socket.recvfrom(4096) return response - async def async_recv(self): - response = await asyncio.get_event_loop().sock_recv(self.socket, 4096) + async def async_recv(self, timeout=1.0): + response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout) return response async def check_safe_gameplay(self, throw=True): @@ -169,6 +173,8 @@ class RAGameboy(): raise InvalidEmulatorStateError() return False if not await check_wram(): + if throw: + raise InvalidEmulatorStateError() return False return True @@ -227,20 +233,30 @@ class RAGameboy(): return r + def check_command_response(self, command: str, response: bytes): + if command == "VERSION": + ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None + else: + ok = response.startswith(command.encode()) + if not ok: + logger.warning(f"Bad response to command {command} - {response}") + raise BadRetroArchResponse() + def read_memory(self, address, size=1): command = "READ_CORE_MEMORY" self.send(f'{command} {hex(address)} {size}\n') response = self.recv() + self.check_command_response(command, response) + splits = response.decode().split(" ", 2) - - assert (splits[0] == command) # Ignore the address for now - - # TODO: transform to bytes - if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY": + if splits[2][:2] == "-1": raise BadRetroArchResponse() + + # TODO: check response address, check hex behavior between RA and BH + return bytearray.fromhex(splits[2]) async def async_read_memory(self, address, size=1): @@ -248,14 +264,21 @@ class RAGameboy(): self.send(f'{command} {hex(address)} {size}\n') response = await self.async_recv() + self.check_command_response(command, response) response = response[:-1] splits = response.decode().split(" ", 2) + try: + response_addr = int(splits[1], 16) + except ValueError: + raise BadRetroArchResponse() - assert (splits[0] == command) - # Ignore the address for now + if response_addr != address: + raise BadRetroArchResponse() - # TODO: transform to bytes - return bytearray.fromhex(splits[2]) + ret = bytearray.fromhex(splits[2]) + if len(ret) > size: + raise BadRetroArchResponse() + return ret def write_memory(self, address, bytes): command = "WRITE_CORE_MEMORY" @@ -263,7 +286,7 @@ class RAGameboy(): self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}') select.select([self.socket], [], []) response, _ = self.socket.recvfrom(4096) - + self.check_command_response(command, response) splits = response.decode().split(" ", 3) assert (splits[0] == command) @@ -281,6 +304,9 @@ class LinksAwakeningClient(): pending_deathlink = False deathlink_debounce = True recvd_checks = {} + retroarch_address = None + retroarch_port = None + gameboy = None def msg(self, m): logger.info(m) @@ -288,50 +314,48 @@ class LinksAwakeningClient(): self.gameboy.send(s) def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355): - self.gameboy = RAGameboy(retroarch_address, retroarch_port) + self.retroarch_address = retroarch_address + self.retroarch_port = retroarch_port + pass + stop_bizhawk_spam = False async def wait_for_retroarch_connection(self): - logger.info("Waiting on connection to Retroarch...") + if not self.stop_bizhawk_spam: + logger.info("Waiting on connection to Retroarch...") + self.stop_bizhawk_spam = True + self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port) + while True: try: - version = self.gameboy.get_retroarch_version() + version = await self.gameboy.get_retroarch_version() NO_CONTENT = b"GET_STATUS CONTENTLESS" status = NO_CONTENT core_type = None GAME_BOY = b"game_boy" while status == NO_CONTENT or core_type != GAME_BOY: - try: - status = self.gameboy.get_retroarch_status(0.1) - if status.count(b" ") < 2: - await asyncio.sleep(1.0) - continue - - GET_STATUS, PLAYING, info = status.split(b" ", 2) - if status.count(b",") < 2: - await asyncio.sleep(1.0) - continue - core_type, rom_name, self.game_crc = info.split(b",", 2) - if core_type != GAME_BOY: - logger.info( - f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") - await asyncio.sleep(1.0) - continue - except (BlockingIOError, TimeoutError) as e: - await asyncio.sleep(0.1) - pass - logger.info(f"Connected to Retroarch {version} {info}") - self.gameboy.read_memory(0x1000) + status = await self.gameboy.get_retroarch_status() + if status.count(b" ") < 2: + await asyncio.sleep(1.0) + continue + GET_STATUS, PLAYING, info = status.split(b" ", 2) + if status.count(b",") < 2: + await asyncio.sleep(1.0) + continue + core_type, rom_name, self.game_crc = info.split(b",", 2) + if core_type != GAME_BOY: + logger.info( + f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") + await asyncio.sleep(1.0) + continue + self.stop_bizhawk_spam = False + logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") return - except ConnectionResetError: + except (BlockingIOError, TimeoutError, ConnectionResetError): await asyncio.sleep(1.0) pass - def reset_auth(self): - auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode() - - if self.auth: - assert (auth == self.auth) - + async def reset_auth(self): + auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() self.auth = auth async def wait_and_init_tracker(self): @@ -365,14 +389,16 @@ class LinksAwakeningClient(): item_id, from_player]) status |= 1 status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status]) - self.gameboy.write_memory(LAClientConstants.wRecvIndex, [next_index]) + self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) + should_reset_auth = False async def wait_for_game_ready(self): logger.info("Waiting on game to be in valid state...") while not await self.gameboy.check_safe_gameplay(throw=False): - pass - logger.info("Ready!") - last_index = 0 + if self.should_reset_auth: + self.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + logger.info("Game connection ready!") async def is_victory(self): return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 @@ -382,11 +408,6 @@ class LinksAwakeningClient(): await self.item_tracker.readItems() await self.gps_tracker.read_location() - next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0] - if next_index != self.last_index: - self.last_index = next_index - # logger.info(f"Got new index {next_index}") - current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] if self.deathlink_debounce and current_health != 0: self.deathlink_debounce = False @@ -404,7 +425,7 @@ class LinksAwakeningClient(): if await self.is_victory(): await win_cb() - recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0] + recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0] # Play back one at a time if recv_index in self.recvd_checks: @@ -438,12 +459,16 @@ class LinksAwakeningContext(CommonContext): found_checks = [] last_resend = time.time() - magpie = MagpieBridge() + magpie_enabled = False + magpie = None magpie_task = None won = False - def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: self.client = LinksAwakeningClient() + if magpie: + self.magpie_enabled = True + self.magpie = MagpieBridge() super().__init__(server_address, password) def run_gui(self) -> None: @@ -462,16 +487,17 @@ class LinksAwakeningContext(CommonContext): def build(self): b = super().build() - button = Button(text="", size=(30, 30), size_hint_x=None, - on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) - image = Image(size=(16, 16), texture=magpie_logo()) - button.add_widget(image) + if self.ctx.magpie_enabled: + button = Button(text="", size=(30, 30), size_hint_x=None, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) + image = Image(size=(16, 16), texture=magpie_logo()) + button.add_widget(image) - def set_center(_, center): - image.center = center - button.bind(center=set_center) + def set_center(_, center): + image.center = center + button.bind(center=set_center) - self.connect_layout.add_widget(button) + self.connect_layout.add_widget(button) return b self.ui = LADXManager(self) @@ -481,6 +507,15 @@ class LinksAwakeningContext(CommonContext): message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] await self.send_msgs(message) + had_invalid_slot_data = None + def event_invalid_slot(self): + # The next time we try to connect, reset the game loop for new auth + self.had_invalid_slot_data = True + self.auth = None + # Don't try to autoreconnect, it will just fail + self.disconnected_intentionally = True + CommonContext.event_invalid_slot(self) + ENABLE_DEATHLINK = False async def send_deathlink(self): if self.ENABLE_DEATHLINK: @@ -506,13 +541,23 @@ class LinksAwakeningContext(CommonContext): def new_checks(self, item_ids, ladxr_ids): self.found_checks += item_ids create_task_log_exception(self.send_checks()) - create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) + if self.magpie_enabled: + create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: await super(LinksAwakeningContext, self).server_auth(password_requested) + + if self.had_invalid_slot_data: + # We are connecting when previously we had the wrong ROM or server - just in case + # re-read the ROM so that if the user had the correct address but wrong ROM, we + # allow a successful reconnect + self.client.should_reset_auth = True + self.had_invalid_slot_data = False + + while self.client.auth == None: + await asyncio.sleep(0.1) self.auth = self.client.auth - await self.get_username() await self.send_connect() def on_package(self, cmd: str, args: dict): @@ -520,9 +565,13 @@ class LinksAwakeningContext(CommonContext): self.game = self.slot_info[self.slot].game # TODO - use watcher_event if cmd == "ReceivedItems": - for index, item in enumerate(args["items"], args["index"]): + for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item + async def sync(self): + sync_msg = [{'cmd': 'Sync'}] + await self.send_msgs(sync_msg) + item_id_lookup = get_locations_to_id() async def run_game_loop(self): @@ -537,18 +586,33 @@ class LinksAwakeningContext(CommonContext): async def deathlink(): await self.send_deathlink() - self.magpie_task = asyncio.create_task(self.magpie.serve()) - + if self.magpie_enabled: + self.magpie_task = asyncio.create_task(self.magpie.serve()) + # yield to allow UI to start await asyncio.sleep(0) while True: try: # TODO: cancel all client tasks - logger.info("(Re)Starting game loop") + if not self.client.stop_bizhawk_spam: + logger.info("(Re)Starting game loop") self.found_checks.clear() + # On restart of game loop, clear all checks, just in case we swapped ROMs + # this isn't totally neccessary, but is extra safety against cross-ROM contamination + self.client.recvd_checks.clear() await self.client.wait_for_retroarch_connection() - self.client.reset_auth() + await self.client.reset_auth() + # If we find ourselves with new auth after the reset, reconnect + if self.auth and self.client.auth != self.auth: + # It would be neat to reconnect here, but connection needs this loop to be running + logger.info("Detected new ROM, disconnecting...") + await self.disconnect() + continue + + if not self.client.recvd_checks: + await self.sync() + await self.client.wait_and_init_tracker() while True: @@ -558,39 +622,62 @@ class LinksAwakeningContext(CommonContext): if self.last_resend + 5.0 < now: self.last_resend = now await self.send_checks() - self.magpie.set_checks(self.client.tracker.all_checks) - await self.magpie.set_item_tracker(self.client.item_tracker) - await self.magpie.send_gps(self.client.gps_tracker) + if self.magpie_enabled: + try: + self.magpie.set_checks(self.client.tracker.all_checks) + await self.magpie.set_item_tracker(self.client.item_tracker) + await self.magpie.send_gps(self.client.gps_tracker) + except Exception: + # Don't let magpie errors take out the client + pass + if self.client.should_reset_auth: + self.client.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError): + await asyncio.sleep(1.0) - except GameboyException: - time.sleep(1.0) - pass +def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["ladx_options"].get("rom_start", True)) + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif isinstance(auto_start, str): + args = shlex.split(auto_start) + # Specify full path to ROM as we are going to cd in popen + full_rom_path = os.path.realpath(romfile) + args.append(full_rom_path) + try: + # set cwd so that paths to lua scripts are always relative to our client + if getattr(sys, 'frozen', False): + # The application is frozen + script_dir = os.path.dirname(sys.executable) + else: + script_dir = os.path.dirname(os.path.realpath(__file__)) + subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir) + except FileNotFoundError: + logger.error(f"Couldn't launch ROM, {args[0]} is missing") async def main(): parser = get_base_parser(description="Link's Awakening Client.") parser.add_argument("--url", help="Archipelago connection url") - + parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") parser.add_argument('diff_file', default="", type=str, nargs="?", help='Path to a .apladx Archipelago Binary Patch file') + args = parser.parse_args() - logger.info(args) if args.diff_file: import Patch logger.info("patch file was supplied - creating rom...") meta, rom_file = Patch.create_rom_file(args.diff_file) - if "server" in meta: - args.url = meta["server"] + if "server" in meta and not args.connect: + args.connect = meta["server"] logger.info(f"wrote rom file to {rom_file}") - if args.url: - url = urllib.parse.urlparse(args.url) - args.connect = url.netloc - if url.password: - args.password = urllib.parse.unquote(url.password) - ctx = LinksAwakeningContext(args.connect, args.password) + ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") @@ -600,6 +687,10 @@ async def main(): ctx.run_gui() ctx.run_cli() + # Down below run_gui so that we get errors out of the process + if args.diff_file: + run_game(rom_file) + await ctx.exit_event.wait() await ctx.shutdown() diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 205a76813a..9c5bd10244 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -25,7 +25,7 @@ ModuleUpdate.update() from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ - get_adjuster_settings, tkinter_center_window, init_logging + get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging GAME_ALTTP = "A Link to the Past" @@ -43,8 +43,49 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action): return textwrap.dedent(action.help) +# See argparse.BooleanOptionalAction +class BooleanOptionalActionWithDisable(argparse.Action): + def __init__(self, + option_strings, + dest, + default=None, + type=None, + choices=None, + required=False, + help=None, + metavar=None): -def main(): + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith('--'): + option_string = '--disable' + option_string[2:] + _option_strings.append(option_string) + + if help is not None and default is not None: + help += " (default: %(default)s)" + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs=0, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar) + + def __call__(self, parser, namespace, values, option_string=None): + if option_string in self.option_strings: + setattr(namespace, self.dest, not option_string.startswith('--disable')) + + def format_usage(self): + return ' | '.join(self.option_strings) + + +def get_argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.') @@ -52,6 +93,8 @@ def main(): help='Path to an ALttP Japan(1.0) rom to use as a base.') parser.add_argument('--loglevel', default='info', const='info', nargs='?', choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.') + parser.add_argument('--auto_apply', default='ask', + choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.') parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?', choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'], help='''\ @@ -61,7 +104,7 @@ def main(): parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true') parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true') parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true') - parser.add_argument('--disablemusic', help='Disables game music.', action='store_true') + parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable) parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?', choices=['normal', 'hide_goal', 'hide_required', 'hide_both'], help='''\ @@ -85,9 +128,6 @@ def main(): parser.add_argument('--ow_palettes', default='default', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', 'sick']) - # parser.add_argument('--link_palettes', default='default', - # choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', - # 'sick']) parser.add_argument('--shield_palettes', default='default', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', 'sick']) @@ -107,10 +147,23 @@ def main(): Alternatively, can be a ALttP Rom patched with a Link sprite that will be extracted. ''') - parser.add_argument('--names', default='', type=str) + parser.add_argument('--sprite_pool', nargs='+', default=[], help=''' + A list of sprites to pull from. + ''') + parser.add_argument('--oof', help='''\ + Path to a sound effect to replace Link's "oof" sound. + Needs to be in a .brr format and have a length of no + more than 2673 bytes, created from a 16-bit signed PCM + .wav at 12khz. https://github.com/boldowa/snesbrr + ''') parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.') - args = parser.parse_args() - args.music = not args.disablemusic + return parser + + +def main(): + parser = get_argparser() + args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP)) + # set up logger loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[ args.loglevel] @@ -126,6 +179,13 @@ def main(): if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite): input('Could not find link sprite sheet at given location. \nPress Enter to exit.') sys.exit(1) + if args.oof is not None and not os.path.isfile(args.oof): + input('Could not find oof sound effect at given location. \nPress Enter to exit.') + sys.exit(1) + if args.oof is not None and os.path.getsize(args.oof) > 2673: + input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.') + sys.exit(1) + args, path = adjust(args=args) if isinstance(args.sprite, Sprite): @@ -165,7 +225,7 @@ def adjust(args): world = getattr(args, "world") apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music, - args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world, + args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world, deathlink=args.deathlink, allowcollect=args.allowcollect) path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc') rom.write_to_file(path) @@ -180,7 +240,7 @@ def adjustGUI(): from tkinter import Tk, LEFT, BOTTOM, TOP, \ StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk from argparse import Namespace - from Main import __version__ as MWVersion + from Utils import __version__ as MWVersion adjustWindow = Tk() adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion) set_icon(adjustWindow) @@ -227,6 +287,7 @@ def adjustGUI(): guiargs.sprite = rom_vars.sprite if rom_vars.sprite_pool: guiargs.world = AdjusterWorld(rom_vars.sprite_pool) + guiargs.oof = rom_vars.oof try: guiargs, path = adjust(args=guiargs) @@ -265,6 +326,7 @@ def adjustGUI(): else: guiargs.sprite = rom_vars.sprite guiargs.sprite_pool = rom_vars.sprite_pool + guiargs.oof = rom_vars.oof persistent_store("adjuster", GAME_ALTTP, guiargs) messagebox.showinfo(title="Success", message="Settings saved to persistent storage") @@ -481,11 +543,38 @@ class BackgroundTaskProgressNullWindow(BackgroundTask): self.stop() +class AttachTooltip(object): + + def __init__(self, parent, text): + self._parent = parent + self._text = text + self._window = None + parent.bind('', lambda event : self.show()) + parent.bind('', lambda event : self.hide()) + + def show(self): + if self._window or not self._text: + return + self._window = Toplevel(self._parent) + #remove window bar controls + self._window.wm_overrideredirect(1) + #adjust positioning + x, y, *_ = self._parent.bbox("insert") + x = x + self._parent.winfo_rootx() + 20 + y = y + self._parent.winfo_rooty() + 20 + self._window.wm_geometry("+{0}+{1}".format(x,y)) + #show text + label = Label(self._window, text=self._text, justify=LEFT) + label.pack(ipadx=1) + + def hide(self): + if self._window: + self._window.destroy() + self._window = None + + def get_rom_frame(parent=None): adjuster_settings = get_adjuster_settings(GAME_ALTTP) - if not adjuster_settings: - adjuster_settings = Namespace() - adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" romFrame = Frame(parent) baseRomLabel = Label(romFrame, text='LttP Base Rom: ') @@ -513,32 +602,8 @@ def get_rom_frame(parent=None): return romFrame, romVar - def get_rom_options_frame(parent=None): adjuster_settings = get_adjuster_settings(GAME_ALTTP) - defaults = { - "auto_apply": 'ask', - "music": True, - "reduceflashing": True, - "deathlink": False, - "sprite": None, - "quickswap": True, - "menuspeed": 'normal', - "heartcolor": 'red', - "heartbeep": 'normal', - "ow_palettes": 'default', - "uw_palettes": 'default', - "hud_palettes": 'default', - "sword_palettes": 'default', - "shield_palettes": 'default', - "sprite_pool": [], - "allowcollect": False, - } - if not adjuster_settings: - adjuster_settings = Namespace() - for key, defaultvalue in defaults.items(): - if not hasattr(adjuster_settings, key): - setattr(adjuster_settings, key, defaultvalue) romOptionsFrame = LabelFrame(parent, text="Rom options") romOptionsFrame.columnconfigure(0, weight=1) @@ -598,12 +663,50 @@ def get_rom_options_frame(parent=None): spriteEntry.pack(side=LEFT) spriteSelectButton.pack(side=LEFT) + oofDialogFrame = Frame(romOptionsFrame) + oofDialogFrame.grid(row=1, column=1) + baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:') + + vars.oofNameVar = StringVar() + vars.oof = adjuster_settings.oof + + def set_oof(oof_param): + nonlocal vars + if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673: + vars.oof = oof_param + vars.oofNameVar.set(oof_param.rsplit('/',1)[-1]) + else: + vars.oof = None + vars.oofNameVar.set('(unchanged)') + + set_oof(adjuster_settings.oof) + oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar) + + def OofSelect(): + nonlocal vars + oof_file = filedialog.askopenfilename( + filetypes=[("BRR files", ".brr"), + ("All Files", "*")]) + try: + set_oof(oof_file) + except Exception: + set_oof(None) + + oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect) + AttachTooltip(oofSelectButton, + text="Select a .brr file no more than 2673 bytes.\n" + \ + "This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.") + + baseOofLabel.pack(side=LEFT) + oofEntry.pack(side=LEFT) + oofSelectButton.pack(side=LEFT) + vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap) quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar) quickSwapCheckbutton.grid(row=1, column=0, sticky=E) menuspeedFrame = Frame(romOptionsFrame) - menuspeedFrame.grid(row=1, column=1, sticky=E) + menuspeedFrame.grid(row=6, column=1, sticky=E) menuspeedLabel = Label(menuspeedFrame, text='Menu speed') menuspeedLabel.pack(side=LEFT) vars.menuspeedVar = StringVar() @@ -901,6 +1004,7 @@ class SpriteSelector(): self.add_to_sprite_pool(sprite) def icon_section(self, frame_label, path, no_results_label): + os.makedirs(path, exist_ok=True) frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5) frame.pack(side=TOP, fill=X) @@ -1056,7 +1160,6 @@ class SpriteSelector(): def custom_sprite_dir(self): return user_path("data", "sprites", "custom") - def get_image_for_sprite(sprite, gif_only: bool = False): if not sprite.valid: return None diff --git a/MMBN3Client.py b/MMBN3Client.py new file mode 100644 index 0000000000..3f7474a6fd --- /dev/null +++ b/MMBN3Client.py @@ -0,0 +1,376 @@ +import asyncio +import hashlib +import json +import os +import multiprocessing +import subprocess +import zipfile + +from asyncio import StreamReader, StreamWriter + +import bsdiff4 + +from CommonClient import CommonContext, server_loop, gui_enabled, \ + ClientCommandProcessor, logger, get_base_parser +import Utils +from NetUtils import ClientStatus +from worlds.mmbn3.Items import items_by_id +from worlds.mmbn3.Rom import get_base_rom_path +from worlds.mmbn3.Locations import all_locations, scoutable_locations + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua" +CONNECTION_REFUSED_STATUS = \ + "Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" +CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version" + +script_version: int = 2 + +debugEnabled = False +locations_checked = [] +items_sent = [] +itemIndex = 1 + +CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442" + + +class MMBN3CommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + + def _cmd_gba(self): + """Check GBA Connection State""" + if isinstance(self.ctx, MMBN3Context): + logger.info(f"GBA Status: {self.ctx.gba_status}") + + def _cmd_debug(self): + """Toggle the Debug Text overlay in ROM""" + global debugEnabled + debugEnabled = not debugEnabled + logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled") + + +class MMBN3Context(CommonContext): + command_processor = MMBN3CommandProcessor + game = "MegaMan Battle Network 3" + items_handling = 0b001 # full local + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.gba_streams: (StreamReader, StreamWriter) = None + self.gba_sync_task = None + self.gba_status = CONNECTION_INITIAL_STATUS + self.awaiting_rom = False + self.location_table = {} + self.version_warning = False + self.auth_name = None + self.slot_data = dict() + self.patching_error = False + self.sent_hints = [] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(MMBN3Context, self).server_auth(password_requested) + + if self.auth_name is None: + self.awaiting_rom = True + logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server") + return + + logger.info("Attempting to decode from ROM... ") + self.awaiting_rom = False + self.auth = self.auth_name.decode("utf8").replace('\x00', '') + logger.info("Connecting as "+self.auth) + await self.send_connect(name=self.auth) + + def run_gui(self): + from kvui import GameManager + + class MMBN3Manager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago MegaMan Battle Network 3 Client" + + self.ui = MMBN3Manager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.slot_data = args.get("slot_data", {}) + print(self.slot_data) + +class ItemInfo: + id = 0x00 + sender = "" + type = "" + count = 1 + itemName = "Unknown" + itemID = 0x00 # Item ID, Chip ID, etc. + subItemID = 0x00 # Code for chips, color for programs + itemIndex = 1 + + def __init__(self, id, sender, type): + self.id = id + self.sender = sender + self.type = type + + def get_json(self): + json_data = { + "id": self.id, + "sender": self.sender, + "type": self.type, + "itemName": self.itemName, + "itemID": self.itemID, + "subItemID": self.subItemID, + "count": self.count, + "itemIndex": self.itemIndex + } + return json_data + + +def get_payload(ctx: MMBN3Context): + global debugEnabled + + items_sent = [] + for i, item in enumerate(ctx.items_received): + item_data = items_by_id[item.item] + new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type) + new_item.itemIndex = i+1 + new_item.itemName = item_data.itemName + new_item.type = item_data.type + new_item.itemID = item_data.itemID + new_item.subItemID = item_data.subItemID + new_item.count = item_data.count + items_sent.append(new_item) + + return json.dumps({ + "items": [item.get_json() for item in items_sent], + "debug": debugEnabled + }) + + +async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool): + # Game completion handling + if payload["gameComplete"] and not ctx.finished_game: + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + ctx.finished_game = True + + # Locations handling + if ctx.location_table != payload["locations"]: + ctx.location_table = payload["locations"] + locs = [loc.id for loc in all_locations + if check_location_packet(loc, ctx.location_table)] + await ctx.send_msgs([{ + "cmd": "LocationChecks", + "locations": locs + }]) + + # If trade hinting is enabled, send scout checks + if ctx.slot_data.get("trade_quest_hinting", 0) == 2: + trade_bits = [loc.id for loc in scoutable_locations + if check_location_scouted(loc, payload["locations"])] + scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints] + if len(scouted_locs) > 0: + ctx.sent_hints.extend(scouted_locs) + await ctx.send_msgs([{ + "cmd": "LocationScouts", + "locations": scouted_locs, + "create_as_hint": 2 + }]) + + +def check_location_packet(location, memory): + if len(memory) == 0: + return False + # Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well + location_key = hex(location.flag_byte)[2:] + byte = memory.get(location_key) + if byte is not None: + return byte & location.flag_mask + + +def check_location_scouted(location, memory): + if len(memory) == 0: + return False + location_key = hex(location.hint_flag)[2:] + byte = memory.get(location_key) + if byte is not None: + return byte & location.hint_flag_mask + + +async def gba_sync_task(ctx: MMBN3Context): + logger.info("Starting GBA connector. Use /gba for status information.") + if ctx.patching_error: + logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.') + while not ctx.exit_event.is_set(): + error_status = None + if ctx.gba_streams: + (reader, writer) = ctx.gba_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 four fields + # 1. str: player name (always) + # 2. int: script version (always) + # 3. dict[str, byte]: value of location's memory byte + # 4. bool: whether the game currently registers as complete + data = await asyncio.wait_for(reader.readline(), timeout=10) + data_decoded = json.loads(data.decode()) + reported_version = data_decoded.get("scriptVersion", 0) + if reported_version >= script_version: + if ctx.game is not None and "locations" in data_decoded: + # Not just a keep alive ping, parse + asyncio.create_task((parse_payload(data_decoded, ctx, False))) + if not ctx.auth: + ctx.auth_name = bytes(data_decoded["playerName"]) + + if ctx.awaiting_rom: + logger.info("Awaiting data from ROM...") + await ctx.server_auth(False) + else: + if not ctx.version_warning: + logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}." + "Please update to the latest version." + "Your connection to the Archipelago server will not be accepted.") + ctx.version_warning = True + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.gba_streams = None + except ConnectionResetError: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.gba_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.gba_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.gba_streams = None + if ctx.gba_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to GBA") + ctx.gba_status = CONNECTION_CONNECTED_STATUS + else: + ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}" + elif error_status: + ctx.gba_status = error_status + logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates") + else: + try: + logger.debug("Attempting to connect to GBA") + ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10) + ctx.gba_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.gba_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.gba_status = CONNECTION_REFUSED_STATUS + continue + + +async def run_game(romfile): + options = Utils.get_options().get("mmbn3_options", None) + if options is None: + auto_start = True + else: + auto_start = options.get("rom_start", True) + if auto_start: + 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(apmmbn3_file): + base_name = os.path.splitext(apmmbn3_file)[0] + + with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive: + try: + with patch_archive.open("delta.bsdiff4", 'r') as stream: + patch_data = stream.read() + except KeyError: + raise FileNotFoundError("Patch file missing from archive.") + rom_file = get_base_rom_path() + + with open(rom_file, 'rb') as rom: + rom_bytes = rom.read() + + patched_bytes = bsdiff4.patch(rom_bytes, patch_data) + patched_rom_file = base_name+".gba" + with open(patched_rom_file, 'wb') as patched_rom: + patched_rom.write(patched_bytes) + + asyncio.create_task(run_game(patched_rom_file)) + + +def confirm_checksum(): + rom_file = get_base_rom_path() + if not os.path.exists(rom_file): + return False + + with open(rom_file, 'rb') as rom: + rom_bytes = rom.read() + + basemd5 = hashlib.md5() + basemd5.update(rom_bytes) + return CHECKSUM_BLUE == basemd5.hexdigest() + + +if __name__ == "__main__": + Utils.init_logging("MMBN3Client") + + async def main(): + multiprocessing.freeze_support() + parser = get_base_parser() + parser.add_argument("patch_file", default="", type=str, nargs="?", + help="Path to an APMMBN3 file") + args = parser.parse_args() + checksum_matches = confirm_checksum() + if checksum_matches: + if args.patch_file: + asyncio.create_task(patch_and_run_game(args.patch_file)) + + ctx = MMBN3Context(args.connect, args.password) + if not checksum_matches: + ctx.patching_error = True + ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync") + await ctx.exit_event.wait() + ctx.server_address = None + await ctx.shutdown() + + if ctx.gba_sync_task: + await ctx.gba_sync_task + + import colorama + + colorama.init() + + asyncio.run(main()) + colorama.deinit() diff --git a/Main.py b/Main.py index 03b2e1b155..dfc4ed1793 100644 --- a/Main.py +++ b/Main.py @@ -1,34 +1,30 @@ import collections +import concurrent.futures import logging import os -import time -import zlib -import concurrent.futures import pickle import tempfile +import time import zipfile -from typing import Dict, List, Tuple, Optional, Set +import zlib +from typing import Dict, List, Optional, Set, Tuple, Union -from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location import worlds -from worlds.alttp.SubClasses import LTTPRegionType -from worlds.alttp.Regions import is_main_entrance -from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned -from worlds.alttp.Shops import FillDisabledShopSlots -from Utils import output_path, get_options, __version__, version_tuple -from worlds.generic.Rules import locality_rules, exclusion_rules +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region +from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items +from Options import StartInventoryPool +from settings import get_settings +from Utils import __version__, output_path, version_tuple from worlds import AutoWorld +from worlds.generic.Rules import exclusion_rules, locality_rules -ordered_areas = ( - 'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', - 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', - 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total" -) +__all__ = ["main"] def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): if not baked_server_options: - baked_server_options = get_options()["server_options"] + baked_server_options = get_settings().server_options.as_dict() + assert isinstance(baked_server_options, dict) if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -112,7 +108,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info('') for player in world.player_ids: - for item_name, count in world.start_inventory[player].value.items(): + for item_name, count in world.worlds[player].options.start_inventory.value.items(): + for _ in range(count): + world.push_precollected(world.create_item(item_name, player)) + + for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): for _ in range(count): world.push_precollected(world.create_item(item_name, player)) @@ -130,25 +130,65 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in world.player_ids: # items can't be both local and non-local, prefer local - world.non_local_items[player].value -= world.local_items[player].value - world.non_local_items[player].value -= set(world.local_early_items[player]) - - if world.players > 1: - locality_rules(world) - else: - world.non_local_items[1].value = set() - world.local_items[1].value = set() + world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value + world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player]) AutoWorld.call_all(world, "set_rules") for player in world.player_ids: - exclusion_rules(world, player, world.exclude_locations[player].value) - world.priority_locations[player].value -= world.exclude_locations[player].value - for location_name in world.priority_locations[player].value: - world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY + exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value) + world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value + for location_name in world.worlds[player].options.priority_locations.value: + try: + location = world.get_location(location_name, player) + except KeyError as e: # failed to find the given location. Check if it's a legitimate location + if location_name not in world.worlds[player].location_name_to_id: + raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e + else: + location.progress_type = LocationProgressType.PRIORITY + # Set local and non-local item rules. + if world.players > 1: + locality_rules(world) + else: + world.worlds[1].options.non_local_items.value = set() + world.worlds[1].options.local_items.value = set() + AutoWorld.call_all(world, "generate_basic") + # remove starting inventory from pool items. + # Because some worlds don't actually create items during create_items this has to be as late as possible. + if any(world.start_inventory_from_pool[player].value for player in world.player_ids): + new_items: List[Item] = [] + depletion_pool: Dict[int, Dict[str, int]] = { + player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids} + for player, items in depletion_pool.items(): + player_world: AutoWorld.World = world.worlds[player] + for count in items.values(): + for _ in range(count): + new_items.append(player_world.create_filler()) + target: int = sum(sum(items.values()) for items in depletion_pool.values()) + for i, item in enumerate(world.itempool): + if depletion_pool[item.player].get(item.name, 0): + target -= 1 + depletion_pool[item.player][item.name] -= 1 + # quick abort if we have found all items + if not target: + new_items.extend(world.itempool[i+1:]) + break + else: + new_items.append(item) + + # leftovers? + if target: + for player, remaining_items in depletion_pool.items(): + remaining_items = {name: count for name, count in remaining_items.items() if count} + if remaining_items: + raise Exception(f"{world.get_player_name(player)}" + f" is trying to remove items from their pool that don't exist: {remaining_items}") + assert len(world.itempool) == len(new_items), "Item Pool amounts should not change." + world.itempool[:] = new_items + # temporary home for item links, should be moved out of Main for group_id, group in world.groups.items(): def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ @@ -247,8 +287,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No AutoWorld.call_all(world, 'post_fill') - if world.players > 1: + if world.players > 1 and not args.skip_prog_balancing: balance_multiworld_progression(world) + else: + logger.info("Progression balancing skipped.") logger.info(f'Beginning output...') @@ -273,35 +315,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No er_hint_data: Dict[int, Dict[int, str]] = {} AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) - checks_in_area = {player: {area: list() for area in ordered_areas} - for player in range(1, world.players + 1)} - - for player in range(1, world.players + 1): - checks_in_area[player]["Total"] = 0 - - for location in world.get_filled_locations(): - if type(location.address) is int: - if location.game != "A Link to the Past": - checks_in_area[location.player]["Light World"].append(location.address) - else: - main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) - if location.parent_region.dungeon: - dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', - 'Inverted Ganons Tower': 'Ganons Tower'} \ - .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) - checks_in_area[location.player][dungeonname].append(location.address) - elif location.parent_region.type == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - checks_in_area[location.player]["Total"] += 1 - - FillDisabledShopSlots(world) - def write_multidata(): import NetUtils slot_data = {} @@ -347,11 +360,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No f" {location}" locations_data[location.player][location.address] = \ location.item.code, location.item.player, location.item.flags - if location.name in world.start_location_hints[location.player]: + if location.name in world.worlds[location.player].options.start_location_hints: precollect_hint(location) - elif location.item.name in world.start_hints[location.item.player]: + elif location.item.name in world.worlds[location.item.player].options.start_hints: precollect_hint(location) - elif any([location.item.name in world.start_hints[player] + elif any([location.item.name in world.worlds[player].options.start_hints for player in world.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) @@ -361,10 +374,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for game_world in world.worlds.values() } + checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} + multidata = { "slot_data": slot_data, "slot_info": slot_info, - "names": names, # TODO: remove after 0.3.9 "connect_names": {name: (0, player) for player, name in world.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, @@ -386,7 +400,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No f.write(bytes([3])) # version of format f.write(multidata) - multidata_task = pool.submit(write_multidata) + output_file_futures.append(pool.submit(write_multidata)) if not check_accessibility_task.result(): if not world.can_beat_game(): raise Exception("Game appears as unbeatable. Aborting.") @@ -394,7 +408,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.warning("Location Accessibility requirements not fulfilled.") # retrieve exceptions via .result() if they occurred. - multidata_task.result() for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1): if i % 10 == 0 or i == len(output_file_futures): logger.info(f'Generating output files ({i}/{len(output_file_futures)}).') diff --git a/MinecraftClient.py b/MinecraftClient.py index dd7a5cfd3e..93385ec538 100644 --- a/MinecraftClient.py +++ b/MinecraftClient.py @@ -299,7 +299,7 @@ if __name__ == '__main__': versions = get_minecraft_versions(data_version, channel) - forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"]) + forge_dir = options["minecraft_options"]["forge_directory"] max_heap = options["minecraft_options"]["max_heap_size"] forge_version = args.forge or versions["forge"] java_version = args.java or versions["java"] diff --git a/ModuleUpdate.py b/ModuleUpdate.py index ac40dbd66b..209f2da672 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -1,6 +1,7 @@ import os import sys import subprocess +import multiprocessing import warnings local_dir = os.path.dirname(__file__) @@ -9,7 +10,8 @@ requirements_files = {os.path.join(local_dir, 'requirements.txt')} if sys.version_info < (3, 8, 6): raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.") -update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled +# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) +update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process() if not update_ran: for entry in os.scandir(os.path.join(local_dir, "worlds")): diff --git a/MultiServer.py b/MultiServer.py index ea055b662e..8be8d64132 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,9 +3,6 @@ from __future__ import annotations import argparse import asyncio import copy -import functools -import logging -import zlib import collections import datetime import functools @@ -41,7 +38,7 @@ import NetUtils import Utils from Utils import version_tuple, restricted_loads, Version, async_start from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ - SlotType + SlotType, LocationStore min_client_version = Version(0, 1, 6) colorama.init() @@ -155,14 +152,16 @@ class Context: "compatibility": int} # team -> slot id -> list of clients authenticated to slot. clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] - locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] + locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] + location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] + hints_used: typing.Dict[typing.Tuple[int, int], int] groups: typing.Dict[int, typing.Set[int]] save_version = 2 stored_data: typing.Dict[str, object] read_data: typing.Dict[str, object] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] slot_info: typing.Dict[int, NetworkSlot] - + generator_version = Version(0, 0, 0) checksums: typing.Dict[str, str] item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] @@ -190,8 +189,6 @@ class Context: self.player_name_lookup: typing.Dict[str, team_slot] = {} self.connect_names = {} # names of slots clients can connect to self.allow_releases = {} - # player location_id item_id target_player_id - self.locations = {} self.host = host self.port = port self.server_password = server_password @@ -226,7 +223,7 @@ class Context: self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} - self.minimum_client_versions: typing.Dict[int, Utils.Version] = {} + self.minimum_client_versions: typing.Dict[int, Version] = {} self.seed_name = "" self.groups = {} self.group_collected: typing.Dict[int, typing.Set[int]] = {} @@ -287,6 +284,7 @@ class Context: except websockets.ConnectionClosed: logging.exception(f"Exception during send_msgs, could not send {msg}") await self.disconnect(endpoint) + return False else: if self.log_network: logging.info(f"Outgoing message: {msg}") @@ -300,6 +298,7 @@ class Context: except websockets.ConnectionClosed: logging.exception("Exception during send_encoded_msgs") await self.disconnect(endpoint) + return False else: if self.log_network: logging.info(f"Outgoing message: {msg}") @@ -314,6 +313,7 @@ class Context: websockets.broadcast(sockets, msg) except RuntimeError: logging.exception("Exception during broadcast_send_encoded_msgs") + return False else: if self.log_network: logging.info(f"Outgoing broadcast: {msg}") @@ -384,15 +384,17 @@ class Context: def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any], use_embedded_server_options: bool): + self.read_data = {} mdata_ver = decoded_obj["minimum_versions"]["server"] - if mdata_ver > Utils.version_tuple: + if mdata_ver > version_tuple: raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," - f"however this server is of version {Utils.version_tuple}") + f"however this server is of version {version_tuple}") + self.generator_version = Version(*decoded_obj["version"]) clients_ver = decoded_obj["minimum_versions"].get("clients", {}) self.minimum_client_versions = {} for player, version in clients_ver.items(): - self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version) + self.minimum_client_versions[player] = max(Version(*version), min_client_version) self.slot_info = decoded_obj["slot_info"] self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} @@ -414,7 +416,7 @@ class Context: self.seed_name = decoded_obj["seed_name"] self.random.seed(self.seed_name) self.connect_names = decoded_obj['connect_names'] - self.locations = decoded_obj['locations'] + self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory self.slot_data = decoded_obj['slot_data'] for slot, data in self.slot_data.items(): self.read_data[f"slot_data_{slot}"] = lambda data=data: data @@ -544,7 +546,7 @@ class Context: "stored_data": self.stored_data, "game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points, "server_password": self.server_password, "password": self.password, - "forfeit_mode": self.release_mode, "release_mode": self.release_mode, # TODO remove forfeit_mode around 0.4 + "release_mode": self.release_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility} @@ -700,6 +702,10 @@ class Context: targets: typing.Set[Client] = set(self.stored_data_notification_clients[key]) if targets: self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}]) + self.broadcast(self.clients[team][slot], [{ + "cmd": "RoomUpdate", + "hint_points": get_slot_points(self, team, slot) + }]) def update_aliases(ctx: Context, team: int): @@ -754,7 +760,8 @@ async def on_client_connected(ctx: Context, client: Client): # tags are for additional features in the communication. # Name them by feature or fork, as you feel is appropriate. 'tags': ctx.tags, - 'version': Utils.version_tuple, + 'version': version_tuple, + 'generator_version': ctx.generator_version, 'permissions': get_permissions(ctx), 'hint_cost': ctx.hint_cost, 'location_check_points': ctx.location_check_points, @@ -769,7 +776,6 @@ async def on_client_connected(ctx: Context, client: Client): def get_permissions(ctx) -> typing.Dict[str, Permission]: return { - "forfeit": Permission.from_text(ctx.release_mode), # TODO remove around 0.4 "release": Permission.from_text(ctx.release_mode), "remaining": Permission.from_text(ctx.remaining_mode), "collect": Permission.from_text(ctx.collect_mode) @@ -789,7 +795,7 @@ async def on_client_joined(ctx: Context, client: Client): ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " f"{verb} {ctx.games[client.slot]} has joined. " - f"Client({version_str}), {client.tags}).", + f"Client({version_str}), {client.tags}.", {"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags}) ctx.notify_client(client, "Now that you are connected, " "you can use !help to list commands to run via the server. " @@ -899,11 +905,7 @@ def release_player(ctx: Context, team: int, slot: int): def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): """register any locations that are in the multidata, pointing towards this player""" - all_locations = collections.defaultdict(set) - for source_slot, location_data in ctx.locations.items(): - for location_id, values in location_data.items(): - if values[1] == slot: - all_locations[source_slot].add(location_id) + all_locations = ctx.locations.get_for_player(slot) ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1), @@ -922,11 +924,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: - items = [] - for location_id in ctx.locations[slot]: - if location_id not in ctx.location_checks[team, slot]: - items.append(ctx.locations[slot][location_id][0]) # item ID - return sorted(items) + return ctx.locations.get_remaining(ctx.location_checks, team, slot) def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem): @@ -974,13 +972,12 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st slots.add(group_id) seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] - for finding_player, check_data in ctx.locations.items(): - for location_id, (item_id, receiving_player, item_flags) in check_data.items(): - if receiving_player in slots and item_id == seeked_item_id: - found = location_id in ctx.location_checks[team, finding_player] - entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") - hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, - item_flags)) + for finding_player, location_id, item_id, receiving_player, item_flags \ + in ctx.locations.find_item(slots, seeked_item_id): + found = location_id in ctx.location_checks[team, finding_player] + entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") + hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, + item_flags)) return hints @@ -1552,15 +1549,11 @@ class ClientMessageProcessor(CommonCommandProcessor): def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: - return [location_id for - location_id in ctx.locations[slot] if - location_id in ctx.location_checks[team, slot]] + return ctx.locations.get_checked(ctx.location_checks, team, slot) def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: - return [location_id for - location_id in ctx.locations[slot] if - location_id not in ctx.location_checks[team, slot]] + return ctx.locations.get_missing(ctx.location_checks, team, slot) def get_client_points(ctx: Context, client: Client) -> int: @@ -1640,7 +1633,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): "players": ctx.get_players_package(), "missing_locations": get_missing_checks(ctx, team, slot), "checked_locations": get_checked_checks(ctx, team, slot), - "slot_info": ctx.slot_info + "slot_info": ctx.slot_info, + "hint_points": get_slot_points(ctx, team, slot), } reply = [connected_packet] start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory) @@ -1742,6 +1736,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location)) locs.append(NetworkItem(target_item, location, target_player, flags)) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) + if locs and create_as_hint: + ctx.save() await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) elif cmd == 'StatusUpdate': @@ -1800,6 +1796,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): targets.add(client) if targets: ctx.broadcast(targets, [args]) + ctx.save() elif cmd == "SetNotify": if "keys" not in args or type(args["keys"]) != list: @@ -1817,6 +1814,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus) ctx.on_goal_achieved(client) ctx.client_game_state[client.team, client.slot] = new_status + ctx.save() class ServerCommandProcessor(CommonCommandProcessor): @@ -1857,7 +1855,7 @@ class ServerCommandProcessor(CommonCommandProcessor): def _cmd_exit(self) -> bool: """Shutdown the server""" - async_start(self.ctx.server.ws_server._close()) + self.ctx.server.ws_server.close() if self.ctx.shutdown_task: self.ctx.shutdown_task.cancel() self.ctx.exit_event.set() @@ -2120,13 +2118,15 @@ class ServerCommandProcessor(CommonCommandProcessor): async def console(ctx: Context): import sys queue = asyncio.Queue() - Utils.stream_input(sys.stdin, queue) + worker = Utils.stream_input(sys.stdin, queue) while not ctx.exit_event.is_set(): try: # I don't get why this while loop is needed. Works fine without it on clients, # but the queue.get() for server never fulfills if the queue is empty when entering the await. while queue.qsize() == 0: await asyncio.sleep(0.05) + if not worker.is_alive(): + return input_text = await queue.get() queue.task_done() ctx.commandprocessor(input_text) @@ -2137,7 +2137,7 @@ async def console(ctx: Context): def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() - defaults = Utils.get_options()["server_options"] + defaults = Utils.get_options()["server_options"].as_dict() parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--port', default=defaults["port"], type=int) @@ -2198,7 +2198,7 @@ async def auto_shutdown(ctx, to_cancel=None): await asyncio.sleep(ctx.auto_shutdown) while not ctx.exit_event.is_set(): if not ctx.client_activity_timers.values(): - async_start(ctx.server.ws_server._close()) + ctx.server.ws_server.close() ctx.exit_event.set() if to_cancel: for task in to_cancel: @@ -2209,7 +2209,7 @@ async def auto_shutdown(ctx, to_cancel=None): delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity seconds = ctx.auto_shutdown - delta.total_seconds() if seconds < 0: - async_start(ctx.server.ws_server._close()) + ctx.server.ws_server.close() ctx.exit_event.set() if to_cancel: for task in to_cancel: @@ -2246,12 +2246,15 @@ async def main(args: argparse.Namespace): if not isinstance(e, ImportError): logging.error(f"Failed to load tkinter ({e})") logging.info("Pass a multidata filename on command line to run headless.") - exit(1) + # when cx_Freeze'd the built-in exit is not available, so we import sys.exit instead + import sys + sys.exit(1) raise if not data_filename: logging.info("No file selected. Exiting.") - exit(1) + import sys + sys.exit(1) try: ctx.load(data_filename, args.use_embedded_options) @@ -2264,8 +2267,7 @@ async def main(args: argparse.Namespace): ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context) ip = args.host if args.host else Utils.get_public_ipv4() logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, 'No password' if not ctx.password else 'Password: %s' % ctx.password)) diff --git a/NetUtils.py b/NetUtils.py index 2b9a653123..a2db6a2ac5 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -2,11 +2,12 @@ from __future__ import annotations import typing import enum +import warnings from json import JSONEncoder, JSONDecoder import websockets -from Utils import Version +from Utils import ByValue, Version class JSONMessagePart(typing.TypedDict, total=False): @@ -20,7 +21,7 @@ class JSONMessagePart(typing.TypedDict, total=False): flags: int -class ClientStatus(enum.IntEnum): +class ClientStatus(ByValue, enum.IntEnum): CLIENT_UNKNOWN = 0 CLIENT_CONNECTED = 5 CLIENT_READY = 10 @@ -28,7 +29,7 @@ class ClientStatus(enum.IntEnum): CLIENT_GOAL = 30 -class SlotType(enum.IntFlag): +class SlotType(ByValue, enum.IntFlag): spectator = 0b00 player = 0b01 group = 0b10 @@ -39,7 +40,7 @@ class SlotType(enum.IntFlag): return self.value != 0b01 -class Permission(enum.IntFlag): +class Permission(ByValue, enum.IntFlag): disabled = 0b000 # 0, completely disables access enabled = 0b001 # 1, allows manual use goal = 0b010 # 2, allows manual use after goal completion @@ -343,3 +344,85 @@ class Hint(typing.NamedTuple): @property def local(self): return self.receiving_player == self.finding_player + + +class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + super().__init__(values) + + if not self: + raise ValueError(f"Rejecting game with 0 players") + + if len(self) != max(self): + raise ValueError("Player IDs not continuous") + + if len(self.get(0, {})): + raise ValueError("Invalid player id 0 for location") + + def find_item(self, slots: typing.Set[int], seeked_item_id: int + ) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]: + for finding_player, check_data in self.items(): + for location_id, (item_id, receiving_player, item_flags) in check_data.items(): + if receiving_player in slots and item_id == seeked_item_id: + yield finding_player, location_id, item_id, receiving_player, item_flags + + def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]: + import collections + all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set) + for source_slot, location_data in self.items(): + for location_id, values in location_data.items(): + if values[1] == slot: + all_locations[source_slot].add(location_id) + return all_locations + + def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + if not checked: + # This optimizes the case where everyone connects to a fresh game at the same time. + return [] + return [location_id for + location_id in self[slot] if + location_id in checked] + + def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + if not checked: + # This optimizes the case where everyone connects to a fresh game at the same time. + return list(self[slot]) + return [location_id for + location_id in self[slot] if + location_id not in checked] + + def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + player_locations = self[slot] + return sorted([player_locations[location_id][0] for + location_id in player_locations if + location_id not in checked]) + + +if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub + LocationStore = _LocationStore +else: + try: + from _speedups import LocationStore + import _speedups + import os.path + if os.path.isfile("_speedups.pyx") and os.path.getctime(_speedups.__file__) < os.path.getctime("_speedups.pyx"): + warnings.warn(f"{_speedups.__file__} outdated! " + f"Please rebuild with `cythonize -b -i _speedups.pyx` or delete it!") + except ImportError: + try: + import pyximport + pyximport.install() + except ImportError: + pyximport = None + try: + from _speedups import LocationStore + except ImportError: + warnings.warn("_speedups not available. Falling back to pure python LocationStore. " + "Install a matching C++ compiler for your platform to compile _speedups.") + LocationStore = _LocationStore diff --git a/OoTAdjuster.py b/OoTAdjuster.py index f449113d22..38ebe62e2a 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -44,7 +44,7 @@ def adjustGUI(): StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \ OptionMenu, filedialog, messagebox, ttk from argparse import Namespace - from Main import __version__ as MWVersion + from Utils import __version__ as MWVersion window = tk.Tk() window.wm_title(f"Archipelago {MWVersion} OoT Adjuster") diff --git a/OoTClient.py b/OoTClient.py index f8a052402f..1154904173 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -17,9 +17,9 @@ from worlds.oot.N64Patch import apply_patch_file from worlds.oot.Utils import data_path -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua" -CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua" +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua" +CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -100,7 +100,7 @@ class OoTContext(CommonContext): await super(OoTContext, self).server_auth(password_requested) if not self.auth: self.awaiting_rom = True - logger.info('Awaiting connection to Bizhawk to get player information') + logger.info('Awaiting connection to EmuHawk to get player information') return await self.send_connect() @@ -179,6 +179,12 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool): locations = payload['locations'] collectibles = payload['collectibles'] + # The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety: + if isinstance(locations, list): + locations = {} + if isinstance(collectibles, list): + collectibles = {} + if ctx.location_table != locations or ctx.collectible_table != collectibles: ctx.location_table = locations ctx.collectible_table = collectibles @@ -290,13 +296,16 @@ async def patch_and_run_game(apz5_file): comp_path = base_name + '.z64' # Load vanilla ROM, patch file, compress ROM rom_file_name = Utils.get_options()["oot_options"]["rom_file"] - if not os.path.exists(rom_file_name): - rom_file_name = Utils.user_path(rom_file_name) rom = Rom(rom_file_name) - apply_patch_file(rom, apz5_file, - sub_file=(os.path.basename(base_name) + '.zpf' - if zipfile.is_zipfile(apz5_file) - else None)) + + sub_file = None + if zipfile.is_zipfile(apz5_file): + for name in zipfile.ZipFile(apz5_file).namelist(): + if name.endswith('.zpf'): + sub_file = name + break + + apply_patch_file(rom, apz5_file, sub_file=sub_file) rom.write_to_file(decomp_path) os.chdir(data_path("Compress")) compress_rom_file(decomp_path, comp_path) diff --git a/Options.py b/Options.py index b6c55468ca..154428dd43 100644 --- a/Options.py +++ b/Options.py @@ -1,18 +1,24 @@ from __future__ import annotations + import abc import logging from copy import deepcopy +from dataclasses import dataclass +import functools import math import numbers -import typing import random +import typing +from copy import deepcopy + +from schema import And, Optional, Or, Schema -from schema import Schema, And, Or, Optional from Utils import get_fuzzy_results if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions from worlds.AutoWorld import World + import pathlib class AssembleOptions(abc.ABCMeta): @@ -208,6 +214,12 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC): else: return self.value > other + def __ge__(self, other: typing.Union[int, NumericOption]) -> bool: + if isinstance(other, NumericOption): + return self.value >= other.value + else: + return self.value >= other + def __bool__(self) -> bool: return bool(self.value) @@ -715,8 +727,16 @@ class SpecialRange(Range): f"random-range-high--, or random-range--.") -class VerifyKeys: - valid_keys = frozenset() +class FreezeValidKeys(AssembleOptions): + def __new__(mcs, name, bases, attrs): + if "valid_keys" in attrs: + attrs["_valid_keys"] = frozenset(attrs["valid_keys"]) + return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs) + + +class VerifyKeys(metaclass=FreezeValidKeys): + valid_keys: typing.Iterable = [] + _valid_keys: frozenset # gets created by AssembleOptions from valid_keys valid_keys_casefold: bool = False convert_name_groups: bool = False verify_item_name: bool = False @@ -728,10 +748,10 @@ class VerifyKeys: if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) - extra = dataset - cls.valid_keys + extra = dataset - cls._valid_keys if extra: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " - f"Allowed keys: {cls.valid_keys}.") + f"Allowed keys: {cls._valid_keys}.") def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if self.convert_name_groups and self.verify_item_name: @@ -760,7 +780,7 @@ class VerifyKeys: f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") -class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): +class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): default: typing.Dict[str, typing.Any] = {} supports_weighting = False @@ -778,8 +798,14 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): def get_option_name(self, value): return ", ".join(f"{key}: {v}" for key, v in value.items()) - def __contains__(self, item): - return item in self.value + def __getitem__(self, item: str) -> typing.Any: + return self.value.__getitem__(item) + + def __iter__(self) -> typing.Iterator[str]: + return self.value.__iter__() + + def __len__(self) -> int: + return self.value.__len__() class ItemDict(OptionDict): @@ -792,6 +818,10 @@ class ItemDict(OptionDict): class OptionList(Option[typing.List[typing.Any]], VerifyKeys): + # Supports duplicate entries and ordering. + # If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead. + # Not a docstring so it doesn't get grabbed by the options system. + default: typing.List[typing.Any] = [] supports_weighting = False @@ -882,10 +912,55 @@ class ProgressionBalancing(SpecialRange): } -common_options = { - "progression_balancing": ProgressionBalancing, - "accessibility": Accessibility -} +class OptionsMetaProperty(type): + def __new__(mcs, + name: str, + bases: typing.Tuple[type, ...], + attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty": + for attr_type in attrs.values(): + assert not isinstance(attr_type, AssembleOptions),\ + f"Options for {name} should be type hinted on the class, not assigned" + return super().__new__(mcs, name, bases, attrs) + + @property + @functools.lru_cache(maxsize=None) + def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]: + """Returns type hints of the class as a dictionary.""" + return typing.get_type_hints(cls) + + +@dataclass +class CommonOptions(metaclass=OptionsMetaProperty): + progression_balancing: ProgressionBalancing + accessibility: Accessibility + + def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]: + """ + Returns a dictionary of [str, Option.value] + + :param option_names: names of the options to return + :param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab` + """ + option_results = {} + for option_name in option_names: + if option_name in type(self).type_hints: + if casing == "snake": + display_name = option_name + elif casing == "camel": + split_name = [name.title() for name in option_name.split("_")] + split_name[0] = split_name[0].lower() + display_name = "".join(split_name) + elif casing == "pascal": + display_name = "".join([name.title() for name in option_name.split("_")]) + elif casing == "kebab": + display_name = option_name.replace("_", "-") + else: + raise ValueError(f"{casing} is invalid casing for as_dict. " + "Valid names are 'snake', 'camel', 'pascal', 'kebab'.") + option_results[display_name] = getattr(self, option_name).value + else: + raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}") + return option_results class LocalItems(ItemSet): @@ -904,6 +979,13 @@ class StartInventory(ItemDict): display_name = "Start Inventory" +class StartInventoryPool(StartInventory): + """Start with these items and don't place them in the world. + The game decides what the replacement items will be.""" + verify_item_name = True + display_name = "Start Inventory from Pool" + + class StartHints(ItemSet): """Start with these item's locations prefilled into the !hint command.""" display_name = "Start Hints" @@ -936,6 +1018,7 @@ class DeathLink(Toggle): class ItemLinks(OptionList): """Share part of your item pool with other players.""" + display_name = "Item Links" default = [] schema = Schema([ { @@ -998,17 +1081,71 @@ class ItemLinks(OptionList): link.setdefault("link_replacement", None) -per_game_common_options = { - **common_options, # can be overwritten per-game - "local_items": LocalItems, - "non_local_items": NonLocalItems, - "start_inventory": StartInventory, - "start_hints": StartHints, - "start_location_hints": StartLocationHints, - "exclude_locations": ExcludeLocations, - "priority_locations": PriorityLocations, - "item_links": ItemLinks -} +@dataclass +class PerGameCommonOptions(CommonOptions): + local_items: LocalItems + non_local_items: NonLocalItems + start_inventory: StartInventory + start_hints: StartHints + start_location_hints: StartLocationHints + exclude_locations: ExcludeLocations + priority_locations: PriorityLocations + item_links: ItemLinks + + +def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): + import os + + import yaml + from jinja2 import Template + + from worlds import AutoWorldRegister + from Utils import local_path, __version__ + + full_path: str + + os.makedirs(target_folder, exist_ok=True) + + # clean out old + for file in os.listdir(target_folder): + full_path = os.path.join(target_folder, file) + if os.path.isfile(full_path) and full_path.endswith(".yaml"): + os.unlink(full_path) + + def dictify_range(option: typing.Union[Range, SpecialRange]): + data = {option.default: 50} + for sub_option in ["random", "random-low", "random-high"]: + if sub_option != option.default: + data[sub_option] = 0 + + notes = {} + for name, number in getattr(option, "special_range_names", {}).items(): + notes[name] = f"equivalent to {number}" + if number in data: + data[name] = data[number] + del data[number] + else: + data[name] = 0 + + return data, notes + + for game_name, world in AutoWorldRegister.world_types.items(): + if not world.hidden or generate_hidden: + all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints + + with open(local_path("data", "options.yaml")) as f: + file_data = f.read() + res = Template(file_data).render( + options=all_options, + __version__=__version__, game=game_name, yaml_dump=yaml.dump, + dictify_range=dictify_range, + ) + + del file_data + + with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f: + f.write(res) + if __name__ == "__main__": diff --git a/PokemonClient.py b/PokemonClient.py index e78e76fa00..6b43a53b8f 100644 --- a/PokemonClient.py +++ b/PokemonClient.py @@ -29,6 +29,9 @@ for location in location_data: 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" @@ -72,13 +75,14 @@ class GBContext(CommonContext): 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 Bizhawk to get Player information') + logger.info('Awaiting connection to EmuHawk to get Player information') return await self.send_connect() @@ -153,6 +157,33 @@ async def parse_locations(data: List, ctx: GBContext): 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", diff --git a/README.md b/README.md index 9454d0f168..54b659397f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ Currently, the following games are supported: * The Legend of Zelda: Link's Awakening DX * Clique * Adventure +* DLC Quest +* Noita +* Undertale +* Bumper Stickers +* Mega Man Battle Network 3: Blue Version +* Muse Dash +* DOOM 1993 +* Terraria For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/SNIClient.py b/SNIClient.py index f4ad53c617..0909c61382 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -68,12 +68,11 @@ class SNIClientCommandProcessor(ClientCommandProcessor): options = snes_options.split() num_options = len(options) - if num_options > 0: - snes_device_number = int(options[0]) - if num_options > 1: snes_address = options[0] snes_device_number = int(options[1]) + elif num_options > 0: + snes_device_number = int(options[0]) self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: @@ -315,7 +314,7 @@ def launch_sni() -> None: f"please start it yourself if it is not running") -async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol: +async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol: address = f"ws://{address}" if "://" not in address else address snes_logger.info("Connecting to SNI at %s ..." % address) seen_problems: typing.Set[str] = set() @@ -336,6 +335,8 @@ async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtoco await asyncio.sleep(1) else: return snes_socket + if not retry: + break class SNESRequest(typing.TypedDict): @@ -563,14 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int, PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} try: for address, data in write_list: - PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] - # REVIEW: above: `if snes_socket is None: return False` - # Does it need to be checked again? - if ctx.snes_socket is not None: - await ctx.snes_socket.send(dumps(PutAddress_Request)) - await ctx.snes_socket.send(data) - else: - snes_logger.warning(f"Could not send data to SNES: {data}") + while data: + # Divide the write into packets of 256 bytes. + PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]] + if ctx.snes_socket is not None: + await ctx.snes_socket.send(dumps(PutAddress_Request)) + await ctx.snes_socket.send(data[:256]) + address += 256 + data = data[256:] + else: + snes_logger.warning(f"Could not send data to SNES: {data}") except ConnectionClosed: return False @@ -684,6 +687,8 @@ async def main() -> None: logging.info(f"Wrote rom file to {romfile}") if args.diff_file.endswith(".apsoe"): import webbrowser + async_start(run_game(romfile)) + await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False) webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}") logging.info("Starting Evermizer Client in your Browser...") import time diff --git a/Starcraft2Client.py b/Starcraft2Client.py index cf16405766..87b50d3506 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -1,1052 +1,11 @@ from __future__ import annotations -import asyncio -import copy -import ctypes -import logging -import multiprocessing -import os.path -import re -import sys -import typing -import queue -import zipfile -import io -from pathlib import Path +import ModuleUpdate +ModuleUpdate.update() -# CommonClient import first to trigger ModuleUpdater -from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser -from Utils import init_logging, is_windows +from worlds.sc2wol.Client import launch +import Utils if __name__ == "__main__": - init_logging("SC2Client", exception_logger="Client") - -logger = logging.getLogger("Client") -sc2_logger = logging.getLogger("Starcraft2") - -import nest_asyncio -import sc2 -from sc2.bot_ai import BotAI -from sc2.data import Race -from sc2.main import run_game -from sc2.player import Bot -from worlds.sc2wol import SC2WoLWorld -from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups -from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET -from worlds.sc2wol.MissionTables import lookup_id_to_mission -from worlds.sc2wol.Regions import MissionInfo - -import colorama -from NetUtils import ClientStatus, NetworkItem, RawJSONtoTextParser -from MultiServer import mark_raw - -nest_asyncio.apply() -max_bonus: int = 8 -victory_modulo: int = 100 - - -class StarcraftClientProcessor(ClientCommandProcessor): - ctx: SC2Context - - def _cmd_difficulty(self, difficulty: str = "") -> bool: - """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" - options = difficulty.split() - num_options = len(options) - - if num_options > 0: - difficulty_choice = options[0].lower() - if difficulty_choice == "casual": - self.ctx.difficulty_override = 0 - elif difficulty_choice == "normal": - self.ctx.difficulty_override = 1 - elif difficulty_choice == "hard": - self.ctx.difficulty_override = 2 - elif difficulty_choice == "brutal": - self.ctx.difficulty_override = 3 - else: - self.output("Unable to parse difficulty '" + options[0] + "'") - return False - - self.output("Difficulty set to " + options[0]) - return True - - else: - if self.ctx.difficulty == -1: - self.output("Please connect to a seed before checking difficulty.") - else: - self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty]) - self.output("To change the difficulty, add the name of the difficulty after the command.") - return False - - def _cmd_disable_mission_check(self) -> bool: - """Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play - the next mission in a chain the other player is doing.""" - self.ctx.missions_unlocked = True - sc2_logger.info("Mission check has been disabled") - return True - - def _cmd_play(self, mission_id: str = "") -> bool: - """Start a Starcraft 2 mission""" - - options = mission_id.split() - num_options = len(options) - - if num_options > 0: - mission_number = int(options[0]) - - self.ctx.play_mission(mission_number) - - else: - sc2_logger.info( - "Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.") - return False - - return True - - def _cmd_available(self) -> bool: - """Get what missions are currently available to play""" - - request_available_missions(self.ctx) - return True - - def _cmd_unfinished(self) -> bool: - """Get what missions are currently available to play and have not had all locations checked""" - - request_unfinished_missions(self.ctx) - return True - - @mark_raw - def _cmd_set_path(self, path: str = '') -> bool: - """Manually set the SC2 install directory (if the automatic detection fails).""" - if path: - os.environ["SC2PATH"] = path - is_mod_installed_correctly() - return True - else: - sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.") - return False - - def _cmd_download_data(self) -> bool: - """Download the most recent release of the necessary files for playing SC2 with - Archipelago. Will overwrite existing files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - if os.path.exists(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - else: - current_ver = None - - tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', - current_version=current_ver, force_download=True) - - if tempzip != '': - try: - zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"]) - sc2_logger.info(f"Download complete. Version {version} installed.") - with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f: - f.write(version) - finally: - os.remove(tempzip) - else: - sc2_logger.warning("Download aborted/failed. Read the log for more information.") - return False - return True - - -class SC2Context(CommonContext): - command_processor = StarcraftClientProcessor - game = "Starcraft 2 Wings of Liberty" - items_handling = 0b111 - difficulty = -1 - all_in_choice = 0 - mission_order = 0 - mission_req_table: typing.Dict[str, MissionInfo] = {} - final_mission: int = 29 - announcements = queue.Queue() - sc2_run_task: typing.Optional[asyncio.Task] = None - missions_unlocked: bool = False # allow launching missions ignoring requirements - current_tooltip = None - last_loc_list = None - difficulty_override = -1 - mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {} - last_bot: typing.Optional[ArchipelagoBot] = None - - def __init__(self, *args, **kwargs): - super(SC2Context, self).__init__(*args, **kwargs) - self.raw_text_parser = RawJSONtoTextParser(self) - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(SC2Context, self).server_auth(password_requested) - await self.get_username() - await self.send_connect() - - def on_package(self, cmd: str, args: dict): - if cmd in {"Connected"}: - self.difficulty = args["slot_data"]["game_difficulty"] - self.all_in_choice = args["slot_data"]["all_in_map"] - slot_req_table = args["slot_data"]["mission_req"] - # Maintaining backwards compatibility with older slot data - self.mission_req_table = { - mission: MissionInfo( - **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} - ) - for mission, mission_info in slot_req_table.items() - } - self.mission_order = args["slot_data"].get("mission_order", 0) - self.final_mission = args["slot_data"].get("final_mission", 29) - - self.build_location_to_mission_mapping() - - # Looks for the required maps and mods for SC2. Runs check_game_install_path. - maps_present = is_mod_installed_correctly() - if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"): - with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f: - current_ver = f.read() - if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver): - sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.") - elif maps_present: - sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). " - "Run /download_data to update them.") - - - def on_print_json(self, args: dict): - # goes to this world - if "receiving" in args and self.slot_concerns_self(args["receiving"]): - relevant = True - # found in this world - elif "item" in args and self.slot_concerns_self(args["item"].player): - relevant = True - # not related - else: - relevant = False - - if relevant: - self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"]))) - - super(SC2Context, self).on_print_json(args) - - def run_gui(self): - from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.app import App - from kivy.clock import Clock - from kivy.uix.tabbedpanel import TabbedPanelItem - from kivy.uix.gridlayout import GridLayout - from kivy.lang import Builder - from kivy.uix.label import Label - from kivy.uix.button import Button - from kivy.uix.floatlayout import FloatLayout - from kivy.properties import StringProperty - - import Utils - - class HoverableButton(HoverBehavior, Button): - pass - - class MissionButton(HoverableButton): - tooltip_text = StringProperty("Test") - ctx: SC2Context - - def __init__(self, *args, **kwargs): - super(HoverableButton, self).__init__(*args, **kwargs) - self.layout = FloatLayout() - self.popuplabel = ServerToolTip(text=self.text) - self.layout.add_widget(self.popuplabel) - - def on_enter(self): - self.popuplabel.text = self.tooltip_text - - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - if self.tooltip_text == "": - self.ctx.current_tooltip = None - else: - App.get_running_app().root.add_widget(self.layout) - self.ctx.current_tooltip = self.layout - - def on_leave(self): - self.ctx.ui.clear_tooltip() - - @property - def ctx(self) -> CommonContext: - return App.get_running_app().ctx - - class MissionLayout(GridLayout): - pass - - class MissionCategory(GridLayout): - pass - - class SC2Manager(GameManager): - logging_pairs = [ - ("Client", "Archipelago"), - ("Starcraft2", "Starcraft2"), - ] - base_title = "Archipelago Starcraft 2 Client" - - mission_panel = None - last_checked_locations = {} - mission_id_to_button = {} - launching: typing.Union[bool, int] = False # if int -> mission ID - refresh_from_launching = True - first_check = True - ctx: SC2Context - - def __init__(self, ctx): - super().__init__(ctx) - - def clear_tooltip(self): - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - self.ctx.current_tooltip = None - - def build(self): - container = super().build() - - panel = TabbedPanelItem(text="Starcraft 2 Launcher") - self.mission_panel = panel.content = MissionLayout() - - self.tabs.add_widget(panel) - - Clock.schedule_interval(self.build_mission_table, 0.5) - - return container - - def build_mission_table(self, dt): - if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or - not self.refresh_from_launching)) or self.first_check: - self.refresh_from_launching = True - - self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: - self.last_checked_locations = self.ctx.checked_locations.copy() - self.first_check = False - - self.mission_id_to_button = {} - categories = {} - available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) - - # separate missions into categories - for mission in self.ctx.mission_req_table: - if not self.ctx.mission_req_table[mission].category in categories: - categories[self.ctx.mission_req_table[mission].category] = [] - - categories[self.ctx.mission_req_table[mission].category].append(mission) - - for category in categories: - category_panel = MissionCategory() - if category.startswith('_'): - category_display_name = '' - else: - category_display_name = category - category_panel.add_widget( - Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) - - for mission in categories[category]: - text: str = mission - tooltip: str = "" - mission_id: int = self.ctx.mission_req_table[mission].id - # Map has uncollected locations - if mission in unfinished_missions: - text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: - text = f"[color=FFFFFF]{text}[/color]" - # Map requirements not met - else: - text = f"[color=a9a9a9]{text}[/color]" - tooltip = f"Requires: " - if self.ctx.mission_req_table[mission].required_world: - tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for - req_mission in - self.ctx.mission_req_table[mission].required_world) - - if self.ctx.mission_req_table[mission].number: - tooltip += " and " - if self.ctx.mission_req_table[mission].number: - tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" - remaining_location_names: typing.List[str] = [ - self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) - if loc in self.ctx.missing_locations] - - if mission_id == self.ctx.final_mission: - if mission in available_missions: - text = f"[color=FFBC95]{mission}[/color]" - else: - text = f"[color=D0C0BE]{mission}[/color]" - if tooltip: - tooltip += "\n" - tooltip += "Final Mission" - - if remaining_location_names: - if tooltip: - tooltip += "\n" - tooltip += f"Uncollected locations:\n" - tooltip += "\n".join(remaining_location_names) - - mission_button = MissionButton(text=text, size_hint_y=None, height=50) - mission_button.tooltip_text = tooltip - mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[mission_id] = mission_button - category_panel.add_widget(mission_button) - - category_panel.add_widget(Label(text="")) - self.mission_panel.add_widget(category_panel) - - elif self.launching: - self.refresh_from_launching = False - - self.mission_panel.clear_widgets() - self.mission_panel.add_widget(Label(text="Launching Mission: " + - lookup_id_to_mission[self.launching])) - if self.ctx.ui: - self.ctx.ui.clear_tooltip() - - def mission_callback(self, button): - if not self.launching: - mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) - self.ctx.play_mission(mission_id) - self.launching = mission_id - Clock.schedule_once(self.finish_launching, 10) - - def finish_launching(self, dt): - self.launching = False - - self.ui = SC2Manager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - import pkgutil - data = pkgutil.get_data(SC2WoLWorld.__module__, "Starcraft2.kv").decode() - Builder.load_string(data) - - async def shutdown(self): - await super(SC2Context, self).shutdown() - if self.last_bot: - self.last_bot.want_close = True - if self.sc2_run_task: - self.sc2_run_task.cancel() - - def play_mission(self, mission_id: int): - if self.missions_unlocked or \ - is_mission_available(self, mission_id): - if self.sc2_run_task: - if not self.sc2_run_task.done(): - sc2_logger.warning("Starcraft 2 Client is still running!") - self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task - if self.slot is None: - sc2_logger.warning("Launching Mission without Archipelago authentication, " - "checks will not be registered to server.") - self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), - name="Starcraft 2 Launch") - else: - sc2_logger.info( - f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " - f"Use /unfinished or /available to see what is available.") - - def build_location_to_mission_mapping(self): - mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = { - mission_info.id: set() for mission_info in self.mission_req_table.values() - } - - for loc in self.server_locations: - mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo) - mission_id_to_location_ids[mission_id].add(objective) - self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in - mission_id_to_location_ids.items()} - - def locations_for_mission(self, mission: str): - mission_id: int = self.mission_req_table[mission].id - objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id] - for objective in objectives: - yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective - - -async def main(): - multiprocessing.freeze_support() - parser = get_base_parser() - parser.add_argument('--name', default=None, help="Slot Name to connect as.") - args = parser.parse_args() - - ctx = SC2Context(args.connect, args.password) - ctx.auth = args.name - if ctx.server_task is None: - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - await ctx.exit_event.wait() - - await ctx.shutdown() - - -maps_table = [ - "ap_traynor01", "ap_traynor02", "ap_traynor03", - "ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b", - "ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05", - "ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b", - "ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s", - "ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04", - "ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03" -] - -wol_default_categories = [ - "Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist", - "Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert", - "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", - "Char", "Char", "Char", "Char" -] -wol_default_category_names = [ - "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" -] - - -def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: - network_item: NetworkItem - accumulators: typing.List[int] = [0 for _ in type_flaggroups] - - for network_item in items: - name: str = lookup_id_to_name[network_item.item] - item_data: ItemData = item_table[name] - - # exists exactly once - if item_data.quantity == 1: - accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number - - # exists multiple times - elif item_data.type == "Upgrade": - accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number - - # sum - else: - accumulators[type_flaggroups[item_data.type]] += item_data.number - - return accumulators - - -def calc_difficulty(difficulty): - if difficulty == 0: - return 'C' - elif difficulty == 1: - return 'N' - elif difficulty == 2: - return 'H' - elif difficulty == 3: - return 'B' - - return 'X' - - -async def starcraft_launch(ctx: SC2Context, mission_id: int): - sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") - - with DllDirectory(None): - run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), - name="Archipelago", fullscreen=True)], realtime=True) - - -class ArchipelagoBot(sc2.bot_ai.BotAI): - game_running: bool = False - mission_completed: bool = False - boni: typing.List[bool] - setup_done: bool - ctx: SC2Context - mission_id: int - want_close: bool = False - can_read_game = False - - last_received_update: int = 0 - - def __init__(self, ctx: SC2Context, mission_id): - self.setup_done = False - self.ctx = ctx - self.ctx.last_bot = self - self.mission_id = mission_id - self.boni = [False for _ in range(max_bonus)] - - super(ArchipelagoBot, self).__init__() - - async def on_step(self, iteration: int): - if self.want_close: - self.want_close = False - await self._client.leave() - return - game_state = 0 - if not self.setup_done: - self.setup_done = True - start_items = calculate_items(self.ctx.items_received) - if self.ctx.difficulty_override >= 0: - difficulty = calc_difficulty(self.ctx.difficulty_override) - else: - difficulty = calc_difficulty(self.ctx.difficulty) - await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format( - difficulty, - start_items[0], start_items[1], start_items[2], start_items[3], start_items[4], - start_items[5], start_items[6], start_items[7], start_items[8], start_items[9], - self.ctx.all_in_choice, start_items[10])) - self.last_received_update = len(self.ctx.items_received) - - else: - if not self.ctx.announcements.empty(): - message = self.ctx.announcements.get(timeout=1) - await self.chat_send("SendMessage " + message) - self.ctx.announcements.task_done() - - # Archipelago reads the health - for unit in self.all_own_units(): - if unit.health_max == 38281: - game_state = int(38281 - unit.health) - self.can_read_game = True - - if iteration == 160 and not game_state & 1: - await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " + - "Starcraft 2 (This is likely a map issue)") - - if self.last_received_update < len(self.ctx.items_received): - current_items = calculate_items(self.ctx.items_received) - await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format( - current_items[0], current_items[1], current_items[2], current_items[3], current_items[4], - current_items[5], current_items[6], current_items[7])) - self.last_received_update = len(self.ctx.items_received) - - if game_state & 1: - if not self.game_running: - print("Archipelago Connected") - self.game_running = True - - if self.can_read_game: - if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != self.ctx.final_mission: - print("Mission Completed") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}]) - self.mission_completed = True - else: - print("Game Complete") - await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) - self.mission_completed = True - - for x, completed in enumerate(self.boni): - if not completed and game_state & (1 << (x + 2)): - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) - self.boni[x] = True - - else: - await self.chat_send("LostConnection - Lost connection to game.") - - -def request_unfinished_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Unfinished Missions: " - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) - - _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) - - # Removing All-In from location pool - final_mission = lookup_id_to_mission[ctx.final_mission] - if final_mission in unfinished_missions.keys(): - message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message - if unfinished_missions[final_mission] == -1: - unfinished_missions.pop(final_mission) - - message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + - mark_up_objectives( - f"[{len(unfinished_missions[mission])}/" - f"{sum(1 for _ in ctx.locations_for_mission(mission))}]", - ctx, unfinished_locations, mission) - for mission in unfinished_missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_unfinished_missions(ctx: SC2Context, unlocks=None): - unfinished_missions = [] - locations_completed = [] - - if not unlocks: - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - available_missions = calc_available_missions(ctx, unlocks) - - for name in available_missions: - objectives = set(ctx.locations_for_mission(name)) - if objectives: - objectives_completed = ctx.checked_locations & objectives - if len(objectives_completed) < len(objectives): - unfinished_missions.append(name) - locations_completed.append(objectives_completed) - - else: # infer that this is the final mission as it has no objectives - unfinished_missions.append(name) - locations_completed.append(-1) - - return available_missions, dict(zip(unfinished_missions, locations_completed)) - - -def is_mission_available(ctx: SC2Context, mission_id_to_check): - unfinished_missions = calc_available_missions(ctx) - - return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) - - -def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): - """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" - - if ctx.mission_req_table[mission].completion_critical: - if ctx.ui: - message = "[color=AF99EF]" + mission + "[/color]" - else: - message = "*" + mission + "*" - else: - message = mission - - if ctx.ui: - unlocks = unlock_table[mission] - - if len(unlocks) > 0: - pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " - pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) - pre_message += f"]" - message = pre_message + message + "[/ref]" - - return message - - -def mark_up_objectives(message, ctx, unfinished_locations, mission): - formatted_message = message - - if ctx.ui: - locations = unfinished_locations[mission] - - pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" - pre_message += "
".join(location for location in locations) - pre_message += f"]" - formatted_message = pre_message + message + "[/ref]" - - return formatted_message - - -def request_available_missions(ctx: SC2Context): - if ctx.mission_req_table: - message = "Available Missions: " - - # Initialize mission unlock table - unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - - missions = calc_available_missions(ctx, unlocks) - message += \ - ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" - f"[{ctx.mission_req_table[mission].id}]" - for mission in missions) - - if ctx.ui: - ctx.ui.log_panels['All'].on_message_markup(message) - ctx.ui.log_panels['Starcraft2'].on_message_markup(message) - else: - sc2_logger.info(message) - else: - sc2_logger.warning("No mission table found, you are likely not connected to a server.") - - -def calc_available_missions(ctx: SC2Context, unlocks=None): - available_missions = [] - missions_complete = 0 - - # Get number of missions completed - for loc in ctx.checked_locations: - if loc % victory_modulo == 0: - missions_complete += 1 - - for name in ctx.mission_req_table: - # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips - if unlocks: - for unlock in ctx.mission_req_table[name].required_world: - unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) - - if mission_reqs_completed(ctx, name, missions_complete): - available_missions.append(name) - - return available_missions - - -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): - """Returns a bool signifying if the mission has all requirements complete and can be done - - Arguments: - ctx -- instance of SC2Context - locations_to_check -- the mission string name to check - missions_complete -- an int of how many missions have been completed - mission_path -- a list of missions that have already been checked -""" - if len(ctx.mission_req_table[mission_name].required_world) >= 1: - # A check for when the requirements are being or'd - or_success = False - - # Loop through required missions - for req_mission in ctx.mission_req_table[mission_name].required_world: - req_success = True - - # Check if required mission has been completed - if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * - victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # Grid-specific logic (to avoid long path checks and infinite recursion) - if ctx.mission_order in (3, 4): - if req_success: - return True - else: - if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: - return False - else: - continue - - # Recursively check required mission to see if it's requirements are met, in case !collect has been done - # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion - if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): - if not ctx.mission_req_table[mission_name].or_requirements: - return False - else: - req_success = False - - # If requirement check succeeded mark or as satisfied - if ctx.mission_req_table[mission_name].or_requirements and req_success: - or_success = True - - if ctx.mission_req_table[mission_name].or_requirements: - # Return false if or requirements not met - if not or_success: - return False - - # Check number of missions - if missions_complete >= ctx.mission_req_table[mission_name].number: - return True - else: - return False - else: - return True - - -def initialize_blank_mission_dict(location_table): - unlocks = {} - - for mission in list(location_table): - unlocks[mission] = [] - - return unlocks - - -def check_game_install_path() -> bool: - # First thing: go to the default location for ExecuteInfo. - # An exception for Windows is included because it's very difficult to find ~\Documents if the user moved it. - if is_windows: - # The next five lines of utterly inscrutable code are brought to you by copy-paste from Stack Overflow. - # https://stackoverflow.com/questions/6227590/finding-the-users-my-documents-path/30924555# - import ctypes.wintypes - CSIDL_PERSONAL = 5 # My Documents - SHGFP_TYPE_CURRENT = 0 # Get current, not default value - - buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) - ctypes.windll.shell32.SHGetFolderPathW(None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf) - documentspath = buf.value - einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt")) - else: - einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF])) - - # Check if the file exists. - if os.path.isfile(einfo): - - # Open the file and read it, picking out the latest executable's path. - with open(einfo) as f: - content = f.read() - if content: - try: - base = re.search(r" = (.*)Versions", content).group(1) - except AttributeError: - sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then " - f"try again.") - return False - if os.path.exists(base): - executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions") - - # Finally, check the path for an actual executable. - # If we find one, great. Set up the SC2PATH. - if os.path.isfile(executable): - sc2_logger.info(f"Found an SC2 install at {base}!") - sc2_logger.debug(f"Latest executable at {executable}.") - os.environ["SC2PATH"] = base - sc2_logger.debug(f"SC2PATH set to {base}.") - return True - else: - sc2_logger.warning(f"We may have found an SC2 install at {base}, but couldn't find {executable}.") - else: - sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.") - else: - sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. " - f"If that fails, please run /set_path with your SC2 install directory.") - return False - - -def is_mod_installed_correctly() -> bool: - """Searches for all required files.""" - if "SC2PATH" not in os.environ: - check_game_install_path() - - mapdir = os.environ['SC2PATH'] / Path('Maps/ArchipelagoCampaign') - modfile = os.environ["SC2PATH"] / Path("Mods/Archipelago.SC2Mod") - wol_required_maps = [ - "ap_thanson01.SC2Map", "ap_thanson02.SC2Map", "ap_thanson03a.SC2Map", "ap_thanson03b.SC2Map", - "ap_thorner01.SC2Map", "ap_thorner02.SC2Map", "ap_thorner03.SC2Map", "ap_thorner04.SC2Map", "ap_thorner05s.SC2Map", - "ap_traynor01.SC2Map", "ap_traynor02.SC2Map", "ap_traynor03.SC2Map", - "ap_ttosh01.SC2Map", "ap_ttosh02.SC2Map", "ap_ttosh03a.SC2Map", "ap_ttosh03b.SC2Map", - "ap_ttychus01.SC2Map", "ap_ttychus02.SC2Map", "ap_ttychus03.SC2Map", "ap_ttychus04.SC2Map", "ap_ttychus05.SC2Map", - "ap_tvalerian01.SC2Map", "ap_tvalerian02a.SC2Map", "ap_tvalerian02b.SC2Map", "ap_tvalerian03.SC2Map", - "ap_tzeratul01.SC2Map", "ap_tzeratul02.SC2Map", "ap_tzeratul03.SC2Map", "ap_tzeratul04.SC2Map" - ] - needs_files = False - - # Check for maps. - missing_maps = [] - for mapfile in wol_required_maps: - if not os.path.isfile(mapdir / mapfile): - missing_maps.append(mapfile) - if len(missing_maps) >= 19: - sc2_logger.warning(f"All map files missing from {mapdir}.") - needs_files = True - elif len(missing_maps) > 0: - for map in missing_maps: - sc2_logger.debug(f"Missing {map} from {mapdir}.") - sc2_logger.warning(f"Missing {len(missing_maps)} map files.") - needs_files = True - else: # Must be no maps missing - sc2_logger.info(f"All maps found in {mapdir}.") - - # Check for mods. - if os.path.isfile(modfile): - sc2_logger.info(f"Archipelago mod found at {modfile}.") - else: - sc2_logger.warning(f"Archipelago mod could not be found at {modfile}.") - needs_files = True - - # Final verdict. - if needs_files: - sc2_logger.warning(f"Required files are missing. Run /download_data to acquire them.") - return False - else: - return True - - -class DllDirectory: - # Credit to Black Sliver for this code. - # More info: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw - _old: typing.Optional[str] = None - _new: typing.Optional[str] = None - - def __init__(self, new: typing.Optional[str]): - self._new = new - - def __enter__(self): - old = self.get() - if self.set(self._new): - self._old = old - - def __exit__(self, *args): - if self._old is not None: - self.set(self._old) - - @staticmethod - def get() -> typing.Optional[str]: - if sys.platform == "win32": - n = ctypes.windll.kernel32.GetDllDirectoryW(0, None) - buf = ctypes.create_unicode_buffer(n) - ctypes.windll.kernel32.GetDllDirectoryW(n, buf) - return buf.value - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return None - - @staticmethod - def set(s: typing.Optional[str]) -> bool: - if sys.platform == "win32": - return ctypes.windll.kernel32.SetDllDirectoryW(s) != 0 - # NOTE: other OS may support os.environ["LD_LIBRARY_PATH"], but this fix is windows-specific - return False - - -def download_latest_release_zip(owner: str, repo: str, current_version: str = None, force_download=False) -> (str, str): - """Downloads the latest release of a GitHub repo to the current directory as a .zip file.""" - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - sc2_logger.info(f"Latest version: {latest_version}.") - else: - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"Failed to reach GitHub. Could not find download link.") - sc2_logger.warning(f"text: {r1.text}") - return "", current_version - - if (force_download is False) and (current_version == latest_version): - sc2_logger.info("Latest version already installed.") - return "", current_version - - sc2_logger.info(f"Attempting to download version {latest_version} of {repo}.") - download_url = r1.json()["assets"][0]["browser_download_url"] - - r2 = requests.get(download_url, headers=headers) - if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)): - with open(f"{repo}.zip", "wb") as fh: - fh.write(r2.content) - sc2_logger.info(f"Successfully downloaded {repo}.zip.") - return f"{repo}.zip", latest_version - else: - sc2_logger.warning(f"Status code: {r2.status_code}") - sc2_logger.warning("Download failed.") - sc2_logger.warning(f"text: {r2.text}") - return "", current_version - - -def is_mod_update_available(owner: str, repo: str, current_version: str) -> bool: - import requests - - headers = {"Accept": 'application/vnd.github.v3+json'} - url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" - - r1 = requests.get(url, headers=headers) - if r1.status_code == 200: - latest_version = r1.json()["tag_name"] - if current_version != latest_version: - return True - else: - return False - - else: - sc2_logger.warning(f"Failed to reach GitHub while checking for updates.") - sc2_logger.warning(f"Status code: {r1.status_code}") - sc2_logger.warning(f"text: {r1.text}") - return False - - -if __name__ == '__main__': - colorama.init() - asyncio.run(main()) - colorama.deinit() + Utils.init_logging("Starcraft2Client", exception_logger="Client") + launch() diff --git a/UndertaleClient.py b/UndertaleClient.py new file mode 100644 index 0000000000..62fbe128bd --- /dev/null +++ b/UndertaleClient.py @@ -0,0 +1,512 @@ +from __future__ import annotations +import os +import sys +import asyncio +import typing +import bsdiff4 +import shutil + +import Utils + +from NetUtils import NetworkItem, ClientStatus +from worlds import undertale +from MultiServer import mark_raw +from CommonClient import CommonContext, server_loop, \ + gui_enabled, ClientCommandProcessor, logger, get_base_parser +from Utils import async_start + + +class UndertaleCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + + def _cmd_resync(self): + """Manually trigger a resync.""" + if isinstance(self.ctx, UndertaleContext): + self.output(f"Syncing items.") + self.ctx.syncing = True + + def _cmd_patch(self): + """Patch the game.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) + self.ctx.patch_game() + self.output("Patched.") + + def _cmd_savepath(self, directory: str): + """Redirect to proper save data folder. (Use before connecting!)""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.save_game_folder = directory + self.output("Changed to the following directory: " + self.ctx.save_game_folder) + + @mark_raw + def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): + """Patch the game automatically.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) + tempInstall = steaminstall + if not os.path.isfile(os.path.join(tempInstall, "data.win")): + tempInstall = None + if tempInstall is None: + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists(tempInstall): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + elif not os.path.exists(tempInstall): + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists(tempInstall): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): + self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." + " command. \"/auto_patch (Steam directory)\".") + else: + for file_name in os.listdir(tempInstall): + if file_name != "steam_api.dll": + shutil.copy(os.path.join(tempInstall, file_name), + os.path.join(os.getcwd(), "Undertale", file_name)) + self.ctx.patch_game() + self.output("Patching successful!") + + def _cmd_online(self): + """Makes you no longer able to see other Undertale players.""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.update_online_mode(not ("Online" in self.ctx.tags)) + if "Online" in self.ctx.tags: + self.output(f"Now online.") + else: + self.output(f"Now offline.") + + def _cmd_deathlink(self): + """Toggles deathlink""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.deathlink_status = not self.ctx.deathlink_status + if self.ctx.deathlink_status: + self.output(f"Deathlink enabled.") + else: + self.output(f"Deathlink disabled.") + + +class UndertaleContext(CommonContext): + tags = {"AP", "Online"} + game = "Undertale" + command_processor = UndertaleCommandProcessor + items_handling = 0b111 + route = None + pieces_needed = None + completed_routes = None + completed_count = 0 + save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.pieces_needed = 0 + self.finished_game = False + self.game = "Undertale" + self.got_deathlink = False + self.syncing = False + self.deathlink_status = False + self.tem_armor = False + self.completed_count = 0 + self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} + # self.save_game_folder: files go in this path to pass data between us and the actual game + self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + + def patch_game(self): + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: + patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) + with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: + f.write(patchedFile) + os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) + with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", + "Which Character.txt")), "w") as f: + f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " + "line other than this one.\n", "frisk"]) + f.close() + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super().server_auth(password_requested) + await self.get_username() + await self.send_connect() + + def clear_undertale_files(self): + path = self.save_game_folder + self.finished_game = False + for root, dirs, files in os.walk(path): + for file in files: + if "check.spot" == file or "scout" == file: + os.remove(os.path.join(root, file)) + elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad", + ".youDied", ".LV", ".mine", ".flag", ".hint")): + os.remove(os.path.join(root, file)) + + async def connect(self, address: typing.Optional[str] = None): + self.clear_undertale_files() + await super().connect(address) + + async def disconnect(self, allow_autoreconnect: bool = False): + self.clear_undertale_files() + await super().disconnect(allow_autoreconnect) + + async def connection_closed(self): + self.clear_undertale_files() + await super().connection_closed() + + async def shutdown(self): + self.clear_undertale_files() + await super().shutdown() + + def update_online_mode(self, online): + old_tags = self.tags.copy() + if online: + self.tags.add("Online") + else: + self.tags -= {"Online"} + if old_tags != self.tags and self.server and not self.server.socket.closed: + async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])) + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.game = self.slot_info[self.slot].game + async_start(process_undertale_cmd(self, cmd, args)) + + def run_gui(self): + from kvui import GameManager + + class UTManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Undertale Client" + + self.ui = UTManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_deathlink(self, data: typing.Dict[str, typing.Any]): + self.got_deathlink = True + super().on_deathlink(data) + + +def to_room_name(place_name: str): + if place_name == "Old Home Exit": + return "room_ruinsexit" + elif place_name == "Snowdin Forest": + return "room_tundra1" + elif place_name == "Snowdin Town Exit": + return "room_fogroom" + elif place_name == "Waterfall": + return "room_water1" + elif place_name == "Waterfall Exit": + return "room_fire2" + elif place_name == "Hotland": + return "room_fire_prelab" + elif place_name == "Hotland Exit": + return "room_fire_precore" + elif place_name == "Core": + return "room_fire_core1" + + +async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): + if cmd == "Connected": + if not os.path.exists(ctx.save_game_folder): + os.mkdir(ctx.save_game_folder) + ctx.route = args["slot_data"]["route"] + ctx.pieces_needed = args["slot_data"]["key_pieces"] + ctx.tem_armor = args["slot_data"]["temy_armor_include"] + + await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + if args["slot_data"]["only_flakes"]: + with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: + f.close() + if not args["slot_data"]["key_hunt"]: + ctx.pieces_needed = 0 + if args["slot_data"]["rando_love"]: + filename = f"LOVErando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + if args["slot_data"]["rando_stats"]: + filename = f"STATrando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"{ctx.route}.route" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in set(args["checked_locations"]): + f.write(str(ss-12000)+"\n") + f.close() + elif cmd == "LocationInfo": + for l in args["locations"]: + locationid = l.location + filename = f"{str(locationid-12000)}.hint" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + toDraw = "" + for i in range(20): + if i < len(str(ctx.item_names[l.item])): + toDraw += str(ctx.item_names[l.item])[i] + else: + break + f.write(toDraw) + f.close() + elif cmd == "Retrieved": + if str(ctx.slot)+" RoutesDone neutral" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None: + ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"] + if str(ctx.slot)+" RoutesDone genocide" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None: + ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"] + if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: + if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: + ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] + elif cmd == "SetReply": + if args["value"] is not None: + if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: + ctx.completed_routes["pacifist"] = args["value"] + elif str(ctx.slot)+" RoutesDone genocide" == args["key"]: + ctx.completed_routes["genocide"] = args["value"] + elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: + ctx.completed_routes["neutral"] = args["value"] + elif cmd == "ReceivedItems": + start_index = args["index"] + + if start_index == 0: + ctx.items_received = [] + elif start_index != len(ctx.items_received): + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", + "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + if start_index == len(ctx.items_received): + counter = -1 + placedWeapon = 0 + placedArmor = 0 + for item in args["items"]: + id = NetworkItem(*item).location + while NetworkItem(*item).location < 0 and \ + counter <= id: + id -= 1 + if NetworkItem(*item).location < 0: + counter -= 1 + filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + if NetworkItem(*item).item == 77701: + if placedWeapon == 0: + f.write(str(77013-11000)) + elif placedWeapon == 1: + f.write(str(77014-11000)) + elif placedWeapon == 2: + f.write(str(77025-11000)) + elif placedWeapon == 3: + f.write(str(77045-11000)) + elif placedWeapon == 4: + f.write(str(77049-11000)) + elif placedWeapon == 5: + f.write(str(77047-11000)) + elif placedWeapon == 6: + if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes": + f.write(str(77052-11000)) + else: + f.write(str(77051-11000)) + else: + f.write(str(77003-11000)) + placedWeapon += 1 + elif NetworkItem(*item).item == 77702: + if placedArmor == 0: + f.write(str(77012-11000)) + elif placedArmor == 1: + f.write(str(77015-11000)) + elif placedArmor == 2: + f.write(str(77024-11000)) + elif placedArmor == 3: + f.write(str(77044-11000)) + elif placedArmor == 4: + f.write(str(77048-11000)) + elif placedArmor == 5: + if str(ctx.route) == "genocide": + f.write(str(77053-11000)) + else: + f.write(str(77046-11000)) + elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor): + if str(ctx.route) == "all_routes": + f.write(str(77053-11000)) + elif str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77050-11000)) + elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77004-11000)) + placedArmor += 1 + else: + f.write(str(NetworkItem(*item).item-11000)) + f.close() + ctx.items_received.append(NetworkItem(*item)) + if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0: + filename = f"{str(-99999)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77787 - 11000)) + f.close() + filename = f"{str(-99998)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77789 - 11000)) + f.close() + ctx.watcher_event.set() + + elif cmd == "RoomUpdate": + if "checked_locations" in args: + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in set(args["checked_locations"]): + f.write(str(ss-12000)+"\n") + f.close() + + elif cmd == "Bounced": + tags = args.get("tags", []) + if "Online" in tags: + data = args.get("data", {}) + if data["player"] != ctx.slot and data["player"] is not None: + filename = f"FRISK" + str(data["player"]) + ".playerspot" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str( + data["spr"]) + str(data["frm"])) + f.close() + + +async def multi_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + path = ctx.save_game_folder + for root, dirs, files in os.walk(path): + for file in files: + if "spots.mine" in file and "Online" in ctx.tags: + with open(os.path.join(root, file), "r") as mine: + this_x = mine.readline() + this_y = mine.readline() + this_room = mine.readline() + this_sprite = mine.readline() + this_frame = mine.readline() + mine.close() + message = [{"cmd": "Bounce", "tags": ["Online"], + "data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, + "spr": this_sprite, "frm": this_frame}}] + await ctx.send_msgs(message) + + await asyncio.sleep(0.1) + + +async def game_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + await ctx.update_death_link(ctx.deathlink_status) + path = ctx.save_game_folder + if ctx.syncing: + for root, dirs, files in os.walk(path): + for file in files: + if ".item" in file: + os.remove(os.path.join(root, file)) + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + if ctx.got_deathlink: + ctx.got_deathlink = False + with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f: + f.close() + sending = [] + victory = False + found_routes = 0 + for root, dirs, files in os.walk(path): + for file in files: + if "DontBeMad.mad" in file: + os.remove(os.path.join(root, file)) + if "DeathLink" in ctx.tags: + await ctx.send_death() + if "scout" == file: + sending = [] + try: + with open(os.path.join(root, file), "r") as f: + lines = f.readlines() + for l in lines: + if ctx.server_locations.__contains__(int(l)+12000): + sending = sending + [int(l.rstrip('\n'))+12000] + finally: + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, + "create_as_hint": int(2)}]) + os.remove(os.path.join(root, file)) + if "check.spot" in file: + sending = [] + try: + with open(os.path.join(root, file), "r") as f: + lines = f.readlines() + for l in lines: + sending = sending+[(int(l.rstrip('\n')))+12000] + finally: + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) + if "victory" in file and str(ctx.route) in file: + victory = True + if ".playerspot" in file and "Online" not in ctx.tags: + os.remove(os.path.join(root, file)) + if "victory" in file: + if str(ctx.route) == "all_routes": + if "neutral" in file and ctx.completed_routes["neutral"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "genocide" in file and ctx.completed_routes["genocide"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + if str(ctx.route) == "all_routes": + found_routes += ctx.completed_routes["neutral"] + found_routes += ctx.completed_routes["pacifist"] + found_routes += ctx.completed_routes["genocide"] + if str(ctx.route) == "all_routes" and found_routes >= 3: + victory = True + ctx.locations_checked = sending + if (not ctx.finished_game) and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +def main(): + Utils.init_logging("UndertaleClient", exception_logger="Client") + + async def _main(): + ctx = UndertaleContext(None, None) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + asyncio.create_task( + game_watcher(ctx), name="UndertaleProgressionWatcher") + + asyncio.create_task( + multi_watcher(ctx), name="UndertaleMultiplayerWatcher") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.exit_event.wait() + await ctx.shutdown() + + import colorama + + colorama.init() + + asyncio.run(_main()) + colorama.deinit() + + +if __name__ == "__main__": + parser = get_base_parser(description="Undertale Client, for text interfacing.") + args = parser.parse_args() + main() diff --git a/Utils.py b/Utils.py index 60b3904ff6..5fb037a173 100644 --- a/Utils.py +++ b/Utils.py @@ -13,8 +13,11 @@ import io import collections import importlib import logging -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union +import warnings +from argparse import Namespace +from settings import Settings, get_settings +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from yaml import load, load_all, dump, SafeLoader try: @@ -27,6 +30,7 @@ except ImportError: if typing.TYPE_CHECKING: import tkinter import pathlib + from BaseClasses import Region def tuplize_version(version: str) -> Version: @@ -38,8 +42,11 @@ class Version(typing.NamedTuple): minor: int build: int + def as_simple_string(self) -> str: + return ".".join(str(item) for item in self) -__version__ = "0.4.0" + +__version__ = "0.4.3" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -135,13 +142,16 @@ def user_path(*path: str) -> str: user_path.cached_path = local_path() else: user_path.cached_path = home_path() - # populate home from local - TODO: upgrade feature - if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): - import shutil - for dn in ("Players", "data/sprites"): - shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json", "host.yaml"): - shutil.copy2(local_path(fn), user_path(fn)) + # populate home from local + if user_path.cached_path != local_path(): + import filecmp + if not os.path.exists(user_path("manifest.json")) or \ + not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): + import shutil + for dn in ("Players", "data/sprites"): + shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) + for fn in ("manifest.json",): + shutil.copy2(local_path(fn), user_path(fn)) return os.path.join(user_path.cached_path, *path) @@ -207,7 +217,13 @@ def get_cert_none_ssl_context(): def get_public_ipv4() -> str: import socket import urllib.request - ip = socket.gethostbyname(socket.gethostname()) + try: + ip = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + # if hostname or resolvconf is not set up properly, this may fail + warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1") + ip = "127.0.0.1" + ctx = get_cert_none_ssl_context() try: ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() @@ -225,7 +241,13 @@ def get_public_ipv4() -> str: def get_public_ipv6() -> str: import socket import urllib.request - ip = socket.gethostbyname(socket.gethostname()) + try: + ip = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + # if hostname or resolvconf is not set up properly, this may fail + warnings.warn("Could not resolve own hostname, falling back to ::1") + ip = "::1" + ctx = get_cert_none_ssl_context() try: ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() @@ -235,151 +257,15 @@ def get_public_ipv6() -> str: return ip -OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]] +OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 @cache_argsless -def get_default_options() -> OptionsType: - # Refer to host.yaml for comments as to what all these options mean. - options = { - "general_options": { - "output_path": "output", - }, - "factorio_options": { - "executable": os.path.join("factorio", "bin", "x64", "factorio"), - "filter_item_sends": False, - "bridge_chat_out": True, - }, - "sni_options": { - "sni_path": "SNI", - "snes_rom_start": True, - }, - "sm_options": { - "rom_file": "Super Metroid (JU).sfc", - }, - "soe_options": { - "rom_file": "Secret of Evermore (USA).sfc", - }, - "lttp_options": { - "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - }, - "ladx_options": { - "rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc", - }, - "server_options": { - "host": None, - "port": 38281, - "password": None, - "multidata": None, - "savefile": None, - "disable_save": False, - "loglevel": "info", - "server_password": None, - "disable_item_cheat": False, - "location_check_points": 1, - "hint_cost": 10, - "release_mode": "goal", - "collect_mode": "disabled", - "remaining_mode": "goal", - "auto_shutdown": 0, - "compatibility": 2, - "log_network": 0 - }, - "generator": { - "enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"), - "player_files_path": "Players", - "players": 0, - "weights_file_path": "weights.yaml", - "meta_file_path": "meta.yaml", - "spoiler": 3, - "glitch_triforce_room": 1, - "race": 0, - "plando_options": "bosses", - }, - "minecraft_options": { - "forge_directory": "Minecraft Forge server", - "max_heap_size": "2G", - "release_channel": "release" - }, - "oot_options": { - "rom_file": "The Legend of Zelda - Ocarina of Time.z64", - "rom_start": True - }, - "dkc3_options": { - "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - }, - "smw_options": { - "rom_file": "Super Mario World (USA).sfc", - }, - "zillion_options": { - "rom_file": "Zillion (UE) [!].sms", - # RetroArch doesn't make it easy to launch a game from the command line. - # You have to know the path to the emulator core library on the user's computer. - "rom_start": "retroarch", - }, - "pokemon_rb_options": { - "red_rom_file": "Pokemon Red (UE) [S][!].gb", - "blue_rom_file": "Pokemon Blue (UE) [S][!].gb", - "rom_start": True - }, - "ffr_options": { - "display_msgs": True, - }, - "lufia2ac_options": { - "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", - }, - "tloz_options": { - "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes", - "rom_start": True, - "display_msgs": True, - }, - "wargroove_options": { - "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" - }, - "adventure_options": { - "rom_file": "ADVNTURE.BIN", - "display_msgs": True, - "rom_start": True, - "rom_args": "" - }, - } - return options +def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 + return Settings(None) -def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: - for key, value in src.items(): - new_keys = keys.copy() - new_keys.append(key) - option_name = '.'.join(new_keys) - if key not in dest: - dest[key] = value - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} is missing {option_name}") - elif isinstance(value, dict): - if not isinstance(dest.get(key, None), dict): - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.") - dest[key] = value - else: - dest[key] = update_options(value, dest[key], filename, new_keys) - return dest - - -@cache_argsless -def get_options() -> OptionsType: - filenames = ("options.yaml", "host.yaml") - locations: typing.List[str] = [] - if os.path.join(os.getcwd()) != local_path(): - locations += filenames # use files from cwd only if it's not the local_path - locations += [user_path(filename) for filename in filenames] - - for location in locations: - if os.path.exists(location): - with open(location) as f: - options = parse_yaml(f.read()) - return update_options(get_default_options(), options, location, list()) - - raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") +get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -447,12 +333,27 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N except Exception as e: logging.debug(f"Could not store data package: {e}") +def get_default_adjuster_settings(game_name: str) -> Namespace: + import LttPAdjuster + adjuster_settings = Namespace() + if game_name == LttPAdjuster.GAME_ALTTP: + return LttPAdjuster.get_argparser().parse_known_args(args=[])[0] -def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: - adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) return adjuster_settings +def get_adjuster_settings_no_defaults(game_name: str) -> Namespace: + return persistent_load().get("adjuster", {}).get(game_name, Namespace()) + + +def get_adjuster_settings(game_name: str) -> Namespace: + adjuster_settings = get_adjuster_settings_no_defaults(game_name) + default_settings = get_default_adjuster_settings(game_name) + + # Fill in any arguments from the argparser that we haven't seen before + return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)}) + + @cache_argsless def get_unique_identifier(): uuid = persistent_load().get("client", {}).get("uuid", None) @@ -472,11 +373,13 @@ safe_builtins = frozenset(( class RestrictedUnpickler(pickle.Unpickler): + generic_properties_module: Optional[object] + def __init__(self, *args, **kwargs): super(RestrictedUnpickler, self).__init__(*args, **kwargs) self.options_module = importlib.import_module("Options") self.net_utils_module = importlib.import_module("NetUtils") - self.generic_properties_module = importlib.import_module("worlds.generic") + self.generic_properties_module = None def find_class(self, module, name): if module == "builtins" and name in safe_builtins: @@ -486,6 +389,8 @@ class RestrictedUnpickler(pickle.Unpickler): return getattr(self.net_utils_module, name) # Options and Plando are unpickled by WebHost -> Generate if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: + if not self.generic_properties_module: + self.generic_properties_module = importlib.import_module("worlds.generic") return getattr(self.generic_properties_module, name) # pep 8 specifies that modules should have "all-lowercase names" (options, not Options) if module.lower().endswith("options"): @@ -505,6 +410,15 @@ def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() +class ByValue: + """ + Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. + See https://github.com/python/cpython/pull/26658 for why this exists. + """ + def __reduce_ex__(self, prot): + return self.__class__, (self._value_, ) + + class KeyedDefaultDict(collections.defaultdict): """defaultdict variant that uses the missing key as argument to default_factory""" default_factory: typing.Callable[[typing.Any], typing.Any] @@ -537,6 +451,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri root_logger.removeHandler(handler) handler.close() root_logger.setLevel(loglevel) + logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets if "a" not in write_mode: name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}" file_handler = logging.FileHandler( @@ -544,11 +459,21 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri write_mode, encoding="utf-8-sig") file_handler.setFormatter(logging.Formatter(log_format)) + + class Filter(logging.Filter): + def __init__(self, filter_name, condition): + super().__init__(filter_name) + self.condition = condition + + def filter(self, record: logging.LogRecord) -> bool: + return self.condition(record) + + file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) root_logger.addHandler(file_handler) if sys.stdout: - root_logger.addHandler( - logging.StreamHandler(sys.stdout) - ) + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) + root_logger.addHandler(stream_handler) # Relay unhandled exceptions to logger. if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified @@ -660,7 +585,7 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ +def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ -> typing.Optional[str]: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -671,11 +596,12 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) + return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - return run(zenity, f"--title={title}", "--file-selection", *z_filters) + selection = (f"--filename={suggest}",) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -686,9 +612,47 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin f'This attempt was made because open_filename was used for "{title}".') raise e else: - root = tkinter.Tk() + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes)) + return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None) + + +def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: + def run(*args: str): + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None + + if is_linux: + # prefer native dialog + from shutil import which + kdialog = which("kdialog") + if kdialog: + return run(kdialog, f"--title={title}", "--getexistingdirectory", + os.path.abspath(suggest) if suggest else ".") + zenity = which("zenity") + if zenity: + z_filters = ("--directory",) + selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + + # fall back to tk + try: + import tkinter + import tkinter.filedialog + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed. ' + f'This attempt was made because open_filename was used for "{title}".') + raise e + else: + try: + root = tkinter.Tk() + except tkinter.TclError: + return None # GUI not available. None is the same as a user clicking "cancel" + root.withdraw() + return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) def messagebox(title: str, text: str, error: bool = False) -> None: @@ -716,6 +680,11 @@ def messagebox(title: str, text: str, error: bool = False) -> None: if zenity: return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") + elif is_windows: + import ctypes + style = 0x10 if error else 0x0 + return ctypes.windll.user32.MessageBoxW(0, text, title, style) + # fall back to tk try: import tkinter @@ -753,10 +722,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray: return buffer -_faf_tasks: "Set[asyncio.Task[None]]" = set() +_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set() -def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None: +def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None: """ Use this to start a task when you don't keep a reference to it or immediately await it, to prevent early garbage collection. "fire-and-forget" @@ -769,6 +738,170 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] # ``` # This implementation follows the pattern given in that documentation. - task = asyncio.create_task(co, name=name) + task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name) _faf_tasks.add(task) task.add_done_callback(_faf_tasks.discard) + + +def deprecate(message: str): + if __debug__: + raise Exception(message) + import warnings + warnings.warn(message) + +def _extend_freeze_support() -> None: + """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" + # upstream issue: https://github.com/python/cpython/issues/76327 + # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26 + import multiprocessing + import multiprocessing.spawn + + def _freeze_support() -> None: + """Minimal freeze_support. Only apply this if frozen.""" + from subprocess import _args_from_interpreter_flags + + # Prevent `spawn` from trying to read `__main__` in from the main script + multiprocessing.process.ORIGINAL_DIR = None + + # Handle the first process that MP will create + if ( + len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( + 'from multiprocessing.semaphore_tracker import main', # Py<3.8 + 'from multiprocessing.resource_tracker import main', # Py>=3.8 + 'from multiprocessing.forkserver import main' + )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) + ): + exec(sys.argv[-1]) + sys.exit() + + # Handle the second process that MP will create + if multiprocessing.spawn.is_forking(sys.argv): + kwargs = {} + for arg in sys.argv[2:]: + name, value = arg.split('=') + if value == 'None': + kwargs[name] = None + else: + kwargs[name] = int(value) + multiprocessing.spawn.spawn_main(**kwargs) + sys.exit() + + if not is_windows and is_frozen(): + multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support + + +def freeze_support() -> None: + """This behaves like multiprocessing.freeze_support but also works on Non-Windows.""" + import multiprocessing + _extend_freeze_support() + multiprocessing.freeze_support() + + +def visualize_regions(root_region: Region, file_name: str, *, + show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, + linetype_ortho: bool = True) -> None: + """Visualize the layout of a world as a PlantUML diagram. + + :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) + :param file_name: The name of the destination .puml file. + :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection. + :param show_locations: (default True) If enabled, the locations will be listed inside each region. + Priority locations will be shown in bold. + Excluded locations will be stricken out. + Locations without ID will be shown in italics. + Locked locations will be shown with a padlock icon. + For filled locations, the item name will be shown after the location name. + Progression items will be shown in bold. + Items without ID will be shown in italics. + :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. + :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. + + Example usage in World code: + from Utils import visualize_regions + visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml") + + Example usage in Main code: + from Utils import visualize_regions + for player in world.player_ids: + visualize_regions(world.get_region("Menu", player), f"{world.get_out_file_name_base(player)}.puml") + """ + assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" + from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region + from collections import deque + import re + + uml: typing.List[str] = list() + seen: typing.Set[Region] = set() + regions: typing.Deque[Region] = deque((root_region,)) + multiworld: MultiWorld = root_region.multiworld + + def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: + name = obj.name + if isinstance(obj, Item): + name = multiworld.get_name_string_for_object(obj) + if obj.advancement: + name = f"**{name}**" + if obj.code is None: + name = f"//{name}//" + if isinstance(obj, Location): + if obj.progress_type == LocationProgressType.PRIORITY: + name = f"**{name}**" + elif obj.progress_type == LocationProgressType.EXCLUDED: + name = f"--{name}--" + if obj.address is None: + name = f"//{name}//" + return re.sub("[\".:]", "", name) + + def visualize_exits(region: Region) -> None: + for exit_ in region.exits: + if exit_.connected_region: + if show_entrance_names: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") + else: + try: + uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") + uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") + except ValueError: + uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") + else: + uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") + uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") + + def visualize_locations(region: Region) -> None: + any_lock = any(location.locked for location in region.locations) + for location in region.locations: + lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else "" + if location.item: + uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}") + else: + uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") + + def visualize_region(region: Region) -> None: + uml.append(f"class \"{fmt(region)}\"") + if show_locations: + visualize_locations(region) + visualize_exits(region) + + def visualize_other_regions() -> None: + if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: + uml.append("package \"other regions\" <> {") + for region in other_regions: + uml.append(f"class \"{fmt(region)}\"") + uml.append("}") + + uml.append("@startuml") + uml.append("hide circle") + uml.append("hide empty members") + if linetype_ortho: + uml.append("skinparam linetype ortho") + while regions: + if (current_region := regions.popleft()) not in seen: + seen.add(current_region) + visualize_region(current_region) + regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) + if show_other_regions: + visualize_other_regions() + uml.append("@enduml") + + with open(file_name, "wt", encoding="utf-8") as f: + f.write("\n".join(uml)) diff --git a/WebHost.py b/WebHost.py index 40d366a02f..8595fa7a27 100644 --- a/WebHost.py +++ b/WebHost.py @@ -10,23 +10,19 @@ ModuleUpdate.update() # in case app gets imported by something like gunicorn import Utils +import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 - -from WebHostLib import register, app as raw_app -from waitress import serve - -from WebHostLib.models import db -from WebHostLib.autolauncher import autohost, autogen -from WebHostLib.lttpsprites import update_sprites_lttp -from WebHostLib.options import create as create_options_files - +settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) def get_app(): + from WebHostLib import register, cache, app as raw_app + from WebHostLib.models import db + register() app = raw_app if os.path.exists(configpath) and not app.config["TESTING"]: @@ -38,6 +34,7 @@ def get_app(): app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") + cache.init_app(app) db.bind(**app.config["PONY"]) db.generate_mapping(create_tables=True) return app @@ -72,6 +69,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] with zipfile.ZipFile(zipfile_path) as zf: for zfile in zf.infolist(): if not zfile.is_dir() and "/docs/" in zfile.filename: + zfile.filename = os.path.basename(zfile.filename) zf.extract(zfile, target_path) else: source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") @@ -117,6 +115,11 @@ if __name__ == "__main__": multiprocessing.freeze_support() multiprocessing.set_start_method('spawn') logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) + + from WebHostLib.lttpsprites import update_sprites_lttp + from WebHostLib.autolauncher import autohost, autogen + from WebHostLib.options import create as create_options_files + try: update_sprites_lttp() except Exception as e: @@ -133,4 +136,5 @@ if __name__ == "__main__": if app.config["DEBUG"]: app.run(debug=True, port=app.config["PORT"]) else: + from waitress import serve serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"]) diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 8bd3609c1d..43ca89f0b3 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -34,7 +34,7 @@ app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit # if you want to deploy, make sure you have a non-guessable secret key app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") # at what amount of worlds should scheduling be used, instead of rolling in the web-thread -app.config["JOB_THRESHOLD"] = 2 +app.config["JOB_THRESHOLD"] = 1 # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. app.config["JOB_TIME"] = 600 app.config['SESSION_PERMANENT'] = True @@ -49,11 +49,10 @@ app.config["PONY"] = { 'create_db': True } app.config["MAX_ROLL"] = 20 -app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache" -app.config["JSON_AS_ASCII"] = False +app.config["CACHE_TYPE"] = "SimpleCache" app.config["HOST_ADDRESS"] = "" -cache = Cache(app) +cache = Cache() Compress(app) diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index 1d9e6fd9c1..61e9164e26 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -2,7 +2,8 @@ import json import pickle from uuid import UUID -from flask import request, session, url_for, Markup +from flask import request, session, url_for +from markupsafe import Markup from pony.orm import commit from WebHostLib import app @@ -48,9 +49,8 @@ def generate_api(): if len(options) > app.config["MAX_ROLL"]: return {"text": "Max size of multiworld exceeded", "detail": app.config["MAX_ROLL"]}, 409 - meta = get_meta(meta_options_source) - meta["race"] = race - results, gen_options = roll_options(options, meta["plando_options"]) + meta = get_meta(meta_options_source, race) + results, gen_options = roll_options(options, set(meta["plando_options"])) if any(type(result) == str for result in results.values()): return {"text": str(results), "detail": results}, 400 diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 484755b3c3..9083867120 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -3,8 +3,6 @@ from __future__ import annotations import json import logging import multiprocessing -import os -import sys import threading import time import typing @@ -13,55 +11,7 @@ from datetime import timedelta, datetime from pony.orm import db_session, select, commit from Utils import restricted_loads - - -class CommonLocker(): - """Uses a file lock to signal that something is already running""" - lock_folder = "file_locks" - - def __init__(self, lockname: str, folder=None): - if folder: - self.lock_folder = folder - os.makedirs(self.lock_folder, exist_ok=True) - self.lockname = lockname - self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") - - -class AlreadyRunningException(Exception): - pass - - -if sys.platform == 'win32': - class Locker(CommonLocker): - def __enter__(self): - try: - if os.path.exists(self.lockfile): - os.unlink(self.lockfile) - self.fp = os.open( - self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fp = getattr(self, "fp", None) - if fp: - os.close(self.fp) - os.unlink(self.lockfile) -else: # unix - import fcntl - - - class Locker(CommonLocker): - def __enter__(self): - try: - self.fp = open(self.lockfile, "wb") - fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError as e: - raise AlreadyRunningException() from e - - def __exit__(self, _type, value, tb): - fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) - self.fp.close() +from .locker import Locker, AlreadyRunningException def launch_room(room: Room, config: dict): @@ -135,7 +85,7 @@ def autogen(config: dict): with Locker("autogen"): with multiprocessing.Pool(config["GENERATORS"], initializer=init_db, - initargs=(config["PONY"],)) as generator_pool: + initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool: with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) diff --git a/WebHostLib/check.py b/WebHostLib/check.py index b7f215da02..c5dfd9f556 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,7 +1,8 @@ import zipfile from typing import * -from flask import request, flash, redirect, url_for, render_template, Markup +from flask import request, flash, redirect, url_for, render_template +from markupsafe import Markup from WebHostLib import app @@ -23,8 +24,8 @@ def check(): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: @@ -38,29 +39,33 @@ def mysterycheck(): return redirect(url_for("check"), 301) -def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]: +def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]: options = {} - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - return 'No selected file' - elif file and allowed_file(file.filename): - if file.filename.endswith(".zip"): + for file in files: + # if user does not select file, browser also + # submit an empty part without filename + if file.filename == '': + return 'No selected file' + elif file.filename in options: + return f'Conflicting files named {file.filename} submitted' + elif file and allowed_file(file.filename): + if file.filename.endswith(".zip"): - with zipfile.ZipFile(file, 'r') as zfile: - infolist = zfile.infolist() + with zipfile.ZipFile(file, 'r') as zfile: + infolist = zfile.infolist() - if any(file.filename.endswith(".archipelago") for file in infolist): - return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') + if any(file.filename.endswith(".archipelago") for file in infolist): + return Markup("Error: Your .zip file contains an .archipelago file. " + 'Did you mean to host a game?') - for file in infolist: - if file.filename.endswith(banned_zip_contents): - return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted." - elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): - options[file.filename] = zfile.open(file, "r").read() - else: - options = {file.filename: file.read()} + for file in infolist: + if file.filename.endswith(banned_zip_contents): + return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ + "Your file was deleted." + elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): + options[file.filename] = zfile.open(file, "r").read() + else: + options[file.filename] = file.read() if not options: return "Did not find a .yaml file to process." return options @@ -90,7 +95,7 @@ def roll_options(options: Dict[str, Union[dict, str]], rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, plando_options=plando_options) except Exception as e: - results[filename] = f"Failed to generate mystery in {filename}: {e}" + results[filename] = f"Failed to generate options in {filename}: {e}" else: results[filename] = True return results, rolled_results diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index b34e196178..6d633314b2 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -11,6 +11,7 @@ import socket import threading import time import typing +import sys import websockets from pony.orm import commit, db_session, select @@ -18,7 +19,8 @@ from pony.orm import commit, db_session, select import Utils from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert -from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless +from Utils import restricted_loads, cache_argsless +from .locker import Locker from .models import Command, GameDataPackage, Room, db @@ -163,19 +165,22 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, db.generate_mapping(check_tables=False) async def main(): + if "worlds" in sys.modules: + raise Exception("Worlds system should not be loaded in the custom server.") + + import gc Utils.init_logging(str(room_id), write_mode="a") ctx = WebHostContext(static_server_data) ctx.load(room_id) ctx.init_save() ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None + gc.collect() # free intermediate objects used during setup try: - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) await ctx.server - except Exception: # likely port in use - in windows this is OSError, but I didn't check the others - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + except OSError: # likely port in use + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) await ctx.server port = 0 @@ -200,16 +205,15 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, await ctx.shutdown_task logging.info("Shutting down") - from .autolauncher import Locker with Locker(room_id): try: asyncio.run(main()) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): with db_session: room = Room.get(id=room_id) # ensure the Room does not spin up again on its own, minute of safety buffer room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) - except: + except Exception: with db_session: room = Room.get(id=room_id) room.last_port = -1 diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index cbacd5153f..ddcc5ffb6c 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -6,7 +6,7 @@ import tempfile import zipfile import concurrent.futures from collections import Counter -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, Union, List from flask import request, flash, redirect, url_for, session, render_template from pony.orm import commit, db_session @@ -22,7 +22,7 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID from .upload import upload_zip_to_db -def get_meta(options_source: dict) -> dict: +def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: plando_options = { options_source.get("plando_bosses", ""), options_source.get("plando_items", ""), @@ -39,7 +39,21 @@ def get_meta(options_source: dict) -> dict: "item_cheat": bool(int(options_source.get("item_cheat", 1))), "server_password": options_source.get("server_password", None), } - return {"server_options": server_options, "plando_options": list(plando_options)} + generator_options = { + "spoiler": int(options_source.get("spoiler", 0)), + "race": race + } + + if race: + server_options["item_cheat"] = False + server_options["remaining_mode"] = "disabled" + generator_options["spoiler"] = 0 + + return { + "server_options": server_options, + "plando_options": list(plando_options), + "generator_options": generator_options, + } @app.route('/generate', methods=['GET', 'POST']) @@ -50,18 +64,13 @@ def generate(race=False): if 'file' not in request.files: flash('No file part') else: - file = request.files['file'] - options = get_yaml_data(file) + files = request.files.getlist('file') + options = get_yaml_data(files) if isinstance(options, str): flash(options) else: - meta = get_meta(request.form) - meta["race"] = race - results, gen_options = roll_options(options, meta["plando_options"]) - - if race: - meta["server_options"]["item_cheat"] = False - meta["server_options"]["remaining_mode"] = "disabled" + meta = get_meta(request.form, race) + results, gen_options = roll_options(options, set(meta["plando_options"])) if any(type(result) == str for result in results.values()): return render_template("checkResult.html", results=results) @@ -97,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non meta: Dict[str, Any] = {} meta.setdefault("server_options", {}).setdefault("hint_cost", 10) - race = meta.setdefault("race", False) + race = meta.setdefault("generator_options", {}).setdefault("race", False) def task(): target = tempfile.TemporaryDirectory() @@ -114,13 +123,14 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non erargs = parse_arguments(['--multi', str(playercount)]) erargs.seed = seed erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery - erargs.spoiler = 0 if race else 3 + erargs.spoiler = meta["generator_options"].get("spoiler", 0) erargs.race = race erargs.outputname = seedname erargs.outputpath = target.name erargs.teams = 1 erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", - {"bosses", "items", "connections", "texts"})) + {"bosses", "items", "connections", "texts"})) + erargs.skip_prog_balancing = False name_counter = Counter() for player, (playerfile, settings) in enumerate(gen_options.items(), 1): diff --git a/WebHostLib/locker.py b/WebHostLib/locker.py new file mode 100644 index 0000000000..5293352887 --- /dev/null +++ b/WebHostLib/locker.py @@ -0,0 +1,51 @@ +import os +import sys + + +class CommonLocker: + """Uses a file lock to signal that something is already running""" + lock_folder = "file_locks" + + def __init__(self, lockname: str, folder=None): + if folder: + self.lock_folder = folder + os.makedirs(self.lock_folder, exist_ok=True) + self.lockname = lockname + self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck") + + +class AlreadyRunningException(Exception): + pass + + +if sys.platform == 'win32': + class Locker(CommonLocker): + def __enter__(self): + try: + if os.path.exists(self.lockfile): + os.unlink(self.lockfile) + self.fp = os.open( + self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fp = getattr(self, "fp", None) + if fp: + os.close(self.fp) + os.unlink(self.lockfile) +else: # unix + import fcntl + + + class Locker(CommonLocker): + def __enter__(self): + try: + self.fp = open(self.lockfile, "wb") + fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as e: + raise AlreadyRunningException() from e + + def __exit__(self, _type, value, tb): + fcntl.flock(self.fp.fileno(), fcntl.LOCK_UN) + self.fp.close() diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index bf9f4e2fd7..e3111ed5b5 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -32,29 +32,34 @@ def page_not_found(err): # Start Playing Page @app.route('/start-playing') +@cache.cached() def start_playing(): return render_template(f"startPlaying.html") @app.route('/weighted-settings') +@cache.cached() def weighted_settings(): return render_template(f"weighted-settings.html") # Player settings pages @app.route('/games//player-settings') +@cache.cached() def player_settings(game): return render_template(f"player-settings.html", game=game, theme=get_world_theme(game)) # Game Info Pages @app.route('/games//info/') +@cache.cached() def game_info(game, lang): return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) # List of supported games @app.route('/games') +@cache.cached() def games(): worlds = {} for game, world in AutoWorldRegister.world_types.items(): @@ -64,21 +69,25 @@ def games(): @app.route('/tutorial///') +@cache.cached() def tutorial(game, file, lang): return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) @app.route('/tutorial/') +@cache.cached() def tutorial_landing(): return render_template("tutorialLanding.html") @app.route('/faq//') +@cache.cached() def faq(lang): return render_template("faq.html", lang=lang) @app.route('/glossary//') +@cache.cached() def terms(lang): return render_template("glossary.html", lang=lang) @@ -116,7 +125,11 @@ def display_log(room: UUID): if room is None: return abort(404) if room.owner == session["_id"]: - return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8") + file_path = os.path.join("logs", str(room.id) + ".txt") + if os.path.exists(file_path): + return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") + return "Log File does not exist." + return "Access Denied", 403 @@ -143,7 +156,7 @@ def host_room(room: UUID): @app.route('/favicon.ico') def favicon(): - return send_from_directory(os.path.join(app.root_path, 'static/static'), + return send_from_directory(os.path.join(app.root_path, "static", "static"), 'favicon.ico', mimetype='image/vnd.microsoft.icon') @@ -163,6 +176,7 @@ def get_datapackage(): @app.route('/index') @app.route('/sitemap') +@cache.cached() def get_sitemap(): available_games: List[Dict[str, Union[str, bool]]] = [] for game, world in AutoWorldRegister.world_types.items(): diff --git a/WebHostLib/models.py b/WebHostLib/models.py index eba5c4eb4d..7fa54f26a0 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -21,7 +21,7 @@ class Slot(db.Entity): class Room(db.Entity): id = PrimaryKey(UUID, default=uuid4) last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow()) + creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page owner = Required(UUID, index=True) commands = Set('Command') seed = Required('Seed', index=True) @@ -38,7 +38,7 @@ class Seed(db.Entity): rooms = Set(Room) multidata = Required(bytes, lazy=True) owner = Required(UUID, index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow()) + creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page slots = Set(Slot) spoiler = Optional(LongStr, lazy=True) meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags diff --git a/WebHostLib/options.py b/WebHostLib/options.py index a4d7ccc17c..18a28045ee 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -17,29 +17,8 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin def create(): target_folder = local_path("WebHostLib", "static", "generated") yaml_folder = os.path.join(target_folder, "configs") - os.makedirs(yaml_folder, exist_ok=True) - for file in os.listdir(yaml_folder): - full_path: str = os.path.join(yaml_folder, file) - if os.path.isfile(full_path): - os.unlink(full_path) - - def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): - data = {option.default: 50} - for sub_option in ["random", "random-low", "random-high"]: - if sub_option != option.default: - data[sub_option] = 0 - - notes = {} - for name, number in getattr(option, "special_range_names", {}).items(): - notes[name] = f"equivalent to {number}" - if number in data: - data[name] = data[number] - del data[number] - else: - data[name] = 0 - - return data, notes + Options.generate_yaml_templates(yaml_folder) def get_html_doc(option_type: type(Options.Option)) -> str: if not option_type.__doc__: @@ -57,27 +36,12 @@ def create(): for game_name, world in AutoWorldRegister.world_types.items(): - all_options: typing.Dict[str, Options.AssembleOptions] = { - **Options.per_game_common_options, - **world.option_definitions - } - with open(local_path("WebHostLib", "templates", "options.yaml")) as f: - file_data = f.read() - res = Template(file_data).render( - options=all_options, - __version__=__version__, game=game_name, yaml_dump=yaml.dump, - dictify_range=dictify_range, - ) - - del file_data - - with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f: - f.write(res) + all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints # Generate JSON files for player-settings pages player_settings = { "baseOptions": { - "description": "Generated by https://archipelago.gg/", + "description": f"Generated by https://archipelago.gg/ for {game_name}", "game": game_name, "name": "Player", }, @@ -131,6 +95,7 @@ def create(): "type": "items-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), + "defaultValue": list(option.default) } elif issubclass(option, Options.LocationSet): @@ -138,15 +103,17 @@ def create(): "type": "locations-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), + "defaultValue": list(option.default) } - elif issubclass(option, Options.VerifyKeys): + elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict): if option.valid_keys: game_options[option_name] = { "type": "custom-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), "options": list(option.valid_keys), + "defaultValue": list(option.default) if hasattr(option, "default") else [] } else: diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index d5c1719863..654104252c 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,7 +1,9 @@ -flask>=2.2.3 -pony>=0.7.16 +flask>=3.0.0 +pony>=0.7.17 waitress>=2.1.2 -Flask-Caching>=2.0.2 -Flask-Compress>=1.13 -Flask-Limiter>=3.3.0 -bokeh>=3.1.0 +Flask-Caching>=2.1.0 +Flask-Compress>=1.14 +Flask-Limiter>=3.5.0 +bokeh>=3.1.1; python_version <= '3.8' +bokeh>=3.2.2; python_version >= '3.9' +markupsafe>=2.1.3 diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index 5d4aaffa9d..4ebec1adbf 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.classList.add('randomize-button'); randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, [select])); + randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); select.disabled = true; @@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.classList.add('randomize-button'); randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, [range])); + randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); range.disabled = true; @@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize( - event, [specialRange, specialRangeSelect]) + event, specialRange, specialRangeSelect) ); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); @@ -294,23 +294,24 @@ const buildOptionsTable = (settings, romOpts = false) => { return table; }; -const toggleRandomize = (event, inputElements) => { +const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { const active = event.target.classList.contains('active'); const randomButton = event.target; if (active) { randomButton.classList.remove('active'); - for (const element of inputElements) { - element.disabled = undefined; - updateGameSetting(element); + inputElement.disabled = undefined; + if (optionalSelectElement) { + optionalSelectElement.disabled = undefined; } } else { randomButton.classList.add('active'); - for (const element of inputElements) { - element.disabled = true; - updateGameSetting(randomButton); + inputElement.disabled = true; + if (optionalSelectElement) { + optionalSelectElement.disabled = true; } } + updateGameSetting(active ? inputElement : randomButton); }; const updateBaseSetting = (event) => { @@ -322,7 +323,6 @@ const updateBaseSetting = (event) => { const updateGameSetting = (settingElement) => { const options = JSON.parse(localStorage.getItem(gameName)); - if (settingElement.classList.contains('randomize-button')) { // If the event passed in is the randomize button, then we know what we must do. options[gameName][settingElement.getAttribute('data-key')] = 'random'; @@ -364,6 +364,7 @@ const generateGame = (raceMode = false) => { weights: { player: settings }, presetData: { player: settings }, playerCount: 1, + spoiler: 3, race: raceMode ? '1' : '0', }).then((response) => { window.location.href = response.data.url; diff --git a/WebHostLib/static/assets/supportedGames.js b/WebHostLib/static/assets/supportedGames.js new file mode 100644 index 0000000000..1acf0e0cc5 --- /dev/null +++ b/WebHostLib/static/assets/supportedGames.js @@ -0,0 +1,83 @@ +window.addEventListener('load', () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + header.addEventListener('click', () => { + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + if (gameInfo.classList.contains('collapsed')) { + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } + }); + }); + + // Handle game filter input + const gameSearch = document.getElementById('game-search'); + gameSearch.value = ''; + + gameSearch.addEventListener('input', (evt) => { + if (!evt.target.value.trim()) { + // If input is empty, display all collapsed games + return Array.from(gameHeaders).forEach((header) => { + header.style.display = null; + const gameName = header.getAttribute('data-game'); + document.getElementById(`${gameName}-arrow`).innerText = '▶'; + document.getElementById(gameName).classList.add('collapsed'); + }); + } + + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + // If the game name includes the search string, display the game. If not, hide it + if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) { + header.style.display = null; + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + } else { + console.log(header); + header.style.display = 'none'; + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + } + }); + }); + + document.getElementById('expand-all').addEventListener('click', expandAll); + document.getElementById('collapse-all').addEventListener('click', collapseAll); +}); + +const expandAll = () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + if (header.style.display === 'none') { return; } + gameArrow.innerText = '▼'; + gameInfo.classList.remove('collapsed'); + }); +}; + +const collapseAll = () => { + const gameHeaders = document.getElementsByClassName('collapse-toggle'); + // Loop over all the games + Array.from(gameHeaders).forEach((header) => { + const gameName = header.getAttribute('data-game'); + const gameArrow = document.getElementById(`${gameName}-arrow`); + const gameInfo = document.getElementById(gameName); + + if (header.style.display === 'none') { return; } + gameArrow.innerText = '▶'; + gameInfo.classList.add('collapsed'); + }); +}; diff --git a/WebHostLib/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index c08590cbf7..41c4020dac 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -14,6 +14,17 @@ const adjustTableHeight = () => { } }; +/** + * Convert an integer number of seconds into a human readable HH:MM format + * @param {Number} seconds + * @returns {string} + */ +const secondsToHours = (seconds) => { + let hours = Math.floor(seconds / 3600); + let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + window.addEventListener('load', () => { const tables = $(".table").DataTable({ paging: false, @@ -27,7 +38,18 @@ window.addEventListener('load', () => { stateLoadCallback: function(settings) { return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); }, + footerCallback: function(tfoot, data, start, end, display) { + if (tfoot) { + const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); + Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = + (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; + } + }, columnDefs: [ + { + targets: 'last-activity', + name: 'lastActivity' + }, { targets: 'hours', render: function (data, type, row) { @@ -40,11 +62,7 @@ window.addEventListener('load', () => { if (data === "None") return data; - let hours = Math.floor(data / 3600); - let minutes = Math.floor((data - (hours * 3600)) / 60); - - if (minutes < 10) {minutes = "0"+minutes;} - return hours+':'+minutes; + return secondsToHours(data); } }, { @@ -114,11 +132,16 @@ window.addEventListener('load', () => { if (status === "success") { target.find(".table").each(function (i, new_table) { const new_trs = $(new_table).find("tbody>tr"); + const footer_tr = $(new_table).find("tfoot>tr"); const old_table = tables.eq(i); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); old_table.clear(); - old_table.rows.add(new_trs).draw(); + if (footer_tr.length) { + $(old_table.table).find("tfoot").html(footer_tr); + } + old_table.rows.add(new_trs); + old_table.draw(); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); }); diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index e471e0837a..fb7d3a349b 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -91,7 +91,7 @@ const createDefaultSettings = (settingData) => { case 'items-list': case 'locations-list': case 'custom-list': - newSettings[game][gameSetting] = []; + newSettings[game][gameSetting] = setting.defaultValue; break; default: @@ -160,6 +160,7 @@ const buildUI = (settingData) => { weightedSettingsDiv.classList.add('invisible'); itemPoolDiv.classList.add('invisible'); hintsDiv.classList.add('invisible'); + locationsDiv.classList.add('invisible'); expandButton.classList.remove('invisible'); }); @@ -168,6 +169,7 @@ const buildUI = (settingData) => { weightedSettingsDiv.classList.remove('invisible'); itemPoolDiv.classList.remove('invisible'); hintsDiv.classList.remove('invisible'); + locationsDiv.classList.remove('invisible'); expandButton.classList.add('invisible'); }); }); @@ -1134,8 +1136,8 @@ const validateSettings = () => { return; } - // Remove any disabled options Object.keys(settings[game]).forEach((setting) => { + // Remove any disabled options Object.keys(settings[game][setting]).forEach((option) => { if (settings[game][setting][option] === 0) { delete settings[game][setting][option]; @@ -1149,6 +1151,32 @@ const validateSettings = () => { ) { errorMessage = `${game} // ${setting} has no values above zero!`; } + + // Remove weights from options with only one possibility + if ( + Object.keys(settings[game][setting]).length === 1 && + !Array.isArray(settings[game][setting]) && + setting !== 'start_inventory' + ) { + settings[game][setting] = Object.keys(settings[game][setting])[0]; + } + + // Remove empty arrays + else if ( + ['exclude_locations', 'priority_locations', 'local_items', + 'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) && + settings[game][setting].length === 0 + ) { + delete settings[game][setting]; + } + + // Remove empty start inventory + else if ( + setting === 'start_inventory' && + Object.keys(settings[game]['start_inventory']).length === 0 + ) { + delete settings[game]['start_inventory']; + } }); }); @@ -1156,6 +1184,11 @@ const validateSettings = () => { errorMessage = 'You have not chosen a game to play!'; } + // Remove weights if there is only one game + else if (Object.keys(settings.game).length === 1) { + settings.game = Object.keys(settings.game)[0]; + } + // If an error occurred, alert the user and do not export the file if (errorMessage) { userMessage.innerText = errorMessage; @@ -1199,6 +1232,7 @@ const generateGame = (raceMode = false) => { weights: { player: JSON.stringify(settings) }, presetData: { player: JSON.stringify(settings) }, playerCount: 1, + spoiler: 3, race: raceMode ? '1' : '0', }).then((response) => { window.location.href = response.data.url; diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png new file mode 100644 index 0000000000..8fb366b93f Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png differ diff --git a/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png new file mode 100644 index 0000000000..336dc5f77a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png differ diff --git a/WebHostLib/static/static/icons/sc2/advanceballistics.png b/WebHostLib/static/static/icons/sc2/advanceballistics.png new file mode 100644 index 0000000000..1bf7df9fb7 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/advanceballistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/autoturretblackops.png b/WebHostLib/static/static/icons/sc2/autoturretblackops.png new file mode 100644 index 0000000000..552707831a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/autoturretblackops.png differ diff --git a/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png new file mode 100644 index 0000000000..e7ebf40316 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/biomechanicaldrone.png differ diff --git a/WebHostLib/static/static/icons/sc2/burstcapacitors.png b/WebHostLib/static/static/icons/sc2/burstcapacitors.png new file mode 100644 index 0000000000..3af9b20a16 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/burstcapacitors.png differ diff --git a/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png new file mode 100644 index 0000000000..d1c0c6c9a0 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/crossspectrumdampeners.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclone.png b/WebHostLib/static/static/icons/sc2/cyclone.png new file mode 100644 index 0000000000..d2016116ea Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclone.png differ diff --git a/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png new file mode 100644 index 0000000000..351be570d1 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/cyclonerangeupgrade.png differ diff --git a/WebHostLib/static/static/icons/sc2/drillingclaws.png b/WebHostLib/static/static/icons/sc2/drillingclaws.png new file mode 100644 index 0000000000..2b067a6e44 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/drillingclaws.png differ diff --git a/WebHostLib/static/static/icons/sc2/emergencythrusters.png b/WebHostLib/static/static/icons/sc2/emergencythrusters.png new file mode 100644 index 0000000000..159fba37c9 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/emergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/hellionbattlemode.png b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png new file mode 100644 index 0000000000..56bfd98c92 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hellionbattlemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png new file mode 100644 index 0000000000..40a5991ebb Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/high-explosive-spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperflightrotors.png b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png new file mode 100644 index 0000000000..3753258458 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperflightrotors.png differ diff --git a/WebHostLib/static/static/icons/sc2/hyperfluxor.png b/WebHostLib/static/static/icons/sc2/hyperfluxor.png new file mode 100644 index 0000000000..cdd95bb515 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/hyperfluxor.png differ diff --git a/WebHostLib/static/static/icons/sc2/impalerrounds.png b/WebHostLib/static/static/icons/sc2/impalerrounds.png new file mode 100644 index 0000000000..b00e0c4758 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/impalerrounds.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedburstlaser.png b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png new file mode 100644 index 0000000000..8a48e38e87 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedburstlaser.png differ diff --git a/WebHostLib/static/static/icons/sc2/improvedsiegemode.png b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png new file mode 100644 index 0000000000..f19dad952b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/improvedsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/interferencematrix.png b/WebHostLib/static/static/icons/sc2/interferencematrix.png new file mode 100644 index 0000000000..ced928aa57 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/interferencematrix.png differ diff --git a/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png new file mode 100644 index 0000000000..e97f3db0d2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/internalizedtechmodule.png differ diff --git a/WebHostLib/static/static/icons/sc2/jotunboosters.png b/WebHostLib/static/static/icons/sc2/jotunboosters.png new file mode 100644 index 0000000000..25720306e5 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jotunboosters.png differ diff --git a/WebHostLib/static/static/icons/sc2/jumpjets.png b/WebHostLib/static/static/icons/sc2/jumpjets.png new file mode 100644 index 0000000000..dfdfef4052 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/jumpjets.png differ diff --git a/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png new file mode 100644 index 0000000000..c57899b270 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lasertargetingsystem.png differ diff --git a/WebHostLib/static/static/icons/sc2/liberator.png b/WebHostLib/static/static/icons/sc2/liberator.png new file mode 100644 index 0000000000..31507be5fe Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/liberator.png differ diff --git a/WebHostLib/static/static/icons/sc2/lockdown.png b/WebHostLib/static/static/icons/sc2/lockdown.png new file mode 100644 index 0000000000..a2e7f5dc3e Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/lockdown.png differ diff --git a/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png new file mode 100644 index 0000000000..0272b4b738 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magfieldaccelerator.png differ diff --git a/WebHostLib/static/static/icons/sc2/magrailmunitions.png b/WebHostLib/static/static/icons/sc2/magrailmunitions.png new file mode 100644 index 0000000000..ec303498cc Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/magrailmunitions.png differ diff --git a/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png new file mode 100644 index 0000000000..1c7ce9d6ab Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/medivacemergencythrusters.png differ diff --git a/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png new file mode 100644 index 0000000000..04d68d35dc Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/neosteelfortifiedarmor.png differ diff --git a/WebHostLib/static/static/icons/sc2/opticalflare.png b/WebHostLib/static/static/icons/sc2/opticalflare.png new file mode 100644 index 0000000000..f888fd518b Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/opticalflare.png differ diff --git a/WebHostLib/static/static/icons/sc2/optimizedlogistics.png b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png new file mode 100644 index 0000000000..dcf5fd72da Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/optimizedlogistics.png differ diff --git a/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png new file mode 100644 index 0000000000..b9f2f055c2 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/reapercombatdrugs.png differ diff --git a/WebHostLib/static/static/icons/sc2/restoration.png b/WebHostLib/static/static/icons/sc2/restoration.png new file mode 100644 index 0000000000..f5c94e1aee Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/restoration.png differ diff --git a/WebHostLib/static/static/icons/sc2/ripwavemissiles.png b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png new file mode 100644 index 0000000000..f68e820397 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/ripwavemissiles.png differ diff --git a/WebHostLib/static/static/icons/sc2/shreddermissile.png b/WebHostLib/static/static/icons/sc2/shreddermissile.png new file mode 100644 index 0000000000..40899095fe Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/shreddermissile.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png new file mode 100644 index 0000000000..1b9f8cf060 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetank-spidermines.png differ diff --git a/WebHostLib/static/static/icons/sc2/siegetankrange.png b/WebHostLib/static/static/icons/sc2/siegetankrange.png new file mode 100644 index 0000000000..5aef00a656 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/siegetankrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/specialordance.png b/WebHostLib/static/static/icons/sc2/specialordance.png new file mode 100644 index 0000000000..4f7410d7ca Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/specialordance.png differ diff --git a/WebHostLib/static/static/icons/sc2/spidermine.png b/WebHostLib/static/static/icons/sc2/spidermine.png new file mode 100644 index 0000000000..bb39cf0bf8 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/spidermine.png differ diff --git a/WebHostLib/static/static/icons/sc2/staticempblast.png b/WebHostLib/static/static/icons/sc2/staticempblast.png new file mode 100644 index 0000000000..38f3615107 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/staticempblast.png differ diff --git a/WebHostLib/static/static/icons/sc2/superstimpack.png b/WebHostLib/static/static/icons/sc2/superstimpack.png new file mode 100644 index 0000000000..0fba8ce574 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/superstimpack.png differ diff --git a/WebHostLib/static/static/icons/sc2/targetingoptics.png b/WebHostLib/static/static/icons/sc2/targetingoptics.png new file mode 100644 index 0000000000..057a40f08e Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/targetingoptics.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-cloak-color.png b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png new file mode 100644 index 0000000000..44d1bb9541 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-cloak-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terran-emp-color.png b/WebHostLib/static/static/icons/sc2/terran-emp-color.png new file mode 100644 index 0000000000..972b828c75 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terran-emp-color.png differ diff --git a/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png new file mode 100644 index 0000000000..9d59826551 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/terrandefendermodestructureattack.png differ diff --git a/WebHostLib/static/static/icons/sc2/thorsiegemode.png b/WebHostLib/static/static/icons/sc2/thorsiegemode.png new file mode 100644 index 0000000000..a298fb57de Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/thorsiegemode.png differ diff --git a/WebHostLib/static/static/icons/sc2/transformationservos.png b/WebHostLib/static/static/icons/sc2/transformationservos.png new file mode 100644 index 0000000000..f7f0524ac1 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/transformationservos.png differ diff --git a/WebHostLib/static/static/icons/sc2/valkyrie.png b/WebHostLib/static/static/icons/sc2/valkyrie.png new file mode 100644 index 0000000000..9cbf339b10 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/valkyrie.png differ diff --git a/WebHostLib/static/static/icons/sc2/warpjump.png b/WebHostLib/static/static/icons/sc2/warpjump.png new file mode 100644 index 0000000000..ff0a7b1af4 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/warpjump.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png new file mode 100644 index 0000000000..8f5e09c6a5 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-attackrange.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png new file mode 100644 index 0000000000..7097db05e6 Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine-deathblossom.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowmine.png b/WebHostLib/static/static/icons/sc2/widowmine.png new file mode 100644 index 0000000000..802c49a83d Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowmine.png differ diff --git a/WebHostLib/static/static/icons/sc2/widowminehidden.png b/WebHostLib/static/static/icons/sc2/widowminehidden.png new file mode 100644 index 0000000000..e568742e8a Binary files /dev/null and b/WebHostLib/static/static/icons/sc2/widowminehidden.png differ diff --git a/WebHostLib/static/styles/landing.css b/WebHostLib/static/styles/landing.css index 202c43badd..96975553c1 100644 --- a/WebHostLib/static/styles/landing.css +++ b/WebHostLib/static/styles/landing.css @@ -235,9 +235,6 @@ html{ line-height: 30px; } -#landing .variable{ - color: #ffff00; -} .landing-deco{ position: absolute; diff --git a/WebHostLib/static/styles/player-settings.css b/WebHostLib/static/styles/player-settings.css index 9ba47d5fd0..e6e0c29292 100644 --- a/WebHostLib/static/styles/player-settings.css +++ b/WebHostLib/static/styles/player-settings.css @@ -5,7 +5,8 @@ html{ } #player-settings{ - max-width: 1000px; + box-sizing: border-box; + max-width: 1024px; margin-left: auto; margin-right: auto; background-color: rgba(0, 0, 0, 0.15); @@ -163,6 +164,11 @@ html{ background-color: #ffef00; /* Same as .interactive in globalStyles.css */ } +#player-settings table .randomize-button[data-tooltip]::after { + left: unset; + right: 0; +} + #player-settings table label{ display: block; min-width: 200px; @@ -177,18 +183,31 @@ html{ vertical-align: top; } -@media all and (max-width: 1000px), all and (orientation: portrait){ +@media all and (max-width: 1024px) { + #player-settings { + border-radius: 0; + } + #player-settings #game-options{ justify-content: flex-start; flex-wrap: wrap; } - #player-settings .left, #player-settings .right{ - flex-grow: unset; + #player-settings .left, + #player-settings .right { + margin: 0; + } + + #game-options table { + margin-bottom: 0; } #game-options table label{ display: block; min-width: 200px; } + + #game-options table tr td { + width: 50%; + } } diff --git a/WebHostLib/static/styles/sc2wolTracker.css b/WebHostLib/static/styles/sc2wolTracker.css index b68668ecf6..a7d8bd28c4 100644 --- a/WebHostLib/static/styles/sc2wolTracker.css +++ b/WebHostLib/static/styles/sc2wolTracker.css @@ -9,7 +9,7 @@ border-top-left-radius: 4px; border-top-right-radius: 4px; padding: 3px 3px 10px; - width: 500px; + width: 710px; background-color: #525494; } @@ -34,10 +34,12 @@ max-height: 40px; border: 1px solid #000000; filter: grayscale(100%) contrast(75%) brightness(20%); + background-color: black; } #inventory-table img.acquired{ filter: none; + background-color: black; } #inventory-table div.counted-item { @@ -52,7 +54,7 @@ } #location-table{ - width: 500px; + width: 710px; border-left: 2px solid #000000; border-right: 2px solid #000000; border-bottom: 2px solid #000000; diff --git a/WebHostLib/static/styles/supportedGames.css b/WebHostLib/static/styles/supportedGames.css index f86ab581ca..1e9a98c17a 100644 --- a/WebHostLib/static/styles/supportedGames.css +++ b/WebHostLib/static/styles/supportedGames.css @@ -18,6 +18,16 @@ margin-bottom: 2px; } +#games h2 .collapse-arrow{ + font-size: 20px; + vertical-align: middle; + cursor: pointer; +} + +#games p.collapsed{ + display: none; +} + #games a{ font-size: 16px; } @@ -31,3 +41,13 @@ line-height: 25px; margin-bottom: 7px; } + +#games #page-controls{ + display: flex; + flex-direction: row; + margin-top: 0.25rem; +} + +#games #page-controls button{ + margin-left: 0.5rem; +} diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index 0e00553c72..0cc2ede59f 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -55,16 +55,16 @@ table.dataTable thead{ font-family: LexendDeca-Regular, sans-serif; } -table.dataTable tbody{ +table.dataTable tbody, table.dataTable tfoot{ background-color: #dce2bd; font-family: LexendDeca-Light, sans-serif; } -table.dataTable tbody tr:hover{ +table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ background-color: #e2eabb; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ padding: 4px 6px; } @@ -97,10 +97,14 @@ table.dataTable thead th.lower-row{ top: 46px; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ border: 1px solid #bba967; } +table.dataTable tfoot td{ + font-weight: bold; +} + div.dataTables_scrollBody{ background-color: inherit !important; } diff --git a/WebHostLib/templates/check.html b/WebHostLib/templates/check.html index 04b51340b5..8a3da7db47 100644 --- a/WebHostLib/templates/check.html +++ b/WebHostLib/templates/check.html @@ -17,9 +17,9 @@

- +
- +
diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index b5fb83252e..33f8dbc09e 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -119,6 +119,28 @@ + + + + + + + + @@ -181,10 +203,10 @@
- +
- + diff --git a/WebHostLib/templates/hintTable.html b/WebHostLib/templates/hintTable.html new file mode 100644 index 0000000000..00b74111ea --- /dev/null +++ b/WebHostLib/templates/hintTable.html @@ -0,0 +1,28 @@ +{% for team, hints in hints.items() %} +
+ + + + + + + + + + + + + {%- for hint in hints -%} + + + + + + + + + {%- endfor -%} + +
FinderReceiverItemLocationEntranceFound
{{ long_player_names[team, hint.finding_player] }}{{ long_player_names[team, hint.receiving_player] }}{{ hint.item|item_name }}{{ hint.location|location_name }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
+
+{% endfor %} \ No newline at end of file diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 6f02dc0944..ba15d64aca 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -32,13 +32,18 @@ {% endif %} {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %} -
-
- - -
-
+
+
+
+ + +
+
+ + Open Log File... + +
{% endblock %} {% block body %} {% include 'header/oceanHeader.html' %}

Currently Supported Games

+
+
+
+ + + +
+
{% for game_name in worlds | title_sorted %} {% set world = worlds[game_name] %} -

{{ game_name }}

-

+

+  {{ game_name }} +

+
  • Create New Room diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 8f9fb14881..0d9ead7951 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -1,7 +1,7 @@ import collections import datetime import typing -from typing import Counter, Optional, Dict, Any, Tuple +from typing import Counter, Optional, Dict, Any, Tuple, List from uuid import UUID from flask import render_template @@ -9,9 +9,9 @@ from jinja2 import pass_context, runtime from werkzeug.exceptions import abort from MultiServer import Context, get_saving_second -from NetUtils import SlotType +from NetUtils import ClientStatus, SlotType, NetworkSlot from Utils import restricted_loads -from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package +from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package, games from worlds.alttp import Items from . import app, cache from .models import GameDataPackage, Room @@ -264,16 +264,17 @@ def get_static_room_data(room: Room): multidata = Context.decompress(room.seed.multidata) # in > 100 players this can take a bit of time and is the main reason for the cache locations: Dict[int, Dict[int, Tuple[int, int, int]]] = multidata['locations'] - names: Dict[int, Dict[int, str]] = multidata["names"] - games = {} + names: List[List[str]] = multidata.get("names", []) + games = multidata.get("games", {}) groups = {} custom_locations = {} custom_items = {} if "slot_info" in multidata: - games = {slot: slot_info.game for slot, slot_info in multidata["slot_info"].items()} - groups = {slot: slot_info.group_members for slot, slot_info in multidata["slot_info"].items() + slot_info_dict: Dict[int, NetworkSlot] = multidata["slot_info"] + games = {slot: slot_info.game for slot, slot_info in slot_info_dict.items()} + groups = {slot: slot_info.group_members for slot, slot_info in slot_info_dict.items() if slot_info.type == SlotType.group} - + names = [[slot_info.name for slot, slot_info in sorted(slot_info_dict.items())]] for game in games.values(): if game not in multidata["datapackage"]: continue @@ -290,8 +291,7 @@ def get_static_room_data(room: Room): {id_: name for name, id_ in game_data["location_name_to_id"].items()}) custom_items.update( {id_: name for name, id_ in game_data["item_name_to_id"].items()}) - elif "games" in multidata: - games = multidata["games"] + seed_checks_in_area = checks_in_area.copy() use_door_tracker = False @@ -302,14 +302,17 @@ def get_static_room_data(room: Room): seed_checks_in_area[area] += len(checks) seed_checks_in_area["Total"] = 249 - player_checks_in_area = {playernumber: {areaname: len(multidata["checks_in_area"][playernumber][areaname]) - if areaname != "Total" else multidata["checks_in_area"][playernumber]["Total"] - for areaname in ordered_areas} - for playernumber in range(1, len(names[0]) + 1) - if playernumber not in groups} + player_checks_in_area = { + playernumber: { + areaname: len(multidata["checks_in_area"][playernumber][areaname]) if areaname != "Total" else + multidata["checks_in_area"][playernumber]["Total"] + for areaname in ordered_areas + } + for playernumber in multidata["checks_in_area"] + } + player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber]) - for playernumber in range(1, len(names[0]) + 1) - if playernumber not in groups} + for playernumber in multidata["checks_in_area"]} saving_second = get_saving_second(multidata["seed_name"]) result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \ multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \ @@ -343,7 +346,7 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \ get_static_room_data(room) player_name = names[tracked_team][tracked_player - 1] - location_to_area = player_location_to_area[tracked_player] + location_to_area = player_location_to_area.get(tracked_player, {}) inventory = collections.Counter() checks_done = {loc_name: 0 for loc_name in default_locations} @@ -375,15 +378,18 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w if recipient in slots_aimed_at_player: # a check done for the tracked player attribute_item_solo(inventory, item) if ms_player == tracked_player: # a check done by the tracked player - checks_done[location_to_area[location]] += 1 + area_name = location_to_area.get(location, None) + if area_name: + checks_done[area_name] += 1 checks_done["Total"] += 1 specific_tracker = game_specific_trackers.get(games[tracked_player], None) if specific_tracker and not want_generic: tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, - seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second) + seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second) else: - tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name, - seed_checks_in_area, checks_done, saving_second, custom_locations, custom_items) + tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, + player_name, seed_checks_in_area, checks_done, saving_second, + custom_locations, custom_items) return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker @@ -984,6 +990,7 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict SC2WOL_LOC_ID_OFFSET = 1000 SC2WOL_ITEM_ID_OFFSET = 1000 + icons = { "Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png", "Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png", @@ -1028,15 +1035,36 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg", "Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Marine)": "/static/static/icons/sc2/superstimpack.png", "Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png", + "Laser Targeting System (Marine)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Magrail Munitions (Marine)": "/static/static/icons/sc2/magrailmunitions.png", + "Optimized Logistics (Marine)": "/static/static/icons/sc2/optimizedlogistics.png", "Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png", "Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png", + "Restoration (Medic)": "/static/static/icons/sc2/restoration.png", + "Optical Flare (Medic)": "/static/static/icons/sc2/opticalflare.png", + "Optimized Logistics (Medic)": "/static/static/icons/sc2/optimizedlogistics.png", "Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png", "Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png", + "Stimpack (Firebat)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Firebat)": "/static/static/icons/sc2/superstimpack.png", + "Optimized Logistics (Firebat)": "/static/static/icons/sc2/optimizedlogistics.png", "Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png", "Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png", + "Stimpack (Marauder)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Marauder)": "/static/static/icons/sc2/superstimpack.png", + "Laser Targeting System (Marauder)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Magrail Munitions (Marauder)": "/static/static/icons/sc2/magrailmunitions.png", + "Internal Tech Module (Marauder)": "/static/static/icons/sc2/internalizedtechmodule.png", "U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png", "G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png", + "Stimpack (Reaper)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Reaper)": "/static/static/icons/sc2/superstimpack.png", + "Laser Targeting System (Reaper)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Advanced Cloaking Field (Reaper)": "/static/static/icons/sc2/terran-cloak-color.png", + "Spider Mines (Reaper)": "/static/static/icons/sc2/spidermine.png", + "Combat Drugs (Reaper)": "/static/static/icons/sc2/reapercombatdrugs.png", "Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg", "Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg", @@ -1046,14 +1074,35 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png", "Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png", - "Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png", + "Hellbat Aspect (Hellion)": "/static/static/icons/sc2/hellionbattlemode.png", + "Smart Servos (Hellion)": "/static/static/icons/sc2/transformationservos.png", + "Optimized Logistics (Hellion)": "/static/static/icons/sc2/optimizedlogistics.png", + "Jump Jets (Hellion)": "/static/static/icons/sc2/jumpjets.png", + "Stimpack (Hellion)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png", + "Super Stimpack (Hellion)": "/static/static/icons/sc2/superstimpack.png", + "Cerberus Mine (Spider Mine)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png", + "High Explosive Munition (Spider Mine)": "/static/static/icons/sc2/high-explosive-spidermine.png", "Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png", + "Ion Thrusters (Vulture)": "/static/static/icons/sc2/emergencythrusters.png", + "Auto Launchers (Vulture)": "/static/static/icons/sc2/jotunboosters.png", "Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png", "Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png", + "Jump Jets (Goliath)": "/static/static/icons/sc2/jumpjets.png", + "Optimized Logistics (Goliath)": "/static/static/icons/sc2/optimizedlogistics.png", "Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png", "Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png", + "Hyperfluxor (Diamondback)": "/static/static/icons/sc2/hyperfluxor.png", + "Burst Capacitors (Diamondback)": "/static/static/icons/sc2/burstcapacitors.png", + "Optimized Logistics (Diamondback)": "/static/static/icons/sc2/optimizedlogistics.png", "Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png", "Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png", + "Jump Jets (Siege Tank)": "/static/static/icons/sc2/jumpjets.png", + "Spider Mines (Siege Tank)": "/static/static/icons/sc2/siegetank-spidermines.png", + "Smart Servos (Siege Tank)": "/static/static/icons/sc2/transformationservos.png", + "Graduating Range (Siege Tank)": "/static/static/icons/sc2/siegetankrange.png", + "Laser Targeting System (Siege Tank)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Advanced Siege Tech (Siege Tank)": "/static/static/icons/sc2/improvedsiegemode.png", + "Internal Tech Module (Siege Tank)": "/static/static/icons/sc2/internalizedtechmodule.png", "Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg", "Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg", @@ -1063,25 +1112,77 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png", "Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png", + "Expanded Hull (Medivac)": "/static/static/icons/sc2/neosteelfortifiedarmor.png", + "Afterburners (Medivac)": "/static/static/icons/sc2/medivacemergencythrusters.png", "Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png", "Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png", + "Advanced Laser Technology (Wraith)": "/static/static/icons/sc2/improvedburstlaser.png", "Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png", "Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png", - "Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png", + "Smart Servos (Viking)": "/static/static/icons/sc2/transformationservos.png", + "Magrail Munitions (Viking)": "/static/static/icons/sc2/magrailmunitions.png", + "Cross-Spectrum Dampeners (Banshee)": "/static/static/icons/sc2/crossspectrumdampeners.png", + "Advanced Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png", "Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png", + "Hyperflight Rotors (Banshee)": "/static/static/icons/sc2/hyperflightrotors.png", + "Laser Targeting System (Banshee)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Internal Tech Module (Banshee)": "/static/static/icons/sc2/internalizedtechmodule.png", "Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png", "Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png", + "Tactical Jump (Battlecruiser)": "/static/static/icons/sc2/warpjump.png", + "Cloak (Battlecruiser)": "/static/static/icons/sc2/terran-cloak-color.png", + "ATX Laser Battery (Battlecruiser)": "/static/static/icons/sc2/specialordance.png", + "Optimized Logistics (Battlecruiser)": "/static/static/icons/sc2/optimizedlogistics.png", + "Internal Tech Module (Battlecruiser)": "/static/static/icons/sc2/internalizedtechmodule.png", "Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg", "Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg", "Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg", + "Widow Mine": "/static/static/icons/sc2/widowmine.png", + "Cyclone": "/static/static/icons/sc2/cyclone.png", + "Liberator": "/static/static/icons/sc2/liberator.png", + "Valkyrie": "/static/static/icons/sc2/valkyrie.png", + "Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png", "Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png", + "EMP Rounds (Ghost)": "/static/static/icons/sc2/terran-emp-color.png", + "Lockdown (Ghost)": "/static/static/icons/sc2/lockdown.png", "Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png", "Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png", + "Impaler Rounds (Spectre)": "/static/static/icons/sc2/impalerrounds.png", "330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png", "Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png", + "High Impact Payload (Thor)": "/static/static/icons/sc2/thorsiegemode.png", + "Smart Servos (Thor)": "/static/static/icons/sc2/transformationservos.png", + + "Optimized Logistics (Predator)": "/static/static/icons/sc2/optimizedlogistics.png", + "Drilling Claws (Widow Mine)": "/static/static/icons/sc2/drillingclaws.png", + "Concealment (Widow Mine)": "/static/static/icons/sc2/widowminehidden.png", + "Black Market Launchers (Widow Mine)": "/static/static/icons/sc2/widowmine-attackrange.png", + "Executioner Missiles (Widow Mine)": "/static/static/icons/sc2/widowmine-deathblossom.png", + "Mag-Field Accelerators (Cyclone)": "/static/static/icons/sc2/magfieldaccelerator.png", + "Mag-Field Launchers (Cyclone)": "/static/static/icons/sc2/cyclonerangeupgrade.png", + "Targeting Optics (Cyclone)": "/static/static/icons/sc2/targetingoptics.png", + "Rapid Fire Launchers (Cyclone)": "/static/static/icons/sc2/ripwavemissiles.png", + "Bio Mechanical Repair Drone (Raven)": "/static/static/icons/sc2/biomechanicaldrone.png", + "Spider Mines (Raven)": "/static/static/icons/sc2/siegetank-spidermines.png", + "Railgun Turret (Raven)": "/static/static/icons/sc2/autoturretblackops.png", + "Hunter-Seeker Weapon (Raven)": "/static/static/icons/sc2/specialordance.png", + "Interference Matrix (Raven)": "/static/static/icons/sc2/interferencematrix.png", + "Anti-Armor Missile (Raven)": "/static/static/icons/sc2/shreddermissile.png", + "Internal Tech Module (Raven)": "/static/static/icons/sc2/internalizedtechmodule.png", + "EMP Shockwave (Science Vessel)": "/static/static/icons/sc2/staticempblast.png", + "Defensive Matrix (Science Vessel)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png", + "Advanced Ballistics (Liberator)": "/static/static/icons/sc2/advanceballistics.png", + "Raid Artillery (Liberator)": "/static/static/icons/sc2/terrandefendermodestructureattack.png", + "Cloak (Liberator)": "/static/static/icons/sc2/terran-cloak-color.png", + "Laser Targeting System (Liberator)": "/static/static/icons/sc2/lasertargetingsystem.png", + "Optimized Logistics (Liberator)": "/static/static/icons/sc2/optimizedlogistics.png", + "Enhanced Cluster Launchers (Valkyrie)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png", + "Shaped Hull (Valkyrie)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png", + "Burst Lasers (Valkyrie)": "/static/static/icons/sc2/improvedburstlaser.png", + "Afterburners (Valkyrie)": "/static/static/icons/sc2/medivacemergencythrusters.png", "War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg", "Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg", @@ -1103,14 +1204,15 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png", "Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png", - "Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png", - "Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png", + "Shrike Turret (Bunker)": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png", + "Fortified Bunker (Bunker)": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png", "Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png", "Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png", "Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png", "Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png", "Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png", - "Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png", + "Regenerative Bio-Steel Level 1": "/static/static/icons/sc2/SC2_Lab_BioSteel_L1.png", + "Regenerative Bio-Steel Level 2": "/static/static/icons/sc2/SC2_Lab_BioSteel_L2.png", "Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png", "Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png", @@ -1126,40 +1228,71 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Nothing": "", } - sc2wol_location_ids = { - "Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106], - "The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201], - "Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303], - "Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403], - "Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502], - "Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603], - "Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703], - "Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804], - "The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903], - "The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008], - "Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104], - "Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205], - "Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302], - "Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403], - "Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502], - "Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605], - "The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703], - "Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804], - "Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905], - "Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004], - "Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105], - "Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203], - "A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303], - "Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402], - "In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502], - "Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601], - "Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703], - "Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805], + "Liberation Day": range(SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 200), + "The Outlaws": range(SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 300), + "Zero Hour": range(SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 400), + "Evacuation": range(SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 500), + "Outbreak": range(SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 600), + "Safe Haven": range(SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 700), + "Haven's Fall": range(SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 800), + "Smash and Grab": range(SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 900), + "The Dig": range(SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 1000), + "The Moebius Factor": range(SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1100), + "Supernova": range(SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1200), + "Maw of the Void": range(SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1300), + "Devil's Playground": range(SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1400), + "Welcome to the Jungle": range(SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1500), + "Breakout": range(SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1600), + "Ghost of a Chance": range(SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1700), + "The Great Train Robbery": range(SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1800), + "Cutthroat": range(SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1900), + "Engine of Destruction": range(SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 2000), + "Media Blitz": range(SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2100), + "Piercing the Shroud": range(SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2200), + "Whispers of Doom": range(SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2300), + "A Sinister Turn": range(SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2400), + "Echoes of the Future": range(SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2500), + "In Utter Darkness": range(SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2600), + "Gates of Hell": range(SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2700), + "Belly of the Beast": range(SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2800), + "Shatter the Sky": range(SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2900), } display_data = {} + # Grouped Items + grouped_item_ids = { + "Progressive Weapon Upgrade": 107 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Armor Upgrade": 108 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Infantry Upgrade": 109 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Upgrade": 110 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Upgrade": 111 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Weapon/Armor Upgrade": 112 + SC2WOL_ITEM_ID_OFFSET + } + grouped_item_replacements = { + "Progressive Weapon Upgrade": ["Progressive Infantry Weapon", "Progressive Vehicle Weapon", "Progressive Ship Weapon"], + "Progressive Armor Upgrade": ["Progressive Infantry Armor", "Progressive Vehicle Armor", "Progressive Ship Armor"], + "Progressive Infantry Upgrade": ["Progressive Infantry Weapon", "Progressive Infantry Armor"], + "Progressive Vehicle Upgrade": ["Progressive Vehicle Weapon", "Progressive Vehicle Armor"], + "Progressive Ship Upgrade": ["Progressive Ship Weapon", "Progressive Ship Armor"] + } + grouped_item_replacements["Progressive Weapon/Armor Upgrade"] = grouped_item_replacements["Progressive Weapon Upgrade"] + grouped_item_replacements["Progressive Armor Upgrade"] + replacement_item_ids = { + "Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET, + } + for grouped_item_name, grouped_item_id in grouped_item_ids.items(): + count: int = inventory[grouped_item_id] + if count > 0: + for replacement_item in grouped_item_replacements[grouped_item_name]: + replacement_id: int = replacement_item_ids[replacement_item] + inventory[replacement_id] = count + # Determine display for progressive items progressive_items = { "Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET, @@ -1167,7 +1300,15 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET, "Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET, "Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET, - "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET + "Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Marine)": 208 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Firebat)": 226 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Marauder)": 228 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Reaper)": 250 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Stimpack (Hellion)": 259 + SC2WOL_ITEM_ID_OFFSET, + "Progressive High Impact Payload (Thor)": 361 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Cross-Spectrum Dampeners (Banshee)": 316 + SC2WOL_ITEM_ID_OFFSET, + "Progressive Regenerative Bio-Steel": 617 + SC2WOL_ITEM_ID_OFFSET } progressive_names = { "Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"], @@ -1175,14 +1316,27 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict "Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"], "Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"], "Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"], - "Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"] + "Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"], + "Progressive Stimpack (Marine)": ["Stimpack (Marine)", "Stimpack (Marine)", "Super Stimpack (Marine)"], + "Progressive Stimpack (Firebat)": ["Stimpack (Firebat)", "Stimpack (Firebat)", "Super Stimpack (Firebat)"], + "Progressive Stimpack (Marauder)": ["Stimpack (Marauder)", "Stimpack (Marauder)", "Super Stimpack (Marauder)"], + "Progressive Stimpack (Reaper)": ["Stimpack (Reaper)", "Stimpack (Reaper)", "Super Stimpack (Reaper)"], + "Progressive Stimpack (Hellion)": ["Stimpack (Hellion)", "Stimpack (Hellion)", "Super Stimpack (Hellion)"], + "Progressive High Impact Payload (Thor)": ["High Impact Payload (Thor)", "High Impact Payload (Thor)", "Smart Servos (Thor)"], + "Progressive Cross-Spectrum Dampeners (Banshee)": ["Cross-Spectrum Dampeners (Banshee)", "Cross-Spectrum Dampeners (Banshee)", "Advanced Cross-Spectrum Dampeners (Banshee)"], + "Progressive Regenerative Bio-Steel": ["Regenerative Bio-Steel Level 1", "Regenerative Bio-Steel Level 1", "Regenerative Bio-Steel Level 2"] } for item_name, item_id in progressive_items.items(): level = min(inventory[item_id], len(progressive_names[item_name]) - 1) display_name = progressive_names[item_name][level] - base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_') + base_name = (item_name.split(maxsplit=1)[1].lower() + .replace(' ', '_') + .replace("-", "") + .replace("(", "") + .replace(")", "")) display_data[base_name + "_level"] = level display_data[base_name + "_url"] = icons[display_name] + display_data[base_name + "_name"] = display_name # Multi-items multi_items = { @@ -1214,12 +1368,12 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict checks_in_area['Total'] = sum(checks_in_area.values()) return render_template("sc2wolTracker.html", - inventory=inventory, icons=icons, - acquired_items={lookup_any_item_id_to_name[id] for id in inventory if - id in lookup_any_item_id_to_name}, - player=player, team=team, room=room, player_name=playerName, - checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, - **display_data) + inventory=inventory, icons=icons, + acquired_items={lookup_any_item_id_to_name[id] for id in inventory if + id in lookup_any_item_id_to_name}, + player=player, team=team, room=room, player_name=playerName, + checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info, + **display_data) def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]], inventory: Counter, team: int, player: int, playerName: str, @@ -1360,6 +1514,10 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s for playernumber in range(1, len(team) + 1) if playernumber not in groups} for teamnumber, team in enumerate(names)} + total_locations = {teamnumber: sum(len(locations[playernumber]) + for playernumber in range(1, len(team) + 1) if playernumber not in groups) + for teamnumber, team in enumerate(names)} + hints = {team: set() for team in range(len(names))} if room.multisave: multisave = restricted_loads(room.multisave) @@ -1373,10 +1531,10 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s if player in groups: continue player_locations = locations[player] - checks_done[team][player]["Total"] = sum(1 for loc in locations_checked if loc in player_locations) + checks_done[team][player]["Total"] = len(locations_checked) percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / - checks_in_area[player]["Total"] * 100) \ - if checks_in_area[player]["Total"] else 100 + len(player_locations) * 100) \ + if player_locations else 100 activity_timers = {} now = datetime.datetime.utcnow() @@ -1384,11 +1542,14 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp) player_names = {} + completed_worlds = 0 states: typing.Dict[typing.Tuple[int, int], int] = {} for team, names in enumerate(names): for player, name in enumerate(names, 1): player_names[team, player] = name states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0) + if states[team, player] == ClientStatus.CLIENT_GOAL and player not in groups: + completed_worlds += 1 long_player_names = player_names.copy() for (team, player), alias in multisave.get("name_aliases", {}).items(): player_names[team, player] = alias @@ -1398,17 +1559,24 @@ def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[s for (team, player), data in multisave.get("video", []): video[team, player] = data - return dict(player_names=player_names, room=room, checks_done=checks_done, - percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area, - activity_timers=activity_timers, video=video, hints=hints, - long_player_names=long_player_names, - multisave=multisave, precollected_items=precollected_items, groups=groups, - locations=locations, games=games, states=states) + return dict( + player_names=player_names, room=room, checks_done=checks_done, + percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area, + activity_timers=activity_timers, video=video, hints=hints, + long_player_names=long_player_names, + multisave=multisave, precollected_items=precollected_items, groups=groups, + locations=locations, total_locations=total_locations, games=games, states=states, + completed_worlds=completed_worlds, + custom_locations=custom_locations, custom_items=custom_items, + ) -def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]: - inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data} - for teamnumber, team_data in data["checks_done"].items()} +def _get_inventory_data(data: typing.Dict[str, typing.Any]) \ + -> typing.Dict[int, typing.Dict[int, typing.Dict[int, int]]]: + inventory: typing.Dict[int, typing.Dict[int, typing.Dict[int, int]]] = { + teamnumber: {playernumber: collections.Counter() for playernumber in team_data} + for teamnumber, team_data in data["checks_done"].items() + } groups = data["groups"] @@ -1427,6 +1595,17 @@ def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, return inventory +def _get_named_inventory(inventory: typing.Dict[int, int], custom_items: typing.Dict[int, str] = None) \ + -> typing.Dict[str, int]: + """slow""" + if custom_items: + mapping = collections.ChainMap(custom_items, lookup_any_item_id_to_name) + else: + mapping = lookup_any_item_id_to_name + + return collections.Counter({mapping.get(item_id, None): count for item_id, count in inventory.items()}) + + @app.route('/tracker/') @cache.memoize(timeout=60) # multisave is currently created at most every minute def get_multiworld_tracker(tracker: UUID): @@ -1438,18 +1617,22 @@ def get_multiworld_tracker(tracker: UUID): return render_template("multiTracker.html", **data) +if "Factorio" in games: + @app.route('/tracker//Factorio') + @cache.memoize(timeout=60) # multisave is currently created at most every minute + def get_Factorio_multiworld_tracker(tracker: UUID): + data = _get_multiworld_tracker_data(tracker) + if not data: + abort(404) -@app.route('/tracker//Factorio') -@cache.memoize(timeout=60) # multisave is currently created at most every minute -def get_Factorio_multiworld_tracker(tracker: UUID): - data = _get_multiworld_tracker_data(tracker) - if not data: - abort(404) + data["inventory"] = _get_inventory_data(data) + data["named_inventory"] = {team_id : { + player_id: _get_named_inventory(inventory, data["custom_items"]) + for player_id, inventory in team_inventory.items() + } for team_id, team_inventory in data["inventory"].items()} + data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio") - data["inventory"] = _get_inventory_data(data) - data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio") - - return render_template("multiFactorioTracker.html", **data) + return render_template("multiFactorioTracker.html", **data) @app.route('/tracker//A Link to the Past') @@ -1500,7 +1683,7 @@ def get_LttP_multiworld_tracker(tracker: UUID): for item_id in precollected: attribute_item(team, player, item_id) for location in locations_checked: - if location not in player_locations or location not in player_location_to_area[player]: + if location not in player_locations or location not in player_location_to_area.get(player, {}): continue item, recipient, flags = player_locations[location] recipients = groups.get(recipient, [recipient]) @@ -1509,8 +1692,8 @@ def get_LttP_multiworld_tracker(tracker: UUID): checks_done[team][player][player_location_to_area[player][location]] += 1 checks_done[team][player]["Total"] += 1 percent_total_checks_done[team][player] = int( - checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if \ - seed_checks_in_area[player]["Total"] else 100 + checks_done[team][player]["Total"] / len(player_locations) * 100) if \ + player_locations else 100 for (team, player), game_state in multisave.get("client_game_state", {}).items(): if player in groups: @@ -1579,5 +1762,7 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = { multi_trackers: typing.Dict[str, typing.Callable] = { "A Link to the Past": get_LttP_multiworld_tracker, - "Factorio": get_Factorio_multiworld_tracker, } + +if "Factorio" in games: + multi_trackers["Factorio"] = get_Factorio_multiworld_tracker diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 0314d64ab1..e7ac033913 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -7,12 +7,13 @@ import zipfile import zlib from io import BytesIO -from flask import request, flash, redirect, url_for, session, render_template, Markup +from flask import request, flash, redirect, url_for, session, render_template +from markupsafe import Markup from pony.orm import commit, flush, select, rollback from pony.orm.core import TransactionIntegrityError import MultiServer -from NetUtils import NetworkSlot, SlotType +from NetUtils import SlotType from Utils import VersionException, __version__ from worlds.Files import AutoPatchRegister from . import app @@ -20,6 +21,41 @@ from .models import Seed, Room, Slot, GameDataPackage banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb") +def process_multidata(compressed_multidata, files={}): + decompressed_multidata = MultiServer.Context.decompress(compressed_multidata) + + slots: typing.Set[Slot] = set() + if "datapackage" in decompressed_multidata: + # strip datapackage from multidata, leaving only the checksums + game_data_packages: typing.List[GameDataPackage] = [] + for game, game_data in decompressed_multidata["datapackage"].items(): + if game_data.get("checksum"): + game_data_package = GameDataPackage(checksum=game_data["checksum"], + data=pickle.dumps(game_data)) + decompressed_multidata["datapackage"][game] = { + "version": game_data.get("version", 0), + "checksum": game_data["checksum"] + } + try: + commit() # commit game data package + game_data_packages.append(game_data_package) + except TransactionIntegrityError: + del game_data_package + rollback() + + if "slot_info" in decompressed_multidata: + for slot, slot_info in decompressed_multidata["slot_info"].items(): + # Ignore Player Groups (e.g. item links) + if slot_info.type == SlotType.group: + continue + slots.add(Slot(data=files.get(slot, None), + player_name=slot_info.name, + player_id=slot, + game=slot_info.game)) + flush() # commit slots + + compressed_multidata = compressed_multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9) + return slots, compressed_multidata def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None): if not owner: @@ -29,7 +65,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s flash(Markup("Error: Your .zip file only contains .yaml files. " 'Did you mean to generate a game?')) return - slots: typing.Set[Slot] = set() + spoiler = "" files = {} multidata = None @@ -68,54 +104,27 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s # Factorio elif file.filename.endswith(".zip"): - _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3) + try: + _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3) + except ValueError: + flash("Error: Unexpected file found in .zip: " + file.filename) + return data = zfile.open(file, "r").read() files[int(slot_id[1:])] = data # All other files using the standard MultiWorld.get_out_file_name_base method else: - _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3) + try: + _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3) + except ValueError: + flash("Error: Unexpected file found in .zip: " + file.filename) + return data = zfile.open(file, "r").read() files[int(slot_id[1:])] = data # Load multi data. if multidata: - decompressed_multidata = MultiServer.Context.decompress(multidata) - recompress = False - - if "datapackage" in decompressed_multidata: - # strip datapackage from multidata, leaving only the checksums - game_data_packages: typing.List[GameDataPackage] = [] - for game, game_data in decompressed_multidata["datapackage"].items(): - if game_data.get("checksum"): - game_data_package = GameDataPackage(checksum=game_data["checksum"], - data=pickle.dumps(game_data)) - decompressed_multidata["datapackage"][game] = { - "version": game_data.get("version", 0), - "checksum": game_data["checksum"] - } - recompress = True - try: - commit() # commit game data package - game_data_packages.append(game_data_package) - except TransactionIntegrityError: - del game_data_package - rollback() - - if "slot_info" in decompressed_multidata: - for slot, slot_info in decompressed_multidata["slot_info"].items(): - # Ignore Player Groups (e.g. item links) - if slot_info.type == SlotType.group: - continue - slots.add(Slot(data=files.get(slot, None), - player_name=slot_info.name, - player_id=slot, - game=slot_info.game)) - - flush() # commit slots - - if recompress: - multidata = multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9) + slots, multidata = process_multidata(multidata, files) seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta), id=sid if sid else uuid.uuid4()) @@ -156,11 +165,11 @@ def uploads(): # noinspection PyBroadException try: multidata = file.read() - MultiServer.Context.decompress(multidata) + slots, multidata = process_multidata(multidata) except Exception as e: flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})") else: - seed = Seed(multidata=multidata, owner=session["_id"]) + seed = Seed(multidata=multidata, slots=slots, owner=session["_id"]) flush() # place into DB and generate ids return redirect(url_for("view_seed", seed=seed.id)) else: diff --git a/Zelda1Client.py b/Zelda1Client.py index a325e4aebe..db3d3519aa 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -23,9 +23,9 @@ from worlds.tloz import Items, Locations, Rom SYSTEM_MESSAGE_ID = 0 -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua" -CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua" +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_tloz.lua" +CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_tloz.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_tloz.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -46,7 +46,7 @@ class ZeldaCommandProcessor(ClientCommandProcessor): logger.info(f"NES Status: {self.ctx.nes_status}") def _cmd_toggle_msgs(self): - """Toggle displaying messages in bizhawk""" + """Toggle displaying messages in EmuHawk""" global DISPLAY_MSGS DISPLAY_MSGS = not DISPLAY_MSGS logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}") diff --git a/ZillionClient.py b/ZillionClient.py index 92585d3168..7d32a72261 100644 --- a/ZillionClient.py +++ b/ZillionClient.py @@ -423,9 +423,9 @@ async def zillion_sync_task(ctx: ZillionContext) -> None: async_start(ctx.send_connect()) log_no_spam("logging in to server...") await asyncio.wait(( - ctx.got_slot_data.wait(), - ctx.exit_event.wait(), - asyncio.sleep(6) + asyncio.create_task(ctx.got_slot_data.wait()), + asyncio.create_task(ctx.exit_event.wait()), + asyncio.create_task(asyncio.sleep(6)) ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets else: # not correct seed name log_no_spam("incorrect seed - did you mix up roms?") @@ -447,9 +447,9 @@ async def zillion_sync_task(ctx: ZillionContext) -> None: ctx.known_name = name async_start(ctx.connect()) await asyncio.wait(( - ctx.got_room_info.wait(), - ctx.exit_event.wait(), - asyncio.sleep(6) + asyncio.create_task(ctx.got_room_info.wait()), + asyncio.create_task(ctx.exit_event.wait()), + asyncio.create_task(asyncio.sleep(6)) ), return_when=asyncio.FIRST_COMPLETED) else: # no name found in game if not help_message_shown: diff --git a/_speedups.pyx b/_speedups.pyx new file mode 100644 index 0000000000..9bf25cce29 --- /dev/null +++ b/_speedups.pyx @@ -0,0 +1,347 @@ +#cython: language_level=3 +#distutils: language = c++ + +""" +Provides faster implementation of some core parts. +This is deliberately .pyx because using a non-compiled "pure python" may be slower. +""" + +# pip install cython cymem +import cython +import warnings +from cpython cimport PyObject +from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING +from cymem.cymem cimport Pool +from libc.stdint cimport int64_t, uint32_t +from libcpp.set cimport set as std_set +from collections import defaultdict + +cdef extern from *: + """ + // avoid warning from cython-generated code with MSVC + pyximport + #ifdef _MSC_VER + #pragma warning( disable: 4551 ) + #endif + """ + +ctypedef uint32_t ap_player_t # on AMD64 this is faster (and smaller) than 64bit ints +ctypedef uint32_t ap_flags_t +ctypedef int64_t ap_id_t + +cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array +cdef size_t INVALID_SIZE = (-1) # this is all 0xff... adding 1 results in 0, but it's not negative + + +cdef struct LocationEntry: + # layout is so that + # 64bit player: location+sender and item+receiver 128bit comparisons, if supported + # 32bit player: aligned to 32/64bit with no unused space + ap_id_t location + ap_player_t sender + ap_player_t receiver + ap_id_t item + ap_flags_t flags + + +cdef struct IndexEntry: + size_t start + size_t count + + +cdef class LocationStore: + """Compact store for locations and their items in a MultiServer""" + # The original implementation uses Dict[int, Dict[int, Tuple(int, int, int]] + # with sender, location, (item, receiver, flags). + # This implementation is a flat list of (sender, location, item, receiver, flags) using native integers + # as well as some mapping arrays used to speed up stuff, saving a lot of memory while speeding up hints. + # Using std::map might be worth investigating, but memory overhead would be ~100% compared to arrays. + + cdef Pool _mem + cdef object _len + cdef LocationEntry* entries # 3.2MB/100k items + cdef size_t entry_count + cdef IndexEntry* sender_index # 16KB/1000 players + cdef size_t sender_index_size + cdef list _keys # ~36KB/1000 players, speed up iter (28 per int + 8 per list entry) + cdef list _items # ~64KB/1000 players, speed up items (56 per tuple + 8 per list entry) + cdef list _proxies # ~92KB/1000 players, speed up self[player] (56 per struct + 28 per len + 8 per list entry) + cdef PyObject** _raw_proxies # 8K/1000 players, faster access to _proxies, but does not keep a ref + + def get_size(self): + from sys import getsizeof + size = getsizeof(self) + getsizeof(self._mem) + getsizeof(self._len) \ + + sizeof(LocationEntry) * self.entry_count + sizeof(IndexEntry) * self.sender_index_size + size += getsizeof(self._keys) + getsizeof(self._items) + getsizeof(self._proxies) + size += sum(sizeof(key) for key in self._keys) + size += sum(sizeof(item) for item in self._items) + size += sum(sizeof(proxy) for proxy in self._proxies) + size += sizeof(self._raw_proxies[0]) * self.sender_index_size + return size + + def __cinit__(self, locations_dict: Dict[int, Dict[int, Sequence[int]]]) -> None: + self._mem = None + self._keys = None + self._items = None + self._proxies = None + self._len = 0 + self.entries = NULL + self.entry_count = 0 + self.sender_index = NULL + self.sender_index_size = 0 + self._raw_proxies = NULL + + def __init__(self, locations_dict: Dict[int, Dict[int, Sequence[int]]]) -> None: + self._mem = Pool() + cdef object key + self._keys = [] + self._items = [] + self._proxies = [] + + # iterate over everything to get all maxima and validate everything + cdef size_t max_sender = INVALID_SIZE # keep track of highest used player id for indexing + cdef size_t sender_count = 0 + cdef size_t count = 0 + for sender, locations in locations_dict.items(): + # we don't require the dict to be sorted here + if not isinstance(sender, int) or sender < 1 or sender > MAX_PLAYER_ID: + raise ValueError(f"Invalid player id {sender} for location") + if max_sender == INVALID_SIZE: + max_sender = sender + else: + max_sender = max(max_sender, sender) + for location, data in locations.items(): + receiver = data[1] + if receiver < 1 or receiver > MAX_PLAYER_ID: + raise ValueError(f"Invalid player id {receiver} for item") + count += 1 + sender_count += 1 + + if not sender_count: + raise ValueError(f"Rejecting game with 0 players") + + if sender_count != max_sender: + # we assume player 0 will never have locations + raise ValueError("Player IDs not continuous") + + if not count: + warnings.warn("Game has no locations") + + # allocate the arrays and invalidate index (0xff...) + self.entries = self._mem.alloc(count, sizeof(LocationEntry)) + self.sender_index = self._mem.alloc(max_sender + 1, sizeof(IndexEntry)) + self._raw_proxies = self._mem.alloc(max_sender + 1, sizeof(PyObject*)) + + # build entries and index + cdef size_t i = 0 + for sender, locations in sorted(locations_dict.items()): + self.sender_index[sender].start = i + self.sender_index[sender].count = 0 + # Sorting locations here makes it possible to write a faster lookup without an additional index. + for location, data in sorted(locations.items()): + self.entries[i].sender = sender + self.entries[i].location = location + self.entries[i].item = data[0] + self.entries[i].receiver = data[1] + if len(data) > 2: + self.entries[i].flags = data[2] # initialized to 0 during alloc + # Ignoring extra data. warn? + self.sender_index[sender].count += 1 + i += 1 + + # build pyobject caches + self._proxies.append(None) # player 0 + assert self.sender_index[0].count == 0 + for i in range(1, max_sender + 1): + assert self.sender_index[i].count == 0 or ( + self.sender_index[i].start < count and + self.sender_index[i].start + self.sender_index[i].count <= count) + key = i # allocate python integer + proxy = PlayerLocationProxy(self, i) + self._keys.append(key) + self._items.append((key, proxy)) + self._proxies.append(proxy) + self._raw_proxies[i] = proxy + + self.sender_index_size = max_sender + 1 + self.entry_count = count + self._len = sender_count + + # fake dict access + def __len__(self) -> int: + return self._len + + def __iter__(self) -> Iterator[int]: + return self._keys.__iter__() + + def __getitem__(self, key: int) -> Any: + # figure out if player actually exists in the multidata and return a proxy + cdef size_t i = key # NOTE: this may raise TypeError + if i < 1 or i >= self.sender_index_size: + raise KeyError(key) + return self._raw_proxies[key] + + T = TypeVar('T') + + def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]: + # calling into self.__getitem__ here is slow, but this is not used in MultiServer + try: + return self[key] + except KeyError: + return default + + def items(self) -> Iterable[Tuple[int, PlayerLocationProxy]]: + return self._items + + # specialized accessors + def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]: + cdef ap_id_t item = seeked_item_id + cdef ap_player_t receiver + cdef std_set[ap_player_t] receivers + cdef size_t slot_count = len(slots) + if slot_count == 1: + # specialized implementation for single slot + receiver = list(slots)[0] + with nogil: + for entry in self.entries[:self.entry_count]: + if entry.item == item and entry.receiver == receiver: + with gil: + yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags + elif slot_count: + # generic implementation with lookup in set + for receiver in slots: + receivers.insert(receiver) + with nogil: + for entry in self.entries[:self.entry_count]: + if entry.item == item and receivers.count(entry.receiver): + with gil: + yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags + + def get_for_player(self, slot: int) -> Dict[int, Set[int]]: + cdef ap_player_t receiver = slot + all_locations: Dict[int, Set[int]] = {} + with nogil: + for entry in self.entries[:self.entry_count]: + if entry.receiver == receiver: + with gil: + sender: int = entry.sender + if sender not in all_locations: + all_locations[sender] = set() + all_locations[sender].add(entry.location) + return all_locations + + if TYPE_CHECKING: + State = Dict[Tuple[int, int], Set[int]] + else: + State = Union[Tuple[int, int], Set[int], defaultdict] + + def get_checked(self, state: State, team: int, slot: int) -> List[int]: + # This used to validate checks actually exist. A remnant from the past. + # If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it. + cdef set checked = state[team, slot] + + if not len(checked): + # Skips loop if none have been checked. + # This optimizes the case where everyone connects to a fresh game at the same time. + return [] + + # Unless the set is close to empty, it's cheaper to use the python set directly, so we do that. + cdef LocationEntry* entry + cdef ap_player_t sender = slot + cdef size_t start = self.sender_index[sender].start + cdef size_t count = self.sender_index[sender].count + return [entry.location for + entry in self.entries[start:start+count] if + entry.location in checked] + + def get_missing(self, state: State, team: int, slot: int) -> List[int]: + cdef LocationEntry* entry + cdef ap_player_t sender = slot + cdef size_t start = self.sender_index[sender].start + cdef size_t count = self.sender_index[sender].count + cdef set checked = state[team, slot] + if not len(checked): + # Skip `in` if none have been checked. + # This optimizes the case where everyone connects to a fresh game at the same time. + return [entry.location for + entry in self.entries[start:start + count]] + else: + # Unless the set is close to empty, it's cheaper to use the python set directly, so we do that. + return [entry.location for + entry in self.entries[start:start + count] if + entry.location not in checked] + + def get_remaining(self, state: State, team: int, slot: int) -> List[int]: + cdef LocationEntry* entry + cdef ap_player_t sender = slot + cdef size_t start = self.sender_index[sender].start + cdef size_t count = self.sender_index[sender].count + cdef set checked = state[team, slot] + return sorted([entry.item for + entry in self.entries[start:start+count] if + entry.location not in checked]) + + +@cython.internal # unsafe. disable direct import +cdef class PlayerLocationProxy: + cdef LocationStore _store + cdef size_t _player + cdef object _len + + def __init__(self, store: LocationStore, player: int) -> None: + self._store = store + self._player = player + self._len = self._store.sender_index[self._player].count + + def __len__(self) -> int: + return self._store.sender_index[self._player].count + + def __iter__(self) -> Generator[int, None, None]: + cdef LocationEntry* entry + cdef size_t i + cdef size_t off = self._store.sender_index[self._player].start + for i in range(self._store.sender_index[self._player].count): + entry = self._store.entries + off + i + yield entry.location + + cdef LocationEntry* _get(self, ap_id_t loc): + # This requires locations to be sorted. + # This is always going to be slower than a pure python dict, because constructing the result tuple takes as long + # as the search in a python dict, which stores a pointer to an existing tuple. + cdef LocationEntry* entry = NULL + # binary search + cdef size_t l = self._store.sender_index[self._player].start + cdef size_t r = l + self._store.sender_index[self._player].count + cdef size_t m + while l < r: + m = (l + r) // 2 + entry = self._store.entries + m + if entry.location < loc: + l = m + 1 + else: + r = m + if entry: # count != 0 + entry = self._store.entries + l + if entry.location == loc: + return entry + return NULL + + def __getitem__(self, key: int) -> Tuple[int, int, int]: + cdef LocationEntry* entry = self._get(key) + if entry: + return entry.item, entry.receiver, entry.flags + raise KeyError(f"No location {key} for player {self._player}") + + T = TypeVar('T') + + def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]: + cdef LocationEntry* entry = self._get(key) + if entry: + return entry.item, entry.receiver, entry.flags + return default + + def items(self) -> Generator[Tuple[int, Tuple[int, int, int]], None, None]: + cdef LocationEntry* entry + start = self._store.sender_index[self._player].start + count = self._store.sender_index[self._player].count + for entry in self._store.entries[start:start+count]: + yield entry.location, (entry.item, entry.receiver, entry.flags) diff --git a/_speedups.pyxbld b/_speedups.pyxbld new file mode 100644 index 0000000000..e1fe19b2ef --- /dev/null +++ b/_speedups.pyxbld @@ -0,0 +1,8 @@ +# This file is required to get pyximport to work with C++. +# Switching from std::set to a pure C implementation is still on the table to simplify everything. + +def make_ext(modname, pyxfilename): + from distutils.extension import Extension + return Extension(name=modname, + sources=[pyxfilename], + language='c++') diff --git a/data/discord-mark-blue.png b/data/discord-mark-blue.png new file mode 100644 index 0000000000..e9dc50d7fe Binary files /dev/null and b/data/discord-mark-blue.png differ diff --git a/data/icon.png b/data/icon.png index 4fd9334dff..1d66043473 100644 Binary files a/data/icon.png and b/data/icon.png differ diff --git a/data/lua/ADVENTURE/socket.lua b/data/lua/ADVENTURE/socket.lua deleted file mode 100644 index a98e952115..0000000000 --- a/data/lua/ADVENTURE/socket.lua +++ /dev/null @@ -1,132 +0,0 @@ ------------------------------------------------------------------------------ --- LuaSocket helper module --- Author: Diego Nehab --- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ ------------------------------------------------------------------------------ - ------------------------------------------------------------------------------ --- Declare module and import dependencies ------------------------------------------------------------------------------ -local base = _G -local string = require("string") -local math = require("math") -local socket = require("socket.core") -module("socket") - ------------------------------------------------------------------------------ --- Exported auxiliar functions ------------------------------------------------------------------------------ -function connect(address, port, laddress, lport) - local sock, err = socket.tcp() - if not sock then return nil, err end - if laddress then - local res, err = sock:bind(laddress, lport, -1) - if not res then return nil, err end - end - local res, err = sock:connect(address, port) - if not res then return nil, err end - return sock -end - -function bind(host, port, backlog) - local sock, err = socket.tcp() - if not sock then return nil, err end - sock:setoption("reuseaddr", true) - local res, err = sock:bind(host, port) - if not res then return nil, err end - res, err = sock:listen(backlog) - if not res then return nil, err end - return sock -end - -try = newtry() - -function choose(table) - return function(name, opt1, opt2) - if base.type(name) ~= "string" then - name, opt1, opt2 = "default", name, opt1 - end - local f = table[name or "nil"] - if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) - else return f(opt1, opt2) end - end -end - ------------------------------------------------------------------------------ --- Socket sources and sinks, conforming to LTN12 ------------------------------------------------------------------------------ --- create namespaces inside LuaSocket namespace -sourcet = {} -sinkt = {} - -BLOCKSIZE = 2048 - -sinkt["close-when-done"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if not chunk then - sock:close() - return 1 - else return sock:send(chunk) end - end - }) -end - -sinkt["keep-open"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if chunk then return sock:send(chunk) - else return 1 end - end - }) -end - -sinkt["default"] = sinkt["keep-open"] - -sink = choose(sinkt) - -sourcet["by-length"] = function(sock, length) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if length <= 0 then return nil end - local size = math.min(socket.BLOCKSIZE, length) - local chunk, err = sock:receive(size) - if err then return nil, err end - length = length - string.len(chunk) - return chunk - end - }) -end - -sourcet["until-closed"] = function(sock) - local done - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if done then return nil end - local chunk, err, partial = sock:receive(socket.BLOCKSIZE) - if not err then return chunk - elseif err == "closed" then - sock:close() - done = 1 - return partial - else return nil, err end - end - }) -end - - -sourcet["default"] = sourcet["until-closed"] - -source = choose(sourcet) diff --git a/data/lua/FF1/core.dll b/data/lua/FF1/core.dll deleted file mode 100644 index 3e9569571a..0000000000 Binary files a/data/lua/FF1/core.dll and /dev/null differ diff --git a/data/lua/FF1/json.lua b/data/lua/FF1/json.lua deleted file mode 100644 index 0833bf6fb4..0000000000 --- a/data/lua/FF1/json.lua +++ /dev/null @@ -1,380 +0,0 @@ --- --- json.lua --- --- Copyright (c) 2015 rxi --- --- This library is free software; you can redistribute it and/or modify it --- under the terms of the MIT license. See LICENSE for details. --- - -local json = { _version = "0.1.0" } - -------------------------------------------------------------------------------- --- Encode -------------------------------------------------------------------------------- - -local encode - -local escape_char_map = { - [ "\\" ] = "\\\\", - [ "\"" ] = "\\\"", - [ "\b" ] = "\\b", - [ "\f" ] = "\\f", - [ "\n" ] = "\\n", - [ "\r" ] = "\\r", - [ "\t" ] = "\\t", -} - -local escape_char_map_inv = { [ "\\/" ] = "/" } -for k, v in pairs(escape_char_map) do - escape_char_map_inv[v] = k -end - - -local function escape_char(c) - return escape_char_map[c] or string.format("\\u%04x", c:byte()) -end - - -local function encode_nil(val) - return "null" -end - - -local function encode_table(val, stack) - local res = {} - stack = stack or {} - - -- Circular reference? - if stack[val] then error("circular reference") end - - stack[val] = true - - if val[1] ~= nil or next(val) == nil then - -- Treat as array -- check keys are valid and it is not sparse - local n = 0 - for k in pairs(val) do - if type(k) ~= "number" then - error("invalid table: mixed or invalid key types") - end - n = n + 1 - end - if n ~= #val then - error("invalid table: sparse array") - end - -- Encode - for i, v in ipairs(val) do - table.insert(res, encode(v, stack)) - end - stack[val] = nil - return "[" .. table.concat(res, ",") .. "]" - - else - -- Treat as an object - for k, v in pairs(val) do - if type(k) ~= "string" then - error("invalid table: mixed or invalid key types") - end - table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) - end - stack[val] = nil - return "{" .. table.concat(res, ",") .. "}" - end -end - - -local function encode_string(val) - return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' -end - - -local function encode_number(val) - -- Check for NaN, -inf and inf - if val ~= val or val <= -math.huge or val >= math.huge then - error("unexpected number value '" .. tostring(val) .. "'") - end - return string.format("%.14g", val) -end - - -local type_func_map = { - [ "nil" ] = encode_nil, - [ "table" ] = encode_table, - [ "string" ] = encode_string, - [ "number" ] = encode_number, - [ "boolean" ] = tostring, -} - - -encode = function(val, stack) - local t = type(val) - local f = type_func_map[t] - if f then - return f(val, stack) - end - error("unexpected type '" .. t .. "'") -end - - -function json.encode(val) - return ( encode(val) ) -end - - -------------------------------------------------------------------------------- --- Decode -------------------------------------------------------------------------------- - -local parse - -local function create_set(...) - local res = {} - for i = 1, select("#", ...) do - res[ select(i, ...) ] = true - end - return res -end - -local space_chars = create_set(" ", "\t", "\r", "\n") -local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") -local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") -local literals = create_set("true", "false", "null") - -local literal_map = { - [ "true" ] = true, - [ "false" ] = false, - [ "null" ] = nil, -} - - -local function next_char(str, idx, set, negate) - for i = idx, #str do - if set[str:sub(i, i)] ~= negate then - return i - end - end - return #str + 1 -end - - -local function decode_error(str, idx, msg) - --local line_count = 1 - --local col_count = 1 - --for i = 1, idx - 1 do - -- col_count = col_count + 1 - -- if str:sub(i, i) == "\n" then - -- line_count = line_count + 1 - -- col_count = 1 - -- end - -- end - -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) -end - - -local function codepoint_to_utf8(n) - -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa - local f = math.floor - if n <= 0x7f then - return string.char(n) - elseif n <= 0x7ff then - return string.char(f(n / 64) + 192, n % 64 + 128) - elseif n <= 0xffff then - return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) - elseif n <= 0x10ffff then - return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, - f(n % 4096 / 64) + 128, n % 64 + 128) - end - error( string.format("invalid unicode codepoint '%x'", n) ) -end - - -local function parse_unicode_escape(s) - local n1 = tonumber( s:sub(3, 6), 16 ) - local n2 = tonumber( s:sub(9, 12), 16 ) - -- Surrogate pair? - if n2 then - return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) - else - return codepoint_to_utf8(n1) - end -end - - -local function parse_string(str, i) - local has_unicode_escape = false - local has_surrogate_escape = false - local has_escape = false - local last - for j = i + 1, #str do - local x = str:byte(j) - - if x < 32 then - decode_error(str, j, "control character in string") - end - - if last == 92 then -- "\\" (escape char) - if x == 117 then -- "u" (unicode escape sequence) - local hex = str:sub(j + 1, j + 5) - if not hex:find("%x%x%x%x") then - decode_error(str, j, "invalid unicode escape in string") - end - if hex:find("^[dD][89aAbB]") then - has_surrogate_escape = true - else - has_unicode_escape = true - end - else - local c = string.char(x) - if not escape_chars[c] then - decode_error(str, j, "invalid escape char '" .. c .. "' in string") - end - has_escape = true - end - last = nil - - elseif x == 34 then -- '"' (end of string) - local s = str:sub(i + 1, j - 1) - if has_surrogate_escape then - s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) - end - if has_unicode_escape then - s = s:gsub("\\u....", parse_unicode_escape) - end - if has_escape then - s = s:gsub("\\.", escape_char_map_inv) - end - return s, j + 1 - - else - last = x - end - end - decode_error(str, i, "expected closing quote for string") -end - - -local function parse_number(str, i) - local x = next_char(str, i, delim_chars) - local s = str:sub(i, x - 1) - local n = tonumber(s) - if not n then - decode_error(str, i, "invalid number '" .. s .. "'") - end - return n, x -end - - -local function parse_literal(str, i) - local x = next_char(str, i, delim_chars) - local word = str:sub(i, x - 1) - if not literals[word] then - decode_error(str, i, "invalid literal '" .. word .. "'") - end - return literal_map[word], x -end - - -local function parse_array(str, i) - local res = {} - local n = 1 - i = i + 1 - while 1 do - local x - i = next_char(str, i, space_chars, true) - -- Empty / end of array? - if str:sub(i, i) == "]" then - i = i + 1 - break - end - -- Read token - x, i = parse(str, i) - res[n] = x - n = n + 1 - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "]" then break end - if chr ~= "," then decode_error(str, i, "expected ']' or ','") end - end - return res, i -end - - -local function parse_object(str, i) - local res = {} - i = i + 1 - while 1 do - local key, val - i = next_char(str, i, space_chars, true) - -- Empty / end of object? - if str:sub(i, i) == "}" then - i = i + 1 - break - end - -- Read key - if str:sub(i, i) ~= '"' then - decode_error(str, i, "expected string for key") - end - key, i = parse(str, i) - -- Read ':' delimiter - i = next_char(str, i, space_chars, true) - if str:sub(i, i) ~= ":" then - decode_error(str, i, "expected ':' after key") - end - i = next_char(str, i + 1, space_chars, true) - -- Read value - val, i = parse(str, i) - -- Set - res[key] = val - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "}" then break end - if chr ~= "," then decode_error(str, i, "expected '}' or ','") end - end - return res, i -end - - -local char_func_map = { - [ '"' ] = parse_string, - [ "0" ] = parse_number, - [ "1" ] = parse_number, - [ "2" ] = parse_number, - [ "3" ] = parse_number, - [ "4" ] = parse_number, - [ "5" ] = parse_number, - [ "6" ] = parse_number, - [ "7" ] = parse_number, - [ "8" ] = parse_number, - [ "9" ] = parse_number, - [ "-" ] = parse_number, - [ "t" ] = parse_literal, - [ "f" ] = parse_literal, - [ "n" ] = parse_literal, - [ "[" ] = parse_array, - [ "{" ] = parse_object, -} - - -parse = function(str, idx) - local chr = str:sub(idx, idx) - local f = char_func_map[chr] - if f then - return f(str, idx) - end - decode_error(str, idx, "unexpected character '" .. chr .. "'") -end - - -function json.decode(str) - if type(str) ~= "string" then - error("expected argument of type string, got " .. type(str)) - end - return ( parse(str, next_char(str, 1, space_chars, true)) ) -end - - -return json \ No newline at end of file diff --git a/data/lua/FF1/socket.lua b/data/lua/FF1/socket.lua deleted file mode 100644 index a98e952115..0000000000 --- a/data/lua/FF1/socket.lua +++ /dev/null @@ -1,132 +0,0 @@ ------------------------------------------------------------------------------ --- LuaSocket helper module --- Author: Diego Nehab --- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ ------------------------------------------------------------------------------ - ------------------------------------------------------------------------------ --- Declare module and import dependencies ------------------------------------------------------------------------------ -local base = _G -local string = require("string") -local math = require("math") -local socket = require("socket.core") -module("socket") - ------------------------------------------------------------------------------ --- Exported auxiliar functions ------------------------------------------------------------------------------ -function connect(address, port, laddress, lport) - local sock, err = socket.tcp() - if not sock then return nil, err end - if laddress then - local res, err = sock:bind(laddress, lport, -1) - if not res then return nil, err end - end - local res, err = sock:connect(address, port) - if not res then return nil, err end - return sock -end - -function bind(host, port, backlog) - local sock, err = socket.tcp() - if not sock then return nil, err end - sock:setoption("reuseaddr", true) - local res, err = sock:bind(host, port) - if not res then return nil, err end - res, err = sock:listen(backlog) - if not res then return nil, err end - return sock -end - -try = newtry() - -function choose(table) - return function(name, opt1, opt2) - if base.type(name) ~= "string" then - name, opt1, opt2 = "default", name, opt1 - end - local f = table[name or "nil"] - if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) - else return f(opt1, opt2) end - end -end - ------------------------------------------------------------------------------ --- Socket sources and sinks, conforming to LTN12 ------------------------------------------------------------------------------ --- create namespaces inside LuaSocket namespace -sourcet = {} -sinkt = {} - -BLOCKSIZE = 2048 - -sinkt["close-when-done"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if not chunk then - sock:close() - return 1 - else return sock:send(chunk) end - end - }) -end - -sinkt["keep-open"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if chunk then return sock:send(chunk) - else return 1 end - end - }) -end - -sinkt["default"] = sinkt["keep-open"] - -sink = choose(sinkt) - -sourcet["by-length"] = function(sock, length) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if length <= 0 then return nil end - local size = math.min(socket.BLOCKSIZE, length) - local chunk, err = sock:receive(size) - if err then return nil, err end - length = length - string.len(chunk) - return chunk - end - }) -end - -sourcet["until-closed"] = function(sock) - local done - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if done then return nil end - local chunk, err, partial = sock:receive(socket.BLOCKSIZE) - if not err then return chunk - elseif err == "closed" then - sock:close() - done = 1 - return partial - else return nil, err end - end - }) -end - - -sourcet["default"] = sourcet["until-closed"] - -source = choose(sourcet) diff --git a/data/lua/OOT/core.dll b/data/lua/OOT/core.dll deleted file mode 100644 index 3e9569571a..0000000000 Binary files a/data/lua/OOT/core.dll and /dev/null differ diff --git a/data/lua/OOT/json.lua b/data/lua/OOT/json.lua deleted file mode 100644 index 0833bf6fb4..0000000000 --- a/data/lua/OOT/json.lua +++ /dev/null @@ -1,380 +0,0 @@ --- --- json.lua --- --- Copyright (c) 2015 rxi --- --- This library is free software; you can redistribute it and/or modify it --- under the terms of the MIT license. See LICENSE for details. --- - -local json = { _version = "0.1.0" } - -------------------------------------------------------------------------------- --- Encode -------------------------------------------------------------------------------- - -local encode - -local escape_char_map = { - [ "\\" ] = "\\\\", - [ "\"" ] = "\\\"", - [ "\b" ] = "\\b", - [ "\f" ] = "\\f", - [ "\n" ] = "\\n", - [ "\r" ] = "\\r", - [ "\t" ] = "\\t", -} - -local escape_char_map_inv = { [ "\\/" ] = "/" } -for k, v in pairs(escape_char_map) do - escape_char_map_inv[v] = k -end - - -local function escape_char(c) - return escape_char_map[c] or string.format("\\u%04x", c:byte()) -end - - -local function encode_nil(val) - return "null" -end - - -local function encode_table(val, stack) - local res = {} - stack = stack or {} - - -- Circular reference? - if stack[val] then error("circular reference") end - - stack[val] = true - - if val[1] ~= nil or next(val) == nil then - -- Treat as array -- check keys are valid and it is not sparse - local n = 0 - for k in pairs(val) do - if type(k) ~= "number" then - error("invalid table: mixed or invalid key types") - end - n = n + 1 - end - if n ~= #val then - error("invalid table: sparse array") - end - -- Encode - for i, v in ipairs(val) do - table.insert(res, encode(v, stack)) - end - stack[val] = nil - return "[" .. table.concat(res, ",") .. "]" - - else - -- Treat as an object - for k, v in pairs(val) do - if type(k) ~= "string" then - error("invalid table: mixed or invalid key types") - end - table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) - end - stack[val] = nil - return "{" .. table.concat(res, ",") .. "}" - end -end - - -local function encode_string(val) - return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' -end - - -local function encode_number(val) - -- Check for NaN, -inf and inf - if val ~= val or val <= -math.huge or val >= math.huge then - error("unexpected number value '" .. tostring(val) .. "'") - end - return string.format("%.14g", val) -end - - -local type_func_map = { - [ "nil" ] = encode_nil, - [ "table" ] = encode_table, - [ "string" ] = encode_string, - [ "number" ] = encode_number, - [ "boolean" ] = tostring, -} - - -encode = function(val, stack) - local t = type(val) - local f = type_func_map[t] - if f then - return f(val, stack) - end - error("unexpected type '" .. t .. "'") -end - - -function json.encode(val) - return ( encode(val) ) -end - - -------------------------------------------------------------------------------- --- Decode -------------------------------------------------------------------------------- - -local parse - -local function create_set(...) - local res = {} - for i = 1, select("#", ...) do - res[ select(i, ...) ] = true - end - return res -end - -local space_chars = create_set(" ", "\t", "\r", "\n") -local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") -local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") -local literals = create_set("true", "false", "null") - -local literal_map = { - [ "true" ] = true, - [ "false" ] = false, - [ "null" ] = nil, -} - - -local function next_char(str, idx, set, negate) - for i = idx, #str do - if set[str:sub(i, i)] ~= negate then - return i - end - end - return #str + 1 -end - - -local function decode_error(str, idx, msg) - --local line_count = 1 - --local col_count = 1 - --for i = 1, idx - 1 do - -- col_count = col_count + 1 - -- if str:sub(i, i) == "\n" then - -- line_count = line_count + 1 - -- col_count = 1 - -- end - -- end - -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) -end - - -local function codepoint_to_utf8(n) - -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa - local f = math.floor - if n <= 0x7f then - return string.char(n) - elseif n <= 0x7ff then - return string.char(f(n / 64) + 192, n % 64 + 128) - elseif n <= 0xffff then - return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) - elseif n <= 0x10ffff then - return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, - f(n % 4096 / 64) + 128, n % 64 + 128) - end - error( string.format("invalid unicode codepoint '%x'", n) ) -end - - -local function parse_unicode_escape(s) - local n1 = tonumber( s:sub(3, 6), 16 ) - local n2 = tonumber( s:sub(9, 12), 16 ) - -- Surrogate pair? - if n2 then - return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) - else - return codepoint_to_utf8(n1) - end -end - - -local function parse_string(str, i) - local has_unicode_escape = false - local has_surrogate_escape = false - local has_escape = false - local last - for j = i + 1, #str do - local x = str:byte(j) - - if x < 32 then - decode_error(str, j, "control character in string") - end - - if last == 92 then -- "\\" (escape char) - if x == 117 then -- "u" (unicode escape sequence) - local hex = str:sub(j + 1, j + 5) - if not hex:find("%x%x%x%x") then - decode_error(str, j, "invalid unicode escape in string") - end - if hex:find("^[dD][89aAbB]") then - has_surrogate_escape = true - else - has_unicode_escape = true - end - else - local c = string.char(x) - if not escape_chars[c] then - decode_error(str, j, "invalid escape char '" .. c .. "' in string") - end - has_escape = true - end - last = nil - - elseif x == 34 then -- '"' (end of string) - local s = str:sub(i + 1, j - 1) - if has_surrogate_escape then - s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) - end - if has_unicode_escape then - s = s:gsub("\\u....", parse_unicode_escape) - end - if has_escape then - s = s:gsub("\\.", escape_char_map_inv) - end - return s, j + 1 - - else - last = x - end - end - decode_error(str, i, "expected closing quote for string") -end - - -local function parse_number(str, i) - local x = next_char(str, i, delim_chars) - local s = str:sub(i, x - 1) - local n = tonumber(s) - if not n then - decode_error(str, i, "invalid number '" .. s .. "'") - end - return n, x -end - - -local function parse_literal(str, i) - local x = next_char(str, i, delim_chars) - local word = str:sub(i, x - 1) - if not literals[word] then - decode_error(str, i, "invalid literal '" .. word .. "'") - end - return literal_map[word], x -end - - -local function parse_array(str, i) - local res = {} - local n = 1 - i = i + 1 - while 1 do - local x - i = next_char(str, i, space_chars, true) - -- Empty / end of array? - if str:sub(i, i) == "]" then - i = i + 1 - break - end - -- Read token - x, i = parse(str, i) - res[n] = x - n = n + 1 - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "]" then break end - if chr ~= "," then decode_error(str, i, "expected ']' or ','") end - end - return res, i -end - - -local function parse_object(str, i) - local res = {} - i = i + 1 - while 1 do - local key, val - i = next_char(str, i, space_chars, true) - -- Empty / end of object? - if str:sub(i, i) == "}" then - i = i + 1 - break - end - -- Read key - if str:sub(i, i) ~= '"' then - decode_error(str, i, "expected string for key") - end - key, i = parse(str, i) - -- Read ':' delimiter - i = next_char(str, i, space_chars, true) - if str:sub(i, i) ~= ":" then - decode_error(str, i, "expected ':' after key") - end - i = next_char(str, i + 1, space_chars, true) - -- Read value - val, i = parse(str, i) - -- Set - res[key] = val - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "}" then break end - if chr ~= "," then decode_error(str, i, "expected '}' or ','") end - end - return res, i -end - - -local char_func_map = { - [ '"' ] = parse_string, - [ "0" ] = parse_number, - [ "1" ] = parse_number, - [ "2" ] = parse_number, - [ "3" ] = parse_number, - [ "4" ] = parse_number, - [ "5" ] = parse_number, - [ "6" ] = parse_number, - [ "7" ] = parse_number, - [ "8" ] = parse_number, - [ "9" ] = parse_number, - [ "-" ] = parse_number, - [ "t" ] = parse_literal, - [ "f" ] = parse_literal, - [ "n" ] = parse_literal, - [ "[" ] = parse_array, - [ "{" ] = parse_object, -} - - -parse = function(str, idx) - local chr = str:sub(idx, idx) - local f = char_func_map[chr] - if f then - return f(str, idx) - end - decode_error(str, idx, "unexpected character '" .. chr .. "'") -end - - -function json.decode(str) - if type(str) ~= "string" then - error("expected argument of type string, got " .. type(str)) - end - return ( parse(str, next_char(str, 1, space_chars, true)) ) -end - - -return json \ No newline at end of file diff --git a/data/lua/OOT/socket.lua b/data/lua/OOT/socket.lua deleted file mode 100644 index a98e952115..0000000000 --- a/data/lua/OOT/socket.lua +++ /dev/null @@ -1,132 +0,0 @@ ------------------------------------------------------------------------------ --- LuaSocket helper module --- Author: Diego Nehab --- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ ------------------------------------------------------------------------------ - ------------------------------------------------------------------------------ --- Declare module and import dependencies ------------------------------------------------------------------------------ -local base = _G -local string = require("string") -local math = require("math") -local socket = require("socket.core") -module("socket") - ------------------------------------------------------------------------------ --- Exported auxiliar functions ------------------------------------------------------------------------------ -function connect(address, port, laddress, lport) - local sock, err = socket.tcp() - if not sock then return nil, err end - if laddress then - local res, err = sock:bind(laddress, lport, -1) - if not res then return nil, err end - end - local res, err = sock:connect(address, port) - if not res then return nil, err end - return sock -end - -function bind(host, port, backlog) - local sock, err = socket.tcp() - if not sock then return nil, err end - sock:setoption("reuseaddr", true) - local res, err = sock:bind(host, port) - if not res then return nil, err end - res, err = sock:listen(backlog) - if not res then return nil, err end - return sock -end - -try = newtry() - -function choose(table) - return function(name, opt1, opt2) - if base.type(name) ~= "string" then - name, opt1, opt2 = "default", name, opt1 - end - local f = table[name or "nil"] - if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) - else return f(opt1, opt2) end - end -end - ------------------------------------------------------------------------------ --- Socket sources and sinks, conforming to LTN12 ------------------------------------------------------------------------------ --- create namespaces inside LuaSocket namespace -sourcet = {} -sinkt = {} - -BLOCKSIZE = 2048 - -sinkt["close-when-done"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if not chunk then - sock:close() - return 1 - else return sock:send(chunk) end - end - }) -end - -sinkt["keep-open"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if chunk then return sock:send(chunk) - else return 1 end - end - }) -end - -sinkt["default"] = sinkt["keep-open"] - -sink = choose(sinkt) - -sourcet["by-length"] = function(sock, length) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if length <= 0 then return nil end - local size = math.min(socket.BLOCKSIZE, length) - local chunk, err = sock:receive(size) - if err then return nil, err end - length = length - string.len(chunk) - return chunk - end - }) -end - -sourcet["until-closed"] = function(sock) - local done - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if done then return nil end - local chunk, err, partial = sock:receive(socket.BLOCKSIZE) - if not err then return chunk - elseif err == "closed" then - sock:close() - done = 1 - return partial - else return nil, err end - end - }) -end - - -sourcet["default"] = sourcet["until-closed"] - -source = choose(sourcet) diff --git a/data/lua/PKMN_RB/core.dll b/data/lua/PKMN_RB/core.dll deleted file mode 100644 index 3e9569571a..0000000000 Binary files a/data/lua/PKMN_RB/core.dll and /dev/null differ diff --git a/data/lua/PKMN_RB/json.lua b/data/lua/PKMN_RB/json.lua deleted file mode 100644 index a1f6e4ede2..0000000000 --- a/data/lua/PKMN_RB/json.lua +++ /dev/null @@ -1,389 +0,0 @@ --- --- json.lua --- --- Copyright (c) 2015 rxi --- --- This library is free software; you can redistribute it and/or modify it --- under the terms of the MIT license. See LICENSE for details. --- - -local json = { _version = "0.1.0" } - -------------------------------------------------------------------------------- --- Encode -------------------------------------------------------------------------------- - -local encode - -function error(err) - print(err) -end - -local escape_char_map = { - [ "\\" ] = "\\\\", - [ "\"" ] = "\\\"", - [ "\b" ] = "\\b", - [ "\f" ] = "\\f", - [ "\n" ] = "\\n", - [ "\r" ] = "\\r", - [ "\t" ] = "\\t", -} - -local escape_char_map_inv = { [ "\\/" ] = "/" } -for k, v in pairs(escape_char_map) do - escape_char_map_inv[v] = k -end - - -local function escape_char(c) - return escape_char_map[c] or string.format("\\u%04x", c:byte()) -end - - -local function encode_nil(val) - return "null" -end - - -local function encode_table(val, stack) - local res = {} - stack = stack or {} - - -- Circular reference? - if stack[val] then error("circular reference") end - - stack[val] = true - - if val[1] ~= nil or next(val) == nil then - -- Treat as array -- check keys are valid and it is not sparse - local n = 0 - for k in pairs(val) do - if type(k) ~= "number" then - error("invalid table: mixed or invalid key types") - end - n = n + 1 - end - if n ~= #val then - print("invalid table: sparse array") - print(n) - print("VAL:") - print(val) - print("STACK:") - print(stack) - end - -- Encode - for i, v in ipairs(val) do - table.insert(res, encode(v, stack)) - end - stack[val] = nil - return "[" .. table.concat(res, ",") .. "]" - - else - -- Treat as an object - for k, v in pairs(val) do - if type(k) ~= "string" then - error("invalid table: mixed or invalid key types") - end - table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) - end - stack[val] = nil - return "{" .. table.concat(res, ",") .. "}" - end -end - - -local function encode_string(val) - return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' -end - - -local function encode_number(val) - -- Check for NaN, -inf and inf - if val ~= val or val <= -math.huge or val >= math.huge then - error("unexpected number value '" .. tostring(val) .. "'") - end - return string.format("%.14g", val) -end - - -local type_func_map = { - [ "nil" ] = encode_nil, - [ "table" ] = encode_table, - [ "string" ] = encode_string, - [ "number" ] = encode_number, - [ "boolean" ] = tostring, -} - - -encode = function(val, stack) - local t = type(val) - local f = type_func_map[t] - if f then - return f(val, stack) - end - error("unexpected type '" .. t .. "'") -end - - -function json.encode(val) - return ( encode(val) ) -end - - -------------------------------------------------------------------------------- --- Decode -------------------------------------------------------------------------------- - -local parse - -local function create_set(...) - local res = {} - for i = 1, select("#", ...) do - res[ select(i, ...) ] = true - end - return res -end - -local space_chars = create_set(" ", "\t", "\r", "\n") -local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") -local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") -local literals = create_set("true", "false", "null") - -local literal_map = { - [ "true" ] = true, - [ "false" ] = false, - [ "null" ] = nil, -} - - -local function next_char(str, idx, set, negate) - for i = idx, #str do - if set[str:sub(i, i)] ~= negate then - return i - end - end - return #str + 1 -end - - -local function decode_error(str, idx, msg) - --local line_count = 1 - --local col_count = 1 - --for i = 1, idx - 1 do - -- col_count = col_count + 1 - -- if str:sub(i, i) == "\n" then - -- line_count = line_count + 1 - -- col_count = 1 - -- end - -- end - -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) -end - - -local function codepoint_to_utf8(n) - -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa - local f = math.floor - if n <= 0x7f then - return string.char(n) - elseif n <= 0x7ff then - return string.char(f(n / 64) + 192, n % 64 + 128) - elseif n <= 0xffff then - return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) - elseif n <= 0x10ffff then - return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, - f(n % 4096 / 64) + 128, n % 64 + 128) - end - error( string.format("invalid unicode codepoint '%x'", n) ) -end - - -local function parse_unicode_escape(s) - local n1 = tonumber( s:sub(3, 6), 16 ) - local n2 = tonumber( s:sub(9, 12), 16 ) - -- Surrogate pair? - if n2 then - return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) - else - return codepoint_to_utf8(n1) - end -end - - -local function parse_string(str, i) - local has_unicode_escape = false - local has_surrogate_escape = false - local has_escape = false - local last - for j = i + 1, #str do - local x = str:byte(j) - - if x < 32 then - decode_error(str, j, "control character in string") - end - - if last == 92 then -- "\\" (escape char) - if x == 117 then -- "u" (unicode escape sequence) - local hex = str:sub(j + 1, j + 5) - if not hex:find("%x%x%x%x") then - decode_error(str, j, "invalid unicode escape in string") - end - if hex:find("^[dD][89aAbB]") then - has_surrogate_escape = true - else - has_unicode_escape = true - end - else - local c = string.char(x) - if not escape_chars[c] then - decode_error(str, j, "invalid escape char '" .. c .. "' in string") - end - has_escape = true - end - last = nil - - elseif x == 34 then -- '"' (end of string) - local s = str:sub(i + 1, j - 1) - if has_surrogate_escape then - s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) - end - if has_unicode_escape then - s = s:gsub("\\u....", parse_unicode_escape) - end - if has_escape then - s = s:gsub("\\.", escape_char_map_inv) - end - return s, j + 1 - - else - last = x - end - end - decode_error(str, i, "expected closing quote for string") -end - - -local function parse_number(str, i) - local x = next_char(str, i, delim_chars) - local s = str:sub(i, x - 1) - local n = tonumber(s) - if not n then - decode_error(str, i, "invalid number '" .. s .. "'") - end - return n, x -end - - -local function parse_literal(str, i) - local x = next_char(str, i, delim_chars) - local word = str:sub(i, x - 1) - if not literals[word] then - decode_error(str, i, "invalid literal '" .. word .. "'") - end - return literal_map[word], x -end - - -local function parse_array(str, i) - local res = {} - local n = 1 - i = i + 1 - while 1 do - local x - i = next_char(str, i, space_chars, true) - -- Empty / end of array? - if str:sub(i, i) == "]" then - i = i + 1 - break - end - -- Read token - x, i = parse(str, i) - res[n] = x - n = n + 1 - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "]" then break end - if chr ~= "," then decode_error(str, i, "expected ']' or ','") end - end - return res, i -end - - -local function parse_object(str, i) - local res = {} - i = i + 1 - while 1 do - local key, val - i = next_char(str, i, space_chars, true) - -- Empty / end of object? - if str:sub(i, i) == "}" then - i = i + 1 - break - end - -- Read key - if str:sub(i, i) ~= '"' then - decode_error(str, i, "expected string for key") - end - key, i = parse(str, i) - -- Read ':' delimiter - i = next_char(str, i, space_chars, true) - if str:sub(i, i) ~= ":" then - decode_error(str, i, "expected ':' after key") - end - i = next_char(str, i + 1, space_chars, true) - -- Read value - val, i = parse(str, i) - -- Set - res[key] = val - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "}" then break end - if chr ~= "," then decode_error(str, i, "expected '}' or ','") end - end - return res, i -end - - -local char_func_map = { - [ '"' ] = parse_string, - [ "0" ] = parse_number, - [ "1" ] = parse_number, - [ "2" ] = parse_number, - [ "3" ] = parse_number, - [ "4" ] = parse_number, - [ "5" ] = parse_number, - [ "6" ] = parse_number, - [ "7" ] = parse_number, - [ "8" ] = parse_number, - [ "9" ] = parse_number, - [ "-" ] = parse_number, - [ "t" ] = parse_literal, - [ "f" ] = parse_literal, - [ "n" ] = parse_literal, - [ "[" ] = parse_array, - [ "{" ] = parse_object, -} - - -parse = function(str, idx) - local chr = str:sub(idx, idx) - local f = char_func_map[chr] - if f then - return f(str, idx) - end - decode_error(str, idx, "unexpected character '" .. chr .. "'") -end - - -function json.decode(str) - if type(str) ~= "string" then - error("expected argument of type string, got " .. type(str)) - end - return ( parse(str, next_char(str, 1, space_chars, true)) ) -end - - -return json \ No newline at end of file diff --git a/data/lua/PKMN_RB/socket.lua b/data/lua/PKMN_RB/socket.lua deleted file mode 100644 index a98e952115..0000000000 --- a/data/lua/PKMN_RB/socket.lua +++ /dev/null @@ -1,132 +0,0 @@ ------------------------------------------------------------------------------ --- LuaSocket helper module --- Author: Diego Nehab --- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ ------------------------------------------------------------------------------ - ------------------------------------------------------------------------------ --- Declare module and import dependencies ------------------------------------------------------------------------------ -local base = _G -local string = require("string") -local math = require("math") -local socket = require("socket.core") -module("socket") - ------------------------------------------------------------------------------ --- Exported auxiliar functions ------------------------------------------------------------------------------ -function connect(address, port, laddress, lport) - local sock, err = socket.tcp() - if not sock then return nil, err end - if laddress then - local res, err = sock:bind(laddress, lport, -1) - if not res then return nil, err end - end - local res, err = sock:connect(address, port) - if not res then return nil, err end - return sock -end - -function bind(host, port, backlog) - local sock, err = socket.tcp() - if not sock then return nil, err end - sock:setoption("reuseaddr", true) - local res, err = sock:bind(host, port) - if not res then return nil, err end - res, err = sock:listen(backlog) - if not res then return nil, err end - return sock -end - -try = newtry() - -function choose(table) - return function(name, opt1, opt2) - if base.type(name) ~= "string" then - name, opt1, opt2 = "default", name, opt1 - end - local f = table[name or "nil"] - if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) - else return f(opt1, opt2) end - end -end - ------------------------------------------------------------------------------ --- Socket sources and sinks, conforming to LTN12 ------------------------------------------------------------------------------ --- create namespaces inside LuaSocket namespace -sourcet = {} -sinkt = {} - -BLOCKSIZE = 2048 - -sinkt["close-when-done"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if not chunk then - sock:close() - return 1 - else return sock:send(chunk) end - end - }) -end - -sinkt["keep-open"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if chunk then return sock:send(chunk) - else return 1 end - end - }) -end - -sinkt["default"] = sinkt["keep-open"] - -sink = choose(sinkt) - -sourcet["by-length"] = function(sock, length) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if length <= 0 then return nil end - local size = math.min(socket.BLOCKSIZE, length) - local chunk, err = sock:receive(size) - if err then return nil, err end - length = length - string.len(chunk) - return chunk - end - }) -end - -sourcet["until-closed"] = function(sock) - local done - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if done then return nil end - local chunk, err, partial = sock:receive(socket.BLOCKSIZE) - if not err then return chunk - elseif err == "closed" then - sock:close() - done = 1 - return partial - else return nil, err end - end - }) -end - - -sourcet["default"] = sourcet["until-closed"] - -source = choose(sourcet) diff --git a/data/lua/TLoZ/core.dll b/data/lua/TLoZ/core.dll deleted file mode 100644 index 3e9569571a..0000000000 Binary files a/data/lua/TLoZ/core.dll and /dev/null differ diff --git a/data/lua/TLoZ/json.lua b/data/lua/TLoZ/json.lua deleted file mode 100644 index 0833bf6fb4..0000000000 --- a/data/lua/TLoZ/json.lua +++ /dev/null @@ -1,380 +0,0 @@ --- --- json.lua --- --- Copyright (c) 2015 rxi --- --- This library is free software; you can redistribute it and/or modify it --- under the terms of the MIT license. See LICENSE for details. --- - -local json = { _version = "0.1.0" } - -------------------------------------------------------------------------------- --- Encode -------------------------------------------------------------------------------- - -local encode - -local escape_char_map = { - [ "\\" ] = "\\\\", - [ "\"" ] = "\\\"", - [ "\b" ] = "\\b", - [ "\f" ] = "\\f", - [ "\n" ] = "\\n", - [ "\r" ] = "\\r", - [ "\t" ] = "\\t", -} - -local escape_char_map_inv = { [ "\\/" ] = "/" } -for k, v in pairs(escape_char_map) do - escape_char_map_inv[v] = k -end - - -local function escape_char(c) - return escape_char_map[c] or string.format("\\u%04x", c:byte()) -end - - -local function encode_nil(val) - return "null" -end - - -local function encode_table(val, stack) - local res = {} - stack = stack or {} - - -- Circular reference? - if stack[val] then error("circular reference") end - - stack[val] = true - - if val[1] ~= nil or next(val) == nil then - -- Treat as array -- check keys are valid and it is not sparse - local n = 0 - for k in pairs(val) do - if type(k) ~= "number" then - error("invalid table: mixed or invalid key types") - end - n = n + 1 - end - if n ~= #val then - error("invalid table: sparse array") - end - -- Encode - for i, v in ipairs(val) do - table.insert(res, encode(v, stack)) - end - stack[val] = nil - return "[" .. table.concat(res, ",") .. "]" - - else - -- Treat as an object - for k, v in pairs(val) do - if type(k) ~= "string" then - error("invalid table: mixed or invalid key types") - end - table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) - end - stack[val] = nil - return "{" .. table.concat(res, ",") .. "}" - end -end - - -local function encode_string(val) - return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' -end - - -local function encode_number(val) - -- Check for NaN, -inf and inf - if val ~= val or val <= -math.huge or val >= math.huge then - error("unexpected number value '" .. tostring(val) .. "'") - end - return string.format("%.14g", val) -end - - -local type_func_map = { - [ "nil" ] = encode_nil, - [ "table" ] = encode_table, - [ "string" ] = encode_string, - [ "number" ] = encode_number, - [ "boolean" ] = tostring, -} - - -encode = function(val, stack) - local t = type(val) - local f = type_func_map[t] - if f then - return f(val, stack) - end - error("unexpected type '" .. t .. "'") -end - - -function json.encode(val) - return ( encode(val) ) -end - - -------------------------------------------------------------------------------- --- Decode -------------------------------------------------------------------------------- - -local parse - -local function create_set(...) - local res = {} - for i = 1, select("#", ...) do - res[ select(i, ...) ] = true - end - return res -end - -local space_chars = create_set(" ", "\t", "\r", "\n") -local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") -local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") -local literals = create_set("true", "false", "null") - -local literal_map = { - [ "true" ] = true, - [ "false" ] = false, - [ "null" ] = nil, -} - - -local function next_char(str, idx, set, negate) - for i = idx, #str do - if set[str:sub(i, i)] ~= negate then - return i - end - end - return #str + 1 -end - - -local function decode_error(str, idx, msg) - --local line_count = 1 - --local col_count = 1 - --for i = 1, idx - 1 do - -- col_count = col_count + 1 - -- if str:sub(i, i) == "\n" then - -- line_count = line_count + 1 - -- col_count = 1 - -- end - -- end - -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) -end - - -local function codepoint_to_utf8(n) - -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa - local f = math.floor - if n <= 0x7f then - return string.char(n) - elseif n <= 0x7ff then - return string.char(f(n / 64) + 192, n % 64 + 128) - elseif n <= 0xffff then - return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) - elseif n <= 0x10ffff then - return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, - f(n % 4096 / 64) + 128, n % 64 + 128) - end - error( string.format("invalid unicode codepoint '%x'", n) ) -end - - -local function parse_unicode_escape(s) - local n1 = tonumber( s:sub(3, 6), 16 ) - local n2 = tonumber( s:sub(9, 12), 16 ) - -- Surrogate pair? - if n2 then - return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) - else - return codepoint_to_utf8(n1) - end -end - - -local function parse_string(str, i) - local has_unicode_escape = false - local has_surrogate_escape = false - local has_escape = false - local last - for j = i + 1, #str do - local x = str:byte(j) - - if x < 32 then - decode_error(str, j, "control character in string") - end - - if last == 92 then -- "\\" (escape char) - if x == 117 then -- "u" (unicode escape sequence) - local hex = str:sub(j + 1, j + 5) - if not hex:find("%x%x%x%x") then - decode_error(str, j, "invalid unicode escape in string") - end - if hex:find("^[dD][89aAbB]") then - has_surrogate_escape = true - else - has_unicode_escape = true - end - else - local c = string.char(x) - if not escape_chars[c] then - decode_error(str, j, "invalid escape char '" .. c .. "' in string") - end - has_escape = true - end - last = nil - - elseif x == 34 then -- '"' (end of string) - local s = str:sub(i + 1, j - 1) - if has_surrogate_escape then - s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) - end - if has_unicode_escape then - s = s:gsub("\\u....", parse_unicode_escape) - end - if has_escape then - s = s:gsub("\\.", escape_char_map_inv) - end - return s, j + 1 - - else - last = x - end - end - decode_error(str, i, "expected closing quote for string") -end - - -local function parse_number(str, i) - local x = next_char(str, i, delim_chars) - local s = str:sub(i, x - 1) - local n = tonumber(s) - if not n then - decode_error(str, i, "invalid number '" .. s .. "'") - end - return n, x -end - - -local function parse_literal(str, i) - local x = next_char(str, i, delim_chars) - local word = str:sub(i, x - 1) - if not literals[word] then - decode_error(str, i, "invalid literal '" .. word .. "'") - end - return literal_map[word], x -end - - -local function parse_array(str, i) - local res = {} - local n = 1 - i = i + 1 - while 1 do - local x - i = next_char(str, i, space_chars, true) - -- Empty / end of array? - if str:sub(i, i) == "]" then - i = i + 1 - break - end - -- Read token - x, i = parse(str, i) - res[n] = x - n = n + 1 - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "]" then break end - if chr ~= "," then decode_error(str, i, "expected ']' or ','") end - end - return res, i -end - - -local function parse_object(str, i) - local res = {} - i = i + 1 - while 1 do - local key, val - i = next_char(str, i, space_chars, true) - -- Empty / end of object? - if str:sub(i, i) == "}" then - i = i + 1 - break - end - -- Read key - if str:sub(i, i) ~= '"' then - decode_error(str, i, "expected string for key") - end - key, i = parse(str, i) - -- Read ':' delimiter - i = next_char(str, i, space_chars, true) - if str:sub(i, i) ~= ":" then - decode_error(str, i, "expected ':' after key") - end - i = next_char(str, i + 1, space_chars, true) - -- Read value - val, i = parse(str, i) - -- Set - res[key] = val - -- Next token - i = next_char(str, i, space_chars, true) - local chr = str:sub(i, i) - i = i + 1 - if chr == "}" then break end - if chr ~= "," then decode_error(str, i, "expected '}' or ','") end - end - return res, i -end - - -local char_func_map = { - [ '"' ] = parse_string, - [ "0" ] = parse_number, - [ "1" ] = parse_number, - [ "2" ] = parse_number, - [ "3" ] = parse_number, - [ "4" ] = parse_number, - [ "5" ] = parse_number, - [ "6" ] = parse_number, - [ "7" ] = parse_number, - [ "8" ] = parse_number, - [ "9" ] = parse_number, - [ "-" ] = parse_number, - [ "t" ] = parse_literal, - [ "f" ] = parse_literal, - [ "n" ] = parse_literal, - [ "[" ] = parse_array, - [ "{" ] = parse_object, -} - - -parse = function(str, idx) - local chr = str:sub(idx, idx) - local f = char_func_map[chr] - if f then - return f(str, idx) - end - decode_error(str, idx, "unexpected character '" .. chr .. "'") -end - - -function json.decode(str) - if type(str) ~= "string" then - error("expected argument of type string, got " .. type(str)) - end - return ( parse(str, next_char(str, 1, space_chars, true)) ) -end - - -return json \ No newline at end of file diff --git a/data/lua/TLoZ/socket.lua b/data/lua/TLoZ/socket.lua deleted file mode 100644 index a98e952115..0000000000 --- a/data/lua/TLoZ/socket.lua +++ /dev/null @@ -1,132 +0,0 @@ ------------------------------------------------------------------------------ --- LuaSocket helper module --- Author: Diego Nehab --- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ ------------------------------------------------------------------------------ - ------------------------------------------------------------------------------ --- Declare module and import dependencies ------------------------------------------------------------------------------ -local base = _G -local string = require("string") -local math = require("math") -local socket = require("socket.core") -module("socket") - ------------------------------------------------------------------------------ --- Exported auxiliar functions ------------------------------------------------------------------------------ -function connect(address, port, laddress, lport) - local sock, err = socket.tcp() - if not sock then return nil, err end - if laddress then - local res, err = sock:bind(laddress, lport, -1) - if not res then return nil, err end - end - local res, err = sock:connect(address, port) - if not res then return nil, err end - return sock -end - -function bind(host, port, backlog) - local sock, err = socket.tcp() - if not sock then return nil, err end - sock:setoption("reuseaddr", true) - local res, err = sock:bind(host, port) - if not res then return nil, err end - res, err = sock:listen(backlog) - if not res then return nil, err end - return sock -end - -try = newtry() - -function choose(table) - return function(name, opt1, opt2) - if base.type(name) ~= "string" then - name, opt1, opt2 = "default", name, opt1 - end - local f = table[name or "nil"] - if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) - else return f(opt1, opt2) end - end -end - ------------------------------------------------------------------------------ --- Socket sources and sinks, conforming to LTN12 ------------------------------------------------------------------------------ --- create namespaces inside LuaSocket namespace -sourcet = {} -sinkt = {} - -BLOCKSIZE = 2048 - -sinkt["close-when-done"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if not chunk then - sock:close() - return 1 - else return sock:send(chunk) end - end - }) -end - -sinkt["keep-open"] = function(sock) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function(self, chunk, err) - if chunk then return sock:send(chunk) - else return 1 end - end - }) -end - -sinkt["default"] = sinkt["keep-open"] - -sink = choose(sinkt) - -sourcet["by-length"] = function(sock, length) - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if length <= 0 then return nil end - local size = math.min(socket.BLOCKSIZE, length) - local chunk, err = sock:receive(size) - if err then return nil, err end - length = length - string.len(chunk) - return chunk - end - }) -end - -sourcet["until-closed"] = function(sock) - local done - return base.setmetatable({ - getfd = function() return sock:getfd() end, - dirty = function() return sock:dirty() end - }, { - __call = function() - if done then return nil end - local chunk, err, partial = sock:receive(socket.BLOCKSIZE) - if not err then return chunk - elseif err == "closed" then - sock:close() - done = 1 - return partial - else return nil, err end - end - }) -end - - -sourcet["default"] = sourcet["until-closed"] - -source = choose(sourcet) diff --git a/data/lua/base64.lua b/data/lua/base64.lua new file mode 100644 index 0000000000..ebe8064353 --- /dev/null +++ b/data/lua/base64.lua @@ -0,0 +1,119 @@ +-- This file originates from this repository: https://github.com/iskolbin/lbase64 +-- It was modified to translate between base64 strings and lists of bytes instead of base64 strings and strings. + +local base64 = {} + +local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode +if not extract then + if _G._VERSION == "Lua 5.4" then + extract = load[[return function( v, from, width ) + return ( v >> from ) & ((1 << width) - 1) + end]]() + elseif _G.bit then -- LuaJIT + local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band + extract = function( v, from, width ) + return band( shr( v, from ), shl( 1, width ) - 1 ) + end + elseif _G._VERSION == "Lua 5.1" then + extract = function( v, from, width ) + local w = 0 + local flag = 2^from + for i = 0, width-1 do + local flag2 = flag + flag + if v % flag2 >= flag then + w = w + 2^i + end + flag = flag2 + end + return w + end + end +end + + +function base64.makeencoder( s62, s63, spad ) + local encoder = {} + for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J', + 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y', + 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n', + 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2', + '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do + encoder[b64code] = char:byte() + end + return encoder +end + +function base64.makedecoder( s62, s63, spad ) + local decoder = {} + for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do + decoder[charcode] = b64code + end + return decoder +end + +local DEFAULT_ENCODER = base64.makeencoder() +local DEFAULT_DECODER = base64.makedecoder() + +local char, concat = string.char, table.concat + +function base64.encode( arr, encoder ) + encoder = encoder or DEFAULT_ENCODER + local t, k, n = {}, 1, #arr + local lastn = n % 3 + for i = 1, n-lastn, 3 do + local a, b, c = arr[i], arr[i + 1], arr[i + 2] + local v = a*0x10000 + b*0x100 + c + local s + s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) + t[k] = s + k = k + 1 + end + if lastn == 2 then + local a, b = arr[n-1], arr[n] + local v = a*0x10000 + b*0x100 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64]) + elseif lastn == 1 then + local v = arr[n]*0x10000 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64]) + end + return concat( t ) +end + +function base64.decode( b64, decoder ) + decoder = decoder or DEFAULT_DECODER + local pattern = '[^%w%+%/%=]' + if decoder then + local s62, s63 + for charcode, b64code in pairs( decoder ) do + if b64code == 62 then s62 = charcode + elseif b64code == 63 then s63 = charcode + end + end + pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) ) + end + b64 = b64:gsub( pattern, '' ) + local t, k = {}, 1 + local n = #b64 + local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0 + for i = 1, padding > 0 and n-4 or n, 4 do + local a, b, c, d = b64:byte( i, i+3 ) + local s + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] + table.insert(t,extract(v,16,8)) + table.insert(t,extract(v,8,8)) + table.insert(t,extract(v,0,8)) + end + if padding == 1 then + local a, b, c = b64:byte( n-3, n-1 ) + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + table.insert(t,extract(v,16,8)) + table.insert(t,extract(v,8,8)) + elseif padding == 2 then + local a, b = b64:byte( n-3, n-2 ) + local v = decoder[a]*0x40000 + decoder[b]*0x1000 + table.insert(t,extract(v,16,8)) + end + return t +end + +return base64 diff --git a/data/lua/common.lua b/data/lua/common.lua new file mode 100644 index 0000000000..c074c63af6 --- /dev/null +++ b/data/lua/common.lua @@ -0,0 +1,109 @@ +print("Loading AP lua connector script") + +local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)") +lua_major = tonumber(lua_major) +lua_minor = tonumber(lua_minor) +-- lua compat shims +if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then + require("lua_5_3_compat") +end + +function table.empty (self) + for _, _ in pairs(self) do + return false + end + return true +end + +local bizhawk_version = client.getversion() +local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)") +bizhawk_major = tonumber(bizhawk_major) +bizhawk_minor = tonumber(bizhawk_minor) +if bizhawk_patch == "" then + bizhawk_patch = 0 +else + bizhawk_patch = tonumber(bizhawk_patch) +end + +local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_major == 2 and bizhawk_minor >= 3 and bizhawk_minor <= 5) +local isGreaterOrEqualTo26 = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor >= 6) +local isUntestedBizHawk = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) +local untestedBizHawkMessage = "Warning: this version of BizHawk is newer than we know about. If it doesn't work, consider downgrading to 2.9" + +u8 = memory.read_u8 +wU8 = memory.write_u8 +u16 = memory.read_u16_le +uRange = memory.readbyterange + +function getMaxMessageLength() + local denominator = 12 + if is23Or24Or25 then + denominator = 11 + end + return math.floor(client.screenwidth()/denominator) +end + +function drawText(x, y, message, color) + if is23Or24Or25 then + gui.addmessage(message) + elseif isGreaterOrEqualTo26 then + gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client") + end +end + +function clearScreen() + if is23Or24Or25 then + return + elseif isGreaterOrEqualTo26 then + drawText(0, 0, "", "black") + end +end + +itemMessages = {} + +function drawMessages() + if table.empty(itemMessages) then + clearScreen() + return + end + local y = 10 + found = false + maxMessageLength = getMaxMessageLength() + for k, v in pairs(itemMessages) do + if v["TTL"] > 0 then + message = v["message"] + while true do + drawText(5, y, message:sub(1, maxMessageLength), v["color"]) + y = y + 16 + + message = message:sub(maxMessageLength + 1, message:len()) + if message:len() == 0 then + break + end + end + newTTL = 0 + if isGreaterOrEqualTo26 then + newTTL = itemMessages[k]["TTL"] - 1 + end + itemMessages[k]["TTL"] = newTTL + found = true + end + end + if found == false then + clearScreen() + end +end + +function checkBizHawkVersion() + if not is23Or24Or25 and not isGreaterOrEqualTo26 then + print("Must use a version of BizHawk 2.3.1 or higher") + return false + elseif isUntestedBizHawk then + print(untestedBizHawkMessage) + end + return true +end + +function stripPrefix(s, p) + return (s:sub(0, #p) == p) and s:sub(#p+1) or s +end diff --git a/data/lua/ADVENTURE/adventure_connector.lua b/data/lua/connector_adventure.lua similarity index 91% rename from data/lua/ADVENTURE/adventure_connector.lua rename to data/lua/connector_adventure.lua index 598d6d74ff..3c4c2ec7b8 100644 --- a/data/lua/ADVENTURE/adventure_connector.lua +++ b/data/lua/connector_adventure.lua @@ -1,6 +1,7 @@ local socket = require("socket") local json = require('json') local math = require('math') +require("common") local STATE_OK = "Ok" local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" @@ -32,8 +33,6 @@ local frames_with_no_item = 0 local ItemTableStart = 0xfe9d local PlayerSlotAddress = 0xfff9 -local itemMessages = {} - local nullObjectId = 0xB4 local ItemsReceived = nil local sha256hash = nil @@ -101,17 +100,6 @@ local current_bat_ap_item = nil local was_in_number_room = false -local u8 = nil -local wU8 = nil -local u16 - -local bizhawk_version = client.getversion() -local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") -local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8") - -u8 = memory.read_u8 -wU8 = memory.write_u8 -u16 = memory.read_u16_le function uRangeRam(address, bytes) data = memory.read_bytes_as_array(address, bytes, "Main RAM") return data @@ -125,23 +113,6 @@ function uRangeAddress(address, bytes) return data end - -function table.empty (self) - for _, _ in pairs(self) do - return false - end - return true -end - -function slice (tbl, s, e) - local pos, new = 1, {} - for i = s + 1, e do - new[pos] = tbl[i] - pos = pos + 1 - end - return new -end - local function createForeignItemsByRoom() foreign_items_by_room = {} if foreign_items == nil then @@ -294,94 +265,11 @@ function processBlock(block) end end -local function clearScreen() - if is23Or24Or25 then - return - elseif is26To28 then - drawText(0, 0, "", "black") - end -end - -local function getMaxMessageLength() - if is23Or24Or25 then - return client.screenwidth()/11 - elseif is26To28 then - return client.screenwidth()/12 - end -end - -function drawText(x, y, message, color) - if is23Or24Or25 then - gui.addmessage(message) - elseif is26To28 then - gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client") - end -end - -local function drawMessages() - if table.empty(itemMessages) then - clearScreen() - return - end - local y = 10 - found = false - maxMessageLength = getMaxMessageLength() - for k, v in pairs(itemMessages) do - if v["TTL"] > 0 then - message = v["message"] - while true do - drawText(5, y, message:sub(1, maxMessageLength), v["color"]) - y = y + 16 - - message = message:sub(maxMessageLength + 1, message:len()) - if message:len() == 0 then - break - end - end - newTTL = 0 - if is26To28 then - newTTL = itemMessages[k]["TTL"] - 1 - end - itemMessages[k]["TTL"] = newTTL - found = true - end - end - if found == false then - clearScreen() - end -end - -function difference(a, b) - local aa = {} - for k,v in pairs(a) do aa[v]=true end - for k,v in pairs(b) do aa[v]=nil end - local ret = {} - local n = 0 - for k,v in pairs(a) do - if aa[v] then n=n+1 ret[n]=v end - end - return ret -end - function getAllRam() uRangeRAM(0,128); return data end -local function arrayEqual(a1, a2) - if #a1 ~= #a2 then - return false - end - - for i, v in ipairs(a1) do - if v ~= a2[i] then - return false - end - end - - return true -end - local function alive_mode() return (u8(PlayerRoomAddr) ~= 0x00 and u8(WinAddr) == 0x00) end @@ -569,8 +457,7 @@ end function main() memory.usememorydomain("System Bus") - if (is23Or24Or25 or is26To28) == false then - print("Must use a version of bizhawk 2.3.1 or higher") + if not checkBizHawkVersion() then return end local playerSlot = memory.read_u8(PlayerSlotAddress) @@ -711,7 +598,7 @@ function main() if ( localItemLocations ~= nil and localItemLocations[tostring(carry_item)] ~= nil ) then pending_local_items_collected[localItemLocations[tostring(carry_item)]] = localItemLocations[tostring(carry_item)] - table.remove(localItemLocations, tostring(carry_item)) + localItemLocations[tostring(carry_item)] = nil skip_inventory_items[carry_item] = carry_item end end diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua new file mode 100644 index 0000000000..b0b06de447 --- /dev/null +++ b/data/lua/connector_bizhawk_generic.lua @@ -0,0 +1,564 @@ +--[[ +Copyright (c) 2023 Zunawe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local SCRIPT_VERSION = 1 + +--[[ +This script expects to receive JSON and will send JSON back. A message should +be a list of 1 or more requests which will be executed in order. Each request +will have a corresponding response in the same order. + +Every individual request and response is a JSON object with at minimum one +field `type`. The value of `type` determines what other fields may exist. + +To get the script version, instead of JSON, send "VERSION" to get the script +version directly (e.g. "2"). + +#### Ex. 1 + +Request: `[{"type": "PING"}]` + +Response: `[{"type": "PONG"}]` + +--- + +#### Ex. 2 + +Request: `[{"type": "LOCK"}, {"type": "HASH"}]` + +Response: `[{"type": "LOCKED"}, {"type": "HASH_RESPONSE", "value": "F7D18982"}]` + +--- + +#### Ex. 3 + +Request: + +```json +[ + {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"}, + {"type": "READ", "address": 500, "size": 4, "domain": "ROM"} +] +``` + +Response: + +```json +[ + {"type": "GUARD_RESPONSE", "address": 100, "value": true}, + {"type": "READ_RESPONSE", "value": "dGVzdA=="} +] +``` + +--- + +#### Ex. 4 + +Request: + +```json +[ + {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"}, + {"type": "READ", "address": 500, "size": 4, "domain": "ROM"} +] +``` + +Response: + +```json +[ + {"type": "GUARD_RESPONSE", "address": 100, "value": false}, + {"type": "GUARD_RESPONSE", "address": 100, "value": false} +] +``` + +--- + +### Supported Request Types + +- `PING` + Does nothing; resets timeout. + + Expected Response Type: `PONG` + +- `SYSTEM` + Returns the system of the currently loaded ROM (N64, GBA, etc...). + + Expected Response Type: `SYSTEM_RESPONSE` + +- `PREFERRED_CORES` + Returns the user's default cores for systems with multiple cores. If the + current ROM's system has multiple cores, the one that is currently + running is very probably the preferred core. + + Expected Response Type: `PREFERRED_CORES_RESPONSE` + +- `HASH` + Returns the hash of the currently loaded ROM calculated by BizHawk. + + Expected Response Type: `HASH_RESPONSE` + +- `GUARD` + Checks a section of memory against `expected_data`. If the bytes starting + at `address` do not match `expected_data`, the response will have `value` + set to `false`, and all subsequent requests will not be executed and + receive the same `GUARD_RESPONSE`. + + Expected Response Type: `GUARD_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to check + - `expected_data` (string): A base64 string of contiguous data + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `LOCK` + Halts emulation and blocks on incoming requests until an `UNLOCK` request + is received or the client times out. All requests processed while locked + will happen on the same frame. + + Expected Response Type: `LOCKED` + +- `UNLOCK` + Resumes emulation after the current list of requests is done being + executed. + + Expected Response Type: `UNLOCKED` + +- `READ` + Reads an array of bytes at the provided address. + + Expected Response Type: `READ_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to read + - `size` (`int`): The number of bytes to read + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `WRITE` + Writes an array of bytes to the provided address. + + Expected Response Type: `WRITE_RESPONSE` + + Additional Fields: + - `address` (`int`): The address of the memory to write to + - `value` (`string`): A base64 string representing the data to write + - `domain` (`string`): The name of the memory domain the address + corresponds to + +- `DISPLAY_MESSAGE` + Adds a message to the message queue which will be displayed using + `gui.addmessage` according to the message interval. + + Expected Response Type: `DISPLAY_MESSAGE_RESPONSE` + + Additional Fields: + - `message` (`string`): The string to display + +- `SET_MESSAGE_INTERVAL` + Sets the minimum amount of time to wait between displaying messages. + Potentially useful if you add many messages quickly but want players + to be able to read each of them. + + Expected Response Type: `SET_MESSAGE_INTERVAL_RESPONSE` + + Additional Fields: + - `value` (`number`): The number of seconds to set the interval to + + +### Response Types + +- `PONG` + Acknowledges `PING`. + +- `SYSTEM_RESPONSE` + Contains the name of the system for currently running ROM. + + Additional Fields: + - `value` (`string`): The returned system name + +- `PREFERRED_CORES_RESPONSE` + Contains the user's preferred cores for systems with multiple supported + cores. Currently includes NES, SNES, GB, GBC, DGB, SGB, PCE, PCECD, and + SGX. + + Additional Fields: + - `value` (`{[string]: [string]}`): A dictionary map from system name to + core name + +- `HASH_RESPONSE` + Contains the hash of the currently loaded ROM calculated by BizHawk. + + Additional Fields: + - `value` (`string`): The returned hash + +- `GUARD_RESPONSE` + The result of an attempted `GUARD` request. + + Additional Fields: + - `value` (`boolean`): true if the memory was validated, false if not + - `address` (`int`): The address of the memory that was invalid (the same + address provided by the `GUARD`, not the address of the individual invalid + byte) + +- `LOCKED` + Acknowledges `LOCK`. + +- `UNLOCKED` + Acknowledges `UNLOCK`. + +- `READ_RESPONSE` + Contains the result of a `READ` request. + + Additional Fields: + - `value` (`string`): A base64 string representing the read data + +- `WRITE_RESPONSE` + Acknowledges `WRITE`. + +- `DISPLAY_MESSAGE_RESPONSE` + Acknowledges `DISPLAY_MESSAGE`. + +- `SET_MESSAGE_INTERVAL_RESPONSE` + Acknowledges `SET_MESSAGE_INTERVAL`. + +- `ERROR` + Signifies that something has gone wrong while processing a request. + + Additional Fields: + - `err` (`string`): A description of the problem +]] + +local base64 = require("base64") +local socket = require("socket") +local json = require("json") + +-- Set to log incoming requests +-- Will cause lag due to large console output +local DEBUG = false + +local SOCKET_PORT = 43055 + +local STATE_NOT_CONNECTED = 0 +local STATE_CONNECTED = 1 + +local server = nil +local client_socket = nil + +local current_state = STATE_NOT_CONNECTED + +local timeout_timer = 0 +local message_timer = 0 +local message_interval = 0 +local prev_time = 0 +local current_time = 0 + +local locked = false + +local rom_hash = nil + +local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)") +lua_major = tonumber(lua_major) +lua_minor = tonumber(lua_minor) + +if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then + require("lua_5_3_compat") +end + +local bizhawk_version = client.getversion() +local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)") +bizhawk_major = tonumber(bizhawk_major) +bizhawk_minor = tonumber(bizhawk_minor) +if bizhawk_patch == "" then + bizhawk_patch = 0 +else + bizhawk_patch = tonumber(bizhawk_patch) +end + +function queue_push (self, value) + self[self.right] = value + self.right = self.right + 1 +end + +function queue_is_empty (self) + return self.right == self.left +end + +function queue_shift (self) + value = self[self.left] + self[self.left] = nil + self.left = self.left + 1 + return value +end + +function new_queue () + local queue = {left = 1, right = 1} + return setmetatable(queue, {__index = {is_empty = queue_is_empty, push = queue_push, shift = queue_shift}}) +end + +local message_queue = new_queue() + +function lock () + locked = true + client_socket:settimeout(2) +end + +function unlock () + locked = false + client_socket:settimeout(0) +end + +function process_request (req) + local res = {} + + if req["type"] == "PING" then + res["type"] = "PONG" + + elseif req["type"] == "SYSTEM" then + res["type"] = "SYSTEM_RESPONSE" + res["value"] = emu.getsystemid() + + elseif req["type"] == "PREFERRED_CORES" then + local preferred_cores = client.getconfig().PreferredCores + res["type"] = "PREFERRED_CORES_RESPONSE" + res["value"] = {} + res["value"]["NES"] = preferred_cores.NES + res["value"]["SNES"] = preferred_cores.SNES + res["value"]["GB"] = preferred_cores.GB + res["value"]["GBC"] = preferred_cores.GBC + res["value"]["DGB"] = preferred_cores.DGB + res["value"]["SGB"] = preferred_cores.SGB + res["value"]["PCE"] = preferred_cores.PCE + res["value"]["PCECD"] = preferred_cores.PCECD + res["value"]["SGX"] = preferred_cores.SGX + + elseif req["type"] == "HASH" then + res["type"] = "HASH_RESPONSE" + res["value"] = rom_hash + + elseif req["type"] == "GUARD" then + res["type"] = "GUARD_RESPONSE" + local expected_data = base64.decode(req["expected_data"]) + + local actual_data = memory.read_bytes_as_array(req["address"], #expected_data, req["domain"]) + + local data_is_validated = true + for i, byte in ipairs(actual_data) do + if byte ~= expected_data[i] then + data_is_validated = false + break + end + end + + res["value"] = data_is_validated + res["address"] = req["address"] + + elseif req["type"] == "LOCK" then + res["type"] = "LOCKED" + lock() + + elseif req["type"] == "UNLOCK" then + res["type"] = "UNLOCKED" + unlock() + + elseif req["type"] == "READ" then + res["type"] = "READ_RESPONSE" + res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"])) + + elseif req["type"] == "WRITE" then + res["type"] = "WRITE_RESPONSE" + memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"]) + + elseif req["type"] == "DISPLAY_MESSAGE" then + res["type"] = "DISPLAY_MESSAGE_RESPONSE" + message_queue:push(req["message"]) + + elseif req["type"] == "SET_MESSAGE_INTERVAL" then + res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE" + message_interval = req["value"] + + else + res["type"] = "ERROR" + res["err"] = "Unknown command: "..req["type"] + end + + return res +end + +-- Receive data from AP client and send message back +function send_receive () + local message, err = client_socket:receive() + + -- Handle errors + if err == "closed" then + if current_state == STATE_CONNECTED then + print("Connection to client closed") + end + current_state = STATE_NOT_CONNECTED + return + elseif err == "timeout" then + unlock() + return + elseif err ~= nil then + print(err) + current_state = STATE_NOT_CONNECTED + unlock() + return + end + + -- Reset timeout timer + timeout_timer = 5 + + -- Process received data + if DEBUG then + print("Received Message ["..emu.framecount().."]: "..'"'..message..'"') + end + + if message == "VERSION" then + local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n") + else + local res = {} + local data = json.decode(message) + local failed_guard_response = nil + for i, req in ipairs(data) do + if failed_guard_response ~= nil then + res[i] = failed_guard_response + else + -- An error is more likely to cause an NLua exception than to return an error here + local status, response = pcall(process_request, req) + if status then + res[i] = response + + -- If the GUARD validation failed, skip the remaining commands + if response["type"] == "GUARD_RESPONSE" and not response["value"] then + failed_guard_response = response + end + else + res[i] = {type = "ERROR", err = response} + end + end + end + + client_socket:send(json.encode(res).."\n") + end +end + +function main () + server, err = socket.bind("localhost", SOCKET_PORT) + if err ~= nil then + print(err) + return + end + + while true do + current_time = socket.socket.gettime() + timeout_timer = timeout_timer - (current_time - prev_time) + message_timer = message_timer - (current_time - prev_time) + prev_time = current_time + + if message_timer <= 0 and not message_queue:is_empty() then + gui.addmessage(message_queue:shift()) + message_timer = message_interval + end + + if current_state == STATE_NOT_CONNECTED then + if emu.framecount() % 60 == 0 then + server:settimeout(2) + local client, timeout = server:accept() + if timeout == nil then + print("Client connected") + current_state = STATE_CONNECTED + client_socket = client + client_socket:settimeout(0) + else + print("No client found. Trying again...") + end + end + else + repeat + send_receive() + until not locked + + if timeout_timer <= 0 then + print("Client timed out") + current_state = STATE_NOT_CONNECTED + end + end + + coroutine.yield() + end +end + +event.onexit(function () + print("\n-- Restarting Script --\n") + if server ~= nil then + server:close() + end +end) + +if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then + print("Must use BizHawk 2.7.0 or newer") +elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then + print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.") +else + if emu.getsystemid() == "NULL" then + print("No ROM is loaded. Please load a ROM.") + while emu.getsystemid() == "NULL" do + emu.frameadvance() + end + end + + rom_hash = gameinfo.getromhash() + + print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n") + + local co = coroutine.create(main) + function tick () + local status, err = coroutine.resume(co) + + if not status then + print("\nERROR: "..err) + print("Consider reporting this crash.\n") + + if server ~= nil then + server:close() + end + + co = coroutine.create(main) + end + end + + -- Gambatte has a setting which can cause script execution to become + -- misaligned, so for GB and GBC we explicitly set the callback on + -- vblank instead. + -- https://github.com/TASEmulators/BizHawk/issues/3711 + if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then + event.onmemoryexecute(tick, 0x40, "tick", "System Bus") + else + event.onframeend(tick) + end + + while true do + emu.frameadvance() + end +end diff --git a/data/lua/FF1/ff1_connector.lua b/data/lua/connector_ff1.lua similarity index 85% rename from data/lua/FF1/ff1_connector.lua rename to data/lua/connector_ff1.lua index 6b2eec269a..455b046961 100644 --- a/data/lua/FF1/ff1_connector.lua +++ b/data/lua/connector_ff1.lua @@ -1,6 +1,7 @@ local socket = require("socket") local json = require('json') local math = require('math') +require("common") local STATE_OK = "Ok" local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" @@ -102,15 +103,12 @@ local noOverworldItemsLookup = { [500] = 0x12, } -local itemMessages = {} local consumableStacks = nil local prevstate = "" local curstate = STATE_UNINITIALIZED local ff1Socket = nil local frame = 0 -local u8 = nil -local wU8 = nil local isNesHawk = false @@ -134,9 +132,6 @@ local function defineMemoryFunctions() end local memDomain = defineMemoryFunctions() -u8 = memory.read_u8 -wU8 = memory.write_u8 -uRange = memory.readbyterange local function StateOKForMainLoop() memDomain.saveram() @@ -146,83 +141,6 @@ local function StateOKForMainLoop() return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2) end -function table.empty (self) - for _, _ in pairs(self) do - return false - end - return true -end - -function slice (tbl, s, e) - local pos, new = 1, {} - for i = s + 1, e do - new[pos] = tbl[i] - pos = pos + 1 - end - return new -end - -local bizhawk_version = client.getversion() -local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") -local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8") - -local function getMaxMessageLength() - if is23Or24Or25 then - return client.screenwidth()/11 - elseif is26To28 then - return client.screenwidth()/12 - end -end - -local function drawText(x, y, message, color) - if is23Or24Or25 then - gui.addmessage(message) - elseif is26To28 then - gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client") - end -end - -local function clearScreen() - if is23Or24Or25 then - return - elseif is26To28 then - drawText(0, 0, "", "black") - end -end - -local function drawMessages() - if table.empty(itemMessages) then - clearScreen() - return - end - local y = 10 - found = false - maxMessageLength = getMaxMessageLength() - for k, v in pairs(itemMessages) do - if v["TTL"] > 0 then - message = v["message"] - while true do - drawText(5, y, message:sub(1, maxMessageLength), v["color"]) - y = y + 16 - - message = message:sub(maxMessageLength + 1, message:len()) - if message:len() == 0 then - break - end - end - newTTL = 0 - if is26To28 then - newTTL = itemMessages[k]["TTL"] - 1 - end - itemMessages[k]["TTL"] = newTTL - found = true - end - end - if found == false then - clearScreen() - end -end - function generateLocationChecked() memDomain.saveram() data = uRange(0x01FF, 0x101) @@ -316,7 +234,14 @@ function getEmptyArmorSlots() end return ret end - +local function slice (tbl, s, e) + local pos, new = 1, {} + for i = s + 1, e do + new[pos] = tbl[i] + pos = pos + 1 + end + return new +end function processBlock(block) local msgBlock = block['messages'] if msgBlock ~= nil then @@ -448,18 +373,6 @@ function processBlock(block) end end -function difference(a, b) - local aa = {} - for k,v in pairs(a) do aa[v]=true end - for k,v in pairs(b) do aa[v]=nil end - local ret = {} - local n = 0 - for k,v in pairs(a) do - if aa[v] then n=n+1 ret[n]=v end - end - return ret -end - function receive() l, e = ff1Socket:receive() if e == 'closed' then @@ -501,8 +414,7 @@ function receive() end function main() - if (is23Or24Or25 or is26To28) == false then - print("Must use a version of bizhawk 2.3.1 or higher") + if not checkBizHawkVersion() then return end server, error = socket.bind('localhost', 52980) diff --git a/data/lua/connector_ladx_bizhawk.lua b/data/lua/connector_ladx_bizhawk.lua index e318015cb0..6a5fbf700c 100644 --- a/data/lua/connector_ladx_bizhawk.lua +++ b/data/lua/connector_ladx_bizhawk.lua @@ -3,8 +3,8 @@ -- SPDX-License-Identifier: MIT -- This script attempts to implement the basic functionality needed in order for --- the LADXR Archipelago client to be able to talk to BizHawk instead of RetroArch --- by reproducing the RetroArch API with BizHawk's Lua interface. +-- the LADXR Archipelago client to be able to talk to EmuHawk instead of RetroArch +-- by reproducing the RetroArch API with EmuHawk's Lua interface. -- -- RetroArch UDP API: https://github.com/libretro/RetroArch/blob/master/command.c -- @@ -16,19 +16,19 @@ -- commands are supported right now. -- -- USAGE: --- Load this script in BizHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script") +-- Load this script in EmuHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script", or drag+drop) -- -- All inconsistencies (like missing newlines for some commands) of the RetroArch -- UDP API (network_cmd_enable) are reproduced as-is in order for clients written to work with -- RetroArch's current API to "just work"(tm). -- -- This script has only been tested on GB(C). If you have made sure it works for N64 or other --- cores supported by BizHawk, please let me know. Note that GET_STATUS, at the very least, will +-- cores supported by EmuHawk, please let me know. Note that GET_STATUS, at the very least, will -- have to be adjusted. -- -- -- NOTE: --- BizHawk's Lua API is very trigger-happy on throwing exceptions. +-- EmuHawk's Lua API is very trigger-happy on throwing exceptions. -- Emulation will continue fine, but the RetroArch API layer will stop working. This -- is indicated only by an exception visible in the Lua console, which most players -- will probably not have in the foreground. @@ -43,13 +43,13 @@ local socket = require("socket") -local udp = socket.udp() +udp = socket.socket.udp() +require('common') udp:setsockname('127.0.0.1', 55355) udp:settimeout(0) - -while true do +function on_vblank() -- Attempt to lessen the CPU load by only polling the UDP socket every x frames. -- x = 10 is entirely arbitrary, very little thought went into it. -- We could try to make use of client.get_approx_framerate() here, but the values returned @@ -82,7 +82,7 @@ while true do -- "GET_STATUS PLAYING game_boy,AP_62468482466172374046_P1_Lonk,crc32=3ecb7b6f" -- CRC32 isn't readily available through the Lua API. We could calculate -- it ourselves, but since LADXR doesn't make use of this field it is - -- simply replaced by the hash that BizHawk _does_ make available. + -- simply replaced by the hash that EmuHawk _does_ make available. udp:sendto( "GET_STATUS " .. status .. " game_boy," .. @@ -97,6 +97,7 @@ while true do end elseif command == "READ_CORE_MEMORY" then local _, address, length = string.match(data, "(%S+) (%S+) (%S+)") + address = stripPrefix(address, "0x") address = tonumber(address, 16) length = tonumber(length) @@ -111,17 +112,20 @@ while true do for _, v in ipairs(mem) do hex_string = hex_string .. string.format("%02X ", v) end + hex_string = hex_string:sub(1, -2) -- Hang head in shame, remove last " " local reply = string.format("%s %02x %s\n", command, address, hex_string) udp:sendto(reply, msg_or_ip, port_or_nil) elseif command == "WRITE_CORE_MEMORY" then local _, address = string.match(data, "(%S+) (%S+)") + address = stripPrefix(address, "0x") address = tonumber(address, 16) local to_write = {} local i = 1 for byte_str in string.gmatch(data, "%S+") do if i > 2 then + byte_str = stripPrefix(byte_str, "0x") table.insert(to_write, tonumber(byte_str, 16)) end i = i + 1 @@ -132,6 +136,10 @@ while true do udp:sendto(reply, msg_or_ip, port_or_nil) end end - - emu.frameadvance() +end + +event.onmemoryexecute(on_vblank, 0x40, "ap_connector_vblank") + +while true do + emu.yield() end diff --git a/data/lua/connector_mmbn3.lua b/data/lua/connector_mmbn3.lua new file mode 100644 index 0000000000..8482bf85b1 --- /dev/null +++ b/data/lua/connector_mmbn3.lua @@ -0,0 +1,723 @@ +local socket = require("socket") +local json = require('json') +local math = require('math') +require('common') + +local last_modified_date = '2023-31-05' -- Should be the last modified date +local script_version = 4 + +local bizhawk_version = client.getversion() +local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)") +bizhawk_major = tonumber(bizhawk_major) +bizhawk_minor = tonumber(bizhawk_minor) +if bizhawk_patch == "" then + bizhawk_patch = 0 +else + bizhawk_patch = tonumber(bizhawk_patch) +end + +local STATE_OK = "Ok" +local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" +local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" +local STATE_UNINITIALIZED = "Uninitialized" + +local prevstate = "" +local curstate = STATE_UNINITIALIZED +local mmbn3Socket = nil +local frame = 0 + +-- States +local ITEMSTATE_NONINITIALIZED = "Game Not Yet Started" -- Game has not yet started +local ITEMSTATE_NONITEM = "Non-Itemable State" -- Do not send item now. RAM is not capable of holding +local ITEMSTATE_IDLE = "Item State Ready" -- Ready for the next item if there are any +local ITEMSTATE_SENT = "Item Sent Not Claimed" -- The ItemBit is set, but the dialog has not been closed yet +local itemState = ITEMSTATE_NONINITIALIZED + +local itemQueued = nil +local itemQueueCounter = 120 + +local debugEnabled = false +local game_complete = false + +local backup_bytes = nil + +local itemsReceived = {} +local previousMessageBit = 0x00 + +local key_item_start_address = 0x20019C0 + +-- The Canary Byte is a flag byte that is intentionally left unused. If this byte is FF, then we know the flag +-- data cannot be trusted, so we don't send checks. +local canary_byte = 0x20001A9 + +local charDict = { + [' ']=0x00,['0']=0x01,['1']=0x02,['2']=0x03,['3']=0x04,['4']=0x05,['5']=0x06,['6']=0x07,['7']=0x08,['8']=0x09,['9']=0x0A, + ['A']=0x0B,['B']=0x0C,['C']=0x0D,['D']=0x0E,['E']=0x0F,['F']=0x10,['G']=0x11,['H']=0x12,['I']=0x13,['J']=0x14,['K']=0x15, + ['L']=0x16,['M']=0x17,['N']=0x18,['O']=0x19,['P']=0x1A,['Q']=0x1B,['R']=0x1C,['S']=0x1D,['T']=0x1E,['U']=0x1F,['V']=0x20, + ['W']=0x21,['X']=0x22,['Y']=0x23,['Z']=0x24,['a']=0x25,['b']=0x26,['c']=0x27,['d']=0x28,['e']=0x29,['f']=0x2A,['g']=0x2B, + ['h']=0x2C,['i']=0x2D,['j']=0x2E,['k']=0x2F,['l']=0x30,['m']=0x31,['n']=0x32,['o']=0x33,['p']=0x34,['q']=0x35,['r']=0x36, + ['s']=0x37,['t']=0x38,['u']=0x39,['v']=0x3A,['w']=0x3B,['x']=0x3C,['y']=0x3D,['z']=0x3E,['-']=0x3F,['×']=0x40,[']=']=0x41, + [':']=0x42,['+']=0x43,['÷']=0x44,['※']=0x45,['*']=0x46,['!']=0x47,['?']=0x48,['%']=0x49,['&']=0x4A,[',']=0x4B,['⋯']=0x4C, + ['.']=0x4D,['・']=0x4E,[';']=0x4F,['\'']=0x50,['\"']=0x51,['~']=0x52,['/']=0x53,['(']=0x54,[')']=0x55,['「']=0x56,['」']=0x57, + ["[V2]"]=0x58,["[V3]"]=0x59,["[V4]"]=0x5A,["[V5]"]=0x5B,['@']=0x5C,['♥']=0x5D,['♪']=0x5E,["[MB]"]=0x5F,['■']=0x60,['_']=0x61, + ["[circle1]"]=0x62,["[circle2]"]=0x63,["[cross1]"]=0x64,["[cross2]"]=0x65,["[bracket1]"]=0x66,["[bracket2]"]=0x67,["[ModTools1]"]=0x68, + ["[ModTools2]"]=0x69,["[ModTools3]"]=0x6A,['Σ']=0x6B,['Ω']=0x6C,['α']=0x6D,['β']=0x6E,['#']=0x6F,['…']=0x70,['>']=0x71, + ['<']=0x72,['エ']=0x73,["[BowneGlobal1]"]=0x74,["[BowneGlobal2]"]=0x75,["[BowneGlobal3]"]=0x76,["[BowneGlobal4]"]=0x77, + ["[BowneGlobal5]"]=0x78,["[BowneGlobal6]"]=0x79,["[BowneGlobal7]"]=0x7A,["[BowneGlobal8]"]=0x7B,["[BowneGlobal9]"]=0x7C, + ["[BowneGlobal10]"]=0x7D,["[BowneGlobal11]"]=0x7E,['\n']=0xE8 +} + +local TableConcat = function(t1,t2) + for i=1,#t2 do + t1[#t1+1] = t2[i] + end + return t1 +end +local int32ToByteList_le = function(x) + bytes = {} + hexString = string.format("%08x", x) + for i=#hexString, 1, -2 do + hbyte = hexString:sub(i-1, i) + table.insert(bytes,tonumber(hbyte,16)) + end + return bytes +end +local int16ToByteList_le = function(x) + bytes = {} + hexString = string.format("%04x", x) + for i=#hexString, 1, -2 do + hbyte = hexString:sub(i-1, i) + table.insert(bytes,tonumber(hbyte,16)) + end + return bytes +end + +local IsInMenu = function() + return bit.band(memory.read_u8(0x0200027A),0x10) ~= 0 +end +local IsInTransition = function() + return bit.band(memory.read_u8(0x02001880), 0x10) ~= 0 +end +local IsInDialog = function() + return bit.band(memory.read_u8(0x02009480),0x01) ~= 0 +end +local IsInBattle = function() + return memory.read_u8(0x020097F8) == 0x08 +end +local IsItemQueued = function() + return memory.read_u8(0x2000224) == 0x01 +end + +-- This function actually determines when you're on ANY full-screen menu (navi cust, link battle, etc.) but we +-- don't want to check any locations there either so it's fine. +local IsOnTitle = function() + return bit.band(memory.read_u8(0x020097F8),0x04) == 0 +end +local IsItemable = function() + return not IsInMenu() and not IsInTransition() and not IsInDialog() and not IsInBattle() and not IsOnTitle() and not IsItemQueued() +end + +local is_game_complete = function() + if IsOnTitle() or itemState == ITEMSTATE_NONINITIALIZED then return game_complete end + + -- If the game is already marked complete, do not read memory + if game_complete then return true end + local is_alpha_defeated = bit.band(memory.read_u8(0x2000433), 0x01) ~= 0 + + if (is_alpha_defeated) then + game_complete = true + return true + end + + -- Game is still ongoing + return false +end + +local saveItemIndexToRAM = function(newIndex) + memory.write_s16_le(0x20000AE,newIndex) +end + +local loadItemIndexFromRAM = function() + last_index = memory.read_s16_le(0x20000AE) + if (last_index < 0) then + last_index = 0 + saveItemIndexToRAM(0) + end + return last_index +end + +local loadPlayerNameFromROM = function() + return memory.read_bytes_as_array(0x7FFFC0,63,"ROM") +end + +local check_all_locations = function() + local location_checks = {} + -- Title Screen should not check items + if itemState == ITEMSTATE_NONINITIALIZED or IsInTransition() then + return location_checks + end + if memory.read_u8(canary_byte) == 0xFF then + return location_checks + end + for k,v in pairs(memory.read_bytes_as_dict(0x02000000, 0x434)) do + str_k = string.format("%x", k) + location_checks[str_k] = v + end + return location_checks +end + +local Check_Progressive_Undernet_ID = function() + ordered_offsets = { 0x020019DB,0x020019DC,0x020019DD,0x020019DE,0x020019DF,0x020019E0,0x020019FA,0x020019E2 } + for i=1,#ordered_offsets do + offset=ordered_offsets[i] + + if memory.read_u8(offset) == 0 then + return i + end + end + return 9 +end +local GenerateTextBytes = function(message) + bytes = {} + for i = 1, #message do + local c = message:sub(i,i) + table.insert(bytes, charDict[c]) + end + return bytes +end + +-- Item Message Generation functions +local Next_Progressive_Undernet_ID = function(index) + ordered_IDs = { 27,28,29,30,31,32,58,34} + if index > #ordered_IDs then + --It shouldn't reach this point, but if it does, just give another GigFreez I guess + return 34 + end + item_index=ordered_IDs[index] + return item_index +end +local Extra_Progressive_Undernet = function() + fragBytes = int32ToByteList_le(20) + bytes = { + 0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF + } + bytes = TableConcat(bytes, GenerateTextBytes("The extra data\ndecompiles into:\n\"20 BugFrags\"!!")) + return bytes +end + +local GenerateChipGet = function(chip, code, amt) + chipBytes = int16ToByteList_le(chip) + bytes = { + 0xF6, 0x10, chipBytes[1], chipBytes[2], code, amt, + charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['c'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'], + + } + if chip < 256 then + bytes = TableConcat(bytes, { + charDict['\"'], 0xF9,0x00,chipBytes[1],0x01,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!'] + }) + else + bytes = TableConcat(bytes, { + charDict['\"'], 0xF9,0x00,chipBytes[1],0x02,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!'] + }) + end + return bytes +end +local GenerateKeyItemGet = function(item, amt) + bytes = { + 0xF6, 0x00, item, amt, + charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'], + charDict['\"'], 0xF9, 0x00, item, 0x00, charDict['\"'],charDict['!'],charDict['!'] + } + return bytes +end +local GenerateSubChipGet = function(subchip, amt) + -- SubChips have an extra bit of trouble. If you have too many, they're supposed to skip to another text bank that doesn't give you the item + -- Instead, I'm going to just let it get eaten + bytes = { + 0xF6, 0x20, subchip, amt, 0xFF, 0xFF, 0xFF, + charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'], + charDict['S'], charDict['u'], charDict['b'], charDict['C'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'], + charDict['\"'], 0xF9, 0x00, subchip, 0x00, charDict['\"'],charDict['!'],charDict['!'] + } + return bytes +end +local GenerateZennyGet = function(amt) + zennyBytes = int32ToByteList_le(amt) + bytes = { + 0xF6, 0x30, zennyBytes[1], zennyBytes[2], zennyBytes[3], zennyBytes[4], 0xFF, 0xFF, 0xFF, + charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'], charDict['\"'] + } + -- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it + zennyStr = tostring(amt) + for i = 1, #zennyStr do + local c = zennyStr:sub(i,i) + table.insert(bytes, charDict[c]) + end + bytes = TableConcat(bytes, { + charDict[' '], charDict['Z'], charDict['e'], charDict['n'], charDict['n'], charDict['y'], charDict['s'], charDict['\"'],charDict['!'],charDict['!'] + }) + return bytes +end +local GenerateProgramGet = function(program, color, amt) + bytes = { + 0xF6, 0x40, (program * 4), amt, color, + charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['N'], charDict['a'], charDict['v'], charDict['i'], charDict['\n'], + charDict['C'], charDict['u'], charDict['s'], charDict['t'], charDict['o'], charDict['m'], charDict['i'], charDict['z'], charDict['e'], charDict['r'], charDict[' '], charDict['P'], charDict['r'], charDict['o'], charDict['g'], charDict['r'], charDict['a'], charDict['m'], charDict[':'], charDict['\n'], + charDict['\"'], 0xF9, 0x00, program, 0x05, charDict['\"'],charDict['!'],charDict['!'] + } + + return bytes +end +local GenerateBugfragGet = function(amt) + fragBytes = int32ToByteList_le(amt) + bytes = { + 0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF, + charDict['G'], charDict['o'], charDict['t'], charDict[':'], charDict['\n'], charDict['\"'] + } + -- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it + bugFragStr = tostring(amt) + for i = 1, #bugFragStr do + local c = bugFragStr:sub(i,i) + table.insert(bytes, charDict[c]) + end + bytes = TableConcat(bytes, { + charDict[' '], charDict['B'], charDict['u'], charDict['g'], charDict['F'], charDict['r'], charDict['a'], charDict['g'], charDict['s'], charDict['\"'],charDict['!'],charDict['!'] + }) + return bytes +end +local GenerateGetMessageFromItem = function(item) + --Special case for progressive undernet + if item["type"] == "undernet" then + undernet_id = Check_Progressive_Undernet_ID() + if undernet_id > 8 then + return Extra_Progressive_Undernet() + end + return GenerateKeyItemGet(Next_Progressive_Undernet_ID(undernet_id),1) + elseif item["type"] == "chip" then + return GenerateChipGet(item["itemID"], item["subItemID"], item["count"]) + elseif item["type"] == "key" then + return GenerateKeyItemGet(item["itemID"], item["count"]) + elseif item["type"] == "subchip" then + return GenerateSubChipGet(item["itemID"], item["count"]) + elseif item["type"] == "zenny" then + return GenerateZennyGet(item["count"]) + elseif item["type"] == "program" then + return GenerateProgramGet(item["itemID"], item["subItemID"], item["count"]) + elseif item["type"] == "bugfrag" then + return GenerateBugfragGet(item["count"]) + end + + return GenerateTextBytes("Empty Message") +end + +local GetMessage = function(item) + startBytes = {0x02, 0x00} + playerLockBytes = {0xF8,0x00, 0xF8, 0x10} + msgOpenBytes = {0xF1, 0x02} + textBytes = GenerateTextBytes("Receiving\ndata from\n"..item["sender"]..".") + dotdotWaitBytes = {0xEA,0x00,0x0A,0x00,0x4D,0xEA,0x00,0x0A,0x00,0x4D} + continueBytes = {0xEB, 0xE9} + -- continueBytes = {0xE9} + playReceiveAnimationBytes = {0xF8,0x04,0x18} + chipGiveBytes = GenerateGetMessageFromItem(item) + playerFinishBytes = {0xF8, 0x0C} + playerUnlockBytes={0xEB, 0xF8, 0x08} + -- playerUnlockBytes={0xF8, 0x08} + endMessageBytes = {0xF8, 0x10, 0xE7} + + bytes = {} + bytes = TableConcat(bytes,startBytes) + bytes = TableConcat(bytes,playerLockBytes) + bytes = TableConcat(bytes,msgOpenBytes) + bytes = TableConcat(bytes,textBytes) + bytes = TableConcat(bytes,dotdotWaitBytes) + bytes = TableConcat(bytes,continueBytes) + bytes = TableConcat(bytes,playReceiveAnimationBytes) + bytes = TableConcat(bytes,chipGiveBytes) + bytes = TableConcat(bytes,playerFinishBytes) + bytes = TableConcat(bytes,playerUnlockBytes) + bytes = TableConcat(bytes,endMessageBytes) + return bytes +end + +local getChipCodeIndex = function(chip_id, chip_code) + chipCodeArrayStartAddress = 0x8011510 + (0x20 * chip_id) + for i=1,6 do + currentCode = memory.read_u8(chipCodeArrayStartAddress + (i-1)) + if currentCode == chip_code then + return i-1 + end + end + return 0 +end + +local getProgramColorIndex = function(program_id, program_color) + -- The general case, most programs use white pink or yellow. This is the values the enums already have + if program_id >= 20 and program_id <= 47 then + return program_color-1 + end + --The final three programs only have a color index 0, so just return those + if program_id > 47 then + return 0 + end + --BrakChrg as an AP item only comes in orange, index 0 + if program_id == 3 then + return 0 + end + -- every other AP obtainable program returns only color index 3 + return 3 +end + +local addChip = function(chip_id, chip_code, amount) + chipStartAddress = 0x02001F60 + chipOffset = 0x12 * chip_id + chip_code_index = getChipCodeIndex(chip_id, chip_code) + currentChipAddress = chipStartAddress + chipOffset + chip_code_index + currentChipCount = memory.read_u8(currentChipAddress) + memory.write_u8(currentChipAddress,currentChipCount+amount) +end + +local addProgram = function(program_id, program_color, amount) + programStartAddress = 0x02001A80 + programOffset = 0x04 * program_id + program_code_index = getProgramColorIndex(program_id, program_color) + currentProgramAddress = programStartAddress + programOffset + program_code_index + currentProgramCount = memory.read_u8(currentProgramAddress) + memory.write_u8(currentProgramAddress, currentProgramCount+amount) +end + +local addSubChip = function(subchip_id, amount) + subChipStartAddress = 0x02001A30 + --SubChip indices start after the key items, so subtract 112 from the index to get the actual subchip index + currentSubChipAddress = subChipStartAddress + (subchip_id - 112) + currentSubChipCount = memory.read_u8(currentSubChipAddress) + --TODO check submem, reject if number too big + memory.write_u8(currentSubChipAddress, currentSubChipCount+amount) +end + +local changeZenny = function(val) + if val == nil then + return 0 + end + if memory.read_u32_le(0x20018F4) <= math.abs(tonumber(val)) and tonumber(val) < 0 then + memory.write_u32_le(0x20018f4, 0) + val = 0 + return "empty" + end + memory.write_u32_le(0x20018f4, memory.read_u32_le(0x20018F4) + tonumber(val)) + if memory.read_u32_le(0x20018F4) > 999999 then + memory.write_u32_le(0x20018F4, 999999) + end + return val +end + +local changeFrags = function(val) + if val == nil then + return 0 + end + if memory.read_u16_le(0x20018F8) <= math.abs(tonumber(val)) and tonumber(val) < 0 then + memory.write_u16_le(0x20018f8, 0) + val = 0 + return "empty" + end + memory.write_u16_le(0x20018f8, memory.read_u16_le(0x20018F8) + tonumber(val)) + if memory.read_u16_le(0x20018F8) > 9999 then + memory.write_u16_le(0x20018F8, 9999) + end + return val +end + +-- Fix Health Pools +local fix_hp = function() + -- Current Health fix + if IsInBattle() and not (memory.read_u16_le(0x20018A0) == memory.read_u16_le(0x2037294)) then + memory.write_u16_le(0x20018A0, memory.read_u16_le(0x2037294)) + end + + -- Max Health Fix + if IsInBattle() and not (memory.read_u16_le(0x20018A2) == memory.read_u16_le(0x2037296)) then + memory.write_u16_le(0x20018A2, memory.read_u16_le(0x2037296)) + end +end + +local changeRegMemory = function(amt) + regMemoryAddress = 0x02001897 + currentRegMem = memory.read_u8(regMemoryAddress) + memory.write_u8(regMemoryAddress, currentRegMem + amt) +end + +local changeMaxHealth = function(val) + fix_hp() + if val == nil then + fix_hp() + return 0 + end + if math.abs(tonumber(val)) >= memory.read_u16_le(0x20018A2) and tonumber(val) < 0 then + memory.write_u16_le(0x20018A2, 0) + if IsInBattle() then + memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2)) + if memory.read_u16_le(0x2037296) >= memory.read_u16_le(0x20018A2) then + memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2)) + end + end + fix_hp() + return "lethal" + end + memory.write_u16_le(0x20018A2, memory.read_u16_le(0x20018A2) + tonumber(val)) + if memory.read_u16_le(0x20018A2) > 9999 then + memory.write_u16_le(0x20018A2, 9999) + end + if IsInBattle() then + memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2)) + end + fix_hp() + return val +end + +local SendItem = function(item) + if item["type"] == "undernet" then + undernet_id = Check_Progressive_Undernet_ID() + if undernet_id > 8 then + -- Generate Extra BugFrags + changeFrags(20) + gui.addmessage("Receiving extra Undernet Rank from "..item["sender"]..", +20 BugFrags") + -- print("Receiving extra Undernet Rank from "..item["sender"]..", +20 BugFrags") + else + itemAddress = key_item_start_address + Next_Progressive_Undernet_ID(undernet_id) + + itemCount = memory.read_u8(itemAddress) + itemCount = itemCount + item["count"] + memory.write_u8(itemAddress, itemCount) + gui.addmessage("Received Undernet Rank from player "..item["sender"]) + -- print("Received Undernet Rank from player "..item["sender"]) + end + elseif item["type"] == "chip" then + addChip(item["itemID"], item["subItemID"], item["count"]) + gui.addmessage("Received Chip "..item["itemName"].." from player "..item["sender"]) + -- print("Received Chip "..item["itemName"].." from player "..item["sender"]) + elseif item["type"] == "key" then + itemAddress = key_item_start_address + item["itemID"] + itemCount = memory.read_u8(itemAddress) + itemCount = itemCount + item["count"] + memory.write_u8(itemAddress, itemCount) + -- HPMemory will increase the internal counter but not actually increase the HP. If the item is one of those, do that + if item["itemID"] == 96 then + changeMaxHealth(20) + end + -- Same for the RegUps, but there's three of those + if item["itemID"] == 98 then + changeRegMemory(1) + end + if item["itemID"] == 99 then + changeRegMemory(2) + end + if item["itemID"] == 100 then + changeRegMemory(3) + end + gui.addmessage("Received Key Item "..item["itemName"].." from player "..item["sender"]) + -- print("Received Key Item "..item["itemName"].." from player "..item["sender"]) + elseif item["type"] == "subchip" then + addSubChip(item["itemID"], item["count"]) + gui.addmessage("Received SubChip "..item["itemName"].." from player "..item["sender"]) + -- print("Received SubChip "..item["itemName"].." from player "..item["sender"]) + elseif item["type"] == "zenny" then + changeZenny(item["count"]) + gui.addmessage("Received "..item["count"].."z from "..item["sender"]) + -- print("Received "..item["count"].."z from "..item["sender"]) + elseif item["type"] == "program" then + addProgram(item["itemID"], item["subItemID"], item["count"]) + gui.addmessage("Received Program "..item["itemName"].." from player "..item["sender"]) + -- print("Received Program "..item["itemName"].." from player "..item["sender"]) + elseif item["type"] == "bugfrag" then + changeFrags(item["count"]) + gui.addmessage("Received "..item["count"].." BugFrag(s) from "..item["sender"]) + -- print("Received "..item["count"].." BugFrag(s) from "..item["sender"]) + end +end + +-- Set the flags for opening the shortcuts as soon as the Cybermetro passes are received to save having to check email +local OpenShortcuts = function() + if (memory.read_u8(key_item_start_address + 92) > 0) then + memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x10)) + end + -- if CSciPass + if (memory.read_u8(key_item_start_address + 93) > 0) then + memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x08)) + end + if (memory.read_u8(key_item_start_address + 94) > 0) then + memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x20)) + end + if (memory.read_u8(key_item_start_address + 95) > 0) then + memory.write_u8(0x2000032, bit.bor(memory.read_u8(0x2000032),0x40)) + end +end + +local RestoreItemRam = function() + if backup_bytes ~= nil then + memory.write_bytes_as_array(0x203fe10, backup_bytes) + end + backup_bytes = nil +end + +local process_block = function(block) + -- Sometimes the block is nothing, if this is the case then quietly stop processing + if block == nil then + return + end + debugEnabled = block['debug'] + -- Queue item for receiving, if one exists + if (itemsReceived ~= block['items']) then + itemsReceived = block['items'] + end + return +end + +local itemStateMachineProcess = function() + if itemState == ITEMSTATE_NONINITIALIZED then + itemQueueCounter = 120 + -- Only exit this state the first time a dialog window pops up. This way we know for sure that we're ready to receive + if not IsInMenu() and (IsInDialog() or IsInTransition()) then + itemState = ITEMSTATE_NONITEM + end + elseif itemState == ITEMSTATE_NONITEM then + itemQueueCounter = 120 + -- Always attempt to restore the previously stored memory in this state + -- Exit this state whenever the game is in an itemable status + if IsItemable() then + itemState = ITEMSTATE_IDLE + end + elseif itemState == ITEMSTATE_IDLE then + -- Remain Idle until an item is sent or we enter a non itemable status + if not IsItemable() then + itemState = ITEMSTATE_NONITEM + end + if itemQueueCounter == 0 then + if #itemsReceived > loadItemIndexFromRAM() and not IsItemQueued() then + itemQueued = itemsReceived[loadItemIndexFromRAM()+1] + SendItem(itemQueued) + itemState = ITEMSTATE_SENT + end + else + itemQueueCounter = itemQueueCounter - 1 + end + elseif itemState == ITEMSTATE_SENT then + -- Once the item is sent, wait for the dialog to close. Then clear the item bit and be ready for the next item. + if IsInTransition() or IsInMenu() or IsOnTitle() then + itemState = ITEMSTATE_NONITEM + itemQueued = nil + RestoreItemRam() + elseif not IsInDialog() then + itemState = ITEMSTATE_IDLE + saveItemIndexToRAM(itemQueued["itemIndex"]) + itemQueued = nil + RestoreItemRam() + end + end +end +local receive = function() + l, e = mmbn3Socket:receive() + + -- Handle incoming message + if e == 'closed' then + if curstate == STATE_OK then + print("Connection closed") + end + curstate = STATE_UNINITIALIZED + return + elseif e == 'timeout' then + print("timeout") + return + elseif e ~= nil then + print(e) + curstate = STATE_UNINITIALIZED + return + end + process_block(json.decode(l)) +end + +local send = function() + -- Determine message to send back + local retTable = {} + retTable["playerName"] = loadPlayerNameFromROM() + retTable["scriptVersion"] = script_version + retTable["locations"] = check_all_locations() + retTable["gameComplete"] = is_game_complete() + + -- Send the message + msg = json.encode(retTable).."\n" + local ret, error = mmbn3Socket: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 (bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor >= 7)==false) then + print("Must use a version of bizhawk 2.7.0 or higher") + return + end + server, error = socket.bind('localhost', 28922) + + while true do + frame = frame + 1 + + if not (curstate == prevstate) then + prevstate = curstate + end + + itemStateMachineProcess() + + if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then + -- If we're connected and everything's fine, receive and send data from the network + if (frame % 60 == 0) then + receive() + send() + -- Perform utility functions which read and write data but aren't directly related to checks + OpenShortcuts() + end + elseif (curstate == STATE_UNINITIALIZED) then + -- If we're uninitialized, attempt to make the connection. + if (frame % 120 == 0) then + server:settimeout(2) + local client, timeout = server:accept() + if timeout == nil then + print('Initial Connection Made') + curstate = STATE_INITIAL_CONNECTION_MADE + mmbn3Socket = client + mmbn3Socket:settimeout(0) + else + print('Connection failed, ensure MMBN3Client is running and rerun connector_mmbn3.lua') + return + end + end + end + + -- Handle the debug data display + gui.cleartext() + if debugEnabled then + -- gui.text(0,0,"Item Queued: "..tostring(IsItemQueued())) + -- gui.text(0,16,"In Battle: "..tostring(IsInBattle())) + -- gui.text(0,32,"In Dialog: "..tostring(IsInDialog())) + -- gui.text(0,48,"In Menu: "..tostring(IsInMenu())) + gui.text(0,48,"Item Wait Time: "..tostring(itemQueueCounter)) + gui.text(0,64,itemState) + if itemQueued == nil then + gui.text(0,80,"No item queued") + else + gui.text(0,80,itemQueued["type"].." "..itemQueued["itemID"]) + end + gui.text(0,96,"Item Index: "..loadItemIndexFromRAM()) + end + + emu.frameadvance() + end +end + +main() \ No newline at end of file diff --git a/data/lua/OOT/oot_connector.lua b/data/lua/connector_oot.lua similarity index 99% rename from data/lua/OOT/oot_connector.lua rename to data/lua/connector_oot.lua index cfcf6e334d..7bec37244b 100644 --- a/data/lua/OOT/oot_connector.lua +++ b/data/lua/connector_oot.lua @@ -1,8 +1,9 @@ local socket = require("socket") local json = require('json') local math = require('math') +require('common') -local last_modified_date = '2022-11-27' -- Should be the last modified date +local last_modified_date = '2022-4-15' -- Should be the last modified date local script_version = 3 -------------------------------------------------- @@ -1861,8 +1862,7 @@ function receive() end function main() - if (is23Or24Or25 or is26To27) == false then - print("Must use a version of bizhawk 2.3.1 or higher") + if not checkBizHawkVersion() then return end server, error = socket.bind('localhost', 28921) @@ -1886,7 +1886,7 @@ function main() ootSocket = client ootSocket:settimeout(0) else - print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua') + print('Connection failed, ensure OoTClient is running and rerun connector_oot.lua') return end end @@ -1895,4 +1895,4 @@ function main() end end -main() \ No newline at end of file +main() diff --git a/data/lua/PKMN_RB/pkmn_rb.lua b/data/lua/connector_pkmn_rb.lua similarity index 84% rename from data/lua/PKMN_RB/pkmn_rb.lua rename to data/lua/connector_pkmn_rb.lua index 036f7a6255..3f56435bdb 100644 --- a/data/lua/PKMN_RB/pkmn_rb.lua +++ b/data/lua/connector_pkmn_rb.lua @@ -1,7 +1,7 @@ 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" @@ -32,9 +32,6 @@ local curstate = STATE_UNINITIALIZED local gbSocket = nil local frame = 0 -local u8 = nil -local wU8 = nil -local u16 local compat = nil local function defineMemoryFunctions() @@ -55,68 +52,42 @@ function uRange(address, bytes) return data end - -function table.empty (self) - for _, _ in pairs(self) do - return false - end - return true -end - -function slice (tbl, s, e) - local pos, new = 1, {} - for i = s + 1, e do - new[pos] = tbl[i] - pos = pos + 1 - end - return new -end - -function difference(a, b) - local aa = {} - for k,v in pairs(a) do aa[v]=true end - for k,v in pairs(b) do aa[v]=nil end - local ret = {} - local n = 0 - for k,v in pairs(a) do - if aa[v] then n=n+1 ret[n]=v end - end - return ret -end - function generateLocationsChecked() memDomain.wram() events = uRange(EventFlagAddress, 0x140) missables = uRange(MissableAddress, 0x20) hiddenitems = uRange(HiddenItemsAddress, 0x0E) + rod = {u8(RodAddress)} dexsanity = uRange(DexSanityAddress, 19) - rod = u8(RodAddress) + data = {} - table.foreach(events, function(k, v) table.insert(data, v) end) - table.foreach(missables, function(k, v) table.insert(data, v) end) - table.foreach(hiddenitems, function(k, v) table.insert(data, v) end) - table.insert(data, rod) + 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 - if compat > 1 then - table.foreach(dexsanity, function(k, v) 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 + if #a1 ~= #a2 then return false end - end - - return true + + for i, v in ipairs(a1) do + if v ~= a2[i] then + return false + end + end + + return true end function receive() @@ -196,8 +167,7 @@ function receive() end function main() - if (is23Or24Or25 or is26To28) == false then - print("Must use a version of bizhawk 2.3.1 or higher") + if not checkBizHawkVersion() then return end server, error = socket.bind('localhost', 17242) diff --git a/data/lua/TLoZ/TheLegendOfZeldaConnector.lua b/data/lua/connector_tloz.lua similarity index 86% rename from data/lua/TLoZ/TheLegendOfZeldaConnector.lua rename to data/lua/connector_tloz.lua index ac33ed3cc4..4a2d2f25bf 100644 --- a/data/lua/TLoZ/TheLegendOfZeldaConnector.lua +++ b/data/lua/connector_tloz.lua @@ -3,13 +3,12 @@ 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 itemMessages = {} local consumableStacks = nil local prevstate = "" local curstate = STATE_UNINITIALIZED @@ -21,8 +20,6 @@ local cave_index local triforce_byte local game_state -local u8 = nil -local wU8 = nil local isNesHawk = false local shopsChecked = {} @@ -70,6 +67,7 @@ local itemsObtained = 0x0677 local takeAnyCavesChecked = 0x0678 local localTriforce = 0x0679 local bonusItemsObtained = 0x067A +local itemsObtainedHigh = 0x067B itemAPids = { ["Boomerang"] = 7100, @@ -176,11 +174,18 @@ for key, value in pairs(itemAPids) do itemIDNames[value] = key end +local function getItemsObtained() + return bit.bor(bit.lshift(u8(itemsObtainedHigh), 8), u8(itemsObtained)) +end +local function setItemsObtained(value) + wU8(itemsObtainedHigh, bit.rshift(value, 8)) + wU8(itemsObtained, bit.band(value, 0xFF)) +end local function determineItem(array) memdomain.ram() - currentItemsObtained = u8(itemsObtained) + currentItemsObtained = getItemsObtained() end @@ -367,8 +372,8 @@ local function gotItem(item) wU8(0x505, itemCode) wU8(0x506, 128) wU8(0x602, 4) - numberObtained = u8(itemsObtained) + 1 - wU8(itemsObtained, numberObtained) + numberObtained = getItemsObtained() + 1 + setItemsObtained(numberObtained) if itemName == "Boomerang" then gotBoomerang() end if itemName == "Bow" then gotBow() end if itemName == "Magical Boomerang" then gotMagicalBoomerang() end @@ -420,83 +425,6 @@ local function checkCaveItemObtained() return returnTable end -function table.empty (self) - for _, _ in pairs(self) do - return false - end - return true -end - -function slice (tbl, s, e) - local pos, new = 1, {} - for i = s + 1, e do - new[pos] = tbl[i] - pos = pos + 1 - end - return new -end - -local bizhawk_version = client.getversion() -local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") -local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8") - -local function getMaxMessageLength() - if is23Or24Or25 then - return client.screenwidth()/11 - elseif is26To28 then - return client.screenwidth()/12 - end -end - -local function drawText(x, y, message, color) - if is23Or24Or25 then - gui.addmessage(message) - elseif is26To28 then - gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client") - end -end - -local function clearScreen() - if is23Or24Or25 then - return - elseif is26To28 then - drawText(0, 0, "", "black") - end -end - -local function drawMessages() - if table.empty(itemMessages) then - clearScreen() - return - end - local y = 10 - found = false - maxMessageLength = getMaxMessageLength() - for k, v in pairs(itemMessages) do - if v["TTL"] > 0 then - message = v["message"] - while true do - drawText(5, y, message:sub(1, maxMessageLength), v["color"]) - y = y + 16 - - message = message:sub(maxMessageLength + 1, message:len()) - if message:len() == 0 then - break - end - end - newTTL = 0 - if is26To28 then - newTTL = itemMessages[k]["TTL"] - 1 - end - itemMessages[k]["TTL"] = newTTL - found = true - end - end - if found == false then - clearScreen() - end -end - function generateOverworldLocationChecked() memDomain.ram() data = uRange(0x067E, 0x81) @@ -556,7 +484,7 @@ function processBlock(block) if i > u8(bonusItemsObtained) then if u8(0x505) == 0 then gotItem(item) - wU8(itemsObtained, u8(itemsObtained) - 1) + setItemsObtained(getItemsObtained() - 1) wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1) end end @@ -574,7 +502,7 @@ function processBlock(block) for i, item in ipairs(itemsBlock) do memDomain.ram() if u8(0x505) == 0 then - if i > u8(itemsObtained) then + if i > getItemsObtained() then gotItem(item) end end @@ -589,18 +517,6 @@ function processBlock(block) end end -function difference(a, b) - local aa = {} - for k,v in pairs(a) do aa[v]=true end - for k,v in pairs(b) do aa[v]=nil end - local ret = {} - local n = 0 - for k,v in pairs(a) do - if aa[v] then n=n+1 ret[n]=v end - end - return ret -end - function receive() l, e = zeldaSocket:receive() if e == 'closed' then @@ -638,7 +554,7 @@ function receive() retTable["gameMode"] = gameMode retTable["overworldHC"] = getHCLocation() retTable["overworldPB"] = getPBLocation() - retTable["itemsObtained"] = u8(itemsObtained) + retTable["itemsObtained"] = getItemsObtained() msg = json.encode(retTable).."\n" local ret, error = zeldaSocket:send(msg) if ret == nil then @@ -653,8 +569,7 @@ function receive() end function main() - if (is23Or24Or25 or is26To28) == false then - print("Must use a version of bizhawk 2.3.1 or higher") + if not checkBizHawkVersion() then return end server, error = socket.bind('localhost', 52980) @@ -699,4 +614,4 @@ function main() end end -main() \ No newline at end of file +main() diff --git a/data/lua/core.dll b/data/lua/core.dll deleted file mode 100644 index 3e9569571a..0000000000 Binary files a/data/lua/core.dll and /dev/null differ diff --git a/data/lua/ADVENTURE/json.lua b/data/lua/json.lua similarity index 100% rename from data/lua/ADVENTURE/json.lua rename to data/lua/json.lua diff --git a/data/lua/lua_5_3_compat.lua b/data/lua/lua_5_3_compat.lua new file mode 100644 index 0000000000..0d9990a430 --- /dev/null +++ b/data/lua/lua_5_3_compat.lua @@ -0,0 +1,12 @@ +function bit.rshift(a, b) + return a >> b +end +function bit.lshift(a, b) + return a << b +end +function bit.bor(a, b) + return a | b +end +function bit.band(a, b) + return a & b +end \ No newline at end of file diff --git a/data/lua/socket.lua b/data/lua/socket.lua index a98e952115..72ffe0c88a 100644 --- a/data/lua/socket.lua +++ b/data/lua/socket.lua @@ -10,8 +10,69 @@ local base = _G local string = require("string") local math = require("math") -local socket = require("socket.core") -module("socket") + +function get_lua_version() + local major, minor = _VERSION:match("Lua (%d+)%.(%d+)") + assert(tonumber(major) == 5) + if tonumber(minor) >= 4 then + return "5-4" + end + return "5-1" +end + +function get_os() + local the_os, ext, arch + if package.config:sub(1,1) == "\\" then + the_os, ext = "windows", "dll" + arch = os.getenv"PROCESSOR_ARCHITECTURE" + else + -- TODO: macos? + the_os, ext = "linux", "so" + arch = "x86_64" -- TODO: read ELF header from /proc/$PID/exe to get arch + end + + if arch:find("64") ~= nil then + arch = "x64" + else + arch = "x86" + end + + return the_os, ext, arch +end + +function get_socket_path() + local the_os, ext, arch = get_os() + -- for some reason ./ isn't working, so use a horrible hack to get the pwd + local pwd = (io.popen and io.popen("cd"):read'*l') or "." + return pwd .. "/" .. arch .. "/socket-" .. the_os .. "-" .. get_lua_version() .. "." .. ext +end +local lua_version = get_lua_version() +local socket_path = get_socket_path() +local socket = assert(package.loadlib(socket_path, "luaopen_socket_core"))() +local event = event +-- http://lua-users.org/wiki/ModulesTutorial +local M = {} +if setfenv then + setfenv(1, M) -- for 5.1 +else + _ENV = M -- for 5.2 +end + +M.socket = socket +-- Bizhawk <= 2.8 has an issue where resetting the lua doesn't close the socket +-- ...to get around this, we register an exit handler to close the socket first +if lua_version == '5-1' then + local old_udp = socket.udp + function udp(self) + s = old_udp(self) + function close_socket(self) + s:close() + end + event.onexit(close_socket) + return s + end + socket.udp = udp +end ----------------------------------------------------------------------------- -- Exported auxiliar functions @@ -39,7 +100,7 @@ function bind(host, port, backlog) return sock end -try = newtry() +try = socket.newtry() function choose(table) return function(name, opt1, opt2) @@ -130,3 +191,5 @@ end sourcet["default"] = sourcet["until-closed"] source = choose(sourcet) + +return M diff --git a/data/lua/x64/luasocket.LICENSE.txt b/data/lua/x64/luasocket.LICENSE.txt new file mode 100644 index 0000000000..ff5c6a73c0 --- /dev/null +++ b/data/lua/x64/luasocket.LICENSE.txt @@ -0,0 +1,20 @@ +LuaSocket 3.0 license +Copyright � 2004-2013 Diego Nehab + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/data/lua/x64/socket-linux-5-1.so b/data/lua/x64/socket-linux-5-1.so new file mode 100644 index 0000000000..df95403fcd Binary files /dev/null and b/data/lua/x64/socket-linux-5-1.so differ diff --git a/data/lua/x64/socket-linux-5-4.so b/data/lua/x64/socket-linux-5-4.so new file mode 100644 index 0000000000..059899d21c Binary files /dev/null and b/data/lua/x64/socket-linux-5-4.so differ diff --git a/data/lua/x64/socket-windows-5-1.dll b/data/lua/x64/socket-windows-5-1.dll new file mode 100644 index 0000000000..3eac1564a8 Binary files /dev/null and b/data/lua/x64/socket-windows-5-1.dll differ diff --git a/data/lua/x64/socket-windows-5-4.dll b/data/lua/x64/socket-windows-5-4.dll new file mode 100644 index 0000000000..896b3cbacd Binary files /dev/null and b/data/lua/x64/socket-windows-5-4.dll differ diff --git a/data/lua/x86/luasocket.LICENSE.txt b/data/lua/x86/luasocket.LICENSE.txt new file mode 100644 index 0000000000..ff5c6a73c0 --- /dev/null +++ b/data/lua/x86/luasocket.LICENSE.txt @@ -0,0 +1,20 @@ +LuaSocket 3.0 license +Copyright � 2004-2013 Diego Nehab + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/data/lua/x86/socket-windows-5-1.dll b/data/lua/x86/socket-windows-5-1.dll new file mode 100644 index 0000000000..2255fed700 Binary files /dev/null and b/data/lua/x86/socket-windows-5-1.dll differ diff --git a/data/mcicon.png b/data/mcicon.png new file mode 100644 index 0000000000..6b8fc54897 Binary files /dev/null and b/data/mcicon.png differ diff --git a/WebHostLib/templates/options.yaml b/data/options.yaml similarity index 100% rename from WebHostLib/templates/options.yaml rename to data/options.yaml diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS new file mode 100644 index 0000000000..e92bfa42b6 --- /dev/null +++ b/docs/CODEOWNERS @@ -0,0 +1,166 @@ +# Archipelago World Code Owners / Maintainers Document +# +# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder. For any pull +# requests that modify these worlds, a code owner must approve the PR in addition to a core maintainer. This is not to +# be used for files/folders outside the /worlds folder, those will always need sign off from a core maintainer. +# +# All usernames must be GitHub usernames (and are case sensitive). + +################### +## Active Worlds ## +################### + +# Adventure +/worlds/adventure/ @JusticePS + +# A Link to the Past +/worlds/alttp/ @Berserker66 + +# ArchipIDLE +/worlds/archipidle/ @LegendaryLinux + +# Sudoku (BK Sudoku) +/worlds/bk_sudoku/ @Jarno458 + +# Blasphemous +/worlds/blasphemous/ @TRPG0 + +# Bumper Stickers +/worlds/bumpstik/ @FelicitusNeko + +# ChecksFinder +/worlds/checksfinder/ @jonloveslegos + +# Clique +/worlds/clique/ @ThePhar + +# Dark Souls III +/worlds/dark_souls_3/ @Marechal-L + +# Donkey Kong Country 3 +/worlds/dkc3/ @PoryGone + +# DLCQuest +/worlds/dlcquest/ @axe-y @agilbert1412 + +# DOOM 1993 +/worlds/doom_1993/ @Daivuk + +# Factorio +/worlds/factorio/ @Berserker66 + +# Final Fantasy +/worlds/ff1/ @jtoyoda + +# Hollow Knight +/worlds/hk/ @BadMagic100 @ThePhar + +# Hylics 2 +/worlds/hylics2/ @TRPG0 + +# Kingdom Hearts 2 +/worlds/kh2/ @JaredWeakStrike + +# Links Awakening DX +/worlds/ladx/ @zig-for + +# Lufia II Ancient Cave +/worlds/lufia2ac/ @el-u +/worlds/lufia2ac/docs/ @wordfcuk @el-u + +# Meritous +/worlds/meritous/ @FelicitusNeko + +# The Messenger +/worlds/messenger/ @alwaysintreble + +# Minecraft +/worlds/minecraft/ @KonoTyran @espeon65536 + +# MegaMan Battle Network 3 +/worlds/mmbn3/ @digiholic + +# Muse Dash +/worlds/musedash/ @DeamonHunter + +# Noita +/worlds/noita/ @ScipioWright @heinermann + +# Ocarina of Time +/worlds/oot/ @espeon65536 + +# Overcooked! 2 +/worlds/overcooked2/ @toasterparty + +# Pokemon Red and Blue +/worlds/pokemon_rb/ @Alchav + +# Raft +/worlds/raft/ @SunnyBat + +# Rogue Legacy +/worlds/rogue_legacy/ @ThePhar + +# Risk of Rain 2 +/worlds/ror2/ @kindasneaki + +# Sonic Adventure 2 Battle +/worlds/sa2b/ @PoryGone @RaspberrySpace + +# Starcraft 2 Wings of Liberty +/worlds/sc2wol/ @Ziktofel + +# Super Metroid +/worlds/sm/ @lordlou + +# Super Mario 64 +/worlds/sm64ex/ @N00byKing + +# Super Mario World +/worlds/smw/ @PoryGone + +# SMZ3 +/worlds/smz3/ @lordlou + +# Secret of Evermore +/worlds/soe/ @black-sliver + +# Slay the Spire +/worlds/spire/ @KonoTyran + +# Stardew Valley +/worlds/stardew_valley/ @agilbert1412 + +# Subnautica +/worlds/subnautica/ @Berserker66 + +# Terraria +/worlds/terraria/ @Seldom-SE + +# Timespinner +/worlds/timespinner/ @Jarno458 + +# The Legend of Zelda (1) +/worlds/tloz/ @Rosalie-A @t3hf1gm3nt + +# Undertale +/worlds/undertale/ @jonloveslegos + +# VVVVVV +/worlds/v6/ @N00byKing + +# Wargroove +/worlds/wargroove/ @FlySniper + +# The Witness +/worlds/witness/ @NewSoupVi @blastron + +# Zillion +/worlds/zillion/ @beauxq + +################################## +## Disabled Unmaintained Worlds ## +################################## + +# Ori and the Blind Forest +# /worlds_disabled/oribf/ diff --git a/docs/adding games.md b/docs/adding games.md index 1c59f256ae..24d9e499cd 100644 --- a/docs/adding games.md +++ b/docs/adding games.md @@ -341,3 +341,4 @@ The various methods and attributes are documented in `/worlds/AutoWorld.py[World [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md), though it is also recommended to look at existing implementations to see how all this works first-hand. Once you get all that, all that remains to do is test the game and publish your work. +Make sure to check out [world maintainer.md](./world%20maintainer.md) before publishing. diff --git a/docs/apworld specification.md b/docs/apworld specification.md index ac89a46e10..98cd25a730 100644 --- a/docs/apworld specification.md +++ b/docs/apworld specification.md @@ -1,16 +1,18 @@ # apworld Specification Archipelago depends on worlds to provide game-specific details like items, locations and output generation. -Those are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). +Those are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). See [world api.md](world%20api.md) for details. apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` file into the worlds folder. +**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! + ## File Format -apworld files are zip archives with the case-sensitive file ending `.apworld`. +apworld files are zip archives, all lower case, with the file ending `.apworld`. The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. diff --git a/docs/contributing.md b/docs/contributing.md index 4e90db95cd..4f7af029cc 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,12 +1,16 @@ # Contributing Contributions are welcome. We have a few requests of any new contributors. +* Follow styling as designated in our [styling documentation](/docs/style.md). * Ensure that all changes which affect logic are covered by unit tests. * Do not introduce any unit test failures/regressions. -* Follow styling as designated in our [styling documentation](/docs/style.md). +* Turn on automated github actions in your fork to have github run all the unit tests after pushing. See example below: +![Github actions example](./img/github-actions-example.png) Otherwise, we tend to judge code on a case to case basis. For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our [Discord](https://archipelago.gg/discord). +If you want to merge a new game, please make sure to read the responsibilities as +[world maintainer](/docs/world%20maintainer.md). diff --git a/docs/img/github-actions-example.png b/docs/img/github-actions-example.png new file mode 100644 index 0000000000..2363a3ed4c Binary files /dev/null and b/docs/img/github-actions-example.png differ diff --git a/docs/network diagram/network diagram.md b/docs/network diagram/network diagram.md index 926c8723a0..cd61d9fefd 100644 --- a/docs/network diagram/network diagram.md +++ b/docs/network diagram/network diagram.md @@ -35,7 +35,7 @@ flowchart LR subgraph Final Fantasy 1 FF1[FF1Client] FFLUA[Lua Connector] - BZFF[BizHawk with Final Fantasy Loaded] + BZFF[EmuHawk with Final Fantasy Loaded] FF1 <-- LuaSockets --> FFLUA FFLUA <--> BZFF end @@ -45,7 +45,7 @@ flowchart LR subgraph Ocarina of Time OC[OoTClient] LC[Lua Connector] - OCB[BizHawk with Ocarina of Time Loaded] + OCB[EmuHawk with Ocarina of Time Loaded] OC <-- LuaSockets --> LC LC <--> OCB end diff --git a/docs/network diagram/network diagram.svg b/docs/network diagram/network diagram.svg index ba29b744d5..b79983d004 100644 --- a/docs/network diagram/network diagram.svg +++ b/docs/network diagram/network diagram.svg @@ -1 +1 @@ -
    Factorio
    Secret of Evermore
    WebHost (archipelago.gg)
    .NET
    Java
    Native
    Lufia II Ancient Cave
    Super Mario World
    Donkey Kong Country 3
    SMZ3
    Super Metroid
    Ocarina of Time
    Final Fantasy 1
    A Link to the Past
    ChecksFinder
    Starcraft 2
    FNA/XNA
    Unity
    Minecraft
    Secret of Evermore
    WebSockets
    WebSockets
    Integrated
    Integrated
    Various, depending on SNES device
    LuaSockets
    Integrated
    LuaSockets
    Integrated
    Integrated
    WebSockets
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    The Witness Randomizer
    Various, depending on SNES device
    WebSockets
    WebSockets
    Mod the Spire
    TCP
    Forge Mod Loader
    WebSockets
    TsRandomizer
    RogueLegacyRandomizer
    BepInEx
    QModLoader (BepInEx)
    HK Modding API
    WebSockets
    SQL
    Subprocesses
    SQL
    Deposit Generated Worlds
    Provide Generation Instructions
    Subprocesses
    Subprocesses
    RCON
    UDP
    Integrated
    Factorio Server
    FactorioClient
    Factorio Games
    Factorio Mod Generated by AP
    Factorio Modding API
    SNES
    Configurable (waitress, gunicorn, flask)
    AutoHoster
    PonyORM DB
    WebHost
    Flask WebContent
    AutoGenerator
    Mod with Archipelago.MultiClient.Net
    Risk of Rain 2
    Subnautica
    Hollow Knight
    Raft
    Timespinner
    Rogue Legacy
    Mod with Archipelago.MultiClient.Java
    Slay the Spire
    Minecraft Forge Server
    Any Java Minecraft Clients
    Game using apclientpp Client Library
    Game using Apcpp Client Library
    Super Mario 64 Ex
    VVVVVV
    Meritous
    The Witness
    Sonic Adventure 2: Battle
    Dark Souls 3
    ap-soeclient
    SNES
    SNES
    SNES
    SNES
    SNES
    OoTClient
    Lua Connector
    BizHawk with Ocarina of Time Loaded
    FF1Client
    Lua Connector
    BizHawk with Final Fantasy Loaded
    SNES
    ChecksFinderClient
    ChecksFinder
    Starcraft 2 Game Client
    Starcraft2Client.py
    apsc2 Python Package
    Archipelago Server
    CommonClient.py
    Super Nintendo Interface (SNI)
    SNIClient
    \ No newline at end of file +
    Factorio
    Secret of Evermore
    WebHost (archipelago.gg)
    .NET
    Java
    Native
    Lufia II Ancient Cave
    Super Mario World
    Donkey Kong Country 3
    SMZ3
    Super Metroid
    Ocarina of Time
    Final Fantasy 1
    A Link to the Past
    ChecksFinder
    Starcraft 2
    FNA/XNA
    Unity
    Minecraft
    Secret of Evermore
    WebSockets
    WebSockets
    Integrated
    Integrated
    Various, depending on SNES device
    LuaSockets
    Integrated
    LuaSockets
    Integrated
    Integrated
    WebSockets
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    Various, depending on SNES device
    The Witness Randomizer
    Various, depending on SNES device
    WebSockets
    WebSockets
    Mod the Spire
    TCP
    Forge Mod Loader
    WebSockets
    TsRandomizer
    RogueLegacyRandomizer
    BepInEx
    QModLoader (BepInEx)
    HK Modding API
    WebSockets
    SQL
    Subprocesses
    SQL
    Deposit Generated Worlds
    Provide Generation Instructions
    Subprocesses
    Subprocesses
    RCON
    UDP
    Integrated
    Factorio Server
    FactorioClient
    Factorio Games
    Factorio Mod Generated by AP
    Factorio Modding API
    SNES
    Configurable (waitress, gunicorn, flask)
    AutoHoster
    PonyORM DB
    WebHost
    Flask WebContent
    AutoGenerator
    Mod with Archipelago.MultiClient.Net
    Risk of Rain 2
    Subnautica
    Hollow Knight
    Raft
    Timespinner
    Rogue Legacy
    Mod with Archipelago.MultiClient.Java
    Slay the Spire
    Minecraft Forge Server
    Any Java Minecraft Clients
    Game using apclientpp Client Library
    Game using Apcpp Client Library
    Super Mario 64 Ex
    VVVVVV
    Meritous
    The Witness
    Sonic Adventure 2: Battle
    Dark Souls 3
    ap-soeclient
    SNES
    SNES
    SNES
    SNES
    SNES
    OoTClient
    Lua Connector
    EmuHawk with Ocarina of Time Loaded
    FF1Client
    Lua Connector
    EmuHawk with Final Fantasy Loaded
    SNES
    ChecksFinderClient
    ChecksFinder
    Starcraft 2 Game Client
    Starcraft2Client.py
    apsc2 Python Package
    Archipelago Server
    CommonClient.py
    Super Nintendo Interface (SNI)
    SNIClient
    \ No newline at end of file diff --git a/docs/network protocol.md b/docs/network protocol.md index f4e261dcee..d461cebce1 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -20,12 +20,13 @@ There are also a number of community-supported libraries available that implemen | Python | [Archipelago CommonClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py) | | | | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). | | JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | | -| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | | +| .NET (C# / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | | | C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | header-only | | | [APCpp](https://github.com/N00byKing/APCpp) | CMake | | JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported | | Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | | | Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | | +| Lua | [lua-apclientpp](https://github.com/black-sliver/lua-apclientpp) | | ## Synchronizing Items When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet. @@ -67,10 +68,11 @@ Sent to clients when they connect to an Archipelago server. | Name | Type | Notes | |-----------------------|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which the server is running. | +| generator_version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which generated the multiworld. | | tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` | | password | bool | Denoted whether a password is required to join this room. | | permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". | -| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. | +| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. | | location_check_points | int | The amount of hint points you receive per item/location check completed. | | games | list\[str\] | List of games present in this multiworld. | | datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** | @@ -128,7 +130,8 @@ Sent to clients when the connection handshake is successfully completed. | missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. | | checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 253-1. | | slot_data | dict\[str, any\] | Contains a json object for slot related data, differs per game. Empty if not required. Not present if slot_data in [Connect](#Connect) is false. | -| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information | +| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information. | +| hint_points | int | Number of hint points that the current player has. | ### ReceivedItems Sent to clients when they receive an item. @@ -146,17 +149,16 @@ Sent to clients to acknowledge a received [LocationScouts](#LocationScouts) pack | locations | list\[[NetworkItem](#NetworkItem)\] | Contains list of item(s) in the location(s) scouted. | ### RoomUpdate -Sent when there is a need to update information about the present game session. Generally useful for async games. -Once authenticated (received Connected), this may also contain data from Connected. +Sent when there is a need to update information about the present game session. #### Arguments -The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring: +RoomUpdate may contain the same arguments from [RoomInfo](#RoomInfo) and, once authenticated, arguments from +[Connected](#Connected) with the following exceptions: -| Name | Type | Notes | -| ---- | ---- | ----- | -| hint_points | int | New argument. The client's current hint points. | -| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Send in the event of an alias rename. Always sends all players, whether connected or not. | -| checked_locations | list\[int\] | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. | -| missing_locations | list\[int\] | Should never be sent as an update, if needed is the inverse of checked_locations. | +| Name | Type | Notes | +|-------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Sent in the event of an alias rename. Always sends all players, whether connected or not. | +| checked_locations | list\[int\] | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. | +| missing_locations | - | Never sent in this packet. If needed, it is the inverse of `checked_locations`. | All arguments for this packet are optional, only changes are sent. diff --git a/docs/options api.md b/docs/options api.md index a1407f2ceb..2c86833800 100644 --- a/docs/options api.md +++ b/docs/options api.md @@ -13,28 +13,38 @@ need to create: - A new option class with a docstring detailing what the option will do to your user. - A `display_name` to be displayed on the webhost. - A new entry in the `option_definitions` dict for your World. -By style and convention, the internal names should be snake_case. If the option supports having multiple sub_options -such as Choice options, these can be defined with `option_my_sub_option`, where the preceding `option_` is required and -stripped for users, so will show as `my_sub_option` in yaml files and if `auto_display_name` is True `My Sub Option` -on the webhost. All options support `random` as a generic option. `random` chooses from any of the available -values for that option, and is reserved by AP. You can set this as your default value but you cannot define your own -new `option_random`. +By style and convention, the internal names should be snake_case. ### Option Creation +- If the option supports having multiple sub_options, such as Choice options, these can be defined with +`option_value1`. Any attributes of the class with a preceding `option_` is added to the class's `options` lookup. The +`option_` is then stripped for users, so will show as `value1` in yaml files. If `auto_display_name` is True, it will +display as `Value1` on the webhost. +- An alternative name can be set for any specific option by setting an alias attribute +(i.e. `alias_value_1 = option_value1`) which will allow users to use either `value_1` or `value1` in their yaml +files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a +Choice, and defining `alias_true = option_full`. +- All options support `random` as a generic option. `random` chooses from any of the available values for that option, +and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`. + As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's -create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our -options: +create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass: ```python # Options.py +from dataclasses import dataclass + +from Options import Toggle, PerGameCommonOptions + + class StartingSword(Toggle): """Adds a sword to your starting inventory.""" display_name = "Start With Sword" -example_options = { - "starting_sword": StartingSword -} +@dataclass +class ExampleGameOptions(PerGameCommonOptions): + starting_sword: StartingSword ``` This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it @@ -42,27 +52,30 @@ to our world's `__init__.py`: ```python from worlds.AutoWorld import World -from .Options import options +from .Options import ExampleGameOptions class ExampleWorld(World): - option_definitions = options + # this gives the generator all the definitions for our options + options_dataclass = ExampleGameOptions + # this gives us typing hints for all the options we defined + options: ExampleGameOptions ``` ### Option Checking Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after world instantiation. These are created as attributes on the MultiWorld and can be accessed with -`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to +`self.options.my_option_name`. This is an instance of the option class, which supports direct comparison methods to relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is the option class's `value` attribute. For our example above we can do a simple check: ```python -if self.multiworld.starting_sword[self.player]: +if self.options.starting_sword: do_some_things() ``` or if I need a boolean object, such as in my slot_data I can access it as: ```python -start_with_sword = bool(self.multiworld.starting_sword[self.player].value) +start_with_sword = bool(self.options.starting_sword.value) ``` ## Generic Option Classes @@ -114,7 +127,7 @@ Like Toggle, but 1 (true) is the default value. A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with: ```python -if self.multiworld.sword_availability[self.player] == "early_sword": +if self.options.sword_availability == "early_sword": do_early_sword_things() ``` @@ -122,7 +135,7 @@ or: ```python from .Options import SwordAvailability -if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword: +if self.options.sword_availability == SwordAvailability.option_early_sword: do_early_sword_things() ``` @@ -154,7 +167,7 @@ within the world. Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any user defined string as a valid option, so will either need to be validated by adding a validation step to the option class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified -point, `self.multiworld.my_option[self.player].current_key` will always return a string. +point, `self.options.my_option.current_key` will always return a string. ### PlandoBosses An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports diff --git a/docs/running from source.md b/docs/running from source.md index cb1a8fa50b..b7367308d8 100644 --- a/docs/running from source.md +++ b/docs/running from source.md @@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r What you'll need: * [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version - * **Python 3.11 does not work currently** + * **Python 3.12 is currently unsupported** * pip: included in downloads from python.org, separate in many Linux distributions * Matching C compiler * possibly optional, read operating system specific sections @@ -30,15 +30,13 @@ After this, you should be able to run the programs. Recommended steps * Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads) - * **Python 3.11 does not work currently** + * **Python 3.12 is currently unsupported** - * Download and install full Visual Studio from - [Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/) - or an older "Build Tools for Visual Studio" from - [Visual Studio Older Downloads](https://visualstudio.microsoft.com/vs/older-downloads/). - - * Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details - * This step is optional. Pre-compiled modules are pinned on + * **Optional**: Download and install Visual Studio Build Tools from + [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). + * Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details. + Generally, selecting the box for "Desktop Development with C++" will provide what you need. + * Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on [Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808) * It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/) @@ -71,6 +69,19 @@ It should be dropped as "SNI" into the root folder of the project. Alternatively host.yaml at your SNI folder. +## Optional: Git + +[Git](https://git-scm.com) is required to install some of the packages that Archipelago depends on. +It may be possible to run Archipelago from source without it, at your own risk. + +It is also generally recommended to have Git installed and understand how to use it, especially if you're thinking about contributing. + +You can download the latest release of Git at [The downloads page on the Git website](https://git-scm.com/downloads). + +Beyond that, there are also graphical interfaces for Git that make it more accessible. +For repositories on Github (such as this one), [Github Desktop](https://desktop.github.com) is one such option. +PyCharm has a built-in version control integration that supports Git. + ## Running tests Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder. diff --git a/docs/settings api.md b/docs/settings api.md new file mode 100644 index 0000000000..f9cbe5e021 --- /dev/null +++ b/docs/settings api.md @@ -0,0 +1,187 @@ +# Archipelago Settings API + +The settings API describes how to use installation-wide config and let the user configure them, like paths, etc. using +host.yaml. For the player settings / player yamls see [options api.md](options api.md). + +The settings API replaces `Utils.get_options()` and `Utils.get_default_options()` +as well as the predefined `host.yaml` in the repository. + +For backwards compatibility with APWorlds, some interfaces are kept for now and will produce a warning when being used. + + +## Config File + +Settings use options.yaml (manual override), if that exists, or host.yaml (the default) otherwise. +The files are searched for in the current working directory, if different from install directory, and in `user_path`, +which either points to the installation directory, if writable, or to %home%/Archipelago otherwise. + +**Examples:** +* C:\Program Data\Archipelago\options.yaml +* C:\Program Data\Archipelago\host.yaml +* path\to\code\repository\host.yaml +* ~/Archipelago/host.yaml + +Using the settings API, AP can update the config file or create a new one with default values and comments, +if it does not exist. + + +## Global Settings + +All non-world-specific settings are defined directly in settings.py. +Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`. + +To access a "global" config value, with correct typing, use one of +```python +from settings import get_settings, GeneralOptions, FolderPath +from typing import cast + +x = get_settings().general_options.output_path +y = cast(GeneralOptions, get_settings()["general_options"]).output_path +z = cast(FolderPath, get_settings()["general_options"]["output_path"]) +``` + + +## World Settings + +Worlds can define the top level key to use by defining `settings_key: ClassVar[str]` in their World class. +It defaults to `{folder_name}_options` if undefined, i.e. `worlds/factorio/...` defaults to `factorio_options`. + +Worlds define the layout of their config section using type annotation of the variable `settings` in the class. +The type has to inherit from `settings.Group`. Each value in the config can have a comment by subclassing a built-in +type. Some helper types are defined in `settings.py`, see [Types](#Types) for a list.``` + +Inside the class code, you can then simply use `self.settings.rom_file` to get the value. +In case of paths they will automatically be read as absolute file paths. No need to use user_path or local_path. + +```python +import settings +from worlds.AutoWorld import World + + +class MyGameSettings(settings.Group): + class RomFile(settings.SNESRomPath): + """Description that is put into host.yaml""" + description = "My Game US v1.0 ROM File" # displayed in the file browser + copy_to = "MyGame.sfc" # instead of storing the path, copy to AP dir + md5s = ["..."] + + rom_file: RomFile = RomFile("MyGame.sfc") # definition and default value + + +class MyGameWorld(World): + ... + settings: MyGameSettings + ... + + def something(self): + pass # use self.settings.rom_file here +``` + + +## Types + +When writing the host.yaml, the code will down cast the values to builtins. +When reading the host.yaml, the code will upcast the values to what is defined in the type annotations. +E.g. an IntEnum becomes int when saving and will construct the IntEnum when loading. + +Types that can not be down cast to / up cast from a builtin can not be used except for Group, which will be converted +to/from a dict. +`bool` is a special case, see settings.py: ServerOptions.disable_item_cheat for an example. + +Below are some predefined types that can be used if they match your requirements: + + +### Group + +A section / dict in the config file. Behaves similar to a dataclass. +Type annotation and default assignment define how loading, saving and default values behave. +It can be accessed using attributes or as a dict: `group["a"]` is equivalent to `group.a`. + +In worlds, this should only be used for the top level to avoid issues when upgrading/migrating. + + +### Bool + +Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml. + +```python +import settings +import typing + +class MySettings(settings.Group): + class MyBool(settings.Bool): + """Doc string""" + + my_value: typing.Union[MyBool, bool] = True +``` + +### UserFilePath + +Path to a single file. Automatically resolves as user_path: +Source folder or AP install path on Windows. ~/Archipelago for the AppImage. +Will open a file browser if the file is missing when in GUI mode. + +#### class method validate(cls, path: str) + +Override this and raise ValueError if validation fails. +Checks the file against [md5s](#md5s) by default. + +#### is_exe: bool + +Resolves to an executable (varying file extension based on platform) + +#### description: Optional\[str\] + +Human-readable name to use in file browser + +#### copy_to: Optional\[str\] + +Instead of storing the path, copy the file. + +#### md5s: List[Union[str, bytes]] + +Provide md5 hashes as hex digests or raw bytes for automatic validation. + + +### UserFolderPath + +Same as [UserFilePath](#UserFilePath), but for a folder instead of a file. + + +### LocalFilePath + +Same as [UserFilePath](#UserFilePath), but resolves as local_path: +path inside the AP dir or Appimage even if read-only. + + +### LocalFolderPath + +Same as [LocalFilePath](#LocalFilePath), but for a folder instead of a file. + + +### OptionalUserFilePath, OptionalUserFolderPath, OptionalLocalFilePath, OptionalLocalFolderPath + +Same as UserFilePath, UserFolderPath, LocalFilePath, LocalFolderPath but does not open a file browser if missing. + + +### SNESRomPath + +Specialized [UserFilePath](#UserFilePath) that ignores an optional 512 byte header when validating. + + +## Caveats + +### Circular Imports + +Because the settings are defined on import, code that runs on import can not use settings since that would result in +circular / partial imports. Instead, the code should fetch from settings on demand during generation. + +"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary, +"global" settings could be used in global scope of worlds. + + +### APWorld Backwards Compatibility + +APWorlds that want to be compatible with both stable and dev versions, have two options: +1. use the old Utils.get_options() API until Archipelago 0.4.2 is out +2. add some sort of compatibility code to your world that mimics the new API diff --git a/docs/world api.md b/docs/world api.md index 66a639f1b8..6fb5b3ac9c 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -22,8 +22,8 @@ allows using WebSockets. ## Coding style -AP follows all the PEPs. When in doubt use an IDE with coding style -linter, for example PyCharm Community Edition. +AP follows [style.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/style.md). +When in doubt use an IDE with coding style linter, for example PyCharm Community Edition. ## Docstrings @@ -44,7 +44,7 @@ class MyGameWorld(World): ## Definitions This section will cover various classes and objects you can use for your world. -While some of the attributes and methods are mentioned here not all of them are, +While some of the attributes and methods are mentioned here, not all of them are, but you can find them in `BaseClasses.py`. ### World Class @@ -56,11 +56,12 @@ game. ### WebWorld Class A `WebWorld` class contains specific attributes and methods that can be modified -for your world specifically on the webhost. +for your world specifically on the webhost: -`settings_page` which can be changed to a link instead of an AP generated settings page. +`settings_page`, which can be changed to a link instead of an AP generated settings page. `theme` to be used for your game specific AP pages. Available themes: + | dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone | |---|---|---|---|---|---|---|---| | | | | | | | | | @@ -75,26 +76,32 @@ prefixed with the same string as defined here. Default already has 'en'. ### MultiWorld Object The `MultiWorld` object references the whole multiworld (all items and locations -for all players) and is accessible through `self.world` inside a `World` object. +for all players) and is accessible through `self.multiworld` inside a `World` object. ### Player The player is just an integer in AP and is accessible through `self.player` -inside a World object. +inside a `World` object. ### Player Options Players provide customized settings for their World in the form of yamls. -Those are accessible through `self.world.[self.player]`. A dict -of valid options has to be provided in `self.option_definitions`. Options are automatically -added to the `World` object for easy access. +A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`. +(It must be a subclass of `PerGameCommonOptions`.) +Option results are automatically added to the `World` object for easy access. +Those are accessible through `self.options.`, and you can get a dictionary of the option values via +`self.options.as_dict()`, passing the desired options as strings. -### World Options +### World Settings -Any AP installation can provide settings for a world, for example a ROM file, -accessible through `Utils.get_options()['_options']['