Files
dockipelago/worlds/tloz_oos/patching/Functions.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

1426 lines
61 KiB
Python

import os
import random
from collections import defaultdict
from pathlib import Path
import Utils
from settings import get_settings
from .Constants import *
from .Util import *
from .asm import asm_files
from ..Options import OracleOfSeasonsOldMenShuffle, OracleOfSeasonsGoal, OracleOfSeasonsAnimalCompanion, \
OracleOfSeasonsMasterKeys, OracleOfSeasonsFoolsOre, OracleOfSeasonsShowDungeonsWithEssence, OracleOfSeasonsLinkedHerosCave
from ..World import OracleOfSeasonsWorld
from ..common.patching.RomData import RomData
from ..common.patching.Util import get_available_random_colors_from_sprite_name, simple_hex
from ..common.patching.text import normalize_text
from ..common.patching.z80asm.Assembler import Z80Assembler
from ..common.patching.z80asm.Util import parse_hex_string_to_value
from ..data.Constants import *
from ..data.Locations import LOCATIONS_DATA
from ..generation.Hints import make_hint_texts
def define_foreign_item_data(assembler: Z80Assembler, texts: dict[str, str], patch_data: dict[str, Any]) -> dict[str, dict[str, Any]]:
# Register all foreign items and save their text as TX_0cxx, id 0x41, subid xx
item_data = ITEMS_DATA.copy()
current_subid = 0
foreign_item_data = []
all_locations = patch_data["locations"]
for location in all_locations:
location_content = all_locations[location]
if "player" not in location_content:
continue
texts[f"TX_0c{simple_hex(current_subid)}"] = normalize_text(f"You got a 🟥{location_content["item"]}⬜ for 🟦{location_content["player"]}⬜!")
foreign_item_data.extend([
0x00, # grab mode, doesn't really matter
current_subid, # parameter, not sure it matters
current_subid, # text id, will need special handling
0x52 if location_content["progression"] else 0x53 # sprite
])
item_data[f"{location_content["item"]}|{location_content["player"]}"] = {
"id": 0x41,
"subid": current_subid,
}
current_subid += 1
assembler.add_floating_chunk("archipelago_items", foreign_item_data)
return item_data
def get_asm_files(patch_data: dict[str, Any]) -> list[str]:
files = list(asm_files["base"])
if patch_data["options"]["quick_flute"]:
files += asm_files["quick_flute"]
if patch_data["options"]["shuffle_old_men"] == OracleOfSeasonsOldMenShuffle.option_turn_into_locations:
files += asm_files["old_men_as_locations"]
if patch_data["options"]["remove_d2_alt_entrance"]:
files += asm_files["remove_d2_alt_entrance"]
if patch_data["dungeon_entrances"]["d3"] == "d0":
files += asm_files["prevent_drowning_d0_warp"]
elif patch_data["dungeon_entrances"]["d3"] == "d2":
files += asm_files["prevent_drowning_d2_warp"]
if patch_data["options"]["goal"] == OracleOfSeasonsGoal.option_beat_ganon:
files += asm_files["ganon_goal"]
if patch_data["options"]["rosa_quick_unlock"]:
files += asm_files["instant_rosa"]
if get_settings()["tloz_oos_options"]["remove_music"]:
files += asm_files["mute_music"]
if patch_data["options"]["cross_items"]:
files += asm_files["cross_items"]
if patch_data["options"]["secret_locations"]:
files += asm_files["secret_locations"]
if patch_data["options"]["linked_heros_cave"]:
files += asm_files["d11"]
if patch_data["options"]["linked_heros_cave"] & OracleOfSeasonsLinkedHerosCave.samasa:
files += asm_files["d11_in_samasa"]
elif patch_data["options"]["linked_heros_cave"] & OracleOfSeasonsLinkedHerosCave.heros_cave:
files += asm_files["d11_in_d0"]
if patch_data["options"]["randomize_puzzles"]:
files += asm_files["random_puzzles"]
return files
def write_chest_contents(rom: RomData, patch_data: dict[str, Any], item_data: dict[str, dict[str, Any]]) -> None:
"""
Chest locations are packed inside several big tables in the ROM, unlike other more specific locations.
This puts the item described in the patch data inside each chest in the game.
"""
locations_data = patch_data["locations"]
for location_name, location_data in LOCATIONS_DATA.items():
if location_data.get("collect", COLLECT_TOUCH) != COLLECT_CHEST and not location_data.get("is_chest", False) or location_name not in locations_data:
continue
chest_addr = rom.get_chest_addr(location_data["room"], 0x15, 0x4f6c)
item = locations_data[location_name]
item_id, item_subid = get_item_id_and_subid(item_data, item)
rom.write_byte(chest_addr, item_id)
rom.write_byte(chest_addr + 1, item_subid)
def define_samasa_combination(assembler: Z80Assembler, patch_data: dict[str, Any]) -> None:
samasa_combination = [int(number) for number in patch_data["samasa_gate_sequence"].split(" ")]
# 1) Define the combination itself and its length for the gate check
assembler.add_floating_chunk("samasaCombination", samasa_combination)
assembler.define_byte("samasaCombinationLengthMinusOne", len(samasa_combination) - 1)
# 2) Build a cutscene for the Piratian to show the new sequence
cutscene = [MOVE_UP, 0x15]
# Add a fake last press on button 1 to make the pirate go back to its original position
sequence = samasa_combination + [1]
current_position = 1
for i, button_to_press in enumerate(sequence):
# If current button is at a different position than the current one,
# make the pirate move
if button_to_press != current_position:
if button_to_press < current_position:
distance_to_move = 0x8 * (current_position - button_to_press) + 1
cutscene.extend([MOVE_LEFT, distance_to_move])
else:
distance_to_move = 0x8 * (button_to_press - current_position) + 1
cutscene.extend([MOVE_RIGHT, distance_to_move])
current_position = button_to_press
# Close the cupboard to mimic a button press on the gate by calling
# the "closeOpenCupboard" subscript. Don't do it if it's the last movement
# (which was only added to make the pirate go back to its initial position)
if i < len(sequence) - 1:
cutscene.extend([CALL_SCRIPT, 0x59, 0x5e])
# Add some termination to the script
cutscene.extend([
MOVE_DOWN, 0x15,
WRITE_OBJECT_BYTE, 0x7c, 0x00,
DELAY_6,
SHOW_TEXT_LOW_INDEX, 0x0d,
ENABLE_ALL_OBJECTS,
0x5e, 0x4b # jump back to script start
])
if len(cutscene) > 0xFE:
raise Exception("Samasa gate sequence is too long")
assembler.add_floating_chunk("showSamasaCutscene", cutscene)
def define_compass_rooms_table(assembler: Z80Assembler, patch_data: dict[str, Any], item_data: dict[str, dict[str, Any]]) -> None:
table = []
for location_name, item in patch_data["locations"].items():
item_id, item_subid = get_item_id_and_subid(item_data, item)
dungeon = 0xff
if item_id == 0x30: # Small Key or Master Key
dungeon = item_subid
elif item_id == 0x31: # Boss Key
dungeon = item_subid + 1
if dungeon != 0xff:
location_data = LOCATIONS_DATA[location_name]
rooms = location_data["room"]
if not isinstance(rooms, list):
rooms = [rooms]
for room in rooms:
room_id = room & 0xff
group_id = room >> 8
table.extend([group_id, room_id, dungeon])
table.append(0xff) # End of table
assembler.add_floating_chunk("compassRoomsTable", table)
def define_collect_properties_table(assembler: Z80Assembler, patch_data: dict[str, Any], item_data: dict[str, dict[str, Any]]) -> None:
"""
Defines a table of (group, room, collect mode) entries for randomized items
to determine how they spawn, how they are grabbed and whether they set
a room flag when obtained.
"""
table = []
for location_name, item in patch_data["locations"].items():
location_data = LOCATIONS_DATA[location_name]
if "collect" not in location_data:
continue
mode = location_data["collect"]
# Use no pickup animation for drop or diving small keys
item_id, _ = get_item_id_and_subid(item_data, item)
if item_id == 0x30 and (mode == COLLECT_DROP or mode == COLLECT_DIVE):
mode &= 0xf8 # Set grab mode to TREASURE_GRAB_INSTANT
rooms = location_data["room"]
if not isinstance(rooms, list):
rooms = [rooms]
for room in rooms:
room_id = room & 0xff
group_id = room >> 8
table.extend([group_id, room_id, mode])
# Specific case for D6 fake rupee
table.extend([0x04, 0xc5, TREASURE_SPAWN_POOF | TREASURE_GRAB_INSTANT | TREASURE_SET_ITEM_ROOM_FLAG])
# Maku Tree gate opening cutscene
table.extend([0x00, 0xd9, TREASURE_SPAWN_INSTANT | TREASURE_GRAB_SPIN_SLASH])
# End of d11
table.extend([0x05, 0x27, TREASURE_SPAWN_CHEST])
table.append(0xff)
assembler.add_floating_chunk("collectPropertiesTable", table)
def define_additional_tile_replacements(assembler: Z80Assembler, patch_data: dict[str, Any]) -> None:
"""
Define a list of entries following the format of `tileReplacementsTable` (see ASM for more info) which end up
being tile replacements on various rooms in the game.
"""
table = []
# Remove Gasha spots when harvested once if deterministic Gasha locations are enabled
if patch_data["options"]["deterministic_gasha_locations"] > 0:
table.extend([
0x00, 0xa6, 0x20, 0x54, 0xe1, # North Horon: Gasha Spot Above Impa
0x00, 0xc8, 0x20, 0x67, 0xe1, # Horon Village: Gasha Spot Near Mayor's House
0x00, 0xac, 0x20, 0x27, 0xe1, # Eastern Suburbs: Gasha Spot
0x00, 0x95, 0x20, 0x32, 0xe1, # Holodrum Plain: Gasha Spot Near Mrs. Ruul's House
0x00, 0x75, 0x20, 0x34, 0xe1, # Holodrum Plain: Gasha Spot on Island Above D1
0x00, 0x80, 0x20, 0x53, 0xe1, # Spool Swamp: Gasha Spot Near Floodgate Keyhole
0x00, 0xc0, 0x20, 0x61, 0xe1, # Spool Swamp: Gasha Spot Near Portal
0x00, 0x3f, 0x20, 0x44, 0xe1, # Sunken City: Gasha Spot
0x00, 0x1f, 0x20, 0x21, 0xe1, # Mt. Cucco: Gasha Spot
0x00, 0x38, 0x20, 0x25, 0xe1, # Goron Mountain: Gasha Spot Left of Entrance
0x00, 0x3b, 0x20, 0x53, 0xe1, # Goron Mountain: Gasha Spot Right of Entrance
0x00, 0x89, 0x20, 0x24, 0xe1, # Eyeglass Lake: Gasha Spot Near D5
0x00, 0x22, 0x20, 0x45, 0xe1, # Tarm Ruins: Gasha Spot
0x00, 0xf0, 0x20, 0x22, 0xe1, # Western Coast: Gasha Spot South of Graveyard
0x00, 0xef, 0x20, 0x66, 0xe1, # Samasa Desert: Gasha Spot
0x00, 0x44, 0x20, 0x44, 0xe1, # Path to Onox Castle: Gasha Spot
])
assembler.add_floating_chunk("additionalTileReplacements", table)
def define_location_constants(assembler: Z80Assembler, patch_data: dict[str, Any], item_data: dict[str, dict[str, Any]]):
# If "Enforce potion in shop" is enabled, put a Potion in a specific location in Horon Shop that was
# disabled at generation time to prevent trackers from tracking it
if patch_data["options"]["enforce_potion_in_shop"]:
patch_data["locations"]["Horon Village: Shop #3"] = {"item": "Potion"}
# If golden ore spots are not shuffled, they are still reachable nonetheless, so we need to enforce their
# vanilla item for systems to work
if not patch_data["options"]["shuffle_golden_ore_spots"]:
for location_name in SUBROSIA_HIDDEN_DIGGING_SPOTS_LOCATIONS:
patch_data["locations"][location_name] = {"item": "Ore Chunks (50)"}
# Define shop prices as constants
for symbolic_name, price in patch_data["shop_prices"].items():
assembler.define_byte(f"shopPrices.{symbolic_name}", RUPEE_VALUES[price])
for location_name, location_data in LOCATIONS_DATA.items():
if "symbolic_name" not in location_data:
continue
symbolic_name = location_data["symbolic_name"]
if location_name in patch_data["locations"]:
item = patch_data["locations"][location_name]
else:
# Put a fake item for disabled locations, since they are unreachable anwyway
item = {"item": "Friendship Ring"}
item_id, item_subid = get_item_id_and_subid(item_data, item)
assembler.define_byte(f"locations.{symbolic_name}.id", item_id)
assembler.define_byte(f"locations.{symbolic_name}.subid", item_subid)
assembler.define_word(f"locations.{symbolic_name}", (item_id << 8) + item_subid)
# Process deterministic Gasha Nut locations to define a table
deterministic_gasha_table = []
for i in range(int(patch_data["options"]["deterministic_gasha_locations"])):
item = patch_data["locations"][f"Gasha Nut #{i + 1}"]
item_id, item_subid = get_item_id_and_subid(item_data, item)
deterministic_gasha_table.extend([item_id, item_subid])
assembler.add_floating_chunk("deterministicGashaLootTable", deterministic_gasha_table)
def define_option_constants(assembler: Z80Assembler, patch_data: dict[str, Any]) -> None:
options = patch_data["options"]
assembler.define_byte("option.startingGroup", 0x00)
assembler.define_byte("option.startingRoom", 0xb6)
assembler.define_byte("option.startingPosY", 0x58)
assembler.define_byte("option.startingPosX", 0x58)
assembler.define_byte("option.warpingGroup", 0x00)
assembler.define_byte("option.warpingRoom", 0xb6)
assembler.define_byte("option.warpingPosY", 0x58)
assembler.define_byte("option.warpingPosX", 0x58)
assembler.define_byte("option.warpingPos", 0x55)
assembler.define_byte("option.warpingSeason", patch_data["default_seasons"]["EYEGLASS_LAKE"])
assembler.define_byte("option.animalCompanion", 0x0b + patch_data["options"]["animal_companion"])
assembler.define_byte("option.defaultSeedType", 0x20 + patch_data["options"]["default_seed"])
assembler.define_byte("option.receivedDamageModifier", options["combat_difficulty"])
assembler.define_byte("option.openAdvanceShop", options["advance_shop"])
assembler.define_byte("option.requiredEssences", options["required_essences"])
assembler.define_byte("option.goldenBeastsRequirement", options["golden_beasts_requirement"])
assembler.define_byte("option.treehouseOldManRequirement", options["treehouse_old_man_requirement"])
assembler.define_byte("option.tarmGateRequiredJewels", options["tarm_gate_required_jewels"])
assembler.define_byte("option.signGuyRequirement", options["sign_guy_requirement"])
assembler.define_byte("option.removeD0AltEntrance", options["remove_d0_alt_entrance"])
assembler.define_byte("option.deterministicGashaLootCount", options["deterministic_gasha_locations"])
fools_ore_damage = 3 if options["fools_ore"] == OracleOfSeasonsFoolsOre.option_balanced else 12
assembler.define_byte("option.foolsOreDamage", (-1 * fools_ore_damage + 0x100))
assembler.define_byte("option.keysanity_small_keys", patch_data["options"]["keysanity_small_keys"])
keysanity = patch_data["options"]["keysanity_small_keys"] or patch_data["options"]["keysanity_boss_keys"]
assembler.define_byte("option.customCompassChimes", 1 if keysanity else 0)
master_keys_as_boss_keys = patch_data["options"]["master_keys"] == OracleOfSeasonsMasterKeys.option_all_dungeon_keys
assembler.define_byte("option.smallKeySprite", 0x43 if master_keys_as_boss_keys else 0x42)
scrubs_all_refill = not patch_data["options"]["shuffle_business_scrubs"]
assembler.define_byte("var.spoolSwampScrubSubid", 0x04 if scrubs_all_refill else 0x00)
assembler.define_byte("var.samasaCaveScrubSubid", 0x04 if scrubs_all_refill else 0x01)
assembler.define_byte("var.d2ScrubSubid", 0x04 if scrubs_all_refill else 0x02)
assembler.define_byte("var.d4ScrubSubid", 0x04 if scrubs_all_refill else 0x03)
# This adds water tiles in d3 inside as long as the floodgate isn't open
if patch_data["dungeon_entrances"]["d3"] == "d0":
assembler.define_byte("d3Entrance", 0x04)
elif patch_data["dungeon_entrances"]["d3"] == "d2":
assembler.define_byte("d3Entrance", 0x39)
elif patch_data["dungeon_entrances"]["d3"] == "d11":
assembler.define_byte("d11Drowning", 0x01)
if patch_data["options"]["linked_heros_cave"]:
assembler.define_byte("d11", 0x01)
if patch_data["options"]["linked_heros_cave"] & OracleOfSeasonsLinkedHerosCave.samasa:
assembler.define_byte("d11InSamasa", 0x01)
chest_dict = {
"d0": 0x75,
"d1": 0x66,
"d2": 0x5b,
"d3": 0x43,
"d4": 0x3b,
"d5": 0x59,
"d6": 0x23,
"d7": 0x73,
"d8": 0x35,
"d11": 0x7b,
}
dungeon_entrances = patch_data["dungeon_entrances"]
inverted_dungeon_entrances = {dungeon_entrances[key]: key for key in dungeon_entrances}
dungeons_in_order_for_d11_puzzle = []
for i in range(1, 9):
dungeons_in_order_for_d11_puzzle.append(chest_dict[inverted_dungeon_entrances[f"d{i}"]])
assembler.add_floating_chunk("dungeonsInOrderForD11Puzzle", list(dungeons_in_order_for_d11_puzzle))
dungeons_in_order_for_d11_puzzle.sort()
assembler.add_floating_chunk("dungeonsForD11Puzzle", dungeons_in_order_for_d11_puzzle)
if patch_data["options"]["show_dungeons_with_map"]:
assembler.define_byte("showDungeonWithMap", 0x01)
def define_season_constants(assembler: Z80Assembler, patch_data: dict[str, Any]) -> None:
for region_name, season_byte in patch_data["default_seasons"].items():
assembler.define_byte(f"defaultSeason.{region_name}", season_byte)
def define_lost_woods_sequences(assembler: Z80Assembler, texts: dict[str, str], patch_data: dict[str, Any]) -> None:
pedestal_sequence = patch_data["lost_woods_item_sequence"]
pedestal_bytes, pedestal_text = process_lost_woods_sequence(pedestal_sequence)
assembler.add_floating_chunk("lostWoodsPedestalSequence", pedestal_bytes)
travel_index = texts["TX_0b50"].index("If temperatures")
texts["TX_0b50"] = texts["TX_0b50"][:travel_index] + pedestal_text
main_sequence = patch_data["lost_woods_main_sequence"]
main_bytes, main_text = process_lost_woods_sequence(main_sequence)
assembler.add_floating_chunk("lostWoodsMainSequence", main_bytes)
texts["TX_3604"] = "" # Unused
travel_index = texts["TX_4500"].index("\ntravel west")
texts["TX_4500"] = texts["TX_4500"][:travel_index] + "\\stop\n" + main_text
def process_lost_woods_sequence(sequence: list[list[int]]) -> tuple[list[int], str]:
"""
Process a sequence of directions + seasons, and outputs two byte arrays:
- one to use as a technical data array to check the sequence being done
- one to use as text hint
"""
sequence_bytes: list[int] = []
text = ""
for i in range(4):
direction = sequence[i][0]
season = sequence[i][1]
sequence_bytes.extend(sequence[i])
text += DIRECTION_STRINGS[direction]
text += SEASON_STRINGS[season]
if i == 1:
text += "\\stop"
if i != 3:
text += "\n"
return sequence_bytes, text
def define_tree_sprites(assembler: Z80Assembler, patch_data: dict[str, Any], item_data: dict[str, dict[str, Any]]) -> None:
tree_data = { # Name: (map, position)
"Horon Village: Seed Tree": (0xf8, 0x48),
"Woods of Winter: Seed Tree": (0x9e, 0x88),
"Holodrum Plain: Seed Tree": (0x67, 0x88),
"Spool Swamp: Seed Tree": (0x72, 0x88),
"Sunken City: Seed Tree": (0x5f, 0x86),
"Tarm Ruins: Seed Tree": (0x10, 0x48),
}
i = 1
for tree_name in tree_data:
seed = patch_data["locations"][tree_name]
if seed["item"] == "Ember Seeds":
continue
seed_id, _ = get_item_id_and_subid(item_data, seed)
assembler.define_byte(f"seedTree{i}.map", tree_data[tree_name][0])
assembler.define_byte(f"seedTree{i}.position", tree_data[tree_name][1])
assembler.define_byte(f"seedTree{i}.gfx", seed_id - 26)
assembler.define(f"seedTree{i}.rectangle", f"treeRect{seed_id}")
i += 1
if i == 5:
# Duplicate ember, we have to blank some data
assembler.define_byte("seedTree5.enabled", 0x0e)
assembler.define_byte("seedTree5.map", 0xff)
assembler.define_byte("seedTree5.position", 0)
assembler.define_byte("seedTree5.gfx", 0)
assembler.define_word("seedTree5.rectangle", 0)
else:
assembler.define_byte("seedTree5.enabled", 0x0d)
def get_treasure_addr(rom: RomData, item_name: str, item_data: dict[str, dict[str, Any]]) -> int:
item_id, item_subid = get_item_id_and_subid(item_data, {"item": item_name})
addr = 0x55129 + (item_id * 4)
if rom.read_byte(addr) & 0x80 != 0:
addr = 0x50000 + rom.read_word(addr + 1)
return addr + (item_subid * 4)
def set_treasure_data(rom: RomData, item_data: dict[str, dict[str, Any]],
item_name: str, text_id: int | None,
sprite_id: int | None = None,
param_value: int | None = None) -> None:
addr = get_treasure_addr(rom, item_name, item_data)
if text_id is not None:
rom.write_byte(addr + 0x02, text_id)
if sprite_id is not None:
rom.write_byte(addr + 0x03, sprite_id)
if param_value is not None:
rom.write_byte(addr + 0x01, param_value)
def set_player_start_inventory(assembler: Z80Assembler, patch_data: dict[str, Any]) -> None:
obtained_treasures_address = parse_hex_string_to_value(DEFINES["wObtainedTreasureFlags"])
start_inventory_changes = defaultdict(int)
# ###### Base changes ##############################################
start_inventory_changes[parse_hex_string_to_value(DEFINES["wIsLinkedGame"])] = 0x00 # No linked gaming
start_inventory_changes[parse_hex_string_to_value(DEFINES["wAnimalTutorialFlags"])] = 0xff # Animal vars
# Remove the requirement to go in the screen under Sunken City tree to make Dimitri bullies appear
start_inventory_changes[parse_hex_string_to_value(DEFINES["wDimitriState"])] = 0x20
# Give L-3 ring box
start_inventory_changes[0xc697] = 0x10
start_inventory_changes[parse_hex_string_to_value(DEFINES["wRingBoxLevel"])] = 0x03
# Starting map/compass
if patch_data["options"]["starting_maps_compasses"]:
dungeon_compass = parse_hex_string_to_value(DEFINES["wDungeonCompasses"])
for i in range(dungeon_compass, dungeon_compass + 4):
start_inventory_changes[i] = 0xff
start_inventory_data: dict[str, int] = patch_data["start_inventory"]
# Handle leveled items
if "Progressive Shield" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wShieldLevel"])] \
= start_inventory_data["Progressive Shield"]
bombs = 0
if "Bombs (10)" in start_inventory_data:
bombs += start_inventory_data["Bombs (10)"] * 0x10
if "Bombs (20)" in start_inventory_data:
bombs += start_inventory_data["Bombs (20)"] * 0x20
if bombs > 0:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wCurrentBombs"])] \
= start_inventory_changes[parse_hex_string_to_value(DEFINES["wMaxBombs"])] \
= min(bombs, 0x99)
# The bomb amounts are stored in decimal
if "Progressive Sword" in start_inventory_data:
start_inventory_changes[0xc6ac] = start_inventory_data["Progressive Sword"]
if "Progressive Boomerang" in start_inventory_data:
start_inventory_changes[0xc6b1] = start_inventory_data["Progressive Boomerang"] # Boomerang level
if "Ricky's Flute" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wFluteIcon"])] = 0x01 # Flute icon
start_inventory_changes[0xc643] |= 0x80 # Ricky State
if "Dimitri's Flute" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wFluteIcon"])] = 0x02 # Flute icon
start_inventory_changes[0xc644] |= 0x80 # Dimitri State
if "Moosh's Flute" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wFluteIcon"])] = 0x03 # Flute icon
start_inventory_changes[0xc645] |= 0x20 # Moosh State
if "Progressive Feather" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wFeatherLevel"])] \
= start_inventory_data["Progressive Feather"]
if "Switch Hook" in start_inventory_data:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wSwitchHookLevel"])] \
= start_inventory_data["Switch Hook"]
bombchus = 0
if "Bombchus (10)" in start_inventory_data:
bombchus += start_inventory_data["Bombchus (10)"] * 0x10
if "Bombchus (20)" in start_inventory_data:
bombchus += start_inventory_data["Bombchus (20)"] * 0x20
if bombchus > 0:
start_inventory_changes[parse_hex_string_to_value(DEFINES["wNumBombchus"])] \
= start_inventory_changes[parse_hex_string_to_value(DEFINES["wMaxBombchus"])] \
= min(bombchus, 0x99)
# The bombchus amounts are stored in decimal
seed_amount = 0
if "Progressive Slingshot" in start_inventory_data:
start_inventory_changes[0xc6b3] = start_inventory_data["Progressive Slingshot"] # Slingshot level
seed_amount = 0x20
if "Seed Shooter" in start_inventory_data:
seed_amount = 0x20
if "Seed Satchel" in start_inventory_data:
satchel_level = start_inventory_data["Seed Satchel"]
start_inventory_changes[parse_hex_string_to_value(DEFINES["wSeedSatchelLevel"])] = satchel_level
if satchel_level == 1:
seed_amount = 0x20
elif satchel_level == 2:
seed_amount = 0x50
else:
seed_amount = 0x99
if seed_amount:
start_inventory_data[SEED_ITEMS[patch_data["options"]["default_seed"]]] = 1 # Add seeds to the start inventory
# Inventory obtained flags
current_inventory_index = parse_hex_string_to_value(DEFINES["wInventoryB"])
for item in start_inventory_data:
item_id = ITEMS_DATA[item]["id"]
item_address = obtained_treasures_address + item_id // 8
item_mask = 0x01 << (item_id % 8)
start_inventory_changes[item_address] |= item_mask
if item_id < 0x20: # items prior to 0x20 are all usable
if item == "Biggoron's Sword":
# Biggoron needs special care since it occupies both hands
if current_inventory_index == parse_hex_string_to_value(DEFINES["wInventoryB"]):
start_inventory_changes[current_inventory_index] \
= start_inventory_changes[current_inventory_index + 1] \
= item_id
current_inventory_index += 2
elif current_inventory_index == parse_hex_string_to_value(DEFINES["wInventoryB"]) + 1:
current_inventory_index += 1
start_inventory_changes[current_inventory_index] = item_id
current_inventory_index += 1
else:
start_inventory_changes[current_inventory_index] = item_id # Place the item in the inventory
current_inventory_index += 1
if item_id == 0x07: # Rod of Seasons
season = ITEMS_DATA[item]["subid"] - 2
start_inventory_changes[0xc6b0] |= 0x01 << season
elif item_id == 0x28: # Rupees
amount = int(item.split("(")[1][:-1]) # Find the value in the item name
start_inventory_changes[0xc6a5] += amount * start_inventory_data[item]
elif item_id == 0x37: # Ore Chunks
amount = int(item.split("(")[1][:-1]) # Find the value in the item name
start_inventory_changes[0xc6a7] += amount * start_inventory_data[item]
elif item_id == 0x30: # Small keys
subid = ITEMS_DATA[item]["subid"] % 0x80
start_inventory_changes[0xc66e + subid] += start_inventory_data[item]
elif item_id == 0x31: # Boss keys
subid = ITEMS_DATA[item]["subid"]
start_inventory_changes[0xc67a + subid // 8] |= 0x01 << subid % 8
elif item_id == 0x32: # Compasses
subid = ITEMS_DATA[item]["subid"]
start_inventory_changes[0xc67c + subid // 8] |= 0x01 << subid % 8
elif item_id == 0x33: # Maps
subid = ITEMS_DATA[item]["subid"]
start_inventory_changes[0xc67e + subid // 8] |= 0x01 << subid % 8
elif item_id == 0x2d: # Rings
subid = ITEMS_DATA[item]["subid"] - 4
start_inventory_changes[parse_hex_string_to_value(DEFINES["wRingsObtained"]) + subid // 8] |= 0x01 << subid % 8
elif item_id == 0x40: # Essences
subid = ITEMS_DATA[item]["subid"]
start_inventory_changes[parse_hex_string_to_value(DEFINES["wEssencesObtained"])] |= 0x01 << subid % 8
elif 0x20 <= item_id <= 0x24: # Seeds
seed_address = parse_hex_string_to_value(DEFINES["wNumEmberSeeds"]) + item_id - 0x20
start_inventory_changes[seed_address] = seed_amount
if 0xc6a5 in start_inventory_changes:
hex_rupee_count = parse_hex_string_to_value(f"${start_inventory_changes[0xc6a5]}")
start_inventory_changes[0xc6a5] = hex_rupee_count % 0x100
start_inventory_changes[0xc6a6] = hex_rupee_count // 0x100
if 0xc6a7 in start_inventory_changes:
hex_ore_count = parse_hex_string_to_value(f"${start_inventory_changes[0xc6a7]}")
start_inventory_changes[0xc6a7] = hex_ore_count % 0x100
start_inventory_changes[0xc6a8] = hex_ore_count // 0x100
if obtained_treasures_address in start_inventory_changes:
start_inventory_changes[obtained_treasures_address] |= 1 << 2 # Add treasure punch flag
heart_pieces = (start_inventory_data.get("Piece of Heart", 0) + start_inventory_data.get("Rare Peach Stone", 0))
additional_hearts = (start_inventory_data.get("Heart Container", 0) + heart_pieces // 4)
if additional_hearts:
start_inventory_changes[0xc6a2] = start_inventory_changes[0xc6a3] = 12 + additional_hearts * 4
if heart_pieces % 4:
start_inventory_changes[0xc6a4] = heart_pieces % 4
if "Gasha Seed" in start_inventory_data:
start_inventory_changes[0xc6ba] = start_inventory_data["Gasha Seed"]
# Make the list used in asm
start_inventory = []
for address in start_inventory_changes:
start_inventory.append(address // 0x100)
start_inventory.append(address % 0x100)
start_inventory.append(start_inventory_changes[address])
start_inventory.append(0x00) # End of the list
assembler.add_floating_chunk("startingInventory", start_inventory)
def alter_treasure_types(rom: RomData, item_data: dict[str, dict[str, Any]]) -> None:
# Some treasures don't exist as interactions in base game, we need to add
# text & sprite references for them to work properly in a randomized context
set_treasure_data(rom, item_data, "Fool's Ore", 0x36, 0x4a)
set_treasure_data(rom, item_data, "Rare Peach Stone", None, 0x3f)
set_treasure_data(rom, item_data, "Ribbon", 0x41, 0x4c)
set_treasure_data(rom, item_data, "Treasure Map", 0x6c, 0x49)
set_treasure_data(rom, item_data, "Member's Card", 0x45, 0x48)
set_treasure_data(rom, item_data, "Potion", 0x6d, 0x4b)
# Make bombs increase max carriable quantity when obtained from treasures,
# not drops (see asm/seasons/bomb_bag_behavior)
set_treasure_data(rom, item_data, "Bombs (10)", None, None, 0x90)
set_treasure_data(rom, item_data, "Bombs (20)", 0x94, None, 0xa0)
set_treasure_data(rom, item_data, "Bombchus (10)", None, None, 0x90)
set_treasure_data(rom, item_data, "Bombchus (20)", None, None, 0xa0)
# Colored Rod of Seasons to make them recognizable
set_treasure_data(rom, item_data, "Rod of Seasons (Spring)", None, 0x4f)
set_treasure_data(rom, item_data, "Rod of Seasons (Autumn)", None, 0x50)
set_treasure_data(rom, item_data, "Rod of Seasons (Winter)", None, 0x51)
def set_old_men_rupee_values(rom: RomData, patch_data: dict[str, Any]) -> None:
if patch_data["options"]["shuffle_old_men"] == OracleOfSeasonsOldMenShuffle.option_turn_into_locations:
return
for i, name in enumerate(OLD_MAN_RUPEE_VALUES.keys()):
if name in patch_data["old_man_rupee_values"]:
value = patch_data["old_man_rupee_values"][name]
value_byte = RUPEE_VALUES[abs(value)]
rom.write_byte(0x56233 + i, value_byte)
if abs(value) == value:
rom.write_word(0x2987b + (i * 2), 0x7472) # Give rupees
else:
rom.write_word(0x2987b + (i * 2), 0x7488) # Take rupees
def apply_miscellaneous_options(rom: RomData, patch_data: dict[str, Any]) -> None:
# If companion is Dimitri, allow calling him using the Flute inside Sunken City
if patch_data["options"]["animal_companion"] == OracleOfSeasonsAnimalCompanion.option_dimitri:
rom.write_byte(GameboyAddress(0x09, 0x4f39).address_in_rom(), 0xa7)
rom.write_byte(GameboyAddress(0x09, 0x4f3b).address_in_rom(), 0xe7)
# If horon shop 3 is set to be a renewable Potion, manually edit the shop flag for
# that slot to zero to make it stay after buying
if patch_data["options"]["enforce_potion_in_shop"]:
rom.write_byte(GameboyAddress(0x08, 0x4cfb).address_in_rom(), 0x00)
if patch_data["options"]["master_keys"] != OracleOfSeasonsMasterKeys.option_disabled:
# Remove small key consumption on keydoor opened
rom.write_byte(GameboyAddress(0x06, 0x4357).address_in_rom(), 0x00)
if patch_data["options"]["master_keys"] == OracleOfSeasonsMasterKeys.option_all_dungeon_keys:
# Remove boss key consumption on boss keydoor opened
rom.write_word(GameboyAddress(0x06, 0x434f).address_in_rom(), 0x0000)
rom.write_byte(GameboyAddress(0x0a, 0x46ed).address_in_rom(),
patch_data["options"]["gasha_nut_kill_requirement"])
rom.write_byte(GameboyAddress(0x04, 0x6a31).address_in_rom(),
patch_data["options"]["gasha_nut_kill_requirement"] // 2)
def set_fixed_subrosia_seaside_location(rom: RomData, patch_data: dict[str, Any]) -> None:
"""
Make the location for Subrosia Seaside fixed among the 4 possible locations from the vanilla game.
This is done to compensate for the poor in-game randomness and potential unfairness in races.
"""
spots_data = [rom.read_word(addr) for addr in range(0x222D3, 0x222DB, 0x02)]
spot = spots_data[patch_data["subrosia_seaside_location"]]
for addr in range(0x222D3, 0x222DB, 0x02):
rom.write_word(addr, spot)
def set_file_select_text(assembler: Z80Assembler, slot_name: str) -> None:
def char_to_tile(c: str) -> int:
if "0" <= c <= "9":
return ord(c) - 0x20
if "A" <= c <= "Z":
return ord(c) + 0xa1
if c == "+":
return 0xfd
if c == "-":
return 0xfe
if c == ".":
return 0xff
else:
return 0xfc # All other chars are blank spaces
row_1 = [char_to_tile(c) for c in
f"AP {OracleOfSeasonsWorld.version()}"
.center(16, " ")]
row_2 = [char_to_tile(c) for c in slot_name.replace("-", " ").upper().center(16, " ")]
text_tiles = [0x74, 0x31]
text_tiles.extend(row_1)
text_tiles.extend([0x41, 0x40])
text_tiles.extend([0x02] * 12) # Offscreen tiles
text_tiles.extend([0x40, 0x41])
text_tiles.extend(row_2)
text_tiles.extend([0x51, 0x50])
text_tiles.extend([0x02] * 12) # Offscreen tiles
assembler.add_floating_chunk("dma_FileSelectStringTiles", text_tiles)
def process_item_name_for_shop_text(item: dict[str, str | bool]) -> str:
if "player" in item:
player_name = item["player"]
if len(player_name) > 14:
player_name = player_name[:13] + "."
item_name = f"🟦{player_name}'s 🟥"
else:
item_name = "🟥"
item_name += item["item"]
item_name = normalize_text(item_name)
item_name += "\\stop\n"
return item_name
def make_text_data(assembler: Z80Assembler, text: dict[str, str], patch_data: dict[str, Any]) -> None:
# Process shops
OVERWORLD_SHOPS = [
"Horon Village: Shop",
"Horon Village: Member's Shop",
"Sunken City: Syrup Shop",
"Horon Village: Advance Shop"
]
tx_indices = {
"horonShop1": "TX_0e04",
"horonShop2": "TX_0e03",
"horonShop3": "TX_0e02",
"memberShop1": "TX_0e1c",
"memberShop2": "TX_0e1d",
"memberShop3": "TX_0e1e",
"syrupShop1": "TX_0d0a",
"syrupShop2": "TX_0d01",
"syrupShop3": "TX_0d05",
"advanceShop1": "TX_0e22",
"advanceShop2": "TX_0e23",
"advanceShop3": "TX_0e25",
"subrosianMarket1": "TX_2b00",
"subrosianMarket2": "TX_2b01",
"subrosianMarket3": "TX_2b05",
"subrosianMarket4": "TX_2b06",
"subrosianMarket5": "TX_2b10",
"spoolSwampScrub": "TX_4509",
"samasaCaveScrub": "TX_450b",
"d4Scrub": "TX_450c",
"d2Scrub": "TX_450d",
}
for shop_name in OVERWORLD_SHOPS:
for i in range(1, 4):
location_name = f"{shop_name} #{i}"
symbolic_name = LOCATIONS_DATA[location_name]["symbolic_name"]
if location_name not in patch_data["locations"]:
continue
item_text = process_item_name_for_shop_text(patch_data["locations"][location_name])
item_text += (" \\num1 Rupees\n"
" \\optOK \\optNo thanks\\cmd(0f)")
text[tx_indices[symbolic_name]] = item_text
for market_slot in range(1, 6):
location_name = f"Subrosia: Market #{market_slot}"
symbolic_name = LOCATIONS_DATA[location_name]["symbolic_name"]
if location_name not in patch_data["locations"]:
continue
item_text = process_item_name_for_shop_text(patch_data["locations"][location_name])
if market_slot == 1:
item_text += ("I'll trade for\n"
"🟥Star-Shaped Ore⬜.\n"
"\\jump(0b)")
else:
item_text += ("I'll trade for\n"
"🟥\\num1 Ore Chunks⬜.\n"
"\\jump(0b)")
text[tx_indices[symbolic_name]] = item_text
BUSINESS_SCRUBS = [
"Spool Swamp: Business Scrub",
"Samasa Desert: Business Scrub",
"Snake's Remains: Business Scrub",
"Dancing Dragon Dungeon (1F): Business Scrub"
]
for location_name in BUSINESS_SCRUBS:
symbolic_name = LOCATIONS_DATA[location_name]["symbolic_name"]
if location_name not in patch_data["locations"]:
continue
# Scrub string asking the player if they want to buy the item
item_text = ("\\sfx(c6)Greetings!\n"
+ process_item_name_for_shop_text(patch_data["locations"][location_name])
+ f"for 🟩{patch_data['shop_prices'][symbolic_name]} Rupees⬜\n"
" \\optOK \\optNo thanks")
text[tx_indices[symbolic_name]] = item_text
# Cross items
assembler.define_byte("text.hook1.treasure", 0x3b)
assembler.define_byte("text.hook2.treasure", 0x51)
assembler.define_byte("text.cane.treasure", 0x53)
assembler.define_byte("text.shooter.treasure", 0x54)
assembler.define_byte("text.hook1.inventory", 0x1e)
assembler.define_byte("text.hook2.inventory", 0x17)
assembler.define_byte("text.cane.inventory", 0x1d)
assembler.define_byte("text.shooter.inventory", 0x2e)
# Default satchel seed
seed_name = SEED_ITEMS[patch_data["options"]["default_seed"]].replace(" ", "\n")
text["TX_002d"] = text["TX_002d"].replace("Ember\nSeeds", seed_name)
# Misc
if patch_data["options"]["rosa_quick_unlock"]:
text["TX_2904"] = ("Since you're so\n"
"nice, I unlocked\n"
"all the doors\n"
"here for you.")
text["TX_3e1b"] = ("You've broken\n🟩\\num1 signs⬜!\n"
"You'd better not\n"
"break more than\n"
f"🟩{patch_data['options']['sign_guy_requirement']}"
", or else...")
# Inform the player of how many gashas are good
wife_text_index = text["TX_3101"].index("The place")
num_seeds = patch_data["options"]["deterministic_gasha_locations"]
if num_seeds == 0:
seed_text = ("nuts will not\n"
"contain anything\n"
"useful.")
elif num_seeds == 16:
seed_text = ("every nut can\n"
"hold something\n"
"useful.")
else:
seed_text = ("only your first\n"
f"🟩{num_seeds}⬜ nuts can\n"
"contain anything\n"
"useful.")
text["TX_3101"] = (text["TX_3101"][:wife_text_index]
+ "\\stop\n"
"You should know\n"
+ seed_text)
# Golden beasts
golden_beasts_requirement = patch_data["options"]["golden_beasts_requirement"]
if golden_beasts_requirement == 0:
# Just a funny text for killing no golden beasts
golden_beast_reward_text = text["TX_1f05"]
post_congratulation_index = golden_beast_reward_text.index("Sir")
text["TX_1f04"] = ""
text["TX_1f05"] = ("You did nothing!\n"
"Truly, " + golden_beast_reward_text[post_congratulation_index:])
elif golden_beasts_requirement < 4:
number = ["one", "two", "three"][golden_beasts_requirement - 1]
text["TX_1f04"] = text["TX_1f04"].replace("the four", number)
text["TX_1f05"] = text["TX_1f05"].replace("all four", number)
if golden_beasts_requirement == 1:
text["TX_1f04"] = text["TX_1f04"].replace("beasts", "beast")
text["TX_1f05"] = text["TX_1f05"].replace("beasts", "beast")
# Maku tree sign
essence_count = patch_data["options"]["required_essences"]
text["TX_2e00"] = (f"Find 🟥{essence_count} essence{'s' if essence_count != 1 else ''}\n"
"to get the seed!")
# Tarm ruins sign
jewel_count = patch_data["options"]["tarm_gate_required_jewels"]
text["TX_2e12"] = (f"Bring 🟩{jewel_count}⬜ jewel{'s' if jewel_count != 1 else ''}\n"
"for the door\n"
"to open.")
# Tree house old man
essence_count = patch_data["options"]["treehouse_old_man_requirement"]
text["TX_3601"] = text["TX_3601"].replace("knows many\n🟥essences⬜...", f"has 🟥{essence_count} essence{'s' if essence_count != 1 else ''}⬜!")
# With quick rosa, the escort code is disabled
if patch_data["options"]["rosa_quick_unlock"]:
text["TX_2906"] = normalize_text("Not me. Maybe ask someone else?")
make_hint_texts(text, patch_data)
def set_heart_beep_interval_from_settings(rom: RomData) -> None:
heart_beep_interval = get_settings()["tloz_oos_options"]["heart_beep_interval"]
if heart_beep_interval == "half":
rom.write_byte(0x9116, 0x3f * 2)
elif heart_beep_interval == "quarter":
rom.write_byte(0x9116, 0x3f * 4)
elif heart_beep_interval == "disabled":
rom.write_bytes(0x9116, [0x00, 0xc9]) # Put a return to avoid beeping entirely
def set_character_sprite_from_settings(rom: RomData) -> None:
sprite = get_settings()["tloz_oos_options"]["character_sprite"]
sprite_dir = Path(Utils.local_path(os.path.join("data", "sprites", "oos_ooa")))
if sprite == "random":
sprite_weights = {f: 1 for f in os.listdir(sprite_dir) if sprite_dir.joinpath(f).is_file() and f.endswith(".bin")}
elif isinstance(sprite, str):
sprite_weights = {sprite: 1}
else:
sprite_weights = sprite
weights = random.randrange(sum(sprite_weights.values()))
for sprite, weight in sprite_weights.items():
weights -= weight
if weights < 0:
break
palette_option = get_settings()["tloz_oos_options"]["character_palette"]
if palette_option == "random":
palette_weights = {palette: 1 for palette in get_available_random_colors_from_sprite_name(sprite)}
elif isinstance(palette_option, str):
palette_weights = {palette_option: 1}
else:
valid_palettes = get_available_random_colors_from_sprite_name(sprite)
palette_weights = {}
for palette, weight in palette_option.items():
splitted_palette = palette.split("|")
if len(splitted_palette) == 2 and splitted_palette[1] != sprite:
continue
palette_name = splitted_palette[0]
if palette_name == "random":
for valid_palette in valid_palettes:
palette_weights[valid_palette] = weight
elif palette_name in valid_palettes:
palette_weights[palette_name] = weight
if len(palette_weights) == 0:
palette_weights["green"] = 1
weights = random.randrange(sum(palette_weights.values()))
for palette, weight in palette_weights.items():
weights -= weight
if weights < 0:
break
if not sprite.endswith(".bin"):
sprite += ".bin"
if sprite != "link.bin":
sprite_path = sprite_dir.joinpath(sprite)
if not (sprite_path.exists() and sprite_path.is_file()):
raise ValueError(f"Path '{sprite_path}' doesn't exist")
sprite_bytes = list(Path(sprite_path).read_bytes())
rom.write_bytes(0x68000, sprite_bytes)
# noinspection PyUnboundLocalVariable
if palette == "green":
return # Nothing to change
# noinspection PyUnboundLocalVariable
if palette not in PALETTE_BYTES:
raise ValueError(f"Palette color '{palette}' doesn't exist (must be 'green', 'blue', 'red' or 'orange')")
palette_byte = PALETTE_BYTES[palette]
# Link in-game
for addr in range(0x141cc, 0x141df, 2):
rom.write_byte(addr, 0x08 | palette_byte)
# Link palette restored after Medusa Head / Ganon stun attacks
rom.write_byte(0x1516d, 0x08 | palette_byte)
# Link standing still in file select (fileSelectDrawLink:@sprites0)
rom.write_byte(0x8d46, palette_byte)
rom.write_byte(0x8d4a, palette_byte)
# Link animated in file select (@sprites1 & @sprites2)
rom.write_byte(0x8d4f, palette_byte)
rom.write_byte(0x8d53, palette_byte)
rom.write_byte(0x8d58, 0x20 | palette_byte)
rom.write_byte(0x8d5c, 0x20 | palette_byte)
def inject_slot_name(rom: RomData, slot_name: str) -> None:
slot_name_as_bytes = list(str.encode(slot_name))
slot_name_as_bytes += [0x00] * (0x40 - len(slot_name_as_bytes))
rom.write_bytes(0xfffc0, slot_name_as_bytes)
def set_dungeon_warps(rom: RomData, patch_data: dict[str, Any], dungeon_entrances: dict[str, Any], dungeon_exits: dict[str, Any]) -> None:
warp_matchings = patch_data["dungeon_entrances"]
enter_values = {name: rom.read_word(dungeon["addr"]) for name, dungeon in dungeon_entrances.items()}
exit_values = {name: rom.read_word(addr) for name, addr in dungeon_exits.items()}
# Apply warp matchings expressed in the patch
for from_name, to_name in warp_matchings.items():
entrance_addr = dungeon_entrances[from_name]["addr"]
exit_addr = dungeon_exits[to_name]
rom.write_word(entrance_addr, enter_values[to_name])
rom.write_word(exit_addr, exit_values[from_name])
# Build a map dungeon => entrance (useful for essence warps)
entrance_map = dict((v, k) for k, v in warp_matchings.items())
# D0 Chest Warp (hardcoded warp using a specific format)
d0_new_entrance = dungeon_entrances[entrance_map["d0"]]
rom.write_bytes(0x2bbe4, [
d0_new_entrance["group"] | 0x80,
d0_new_entrance["room"],
0x00,
d0_new_entrance["position"]
])
# D1-D8 Essence Warps (hardcoded in one array using a unified format)
for i in range(8):
entrance = dungeon_entrances[entrance_map[f"d{i + 1}"]]
rom.write_bytes(0x24b59 + (i * 4), [
entrance["group"] | 0x80,
entrance["room"],
entrance["position"]
])
# Change Minimap popups to indicate the randomized dungeon's name
for i in range(8):
entrance_name = f"d{i}"
dungeon_index = int(warp_matchings[entrance_name][1:])
map_tile = dungeon_entrances[entrance_name]["map_tile"]
rom.write_byte(0xaa19 + map_tile, 0x81 | (dungeon_index << 3))
# Dungeon 8 specific case (since it's in Subrosia)
dungeon_index = int(warp_matchings["d8"][1:])
rom.write_byte(0xab19, 0x81 | (dungeon_index << 3))
if patch_data["options"]["linked_heros_cave"] & OracleOfSeasonsLinkedHerosCave.samasa:
# Change Minimap popups
entrance_name = "d11"
dungeon_index = int(warp_matchings[entrance_name][1:])
map_tile = dungeon_entrances[entrance_name]["map_tile"]
rom.write_byte(0xaa19 + map_tile, 0x81 | (dungeon_index << 3))
if patch_data["options"]["linked_heros_cave"] & OracleOfSeasonsLinkedHerosCave.heros_cave:
rom.write_word(dungeon_exits["d11"], exit_values[entrance_map["d0"]])
def set_portal_warps(rom: RomData, patch_data: dict[str, Any]) -> None:
warp_matchings = patch_data["subrosia_portals"]
values = {}
for portal_1, portal_2 in PORTAL_CONNECTIONS.items():
values[portal_1] = rom.read_word(PORTAL_WARPS[portal_2]["addr"])
values[portal_2] = rom.read_word(PORTAL_WARPS[portal_1]["addr"])
# Apply warp matchings expressed in the patch
for name_1, name_2 in warp_matchings.items():
portal_1 = PORTAL_WARPS[name_1]
portal_2 = PORTAL_WARPS[name_2]
# Set warp destinations for both portals
rom.write_word(portal_1["addr"], values[name_2])
rom.write_word(portal_2["addr"], values[name_1])
# Set portal text in map menu for both portals
portal_text_addr = 0xab19 if portal_1["in_subrosia"] else 0xaa19
portal_text_addr += portal_1["map_tile"]
rom.write_byte(portal_text_addr, 0x80 | (portal_2["text_index"] << 3))
portal_text_addr = 0xab19 if portal_2["in_subrosia"] else 0xaa19
portal_text_addr += portal_2["map_tile"]
rom.write_byte(portal_text_addr, 0x80 | (portal_1["text_index"] << 3))
def define_dungeon_items_text_constants(texts: dict[str, str], patch_data: dict[str, Any]) -> None:
base_id = 0x73
for i in range(10):
if i == 0:
dungeon_precision = " for\nHero's Cave"
elif i == 9:
dungeon_precision = " for\nLinked Hero's\nCave"
else:
dungeon_precision = f" for\nDungeon {i}"
# ###### Small keys ##############################################
small_key_text = "You found a\n🟥"
if patch_data["options"]["master_keys"]:
small_key_text += "Master Key"
else:
small_key_text += "Small Key"
if patch_data["options"]["keysanity_small_keys"]:
small_key_text += dungeon_precision
small_key_text += "⬜!"
texts[f"TX_00{simple_hex(base_id + i)}"] = small_key_text
# Hero's Cave only has Small Keys, so skip other texts
if i == 0 or i == 9:
continue
# ###### Boss keys ##############################################
boss_key_text = "You found the\n🟥Boss Key"
if patch_data["options"]["keysanity_boss_keys"]:
boss_key_text += dungeon_precision
boss_key_text += "⬜!"
texts[f"TX_00{simple_hex(base_id + i + 9)}"] = boss_key_text
# ###### Dungeon maps ##############################################
dungeon_map_text = "You found the\n🟥"
if patch_data["options"]["keysanity_maps_compasses"]:
dungeon_map_text += "Map"
dungeon_map_text += dungeon_precision
else:
dungeon_map_text += "Dungeon Map"
dungeon_map_text += "⬜!"
texts[f"TX_00{simple_hex(base_id + i + 17)}"] = dungeon_map_text
# ###### Compasses ##############################################
compasses_text = "You found the\n🟥Compass"
if patch_data["options"]["keysanity_maps_compasses"]:
compasses_text += dungeon_precision
compasses_text += "⬜!"
texts[f"TX_00{simple_hex(base_id + i + 25)}"] = compasses_text
if patch_data["options"]["master_keys"]:
texts["TX_001a"] = texts["TX_001a"].replace("Small", "Master")
def define_essence_sparkle_constants(assembler: Z80Assembler, patch_data: dict[str, Any], dungeon_entrances: dict[str, Any]) -> None:
byte_array = []
show_dungeons_with_essence = patch_data["options"]["show_dungeons_with_essence"]
essence_pedestals = [k for k, v in LOCATIONS_DATA.items() if v.get("essence", False)]
if show_dungeons_with_essence and not patch_data["options"]["shuffle_essences"]:
for i, pedestal in enumerate(essence_pedestals):
if patch_data["locations"][pedestal]["item"] not in ITEM_GROUPS["Essences"]:
byte_array.extend([0xF0, 0x00]) # Nonexistent room, for padding
continue
# Find where dungeon entrance is located, and place the sparkle hint there
dungeon = f"d{i + 1}"
dungeon_entrance = [k for k, v in patch_data["dungeon_entrances"].items() if v == dungeon][0]
entrance_data = dungeon_entrances[dungeon_entrance]
byte_array.extend([entrance_data["group"], entrance_data["room"]])
assembler.add_floating_chunk("essenceLocationsTable", byte_array)
require_compass = show_dungeons_with_essence == OracleOfSeasonsShowDungeonsWithEssence.option_with_compass
assembler.define_byte("option.essenceSparklesRequireCompass", 1 if require_compass else 0)
def set_faq_trap(assembler: Z80Assembler) -> None:
assembler.define_byte("option.startingGroup", 0x04, True)
assembler.define_byte("option.startingRoom", 0xec, True)
assembler.define_byte("option.startingPosY", 0x50, True)
assembler.define_byte("option.startingPosX", 0x78, True)
def randomize_ai_for_april_fools(rom: RomData, seed: int):
code_table = 0x2f16
# TODO : properly implement that ?
enemy_table = [
# enemy id in (08, 2f), specially for the blade traps since they can't take the beamos AI, or they'd block d6
{
0: [
0x08, # river zora
0x09, # octorok
0x0c, # arrow moblin
0x0d, # lynel
0x0f,
0x11, # Pokey is too unreliable and laggy
0x12, # gibdo
0x13, # spark
0x14, # spiked beetle
0x15, # bubble
0x18, # buzzblob
0x19, # whisp
0x1a, # crab
0x20, # masked moblin
0x22,
0x23, # pol's voice
0x25, # goponga flower
0x29,
0x2d,
0x2e,
0x2f
],
1: [
0x0a, # boomerang moblin
0x1c, # iron mask
0x1e, # piranha
0x25,
0x2c, # cheep cheep, will probably break
],
2: [
0x0b, # leever
0x17, # ghini
0x21, # arrow darknut
],
3: [
0x1b, # spiny beetle
0x24, # like like, flagged as unkillable as the spawner, and is logically not required
0x2a,
],
4: [
0x10, # rope
],
5: [
0x0e, # blade trap
0x2b,
],
},
# enemy id in (08, 2f)
{
0: [
0x08, # river zora
0x09, # octorok
0x0c, # arrow moblin
0x0d, # lynel
0x0f,
0x11, # Pokey is too unreliable and laggy
0x12, # gibdo
0x13, # spark
0x14, # spiked beetle
0x15, # bubble
0x16, # beamos
0x18, # buzzblob
0x19, # whisp
0x1a, # crab
0x20, # masked moblin
0x22,
0x23, # pol's voice
0x25, # goponga flower
0x29,
0x2d,
0x2e,
0x2f
],
1: [
0x0a, # boomerang moblin
0x1c, # iron mask
0x1e, # piranha
0x25,
0x2c, # cheep cheep, will probably break
],
2: [
0x0b, # leever
0x17, # ghini
0x21, # arrow darknut
],
3: [
0x1b, # spiny beetle
0x24, # like like, flagged as unkillable as the spawner, and is logically not required
0x2a,
],
4: [
0x10, # rope
],
5: [
0x2b,
],
},
# enemy id in (08, 2f), killable
{
0: [
0x09, # octorok
0x12, # gibdo
0x14, # spiked beetle
0x18, # buzzblob
0x1a, # crab
0x22,
0x23, # pol's voice
0x0d, # lynel
0x0c, # arrow moblin
0x20, # masked moblin
],
1: [
0x0a, # boomerang moblin
0x1c, # iron mask
0x1e, # piranha
0x25,
],
2: [
0x0b, # leever
0x17, # ghini
0x21, # arrow darknut
],
4: [
0x10, # rope
],
},
# enemy id in (30, 60)
{
0: [
0x30,
0x31,
0x33,
0x36,
0x37,
0x38,
0x3b,
0x3c,
0x3d,
0x3e,
0x43,
0x46,
0x48,
0x49,
0x4a,
0x4b,
0x4d,
0x4e,
0x5e,
],
1: [
0x32,
0x34,
0x39,
0x41,
0x4c,
0x4f,
],
2: [
# 0x40,
0x52,
],
3: [
0x51,
0x58,
],
4: [
0x45, # pincer
]
},
# enemy id in (30, 60), killable
{
0: [
0x30,
0x31,
0x3c,
0x3d,
0x3e,
0x43,
0x48,
0x49,
0x4a,
0x4b,
0x4d,
0x4e,
],
1: [
0x32,
0x34,
0x39,
0x4c,
0x4f,
],
2: [
# 0x40
],
}
]
r = random.Random(seed)
ai_table = {}
for bank in enemy_table:
for cat in bank:
enemies = bank[cat]
if isinstance(cat, int) and cat != 0:
ais = list(bank[cat])
for cat2 in bank:
if isinstance(cat2, int):
if cat2 == 0:
ais.extend(bank[cat2])
elif cat2 >= cat:
ais.extend(bank[cat2])
ais.extend(bank[cat2])
else:
ais = list(bank[cat])
r.shuffle(ais)
for i in range(len(enemies)):
enemy = enemies[i]
ai = ais.pop()
ai_table[enemy] = ai
ai_table[0x2f] = 0x2f # Thwomps have to stay vanilla for platforming
for enemy in ai_table:
ai = ai_table[enemy]
rom.write_word(code_table + enemy * 2, rom.read_word(code_table + ai * 2))
blinkers = {
0x08,
0x0b,
0x10,
0x24,
0x34,
0x40,
0x41
}
# Make some enemies hittable without having access to their AI
if ai_table[0x08] not in blinkers:
rom.write_byte(0xFDD92, 0x8F) # river zora
if ai_table[0x0b] not in blinkers:
rom.write_byte(0xFDD9E, 0x90) # leever
if ai_table[0x10] not in blinkers:
rom.write_byte(0xFDDB2, 0x90) # rope
if ai_table[0x24] not in blinkers:
rom.write_byte(0xFDE02, 0xA2) # like-like
if ai_table[0x34] not in blinkers:
rom.write_byte(0xFDE42, 0xAA) # zol
# if ai_table[0x40] not in blinkers:
# rom.write_byte(0xFDE72, 0xAF) # wizzrobes
if ai_table[0x41] not in blinkers:
rom.write_byte(0xFDE76, 0xB0) # crow
if ai_table[0x14] != 0x14:
rom.write_byte(0xFDDC2, 0xCE) # Make spiked beetles have the flipped collisions
if ai_table[0x1C] != 0x1C:
rom.write_byte(0xFDDE2, 0xD0) # Make iron mask have the unmasked collisions
if ai_table[0x3e] != 0x3e:
rom.write_byte(0xFDE6A, 0xAE) # Make peahats have vulnerable collisions
if ai_table[0x24] != 0x24:
# make like like deal low knockback instead of softlocking by grabbing him then never releasing him due to the lack of AI
rom.write_byte(0x1EED0, 0x01)