From 7a8048a8fddbb016de0f9e73bff7067e9a786d43 Mon Sep 17 00:00:00 2001 From: threeandthreee Date: Sat, 26 Jul 2025 16:16:00 -0400 Subject: [PATCH] LADX: generate without rom (#4278) --- worlds/ladx/LADXR/generator.py | 279 +++++++++------------ worlds/ladx/LADXR/hints.py | 56 ++++- worlds/ladx/LADXR/patches/core.py | 4 +- worlds/ladx/LADXR/patches/enemies.py | 2 +- worlds/ladx/LADXR/patches/titleScreen.py | 22 +- worlds/ladx/LADXR/patches/tradeSequence.py | 2 +- worlds/ladx/LADXR/rom.py | 16 +- worlds/ladx/LADXR/romTables.py | 8 +- worlds/ladx/Options.py | 39 +-- worlds/ladx/Rom.py | 99 +++++++- worlds/ladx/__init__.py | 80 +++--- 11 files changed, 317 insertions(+), 290 deletions(-) diff --git a/worlds/ladx/LADXR/generator.py b/worlds/ladx/LADXR/generator.py index 413bf89c..81ca6660 100644 --- a/worlds/ladx/LADXR/generator.py +++ b/worlds/ladx/LADXR/generator.py @@ -2,13 +2,15 @@ import binascii import importlib.util import importlib.machinery import os -import pkgutil +import random +import pickle +import Utils +import settings from collections import defaultdict -from typing import TYPE_CHECKING +from typing import Dict from .romTables import ROMWithTables from . import assembler -from . import mapgen from . import patches from .patches import overworld as _ from .patches import dungeon as _ @@ -57,27 +59,20 @@ from .patches import tradeSequence as _ from . import hints from .patches import bank34 -from .utils import formatText from .roomEditor import RoomEditor, Object from .patches.aesthetics import rgb_to_bin, bin_to_rgb -from .locations.keyLocation import KeyLocation - -from BaseClasses import ItemClassification -from ..Locations import LinksAwakeningLocation -from ..Options import TrendyGame, Palette, MusicChangeCondition, Warps - -if TYPE_CHECKING: - from .. import LinksAwakeningWorld - +from .. import Options # Function to generate a final rom, this patches the rom with all required patches -def generateRom(args, world: "LinksAwakeningWorld"): +def generateRom(base_rom: bytes, args, patch_data: Dict): + random.seed(patch_data["seed"] + patch_data["player"]) + multi_key = binascii.unhexlify(patch_data["multi_key"].encode()) + item_list = pickle.loads(binascii.unhexlify(patch_data["item_list"].encode())) + options = patch_data["options"] rom_patches = [] - player_names = list(world.multiworld.player_name.values()) - - rom = ROMWithTables(args.input_filename, rom_patches) - rom.player_names = player_names + rom = ROMWithTables(base_rom, rom_patches) + rom.player_names = patch_data["other_player_names"] pymods = [] if args.pymod: for pymod in args.pymod: @@ -88,10 +83,13 @@ def generateRom(args, world: "LinksAwakeningWorld"): for pymod in pymods: pymod.prePatch(rom) - if world.ladxr_settings.gfxmod: - patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", world.ladxr_settings.gfxmod)) - - item_list = [item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)] + if options["gfxmod"]: + user_settings = settings.get_settings() + try: + gfx_mod_file = user_settings["ladx_options"]["gfx_mod_file"] + patches.aesthetics.gfxMod(rom, gfx_mod_file) + except FileNotFoundError: + pass # if user just doesnt provide gfxmod file, let patching continue assembler.resetConsts() assembler.const("INV_SIZE", 16) @@ -121,7 +119,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): assembler.const("wLinkSpawnDelay", 0xDE13) #assembler.const("HARDWARE_LINK", 1) - assembler.const("HARD_MODE", 1 if world.ladxr_settings.hardmode != "none" else 0) + assembler.const("HARD_MODE", 1 if options["hard_mode"] else 0) patches.core.cleanup(rom) patches.save.singleSaveSlot(rom) @@ -135,7 +133,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.core.easyColorDungeonAccess(rom) patches.owl.removeOwlEvents(rom) patches.enemies.fixArmosKnightAsMiniboss(rom) - patches.bank3e.addBank3E(rom, world.multi_key, world.player, player_names) + patches.bank3e.addBank3E(rom, multi_key, patch_data["player"], patch_data["other_player_names"]) patches.bank3f.addBank3F(rom) patches.bank34.addBank34(rom, item_list) patches.core.removeGhost(rom) @@ -144,19 +142,17 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.core.alwaysAllowSecretBook(rom) patches.core.injectMainLoop(rom) - from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys - - if world.options.shuffle_small_keys != ShuffleSmallKeys.option_original_dungeon or\ - world.options.shuffle_nightmare_keys != ShuffleNightmareKeys.option_original_dungeon: + if options["shuffle_small_keys"] != Options.ShuffleSmallKeys.option_original_dungeon or\ + options["shuffle_nightmare_keys"] != Options.ShuffleNightmareKeys.option_original_dungeon: patches.inventory.advancedInventorySubscreen(rom) patches.inventory.moreSlots(rom) - if world.ladxr_settings.witch: - patches.witch.updateWitch(rom) + # if ladxr_settings["witch"]: + patches.witch.updateWitch(rom) patches.softlock.fixAll(rom) - if not world.ladxr_settings.rooster: + if not options["rooster"]: patches.maptweaks.tweakMap(rom) patches.maptweaks.tweakBirdKeyRoom(rom) - if world.ladxr_settings.overworld == "openmabe": + if options["overworld"] == Options.Overworld.option_open_mabe: patches.maptweaks.openMabe(rom) patches.chest.fixChests(rom) patches.shop.fixShop(rom) @@ -168,10 +164,10 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.tarin.updateTarin(rom) patches.fishingMinigame.updateFinishingMinigame(rom) patches.health.upgradeHealthContainers(rom) - if world.ladxr_settings.owlstatues in ("dungeon", "both"): - patches.owl.upgradeDungeonOwlStatues(rom) - if world.ladxr_settings.owlstatues in ("overworld", "both"): - patches.owl.upgradeOverworldOwlStatues(rom) + # if ladxr_settings["owlstatues"] in ("dungeon", "both"): + # patches.owl.upgradeDungeonOwlStatues(rom) + # if ladxr_settings["owlstatues"] in ("overworld", "both"): + # patches.owl.upgradeOverworldOwlStatues(rom) patches.goldenLeaf.fixGoldenLeaf(rom) patches.heartPiece.fixHeartPiece(rom) patches.seashell.fixSeashell(rom) @@ -180,143 +176,95 @@ def generateRom(args, world: "LinksAwakeningWorld"): patches.songs.upgradeMarin(rom) patches.songs.upgradeManbo(rom) patches.songs.upgradeMamu(rom) - patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings) - patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal') - if world.ladxr_settings.bowwow != 'normal': - patches.bowwow.bowwowMapPatches(rom) + + patches.tradeSequence.patchTradeSequence(rom, options) + patches.bowwow.fixBowwow(rom, everywhere=False) + # if ladxr_settings["bowwow"] != 'normal': + # patches.bowwow.bowwowMapPatches(rom) patches.desert.desertAccess(rom) - if world.ladxr_settings.overworld == 'dungeondive': - patches.overworld.patchOverworldTilesets(rom) - patches.overworld.createDungeonOnlyOverworld(rom) - elif world.ladxr_settings.overworld == 'nodungeons': - patches.dungeon.patchNoDungeons(rom) - elif world.ladxr_settings.overworld == 'random': - patches.overworld.patchOverworldTilesets(rom) - mapgen.store_map(rom, world.ladxr_logic.world.map) + # if ladxr_settings["overworld"] == 'dungeondive': + # patches.overworld.patchOverworldTilesets(rom) + # patches.overworld.createDungeonOnlyOverworld(rom) + # elif ladxr_settings["overworld"] == 'nodungeons': + # patches.dungeon.patchNoDungeons(rom) + #elif world.ladxr_settings["overworld"] == 'random': + # patches.overworld.patchOverworldTilesets(rom) + # mapgen.store_map(rom, world.ladxr_logic.world.map) #if settings.dungeon_items == 'keysy': # patches.dungeon.removeKeyDoors(rom) # patches.reduceRNG.slowdownThreeOfAKind(rom) patches.reduceRNG.fixHorseHeads(rom) patches.bomb.onlyDropBombsWhenHaveBombs(rom) - if world.options.music_change_condition == MusicChangeCondition.option_always: + if options["music_change_condition"] == Options.MusicChangeCondition.option_always: patches.aesthetics.noSwordMusic(rom) - patches.aesthetics.reduceMessageLengths(rom, world.random) + patches.aesthetics.reduceMessageLengths(rom, random) patches.aesthetics.allowColorDungeonSpritesEverywhere(rom) - if world.ladxr_settings.music == 'random': - patches.music.randomizeMusic(rom, world.random) - elif world.ladxr_settings.music == 'off': + if options["music"] == Options.Music.option_shuffled: + patches.music.randomizeMusic(rom, random) + elif options["music"] == Options.Music.option_off: patches.music.noMusic(rom) - if world.ladxr_settings.noflash: + if options["no_flash"]: patches.aesthetics.removeFlashingLights(rom) - if world.ladxr_settings.hardmode == "oracle": + if options["hard_mode"] == Options.HardMode.option_oracle: patches.hardMode.oracleMode(rom) - elif world.ladxr_settings.hardmode == "hero": + elif options["hard_mode"] == Options.HardMode.option_hero: patches.hardMode.heroMode(rom) - elif world.ladxr_settings.hardmode == "ohko": + elif options["hard_mode"] == Options.HardMode.option_ohko: patches.hardMode.oneHitKO(rom) - if world.ladxr_settings.superweapons: - patches.weapons.patchSuperWeapons(rom) - if world.ladxr_settings.textmode == 'fast': + #if ladxr_settings["superweapons"]: + # patches.weapons.patchSuperWeapons(rom) + if options["text_mode"] == Options.TextMode.option_fast: patches.aesthetics.fastText(rom) - if world.ladxr_settings.textmode == 'none': - patches.aesthetics.fastText(rom) - patches.aesthetics.noText(rom) - if not world.ladxr_settings.nagmessages: + #if ladxr_settings["textmode"] == 'none': + # patches.aesthetics.fastText(rom) + # patches.aesthetics.noText(rom) + if not options["nag_messages"]: patches.aesthetics.removeNagMessages(rom) - if world.ladxr_settings.lowhpbeep == 'slow': + if options["low_hp_beep"] == Options.LowHpBeep.option_slow: patches.aesthetics.slowLowHPBeep(rom) - if world.ladxr_settings.lowhpbeep == 'none': + if options["low_hp_beep"] == Options.LowHpBeep.option_none: patches.aesthetics.removeLowHPBeep(rom) - if 0 <= int(world.ladxr_settings.linkspalette): - patches.aesthetics.forceLinksPalette(rom, int(world.ladxr_settings.linkspalette)) + if 0 <= options["link_palette"]: + patches.aesthetics.forceLinksPalette(rom, options["link_palette"]) if args.romdebugmode: # The default rom has this build in, just need to set a flag and we get this save. rom.patch(0, 0x0003, "00", "01") # Patch the sword check on the shopkeeper turning around. - if world.ladxr_settings.steal == 'never': - rom.patch(4, 0x36F9, "FA4EDB", "3E0000") - elif world.ladxr_settings.steal == 'always': - rom.patch(4, 0x36F9, "FA4EDB", "3E0100") + #if ladxr_settings["steal"] == 'never': + # rom.patch(4, 0x36F9, "FA4EDB", "3E0000") + #elif ladxr_settings["steal"] == 'always': + # rom.patch(4, 0x36F9, "FA4EDB", "3E0100") - if world.ladxr_settings.hpmode == 'inverted': - patches.health.setStartHealth(rom, 9) - elif world.ladxr_settings.hpmode == '1': - patches.health.setStartHealth(rom, 1) + #if ladxr_settings["hpmode"] == 'inverted': + # patches.health.setStartHealth(rom, 9) + #elif ladxr_settings["hpmode"] == '1': + # patches.health.setStartHealth(rom, 1) patches.inventory.songSelectAfterOcarinaSelect(rom) - if world.ladxr_settings.quickswap == 'a': + if options["quickswap"] == 'a': patches.core.quickswap(rom, 1) - elif world.ladxr_settings.quickswap == 'b': + elif options["quickswap"] == 'b': patches.core.quickswap(rom, 0) - patches.core.addBootsControls(rom, world.options.boots_controls) + patches.core.addBootsControls(rom, options["boots_controls"]) + random.seed(patch_data["seed"] + patch_data["player"]) + hints.addHints(rom, random, patch_data["hint_texts"]) - world_setup = world.ladxr_logic.world_setup - - JUNK_HINT = 0.33 - RANDOM_HINT= 0.66 - # USEFUL_HINT = 1.0 - # TODO: filter events, filter unshuffled keys - all_items = world.multiworld.get_items() - our_items = [item for item in all_items - if item.player == world.player - and item.location - and item.code is not None - and item.location.show_in_spoiler] - our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification] - - def gen_hint(): - if not world.options.in_game_hints: - return 'Hints are disabled!' - chance = world.random.uniform(0, 1) - if chance < JUNK_HINT: - return None - elif chance < RANDOM_HINT: - location = world.random.choice(our_items).location - else: # USEFUL_HINT - location = world.random.choice(our_useful_items).location - - if location.item.player == world.player: - name = "Your" - else: - name = f"{world.multiworld.player_name[location.item.player]}'s" - # filter out { and } since they cause issues with string.format later on - name = name.replace("{", "").replace("}", "") - - if isinstance(location, LinksAwakeningLocation): - location_name = location.ladxr_item.metadata.name - else: - location_name = location.name - - hint = f"{name} {location.item.name} is at {location_name}" - if location.player != world.player: - # filter out { and } since they cause issues with string.format later on - player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "") - hint += f" in {player_name}'s world" - - # Cap hint size at 85 - # Realistically we could go bigger but let's be safe instead - hint = hint[:85] - - return hint - - hints.addHints(rom, world.random, gen_hint) - - if world_setup.goal == "raft": + if patch_data["world_setup"]["goal"] == "raft": patches.goal.setRaftGoal(rom) - elif world_setup.goal in ("bingo", "bingo-full"): - patches.bingo.setBingoGoal(rom, world_setup.bingo_goals, world_setup.goal) - elif world_setup.goal == "seashells": + elif patch_data["world_setup"]["goal"] in ("bingo", "bingo-full"): + patches.bingo.setBingoGoal(rom, patch_data["world_setup"]["bingo_goals"], patch_data["world_setup"]["goal"]) + elif patch_data["world_setup"]["goal"] == "seashells": patches.goal.setSeashellGoal(rom, 20) else: - patches.goal.setRequiredInstrumentCount(rom, world_setup.goal) + patches.goal.setRequiredInstrumentCount(rom, patch_data["world_setup"]["goal"]) # Patch the generated logic into the rom - patches.chest.setMultiChest(rom, world_setup.multichest) - if world.ladxr_settings.overworld not in {"dungeondive", "random"}: - patches.entrances.changeEntrances(rom, world_setup.entrance_mapping) + patches.chest.setMultiChest(rom, patch_data["world_setup"]["multichest"]) + #if ladxr_settings["overworld"] not in {"dungeondive", "random"}: + patches.entrances.changeEntrances(rom, patch_data["world_setup"]["entrance_mapping"]) for spot in item_list: if spot.item and spot.item.startswith("*"): spot.item = spot.item[1:] @@ -327,23 +275,22 @@ def generateRom(args, world: "LinksAwakeningWorld"): # There are only 101 player name slots (99 + "The Server" + "another world"), so don't use more than that mw = 100 spot.patch(rom, spot.item, multiworld=mw) - patches.enemies.changeBosses(rom, world_setup.boss_mapping) - patches.enemies.changeMiniBosses(rom, world_setup.miniboss_mapping) + patches.enemies.changeBosses(rom, patch_data["world_setup"]["boss_mapping"]) + patches.enemies.changeMiniBosses(rom, patch_data["world_setup"]["miniboss_mapping"]) if not args.romdebugmode: patches.core.addFrameCounter(rom, len(item_list)) patches.core.warpHome(rom) # Needs to be done after setting the start location. - patches.titleScreen.setRomInfo(rom, world.multi_key, world.multiworld.seed_name, world.ladxr_settings, - world.player_name, world.player) - if world.options.ap_title_screen: + patches.titleScreen.setRomInfo(rom, patch_data) + if options["ap_title_screen"]: patches.titleScreen.setTitleGraphics(rom) patches.endscreen.updateEndScreen(rom) patches.aesthetics.updateSpriteData(rom) if args.doubletrouble: patches.enemies.doubleTrouble(rom) - if world.options.text_shuffle: + if options["text_shuffle"]: excluded_ids = [ # Overworld owl statues 0x1B6, 0x1B7, 0x1B8, 0x1B9, 0x1BA, 0x1BB, 0x1BC, 0x1BD, 0x1BE, 0x22D, @@ -388,6 +335,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): excluded_texts = [ rom.texts[excluded_id] for excluded_id in excluded_ids] buckets = defaultdict(list) # For each ROM bank, shuffle text within the bank + random.seed(patch_data["seed"] + patch_data["player"]) for n, data in enumerate(rom.texts._PointerTable__data): # Don't muck up which text boxes are questions and which are statements if type(data) != int and data and data != b'\xFF' and data not in excluded_texts: @@ -395,20 +343,20 @@ def generateRom(args, world: "LinksAwakeningWorld"): for bucket in buckets.values(): # For each bucket, make a copy and shuffle shuffled = bucket.copy() - world.random.shuffle(shuffled) + random.shuffle(shuffled) # Then put new text in for bucket_idx, (orig_idx, data) in enumerate(bucket): rom.texts[shuffled[bucket_idx][0]] = data - if world.options.trendy_game != TrendyGame.option_normal: + if options["trendy_game"] != Options.TrendyGame.option_normal: # TODO: if 0 or 4, 5, remove inaccurate conveyor tiles room_editor = RoomEditor(rom, 0x2A0) - if world.options.trendy_game == TrendyGame.option_easy: + if options["trendy_game"] == Options.TrendyGame.option_easy: # Set physics flag on all objects for i in range(0, 6): rom.banks[0x4][0x6F1E + i -0x4000] = 0x4 @@ -419,7 +367,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): # Add new conveyor to "push" yoshi (it's only a visual) room_editor.objects.append(Object(5, 3, 0xD0)) - if world.options.trendy_game >= TrendyGame.option_harder: + if options["trendy_game"] >= Options.TrendyGame.option_harder: """ Data_004_76A0:: db $FC, $00, $04, $00, $00 @@ -428,17 +376,18 @@ def generateRom(args, world: "LinksAwakeningWorld"): db $00, $04, $00, $FC, $00 """ speeds = { - TrendyGame.option_harder: (3, 8), - TrendyGame.option_hardest: (3, 8), - TrendyGame.option_impossible: (3, 16), + Options.TrendyGame.option_harder: (3, 8), + Options.TrendyGame.option_hardest: (3, 8), + Options.TrendyGame.option_impossible: (3, 16), } def speed(): - return world.random.randint(*speeds[world.options.trendy_game]) + random.seed(patch_data["seed"] + patch_data["player"]) + return random.randint(*speeds[options["trendy_game"]]) rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A2-0x4000] = speed() rom.banks[0x4][0x76A6-0x4000] = speed() rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed() - if world.options.trendy_game >= TrendyGame.option_hardest: + if options["trendy_game"] >= Options.TrendyGame.option_hardest: rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A3-0x4000] = speed() rom.banks[0x4][0x76A5-0x4000] = speed() @@ -462,11 +411,11 @@ def generateRom(args, world: "LinksAwakeningWorld"): for channel in range(3): color[channel] = color[channel] * 31 // 0xbc - if world.options.warps != Warps.option_vanilla: - patches.core.addWarpImprovements(rom, world.options.warps == Warps.option_improved_additional) + if options["warps"] != Options.Warps.option_vanilla: + patches.core.addWarpImprovements(rom, options["warps"] == Options.Warps.option_improved_additional) - palette = world.options.palette - if palette != Palette.option_normal: + palette = options["palette"] + if palette != Options.Palette.option_normal: ranges = { # Object palettes # Overworld palettes @@ -496,22 +445,22 @@ def generateRom(args, world: "LinksAwakeningWorld"): r,g,b = bin_to_rgb(packed) # 1 bit - if palette == Palette.option_1bit: + if palette == Options.Palette.option_1bit: r &= 0b10000 g &= 0b10000 b &= 0b10000 # 2 bit - elif palette == Palette.option_1bit: + elif palette == Options.Palette.option_1bit: r &= 0b11000 g &= 0b11000 b &= 0b11000 # Invert - elif palette == Palette.option_inverted: + elif palette == Options.Palette.option_inverted: r = 31 - r g = 31 - g b = 31 - b # Pink - elif palette == Palette.option_pink: + elif palette == Options.Palette.option_pink: r = r // 2 r += 16 r = int(r) @@ -520,7 +469,7 @@ def generateRom(args, world: "LinksAwakeningWorld"): b += 16 b = int(b) b = clamp(b, 0, 0x1F) - elif palette == Palette.option_greyscale: + elif palette == Options.Palette.option_greyscale: # gray=int(0.299*r+0.587*g+0.114*b) gray = (r + g + b) // 3 r = g = b = gray @@ -531,10 +480,10 @@ def generateRom(args, world: "LinksAwakeningWorld"): SEED_LOCATION = 0x0134 # Patch over the title - assert(len(world.multi_key) == 12) - rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(world.multi_key)) + assert(len(multi_key) == 12) + rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(multi_key)) for pymod in pymods: pymod.postPatch(rom) - return rom + return rom.save() diff --git a/worlds/ladx/LADXR/hints.py b/worlds/ladx/LADXR/hints.py index aa785488..6f9f3e60 100644 --- a/worlds/ladx/LADXR/hints.py +++ b/worlds/ladx/LADXR/hints.py @@ -1,5 +1,7 @@ from .locations.items import * from .utils import formatText +from BaseClasses import ItemClassification +from ..Locations import LinksAwakeningLocation hint_text_ids = [ @@ -49,14 +51,64 @@ useless_hint = [ ] -def addHints(rom, rnd, hint_generator): +def addHints(rom, rnd, hint_texts): + hint_texts_copy = hint_texts.copy() text_ids = hint_text_ids.copy() rnd.shuffle(text_ids) for text_id in text_ids: - hint = hint_generator() + hint = hint_texts_copy.pop() if not hint: hint = rnd.choice(hints).format(*rnd.choice(useless_hint)) rom.texts[text_id] = formatText(hint) for text_id in range(0x200, 0x20C, 2): rom.texts[text_id] = formatText("Read this book?", ask="YES NO") + + +def generate_hint_texts(world): + JUNK_HINT = 0.33 + RANDOM_HINT= 0.66 + # USEFUL_HINT = 1.0 + # TODO: filter events, filter unshuffled keys + all_items = world.multiworld.get_items() + our_items = [item for item in all_items + if item.player == world.player + and item.location + and item.code is not None + and item.location.show_in_spoiler] + our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification] + hint_texts = [] + def gen_hint(): + chance = world.random.uniform(0, 1) + if chance < JUNK_HINT: + return None + elif chance < RANDOM_HINT: + location = world.random.choice(our_items).location + else: # USEFUL_HINT + location = world.random.choice(our_useful_items).location + + if location.item.player == world.player: + name = "Your" + else: + name = f"{world.multiworld.player_name[location.item.player]}'s" + # filter out { and } since they cause issues with string.format later on + name = name.replace("{", "").replace("}", "") + + if isinstance(location, LinksAwakeningLocation): + location_name = location.ladxr_item.metadata.name + else: + location_name = location.name + + hint = f"{name} {location.item} is at {location_name}" + if location.player != world.player: + # filter out { and } since they cause issues with string.format later on + player_name = world.multiworld.player_name[location.player].replace("{", "").replace("}", "") + hint += f" in {player_name}'s world" + + # Cap hint size at 85 + # Realistically we could go bigger but let's be safe instead + hint = hint[:85] + return hint + for _ in hint_text_ids: + hint_texts.append(gen_hint()) + return hint_texts diff --git a/worlds/ladx/LADXR/patches/core.py b/worlds/ladx/LADXR/patches/core.py index d9fcd62e..10e85f9d 100644 --- a/worlds/ladx/LADXR/patches/core.py +++ b/worlds/ladx/LADXR/patches/core.py @@ -541,7 +541,7 @@ OAMData: rom.banks[0x38][0x1400+n*0x20:0x1410+n*0x20] = utils.createTileData(gfx_high) rom.banks[0x38][0x1410+n*0x20:0x1420+n*0x20] = utils.createTileData(gfx_low) -def addBootsControls(rom, boots_controls: BootsControls): +def addBootsControls(rom, boots_controls: int): if boots_controls == BootsControls.option_vanilla: return consts = { @@ -578,7 +578,7 @@ def addBootsControls(rom, boots_controls: BootsControls): jr z, .yesBoots ld a, [hl] """ - }[boots_controls.value] + }[boots_controls] # The new code fits exactly within Nintendo's poorly space optimzied code while having more features boots_code = assembler.ASM(""" diff --git a/worlds/ladx/LADXR/patches/enemies.py b/worlds/ladx/LADXR/patches/enemies.py index f5e1df13..29322918 100644 --- a/worlds/ladx/LADXR/patches/enemies.py +++ b/worlds/ladx/LADXR/patches/enemies.py @@ -42,7 +42,7 @@ MINIBOSS_ENTITIES = { "ARMOS_KNIGHT": [(4, 3, 0x88)], } MINIBOSS_ROOMS = { - 0: 0x111, 1: 0x128, 2: 0x145, 3: 0x164, 4: 0x193, 5: 0x1C5, 6: 0x228, 7: 0x23F, + "0": 0x111, "1": 0x128, "2": 0x145, "3": 0x164, "4": 0x193, "5": 0x1C5, "6": 0x228, "7": 0x23F, "c1": 0x30C, "c2": 0x303, "moblin_cave": 0x2E1, "armos_temple": 0x27F, diff --git a/worlds/ladx/LADXR/patches/titleScreen.py b/worlds/ladx/LADXR/patches/titleScreen.py index 3a4dade2..d986a570 100644 --- a/worlds/ladx/LADXR/patches/titleScreen.py +++ b/worlds/ladx/LADXR/patches/titleScreen.py @@ -1,7 +1,6 @@ from ..backgroundEditor import BackgroundEditor from .aesthetics import rgb_to_bin, bin_to_rgb, prepatch import copy -import pkgutil CHAR_MAP = {'z': 0x3E, '-': 0x3F, '.': 0x39, ':': 0x42, '?': 0x3C, '!': 0x3D} def _encode(s): @@ -18,17 +17,18 @@ def _encode(s): return result -def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): +def setRomInfo(rom, patch_data): + seed_name = patch_data["seed_name"] try: - seednr = int(seed, 16) + seednr = int(patch_data["seed"], 16) except: import hashlib - seednr = int(hashlib.md5(seed).hexdigest(), 16) + seednr = int(hashlib.md5(str(patch_data["seed"]).encode()).hexdigest(), 16) - if settings.race: + if patch_data["is_race"]: seed_name = "Race" - if isinstance(settings.race, str): - seed_name += " " + settings.race + if isinstance(patch_data["is_race"], str): + seed_name += " " + patch_data["is_race"] rom.patch(0x00, 0x07, "00", "01") else: rom.patch(0x00, 0x07, "00", "52") @@ -37,7 +37,7 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): #line_2_hex = _encode(seed[16:]) BASE_DRAWING_AREA = 0x98a0 LINE_WIDTH = 0x20 - player_id_text = f"Player {player_id}:" + player_id_text = f"Player {patch_data['player']}:" for n in (3, 4): be = BackgroundEditor(rom, n) ba = BackgroundEditor(rom, n, attributes=True) @@ -45,9 +45,9 @@ def setRomInfo(rom, seed, seed_name, settings, player_name, player_id): for n, v in enumerate(_encode(player_id_text)): be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = v ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 5 + 2 + n] = 0x00 - for n, v in enumerate(_encode(player_name)): - be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = v - ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(player_name) + n] = 0x00 + for n, v in enumerate(_encode(patch_data['player_name'])): + be.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = v + ba.tiles[BASE_DRAWING_AREA + LINE_WIDTH * 6 + 0x13 - len(patch_data['player_name']) + n] = 0x00 for n, v in enumerate(line_1_hex): be.tiles[0x9a20 + n] = v ba.tiles[0x9a20 + n] = 0x00 diff --git a/worlds/ladx/LADXR/patches/tradeSequence.py b/worlds/ladx/LADXR/patches/tradeSequence.py index 0eb46ae2..ef6f635d 100644 --- a/worlds/ladx/LADXR/patches/tradeSequence.py +++ b/worlds/ladx/LADXR/patches/tradeSequence.py @@ -387,7 +387,7 @@ def patchVarious(rom, settings): # Boomerang trade guy # if settings.boomerang not in {'trade', 'gift'} or settings.overworld in {'normal', 'nodungeons'}: - if settings.tradequest: + if settings["tradequest"]: # Update magnifier checks rom.patch(0x19, 0x05EC, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njp nz, $7E61"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njp z, $7E61")) # show the guy rom.patch(0x00, 0x3199, ASM("ld a, [wTradeSequenceItem]\ncp $0E\njr nz, $06"), ASM("ld a, [wTradeSequenceItem2]\nand $20\njr z, $06")) # load the proper room layout diff --git a/worlds/ladx/LADXR/rom.py b/worlds/ladx/LADXR/rom.py index 21969f4a..54d8f029 100644 --- a/worlds/ladx/LADXR/rom.py +++ b/worlds/ladx/LADXR/rom.py @@ -7,9 +7,7 @@ h2b = binascii.unhexlify class ROM: - def __init__(self, filename, patches=None): - data = open(Utils.user_path(filename), "rb").read() - + def __init__(self, data, patches=None): if patches: for patch in patches: data = bsdiff4.patch(data, patch) @@ -64,18 +62,10 @@ class ROM: self.banks[0][0x14E] = checksum >> 8 self.banks[0][0x14F] = checksum & 0xFF - def save(self, file, *, name=None): + def save(self): # don't pass the name to fixHeader self.fixHeader() - if isinstance(file, str): - f = open(file, "wb") - for bank in self.banks: - f.write(bank) - f.close() - print("Saved:", file) - else: - for bank in self.banks: - file.write(bank) + return b"".join(self.banks) def readHexSeed(self): return self.banks[0x3E][0x2F00:0x2F10].hex().upper() diff --git a/worlds/ladx/LADXR/romTables.py b/worlds/ladx/LADXR/romTables.py index 31924436..51acacc3 100644 --- a/worlds/ladx/LADXR/romTables.py +++ b/worlds/ladx/LADXR/romTables.py @@ -181,8 +181,8 @@ class IndoorRoomSpriteData(PointerTable): class ROMWithTables(ROM): - def __init__(self, filename, patches=None): - super().__init__(filename, patches) + def __init__(self, data, patches=None): + super().__init__(data, patches) # Ability to patch any text in the game with different text self.texts = Texts(self) @@ -203,7 +203,7 @@ class ROMWithTables(ROM): self.itemNames = {} - def save(self, filename, *, name=None): + def save(self): # Assert special handling of bank 9 expansion is fine for i in range(0x3d42, 0x4000): assert self.banks[9][i] == 0, self.banks[9][i] @@ -221,4 +221,4 @@ class ROMWithTables(ROM): self.room_sprite_data_indoor.store(self) self.background_tiles.store(self) self.background_attributes.store(self) - super().save(filename, name=name) + return super().save() diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 7ea7df36..8abfb0fb 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -425,46 +425,11 @@ class TrendyGame(Choice): default = option_normal -class GfxMod(FreeText, LADXROption): +class GfxMod(DefaultOffToggle): """ - Sets the sprite for link, among other things - The option should be the same name as a with sprite (and optional name) file in data/sprites/ladx + If enabled, the patcher will prompt the user for a modification file to change sprites in the game and optionally some text. """ display_name = "GFX Modification" - ladxr_name = "gfxmod" - normal = '' - default = 'Link' - - __spriteDir: str = Utils.local_path(os.path.join('data', 'sprites', 'ladx')) - __spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list) - - extensions = [".bin", ".bdiff", ".png", ".bmp"] - - for file in os.listdir(__spriteDir): - name, extension = os.path.splitext(file) - if extension in extensions: - __spriteFiles[name].append(file) - - def __init__(self, value: str): - super().__init__(value) - - def verify(self, world, player_name: str, plando_options) -> None: - if self.value == "Link" or self.value in GfxMod.__spriteFiles: - return - raise Exception( - f"LADX Sprite '{self.value}' not found. Possible sprites are: {['Link'] + list(GfxMod.__spriteFiles.keys())}") - - def to_ladxr_option(self, all_options): - if self.value == -1 or self.value == "Link": - return None, None - - assert self.value in GfxMod.__spriteFiles - - if len(GfxMod.__spriteFiles[self.value]) > 1: - logger.warning( - f"{self.value} does not uniquely identify a file. Possible matches: {GfxMod.__spriteFiles[self.value]}. Using {GfxMod.__spriteFiles[self.value][0]}") - - return self.ladxr_name, self.__spriteDir + "/" + GfxMod.__spriteFiles[self.value][0] class Palette(Choice): diff --git a/worlds/ladx/Rom.py b/worlds/ladx/Rom.py index 8ae1fac0..969215a5 100644 --- a/worlds/ladx/Rom.py +++ b/worlds/ladx/Rom.py @@ -3,19 +3,112 @@ import worlds.Files import hashlib import Utils import os +import json +import pkgutil +import bsdiff4 +import binascii +import pickle +from typing import TYPE_CHECKING +from .Common import * +from .LADXR import generator +from .LADXR.main import get_parser +from .LADXR.hints import generate_hint_texts +from .LADXR.locations.keyLocation import KeyLocation LADX_HASH = "07c211479386825042efb4ad31bb525f" -class LADXDeltaPatch(worlds.Files.APDeltaPatch): +if TYPE_CHECKING: + from . import LinksAwakeningWorld + + +class LADXPatchExtensions(worlds.Files.APPatchExtension): + game = LINKS_AWAKENING + + @staticmethod + def generate_rom(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes: + patch_data = json.loads(caller.get_file(data_file).decode("utf-8")) + # TODO local option overrides + rom_name = get_base_rom_path() + out_name = f"{patch_data['out_base']}{caller.result_file_ending}" + parser = get_parser() + args = parser.parse_args([rom_name, "-o", out_name, "--dump"]) + return generator.generateRom(rom, args, patch_data) + + @staticmethod + def patch_title_screen(caller: worlds.Files.APProcedurePatch, rom: bytes, data_file: str) -> bytes: + patch_data = json.loads(caller.get_file(data_file).decode("utf-8")) + if patch_data["options"]["ap_title_screen"]: + return bsdiff4.patch(rom, pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4")) + return rom + +class LADXProcedurePatch(worlds.Files.APProcedurePatch): hash = LADX_HASH - game = "Links Awakening DX" - patch_file_ending = ".apladx" + game = LINKS_AWAKENING + patch_file_ending: str = ".apladx" result_file_ending: str = ".gbc" + procedure = [ + ("generate_rom", ["data.json"]), + ("patch_title_screen", ["data.json"]) + ] + @classmethod def get_source_data(cls) -> bytes: return get_base_rom_bytes() +def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch): + item_list = pickle.dumps([item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)]) + data_dict = { + "out_base": world.multiworld.get_out_file_name_base(patch.player), + "is_race": world.multiworld.is_race, + "seed": world.multiworld.seed, + "seed_name": world.multiworld.seed_name, + "multi_key": binascii.hexlify(world.multi_key).decode(), + "player": patch.player, + "player_name": patch.player_name, + "other_player_names": list(world.multiworld.player_name.values()), + "item_list": binascii.hexlify(item_list).decode(), + "hint_texts": generate_hint_texts(world), + "world_setup": { + "goal": world.ladxr_logic.world_setup.goal, + "bingo_goals": world.ladxr_logic.world_setup.bingo_goals, + "multichest": world.ladxr_logic.world_setup.multichest, + "entrance_mapping": world.ladxr_logic.world_setup.entrance_mapping, + "boss_mapping": world.ladxr_logic.world_setup.boss_mapping, + "miniboss_mapping": world.ladxr_logic.world_setup.miniboss_mapping, + }, + "options": world.options.as_dict( + "tradequest", + "rooster", + "experimental_dungeon_shuffle", + "experimental_entrance_shuffle", + "goal", + "instrument_count", + "link_palette", + "warps", + "trendy_game", + "gfxmod", + "palette", + "text_shuffle", + "shuffle_nightmare_keys", + "shuffle_small_keys", + "music", + "music_change_condition", + "nag_messages", + "ap_title_screen", + "boots_controls", + # "stealing", + "quickswap", + "hard_mode", + "low_hp_beep", + "text_mode", + "no_flash", + "overworld", + ), + } + patch.write_file("data.json", json.dumps(data_dict).encode('utf-8')) + + def get_base_rom_bytes(file_name: str = "") -> bytes: base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None) if not base_rom_bytes: diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index b1b033e0..f17b602e 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -1,16 +1,12 @@ import binascii import dataclasses import os -import pkgutil -import tempfile import typing import logging import re -import bsdiff4 - import settings -from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial, MultiWorld +from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial from Fill import fill_restrictive from worlds.AutoWorld import WebWorld, World from .Common import * @@ -18,19 +14,17 @@ from . import ItemIconGuessing from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData, ladxr_item_to_la_item_name, links_awakening_items, links_awakening_items_by_name, links_awakening_item_name_groups) -from .LADXR import generator from .LADXR.itempool import ItemPool as LADXRItemPool from .LADXR.locations.constants import CHEST_ITEMS from .LADXR.locations.instrument import Instrument from .LADXR.logic import Logic as LADXRLogic -from .LADXR.main import get_parser from .LADXR.settings import Settings as LADXRSettings from .LADXR.worldSetup import WorldSetup as LADXRWorldSetup from .Locations import (LinksAwakeningLocation, LinksAwakeningRegion, create_regions_from_ladxr, get_locations_to_id, links_awakening_location_name_groups) from .Options import DungeonItemShuffle, ShuffleInstruments, LinksAwakeningOptions, ladx_option_groups -from .Rom import LADXDeltaPatch, get_base_rom_path +from .Rom import LADXProcedurePatch, write_patch_data DEVELOPER_MODE = False @@ -40,7 +34,7 @@ class LinksAwakeningSettings(settings.Group): """File name of the Link's Awakening DX rom""" copy_to = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc" description = "LADX ROM File" - md5s = [LADXDeltaPatch.hash] + md5s = [LADXProcedurePatch.hash] class RomStart(str): """ @@ -57,8 +51,16 @@ class LinksAwakeningSettings(settings.Group): class DisplayMsgs(settings.Bool): """Display message inside of Bizhawk""" + class GfxModFile(settings.FilePath): + """ + Gfxmod file, get it from upstream: https://github.com/daid/LADXR/tree/master/gfx + Only .bin or .bdiff files + The same directory will be checked for a matching text modification file + """ + rom_file: RomFile = RomFile(RomFile.copy_to) rom_start: typing.Union[RomStart, bool] = True + gfx_mod_file: GfxModFile = GfxModFile() class LinksAwakeningWebWorld(WebWorld): tutorials = [Tutorial( @@ -179,10 +181,10 @@ class LinksAwakeningWorld(World): assert(start) - menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld) + menu_region = LinksAwakeningRegion("Menu", None, "Menu", self.player, self.multiworld) menu_region.exits = [Entrance(self.player, "Start Game", menu_region)] menu_region.exits[0].connect(start) - + self.multiworld.regions.append(menu_region) # Place RAFT, other access events @@ -190,14 +192,14 @@ class LinksAwakeningWorld(World): for loc in region.locations: if loc.address is None: loc.place_locked_item(self.create_event(loc.ladxr_item.event)) - + # Connect Windfish -> Victory windfish = self.multiworld.get_region("Windfish", self.player) l = Location(self.player, "Windfish", parent=windfish) windfish.locations = [l] - + l.place_locked_item(self.create_event("An Alarm Clock")) - + self.multiworld.completion_condition[self.player] = lambda state: state.has("An Alarm Clock", player=self.player) def create_item(self, item_name: str): @@ -279,8 +281,8 @@ class LinksAwakeningWorld(World): event_location = Location(self.player, "Can Play Trendy Game", parent=trendy_region) trendy_region.locations.insert(0, event_location) event_location.place_locked_item(self.create_event("Can Play Trendy Game")) - - self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []] + + self.dungeon_locations_by_dungeon = [[], [], [], [], [], [], [], [], []] for r in self.multiworld.get_regions(self.player): # Set aside dungeon locations if r.dungeon_index: @@ -354,7 +356,7 @@ class LinksAwakeningWorld(World): # set containing the list of all possible dungeon locations for the player all_dungeon_locs = set() - + # Do dungeon specific things for dungeon_index in range(0, 9): # set up allow-list for dungeon specific items @@ -367,7 +369,7 @@ class LinksAwakeningWorld(World): # ...also set the rules for the dungeon for location in locs: orig_rule = location.item_rule - # If an item is about to be placed on a dungeon location, it can go there iff + # If an item is about to be placed on a dungeon location, it can go there iff # 1. it fits the general rules for that location (probably 'return True' for most places) # 2. Either # 2a. it's not a restricted dungeon item @@ -421,7 +423,7 @@ class LinksAwakeningWorld(World): partial_all_state.sweep_for_advancements() fill_restrictive(self.multiworld, partial_all_state, all_dungeon_locs_to_fill, all_dungeon_items_to_fill, lock=True, single_player_placement=True, allow_partial=False) - + name_cache = {} # Tries to associate an icon from another game with an icon we have @@ -458,22 +460,16 @@ class LinksAwakeningWorld(World): for name in possibles: if name in self.name_cache: return self.name_cache[name] - + return "TRADING_ITEM_LETTER" - @classmethod - def stage_assert_generate(cls, multiworld: MultiWorld): - rom_file = get_base_rom_path() - if not os.path.exists(rom_file): - raise FileNotFoundError(rom_file) - def generate_output(self, output_directory: str): # copy items back to locations for r in self.multiworld.get_regions(self.player): for loc in r.locations: if isinstance(loc, LinksAwakeningLocation): assert(loc.item) - + # If we're a links awakening item, just use the item if isinstance(loc.item, LinksAwakeningItem): loc.ladxr_item.item = loc.item.item_data.ladxr_id @@ -499,31 +495,13 @@ class LinksAwakeningWorld(World): # Kind of kludge, make it possible for the location to differentiate between local and remote items loc.ladxr_item.location_owner = self.player - rom_name = Rom.get_base_rom_path() - out_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.player_name}.gbc" - out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.gbc") + + patch = LADXProcedurePatch(player=self.player, player_name=self.player_name) + write_patch_data(self, patch) + out_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}" + f"{patch.patch_file_ending}") - parser = get_parser() - args = parser.parse_args([rom_name, "-o", out_name, "--dump"]) - - rom = generator.generateRom(args, self) - - with open(out_path, "wb") as handle: - rom.save(handle, name="LADXR") - - # Write title screen after everything else is done - full gfxmods may stomp over the egg tiles - if self.options.ap_title_screen: - with tempfile.NamedTemporaryFile(delete=False) as title_patch: - title_patch.write(pkgutil.get_data(__name__, "LADXR/patches/title_screen.bdiff4")) - - bsdiff4.file_patch_inplace(out_path, title_patch.name) - os.unlink(title_patch.name) - - patch = LADXDeltaPatch(os.path.splitext(out_path)[0]+LADXDeltaPatch.patch_file_ending, player=self.player, - player_name=self.player_name, patched_path=out_path) - patch.write() - if not DEVELOPER_MODE: - os.unlink(out_path) + patch.write(out_path) def generate_multi_key(self): return bytearray(self.random.getrandbits(8) for _ in range(10)) + self.player.to_bytes(2, 'big')