Mega Man 3: Implement new game (#5237)

This commit is contained in:
Silvris
2026-03-08 15:42:06 -05:00
committed by GitHub
parent 4bb6cac7c4
commit 5b99118dda
24 changed files with 4090 additions and 0 deletions

View File

@@ -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

View File

@@ -134,6 +134,9 @@
# Mega Man 2
/worlds/mm2/ @Silvris
# Mega Man 3
/worlds/mm3/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic

View File

@@ -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: "";

1
worlds/mm3/.apignore Normal file
View File

@@ -0,0 +1 @@
/src/*

275
worlds/mm3/__init__.py Normal file
View File

@@ -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]

View File

@@ -0,0 +1,6 @@
{
"game": "Mega Man 3",
"authors": ["Silvris"],
"world_version": "0.1.7",
"minimum_ap_version": "0.6.4"
}

783
worlds/mm3/client.py Normal file
View File

@@ -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]}])

331
worlds/mm3/color.py Normal file
View File

@@ -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]))

Binary file not shown.

View File

@@ -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 <amount> <type>` 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

View File

@@ -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.

80
worlds/mm3/items.py Normal file
View File

@@ -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}

312
worlds/mm3/locations.py Normal file
View File

@@ -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}

221
worlds/mm3/names.py Normal file
View File

@@ -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"

164
worlds/mm3/options.py Normal file
View File

@@ -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

374
worlds/mm3/rom.py Normal file
View File

@@ -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)

388
worlds/mm3/rules.py Normal file
View File

@@ -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))

View File

View File

@@ -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 <bank> == $3E
org <address>-$C000+($2000*<bank>)+!headersize ; org sets the position in the output file to write to (in norom, at least)
base <address> ; 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 <bank> == $3F
org <address>-$E000+($2000*<bank>)+!headersize ; org sets the position in the output file to write to (in norom, at least)
base <address> ; 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 <address> >= $A000
org <address>-$A000+($2000*<bank>)+!headersize
base <address>
else
org <address>-$8000+($2000*<bank>)+!headersize
base <address>
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 :(

View File

@@ -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)

View File

5
worlds/mm3/test/bases.py Normal file
View File

@@ -0,0 +1,5 @@
from test.bases import WorldTestBase
class MM3TestBase(WorldTestBase):
game = "Mega Man 3"

View File

@@ -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"
}

63
worlds/mm3/text.py Normal file
View File

@@ -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)