From 5b99118dda7025d78aa457c4098d58aef69e6378 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:42:06 -0500 Subject: [PATCH] Mega Man 3: Implement new game (#5237) --- README.md | 1 + docs/CODEOWNERS | 3 + inno_setup.iss | 5 + worlds/mm3/.apignore | 1 + worlds/mm3/__init__.py | 275 +++++++++ worlds/mm3/archipelago.json | 6 + worlds/mm3/client.py | 783 ++++++++++++++++++++++++++ worlds/mm3/color.py | 331 +++++++++++ worlds/mm3/data/mm3_basepatch.bsdiff4 | Bin 0 -> 1235 bytes worlds/mm3/docs/en_Mega Man 3.md | 131 +++++ worlds/mm3/docs/setup_en.md | 53 ++ worlds/mm3/items.py | 80 +++ worlds/mm3/locations.py | 312 ++++++++++ worlds/mm3/names.py | 221 ++++++++ worlds/mm3/options.py | 164 ++++++ worlds/mm3/rom.py | 374 ++++++++++++ worlds/mm3/rules.py | 388 +++++++++++++ worlds/mm3/src/__init__.py | 0 worlds/mm3/src/mm3_basepatch.asm | 781 +++++++++++++++++++++++++ worlds/mm3/src/patch_mm3base.py | 8 + worlds/mm3/test/__init__.py | 0 worlds/mm3/test/bases.py | 5 + worlds/mm3/test/test_weakness.py | 105 ++++ worlds/mm3/text.py | 63 +++ 24 files changed, 4090 insertions(+) create mode 100644 worlds/mm3/.apignore create mode 100644 worlds/mm3/__init__.py create mode 100644 worlds/mm3/archipelago.json create mode 100644 worlds/mm3/client.py create mode 100644 worlds/mm3/color.py create mode 100644 worlds/mm3/data/mm3_basepatch.bsdiff4 create mode 100644 worlds/mm3/docs/en_Mega Man 3.md create mode 100644 worlds/mm3/docs/setup_en.md create mode 100644 worlds/mm3/items.py create mode 100644 worlds/mm3/locations.py create mode 100644 worlds/mm3/names.py create mode 100644 worlds/mm3/options.py create mode 100644 worlds/mm3/rom.py create mode 100644 worlds/mm3/rules.py create mode 100644 worlds/mm3/src/__init__.py create mode 100644 worlds/mm3/src/mm3_basepatch.asm create mode 100644 worlds/mm3/src/patch_mm3base.py create mode 100644 worlds/mm3/test/__init__.py create mode 100644 worlds/mm3/test/bases.py create mode 100644 worlds/mm3/test/test_weakness.py create mode 100644 worlds/mm3/text.py diff --git a/README.md b/README.md index efa18bc1ef..7a0c663db0 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Currently, the following games are supported: * APQuest * Satisfactory * EarthBound +* Mega Man 3 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 0e368386c5..46afd30456 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -134,6 +134,9 @@ # Mega Man 2 /worlds/mm2/ @Silvris +# Mega Man 3 +/worlds/mm3/ @Silvris + # MegaMan Battle Network 3 /worlds/mmbn3/ @digiholic diff --git a/inno_setup.iss b/inno_setup.iss index c396224c56..999070ad07 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -213,6 +213,11 @@ Root: HKCR; Subkey: "{#MyAppName}ebpatch"; ValueData: "Archi Root: HKCR; Subkey: "{#MyAppName}ebpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ebpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apmm3"; ValueData: "{#MyAppName}mm3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm3patch"; ValueData: "Archipelago Mega Man 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm3patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; + Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; diff --git a/worlds/mm3/.apignore b/worlds/mm3/.apignore new file mode 100644 index 0000000000..4ae3da2695 --- /dev/null +++ b/worlds/mm3/.apignore @@ -0,0 +1 @@ +/src/* diff --git a/worlds/mm3/__init__.py b/worlds/mm3/__init__.py new file mode 100644 index 0000000000..5b349bc9c3 --- /dev/null +++ b/worlds/mm3/__init__.py @@ -0,0 +1,275 @@ +import hashlib +import logging +from copy import deepcopy +from typing import Any, Sequence, ClassVar + +from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location +from worlds.AutoWorld import World, WebWorld +from .names import (gamma, gemini_man_stage, needle_man_stage, hard_man_stage, magnet_man_stage, top_man_stage, + snake_man_stage, spark_man_stage, shadow_man_stage, rush_marine, rush_jet, rush_coil) +from .items import (item_table, item_names, MM3Item, filler_item_weights, robot_master_weapon_table, + stage_access_table, rush_item_table, lookup_item_to_id) +from .locations import (MM3Location, mm3_regions, MM3Region, lookup_location_to_id, + location_groups) +from .rom import patch_rom, MM3ProcedurePatch, MM3LCHASH, MM3VCHASH, PROTEUSHASH, MM3NESHASH +from .options import MM3Options, Consumables +from .client import MegaMan3Client +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 3") + + +class MM3Settings(settings.Group): + class RomFile(settings.UserFilePath): + """File name of the MM3 EN rom""" + description = "Mega Man 3 ROM File" + copy_to: str | None = "Mega Man 3 (USA).nes" + md5s = [MM3NESHASH, MM3LCHASH, PROTEUSHASH, MM3VCHASH] + + def browse(self: settings.T, + filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, + **kwargs: Any) -> settings.T | None: + 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 MM3WebWorld(WebWorld): + theme = "partyTime" + tutorials = [ + + Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Mega Man 3 randomizer connected to an Archipelago Multiworld.", + "English", + "setup_en.md", + "setup/en", + ["Silvris"] + ) + ] + + +class MM3World(World): + """ + Following his second defeat by Mega Man, Dr. Wily has finally come to his senses. He and Dr. Light begin work on + Gamma, a giant peacekeeping robot. However, Gamma's power source, the Energy Elements, are being guarded by the + Robot Masters sent to retrieve them. It's up to Mega Man to retrieve the Energy Elements and defeat the mastermind + behind the Robot Masters' betrayal. + """ + + game = "Mega Man 3" + settings: ClassVar[MM3Settings] + options_dataclass = MM3Options + options: MM3Options + 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 = MM3WebWorld() + rom_name: bytearray + + 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_4_weapons: dict[int, list[int]] = {} + + def create_regions(self) -> None: + menu = MM3Region("Menu", self.player, self.multiworld) + self.multiworld.regions.append(menu) + location: MM3Location + for name, region in mm3_regions.items(): + stage = MM3Region(name, self.player, self.multiworld) + if not region.parent: + menu.connect(stage, f"To {name}", + lambda state, req=tuple(region.required_items): state.has_all(req, self.player)) + else: + old_stage = self.get_region(region.parent) + old_stage.connect(stage, f"To {name}", + lambda state, req=tuple(region.required_items): state.has_all(req, self.player)) + stage.add_locations({loc: data.location_id for loc, data in region.locations.items() + if (not data.energy or self.options.consumables.value in (Consumables.option_weapon_health, Consumables.option_all)) + and (not data.oneup_tank or self.options.consumables.value in (Consumables.option_1up_etank, Consumables.option_all))}) + for location in stage.get_locations(): + if location.address is None and location.name != gamma: + location.place_locked_item(MM3Item(location.name, ItemClassification.progression, + None, self.player)) + self.multiworld.regions.append(stage) + goal_location = self.get_location(gamma) + goal_location.place_locked_item(MM3Item("Victory", ItemClassification.progression, None, self.player)) + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + def create_item(self, name: str, force_non_progression: bool = False) -> MM3Item: + item = item_table[name] + classification = ItemClassification.filler + if item.progression and not force_non_progression: + classification = ItemClassification.progression_skip_balancing \ + if item.skip_balancing else ItemClassification.progression + if item.useful: + classification |= ItemClassification.useful + return MM3Item(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[0x0101 + 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 rush_item_table.keys()]) + total_checks = 31 + if self.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + total_checks += 33 + if self.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + total_checks += 106 + 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 (self.options.starting_robot_master.current_key == "gemini_man" + and not any(item in self.options.start_inventory for item in rush_item_table.keys())) or \ + (self.options.starting_robot_master.current_key == "hard_man" + and not any(item in self.options.start_inventory for item in [rush_coil, rush_jet])): + robot_master_pool = [0, 1, 4, 5, 6, 7, ] + if rush_marine in self.options.start_inventory: + robot_master_pool.append(2) + self.options.starting_robot_master.value = self.random.choice(robot_master_pool) + logger.warning( + f"Incompatible starting Robot Master, changing to " + f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}") + + def fill_hook(self, + prog_item_pool: list["Item"], + useful_item_pool: list["Item"], + filler_item_pool: 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 + # MM3 is worse than MM2 here, some of the RBMs can also require Rush + 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: needle_man_stage, + 1: magnet_man_stage, + 2: gemini_man_stage, + 3: hard_man_stage, + 4: top_man_stage, + 5: snake_man_stage, + 6: spark_man_stage, + 7: shadow_man_stage + } + affected_rbm = [2, 3] # Gemini and Hard will always have this happen + possible_rbm = [0, 7] # Needle and Shadow are always valid targets, due to Rush Marine/Jet receive + if self.options.consumables: + possible_rbm.extend([4, 5]) # every stage has at least one of each consumable + if self.options.consumables in (Consumables.option_weapon_health, Consumables.option_all): + possible_rbm.extend([1, 6]) + else: + affected_rbm.extend([1, 6]) + else: + affected_rbm.extend([1, 4, 5, 6]) # only two checks on non consumables + 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 prog_item_pool + 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) + prog_item_pool.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 prog_item_pool + 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 == 0x0101 + self.options.starting_robot_master.value) + weapon_location = self.get_location(weapon_name) + weapon_location.place_locked_item(placed_weapon) + prog_item_pool.remove(placed_weapon) + fill_locations.remove(weapon_location) + + def generate_output(self, output_directory: str) -> None: + try: + patch = MM3ProcedurePatch(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_4_weapons": self.wily_4_weapons + } + + @staticmethod + def interpret_slot_data(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_4_weapons"].items()} + return {"weapon_damage": local_weapon, "wily_4_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/mm3/archipelago.json b/worlds/mm3/archipelago.json new file mode 100644 index 0000000000..ed5ecffc6c --- /dev/null +++ b/worlds/mm3/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Mega Man 3", + "authors": ["Silvris"], + "world_version": "0.1.7", + "minimum_ap_version": "0.6.4" +} diff --git a/worlds/mm3/client.py b/worlds/mm3/client.py new file mode 100644 index 0000000000..0e069043a7 --- /dev/null +++ b/worlds/mm3/client.py @@ -0,0 +1,783 @@ +import logging +import time +from enum import IntEnum +from base64 import b64encode +from typing import TYPE_CHECKING, 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") + +MM3_CURRENT_STAGE = 0x22 +MM3_MEGAMAN_STATE = 0x30 +MM3_PROG_STATE = 0x60 +MM3_ROBOT_MASTERS_DEFEATED = 0x61 +MM3_DOC_STATUS = 0x62 +MM3_HEALTH = 0xA2 +MM3_WEAPON_ENERGY = 0xA3 +MM3_WEAPONS = { + 1: 1, + 2: 3, + 3: 0, + 4: 2, + 5: 4, + 6: 5, + 7: 7, + 8: 9, + 0x11: 6, + 0x12: 8, + 0x13: 10, +} + +MM3_DOC_REMAP = { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 6, + 5: 7, + 6: 4, + 7: 5 +} +MM3_LIVES = 0xAE +MM3_E_TANKS = 0xAF +MM3_ENERGY_BAR = 0xB2 +MM3_CONSUMABLES = 0x150 +MM3_ROBOT_MASTERS_UNLOCKED = 0x680 +MM3_DOC_ROBOT_UNLOCKED = 0x681 +MM3_ENERGYLINK = 0x682 +MM3_LAST_WILY = 0x683 +MM3_RBM_STROBE = 0x684 +MM3_SFX_QUEUE = 0x685 +MM3_DOC_ROBOT_DEFEATED = 0x686 +MM3_COMPLETED_STAGES = 0x687 +MM3_RECEIVED_ITEMS = 0x688 +MM3_RUSH_RECEIVED = 0x689 + +MM3_CONSUMABLE_TABLE: dict[int, dict[int, tuple[int, int]]] = { + # Stage: + # Item: (byte offset, bit mask) + 0: { + 0x0200: (0, 5), + 0x0201: (3, 2), + }, + 1: { + 0x0202: (2, 6), + 0x0203: (2, 5), + 0x0204: (2, 4), + 0x0205: (2, 3), + 0x0206: (3, 6), + 0x0207: (3, 5), + 0x0208: (3, 7), + 0x0209: (4, 0) + }, + 2: { + 0x020A: (2, 7), + 0x020B: (3, 0), + 0x020C: (3, 1), + 0x020D: (3, 2), + 0x020E: (4, 2), + 0x020F: (4, 3), + 0x0210: (4, 7), + 0x0211: (5, 1), + 0x0212: (6, 1), + 0x0213: (7, 0) + }, + 3: { + 0x0214: (0, 6), + 0x0215: (1, 5), + 0x0216: (2, 3), + 0x0217: (2, 7), + 0x0218: (2, 6), + 0x0219: (2, 5), + 0x021A: (4, 5), + }, + 4: { + 0x021B: (1, 3), + 0x021C: (1, 5), + 0x021D: (1, 7), + 0x021E: (2, 0), + 0x021F: (1, 6), + 0x0220: (2, 4), + 0x0221: (2, 5), + 0x0222: (4, 5) + }, + 5: { + 0x0223: (3, 0), + 0x0224: (3, 2), + 0x0225: (4, 5), + 0x0226: (4, 6), + 0x0227: (6, 4), + }, + 6: { + 0x0228: (2, 0), + 0x0229: (2, 1), + 0x022A: (3, 1), + 0x022B: (3, 2), + 0x022C: (3, 3), + 0x022D: (3, 4), + }, + 7: { + 0x022E: (3, 5), + 0x022F: (3, 4), + 0x0230: (3, 3), + 0x0231: (3, 2), + }, + 8: { + 0x0232: (1, 4), + 0x0233: (2, 1), + 0x0234: (2, 2), + 0x0235: (2, 5), + 0x0236: (3, 5), + 0x0237: (4, 2), + 0x0238: (4, 4), + 0x0239: (5, 3), + 0x023A: (6, 0), + 0x023B: (6, 1), + 0x023C: (7, 5), + + }, + 9: { + 0x023D: (3, 2), + 0x023E: (3, 6), + 0x023F: (4, 5), + 0x0240: (5, 4), + }, + 10: { + 0x0241: (0, 2), + 0x0242: (2, 4) + }, + 11: { + 0x0243: (4, 1), + 0x0244: (6, 0), + 0x0245: (6, 1), + 0x0246: (6, 2), + 0x0247: (6, 3), + }, + 12: { + 0x0248: (0, 0), + 0x0249: (0, 3), + 0x024A: (0, 5), + 0x024B: (1, 6), + 0x024C: (2, 7), + 0x024D: (2, 3), + 0x024E: (2, 1), + 0x024F: (2, 2), + 0x0250: (3, 5), + 0x0251: (3, 4), + 0x0252: (3, 6), + 0x0253: (3, 7) + }, + 13: { + 0x0254: (0, 3), + 0x0255: (0, 6), + 0x0256: (1, 0), + 0x0257: (3, 0), + 0x0258: (3, 2), + 0x0259: (3, 3), + 0x025A: (3, 4), + 0x025B: (3, 5), + 0x025C: (3, 6), + 0x025D: (4, 0), + 0x025E: (3, 7), + 0x025F: (4, 1), + 0x0260: (4, 2), + }, + 14: { + 0x0261: (0, 3), + 0x0262: (0, 2), + 0x0263: (0, 6), + 0x0264: (1, 2), + 0x0265: (1, 7), + 0x0266: (2, 0), + 0x0267: (2, 1), + 0x0268: (2, 2), + 0x0269: (2, 3), + 0x026A: (5, 2), + 0x026B: (5, 3), + }, + 15: { + 0x026C: (0, 0), + 0x026D: (0, 1), + 0x026E: (0, 2), + 0x026F: (0, 3), + 0x0270: (0, 4), + 0x0271: (0, 6), + 0x0272: (1, 0), + 0x0273: (1, 2), + 0x0274: (1, 3), + 0x0275: (1, 1), + 0x0276: (0, 7), + 0x0277: (3, 2), + 0x0278: (2, 2), + 0x0279: (2, 3), + 0x027A: (2, 4), + 0x027B: (2, 5), + 0x027C: (3, 1), + 0x027D: (3, 0), + 0x027E: (2, 7), + 0x027F: (2, 6), + }, + 16: { + 0x0280: (0, 0), + 0x0281: (0, 3), + 0x0282: (0, 1), + 0x0283: (0, 2), + }, + 17: { + 0x0284: (0, 2), + 0x0285: (0, 6), + 0x0286: (0, 1), + 0x0287: (0, 5), + 0x0288: (0, 3), + 0x0289: (0, 0), + 0x028A: (0, 4) + } +} + + +def to_oneup_format(val: int) -> int: + return ((val // 10) * 0x10) + val % 10 + + +def from_oneup_format(val: int) -> int: + return ((val // 0x10) * 10) + val % 0x10 + + +class MM3EnergyLinkType(IntEnum): + Life = 0 + NeedleCannon = 1 + MagnetMissile = 2 + GeminiLaser = 3 + HardKnuckle = 4 + TopSpin = 5 + SearchSnake = 6 + SparkShot = 7 + ShadowBlade = 8 + OneUP = 12 + RushCoil = 0x11 + RushMarine = 0x12 + RushJet = 0x13 + + +request_to_name: dict[str, str] = { + "HP": "health", + "NE": "Needle Cannon energy", + "MA": "Magnet Missile energy", + "GE": "Gemini Laser energy", + "HA": "Hard Knuckle energy", + "TO": "Top Spin energy", + "SN": "Search Snake energy", + "SP": "Spark Shot energy", + "SH": "Shadow Blade energy", + "RC": "Rush Coil energy", + "RM": "Rush Marine energy", + "RJ": "Rush Jet 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 3": + logger.warning("This command can only be used when playing Mega Man 3.") + 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: + """Request a refill from EnergyLink.""" + from worlds._bizhawk.context import BizHawkClientContext + if self.ctx.game != "Mega Man 3": + logger.warning("This command can only be used when playing Mega Man 3.") + 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, MM3EnergyLinkType] = { + "HP": MM3EnergyLinkType.Life, + "NE": MM3EnergyLinkType.NeedleCannon, + "MA": MM3EnergyLinkType.MagnetMissile, + "GE": MM3EnergyLinkType.GeminiLaser, + "HA": MM3EnergyLinkType.HardKnuckle, + "TO": MM3EnergyLinkType.TopSpin, + "SN": MM3EnergyLinkType.SearchSnake, + "SP": MM3EnergyLinkType.SparkShot, + "SH": MM3EnergyLinkType.ShadowBlade, + "RC": MM3EnergyLinkType.RushCoil, + "RM": MM3EnergyLinkType.RushMarine, + "RJ": MM3EnergyLinkType.RushJet, + "1U": MM3EnergyLinkType.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, MegaMan3Client) + client.refill_queue.append((valid_targets[target.upper()], int(amount))) + logger.info(f"Restoring {amount} {request_to_name[target.upper()]}.") + + +def cmd_autoheal(self: "BizHawkClientCommandProcessor") -> None: + """Enable auto heal from EnergyLink.""" + if self.ctx.game != "Mega Man 3": + logger.warning("This command can only be used when playing Mega Man 3.") + 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, MegaMan3Client) + 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[int, bytes, str]: + return MM3_SFX_QUEUE, sfx.to_bytes(1, 'little'), "RAM" + + +class MegaMan3Client(BizHawkClient): + game = "Mega Man 3" + system = "NES" + patch_suffix = ".apmm3" + 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: bytes | None = None + weapon_energy: int = 0 + health_energy: int = 0 + auto_heal: bool = False + refill_queue: list[tuple[MM3EnergyLinkType, int]] = [] + last_wily: int | None = None # default to wily 1 + doc_status: int | None = None # default to no doc progress + + async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: + from worlds._bizhawk import RequestFailedError, read, get_memory_size + from . import MM3World + + try: + + if (await get_memory_size(ctx.bizhawk_ctx, "PRG ROM")) < 0x3FFB0: + # not the entire size, but enough to check validation + 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 + + game_name, version = (await read(ctx.bizhawk_ctx, [(0x3F320, 21, "PRG ROM"), + (0x3F33C, 3, "PRG ROM")])) + if game_name[:3] != b"MM3" or version != bytes(MM3World.world_version): + if game_name[:3] == b"MM3": + # I think this is an easier check than the other? + older_version = f"{version[0]}.{version[1]}.{version[2]}" + logger.warning(f"This Mega Man 3 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 MM3World.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, [(0x3F336, 1, "PRG ROM")]))[0][0] + if deathlink & 0x01: + self.death_link = True + await ctx.update_death_link(self.death_link) + 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"MM3_LAST_WILY_{ctx.team}_{ctx.slot}" in args["keys"]: + self.last_wily = args["keys"][f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}"] + if f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}" in args["keys"]: + self.doc_status = args["keys"][f"MM3_DOC_STATUS_{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 + (prog_state, robot_masters_unlocked, robot_masters_defeated, doc_status, doc_robo_unlocked, doc_robo_defeated, + rush_acquired, received_items, completed_stages, consumable_checks, + e_tanks, lives, weapon_energy, health, state, bar_state, current_stage, + energy_link_packet, last_wily) = await read(ctx.bizhawk_ctx, [ + (MM3_PROG_STATE, 1, "RAM"), + (MM3_ROBOT_MASTERS_UNLOCKED, 1, "RAM"), + (MM3_ROBOT_MASTERS_DEFEATED, 1, "RAM"), + (MM3_DOC_STATUS, 1, "RAM"), + (MM3_DOC_ROBOT_UNLOCKED, 1, "RAM"), + (MM3_DOC_ROBOT_DEFEATED, 1, "RAM"), + (MM3_RUSH_RECEIVED, 1, "RAM"), + (MM3_RECEIVED_ITEMS, 1, "RAM"), + (MM3_COMPLETED_STAGES, 0x1, "RAM"), + (MM3_CONSUMABLES, 16, "RAM"), # Could be more but 16 definitely catches all current + (MM3_E_TANKS, 1, "RAM"), + (MM3_LIVES, 1, "RAM"), + (MM3_WEAPON_ENERGY, 11, "RAM"), + (MM3_HEALTH, 1, "RAM"), + (MM3_MEGAMAN_STATE, 1, "RAM"), + (MM3_ENERGY_BAR, 2, "RAM"), + (MM3_CURRENT_STAGE, 1, "RAM"), + (MM3_ENERGYLINK, 1, "RAM"), + (MM3_LAST_WILY, 1, "RAM"), + ]) + + if bar_state[0] not in (0x00, 0x80): + return # Game is not initialized + # Bit of a trick here, bar state can only be 0x00 or 0x80 (display health bar, or don't) + # This means it can double as init guard and in-stage tracker + + if not ctx.finished_game and completed_stages[0] & 0x20: + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + writes = [] + + # deathlink + # only handle deathlink in bar state 0x80 (in stage) + if bar_state[0] == 0x80: + if self.pending_death_link: + writes.append((MM3_MEGAMAN_STATE, bytes([0x0E]), "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 state[0] == 0x0E and not self.sending_death_link: + await self.send_deathlink(ctx) + elif state[0] != 0x0E: + 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"MM3_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "default", "value": 0xC} + ]}]) + await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM3_LAST_WILY_{ctx.team}_{ctx.slot}"]}]) + elif last_wily[0] == 0: + writes.append((MM3_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"MM3_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "replace", "value": self.last_wily} + ]}]) + + if self.doc_status != doc_status[0]: + if self.doc_status is None: + # revalidate doc status from data storage + await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "default", "value": 0} + ]}]) + await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}"]}]) + elif doc_status[0] == 0: + writes.append((MM3_DOC_STATUS, self.doc_status.to_bytes(1, "little"), "RAM")) + else: + # correct our setting + # shouldn't be possible to desync, but we'll account for it anyways + self.doc_status |= doc_status[0] + await ctx.send_msgs([{"cmd": "Set", "key": f"MM3_DOC_STATUS_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "replace", "value": self.doc_status} + ]}]) + + weapon_energy = bytearray(weapon_energy) + # handle receiving items + recv_amount = received_items[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_slot(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 & 0x120 == 0: + # Robot Master Weapon, or Rush + new_weapons = item.item & 0xFF + weapon_energy[MM3_WEAPONS[new_weapons]] |= 0x9C + writes.append((MM3_WEAPON_ENERGY, weapon_energy, "RAM")) + writes.append(get_sfx_writes(0x32)) + elif item.item & 0x20 == 0: + # Robot Master Stage Access + # Catch the Doc Robo here + if item.item & 0x10: + ptr = MM3_DOC_ROBOT_UNLOCKED + unlocked = doc_robo_unlocked + else: + ptr = MM3_ROBOT_MASTERS_UNLOCKED + unlocked = robot_masters_unlocked + new_stages = unlocked[0] | (1 << ((item.item & 0xF) - 1)) + print(new_stages) + writes.append((ptr, new_stages.to_bytes(1, 'little'), "RAM")) + writes.append(get_sfx_writes(0x34)) + writes.append((MM3_RBM_STROBE, b"\x01", "RAM")) + else: + # append to the queue, so we handle it later + self.item_queue.append(item) + recv_amount += 1 + writes.append((MM3_RECEIVED_ITEMS, recv_amount.to_bytes(1, 'little'), "RAM")) + + if energy_link_packet[0]: + pickup = energy_link_packet[0] + if pickup in (0x64, 0x65): + # Health pickups + if pickup == 0x65: + value = 2 + else: + value = 10 + exchange_rate = HP_EXCHANGE_RATE + elif pickup in (0x66, 0x67): + # Weapon Energy + if pickup == 0x67: + value = 2 + else: + value = 10 + exchange_rate = WEAPON_EXCHANGE_RATE + elif pickup == 0x69: + # 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((MM3_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 + for i, weapon in zip(range(len(weapon_energy)), weapon_energy): + if weapon & 0x80 and (weapon & 0x7F) < 0x1C: + missing = 0x1C - (weapon & 0x7F) + 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((MM3_WEAPON_ENERGY, weapon_energy, "RAM")) + break + else: + if current_energy != self.weapon_energy: + writes.append((MM3_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 & 0x7F) < 0x1C: + health_diff = 0x1C - (current_health & 0x7F) + 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((MM3_HEALTH, current_health.to_bytes(1, 'little'), "RAM")) + + if self.refill_queue: + refill_type, refill_amount = self.refill_queue.pop() + if refill_type == MM3EnergyLinkType.Life: + exchange_rate = HP_EXCHANGE_RATE + elif refill_type == MM3EnergyLinkType.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 == MM3EnergyLinkType.Life: + refill_ptr = MM3_HEALTH + elif refill_type == MM3EnergyLinkType.OneUP: + refill_ptr = MM3_LIVES + else: + refill_ptr = MM3_WEAPON_ENERGY + MM3_WEAPONS[refill_type] + current_value = (await read(ctx.bizhawk_ctx, [(refill_ptr, 1, "RAM")]))[0][0] + if refill_type == MM3EnergyLinkType.OneUP: + current_value = from_oneup_format(current_value) + new_value = min(0x9C if refill_type != MM3EnergyLinkType.OneUP else 99, current_value + refill_amount) + if refill_type == MM3EnergyLinkType.OneUP: + new_value = to_oneup_format(new_value) + 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 = from_oneup_format(lives[0]) + if current_lives > 99: + self.item_queue.append(item) + else: + current_lives += 1 + current_lives = to_oneup_format(current_lives) + writes.append((MM3_LIVES, current_lives.to_bytes(1, 'little'), "RAM")) + writes.append(get_sfx_writes(0x14)) + elif idx == 1: + self.weapon_energy += 0xE + writes.append(get_sfx_writes(0x1C)) + elif idx == 2: + self.health_energy += 0xE + writes.append(get_sfx_writes(0x1C)) + elif idx == 3: + current_tanks = from_oneup_format(e_tanks[0]) + if current_tanks > 99: + self.item_queue.append(item) + else: + current_tanks += 1 + current_tanks = to_oneup_format(current_tanks) + writes.append((MM3_E_TANKS, current_tanks.to_bytes(1, 'little'), "RAM")) + writes.append(get_sfx_writes(0x14)) + + 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: + rbm_id = 0x0001 + i + if rbm_id not in ctx.checked_locations: + new_checks.append(rbm_id) + wep_id = 0x0101 + i + if wep_id not in ctx.checked_locations: + new_checks.append(wep_id) + if doc_robo_defeated[0] & flag: + doc_id = 0x0010 + MM3_DOC_REMAP[i] + if doc_id not in ctx.checked_locations: + new_checks.append(doc_id) + + for i in range(2): + flag = 1 << i + if rush_acquired[0] & flag: + itm_id = 0x0111 + i + if itm_id not in ctx.checked_locations: + new_checks.append(itm_id) + + for i in (0, 1, 2, 4): + # Wily 4 does not have a boss check + boss_id = 0x0009 + i + if completed_stages[0] & (1 << i) != 0: + if boss_id not in ctx.checked_locations: + new_checks.append(boss_id) + + if completed_stages[0] & 0x80 and 0x000F not in ctx.checked_locations: + new_checks.append(0x000F) + + if bar_state[0] == 0x80: # currently in stage + if (prog_state[0] > 0x00 and current_stage[0] >= 8) or prog_state[0] == 0x00: + # need to block the specific state of Break Man prog=0x12 stage=0x5 + # it doesn't clean the consumable table and he doesn't have any anyways + for consumable in MM3_CONSUMABLE_TABLE[current_stage[0]]: + consumable_info = MM3_CONSUMABLE_TABLE[current_stage[0]][consumable] + if consumable not in ctx.checked_locations: + is_checked = consumable_checks[consumable_info[0]] & (1 << consumable_info[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/mm3/color.py b/worlds/mm3/color.py new file mode 100644 index 0000000000..0944026432 --- /dev/null +++ b/worlds/mm3/color.py @@ -0,0 +1,331 @@ +import sys +from typing import TYPE_CHECKING +from . import names +from zlib import crc32 +import struct +import logging + +if TYPE_CHECKING: + from . import MM3World + from .rom import MM3ProcedurePatch + +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 +} + +MM3_COLORS: dict[str, tuple[int, int]] = { + names.gemini_laser: (0x30, 0x21), + names.needle_cannon: (0x30, 0x17), + names.hard_knuckle: (0x10, 0x01), + names.magnet_missile: (0x10, 0x16), + names.top_spin: (0x36, 0x00), + names.search_snake: (0x30, 0x19), + names.rush_coil: (0x30, 0x15), + names.spark_shock: (0x30, 0x26), + names.rush_marine: (0x30, 0x15), + names.shadow_blade: (0x34, 0x14), + names.rush_jet: (0x30, 0x15), + names.needle_man_stage: (0x3C, 0x11), + names.magnet_man_stage: (0x30, 0x15), + names.gemini_man_stage: (0x30, 0x21), + names.hard_man_stage: (0x10, 0xC), + names.top_man_stage: (0x30, 0x26), + names.snake_man_stage: (0x30, 0x29), + names.spark_man_stage: (0x30, 0x26), + names.shadow_man_stage: (0x30, 0x11), + names.doc_needle_stage: (0x27, 0x15), + names.doc_gemini_stage: (0x27, 0x15), + names.doc_spark_stage: (0x27, 0x15), + names.doc_shadow_stage: (0x27, 0x15), +} + +MM3_KNOWN_COLORS: dict[str, tuple[int, int]] = { + **MM3_COLORS, + # Metroid series + "Varia Suit": (0x27, 0x16), + "Gravity Suit": (0x14, 0x16), + "Phazon Suit": (0x06, 0x1D), + # Street Fighter, technically + "Hadouken": (0x3C, 0x11), + "Shoryuken": (0x38, 0x16), + # X Series + "Z-Saber": (0x20, 0x16), + "Helmet Upgrade": (0x20, 0x01), + "Body Upgrade": (0x20, 0x01), + "Arms Upgrade": (0x20, 0x01), + "Plasma Shot Upgrade": (0x20, 0x01), + "Stock Charge Upgrade": (0x20, 0x01), + "Legs Upgrade": (0x20, 0x01), + # 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), + # X4 + "Lightning Web": (0x3D, 0x28), + "Aiming Laser": (0x2C, 0x14), + "Double Cyclone": (0x28, 0x1A), + "Rising Fire": (0x20, 0x16), + "Ground Hunter": (0x2C, 0x15), + "Soul Body": (0x37, 0x27), + "Twin Slasher": (0x28, 0x00), + "Frost Tower": (0x3D, 0x2C), +} + +if "worlds.mm2" in sys.modules: + # is this the proper way to do this? who knows! + try: + mm2 = sys.modules["worlds.mm2"] + MM3_KNOWN_COLORS.update(mm2.color.MM2_COLORS) + for item in MM3_COLORS: + mm2.color.add_color_to_mm2(item, MM3_COLORS[item]) + except AttributeError: + # pass through if an old MM2 is found + pass + +palette_pointers: dict[str, list[int]] = { + "Mega Buster": [0x7C8A8, 0x4650], + "Gemini Laser": [0x4654], + "Needle Cannon": [0x4658], + "Hard Knuckle": [0x465C], + "Magnet Missile": [0x4660], + "Top Spin": [0x4664], + "Search Snake": [0x4668], + "Rush Coil": [0x466C], + "Spark Shock": [0x4670], + "Rush Marine": [0x4674], + "Shadow Blade": [0x4678], + "Rush Jet": [0x467C], + "Needle Man": [0x216C], + "Magnet Man": [0x215C], + "Gemini Man": [0x217C], + "Hard Man": [0x2164], + "Top Man": [0x2194], + "Snake Man": [0x2174], + "Spark Man": [0x2184], + "Shadow Man": [0x218C], + "Doc Robot": [0x20B8] +} + + +def add_color_to_mm3(name: str, color: tuple[int, int]) -> None: + """ + Add a color combo for Mega Man 3 to recognize as the color to display for a given item. + For information on available colors: https://www.nesdev.org/wiki/PPU_palettes#2C02 + """ + MM3_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 expand_colors(color_1: int, color_2: int) -> tuple[tuple[int, int, int], tuple[int, int, int]]: + if color_2 >= 0x30: + color_a = color_b = color_2 + else: + color_a = color_2 + 0x10 + color_b = color_2 + + if color_1 < 0x10: + color_c = color_1 + 0x10 + color_d = color_1 + color_e = color_1 + 0x20 + elif color_1 >= 0x30: + color_c = color_1 - 0x10 + color_d = color_1 - 0x20 + color_e = color_1 + else: + color_c = color_1 + color_d = color_1 - 0x10 + color_e = color_1 + 0x10 + + return (0x30, color_a, color_b), (color_d, color_e, color_c) + + +def get_colors_for_item(name: str) -> tuple[tuple[int, int, int], tuple[int, int, int]]: + if name in MM3_KNOWN_COLORS: + return expand_colors(*MM3_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 expand_colors(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: "MM3World", rom: "MM3ProcedurePatch") -> None: + palette_shuffle: 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.player_name} " + 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 != 0: + if palette_shuffle > 1: + if palette_shuffle == 3: + # 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 + else: + 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: + shuffled_colors = list(MM3_COLORS.values())[:-3] # only include one Doc Robot + 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() + + for character in palettes_to_write: + for pointer in palette_pointers[character]: + rom.write_bytes(pointer + 2, bytes(palettes_to_write[character])) diff --git a/worlds/mm3/data/mm3_basepatch.bsdiff4 b/worlds/mm3/data/mm3_basepatch.bsdiff4 new file mode 100644 index 0000000000000000000000000000000000000000..f80cb76d67cfabb7966f20053e5fc7eee454d066 GIT binary patch literal 1235 zcmV;^1T6bPQ$$HdMl>+C000000001}0RR910000G015yA0000&T4*&fL0KkKS%F}> zEC2*-|MmU2gh&Jchy+0(z<|IY5CGr;zyKfwKmjTN00AHXiiDLX>QB;{4Lwb$4H^Sa zQIJU@ntB2TBO@WEnqe81zjLVq0#rTFg?qI9x#KPy?X%1?2tYM@B!nPJ-%davhF$Tl zIiQx9IRsK^@InFXApw4Wk((hP9HukLu*#@mW7;7BK!gT3(#}EbP{I%l&Sp1fuJ`R` z)1SrMkxmpOAXqMo076=5H8DY1CR154gXBH{004jg{`SR7$$$Vu_HSX?*R;Z^xg zN~RefD;61p@_)9BLve%jTr__4F;J8nW=~*lAr(pG&BZ)27qK_00000 z8UO$p27?l5rbv5K0(rmI4pd;$A>;sOqPkexARL4PSb#YCDB=L*w^7+8+^OF9^b%)!c zTe|0`3%i#3PAn3jp>W!T@T%g&6c3SJfAqDq(5 z5zZ~pgXl;67p-uDx`V;Oa>g~JbIfQ+CKtA&^X8nlQL07SqM z0uvIzMFzk_w|EUnwGHN zT(ZiIHw384c-|)-l=*9tHOx%iW1EfJxZuj8?X?gr;mM3+kH4=}*UJe<$7F6!Vl1Ip zTlcpSgA`JbuD*Xa0y2LyLHW&qkSM08tldQMOf%dD7z?yF{#(OguYm^gokIx_Cz>AN zc47huEyVFV%9i33j^bUngBQWyOJrwgoW8i4A5KL1;hXV literal 0 HcmV?d00001 diff --git a/worlds/mm3/docs/en_Mega Man 3.md b/worlds/mm3/docs/en_Mega Man 3.md new file mode 100644 index 0000000000..abb619858c --- /dev/null +++ b/worlds/mm3/docs/en_Mega Man 3.md @@ -0,0 +1,131 @@ +# Mega Man 3 + +## Where is the options page? + +The [player options page for this game](../player-options) contains all the options you need to configure and export a +config file. + +## What does randomization do to this game? + +Weapons received from Robot Masters, access to each individual stage (including Doc Robot stages), and Items from Dr. Light are randomized +into the multiworld. Access to the Wily Stages is locked behind clearing the 4 Doc Robot stages and defeating Break Man. The game is complete upon +viewing the ending sequence after defeating Gamma. + +## What Mega Man 3 items can appear in other players' worlds? +- Robot Master weapons +- Robot Master Access Codes (stage access) +- Doc Robot Access Codes (stage access) +- Rush Coil/Jet/Marine +- 1-Ups +- E-Tanks +- Health Energy (L) +- Weapon Energy (L) + +## What is considered a location check in Mega Man 3? +- The defeat of a Robot Master, Doc Robot, or Wily Boss +- Receiving a weapon or Rush item from Dr. Light +- Optionally, 1-Ups and E-Tanks present within stages +- Optionally, Weapon and Health Energy pickups present within stages + +## When the player receives an item, what happens? +A sound effect will play based on the type of item received, and the effects of the item will be immediately applied, +such as unlocking the use of a weapon mid-stage. If the effects of the item cannot be fully applied (such as receiving +Health Energy while at full health), the remaining are withheld until they can be applied. + +## How do I access the Doc Robot stages? +By pressing Select on the Robot Master screen, the screen will transition between Robot Masters and +Doc Robots. + +## Useful Information +* **NesHawk is the recommended core for this game!** Players using QuickNes (or QuickerNes) will experience graphical + glitches while in Gemini Man's stage and fighting Gamma. +* Pressing A+B+Start+Select while in a stage will take you to the Game Over screen, allowing you to leave the stage. + Your E-Tanks will be preserved. +* Your current progress through the Wily stages is saved to the multiworld, allowing you to return to the last stage you + reached should you need to leave and enter a Robot Master stage. If you need to return to an earlier Wily stage, holding + Select while entering Break Man's stage will take you to Wily 1. +* When Random Weaknesses are enabled, Break Man's weakness will be changed from Mega Buster to one random weapon. + + +## What is EnergyLink? +EnergyLink is an energy storage supported by certain games that is shared across all worlds in a multiworld. In Mega Man + 3, when enabled, drops from enemies are not applied directly to Mega Man and are instead deposited into the EnergyLink. +Half of the energy that would be gained is lost upon transfer to the EnergyLink. + +Energy from the EnergyLink storage can be converted into health, weapon energy, and lives at different conversion rates. +You can find out how much of each type you can pull using `/pool` in the client. Additionally, you can have it +automatically pull from the EnergyLink storage to keep Mega Man healed using the `/autoheal` command in the client. +Finally, you can use the `/request` command to request a certain type of energy from the storage. + +## Plando Palettes +The palette shuffle option supports specifying a specific palette for a given weapon/Robot Master. The format for doing +so is `Character-Color1|Color2;Option`. Character is the individual that this should apply to, and can only be one of +the following: +- Mega Buster +- Gemini Laser +- Needle Cannon +- Hard Knuckle +- Magnet Missile +- Top Spin +- Search Snake +- Spark Shot +- Shadow Blade +- Rush Coil +- Rush Jet +- Rush Marine +- Needle Man +- Magnet Man +- Gemini Man +- Hard Man +- Top Man +- Snake Man +- Spark Man +- Shadow Man +- Doc Robot + +Colors attempt to map a list of HTML-defined colors to what the NES can render. A full list of applicable colors can be +found [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/mm2/Color.py#L11). Alternatively, colors can +be supplied directly using `$xx` format. A full list of NES colors can be found [here](https://www.nesdev.org/wiki/PPU_palettes#2C02). + +You can also pass only one color (such as `Mega Buster-Red`) and it will interpret a second color based off of the color +given. Additionally, passing only colors (such as `Red|Blue`) and not any specific boss/weapon will apply that color to +all weapons/bosses that did not have a prior color specified. + +The option is the method to be used to set the palettes of the remaining bosses/weapons, and will not overwrite any +plando placements. + +## Plando Weaknesses +Plando Weaknesses allows you to override the amount of damage a boss should take from a given weapon, ignoring prior +weaknesses generated by strict/random weakness options. Formatting for this is as follows: +```yaml +plando_weakness: + Needle Man: + Top Spin: 0 + Hard Knuckle: 4 +``` +This would cause Air Man to take 4 damage from Hard Knuckle, and 0 from Top Spin. + +Note: it is possible that plando weakness is not be respected should the plando create a situation in which the game +becomes impossible to complete. In this situation, the damage would be boosted to the minimum required to defeat the +Robot Master. + + +## Unique Local Commands +- `/pool` Only present with EnergyLink, prints the max amount of each type of request that could be fulfilled. +- `/autoheal` Only present with EnergyLink, will automatically drain energy from the EnergyLink in order to +restore Mega Man's health. +- `/request ` 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 + - `NE` Needle Cannon + - `MA` Magnet Missile + - `GE` Gemini Laser + - `HA` Hard Knuckle + - `TO` Top Spin + - `SN` Search Snake + - `SP` Spark Shot + - `SH` Shadow Blade + - `RC` Rush Coil + - `RM` Rush Marine + - `RJ` Rush Jet + - `1U` Lives \ No newline at end of file diff --git a/worlds/mm3/docs/setup_en.md b/worlds/mm3/docs/setup_en.md new file mode 100644 index 0000000000..07cae74a8a --- /dev/null +++ b/worlds/mm3/docs/setup_en.md @@ -0,0 +1,53 @@ +# Mega Man 3 Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) +- An English Mega Man 3 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. Bizhawk 2.10 + +### 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 3 options page](../../../games/Mega%20Man%203/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 `.apmm3` 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 3 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 3. +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/mm3/items.py b/worlds/mm3/items.py new file mode 100644 index 0000000000..40e6114fff --- /dev/null +++ b/worlds/mm3/items.py @@ -0,0 +1,80 @@ +from BaseClasses import Item +from typing import NamedTuple +from .names import (needle_cannon, magnet_missile, gemini_laser, hard_knuckle, top_spin, search_snake, spark_shock, + shadow_blade, rush_coil, rush_marine, rush_jet, needle_man_stage, magnet_man_stage, + gemini_man_stage, hard_man_stage, top_man_stage, snake_man_stage, spark_man_stage, shadow_man_stage, + doc_needle_stage, doc_gemini_stage, doc_spark_stage, doc_shadow_stage, e_tank, weapon_energy, + health_energy, one_up) + + +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 MM3Item(Item): + game = "Mega Man 3" + + +robot_master_weapon_table = { + needle_cannon: ItemData(0x0001, True), + magnet_missile: ItemData(0x0002, True, True), + gemini_laser: ItemData(0x0003, True), + hard_knuckle: ItemData(0x0004, True), + top_spin: ItemData(0x0005, True, True), + search_snake: ItemData(0x0006, True), + spark_shock: ItemData(0x0007, True), + shadow_blade: ItemData(0x0008, True, True), +} + +stage_access_table = { + needle_man_stage: ItemData(0x0101, True), + magnet_man_stage: ItemData(0x0102, True), + gemini_man_stage: ItemData(0x0103, True), + hard_man_stage: ItemData(0x0104, True), + top_man_stage: ItemData(0x0105, True), + snake_man_stage: ItemData(0x0106, True), + spark_man_stage: ItemData(0x0107, True), + shadow_man_stage: ItemData(0x0108, True), + doc_needle_stage: ItemData(0x0111, True, True), + doc_gemini_stage: ItemData(0x0113, True, True), + doc_spark_stage: ItemData(0x0117, True, True), + doc_shadow_stage: ItemData(0x0118, True, True), +} + +rush_item_table = { + rush_coil: ItemData(0x0011, True, True), + rush_marine: ItemData(0x0012, True), + rush_jet: ItemData(0x0013, True, True), +} + +filler_item_table = { + one_up: ItemData(0x0020, False), + weapon_energy: ItemData(0x0021, False), + health_energy: ItemData(0x0022, False), + e_tank: ItemData(0x0023, False, True), +} + +filler_item_weights = { + one_up: 1, + weapon_energy: 4, + health_energy: 1, + e_tank: 2, +} + +item_table = { + **robot_master_weapon_table, + **stage_access_table, + **rush_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()}, + "Rush": {name for name in rush_item_table.keys()} +} + +lookup_item_to_id: dict[str, int] = {item_name: data.code for item_name, data in item_table.items() if data.code} diff --git a/worlds/mm3/locations.py b/worlds/mm3/locations.py new file mode 100644 index 0000000000..2504236bda --- /dev/null +++ b/worlds/mm3/locations.py @@ -0,0 +1,312 @@ +from BaseClasses import Location, Region +from typing import NamedTuple +from . import names + + +class MM3Location(Location): + game = "Mega Man 3" + + +class MM3Region(Region): + game = "Mega Man 3" + + +class LocationData(NamedTuple): + location_id: int | None + energy: bool = False + oneup_tank: bool = False + + +class RegionData(NamedTuple): + locations: dict[str, LocationData] + required_items: list[str] + parent: str = "" + +mm3_regions: dict[str, RegionData] = { + "Needle Man Stage": RegionData({ + names.needle_man: LocationData(0x0001), + names.get_needle_cannon: LocationData(0x0101), + names.get_rush_jet: LocationData(0x0111), + names.needle_man_c1: LocationData(0x0200, energy=True), + names.needle_man_c2: LocationData(0x0201, oneup_tank=True), + }, [names.needle_man_stage]), + + "Magnet Man Stage": RegionData({ + names.magnet_man: LocationData(0x0002), + names.get_magnet_missile: LocationData(0x0102), + names.magnet_man_c1: LocationData(0x0202, energy=True), + names.magnet_man_c2: LocationData(0x0203, energy=True), + names.magnet_man_c3: LocationData(0x0204, energy=True), + names.magnet_man_c4: LocationData(0x0205, energy=True), + names.magnet_man_c5: LocationData(0x0206, energy=True), + names.magnet_man_c6: LocationData(0x0207, energy=True), + names.magnet_man_c7: LocationData(0x0208, energy=True), + names.magnet_man_c8: LocationData(0x0209, energy=True), + }, [names.magnet_man_stage]), + + "Gemini Man Stage": RegionData({ + names.gemini_man: LocationData(0x0003), + names.get_gemini_laser: LocationData(0x0103), + names.gemini_man_c1: LocationData(0x020A, oneup_tank=True), + names.gemini_man_c2: LocationData(0x020B, energy=True), + names.gemini_man_c3: LocationData(0x020C, oneup_tank=True), + names.gemini_man_c4: LocationData(0x020D, energy=True), + names.gemini_man_c5: LocationData(0x020E, energy=True), + names.gemini_man_c6: LocationData(0x020F, oneup_tank=True), + names.gemini_man_c7: LocationData(0x0210, oneup_tank=True), + names.gemini_man_c8: LocationData(0x0211, energy=True), + names.gemini_man_c9: LocationData(0x0212, energy=True), + names.gemini_man_c10: LocationData(0x0213, oneup_tank=True), + }, [names.gemini_man_stage]), + + "Hard Man Stage": RegionData({ + names.hard_man: LocationData(0x0004), + names.get_hard_knuckle: LocationData(0x0104), + names.hard_man_c1: LocationData(0x0214, energy=True), + names.hard_man_c2: LocationData(0x0215, energy=True), + names.hard_man_c3: LocationData(0x0216, oneup_tank=True), + names.hard_man_c4: LocationData(0x0217, energy=True), + names.hard_man_c5: LocationData(0x0218, energy=True), + names.hard_man_c6: LocationData(0x0219, energy=True), + names.hard_man_c7: LocationData(0x021A, energy=True), + }, [names.hard_man_stage]), + + "Top Man Stage": RegionData({ + names.top_man: LocationData(0x0005), + names.get_top_spin: LocationData(0x0105), + names.top_man_c1: LocationData(0x021B, energy=True), + names.top_man_c2: LocationData(0x021C, energy=True), + names.top_man_c3: LocationData(0x021D, energy=True), + names.top_man_c4: LocationData(0x021E, energy=True), + names.top_man_c5: LocationData(0x021F, energy=True), + names.top_man_c6: LocationData(0x0220, oneup_tank=True), + names.top_man_c7: LocationData(0x0221, energy=True), + names.top_man_c8: LocationData(0x0222, energy=True), + }, [names.top_man_stage]), + + "Snake Man Stage": RegionData({ + names.snake_man: LocationData(0x0006), + names.get_search_snake: LocationData(0x0106), + names.snake_man_c1: LocationData(0x0223, energy=True), + names.snake_man_c2: LocationData(0x0224, energy=True), + names.snake_man_c3: LocationData(0x0225, oneup_tank=True), + names.snake_man_c4: LocationData(0x0226, oneup_tank=True), + names.snake_man_c5: LocationData(0x0227, energy=True), + }, [names.snake_man_stage]), + + "Spark Man Stage": RegionData({ + names.spark_man: LocationData(0x0007), + names.get_spark_shock: LocationData(0x0107), + names.spark_man_c1: LocationData(0x0228, energy=True), + names.spark_man_c2: LocationData(0x0229, energy=True), + names.spark_man_c3: LocationData(0x022A, energy=True), + names.spark_man_c4: LocationData(0x022B, energy=True), + names.spark_man_c5: LocationData(0x022C, energy=True), + names.spark_man_c6: LocationData(0x022D, energy=True), + }, [names.spark_man_stage]), + + "Shadow Man Stage": RegionData({ + names.shadow_man: LocationData(0x0008), + names.get_shadow_blade: LocationData(0x0108), + names.get_rush_marine: LocationData(0x0112), + names.shadow_man_c1: LocationData(0x022E, energy=True), + names.shadow_man_c2: LocationData(0x022F, energy=True), + names.shadow_man_c3: LocationData(0x0230, energy=True), + names.shadow_man_c4: LocationData(0x0231, energy=True), + }, [names.shadow_man_stage]), + + "Doc Robot (Needle) - Air": RegionData({ + names.doc_air: LocationData(0x0010), + names.doc_needle_c1: LocationData(0x0232, energy=True), + names.doc_needle_c2: LocationData(0x0233, oneup_tank=True), + names.doc_needle_c3: LocationData(0x0234, oneup_tank=True), + }, [names.doc_needle_stage]), + + "Doc Robot (Needle) - Crash": RegionData({ + names.doc_crash: LocationData(0x0011), + names.doc_needle: LocationData(None), + names.doc_needle_c4: LocationData(0x0235, energy=True), + names.doc_needle_c5: LocationData(0x0236, energy=True), + names.doc_needle_c6: LocationData(0x0237, energy=True), + names.doc_needle_c7: LocationData(0x0238, energy=True), + names.doc_needle_c8: LocationData(0x0239, energy=True), + names.doc_needle_c9: LocationData(0x023A, energy=True), + names.doc_needle_c10: LocationData(0x023B, energy=True), + names.doc_needle_c11: LocationData(0x023C, energy=True), + }, [], parent="Doc Robot (Needle) - Air"), + + "Doc Robot (Gemini) - Flash": RegionData({ + names.doc_flash: LocationData(0x0012), + names.doc_gemini_c1: LocationData(0x023D, oneup_tank=True), + names.doc_gemini_c2: LocationData(0x023E, oneup_tank=True), + }, [names.doc_gemini_stage]), + + "Doc Robot (Gemini) - Bubble": RegionData({ + names.doc_bubble: LocationData(0x0013), + names.doc_gemini: LocationData(None), + names.doc_gemini_c3: LocationData(0x023F, energy=True), + names.doc_gemini_c4: LocationData(0x0240, energy=True), + }, [], parent="Doc Robot (Gemini) - Flash"), + + "Doc Robot (Shadow) - Wood": RegionData({ + names.doc_wood: LocationData(0x0014), + }, [names.doc_shadow_stage]), + + "Doc Robot (Shadow) - Heat": RegionData({ + names.doc_heat: LocationData(0x0015), + names.doc_shadow: LocationData(None), + names.doc_shadow_c1: LocationData(0x0243, energy=True), + names.doc_shadow_c2: LocationData(0x0244, energy=True), + names.doc_shadow_c3: LocationData(0x0245, energy=True), + names.doc_shadow_c4: LocationData(0x0246, energy=True), + names.doc_shadow_c5: LocationData(0x0247, energy=True), + }, [], parent="Doc Robot (Shadow) - Wood"), + + "Doc Robot (Spark) - Metal": RegionData({ + names.doc_metal: LocationData(0x0016), + names.doc_spark_c1: LocationData(0x0241, energy=True), + }, [names.doc_spark_stage]), + + "Doc Robot (Spark) - Quick": RegionData({ + names.doc_quick: LocationData(0x0017), + names.doc_spark: LocationData(None), + names.doc_spark_c2: LocationData(0x0242, energy=True), + }, [], parent="Doc Robot (Spark) - Metal"), + + "Break Man": RegionData({ + names.break_man: LocationData(0x000F), + names.break_stage: LocationData(None), + }, [names.doc_needle, names.doc_gemini, names.doc_spark, names.doc_shadow]), + + "Wily Stage 1": RegionData({ + names.wily_1_boss: LocationData(0x0009), + names.wily_stage_1: LocationData(None), + names.wily_1_c1: LocationData(0x0248, oneup_tank=True), + names.wily_1_c2: LocationData(0x0249, oneup_tank=True), + names.wily_1_c3: LocationData(0x024A, energy=True), + names.wily_1_c4: LocationData(0x024B, oneup_tank=True), + names.wily_1_c5: LocationData(0x024C, energy=True), + names.wily_1_c6: LocationData(0x024D, energy=True), + names.wily_1_c7: LocationData(0x024E, energy=True), + names.wily_1_c8: LocationData(0x024F, oneup_tank=True), + names.wily_1_c9: LocationData(0x0250, energy=True), + names.wily_1_c10: LocationData(0x0251, energy=True), + names.wily_1_c11: LocationData(0x0252, energy=True), + names.wily_1_c12: LocationData(0x0253, energy=True), + }, [names.break_stage], parent="Break Man"), + + "Wily Stage 2": RegionData({ + names.wily_2_boss: LocationData(0x000A), + names.wily_stage_2: LocationData(None), + names.wily_2_c1: LocationData(0x0254, energy=True), + names.wily_2_c2: LocationData(0x0255, energy=True), + names.wily_2_c3: LocationData(0x0256, oneup_tank=True), + names.wily_2_c4: LocationData(0x0257, energy=True), + names.wily_2_c5: LocationData(0x0258, energy=True), + names.wily_2_c6: LocationData(0x0259, energy=True), + names.wily_2_c7: LocationData(0x025A, energy=True), + names.wily_2_c8: LocationData(0x025B, energy=True), + names.wily_2_c9: LocationData(0x025C, oneup_tank=True), + names.wily_2_c10: LocationData(0x025D, energy=True), + names.wily_2_c11: LocationData(0x025E, oneup_tank=True), + names.wily_2_c12: LocationData(0x025F, energy=True), + names.wily_2_c13: LocationData(0x0260, energy=True), + }, [names.wily_stage_1], parent="Wily Stage 1"), + + "Wily Stage 3": RegionData({ + names.wily_3_boss: LocationData(0x000B), + names.wily_stage_3: LocationData(None), + names.wily_3_c1: LocationData(0x0261, energy=True), + names.wily_3_c2: LocationData(0x0262, energy=True), + names.wily_3_c3: LocationData(0x0263, oneup_tank=True), + names.wily_3_c4: LocationData(0x0264, oneup_tank=True), + names.wily_3_c5: LocationData(0x0265, energy=True), + names.wily_3_c6: LocationData(0x0266, energy=True), + names.wily_3_c7: LocationData(0x0267, energy=True), + names.wily_3_c8: LocationData(0x0268, energy=True), + names.wily_3_c9: LocationData(0x0269, energy=True), + names.wily_3_c10: LocationData(0x026A, oneup_tank=True), + names.wily_3_c11: LocationData(0x026B, oneup_tank=True) + }, [names.wily_stage_2], parent="Wily Stage 2"), + + "Wily Stage 4": RegionData({ + names.wily_stage_4: LocationData(None), + names.wily_4_c1: LocationData(0x026C, energy=True), + names.wily_4_c2: LocationData(0x026D, energy=True), + names.wily_4_c3: LocationData(0x026E, energy=True), + names.wily_4_c4: LocationData(0x026F, energy=True), + names.wily_4_c5: LocationData(0x0270, energy=True), + names.wily_4_c6: LocationData(0x0271, energy=True), + names.wily_4_c7: LocationData(0x0272, energy=True), + names.wily_4_c8: LocationData(0x0273, energy=True), + names.wily_4_c9: LocationData(0x0274, energy=True), + names.wily_4_c10: LocationData(0x0275, oneup_tank=True), + names.wily_4_c11: LocationData(0x0276, energy=True), + names.wily_4_c12: LocationData(0x0277, oneup_tank=True), + names.wily_4_c13: LocationData(0x0278, energy=True), + names.wily_4_c14: LocationData(0x0279, energy=True), + names.wily_4_c15: LocationData(0x027A, energy=True), + names.wily_4_c16: LocationData(0x027B, energy=True), + names.wily_4_c17: LocationData(0x027C, energy=True), + names.wily_4_c18: LocationData(0x027D, energy=True), + names.wily_4_c19: LocationData(0x027E, energy=True), + names.wily_4_c20: LocationData(0x027F, energy=True), + }, [names.wily_stage_3], parent="Wily Stage 3"), + + "Wily Stage 5": RegionData({ + names.wily_5_boss: LocationData(0x000D), + names.wily_stage_5: LocationData(None), + names.wily_5_c1: LocationData(0x0280, energy=True), + names.wily_5_c2: LocationData(0x0281, energy=True), + names.wily_5_c3: LocationData(0x0282, oneup_tank=True), + names.wily_5_c4: LocationData(0x0283, oneup_tank=True), + }, [names.wily_stage_4], parent="Wily Stage 4"), + + "Wily Stage 6": RegionData({ + names.gamma: LocationData(None), + names.wily_6_c1: LocationData(0x0284, oneup_tank=True), + names.wily_6_c2: LocationData(0x0285, oneup_tank=True), + names.wily_6_c3: LocationData(0x0286, energy=True), + names.wily_6_c4: LocationData(0x0287, energy=True), + names.wily_6_c5: LocationData(0x0288, oneup_tank=True), + names.wily_6_c6: LocationData(0x0289, oneup_tank=True), + names.wily_6_c7: LocationData(0x028A, energy=True), + }, [names.wily_stage_5], parent="Wily Stage 5"), +} + + +def get_boss_locations(region: str) -> list[str]: + return [location for location, data in mm3_regions[region].locations.items() + if not data.energy and not data.oneup_tank] + + +def get_energy_locations(region: str) -> list[str]: + return [location for location, data in mm3_regions[region].locations.items() if data.energy] + + +def get_oneup_locations(region: str) -> list[str]: + return [location for location, data in mm3_regions[region].locations.items() if data.oneup_tank] + + +location_table: dict[str, int | None] = { + location: data.location_id for region in mm3_regions.values() for location, data in region.locations.items() +} + + +location_groups = { + "Get Equipped": { + names.get_needle_cannon, + names.get_magnet_missile, + names.get_gemini_laser, + names.get_hard_knuckle, + names.get_top_spin, + names.get_search_snake, + names.get_spark_shock, + names.get_shadow_blade, + names.get_rush_marine, + names.get_rush_jet, + }, + **{name: {location for location, data in region.locations.items() if data.location_id} for name, region in mm3_regions.items()} +} + +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/mm3/names.py b/worlds/mm3/names.py new file mode 100644 index 0000000000..dfad752676 --- /dev/null +++ b/worlds/mm3/names.py @@ -0,0 +1,221 @@ +# Robot Master Weapons +gemini_laser = "Gemini Laser" +needle_cannon = "Needle Cannon" +hard_knuckle = "Hard Knuckle" +magnet_missile = "Magnet Missile" +top_spin = "Top Spin" +search_snake = "Search Snake" +spark_shock = "Spark Shock" +shadow_blade = "Shadow Blade" + +# Rush +rush_coil = "Rush Coil" +rush_jet = "Rush Jet" +rush_marine = "Rush Marine" + +# Access Codes +needle_man_stage = "Needle Man Access Codes" +magnet_man_stage = "Magnet Man Access Codes" +gemini_man_stage = "Gemini Man Access Codes" +hard_man_stage = "Hard Man Access Codes" +top_man_stage = "Top Man Access Codes" +snake_man_stage = "Snake Man Access Codes" +spark_man_stage = "Spark Man Access Codes" +shadow_man_stage = "Shadow Man Access Codes" +doc_needle_stage = "Doc Robot (Needle) Access Codes" +doc_gemini_stage = "Doc Robot (Gemini) Access Codes" +doc_spark_stage = "Doc Robot (Spark) Access Codes" +doc_shadow_stage = "Doc Robot (Shadow) Access Codes" + +# Misc. Items +one_up = "1-Up" +weapon_energy = "Weapon Energy (L)" +health_energy = "Health Energy (L)" +e_tank = "E-Tank" + +needle_man = "Needle Man - Defeated" +magnet_man = "Magnet Man - Defeated" +gemini_man = "Gemini Man - Defeated" +hard_man = "Hard Man - Defeated" +top_man = "Top Man - Defeated" +snake_man = "Snake Man - Defeated" +spark_man = "Spark Man - Defeated" +shadow_man = "Shadow Man - Defeated" +doc_air = "Doc Robot (Air) - Defeated" +doc_crash = "Doc Robot (Crash) - Defeated" +doc_flash = "Doc Robot (Flash) - Defeated" +doc_bubble = "Doc Robot (Bubble) - Defeated" +doc_wood = "Doc Robot (Wood) - Defeated" +doc_heat = "Doc Robot (Heat) - Defeated" +doc_metal = "Doc Robot (Metal) - Defeated" +doc_quick = "Doc Robot (Quick) - Defeated" +break_man = "Break Man - Defeated" +wily_1_boss = "Kamegoro Maker - Defeated" +wily_2_boss = "Yellow Devil MK-II - Defeated" +wily_3_boss = "Holograph Mega Man - Defeated" +wily_5_boss = "Wily Machine 3 - Defeated" +gamma = "Gamma - Defeated" + +get_gemini_laser = "Gemini Laser - Received" +get_needle_cannon = "Needle Cannon - Received" +get_hard_knuckle = "Hard Knuckle - Received" +get_magnet_missile = "Magnet Missile - Received" +get_top_spin = "Top Spin - Received" +get_search_snake = "Search Snake - Received" +get_spark_shock = "Spark Shock - Received" +get_shadow_blade = "Shadow Blade - Received" +get_rush_jet = "Rush Jet - Received" +get_rush_marine = "Rush Marine - Received" + +# Wily Stage Event Items +doc_needle = "Doc Robot (Needle) - Completed" +doc_gemini = "Doc Robot (Gemini) - Completed" +doc_spark = "Doc Robot (Spark) - Completed" +doc_shadow = "Doc Robot (Shadow) - Completed" +break_stage = "Break Man" +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 +needle_man_c1 = "Needle Man Stage - Weapon Energy 1" +needle_man_c2 = "Needle Man Stage - E-Tank" +magnet_man_c1 = "Magnet Man Stage - Health Energy 1" +magnet_man_c2 = "Magnet Man Stage - Health Energy 2" +magnet_man_c3 = "Magnet Man Stage - Health Energy 3" +magnet_man_c4 = "Magnet Man Stage - Health Energy 4" +magnet_man_c5 = "Magnet Man Stage - Weapon Energy 1" +magnet_man_c6 = "Magnet Man Stage - Weapon Energy 2" +magnet_man_c7 = "Magnet Man Stage - Weapon Energy 3" +magnet_man_c8 = "Magnet Man Stage - Health Energy 5" +gemini_man_c1 = "Gemini Man Stage - 1-Up 1" +gemini_man_c2 = "Gemini Man Stage - Health Energy 1" +gemini_man_c3 = "Gemini Man Stage - Mystery Tank" +gemini_man_c4 = "Gemini Man Stage - Weapon Energy 1" +gemini_man_c5 = "Gemini Man Stage - Health Energy 2" +gemini_man_c6 = "Gemini Man Stage - 1-Up 2" +gemini_man_c7 = "Gemini Man Stage - E-Tank 1" +gemini_man_c8 = "Gemini Man Stage - Weapon Energy 2" +gemini_man_c9 = "Gemini Man Stage - Weapon Energy 3" +gemini_man_c10 = "Gemini Man Stage - E-Tank 2" +hard_man_c1 = "Hard Man Stage - Health Energy 1" +hard_man_c2 = "Hard Man Stage - Health Energy 2" +hard_man_c3 = "Hard Man Stage - E-Tank" +hard_man_c4 = "Hard Man Stage - Health Energy 3" +hard_man_c5 = "Hard Man Stage - Health Energy 4" +hard_man_c6 = "Hard Man Stage - Health Energy 5" +hard_man_c7 = "Hard Man Stage - Health Energy 6" +top_man_c1 = "Top Man Stage - Health Energy 1" +top_man_c2 = "Top Man Stage - Health Energy 2" +top_man_c3 = "Top Man Stage - Health Energy 3" +top_man_c4 = "Top Man Stage - Health Energy 4" +top_man_c5 = "Top Man Stage - Weapon Energy 1" +top_man_c6 = "Top Man Stage - 1-Up" +top_man_c7 = "Top Man Stage - Health Energy 5" +top_man_c8 = "Top Man Stage - Health Energy 6" +snake_man_c1 = "Snake Man Stage - Health Energy 1" +snake_man_c2 = "Snake Man Stage - Health Energy 2" +snake_man_c3 = "Snake Man Stage - Mystery Tank 1" +snake_man_c4 = "Snake Man Stage - Mystery Tank 2" +snake_man_c5 = "Snake Man Stage - Health Energy 3" +spark_man_c1 = "Spark Man Stage - Health Energy 1" +spark_man_c2 = "Spark Man Stage - Weapon Energy 1" +spark_man_c3 = "Spark Man Stage - Weapon Energy 2" +spark_man_c4 = "Spark Man Stage - Weapon Energy 3" +spark_man_c5 = "Spark Man Stage - Weapon Energy 4" +spark_man_c6 = "Spark Man Stage - Weapon Energy 5" +shadow_man_c1 = "Shadow Man Stage - Weapon Energy 1" +shadow_man_c2 = "Shadow Man Stage - Weapon Energy 2" +shadow_man_c3 = "Shadow Man Stage - Weapon Energy 3" +shadow_man_c4 = "Shadow Man Stage - Weapon Energy 4" +doc_needle_c1 = "Doc Robot (Needle) - Health Energy 1" +doc_needle_c2 = "Doc Robot (Needle) - 1-Up 1" +doc_needle_c3 = "Doc Robot (Needle) - E-Tank 1" +doc_needle_c4 = "Doc Robot (Needle) - Weapon Energy 1" +doc_needle_c5 = "Doc Robot (Needle) - Weapon Energy 2" +doc_needle_c6 = "Doc Robot (Needle) - Weapon Energy 3" +doc_needle_c7 = "Doc Robot (Needle) - Weapon Energy 4" +doc_needle_c8 = "Doc Robot (Needle) - Weapon Energy 5" +doc_needle_c9 = "Doc Robot (Needle) - Weapon Energy 6" +doc_needle_c10 = "Doc Robot (Needle) - Weapon Energy 7" +doc_needle_c11 = "Doc Robot (Needle) - Health Energy 2" +doc_gemini_c1 = "Doc Robot (Gemini) - Mystery Tank 1" +doc_gemini_c2 = "Doc Robot (Gemini) - Mystery Tank 2" +doc_gemini_c3 = "Doc Robot (Gemini) - Weapon Energy 1" +doc_gemini_c4 = "Doc Robot (Gemini) - Weapon Energy 2" +doc_spark_c1 = "Doc Robot (Spark) - Health Energy 1" +doc_spark_c2 = "Doc Robot (Spark) - Health Energy 2" +doc_shadow_c1 = "Doc Robot (Shadow) - Health Energy 1" +doc_shadow_c2 = "Doc Robot (Shadow) - Weapon Energy 1" +doc_shadow_c3 = "Doc Robot (Shadow) - Weapon Energy 2" +doc_shadow_c4 = "Doc Robot (Shadow) - Weapon Energy 3" +doc_shadow_c5 = "Doc Robot (Shadow) - Weapon Energy 4" +wily_1_c1 = "Wily Stage 1 - 1-Up 1" +wily_1_c2 = "Wily Stage 1 - E-Tank 1" +wily_1_c3 = "Wily Stage 1 - Weapon Energy 1" +wily_1_c4 = "Wily Stage 1 - 1-Up 2" # Hard Knuckle +wily_1_c5 = "Wily Stage 1 - Health Energy 1" # Hard Knuckle +wily_1_c6 = "Wily Stage 1 - Weapon Energy 2" # Hard Knuckle & Rush Vertical +wily_1_c7 = "Wily Stage 1 - Health Energy 2" # Hard Knuckle & Rush Vertical +wily_1_c8 = "Wily Stage 1 - E-Tank 2" # Hard Knuckle & Rush Vertical +wily_1_c9 = "Wily Stage 1 - Health Energy 3" +wily_1_c10 = "Wily Stage 1 - Health Energy 4" +wily_1_c11 = "Wily Stage 1 - Weapon Energy 3" # Rush Vertical +wily_1_c12 = "Wily Stage 1 - Weapon Energy 4" # Rush Vertical +wily_2_c1 = "Wily Stage 2 - Weapon Energy 1" +wily_2_c2 = "Wily Stage 2 - Weapon Energy 2" +wily_2_c3 = "Wily Stage 2 - 1-Up 1" +wily_2_c4 = "Wily Stage 2 - Weapon Energy 3" +wily_2_c5 = "Wily Stage 2 - Health Energy 1" +wily_2_c6 = "Wily Stage 2 - Health Energy 2" +wily_2_c7 = "Wily Stage 2 - Health Energy 3" +wily_2_c8 = "Wily Stage 2 - Weapon Energy 4" +wily_2_c9 = "Wily Stage 2 - E-Tank 1" +wily_2_c10 = "Wily Stage 2 - Weapon Energy 5" +wily_2_c11 = "Wily Stage 2 - E-Tank 2" +wily_2_c12 = "Wily Stage 2 - Weapon Energy 6" +wily_2_c13 = "Wily Stage 2 - Weapon Energy 7" +wily_3_c1 = "Wily Stage 3 - Weapon Energy 1" # Hard Knuckle +wily_3_c2 = "Wily Stage 3 - Weapon Energy 2" # Hard Knuckle +wily_3_c3 = "Wily Stage 3 - E-Tank 1" +wily_3_c4 = "Wily Stage 3 - 1-Up 1" +wily_3_c5 = "Wily Stage 3 - Health Energy 1" +wily_3_c6 = "Wily Stage 3 - Health Energy 2" +wily_3_c7 = "Wily Stage 3 - Health Energy 3" +wily_3_c8 = "Wily Stage 3 - Health Energy 4" +wily_3_c9 = "Wily Stage 3 - Weapon Energy 3" +wily_3_c10 = "Wily Stage 3 - Mystery Tank 1" # Hard Knuckle +wily_3_c11 = "Wily Stage 3 - Mystery Tank 2" # Hard Knuckle +wily_4_c1 = "Wily Stage 4 - Weapon Energy 1" +wily_4_c2 = "Wily Stage 4 - Weapon Energy 2" +wily_4_c3 = "Wily Stage 4 - Weapon Energy 3" +wily_4_c4 = "Wily Stage 4 - Weapon Energy 4" +wily_4_c5 = "Wily Stage 4 - Weapon Energy 5" +wily_4_c6 = "Wily Stage 4 - Health Energy 1" +wily_4_c7 = "Wily Stage 4 - Health Energy 2" +wily_4_c8 = "Wily Stage 4 - Health Energy 3" +wily_4_c9 = "Wily Stage 4 - Health Energy 4" +wily_4_c10 = "Wily Stage 4 - Mystery Tank" +wily_4_c11 = "Wily Stage 4 - Weapon Energy 6" +wily_4_c12 = "Wily Stage 4 - 1-Up" +wily_4_c13 = "Wily Stage 4 - Weapon Energy 7" +wily_4_c14 = "Wily Stage 4 - Weapon Energy 8" +wily_4_c15 = "Wily Stage 4 - Weapon Energy 9" +wily_4_c16 = "Wily Stage 4 - Weapon Energy 10" +wily_4_c17 = "Wily Stage 4 - Weapon Energy 11" +wily_4_c18 = "Wily Stage 4 - Weapon Energy 12" +wily_4_c19 = "Wily Stage 4 - Weapon Energy 13" +wily_4_c20 = "Wily Stage 4 - Weapon Energy 14" +wily_5_c1 = "Wily Stage 5 - Weapon Energy 1" +wily_5_c2 = "Wily Stage 5 - Weapon Energy 2" +wily_5_c3 = "Wily Stage 5 - Mystery Tank 1" +wily_5_c4 = "Wily Stage 5 - Mystery Tank 2" +wily_6_c1 = "Wily Stage 6 - Mystery Tank 1" +wily_6_c2 = "Wily Stage 6 - Mystery Tank 2" +wily_6_c3 = "Wily Stage 6 - Weapon Energy 1" +wily_6_c4 = "Wily Stage 6 - Weapon Energy 2" +wily_6_c5 = "Wily Stage 6 - 1-Up" +wily_6_c6 = "Wily Stage 6 - E-Tank" +wily_6_c7 = "Wily Stage 6 - Health Energy" diff --git a/worlds/mm3/options.py b/worlds/mm3/options.py new file mode 100644 index 0000000000..a1e9b24834 --- /dev/null +++ b/worlds/mm3/options.py @@ -0,0 +1,164 @@ +from dataclasses import dataclass + +from Options import Choice, Toggle, DeathLink, TextChoice, Range, OptionDict, PerGameCommonOptions +from schema import Schema, And, Use, Optional +from .rules import bosses, weapons_to_id + + +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_needle_man = 0 + option_magnet_man = 1 + option_gemini_man = 2 + option_hard_man = 3 + option_top_man = 4 + option_snake_man = 5 + option_spark_man = 6 + option_shadow_man = 7 + default = "random" + + +class Consumables(Choice): + """ + When enabled, e-tanks/1-ups/health/weapon energy will be added to the pool of items and included as checks. + """ + 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" + elif value == 2: + return "Weapon/Health Energy" + return super().get_option_name(value) + + +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. Certain enemies 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 Wily/Gamma). + """ + 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 Shadow Blade may do Top Spin damage. + 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 Wily4Requirement(Range): + """ + Change the amount of Robot Masters that are required to be defeated for + the door to the Wily Machine to open. + """ + display_name = "Wily 4 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(0, 14)) + } + }) + default = {} + + +class ReduceFlashing(Toggle): + """ + Reduce flashing seen in gameplay, such as in stages and when defeating certain bosses. + """ + display_name = "Reduce Flashing" + + +class MusicShuffle(Choice): + """ + Shuffle the music that plays in every stage + """ + display_name = "Music Shuffle" + option_none = 0 + option_shuffled = 1 + option_randomized = 2 + option_no_music = 3 + default = 0 + + +@dataclass +class MM3Options(PerGameCommonOptions): + death_link: DeathLink + energy_link: EnergyLink + starting_robot_master: StartingRobotMaster + consumables: Consumables + enemy_weakness: EnemyWeaknesses + strict_weakness: StrictWeaknesses + random_weakness: RandomWeaknesses + wily_4_requirement: Wily4Requirement + plando_weakness: WeaknessPlando + palette_shuffle: PaletteShuffle + reduce_flashing: ReduceFlashing + music_shuffle: MusicShuffle diff --git a/worlds/mm3/rom.py b/worlds/mm3/rom.py new file mode 100644 index 0000000000..8803f38cc5 --- /dev/null +++ b/worlds/mm3/rom.py @@ -0,0 +1,374 @@ +import pkgutil +from typing import TYPE_CHECKING, Iterable +import hashlib +import Utils +import os + +from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes +from . import names +from .rules import bosses + +from .text import MM3TextEntry +from .color import get_colors_for_item, write_palette_shuffle +from .options import Consumables + +if TYPE_CHECKING: + from . import MM3World + +MM3LCHASH = "5266687de215e790b2008284402f3917" +PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61" +MM3NESHASH = "4a53b6f58067d62c9a43404fe835dd5c" +MM3VCHASH = "c50008f1ac86fae8d083232cdd3001a5" + +enemy_weakness_ptrs: dict[int, int] = { + 0: 0x14100, + 1: 0x14200, + 2: 0x14300, + 3: 0x14400, + 4: 0x14500, + 5: 0x14600, + 6: 0x14700, + 7: 0x14800, + 8: 0x14900, +} + +enemy_addresses: dict[str, int] = { + "Dada": 0x12, + "Potton": 0x13, + "New Shotman": 0x15, + "Hammer Joe": 0x16, + "Peterchy": 0x17, + "Bubukan": 0x18, + "Vault Pole": 0x19, # Capcom..., why did you name an enemy Pole? + "Bomb Flier": 0x1A, + "Yambow": 0x1D, + "Metall 2": 0x1E, + "Cannon": 0x22, + "Jamacy": 0x25, + "Jamacy 2": 0x26, # dunno what this is, but I won't question + "Jamacy 3": 0x27, + "Jamacy 4": 0x28, # tf is this Capcom + "Mag Fly": 0x2A, + "Egg": 0x2D, + "Gyoraibo 2": 0x2E, + "Junk Golem": 0x2F, + "Pickelman Bull": 0x30, + "Nitron": 0x35, + "Pole": 0x37, + "Gyoraibo": 0x38, + "Hari Harry": 0x3A, + "Penpen Maker": 0x3B, + "Returning Monking": 0x3C, + "Have 'Su' Bee": 0x3E, + "Hive": 0x3F, + "Bolton-Nutton": 0x40, + "Walking Bomb": 0x44, + "Elec'n": 0x45, + "Mechakkero": 0x47, + "Chibee": 0x4B, + "Swimming Penpen": 0x4D, + "Top": 0x52, + "Penpen": 0x56, + "Komasaburo": 0x57, + "Parasyu": 0x59, + "Hologran (Static)": 0x5A, + "Hologran (Moving)": 0x5B, + "Bomber Pepe": 0x5C, + "Metall DX": 0x5D, + "Petit Snakey": 0x5E, + "Proto Man": 0x62, + "Break Man": 0x63, + "Metall": 0x7D, + "Giant Springer": 0x83, + "Springer Missile": 0x85, + "Giant Snakey": 0x99, + "Tama": 0x9A, + "Doc Robot (Flash)": 0xB0, + "Doc Robot (Wood)": 0xB1, + "Doc Robot (Crash)": 0xB2, + "Doc Robot (Metal)": 0xB3, + "Doc Robot (Bubble)": 0xC0, + "Doc Robot (Heat)": 0xC1, + "Doc Robot (Quick)": 0xC2, + "Doc Robot (Air)": 0xC3, + "Snake": 0xCA, + "Needle Man": 0xD0, + "Magnet Man": 0xD1, + "Top Man": 0xD2, + "Shadow Man": 0xD3, + "Top Man's Top": 0xD5, + "Shadow Man (Sliding)": 0xD8, # Capcom I swear + "Hard Man": 0xE0, + "Spark Man": 0xE2, + "Snake Man": 0xE4, + "Gemini Man": 0xE6, + "Gemini Man (Clone)": 0xE7, # Capcom why + "Yellow Devil MK-II": 0xF1, + "Wily Machine 3": 0xF3, + "Gamma": 0xF8, + "Kamegoro": 0x101, + "Kamegoro Shell": 0x102, + "Holograph Mega Man": 0x105, + "Giant Metall": 0x10C, # This is technically FC but we're +16 from the rom header +} + +# addresses printed when assembling basepatch +wily_4_ptr: int = 0x7F570 +consumables_ptr: int = 0x7FDEA +energylink_ptr: int = 0x7FDF9 + + +class MM3ProcedurePatch(APProcedurePatch, APTokenMixin): + hash = [MM3LCHASH, MM3NESHASH, MM3VCHASH] + game = "Mega Man 3" + patch_file_ending = ".apmm3" + result_file_ending = ".nes" + name: bytearray + procedure = [ + ("apply_bsdiff4", ["mm3_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: "MM3World", patch: MM3ProcedurePatch) -> None: + patch.write_file("mm3_basepatch.bsdiff4", pkgutil.get_data(__name__, os.path.join("data", "mm3_basepatch.bsdiff4"))) + # text writing + + base_address = 0x3C000 + color_address = 0x31BC7 + for i, offset, location in zip([0, 8, 1, 2, + 3, 4, 5, 6, + 7, 9], + [0x10, 0x50, 0x91, 0xD2, + 0x113, 0x154, 0x195, 0x1D6, + 0x217, 0x257], + [ + names.get_needle_cannon, + names.get_rush_jet, + names.get_magnet_missile, + names.get_gemini_laser, + names.get_hard_knuckle, + names.get_top_spin, + names.get_search_snake, + names.get_spark_shock, + names.get_shadow_blade, + names.get_rush_marine, + ]): + item = world.get_location(location).item + if item: + if len(item.name) <= 13: + # we want to just place it in the center + first_str = "" + second_str = item.name + third_str = "" + elif len(item.name) <= 26: + # spread across second and third + first_str = "" + second_str = item.name[:13] + third_str = item.name[13:] + else: + # all three + first_str = item.name[:13] + second_str = item.name[13:26] + third_str = item.name[26:] + if len(third_str) > 13: + third_str = third_str[:13] + player_str = world.multiworld.get_player_name(item.player) + if len(player_str) > 13: + player_str = player_str[:13] + y_coords = 0xA5 + row = 0x21 + if location in [names.get_rush_marine, names.get_rush_jet]: + y_coords = 0x45 + row = 0x22 + patch.write_bytes(base_address + offset, MM3TextEntry(first_str, y_coords, row).resolve()) + patch.write_bytes(base_address + 16 + offset, MM3TextEntry(second_str, y_coords + 0x20, row).resolve()) + patch.write_bytes(base_address + 32 + offset, MM3TextEntry(third_str, y_coords + 0x40, row).resolve()) + if y_coords + 0x60 > 0xFF: + row += 1 + y_coords = 0x01 + patch.write_bytes(base_address + 48 + offset, MM3TextEntry(player_str, y_coords, row).resolve()) + colors_high, colors_low = get_colors_for_item(item.name) + patch.write_bytes(color_address + (i * 8) + 1, colors_high) + patch.write_bytes(color_address + (i * 8) + 5, colors_low) + else: + patch.write_bytes(base_address + 48 + offset, MM3TextEntry(player_str, y_coords + 0x60, row).resolve()) + + 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 + for boss in bosses: + if boss == "Kamegoro Maker": + enemy_weaknesses["Kamegoro"] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage} + enemy_weaknesses["Kamegoro Shell"] = {i: world.weapon_damage[i][bosses[boss]] + for i in world.weapon_damage} + elif boss == "Gemini Man": + enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage} + enemy_weaknesses["Gemini Man (Clone)"] = {i: world.weapon_damage[i][bosses[boss]] + for i in world.weapon_damage} + elif boss == "Shadow Man": + enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage} + enemy_weaknesses["Shadow Man (Sliding)"] = {i: world.weapon_damage[i][bosses[boss]] + for i in world.weapon_damage} + else: + enemy_weaknesses[boss] = {i: world.weapon_damage[i][bosses[boss]] for i in world.weapon_damage} + + if world.options.enemy_weakness: + for enemy in enemy_addresses: + if enemy in [*bosses.keys(), "Kamegoro", "Kamegoro Shell", "Gemini Man (Clone)", "Shadow Man (Sliding)"]: + continue + enemy_weaknesses[enemy] = {weapon: world.random.randint(-4, 4) for weapon in enemy_weakness_ptrs} + if enemy in ["Tama", "Giant Snakey", "Proto Man", "Giant Metall"] and enemy_weaknesses[enemy][0] <= 0: + enemy_weaknesses[enemy][0] = 1 + elif enemy == "Jamacy 2": + # bruh + if not enemy_weaknesses[enemy][8] > 0: + enemy_weaknesses[enemy][8] = 1 + if not enemy_weaknesses[enemy][3] > 0: + enemy_weaknesses[enemy][3] = 1 + + for enemy, damage in enemy_weaknesses.items(): + for weapon in enemy_weakness_ptrs: + if damage[weapon] < 0: + damage[weapon] = 256 + damage[weapon] + patch.write_byte(enemy_weakness_ptrs[weapon] + enemy_addresses[enemy], damage[weapon]) + + if world.options.consumables != Consumables.option_all: + value_a = 0x64 + value_b = 0x6A + if world.options.consumables in (Consumables.option_none, Consumables.option_1up_etank): + value_a = 0x68 + if world.options.consumables in (Consumables.option_none, Consumables.option_weapon_health): + value_b = 0x67 + patch.write_byte(consumables_ptr - 3, value_a) + patch.write_byte(consumables_ptr + 1, value_b) + + patch.write_byte(wily_4_ptr + 1, world.options.wily_4_requirement.value) + + patch.write_byte(energylink_ptr + 1, world.options.energy_link.value) + + if world.options.reduce_flashing: + # Spark Man + patch.write_byte(0x12649, 8) + patch.write_byte(0x1264E, 8) + patch.write_byte(0x12653, 8) + # Shadow Man + patch.write_byte(0x12658, 0x10) + # Gemini Man + patch.write_byte(0x12637, 0x20) + patch.write_byte(0x1263D, 0x20) + patch.write_byte(0x12643, 0x20) + # Gamma + patch.write_byte(0x7DA4A, 0xF) + + if world.options.music_shuffle: + if world.options.music_shuffle.current_key == "no_music": + pool = [0xF0] * 18 + elif world.options.music_shuffle.current_key == "randomized": + pool = world.random.choices(range(1, 0xC), k=18) + else: + pool = [1, 2, 3, 4, 5, 6, 7, 8, 1, 3, 7, 8, 9, 9, 10, 10, 11, 11] + world.random.shuffle(pool) + patch.write_bytes(0x7CD1C, pool) + + from Utils import __version__ + patch.name = bytearray(f'MM3{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', + 'utf8')[:21] + patch.name.extend([0] * (21 - len(patch.name))) + patch.write_bytes(0x3F330, patch.name) # We changed this section, but this pointer is still valid! + deathlink_byte = world.options.death_link.value | (world.options.energy_link.value << 1) + patch.write_byte(0x3F346, deathlink_byte) + + patch.write_bytes(0x3F34C, world.world_version) + + version_map = { + "0": 0x00, + "1": 0x01, + "2": 0x02, + "3": 0x03, + "4": 0x04, + "5": 0x05, + "6": 0x06, + "7": 0x07, + "8": 0x08, + "9": 0x09, + ".": 0x26 + } + patch.write_token(APTokenTypes.RLE, 0x653B, (11, 0x25)) + patch.write_token(APTokenTypes.RLE, 0x6549, (25, 0x25)) + + # BY SILVRIS + patch.write_bytes(0x653B, [0x0B, 0x22, 0x25, 0x1C, 0x12, 0x15, 0x1F, 0x1B, 0x12, 0x1C]) + # ARCHIPELAGO x.x.x + patch.write_bytes(0x654D, + [0x0A, 0x1B, 0x0C, 0x11, 0x12, 0x19, 0x0E, 0x15, 0x0A, 0x10, 0x18]) + patch.write_bytes(0x6559, 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\x10\x40\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: bytes | None = 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_mm3(base_rom_bytes) + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() not in {MM3LCHASH, MM3NESHASH, MM3VCHASH}: + 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: + from . import MM3World + if not file_name: + file_name = MM3World.settings.rom_file + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name + + +prg_offset = 0xCF1B0 +prg_size = 0x40000 +chr_offset = 0x10F1B0 +chr_size = 0x20000 + + +def extract_mm3(proteus: bytes) -> bytes: + mm3 = bytearray(proteus[prg_offset:prg_offset + prg_size]) + mm3.extend(proteus[chr_offset:chr_offset + chr_size]) + return bytes(mm3) diff --git a/worlds/mm3/rules.py b/worlds/mm3/rules.py new file mode 100644 index 0000000000..b43908f42c --- /dev/null +++ b/worlds/mm3/rules.py @@ -0,0 +1,388 @@ +from math import ceil +from typing import TYPE_CHECKING +from . import names +from .locations import get_boss_locations, get_oneup_locations, get_energy_locations +from worlds.generic.Rules import add_rule + +if TYPE_CHECKING: + from . import MM3World + from BaseClasses import CollectionState + +bosses: dict[str, int] = { + "Needle Man": 0, + "Magnet Man": 1, + "Gemini Man": 2, + "Hard Man": 3, + "Top Man": 4, + "Snake Man": 5, + "Spark Man": 6, + "Shadow Man": 7, + "Doc Robot (Metal)": 8, + "Doc Robot (Quick)": 9, + "Doc Robot (Air)": 10, + "Doc Robot (Crash)": 11, + "Doc Robot (Flash)": 12, + "Doc Robot (Bubble)": 13, + "Doc Robot (Wood)": 14, + "Doc Robot (Heat)": 15, + "Break Man": 16, + "Kamegoro Maker": 17, + "Yellow Devil MK-II": 18, + "Holograph Mega Man": 19, + "Wily Machine 3": 20, + "Gamma": 21 +} + +weapons_to_id: dict[str, int] = { + "Mega Buster": 0, + "Needle Cannon": 1, + "Magnet Missile": 2, + "Gemini Laser": 3, + "Hard Knuckle": 4, + "Top Spin": 5, + "Search Snake": 6, + "Spark Shot": 7, + "Shadow Blade": 8, +} + +weapon_damage: dict[int, list[int]] = { + 0: [1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 3, 1, 1, 1, 0, ], # Mega Buster + 1: [4, 1, 1, 0, 2, 4, 2, 1, 0, 1, 1, 2, 4, 2, 4, 2, 0, 3, 1, 1, 1, 0, ], # Needle Cannon + 2: [1, 4, 2, 4, 1, 0, 0, 1, 4, 2, 4, 1, 1, 0, 0, 1, 0, 3, 1, 0, 1, 0, ], # Magnet Missile + 3: [7, 2, 4, 1, 0, 1, 1, 1, 1, 4, 2, 0, 4, 1, 1, 1, 0, 3, 1, 1, 1, 0, ], # Gemini Laser + 4: [0, 2, 2, 4, 7, 2, 2, 2, 4, 1, 2, 7, 0, 2, 2, 2, 0, 1, 5, 4, 7, 4, ], # Hard Knuckle + 5: [1, 1, 2, 0, 4, 2, 1, 7, 0, 1, 1, 4, 1, 1, 2, 7, 0, 1, 0, 7, 0, 2, ], # Top Spin + 6: [1, 1, 5, 0, 1, 4, 0, 1, 0, 4, 1, 1, 1, 0, 4, 1, 0, 1, 0, 7, 4, 2, ], # Search Snake + 7: [0, 7, 1, 0, 1, 1, 4, 1, 2, 1, 4, 1, 0, 4, 1, 1, 0, 0, 0, 0, 7, 0, ], # Spark Shot + 8: [2, 7, 2, 0, 1, 2, 4, 4, 2, 2, 0, 1, 2, 4, 2, 4, 0, 1, 3, 2, 2, 2, ], # Shadow Blade +} + +weapons_to_name: dict[int, str] = { + 1: names.needle_cannon, + 2: names.magnet_missile, + 3: names.gemini_laser, + 4: names.hard_knuckle, + 5: names.top_spin, + 6: names.search_snake, + 7: names.spark_shock, + 8: names.shadow_blade +} + +minimum_weakness_requirement: dict[int, int] = { + 0: 1, # Mega Buster is free + 1: 1, # 112 shots of Needle Cannon + 2: 2, # 14 shots of Magnet Missile + 3: 2, # 14 shots of Gemini Laser + 4: 2, # 14 uses of Hard Knuckle + 5: 4, # an unknown amount of Top Spin (4 means you should be able to be fine) + 6: 1, # 56 uses of Search Snake + 7: 2, # 14 functional uses of Spark Shot (fires in twos) + 8: 1, # 56 uses of Shadow Blade +} + +robot_masters: dict[int, str] = { + 0: "Needle Man Defeated", + 1: "Magnet Man Defeated", + 2: "Gemini Man Defeated", + 3: "Hard Man Defeated", + 4: "Top Man Defeated", + 5: "Snake Man Defeated", + 6: "Spark Man Defeated", + 7: "Shadow Man Defeated" +} + +weapon_costs = { + 0: 0, + 1: 0.25, + 2: 2, + 3: 2, + 4: 2, + 5: 7, # Not really, but we can really only rely on Top for one RBM + 6: 0.5, + 7: 2, + 8: 0.5, +} + + +def can_defeat_enough_rbms(state: "CollectionState", player: int, + required: int, boss_requirements: dict[int, list[int]]) -> bool: + 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 has_rush_vertical(state: "CollectionState", player: int) -> bool: + return state.has_any([names.rush_coil, names.rush_jet], player) + + +def can_traverse_long_water(state: "CollectionState", player: int) -> bool: + return state.has_any([names.rush_marine, names.rush_jet], player) + + +def has_any_rush(state: "CollectionState", player: int) -> bool: + return state.has_any([names.rush_coil, names.rush_jet, names.rush_marine], player) + + +def has_rush_jet(state: "CollectionState", player: int) -> bool: + return state.has(names.rush_jet, player) + + +def set_rules(world: "MM3World") -> 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"): + slot_data = getattr(world.multiworld, "re_gen_passthrough")["Mega Man 3"] + world.weapon_damage = slot_data["weapon_damage"] + else: + if world.options.random_weakness == world.options.random_weakness.option_shuffled: + weapon_tables = [table.copy() for weapon, table in weapon_damage.items() if weapon != 0] + world.random.shuffle(weapon_tables) + for i in range(1, 9): + world.weapon_damage[i] = weapon_tables.pop() + elif world.options.random_weakness == world.options.random_weakness.option_randomized: + world.weapon_damage = {i: [] for i in range(9)} + for boss in range(22): + for weapon in world.weapon_damage: + world.weapon_damage[weapon].append(min(14, max(0, int(world.random.normalvariate(3, 3))))) + if not any([world.weapon_damage[weapon][boss] >= 4 + for weapon in range(1, 9)]): + # 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(4, 14) # Force weakness + # handle Break Man + boss = 16 + for weapon in world.weapon_damage: + world.weapon_damage[weapon][boss] = 0 + 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(22): + if i == 16: + continue # Break is only weak to buster on non-random, and minimal damage on random + elif weapon == 0: + world.weapon_damage[weapon][i] = 0 + elif i in (20, 21) and not world.options.random_weakness: + continue + # Gamma and Wily Machine need all weaknesses present, so allow + elif not world.options.random_weakness == world.options.random_weakness.option_randomized \ + and i == 17: + if 3 > world.weapon_damage[weapon][i] > 0: + # Kamegoros take 3 max from weapons on non-random + world.weapon_damage[weapon][i] = 0 + elif 4 > world.weapon_damage[weapon][i] > 0: + world.weapon_damage[weapon][i] = 0 + + for p_boss in world.options.plando_weakness: + for p_weapon in world.options.plando_weakness[p_boss]: + if not any(w for w in world.weapon_damage + if w != weapons_to_id[p_weapon] + and world.weapon_damage[w][bosses[p_boss]] > minimum_weakness_requirement[w]): + # we need to replace this weakness + weakness = world.random.choice([key for key in world.weapon_damage + if key != weapons_to_id[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] + + # handle special cases + for boss in range(22): + for weapon in range(1, 9): + if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and + not any(world.weapon_damage[i][boss] >= minimum_weakness_requirement[weapon] + for i in range(1, 8) if i != weapon)): + world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon] + + if world.weapon_damage[0][world.options.starting_robot_master.value] < 1: + world.weapon_damage[0][world.options.starting_robot_master.value] = 1 + + # weakness validation, it is better to confirm a completable seed than respect plando + boss_health = {boss: 0x1C for boss in range(8)} + + 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)} + 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() + } + boss_flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value + used_weapons: dict[int, set[int]] = {i: set() for i in range(8)} + for boss in boss_flexibility: + 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} + 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] + 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 + used_weapons[boss].add(wp) + 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 + max_uses, wp = max((weapon_energy[weapon] // weapon_costs[weapon], 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) + used_weapons[boss].add(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) + used_weapons[boss].add(wp) + + world.wily_4_weapons = {boss: sorted(weapons) for boss, weapons in used_weapons.items()} + + for i, boss_locations in zip(range(22), [ + get_boss_locations("Needle Man Stage"), + get_boss_locations("Magnet Man Stage"), + get_boss_locations("Gemini Man Stage"), + get_boss_locations("Hard Man Stage"), + get_boss_locations("Top Man Stage"), + get_boss_locations("Snake Man Stage"), + get_boss_locations("Spark Man Stage"), + get_boss_locations("Shadow Man Stage"), + get_boss_locations("Doc Robot (Spark) - Metal"), + get_boss_locations("Doc Robot (Spark) - Quick"), + get_boss_locations("Doc Robot (Needle) - Air"), + get_boss_locations("Doc Robot (Needle) - Crash"), + get_boss_locations("Doc Robot (Gemini) - Flash"), + get_boss_locations("Doc Robot (Gemini) - Bubble"), + get_boss_locations("Doc Robot (Shadow) - Wood"), + get_boss_locations("Doc Robot (Shadow) - Heat"), + get_boss_locations("Break Man"), + get_boss_locations("Wily Stage 1"), + get_boss_locations("Wily Stage 2"), + get_boss_locations("Wily Stage 3"), + get_boss_locations("Wily Stage 5"), + get_boss_locations("Wily Stage 6") + ]): + 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 + 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 in (20, 21): + # multi-phase fights, get all potential weaknesses + # we should probably do this smarter, but this works for now + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_all(weps, world.player)) + else: + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_any(weps, world.player)) + + # Need to defeat x amount of robot masters for Wily 4 + add_rule(world.get_location(names.wily_stage_4), + lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_4_requirement.value, + world.wily_4_weapons)) + + # Handle Doc Robo stage connections + for entrance, location in (("To Doc Robot (Needle) - Crash", names.doc_air), + ("To Doc Robot (Gemini) - Bubble", names.doc_flash), + ("To Doc Robot (Shadow) - Heat", names.doc_wood), + ("To Doc Robot (Spark) - Quick", names.doc_metal)): + entrance_object = world.get_entrance(entrance) + add_rule(entrance_object, lambda state, loc=location: state.can_reach(loc, "Location", world.player)) + + # finally, real logic + for location in get_boss_locations("Hard Man Stage"): + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + + for location in get_boss_locations("Gemini Man Stage"): + add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player)) + + add_rule(world.get_entrance("To Doc Robot (Spark) - Metal"), + lambda state: has_rush_vertical(state, world.player) and + state.has_any([names.shadow_blade, names.gemini_laser], world.player)) + add_rule(world.get_entrance("To Doc Robot (Needle) - Air"), + lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_entrance("To Doc Robot (Needle) - Crash"), + lambda state: has_rush_jet(state, world.player)) + add_rule(world.get_entrance("To Doc Robot (Gemini) - Bubble"), + lambda state: has_rush_vertical(state, world.player) and can_traverse_long_water(state, world.player)) + + for location in get_boss_locations("Wily Stage 1"): + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + + for location in get_boss_locations("Wily Stage 2"): + add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player)) + + # Wily 3 technically needs vertical + # However, Wily 3 requires beating Wily 2, and Wily 2 explicitly needs Jet + # So we can skip the additional rule on Wily 3 + + if world.options.consumables in (world.options.consumables.option_1up_etank, + world.options.consumables.option_all): + add_rule(world.get_location(names.needle_man_c2), lambda state: has_rush_jet(state, world.player)) + add_rule(world.get_location(names.gemini_man_c1), lambda state: has_rush_jet(state, world.player)) + add_rule(world.get_location(names.gemini_man_c3), + lambda state: has_rush_vertical(state, world.player) + or state.has_any([names.gemini_laser, names.shadow_blade], world.player)) + for location in (names.gemini_man_c6, names.gemini_man_c7, names.gemini_man_c10): + add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player)) + for location in get_oneup_locations("Hard Man Stage"): + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.top_man_c6), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.doc_needle_c2), lambda state: has_rush_jet(state, world.player)) + add_rule(world.get_location(names.doc_needle_c3), lambda state: has_rush_jet(state, world.player)) + add_rule(world.get_location(names.doc_gemini_c1), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.doc_gemini_c2), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.wily_1_c8), lambda state: has_rush_vertical(state, world.player)) + for location in [names.wily_1_c4, names.wily_1_c8]: + add_rule(world.get_location(location), lambda state: state.has(names.hard_knuckle, world.player)) + for location in get_oneup_locations("Wily Stage 2"): + if location == names.wily_2_c3: + continue + add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player)) + if world.options.consumables in (world.options.consumables.option_weapon_health, + world.options.consumables.option_all): + add_rule(world.get_location(names.gemini_man_c2), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.gemini_man_c4), lambda state: has_rush_vertical(state, world.player)) + add_rule(world.get_location(names.gemini_man_c5), lambda state: has_rush_vertical(state, world.player)) + for location in (names.gemini_man_c8, names.gemini_man_c9): + add_rule(world.get_location(location), lambda state: has_any_rush(state, world.player)) + for location in get_energy_locations("Hard Man Stage"): + if location == names.hard_man_c1: + continue + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + for location in (names.spark_man_c1, names.spark_man_c2): + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + for location in [names.top_man_c2, names.top_man_c3, names.top_man_c4, names.top_man_c7]: + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + for location in [names.wily_1_c5, names.wily_1_c6, names.wily_1_c7]: + add_rule(world.get_location(location), lambda state: state.has(names.hard_knuckle, world.player)) + for location in [names.wily_1_c6, names.wily_1_c7, names.wily_1_c11, names.wily_1_c12]: + add_rule(world.get_location(location), lambda state: has_rush_vertical(state, world.player)) + for location in get_energy_locations("Wily Stage 2"): + if location in (names.wily_2_c1, names.wily_2_c2, names.wily_2_c4): + continue + add_rule(world.get_location(location), lambda state: has_rush_jet(state, world.player)) diff --git a/worlds/mm3/src/__init__.py b/worlds/mm3/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/mm3/src/mm3_basepatch.asm b/worlds/mm3/src/mm3_basepatch.asm new file mode 100644 index 0000000000..16e0567ff5 --- /dev/null +++ b/worlds/mm3/src/mm3_basepatch.asm @@ -0,0 +1,781 @@ +norom +!headersize = 16 + +!controller_flip = $14 ; only on first frame of input, used by crash man, etc +!controller_mirror = $16 +!current_stage = $22 +!current_state = $60 +!completed_rbm_stages = $61 +!completed_doc_stages = $62 +!current_wily = $75 +!received_rbm_stages = $680 +!received_doc_stages = $681 +; !deathlink = $30, set to $0E +!energylink_packet = $682 +!last_wily = $683 +!rbm_strobe = $684 +!sound_effect_strobe = $685 +!doc_robo_kills = $686 +!wily_stage_completion = $687 +;!received_items = $688 +!acquired_rush = $689 + +!current_weapon = $A0 +!current_health = $A2 +!received_weapons = $A3 + +'0' = $00 +'1' = $01 +'2' = $02 +'3' = $03 +'4' = $04 +'5' = $05 +'6' = $06 +'7' = $07 +'8' = $08 +'9' = $09 +'A' = $0A +'B' = $0B +'C' = $0C +'D' = $0D +'E' = $0E +'F' = $0F +'G' = $10 +'H' = $11 +'I' = $12 +'J' = $13 +'K' = $14 +'L' = $15 +'M' = $16 +'N' = $17 +'O' = $18 +'P' = $19 +'Q' = $1A +'R' = $1B +'S' = $1C +'T' = $1D +'U' = $1E +'V' = $1F +'W' = $20 +'X' = $21 +'Y' = $22 +'Z' = $23 +' ' = $25 +'.' = $26 +',' = $27 +'!' = $29 +'r' = $2A +':' = $2B + +; !consumable_checks = $0F80 ; have to find in-stage solutions for this, there's literally not enough ram + +!CONTROLLER_SELECT = #$20 +!CONTROLLER_SELECT_START = #$30 +!CONTROLLER_ALL_BUTTON = #$F0 + +!PpuControl_2000 = $2000 +!PpuMask_2001 = $2001 +!PpuAddr_2006 = $2006 +!PpuData_2007 = $2007 + +;!LOAD_BANK = $C000 + +macro org(address,bank) + if == $3E + org
-$C000+($2000*)+!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 + if == $3F + org
-$E000+($2000*)+!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 + if
>= $A000 + org
-$A000+($2000*)+!headersize + base
+ else + org
-$8000+($2000*)+!headersize + base
+ endif + endif + endif +endmacro + +; capcom..... +; i can't keep defending you like this + +;P +%org($BEBA, $13) +RemoveP: +db $25 +;A +%org($BD7D, $13) +RemoveA: +db $25 +;S +%org($BE7D, $13) +RemoveS1: +db $25 +;S +%org($BDD5, $13) +RemoveS2: +db $25 + +;W +%org($BDC7, $13) +RemoveW: +db $25 +;O +%org($BEC7, $13) +RemoveO: +db $25 +;R +%org($BDCF, $13) +RemoveR: +db $25 +;D +%org($BECF, $13) +RemoveD: +db $25 + +%org($A17C, $02) +AdjustWeaponRefill: + ; compare vs unreceived instead. Since the stage ends anyways, this just means you aren't granted the weapon if you don't have it already + CMP #$1C + BCS WeaponRefillJump + +%org($A18B, $02) +WeaponRefillJump: + ; just as a branch target + +%org($A3BF, $02) +FixPseudoSnake: + JMP CheckFirstWep + NOP + +%org($A3CB, $02) +FixPseudoRush: + JMP CheckRushWeapon + NOP + +%org($BF80, $02) +CheckRushWeapon: + AND #$01 + BNE .Rush + JMP $A3CF + .Rush: + LDA $A1 + CLC + ADC $B4 + TAY + LDA $00A2, Y + BNE .Skip + DEC $A1 + .Skip: + JMP $A477 + +; don't even try to go past this point + +%org($802F, $0B) +HookBreakMan: + JSR SetBreakMan + NOP + +%org($90BC, $18) +BlockPassword: + AND #$08 ; originally 0C, just block down inputs + +%org($9258, $18) +HookStageSelect: + JSR ChangeStageMode + NOP + +%org($92F2, $18) +AccessStageTarget: + +%org($9316, $18) +AccessStage: + JSR RewireDocRobotAccess + NOP #2 + BEQ AccessStageTarget + +%org($9468, $18) +HookWeaponGet: + JSR WeaponReceived + NOP #4 + +%org($9917, $18) +GameOverStageSelect: + ; fix it returning to Wily 1 + CMP #$16 + +%org($9966, $18) +SwapSelectTiles: + ; swaps when stage select face tiles should be shown + JMP InvertSelectTiles + NOP + +%org($9A54, $18) +SwapSelectSprites: + JMP InvertSelectSprites + NOP + +%org($9AFF, $18) +BreakManSelect: + JSR ApplyLastWily + NOP + +%org($BE22, $1D) +ConsumableHook: + JMP CheckConsumable + +%org($BE32, $1D) +EnergyLinkHook: + JSR EnergyLink + +%org($A000, $1E) +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P" +db $22, $45, $0C, "PLACEHOLDER 1" +db $22, $65, $0C, "PLACEHOLDER 2" +db $22, $85, $0C, "PLACEHOLDER 3" +db $22, $A5, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P", $FF +db $21, $A5, $0C, "PLACEHOLDER 1" +db $21, $C5, $0C, "PLACEHOLDER 2" +db $21, $E5, $0C, "PLACEHOLDER 3" +db $22, $05, $0C, "PLACEHOLDER P" +db $22, $45, $0C, "PLACEHOLDER 1" +db $22, $65, $0C, "PLACEHOLDER 2" +db $22, $85, $0C, "PLACEHOLDER 3" +db $22, $A5, $0C, "PLACEHOLDER P", $FF + +ShowItemString: + STY $04 + LDA ItemLower,X + STA $02 + LDA ItemUpper,X + STA $03 + LDY #$00 + .LoadString: + LDA ($02),Y + ORA $10 + STA $0780,Y + BMI .Return + INY + LDA ($02),Y + STA $0780,Y + INY + LDA ($02),Y + STA $0780,Y + STA $00 + INY + .LoadCharacters: + LDA ($02),Y + STA $0780,Y + INY + DEC $00 + BPL .LoadCharacters + BMI .LoadString + .Return: + STA $19 + LDY $04 + RTS + +ItemUpper: + db $A0, $A0, $A0, $A1, $A1, $A1, $A1, $A2, $A2 + +ItemLower: + db $00, $81, $C2, $03, $44, $85, $C6, $07, $47 + +%org($C8F7, $3E) +RemoveRushCoil: + NOP #4 + +%org($CA73, $3E) +HookController: + JMP ControllerHook + NOP + +%org($DA18, $3E) +NullWeaponGet: + NOP #5 ; TODO: see if I can reroute this write instead for nicer timings + +%org($DB99, $3E) +HookMidDoc: + JSR SetMidDoc + NOP + +%org($DBB0, $3E) +HoodEndDoc: + JSR SetEndDoc + NOP + +%org($DC57, $3E) +RerouteStageComplete: + LDA $60 + JSR SetStageComplete + NOP #2 + +%org($DC6F, $3E) +RerouteRushMarine: + JMP SetRushMarine + NOP + +%org($DC6A, $3E) +RerouteRushJet: + JMP SetRushJet + NOP + +%org($DC78, $3E) +RerouteWilyComplete: + JMP SetEndWily + NOP + EndWilyReturn: + +%org($DF81, $3E) +NullBreak: + NOP #5 ; nop break man giving every weapon + +%org($E15F, $3F) +Wily4: + JMP Wily4Comparison + NOP + + +%org($F340, $3F) +RewireDocRobotAccess: + LDA !current_state + BNE .DocRobo + LDA !received_rbm_stages + SEC + BCS .Return + .DocRobo: + LDA !received_doc_stages + .Return: + AND $9DED,Y + RTS + +ChangeStageMode: + ; also handles hot reload of stage select + ; kinda broken, sprites don't disappear and palettes go wonky with Break Man access + ; but like, it functions! + LDA !sound_effect_strobe + BEQ .Continue + JSR $F89A + LDA #$00 + STA !sound_effect_strobe + .Continue: + LDA $14 + AND #$20 + BEQ .Next + LDA !current_state + BNE .Set + LDA !completed_doc_stages + CMP #$C5 + BEQ .BreakMan + LDA #$09 + SEC + BCS .Set + .EarlyReturn: + LDA $14 + AND #$90 + RTS + .BreakMan: + LDA #$12 + .Set: + EOR !current_state + STA !current_state + LDA #$01 + STA !rbm_strobe + .Next: + LDA !rbm_strobe + BEQ .EarlyReturn + LDA #$00 + STA !rbm_strobe + ; Clear the sprite buffer + LDX #$98 + .Loop: + LDA #$00 + STA $01FF, X + DEX + STA $01FF, X + DEX + STA $01FF, X + DEX + LDA #$F8 + STA $01FF, X + DEX + CPX #$00 + BNE .Loop + ; Break Man Sprites + LDX #$24 + .Loop2: + LDA #$00 + STA $02DB, X + DEX + STA $02DB, X + DEX + STA $02DB, X + DEX + LDA #$F8 + STA $02DB, X + DEX + CPX #$00 + BNE .Loop2 + ; Swap out the tilemap and write sprites + LDY #$10 + LDA $11 + BMI .B1 + LDA $FD + EOR #$01 + ASL A + ASL A + STA $10 + LDA #$01 + JSR $E8B4 + LDA #$00 + STA $70 + STA $EE + .B3: + LDA $10 + PHA + JSR $EF8C + PLA + STA $10 + JSR $FF21 + LDA $70 + BNE .B3 + JSR $995C + LDX #$03 + JSR $939E + JSR $FF21 + LDX #$04 + JSR $939E + LDA $FD + EOR #$01 + STA $FD + LDY #$00 + LDA #$7E + STA $E9 + JSR $FF3C + .B1: + LDX #$00 + ; palettes + .B2: + LDA $9C33,Y + STA $0600,X + LDA $9C23,Y + STA $0610,X + INY + INX + CPX #$10 + BNE .B2 + LDA #$FF + STA $18 + LDA #$01 + STA $12 + LDA #$03 + STA $13 + LDA $11 + JSR $99FA + LDA $14 + AND #$90 + RTS + +InvertSelectTiles: + LDY !current_state + BNE .DocRobo + AND !received_rbm_stages + SEC + BCS .Compare + .DocRobo: + AND !received_doc_stages + .Compare: + BNE .False + JMP $996A + .False: + JMP $99BA + +InvertSelectSprites: + LDY !current_state + BNE .DocRobo + AND !received_rbm_stages + SEC + BCS .Compare + .DocRobo: + AND !received_doc_stages + .Compare: + BNE .False + JMP $9A58 + .False: + JMP $9A6D + +SetStageComplete: + CMP #$00 + BNE .DocRobo + LDA !completed_rbm_stages + ORA $DEC2, Y + STA !completed_rbm_stages + SEC + BCS .Return + .DocRobo: + LDA !completed_doc_stages + ORA $DEC2, Y + STA !completed_doc_stages + .Return: + RTS + +ControllerHook: + ; Jump in here too for sfx + LDA !sound_effect_strobe + BEQ .Next + JSR $F89A + LDA #$00 + STA !sound_effect_strobe + .Next: + LDA !controller_mirror + CMP !CONTROLLER_ALL_BUTTON + BNE .Continue + JMP $CBB1 + .Continue: + LDA !controller_flip + AND #$10 ; start + JMP $CA77 + +SetRushMarine: + LDA #$01 + SEC + BCS SetRushAcquire + +SetRushJet: + LDA #$02 + SEC + BCS SetRushAcquire + +SetRushAcquire: + ORA !acquired_rush + STA !acquired_rush + RTS + +ApplyLastWily: + LDA !controller_mirror + AND !CONTROLLER_SELECT + BEQ .LastWily + .Default: + LDA #$00 + SEC + BCS .Set + .LastWily: + LDA !last_wily + BEQ .Default + SEC + SBC #$0C + .Set: + STA $75 ; wily index + LDA #$03 + STA !current_stage + RTS + +SetMidDoc: + LDA !current_stage + SEC + SBC #$08 + ASL + TAY + LDA #$01 + .Loop: + CPY #$00 + BEQ .Return + DEY + ASL + SEC + BCS .Loop + .Return: + ORA !doc_robo_kills + STA !doc_robo_kills + LDA #$00 + STA $30 + RTS + +SetEndDoc: + LDA !current_stage + SEC + SBC #$08 + ASL + TAY + INY + LDA #$01 + .Loop: + CPY #$00 + BEQ .Set + DEY + ASL + SEC + BCS .Loop + .Set: + ORA !doc_robo_kills + STA !doc_robo_kills + .Return: + LDA #$0D + STA $30 + RTS + +SetEndWily: + LDA !current_wily + PHA + CLC + ADC #$0C + STA !last_wily + PLA + TAX + LDA #$01 + .WLoop: + CPX #$00 + BEQ .WContinue + DEX + ASL A + SEC + BCS .WLoop + .WContinue: + ORA !wily_stage_completion + STA !wily_stage_completion + INC !current_wily + LDA #$9C + JMP EndWilyReturn + + +SetBreakMan: + LDA #$80 + ORA !wily_stage_completion + STA !wily_stage_completion + LDA #$16 + STA $22 + RTS + +CheckFirstWep: + LDA $B4 + BEQ .SetNone + TAY + .Loop: + LDA $00A2,Y + BMI .SetNew + INY + CPY #$0C + BEQ .SetSame + BCC .Loop + .SetSame: + LDA #$80 + STA $A1 + JMP $A3A1 + .SetNew: + TYA + SEC + SBC $B4 + BCS .Set + .SetNone: + LDA #$00 + .Set: + STA $A1 + JMP $A3DE + +Wily4Comparison: + TYA + PHA + TXA + PHA + LDY #$00 + LDX #$08 + LDA #$01 + .Loop: + PHA + AND $6E + BEQ .Skip + INY + .Skip: + PLA + ASL + DEX + BNE .Loop + print "Wily 4 Requirement:", hex(realbase()) + CPY #$08 + BCC .Return + LDA #$FF + STA $6E + .Return: + PLA + TAX + PLA + TAY + LDA #$0C + STA $EC + RTS + +; out of space here :( + +%org($FDBA, $3F) +WeaponReceived: + TAX + LDA $F5 + PHA + LDA #$1E + STA $F5 + JSR $FF6B + TXA + JSR ShowItemString + PLA + STA $F5 + JSR $FF6B + RTS + +CheckConsumable: + STA $0150, Y + LDA $0320, X + CMP #$64 + BMI .Return + print "Consumables (replace 67): ", hex(realbase()) + CMP #$6A + BPL .Return + LDA #$00 + STA $0300, X + JMP $BE49 + .Return: + JMP $BE25 + +EnergyLink: + print "Energylink: ", hex(realbase()) + LDA #$01 + BEQ .Return + TYA + STA !energylink_packet + LDA #$49 + STA $00 + .Return: + LDA $BDEC, Y + RTS + +; out of room here :( diff --git a/worlds/mm3/src/patch_mm3base.py b/worlds/mm3/src/patch_mm3base.py new file mode 100644 index 0000000000..c64c83c3c0 --- /dev/null +++ b/worlds/mm3/src/patch_mm3base.py @@ -0,0 +1,8 @@ +import os + +os.chdir(os.path.dirname(os.path.realpath(__file__))) + +mm3 = bytearray(open("Mega Man 3 (USA).nes", 'rb').read()) +mm3[0x3C010:0x3C010] = [0] * 0x40000 +mm3[0x4] = 0x20 # have to do it here, because we don't this in the basepatch itself +open("mm3_basepatch.nes", 'wb').write(mm3) diff --git a/worlds/mm3/test/__init__.py b/worlds/mm3/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/mm3/test/bases.py b/worlds/mm3/test/bases.py new file mode 100644 index 0000000000..38ea47ab2f --- /dev/null +++ b/worlds/mm3/test/bases.py @@ -0,0 +1,5 @@ +from test.bases import WorldTestBase + + +class MM3TestBase(WorldTestBase): + game = "Mega Man 3" diff --git a/worlds/mm3/test/test_weakness.py b/worlds/mm3/test/test_weakness.py new file mode 100644 index 0000000000..400eab1f4b --- /dev/null +++ b/worlds/mm3/test/test_weakness.py @@ -0,0 +1,105 @@ +from math import ceil + +from .bases import MM3TestBase +from ..rules import minimum_weakness_requirement, bosses + + +# Need to figure out how this test should work +def validate_wily_4(base: MM3TestBase) -> None: + world = base.multiworld.worlds[base.player] + weapon_damage = world.weapon_damage + weapon_costs = { + 0: 0, + 1: 0.25, + 2: 2, + 3: 1, + 4: 2, + 5: 7, # Not really, but we can really only rely on Top for one RBM + 6: 0.5, + 7: 2, + 8: 0.5, + } + boss_health = {boss: 0x1C for boss in range(8)} + 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)} + 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() + } + boss_flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value + used_weapons: dict[int, set[int]] = {i: set() for i in range(8)} + for boss in boss_flexibility: + 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} + 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 + 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 WeaknessTests(MM3TestBase): + 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(22): + if not any(weapon_damage[weapon][boss] >= minimum_weakness_requirement[weapon] for weapon in range(9)): + self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}") + + def test_wily_4(self) -> None: + validate_wily_4(self) + + +class StrictWeaknessTests(WeaknessTests): + options = { + "strict_weakness": True, + } + + +class RandomWeaknessTests(WeaknessTests): + options = { + "random_weakness": "randomized" + } + + +class ShuffledWeaknessTests(WeaknessTests): + options = { + "random_weakness": "shuffled" + } + + +class RandomStrictWeaknessTests(WeaknessTests): + options = { + "strict_weakness": True, + "random_weakness": "randomized", + } + + +class ShuffledStrictWeaknessTests(WeaknessTests): + options = { + "strict_weakness": True, + "random_weakness": "shuffled" + } diff --git a/worlds/mm3/text.py b/worlds/mm3/text.py new file mode 100644 index 0000000000..337837244c --- /dev/null +++ b/worlds/mm3/text.py @@ -0,0 +1,63 @@ +from collections import defaultdict +from typing import DefaultDict + +MM3_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda: 0x25, { + '0': 0x00, + '1': 0x01, + '2': 0x02, + '3': 0x03, + '4': 0x04, + '5': 0x05, + '6': 0x06, + '7': 0x07, + '8': 0x08, + '9': 0x09, + 'A': 0x0A, + 'B': 0x0B, + 'C': 0x0C, + 'D': 0x0D, + 'E': 0x0E, + 'F': 0x0F, + 'G': 0x10, + 'H': 0x11, + 'I': 0x12, + 'J': 0x13, + 'K': 0x14, + 'L': 0x15, + 'M': 0x16, + 'N': 0x17, + 'O': 0x18, + 'P': 0x19, + 'Q': 0x1A, + 'R': 0x1B, + 'S': 0x1C, + 'T': 0x1D, + 'U': 0x1E, + 'V': 0x1F, + 'W': 0x20, + 'X': 0x21, + 'Y': 0x22, + 'Z': 0x23, + ' ': 0x25, + '.': 0x26, + ',': 0x27, + '\'': 0x28, + '!': 0x29, + ':': 0x2B +}) + + +class MM3TextEntry: + def __init__(self, text: str = "", y_coords: int = 0xA5, row: int = 0x21): + self.target_area: int = row # don't change + self.coords: int = y_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.append(12) + data.extend([MM3_WEAPON_ENCODING[x] for x in self.text.upper()]) + data.extend([0x25] * (13 - len(self.text))) + return bytes(data)