Files
Archipelago/worlds/mm3/rom.py
2026-03-08 21:42:06 +01:00

375 lines
14 KiB
Python

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)