Compare commits

..

22 Commits

Author SHA1 Message Date
Jérémie Bolduc
b2d2c8e596 Stardew Valley: Add void mayo requirement for Goblin Problem quest (#4933)
This adds the requirement of a void mayo for the Goblin Problem quest. There are also some small adjustments to related rules
- Fishing a void mayo is only considered an option during the Goblin Problem quest, as the odds of finding one after the quest drops drastically.
- Entrance to the witch hut now requires the goblin problem quest, not just a void mayo.
- Fishing rules are all moved to `fishing_logic.py`.
- `can_fish_at` no longer check that you have any of the fishing regions and the region you actually want to fish in.
- created `can_fish_anywhere` and `can_crab_pot_anywhere` to better illustrate when any fish satisfies the rule.
2025-05-04 16:28:38 +02:00
Fabian Dill
68e37b8f9a Factorio: client cleanup and prevent process bomb (#4882)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-04 16:22:48 +02:00
Fabian Dill
fa2d7797f4 Core: update certifi (#4954) 2025-05-04 15:59:41 +02:00
Jonathan Tan
1885dab066 TWW: Documentation Cleanup (#4942) 2025-05-03 20:06:16 -04:00
Tim Mahan
9425f5b772 Docs: Direct Mac users to Launcher.py (#4767) 2025-05-03 08:42:52 -04:00
Fabian Dill
83ed3c8b50 Core: always embed Archipelago (#4880) 2025-05-03 11:53:52 +02:00
qwint
f4690e296d CommonClient: remove Datapackage Version handling (#4487)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-03 01:31:40 +02:00
Fabian Dill
68c350b4c0 CommonClient: rip out old global name lookup (#4941) 2025-05-02 23:39:52 +02:00
Fabian Dill
da0207f5cb Factorio: implement custom filler items (#4945) 2025-05-02 23:39:14 +02:00
Aaron Wagener
2455f1158f Options: Cleanup CommonOptions.as_dict (#4921)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-02 12:39:58 -04:00
Fabian Dill
1031fc4923 Factorio: remove FactorioClient executable (#4928) 2025-05-02 15:59:27 +02:00
qwint
6beaacb905 Generate: Better yaml parsing error messaging (#4927)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-05-02 09:46:34 -04:00
Scipio Wright
c46ee7c420 TUNIC: Lock pre-placed filler to make the game play nicer with prog balancing (#4917) 2025-04-30 21:57:46 +02:00
Bryce Wilson
227f0bce3d Pokemon Red/Blue: Convert to Procedure Patch (#4801) 2025-04-30 16:31:33 +02:00
PoryGone
611e1c2b19 SMW: v2.1 Feature Update (#4652)
### Features:
- Trap Link
  - When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
    - Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players

Co-authored-by: TheLX5 <luisyuregi@gmail.com>
2025-04-30 16:24:10 +02:00
Mysteryem
5f974b7457 SM: Fix FakeROM instances sharing the same data dictionary (#4912)
FakeROM instances were being created with default arguments, which
included a mutable default argument data dictionary, so all FakeROM
instances would be writing to and reading the same dictionary, resulting
in broken patch data in multiworlds with more than one Super Metroid
world.
2025-04-30 04:57:35 +02:00
threeandthreee
3ef35105c8 LADX: Remove copyrighted assets (#4935) 2025-04-30 04:27:54 +02:00
Alchav
ec768a2e89 ALTTP: Swamp Palace West logic fix (#4936) 2025-04-29 16:53:31 +02:00
black-sliver
b580d3c25a CI: add optional windows release build and build attestation (#4940)
* CI: github attestation for manually started builds

* CI: include appimage zsync in build attestation

* CI: github attestation for Linux release builds

* CI: reorder steps in build.yml

* CI: add windows builds to release.yml

* CI: order jobs in release.yml

* CI: add missing permission to release.yml

* CI: enable windows build in release.yml

* CI: false is skip
2025-04-29 08:32:36 +02:00
Jérémie Bolduc
ce14f190fb Stardew Valley: Replace event creation stardew code with add_event (#4922)
* replace event creation stardew code with add_event

* delete unnecessary default args
2025-04-29 00:12:52 +02:00
Jonathan Tan
4e3da005d4 TWW: Fix generation failure with output file (#4932) 2025-04-27 09:43:24 +02:00
Exempt-Medic
0d9967e8d8 OC2: Account for Multiclass Items in Progression Balancing (#4929) 2025-04-26 13:28:07 -04:00
50 changed files with 1095 additions and 633 deletions

View File

@@ -21,12 +21,17 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs:
# build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand
build-win: # RCs and releases may still be built and signed by hand
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -65,6 +70,18 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds
shell: bash
run: |
@@ -142,6 +159,16 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate

View File

@@ -11,6 +11,11 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs:
create-release:
runs-on: ubuntu-latest
@@ -26,11 +31,79 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 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
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -74,6 +147,14 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

View File

@@ -196,25 +196,11 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
@@ -254,7 +240,6 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -356,7 +341,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
@@ -571,7 +555,6 @@ class CommonContext:
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -580,33 +563,26 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
if game not in remote_data_package_checksums:
continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
if not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
if remote_checksum != cached_checksum:
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
if remote_checksum == local_checksum:
self.update_game(network_data_package["games"][game], game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
if remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -616,7 +592,6 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
@@ -887,9 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.server_auth(args['password'])

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

View File

@@ -252,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return tuple(parse_yamls(yaml))
from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
def interpret_on_off(value) -> bool:

View File

@@ -52,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
# Connector version
VERSION = 0x01
@@ -530,7 +514,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None:
import webbrowser
from kvui import GameManager, ImageButton
from kvui import GameManager
from kivy.metrics import dp
from kivymd.uix.button import MDButton, MDButtonText
class LADXManager(GameManager):
logging_pairs = [
@@ -543,8 +529,10 @@ class LinksAwakeningContext(CommonContext):
b = super().build()
if self.ctx.magpie_enabled:
button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button.height = self.server_connect_bar.height
self.connect_layout.add_widget(button)
return b

View File

@@ -301,6 +301,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
}
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}

View File

@@ -1292,42 +1292,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> 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`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
:param option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
will be returned as a sorted list.
"""
assert option_names, "options.as_dict() was used without any option names."
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'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
if option_name not in type(self).type_hints:
raise ValueError(f"{option_name} not found in {tuple(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'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
return option_results

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@ schema>=0.7.7
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.1.31
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15

View File

@@ -47,17 +47,6 @@ class TestCommonContext(unittest.IsolatedAsyncioTestCase):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self):
# Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"

View File

@@ -485,7 +485,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys()))
return self.random.choice(tuple(self.item_name_to_id.keys()))
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:

View File

@@ -393,9 +393,7 @@ def global_rules(multiworld: MultiWorld, player: int):
if world.options.pot_shuffle:
# it could move the key to the top right platform which can only be reached with bombs
add_rule(multiworld.get_location('Swamp Palace - Hookshot Pot Key', player), lambda state: can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6)
if state.has('Hookshot', player)
else state._lttp_has_key('Small Key (Swamp Palace)', player, 4))
set_rule(multiworld.get_entrance('Swamp Palace (West)', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
set_rule(multiworld.get_location('Swamp Palace - Big Chest', player), lambda state: state.has('Big Key (Swamp Palace)', player))
if world.options.accessibility != 'full':
allow_self_locking_items(multiworld.get_location('Swamp Palace - Big Chest', player), 'Big Key (Swamp Palace)')

View File

@@ -24,7 +24,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - Big Key Chest", False, [], ['Open Floodgate']],
["Swamp Palace - Big Key Chest", False, [], ['Hammer']],
["Swamp Palace - Big Key Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Big Key Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Map Chest", False, []],
["Swamp Palace - Map Chest", False, [], ['Flippers']],
@@ -38,7 +38,7 @@ class TestSwampPalace(TestDungeon):
["Swamp Palace - West Chest", False, [], ['Open Floodgate']],
["Swamp Palace - West Chest", False, [], ['Hammer']],
["Swamp Palace - West Chest", False, [], ['Small Key (Swamp Palace)']],
["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - West Chest", True, ['Open Floodgate', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Small Key (Swamp Palace)', 'Flippers', 'Hammer']],
["Swamp Palace - Compass Chest", False, []],
["Swamp Palace - Compass Chest", False, [], ['Flippers']],

View File

@@ -9,7 +9,6 @@ import random
import re
import string
import subprocess
import sys
import time
import typing
@@ -17,15 +16,16 @@ from queue import Queue
import factorio_rcon
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart
from Utils import async_start, get_file_safe_name
from Utils import async_start, get_file_safe_name, is_windows, Version, format_SI_prefix, get_text_between
from .settings import FactorioSettings
from settings import get_settings
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
if is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
@@ -67,7 +67,7 @@ class FactorioContext(CommonContext):
items_handling = 0b111 # full remote
# updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0)
mod_version: Version = Version(0, 0, 0)
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool):
super(FactorioContext, self).__init__(server_address, password)
@@ -133,7 +133,7 @@ class FactorioContext(CommonContext):
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
return f"{format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict):
if self.rcon_client:
@@ -155,10 +155,10 @@ class FactorioContext(CommonContext):
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"
gained_text = format_SI_prefix(gained) + "J"
if gained:
logger.debug(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
f"{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]:
@@ -278,7 +278,7 @@ async def game_watcher(ctx: FactorioContext):
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
logger.debug(f"EnergyLink: Sent {format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -439,9 +439,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
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(".")))
ctx.mod_version = 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: ", " [")
ctx.write_data_path = 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. "
@@ -521,10 +521,16 @@ 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_settings()
executable = options["factorio_options"]["executable"]
settings: FactorioSettings = get_settings().factorio_options
if os.path.samefile(settings.executable, sys.executable):
selected_executable = settings.executable
settings.executable = FactorioSettings.executable # reset to default
raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.")
executable = settings.executable
server_settings = args.server_settings if args.server_settings \
else options["factorio_options"].get("server_settings", None)
else getattr(settings, "server_settings", None)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
@@ -535,12 +541,8 @@ def launch():
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"])
initial_filter_item_sends = bool(settings.filter_item_sends)
initial_bridge_chat_out = bool(settings.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.")

View File

@@ -5,7 +5,6 @@ import logging
import typing
import Utils
import settings
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
@@ -20,6 +19,7 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
fluids, stacking_items, valid_ingredients, progressive_rows
from .settings import FactorioSettings
def launch_client():
@@ -27,30 +27,7 @@ def launch_client():
launch_component(launch, name="FactorioClient")
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[FactorioSettings.ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True
components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))
class FactorioWeb(WebWorld):
@@ -115,6 +92,7 @@ class Factorio(World):
settings: typing.ClassVar[FactorioSettings]
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
want_progressives: dict[str, bool] = collections.defaultdict(lambda: False)
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
@@ -133,6 +111,8 @@ class Factorio(World):
self.options.max_tech_cost.value, self.options.min_tech_cost.value
self.tech_mix = self.options.tech_cost_mix.value
self.skip_silo = self.options.silo.value == Silo.option_spawn
self.want_progressives = collections.defaultdict(
lambda: self.options.progressive.want_progressives(self.random))
def create_regions(self):
player = self.player
@@ -201,9 +181,6 @@ class Factorio(World):
range(getattr(self.options,
f"{trap_name.lower().replace(' ', '_')}_traps")))
want_progressives = collections.defaultdict(lambda: self.options.progressive.
want_progressives(self.random))
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
special_index = {"automation": 0,
"logistics": 1,
@@ -218,7 +195,7 @@ class Factorio(World):
for tech_name in base_tech_table:
if tech_name not in self.removed_technologies:
progressive_item_name = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive = want_progressives[progressive_item_name]
want_progressive = self.want_progressives[progressive_item_name]
item_name = progressive_item_name if want_progressive else tech_name
tech_item = self.create_item(item_name)
index = special_index.get(tech_name, None)
@@ -233,6 +210,12 @@ class Factorio(World):
loc.place_locked_item(tech_item)
loc.revealed = True
def get_filler_item_name(self) -> str:
tech_name: str = self.random.choice(tuple(tech_table))
progressive_item_name: str = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive: bool = self.want_progressives[progressive_item_name]
return progressive_item_name if want_progressive else tech_name
def set_rules(self):
player = self.player
shapes = get_shapes(self)

View File

@@ -0,0 +1,26 @@
import typing
import settings
class FactorioSettings(settings.Group):
class Executable(settings.UserFilePath):
is_exe = True
class ServerSettings(settings.OptionalUserFilePath):
"""
by default, no settings are loaded if this file does not exist. \
If this file does exist, then it will be used.
server_settings: "factorio\\\\data\\\\server-settings.json"
"""
class FilterItemSends(settings.Bool):
"""Whether to filter item send messages displayed in-game to only those that involve you."""
class BridgeChatOut(settings.Bool):
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
executable: Executable = Executable("factorio/bin/x64/factorio")
server_settings: typing.Optional[ServerSettings] = None
filter_item_sends: typing.Union[FilterItemSends, bool] = False
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True

View File

@@ -20,9 +20,11 @@ It is generally recommended that you use a virtual environment to run python bas
3. Run the command `source venv/bin/activate` to activate the virtual environment.
4. If you want to exit the virtual environment, run the command `deactivate`.
## Steps to Run the Clients
1. If your game doesn't have a patch file, run the command `python3 SNIClient.py`, changing the filename with the file of the client you want to run.
2. If your game does have a patch file, move the base rom to the Archipelago directory and run the command `python3 SNIClient.py 'patchfile'` with the filename extension for the patch file (apsm, aplttp, apsmz3, etc.) included and changing the filename with the file of the client you want to run.
3. Your client should now be running and rom created (where applicable).
1. Run the command `python3 Launcher.py`.
2. If your game doesn't have a patch file, just click the desired client in the right side column.
3. If your game does have a patch file, click the 'Open Patch' button and navigate to your patch file (the filename extension will look something like apsm, aplttp, apsmz3, etc.).
4. If the patching process needs a rom, but cannot find it, it will ask you to navigate to your legally obtained rom.
5. Your client should now be running and rom created (where applicable).
## Additional Steps for SNES Games
1. If using RetroArch, the instructions to set up your emulator [here in the Link to the Past setup guide](https://archipelago.gg/tutorial/A%20Link%20to%20the%20Past/multiworld/en) also work on the macOS version of RetroArch.
2. Double click on the SNI tar.gz download to extract the files to an SNI directory. If it isn't already, rename this directory to SNI to make some steps easier.

View File

@@ -18,7 +18,7 @@ from .regions import create_regions
from .options import PokemonRBOptions
from .rom_addresses import rom_addresses
from .text import encode_text
from .rom import generate_output, get_base_rom_bytes, get_base_rom_path, RedDeltaPatch, BlueDeltaPatch
from .rom import generate_output, PokemonRedProcedurePatch, PokemonBlueProcedurePatch
from .pokemon import process_pokemon_data, process_move_data, verify_hm_moves
from .encounters import process_pokemon_locations, process_trainer_data
from .rules import set_rules
@@ -33,12 +33,12 @@ class PokemonSettings(settings.Group):
"""File names of the Pokemon Red and Blue roms"""
description = "Pokemon Red (UE) ROM File"
copy_to = "Pokemon Red (UE) [S][!].gb"
md5s = [RedDeltaPatch.hash]
md5s = [PokemonRedProcedurePatch.hash]
class BlueRomFile(settings.UserFilePath):
description = "Pokemon Blue (UE) ROM File"
copy_to = "Pokemon Blue (UE) [S][!].gb"
md5s = [BlueDeltaPatch.hash]
md5s = [PokemonBlueProcedurePatch.hash]
red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to)
blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to)
@@ -113,16 +113,6 @@ class PokemonRedBlueWorld(World):
self.local_locs = []
self.pc_item = None
@classmethod
def stage_assert_generate(cls, multiworld: MultiWorld):
versions = set()
for player in multiworld.player_ids:
if multiworld.worlds[player].game == "Pokemon Red and Blue":
versions.add(multiworld.worlds[player].options.game_version.current_key)
for version in versions:
if not os.path.exists(get_base_rom_path(version)):
raise FileNotFoundError(get_base_rom_path(version))
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld):

View File

@@ -1,9 +1,17 @@
from copy import deepcopy
import typing
from worlds.Files import APTokenTypes
from . import poke_data, logic
from .rom_addresses import rom_addresses
if typing.TYPE_CHECKING:
from . import PokemonRedBlueWorld
from .rom import PokemonRedProcedurePatch, PokemonBlueProcedurePatch
def set_mon_palettes(world, random, data):
def set_mon_palettes(world: "PokemonRedBlueWorld", patch: "PokemonRedProcedurePatch | PokemonBlueProcedurePatch"):
if world.options.randomize_pokemon_palettes == "vanilla":
return
pallet_map = {
@@ -31,12 +39,9 @@ def set_mon_palettes(world, random, data):
poke_data.evolves_from and poke_data.evolves_from[mon] != "Eevee"):
pallet = palettes[-1]
else: # completely_random or follow_evolutions and it is not an evolved form (except eeveelutions)
pallet = random.choice(list(pallet_map.values()))
pallet = world.random.choice(list(pallet_map.values()))
palettes.append(pallet)
address = rom_addresses["Mon_Palettes"]
for pallet in palettes:
data[address] = pallet
address += 1
patch.write_token(APTokenTypes.WRITE, rom_addresses["Mon_Palettes"], bytes(palettes))
def choose_forced_type(chances, random):
@@ -253,9 +258,9 @@ def process_pokemon_data(self):
mon_data[f"start move {i}"] = learnsets[mon].pop(0)
if self.options.randomize_pokemon_catch_rates:
mon_data["catch rate"] = self.random.randint(self.options.minimum_catch_rate, 255)
mon_data["catch rate"] = self.random.randint(self.options.minimum_catch_rate.value, 255)
else:
mon_data["catch rate"] = max(self.options.minimum_catch_rate, mon_data["catch rate"])
mon_data["catch rate"] = max(self.options.minimum_catch_rate.value, mon_data["catch rate"])
def roll_tm_compat(roll_move):
if self.local_move_data[roll_move]["type"] in [mon_data["type1"], mon_data["type2"]]:

View File

@@ -1,5 +1,55 @@
import random
import typing
from worlds.Files import APTokenTypes
from .rom_addresses import rom_addresses
if typing.TYPE_CHECKING:
from .rom import PokemonBlueProcedurePatch, PokemonRedProcedurePatch
layout1F = [
[20, 22, 32, 34, 20, 25, 22, 32, 34, 20, 25, 25, 25, 22, 20, 25, 22, 2, 2, 2],
[24, 26, 40, 1, 24, 25, 26, 62, 1, 28, 29, 29, 29, 30, 28, 29, 30, 1, 40, 2],
[28, 30, 1, 1, 28, 29, 30, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 23],
[23, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 31, 32, 34, 2, 1, 1, 2, 32, 34, 32, 34, 1, 1, 23],
[23, 1, 1, 23, 1, 1, 23, 1, 40, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[31, 1, 1, 31, 1, 1, 31, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[23, 1, 1, 23, 1, 1, 1, 1, 1, 2, 32, 34, 32, 34, 32, 34, 32, 34, 2, 31],
[31, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23, 1, 1, 40, 23],
[23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31, 1, 1, 1, 31, 1, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23, 1, 1, 1, 23],
[23, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 31, 1, 1, 1, 31, 1, 1, 1, 31],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 23],
[ 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31, 1, 1, 1, 31],
[20, 21, 21, 21, 22, 42, 1, 1, 1, 1, 20, 21, 22, 1, 1, 1, 1, 1, 1, 23],
[24, 25, 25, 25, 26, 1, 1, 1, 1, 1, 24, 25, 26, 1, 1, 1, 1, 1, 1, 31],
[24, 25, 25, 25, 26, 1, 1, 62, 1, 1, 24, 25, 26, 20, 21, 21, 21, 21, 21, 22],
[28, 29, 29, 29, 30, 78, 81, 82, 77, 78, 28, 29, 30, 28, 29, 29, 29, 29, 29, 30],
]
layout2F = [
[23, 2, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34],
[31, 62, 1, 23, 1, 1, 23, 1, 1, 1, 1, 1, 23, 62, 1, 1, 1, 1, 1, 2],
[23, 1, 1, 31, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 23, 1, 1, 23, 1, 1, 23, 1, 1, 23, 23, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 31, 1, 1, 31, 2, 2, 31, 1, 1, 31, 31, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 62, 23, 1, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 1, 1, 1, 23, 32, 34, 32, 34, 32, 34, 1, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 23, 1, 1, 2, 1, 1, 23, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 31, 1, 1, 2, 1, 1, 31, 1, 1, 1, 32, 34, 32, 34, 32, 34, 23],
[31, 2, 2, 2, 1, 1, 32, 34, 32, 34, 1, 1, 1, 23, 1, 1, 1, 1, 1, 31],
[23, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 1, 31, 1, 1, 62, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 31, 1, 1, 1, 1, 1, 1, 23, 1, 1, 1, 1, 1, 31],
[23, 32, 34, 32, 34, 32, 34, 1, 1, 32, 34, 32, 34, 31, 1, 1, 1, 1, 1, 23],
[31, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 31],
[ 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 23],
[32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 32, 34, 2, 31]
]
disallowed1F = [[2, 2], [3, 2], [1, 8], [2, 8], [7, 7], [8, 7], [10, 4], [11, 4], [11, 12],
[11, 13], [16, 10], [17, 10], [18, 10], [16, 12], [17, 12], [18, 12]]
disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10], [12, 10], [7, 14], [8, 14], [1, 15],
@@ -7,29 +57,12 @@ disallowed2F = [[16, 2], [17, 2], [18, 2], [15, 5], [15, 6], [10, 10], [11, 10],
[11, 1]]
def randomize_rock_tunnel(data, random):
def randomize_rock_tunnel(patch: "PokemonRedProcedurePatch | PokemonBlueProcedurePatch", random: random.Random):
seed = random.randint(0, 999999999999999999)
random.seed(seed)
map1f = []
map2f = []
address = rom_addresses["Map_Rock_Tunnel1F"]
for y in range(0, 18):
row = []
for x in range(0, 20):
row.append(data[address])
address += 1
map1f.append(row)
address = rom_addresses["Map_Rock_TunnelB1F"]
for y in range(0, 18):
row = []
for x in range(0, 20):
row.append(data[address])
address += 1
map2f.append(row)
map1f = [row.copy() for row in layout1F]
map2f = [row.copy() for row in layout2F]
current_map = map1f
@@ -305,14 +338,6 @@ def randomize_rock_tunnel(data, random):
current_map = map2f
check_addable_block(map2f, disallowed2F)
address = rom_addresses["Map_Rock_Tunnel1F"]
for y in map1f:
for x in y:
data[address] = x
address += 1
address = rom_addresses["Map_Rock_TunnelB1F"]
for y in map2f:
for x in y:
data[address] = x
address += 1
return seed
patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_Tunnel1F"], bytes([b for row in map1f for b in row]))
patch.write_token(APTokenTypes.WRITE, rom_addresses["Map_Rock_TunnelB1F"], bytes([b for row in map2f for b in row]))
return seed

View File

@@ -1,21 +1,66 @@
import os
import hashlib
import Utils
import bsdiff4
import pkgutil
from worlds.Files import APDeltaPatch
from .text import encode_text
import typing
import Utils
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes
from . import poke_data
from .items import item_table
from .text import encode_text
from .pokemon import set_mon_palettes
from .regions import PokemonRBWarp, map_ids, town_map_coords
from .rock_tunnel import randomize_rock_tunnel
from .rom_addresses import rom_addresses
from .regions import PokemonRBWarp, map_ids, town_map_coords
from . import poke_data
if typing.TYPE_CHECKING:
from . import PokemonRedBlueWorld
def write_quizzes(world, data, random):
class PokemonRedProcedurePatch(APProcedurePatch, APTokenMixin):
game = "Pokemon Red and Blue"
hash = "3d45c1ee9abd5738df46d2bdda8b57dc"
patch_file_ending = ".apred"
result_file_ending = ".gb"
def get_quiz(q, a):
procedure = [
("apply_bsdiff4", ["base_patch.bsdiff4"]),
("apply_tokens", ["token_data.bin"]),
]
@classmethod
def get_source_data(cls) -> bytes:
from . import PokemonRedBlueWorld
with open(PokemonRedBlueWorld.settings.red_rom_file, "rb") as infile:
base_rom_bytes = bytes(infile.read())
return base_rom_bytes
class PokemonBlueProcedurePatch(APProcedurePatch, APTokenMixin):
game = "Pokemon Red and Blue"
hash = "50927e843568814f7ed45ec4f944bd8b"
patch_file_ending = ".apblue"
result_file_ending = ".gb"
procedure = [
("apply_bsdiff4", ["base_patch.bsdiff4"]),
("apply_tokens", ["token_data.bin"]),
]
@classmethod
def get_source_data(cls) -> bytes:
from . import PokemonRedBlueWorld
with open(PokemonRedBlueWorld.settings.blue_rom_file, "rb") as infile:
base_rom_bytes = bytes(infile.read())
return base_rom_bytes
def write_quizzes(world: "PokemonRedBlueWorld", patch: PokemonBlueProcedurePatch | PokemonRedProcedurePatch):
random = world.random
def get_quiz(q: int, a: int):
if q == 0:
r = random.randint(0, 3)
if r == 0:
@@ -122,13 +167,13 @@ def write_quizzes(world, data, random):
elif q2 == 1:
if a:
state = random.choice(
['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut',
'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas',
'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota',
'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Jersey', 'New Mexico',
'New York', 'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania',
'Rhode Island', 'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont',
'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming'])
["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
"Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas",
"Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
"Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Jersey", "New Mexico",
"New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
"Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"])
else:
state = "New Hampshire"
return encode_text(
@@ -209,7 +254,7 @@ def write_quizzes(world, data, random):
return encode_text(f"{type1} deals<LINE>{eff}damage to<CONT>{type2} type?<DONE>")
elif q == 14:
fossil_level = world.multiworld.get_location("Fossil Level - Trainer Parties",
world.player).party_data[0]['level']
world.player).party_data[0]["level"]
if not a:
fossil_level += random.choice((-5, 5))
return encode_text(f"Fossil #MON<LINE>revive at level<CONT>{fossil_level}?<DONE>")
@@ -224,46 +269,49 @@ def write_quizzes(world, data, random):
return encode_text(f"According to<LINE>Monash Uni.,<CONT>{fodmap} {are_is}<CONT>considered high<CONT>in FODMAPs?<DONE>")
answers = [random.randint(0, 1) for _ in range(6)]
questions = random.sample((range(0, 16)), 6)
question_texts = []
question_texts: list[bytearray] = []
for i, question in enumerate(questions):
question_texts.append(get_quiz(question, answers[i]))
for i, quiz in enumerate(["A", "B", "C", "D", "E", "F"]):
data[rom_addresses[f"Quiz_Answer_{quiz}"]] = int(not answers[i]) << 4 | (i + 1)
write_bytes(data, question_texts[i], rom_addresses[f"Text_Quiz_{quiz}"])
patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Quiz_Answer_{quiz}"], bytes([int(not answers[i]) << 4 | (i + 1)]))
patch.write_token(APTokenTypes.WRITE, rom_addresses[f"Text_Quiz_{quiz}"], bytes(question_texts[i]))
def generate_output(world, output_directory: str):
random = world.random
def generate_output(world: "PokemonRedBlueWorld", output_directory: str):
game_version = world.options.game_version.current_key
data = bytes(get_base_rom_bytes(game_version))
base_patch = pkgutil.get_data(__name__, f'basepatch_{game_version}.bsdiff4')
patch_type = PokemonBlueProcedurePatch if game_version == "blue" else PokemonRedProcedurePatch
patch = patch_type(player=world.player, player_name=world.player_name)
patch.write_file("base_patch.bsdiff4", pkgutil.get_data(__name__, f"basepatch_{game_version}.bsdiff4"))
data = bytearray(bsdiff4.patch(data, base_patch))
def write_bytes(address: int, data: typing.Sequence[int] | int):
if isinstance(data, int):
data = bytes([data])
else:
data = bytes(data)
basemd5 = hashlib.md5()
basemd5.update(data)
patch.write_token(APTokenTypes.WRITE, address, data)
pallet_connections = {entrance: world.multiworld.get_entrance(f"Pallet Town to {entrance}",
world.player).connected_region.name for
entrance in ["Player's House 1F", "Oak's Lab",
"Rival's House"]}
world.player).connected_region.name
for entrance in ["Player's House 1F", "Oak's Lab", "Rival's House"]}
paths = None
if pallet_connections["Player's House 1F"] == "Oak's Lab":
paths = ((0x00, 4, 0x80, 5, 0x40, 1, 0xE0, 1, 0xFF), (0x40, 2, 0x20, 5, 0x80, 5, 0xFF))
paths = (bytes([0x00, 4, 0x80, 5, 0x40, 1, 0xE0, 1, 0xFF]), bytes([0x40, 2, 0x20, 5, 0x80, 5, 0xFF]))
elif pallet_connections["Rival's House"] == "Oak's Lab":
paths = ((0x00, 4, 0xC0, 3, 0x40, 1, 0xE0, 1, 0xFF), (0x40, 2, 0x10, 3, 0x80, 5, 0xFF))
paths = (bytes([0x00, 4, 0xC0, 3, 0x40, 1, 0xE0, 1, 0xFF]), bytes([0x40, 2, 0x10, 3, 0x80, 5, 0xFF]))
if paths:
write_bytes(data, paths[0], rom_addresses["Path_Pallet_Oak"])
write_bytes(data, paths[1], rom_addresses["Path_Pallet_Player"])
write_bytes(rom_addresses["Path_Pallet_Oak"], paths[0])
write_bytes(rom_addresses["Path_Pallet_Player"], paths[1])
if pallet_connections["Rival's House"] == "Player's House 1F":
write_bytes(data, [0x2F, 0xC7, 0x06, 0x0D, 0x00, 0x01], rom_addresses["Pallet_Fly_Coords"])
write_bytes(rom_addresses["Pallet_Fly_Coords"], [0x2F, 0xC7, 0x06, 0x0D, 0x00, 0x01])
elif pallet_connections["Oak's Lab"] == "Player's House 1F":
write_bytes(data, [0x5F, 0xC7, 0x0C, 0x0C, 0x00, 0x00], rom_addresses["Pallet_Fly_Coords"])
write_bytes(rom_addresses["Pallet_Fly_Coords"], [0x5F, 0xC7, 0x0C, 0x0C, 0x00, 0x00])
for region in world.multiworld.get_regions(world.player):
for entrance in region.exits:
@@ -281,16 +329,18 @@ def generate_output(world, output_directory: str):
while i > len(warp_to_ids) - 1:
i -= len(warp_to_ids)
connected_map_name = entrance.connected_region.name.split("-")[0]
data[address] = 0 if "Elevator" in connected_map_name else warp_to_ids[i]
data[address + 1] = map_ids[connected_map_name]
write_bytes(address, [
0 if "Elevator" in connected_map_name else warp_to_ids[i],
map_ids[connected_map_name]
])
if world.options.door_shuffle == "simple":
for (entrance, _, _, map_coords_entries, map_name, _) in town_map_coords.values():
destination = world.multiworld.get_entrance(entrance, world.player).connected_region.name
(_, x, y, _, _, map_order_entry) = town_map_coords[destination]
for map_coord_entry in map_coords_entries:
data[rom_addresses["Town_Map_Coords"] + (map_coord_entry * 4) + 1] = (y << 4) | x
data[rom_addresses["Town_Map_Order"] + map_order_entry] = map_ids[map_name]
write_bytes(rom_addresses["Town_Map_Coords"] + (map_coord_entry * 4) + 1, (y << 4) | x)
write_bytes(rom_addresses["Town_Map_Order"] + map_order_entry, map_ids[map_name])
if not world.options.key_items_only:
for i, gym_leader in enumerate(("Pewter Gym - Brock TM", "Cerulean Gym - Misty TM",
@@ -302,13 +352,13 @@ def generate_output(world, output_directory: str):
try:
tm = int(item_name[2:4])
move = poke_data.moves[world.local_tms[tm - 1]]["id"]
data[rom_addresses["Gym_Leader_Moves"] + (2 * i)] = move
write_bytes(rom_addresses["Gym_Leader_Moves"] + (2 * i), move)
except KeyError:
pass
def set_trade_mon(address, loc):
mon = world.multiworld.get_location(loc, world.player).item.name
data[rom_addresses[address]] = poke_data.pokemon_data[mon]["id"]
write_bytes(rom_addresses[address], poke_data.pokemon_data[mon]["id"])
world.trade_mons[address] = mon
if game_version == "red":
@@ -325,141 +375,139 @@ def generate_output(world, output_directory: str):
set_trade_mon("Trade_Doris", "Cerulean Cave 1F - Wild Pokemon - 9")
set_trade_mon("Trade_Crinkles", "Route 12 - Wild Pokemon - 4")
data[rom_addresses['Fly_Location']] = world.fly_map_code
data[rom_addresses['Map_Fly_Location']] = world.town_map_fly_map_code
write_bytes(rom_addresses["Fly_Location"], world.fly_map_code)
write_bytes(rom_addresses["Map_Fly_Location"], world.town_map_fly_map_code)
if world.options.fix_combat_bugs:
data[rom_addresses["Option_Fix_Combat_Bugs"]] = 1
data[rom_addresses["Option_Fix_Combat_Bugs_Focus_Energy"]] = 0x28 # jr z
data[rom_addresses["Option_Fix_Combat_Bugs_HP_Drain_Dream_Eater"]] = 0x1A # ld a, (de)
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"]] = 0xe6 # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1] = 0b0011111
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"]] = 0xe6 # and a, direct
data[rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1] = 0x3f
data[rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"]] = 0b10001100
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"]] = 0x20 # jr nz,
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"] + 1] = 5 # 5 bytes ahead
data[rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"]] = 1
write_bytes(rom_addresses["Option_Fix_Combat_Bugs"], 1)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Focus_Energy"], 0x28) # jr z
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_HP_Drain_Dream_Eater"], 0x1A) # ld a, (de)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"], 0xe6) # and a, direct
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_PP_Restore"] + 1, 0b0011111)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"], 0xe6) # and a, direct
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Struggle"] + 1, 0x3f)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Dig_Fly"], 0b10001100)
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"], 0x20) # jr nz,
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Effect"] + 1, 5) # 5 bytes ahead
write_bytes(rom_addresses["Option_Fix_Combat_Bugs_Heal_Stat_Modifiers"], 1)
if world.options.poke_doll_skip == "in_logic":
data[rom_addresses["Option_Silph_Scope_Skip"]] = 0x00 # nop
data[rom_addresses["Option_Silph_Scope_Skip"] + 1] = 0x00 # nop
data[rom_addresses["Option_Silph_Scope_Skip"] + 2] = 0x00 # nop
write_bytes(rom_addresses["Option_Silph_Scope_Skip"], 0x00) # nop
write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 1, 0x00) # nop
write_bytes(rom_addresses["Option_Silph_Scope_Skip"] + 2, 0x00) # nop
if world.options.bicycle_gate_skips == "patched":
data[rom_addresses["Option_Route_16_Gate_Fix"]] = 0x00 # nop
data[rom_addresses["Option_Route_16_Gate_Fix"] + 1] = 0x00 # nop
data[rom_addresses["Option_Route_18_Gate_Fix"]] = 0x00 # nop
data[rom_addresses["Option_Route_18_Gate_Fix"] + 1] = 0x00 # nop
write_bytes(rom_addresses["Option_Route_16_Gate_Fix"], 0x00) # nop
write_bytes(rom_addresses["Option_Route_16_Gate_Fix"] + 1, 0x00) # nop
write_bytes(rom_addresses["Option_Route_18_Gate_Fix"], 0x00) # nop
write_bytes(rom_addresses["Option_Route_18_Gate_Fix"] + 1, 0x00) # nop
if world.options.door_shuffle:
data[rom_addresses["Entrance_Shuffle_Fuji_Warp"]] = 1 # prevent warping to Fuji's House from Pokemon Tower 7F
write_bytes(rom_addresses["Entrance_Shuffle_Fuji_Warp"], 1) # prevent warping to Fuji's House from Pokemon Tower 7F
if world.options.all_elevators_locked:
data[rom_addresses["Option_Locked_Elevator_Celadon"]] = 0x20 # jr nz
data[rom_addresses["Option_Locked_Elevator_Silph"]] = 0x20 # jr nz
write_bytes(rom_addresses["Option_Locked_Elevator_Celadon"], 0x20) # jr nz
write_bytes(rom_addresses["Option_Locked_Elevator_Silph"], 0x20) # jr nz
if world.options.tea:
data[rom_addresses["Option_Tea"]] = 1
data[rom_addresses["Guard_Drink_List"]] = 0x54
data[rom_addresses["Guard_Drink_List"] + 1] = 0
data[rom_addresses["Guard_Drink_List"] + 2] = 0
write_bytes(data, encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though."
"<PARA>Oh wait there,<LINE>the road's closed.<DONE>"),
rom_addresses["Text_Saffron_Gate"])
write_bytes(rom_addresses["Option_Tea"], 1)
write_bytes(rom_addresses["Guard_Drink_List"], 0x54)
write_bytes(rom_addresses["Guard_Drink_List"] + 1, 0)
write_bytes(rom_addresses["Guard_Drink_List"] + 2, 0)
write_bytes(rom_addresses["Text_Saffron_Gate"],
encode_text("<LINE>Gee, I have the<CONT>worst caffeine<CONT>headache though."
"<PARA>Oh wait there,<LINE>the road's closed.<DONE>"))
data[rom_addresses["Tea_Key_Item_A"]] = 0x28 # jr .z
data[rom_addresses["Tea_Key_Item_B"]] = 0x28 # jr .z
data[rom_addresses["Tea_Key_Item_C"]] = 0x28 # jr .z
write_bytes(rom_addresses["Tea_Key_Item_A"], 0x28) # jr .z
write_bytes(rom_addresses["Tea_Key_Item_B"], 0x28) # jr .z
write_bytes(rom_addresses["Tea_Key_Item_C"], 0x28) # jr .z
data[rom_addresses["Fossils_Needed_For_Second_Item"]] = (
world.options.second_fossil_check_condition.value)
write_bytes(rom_addresses["Fossils_Needed_For_Second_Item"], world.options.second_fossil_check_condition.value)
data[rom_addresses["Option_Lose_Money"]] = int(not world.options.lose_money_on_blackout.value)
write_bytes(rom_addresses["Option_Lose_Money"], int(not world.options.lose_money_on_blackout.value))
if world.options.extra_key_items:
data[rom_addresses['Option_Extra_Key_Items_A']] = 1
data[rom_addresses['Option_Extra_Key_Items_B']] = 1
data[rom_addresses['Option_Extra_Key_Items_C']] = 1
data[rom_addresses['Option_Extra_Key_Items_D']] = 1
data[rom_addresses["Option_Split_Card_Key"]] = world.options.split_card_key.value
data[rom_addresses["Option_Blind_Trainers"]] = round(world.options.blind_trainers.value * 2.55)
data[rom_addresses["Option_Cerulean_Cave_Badges"]] = world.options.cerulean_cave_badges_condition.value
data[rom_addresses["Option_Cerulean_Cave_Key_Items"]] = world.options.cerulean_cave_key_items_condition.total
write_bytes(data, encode_text(str(world.options.cerulean_cave_badges_condition.value)), rom_addresses["Text_Cerulean_Cave_Badges"])
write_bytes(data, encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."), rom_addresses["Text_Cerulean_Cave_Key_Items"])
data[rom_addresses['Option_Encounter_Minimum_Steps']] = world.options.minimum_steps_between_encounters.value
data[rom_addresses['Option_Route23_Badges']] = world.options.victory_road_condition.value
data[rom_addresses['Option_Victory_Road_Badges']] = world.options.route_22_gate_condition.value
data[rom_addresses['Option_Elite_Four_Pokedex']] = world.options.elite_four_pokedex_condition.total
data[rom_addresses['Option_Elite_Four_Key_Items']] = world.options.elite_four_key_items_condition.total
data[rom_addresses['Option_Elite_Four_Badges']] = world.options.elite_four_badges_condition.value
write_bytes(data, encode_text(str(world.options.elite_four_badges_condition.value)), rom_addresses["Text_Elite_Four_Badges"])
write_bytes(data, encode_text(str(world.options.elite_four_key_items_condition.total) + " key items, and"), rom_addresses["Text_Elite_Four_Key_Items"])
write_bytes(data, encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"), rom_addresses["Text_Elite_Four_Pokedex"])
write_bytes(data, encode_text(str(world.total_key_items), length=2), rom_addresses["Trainer_Screen_Total_Key_Items"])
write_bytes(rom_addresses["Option_Extra_Key_Items_A"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_B"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_C"], 1)
write_bytes(rom_addresses["Option_Extra_Key_Items_D"], 1)
write_bytes(rom_addresses["Option_Split_Card_Key"], world.options.split_card_key.value)
write_bytes(rom_addresses["Option_Blind_Trainers"], round(world.options.blind_trainers.value * 2.55))
write_bytes(rom_addresses["Option_Cerulean_Cave_Badges"], world.options.cerulean_cave_badges_condition.value)
write_bytes(rom_addresses["Option_Cerulean_Cave_Key_Items"], world.options.cerulean_cave_key_items_condition.total)
write_bytes(rom_addresses["Text_Cerulean_Cave_Badges"], encode_text(str(world.options.cerulean_cave_badges_condition.value)))
write_bytes(rom_addresses["Text_Cerulean_Cave_Key_Items"], encode_text(str(world.options.cerulean_cave_key_items_condition.total) + " key items."))
write_bytes(rom_addresses["Option_Encounter_Minimum_Steps"], world.options.minimum_steps_between_encounters.value)
write_bytes(rom_addresses["Option_Route23_Badges"], world.options.victory_road_condition.value)
write_bytes(rom_addresses["Option_Victory_Road_Badges"], world.options.route_22_gate_condition.value)
write_bytes(rom_addresses["Option_Elite_Four_Pokedex"], world.options.elite_four_pokedex_condition.total)
write_bytes(rom_addresses["Option_Elite_Four_Key_Items"], world.options.elite_four_key_items_condition.total)
write_bytes(rom_addresses["Option_Elite_Four_Badges"], world.options.elite_four_badges_condition.value)
write_bytes(rom_addresses["Text_Elite_Four_Badges"], encode_text(str(world.options.elite_four_badges_condition.value)))
write_bytes(rom_addresses["Text_Elite_Four_Key_Items"], encode_text(str(world.options.elite_four_key_items_condition.total) + " key items, and"))
write_bytes(rom_addresses["Text_Elite_Four_Pokedex"], encode_text(str(world.options.elite_four_pokedex_condition.total) + " #MON"))
write_bytes(rom_addresses["Trainer_Screen_Total_Key_Items"], encode_text(str(world.total_key_items), length=2))
data[rom_addresses['Option_Viridian_Gym_Badges']] = world.options.viridian_gym_condition.value
data[rom_addresses['Option_EXP_Modifier']] = world.options.exp_modifier.value
write_bytes(rom_addresses["Option_Viridian_Gym_Badges"], world.options.viridian_gym_condition.value)
write_bytes(rom_addresses["Option_EXP_Modifier"], world.options.exp_modifier.value)
if not world.options.require_item_finder:
data[rom_addresses['Option_Itemfinder']] = 0 # nop
write_bytes(rom_addresses["Option_Itemfinder"], 0) # nop
if world.options.extra_strength_boulders:
for i in range(0, 3):
data[rom_addresses['Option_Boulders'] + (i * 3)] = 0x15
write_bytes(rom_addresses["Option_Boulders"] + (i * 3), 0x15)
if world.options.extra_key_items:
for i in range(0, 4):
data[rom_addresses['Option_Rock_Tunnel_Extra_Items'] + (i * 3)] = 0x15
write_bytes(rom_addresses["Option_Rock_Tunnel_Extra_Items"] + (i * 3), 0x15)
if world.options.old_man == "open_viridian_city":
data[rom_addresses['Option_Old_Man']] = 0x11
data[rom_addresses['Option_Old_Man_Lying']] = 0x15
data[rom_addresses['Option_Route3_Guard_B']] = world.options.route_3_condition.value
write_bytes(rom_addresses["Option_Old_Man"], 0x11)
write_bytes(rom_addresses["Option_Old_Man_Lying"], 0x15)
write_bytes(rom_addresses["Option_Route3_Guard_B"], world.options.route_3_condition.value)
if world.options.route_3_condition == "open":
data[rom_addresses['Option_Route3_Guard_A']] = 0x11
write_bytes(rom_addresses["Option_Route3_Guard_A"], 0x11)
if not world.options.robbed_house_officer:
data[rom_addresses['Option_Trashed_House_Guard_A']] = 0x15
data[rom_addresses['Option_Trashed_House_Guard_B']] = 0x11
write_bytes(rom_addresses["Option_Trashed_House_Guard_A"], 0x15)
write_bytes(rom_addresses["Option_Trashed_House_Guard_B"], 0x11)
if world.options.require_pokedex:
data[rom_addresses["Require_Pokedex_A"]] = 1
data[rom_addresses["Require_Pokedex_B"]] = 1
data[rom_addresses["Require_Pokedex_C"]] = 1
write_bytes(rom_addresses["Require_Pokedex_A"], 1)
write_bytes(rom_addresses["Require_Pokedex_B"], 1)
write_bytes(rom_addresses["Require_Pokedex_C"], 1)
else:
data[rom_addresses["Require_Pokedex_D"]] = 0x18 # jr
write_bytes(rom_addresses["Require_Pokedex_D"], 0x18) # jr
if world.options.dexsanity:
data[rom_addresses["Option_Dexsanity_A"]] = 1
data[rom_addresses["Option_Dexsanity_B"]] = 1
write_bytes(rom_addresses["Option_Dexsanity_A"], 1)
write_bytes(rom_addresses["Option_Dexsanity_B"], 1)
if world.options.all_pokemon_seen:
data[rom_addresses["Option_Pokedex_Seen"]] = 1
write_bytes(rom_addresses["Option_Pokedex_Seen"], 1)
money = str(world.options.starting_money.value).zfill(6)
data[rom_addresses["Starting_Money_High"]] = int(money[:2], 16)
data[rom_addresses["Starting_Money_Middle"]] = int(money[2:4], 16)
data[rom_addresses["Starting_Money_Low"]] = int(money[4:], 16)
data[rom_addresses["Text_Badges_Needed_Viridian_Gym"]] = encode_text(
str(world.options.viridian_gym_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_A"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_B"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_C"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Rt23_Badges_D"]] = encode_text(
str(world.options.victory_road_condition.value))[0]
data[rom_addresses["Text_Badges_Needed"]] = encode_text(
str(world.options.elite_four_badges_condition.value))[0]
write_bytes(data, encode_text(
" ".join(world.multiworld.get_location("Route 4 Pokemon Center - Pokemon For Sale", world.player).item.name.upper().split()[1:])),
rom_addresses["Text_Magikarp_Salesman"])
write_bytes(rom_addresses["Starting_Money_High"], int(money[:2], 16))
write_bytes(rom_addresses["Starting_Money_Middle"], int(money[2:4], 16))
write_bytes(rom_addresses["Starting_Money_Low"], int(money[4:], 16))
write_bytes(rom_addresses["Text_Badges_Needed_Viridian_Gym"],
encode_text(str(world.options.viridian_gym_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_A"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_B"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_C"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Rt23_Badges_D"],
encode_text(str(world.options.victory_road_condition.value))[0])
write_bytes(rom_addresses["Text_Badges_Needed"],
encode_text(str(world.options.elite_four_badges_condition.value))[0])
write_bytes(rom_addresses["Text_Magikarp_Salesman"],
encode_text(" ".join(world.multiworld.get_location("Route 4 Pokemon Center - Pokemon For Sale", world.player).item.name.upper().split()[1:])))
if world.options.badges_needed_for_hm_moves.value == 0:
for hm_move in poke_data.hm_moves:
write_bytes(data, bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
rom_addresses["HM_" + hm_move + "_Badge_a"])
write_bytes(rom_addresses["HM_" + hm_move + "_Badge_a"], [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
elif world.extra_badges:
written_badges = {}
badge_codes = {"Boulder Badge": 0x47, "Cascade Badge": 0x4F,
"Thunder Badge": 0x57, "Rainbow Badge": 0x5F,
"Soul Badge": 0x67, "Marsh Badge": 0x6F,
"Volcano Badge": 0x77, "Earth Badge": 0x7F}
for hm_move, badge in world.extra_badges.items():
data[rom_addresses["HM_" + hm_move + "_Badge_b"]] = {"Boulder Badge": 0x47, "Cascade Badge": 0x4F,
"Thunder Badge": 0x57, "Rainbow Badge": 0x5F,
"Soul Badge": 0x67, "Marsh Badge": 0x6F,
"Volcano Badge": 0x77, "Earth Badge": 0x7F}[badge]
write_bytes(rom_addresses["HM_" + hm_move + "_Badge_b"], badge_codes[badge])
move_text = hm_move
if badge not in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
move_text = ", " + move_text
@@ -467,62 +515,58 @@ def generate_output(world, output_directory: str):
if badge in written_badges:
rom_address += len(written_badges[badge])
move_text = ", " + move_text
write_bytes(data, encode_text(move_text.upper()), rom_address)
write_bytes(rom_address, encode_text(move_text.upper()))
written_badges[badge] = move_text
for badge in ["Marsh Badge", "Volcano Badge", "Earth Badge"]:
if badge not in written_badges:
write_bytes(data, encode_text("Nothing"), rom_addresses["Badge_Text_" + badge.replace(" ", "_")])
write_bytes(rom_addresses["Badge_Text_" + badge.replace(" ", "_")], encode_text("Nothing"))
type_loc = rom_addresses["Type_Chart"]
for matchup in world.type_chart:
if matchup[2] != 10: # don't needlessly divide damage by 10 and multiply by 10
data[type_loc] = poke_data.type_ids[matchup[0]]
data[type_loc + 1] = poke_data.type_ids[matchup[1]]
data[type_loc + 2] = matchup[2]
write_bytes(type_loc, [poke_data.type_ids[matchup[0]], poke_data.type_ids[matchup[1]], matchup[2]])
type_loc += 3
data[type_loc] = 0xFF
data[type_loc + 1] = 0xFF
data[type_loc + 2] = 0xFF
write_bytes(type_loc, b"\xFF\xFF\xFF")
if world.options.normalize_encounter_chances.value:
chances = [25, 51, 77, 103, 129, 155, 180, 205, 230, 255]
for i, chance in enumerate(chances):
data[rom_addresses['Encounter_Chances'] + (i * 2)] = chance
write_bytes(rom_addresses["Encounter_Chances"] + (i * 2), chance)
for mon, mon_data in world.local_poke_data.items():
if mon == "Mew":
address = rom_addresses["Base_Stats_Mew"]
else:
address = rom_addresses["Base_Stats"] + (28 * (mon_data["dex"] - 1))
data[address + 1] = world.local_poke_data[mon]["hp"]
data[address + 2] = world.local_poke_data[mon]["atk"]
data[address + 3] = world.local_poke_data[mon]["def"]
data[address + 4] = world.local_poke_data[mon]["spd"]
data[address + 5] = world.local_poke_data[mon]["spc"]
data[address + 6] = poke_data.type_ids[world.local_poke_data[mon]["type1"]]
data[address + 7] = poke_data.type_ids[world.local_poke_data[mon]["type2"]]
data[address + 8] = world.local_poke_data[mon]["catch rate"]
data[address + 15] = poke_data.moves[world.local_poke_data[mon]["start move 1"]]["id"]
data[address + 16] = poke_data.moves[world.local_poke_data[mon]["start move 2"]]["id"]
data[address + 17] = poke_data.moves[world.local_poke_data[mon]["start move 3"]]["id"]
data[address + 18] = poke_data.moves[world.local_poke_data[mon]["start move 4"]]["id"]
write_bytes(data, world.local_poke_data[mon]["tms"], address + 20)
write_bytes(address + 1, world.local_poke_data[mon]["hp"])
write_bytes(address + 2, world.local_poke_data[mon]["atk"])
write_bytes(address + 3, world.local_poke_data[mon]["def"])
write_bytes(address + 4, world.local_poke_data[mon]["spd"])
write_bytes(address + 5, world.local_poke_data[mon]["spc"])
write_bytes(address + 6, poke_data.type_ids[world.local_poke_data[mon]["type1"]])
write_bytes(address + 7, poke_data.type_ids[world.local_poke_data[mon]["type2"]])
write_bytes(address + 8, world.local_poke_data[mon]["catch rate"])
write_bytes(address + 15, poke_data.moves[world.local_poke_data[mon]["start move 1"]]["id"])
write_bytes(address + 16, poke_data.moves[world.local_poke_data[mon]["start move 2"]]["id"])
write_bytes(address + 17, poke_data.moves[world.local_poke_data[mon]["start move 3"]]["id"])
write_bytes(address + 18, poke_data.moves[world.local_poke_data[mon]["start move 4"]]["id"])
write_bytes(address + 20, world.local_poke_data[mon]["tms"])
if mon in world.learnsets and world.learnsets[mon]:
address = rom_addresses["Learnset_" + mon.replace(" ", "")]
for i, move in enumerate(world.learnsets[mon]):
data[(address + 1) + i * 2] = poke_data.moves[move]["id"]
write_bytes((address + 1) + i * 2, poke_data.moves[move]["id"])
data[rom_addresses["Option_Aide_Rt2"]] = world.options.oaks_aide_rt_2.value
data[rom_addresses["Option_Aide_Rt11"]] = world.options.oaks_aide_rt_11.value
data[rom_addresses["Option_Aide_Rt15"]] = world.options.oaks_aide_rt_15.value
write_bytes(rom_addresses["Option_Aide_Rt2"], world.options.oaks_aide_rt_2.value)
write_bytes(rom_addresses["Option_Aide_Rt11"], world.options.oaks_aide_rt_11.value)
write_bytes(rom_addresses["Option_Aide_Rt15"], world.options.oaks_aide_rt_15.value)
if world.options.safari_zone_normal_battles.value == 1:
data[rom_addresses["Option_Safari_Zone_Battle_Type"]] = 255
write_bytes(rom_addresses["Option_Safari_Zone_Battle_Type"], 255)
if world.options.reusable_tms.value:
data[rom_addresses["Option_Reusable_TMs"]] = 0xC9
write_bytes(rom_addresses["Option_Reusable_TMs"], 0xC9)
data[rom_addresses["Option_Always_Half_STAB"]] = int(not world.options.same_type_attack_bonus.value)
write_bytes(rom_addresses["Option_Always_Half_STAB"], int(not world.options.same_type_attack_bonus.value))
if world.options.better_shops:
inventory = ["Poke Ball", "Great Ball", "Ultra Ball"]
@@ -531,43 +575,45 @@ def generate_output(world, output_directory: str):
inventory += ["Potion", "Super Potion", "Hyper Potion", "Max Potion", "Full Restore", "Revive", "Antidote",
"Awakening", "Burn Heal", "Ice Heal", "Paralyze Heal", "Full Heal", "Repel", "Super Repel",
"Max Repel", "Escape Rope"]
shop_data = bytearray([0xFE, len(inventory)])
shop_data += bytearray([item_table[item].id - 172000000 for item in inventory])
shop_data = [0xFE, len(inventory)]
shop_data += [item_table[item].id - 172000000 for item in inventory]
shop_data.append(0xFF)
for shop in range(1, 11):
write_bytes(data, shop_data, rom_addresses[f"Shop{shop}"])
write_bytes(rom_addresses[f"Shop{shop}"], shop_data)
if world.options.stonesanity:
write_bytes(data, bytearray([0xFE, 1, item_table["Poke Doll"].id - 172000000, 0xFF]), rom_addresses[f"Shop_Stones"])
write_bytes(rom_addresses["Shop_Stones"], [0xFE, 1, item_table["Poke Doll"].id - 172000000, 0xFF])
price = str(world.options.master_ball_price.value).zfill(6)
price = bytearray([int(price[:2], 16), int(price[2:4], 16), int(price[4:], 16)])
write_bytes(data, price, rom_addresses["Price_Master_Ball"]) # Money values in Red and Blue are weird
price = [int(price[:2], 16), int(price[2:4], 16), int(price[4:], 16)]
write_bytes(rom_addresses["Price_Master_Ball"], price) # Money values in Red and Blue are weird
for item in reversed(world.multiworld.precollected_items[world.player]):
if data[rom_addresses["Start_Inventory"] + item.code - 172000000] < 255:
data[rom_addresses["Start_Inventory"] + item.code - 172000000] += 1
from collections import Counter
start_inventory = Counter(item.code for item in reversed(world.multiworld.precollected_items[world.player]))
for item, value in start_inventory.items():
write_bytes(rom_addresses["Start_Inventory"] + item - 172000000, min(value, 255))
set_mon_palettes(world, random, data)
set_mon_palettes(world, patch)
for move_data in world.local_move_data.values():
if move_data["id"] == 0:
continue
address = rom_addresses["Move_Data"] + ((move_data["id"] - 1) * 6)
write_bytes(data, bytearray([move_data["id"], move_data["effect"], move_data["power"],
poke_data.type_ids[move_data["type"]], round(move_data["accuracy"] * 2.55), move_data["pp"]]), address)
write_bytes(address, [move_data["id"], move_data["effect"], move_data["power"],
poke_data.type_ids[move_data["type"]], round(move_data["accuracy"] * 2.55),
move_data["pp"]])
TM_IDs = bytearray([poke_data.moves[move]["id"] for move in world.local_tms])
write_bytes(data, TM_IDs, rom_addresses["TM_Moves"])
TM_IDs = [poke_data.moves[move]["id"] for move in world.local_tms]
write_bytes(rom_addresses["TM_Moves"], TM_IDs)
if world.options.randomize_rock_tunnel:
seed = randomize_rock_tunnel(data, random)
write_bytes(data, encode_text(f"SEED: <LINE>{seed}"), rom_addresses["Text_Rock_Tunnel_Sign"])
seed = randomize_rock_tunnel(patch, world.random)
write_bytes(rom_addresses["Text_Rock_Tunnel_Sign"], encode_text(f"SEED: <LINE>{seed}"))
mons = [mon["id"] for mon in poke_data.pokemon_data.values()]
random.shuffle(mons)
data[rom_addresses['Title_Mon_First']] = mons.pop()
world.random.shuffle(mons)
write_bytes(rom_addresses["Title_Mon_First"], mons.pop())
for mon in range(0, 16):
data[rom_addresses['Title_Mons'] + mon] = mons.pop()
write_bytes(rom_addresses["Title_Mons"] + mon, mons.pop())
if world.options.game_version.value:
mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name
else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name else
@@ -576,34 +622,34 @@ def generate_output(world, output_directory: str):
mons.sort(key=lambda mon: 0 if mon == world.multiworld.get_location("Oak's Lab - Starter 2", world.player).item.name
else 1 if mon == world.multiworld.get_location("Oak's Lab - Starter 1", world.player).item.name else
2 if mon == world.multiworld.get_location("Oak's Lab - Starter 3", world.player).item.name else 3)
write_bytes(data, encode_text(world.multiworld.seed_name[-20:], 20, True), rom_addresses['Title_Seed'])
write_bytes(rom_addresses["Title_Seed"], encode_text(world.multiworld.seed_name[-20:], 20, True))
slot_name = world.multiworld.player_name[world.player]
slot_name.replace("@", " ")
slot_name.replace("<", " ")
slot_name.replace(">", " ")
write_bytes(data, encode_text(slot_name, 16, True, True), rom_addresses['Title_Slot_Name'])
write_bytes(rom_addresses["Title_Slot_Name"], encode_text(slot_name, 16, True, True))
if world.trainer_name == "choose_in_game":
data[rom_addresses["Skip_Player_Name"]] = 0
write_bytes(rom_addresses["Skip_Player_Name"], 0)
else:
write_bytes(data, world.trainer_name, rom_addresses['Player_Name'])
write_bytes(rom_addresses["Player_Name"], world.trainer_name)
if world.rival_name == "choose_in_game":
data[rom_addresses["Skip_Rival_Name"]] = 0
write_bytes(rom_addresses["Skip_Rival_Name"], 0)
else:
write_bytes(data, world.rival_name, rom_addresses['Rival_Name'])
write_bytes(rom_addresses["Rival_Name"], world.rival_name)
data[0xFF00] = 2 # client compatibility version
rom_name = bytearray(f'AP{Utils.__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0',
'utf8')[:21]
write_bytes(0xFF00, 2) # client compatibility version
rom_name = bytearray(f"AP{Utils.__version__.replace('.', '')[0:3]}_{world.player}_{world.multiworld.seed:11}\0",
"utf8")[:21]
rom_name.extend([0] * (21 - len(rom_name)))
write_bytes(data, rom_name, 0xFFC6)
write_bytes(data, world.multiworld.seed_name.encode(), 0xFFDB)
write_bytes(data, world.multiworld.player_name[world.player].encode(), 0xFFF0)
write_bytes(0xFFC6, rom_name)
write_bytes(0xFFDB, world.multiworld.seed_name.encode())
write_bytes(0xFFF0, world.multiworld.player_name[world.player].encode())
world.finished_level_scaling.wait()
write_quizzes(world, data, random)
write_quizzes(world, patch)
for location in world.multiworld.get_locations(world.player):
if location.party_data:
@@ -617,18 +663,18 @@ def generate_output(world, output_directory: str):
levels = party["level"]
for address, party in zip(addresses, parties):
if isinstance(levels, int):
data[address] = levels
write_bytes(address, levels)
address += 1
for mon in party:
data[address] = poke_data.pokemon_data[mon]["id"]
write_bytes(address, poke_data.pokemon_data[mon]["id"])
address += 1
else:
address += 1
for level, mon in zip(levels, party):
data[address] = level
data[address + 1] = poke_data.pokemon_data[mon]["id"]
write_bytes(address, [level, poke_data.pokemon_data[mon]["id"]])
address += 2
assert data[address] == 0 or location.name == "Fossil Level - Trainer Parties"
# This assert can't be done with procedure patch tokens.
# assert data[address] == 0 or location.name == "Fossil Level - Trainer Parties"
continue
elif location.rom_address is None:
continue
@@ -639,85 +685,24 @@ def generate_output(world, output_directory: str):
rom_address = [rom_address]
for address in rom_address:
if location.item.name in poke_data.pokemon_data.keys():
data[address] = poke_data.pokemon_data[location.item.name]["id"]
write_bytes(address, poke_data.pokemon_data[location.item.name]["id"])
elif " ".join(location.item.name.split()[1:]) in poke_data.pokemon_data.keys():
data[address] = poke_data.pokemon_data[" ".join(location.item.name.split()[1:])]["id"]
write_bytes(address, poke_data.pokemon_data[" ".join(location.item.name.split()[1:])]["id"])
else:
item_id = world.item_name_to_id[location.item.name] - 172000000
if item_id > 255:
item_id -= 256
data[address] = item_id
write_bytes(address, item_id)
if location.level:
data[location.level_address] = location.level
write_bytes(location.level_address, location.level)
else:
rom_address = location.rom_address
if not isinstance(rom_address, list):
rom_address = [rom_address]
for address in rom_address:
data[address] = 0x2C # AP Item
write_bytes(address, 0x2C) # AP Item
outfilepname = f'_P{world.player}'
outfilepname += f"_{world.multiworld.get_file_safe_player_name(world.player).replace(' ', '_')}" \
if world.multiworld.player_name[world.player] != 'Player%d' % world.player else ''
rompath = os.path.join(output_directory, f'AP_{world.multiworld.seed_name}{outfilepname}.gb')
with open(rompath, 'wb') as outfile:
outfile.write(data)
if world.options.game_version.current_key == "red":
patch = RedDeltaPatch(os.path.splitext(rompath)[0] + RedDeltaPatch.patch_file_ending, player=world.player,
player_name=world.multiworld.player_name[world.player], patched_path=rompath)
else:
patch = BlueDeltaPatch(os.path.splitext(rompath)[0] + BlueDeltaPatch.patch_file_ending, player=world.player,
player_name=world.multiworld.player_name[world.player], patched_path=rompath)
patch.write()
os.unlink(rompath)
def write_bytes(data, byte_array, address):
for byte in byte_array:
data[address] = byte
address += 1
def get_base_rom_bytes(game_version: str, hash: str="") -> bytes:
file_name = get_base_rom_path(game_version)
with open(file_name, "rb") as file:
base_rom_bytes = bytes(file.read())
if hash:
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if hash != basemd5.hexdigest():
raise Exception(f"Supplied Base Rom does not match known MD5 for Pokémon {game_version.title()} UE "
"release. Get the correct game and version, then dump it")
return base_rom_bytes
def get_base_rom_path(game_version: str) -> str:
options = Utils.get_options()
file_name = options["pokemon_rb_options"][f"{game_version}_rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name
class BlueDeltaPatch(APDeltaPatch):
patch_file_ending = ".apblue"
hash = "50927e843568814f7ed45ec4f944bd8b"
game_version = "blue"
game = "Pokemon Red and Blue"
result_file_ending = ".gb"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes(cls.game_version, cls.hash)
class RedDeltaPatch(APDeltaPatch):
patch_file_ending = ".apred"
hash = "3d45c1ee9abd5738df46d2bdda8b57dc"
game_version = "red"
game = "Pokemon Red and Blue"
result_file_ending = ".gb"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes(cls.game_version, cls.hash)
patch.write_file("token_data.bin", patch.get_token_binary())
out_file_name = world.multiworld.get_out_file_name_base(world.player)
patch.write(os.path.join(output_directory, f"{out_file_name}{patch.patch_file_ending}"))

View File

@@ -89,9 +89,12 @@ class ROM(object):
class FakeROM(ROM):
# to have the same code for real ROM and the webservice
def __init__(self, data={}):
def __init__(self, data=None):
super(FakeROM, self).__init__()
self.data = data
if data is None:
self.data = {}
else:
self.data = data
self.ipsPatches = []
def write(self, bytes):

View File

@@ -1,6 +1,15 @@
# Super Mario World - Changelog
## v2.1
### Features:
- Trap Link
- When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
- Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players
## v2.0
### Features:

View File

@@ -1,9 +1,11 @@
import logging
import time
from typing import Any
from NetUtils import ClientStatus, color
from NetUtils import ClientStatus, NetworkItem, color
from worlds.AutoSNIClient import SNIClient
from .Names.TextBox import generate_received_text
from .Names.TextBox import generate_received_text, generate_received_trap_link_text
from .Items import trap_value_to_name, trap_name_to_value
snes_logger = logging.getLogger("SNES")
@@ -42,10 +44,13 @@ SMW_MOON_ACTIVE_ADDR = ROM_START + 0x01BFA8
SMW_HIDDEN_1UP_ACTIVE_ADDR = ROM_START + 0x01BFA9
SMW_BONUS_BLOCK_ACTIVE_ADDR = ROM_START + 0x01BFAA
SMW_BLOCKSANITY_ACTIVE_ADDR = ROM_START + 0x01BFAB
SMW_TRAP_LINK_ACTIVE_ADDR = ROM_START + 0x01BFB7
SMW_RING_LINK_ACTIVE_ADDR = ROM_START + 0x01BFB8
SMW_GAME_STATE_ADDR = WRAM_START + 0x100
SMW_MARIO_STATE_ADDR = WRAM_START + 0x71
SMW_COIN_COUNT_ADDR = WRAM_START + 0xDBF
SMW_BOSS_STATE_ADDR = WRAM_START + 0xD9B
SMW_ACTIVE_BOSS_ADDR = WRAM_START + 0x13FC
SMW_CURRENT_LEVEL_ADDR = WRAM_START + 0x13BF
@@ -76,6 +81,7 @@ SMW_UNCOLLECTABLE_DRAGON_COINS = [0x24]
class SMWSNIClient(SNIClient):
game = "Super Mario World"
patch_suffix = ".apsmw"
slot_data: dict[str, Any] | None
async def deathlink_kill_player(self, ctx):
from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read
@@ -111,6 +117,84 @@ class SMWSNIClient(SNIClient):
ctx.last_death_link = time.time()
def on_package(self, ctx: SNIClient, cmd: str, args: dict[str, Any]) -> None:
super().on_package(ctx, cmd, args)
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
if cmd != "Bounced":
return
if "tags" not in args:
return
if not hasattr(self, "instance_id"):
self.instance_id = time.time()
source_name = args["data"]["source"]
if "TrapLink" in ctx.tags and "TrapLink" in args["tags"] and source_name != ctx.slot_info[ctx.slot].name:
trap_name: str = args["data"]["trap_name"]
if trap_name not in trap_name_to_value:
# We don't know how to handle this trap, ignore it
return
trap_id: int = trap_name_to_value[trap_name]
if "trap_weights" not in self.slot_data:
return
if f"{trap_id}" not in self.slot_data["trap_weights"]:
return
if self.slot_data["trap_weights"][f"{trap_id}"] == 0:
# The player disabled this trap type
return
self.priority_trap = NetworkItem(trap_id, None, None)
self.priority_trap_message = generate_received_trap_link_text(trap_name, source_name)
self.priority_trap_message_str = f"Received linked {trap_name} from {source_name}"
elif "RingLink" in ctx.tags and "RingLink" in args["tags"] and source_name != self.instance_id:
if not hasattr(self, "pending_ring_link"):
self.pending_ring_link = 0
self.pending_ring_link += args["data"]["amount"]
async def send_trap_link(self, ctx: SNIClient, trap_name: str):
if "TrapLink" not in ctx.tags or ctx.slot == None:
return
await ctx.send_msgs([{
"cmd": "Bounce", "tags": ["TrapLink"],
"data": {
"time": time.time(),
"source": ctx.player_names[ctx.slot],
"trap_name": trap_name
}
}])
snes_logger.info(f"Sent linked {trap_name}")
async def send_ring_link(self, ctx: SNIClient, amount: int):
from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read
if "RingLink" not in ctx.tags or ctx.slot == None:
return
game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
if game_state[0] != 0x14:
return
if not hasattr(self, "instance_id"):
self.instance_id = time.time()
await ctx.send_msgs([{
"cmd": "Bounce", "tags": ["RingLink"],
"data": {
"time": time.time(),
"source": self.instance_id,
"amount": amount
}
}])
async def validate_rom(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -123,9 +207,11 @@ class SMWSNIClient(SNIClient):
receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1)
send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1)
trap_link = await snes_read(ctx, SMW_TRAP_LINK_ACTIVE_ADDR, 0x1)
ctx.receive_option = receive_option[0]
ctx.send_option = send_option[0]
ctx.trap_link = trap_link[0]
ctx.allow_collect = True
@@ -133,6 +219,15 @@ class SMWSNIClient(SNIClient):
if death_link:
await ctx.update_death_link(bool(death_link[0] & 0b1))
if trap_link and bool(trap_link[0] & 0b1) and "TrapLink" not in ctx.tags:
ctx.tags.add("TrapLink")
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
ring_link = await snes_read(ctx, SMW_RING_LINK_ACTIVE_ADDR, 1)
if ring_link and bool(ring_link[0] & 0b1) and "RingLink" not in ctx.tags:
ctx.tags.add("RingLink")
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
if ctx.rom != rom_name:
ctx.current_sublevel_value = 0
@@ -142,12 +237,17 @@ class SMWSNIClient(SNIClient):
def add_message_to_queue(self, new_message):
if not hasattr(self, "message_queue"):
self.message_queue = []
self.message_queue.append(new_message)
def add_message_to_queue_front(self, new_message):
if not hasattr(self, "message_queue"):
self.message_queue = []
self.message_queue.insert(0, new_message)
async def handle_message_queue(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -206,7 +306,8 @@ class SMWSNIClient(SNIClient):
async def handle_trap_queue(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
if not hasattr(self, "trap_queue") or len(self.trap_queue) == 0:
if (not hasattr(self, "trap_queue") or len(self.trap_queue) == 0) and\
(not hasattr(self, "priority_trap") or self.priority_trap == 0):
return
game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
@@ -221,7 +322,24 @@ class SMWSNIClient(SNIClient):
if pause_state[0] != 0x00:
return
next_trap, message = self.trap_queue.pop(0)
next_trap = None
message = bytearray()
message_str = ""
from_queue = False
if getattr(self, "priority_trap", None) and self.priority_trap.item != 0:
next_trap = self.priority_trap
message = self.priority_trap_message
message_str = self.priority_trap_message_str
self.priority_trap = None
self.priority_trap_message = bytearray()
self.priority_trap_message_str = ""
elif hasattr(self, "trap_queue") and len(self.trap_queue) > 0:
from_queue = True
next_trap, message = self.trap_queue.pop(0)
else:
return
from .Rom import trap_rom_data
if next_trap.item in trap_rom_data:
@@ -231,16 +349,22 @@ class SMWSNIClient(SNIClient):
# Timer Trap
if trap_active[0] == 0 or (trap_active[0] == 1 and trap_active[1] == 0 and trap_active[2] == 0):
# Trap already active
self.add_trap_to_queue(next_trap, message)
if from_queue:
self.add_trap_to_queue(next_trap, message)
return
else:
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([0x01]))
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 1, bytes([0x00]))
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0] + 2, bytes([0x00]))
else:
if trap_active[0] > 0:
# Trap already active
self.add_trap_to_queue(next_trap, message)
if from_queue:
self.add_trap_to_queue(next_trap, message)
return
else:
if next_trap.item == 0xBC001D:
@@ -248,12 +372,18 @@ class SMWSNIClient(SNIClient):
# Do not fire if the previous thwimp hasn't reached the player's Y pos
active_thwimp = await snes_read(ctx, SMW_ACTIVE_THWIMP_ADDR, 0x1)
if active_thwimp[0] != 0xFF:
self.add_trap_to_queue(next_trap, message)
if from_queue:
self.add_trap_to_queue(next_trap, message)
return
verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1)
if verify_game_state[0] == 0x14 and len(trap_rom_data[next_trap.item]) > 2:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([trap_rom_data[next_trap.item][2]]))
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
new_item_count = trap_rom_data[next_trap.item][1]
snes_buffered_write(ctx, WRAM_START + trap_rom_data[next_trap.item][0], bytes([new_item_count]))
@@ -270,9 +400,75 @@ class SMWSNIClient(SNIClient):
return
if self.should_show_message(ctx, next_trap):
self.add_message_to_queue_front(message)
elif next_trap.item == 0xBC0015:
if self.should_show_message(ctx, next_trap):
self.add_message_to_queue_front(message)
if len(message_str) > 0:
snes_logger.info(message_str)
if "TrapLink" in ctx.tags and from_queue:
await self.send_trap_link(ctx, trap_value_to_name[next_trap.item])
# Handle Literature Trap
from .Names.LiteratureTrap import lit_trap_text_list
import random
rand_trap = random.choice(lit_trap_text_list)
for message in rand_trap:
self.add_message_to_queue(message)
async def handle_ring_link(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
if "RingLink" not in ctx.tags:
return
if not hasattr(self, "prev_coins"):
self.prev_coins = 0
curr_coins_byte = await snes_read(ctx, SMW_COIN_COUNT_ADDR, 0x1)
curr_coins = curr_coins_byte[0]
if curr_coins < self.prev_coins:
# Coins rolled over from 1-Up
curr_coins += 100
coins_diff = curr_coins - self.prev_coins
if coins_diff > 0:
await self.send_ring_link(ctx, coins_diff)
self.prev_coins = curr_coins % 100
new_coins = curr_coins
if not hasattr(self, "pending_ring_link"):
self.pending_ring_link = 0
if self.pending_ring_link != 0:
new_coins += self.pending_ring_link
new_coins = max(new_coins, 0)
new_1_ups = 0
while new_coins >= 100:
new_1_ups += 1
new_coins -= 100
if new_1_ups > 0:
curr_lives_inc_byte = await snes_read(ctx, WRAM_START + 0x18E4, 0x1)
curr_lives_inc = curr_lives_inc_byte[0]
new_lives_inc = curr_lives_inc + new_1_ups
snes_buffered_write(ctx, WRAM_START + 0x18E4, bytes([new_lives_inc]))
snes_buffered_write(ctx, SMW_COIN_COUNT_ADDR, bytes([new_coins]))
if self.pending_ring_link > 0:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x01]))
else:
snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x2A]))
self.pending_ring_link = 0
self.prev_coins = new_coins
await snes_flush_writes(ctx)
async def game_watcher(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
@@ -333,6 +529,7 @@ class SMWSNIClient(SNIClient):
await self.handle_message_queue(ctx)
await self.handle_trap_queue(ctx)
await self.handle_ring_link(ctx)
new_checks = []
event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60)
@@ -506,7 +703,7 @@ class SMWSNIClient(SNIClient):
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
if self.should_show_message(ctx, item):
if item.item != 0xBC0012 and item.item not in trap_rom_data:
if item.item != 0xBC0012 and item.item != 0xBC0015 and item.item not in trap_rom_data:
# Don't send messages for Boss Tokens
item_name = ctx.item_names.lookup_in_game(item.item)
player_name = ctx.player_names[item.player]
@@ -515,7 +712,7 @@ class SMWSNIClient(SNIClient):
self.add_message_to_queue(receive_message)
snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index&0xFF, (recv_index>>8)&0xFF]))
if item.item in trap_rom_data:
if item.item in trap_rom_data or item.item == 0xBC0015:
item_name = ctx.item_names.lookup_in_game(item.item)
player_name = ctx.player_names[item.player]
@@ -572,14 +769,6 @@ class SMWSNIClient(SNIClient):
else:
# Extra Powerup?
pass
elif item.item == 0xBC0015:
# Handle Literature Trap
from .Names.LiteratureTrap import lit_trap_text_list
import random
rand_trap = random.choice(lit_trap_text_list)
for message in rand_trap:
self.add_message_to_queue(message)
await snes_flush_writes(ctx)

View File

@@ -75,3 +75,49 @@ item_table = {
}
lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code}
trap_value_to_name: typing.Dict[int, str] = {
0xBC0013: ItemName.ice_trap,
0xBC0014: ItemName.stun_trap,
0xBC0015: ItemName.literature_trap,
0xBC0016: ItemName.timer_trap,
0xBC001C: ItemName.reverse_controls_trap,
0xBC001D: ItemName.thwimp_trap,
}
trap_name_to_value: typing.Dict[str, int] = {
# Our native Traps
ItemName.ice_trap: 0xBC0013,
ItemName.stun_trap: 0xBC0014,
ItemName.literature_trap: 0xBC0015,
ItemName.timer_trap: 0xBC0016,
ItemName.reverse_controls_trap: 0xBC001C,
ItemName.thwimp_trap: 0xBC001D,
# Common other trap names
"Chaos Control Trap": 0xBC0014, # Stun Trap
"Confuse Trap": 0xBC001C, # Reverse Trap
"Exposition Trap": 0xBC0015, # Literature Trap
"Cutscene Trap": 0xBC0015, # Literature Trap
"Freeze Trap": 0xBC0014, # Stun Trap
"Frozen Trap": 0xBC0014, # Stun Trap
"Paralyze Trap": 0xBC0014, # Stun Trap
"Reversal Trap": 0xBC001C, # Reverse Trap
"Fuzzy Trap": 0xBC001C, # Reverse Trap
"Confound Trap": 0xBC001C, # Reverse Trap
"Confusion Trap": 0xBC001C, # Reverse Trap
"Police Trap": 0xBC001D, # Thwimp Trap
"Buyon Trap": 0xBC001D, # Thwimp Trap
"Gooey Bag": 0xBC001D, # Thwimp Trap
"TNT Barrel Trap": 0xBC001D, # Thwimp Trap
"Honey Trap": 0xBC0014, # Stun Trap
"Screen Flip Trap": 0xBC001C, # Reverse Trap
"Banana Trap": 0xBC0013, # Ice Trap
"Bomb": 0xBC001D, # Thwimp Trap
"Bonk Trap": 0xBC0014, # Stun Trap
"Ghost": 0xBC001D, # Thwimp Trap
"Fast Trap": 0xBC0016, # Timer Trap
"Nut Trap": 0xBC001D, # Thwimp Trap
"Army Trap": 0xBC001D, # Thwimp Trap
}

View File

@@ -117,6 +117,31 @@ def generate_received_text(item_name: str, player_name: str):
return out_array
def generate_received_trap_link_text(item_name: str, player_name: str):
out_array = bytearray()
item_name = item_name[:18]
player_name = player_name[:18]
item_buffer = max(0, math.floor((18 - len(item_name)) / 2))
player_buffer = max(0, math.floor((18 - len(player_name)) / 2))
out_array += bytearray([0x9F, 0x9F])
out_array += string_to_bytes(" Received linked")
out_array[-1] += 0x80
out_array += bytearray([0x1F] * item_buffer)
out_array += string_to_bytes(item_name)
out_array[-1] += 0x80
out_array += string_to_bytes(" from")
out_array[-1] += 0x80
out_array += bytearray([0x1F] * player_buffer)
out_array += string_to_bytes(player_name)
out_array[-1] += 0x80
out_array += bytearray([0x9F, 0x9F])
return out_array
def generate_sent_text(item_name: str, player_name: str):
out_array = bytearray()

View File

@@ -398,6 +398,20 @@ class StartingLifeCount(Range):
default = 5
class RingLink(Toggle):
"""
Whether your in-level coin gain/loss is linked to other players
"""
display_name = "Ring Link"
class TrapLink(Toggle):
"""
Whether your received traps are linked to other players
"""
display_name = "Trap Link"
smw_option_groups = [
OptionGroup("Goal Options", [
Goal,
@@ -447,6 +461,8 @@ smw_option_groups = [
@dataclass
class SMWOptions(PerGameCommonOptions):
death_link: DeathLink
ring_link: RingLink
trap_link: TrapLink
goal: Goal
bosses_required: BossesRequired
max_yoshi_egg_cap: NumberOfYoshiEggs

View File

@@ -719,8 +719,8 @@ def handle_vertical_scroll(rom):
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0D0-0DF
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, # Levels 0E0-0EF
0x02, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, # Levels 0F0-0FF
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, # Levels 100-10F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 110-11F
0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x01, # Levels 100-10F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x01, # Levels 110-11F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 120-12F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 130-13F
0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, # Levels 140-14F
@@ -3160,6 +3160,8 @@ def patch_rom(world: World, rom, player, active_level_dict):
rom.write_byte(0x01BFA9, world.options.hidden_1up_checks.value)
rom.write_byte(0x01BFAA, world.options.bonus_block_checks.value)
rom.write_byte(0x01BFAB, world.options.blocksanity.value)
rom.write_byte(0x01BFB7, world.options.trap_link.value)
rom.write_byte(0x01BFB8, world.options.ring_link.value)
from Utils import __version__

View File

@@ -90,6 +90,7 @@ class SMWWorld(World):
"blocksanity",
)
slot_data["active_levels"] = self.active_level_dict
slot_data["trap_weights"] = self.output_trap_weights()
return slot_data
@@ -322,3 +323,15 @@ class SMWWorld(World):
def set_rules(self):
set_rules(self)
def output_trap_weights(self) -> dict[int, int]:
trap_data = {}
trap_data[0xBC0013] = self.options.ice_trap_weight.value
trap_data[0xBC0014] = self.options.stun_trap_weight.value
trap_data[0xBC0015] = self.options.literature_trap_weight.value
trap_data[0xBC0016] = self.options.timer_trap_weight.value
trap_data[0xBC001C] = self.options.reverse_trap_weight.value
trap_data[0xBC001D] = self.options.thwimp_trap_weight.value
return trap_data

View File

@@ -299,17 +299,9 @@ class StardewValleyWorld(World):
return StardewItem(item.name, override_classification, item.code, self.player)
def create_event_location(self, location_data: LocationData, rule: StardewRule = None, item: Optional[str] = None):
if rule is None:
rule = True_()
if item is None:
item = location_data.name
def create_event_location(self, location_data: LocationData, rule: StardewRule, item: str):
region = self.multiworld.get_region(location_data.region, self.player)
location = StardewLocation(self.player, location_data.name, None, region)
location.access_rule = rule
region.locations.append(location)
location.place_locked_item(StardewItem(item, ItemClassification.progression, None, self.player))
region.add_event(location_data.name, item, rule, StardewLocation, StardewItem)
def set_rules(self):
set_rules(self)

View File

@@ -154,7 +154,7 @@ class FestivalLogic(BaseLogic):
# Salads at the bar are good enough
cooking_rule = self.logic.money.can_spend_at(Region.saloon, 220)
fish_rule = self.logic.skill.can_fish(difficulty=50)
fish_rule = self.logic.fishing.can_fish_anywhere(50)
# Hazelnut always available since the grange display is in fall
forage_rule = self.logic.region.can_reach_any((Region.forest, Region.backwoods))
@@ -179,7 +179,7 @@ class FestivalLogic(BaseLogic):
return animal_rule & artisan_rule & cooking_rule & fish_rule & forage_rule & fruit_rule & mineral_rule & vegetable_rule
def can_win_fishing_competition(self) -> StardewRule:
return self.logic.skill.can_fish(difficulty=60)
return self.logic.fishing.can_fish(60)
def has_all_rarecrows(self) -> StardewRule:
rules = []

View File

@@ -1,3 +1,5 @@
from functools import cached_property
from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic
from ..data import fish_data
@@ -12,6 +14,8 @@ from ..strings.quality_names import FishQuality
from ..strings.region_names import Region
from ..strings.skill_names import Skill
fishing_regions = (Region.beach, Region.town, Region.forest, Region.mountain, Region.island_south, Region.island_west)
class FishingLogicMixin(BaseLogicMixin):
def __init__(self, *args, **kwargs):
@@ -20,17 +24,35 @@ class FishingLogicMixin(BaseLogicMixin):
class FishingLogic(BaseLogic):
def can_fish_in_freshwater(self) -> StardewRule:
return self.logic.skill.can_fish() & self.logic.region.can_reach_any((Region.forest, Region.town, Region.mountain))
@cache_self1
def can_fish_anywhere(self, difficulty: int = 0) -> StardewRule:
return self.logic.fishing.can_fish(difficulty) & self.logic.region.can_reach_any(fishing_regions)
def can_fish_in_freshwater(self) -> StardewRule:
return self.logic.fishing.can_fish() & self.logic.region.can_reach_any((Region.forest, Region.town, Region.mountain))
@cached_property
def has_max_fishing(self) -> StardewRule:
return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 10)
@cached_property
def can_fish_chests(self) -> StardewRule:
return self.logic.tool.has_fishing_rod(4) & self.logic.skill.has_level(Skill.fishing, 6)
@cache_self1
def can_fish_at(self, region: str) -> StardewRule:
return self.logic.skill.can_fish() & self.logic.region.can_reach(region)
return self.logic.fishing.can_fish() & self.logic.region.can_reach(region)
@cache_self1
def can_fish(self, difficulty: int = 0) -> StardewRule:
skill_required = min(10, max(0, int((difficulty / 10) - 1)))
if difficulty <= 40:
skill_required = 0
skill_rule = self.logic.skill.has_level(Skill.fishing, skill_required)
# Training rod only works with fish < 50. Fiberglass does not help you to catch higher difficulty fish, so it's skipped in logic.
number_fishing_rod_required = 1 if difficulty < 50 else (2 if difficulty < 80 else 4)
return self.logic.tool.has_fishing_rod(number_fishing_rod_required) & skill_rule
@cache_self1
def can_catch_fish(self, fish: FishItem) -> StardewRule:
@@ -39,14 +61,17 @@ class FishingLogic(BaseLogic):
quest_rule = self.logic.fishing.can_start_extended_family_quest()
region_rule = self.logic.region.can_reach_any(fish.locations)
season_rule = self.logic.season.has_any(fish.seasons)
if fish.difficulty == -1:
difficulty_rule = self.logic.skill.can_crab_pot
difficulty_rule = self.logic.fishing.can_crab_pot
else:
difficulty_rule = self.logic.skill.can_fish(difficulty=(120 if fish.legendary else fish.difficulty))
difficulty_rule = self.logic.fishing.can_fish(120 if fish.legendary else fish.difficulty)
if fish.name == SVEFish.kittyfish:
item_rule = self.logic.received(SVEQuestItem.kittyfish_spell)
else:
item_rule = True_()
return quest_rule & region_rule & season_rule & difficulty_rule & item_rule
def can_catch_fish_for_fishsanity(self, fish: FishItem) -> StardewRule:
@@ -78,7 +103,7 @@ class FishingLogic(BaseLogic):
return self.logic.tool.has_fishing_rod(4) & self.logic.has(tackle)
def can_catch_every_fish(self) -> StardewRule:
rules = [self.has_max_fishing()]
rules = [self.has_max_fishing]
rules.extend(
self.logic.fishing.can_catch_fish(fish)
@@ -89,3 +114,23 @@ class FishingLogic(BaseLogic):
def has_specific_bait(self, fish: FishItem) -> StardewRule:
return self.can_catch_fish(fish) & self.logic.has(Machine.bait_maker)
@cached_property
def can_crab_pot_anywhere(self) -> StardewRule:
return self.logic.fishing.can_fish() & self.logic.region.can_reach_any(fishing_regions)
@cache_self1
def can_crab_pot_at(self, region: str) -> StardewRule:
return self.logic.fishing.can_crab_pot & self.logic.region.can_reach(region)
@cached_property
def can_crab_pot(self) -> StardewRule:
crab_pot_rule = self.logic.has(Fishing.bait)
# We can't use the same rule if skills are vanilla, because fishing levels are required to crab pot, which is required to get fishing levels...
if self.content.features.skill_progression.is_progressive:
crab_pot_rule = crab_pot_rule & self.logic.has(Machine.crab_pot)
else:
crab_pot_rule = crab_pot_rule & self.logic.skill.can_get_fishing_xp
return crab_pot_rule

View File

@@ -60,7 +60,7 @@ class GoalLogic(BaseLogic):
if not self.content.features.fishsanity.is_enabled:
return self.logic.fishing.can_catch_every_fish()
rules = [self.logic.fishing.has_max_fishing()]
rules = [self.logic.fishing.has_max_fishing]
rules.extend(
self.logic.fishing.can_catch_fish_for_fishsanity(fish)

View File

@@ -130,9 +130,9 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
# @formatter:off
self.registry.item_rules.update({
"Energy Tonic": self.money.can_spend_at(Region.hospital, 1000),
WaterChest.fishing_chest: self.fishing.can_fish_chests(),
WaterChest.golden_fishing_chest: self.fishing.can_fish_chests() & self.skill.has_mastery(Skill.fishing),
WaterChest.treasure: self.fishing.can_fish_chests(),
WaterChest.fishing_chest: self.fishing.can_fish_chests,
WaterChest.golden_fishing_chest: self.fishing.can_fish_chests & self.skill.has_mastery(Skill.fishing),
WaterChest.treasure: self.fishing.can_fish_chests,
Ring.hot_java_ring: self.region.can_reach(Region.volcano_floor_10),
"Galaxy Soul": self.money.can_trade_at(Region.qi_walnut_room, Currency.qi_gem, 40),
"JotPK Big Buff": self.arcade.has_jotpk_power_level(7),
@@ -164,7 +164,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
AnimalProduct.large_milk: self.animal.has_happy_animal(Animal.cow),
AnimalProduct.milk: self.animal.has_animal(Animal.cow),
AnimalProduct.rabbit_foot: self.animal.has_happy_animal(Animal.rabbit),
AnimalProduct.roe: self.skill.can_fish() & self.building.has_building(Building.fish_pond),
AnimalProduct.roe: self.fishing.can_fish_anywhere() & self.building.has_building(Building.fish_pond),
AnimalProduct.squid_ink: self.mine.can_mine_in_the_mines_floor_81_120() | (self.building.has_building(Building.fish_pond) & self.has(Fish.squid)),
AnimalProduct.sturgeon_roe: self.has(Fish.sturgeon) & self.building.has_building(Building.fish_pond),
AnimalProduct.truffle: self.animal.has_animal(Animal.pig) & self.season.has_any_not_winter(),
@@ -198,7 +198,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
ArtisanGood.targeted_bait: self.artisan.has_targeted_bait(),
ArtisanGood.stardrop_tea: self.has(WaterChest.golden_fishing_chest),
ArtisanGood.truffle_oil: self.has(AnimalProduct.truffle) & self.has(Machine.oil_maker),
ArtisanGood.void_mayonnaise: (self.skill.can_fish(Region.witch_swamp)) | (self.artisan.can_mayonnaise(AnimalProduct.void_egg)),
ArtisanGood.void_mayonnaise: self.artisan.can_mayonnaise(AnimalProduct.void_egg),
Beverage.pina_colada: self.money.can_spend_at(Region.island_resort, 600),
Beverage.triple_shot_espresso: self.has("Hot Java Ring"),
Consumable.butterfly_powder: self.money.can_spend_at(Region.sewer, 20000),
@@ -217,15 +217,15 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Fertilizer.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
Fertilizer.tree: self.skill.has_level(Skill.foraging, 7) & self.has(Material.fiber) & self.has(Material.stone),
Fish.any: self.logic.or_(*(self.fishing.can_catch_fish(fish) for fish in content.fishes.values())),
Fish.crab: self.skill.can_crab_pot_at(Region.beach),
Fish.crayfish: self.skill.can_crab_pot_at(Region.town),
Fish.lobster: self.skill.can_crab_pot_at(Region.beach),
Fish.crab: self.fishing.can_crab_pot_at(Region.beach),
Fish.crayfish: self.fishing.can_crab_pot_at(Region.town),
Fish.lobster: self.fishing.can_crab_pot_at(Region.beach),
Fish.mussel: self.tool.can_forage(Generic.any, Region.beach) or self.has(Fish.mussel_node),
Fish.mussel_node: self.region.can_reach(Region.island_west),
Fish.oyster: self.tool.can_forage(Generic.any, Region.beach),
Fish.periwinkle: self.skill.can_crab_pot_at(Region.town),
Fish.shrimp: self.skill.can_crab_pot_at(Region.beach),
Fish.snail: self.skill.can_crab_pot_at(Region.town),
Fish.periwinkle: self.fishing.can_crab_pot_at(Region.town),
Fish.shrimp: self.fishing.can_crab_pot_at(Region.beach),
Fish.snail: self.fishing.can_crab_pot_at(Region.town),
Fishing.curiosity_lure: self.monster.can_kill(self.monster.all_monsters_by_name[Monster.mummy]),
Fishing.lead_bobber: self.skill.has_level(Skill.fishing, 6) & self.money.can_spend_at(Region.fish_shop, 200),
Forageable.hay: self.building.has_building(Building.silo) & self.tool.has_tool(Tool.scythe), #
@@ -235,7 +235,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Fossil.fossilized_leg: self.region.can_reach(Region.dig_site) & self.tool.has_tool(Tool.pickaxe),
Fossil.fossilized_ribs: self.region.can_reach(Region.island_south) & self.tool.has_tool(Tool.hoe),
Fossil.fossilized_skull: self.action.can_open_geode(Geode.golden_coconut),
Fossil.fossilized_spine: self.skill.can_fish(Region.dig_site),
Fossil.fossilized_spine: self.fishing.can_fish_at(Region.dig_site),
Fossil.fossilized_tail: self.action.can_pan_at(Region.dig_site, ToolMaterial.copper),
Fossil.mummified_bat: self.region.can_reach(Region.volcano_floor_10),
Fossil.mummified_frog: self.region.can_reach(Region.island_east) & self.tool.has_tool(Tool.scythe),
@@ -296,12 +296,12 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
RetainingSoil.quality: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
SpeedGro.basic: self.money.can_spend_at(Region.pierre_store, 100),
SpeedGro.deluxe: self.time.has_year_two & self.money.can_spend_at(Region.pierre_store, 150),
Trash.broken_cd: self.skill.can_crab_pot,
Trash.broken_glasses: self.skill.can_crab_pot,
Trash.driftwood: self.skill.can_crab_pot,
Trash.broken_cd: self.fishing.can_crab_pot_anywhere,
Trash.broken_glasses: self.fishing.can_crab_pot_anywhere,
Trash.driftwood: self.fishing.can_crab_pot_anywhere,
Trash.joja_cola: self.money.can_spend_at(Region.saloon, 75),
Trash.soggy_newspaper: self.skill.can_crab_pot,
Trash.trash: self.skill.can_crab_pot,
Trash.soggy_newspaper: self.fishing.can_crab_pot_anywhere,
Trash.trash: self.fishing.can_crab_pot_anywhere,
TreeSeed.acorn: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(),
TreeSeed.mahogany: self.region.can_reach(Region.secret_woods) & self.tool.has_tool(Tool.axe, ToolMaterial.iron) & self.skill.has_level(Skill.foraging, 1),
TreeSeed.maple: self.skill.has_level(Skill.foraging, 1) & self.ability.can_chop_trees(),
@@ -314,8 +314,8 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
WaterItem.cave_jelly: self.fishing.can_fish_at(Region.mines_floor_100) & self.tool.has_fishing_rod(2),
WaterItem.river_jelly: self.fishing.can_fish_at(Region.town) & self.tool.has_fishing_rod(2),
WaterItem.sea_jelly: self.fishing.can_fish_at(Region.beach) & self.tool.has_fishing_rod(2),
WaterItem.seaweed: self.skill.can_fish(Region.tide_pools),
WaterItem.white_algae: self.skill.can_fish(Region.mines_floor_20),
WaterItem.seaweed: self.fishing.can_fish_at(Region.tide_pools),
WaterItem.white_algae: self.fishing.can_fish_at(Region.mines_floor_20),
WildSeeds.grass_starter: self.money.can_spend_at(Region.pierre_store, 100),
})
# @formatter:on

View File

@@ -39,7 +39,7 @@ class QuestLogic(BaseLogic):
Quest.raising_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.coop),
Quest.feeding_animals: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.building.has_building(Building.silo),
Quest.advancement: self.logic.quest.can_complete_quest(Quest.getting_started) & self.logic.has(Craftable.scarecrow),
Quest.archaeology: self.logic.tool.has_tool(Tool.hoe) | self.logic.mine.can_mine_in_the_mines_floor_1_40() | self.logic.skill.can_fish(),
Quest.archaeology: self.logic.tool.has_tool(Tool.hoe) | self.logic.mine.can_mine_in_the_mines_floor_1_40() | self.logic.fishing.can_fish_chests,
Quest.rat_problem: self.logic.region.can_reach_all((Region.town, Region.community_center)),
Quest.meet_the_wizard: self.logic.quest.can_complete_quest(Quest.rat_problem),
Quest.forging_ahead: self.logic.has(Ore.copper) & self.logic.has(Machine.furnace),
@@ -86,7 +86,9 @@ class QuestLogic(BaseLogic):
Quest.catch_a_lingcod: self.logic.season.has(Season.winter) & self.logic.has(Fish.lingcod) & self.logic.relationship.can_meet(NPC.willy),
Quest.dark_talisman: self.logic.region.can_reach(Region.railroad) & self.logic.wallet.has_rusty_key() & self.logic.relationship.can_meet(
NPC.krobus),
Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp),
Quest.goblin_problem: self.logic.region.can_reach(Region.witch_swamp)
# Void mayo can be fished at 5% chance in the witch swamp while the quest is active. It drops a lot after the quest.
& (self.logic.has(ArtisanGood.void_mayonnaise) | self.logic.fishing.can_fish()),
Quest.magic_ink: self.logic.relationship.can_meet(NPC.wizard),
Quest.the_pirates_wife: self.logic.relationship.can_meet(NPC.kent) & self.logic.relationship.can_meet(NPC.gus) &
self.logic.relationship.can_meet(NPC.sandy) & self.logic.relationship.can_meet(NPC.george) &

View File

@@ -1,13 +1,10 @@
from functools import cached_property
from typing import Union, Tuple
from Utils import cache_self1
from .base_logic import BaseLogicMixin, BaseLogic
from ..data.harvest import HarvestCropSource
from ..mods.logic.mod_skills_levels import get_mod_skill_levels
from ..stardew_rule import StardewRule, true_, True_, False_
from ..strings.craftable_names import Fishing
from ..strings.machine_names import Machine
from ..strings.performance_names import Performance
from ..strings.quality_names import ForageQuality
from ..strings.region_names import Region
@@ -15,7 +12,6 @@ from ..strings.skill_names import Skill, all_mod_skills, all_vanilla_skills
from ..strings.tool_names import ToolMaterial, Tool
from ..strings.wallet_item_names import Wallet
fishing_regions = (Region.beach, Region.town, Region.forest, Region.mountain, Region.island_south, Region.island_west)
vanilla_skill_items = ("Farming Level", "Mining Level", "Foraging Level", "Fishing Level", "Combat Level")
@@ -138,44 +134,9 @@ class SkillLogic(BaseLogic):
@cached_property
def can_get_fishing_xp(self) -> StardewRule:
if self.content.features.skill_progression.is_progressive:
return self.logic.skill.can_fish() | self.logic.skill.can_crab_pot
return self.logic.fishing.can_fish_anywhere() | self.logic.fishing.can_crab_pot
return self.logic.skill.can_fish()
# Should be cached
def can_fish(self, regions: Union[str, Tuple[str, ...]] = None, difficulty: int = 0) -> StardewRule:
if isinstance(regions, str):
regions = regions,
if regions is None or len(regions) == 0:
regions = fishing_regions
skill_required = min(10, max(0, int((difficulty / 10) - 1)))
if difficulty <= 40:
skill_required = 0
skill_rule = self.logic.skill.has_level(Skill.fishing, skill_required)
region_rule = self.logic.region.can_reach_any(regions)
# Training rod only works with fish < 50. Fiberglass does not help you to catch higher difficulty fish, so it's skipped in logic.
number_fishing_rod_required = 1 if difficulty < 50 else (2 if difficulty < 80 else 4)
return self.logic.tool.has_fishing_rod(number_fishing_rod_required) & skill_rule & region_rule
@cache_self1
def can_crab_pot_at(self, region: str) -> StardewRule:
return self.logic.skill.can_crab_pot & self.logic.region.can_reach(region)
@cached_property
def can_crab_pot(self) -> StardewRule:
crab_pot_rule = self.logic.has(Fishing.bait)
# We can't use the same rule if skills are vanilla, because fishing levels are required to crab pot, which is required to get fishing levels...
if self.content.features.skill_progression.is_progressive:
crab_pot_rule = crab_pot_rule & self.logic.has(Machine.crab_pot)
else:
crab_pot_rule = crab_pot_rule & self.logic.skill.can_get_fishing_xp
water_region_rules = self.logic.region.can_reach_any(fishing_regions)
return crab_pot_rule & water_region_rules
return self.logic.fishing.can_fish_anywhere()
def can_forage_quality(self, quality: str) -> StardewRule:
if quality == ForageQuality.basic:

View File

@@ -42,7 +42,7 @@ class SpecialOrderLogic(BaseLogic):
SpecialOrder.fragments_of_the_past: self.logic.monster.can_kill(Monster.skeleton),
SpecialOrder.gus_famous_omelet: self.logic.has(AnimalProduct.any_egg),
SpecialOrder.crop_order: self.logic.ability.can_farm_perfectly() & self.logic.shipping.can_use_shipping_bin,
SpecialOrder.community_cleanup: self.logic.skill.can_crab_pot,
SpecialOrder.community_cleanup: self.logic.fishing.can_crab_pot_anywhere,
SpecialOrder.the_strong_stuff: self.logic.has(ArtisanGood.specific_juice(Vegetable.potato)),
SpecialOrder.pierres_prime_produce: self.logic.ability.can_farm_perfectly(),
SpecialOrder.robins_project: self.logic.relationship.can_meet(NPC.robin) & self.logic.ability.can_chop_perfectly() &

View File

@@ -44,9 +44,9 @@ class ModSkillLogic(BaseLogic):
def can_earn_luck_skill_level(self, level: int) -> StardewRule:
if level >= 6:
return self.logic.fishing.can_fish_chests() | self.logic.action.can_open_geode(Geode.magma)
return self.logic.fishing.can_fish_chests | self.logic.action.can_open_geode(Geode.magma)
if level >= 3:
return self.logic.fishing.can_fish_chests() | self.logic.action.can_open_geode(Geode.geode)
return self.logic.fishing.can_fish_chests | self.logic.action.can_open_geode(Geode.geode)
return True_() # You can literally wake up and or get them by opening starting chests.
def can_earn_magic_skill_level(self, level: int) -> StardewRule:

View File

@@ -214,7 +214,7 @@ def set_entrance_rules(logic: StardewLogic, multiworld, player, world_options: S
set_entrance_rule(multiworld, player, Entrance.mountain_to_railroad, logic.received("Railroad Boulder Removed"))
set_entrance_rule(multiworld, player, Entrance.enter_witch_warp_cave, logic.quest.has_dark_talisman() | (logic.mod.magic.can_blink()))
set_entrance_rule(multiworld, player, Entrance.enter_witch_hut, (logic.has(ArtisanGood.void_mayonnaise) | logic.mod.magic.can_blink()))
set_entrance_rule(multiworld, player, Entrance.enter_witch_hut, (logic.quest.can_complete_quest(Quest.goblin_problem) | logic.mod.magic.can_blink()))
set_entrance_rule(multiworld, player, Entrance.enter_mutant_bug_lair,
(logic.wallet.has_rusty_key() & logic.region.can_reach(Region.railroad) & logic.relationship.can_meet(
NPC.krobus)) | logic.mod.magic.can_blink())
@@ -923,9 +923,9 @@ def set_magic_spell_rules(logic: StardewLogic, multiworld: MultiWorld, player: i
set_rule(multiworld.get_location("Analyze: Fireball", player),
logic.has("Fire Quartz"))
set_rule(multiworld.get_location("Analyze: Frostbolt", player),
logic.region.can_reach(Region.mines_floor_60) & logic.skill.can_fish(difficulty=85))
logic.region.can_reach(Region.mines_floor_60) & logic.fishing.can_fish(85))
set_rule(multiworld.get_location("Analyze All Elemental School Locations", player),
logic.has("Fire Quartz") & logic.region.can_reach(Region.mines_floor_60) & logic.skill.can_fish(difficulty=85))
logic.has("Fire Quartz") & logic.region.can_reach(Region.mines_floor_60) & logic.fishing.can_fish(85))
# set_rule(multiworld.get_location("Analyze: Lantern", player),)
set_rule(multiworld.get_location("Analyze: Tendrils", player),
logic.region.can_reach(Region.farm))
@@ -948,7 +948,7 @@ def set_magic_spell_rules(logic: StardewLogic, multiworld: MultiWorld, player: i
& (logic.tool.has_tool("Axe", "Basic") | logic.tool.has_tool("Pickaxe", "Basic")) &
logic.has("Coffee") & logic.has("Life Elixir")
& logic.ability.can_mine_perfectly() & logic.has("Earth Crystal") &
logic.has("Fire Quartz") & logic.skill.can_fish(difficulty=85) &
logic.has("Fire Quartz") & logic.fishing.can_fish(85) &
logic.region.can_reach(Region.witch_hut) &
logic.region.can_reach(Region.mines_floor_100) &
logic.region.can_reach(Region.farm) & logic.time.has_lived_months(12)))

View File

@@ -486,10 +486,10 @@ class TunicWorld(World):
multiworld.random.shuffle(non_grass_fill_locations)
for filler_item in grass_fill:
multiworld.push_item(grass_fill_locations.pop(), filler_item, collect=False)
grass_fill_locations.pop().place_locked_item(filler_item)
for filler_item in non_grass_fill:
multiworld.push_item(non_grass_fill_locations.pop(), filler_item, collect=False)
non_grass_fill_locations.pop().place_locked_item(filler_item)
def create_regions(self) -> None:
self.tunic_portal_pairs = {}

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
from Options import (
Choice,
@@ -18,6 +19,8 @@ from .Locations import DUNGEON_NAMES
class Dungeons(DefaultOnToggle):
"""
This controls whether dungeon locations are randomized.
This means the items found in dungeons will be randomized, not that the entrances to dungeons will be randomized.
"""
display_name = "Dungeons"
@@ -752,6 +755,68 @@ class TWWOptions(PerGameCommonOptions):
remove_music: RemoveMusic
death_link: DeathLink
def get_output_dict(self) -> dict[str, Any]:
"""
Returns a dictionary of option name to value to be placed in
the output APTWW file.
:return: Dictionary of option name to value for the output file.
"""
# Note: these options' values must be able to be passed through
# `yaml.safe_dump`.
return self.as_dict(
"progression_dungeons",
"progression_tingle_chests",
"progression_dungeon_secrets",
"progression_puzzle_secret_caves",
"progression_combat_secret_caves",
"progression_savage_labyrinth",
"progression_great_fairies",
"progression_short_sidequests",
"progression_long_sidequests",
"progression_spoils_trading",
"progression_minigames",
"progression_battlesquid",
"progression_free_gifts",
"progression_mail",
"progression_platforms_rafts",
"progression_submarines",
"progression_eye_reef_chests",
"progression_big_octos_gunboats",
"progression_triforce_charts",
"progression_treasure_charts",
"progression_expensive_purchases",
"progression_island_puzzles",
"progression_misc",
"randomize_mapcompass",
"randomize_smallkeys",
"randomize_bigkeys",
"sword_mode",
"required_bosses",
"num_required_bosses",
"chest_type_matches_contents",
"hero_mode",
"logic_obscurity",
"logic_precision",
"randomize_dungeon_entrances",
"randomize_secret_cave_entrances",
"randomize_miniboss_entrances",
"randomize_boss_entrances",
"randomize_secret_cave_inner_entrances",
"randomize_fairy_fountain_entrances",
"mix_entrances",
"randomize_enemies",
"randomize_starting_island",
"randomize_charts",
"swift_sail",
"instant_text_boxes",
"reveal_full_sea_chart",
"add_shortcut_warps_between_dungeons",
"skip_rematch_bosses",
"remove_music",
)
tww_option_groups: list[OptionGroup] = [
OptionGroup(

View File

@@ -462,7 +462,7 @@ class TWWWorld(World):
"Seed": multiworld.seed_name,
"Slot": player,
"Name": self.player_name,
"Options": self.options.as_dict(*self.options_dataclass.type_hints),
"Options": self.options.get_output_dict(),
"Required Bosses": self.boss_reqs.required_boss_item_locations,
"Locations": {},
"Entrances": {},

View File

@@ -19,17 +19,18 @@ a yellow Rupee, which includes a message that the location is not randomized.
## What is the goal of The Wind Waker?
Reach and defeat Ganondorf atop Ganon's Tower. This will require all eight shards of the Triforce of Courage, the
fully-powered Master Sword (unless it's swordless mode), Light Arrows, and any other items necessary to reach Ganondorf.
fully-powered Master Sword (unless it's swords optional or swordless mode), Light Arrows, and any other items necessary
to reach Ganondorf.
## What does another world's item look like in TWW?
Items belonging to other non-TWW worlds are represented by Father's Letter (the letter Medli gives you to give to
Komali), an unused item in the randomizer.
## When the player receives an item, what happens?
## What happens when the player receives an item?
When the player receives an item, it will automatically be added to Link's inventory. Unlike many other Zelda
randomizers, Link **will not** hold the item above his head.
When the player receives an item, it will automatically be added to Link's inventory. Link **will not** hold the item
above his head like many other Zelda randomizers.
## I need help! What do I do?
@@ -37,16 +38,20 @@ Refer to the [FAQ](https://lagolunatic.github.io/wwrando/faq/) first. Then, try
[setup guide](/tutorial/The%20Wind%20Waker/setup/en). If you are still stuck, please ask in the Wind Waker channel in
the Archipelago server.
## I opened the game in Dolphin, but I don't have any of my starting items!
You must connect to the multiworld room to receive any items, including your starting inventory.
## Known issues
- Randomized freestanding rupees, spoils, and bait will also be given to the player picking up the item. The item will
be sent properly, but the collecting player will receive an extra copy.
- Demo items (items which are held over Link's head) which are **not** randomized, such as rupees from salvages from
random light rings or rewards from minigames, will not work.
- Demo items (items held over Link's head) that are **not** randomized, such as rupees from salvages from random light
rings or rewards from minigames, will not work.
- Item get messages for progressive items received on locations that send earlier than intended will be incorrect. This
does not affect gameplay.
- The Heart Piece count in item get messages will be off by one. This does not affect gameplay.
- It has been reported that item links can be buggy. Nothing game-breaking, but do be aware of it.
- It has been reported that item links can be buggy. It is nothing game-breaking, but do be aware of it.
Feel free to report any other issues or suggest improvements in the Wind Waker channel in the Archipelago server!
@@ -76,14 +81,14 @@ A few presets are available on the [player options page](../player-options) for
The preset features 3 required bosses and hard obscurity difficulty, and while the list of enabled progression options
may seem intimidating, the preset also excludes several locations.
- **Miniblins 2025**: These are (as close to as possible) the settings used in the WWR Racing Server's
[2025 Season of Minblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This
[2025 Season of Miniblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This
preset is great if you're new to Wind Waker! There aren't too many locations in the world, and you only need to
complete two dungeons. You also start with many convenience items, such as double magic, a capacity upgrade for your
bow and bombs, and six hearts.
- **Mixed Pools**: These are the settings used in the WWR Racing Server's
[Mixed Pools Co-op Tournament](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg). This
preset features full entrance rando and includes many locations behind a randomized entrance. There are also a bunch
of overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6
preset features full entrance rando and includes most locations behind a randomized entrance. There are also many
overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6
required bosses, but since entrance pools are randomized, the bosses could be found anywhere! Check your Sea Chart to
find out which island the bosses are on.
@@ -106,7 +111,7 @@ This randomizer would not be possible without the help from:
- CrainWWR: (multiworld and Dolphin memory assistance, additional programming)
- Cyb3R: (reference for `TWWClient`)
- DeamonHunter: (additional programming)
- Dev5ter: (initial TWW AP implmentation)
- Dev5ter: (initial TWW AP implementation)
- Gamma / SageOfMirrors: (additional programming)
- LagoLunatic: (base randomizer, additional assistance)
- Lunix: (Linux support, additional programming)

View File

@@ -5,11 +5,13 @@ If you're playing The Wind Waker, you must follow a few simple steps to get star
## Requirements
You'll need the following components to be able to play with The Wind Waker:
You'll need the following components to be able to play The Wind Waker:
* Install [Dolphin Emulator](https://dolphin-emu.org/download/). **We recommend using the latest release.**
* For Linux users, you can use the flatpak package
* Linux users can use the flatpak package
[available on Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu).
* The 2.5.0 version of the [TWW AP Randomizer Build](https://github.com/tanjo3/wwrando/releases/tag/ap_2.5.0).
* The latest version of the [TWW AP Randomizer Build](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2).
* Please note that this build is **different** from the one the standalone randomizer uses. This build is
specifically for Archipelago.
* A The Wind Waker ISO (North American version), probably named "Legend of Zelda, The - The Wind Waker (USA).iso".
Optionally, you can also download:
@@ -26,17 +28,17 @@ world. Once you're happy with your settings, provide the room host with your YAM
## Connecting to a Room
The multiworld host will provide you a link to download your `aptww` file or a zip file containing everyone's files. The
`aptww` file should be named `P#_<name>_XXXXX.aptww`, where `#` is your player ID, `<name>` is your player name, and
The multiworld host will provide you a link to download your APTWW file or a zip file containing everyone's files. The
APTWW file should be named `P#_<name>_XXXXX.aptww`, where `#` is your player ID, `<name>` is your player name, and
`XXXXX` is the room ID. The host should also provide you with the room's server name and port number.
Once you do, follow these steps to connect to the room:
Once you're ready, follow these steps to connect to the room:
1. Run the TWW AP Randomizer Build. If this is the first time you've opened the randomizer, you'll need to specify the
path to your The Wind Waker ISO and the output folder for the randomized ISO. These will be saved for the next time you
open the program.
2. Modify any cosmetic convenience tweaks and player customization options as desired.
3. For the APTWW file, browse and locate the path to your `aptww` file.
4. Click `Randomize` at the bottom-right. This randomizes the ISO and puts it in the output folder you specified. The
3. For the APTWW file, browse and locate the path to your APTWW file.
4. Click `Randomize` at the bottom right. This randomizes the ISO and puts it in the output folder you specified. The
file will be named `TWW AP_YYYYY_P# (<name>).iso`, where `YYYYY` is the seed name, `#` is your player ID, and `<name>`
is your player (slot) name. Verify that the values are correct for the multiworld.
5. Open Dolphin and use it to open the randomized ISO.
@@ -47,7 +49,7 @@ text client. If Dolphin is not already open, or you have yet to start a new file
on the website, this will be `archipelago.gg:<port>`, where `<port>` is the port number. If a game is hosted from the
`ArchipelagoServer.exe` (without `.exe` on Linux), the port number will default to `38281` but may be changed in the
`host.yaml`.
8. If you've opened a ROM corresponding to the multiworld to which you are connected, it should authenticate your slot
8. If you've opened an ISO corresponding to the multiworld to which you are connected, it should authenticate your slot
name automatically when you start a new save file.
## Troubleshooting
@@ -55,13 +57,18 @@ name automatically when you start a new save file.
* Ensure you are running the same version of Archipelago on which the multiworld was generated.
* Ensure `tww.apworld` is not in your Archipelago installation's `custom_worlds` folder.
* Ensure you are using the correct randomizer build for the version of Archipelago you are using. The build should
provide an error message directing you to the correct version. You can also look at the release notes of TWW AP builds
[here](https://github.com/tanjo3/wwrando/releases) to see which versions of Archipelago each build is compatible with.
provide an error message directing you to the correct version. You can also look at the release notes of TWW AP builds
[here](https://github.com/tanjo3/wwrando/releases?q=tag%3Aap_2) to see which versions of Archipelago each build is
compatible with.
* Do not run the Archipelago Launcher or Dolphin as an administrator on Windows.
* If you encounter issues with authenticating, ensure that the randomized ROM is open in Dolphin and corresponds to the
multiworld to which you are connecting.
multiworld to which you are connecting.
* Ensure that you do not have any Dolphin cheats or codes enabled. Some cheats or codes can unexpectedly interfere with
emulation and make troubleshooting errors difficult.
* If you get an error message, ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` >
`Configuration` > `Advanced`) is **disabled**.
emulation and make troubleshooting errors difficult.
* Ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` > `Configuration` > `Advanced`) is
**disabled**.
* If the client cannot connect to Dolphin, ensure Dolphin is on the same drive as Archipelago. Having Dolphin on an
external drive has reportedly caused connection issues.
* Ensure the `Fallback Region` in Dolphin (under `Options` > `Configuration` > `General`) is set to `NTSC-U`.
* If you run with a custom GC boot menu, you'll need to skip it by going to `Options` > `Configuration` > `GameCube`
and checking `Skip Main Menu`.
and checking `Skip Main Menu`.