diff --git a/.gitignore b/.gitignore index 4ac9fd67..65c35fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ README.html .vs/ *multidata *multisave +EnemizerCLI/ diff --git a/EntranceRandomizer.py b/EntranceRandomizer.py index 55bf1d58..da16e2a6 100755 --- a/EntranceRandomizer.py +++ b/EntranceRandomizer.py @@ -207,14 +207,19 @@ def start(): ''') parser.add_argument('--suppress_rom', help='Do not create an output rom file.', action='store_true') parser.add_argument('--gui', help='Launch the GUI', action='store_true') - # Deliberately not documented, only useful for vt site integration right now: - parser.add_argument('--shufflebosses', help=argparse.SUPPRESS, default='none', const='none', nargs='?', choices=['none', 'basic', 'normal', 'chaos']) parser.add_argument('--jsonout', action='store_true', help='''\ Output .json patch to stdout instead of a patched rom. Used for VT site integration, do not use otherwise. ''') - parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255)) parser.add_argument('--skip_playthrough', action='store_true', default=False) + parser.add_argument('--enemizercli', default='') + parser.add_argument('--shufflebosses', default='none', choices=['none', 'basic', 'normal', 'chaos']) + parser.add_argument('--shuffleenemies', default=False, action='store_true') + parser.add_argument('--enemy_health', default='default', choices=['default', 'easy', 'normal', 'hard', 'expert']) + parser.add_argument('--enemy_damage', default='default', choices=['default', 'shuffled', 'chaos']) + parser.add_argument('--shufflepalette', default=False, action='store_true') + parser.add_argument('--shufflepots', default=False, action='store_true') + parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255)) parser.add_argument('--outputpath') args = parser.parse_args() diff --git a/Gui.py b/Gui.py index 50f60756..be544aa8 100755 --- a/Gui.py +++ b/Gui.py @@ -5,7 +5,7 @@ import json import random import os import shutil -from tkinter import Checkbutton, OptionMenu, Toplevel, LabelFrame, PhotoImage, Tk, LEFT, RIGHT, BOTTOM, TOP, StringVar, IntVar, Frame, Label, W, E, X, Entry, Spinbox, Button, filedialog, messagebox, ttk +from tkinter import Checkbutton, OptionMenu, Toplevel, LabelFrame, PhotoImage, Tk, LEFT, RIGHT, BOTTOM, TOP, StringVar, IntVar, Frame, Label, W, E, X, BOTH, Entry, Spinbox, Button, filedialog, messagebox, ttk from urllib.parse import urlparse from urllib.request import urlopen @@ -242,12 +242,67 @@ def guiMain(args=None): heartcolorFrame.pack(expand=True, anchor=E) fastMenuFrame.pack(expand=True, anchor=E) - bottomFrame = Frame(randomizerWindow) + enemizerFrame = LabelFrame(randomizerWindow, text="Enemizer", padx=5, pady=5) + enemizerFrame.columnconfigure(0, weight=1) + enemizerFrame.columnconfigure(1, weight=1) + enemizerFrame.columnconfigure(2, weight=1) + + enemizerPathFrame = Frame(enemizerFrame) + enemizerPathFrame.grid(row=0, column=0, columnspan=3, sticky=W) + enemizerCLIlabel = Label(enemizerPathFrame, text="EnemizerCLI path: ") + enemizerCLIlabel.pack(side=LEFT) + enemizerCLIpathVar = StringVar() + enemizerCLIpathEntry = Entry(enemizerPathFrame, textvariable=enemizerCLIpathVar, width=80) + enemizerCLIpathEntry.pack(side=LEFT) + def EnemizerSelectPath(): + path = filedialog.askopenfilename(filetypes=[("EnemizerCLI executable", "*EnemizerCLI*")]) + if path: + enemizerCLIpathVar.set(path) + enemizerCLIbrowseButton = Button(enemizerPathFrame, text='...', command=EnemizerSelectPath) + enemizerCLIbrowseButton.pack(side=LEFT) + + enemyShuffleVar = IntVar() + enemyShuffleButton = Checkbutton(enemizerFrame, text="Enemy shuffle", variable=enemyShuffleVar) + enemyShuffleButton.grid(row=1, column=0) + paletteShuffleVar = IntVar() + paletteShuffleButton = Checkbutton(enemizerFrame, text="Palette shuffle", variable=paletteShuffleVar) + paletteShuffleButton.grid(row=1, column=1) + potShuffleVar = IntVar() + potShuffleButton = Checkbutton(enemizerFrame, text="Pot shuffle", variable=potShuffleVar) + potShuffleButton.grid(row=1, column=2) + + enemizerBossFrame = Frame(enemizerFrame) + enemizerBossFrame.grid(row=2, column=0) + enemizerBossLabel = Label(enemizerBossFrame, text='Boss shuffle') + enemizerBossLabel.pack(side=LEFT) + enemizerBossVar = StringVar() + enemizerBossVar.set('none') + enemizerBossOption = OptionMenu(enemizerBossFrame, enemizerBossVar, 'none', 'basic', 'normal', 'chaos') + enemizerBossOption.pack(side=LEFT) + + enemizerDamageFrame = Frame(enemizerFrame) + enemizerDamageFrame.grid(row=2, column=1) + enemizerDamageLabel = Label(enemizerDamageFrame, text='Enemy damage') + enemizerDamageLabel.pack(side=LEFT) + enemizerDamageVar = StringVar() + enemizerDamageVar.set('default') + enemizerDamageOption = OptionMenu(enemizerDamageFrame, enemizerDamageVar, 'default', 'shuffled', 'chaos') + enemizerDamageOption.pack(side=LEFT) + + enemizerHealthFrame = Frame(enemizerFrame) + enemizerHealthFrame.grid(row=2, column=2) + enemizerHealthLabel = Label(enemizerHealthFrame, text='Enemy health') + enemizerHealthLabel.pack(side=LEFT) + enemizerHealthVar = StringVar() + enemizerHealthVar.set('default') + enemizerHealthOption = OptionMenu(enemizerHealthFrame, enemizerHealthVar, 'default', 'easy', 'normal', 'hard', 'expert') + enemizerHealthOption.pack(side=LEFT) + + bottomFrame = Frame(randomizerWindow, pady=5) worldLabel = Label(bottomFrame, text='Worlds') worldVar = StringVar() worldSpinbox = Spinbox(bottomFrame, from_=1, to=100, width=5, textvariable=worldVar) - seedLabel = Label(bottomFrame, text='Seed #') seedVar = StringVar() seedEntry = Entry(bottomFrame, width=15, textvariable=seedVar) @@ -281,6 +336,13 @@ def guiMain(args=None): guiargs.disablemusic = bool(disableMusicVar.get()) guiargs.shuffleganon = bool(shuffleGanonVar.get()) guiargs.hints = bool(hintsVar.get()) + guiargs.enemizercli = enemizerCLIpathVar.get() + guiargs.shufflebosses = enemizerBossVar.get() + guiargs.shuffleenemies = bool(enemyShuffleVar.get()) + guiargs.enemy_health = enemizerHealthVar.get() + guiargs.enemy_damage = enemizerDamageVar.get() + guiargs.shufflepalette = bool(paletteShuffleVar.get()) + guiargs.shufflepots = bool(potShuffleVar.get()) guiargs.custom = bool(customVar.get()) guiargs.customitemarray = [int(bowVar.get()), int(silverarrowVar.get()), int(boomerangVar.get()), int(magicboomerangVar.get()), int(hookshotVar.get()), int(mushroomVar.get()), int(magicpowderVar.get()), int(firerodVar.get()), int(icerodVar.get()), int(bombosVar.get()), int(etherVar.get()), int(quakeVar.get()), int(lampVar.get()), int(hammerVar.get()), int(shovelVar.get()), int(fluteVar.get()), int(bugnetVar.get()), @@ -291,7 +353,6 @@ def guiMain(args=None): int(arrow1Var.get()), int(arrow10Var.get()), int(bomb1Var.get()), int(bomb3Var.get()), int(rupee1Var.get()), int(rupee5Var.get()), int(rupee20Var.get()), int(rupee50Var.get()), int(rupee100Var.get()), int(rupee300Var.get()), int(rupoorVar.get()), int(blueclockVar.get()), int(greenclockVar.get()), int(redclockVar.get()), int(triforcepieceVar.get()), int(triforcecountVar.get()), int(triforceVar.get()), int(rupoorcostVar.get()), int(universalkeyVar.get())] - guiargs.shufflebosses = None guiargs.rom = romVar.get() guiargs.jsonout = None guiargs.sprite = sprite @@ -326,6 +387,7 @@ def guiMain(args=None): rightHalfFrame.pack(side=RIGHT) topFrame.pack(side=TOP) bottomFrame.pack(side=BOTTOM) + enemizerFrame.pack(side=BOTTOM, fill=BOTH) # Adjuster Controls diff --git a/Main.py b/Main.py index 413c1a0e..fe06a64d 100644 --- a/Main.py +++ b/Main.py @@ -3,13 +3,14 @@ import copy from itertools import zip_longest import json import logging +import os import random import time from BaseClasses import World, CollectionState, Item, Region, Location, Shop from Regions import create_regions, mark_light_world_regions from EntranceShuffle import link_entrances -from Rom import patch_rom, Sprite, LocalRom, JsonRom +from Rom import patch_rom, get_enemizer_patch, apply_rom_settings, Sprite, LocalRom, JsonRom from Rules import set_rules from Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive from Fill import distribute_items_cutoff, distribute_items_staleness, distribute_items_restrictive, flood_items, balance_multiworld_progression @@ -124,6 +125,8 @@ def main(args, seed=None): outfilebase = 'ER_%s_%s-%s-%s%s_%s-%s%s%s%s%s_%s' % (world.logic, world.difficulty, world.mode, world.goal, "" if world.timer in ['none', 'display'] else "-" + world.timer, world.shuffle, world.algorithm, "-keysanity" if world.keysanity else "", "-retro" if world.retro else "", "-prog_" + world.progressive if world.progressive in ['off', 'random'] else "", "-nohints" if not world.hints else "", world.seed) + use_enemizer = args.enemizercli and (args.shufflebosses != 'none' or args.shuffleenemies or args.enemy_health != 'default' or args.enemy_health != 'default' or args.enemy_damage or args.shufflepalette or args.shufflepots) + jsonout = {} if not args.suppress_rom: if world.players > 1: @@ -131,17 +134,32 @@ def main(args, seed=None): else: player = 1 + local_rom = None if args.jsonout: rom = JsonRom() else: - rom = LocalRom(args.rom) - patch_rom(world, player, rom, bytearray(logic_hash), args.heartbeep, args.heartcolor, sprite, player_names) + if use_enemizer: + local_rom = LocalRom(args.rom) + rom = JsonRom() + else: + rom = LocalRom(args.rom) + + patch_rom(world, player, rom, bytearray(logic_hash)) + + enemizer_patch = [] + if use_enemizer: + enemizer_patch = get_enemizer_patch(world, player, rom, args.rom, args.enemizercli, args.shuffleenemies, args.enemy_health, args.enemy_damage, args.shufflepalette, args.shufflepots) if args.jsonout: jsonout['patch'] = rom.patches - + if use_enemizer: + jsonout['enemizer' % player] = enemizer_patch else: - apply_rom_settings(rom, args.heartbeep, args.heartcolor, world.quickswap, world.fastmenu, world.disable_music, sprite, player_names) + if use_enemizer: + local_rom.patch_enemizer(rom.patches, os.path.join(os.path.dirname(args.enemizercli), "enemizerBasePatch.json"), enemizer_patch) + rom = local_rom + + apply_rom_settings(rom, args.heartbeep, args.heartcolor, world.quickswap, world.fastmenu, world.disable_music, sprite) rom.write_to_file(output_path('%s.sfc' % outfilebase)) if args.create_spoiler and not args.jsonout: diff --git a/Rom.py b/Rom.py index c439f07d..61105b5b 100644 --- a/Rom.py +++ b/Rom.py @@ -4,6 +4,7 @@ import hashlib import logging import os import struct +import subprocess import random from BaseClasses import ShopType, Region, Location, Item @@ -11,7 +12,7 @@ from Dungeons import dungeon_music_addresses from Text import MultiByteTextMapper, text_addresses, Credits, TextTable from Text import Uncle_texts, Ganon1_texts, TavernMan_texts, Sahasrahla2_texts, Triforce_texts, Blind_texts, BombShop2_texts, junk_texts from Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts, Blacksmiths_texts, DeathMountain_texts, LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names -from Utils import local_path, int16_as_bytes, int32_as_bytes +from Utils import output_path, local_path, int16_as_bytes, int32_as_bytes from Items import ItemFactory, item_table @@ -84,7 +85,7 @@ class LocalRom(object): logging.getLogger('').warning('Supplied Base Rom does not match known MD5 for JAP(1.0) release. Will try to patch anyway.') # extend to 2MB - self.buffer.extend(bytearray([0x00] * (2097152 - len(self.buffer)))) + self.buffer.extend(bytearray([0x00] * (0x200000 - len(self.buffer)))) # load randomizer patches with open(local_path('data/base2current.json'), 'r') as stream: @@ -100,6 +101,24 @@ class LocalRom(object): if RANDOMIZERBASEHASH != patchedmd5.hexdigest(): raise RuntimeError('Provided Base Rom unsuitable for patching. Please provide a JAP(1.0) "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" rom to use as a base.') + def patch_enemizer(self, rando_patch, base_enemizer_patch_path, enemizer_patch): + # extend to 4MB + self.buffer.extend(bytearray([0x00] * (0x400000 - len(self.buffer)))) + + # apply randomizer patches + for address, values in rando_patch.items(): + self.write_bytes(int(address), values) + + # load base enemizer patches + with open(base_enemizer_patch_path, 'r') as f: + base_enemizer_patch = json.load(f) + for patch in base_enemizer_patch: + self.write_bytes(patch["address"], patch["patchData"]) + + # apply enemizer patches + for patch in enemizer_patch: + self.write_bytes(patch["address"], patch["patchData"]) + def write_crc(self): crc = (sum(self.buffer[:0x7FDC] + self.buffer[0x7FE0:]) + 0x01FE) & 0xFFFF inv = crc ^ 0xFFFF @@ -117,6 +136,124 @@ def read_rom(stream): buffer = buffer[0x200:] return buffer +def get_enemizer_patch(world, player, rom, baserom_path, enemizercli, shuffleenemies, enemy_health, enemy_damage, shufflepalette, shufflepots): + baserom_path = os.path.abspath(baserom_path) + basepatch_path = os.path.abspath(local_path('data/base2current.json')) + randopatch_path = os.path.abspath(output_path('enemizer_randopatch.json')) + options_path = os.path.abspath(output_path('enemizer_options.json')) + enemizer_output_path = os.path.abspath(output_path('enemizer_output.json')) + + # write options file for enemizer + options = { + 'RandomizeEnemies': shuffleenemies, + 'RandomizeEnemiesType': 3, + 'RandomizeBushEnemyChance': True, + 'RandomizeEnemyHealthRange': enemy_health != 'default', + 'RandomizeEnemyHealthType': {'default': 0, 'easy': 0, 'normal': 1, 'hard': 2, 'expert': 3}[enemy_health], + 'OHKO': False, + 'RandomizeEnemyDamage': enemy_damage != 'default', + 'AllowEnemyZeroDamage': True, + 'ShuffleEnemyDamageGroups': enemy_damage != 'default', + 'EnemyDamageChaosMode': enemy_damage == 'chaos', + 'EasyModeEscape': False, + 'EnemiesAbsorbable': False, + 'AbsorbableSpawnRate': 10, + 'AbsorbableTypes': { + 'FullMagic': True, 'SmallMagic': True, 'Bomb_1': True, 'BlueRupee': True, 'Heart': True, 'BigKey': True, 'Key': True, + 'Fairy': True, 'Arrow_10': True, 'Arrow_5': True, 'Bomb_8': True, 'Bomb_4': True, 'GreenRupee': True, 'RedRupee': True + }, + 'BossMadness': False, + 'RandomizeBosses': True, + 'RandomizeBossesType': 0, + 'RandomizeBossHealth': False, + 'RandomizeBossHealthMinAmount': 0, + 'RandomizeBossHealthMaxAmount': 300, + 'RandomizeBossDamage': False, + 'RandomizeBossDamageMinAmount': 0, + 'RandomizeBossDamageMaxAmount': 200, + 'RandomizeBossBehavior': False, + 'RandomizeDungeonPalettes': shufflepalette, + 'SetBlackoutMode': False, + 'RandomizeOverworldPalettes': shufflepalette, + 'RandomizeSpritePalettes': shufflepalette, + 'SetAdvancedSpritePalettes': False, + 'PukeMode': False, + 'NegativeMode': False, + 'GrayscaleMode': False, + 'GenerateSpoilers': False, + 'RandomizeLinkSpritePalette': False, + 'RandomizePots': shufflepots, + 'ShuffleMusic': False, + 'BootlegMagic': True, + 'CustomBosses': False, + 'AndyMode': False, + 'HeartBeepSpeed': 0, + 'AlternateGfx': False, + 'ShieldGraphics': "shield_gfx/normal.gfx", + 'SwordGraphics': "sword_gfx/normal.gfx", + 'BeeMizer': False, + 'BeesLevel': 0, + 'RandomizeTileTrapPattern': True, + 'RandomizeTileTrapFloorTile': False, + 'AllowKillableThief': shuffleenemies, + 'RandomizeSpriteOnHit': False, + 'DebugMode': False, + 'DebugForceEnemy': False, + 'DebugForceEnemyId': 0, + 'DebugForceBoss': False, + 'DebugForceBossId': 0, + 'DebugOpenShutterDoors': False, + 'DebugForceEnemyDamageZero': False, + 'DebugShowRoomIdInRupeeCounter': False, + 'UseManualBosses': True, + 'ManualBosses': { + 'EasternPalace': world.get_dungeon("Eastern Palace", player).boss.enemizer_name, + 'DesertPalace': world.get_dungeon("Desert Palace", player).boss.enemizer_name, + 'TowerOfHera': world.get_dungeon("Tower of Hera", player).boss.enemizer_name, + 'AgahnimsTower': 'Agahnim', + 'PalaceOfDarkness': world.get_dungeon("Palace of Darkness", player).boss.enemizer_name, + 'SwampPalace': world.get_dungeon("Swamp Palace", player).boss.enemizer_name, + 'SkullWoods': world.get_dungeon("Skull Woods", player).boss.enemizer_name, + 'ThievesTown': world.get_dungeon("Thieves Town", player).boss.enemizer_name, + 'IcePalace': world.get_dungeon("Ice Palace", player).boss.enemizer_name, + 'MiseryMire': world.get_dungeon("Misery Mire", player).boss.enemizer_name, + 'TurtleRock': world.get_dungeon("Turtle Rock", player).boss.enemizer_name, + 'GanonsTower1': world.get_dungeon('Ganons Tower', player).bosses['bottom'].enemizer_name, + 'GanonsTower2': world.get_dungeon('Ganons Tower', player).bosses['middle'].enemizer_name, + 'GanonsTower3': world.get_dungeon('Ganons Tower', player).bosses['top'].enemizer_name, + 'GanonsTower4': 'Agahnim2', + 'Ganon': 'Ganon', + } + } + + rom.write_to_file(randopatch_path) + + with open(options_path, 'w') as f: + json.dump(options, f) + + subprocess.check_call([os.path.abspath(enemizercli), + '--rom', baserom_path, + '--seed', str(world.rom_seeds[player]), + '--base', basepatch_path, + '--randomizer', randopatch_path, + '--enemizer', options_path, + '--output', enemizer_output_path], + cwd=os.path.dirname(enemizercli), stdout=subprocess.DEVNULL) + + with open(enemizer_output_path, 'r') as f: + ret = json.load(f) + + if os.path.exists(randopatch_path): + os.remove(randopatch_path) + + if os.path.exists(options_path): + os.remove(options_path) + + if os.path.exists(enemizer_output_path): + os.remove(enemizer_output_path) + + return ret + class Sprite(object): default_palette = [255, 127, 126, 35, 183, 17, 158, 54, 165, 20, 255, 1, 120, 16, 157, 89, 71, 54, 104, 59, 74, 10, 239, 18, 92, 42, 113, 21, 24, 122, @@ -275,7 +412,7 @@ class Sprite(object): # split into palettes of 15 colors return array_chunk(palette_as_colors, 15) -def patch_rom(world, player, rom, hashtable, beep='normal', color='red', sprite=None): +def patch_rom(world, player, rom, hashtable): random.seed(world.rom_seeds[player]) # patch items for location in world.get_locations(): @@ -852,8 +989,6 @@ def patch_rom(world, player, rom, hashtable, beep='normal', color='red', sprite= ] rom.write_bytes(0x180215, code) - apply_rom_settings(rom, beep, color, world.quickswap, world.fastmenu, world.disable_music, sprite) - return rom def write_custom_shops(rom, world, player):