import pkgutil from typing import Optional, TYPE_CHECKING, Iterable, Dict, Sequence import hashlib import Utils import os import settings from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes from . import names from .rules import minimum_weakness_requirement from .text import MM2TextEntry from .color import get_colors_for_item, write_palette_shuffle from .options import Consumables, ReduceFlashing, RandomMusic if TYPE_CHECKING: from . import MM2World MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" enemy_weakness_ptrs: Dict[int, int] = { 0: 0x3E9A8, 1: 0x3EA24, 2: 0x3EA9C, 3: 0x3EB14, 4: 0x3EB8C, 5: 0x3EC04, 6: 0x3EC7C, 7: 0x3ECF4, } enemy_addresses: Dict[str, int] = { "Shrink": 0x00, "M-445": 0x04, "Claw": 0x08, "Tanishi": 0x0A, "Kerog": 0x0C, "Petit Kerog": 0x0D, "Anko": 0x0F, "Batton": 0x16, "Robitto": 0x17, "Friender": 0x1C, "Monking": 0x1D, "Kukku": 0x1F, "Telly": 0x22, "Changkey Maker": 0x23, "Changkey": 0x24, "Pierrobot": 0x29, "Fly Boy": 0x2C, # "Crash Wall": 0x2D # "Friender Wall": 0x2E "Blocky": 0x31, "Neo Metall": 0x34, "Matasaburo": 0x36, "Pipi": 0x38, "Pipi Egg": 0x3A, "Copipi": 0x3C, "Kaminari Goro": 0x3E, "Petit Goblin": 0x45, "Springer": 0x46, "Mole (Up)": 0x48, "Mole (Down)": 0x49, "Shotman (Left)": 0x4B, "Shotman (Right)": 0x4C, "Sniper Armor": 0x4E, "Sniper Joe": 0x4F, "Scworm": 0x50, "Scworm Worm": 0x51, "Picopico-kun": 0x6A, "Boobeam Trap": 0x6D, "Big Fish": 0x71 } # addresses printed when assembling basepatch consumables_ptr: int = 0x3F2FE quickswap_ptr: int = 0x3F363 wily_5_ptr: int = 0x3F3A1 energylink_ptr: int = 0x3F46B get_equipped_sound_ptr: int = 0x3F384 class RomData: def __init__(self, file: bytes, name: str = "") -> None: self.file = bytearray(file) self.name = name def read_byte(self, offset: int) -> int: return self.file[offset] def read_bytes(self, offset: int, length: int) -> bytearray: return self.file[offset:offset + length] def write_byte(self, offset: int, value: int) -> None: self.file[offset] = value def write_bytes(self, offset: int, values: Sequence[int]) -> None: self.file[offset:offset + len(values)] = values def write_to_file(self, file: str) -> None: with open(file, 'wb') as outfile: outfile.write(self.file) class MM2ProcedurePatch(APProcedurePatch, APTokenMixin): hash = [MM2LCHASH, MM2NESHASH, MM2VCHASH] game = "Mega Man 2" patch_file_ending = ".apmm2" result_file_ending = ".nes" name: bytearray procedure = [ ("apply_bsdiff4", ["mm2_basepatch.bsdiff4"]), ("apply_tokens", ["token_patch.bin"]), ] @classmethod def get_source_data(cls) -> bytes: return get_base_rom_bytes() def write_byte(self, offset: int, value: int) -> None: self.write_token(APTokenTypes.WRITE, offset, value.to_bytes(1, "little")) def write_bytes(self, offset: int, value: Iterable[int]) -> None: self.write_token(APTokenTypes.WRITE, offset, bytes(value)) def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: patch.write_file("mm2_basepatch.bsdiff4", pkgutil.get_data(__name__, "data/mm2_basepatch.bsdiff4")) # text writing patch.write_bytes(0x37E2A, MM2TextEntry("FOR ", 0xCB).resolve()) patch.write_bytes(0x37EAA, MM2TextEntry("GET EQUIPPED ", 0x0B).resolve()) patch.write_bytes(0x37EBA, MM2TextEntry("WITH ", 0x2B).resolve()) base_address = 0x3F650 color_address = 0x37F6C for i, location in zip(range(11), [ names.atomic_fire_get, names.air_shooter_get, names.leaf_shield_get, names.bubble_lead_get, names.quick_boomerang_get, names.time_stopper_get, names.metal_blade_get, names.crash_bomber_get, names.item_1_get, names.item_2_get, names.item_3_get ]): item = world.multiworld.get_location(location, world.player).item if item: if len(item.name) <= 14: # we want to just place it in the center first_str = "" second_str = item.name third_str = "" elif len(item.name) <= 28: # spread across second and third first_str = "" second_str = item.name[:14] third_str = item.name[14:] else: # all three first_str = item.name[:14] second_str = item.name[14:28] third_str = item.name[28:] if len(third_str) > 16: third_str = third_str[:16] player_str = world.multiworld.get_player_name(item.player) if len(player_str) > 14: player_str = player_str[:14] patch.write_bytes(base_address + (64 * i), MM2TextEntry(first_str, 0x4B).resolve()) patch.write_bytes(base_address + (64 * i) + 16, MM2TextEntry(second_str, 0x6B).resolve()) patch.write_bytes(base_address + (64 * i) + 32, MM2TextEntry(third_str, 0x8B).resolve()) patch.write_bytes(base_address + (64 * i) + 48, MM2TextEntry(player_str, 0xEB).resolve()) colors = get_colors_for_item(item.name) if i > 7: patch.write_bytes(color_address + 27 + ((i - 8) * 2), colors) else: patch.write_bytes(color_address + (i * 2), colors) write_palette_shuffle(world, patch) enemy_weaknesses: Dict[str, Dict[int, int]] = {} if world.options.strict_weakness or world.options.random_weakness or world.options.plando_weakness: # we need to write boss weaknesses output = bytearray() for weapon in world.weapon_damage: if weapon == 8: continue # Time Stopper is a special case weapon_damage = [world.weapon_damage[weapon][i] if world.weapon_damage[weapon][i] >= 0 else 256 + world.weapon_damage[weapon][i] for i in range(14)] output.extend(weapon_damage) patch.write_bytes(0x2E952, bytes(output)) time_stopper_damage = world.weapon_damage[8] time_offset = 0x2C03B damage_table = { 4: 0xF, 3: 0x17, 2: 0x1E, 1: 0x25 } for boss, damage in enumerate(time_stopper_damage): if damage > 4: damage = 4 # 4 is a guaranteed kill, no need to exceed if damage <= 0: patch.write_byte(time_offset + 14 + boss, 0) else: patch.write_byte(time_offset + 14 + boss, 1) patch.write_byte(time_offset + boss, damage_table[damage]) if world.options.random_weakness: wily_5_weaknesses = [i for i in range(8) if world.weapon_damage[i][12] > minimum_weakness_requirement[i]] world.random.shuffle(wily_5_weaknesses) if len(wily_5_weaknesses) >= 3: weak1 = wily_5_weaknesses.pop() weak2 = wily_5_weaknesses.pop() weak3 = wily_5_weaknesses.pop() elif len(wily_5_weaknesses) == 2: weak1 = weak2 = wily_5_weaknesses.pop() weak3 = wily_5_weaknesses.pop() else: weak1 = weak2 = weak3 = 0 patch.write_byte(0x2DA2E, weak1) patch.write_byte(0x2DA32, weak2) patch.write_byte(0x2DA3A, weak3) enemy_weaknesses["Picopico-kun"] = {weapon: world.weapon_damage[weapon][9] for weapon in range(8)} enemy_weaknesses["Boobeam Trap"] = {weapon: world.weapon_damage[weapon][11] for weapon in range(8)} if world.options.enemy_weakness: for enemy in enemy_addresses: if enemy in ("Picopico-kun", "Boobeam Trap"): continue enemy_weaknesses[enemy] = {weapon: world.random.randint(-4, 4) for weapon in enemy_weakness_ptrs} if enemy == "Friender": # Friender has to be killed, need buster damage to not break logic enemy_weaknesses[enemy][0] = max(enemy_weaknesses[enemy][0], 1) for enemy, damage_table in enemy_weaknesses.items(): for weapon in enemy_weakness_ptrs: if damage_table[weapon] < 0: damage_table[weapon] = 256 + damage_table[weapon] patch.write_byte(enemy_weakness_ptrs[weapon] + enemy_addresses[enemy], damage_table[weapon]) if world.options.quickswap: patch.write_byte(quickswap_ptr + 1, 0x01) if world.options.consumables != Consumables.option_all: value_a = 0x7C value_b = 0x76 if world.options.consumables == Consumables.option_1up_etank: value_b = 0x7A else: value_a = 0x7A patch.write_byte(consumables_ptr - 3, value_a) patch.write_byte(consumables_ptr + 1, value_b) patch.write_byte(wily_5_ptr + 1, world.options.wily_5_requirement.value) if world.options.energy_link: patch.write_byte(energylink_ptr + 1, 1) if world.options.reduce_flashing: if world.options.reduce_flashing.value == ReduceFlashing.option_virtual_console: color = 0x2D # Dark Gray speed = -1 elif world.options.reduce_flashing.value == ReduceFlashing.option_minor: color = 0x2D speed = 0x08 else: color = 0x0F speed = 0x00 patch.write_byte(0x2D1B0, color) # Change white to a dark gray, Mecha Dragon patch.write_byte(0x2D397, 0x0F) # Longer flash time, Mecha Dragon kill patch.write_byte(0x2D3A0, color) # Change white to a dark gray, Picopico-kun/Boobeam Trap patch.write_byte(0x2D65F, color) # Change white to a dark gray, Guts Tank patch.write_byte(0x2DA94, color) # Change white to a dark gray, Wily Machine patch.write_byte(0x2DC97, color) # Change white to a dark gray, Alien patch.write_byte(0x2DD68, 0x10) # Longer flash time, Alien kill patch.write_bytes(0x2DF14, [0xEA, 0xEA, 0xEA, 0xEA, 0xEA, 0xEA]) # Reduce final Alien flash to 1 big flash patch.write_byte(0x34132, 0x08) # Longer flash time, Stage Select if world.options.reduce_flashing.value == ReduceFlashing.option_full: # reduce color of stage flashing patch.write_bytes(0x344C9, [0x2D, 0x10, 0x00, 0x2D, 0x0F, 0x10, 0x2D, 0x00, 0x0F, 0x10, 0x2D, 0x00, 0x0F, 0x10, 0x2D, 0x00, 0x2D, 0x10, 0x2D, 0x00, 0x0F, 0x10, 0x2D, 0x00, 0x0F, 0x10, 0x2D, 0x00, 0x0F, 0x10, 0x2D, 0x00]) # remove wily castle flash patch.write_byte(0x3596D, 0x0F) if speed != -1: patch.write_byte(0xFE01, speed) # Bubble Man Stage patch.write_byte(0x1BE01, speed) # Metal Man Stage if world.options.random_music: if world.options.random_music == RandomMusic.option_none: pool = [0xFF] * 20 # A couple of additional mutes we want here patch.write_byte(0x37819, 0xFF) # Credits patch.write_byte(0x378A4, 0xFF) # Credits #2 patch.write_byte(0x37149, 0xFF) # Game Over Jingle patch.write_byte(0x341BA, 0xFF) # Robot Master Jingle patch.write_byte(0x2E0B4, 0xFF) # Robot Master Defeated patch.write_byte(0x35B78, 0xFF) # Wily Castle patch.write_byte(0x2DFA5, 0xFF) # Wily Defeated elif world.options.random_music == RandomMusic.option_shuffled: pool = [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 9, 9, 0x10, 0xC, 0xB, 0x17, 0x13, 0xE, 0xD] world.random.shuffle(pool) else: pool = world.random.choices([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xB, 0xC, 0xD, 0xE, 0x10, 0x13, 0x17], k=20) patch.write_bytes(0x381E0, pool[:13]) patch.write_byte(0x36318, pool[13]) # Game Start patch.write_byte(0x37181, pool[13]) # Game Over patch.write_byte(0x340AE, pool[14]) # RBM Select patch.write_byte(0x39005, pool[15]) # Robot Master Battle patch.write_byte(get_equipped_sound_ptr + 1, pool[16]) # Get Equipped, we actually hook this already lmao patch.write_byte(0x3775A, pool[17]) # Epilogue patch.write_byte(0x36089, pool[18]) # Intro patch.write_byte(0x361F1, pool[19]) # Title from Utils import __version__ patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', 'utf8')[:21] patch.name.extend([0] * (21 - len(patch.name))) patch.write_bytes(0x3FFC0, patch.name) deathlink_byte = world.options.death_link.value | (world.options.energy_link.value << 1) patch.write_byte(0x3FFD5, deathlink_byte) patch.write_bytes(0x3FFD8, world.world_version) version_map = { "0": 0x90, "1": 0x91, "2": 0x92, "3": 0x93, "4": 0x94, "5": 0x95, "6": 0x96, "7": 0x97, "8": 0x98, "9": 0x99, ".": 0xDC } patch.write_token(APTokenTypes.RLE, 0x36EE0, (11, 0)) patch.write_token(APTokenTypes.RLE, 0x36EEE, (25, 0)) # BY SILVRIS patch.write_bytes(0x36EE0, [0xC2, 0xD9, 0xC0, 0xD3, 0xC9, 0xCC, 0xD6, 0xD2, 0xC9, 0xD3]) # ARCHIPELAGO x.x.x patch.write_bytes(0x36EF2, [0xC1, 0xD2, 0xC3, 0xC8, 0xC9, 0xD0, 0xC5, 0xCC, 0xC1, 0xC7, 0xCF, 0xC0]) patch.write_bytes(0x36EFE, list(map(lambda c: version_map[c], __version__))) patch.write_file("token_patch.bin", patch.get_token_binary()) header = b"\x4E\x45\x53\x1A\x10\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00" def read_headerless_nes_rom(rom: bytes) -> bytes: if rom[:4] == b"NES\x1A": return rom[16:] else: return rom def get_base_rom_bytes(file_name: str = "") -> bytes: base_rom_bytes: Optional[bytes] = getattr(get_base_rom_bytes, "base_rom_bytes", None) if not base_rom_bytes: file_name = get_base_rom_path(file_name) base_rom_bytes = read_headerless_nes_rom(bytes(open(file_name, "rb").read())) basemd5 = hashlib.md5() basemd5.update(base_rom_bytes) if basemd5.hexdigest() == PROTEUSHASH: base_rom_bytes = extract_mm2(base_rom_bytes) basemd5 = hashlib.md5() basemd5.update(base_rom_bytes) if basemd5.hexdigest() not in {MM2LCHASH, MM2NESHASH, MM2VCHASH}: print(basemd5.hexdigest()) raise Exception("Supplied Base Rom does not match known MD5 for US, LC, or US VC release. " "Get the correct game and version, then dump it") headered_rom = bytearray(base_rom_bytes) headered_rom[0:0] = header setattr(get_base_rom_bytes, "base_rom_bytes", bytes(headered_rom)) return bytes(headered_rom) return base_rom_bytes def get_base_rom_path(file_name: str = "") -> str: options: settings.Settings = settings.get_settings() if not file_name: file_name = options["mm2_options"]["rom_file"] if not os.path.exists(file_name): file_name = Utils.user_path(file_name) return file_name PRG_OFFSET = 0x8ED70 PRG_SIZE = 0x40000 def extract_mm2(proteus: bytes) -> bytes: mm2 = bytearray(proteus[PRG_OFFSET:PRG_OFFSET + PRG_SIZE]) return bytes(mm2)