From 0e6e35974735764a99e06eb7ce1d63b11bc32e83 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Mon, 19 Aug 2024 21:59:29 -0500 Subject: [PATCH] Mega Man 2: Implement New Game (#3256) * initial (broken) commit * small work on init * Update Items.py * beginning work, some rom patches * commit progress from bh branch * deathlink, fix soft-reset kill, e-tank loss * begin work on targeting new bhclient * write font * definitely didn't forget to add the other two hashes no * update to modern options, begin colors * fix 6th letter bug * palette shuffle + logic rewrite * fix a bunch of pointers * fix color changes, deathlink, and add wily 5 req * adjust weapon weakness generation * Update Rules.py * attempt wily 5 softlock fix * add explicit test for rbm weaknesses * fix difficulty and hard reset * fix connect deathlink and off by one item color * fix atomic fire again * de-jank deathlink * rewrite wily5 rule * fix rare solo-gen fill issue, hopefully * Update Client.py * fix wily 5 requirements * undo fill hook * fix picopico-kun rules * for real this time * update minimum damage requirement * begin move to procedure patch * finish move to APPP, allow rando boobeam, color updates * fix color bug, UT support? * what do you mean I forgot the procedure * fix UT? * plando weakness and fixes * sfx when item received, more time stopper edge cases * Update test_weakness.py * fix rules and color bug * fix color bug, support reduced flashing * major world overhaul * Update Locations.py * fix first found bugs * mypy cleanup * headerless roms * Update Rom.py * further cleanup * work on energylink * el fixes * update to energylink 2.0 packet * energylink balancing * potentially break other clients, more balancing * Update Items.py * remove startup change from basepatch we write that in patch, since we also need to clean the area before applying * el balancing and feedback * hopefully less test failures? * implement world version check * add weapon/health option * Update Rom.py * x/x2 * specials * Update Color.py * Update Options.py * finally apply location groups * bump minor version number instead * fix duplicate stage sends * validate wily 5, tests * see if renaming fixes * add shuffled weakness * remove passwords * refresh rbm select, fix wily 5 validation * forgot we can't check 0 * oops I broke the basepatch (remove failing test later) * fix solo gen fill error? * fix webhost patch recognition * fix imports, basepatch * move to flexibility metric for boss validation * special case boobeam trap * block strobe on stage select init * more energylink balancing * bump world version * wily HP inaccurate in validation * fix validation edge case * save last completed wily to data storage * mypy and pep8 cleanup * fix file browse validation * fix test failure, add enemy weakness * remove test seed * update enemy damage * inno setup * Update en_Mega Man 2.md * setup guide * Update en_Mega Man 2.md * finish plando weakness section * starting rbm edge case * remove * imports * properly wrap later weakness additions in regen playthrough * fix import * forgot readme * remove time stopper special casing since we moved to proper wily 5 validation, this special casing is no longer important * properly type added locations * Update CODEOWNERS * add animation reduction * deprioritize Time Stopper in rush checks * special case wily phase 1 * fix key error * forgot the test * music and general cleanup * the great rename * fix import * thanks pycharm * reorder palette shuffle * account for alien on shuffled weakness * apply suggestions * fix seedbleed * fix invalid buster passthrough * fix weakness landing beneath required amount * fix failsafe * finish music * fix Time Stopper on Flash/Alien * asar pls * Apply suggestions from code review Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * world helpers * init cleanup * apostrophes * clearer wording * mypy and cleanup * options doc cleanup * Update rom.py * rules cleanup * Update __init__.py * Update __init__.py * move to defaultdict * cleanup world helpers * Update __init__.py * remove unnecessary line from fill hook * forgot the other one * apply code review * remove collect * Update rules.py * forgot another --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- README.md | 1 + docs/CODEOWNERS | 3 + inno_setup.iss | 5 + worlds/mm2/__init__.py | 290 +++++++++ worlds/mm2/client.py | 562 +++++++++++++++++ worlds/mm2/color.py | 276 +++++++++ worlds/mm2/data/mm2_basepatch.bsdiff4 | Bin 0 -> 1440 bytes worlds/mm2/docs/en_Mega Man 2.md | 114 ++++ worlds/mm2/docs/setup_en.md | 53 ++ worlds/mm2/items.py | 72 +++ worlds/mm2/locations.py | 239 +++++++ worlds/mm2/names.py | 114 ++++ worlds/mm2/options.py | 229 +++++++ worlds/mm2/rom.py | 415 +++++++++++++ worlds/mm2/rules.py | 319 ++++++++++ worlds/mm2/src/mm2_basepatch.asm | 861 ++++++++++++++++++++++++++ worlds/mm2/src/mm2font.dat | Bin 0 -> 416 bytes worlds/mm2/src/mm2titlefont.dat | Bin 0 -> 160 bytes worlds/mm2/test/__init__.py | 5 + worlds/mm2/test/test_access.py | 47 ++ worlds/mm2/test/test_weakness.py | 93 +++ worlds/mm2/text.py | 90 +++ 22 files changed, 3788 insertions(+) create mode 100644 worlds/mm2/__init__.py create mode 100644 worlds/mm2/client.py create mode 100644 worlds/mm2/color.py create mode 100644 worlds/mm2/data/mm2_basepatch.bsdiff4 create mode 100644 worlds/mm2/docs/en_Mega Man 2.md create mode 100644 worlds/mm2/docs/setup_en.md create mode 100644 worlds/mm2/items.py create mode 100644 worlds/mm2/locations.py create mode 100644 worlds/mm2/names.py create mode 100644 worlds/mm2/options.py create mode 100644 worlds/mm2/rom.py create mode 100644 worlds/mm2/rules.py create mode 100644 worlds/mm2/src/mm2_basepatch.asm create mode 100644 worlds/mm2/src/mm2font.dat create mode 100644 worlds/mm2/src/mm2titlefont.dat create mode 100644 worlds/mm2/test/__init__.py create mode 100644 worlds/mm2/test/test_access.py create mode 100644 worlds/mm2/test/test_weakness.py create mode 100644 worlds/mm2/text.py diff --git a/README.md b/README.md index a2e9d3e5e5a3..0d9a41de9f1a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Currently, the following games are supported: * A Hat in Time * Old School Runescape * Kingdom Hearts 1 +* Mega Man 2 For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index bba79c649fc1..6a3c8f45c174 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -106,6 +106,9 @@ # Minecraft /worlds/minecraft/ @KonoTyran @espeon65536 +# Mega Man 2 +/worlds/mm2/ @Silvris + # MegaMan Battle Network 3 /worlds/mmbn3/ @digiholic diff --git a/inno_setup.iss b/inno_setup.iss index f097500f7d7d..3bb76fc40abe 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; + Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; diff --git a/worlds/mm2/__init__.py b/worlds/mm2/__init__.py new file mode 100644 index 000000000000..07e1823f9387 --- /dev/null +++ b/worlds/mm2/__init__.py @@ -0,0 +1,290 @@ +import hashlib +import logging +from copy import deepcopy +from typing import Dict, Any, TYPE_CHECKING, Optional, Sequence, Tuple, ClassVar, List + +from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location +from worlds.AutoWorld import World, WebWorld +from .names import (dr_wily, heat_man_stage, air_man_stage, wood_man_stage, bubble_man_stage, quick_man_stage, + flash_man_stage, metal_man_stage, crash_man_stage) +from .items import (item_table, item_names, MM2Item, filler_item_weights, robot_master_weapon_table, + stage_access_table, item_item_table, lookup_item_to_id) +from .locations import (MM2Location, mm2_regions, MM2Region, energy_pickups, etank_1ups, lookup_location_to_id, + location_groups) +from .rom import patch_rom, MM2ProcedurePatch, MM2LCHASH, PROTEUSHASH, MM2VCHASH, MM2NESHASH +from .options import MM2Options, Consumables +from .client import MegaMan2Client +from .rules import set_rules, weapon_damage, robot_masters, weapons_to_name, minimum_weakness_requirement +import os +import threading +import base64 +import settings +logger = logging.getLogger("Mega Man 2") + +if TYPE_CHECKING: + from BaseClasses import CollectionState + + +class MM2Settings(settings.Group): + class RomFile(settings.UserFilePath): + """File name of the MM2 EN rom""" + description = "Mega Man 2 ROM File" + copy_to: Optional[str] = "Mega Man 2 (USA).nes" + md5s = [MM2NESHASH, MM2VCHASH, MM2LCHASH, PROTEUSHASH] + + def browse(self: settings.T, + filetypes: Optional[Sequence[Tuple[str, Sequence[str]]]] = None, + **kwargs: Any) -> Optional[settings.T]: + if not filetypes: + file_types = [("NES", [".nes"]), ("Program", [".exe"])] # LC1 is only a windows executable, no linux + return super().browse(file_types, **kwargs) + else: + return super().browse(filetypes, **kwargs) + + @classmethod + def validate(cls, path: str) -> None: + """Try to open and validate file against hashes""" + with open(path, "rb", buffering=0) as f: + try: + f.seek(0) + if f.read(4) == b"NES\x1A": + f.seek(16) + else: + f.seek(0) + cls._validate_stream_hashes(f) + base_rom_bytes = f.read() + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() == PROTEUSHASH: + # we need special behavior here + cls.copy_to = None + except ValueError: + raise ValueError(f"File hash does not match for {path}") + + rom_file: RomFile = RomFile(RomFile.copy_to) + + +class MM2WebWorld(WebWorld): + theme = "partyTime" + tutorials = [ + + Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Mega Man 2 randomizer connected to an Archipelago Multiworld.", + "English", + "setup_en.md", + "setup/en", + ["Silvris"] + ) + ] + + +class MM2World(World): + """ + In the year 200X, following his prior defeat by Mega Man, the evil Dr. Wily has returned to take over the world with + his own group of Robot Masters. Mega Man once again sets out to defeat the eight Robot Masters and stop Dr. Wily. + + """ + + game = "Mega Man 2" + settings: ClassVar[MM2Settings] + options_dataclass = MM2Options + options: MM2Options + item_name_to_id = lookup_item_to_id + location_name_to_id = lookup_location_to_id + item_name_groups = item_names + location_name_groups = location_groups + web = MM2WebWorld() + rom_name: bytearray + world_version: Tuple[int, int, int] = (0, 3, 1) + wily_5_weapons: Dict[int, List[int]] + + def __init__(self, world: MultiWorld, player: int): + self.rom_name = bytearray() + self.rom_name_available_event = threading.Event() + super().__init__(world, player) + self.weapon_damage = deepcopy(weapon_damage) + self.wily_5_weapons = {} + + def create_regions(self) -> None: + menu = MM2Region("Menu", self.player, self.multiworld) + self.multiworld.regions.append(menu) + for region in mm2_regions: + stage = MM2Region(region, self.player, self.multiworld) + required_items = mm2_regions[region][0] + locations = mm2_regions[region][1] + prev_stage = mm2_regions[region][2] + if prev_stage is None: + menu.connect(stage, f"To {region}", + lambda state, items=required_items: state.has_all(items, self.player)) + else: + old_stage = self.get_region(prev_stage) + old_stage.connect(stage, f"To {region}", + lambda state, items=required_items: state.has_all(items, self.player)) + stage.add_locations(locations, MM2Location) + for location in stage.get_locations(): + if location.address is None and location.name != dr_wily: + location.place_locked_item(MM2Item(location.name, ItemClassification.progression, + None, self.player)) + if region in etank_1ups and self.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + stage.add_locations(etank_1ups[region], MM2Location) + if region in energy_pickups and self.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + stage.add_locations(energy_pickups[region], MM2Location) + self.multiworld.regions.append(stage) + + def create_item(self, name: str) -> MM2Item: + item = item_table[name] + classification = ItemClassification.filler + if item.progression: + classification = ItemClassification.progression_skip_balancing \ + if item.skip_balancing else ItemClassification.progression + if item.useful: + classification |= ItemClassification.useful + return MM2Item(name, classification, item.code, self.player) + + def get_filler_item_name(self) -> str: + return self.random.choices(list(filler_item_weights.keys()), + weights=list(filler_item_weights.values()))[0] + + def create_items(self) -> None: + itempool = [] + # grab first robot master + robot_master = self.item_id_to_name[0x880101 + self.options.starting_robot_master.value] + self.multiworld.push_precollected(self.create_item(robot_master)) + itempool.extend([self.create_item(name) for name in stage_access_table.keys() + if name != robot_master]) + itempool.extend([self.create_item(name) for name in robot_master_weapon_table.keys()]) + itempool.extend([self.create_item(name) for name in item_item_table.keys()]) + total_checks = 24 + if self.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + total_checks += 20 + if self.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + total_checks += 27 + remaining = total_checks - len(itempool) + itempool.extend([self.create_item(name) + for name in self.random.choices(list(filler_item_weights.keys()), + weights=list(filler_item_weights.values()), + k=remaining)]) + self.multiworld.itempool += itempool + + set_rules = set_rules + + def generate_early(self) -> None: + if (not self.options.yoku_jumps + and self.options.starting_robot_master == "heat_man") or \ + (not self.options.enable_lasers + and self.options.starting_robot_master == "quick_man"): + robot_master_pool = [1, 2, 3, 5, 6, 7, ] + if self.options.yoku_jumps: + robot_master_pool.append(0) + if self.options.enable_lasers: + robot_master_pool.append(4) + self.options.starting_robot_master.value = self.random.choice(robot_master_pool) + logger.warning( + f"Mega Man 2 ({self.player_name}): " + f"Incompatible starting Robot Master, changing to " + f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}") + + def generate_basic(self) -> None: + goal_location = self.get_location(dr_wily) + goal_location.place_locked_item(MM2Item("Victory", ItemClassification.progression, None, self.player)) + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + def fill_hook(self, + progitempool: List["Item"], + usefulitempool: List["Item"], + filleritempool: List["Item"], + fill_locations: List["Location"]) -> None: + # on a solo gen, fill can try to force Wily into sphere 2, but for most generations this is impossible + # since MM2 can have a 2 item sphere 1, and 3 items are required for Wily + if self.multiworld.players > 1: + return # Don't need to change anything on a multi gen, fill should be able to solve it with a 4 sphere 1 + rbm_to_item = { + 0: heat_man_stage, + 1: air_man_stage, + 2: wood_man_stage, + 3: bubble_man_stage, + 4: quick_man_stage, + 5: flash_man_stage, + 6: metal_man_stage, + 7: crash_man_stage + } + affected_rbm = [2, 3] # Wood and Bubble will always have this happen + possible_rbm = [1, 5] # Air and Flash are always valid targets, due to Item 2/3 receive + if self.options.consumables: + possible_rbm.append(6) # Metal has 3 consumables + possible_rbm.append(7) # Crash has 3 consumables + if self.options.enable_lasers: + possible_rbm.append(4) # Quick has a lot of consumables, but needs logical time stopper if not enabled + else: + affected_rbm.extend([6, 7]) # only two checks on non consumables + if self.options.yoku_jumps: + possible_rbm.append(0) # Heat has 3 locations always, but might need 2 items logically + if self.options.starting_robot_master.value in affected_rbm: + rbm_names = list(map(lambda s: rbm_to_item[s], possible_rbm)) + valid_second = [item for item in progitempool + if item.name in rbm_names + and item.player == self.player] + placed_item = self.random.choice(valid_second) + rbm_defeated = (f"{robot_masters[self.options.starting_robot_master.value].replace(' Defeated', '')}" + f" - Defeated") + rbm_location = self.get_location(rbm_defeated) + rbm_location.place_locked_item(placed_item) + progitempool.remove(placed_item) + fill_locations.remove(rbm_location) + target_rbm = (placed_item.code & 0xF) - 1 + if self.options.strict_weakness or (self.options.random_weakness + and not (self.weapon_damage[0][target_rbm] > 0)): + # we need to find a weakness for this boss + weaknesses = [weapon for weapon in range(1, 9) + if self.weapon_damage[weapon][target_rbm] >= minimum_weakness_requirement[weapon]] + weapons = list(map(lambda s: weapons_to_name[s], weaknesses)) + valid_weapons = [item for item in progitempool + if item.name in weapons + and item.player == self.player] + placed_weapon = self.random.choice(valid_weapons) + weapon_name = next(name for name, idx in lookup_location_to_id.items() + if idx == 0x880101 + self.options.starting_robot_master.value) + weapon_location = self.get_location(weapon_name) + weapon_location.place_locked_item(placed_weapon) + progitempool.remove(placed_weapon) + fill_locations.remove(weapon_location) + + def generate_output(self, output_directory: str) -> None: + try: + patch = MM2ProcedurePatch(player=self.player, player_name=self.player_name) + patch_rom(self, patch) + + self.rom_name = patch.name + + patch.write(os.path.join(output_directory, + f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}")) + except Exception: + raise + finally: + self.rom_name_available_event.set() # make sure threading continues and errors are collected + + def fill_slot_data(self) -> Dict[str, Any]: + return { + "death_link": self.options.death_link.value, + "weapon_damage": self.weapon_damage, + "wily_5_weapons": self.wily_5_weapons, + } + + def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]: + local_weapon = {int(key): value for key, value in slot_data["weapon_damage"].items()} + local_wily = {int(key): value for key, value in slot_data["wily_5_weapons"].items()} + return {"weapon_damage": local_weapon, "wily_5_weapons": local_wily} + + def modify_multidata(self, multidata: Dict[str, Any]) -> None: + # wait for self.rom_name to be available. + self.rom_name_available_event.wait() + rom_name = getattr(self, "rom_name", None) + # we skip in case of error, so that the original error in the output thread is the one that gets raised + if rom_name: + new_name = base64.b64encode(bytes(self.rom_name)).decode() + multidata["connect_names"][new_name] = multidata["connect_names"][self.player_name] diff --git a/worlds/mm2/client.py b/worlds/mm2/client.py new file mode 100644 index 000000000000..aaa0813c763a --- /dev/null +++ b/worlds/mm2/client.py @@ -0,0 +1,562 @@ +import logging +import time +from enum import IntEnum +from base64 import b64encode +from typing import TYPE_CHECKING, Dict, Tuple, List, Optional, Any +from NetUtils import ClientStatus, color, NetworkItem +from worlds._bizhawk.client import BizHawkClient + +if TYPE_CHECKING: + from worlds._bizhawk.context import BizHawkClientContext, BizHawkClientCommandProcessor + +nes_logger = logging.getLogger("NES") +logger = logging.getLogger("Client") + +MM2_ROBOT_MASTERS_UNLOCKED = 0x8A +MM2_ROBOT_MASTERS_DEFEATED = 0x8B +MM2_ITEMS_ACQUIRED = 0x8C +MM2_LAST_WILY = 0x8D +MM2_RECEIVED_ITEMS = 0x8E +MM2_DEATHLINK = 0x8F +MM2_ENERGYLINK = 0x90 +MM2_RBM_STROBE = 0x91 +MM2_WEAPONS_UNLOCKED = 0x9A +MM2_ITEMS_UNLOCKED = 0x9B +MM2_WEAPON_ENERGY = 0x9C +MM2_E_TANKS = 0xA7 +MM2_LIVES = 0xA8 +MM2_DIFFICULTY = 0xCB +MM2_HEALTH = 0x6C0 +MM2_COMPLETED_STAGES = 0x770 +MM2_CONSUMABLES = 0x780 + +MM2_SFX_QUEUE = 0x580 +MM2_SFX_STROBE = 0x66 + +MM2_CONSUMABLE_TABLE: Dict[int, Tuple[int, int]] = { + # Item: (byte offset, bit mask) + 0x880201: (0, 8), + 0x880202: (16, 1), + 0x880203: (16, 2), + 0x880204: (16, 4), + 0x880205: (16, 8), + 0x880206: (16, 16), + 0x880207: (16, 32), + 0x880208: (16, 64), + 0x880209: (16, 128), + 0x88020A: (20, 1), + 0x88020B: (20, 4), + 0x88020C: (20, 64), + 0x88020D: (21, 1), + 0x88020E: (21, 2), + 0x88020F: (21, 4), + 0x880210: (24, 1), + 0x880211: (24, 2), + 0x880212: (24, 4), + 0x880213: (28, 1), + 0x880214: (28, 2), + 0x880215: (28, 4), + 0x880216: (33, 4), + 0x880217: (33, 8), + 0x880218: (37, 8), + 0x880219: (37, 16), + 0x88021A: (38, 1), + 0x88021B: (38, 2), + 0x880227: (38, 4), + 0x880228: (38, 32), + 0x880229: (38, 128), + 0x88022A: (39, 4), + 0x88022B: (39, 2), + 0x88022C: (39, 1), + 0x88022D: (38, 64), + 0x88022E: (38, 16), + 0x88022F: (38, 8), + 0x88021C: (39, 32), + 0x88021D: (39, 64), + 0x88021E: (39, 128), + 0x88021F: (41, 16), + 0x880220: (42, 2), + 0x880221: (42, 4), + 0x880222: (42, 8), + 0x880223: (46, 1), + 0x880224: (46, 2), + 0x880225: (46, 4), + 0x880226: (46, 8), +} + + +class MM2EnergyLinkType(IntEnum): + Life = 0 + AtomicFire = 1 + AirShooter = 2 + LeafShield = 3 + BubbleLead = 4 + QuickBoomerang = 5 + TimeStopper = 6 + MetalBlade = 7 + CrashBomber = 8 + Item1 = 9 + Item2 = 10 + Item3 = 11 + OneUP = 12 + + +request_to_name: Dict[str, str] = { + "HP": "health", + "AF": "Atomic Fire energy", + "AS": "Air Shooter energy", + "LS": "Leaf Shield energy", + "BL": "Bubble Lead energy", + "QB": "Quick Boomerang energy", + "TS": "Time Stopper energy", + "MB": "Metal Blade energy", + "CB": "Crash Bomber energy", + "I1": "Item 1 energy", + "I2": "Item 2 energy", + "I3": "Item 3 energy", + "1U": "lives" +} + +HP_EXCHANGE_RATE = 500000000 +WEAPON_EXCHANGE_RATE = 250000000 +ONEUP_EXCHANGE_RATE = 14000000000 + + +def cmd_pool(self: "BizHawkClientCommandProcessor") -> None: + """Check the current pool of EnergyLink, and requestable refills from it.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + energylink = self.ctx.stored_data.get(f"EnergyLink{self.ctx.team}", 0) + health_points = energylink // HP_EXCHANGE_RATE + weapon_points = energylink // WEAPON_EXCHANGE_RATE + lives = energylink // ONEUP_EXCHANGE_RATE + logger.info(f"Healing available: {health_points}\n" + f"Weapon refill available: {weapon_points}\n" + f"Lives available: {lives}") + + +def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None: + from worlds._bizhawk.context import BizHawkClientContext + """Request a refill from EnergyLink.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + valid_targets: Dict[str, MM2EnergyLinkType] = { + "HP": MM2EnergyLinkType.Life, + "AF": MM2EnergyLinkType.AtomicFire, + "AS": MM2EnergyLinkType.AirShooter, + "LS": MM2EnergyLinkType.LeafShield, + "BL": MM2EnergyLinkType.BubbleLead, + "QB": MM2EnergyLinkType.QuickBoomerang, + "TS": MM2EnergyLinkType.TimeStopper, + "MB": MM2EnergyLinkType.MetalBlade, + "CB": MM2EnergyLinkType.CrashBomber, + "I1": MM2EnergyLinkType.Item1, + "I2": MM2EnergyLinkType.Item2, + "I3": MM2EnergyLinkType.Item3, + "1U": MM2EnergyLinkType.OneUP + } + if target.upper() not in valid_targets: + logger.warning(f"Unrecognized target {target.upper()}. Available targets: {', '.join(valid_targets.keys())}") + return + ctx = self.ctx + assert isinstance(ctx, BizHawkClientContext) + client = ctx.client_handler + assert isinstance(client, MegaMan2Client) + client.refill_queue.append((valid_targets[target.upper()], int(amount))) + logger.info(f"Restoring {amount} {request_to_name[target.upper()]}.") + + +def cmd_autoheal(self) -> None: + """Enable auto heal from EnergyLink.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + else: + assert isinstance(self.ctx.client_handler, MegaMan2Client) + if self.ctx.client_handler.auto_heal: + self.ctx.client_handler.auto_heal = False + logger.info(f"Auto healing disabled.") + else: + self.ctx.client_handler.auto_heal = True + logger.info(f"Auto healing enabled.") + + +def get_sfx_writes(sfx: int) -> Tuple[Tuple[int, bytes, str], ...]: + return (MM2_SFX_QUEUE, sfx.to_bytes(1, 'little'), "RAM"), (MM2_SFX_STROBE, 0x01.to_bytes(1, "little"), "RAM") + + +class MegaMan2Client(BizHawkClient): + game = "Mega Man 2" + system = "NES" + patch_suffix = ".apmm2" + item_queue: List[NetworkItem] = [] + pending_death_link: bool = False + # default to true, as we don't want to send a deathlink until Mega Man's HP is initialized once + sending_death_link: bool = True + death_link: bool = False + energy_link: bool = False + rom: Optional[bytes] = None + weapon_energy: int = 0 + health_energy: int = 0 + auto_heal: bool = False + refill_queue: List[Tuple[MM2EnergyLinkType, int]] = [] + last_wily: Optional[int] = None # default to wily 1 + + async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: + from worlds._bizhawk import RequestFailedError, read + from . import MM2World + + try: + game_name, version = (await read(ctx.bizhawk_ctx, [(0x3FFB0, 21, "PRG ROM"), + (0x3FFC8, 3, "PRG ROM")])) + if game_name[:3] != b"MM2" or version != bytes(MM2World.world_version): + if game_name[:3] == b"MM2": + # I think this is an easier check than the other? + older_version = "0.2.1" if version == b"\xFF\xFF\xFF" else f"{version[0]}.{version[1]}.{version[2]}" + logger.warning(f"This Mega Man 2 patch was generated for an different version of the apworld. " + f"Please use that version to connect instead.\n" + f"Patch version: ({older_version})\n" + f"Client version: ({'.'.join([str(i) for i in MM2World.world_version])})") + if "pool" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("pool") + if "request" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("request") + if "autoheal" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("autoheal") + return False + except UnicodeDecodeError: + return False + except RequestFailedError: + return False # Should verify on the next pass + + ctx.game = self.game + self.rom = game_name + ctx.items_handling = 0b111 + ctx.want_slot_data = False + deathlink = (await read(ctx.bizhawk_ctx, [(0x3FFC5, 1, "PRG ROM")]))[0][0] + if deathlink & 0x01: + self.death_link = True + if deathlink & 0x02: + self.energy_link = True + + if self.energy_link: + if "pool" not in ctx.command_processor.commands: + ctx.command_processor.commands["pool"] = cmd_pool + if "request" not in ctx.command_processor.commands: + ctx.command_processor.commands["request"] = cmd_request + if "autoheal" not in ctx.command_processor.commands: + ctx.command_processor.commands["autoheal"] = cmd_autoheal + + return True + + async def set_auth(self, ctx: "BizHawkClientContext") -> None: + if self.rom: + ctx.auth = b64encode(self.rom).decode() + + def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: Dict[str, Any]) -> None: + if cmd == "Bounced": + if "tags" in args: + assert ctx.slot is not None + if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name: + self.on_deathlink(ctx) + elif cmd == "Retrieved": + if f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}" in args["keys"]: + self.last_wily = args["keys"][f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"] + elif cmd == "Connected": + if self.energy_link: + ctx.set_notify(f"EnergyLink{ctx.team}") + if ctx.ui: + ctx.ui.enable_energy_link() + + async def send_deathlink(self, ctx: "BizHawkClientContext") -> None: + self.sending_death_link = True + ctx.last_death_link = time.time() + await ctx.send_death("Mega Man was defeated.") + + def on_deathlink(self, ctx: "BizHawkClientContext") -> None: + ctx.last_death_link = time.time() + self.pending_death_link = True + + async def game_watcher(self, ctx: "BizHawkClientContext") -> None: + from worlds._bizhawk import read, write + + if ctx.server is None: + return + + if ctx.slot is None: + return + + # get our relevant bytes + robot_masters_unlocked, robot_masters_defeated, items_acquired, \ + weapons_unlocked, items_unlocked, items_received, \ + completed_stages, consumable_checks, \ + e_tanks, lives, weapon_energy, health, difficulty, death_link_status, \ + energy_link_packet, last_wily = await read(ctx.bizhawk_ctx, [ + (MM2_ROBOT_MASTERS_UNLOCKED, 1, "RAM"), + (MM2_ROBOT_MASTERS_DEFEATED, 1, "RAM"), + (MM2_ITEMS_ACQUIRED, 1, "RAM"), + (MM2_WEAPONS_UNLOCKED, 1, "RAM"), + (MM2_ITEMS_UNLOCKED, 1, "RAM"), + (MM2_RECEIVED_ITEMS, 1, "RAM"), + (MM2_COMPLETED_STAGES, 0xE, "RAM"), + (MM2_CONSUMABLES, 52, "RAM"), + (MM2_E_TANKS, 1, "RAM"), + (MM2_LIVES, 1, "RAM"), + (MM2_WEAPON_ENERGY, 11, "RAM"), + (MM2_HEALTH, 1, "RAM"), + (MM2_DIFFICULTY, 1, "RAM"), + (MM2_DEATHLINK, 1, "RAM"), + (MM2_ENERGYLINK, 1, "RAM"), + (MM2_LAST_WILY, 1, "RAM"), + ]) + + if difficulty[0] not in (0, 1): + return # Game is not initialized + + if not ctx.finished_game and completed_stages[0xD] != 0: + # this sets on credits fade, no real better way to do this + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + writes = [] + + # deathlink + if self.death_link: + await ctx.update_death_link(self.death_link) + if self.pending_death_link: + writes.append((MM2_DEATHLINK, bytes([0x01]), "RAM")) + self.pending_death_link = False + self.sending_death_link = True + if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): + if health[0] == 0x00 and not self.sending_death_link: + await self.send_deathlink(ctx) + elif health[0] != 0x00 and not death_link_status[0]: + self.sending_death_link = False + + if self.last_wily != last_wily[0]: + if self.last_wily is None: + # revalidate last wily from data storage + await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "default", "value": 8} + ]}]) + await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"]}]) + elif last_wily[0] == 0: + writes.append((MM2_LAST_WILY, self.last_wily.to_bytes(1, "little"), "RAM")) + else: + # correct our setting + self.last_wily = last_wily[0] + await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "replace", "value": self.last_wily} + ]}]) + + # handle receiving items + recv_amount = items_received[0] + if recv_amount < len(ctx.items_received): + item = ctx.items_received[recv_amount] + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received))) + + if item.item & 0x130 == 0: + # Robot Master Weapon + new_weapons = weapons_unlocked[0] | (1 << ((item.item & 0xF) - 1)) + writes.append((MM2_WEAPONS_UNLOCKED, new_weapons.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x21)) + elif item.item & 0x30 == 0: + # Robot Master Stage Access + new_stages = robot_masters_unlocked[0] & ~(1 << ((item.item & 0xF) - 1)) + writes.append((MM2_ROBOT_MASTERS_UNLOCKED, new_stages.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x3a)) + writes.append((MM2_RBM_STROBE, b"\x01", "RAM")) + elif item.item & 0x20 == 0: + # Items + new_items = items_unlocked[0] | (1 << ((item.item & 0xF) - 1)) + writes.append((MM2_ITEMS_UNLOCKED, new_items.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x21)) + else: + # append to the queue, so we handle it later + self.item_queue.append(item) + recv_amount += 1 + writes.append((MM2_RECEIVED_ITEMS, recv_amount.to_bytes(1, 'little'), "RAM")) + + if energy_link_packet[0]: + pickup = energy_link_packet[0] + if pickup in (0x76, 0x77): + # Health pickups + if pickup == 0x77: + value = 2 + else: + value = 10 + exchange_rate = HP_EXCHANGE_RATE + elif pickup in (0x78, 0x79): + # Weapon Energy + if pickup == 0x79: + value = 2 + else: + value = 10 + exchange_rate = WEAPON_EXCHANGE_RATE + elif pickup == 0x7B: + # 1-Up + value = 1 + exchange_rate = ONEUP_EXCHANGE_RATE + else: + # if we managed to pickup something else, we should just fall through + value = 0 + exchange_rate = 0 + contribution = (value * exchange_rate) >> 1 + if contribution: + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": contribution}, + {"operation": "max", "value": 0}]}]) + logger.info(f"Deposited {contribution / HP_EXCHANGE_RATE} health into the pool.") + writes.append((MM2_ENERGYLINK, 0x00.to_bytes(1, "little"), "RAM")) + + if self.weapon_energy: + # Weapon Energy + # We parse the whole thing to spread it as thin as possible + current_energy = self.weapon_energy + weapon_energy = bytearray(weapon_energy) + for i, weapon in zip(range(len(weapon_energy)), weapon_energy): + if weapon < 0x1C: + missing = 0x1C - weapon + if missing > self.weapon_energy: + missing = self.weapon_energy + self.weapon_energy -= missing + weapon_energy[i] = weapon + missing + if not self.weapon_energy: + writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM")) + break + else: + if current_energy != self.weapon_energy: + writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM")) + + if self.health_energy or self.auto_heal: + # Health Energy + # We save this if the player has not taken any damage + current_health = health[0] + if 0 < current_health < 0x1C: + health_diff = 0x1C - current_health + if self.health_energy: + if health_diff > self.health_energy: + health_diff = self.health_energy + self.health_energy -= health_diff + else: + pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0) + if health_diff * HP_EXCHANGE_RATE > pool: + health_diff = int(pool // HP_EXCHANGE_RATE) + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": -health_diff * HP_EXCHANGE_RATE}, + {"operation": "max", "value": 0}]}]) + current_health += health_diff + writes.append((MM2_HEALTH, current_health.to_bytes(1, 'little'), "RAM")) + + if self.refill_queue: + refill_type, refill_amount = self.refill_queue.pop() + if refill_type == MM2EnergyLinkType.Life: + exchange_rate = HP_EXCHANGE_RATE + elif refill_type == MM2EnergyLinkType.OneUP: + exchange_rate = ONEUP_EXCHANGE_RATE + else: + exchange_rate = WEAPON_EXCHANGE_RATE + pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0) + request = exchange_rate * refill_amount + if request > pool: + logger.warning( + f"Not enough energy to fulfill the request. Maximum request: {pool // exchange_rate}") + else: + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": -request}, + {"operation": "max", "value": 0}]}]) + if refill_type == MM2EnergyLinkType.Life: + refill_ptr = MM2_HEALTH + elif refill_type == MM2EnergyLinkType.OneUP: + refill_ptr = MM2_LIVES + else: + refill_ptr = MM2_WEAPON_ENERGY - 1 + refill_type + current_value = (await read(ctx.bizhawk_ctx, [(refill_ptr, 1, "RAM")]))[0][0] + new_value = min(0x1C if refill_type != MM2EnergyLinkType.OneUP else 99, current_value + refill_amount) + writes.append((refill_ptr, new_value.to_bytes(1, "little"), "RAM")) + + if len(self.item_queue): + item = self.item_queue.pop(0) + idx = item.item & 0xF + if idx == 0: + # 1-Up + current_lives = lives[0] + if current_lives > 99: + self.item_queue.append(item) + else: + current_lives += 1 + writes.append((MM2_LIVES, current_lives.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x42)) + elif idx == 1: + self.weapon_energy += 0xE + writes.extend(get_sfx_writes(0x28)) + elif idx == 2: + self.health_energy += 0xE + writes.extend(get_sfx_writes(0x28)) + elif idx == 3: + # E-Tank + # visuals only allow 4, but we're gonna go up to 9 anyway? May change + current_tanks = e_tanks[0] + if current_tanks < 9: + current_tanks += 1 + writes.append((MM2_E_TANKS, current_tanks.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x42)) + else: + self.item_queue.append(item) + + await write(ctx.bizhawk_ctx, writes) + + new_checks = [] + # check for locations + for i in range(8): + flag = 1 << i + if robot_masters_defeated[0] & flag: + wep_id = 0x880101 + i + if wep_id not in ctx.checked_locations: + new_checks.append(wep_id) + + for i in range(3): + flag = 1 << i + if items_acquired[0] & flag: + itm_id = 0x880111 + i + if itm_id not in ctx.checked_locations: + new_checks.append(itm_id) + + for i in range(0xD): + rbm_id = 0x880001 + i + if completed_stages[i] != 0: + if rbm_id not in ctx.checked_locations: + new_checks.append(rbm_id) + + for consumable in MM2_CONSUMABLE_TABLE: + if consumable not in ctx.checked_locations: + is_checked = consumable_checks[MM2_CONSUMABLE_TABLE[consumable][0]] \ + & MM2_CONSUMABLE_TABLE[consumable][1] + if is_checked: + new_checks.append(consumable) + + for new_check_id in new_checks: + ctx.locations_checked.add(new_check_id) + location = ctx.location_names.lookup_in_game(new_check_id) + nes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/' + f'{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}]) diff --git a/worlds/mm2/color.py b/worlds/mm2/color.py new file mode 100644 index 000000000000..77b39caf3d4f --- /dev/null +++ b/worlds/mm2/color.py @@ -0,0 +1,276 @@ +from typing import Dict, Tuple, List, TYPE_CHECKING, Union +from . import names +from zlib import crc32 +import struct +import logging + +if TYPE_CHECKING: + from . import MM2World + from .rom import MM2ProcedurePatch + +HTML_TO_NES: Dict[str, int] = { + "SNOW": 0x20, + "LINEN": 0x36, + "SEASHELL": 0x36, + "AZURE": 0x3C, + "LAVENDER": 0x33, + "WHITE": 0x30, + "BLACK": 0x0F, + "GREY": 0x00, + "GRAY": 0x00, + "ROYALBLUE": 0x12, + "BLUE": 0x11, + "SKYBLUE": 0x21, + "LIGHTBLUE": 0x31, + "TURQUOISE": 0x2B, + "CYAN": 0x2C, + "AQUAMARINE": 0x3B, + "DARKGREEN": 0x0A, + "GREEN": 0x1A, + "YELLOW": 0x28, + "GOLD": 0x28, + "WHEAT": 0x37, + "TAN": 0x37, + "CHOCOLATE": 0x07, + "BROWN": 0x07, + "SALMON": 0x26, + "ORANGE": 0x27, + "CORAL": 0x36, + "TOMATO": 0x16, + "RED": 0x16, + "PINK": 0x25, + "MAROON": 0x06, + "MAGENTA": 0x24, + "FUSCHIA": 0x24, + "VIOLET": 0x24, + "PLUM": 0x33, + "PURPLE": 0x14, + "THISTLE": 0x34, + "DARKBLUE": 0x01, + "SILVER": 0x10, + "NAVY": 0x02, + "TEAL": 0x1C, + "OLIVE": 0x18, + "LIME": 0x2A, + "AQUA": 0x2C, + # can add more as needed +} + +MM2_COLORS: Dict[str, Tuple[int, int]] = { + names.atomic_fire: (0x28, 0x15), + names.air_shooter: (0x20, 0x11), + names.leaf_shield: (0x20, 0x19), + names.bubble_lead: (0x20, 0x00), + names.time_stopper: (0x34, 0x25), + names.quick_boomerang: (0x34, 0x14), + names.metal_blade: (0x37, 0x18), + names.crash_bomber: (0x20, 0x26), + names.item_1: (0x20, 0x16), + names.item_2: (0x20, 0x16), + names.item_3: (0x20, 0x16), + names.heat_man_stage: (0x28, 0x15), + names.air_man_stage: (0x28, 0x11), + names.wood_man_stage: (0x36, 0x17), + names.bubble_man_stage: (0x30, 0x19), + names.quick_man_stage: (0x28, 0x15), + names.flash_man_stage: (0x30, 0x12), + names.metal_man_stage: (0x28, 0x15), + names.crash_man_stage: (0x30, 0x16) +} + +MM2_KNOWN_COLORS: Dict[str, Tuple[int, int]] = { + **MM2_COLORS, + # Street Fighter, technically + "Hadouken": (0x3C, 0x11), + "Shoryuken": (0x38, 0x16), + # X Series + "Z-Saber": (0x20, 0x16), + # X1 + "Homing Torpedo": (0x3D, 0x37), + "Chameleon Sting": (0x3B, 0x1A), + "Rolling Shield": (0x3A, 0x25), + "Fire Wave": (0x37, 0x26), + "Storm Tornado": (0x34, 0x14), + "Electric Spark": (0x3D, 0x28), + "Boomerang Cutter": (0x3B, 0x2D), + "Shotgun Ice": (0x28, 0x2C), + # X2 + "Crystal Hunter": (0x33, 0x21), + "Bubble Splash": (0x35, 0x28), + "Spin Wheel": (0x34, 0x1B), + "Silk Shot": (0x3B, 0x27), + "Sonic Slicer": (0x27, 0x01), + "Strike Chain": (0x30, 0x23), + "Magnet Mine": (0x28, 0x2D), + "Speed Burner": (0x31, 0x16), + # X3 + "Acid Burst": (0x28, 0x2A), + "Tornado Fang": (0x28, 0x2C), + "Triad Thunder": (0x2B, 0x23), + "Spinning Blade": (0x20, 0x16), + "Ray Splasher": (0x28, 0x17), + "Gravity Well": (0x38, 0x14), + "Parasitic Bomb": (0x31, 0x28), + "Frost Shield": (0x23, 0x2C), +} + +palette_pointers: Dict[str, List[int]] = { + "Mega Buster": [0x3D314], + "Atomic Fire": [0x3D318], + "Air Shooter": [0x3D31C], + "Leaf Shield": [0x3D320], + "Bubble Lead": [0x3D324], + "Quick Boomerang": [0x3D328], + "Time Stopper": [0x3D32C], + "Metal Blade": [0x3D330], + "Crash Bomber": [0x3D334], + "Item 1": [0x3D338], + "Item 2": [0x3D33C], + "Item 3": [0x3D340], + "Heat Man": [0x34B6, 0x344F7], + "Air Man": [0x74B6, 0x344FF], + "Wood Man": [0xB4EC, 0x34507], + "Bubble Man": [0xF4B6, 0x3450F], + "Quick Man": [0x134C8, 0x34517], + "Flash Man": [0x174B6, 0x3451F], + "Metal Man": [0x1B4A4, 0x34527], + "Crash Man": [0x1F4EC, 0x3452F], +} + + +def add_color_to_mm2(name: str, color: Tuple[int, int]) -> None: + """ + Add a color combo for Mega Man 2 to recognize as the color to display for a given item. + For information on available colors: https://www.nesdev.org/wiki/PPU_palettes#2C02 + """ + MM2_KNOWN_COLORS[name] = validate_colors(*color) + + +def extrapolate_color(color: int) -> Tuple[int, int]: + if color > 0x1F: + color_1 = color + color_2 = color_1 - 0x10 + else: + color_2 = color + color_1 = color_2 + 0x10 + return color_1, color_2 + + +def validate_colors(color_1: int, color_2: int, allow_match: bool = False) -> Tuple[int, int]: + # Black should be reserved for outlines, a gray should suffice + if color_1 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]: + color_1 = 0x10 + if color_2 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]: + color_2 = 0x10 + + # one final check, make sure we don't have two matching + if not allow_match and color_1 == color_2: + color_1 = 0x30 # color 1 to white works with about any paired color + + return color_1, color_2 + + +def get_colors_for_item(name: str) -> Tuple[int, int]: + if name in MM2_KNOWN_COLORS: + return MM2_KNOWN_COLORS[name] + + check_colors = {color: color in name.upper().replace(" ", "") for color in HTML_TO_NES} + colors = [color for color in check_colors if check_colors[color]] + if colors: + # we have at least one color pattern matched + if len(colors) > 1: + # we have at least 2 + color_1 = HTML_TO_NES[colors[0]] + color_2 = HTML_TO_NES[colors[1]] + else: + color_1, color_2 = extrapolate_color(HTML_TO_NES[colors[0]]) + else: + # generate hash + crc_hash = crc32(name.encode("utf-8")) + hash_color = struct.pack("I", crc_hash) + color_1 = hash_color[0] % 0x3F + color_2 = hash_color[1] % 0x3F + + if color_1 < color_2: + temp = color_1 + color_1 = color_2 + color_2 = temp + + color_1, color_2 = validate_colors(color_1, color_2) + + return color_1, color_2 + + +def parse_color(colors: List[str]) -> Tuple[int, int]: + color_a = colors[0] + if color_a.startswith("$"): + color_1 = int(color_a[1:], 16) + else: + # assume it's in our list of colors + color_1 = HTML_TO_NES[color_a.upper()] + + if len(colors) == 1: + color_1, color_2 = extrapolate_color(color_1) + else: + color_b = colors[1] + if color_b.startswith("$"): + color_2 = int(color_b[1:], 16) + else: + color_2 = HTML_TO_NES[color_b.upper()] + return color_1, color_2 + + +def write_palette_shuffle(world: "MM2World", rom: "MM2ProcedurePatch") -> None: + palette_shuffle: Union[int, str] = world.options.palette_shuffle.value + palettes_to_write: Dict[str, Tuple[int, int]] = {} + if isinstance(palette_shuffle, str): + color_sets = palette_shuffle.split(";") + if len(color_sets) == 1: + palette_shuffle = world.options.palette_shuffle.option_none + # singularity is more correct, but this is faster + else: + palette_shuffle = world.options.palette_shuffle.options[color_sets.pop()] + for color_set in color_sets: + if "-" in color_set: + character, color = color_set.split("-") + if character.title() not in palette_pointers: + logging.warning(f"Player {world.multiworld.get_player_name(world.player)} " + f"attempted to set color for unrecognized option {character}") + colors = color.split("|") + real_colors = validate_colors(*parse_color(colors), allow_match=True) + palettes_to_write[character.title()] = real_colors + else: + # If color is provided with no character, assume singularity + colors = color_set.split("|") + real_colors = validate_colors(*parse_color(colors), allow_match=True) + for character in palette_pointers: + palettes_to_write[character] = real_colors + # Now we handle the real values + if palette_shuffle == 1: + shuffled_colors = list(MM2_COLORS.values()) + shuffled_colors.append((0x2C, 0x11)) # Mega Buster + world.random.shuffle(shuffled_colors) + for character in palette_pointers: + if character not in palettes_to_write: + palettes_to_write[character] = shuffled_colors.pop() + elif palette_shuffle > 1: + if palette_shuffle == 2: + for character in palette_pointers: + if character not in palettes_to_write: + real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F)) + palettes_to_write[character] = real_colors + else: + # singularity + real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F)) + for character in palette_pointers: + if character not in palettes_to_write: + palettes_to_write[character] = real_colors + + for character in palettes_to_write: + for pointer in palette_pointers[character]: + rom.write_bytes(pointer, bytes(palettes_to_write[character])) + + if character == "Atomic Fire": + # special case, we need to update Atomic Fire's flashing routine + rom.write_byte(0x3DE4A, palettes_to_write[character][1]) + rom.write_byte(0x3DE4C, palettes_to_write[character][1]) diff --git a/worlds/mm2/data/mm2_basepatch.bsdiff4 b/worlds/mm2/data/mm2_basepatch.bsdiff4 new file mode 100644 index 0000000000000000000000000000000000000000..8f3c17c3c7af32fa06906208f5d94058d7c2cea8 GIT binary patch literal 1440 zcmV;R1z-9?Q$$HdMl>*`000000002o0ssI20000G00aO40000&T4*&fL0KkKS)8}E z3;+Om|L=OdD*yllz6?MBK!6G(Mg~D3KoS9vKmZ^B01zMmXlJAV8UQ^bMi2l7jVY#) zqd+tO$TU4BpQ;cdD`{y?Fv>?~gxj9sB*IH8D%Z)S6=j+uLNNdU0ACeh7$Ql#umfZY z04`uef}&|Jd;=~D2!RhNy)FM*Gd&e6xt=+c6j9phF~ta3&+&I8Q-ui$%X?73LRx4w zF+o`-Q(58#Oo#vi9e@A-_y65}LmuD%cmD*(6|tT9kyIU7L=aU~f8F2zXa9e|0nQxf zHN{Dk(diFUBPNXongQxG007fNAO?U2o`e`6$Z6<+G7U5anjWXArh_A8G%BX_r=m1s zG8!5h223E)pk&C<0s4@{(;&zV42=QmG-L*hnrP9W85#^sffGrRnE~nzJw|{H0002c zG#UT^000000LTCU0MkHvK_sY#hNc=5O$>tp4HyJ8$)-a9AZTI@4KXlG5rHx?WXNd5 zG{G`p$qTLnqPUy2X~4|l6wsceav~I}gak#=s)%TiKpB#*8L`1Oni1T=&_SU^O8pf+ z;vvZ&6(`t!)zE=Ewe-$uSmkj#E>OR$leRz12^*}CxXgq+VI0O10Ec9P4S)d9U=W_L zAbh|jyuv^Wj_JfQ5USV&`H}>HAsHbglGtR0&E>+m1UO`c#dzV<4+cOal6C91fu?^= zm#Pq~$_h^u7(*AxK#(aG3_z43LNXa~1}TvA%+G3W5OzmD#Q$XhlCt!v!bsu;A2$`KQ z>_6lX8J)j)$`2`DcEYhK7>^%k{Z78}kXqdyB>y7U^}Fwvp3=p9pYK=YezwdM+j}AtK6U#v%Z@&43bO1wKK=m5{@Ka=cYA*dhYC zK;r~*D}UIA9U;qFA_rQ|QuzW#oOz{E0Y(WM`THtz9en1Cca6kx4XkWsx z0dozIY9NRUy42O&I_h z02%-TKpF$o(9jJ5B%)0+JwN~r0D6D`WNDz&O&S>hH1z-g85cy2`nh~04FJU8awD+% zije{AL%=Sx5*))pr0xy&ft3bSLkm9ZmtnSCa>C@$a95)gEt9ZV^MpQ`u8F&wB>Kv2 z&j}+=rE`e?e~ihp8wJ3Ce-4zf5)w5MHi;<_3W+3VZ5f;6yE24$u^AvMhn72?Q{hE| zq!K0qLjp$NBuxa2xE^@voCyp<-ALgPEx6DHYp#Ms@JMkxM#K^!q)blOhCveoN}7?MFL zSn^d ` Only present with EnergyLink, sends a request of a certain type of energy to be pulled from +the EnergyLink. Types are as follows: + - `HP` Health + - `AF` Atomic Fire + - `AS` Air Shooter + - `LS` Leaf Shield + - `BL` Bubble Lead + - `QB` Quick Boomerang + - `TS` Time Stopper + - `MB` Metal Blade + - `CB` Crash Bomber + - `I1` Item 1 + - `I2` Item 2 + - `I3` Item 3 + - `1U` Lives \ No newline at end of file diff --git a/worlds/mm2/docs/setup_en.md b/worlds/mm2/docs/setup_en.md new file mode 100644 index 000000000000..3b8f833b9967 --- /dev/null +++ b/worlds/mm2/docs/setup_en.md @@ -0,0 +1,53 @@ +# Mega Man 2 Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) +- An English Mega Man 2 ROM. Alternatively, the [Mega Man Legacy Collection](https://store.steampowered.com/app/363440/Mega_Man_Legacy_Collection/) on Steam. +- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later + +### Configuring Bizhawk + +Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings: + +- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from +`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.) +- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're +tabbed out of EmuHawk. +- Open a `.nes` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click +`Controllers…`, load any `.nes` ROM first. +- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to +clear it. + +## Generating and Patching a Game + +1. Create your options file (YAML). You can make one on the +[Mega Man 2 options page](../../../games/Mega%20Man%202/player-options). +2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game). +This will generate an output file for you. Your patch file will have the `.apmm2` file extension. +3. Open `ArchipelagoLauncher.exe` +4. Select "Open Patch" on the left side and select your patch file. +5. If this is your first time patching, you will be prompted to locate your vanilla ROM. If you are using the Legacy +Collection, provide `Proteus.exe` in place of your rom. +6. A patched `.nes` file will be created in the same place as the patch file. +7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your +BizHawk install. + +## Connecting to a Server + +By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just +in case you have to close and reopen a window mid-game for some reason. + +1. Mega Man 2 uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game, +you can re-open it from the launcher. +2. Ensure EmuHawk is running the patched ROM. +3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing. +4. In the Lua Console window, go to `Script > Open Script…`. +5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`. +6. The emulator and client will eventually connect to each other. The BizHawk Client window should indicate that it +connected and recognized Mega Man 2. +7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the +top text field of the client and click Connect. + +You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is +perfectly safe to make progress offline; everything will re-sync when you reconnect. diff --git a/worlds/mm2/items.py b/worlds/mm2/items.py new file mode 100644 index 000000000000..e644b171dded --- /dev/null +++ b/worlds/mm2/items.py @@ -0,0 +1,72 @@ +from BaseClasses import Item +from typing import NamedTuple, Dict +from . import names + + +class ItemData(NamedTuple): + code: int + progression: bool + useful: bool = False # primarily use this for incredibly useful items of their class, like Metal Blade + skip_balancing: bool = False + + +class MM2Item(Item): + game = "Mega Man 2" + + +robot_master_weapon_table = { + names.atomic_fire: ItemData(0x880001, True), + names.air_shooter: ItemData(0x880002, True), + names.leaf_shield: ItemData(0x880003, True), + names.bubble_lead: ItemData(0x880004, True), + names.quick_boomerang: ItemData(0x880005, True), + names.time_stopper: ItemData(0x880006, True, True), + names.metal_blade: ItemData(0x880007, True, True), + names.crash_bomber: ItemData(0x880008, True), +} + +stage_access_table = { + names.heat_man_stage: ItemData(0x880101, True), + names.air_man_stage: ItemData(0x880102, True), + names.wood_man_stage: ItemData(0x880103, True), + names.bubble_man_stage: ItemData(0x880104, True), + names.quick_man_stage: ItemData(0x880105, True), + names.flash_man_stage: ItemData(0x880106, True), + names.metal_man_stage: ItemData(0x880107, True), + names.crash_man_stage: ItemData(0x880108, True), +} + +item_item_table = { + names.item_1: ItemData(0x880011, True, True, True), + names.item_2: ItemData(0x880012, True, True, True), + names.item_3: ItemData(0x880013, True, True, True) +} + +filler_item_table = { + names.one_up: ItemData(0x880020, False), + names.weapon_energy: ItemData(0x880021, False), + names.health_energy: ItemData(0x880022, False), + names.e_tank: ItemData(0x880023, False, True), +} + +filler_item_weights = { + names.one_up: 1, + names.weapon_energy: 4, + names.health_energy: 1, + names.e_tank: 2, +} + +item_table = { + **robot_master_weapon_table, + **stage_access_table, + **item_item_table, + **filler_item_table, +} + +item_names = { + "Weapons": {name for name in robot_master_weapon_table.keys()}, + "Stages": {name for name in stage_access_table.keys()}, + "Items": {name for name in item_item_table.keys()} +} + +lookup_item_to_id: Dict[str, int] = {item_name: data.code for item_name, data in item_table.items()} diff --git a/worlds/mm2/locations.py b/worlds/mm2/locations.py new file mode 100644 index 000000000000..4807d25d6992 --- /dev/null +++ b/worlds/mm2/locations.py @@ -0,0 +1,239 @@ +from BaseClasses import Location, Region +from typing import Dict, Tuple, Optional +from . import names + + +class MM2Location(Location): + game = "Mega Man 2" + + +class MM2Region(Region): + game = "Mega Man 2" + + +heat_man_locations: Dict[str, Optional[int]] = { + names.heat_man: 0x880001, + names.atomic_fire_get: 0x880101, + names.item_1_get: 0x880111, +} + +air_man_locations: Dict[str, Optional[int]] = { + names.air_man: 0x880002, + names.air_shooter_get: 0x880102, + names.item_2_get: 0x880112 +} + +wood_man_locations: Dict[str, Optional[int]] = { + names.wood_man: 0x880003, + names.leaf_shield_get: 0x880103 +} + +bubble_man_locations: Dict[str, Optional[int]] = { + names.bubble_man: 0x880004, + names.bubble_lead_get: 0x880104 +} + +quick_man_locations: Dict[str, Optional[int]] = { + names.quick_man: 0x880005, + names.quick_boomerang_get: 0x880105, +} + +flash_man_locations: Dict[str, Optional[int]] = { + names.flash_man: 0x880006, + names.time_stopper_get: 0x880106, + names.item_3_get: 0x880113, +} + +metal_man_locations: Dict[str, Optional[int]] = { + names.metal_man: 0x880007, + names.metal_blade_get: 0x880107 +} + +crash_man_locations: Dict[str, Optional[int]] = { + names.crash_man: 0x880008, + names.crash_bomber_get: 0x880108 +} + +wily_1_locations: Dict[str, Optional[int]] = { + names.wily_1: 0x880009, + names.wily_stage_1: None +} + +wily_2_locations: Dict[str, Optional[int]] = { + names.wily_2: 0x88000A, + names.wily_stage_2: None +} + +wily_3_locations: Dict[str, Optional[int]] = { + names.wily_3: 0x88000B, + names.wily_stage_3: None +} + +wily_4_locations: Dict[str, Optional[int]] = { + names.wily_4: 0x88000C, + names.wily_stage_4: None +} + +wily_5_locations: Dict[str, Optional[int]] = { + names.wily_5: 0x88000D, + names.wily_stage_5: None +} + +wily_6_locations: Dict[str, Optional[int]] = { + names.dr_wily: None +} + +etank_1ups: Dict[str, Dict[str, Optional[int]]] = { + "Heat Man Stage": { + names.heat_man_c1: 0x880201, + }, + "Quick Man Stage": { + names.quick_man_c1: 0x880202, + names.quick_man_c2: 0x880203, + names.quick_man_c3: 0x880204, + names.quick_man_c7: 0x880208, + }, + "Flash Man Stage": { + names.flash_man_c2: 0x88020B, + names.flash_man_c6: 0x88020F, + }, + "Metal Man Stage": { + names.metal_man_c1: 0x880210, + names.metal_man_c2: 0x880211, + names.metal_man_c3: 0x880212, + }, + "Crash Man Stage": { + names.crash_man_c2: 0x880214, + names.crash_man_c3: 0x880215, + }, + "Wily Stage 1": { + names.wily_1_c1: 0x880216, + }, + "Wily Stage 2": { + names.wily_2_c3: 0x88021A, + names.wily_2_c4: 0x88021B, + names.wily_2_c5: 0x88021C, + names.wily_2_c6: 0x88021D, + }, + "Wily Stage 3": { + names.wily_3_c2: 0x880220, + }, + "Wily Stage 4": { + names.wily_4_c3: 0x880225, + names.wily_4_c4: 0x880226, + } +} + +energy_pickups: Dict[str, Dict[str, Optional[int]]] = { + "Quick Man Stage": { + names.quick_man_c4: 0x880205, + names.quick_man_c5: 0x880206, + names.quick_man_c6: 0x880207, + names.quick_man_c8: 0x880209, + }, + "Flash Man Stage": { + names.flash_man_c1: 0x88020A, + names.flash_man_c3: 0x88020C, + names.flash_man_c4: 0x88020D, + names.flash_man_c5: 0x88020E, + }, + "Crash Man Stage": { + names.crash_man_c1: 0x880213, + }, + "Wily Stage 1": { + names.wily_1_c2: 0x880217, + }, + "Wily Stage 2": { + names.wily_2_c1: 0x880218, + names.wily_2_c2: 0x880219, + names.wily_2_c7: 0x88021E, + names.wily_2_c8: 0x880227, + names.wily_2_c9: 0x880228, + names.wily_2_c10: 0x880229, + names.wily_2_c11: 0x88022A, + names.wily_2_c12: 0x88022B, + names.wily_2_c13: 0x88022C, + names.wily_2_c14: 0x88022D, + names.wily_2_c15: 0x88022E, + names.wily_2_c16: 0x88022F, + }, + "Wily Stage 3": { + names.wily_3_c1: 0x88021F, + names.wily_3_c3: 0x880221, + names.wily_3_c4: 0x880222, + }, + "Wily Stage 4": { + names.wily_4_c1: 0x880223, + names.wily_4_c2: 0x880224, + } +} + +mm2_regions: Dict[str, Tuple[Tuple[str, ...], Dict[str, Optional[int]], Optional[str]]] = { + "Heat Man Stage": ((names.heat_man_stage,), heat_man_locations, None), + "Air Man Stage": ((names.air_man_stage,), air_man_locations, None), + "Wood Man Stage": ((names.wood_man_stage,), wood_man_locations, None), + "Bubble Man Stage": ((names.bubble_man_stage,), bubble_man_locations, None), + "Quick Man Stage": ((names.quick_man_stage,), quick_man_locations, None), + "Flash Man Stage": ((names.flash_man_stage,), flash_man_locations, None), + "Metal Man Stage": ((names.metal_man_stage,), metal_man_locations, None), + "Crash Man Stage": ((names.crash_man_stage,), crash_man_locations, None), + "Wily Stage 1": ((names.item_1, names.item_2, names.item_3), wily_1_locations, None), + "Wily Stage 2": ((names.wily_stage_1,), wily_2_locations, "Wily Stage 1"), + "Wily Stage 3": ((names.wily_stage_2,), wily_3_locations, "Wily Stage 2"), + "Wily Stage 4": ((names.wily_stage_3,), wily_4_locations, "Wily Stage 3"), + "Wily Stage 5": ((names.wily_stage_4,), wily_5_locations, "Wily Stage 4"), + "Wily Stage 6": ((names.wily_stage_5,), wily_6_locations, "Wily Stage 5") +} + +location_table: Dict[str, Optional[int]] = { + **heat_man_locations, + **air_man_locations, + **wood_man_locations, + **bubble_man_locations, + **quick_man_locations, + **flash_man_locations, + **metal_man_locations, + **crash_man_locations, + **wily_1_locations, + **wily_2_locations, + **wily_3_locations, + **wily_4_locations, + **wily_5_locations, +} + +for table in etank_1ups: + location_table.update(etank_1ups[table]) + +for table in energy_pickups: + location_table.update(energy_pickups[table]) + +location_groups = { + "Get Equipped": { + names.atomic_fire_get, + names.air_shooter_get, + names.leaf_shield_get, + names.bubble_lead_get, + names.quick_boomerang_get, + names.time_stopper_get, + names.metal_blade_get, + names.crash_bomber_get, + names.item_1_get, + names.item_2_get, + names.item_3_get + }, + "Heat Man Stage": {*heat_man_locations.keys(), *etank_1ups["Heat Man Stage"].keys()}, + "Air Man Stage": {*air_man_locations.keys()}, + "Wood Man Stage": {*wood_man_locations.keys()}, + "Bubble Man Stage": {*bubble_man_locations.keys()}, + "Quick Man Stage": {*quick_man_locations.keys(), *etank_1ups["Quick Man Stage"].keys(), + *energy_pickups["Quick Man Stage"].keys()}, + "Flash Man Stage": {*flash_man_locations.keys(), *etank_1ups["Flash Man Stage"].keys(), + *energy_pickups["Flash Man Stage"].keys()}, + "Metal Man Stage": {*metal_man_locations.keys(), *etank_1ups["Metal Man Stage"].keys()}, + "Crash Man Stage": {*crash_man_locations.keys(), *etank_1ups["Crash Man Stage"].keys(), + *energy_pickups["Crash Man Stage"].keys()}, + "Wily 2 Weapon Energy": {names.wily_2_c8, names.wily_2_c9, names.wily_2_c10, names.wily_2_c11, names.wily_2_c12, + names.wily_2_c13, names.wily_2_c14, names.wily_2_c15, names.wily_2_c16} +} + +lookup_location_to_id: Dict[str, int] = {location: idx for location, idx in location_table.items() if idx is not None} diff --git a/worlds/mm2/names.py b/worlds/mm2/names.py new file mode 100644 index 000000000000..fbbea85f0317 --- /dev/null +++ b/worlds/mm2/names.py @@ -0,0 +1,114 @@ +# Robot Master Weapons +crash_bomber = "Crash Bomber" +metal_blade = "Metal Blade" +quick_boomerang = "Quick Boomerang" +bubble_lead = "Bubble Lead" +atomic_fire = "Atomic Fire" +leaf_shield = "Leaf Shield" +time_stopper = "Time Stopper" +air_shooter = "Air Shooter" + +# Stage Entry +crash_man_stage = "Crash Man Access Codes" +metal_man_stage = "Metal Man Access Codes" +quick_man_stage = "Quick Man Access Codes" +bubble_man_stage = "Bubble Man Access Codes" +heat_man_stage = "Heat Man Access Codes" +wood_man_stage = "Wood Man Access Codes" +flash_man_stage = "Flash Man Access Codes" +air_man_stage = "Air Man Access Codes" + +# The Items +item_1 = "Item 1 - Propeller" +item_2 = "Item 2 - Rocket" +item_3 = "Item 3 - Bouncy" + +# Misc. Items +one_up = "1-Up" +weapon_energy = "Weapon Energy (L)" +health_energy = "Health Energy (L)" +e_tank = "E-Tank" + +# Locations +crash_man = "Crash Man - Defeated" +metal_man = "Metal Man - Defeated" +quick_man = "Quick Man - Defeated" +bubble_man = "Bubble Man - Defeated" +heat_man = "Heat Man - Defeated" +wood_man = "Wood Man - Defeated" +flash_man = "Flash Man - Defeated" +air_man = "Air Man - Defeated" +crash_bomber_get = "Crash Bomber - Received" +metal_blade_get = "Metal Blade - Received" +quick_boomerang_get = "Quick Boomerang - Received" +bubble_lead_get = "Bubble Lead - Received" +atomic_fire_get = "Atomic Fire - Received" +leaf_shield_get = "Leaf Shield - Received" +time_stopper_get = "Time Stopper - Received" +air_shooter_get = "Air Shooter - Received" +item_1_get = "Item 1 - Received" +item_2_get = "Item 2 - Received" +item_3_get = "Item 3 - Received" +wily_1 = "Mecha Dragon - Defeated" +wily_2 = "Picopico-kun - Defeated" +wily_3 = "Guts Tank - Defeated" +wily_4 = "Boobeam Trap - Defeated" +wily_5 = "Wily Machine 2 - Defeated" +dr_wily = "Dr. Wily (Alien) - Defeated" + +# Wily Stage Event Items +wily_stage_1 = "Wily Stage 1 - Completed" +wily_stage_2 = "Wily Stage 2 - Completed" +wily_stage_3 = "Wily Stage 3 - Completed" +wily_stage_4 = "Wily Stage 4 - Completed" +wily_stage_5 = "Wily Stage 5 - Completed" + +# Consumable Locations +heat_man_c1 = "Heat Man Stage - 1-Up" # 3, requires Yoku jumps or Item 2 +flash_man_c1 = "Flash Man Stage - Health Energy 1" # 0 +flash_man_c2 = "Flash Man Stage - 1-Up" # 2, requires any Item +flash_man_c3 = "Flash Man Stage - Health Energy 2" # 6, requires Crash Bomber +flash_man_c4 = "Flash Man Stage - Weapon Energy 1" # 8, requires Crash Bomber +flash_man_c5 = "Flash Man Stage - Health Energy 3" # 9 +flash_man_c6 = "Flash Man Stage - E-Tank" # 10 +quick_man_c1 = "Quick Man Stage - 1-Up 1" # 0, needs any Item +quick_man_c2 = "Quick Man Stage - E-Tank" # 1, requires allow lasers or Time Stopper +quick_man_c3 = "Quick Man Stage - 1-Up 2" # 2, requires allow lasers or Time Stopper +quick_man_c4 = "Quick Man Stage - Weapon Energy 1" # 3, requires allow lasers or Time Stopper +quick_man_c5 = "Quick Man Stage - Weapon Energy 2" # 4, requires allow lasers or Time Stopper +quick_man_c6 = "Quick Man Stage - Health Energy" # 5, requires allow lasers or Time Stopper +quick_man_c7 = "Quick Man Stage - 1-Up 3" # 6, requires allow lasers or Time Stopper +quick_man_c8 = "Quick Man Stage - Weapon Energy 3" # 7, requires allow lasers or Time Stopper +metal_man_c1 = "Metal Man Stage - E-Tank 1" # 0 +metal_man_c2 = "Metal Man Stage - 1-Up" # 1, needs Item 1/2 +metal_man_c3 = "Metal Man Stage - E-Tank 2" # 2, needs Item 1/2 (without putting dying in logic at least) +crash_man_c1 = "Crash Man Stage - Health Energy" # 0 +crash_man_c2 = "Crash Man Stage - E-Tank" # 1 +crash_man_c3 = "Crash Man Stage - 1-Up" # 2, any Item +wily_1_c1 = "Wily Stage 1 - 1-Up" # 10 +wily_1_c2 = "Wily Stage 1 - Weapon Energy 1" # 11 +wily_2_c1 = "Wily Stage 2 - Weapon Energy 1" # 11 +wily_2_c2 = "Wily Stage 2 - Weapon Energy 2" # 12 +wily_2_c3 = "Wily Stage 2 - E-Tank 1" # 16 +wily_2_c4 = "Wily Stage 2 - 1-Up 1" # 17 +# 18 - 27 are all small weapon energies, might force these local junk? +wily_2_c8 = "Wily Stage 2 - Weapon Energy 3" # 18 +wily_2_c9 = "Wily Stage 2 - Weapon Energy 4" # 19 +wily_2_c10 = "Wily Stage 2 - Weapon Energy 5" # 20 +wily_2_c11 = "Wily Stage 2 - Weapon Energy 6" # 21 +wily_2_c12 = "Wily Stage 2 - Weapon Energy 7" # 22 +wily_2_c13 = "Wily Stage 2 - Weapon Energy 8" # 23 +wily_2_c14 = "Wily Stage 2 - Weapon Energy 9" # 24 +wily_2_c15 = "Wily Stage 2 - Weapon Energy 10" # 25 +wily_2_c16 = "Wily Stage 2 - Weapon Energy 11" # 26 +wily_2_c5 = "Wily Stage 2 - 1-Up 2" # 29, requires Crash Bomber +wily_2_c6 = "Wily Stage 2 - E-Tank 2" # 30, requires Crash Bomber +wily_2_c7 = "Wily Stage 2 - Health Energy" # 31, item 2 (already required to reach wily 2) +wily_3_c1 = "Wily Stage 3 - Weapon Energy 1" # 12, requires Crash Bomber +wily_3_c2 = "Wily Stage 3 - E-Tank" # 17, requires Crash Bomber +wily_3_c3 = "Wily Stage 3 - Weapon Energy 2" # 18 +wily_3_c4 = "Wily Stage 3 - Weapon Energy 3" # 19 +wily_4_c1 = "Wily Stage 4 - Weapon Energy 1" # 16 +wily_4_c2 = "Wily Stage 4 - Weapon Energy 2" # 17 +wily_4_c3 = "Wily Stage 4 - 1-Up 1" # 18 +wily_4_c4 = "Wily Stage 4 - E-Tank 1" # 19 diff --git a/worlds/mm2/options.py b/worlds/mm2/options.py new file mode 100644 index 000000000000..2d90395cacda --- /dev/null +++ b/worlds/mm2/options.py @@ -0,0 +1,229 @@ +from dataclasses import dataclass + +from Options import Choice, Toggle, DeathLink, DefaultOnToggle, TextChoice, Range, OptionDict, PerGameCommonOptions +from schema import Schema, And, Use, Optional + +bosses = { + "Heat Man": 0, + "Air Man": 1, + "Wood Man": 2, + "Bubble Man": 3, + "Quick Man": 4, + "Flash Man": 5, + "Metal Man": 6, + "Crash Man": 7, + "Mecha Dragon": 8, + "Picopico-kun": 9, + "Guts Tank": 10, + "Boobeam Trap": 11, + "Wily Machine 2": 12, + "Alien": 13 +} + +weapons_to_id = { + "Mega Buster": 0, + "Atomic Fire": 1, + "Air Shooter": 2, + "Leaf Shield": 3, + "Bubble Lead": 4, + "Quick Boomerang": 5, + "Metal Blade": 7, + "Crash Bomber": 6, + "Time Stopper": 8, +} + + +class EnergyLink(Toggle): + """ + Enables EnergyLink support. + When enabled, pickups dropped from enemies are sent to the EnergyLink pool, and healing/weapon energy/1-Ups can + be requested from the EnergyLink pool. + Some of the energy sent to the pool will be lost on transfer. + """ + display_name = "EnergyLink" + + +class StartingRobotMaster(Choice): + """ + The initial stage unlocked at the start. + """ + display_name = "Starting Robot Master" + option_heat_man = 0 + option_air_man = 1 + option_wood_man = 2 + option_bubble_man = 3 + option_quick_man = 4 + option_flash_man = 5 + option_metal_man = 6 + option_crash_man = 7 + default = "random" + + +class YokuJumps(Toggle): + """ + When enabled, the player is expected to be able to perform the yoku block sequence in Heat Man's + stage without Item 2. + """ + display_name = "Yoku Block Jumps" + + +class EnableLasers(Toggle): + """ + When enabled, the player is expected to complete (and acquire items within) the laser sections of Quick Man's + stage without the Time Stopper. + """ + display_name = "Enable Lasers" + + +class Consumables(Choice): + """ + When enabled, e-tanks/1-ups/health/weapon energy will be added to the pool of items and included as checks. + E-Tanks and 1-Ups add 20 checks to the pool. + Weapon/Health Energy add 27 checks to the pool. + """ + display_name = "Consumables" + option_none = 0 + option_1up_etank = 1 + option_weapon_health = 2 + option_all = 3 + default = 1 + alias_true = 3 + alias_false = 0 + + @classmethod + def get_option_name(cls, value: int) -> str: + if value == 1: + return "1-Ups/E-Tanks" + if value == 2: + return "Weapon/Health Energy" + return super().get_option_name(value) + + +class Quickswap(DefaultOnToggle): + """ + When enabled, the player can quickswap through all received weapons by pressing Select. + """ + display_name = "Quickswap" + + +class PaletteShuffle(TextChoice): + """ + Change the color of Mega Man and the Robot Masters. + None: The palettes are unchanged. + Shuffled: Palette colors are shuffled amongst the robot masters. + Randomized: Random (usually good) palettes are generated for each robot master. + Singularity: one palette is generated and used for all robot masters. + Supports custom palettes using HTML named colors in the + following format: Mega Buster-Lavender|Violet;randomized + The first value is the character whose palette you'd like to define, then separated by - is a set of 2 colors for + that character. separate every color with a pipe, and separate every character as well as the remaining shuffle with + a semicolon. + """ + display_name = "Palette Shuffle" + option_none = 0 + option_shuffled = 1 + option_randomized = 2 + option_singularity = 3 + + +class EnemyWeaknesses(Toggle): + """ + Randomizes the damage dealt to enemies by weapons. Friender will always take damage from the buster. + """ + display_name = "Random Enemy Weaknesses" + + +class StrictWeaknesses(Toggle): + """ + Only your starting Robot Master will take damage from the Mega Buster, the rest must be defeated with weapons. + Weapons that only do 1-3 damage to bosses no longer deal damage (aside from Alien). + """ + display_name = "Strict Boss Weaknesses" + + +class RandomWeaknesses(Choice): + """ + None: Bosses will have their regular weaknesses. + Shuffled: Weapon damage will be shuffled amongst the weapons, so Metal Blade may do Bubble Lead damage. + Time Stopper will deplete half of a random Robot Master's HP. + Randomized: Weapon damage will be fully randomized. + """ + display_name = "Random Boss Weaknesses" + option_none = 0 + option_shuffled = 1 + option_randomized = 2 + alias_false = 0 + alias_true = 2 + + +class Wily5Requirement(Range): + """Change the number of Robot Masters that are required to be defeated for + the teleporter to the Wily Machine to appear.""" + display_name = "Wily 5 Requirement" + default = 8 + range_start = 1 + range_end = 8 + + +class WeaknessPlando(OptionDict): + """ + Specify specific damage numbers for boss damage. Can be used even without strict/random weaknesses. + plando_weakness: + Robot Master: + Weapon: Damage + """ + display_name = "Plando Weaknesses" + schema = Schema({ + Optional(And(str, Use(str.title), lambda s: s in bosses)): { + And(str, Use(str.title), lambda s: s in weapons_to_id): And(int, lambda i: i in range(-1, 14)) + } + }) + default = {} + + +class ReduceFlashing(Choice): + """ + Reduce flashing seen in gameplay, such as the stage select and when defeating a Wily boss. + Virtual Console: increases length of most flashes, changes some flashes from white to a dark gray. + Minor: VC changes + decreasing the speed of Bubble/Metal Man stage animations. + Full: VC changes + further decreasing the brightness of most flashes and + disables stage animations for Metal/Bubble Man stages. + """ + display_name = "Reduce Flashing" + option_none = 0 + option_virtual_console = 1 + option_minor = 2 + option_full = 3 + default = 1 + + +class RandomMusic(Choice): + """ + Vanilla: music is unchanged + Shuffled: stage and certain menu music is shuffled. + Randomized: stage and certain menu music is randomly selected + None: no music will play + """ + display_name = "Random Music" + option_vanilla = 0 + option_shuffled = 1 + option_randomized = 2 + option_none = 3 + +@dataclass +class MM2Options(PerGameCommonOptions): + death_link: DeathLink + energy_link: EnergyLink + starting_robot_master: StartingRobotMaster + consumables: Consumables + yoku_jumps: YokuJumps + enable_lasers: EnableLasers + enemy_weakness: EnemyWeaknesses + strict_weakness: StrictWeaknesses + random_weakness: RandomWeaknesses + wily_5_requirement: Wily5Requirement + plando_weakness: WeaknessPlando + palette_shuffle: PaletteShuffle + quickswap: Quickswap + reduce_flashing: ReduceFlashing + random_music: RandomMusic diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py new file mode 100644 index 000000000000..cac0a8706007 --- /dev/null +++ b/worlds/mm2/rom.py @@ -0,0 +1,415 @@ +import pkgutil +from typing import Optional, TYPE_CHECKING, Iterable, Dict, Sequence +import hashlib +import Utils +import os + +import settings +from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes +from . import names +from .rules import minimum_weakness_requirement +from .text import MM2TextEntry +from .color import get_colors_for_item, write_palette_shuffle +from .options import Consumables, ReduceFlashing, RandomMusic + +if TYPE_CHECKING: + from . import MM2World + +MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" +PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" +MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" +MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" + +enemy_weakness_ptrs: Dict[int, int] = { + 0: 0x3E9A8, + 1: 0x3EA24, + 2: 0x3EA9C, + 3: 0x3EB14, + 4: 0x3EB8C, + 5: 0x3EC04, + 6: 0x3EC7C, + 7: 0x3ECF4, +} + +enemy_addresses: Dict[str, int] = { + "Shrink": 0x00, + "M-445": 0x04, + "Claw": 0x08, + "Tanishi": 0x0A, + "Kerog": 0x0C, + "Petit Kerog": 0x0D, + "Anko": 0x0F, + "Batton": 0x16, + "Robitto": 0x17, + "Friender": 0x1C, + "Monking": 0x1D, + "Kukku": 0x1F, + "Telly": 0x22, + "Changkey Maker": 0x23, + "Changkey": 0x24, + "Pierrobot": 0x29, + "Fly Boy": 0x2C, + # "Crash Wall": 0x2D + # "Friender Wall": 0x2E + "Blocky": 0x31, + "Neo Metall": 0x34, + "Matasaburo": 0x36, + "Pipi": 0x38, + "Pipi Egg": 0x3A, + "Copipi": 0x3C, + "Kaminari Goro": 0x3E, + "Petit Goblin": 0x45, + "Springer": 0x46, + "Mole (Up)": 0x48, + "Mole (Down)": 0x49, + "Shotman (Left)": 0x4B, + "Shotman (Right)": 0x4C, + "Sniper Armor": 0x4E, + "Sniper Joe": 0x4F, + "Scworm": 0x50, + "Scworm Worm": 0x51, + "Picopico-kun": 0x6A, + "Boobeam Trap": 0x6D, + "Big Fish": 0x71 +} + +# addresses printed when assembling basepatch +consumables_ptr: int = 0x3F2FE +quickswap_ptr: int = 0x3F363 +wily_5_ptr: int = 0x3F3A1 +energylink_ptr: int = 0x3F46B +get_equipped_sound_ptr: int = 0x3F384 + + +class RomData: + def __init__(self, file: bytes, name: str = "") -> None: + self.file = bytearray(file) + self.name = name + + def read_byte(self, offset: int) -> int: + return self.file[offset] + + def read_bytes(self, offset: int, length: int) -> bytearray: + return self.file[offset:offset + length] + + def write_byte(self, offset: int, value: int) -> None: + self.file[offset] = value + + def write_bytes(self, offset: int, values: Sequence[int]) -> None: + self.file[offset:offset + len(values)] = values + + def write_to_file(self, file: str) -> None: + with open(file, 'wb') as outfile: + outfile.write(self.file) + + +class MM2ProcedurePatch(APProcedurePatch, APTokenMixin): + hash = [MM2LCHASH, MM2NESHASH, MM2VCHASH] + game = "Mega Man 2" + patch_file_ending = ".apmm2" + result_file_ending = ".nes" + name: bytearray + procedure = [ + ("apply_bsdiff4", ["mm2_basepatch.bsdiff4"]), + ("apply_tokens", ["token_patch.bin"]), + ] + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + def write_byte(self, offset: int, value: int) -> None: + self.write_token(APTokenTypes.WRITE, offset, value.to_bytes(1, "little")) + + def write_bytes(self, offset: int, value: Iterable[int]) -> None: + self.write_token(APTokenTypes.WRITE, offset, bytes(value)) + + +def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: + patch.write_file("mm2_basepatch.bsdiff4", pkgutil.get_data(__name__, os.path.join("data", "mm2_basepatch.bsdiff4"))) + # text writing + patch.write_bytes(0x37E2A, MM2TextEntry("FOR ", 0xCB).resolve()) + patch.write_bytes(0x37EAA, MM2TextEntry("GET EQUIPPED ", 0x0B).resolve()) + patch.write_bytes(0x37EBA, MM2TextEntry("WITH ", 0x2B).resolve()) + + base_address = 0x3F650 + color_address = 0x37F6C + for i, location in zip(range(11), [ + names.atomic_fire_get, + names.air_shooter_get, + names.leaf_shield_get, + names.bubble_lead_get, + names.quick_boomerang_get, + names.time_stopper_get, + names.metal_blade_get, + names.crash_bomber_get, + names.item_1_get, + names.item_2_get, + names.item_3_get + ]): + item = world.multiworld.get_location(location, world.player).item + if item: + if len(item.name) <= 14: + # we want to just place it in the center + first_str = "" + second_str = item.name + third_str = "" + elif len(item.name) <= 28: + # spread across second and third + first_str = "" + second_str = item.name[:14] + third_str = item.name[14:] + else: + # all three + first_str = item.name[:14] + second_str = item.name[14:28] + third_str = item.name[28:] + if len(third_str) > 16: + third_str = third_str[:16] + player_str = world.multiworld.get_player_name(item.player) + if len(player_str) > 14: + player_str = player_str[:14] + patch.write_bytes(base_address + (64 * i), MM2TextEntry(first_str, 0x4B).resolve()) + patch.write_bytes(base_address + (64 * i) + 16, MM2TextEntry(second_str, 0x6B).resolve()) + patch.write_bytes(base_address + (64 * i) + 32, MM2TextEntry(third_str, 0x8B).resolve()) + patch.write_bytes(base_address + (64 * i) + 48, MM2TextEntry(player_str, 0xEB).resolve()) + + colors = get_colors_for_item(item.name) + if i > 7: + patch.write_bytes(color_address + 27 + ((i - 8) * 2), colors) + else: + patch.write_bytes(color_address + (i * 2), colors) + + write_palette_shuffle(world, patch) + + enemy_weaknesses: Dict[str, Dict[int, int]] = {} + + if world.options.strict_weakness or world.options.random_weakness or world.options.plando_weakness: + # we need to write boss weaknesses + output = bytearray() + for weapon in world.weapon_damage: + if weapon == 8: + continue # Time Stopper is a special case + weapon_damage = [world.weapon_damage[weapon][i] + if world.weapon_damage[weapon][i] >= 0 + else 256 + world.weapon_damage[weapon][i] + for i in range(14)] + output.extend(weapon_damage) + patch.write_bytes(0x2E952, bytes(output)) + time_stopper_damage = world.weapon_damage[8] + time_offset = 0x2C03B + damage_table = { + 4: 0xF, + 3: 0x17, + 2: 0x1E, + 1: 0x25 + } + for boss, damage in enumerate(time_stopper_damage): + if damage > 4: + damage = 4 # 4 is a guaranteed kill, no need to exceed + if damage <= 0: + patch.write_byte(time_offset + 14 + boss, 0) + else: + patch.write_byte(time_offset + 14 + boss, 1) + patch.write_byte(time_offset + boss, damage_table[damage]) + if world.options.random_weakness: + wily_5_weaknesses = [i for i in range(8) if world.weapon_damage[i][12] > minimum_weakness_requirement[i]] + world.random.shuffle(wily_5_weaknesses) + if len(wily_5_weaknesses) >= 3: + weak1 = wily_5_weaknesses.pop() + weak2 = wily_5_weaknesses.pop() + weak3 = wily_5_weaknesses.pop() + elif len(wily_5_weaknesses) == 2: + weak1 = weak2 = wily_5_weaknesses.pop() + weak3 = wily_5_weaknesses.pop() + else: + weak1 = weak2 = weak3 = 0 + patch.write_byte(0x2DA2E, weak1) + patch.write_byte(0x2DA32, weak2) + patch.write_byte(0x2DA3A, weak3) + enemy_weaknesses["Picopico-kun"] = {weapon: world.weapon_damage[weapon][9] for weapon in range(8)} + enemy_weaknesses["Boobeam Trap"] = {weapon: world.weapon_damage[weapon][11] for weapon in range(8)} + + if world.options.enemy_weakness: + for enemy in enemy_addresses: + if enemy in ("Picopico-kun", "Boobeam Trap"): + continue + enemy_weaknesses[enemy] = {weapon: world.random.randint(-4, 4) for weapon in enemy_weakness_ptrs} + if enemy == "Friender": + # Friender has to be killed, need buster damage to not break logic + enemy_weaknesses[enemy][0] = max(enemy_weaknesses[enemy][0], 1) + + for enemy, damage_table in enemy_weaknesses.items(): + for weapon in enemy_weakness_ptrs: + if damage_table[weapon] < 0: + damage_table[weapon] = 256 + damage_table[weapon] + patch.write_byte(enemy_weakness_ptrs[weapon] + enemy_addresses[enemy], damage_table[weapon]) + + if world.options.quickswap: + patch.write_byte(quickswap_ptr + 1, 0x01) + + if world.options.consumables != Consumables.option_all: + value_a = 0x7C + value_b = 0x76 + if world.options.consumables == Consumables.option_1up_etank: + value_b = 0x7A + else: + value_a = 0x7A + patch.write_byte(consumables_ptr - 3, value_a) + patch.write_byte(consumables_ptr + 1, value_b) + + patch.write_byte(wily_5_ptr + 1, world.options.wily_5_requirement.value) + + if world.options.energy_link: + patch.write_byte(energylink_ptr + 1, 1) + + if world.options.reduce_flashing: + if world.options.reduce_flashing.value == ReduceFlashing.option_virtual_console: + color = 0x2D # Dark Gray + speed = -1 + elif world.options.reduce_flashing.value == ReduceFlashing.option_minor: + color = 0x2D + speed = 0x08 + else: + color = 0x0F + speed = 0x00 + patch.write_byte(0x2D1B0, color) # Change white to a dark gray, Mecha Dragon + patch.write_byte(0x2D397, 0x0F) # Longer flash time, Mecha Dragon kill + patch.write_byte(0x2D3A0, color) # Change white to a dark gray, Picopico-kun/Boobeam Trap + patch.write_byte(0x2D65F, color) # Change white to a dark gray, Guts Tank + patch.write_byte(0x2DA94, color) # Change white to a dark gray, Wily Machine + patch.write_byte(0x2DC97, color) # Change white to a dark gray, Alien + patch.write_byte(0x2DD68, 0x10) # Longer flash time, Alien kill + patch.write_bytes(0x2DF14, [0xEA, 0xEA, 0xEA, 0xEA, 0xEA, 0xEA]) # Reduce final Alien flash to 1 big flash + patch.write_byte(0x34132, 0x08) # Longer flash time, Stage Select + + if world.options.reduce_flashing.value == ReduceFlashing.option_full: + # reduce color of stage flashing + patch.write_bytes(0x344C9, [0x2D, 0x10, 0x00, 0x2D, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x2D, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00]) + # remove wily castle flash + patch.write_byte(0x3596D, 0x0F) + + if speed != -1: + patch.write_byte(0xFE01, speed) # Bubble Man Stage + patch.write_byte(0x1BE01, speed) # Metal Man Stage + + if world.options.random_music: + if world.options.random_music == RandomMusic.option_none: + pool = [0xFF] * 20 + # A couple of additional mutes we want here + patch.write_byte(0x37819, 0xFF) # Credits + patch.write_byte(0x378A4, 0xFF) # Credits #2 + patch.write_byte(0x37149, 0xFF) # Game Over Jingle + patch.write_byte(0x341BA, 0xFF) # Robot Master Jingle + patch.write_byte(0x2E0B4, 0xFF) # Robot Master Defeated + patch.write_byte(0x35B78, 0xFF) # Wily Castle + patch.write_byte(0x2DFA5, 0xFF) # Wily Defeated + + elif world.options.random_music == RandomMusic.option_shuffled: + pool = [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 9, 9, 0x10, 0xC, 0xB, 0x17, 0x13, 0xE, 0xD] + world.random.shuffle(pool) + else: + pool = world.random.choices([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xB, 0xC, 0xD, 0xE, 0x10, 0x13, 0x17], k=20) + patch.write_bytes(0x381E0, pool[:13]) + patch.write_byte(0x36318, pool[13]) # Game Start + patch.write_byte(0x37181, pool[13]) # Game Over + patch.write_byte(0x340AE, pool[14]) # RBM Select + patch.write_byte(0x39005, pool[15]) # Robot Master Battle + patch.write_byte(get_equipped_sound_ptr + 1, pool[16]) # Get Equipped, we actually hook this already lmao + patch.write_byte(0x3775A, pool[17]) # Epilogue + patch.write_byte(0x36089, pool[18]) # Intro + patch.write_byte(0x361F1, pool[19]) # Title + + + + from Utils import __version__ + patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', + 'utf8')[:21] + patch.name.extend([0] * (21 - len(patch.name))) + patch.write_bytes(0x3FFC0, patch.name) + deathlink_byte = world.options.death_link.value | (world.options.energy_link.value << 1) + patch.write_byte(0x3FFD5, deathlink_byte) + + patch.write_bytes(0x3FFD8, world.world_version) + + version_map = { + "0": 0x90, + "1": 0x91, + "2": 0x92, + "3": 0x93, + "4": 0x94, + "5": 0x95, + "6": 0x96, + "7": 0x97, + "8": 0x98, + "9": 0x99, + ".": 0xDC + } + patch.write_token(APTokenTypes.RLE, 0x36EE0, (11, 0)) + patch.write_token(APTokenTypes.RLE, 0x36EEE, (25, 0)) + + # BY SILVRIS + patch.write_bytes(0x36EE0, [0xC2, 0xD9, 0xC0, 0xD3, 0xC9, 0xCC, 0xD6, 0xD2, 0xC9, 0xD3]) + # ARCHIPELAGO x.x.x + patch.write_bytes(0x36EF2, [0xC1, 0xD2, 0xC3, 0xC8, 0xC9, 0xD0, 0xC5, 0xCC, 0xC1, 0xC7, 0xCF, 0xC0]) + patch.write_bytes(0x36EFE, list(map(lambda c: version_map[c], __version__))) + + patch.write_file("token_patch.bin", patch.get_token_binary()) + + +header = b"\x4E\x45\x53\x1A\x10\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00" + + +def read_headerless_nes_rom(rom: bytes) -> bytes: + if rom[:4] == b"NES\x1A": + return rom[16:] + else: + return rom + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + base_rom_bytes: Optional[bytes] = getattr(get_base_rom_bytes, "base_rom_bytes", None) + if not base_rom_bytes: + file_name = get_base_rom_path(file_name) + base_rom_bytes = read_headerless_nes_rom(bytes(open(file_name, "rb").read())) + + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() == PROTEUSHASH: + base_rom_bytes = extract_mm2(base_rom_bytes) + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() not in {MM2LCHASH, MM2NESHASH, MM2VCHASH}: + print(basemd5.hexdigest()) + raise Exception("Supplied Base Rom does not match known MD5 for US, LC, or US VC release. " + "Get the correct game and version, then dump it") + headered_rom = bytearray(base_rom_bytes) + headered_rom[0:0] = header + setattr(get_base_rom_bytes, "base_rom_bytes", bytes(headered_rom)) + return bytes(headered_rom) + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + options: settings.Settings = settings.get_settings() + if not file_name: + file_name = options["mm2_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name + + +PRG_OFFSET = 0x8ED70 +PRG_SIZE = 0x40000 + + +def extract_mm2(proteus: bytes) -> bytes: + mm2 = bytearray(proteus[PRG_OFFSET:PRG_OFFSET + PRG_SIZE]) + return bytes(mm2) diff --git a/worlds/mm2/rules.py b/worlds/mm2/rules.py new file mode 100644 index 000000000000..43d4b5a6aabd --- /dev/null +++ b/worlds/mm2/rules.py @@ -0,0 +1,319 @@ +from math import ceil +from typing import TYPE_CHECKING, Dict, List +from . import names +from .locations import heat_man_locations, air_man_locations, wood_man_locations, bubble_man_locations, \ + quick_man_locations, flash_man_locations, metal_man_locations, crash_man_locations, wily_1_locations, \ + wily_2_locations, wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations +from .options import bosses, weapons_to_id, Consumables, RandomWeaknesses +from worlds.generic.Rules import add_rule + +if TYPE_CHECKING: + from . import MM2World + from BaseClasses import CollectionState + +weapon_damage: Dict[int, List[int]] = { + 0: [2, 2, 1, 1, 2, 2, 1, 1, 1, 7, 1, 0, 1, -1], # Mega Buster + 1: [-1, 6, 0xE, 0, 0xA, 6, 4, 6, 8, 13, 8, 0, 0xE, -1], # Atomic Fire + 2: [2, 0, 4, 0, 2, 0, 0, 0xA, 0, 0, 0, 0, 1, -1], # Air Shooter + 3: [0, 8, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1], # Leaf Shield + 4: [6, 0, 0, -1, 0, 2, 0, 1, 0, 14, 1, 0, 0, 1], # Bubble Lead + 5: [2, 2, 0, 2, 0, 0, 4, 1, 1, 7, 2, 0, 1, -1], # Quick Boomerang + 6: [-1, 0, 2, 2, 4, 3, 0, 0, 1, 0, 1, 0x14, 1, -1], # Crash Bomber + 7: [1, 0, 2, 4, 0, 4, 0xE, 0, 0, 7, 0, 0, 1, -1], # Metal Blade + 8: [0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], # Time Stopper +} + +weapons_to_name: Dict[int, str] = { + 1: names.atomic_fire, + 2: names.air_shooter, + 3: names.leaf_shield, + 4: names.bubble_lead, + 5: names.quick_boomerang, + 6: names.crash_bomber, + 7: names.metal_blade, + 8: names.time_stopper +} + +minimum_weakness_requirement: Dict[int, int] = { + 0: 1, # Mega Buster is free + 1: 14, # 2 shots of Atomic Fire + 2: 1, # 14 shots of Air Shooter, although you likely hit more than one shot + 3: 4, # 9 uses of Leaf Shield, 3 ends up 1 damage off + 4: 1, # 56 uses of Bubble Lead + 5: 1, # 224 uses of Quick Boomerang + 6: 4, # 7 uses of Crash Bomber + 7: 1, # 112 uses of Metal Blade + 8: 4, # 1 use of Time Stopper, but setting to 4 means we shave the entire HP bar +} + +robot_masters: Dict[int, str] = { + 0: "Heat Man Defeated", + 1: "Air Man Defeated", + 2: "Wood Man Defeated", + 3: "Bubble Man Defeated", + 4: "Quick Man Defeated", + 5: "Flash Man Defeated", + 6: "Metal Man Defeated", + 7: "Crash Man Defeated" +} + +weapon_costs = { + 0: 0, + 1: 10, + 2: 2, + 3: 3, + 4: 0.5, + 5: 0.125, + 6: 4, + 7: 0.25, + 8: 7, +} + + +def can_defeat_enough_rbms(state: "CollectionState", player: int, + required: int, boss_requirements: Dict[int, List[int]]): + can_defeat = 0 + for boss, reqs in boss_requirements.items(): + if boss in robot_masters: + if state.has_all(map(lambda x: weapons_to_name[x], reqs), player): + can_defeat += 1 + if can_defeat >= required: + return True + return False + + +def set_rules(world: "MM2World") -> None: + # most rules are set on region, so we only worry about rules required within stage access + # or rules variable on settings + if (hasattr(world.multiworld, "re_gen_passthrough") + and "Mega Man 2" in getattr(world.multiworld, "re_gen_passthrough")): + slot_data = getattr(world.multiworld, "re_gen_passthrough")["Mega Man 2"] + world.weapon_damage = slot_data["weapon_damage"] + world.wily_5_weapons = slot_data["wily_5_weapons"] + else: + if world.options.random_weakness == RandomWeaknesses.option_shuffled: + weapon_tables = [table for weapon, table in weapon_damage.items() if weapon not in (0, 8)] + world.random.shuffle(weapon_tables) + for i in range(1, 8): + world.weapon_damage[i] = weapon_tables.pop() + # alien must take minimum required damage from his weakness + alien_weakness = next(weapon for weapon in range(8) if world.weapon_damage[weapon][13] != -1) + world.weapon_damage[alien_weakness][13] = minimum_weakness_requirement[alien_weakness] + world.weapon_damage[8] = [0 for _ in range(14)] + world.weapon_damage[8][world.random.choice(range(8))] = 2 + elif world.options.random_weakness == RandomWeaknesses.option_randomized: + world.weapon_damage = {i: [] for i in range(9)} + for boss in range(13): + for weapon in world.weapon_damage: + world.weapon_damage[weapon].append(min(14, max(-1, int(world.random.normalvariate(3, 3))))) + if not any([world.weapon_damage[weapon][boss] >= max(4, minimum_weakness_requirement[weapon]) + for weapon in range(1, 7)]): + # failsafe, there should be at least one defined non-Buster weakness + weapon = world.random.randint(1, 7) + world.weapon_damage[weapon][boss] = world.random.randint( + max(4, minimum_weakness_requirement[weapon]), 14) # Force weakness + # special case, if boobeam trap has a weakness to Crash, it needs to be max damage + if world.weapon_damage[6][11] > 4: + world.weapon_damage[6][11] = 14 + # handle the alien + boss = 13 + for weapon in world.weapon_damage: + world.weapon_damage[weapon].append(-1) + weapon = world.random.choice(list(world.weapon_damage.keys())) + world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon] + + if world.options.strict_weakness: + for weapon in weapon_damage: + for i in range(13): + if weapon == 0: + world.weapon_damage[weapon][i] = 0 + elif i in (8, 12) and not world.options.random_weakness: + continue + # Mecha Dragon only has damage range of 0-1, so allow the 1 + # Wily Machine needs all three weaknesses present, so allow + elif 4 > world.weapon_damage[weapon][i] > 0: + world.weapon_damage[weapon][i] = 0 + # handle special cases + for boss in range(14): + for weapon in (1, 3, 6, 8): + if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and + not any(world.weapon_damage[i][boss] > 0 for i in range(1, 8) if i != weapon)): + # Weapon does not have enough possible ammo to kill the boss, raise the damage + if boss == 9: + if weapon != 3: + # Atomic Fire and Crash Bomber cannot be Picopico-kun's only weakness + world.weapon_damage[weapon][boss] = 0 + weakness = world.random.choice((2, 3, 4, 5, 7, 8)) + world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness] + elif boss == 11: + if weapon == 1: + # Atomic Fire cannot be Boobeam Trap's only weakness + world.weapon_damage[weapon][boss] = 0 + weakness = world.random.choice((2, 3, 4, 5, 6, 7, 8)) + world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness] + else: + world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon] + starting = world.options.starting_robot_master.value + world.weapon_damage[0][starting] = 1 + + for p_boss in world.options.plando_weakness: + for p_weapon in world.options.plando_weakness[p_boss]: + if world.options.plando_weakness[p_boss][p_weapon] < minimum_weakness_requirement[p_weapon] \ + and not any(w != p_weapon + and world.weapon_damage[w][bosses[p_boss]] > minimum_weakness_requirement[w] + for w in world.weapon_damage): + # we need to replace this weakness + weakness = world.random.choice([key for key in world.weapon_damage if key != p_weapon]) + world.weapon_damage[weakness][bosses[p_boss]] = minimum_weakness_requirement[weakness] + world.weapon_damage[weapons_to_id[p_weapon]][bosses[p_boss]] \ + = world.options.plando_weakness[p_boss][p_weapon] + + if world.weapon_damage[0][world.options.starting_robot_master.value] < 1: + world.weapon_damage[0][world.options.starting_robot_master.value] = weapon_damage[0][world.options.starting_robot_master.value] + + # final special case + # There's a vanilla crash if Time Stopper kills Wily phase 1 + # There's multiple fixes, but ensuring Wily cannot take Time Stopper damage is best + if world.weapon_damage[8][12] > 0: + world.weapon_damage[8][12] = 0 + + # weakness validation, it is better to confirm a completable seed than respect plando + boss_health = {boss: 0x1C if boss != 12 else 0x1C * 2 for boss in [*range(8), 12]} + + weapon_energy = {key: float(0x1C) for key in weapon_costs} + weapon_boss = {boss: {weapon: world.weapon_damage[weapon][boss] for weapon in world.weapon_damage} + for boss in [*range(8), 12]} + flexibility = { + boss: ( + sum(damage_value > 0 for damage_value in + weapon_damages.values()) # Amount of weapons that hit this boss + * sum(weapon_damages.values()) # Overall damage that those weapons do + ) + for boss, weapon_damages in weapon_boss.items() if boss != 12 + } + flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value + used_weapons = {i: set() for i in [*range(8), 12]} + for boss in [*flexibility, 12]: + boss_damage = weapon_boss[boss] + weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in + boss_damage.items() if weapon_energy[weapon] > 0} + if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight: + # We get exactly one use of Time Stopper during the rush + # So we want to make sure that use is absolutely needed + weapon_weight[8] = min(weapon_weight[8], 0.001) + while boss_health[boss] > 0: + if boss_damage[0] > 0: + boss_health[boss] = 0 # if we can buster, we should buster + continue + highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys())) + uses = weapon_energy[wp] // weapon_costs[wp] + used_weapons[boss].add(wp) + if int(uses * boss_damage[wp]) > boss_health[boss]: + used = ceil(boss_health[boss] / boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] = 0 + elif highest <= 0: + # we are out of weapons that can actually damage the boss + # so find the weapon that has the most uses, and apply that as an additional weakness + # it should be impossible to be out of energy, simply because even if every boss took 1 from + # Quick Boomerang and no other, it would only be 28 off from defeating all 9, which Metal Blade should + # be able to cover + wp, max_uses = max((weapon, weapon_energy[weapon] // weapon_costs[weapon]) for weapon in weapon_weight + if weapon != 0) + world.weapon_damage[wp][boss] = minimum_weakness_requirement[wp] + used = min(int(weapon_energy[wp] // weapon_costs[wp]), + ceil(boss_health[boss] // minimum_weakness_requirement[wp])) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] -= int(used * minimum_weakness_requirement[wp]) + weapon_weight.pop(wp) + else: + # drain the weapon and continue + boss_health[boss] -= int(uses * boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * uses + weapon_weight.pop(wp) + + world.wily_5_weapons = {boss: sorted(used_weapons[boss]) for boss in used_weapons} + + for i, boss_locations in enumerate([ + heat_man_locations, + air_man_locations, + wood_man_locations, + bubble_man_locations, + quick_man_locations, + flash_man_locations, + metal_man_locations, + crash_man_locations, + wily_1_locations, + wily_2_locations, + wily_3_locations, + wily_4_locations, + wily_5_locations, + wily_6_locations + ]): + if world.weapon_damage[0][i] > 0: + continue # this can always be in logic + weapons = [] + for weapon in range(1, 9): + if world.weapon_damage[weapon][i] > 0: + if world.weapon_damage[weapon][i] < minimum_weakness_requirement[weapon]: + continue # Atomic Fire can only be considered logical for bosses it can kill in 2 hits + weapons.append(weapons_to_name[weapon]) + if not weapons: + raise Exception(f"Attempted to have boss {i} with no weakness! Seed: {world.multiworld.seed}") + for location in boss_locations: + if i == 12: + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_all(weps, world.player)) + # TODO: when has_list gets added, check for a subset of possible weaknesses + else: + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_any(weps, world.player)) + + # Always require Crash Bomber for Boobeam Trap + add_rule(world.get_location(names.wily_4), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_stage_4), + lambda state: state.has(names.crash_bomber, world.player)) + + # Need to defeat x amount of robot masters for Wily 5 + add_rule(world.get_location(names.wily_5), + lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value, + world.wily_5_weapons)) + add_rule(world.get_location(names.wily_stage_5), + lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value, + world.wily_5_weapons)) + + if not world.options.yoku_jumps: + add_rule(world.get_entrance("To Heat Man Stage"), + lambda state: state.has(names.item_2, world.player)) + + if not world.options.enable_lasers: + add_rule(world.get_entrance("To Quick Man Stage"), + lambda state: state.has(names.time_stopper, world.player)) + + if world.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + add_rule(world.get_location(names.flash_man_c2), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.quick_man_c1), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.metal_man_c2), + lambda state: state.has_any([names.item_1, names.item_2], world.player)) + add_rule(world.get_location(names.metal_man_c3), + lambda state: state.has_any([names.item_1, names.item_2], world.player)) + add_rule(world.get_location(names.crash_man_c3), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.wily_2_c5), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_2_c6), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_3_c2), + lambda state: state.has(names.crash_bomber, world.player)) + if world.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + add_rule(world.get_location(names.flash_man_c3), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.flash_man_c4), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_3_c1), + lambda state: state.has(names.crash_bomber, world.player)) diff --git a/worlds/mm2/src/mm2_basepatch.asm b/worlds/mm2/src/mm2_basepatch.asm new file mode 100644 index 000000000000..00c8500f03df --- /dev/null +++ b/worlds/mm2/src/mm2_basepatch.asm @@ -0,0 +1,861 @@ +norom +!headersize = 16 + +!controller_mirror = $23 +!controller_flip = $27 ; only on first frame of input, used by crash man, etc +!current_stage = $2A +!received_stages = $8A +!completed_stages = $8B +!received_item_checks = $8C +!last_wily = $8D +!deathlink = $8F +!energylink_packet = $90 +!rbm_strobe = $91 +!received_weapons = $9A +!received_items = $9B +!current_weapon = $A9 + +!stage_completion = $0F70 +!consumable_checks = $0F80 + +!CONTROLLER_SELECT = #$04 +!CONTROLLER_SELECT_START = #$0C +!CONTROLLER_ALL_BUTTON = #$0F + +!PpuControl_2000 = $2000 +!PpuMask_2001 = $2001 +!PpuAddr_2006 = $2006 +!PpuData_2007 = $2007 + +!LOAD_BANK = $C000 + +macro org(address,bank) + if == $0F + org
-$C000+($4000*)+!headersize ; org sets the position in the output file to write to (in norom, at least) + base
; base sets the position that all labels are relative to - this is necessary so labels will still start from $8000, instead of $0000 or somewhere + else + org
-$8000+($4000*)+!headersize + base
+ endif +endmacro + +%org($8400, $08) +incbin "mm2font.dat" + +%org($A900, $09) +incbin "mm2titlefont.dat" + +%org($807E, $0B) +FlashFixes: + CMP #$FF + BEQ FlashFixTarget1 + CMP #$FF + BNE FlashFixTarget2 + +%org($8086, $0B) +FlashFixTarget1: + +%org($808D, $0B) +FlashFixTarget2: + +%org($8015, $0D) +ClearRefreshHook: + ; if we're already doing a fresh load of the stage select + ; we don't need to immediately refresh it + JSR ClearRefresh + NOP + +%org($802B, $0D) +PatchFaceTiles: + LDA !received_stages + +%org($8072, $0D) +PatchFaceSprites: + LDA !received_stages + +%org($80CC, $0D) +CheckItemsForWily: + LDA !received_items + CMP #$07 + +%org($80D2, $0D) +LoadWily: + JSR GoToMostRecentWily + NOP + +%org($80DC, $0D) +CheckAccessCodes: + LDA !received_stages + +%org($8312, $0D) +HookStageSelect: + JSR RefreshRBMTiles + NOP + +%org($A315, $0D) +RemoveWeaponClear: + NOP + NOP + NOP + NOP + +;Adjust Password select flasher +%org($A32A, $0D) + LDX #$68 + +;Block password input +%org($A346, $0D) + EOR #$00 + +;Remove password text +%org($AF3A, $0D) +StartHeight: + db $AC ; set Start to center + +%org($AF49, $0D) +PasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AF6C, $0D) +ContinueHeight: + db $AB ; split height between 2 remaining options + +%org($AF77, $0D) +StageSelectHeight: + db $EB ; split between 2 remaining options + +%org($AF88, $0D) +GameOverPasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AFA5, $0D) +GetEquippedPasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AFAE, $0D) +GetEquippedStageSelect: + db $26, $EA + +%org($B195, $0D) +GameOverPasswordUp: + LDA #$01 ; originally 02, removing last option + +%org($B19F, $0D) +GameOverPassword: + CMP #$02 ; originally 03, remove the last option + +%org($B1ED, $0D) +FixupGameOverArrows: + db $68, $78 + +%org($BB74, $0D) +GetEquippedStage: + JSR StageGetEquipped + NOP #13 + +%org($BBD9, $0D) +GetEquippedDefault: + LDA #$01 + +%org($BC01, $0D) +GetEquippedPasswordRemove: + ORA #$01 ; originally EOR #$01, we always want 1 here + +%org($BCF1, $0D) +GetEquippedItem: + ADC #$07 + JSR ItemGetEquipped + JSR LoadItemsColor + NOP ; !!!! This is a load-bearing NOP. It gets branched to later in the function + LDX $FF + + +%org($BB08, $0D) +WilyProgress: + JSR StoreWilyProgress + NOP + +%org($BF6F, $0D) +GetEquippedStageSelectHeight: + db $B8 + +%org($805B, $0E) +InitalizeStartingRBM: + LDA #$FF ; this does two things + STA !received_stages ; we're overwriting clearing e-tanks and setting RBM available to none + +%org($8066, $0E) +BlockStartupAutoWily: + ; presumably this would be called from password? + LDA #$00 + +%org($80A7, $0E) +StageLoad: + JMP CleanWily5 + NOP + +%org($8178, $0E) +Main1: + JSR MainLoopHook + NOP + +%org($81DE, $0E) +Wily5Teleporter: + LDA $99 + CMP #$01 + BCC SkipSpawn + +%org($81F9, $0E) +SkipSpawn: +; just present to fix the branch, if we try to branch raw it'll get confused + +%org($822D, $0E) +Main2: + ; believe used in the wily 5 refights? + JSR MainLoopHook + NOP + +%org($842F, $0E) +Wily5Hook: + JMP Wily5Requirement + NOP + +%org($C10D, $0F) +Deathlink: + JSR KillMegaMan + +%org($C1BC, $0F) +RemoveETankLoss: + NOP + NOP + +%org($C23C, $0F) +WriteStageComplete: + ORA !completed_stages + STA !completed_stages + +%org($C243, $0F) +WriteReceiveItem: + ORA !received_item_checks + STA !received_item_checks + +%org($C254, $0F) +BlockAutoWily: + ; and this one is on return from stage? + LDA #$00 + +%org($C261, $0F) +WilyStageCompletion: + JSR StoreWilyStageCompletion + NOP + +%org($E5AC, $0F) +NullDeathlink: + STA $8F ; we null his HP later in the process + NOP + +%org($E5D1, $0F) +EnergylinkHook: + JSR Energylink + NOP #2 ; comment this out to enable item giving their usual reward alongside EL + +%org($E5E8, $0F) +ConsumableHook: + JSR CheckConsumable + +%org($F2E3, $0F) + +CheckConsumable: + STA $0140, Y + TXA + PHA + LDA $AD ; the consumable value + CMP #$7C + BPL .Store + print "Consumables (replace 7a): ", hex(realbase()) + CMP #$76 + BMI .Store + LDA #$00 + .Store: + STA $AD + LDA $2A + ASL + ASL + TAX + TYA + .LoopHead: + CMP #$08 + BMI .GetFlag + INX + SBC #$08 + BNE .LoopHead + .GetFlag: + TAY + LDA #$01 + .Loop2Head: + CPY #$00 + BEQ .Apply + ASL + DEY + BNE .Loop2Head + .Apply: + ORA !consumable_checks, X + STA !consumable_checks, X + PLA + TAX + RTS + +GoToMostRecentWily: + LDA !controller_mirror + CMP !CONTROLLER_SELECT_START + BEQ .Default + LDA !last_wily + BNE .Store + .Default: + LDA #$08 ; wily stage 1 + .Store: + STA !current_stage + RTS + +StoreWilyStageCompletion: + LDA #$01 + STA !stage_completion, X + INC !current_stage + LDA !current_stage + STA !last_wily + RTS + +ReturnToGameOver: + LDA #$10 + STA !PpuControl_2000 + LDA #$06 + STA !PpuMask_2001 + JMP $C1BE ; specific code that loads game over + +MainLoopHook: + LDA !controller_mirror + CMP !CONTROLLER_ALL_BUTTON + BNE .Next + JMP ReturnToGameOver + .Next: + LDA !deathlink + CMP #$01 + BNE .Next2 + JMP $E5A8 ; this kills the Mega Man + .Next2: + print "Quickswap:", hex(realbase()) + LDA #$00 ; slot data, write in enable for quickswap + CMP #$01 + BNE .Finally + LDA !controller_flip + AND !CONTROLLER_SELECT + BEQ .Finally + JMP Quickswap + .Finally: + LDA !controller_flip + AND #$08 ; this is checking for menu + RTS + +StoreWilyProgress: + STA !current_stage + TXA + PHA + LDX !current_stage + LDA #$01 + STA !stage_completion, X + PLA + TAX + print "Get Equipped Music: ", hex(realbase()) + LDA #$17 + RTS + +KillMegaMan: + JSR $C051 ; this kills the mega man + LDA #$00 + STA $06C0 ; set HP to zero so client can actually detect he died + RTS + +Wily5Requirement: + LDA #$01 + LDX #$08 + LDY #$00 + .LoopHead: + BIT $BC + BEQ .Skip + INY + .Skip: + DEX + ASL + CPX #$00 + BNE .LoopHead + print "Wily 5 Requirement:", hex(realbase()) + CPY #$08 + BCS .SpawnTeleporter + JMP $8450 + .SpawnTeleporter: + LDA #$FF + STA $BC + LDA #$01 + STA $99 + JMP $8433 + +CleanWily5: + LDA #$00 + STA $BC + STA $99 + JMP $80AB + +LoadString: + STY $00 + ASL + ASL + ASL + ASL + TAY + LDA $DB + ADC #$00 + STA $C8 + LDA #$40 + STA $C9 + LDA #$F6 + CLC + ADC $C8 + STA $CA + LDA ($C9), Y + STA $03B6 + TYA + CLC + ADC #$01 + TAY + LDA $CA + ADC #$00 + STA $CA + LDA ($C9), Y + STA $03B7 + TYA + CLC + ADC #$01 + TAY + LDA $CA + ADC #$00 + STA $CA + STY $FE + LDA #$0E + STA $FD + .LoopHead: + JSR $BD34 + LDY $FE + CPY #$40 + BNE .NotEqual + LDA $0420 + BNE .Skip + .NotEqual: + LDA ($C9), Y + .Skip: + STA $03B8 + INC $47 + INC $03B7 + LDA $FE + CLC + ADC #$01 + STA $FE + LDA $CA + ADC #$00 + STA $CA + DEC $FD + BNE .LoopHead + LDY $00 + JSR $C0AB + RTS + +StageGetEquipped: + LDA !current_stage + LDX #$00 + BCS LoadGetEquipped +ItemGetEquipped: + LDX #$02 +LoadGetEquipped: + STX $DB + ASL + ASL + PHA + SEC + JSR LoadString + PLA + ADC #$00 + PHA + SEC + JSR LoadString + PLA + ADC #$00 + PHA + SEC + JSR LoadString + LDA #$00 + SEC + JSR $BD3E + PLA + ADC #$00 + SEC + JSR LoadString + RTS + +LoadItemsColor: + LDA #$7D + STA $FD + LDA $0420 + AND #$0F + ASL + SEC + ADC #$1A + STA $FF + RTS + +Energylink: + LSR $0420, X + print "Energylink: ", hex(realbase()) + LDA #$00 + BEQ .ApplyDrop + LDA $04E0, X + BEQ .ApplyDrop ; This is a stage pickup, and not an enemy drop + STY !energylink_packet + SEC + BCS .Return + .ApplyDrop: + STY $AD + .Return: + RTS + + +Quickswap: + LDX #$0F + .LoopHead: + LDA $0420, X + BMI .Return1 ; return if we have any weapon entities spawned + DEX + CPX #$01 + BNE .LoopHead + LDX !current_weapon + BNE .DoQuickswap + LDX #$00 + .DoQuickswap: + TYA + PHA + LDX !current_weapon + INX + CPX #$09 + BPL .Items + LDA #$01 + .Loop2Head: + DEX + BEQ .FoundTarget + ASL + CPX #$00 + BNE .Loop2Head + .FoundTarget: + LDX !current_weapon + INX + .Loop3Head: + PHA + AND !received_weapons + BNE .CanSwap + PLA + INX + CPX #$09 + BPL .Items + ASL + BNE .Loop3Head + .CanSwap: + PLA + SEC + BCS .ApplySwap + .Items: + TXA + PHA + SEC + SBC #$08 + TAX + LDA #$01 + .Loop4Head: + DEX + BEQ .CheckItem + ASL + CPX #$00 + BNE .Loop4Head + .CheckItem: + TAY + PLA + TAX + TYA + .Loop5Head: + PHA + AND !received_items + BNE .CanSwap + PLA + INX + ASL + BNE .Loop5Head + LDX #$00 + SEC + BCS .ApplySwap + .Return1: + RTS + .ApplySwap: ; $F408 on old rom + LDA #$0D + JSR !LOAD_BANK + ; this is a bunch of boiler plate to make the swap work + LDA $B5 + PHA + LDA $B6 + PHA + LDA $B7 + PHA + LDA $B8 + PHA + LDA $B9 + PHA + LDA $20 + PHA + LDA $1F + PHA + ;but wait, there's more + STX !current_weapon + JSR $CC6C + LDA $1A + PHA + LDX #$00 + .Loop6Head: + STX $FD + CLC + LDA $52 + ADC $957F, X + STA $08 + LDA $53 + ADC #$00 + STA $09 + LDA $08 + LSR $09 + ROR + LSR $09 + ROR + STA $08 + AND #$3F + STA $1A + CLC + LDA $09 + ADC #$85 + STA $09 + LDA #$00 + STA $1B + LDA $FD + CMP #$08 + BCS .Past8 + LDX $A9 + LDA $9664, X + TAY + CPX #$09 + BCC .LessThanNine + LDX #$00 + BEQ .Apply + .LessThanNine: + LDX #$05 + BNE .Apply + .Past8: + LDY #$90 + LDX #$00 + .Apply: + JSR $C760 + JSR $C0AB ; iirc this is loading graphics? + LDX $FD + INX + CPX #$0F + BNE .Loop6Head + STX $FD + LDY #$90 + LDX #$00 + JSR $C760 + JSR $D2ED + ; two sections redacted here, might need to look at what they actually do? + PLA + STA $1A + PLA + STA $1F + PLA + STA $20 + PLA + STA $B9 + PLA + STA $B8 + PLA + STA $B7 + PLA + STA $B6 + PLA + STA $B5 + LDA #$00 + STA $AC + STA $2C + STA $0680 + STA $06A0 + LDA #$1A + STA $0400 + LDA #$03 + STA $AA + LDA #$30 + JSR $C051 + .Finally: + LDA #$0E + JSR !LOAD_BANK + PLA + TAY + .Return: + RTS + +RefreshRBMTiles: + ; primarily just a copy of the startup RBM setup, we just do it again + ; can't jump to it as it leads into the main loop + LDA !rbm_strobe + BNE .Update + JMP .NoUpdate + .Update: + LDA #$00 + STA !rbm_strobe + LDA #$10 + STA $F7 + STA !PpuControl_2000 + LDA #$06 + STA $F8 + STA !PpuMask_2001 + JSR $847E + JSR $843C + LDX #$00 + LDA $8A + STA $01 + .TileLoop: + STX $00 + LSR $01 + BCC .SkipTile + LDA $8531,X + STA $09 + LDA $8539,X + STA $08 + LDX #$04 + LDA #$00 + .ClearBody: + LDA $09 + STA !PpuAddr_2006 + LDA $08 + STA !PpuAddr_2006 + LDY #$04 + LDA #$00 + .ClearLine: + STA !PpuData_2007 + DEY + BNE .ClearLine + CLC + LDA $08 + ADC #$20 + STA $08 + DEX + BNE .ClearBody + .SkipTile: + LDX $00 + INX + CPX #$08 + BNE .TileLoop + LDX #$1F + JSR $829E + JSR $8473 + LDX #$00 + LDA $8A + STA $02 + LDY #$00 + .SpriteLoop: + STX $01 + LSR $02 + BCS .SkipRBM + LDA $8605,X + STA $00 + LDA $85FD,X + TAX + .WriteSprite: + LDA $8541,X + STA $0200,Y + INY + INX + DEC $00 + BNE .WriteSprite + .SkipRBM: + LDX $01 + INX + CPX #$08 + BNE .SpriteLoop + JSR $A51D + LDA #$0C + JSR $C051 + LDA #$00 + STA $2A + STA $FD + JSR $C0AB + .NoUpdate: + LDA $1C + AND #$08 + RTS + +ClearRefresh: + LDA #$00 + STA !rbm_strobe + LDA #$10 + STA $F7 + RTS + +assert realbase() <= $03F650 ; This is the start of our text data, and we absolutely cannot go past this point (text takes too much room). + +%org($F640, $0F) +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" + +%org($FFB0, $0F) +db "MM2_BASEPATCH_ARCHI " \ No newline at end of file diff --git a/worlds/mm2/src/mm2font.dat b/worlds/mm2/src/mm2font.dat new file mode 100644 index 0000000000000000000000000000000000000000..4bf97ee42c691a2f45e5afbe7ed5d16ec06047b8 GIT binary patch literal 416 zcmY+Au?hk)42CltM~53Sc#yF}L2>GMI2=_Jaj)aSJ&07Cd=KAD@I5ruYjXZ%==bwC zO={4x(7+@#70X~@aLI8AF8Ubzw(X1Ye7p=U5BI`AZiVapMmTntE(!qRHk*V4|4{gBNb1j=IrEQ)hX*eI@4$gS$nrJE4*Wqj W{~sHVgoT9#Ts_c!5JKT2=?4HnNh6;C literal 0 HcmV?d00001 diff --git a/worlds/mm2/test/__init__.py b/worlds/mm2/test/__init__.py new file mode 100644 index 000000000000..e712b0fe2ba6 --- /dev/null +++ b/worlds/mm2/test/__init__.py @@ -0,0 +1,5 @@ +from test.bases import WorldTestBase + + +class MM2TestBase(WorldTestBase): + game = "Mega Man 2" diff --git a/worlds/mm2/test/test_access.py b/worlds/mm2/test/test_access.py new file mode 100644 index 000000000000..97ef5075a3cb --- /dev/null +++ b/worlds/mm2/test/test_access.py @@ -0,0 +1,47 @@ +from . import MM2TestBase +from ..locations import (quick_man_locations, heat_man_locations, wily_1_locations, wily_2_locations, + wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations, + energy_pickups, etank_1ups) +from ..names import * + + +class TestAccess(MM2TestBase): + options = { + "consumables": "all" + } + + def test_time_stopper(self) -> None: + """Optional based on Enable Lasers setting, confirm these are the locations affected""" + locations = [*quick_man_locations, *energy_pickups["Quick Man Stage"], *etank_1ups["Quick Man Stage"]] + items = [["Time Stopper"]] + self.assertAccessDependency(locations, items) + + def test_item_2(self) -> None: + """Optional based on Yoku Block setting, confirm these are the locations affected""" + locations = [*heat_man_locations, *etank_1ups["Heat Man Stage"]] + items = [["Item 2 - Rocket"]] + self.assertAccessDependency(locations, items, True) + + def test_any_item(self) -> None: + locations = [flash_man_c2, quick_man_c1, crash_man_c3] + items = [["Item 1 - Propeller"], ["Item 2 - Rocket"], ["Item 3 - Bouncy"]] + self.assertAccessDependency(locations, items, True) + locations = [metal_man_c2, metal_man_c3] + items = [["Item 1 - Propeller"], ["Item 2 - Rocket"]] + self.assertAccessDependency(locations, items, True) + + def test_all_items(self) -> None: + locations = [flash_man_c2, quick_man_c1, crash_man_c3, metal_man_c2, metal_man_c3, *heat_man_locations, + *etank_1ups["Heat Man Stage"], *wily_1_locations, *wily_2_locations, *wily_3_locations, + *wily_4_locations, *wily_5_locations, *wily_6_locations, *etank_1ups["Wily Stage 1"], + *etank_1ups["Wily Stage 2"], *etank_1ups["Wily Stage 3"], *etank_1ups["Wily Stage 4"], + *energy_pickups["Wily Stage 1"], *energy_pickups["Wily Stage 2"], *energy_pickups["Wily Stage 3"], + *energy_pickups["Wily Stage 4"]] + items = [["Item 1 - Propeller", "Item 2 - Rocket", "Item 3 - Bouncy"]] + self.assertAccessDependency(locations, items) + + def test_crash_bomber(self) -> None: + locations = [flash_man_c3, flash_man_c4, wily_2_c5, wily_2_c6, wily_3_c1, wily_3_c2, + wily_4, wily_stage_4] + items = [["Crash Bomber"]] + self.assertAccessDependency(locations, items) diff --git a/worlds/mm2/test/test_weakness.py b/worlds/mm2/test/test_weakness.py new file mode 100644 index 000000000000..d3dc7b686704 --- /dev/null +++ b/worlds/mm2/test/test_weakness.py @@ -0,0 +1,93 @@ +from math import ceil + +from . import MM2TestBase +from ..options import bosses + + +# Need to figure out how this test should work +def validate_wily_5(base: MM2TestBase) -> None: + world = base.multiworld.worlds[base.player] + weapon_damage = world.weapon_damage + boss_health = {boss: 0x1C for boss in [*list(range(8)), 12]} + weapon_costs = { + 0: 0, + 1: 10, + 2: 2, + 3: 3, + 4: 0.5, + 5: 0.125, + 6: 4, + 7: 0.25, + 8: 7, + } + weapon_energy = {key: float(0x1C * 2) if key == 12 else float(0x1C) for key in weapon_costs} + weapon_boss = {boss: {weapon: weapon_damage[weapon][boss] for weapon in weapon_damage} + for boss in [*list(range(8)), 12]} + flexibility = [(sum(1 if weapon_boss[boss][weapon] > 0 else 0 for weapon in range(9)) * + sum(weapon_boss[boss].values()), boss) for boss in weapon_boss if boss != 12] + for _, boss in [*sorted(flexibility), (0, 12)]: + boss_damage = weapon_boss[boss] + weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in + boss_damage.items() if weapon_energy[weapon]} + if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight: + # We get exactly one use of Time Stopper during the rush + # So we want to make sure that use is absolutely needed + weapon_weight[8] = min(weapon_weight[8], 0.001) + while boss_health[boss] > 0: + if boss_damage[0]: + boss_health[boss] = 0 # if we can buster, we should buster + continue + highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys())) + uses = weapon_energy[wp] // weapon_costs[wp] + if int(uses * boss_damage[wp]) > boss_health[boss]: + used = ceil(boss_health[boss] / boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] = 0 + elif highest <= 0: + # we are out of weapons that can actually damage the boss + base.fail(f"Ran out of weapon energy to damage " + f"{next(name for name in bosses if bosses[name] == boss)}\n" + f"Seed: {base.multiworld.seed}\n" + f"Damage Table: {weapon_damage}") + else: + # drain the weapon and continue + boss_health[boss] -= int(uses * boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * uses + weapon_weight.pop(wp) + + +class StrictWeaknessTests(MM2TestBase): + options = { + "strict_weakness": True, + "yoku_jumps": True, + "enable_lasers": True + } + + def test_that_every_boss_has_a_weakness(self) -> None: + world = self.multiworld.worlds[self.player] + weapon_damage = world.weapon_damage + for boss in range(14): + if not any(weapon_damage[weapon][boss] for weapon in range(9)): + self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}") + + def test_wily_5(self) -> None: + validate_wily_5(self) + + +class RandomStrictWeaknessTests(MM2TestBase): + options = { + "strict_weakness": True, + "random_weakness": "randomized", + "yoku_jumps": True, + "enable_lasers": True + } + + def test_that_every_boss_has_a_weakness(self) -> None: + world = self.multiworld.worlds[self.player] + weapon_damage = world.weapon_damage + for boss in range(14): + if not any(weapon_damage[weapon][boss] for weapon in range(9)): + self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}") + + def test_wily_5(self) -> None: + validate_wily_5(self) diff --git a/worlds/mm2/text.py b/worlds/mm2/text.py new file mode 100644 index 000000000000..32d665bf6c7f --- /dev/null +++ b/worlds/mm2/text.py @@ -0,0 +1,90 @@ +from typing import DefaultDict +from collections import defaultdict + +MM2_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda x: 0x6F, { + ' ': 0x40, + 'A': 0x41, + 'B': 0x42, + 'C': 0x43, + 'D': 0x44, + 'E': 0x45, + 'F': 0x46, + 'G': 0x47, + 'H': 0x48, + 'I': 0x49, + 'J': 0x4A, + 'K': 0x4B, + 'L': 0x4C, + 'M': 0x4D, + 'N': 0x4E, + 'O': 0x4F, + 'P': 0x50, + 'Q': 0x51, + 'R': 0x52, + 'S': 0x53, + 'T': 0x54, + 'U': 0x55, + 'V': 0x56, + 'W': 0x57, + 'X': 0x58, + 'Y': 0x59, + 'Z': 0x5A, + # 0x5B is the small r in Dr Light + '.': 0x5C, + ',': 0x5D, + '\'': 0x5E, + '!': 0x5F, + '(': 0x60, + ')': 0x61, + '#': 0x62, + '$': 0x63, + '%': 0x64, + '&': 0x65, + '*': 0x66, + '+': 0x67, + '/': 0x68, + '\\': 0x69, + ':': 0x6A, + ';': 0x6B, + '<': 0x6C, + '>': 0x6D, + '=': 0x6E, + '?': 0x6F, + '@': 0x70, + '[': 0x71, + ']': 0x72, + '^': 0x73, + '_': 0x74, + '`': 0x75, + '{': 0x76, + '}': 0x77, + '|': 0x78, + '~': 0x79, + '\"': 0x92, + '-': 0x94, + '0': 0xA0, + '1': 0xA1, + '2': 0xA2, + '3': 0xA3, + '4': 0xA4, + '5': 0xA5, + '6': 0xA6, + '7': 0xA7, + '8': 0xA8, + '9': 0xA9, +}) + + +class MM2TextEntry: + def __init__(self, text: str = "", coords: int = 0x0B): + self.target_area: int = 0x25 # don't change + self.coords: int = coords # 0xYX, Y can only be increments of 0x20 + self.text: str = text + + def resolve(self) -> bytes: + data = bytearray() + data.append(self.target_area) + data.append(self.coords) + data.extend([MM2_WEAPON_ENCODING[x] for x in self.text.upper()]) + data.extend([0x40] * (14 - len(self.text))) + return bytes(data)