diff --git a/worlds/soe/.gitignore b/worlds/soe/.gitignore new file mode 100644 index 00000000..e346fd15 --- /dev/null +++ b/worlds/soe/.gitignore @@ -0,0 +1,3 @@ +dumpy.py +pyevermizer +.pyevermizer diff --git a/worlds/soe/Logic.py b/worlds/soe/Logic.py index 90d52cf1..f25f2ada 100644 --- a/worlds/soe/Logic.py +++ b/worlds/soe/Logic.py @@ -1,7 +1,6 @@ from BaseClasses import MultiWorld from ..AutoWorld import LogicMixin from typing import Set -# TODO: import Options # TODO: Options may preset certain progress steps (i.e. P_ROCK_SKIP), set in generate_early? from . import pyevermizer @@ -19,8 +18,8 @@ items = [item for item in filter(lambda item: item.progression, pyevermizer.get_ class SecretOfEvermoreLogic(LogicMixin): def _soe_count(self, progress: int, world: MultiWorld, player: int, max_count: int = 0) -> int: """ - Returns reached count of one of evermizer's progress steps based on - collected items. i.e. returns 0-3 for P_DE based on items giving CHECK_BOSS,DIAMOND_EYE_DROP + Returns reached count of one of evermizer's progress steps based on collected items. + i.e. returns 0-3 for P_DE based on items providing CHECK_BOSS,DIAMOND_EYE_DROP """ n = 0 for item in items: @@ -46,7 +45,6 @@ class SecretOfEvermoreLogic(LogicMixin): def _soe_has(self, progress: int, world: MultiWorld, player: int, count: int = 1) -> bool: """ - Returns True if count of an evermizer progress steps are reached based - on collected items. i.e. 2 * P_DE + Returns True if count of one of evermizer's progress steps is reached based on collected items. i.e. 2 * P_DE """ return self._soe_count(progress, world, player, count) >= count diff --git a/worlds/soe/Options.py b/worlds/soe/Options.py index 57b32bd3..9eddd0e7 100644 --- a/worlds/soe/Options.py +++ b/worlds/soe/Options.py @@ -1,7 +1,154 @@ import typing -from Options import Option +from Options import Option, Range, Choice, Toggle, DefaultOnToggle + + +class EvermizerFlags: + flags: typing.List[str] + + def to_flag(self) -> str: + return self.flags[self.value] + + +class EvermizerFlag: + flag: str + + def to_flag(self) -> str: + return self.flag if self.value != self.default else '' + + +class OffOnChaosChoice(Choice): + option_off = 0 + option_on = 1 + option_chaos = 2 + alias_false = 0 + alias_true = 1 + + +class Difficulty(EvermizerFlags, Choice): + """Changes relative spell cost and stuff""" + displayname = "Difficulty" + option_easy = 0 + option_normal = 1 + option_hard = 2 + option_chaos = 3 # random is reserved pre 0.2 + default = 1 + flags = ['e', 'n', 'h', 'x'] + + +class MoneyModifier(Range): + """Money multiplier in %""" + displayname = "Money Modifier" + range_start = 1 + range_end = 2500 + default = 200 + + +class ExpModifier(Range): + """EXP multiplier for Weapons, Characters and Spells in %""" + displayname = "Exp Modifier" + range_start = 1 + range_end = 2500 + default = 200 + + +class FixSequence(EvermizerFlag, DefaultOnToggle): + """Fix some sequence breaks""" + displayname = "Fix Sequence" + flag = '1' + + +class FixCheats(EvermizerFlag, DefaultOnToggle): + """Fix cheats left in by the devs (not desert skip)""" + displayname = "Fix Cheats" + flag = '2' + + +class FixInfiniteAmmo(EvermizerFlag, Toggle): + """Fix infinite ammo glitch""" + displayname = "Fix Infinite Ammo" + flag = '5' + + +class FixAtlasGlitch(EvermizerFlag, Toggle): + """Fix atlas underflowing stats""" + displayname = "Fix Atlas Glitch" + flag = '6' + + +class FixWingsGlitch(EvermizerFlag, Toggle): + """Fix wings making you invincible in some areas""" + displayname = "Fix Wings Glitch" + flag = '7' + + +class ShorterDialogs(EvermizerFlag, Toggle): + """Cuts some dialogs""" + displayname = "Shorter Dialogs" + flag = '9' + + +class ShortBossRush(EvermizerFlag, Toggle): + """Start boss rush at Magmar, cut HP in half""" + displayname = "Short Boss Rush" + flag = 'f' + + +class Ingredienizer(EvermizerFlags, OffOnChaosChoice): + """Shuffles or randomizes spell ingredients""" + displayname = "Ingredienizer" + default = 1 + flags = ['i', '', 'I'] + + +class Sniffamizer(EvermizerFlags, OffOnChaosChoice): + """Shuffles or randomizes drops in sniff locations""" + displayname = "Sniffamizer" + default = 1 + flags = ['s', '', 'S'] + + +class Callbeadamizer(EvermizerFlags, OffOnChaosChoice): + """Shuffles call bead characters or spells""" + displayname = "Callbeadamizer" + default = 1 + flags = ['c', '', 'C'] + + +class Musicmizer(EvermizerFlag, Toggle): + """Randomize music for some rooms""" + displayname = "Musicmizer" + flag = 'm' + + +class Doggomizer(EvermizerFlags, OffOnChaosChoice): + """On shuffles dog per act, Chaos randomizes dog per screen, Pupdunk gives you Everpupper everywhere""" + displayname = "Doggomizer" + option_pupdunk = 3 + default = 0 + flags = ['', 'd', 'D', 'p'] + + +class TurdoMode(EvermizerFlag, Toggle): + """Replace offensive spells by Turd Balls with varying strength and make weapons weak""" + displayname = "Turdo Mode" + flag = 't' -# TODO: add options soe_options: typing.Dict[str, type(Option)] = { + "difficulty": Difficulty, + "money_modifier": MoneyModifier, + "exp_modifier": ExpModifier, + "fix_sequence": FixSequence, + "fix_cheats": FixCheats, + "fix_infinite_ammo": FixInfiniteAmmo, + "fix_atlas_glitch": FixAtlasGlitch, + "fix_wings_glitch": FixWingsGlitch, + "shorter_dialogs": ShorterDialogs, + "short_boss_rush": ShortBossRush, + "ingredienizer": Ingredienizer, + "sniffamizer": Sniffamizer, + "callbeadamizer": Callbeadamizer, + "musicmizer": Musicmizer, + "doggomizer": Doggomizer, + "turdo_mode": TurdoMode, } diff --git a/worlds/soe/Patch.py b/worlds/soe/Patch.py new file mode 100644 index 00000000..a9c1bade --- /dev/null +++ b/worlds/soe/Patch.py @@ -0,0 +1,52 @@ +import bsdiff4 +import yaml +from typing import Optional +import Utils + + +def read_rom(stream, strip_header=True) -> bytes: + """Reads rom into bytearray and optionally strips off any smc header""" + data = stream.read() + if strip_header and len(data) % 0x400 == 0x200: + return data[0x200:] + return data + + +def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes: + patch = yaml.dump({"meta": metadata, + "patch": patch, + "game": "Secret of Evermore", + # minimum version of patch system expected for patching to be successful + "compatible_version": 1, + "version": 1}) + return patch.encode(encoding="utf-8-sig") + + +def generate_patch(vanilla_file, randomized_file, metadata: Optional[dict] = None) -> bytes: + with open(vanilla_file, "rb") as f: + vanilla = read_rom(f) + with open(randomized_file, "rb") as f: + randomized = read_rom(f) + if metadata is None: + metadata = {} + patch = bsdiff4.diff(vanilla, randomized) + return generate_yaml(patch, metadata) + + +if __name__ == '__main__': + import argparse + import pathlib + import lzma + parser = argparse.ArgumentParser(description='Apply patch to Secret of Evermore.') + parser.add_argument('patch', type=pathlib.Path, help='path to .absoe file') + args = parser.parse_args() + with open(args.patch, "rb") as f: + data = Utils.parse_yaml(lzma.decompress(f.read()).decode("utf-8-sig")) + if data['game'] != 'Secret of Evermore': + raise RuntimeError('Patch is not for Secret of Evermore') + with open(Utils.get_options()['soe_options']['rom_file'], 'rb') as f: + vanilla_data = read_rom(f) + patched_data = bsdiff4.patch(vanilla_data, data["patch"]) + with open(args.patch.parent / (args.patch.stem + '.sfc'), 'wb') as f: + f.write(patched_data) + diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index 9628ba50..42b74fdb 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -1,15 +1,23 @@ -from .Options import soe_options from ..AutoWorld import World -from ..generic.Rules import set_rule +from ..generic.Rules import set_rule, add_item_rule from BaseClasses import Region, Location, Entrance, Item +from Utils import get_options, output_path import typing -from . import Logic # load logic mixin +import lzma +import os +import threading try: import pyevermizer # from package except ImportError: + import traceback + traceback.print_exc() from . import pyevermizer # as part of the source tree +from . import Logic # load logic mixin +from .Options import soe_options +from .Patch import generate_patch + """ In evermizer: @@ -36,99 +44,115 @@ TODO: for balancing we may want to generate Regions (with Entrances) for some common rules, place the locations in those Regions and shorten the rules. """ -GAME_NAME = "Secret of Evermore" -ID_OFF_BASE = 64000 -ID_OFFS: typing.Dict[int,int] = { - pyevermizer.CHECK_ALCHEMY: ID_OFF_BASE + 0, # alchemy 64000..64049 - pyevermizer.CHECK_BOSS: ID_OFF_BASE + 50, # bosses 64050..6499 - pyevermizer.CHECK_GOURD: ID_OFF_BASE + 100, # gourds 64100..64399 - pyevermizer.CHECK_NPC: ID_OFF_BASE + 400, # npc 64400..64499 +_id_base = 64000 +_id_offset: typing.Dict[int, int] = { + pyevermizer.CHECK_ALCHEMY: _id_base + 0, # alchemy 64000..64049 + pyevermizer.CHECK_BOSS: _id_base + 50, # bosses 64050..6499 + pyevermizer.CHECK_GOURD: _id_base + 100, # gourds 64100..64399 + pyevermizer.CHECK_NPC: _id_base + 400, # npc 64400..64499 # TODO: sniff 64500..64799 } - -def _get_locations(): - locs = pyevermizer.get_locations() - for loc in locs: - if loc.type == 3: # TODO: CHECK_GOURD - loc.name = f'{loc.name} #{loc.index}' - return locs +# cache native evermizer items and locations +_items = pyevermizer.get_items() +_locations = pyevermizer.get_locations() +# fix up texts for AP +for _loc in _locations: + if _loc.type == pyevermizer.CHECK_GOURD: + _loc.name = f'{_loc.name} #{_loc.index}' -def _get_location_ids(): - m = {} - for loc in _get_locations(): - m[loc.name] = ID_OFFS[loc.type] + loc.index - m['Done'] = None - return m +def _get_location_mapping() -> typing.Tuple[typing.Dict[str, int], typing.Dict[int, pyevermizer.Location]]: + name_to_id = {} + id_to_raw = {} + for loc in _locations: + apid = _id_offset[loc.type] + loc.index + id_to_raw[apid] = loc + name_to_id[loc.name] = apid + name_to_id['Done'] = None + return name_to_id, id_to_raw -def _get_items(): - return pyevermizer.get_items() - - -def _get_item_ids(): - m = {} - for item in _get_items(): - if item.name in m: continue - m[item.name] = ID_OFFS[item.type] + item.index - m['Victory'] = None - return m +def _get_item_mapping() -> typing.Tuple[typing.Dict[str, int], typing.Dict[int, pyevermizer.Item]]: + name_to_id = {} + id_to_raw = {} + for item in _items: + if item.name in name_to_id: + continue + apid = _id_offset[item.type] + item.index + id_to_raw[apid] = item + name_to_id[item.name] = apid + name_to_id['Victory'] = None + return name_to_id, id_to_raw class SoEWorld(World): """ - TODO: insert game description here + Secret of Evermore is a SNES action RPG. You learn alchemy spells, fight bosses and gather rocket parts to visit a + space station where the final boss must be defeated. """ - game: str = GAME_NAME - # options = soe_options + game: str = "Secret of Evermore" + options = soe_options topology_present: bool = True + remote_items: bool = False # True only for testing + data_version = 0 - item_name_to_id = _get_item_ids() - location_name_to_id = _get_location_ids() + item_name_to_id, item_id_to_raw = _get_item_mapping() + location_name_to_id, location_id_to_raw = _get_location_mapping() - remote_items: bool = True # False # True only for testing + evermizer_seed: int + restrict_item_placement: bool = False # placeholder to force certain item types to certain pools - def generate_basic(self): - print('SoE: generate_basic') - itempool = [item for item in map(lambda item: self.create_item(item), _get_items())] - self.world.itempool += itempool - self.world.get_location('Done', self.player).place_locked_item(self.create_event('Victory')) + def __init__(self, *args, **kwargs): + self.connect_name_available_event = threading.Event() + super(SoEWorld, self).__init__(*args, **kwargs) + + def create_event(self, event: str) -> Item: + progression = True + return SoEItem(event, progression, None, self.player) + + def create_item(self, item: typing.Union[pyevermizer.Item, str], force_progression: bool = False) -> Item: + if type(item) is str: + item = self.item_id_to_raw[self.item_name_to_id[item]] + return SoEItem(item.name, force_progression or item.progression, self.item_name_to_id[item.name], self.player) def create_regions(self): - # TODO: generate *some* regions from locations' requirements + # TODO: generate *some* regions from locations' requirements? r = Region('Menu', None, 'Menu', self.player, self.world) r.exits = [Entrance(self.player, 'New Game', r)] self.world.regions += [r] r = Region('Ingame', None, 'Ingame', self.player, self.world) r.locations = [SoELocation(self.player, loc.name, self.location_name_to_id[loc.name], r) - for loc in _get_locations()] + for loc in _locations] r.locations.append(SoELocation(self.player, 'Done', None, r)) self.world.regions += [r] self.world.get_entrance('New Game', self.player).connect(self.world.get_region('Ingame', self.player)) - def create_event(self, event: str) -> Item: - progression = True - return SoEItem(event, progression, None, self.player) - - def create_item(self, item) -> Item: - # TODO: if item is string: look up item by name - return SoEItem(item.name, item.progression, self.item_name_to_id[item.name], self.player) + def create_items(self): + # clear precollected items since we don't support them yet + if type(self.world.precollected_items) is dict: + self.world.precollected_items[self.player] = [] + # add items to the pool + self.world.itempool += [item for item in + map(lambda item: self.create_item(item, self.restrict_item_placement), _items)] def set_rules(self): - print('SoE: set_rules') self.world.completion_condition[self.player] = lambda state: state.has('Victory', self.player) # set Done from goal option once we have multiple goals set_rule(self.world.get_location('Done', self.player), lambda state: state._soe_has(pyevermizer.P_FINAL_BOSS, self.world, self.player)) set_rule(self.world.get_entrance('New Game', self.player), lambda state: True) - for loc in _get_locations(): - set_rule(self.world.get_location(loc.name, self.player), self.make_rule(loc.requires)) + for loc in _locations: + location = self.world.get_location(loc.name, self.player) + set_rule(location, self.make_rule(loc.requires)) + # limit location pool by item type + if self.restrict_item_placement: + add_item_rule(location, self.make_item_type_limit_rule(loc.type)) - def make_rule(self, requires): - def rule(state): + def make_rule(self, requires: typing.List[typing.Tuple[int]]) -> typing.Callable[[typing.Any], bool]: + def rule(state) -> bool: for count, progress in requires: if not state._soe_has(progress, self.world, self.player, count): return False @@ -136,13 +160,79 @@ class SoEWorld(World): return rule + def make_item_type_limit_rule(self, item_type: int): + return lambda item: item.player != self.player or self.item_id_to_raw[item.code].type == item_type + + def generate_basic(self): + # place Victory event + self.world.get_location('Done', self.player).place_locked_item(self.create_event('Victory')) + # generate stuff for later + self.evermizer_seed = self.world.random.randint(0, 2**16-1) # TODO: make this an option for "full" plando? + + def post_fill(self): + # fix up the advancement property of items so they are displayed correctly in other games + if self.restrict_item_placement: + for location in self.world.get_locations(): + item = location.item + if item.code and item.player == self.player and not self.item_id_to_raw[location.item.code].progression: + item.advancement = False + + def generate_output(self, output_directory: str): + player_name = self.world.get_player_name(self.player) + self.connect_name = player_name[:32] + while len(self.connect_name.encode('utf-8')) > 32: + self.connect_name = self.connect_name[:-1] + self.connect_name_available_event.set() + placement_file = None + out_file = None + try: + money = self.world.money_modifier[self.player].value + exp = self.world.exp_modifier[self.player].value + rom_file = get_options()['soe_options']['rom_file'] + out_base = output_path(output_directory, f'AP_{self.world.seed_name}_P{self.player}_{player_name}') + out_file = out_base + '.sfc' + placement_file = out_base + '.txt' + patch_file = out_base + '.apsoe' + flags = 'l' # spoiler log + for option_name in self.options: + option = getattr(self.world, option_name)[self.player] + if hasattr(option, 'to_flag'): + flags += option.to_flag() + + with open(placement_file, "wb") as f: # generate placement file + for location in filter(lambda l: l.player == self.player, self.world.get_locations()): + item = location.item + if item.code is None: + continue # skip events + loc = self.location_id_to_raw[location.address] + if item.player != self.player: + line = f'{loc.type},{loc.index}:{pyevermizer.CHECK_NONE},{item.code},{item.player}\n' + else: + item = self.item_id_to_raw[item.code] + line = f'{loc.type},{loc.index}:{item.type},{item.index}\n' + f.write(line.encode('utf-8')) + + if (pyevermizer.main(rom_file, out_file, placement_file, self.world.seed_name, self.connect_name, self.evermizer_seed, + flags, money, exp)): + raise RuntimeError() + with lzma.LZMAFile(patch_file, 'wb') as f: + f.write(generate_patch(rom_file, out_file)) + except: + raise + finally: + try: + os.unlink(placement_file) + os.unlink(out_file) + os.unlink(out_file[:-4]+'_SPOILER.log') + except: + pass class SoEItem(Item): - game: str = GAME_NAME + game: str = "Secret of Evermore" class SoELocation(Location): - game: str = GAME_NAME + game: str = "Secret of Evermore" def __init__(self, player: int, name: str, address: typing.Optional[int], parent): super().__init__(player, name, address, parent) diff --git a/worlds/soe/requirements.txt b/worlds/soe/requirements.txt new file mode 100644 index 00000000..c0ac8ae7 --- /dev/null +++ b/worlds/soe/requirements.txt @@ -0,0 +1,14 @@ +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp38-cp38-win_amd64.whl#egg=pyevermizer; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.8' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp39-cp39-win_amd64.whl#egg=pyevermizer; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.9' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp310-cp310-win_amd64.whl#egg=pyevermizer; sys_platform == 'win32' and platform_machine == 'AMD64' and python_version == '3.10' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.8' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.9' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.10' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.8' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.9' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl#egg=pyevermizer; sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.10' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp38-cp38-macosx_10_9_amd64.whl#egg=pyevermizer; sys_platform == 'darwin' and python_version == '3.8' +#https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp39-cp39-macosx_10_9_amd64.whl#egg=pyevermizer; sys_platform == 'darwin' and python_version == '3.9' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp39-cp39-macosx_10_9_universal2.whl#egg=pyevermizer; sys_platform == 'darwin' and python_version == '3.9' +https://github.com/black-sliver/pyevermizer/releases/download/v0.39.0/pyevermizer-0.39-cp310-cp310-macosx_10_9_universal2.whl#egg=pyevermizer; sys_platform == 'darwin' and python_version == '3.10' +bsdiff4>=1.2.1 \ No newline at end of file