Mega Man 2: Implement New Game (#3256)

* initial (broken) commit

* small work on init

* Update Items.py

* beginning work, some rom patches

* commit progress from bh branch

* deathlink, fix soft-reset kill, e-tank loss

* begin work on targeting new bhclient

* write font

* definitely didn't forget to add the other two hashes no

* update to modern options, begin colors

* fix 6th letter bug

* palette shuffle + logic rewrite

* fix a bunch of pointers

* fix color changes, deathlink, and add wily 5 req

* adjust weapon weakness generation

* Update Rules.py

* attempt wily 5 softlock fix

* add explicit test for rbm weaknesses

* fix difficulty and hard reset

* fix connect deathlink and off by one item color

* fix atomic fire again

* de-jank deathlink

* rewrite wily5 rule

* fix rare solo-gen fill issue, hopefully

* Update Client.py

* fix wily 5 requirements

* undo fill hook

* fix picopico-kun rules

* for real this time

* update minimum damage requirement

* begin move to procedure patch

* finish move to APPP, allow rando boobeam, color updates

* fix color bug, UT support?

* what do you mean I forgot the procedure

* fix UT?

* plando weakness and fixes

* sfx when item received, more time stopper edge cases

* Update test_weakness.py

* fix rules and color bug

* fix color bug, support reduced flashing

* major world overhaul

* Update Locations.py

* fix first found bugs

* mypy cleanup

* headerless roms

* Update Rom.py

* further cleanup

* work on energylink

* el fixes

* update to energylink 2.0 packet

* energylink balancing

* potentially break other clients, more balancing

* Update Items.py

* remove startup change from basepatch

we write that in patch, since we also need to clean the area before applying

* el balancing and feedback

* hopefully less test failures?

* implement world version check

* add weapon/health option

* Update Rom.py

* x/x2

* specials

* Update Color.py

* Update Options.py

* finally apply location groups

* bump minor version number instead

* fix duplicate stage sends

* validate wily 5, tests

* see if renaming fixes

* add shuffled weakness

* remove passwords

* refresh rbm select, fix wily 5 validation

* forgot we can't check 0

* oops I broke the basepatch (remove failing test later)

* fix solo gen fill error?

* fix webhost patch recognition

* fix imports, basepatch

* move to flexibility metric for boss validation

* special case boobeam trap

* block strobe on stage select init

* more energylink balancing

* bump world version

* wily HP inaccurate in validation

* fix validation edge case

* save last completed wily to data storage

* mypy and pep8 cleanup

* fix file browse validation

* fix test failure, add enemy weakness

* remove test seed

* update enemy damage

* inno setup

* Update en_Mega Man 2.md

* setup guide

* Update en_Mega Man 2.md

* finish plando weakness section

* starting rbm edge case

* remove * imports

* properly wrap later weakness additions in regen playthrough

* fix import

* forgot readme

* remove time stopper special casing

since we moved to proper wily 5 validation, this special casing is no longer important

* properly type added locations

* Update CODEOWNERS

* add animation reduction

* deprioritize Time Stopper in rush checks

* special case wily phase 1

* fix key error

* forgot the test

* music and general cleanup

* the great rename

* fix import

* thanks pycharm

* reorder palette shuffle

* account for alien on shuffled weakness

* apply suggestions

* fix seedbleed

* fix invalid buster passthrough

* fix weakness landing beneath required amount

* fix failsafe

* finish music

* fix Time Stopper on Flash/Alien

* asar pls

* Apply suggestions from code review

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* world helpers

* init cleanup

* apostrophes

* clearer wording

* mypy and cleanup

* options doc cleanup

* Update rom.py

* rules cleanup

* Update __init__.py

* Update __init__.py

* move to defaultdict

* cleanup world helpers

* Update __init__.py

* remove unnecessary line from fill hook

* forgot the other one

* apply code review

* remove collect

* Update rules.py

* forgot another

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
This commit is contained in:
Silvris 2024-08-19 21:59:29 -05:00 committed by GitHub
parent c4e7b6ca82
commit 0e6e359747
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 3788 additions and 0 deletions

View File

@ -74,6 +74,7 @@ Currently, the following games are supported:
* A Hat in Time
* Old School Runescape
* Kingdom Hearts 1
* Mega Man 2
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@ -106,6 +106,9 @@
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
# MegaMan Battle Network 3
/worlds/mmbn3/ @digiholic

View File

@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";

290
worlds/mm2/__init__.py Normal file
View File

@ -0,0 +1,290 @@
import hashlib
import logging
from copy import deepcopy
from typing import Dict, Any, TYPE_CHECKING, Optional, Sequence, Tuple, ClassVar, List
from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location
from worlds.AutoWorld import World, WebWorld
from .names import (dr_wily, heat_man_stage, air_man_stage, wood_man_stage, bubble_man_stage, quick_man_stage,
flash_man_stage, metal_man_stage, crash_man_stage)
from .items import (item_table, item_names, MM2Item, filler_item_weights, robot_master_weapon_table,
stage_access_table, item_item_table, lookup_item_to_id)
from .locations import (MM2Location, mm2_regions, MM2Region, energy_pickups, etank_1ups, lookup_location_to_id,
location_groups)
from .rom import patch_rom, MM2ProcedurePatch, MM2LCHASH, PROTEUSHASH, MM2VCHASH, MM2NESHASH
from .options import MM2Options, Consumables
from .client import MegaMan2Client
from .rules import set_rules, weapon_damage, robot_masters, weapons_to_name, minimum_weakness_requirement
import os
import threading
import base64
import settings
logger = logging.getLogger("Mega Man 2")
if TYPE_CHECKING:
from BaseClasses import CollectionState
class MM2Settings(settings.Group):
class RomFile(settings.UserFilePath):
"""File name of the MM2 EN rom"""
description = "Mega Man 2 ROM File"
copy_to: Optional[str] = "Mega Man 2 (USA).nes"
md5s = [MM2NESHASH, MM2VCHASH, MM2LCHASH, PROTEUSHASH]
def browse(self: settings.T,
filetypes: Optional[Sequence[Tuple[str, Sequence[str]]]] = None,
**kwargs: Any) -> Optional[settings.T]:
if not filetypes:
file_types = [("NES", [".nes"]), ("Program", [".exe"])] # LC1 is only a windows executable, no linux
return super().browse(file_types, **kwargs)
else:
return super().browse(filetypes, **kwargs)
@classmethod
def validate(cls, path: str) -> None:
"""Try to open and validate file against hashes"""
with open(path, "rb", buffering=0) as f:
try:
f.seek(0)
if f.read(4) == b"NES\x1A":
f.seek(16)
else:
f.seek(0)
cls._validate_stream_hashes(f)
base_rom_bytes = f.read()
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if basemd5.hexdigest() == PROTEUSHASH:
# we need special behavior here
cls.copy_to = None
except ValueError:
raise ValueError(f"File hash does not match for {path}")
rom_file: RomFile = RomFile(RomFile.copy_to)
class MM2WebWorld(WebWorld):
theme = "partyTime"
tutorials = [
Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Mega Man 2 randomizer connected to an Archipelago Multiworld.",
"English",
"setup_en.md",
"setup/en",
["Silvris"]
)
]
class MM2World(World):
"""
In the year 200X, following his prior defeat by Mega Man, the evil Dr. Wily has returned to take over the world with
his own group of Robot Masters. Mega Man once again sets out to defeat the eight Robot Masters and stop Dr. Wily.
"""
game = "Mega Man 2"
settings: ClassVar[MM2Settings]
options_dataclass = MM2Options
options: MM2Options
item_name_to_id = lookup_item_to_id
location_name_to_id = lookup_location_to_id
item_name_groups = item_names
location_name_groups = location_groups
web = MM2WebWorld()
rom_name: bytearray
world_version: Tuple[int, int, int] = (0, 3, 1)
wily_5_weapons: Dict[int, List[int]]
def __init__(self, world: MultiWorld, player: int):
self.rom_name = bytearray()
self.rom_name_available_event = threading.Event()
super().__init__(world, player)
self.weapon_damage = deepcopy(weapon_damage)
self.wily_5_weapons = {}
def create_regions(self) -> None:
menu = MM2Region("Menu", self.player, self.multiworld)
self.multiworld.regions.append(menu)
for region in mm2_regions:
stage = MM2Region(region, self.player, self.multiworld)
required_items = mm2_regions[region][0]
locations = mm2_regions[region][1]
prev_stage = mm2_regions[region][2]
if prev_stage is None:
menu.connect(stage, f"To {region}",
lambda state, items=required_items: state.has_all(items, self.player))
else:
old_stage = self.get_region(prev_stage)
old_stage.connect(stage, f"To {region}",
lambda state, items=required_items: state.has_all(items, self.player))
stage.add_locations(locations, MM2Location)
for location in stage.get_locations():
if location.address is None and location.name != dr_wily:
location.place_locked_item(MM2Item(location.name, ItemClassification.progression,
None, self.player))
if region in etank_1ups and self.options.consumables in (Consumables.option_1up_etank,
Consumables.option_all):
stage.add_locations(etank_1ups[region], MM2Location)
if region in energy_pickups and self.options.consumables in (Consumables.option_weapon_health,
Consumables.option_all):
stage.add_locations(energy_pickups[region], MM2Location)
self.multiworld.regions.append(stage)
def create_item(self, name: str) -> MM2Item:
item = item_table[name]
classification = ItemClassification.filler
if item.progression:
classification = ItemClassification.progression_skip_balancing \
if item.skip_balancing else ItemClassification.progression
if item.useful:
classification |= ItemClassification.useful
return MM2Item(name, classification, item.code, self.player)
def get_filler_item_name(self) -> str:
return self.random.choices(list(filler_item_weights.keys()),
weights=list(filler_item_weights.values()))[0]
def create_items(self) -> None:
itempool = []
# grab first robot master
robot_master = self.item_id_to_name[0x880101 + self.options.starting_robot_master.value]
self.multiworld.push_precollected(self.create_item(robot_master))
itempool.extend([self.create_item(name) for name in stage_access_table.keys()
if name != robot_master])
itempool.extend([self.create_item(name) for name in robot_master_weapon_table.keys()])
itempool.extend([self.create_item(name) for name in item_item_table.keys()])
total_checks = 24
if self.options.consumables in (Consumables.option_1up_etank,
Consumables.option_all):
total_checks += 20
if self.options.consumables in (Consumables.option_weapon_health,
Consumables.option_all):
total_checks += 27
remaining = total_checks - len(itempool)
itempool.extend([self.create_item(name)
for name in self.random.choices(list(filler_item_weights.keys()),
weights=list(filler_item_weights.values()),
k=remaining)])
self.multiworld.itempool += itempool
set_rules = set_rules
def generate_early(self) -> None:
if (not self.options.yoku_jumps
and self.options.starting_robot_master == "heat_man") or \
(not self.options.enable_lasers
and self.options.starting_robot_master == "quick_man"):
robot_master_pool = [1, 2, 3, 5, 6, 7, ]
if self.options.yoku_jumps:
robot_master_pool.append(0)
if self.options.enable_lasers:
robot_master_pool.append(4)
self.options.starting_robot_master.value = self.random.choice(robot_master_pool)
logger.warning(
f"Mega Man 2 ({self.player_name}): "
f"Incompatible starting Robot Master, changing to "
f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}")
def generate_basic(self) -> None:
goal_location = self.get_location(dr_wily)
goal_location.place_locked_item(MM2Item("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
def fill_hook(self,
progitempool: List["Item"],
usefulitempool: List["Item"],
filleritempool: List["Item"],
fill_locations: List["Location"]) -> None:
# on a solo gen, fill can try to force Wily into sphere 2, but for most generations this is impossible
# since MM2 can have a 2 item sphere 1, and 3 items are required for Wily
if self.multiworld.players > 1:
return # Don't need to change anything on a multi gen, fill should be able to solve it with a 4 sphere 1
rbm_to_item = {
0: heat_man_stage,
1: air_man_stage,
2: wood_man_stage,
3: bubble_man_stage,
4: quick_man_stage,
5: flash_man_stage,
6: metal_man_stage,
7: crash_man_stage
}
affected_rbm = [2, 3] # Wood and Bubble will always have this happen
possible_rbm = [1, 5] # Air and Flash are always valid targets, due to Item 2/3 receive
if self.options.consumables:
possible_rbm.append(6) # Metal has 3 consumables
possible_rbm.append(7) # Crash has 3 consumables
if self.options.enable_lasers:
possible_rbm.append(4) # Quick has a lot of consumables, but needs logical time stopper if not enabled
else:
affected_rbm.extend([6, 7]) # only two checks on non consumables
if self.options.yoku_jumps:
possible_rbm.append(0) # Heat has 3 locations always, but might need 2 items logically
if self.options.starting_robot_master.value in affected_rbm:
rbm_names = list(map(lambda s: rbm_to_item[s], possible_rbm))
valid_second = [item for item in progitempool
if item.name in rbm_names
and item.player == self.player]
placed_item = self.random.choice(valid_second)
rbm_defeated = (f"{robot_masters[self.options.starting_robot_master.value].replace(' Defeated', '')}"
f" - Defeated")
rbm_location = self.get_location(rbm_defeated)
rbm_location.place_locked_item(placed_item)
progitempool.remove(placed_item)
fill_locations.remove(rbm_location)
target_rbm = (placed_item.code & 0xF) - 1
if self.options.strict_weakness or (self.options.random_weakness
and not (self.weapon_damage[0][target_rbm] > 0)):
# we need to find a weakness for this boss
weaknesses = [weapon for weapon in range(1, 9)
if self.weapon_damage[weapon][target_rbm] >= minimum_weakness_requirement[weapon]]
weapons = list(map(lambda s: weapons_to_name[s], weaknesses))
valid_weapons = [item for item in progitempool
if item.name in weapons
and item.player == self.player]
placed_weapon = self.random.choice(valid_weapons)
weapon_name = next(name for name, idx in lookup_location_to_id.items()
if idx == 0x880101 + self.options.starting_robot_master.value)
weapon_location = self.get_location(weapon_name)
weapon_location.place_locked_item(placed_weapon)
progitempool.remove(placed_weapon)
fill_locations.remove(weapon_location)
def generate_output(self, output_directory: str) -> None:
try:
patch = MM2ProcedurePatch(player=self.player, player_name=self.player_name)
patch_rom(self, patch)
self.rom_name = patch.name
patch.write(os.path.join(output_directory,
f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}"))
except Exception:
raise
finally:
self.rom_name_available_event.set() # make sure threading continues and errors are collected
def fill_slot_data(self) -> Dict[str, Any]:
return {
"death_link": self.options.death_link.value,
"weapon_damage": self.weapon_damage,
"wily_5_weapons": self.wily_5_weapons,
}
def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]:
local_weapon = {int(key): value for key, value in slot_data["weapon_damage"].items()}
local_wily = {int(key): value for key, value in slot_data["wily_5_weapons"].items()}
return {"weapon_damage": local_weapon, "wily_5_weapons": local_wily}
def modify_multidata(self, multidata: Dict[str, Any]) -> None:
# wait for self.rom_name to be available.
self.rom_name_available_event.wait()
rom_name = getattr(self, "rom_name", None)
# we skip in case of error, so that the original error in the output thread is the one that gets raised
if rom_name:
new_name = base64.b64encode(bytes(self.rom_name)).decode()
multidata["connect_names"][new_name] = multidata["connect_names"][self.player_name]

562
worlds/mm2/client.py Normal file
View File

@ -0,0 +1,562 @@
import logging
import time
from enum import IntEnum
from base64 import b64encode
from typing import TYPE_CHECKING, Dict, Tuple, List, Optional, Any
from NetUtils import ClientStatus, color, NetworkItem
from worlds._bizhawk.client import BizHawkClient
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext, BizHawkClientCommandProcessor
nes_logger = logging.getLogger("NES")
logger = logging.getLogger("Client")
MM2_ROBOT_MASTERS_UNLOCKED = 0x8A
MM2_ROBOT_MASTERS_DEFEATED = 0x8B
MM2_ITEMS_ACQUIRED = 0x8C
MM2_LAST_WILY = 0x8D
MM2_RECEIVED_ITEMS = 0x8E
MM2_DEATHLINK = 0x8F
MM2_ENERGYLINK = 0x90
MM2_RBM_STROBE = 0x91
MM2_WEAPONS_UNLOCKED = 0x9A
MM2_ITEMS_UNLOCKED = 0x9B
MM2_WEAPON_ENERGY = 0x9C
MM2_E_TANKS = 0xA7
MM2_LIVES = 0xA8
MM2_DIFFICULTY = 0xCB
MM2_HEALTH = 0x6C0
MM2_COMPLETED_STAGES = 0x770
MM2_CONSUMABLES = 0x780
MM2_SFX_QUEUE = 0x580
MM2_SFX_STROBE = 0x66
MM2_CONSUMABLE_TABLE: Dict[int, Tuple[int, int]] = {
# Item: (byte offset, bit mask)
0x880201: (0, 8),
0x880202: (16, 1),
0x880203: (16, 2),
0x880204: (16, 4),
0x880205: (16, 8),
0x880206: (16, 16),
0x880207: (16, 32),
0x880208: (16, 64),
0x880209: (16, 128),
0x88020A: (20, 1),
0x88020B: (20, 4),
0x88020C: (20, 64),
0x88020D: (21, 1),
0x88020E: (21, 2),
0x88020F: (21, 4),
0x880210: (24, 1),
0x880211: (24, 2),
0x880212: (24, 4),
0x880213: (28, 1),
0x880214: (28, 2),
0x880215: (28, 4),
0x880216: (33, 4),
0x880217: (33, 8),
0x880218: (37, 8),
0x880219: (37, 16),
0x88021A: (38, 1),
0x88021B: (38, 2),
0x880227: (38, 4),
0x880228: (38, 32),
0x880229: (38, 128),
0x88022A: (39, 4),
0x88022B: (39, 2),
0x88022C: (39, 1),
0x88022D: (38, 64),
0x88022E: (38, 16),
0x88022F: (38, 8),
0x88021C: (39, 32),
0x88021D: (39, 64),
0x88021E: (39, 128),
0x88021F: (41, 16),
0x880220: (42, 2),
0x880221: (42, 4),
0x880222: (42, 8),
0x880223: (46, 1),
0x880224: (46, 2),
0x880225: (46, 4),
0x880226: (46, 8),
}
class MM2EnergyLinkType(IntEnum):
Life = 0
AtomicFire = 1
AirShooter = 2
LeafShield = 3
BubbleLead = 4
QuickBoomerang = 5
TimeStopper = 6
MetalBlade = 7
CrashBomber = 8
Item1 = 9
Item2 = 10
Item3 = 11
OneUP = 12
request_to_name: Dict[str, str] = {
"HP": "health",
"AF": "Atomic Fire energy",
"AS": "Air Shooter energy",
"LS": "Leaf Shield energy",
"BL": "Bubble Lead energy",
"QB": "Quick Boomerang energy",
"TS": "Time Stopper energy",
"MB": "Metal Blade energy",
"CB": "Crash Bomber energy",
"I1": "Item 1 energy",
"I2": "Item 2 energy",
"I3": "Item 3 energy",
"1U": "lives"
}
HP_EXCHANGE_RATE = 500000000
WEAPON_EXCHANGE_RATE = 250000000
ONEUP_EXCHANGE_RATE = 14000000000
def cmd_pool(self: "BizHawkClientCommandProcessor") -> None:
"""Check the current pool of EnergyLink, and requestable refills from it."""
if self.ctx.game != "Mega Man 2":
logger.warning("This command can only be used when playing Mega Man 2.")
return
if not self.ctx.server or not self.ctx.slot:
logger.warning("You must be connected to a server to use this command.")
return
energylink = self.ctx.stored_data.get(f"EnergyLink{self.ctx.team}", 0)
health_points = energylink // HP_EXCHANGE_RATE
weapon_points = energylink // WEAPON_EXCHANGE_RATE
lives = energylink // ONEUP_EXCHANGE_RATE
logger.info(f"Healing available: {health_points}\n"
f"Weapon refill available: {weapon_points}\n"
f"Lives available: {lives}")
def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None:
from worlds._bizhawk.context import BizHawkClientContext
"""Request a refill from EnergyLink."""
if self.ctx.game != "Mega Man 2":
logger.warning("This command can only be used when playing Mega Man 2.")
return
if not self.ctx.server or not self.ctx.slot:
logger.warning("You must be connected to a server to use this command.")
return
valid_targets: Dict[str, MM2EnergyLinkType] = {
"HP": MM2EnergyLinkType.Life,
"AF": MM2EnergyLinkType.AtomicFire,
"AS": MM2EnergyLinkType.AirShooter,
"LS": MM2EnergyLinkType.LeafShield,
"BL": MM2EnergyLinkType.BubbleLead,
"QB": MM2EnergyLinkType.QuickBoomerang,
"TS": MM2EnergyLinkType.TimeStopper,
"MB": MM2EnergyLinkType.MetalBlade,
"CB": MM2EnergyLinkType.CrashBomber,
"I1": MM2EnergyLinkType.Item1,
"I2": MM2EnergyLinkType.Item2,
"I3": MM2EnergyLinkType.Item3,
"1U": MM2EnergyLinkType.OneUP
}
if target.upper() not in valid_targets:
logger.warning(f"Unrecognized target {target.upper()}. Available targets: {', '.join(valid_targets.keys())}")
return
ctx = self.ctx
assert isinstance(ctx, BizHawkClientContext)
client = ctx.client_handler
assert isinstance(client, MegaMan2Client)
client.refill_queue.append((valid_targets[target.upper()], int(amount)))
logger.info(f"Restoring {amount} {request_to_name[target.upper()]}.")
def cmd_autoheal(self) -> None:
"""Enable auto heal from EnergyLink."""
if self.ctx.game != "Mega Man 2":
logger.warning("This command can only be used when playing Mega Man 2.")
return
if not self.ctx.server or not self.ctx.slot:
logger.warning("You must be connected to a server to use this command.")
return
else:
assert isinstance(self.ctx.client_handler, MegaMan2Client)
if self.ctx.client_handler.auto_heal:
self.ctx.client_handler.auto_heal = False
logger.info(f"Auto healing disabled.")
else:
self.ctx.client_handler.auto_heal = True
logger.info(f"Auto healing enabled.")
def get_sfx_writes(sfx: int) -> Tuple[Tuple[int, bytes, str], ...]:
return (MM2_SFX_QUEUE, sfx.to_bytes(1, 'little'), "RAM"), (MM2_SFX_STROBE, 0x01.to_bytes(1, "little"), "RAM")
class MegaMan2Client(BizHawkClient):
game = "Mega Man 2"
system = "NES"
patch_suffix = ".apmm2"
item_queue: List[NetworkItem] = []
pending_death_link: bool = False
# default to true, as we don't want to send a deathlink until Mega Man's HP is initialized once
sending_death_link: bool = True
death_link: bool = False
energy_link: bool = False
rom: Optional[bytes] = None
weapon_energy: int = 0
health_energy: int = 0
auto_heal: bool = False
refill_queue: List[Tuple[MM2EnergyLinkType, int]] = []
last_wily: Optional[int] = None # default to wily 1
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
from worlds._bizhawk import RequestFailedError, read
from . import MM2World
try:
game_name, version = (await read(ctx.bizhawk_ctx, [(0x3FFB0, 21, "PRG ROM"),
(0x3FFC8, 3, "PRG ROM")]))
if game_name[:3] != b"MM2" or version != bytes(MM2World.world_version):
if game_name[:3] == b"MM2":
# I think this is an easier check than the other?
older_version = "0.2.1" if version == b"\xFF\xFF\xFF" else f"{version[0]}.{version[1]}.{version[2]}"
logger.warning(f"This Mega Man 2 patch was generated for an different version of the apworld. "
f"Please use that version to connect instead.\n"
f"Patch version: ({older_version})\n"
f"Client version: ({'.'.join([str(i) for i in MM2World.world_version])})")
if "pool" in ctx.command_processor.commands:
ctx.command_processor.commands.pop("pool")
if "request" in ctx.command_processor.commands:
ctx.command_processor.commands.pop("request")
if "autoheal" in ctx.command_processor.commands:
ctx.command_processor.commands.pop("autoheal")
return False
except UnicodeDecodeError:
return False
except RequestFailedError:
return False # Should verify on the next pass
ctx.game = self.game
self.rom = game_name
ctx.items_handling = 0b111
ctx.want_slot_data = False
deathlink = (await read(ctx.bizhawk_ctx, [(0x3FFC5, 1, "PRG ROM")]))[0][0]
if deathlink & 0x01:
self.death_link = True
if deathlink & 0x02:
self.energy_link = True
if self.energy_link:
if "pool" not in ctx.command_processor.commands:
ctx.command_processor.commands["pool"] = cmd_pool
if "request" not in ctx.command_processor.commands:
ctx.command_processor.commands["request"] = cmd_request
if "autoheal" not in ctx.command_processor.commands:
ctx.command_processor.commands["autoheal"] = cmd_autoheal
return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
if self.rom:
ctx.auth = b64encode(self.rom).decode()
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: Dict[str, Any]) -> None:
if cmd == "Bounced":
if "tags" in args:
assert ctx.slot is not None
if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name:
self.on_deathlink(ctx)
elif cmd == "Retrieved":
if f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}" in args["keys"]:
self.last_wily = args["keys"][f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"]
elif cmd == "Connected":
if self.energy_link:
ctx.set_notify(f"EnergyLink{ctx.team}")
if ctx.ui:
ctx.ui.enable_energy_link()
async def send_deathlink(self, ctx: "BizHawkClientContext") -> None:
self.sending_death_link = True
ctx.last_death_link = time.time()
await ctx.send_death("Mega Man was defeated.")
def on_deathlink(self, ctx: "BizHawkClientContext") -> None:
ctx.last_death_link = time.time()
self.pending_death_link = True
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
from worlds._bizhawk import read, write
if ctx.server is None:
return
if ctx.slot is None:
return
# get our relevant bytes
robot_masters_unlocked, robot_masters_defeated, items_acquired, \
weapons_unlocked, items_unlocked, items_received, \
completed_stages, consumable_checks, \
e_tanks, lives, weapon_energy, health, difficulty, death_link_status, \
energy_link_packet, last_wily = await read(ctx.bizhawk_ctx, [
(MM2_ROBOT_MASTERS_UNLOCKED, 1, "RAM"),
(MM2_ROBOT_MASTERS_DEFEATED, 1, "RAM"),
(MM2_ITEMS_ACQUIRED, 1, "RAM"),
(MM2_WEAPONS_UNLOCKED, 1, "RAM"),
(MM2_ITEMS_UNLOCKED, 1, "RAM"),
(MM2_RECEIVED_ITEMS, 1, "RAM"),
(MM2_COMPLETED_STAGES, 0xE, "RAM"),
(MM2_CONSUMABLES, 52, "RAM"),
(MM2_E_TANKS, 1, "RAM"),
(MM2_LIVES, 1, "RAM"),
(MM2_WEAPON_ENERGY, 11, "RAM"),
(MM2_HEALTH, 1, "RAM"),
(MM2_DIFFICULTY, 1, "RAM"),
(MM2_DEATHLINK, 1, "RAM"),
(MM2_ENERGYLINK, 1, "RAM"),
(MM2_LAST_WILY, 1, "RAM"),
])
if difficulty[0] not in (0, 1):
return # Game is not initialized
if not ctx.finished_game and completed_stages[0xD] != 0:
# this sets on credits fade, no real better way to do this
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL
}])
writes = []
# deathlink
if self.death_link:
await ctx.update_death_link(self.death_link)
if self.pending_death_link:
writes.append((MM2_DEATHLINK, bytes([0x01]), "RAM"))
self.pending_death_link = False
self.sending_death_link = True
if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time():
if health[0] == 0x00 and not self.sending_death_link:
await self.send_deathlink(ctx)
elif health[0] != 0x00 and not death_link_status[0]:
self.sending_death_link = False
if self.last_wily != last_wily[0]:
if self.last_wily is None:
# revalidate last wily from data storage
await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [
{"operation": "default", "value": 8}
]}])
await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"]}])
elif last_wily[0] == 0:
writes.append((MM2_LAST_WILY, self.last_wily.to_bytes(1, "little"), "RAM"))
else:
# correct our setting
self.last_wily = last_wily[0]
await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [
{"operation": "replace", "value": self.last_wily}
]}])
# handle receiving items
recv_amount = items_received[0]
if recv_amount < len(ctx.items_received):
item = ctx.items_received[recv_amount]
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received)))
if item.item & 0x130 == 0:
# Robot Master Weapon
new_weapons = weapons_unlocked[0] | (1 << ((item.item & 0xF) - 1))
writes.append((MM2_WEAPONS_UNLOCKED, new_weapons.to_bytes(1, 'little'), "RAM"))
writes.extend(get_sfx_writes(0x21))
elif item.item & 0x30 == 0:
# Robot Master Stage Access
new_stages = robot_masters_unlocked[0] & ~(1 << ((item.item & 0xF) - 1))
writes.append((MM2_ROBOT_MASTERS_UNLOCKED, new_stages.to_bytes(1, 'little'), "RAM"))
writes.extend(get_sfx_writes(0x3a))
writes.append((MM2_RBM_STROBE, b"\x01", "RAM"))
elif item.item & 0x20 == 0:
# Items
new_items = items_unlocked[0] | (1 << ((item.item & 0xF) - 1))
writes.append((MM2_ITEMS_UNLOCKED, new_items.to_bytes(1, 'little'), "RAM"))
writes.extend(get_sfx_writes(0x21))
else:
# append to the queue, so we handle it later
self.item_queue.append(item)
recv_amount += 1
writes.append((MM2_RECEIVED_ITEMS, recv_amount.to_bytes(1, 'little'), "RAM"))
if energy_link_packet[0]:
pickup = energy_link_packet[0]
if pickup in (0x76, 0x77):
# Health pickups
if pickup == 0x77:
value = 2
else:
value = 10
exchange_rate = HP_EXCHANGE_RATE
elif pickup in (0x78, 0x79):
# Weapon Energy
if pickup == 0x79:
value = 2
else:
value = 10
exchange_rate = WEAPON_EXCHANGE_RATE
elif pickup == 0x7B:
# 1-Up
value = 1
exchange_rate = ONEUP_EXCHANGE_RATE
else:
# if we managed to pickup something else, we should just fall through
value = 0
exchange_rate = 0
contribution = (value * exchange_rate) >> 1
if contribution:
await ctx.send_msgs([{
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
[{"operation": "add", "value": contribution},
{"operation": "max", "value": 0}]}])
logger.info(f"Deposited {contribution / HP_EXCHANGE_RATE} health into the pool.")
writes.append((MM2_ENERGYLINK, 0x00.to_bytes(1, "little"), "RAM"))
if self.weapon_energy:
# Weapon Energy
# We parse the whole thing to spread it as thin as possible
current_energy = self.weapon_energy
weapon_energy = bytearray(weapon_energy)
for i, weapon in zip(range(len(weapon_energy)), weapon_energy):
if weapon < 0x1C:
missing = 0x1C - weapon
if missing > self.weapon_energy:
missing = self.weapon_energy
self.weapon_energy -= missing
weapon_energy[i] = weapon + missing
if not self.weapon_energy:
writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM"))
break
else:
if current_energy != self.weapon_energy:
writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM"))
if self.health_energy or self.auto_heal:
# Health Energy
# We save this if the player has not taken any damage
current_health = health[0]
if 0 < current_health < 0x1C:
health_diff = 0x1C - current_health
if self.health_energy:
if health_diff > self.health_energy:
health_diff = self.health_energy
self.health_energy -= health_diff
else:
pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0)
if health_diff * HP_EXCHANGE_RATE > pool:
health_diff = int(pool // HP_EXCHANGE_RATE)
await ctx.send_msgs([{
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
[{"operation": "add", "value": -health_diff * HP_EXCHANGE_RATE},
{"operation": "max", "value": 0}]}])
current_health += health_diff
writes.append((MM2_HEALTH, current_health.to_bytes(1, 'little'), "RAM"))
if self.refill_queue:
refill_type, refill_amount = self.refill_queue.pop()
if refill_type == MM2EnergyLinkType.Life:
exchange_rate = HP_EXCHANGE_RATE
elif refill_type == MM2EnergyLinkType.OneUP:
exchange_rate = ONEUP_EXCHANGE_RATE
else:
exchange_rate = WEAPON_EXCHANGE_RATE
pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0)
request = exchange_rate * refill_amount
if request > pool:
logger.warning(
f"Not enough energy to fulfill the request. Maximum request: {pool // exchange_rate}")
else:
await ctx.send_msgs([{
"cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations":
[{"operation": "add", "value": -request},
{"operation": "max", "value": 0}]}])
if refill_type == MM2EnergyLinkType.Life:
refill_ptr = MM2_HEALTH
elif refill_type == MM2EnergyLinkType.OneUP:
refill_ptr = MM2_LIVES
else:
refill_ptr = MM2_WEAPON_ENERGY - 1 + refill_type
current_value = (await read(ctx.bizhawk_ctx, [(refill_ptr, 1, "RAM")]))[0][0]
new_value = min(0x1C if refill_type != MM2EnergyLinkType.OneUP else 99, current_value + refill_amount)
writes.append((refill_ptr, new_value.to_bytes(1, "little"), "RAM"))
if len(self.item_queue):
item = self.item_queue.pop(0)
idx = item.item & 0xF
if idx == 0:
# 1-Up
current_lives = lives[0]
if current_lives > 99:
self.item_queue.append(item)
else:
current_lives += 1
writes.append((MM2_LIVES, current_lives.to_bytes(1, 'little'), "RAM"))
writes.extend(get_sfx_writes(0x42))
elif idx == 1:
self.weapon_energy += 0xE
writes.extend(get_sfx_writes(0x28))
elif idx == 2:
self.health_energy += 0xE
writes.extend(get_sfx_writes(0x28))
elif idx == 3:
# E-Tank
# visuals only allow 4, but we're gonna go up to 9 anyway? May change
current_tanks = e_tanks[0]
if current_tanks < 9:
current_tanks += 1
writes.append((MM2_E_TANKS, current_tanks.to_bytes(1, 'little'), "RAM"))
writes.extend(get_sfx_writes(0x42))
else:
self.item_queue.append(item)
await write(ctx.bizhawk_ctx, writes)
new_checks = []
# check for locations
for i in range(8):
flag = 1 << i
if robot_masters_defeated[0] & flag:
wep_id = 0x880101 + i
if wep_id not in ctx.checked_locations:
new_checks.append(wep_id)
for i in range(3):
flag = 1 << i
if items_acquired[0] & flag:
itm_id = 0x880111 + i
if itm_id not in ctx.checked_locations:
new_checks.append(itm_id)
for i in range(0xD):
rbm_id = 0x880001 + i
if completed_stages[i] != 0:
if rbm_id not in ctx.checked_locations:
new_checks.append(rbm_id)
for consumable in MM2_CONSUMABLE_TABLE:
if consumable not in ctx.checked_locations:
is_checked = consumable_checks[MM2_CONSUMABLE_TABLE[consumable][0]] \
& MM2_CONSUMABLE_TABLE[consumable][1]
if is_checked:
new_checks.append(consumable)
for new_check_id in new_checks:
ctx.locations_checked.add(new_check_id)
location = ctx.location_names.lookup_in_game(new_check_id)
nes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/'
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])

276
worlds/mm2/color.py Normal file
View File

@ -0,0 +1,276 @@
from typing import Dict, Tuple, List, TYPE_CHECKING, Union
from . import names
from zlib import crc32
import struct
import logging
if TYPE_CHECKING:
from . import MM2World
from .rom import MM2ProcedurePatch
HTML_TO_NES: Dict[str, int] = {
"SNOW": 0x20,
"LINEN": 0x36,
"SEASHELL": 0x36,
"AZURE": 0x3C,
"LAVENDER": 0x33,
"WHITE": 0x30,
"BLACK": 0x0F,
"GREY": 0x00,
"GRAY": 0x00,
"ROYALBLUE": 0x12,
"BLUE": 0x11,
"SKYBLUE": 0x21,
"LIGHTBLUE": 0x31,
"TURQUOISE": 0x2B,
"CYAN": 0x2C,
"AQUAMARINE": 0x3B,
"DARKGREEN": 0x0A,
"GREEN": 0x1A,
"YELLOW": 0x28,
"GOLD": 0x28,
"WHEAT": 0x37,
"TAN": 0x37,
"CHOCOLATE": 0x07,
"BROWN": 0x07,
"SALMON": 0x26,
"ORANGE": 0x27,
"CORAL": 0x36,
"TOMATO": 0x16,
"RED": 0x16,
"PINK": 0x25,
"MAROON": 0x06,
"MAGENTA": 0x24,
"FUSCHIA": 0x24,
"VIOLET": 0x24,
"PLUM": 0x33,
"PURPLE": 0x14,
"THISTLE": 0x34,
"DARKBLUE": 0x01,
"SILVER": 0x10,
"NAVY": 0x02,
"TEAL": 0x1C,
"OLIVE": 0x18,
"LIME": 0x2A,
"AQUA": 0x2C,
# can add more as needed
}
MM2_COLORS: Dict[str, Tuple[int, int]] = {
names.atomic_fire: (0x28, 0x15),
names.air_shooter: (0x20, 0x11),
names.leaf_shield: (0x20, 0x19),
names.bubble_lead: (0x20, 0x00),
names.time_stopper: (0x34, 0x25),
names.quick_boomerang: (0x34, 0x14),
names.metal_blade: (0x37, 0x18),
names.crash_bomber: (0x20, 0x26),
names.item_1: (0x20, 0x16),
names.item_2: (0x20, 0x16),
names.item_3: (0x20, 0x16),
names.heat_man_stage: (0x28, 0x15),
names.air_man_stage: (0x28, 0x11),
names.wood_man_stage: (0x36, 0x17),
names.bubble_man_stage: (0x30, 0x19),
names.quick_man_stage: (0x28, 0x15),
names.flash_man_stage: (0x30, 0x12),
names.metal_man_stage: (0x28, 0x15),
names.crash_man_stage: (0x30, 0x16)
}
MM2_KNOWN_COLORS: Dict[str, Tuple[int, int]] = {
**MM2_COLORS,
# Street Fighter, technically
"Hadouken": (0x3C, 0x11),
"Shoryuken": (0x38, 0x16),
# X Series
"Z-Saber": (0x20, 0x16),
# X1
"Homing Torpedo": (0x3D, 0x37),
"Chameleon Sting": (0x3B, 0x1A),
"Rolling Shield": (0x3A, 0x25),
"Fire Wave": (0x37, 0x26),
"Storm Tornado": (0x34, 0x14),
"Electric Spark": (0x3D, 0x28),
"Boomerang Cutter": (0x3B, 0x2D),
"Shotgun Ice": (0x28, 0x2C),
# X2
"Crystal Hunter": (0x33, 0x21),
"Bubble Splash": (0x35, 0x28),
"Spin Wheel": (0x34, 0x1B),
"Silk Shot": (0x3B, 0x27),
"Sonic Slicer": (0x27, 0x01),
"Strike Chain": (0x30, 0x23),
"Magnet Mine": (0x28, 0x2D),
"Speed Burner": (0x31, 0x16),
# X3
"Acid Burst": (0x28, 0x2A),
"Tornado Fang": (0x28, 0x2C),
"Triad Thunder": (0x2B, 0x23),
"Spinning Blade": (0x20, 0x16),
"Ray Splasher": (0x28, 0x17),
"Gravity Well": (0x38, 0x14),
"Parasitic Bomb": (0x31, 0x28),
"Frost Shield": (0x23, 0x2C),
}
palette_pointers: Dict[str, List[int]] = {
"Mega Buster": [0x3D314],
"Atomic Fire": [0x3D318],
"Air Shooter": [0x3D31C],
"Leaf Shield": [0x3D320],
"Bubble Lead": [0x3D324],
"Quick Boomerang": [0x3D328],
"Time Stopper": [0x3D32C],
"Metal Blade": [0x3D330],
"Crash Bomber": [0x3D334],
"Item 1": [0x3D338],
"Item 2": [0x3D33C],
"Item 3": [0x3D340],
"Heat Man": [0x34B6, 0x344F7],
"Air Man": [0x74B6, 0x344FF],
"Wood Man": [0xB4EC, 0x34507],
"Bubble Man": [0xF4B6, 0x3450F],
"Quick Man": [0x134C8, 0x34517],
"Flash Man": [0x174B6, 0x3451F],
"Metal Man": [0x1B4A4, 0x34527],
"Crash Man": [0x1F4EC, 0x3452F],
}
def add_color_to_mm2(name: str, color: Tuple[int, int]) -> None:
"""
Add a color combo for Mega Man 2 to recognize as the color to display for a given item.
For information on available colors: https://www.nesdev.org/wiki/PPU_palettes#2C02
"""
MM2_KNOWN_COLORS[name] = validate_colors(*color)
def extrapolate_color(color: int) -> Tuple[int, int]:
if color > 0x1F:
color_1 = color
color_2 = color_1 - 0x10
else:
color_2 = color
color_1 = color_2 + 0x10
return color_1, color_2
def validate_colors(color_1: int, color_2: int, allow_match: bool = False) -> Tuple[int, int]:
# Black should be reserved for outlines, a gray should suffice
if color_1 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]:
color_1 = 0x10
if color_2 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]:
color_2 = 0x10
# one final check, make sure we don't have two matching
if not allow_match and color_1 == color_2:
color_1 = 0x30 # color 1 to white works with about any paired color
return color_1, color_2
def get_colors_for_item(name: str) -> Tuple[int, int]:
if name in MM2_KNOWN_COLORS:
return MM2_KNOWN_COLORS[name]
check_colors = {color: color in name.upper().replace(" ", "") for color in HTML_TO_NES}
colors = [color for color in check_colors if check_colors[color]]
if colors:
# we have at least one color pattern matched
if len(colors) > 1:
# we have at least 2
color_1 = HTML_TO_NES[colors[0]]
color_2 = HTML_TO_NES[colors[1]]
else:
color_1, color_2 = extrapolate_color(HTML_TO_NES[colors[0]])
else:
# generate hash
crc_hash = crc32(name.encode("utf-8"))
hash_color = struct.pack("I", crc_hash)
color_1 = hash_color[0] % 0x3F
color_2 = hash_color[1] % 0x3F
if color_1 < color_2:
temp = color_1
color_1 = color_2
color_2 = temp
color_1, color_2 = validate_colors(color_1, color_2)
return color_1, color_2
def parse_color(colors: List[str]) -> Tuple[int, int]:
color_a = colors[0]
if color_a.startswith("$"):
color_1 = int(color_a[1:], 16)
else:
# assume it's in our list of colors
color_1 = HTML_TO_NES[color_a.upper()]
if len(colors) == 1:
color_1, color_2 = extrapolate_color(color_1)
else:
color_b = colors[1]
if color_b.startswith("$"):
color_2 = int(color_b[1:], 16)
else:
color_2 = HTML_TO_NES[color_b.upper()]
return color_1, color_2
def write_palette_shuffle(world: "MM2World", rom: "MM2ProcedurePatch") -> None:
palette_shuffle: Union[int, str] = world.options.palette_shuffle.value
palettes_to_write: Dict[str, Tuple[int, int]] = {}
if isinstance(palette_shuffle, str):
color_sets = palette_shuffle.split(";")
if len(color_sets) == 1:
palette_shuffle = world.options.palette_shuffle.option_none
# singularity is more correct, but this is faster
else:
palette_shuffle = world.options.palette_shuffle.options[color_sets.pop()]
for color_set in color_sets:
if "-" in color_set:
character, color = color_set.split("-")
if character.title() not in palette_pointers:
logging.warning(f"Player {world.multiworld.get_player_name(world.player)} "
f"attempted to set color for unrecognized option {character}")
colors = color.split("|")
real_colors = validate_colors(*parse_color(colors), allow_match=True)
palettes_to_write[character.title()] = real_colors
else:
# If color is provided with no character, assume singularity
colors = color_set.split("|")
real_colors = validate_colors(*parse_color(colors), allow_match=True)
for character in palette_pointers:
palettes_to_write[character] = real_colors
# Now we handle the real values
if palette_shuffle == 1:
shuffled_colors = list(MM2_COLORS.values())
shuffled_colors.append((0x2C, 0x11)) # Mega Buster
world.random.shuffle(shuffled_colors)
for character in palette_pointers:
if character not in palettes_to_write:
palettes_to_write[character] = shuffled_colors.pop()
elif palette_shuffle > 1:
if palette_shuffle == 2:
for character in palette_pointers:
if character not in palettes_to_write:
real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F))
palettes_to_write[character] = real_colors
else:
# singularity
real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F))
for character in palette_pointers:
if character not in palettes_to_write:
palettes_to_write[character] = real_colors
for character in palettes_to_write:
for pointer in palette_pointers[character]:
rom.write_bytes(pointer, bytes(palettes_to_write[character]))
if character == "Atomic Fire":
# special case, we need to update Atomic Fire's flashing routine
rom.write_byte(0x3DE4A, palettes_to_write[character][1])
rom.write_byte(0x3DE4C, palettes_to_write[character][1])

Binary file not shown.

View File

@ -0,0 +1,114 @@
# Mega Man 2
## 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, and Items from Dr. Light are randomized
into the multiworld. Access to the Wily Stages is locked behind receiving Item 1, 2, and 3. The game is completed when
viewing the ending sequence after defeating the Alien.
## What Mega Man 2 items can appear in other players' worlds?
- Robot Master weapons
- Robot Master Access Codes (stage access)
- Items 1/2/3
- 1-Ups
- E-Tanks
- Health Energy (L)
- Weapon Energy (L)
## What is considered a location check in Mega Man 2?
- The defeat of a Robot Master or Wily Boss
- Receiving a weapon or 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 leftover amount is withheld until it can be applied.
## What is EnergyLink?
EnergyLink is an energy storage supported by certain games that is shared across all worlds in a multiworld. In Mega Man
2, 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 the `/pool` command 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
- Atomic Fire
- Air Shooter
- Leaf Shield
- Bubble Lead
- Quick Boomerang
- Time Stopper
- Metal Blade
- Crash Bomber
- Item 1
- Item 2
- Item 3
- Heat Man
- Air Man
- Wood Man
- Bubble Man
- Quick Man
- Flash Man
- Metal Man
- Crash Man
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:
Air Man:
Atomic Fire: 0
Bubble Lead: 4
```
This would cause Air Man to take 4 damage from Bubble Lead, and 0 from Atomic Fire.
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
- `AF` Atomic Fire
- `AS` Air Shooter
- `LS` Leaf Shield
- `BL` Bubble Lead
- `QB` Quick Boomerang
- `TS` Time Stopper
- `MB` Metal Blade
- `CB` Crash Bomber
- `I1` Item 1
- `I2` Item 2
- `I3` Item 3
- `1U` Lives

View File

@ -0,0 +1,53 @@
# Mega Man 2 Setup Guide
## Required Software
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- An English Mega Man 2 ROM. Alternatively, the [Mega Man Legacy Collection](https://store.steampowered.com/app/363440/Mega_Man_Legacy_Collection/) on Steam.
- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later
### Configuring Bizhawk
Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings:
- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from
`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.)
- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're
tabbed out of EmuHawk.
- Open a `.nes` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click
`Controllers…`, load any `.nes` ROM first.
- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to
clear it.
## Generating and Patching a Game
1. Create your options file (YAML). You can make one on the
[Mega Man 2 options page](../../../games/Mega%20Man%202/player-options).
2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game).
This will generate an output file for you. Your patch file will have the `.apmm2` file extension.
3. Open `ArchipelagoLauncher.exe`
4. Select "Open Patch" on the left side and select your patch file.
5. If this is your first time patching, you will be prompted to locate your vanilla ROM. If you are using the Legacy
Collection, provide `Proteus.exe` in place of your rom.
6. A patched `.nes` file will be created in the same place as the patch file.
7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your
BizHawk install.
## Connecting to a Server
By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just
in case you have to close and reopen a window mid-game for some reason.
1. Mega Man 2 uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game,
you can re-open it from the launcher.
2. Ensure EmuHawk is running the patched ROM.
3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing.
4. In the Lua Console window, go to `Script > Open Script…`.
5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`.
6. The emulator and client will eventually connect to each other. The BizHawk Client window should indicate that it
connected and recognized Mega Man 2.
7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the
top text field of the client and click Connect.
You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is
perfectly safe to make progress offline; everything will re-sync when you reconnect.

72
worlds/mm2/items.py Normal file
View File

@ -0,0 +1,72 @@
from BaseClasses import Item
from typing import NamedTuple, Dict
from . import names
class ItemData(NamedTuple):
code: int
progression: bool
useful: bool = False # primarily use this for incredibly useful items of their class, like Metal Blade
skip_balancing: bool = False
class MM2Item(Item):
game = "Mega Man 2"
robot_master_weapon_table = {
names.atomic_fire: ItemData(0x880001, True),
names.air_shooter: ItemData(0x880002, True),
names.leaf_shield: ItemData(0x880003, True),
names.bubble_lead: ItemData(0x880004, True),
names.quick_boomerang: ItemData(0x880005, True),
names.time_stopper: ItemData(0x880006, True, True),
names.metal_blade: ItemData(0x880007, True, True),
names.crash_bomber: ItemData(0x880008, True),
}
stage_access_table = {
names.heat_man_stage: ItemData(0x880101, True),
names.air_man_stage: ItemData(0x880102, True),
names.wood_man_stage: ItemData(0x880103, True),
names.bubble_man_stage: ItemData(0x880104, True),
names.quick_man_stage: ItemData(0x880105, True),
names.flash_man_stage: ItemData(0x880106, True),
names.metal_man_stage: ItemData(0x880107, True),
names.crash_man_stage: ItemData(0x880108, True),
}
item_item_table = {
names.item_1: ItemData(0x880011, True, True, True),
names.item_2: ItemData(0x880012, True, True, True),
names.item_3: ItemData(0x880013, True, True, True)
}
filler_item_table = {
names.one_up: ItemData(0x880020, False),
names.weapon_energy: ItemData(0x880021, False),
names.health_energy: ItemData(0x880022, False),
names.e_tank: ItemData(0x880023, False, True),
}
filler_item_weights = {
names.one_up: 1,
names.weapon_energy: 4,
names.health_energy: 1,
names.e_tank: 2,
}
item_table = {
**robot_master_weapon_table,
**stage_access_table,
**item_item_table,
**filler_item_table,
}
item_names = {
"Weapons": {name for name in robot_master_weapon_table.keys()},
"Stages": {name for name in stage_access_table.keys()},
"Items": {name for name in item_item_table.keys()}
}
lookup_item_to_id: Dict[str, int] = {item_name: data.code for item_name, data in item_table.items()}

239
worlds/mm2/locations.py Normal file
View File

@ -0,0 +1,239 @@
from BaseClasses import Location, Region
from typing import Dict, Tuple, Optional
from . import names
class MM2Location(Location):
game = "Mega Man 2"
class MM2Region(Region):
game = "Mega Man 2"
heat_man_locations: Dict[str, Optional[int]] = {
names.heat_man: 0x880001,
names.atomic_fire_get: 0x880101,
names.item_1_get: 0x880111,
}
air_man_locations: Dict[str, Optional[int]] = {
names.air_man: 0x880002,
names.air_shooter_get: 0x880102,
names.item_2_get: 0x880112
}
wood_man_locations: Dict[str, Optional[int]] = {
names.wood_man: 0x880003,
names.leaf_shield_get: 0x880103
}
bubble_man_locations: Dict[str, Optional[int]] = {
names.bubble_man: 0x880004,
names.bubble_lead_get: 0x880104
}
quick_man_locations: Dict[str, Optional[int]] = {
names.quick_man: 0x880005,
names.quick_boomerang_get: 0x880105,
}
flash_man_locations: Dict[str, Optional[int]] = {
names.flash_man: 0x880006,
names.time_stopper_get: 0x880106,
names.item_3_get: 0x880113,
}
metal_man_locations: Dict[str, Optional[int]] = {
names.metal_man: 0x880007,
names.metal_blade_get: 0x880107
}
crash_man_locations: Dict[str, Optional[int]] = {
names.crash_man: 0x880008,
names.crash_bomber_get: 0x880108
}
wily_1_locations: Dict[str, Optional[int]] = {
names.wily_1: 0x880009,
names.wily_stage_1: None
}
wily_2_locations: Dict[str, Optional[int]] = {
names.wily_2: 0x88000A,
names.wily_stage_2: None
}
wily_3_locations: Dict[str, Optional[int]] = {
names.wily_3: 0x88000B,
names.wily_stage_3: None
}
wily_4_locations: Dict[str, Optional[int]] = {
names.wily_4: 0x88000C,
names.wily_stage_4: None
}
wily_5_locations: Dict[str, Optional[int]] = {
names.wily_5: 0x88000D,
names.wily_stage_5: None
}
wily_6_locations: Dict[str, Optional[int]] = {
names.dr_wily: None
}
etank_1ups: Dict[str, Dict[str, Optional[int]]] = {
"Heat Man Stage": {
names.heat_man_c1: 0x880201,
},
"Quick Man Stage": {
names.quick_man_c1: 0x880202,
names.quick_man_c2: 0x880203,
names.quick_man_c3: 0x880204,
names.quick_man_c7: 0x880208,
},
"Flash Man Stage": {
names.flash_man_c2: 0x88020B,
names.flash_man_c6: 0x88020F,
},
"Metal Man Stage": {
names.metal_man_c1: 0x880210,
names.metal_man_c2: 0x880211,
names.metal_man_c3: 0x880212,
},
"Crash Man Stage": {
names.crash_man_c2: 0x880214,
names.crash_man_c3: 0x880215,
},
"Wily Stage 1": {
names.wily_1_c1: 0x880216,
},
"Wily Stage 2": {
names.wily_2_c3: 0x88021A,
names.wily_2_c4: 0x88021B,
names.wily_2_c5: 0x88021C,
names.wily_2_c6: 0x88021D,
},
"Wily Stage 3": {
names.wily_3_c2: 0x880220,
},
"Wily Stage 4": {
names.wily_4_c3: 0x880225,
names.wily_4_c4: 0x880226,
}
}
energy_pickups: Dict[str, Dict[str, Optional[int]]] = {
"Quick Man Stage": {
names.quick_man_c4: 0x880205,
names.quick_man_c5: 0x880206,
names.quick_man_c6: 0x880207,
names.quick_man_c8: 0x880209,
},
"Flash Man Stage": {
names.flash_man_c1: 0x88020A,
names.flash_man_c3: 0x88020C,
names.flash_man_c4: 0x88020D,
names.flash_man_c5: 0x88020E,
},
"Crash Man Stage": {
names.crash_man_c1: 0x880213,
},
"Wily Stage 1": {
names.wily_1_c2: 0x880217,
},
"Wily Stage 2": {
names.wily_2_c1: 0x880218,
names.wily_2_c2: 0x880219,
names.wily_2_c7: 0x88021E,
names.wily_2_c8: 0x880227,
names.wily_2_c9: 0x880228,
names.wily_2_c10: 0x880229,
names.wily_2_c11: 0x88022A,
names.wily_2_c12: 0x88022B,
names.wily_2_c13: 0x88022C,
names.wily_2_c14: 0x88022D,
names.wily_2_c15: 0x88022E,
names.wily_2_c16: 0x88022F,
},
"Wily Stage 3": {
names.wily_3_c1: 0x88021F,
names.wily_3_c3: 0x880221,
names.wily_3_c4: 0x880222,
},
"Wily Stage 4": {
names.wily_4_c1: 0x880223,
names.wily_4_c2: 0x880224,
}
}
mm2_regions: Dict[str, Tuple[Tuple[str, ...], Dict[str, Optional[int]], Optional[str]]] = {
"Heat Man Stage": ((names.heat_man_stage,), heat_man_locations, None),
"Air Man Stage": ((names.air_man_stage,), air_man_locations, None),
"Wood Man Stage": ((names.wood_man_stage,), wood_man_locations, None),
"Bubble Man Stage": ((names.bubble_man_stage,), bubble_man_locations, None),
"Quick Man Stage": ((names.quick_man_stage,), quick_man_locations, None),
"Flash Man Stage": ((names.flash_man_stage,), flash_man_locations, None),
"Metal Man Stage": ((names.metal_man_stage,), metal_man_locations, None),
"Crash Man Stage": ((names.crash_man_stage,), crash_man_locations, None),
"Wily Stage 1": ((names.item_1, names.item_2, names.item_3), wily_1_locations, None),
"Wily Stage 2": ((names.wily_stage_1,), wily_2_locations, "Wily Stage 1"),
"Wily Stage 3": ((names.wily_stage_2,), wily_3_locations, "Wily Stage 2"),
"Wily Stage 4": ((names.wily_stage_3,), wily_4_locations, "Wily Stage 3"),
"Wily Stage 5": ((names.wily_stage_4,), wily_5_locations, "Wily Stage 4"),
"Wily Stage 6": ((names.wily_stage_5,), wily_6_locations, "Wily Stage 5")
}
location_table: Dict[str, Optional[int]] = {
**heat_man_locations,
**air_man_locations,
**wood_man_locations,
**bubble_man_locations,
**quick_man_locations,
**flash_man_locations,
**metal_man_locations,
**crash_man_locations,
**wily_1_locations,
**wily_2_locations,
**wily_3_locations,
**wily_4_locations,
**wily_5_locations,
}
for table in etank_1ups:
location_table.update(etank_1ups[table])
for table in energy_pickups:
location_table.update(energy_pickups[table])
location_groups = {
"Get Equipped": {
names.atomic_fire_get,
names.air_shooter_get,
names.leaf_shield_get,
names.bubble_lead_get,
names.quick_boomerang_get,
names.time_stopper_get,
names.metal_blade_get,
names.crash_bomber_get,
names.item_1_get,
names.item_2_get,
names.item_3_get
},
"Heat Man Stage": {*heat_man_locations.keys(), *etank_1ups["Heat Man Stage"].keys()},
"Air Man Stage": {*air_man_locations.keys()},
"Wood Man Stage": {*wood_man_locations.keys()},
"Bubble Man Stage": {*bubble_man_locations.keys()},
"Quick Man Stage": {*quick_man_locations.keys(), *etank_1ups["Quick Man Stage"].keys(),
*energy_pickups["Quick Man Stage"].keys()},
"Flash Man Stage": {*flash_man_locations.keys(), *etank_1ups["Flash Man Stage"].keys(),
*energy_pickups["Flash Man Stage"].keys()},
"Metal Man Stage": {*metal_man_locations.keys(), *etank_1ups["Metal Man Stage"].keys()},
"Crash Man Stage": {*crash_man_locations.keys(), *etank_1ups["Crash Man Stage"].keys(),
*energy_pickups["Crash Man Stage"].keys()},
"Wily 2 Weapon Energy": {names.wily_2_c8, names.wily_2_c9, names.wily_2_c10, names.wily_2_c11, names.wily_2_c12,
names.wily_2_c13, names.wily_2_c14, names.wily_2_c15, names.wily_2_c16}
}
lookup_location_to_id: Dict[str, int] = {location: idx for location, idx in location_table.items() if idx is not None}

114
worlds/mm2/names.py Normal file
View File

@ -0,0 +1,114 @@
# Robot Master Weapons
crash_bomber = "Crash Bomber"
metal_blade = "Metal Blade"
quick_boomerang = "Quick Boomerang"
bubble_lead = "Bubble Lead"
atomic_fire = "Atomic Fire"
leaf_shield = "Leaf Shield"
time_stopper = "Time Stopper"
air_shooter = "Air Shooter"
# Stage Entry
crash_man_stage = "Crash Man Access Codes"
metal_man_stage = "Metal Man Access Codes"
quick_man_stage = "Quick Man Access Codes"
bubble_man_stage = "Bubble Man Access Codes"
heat_man_stage = "Heat Man Access Codes"
wood_man_stage = "Wood Man Access Codes"
flash_man_stage = "Flash Man Access Codes"
air_man_stage = "Air Man Access Codes"
# The Items
item_1 = "Item 1 - Propeller"
item_2 = "Item 2 - Rocket"
item_3 = "Item 3 - Bouncy"
# Misc. Items
one_up = "1-Up"
weapon_energy = "Weapon Energy (L)"
health_energy = "Health Energy (L)"
e_tank = "E-Tank"
# Locations
crash_man = "Crash Man - Defeated"
metal_man = "Metal Man - Defeated"
quick_man = "Quick Man - Defeated"
bubble_man = "Bubble Man - Defeated"
heat_man = "Heat Man - Defeated"
wood_man = "Wood Man - Defeated"
flash_man = "Flash Man - Defeated"
air_man = "Air Man - Defeated"
crash_bomber_get = "Crash Bomber - Received"
metal_blade_get = "Metal Blade - Received"
quick_boomerang_get = "Quick Boomerang - Received"
bubble_lead_get = "Bubble Lead - Received"
atomic_fire_get = "Atomic Fire - Received"
leaf_shield_get = "Leaf Shield - Received"
time_stopper_get = "Time Stopper - Received"
air_shooter_get = "Air Shooter - Received"
item_1_get = "Item 1 - Received"
item_2_get = "Item 2 - Received"
item_3_get = "Item 3 - Received"
wily_1 = "Mecha Dragon - Defeated"
wily_2 = "Picopico-kun - Defeated"
wily_3 = "Guts Tank - Defeated"
wily_4 = "Boobeam Trap - Defeated"
wily_5 = "Wily Machine 2 - Defeated"
dr_wily = "Dr. Wily (Alien) - Defeated"
# Wily Stage Event Items
wily_stage_1 = "Wily Stage 1 - Completed"
wily_stage_2 = "Wily Stage 2 - Completed"
wily_stage_3 = "Wily Stage 3 - Completed"
wily_stage_4 = "Wily Stage 4 - Completed"
wily_stage_5 = "Wily Stage 5 - Completed"
# Consumable Locations
heat_man_c1 = "Heat Man Stage - 1-Up" # 3, requires Yoku jumps or Item 2
flash_man_c1 = "Flash Man Stage - Health Energy 1" # 0
flash_man_c2 = "Flash Man Stage - 1-Up" # 2, requires any Item
flash_man_c3 = "Flash Man Stage - Health Energy 2" # 6, requires Crash Bomber
flash_man_c4 = "Flash Man Stage - Weapon Energy 1" # 8, requires Crash Bomber
flash_man_c5 = "Flash Man Stage - Health Energy 3" # 9
flash_man_c6 = "Flash Man Stage - E-Tank" # 10
quick_man_c1 = "Quick Man Stage - 1-Up 1" # 0, needs any Item
quick_man_c2 = "Quick Man Stage - E-Tank" # 1, requires allow lasers or Time Stopper
quick_man_c3 = "Quick Man Stage - 1-Up 2" # 2, requires allow lasers or Time Stopper
quick_man_c4 = "Quick Man Stage - Weapon Energy 1" # 3, requires allow lasers or Time Stopper
quick_man_c5 = "Quick Man Stage - Weapon Energy 2" # 4, requires allow lasers or Time Stopper
quick_man_c6 = "Quick Man Stage - Health Energy" # 5, requires allow lasers or Time Stopper
quick_man_c7 = "Quick Man Stage - 1-Up 3" # 6, requires allow lasers or Time Stopper
quick_man_c8 = "Quick Man Stage - Weapon Energy 3" # 7, requires allow lasers or Time Stopper
metal_man_c1 = "Metal Man Stage - E-Tank 1" # 0
metal_man_c2 = "Metal Man Stage - 1-Up" # 1, needs Item 1/2
metal_man_c3 = "Metal Man Stage - E-Tank 2" # 2, needs Item 1/2 (without putting dying in logic at least)
crash_man_c1 = "Crash Man Stage - Health Energy" # 0
crash_man_c2 = "Crash Man Stage - E-Tank" # 1
crash_man_c3 = "Crash Man Stage - 1-Up" # 2, any Item
wily_1_c1 = "Wily Stage 1 - 1-Up" # 10
wily_1_c2 = "Wily Stage 1 - Weapon Energy 1" # 11
wily_2_c1 = "Wily Stage 2 - Weapon Energy 1" # 11
wily_2_c2 = "Wily Stage 2 - Weapon Energy 2" # 12
wily_2_c3 = "Wily Stage 2 - E-Tank 1" # 16
wily_2_c4 = "Wily Stage 2 - 1-Up 1" # 17
# 18 - 27 are all small weapon energies, might force these local junk?
wily_2_c8 = "Wily Stage 2 - Weapon Energy 3" # 18
wily_2_c9 = "Wily Stage 2 - Weapon Energy 4" # 19
wily_2_c10 = "Wily Stage 2 - Weapon Energy 5" # 20
wily_2_c11 = "Wily Stage 2 - Weapon Energy 6" # 21
wily_2_c12 = "Wily Stage 2 - Weapon Energy 7" # 22
wily_2_c13 = "Wily Stage 2 - Weapon Energy 8" # 23
wily_2_c14 = "Wily Stage 2 - Weapon Energy 9" # 24
wily_2_c15 = "Wily Stage 2 - Weapon Energy 10" # 25
wily_2_c16 = "Wily Stage 2 - Weapon Energy 11" # 26
wily_2_c5 = "Wily Stage 2 - 1-Up 2" # 29, requires Crash Bomber
wily_2_c6 = "Wily Stage 2 - E-Tank 2" # 30, requires Crash Bomber
wily_2_c7 = "Wily Stage 2 - Health Energy" # 31, item 2 (already required to reach wily 2)
wily_3_c1 = "Wily Stage 3 - Weapon Energy 1" # 12, requires Crash Bomber
wily_3_c2 = "Wily Stage 3 - E-Tank" # 17, requires Crash Bomber
wily_3_c3 = "Wily Stage 3 - Weapon Energy 2" # 18
wily_3_c4 = "Wily Stage 3 - Weapon Energy 3" # 19
wily_4_c1 = "Wily Stage 4 - Weapon Energy 1" # 16
wily_4_c2 = "Wily Stage 4 - Weapon Energy 2" # 17
wily_4_c3 = "Wily Stage 4 - 1-Up 1" # 18
wily_4_c4 = "Wily Stage 4 - E-Tank 1" # 19

229
worlds/mm2/options.py Normal file
View File

@ -0,0 +1,229 @@
from dataclasses import dataclass
from Options import Choice, Toggle, DeathLink, DefaultOnToggle, TextChoice, Range, OptionDict, PerGameCommonOptions
from schema import Schema, And, Use, Optional
bosses = {
"Heat Man": 0,
"Air Man": 1,
"Wood Man": 2,
"Bubble Man": 3,
"Quick Man": 4,
"Flash Man": 5,
"Metal Man": 6,
"Crash Man": 7,
"Mecha Dragon": 8,
"Picopico-kun": 9,
"Guts Tank": 10,
"Boobeam Trap": 11,
"Wily Machine 2": 12,
"Alien": 13
}
weapons_to_id = {
"Mega Buster": 0,
"Atomic Fire": 1,
"Air Shooter": 2,
"Leaf Shield": 3,
"Bubble Lead": 4,
"Quick Boomerang": 5,
"Metal Blade": 7,
"Crash Bomber": 6,
"Time Stopper": 8,
}
class EnergyLink(Toggle):
"""
Enables EnergyLink support.
When enabled, pickups dropped from enemies are sent to the EnergyLink pool, and healing/weapon energy/1-Ups can
be requested from the EnergyLink pool.
Some of the energy sent to the pool will be lost on transfer.
"""
display_name = "EnergyLink"
class StartingRobotMaster(Choice):
"""
The initial stage unlocked at the start.
"""
display_name = "Starting Robot Master"
option_heat_man = 0
option_air_man = 1
option_wood_man = 2
option_bubble_man = 3
option_quick_man = 4
option_flash_man = 5
option_metal_man = 6
option_crash_man = 7
default = "random"
class YokuJumps(Toggle):
"""
When enabled, the player is expected to be able to perform the yoku block sequence in Heat Man's
stage without Item 2.
"""
display_name = "Yoku Block Jumps"
class EnableLasers(Toggle):
"""
When enabled, the player is expected to complete (and acquire items within) the laser sections of Quick Man's
stage without the Time Stopper.
"""
display_name = "Enable Lasers"
class Consumables(Choice):
"""
When enabled, e-tanks/1-ups/health/weapon energy will be added to the pool of items and included as checks.
E-Tanks and 1-Ups add 20 checks to the pool.
Weapon/Health Energy add 27 checks to the pool.
"""
display_name = "Consumables"
option_none = 0
option_1up_etank = 1
option_weapon_health = 2
option_all = 3
default = 1
alias_true = 3
alias_false = 0
@classmethod
def get_option_name(cls, value: int) -> str:
if value == 1:
return "1-Ups/E-Tanks"
if value == 2:
return "Weapon/Health Energy"
return super().get_option_name(value)
class Quickswap(DefaultOnToggle):
"""
When enabled, the player can quickswap through all received weapons by pressing Select.
"""
display_name = "Quickswap"
class PaletteShuffle(TextChoice):
"""
Change the color of Mega Man and the Robot Masters.
None: The palettes are unchanged.
Shuffled: Palette colors are shuffled amongst the robot masters.
Randomized: Random (usually good) palettes are generated for each robot master.
Singularity: one palette is generated and used for all robot masters.
Supports custom palettes using HTML named colors in the
following format: Mega Buster-Lavender|Violet;randomized
The first value is the character whose palette you'd like to define, then separated by - is a set of 2 colors for
that character. separate every color with a pipe, and separate every character as well as the remaining shuffle with
a semicolon.
"""
display_name = "Palette Shuffle"
option_none = 0
option_shuffled = 1
option_randomized = 2
option_singularity = 3
class EnemyWeaknesses(Toggle):
"""
Randomizes the damage dealt to enemies by weapons. Friender will always take damage from the buster.
"""
display_name = "Random Enemy Weaknesses"
class StrictWeaknesses(Toggle):
"""
Only your starting Robot Master will take damage from the Mega Buster, the rest must be defeated with weapons.
Weapons that only do 1-3 damage to bosses no longer deal damage (aside from Alien).
"""
display_name = "Strict Boss Weaknesses"
class RandomWeaknesses(Choice):
"""
None: Bosses will have their regular weaknesses.
Shuffled: Weapon damage will be shuffled amongst the weapons, so Metal Blade may do Bubble Lead damage.
Time Stopper will deplete half of a random Robot Master's HP.
Randomized: Weapon damage will be fully randomized.
"""
display_name = "Random Boss Weaknesses"
option_none = 0
option_shuffled = 1
option_randomized = 2
alias_false = 0
alias_true = 2
class Wily5Requirement(Range):
"""Change the number of Robot Masters that are required to be defeated for
the teleporter to the Wily Machine to appear."""
display_name = "Wily 5 Requirement"
default = 8
range_start = 1
range_end = 8
class WeaknessPlando(OptionDict):
"""
Specify specific damage numbers for boss damage. Can be used even without strict/random weaknesses.
plando_weakness:
Robot Master:
Weapon: Damage
"""
display_name = "Plando Weaknesses"
schema = Schema({
Optional(And(str, Use(str.title), lambda s: s in bosses)): {
And(str, Use(str.title), lambda s: s in weapons_to_id): And(int, lambda i: i in range(-1, 14))
}
})
default = {}
class ReduceFlashing(Choice):
"""
Reduce flashing seen in gameplay, such as the stage select and when defeating a Wily boss.
Virtual Console: increases length of most flashes, changes some flashes from white to a dark gray.
Minor: VC changes + decreasing the speed of Bubble/Metal Man stage animations.
Full: VC changes + further decreasing the brightness of most flashes and
disables stage animations for Metal/Bubble Man stages.
"""
display_name = "Reduce Flashing"
option_none = 0
option_virtual_console = 1
option_minor = 2
option_full = 3
default = 1
class RandomMusic(Choice):
"""
Vanilla: music is unchanged
Shuffled: stage and certain menu music is shuffled.
Randomized: stage and certain menu music is randomly selected
None: no music will play
"""
display_name = "Random Music"
option_vanilla = 0
option_shuffled = 1
option_randomized = 2
option_none = 3
@dataclass
class MM2Options(PerGameCommonOptions):
death_link: DeathLink
energy_link: EnergyLink
starting_robot_master: StartingRobotMaster
consumables: Consumables
yoku_jumps: YokuJumps
enable_lasers: EnableLasers
enemy_weakness: EnemyWeaknesses
strict_weakness: StrictWeaknesses
random_weakness: RandomWeaknesses
wily_5_requirement: Wily5Requirement
plando_weakness: WeaknessPlando
palette_shuffle: PaletteShuffle
quickswap: Quickswap
reduce_flashing: ReduceFlashing
random_music: RandomMusic

415
worlds/mm2/rom.py Normal file
View File

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

319
worlds/mm2/rules.py Normal file
View File

@ -0,0 +1,319 @@
from math import ceil
from typing import TYPE_CHECKING, Dict, List
from . import names
from .locations import heat_man_locations, air_man_locations, wood_man_locations, bubble_man_locations, \
quick_man_locations, flash_man_locations, metal_man_locations, crash_man_locations, wily_1_locations, \
wily_2_locations, wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations
from .options import bosses, weapons_to_id, Consumables, RandomWeaknesses
from worlds.generic.Rules import add_rule
if TYPE_CHECKING:
from . import MM2World
from BaseClasses import CollectionState
weapon_damage: Dict[int, List[int]] = {
0: [2, 2, 1, 1, 2, 2, 1, 1, 1, 7, 1, 0, 1, -1], # Mega Buster
1: [-1, 6, 0xE, 0, 0xA, 6, 4, 6, 8, 13, 8, 0, 0xE, -1], # Atomic Fire
2: [2, 0, 4, 0, 2, 0, 0, 0xA, 0, 0, 0, 0, 1, -1], # Air Shooter
3: [0, 8, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1], # Leaf Shield
4: [6, 0, 0, -1, 0, 2, 0, 1, 0, 14, 1, 0, 0, 1], # Bubble Lead
5: [2, 2, 0, 2, 0, 0, 4, 1, 1, 7, 2, 0, 1, -1], # Quick Boomerang
6: [-1, 0, 2, 2, 4, 3, 0, 0, 1, 0, 1, 0x14, 1, -1], # Crash Bomber
7: [1, 0, 2, 4, 0, 4, 0xE, 0, 0, 7, 0, 0, 1, -1], # Metal Blade
8: [0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], # Time Stopper
}
weapons_to_name: Dict[int, str] = {
1: names.atomic_fire,
2: names.air_shooter,
3: names.leaf_shield,
4: names.bubble_lead,
5: names.quick_boomerang,
6: names.crash_bomber,
7: names.metal_blade,
8: names.time_stopper
}
minimum_weakness_requirement: Dict[int, int] = {
0: 1, # Mega Buster is free
1: 14, # 2 shots of Atomic Fire
2: 1, # 14 shots of Air Shooter, although you likely hit more than one shot
3: 4, # 9 uses of Leaf Shield, 3 ends up 1 damage off
4: 1, # 56 uses of Bubble Lead
5: 1, # 224 uses of Quick Boomerang
6: 4, # 7 uses of Crash Bomber
7: 1, # 112 uses of Metal Blade
8: 4, # 1 use of Time Stopper, but setting to 4 means we shave the entire HP bar
}
robot_masters: Dict[int, str] = {
0: "Heat Man Defeated",
1: "Air Man Defeated",
2: "Wood Man Defeated",
3: "Bubble Man Defeated",
4: "Quick Man Defeated",
5: "Flash Man Defeated",
6: "Metal Man Defeated",
7: "Crash Man Defeated"
}
weapon_costs = {
0: 0,
1: 10,
2: 2,
3: 3,
4: 0.5,
5: 0.125,
6: 4,
7: 0.25,
8: 7,
}
def can_defeat_enough_rbms(state: "CollectionState", player: int,
required: int, boss_requirements: Dict[int, List[int]]):
can_defeat = 0
for boss, reqs in boss_requirements.items():
if boss in robot_masters:
if state.has_all(map(lambda x: weapons_to_name[x], reqs), player):
can_defeat += 1
if can_defeat >= required:
return True
return False
def set_rules(world: "MM2World") -> None:
# most rules are set on region, so we only worry about rules required within stage access
# or rules variable on settings
if (hasattr(world.multiworld, "re_gen_passthrough")
and "Mega Man 2" in getattr(world.multiworld, "re_gen_passthrough")):
slot_data = getattr(world.multiworld, "re_gen_passthrough")["Mega Man 2"]
world.weapon_damage = slot_data["weapon_damage"]
world.wily_5_weapons = slot_data["wily_5_weapons"]
else:
if world.options.random_weakness == RandomWeaknesses.option_shuffled:
weapon_tables = [table for weapon, table in weapon_damage.items() if weapon not in (0, 8)]
world.random.shuffle(weapon_tables)
for i in range(1, 8):
world.weapon_damage[i] = weapon_tables.pop()
# alien must take minimum required damage from his weakness
alien_weakness = next(weapon for weapon in range(8) if world.weapon_damage[weapon][13] != -1)
world.weapon_damage[alien_weakness][13] = minimum_weakness_requirement[alien_weakness]
world.weapon_damage[8] = [0 for _ in range(14)]
world.weapon_damage[8][world.random.choice(range(8))] = 2
elif world.options.random_weakness == RandomWeaknesses.option_randomized:
world.weapon_damage = {i: [] for i in range(9)}
for boss in range(13):
for weapon in world.weapon_damage:
world.weapon_damage[weapon].append(min(14, max(-1, int(world.random.normalvariate(3, 3)))))
if not any([world.weapon_damage[weapon][boss] >= max(4, minimum_weakness_requirement[weapon])
for weapon in range(1, 7)]):
# failsafe, there should be at least one defined non-Buster weakness
weapon = world.random.randint(1, 7)
world.weapon_damage[weapon][boss] = world.random.randint(
max(4, minimum_weakness_requirement[weapon]), 14) # Force weakness
# special case, if boobeam trap has a weakness to Crash, it needs to be max damage
if world.weapon_damage[6][11] > 4:
world.weapon_damage[6][11] = 14
# handle the alien
boss = 13
for weapon in world.weapon_damage:
world.weapon_damage[weapon].append(-1)
weapon = world.random.choice(list(world.weapon_damage.keys()))
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
if world.options.strict_weakness:
for weapon in weapon_damage:
for i in range(13):
if weapon == 0:
world.weapon_damage[weapon][i] = 0
elif i in (8, 12) and not world.options.random_weakness:
continue
# Mecha Dragon only has damage range of 0-1, so allow the 1
# Wily Machine needs all three weaknesses present, so allow
elif 4 > world.weapon_damage[weapon][i] > 0:
world.weapon_damage[weapon][i] = 0
# handle special cases
for boss in range(14):
for weapon in (1, 3, 6, 8):
if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and
not any(world.weapon_damage[i][boss] > 0 for i in range(1, 8) if i != weapon)):
# Weapon does not have enough possible ammo to kill the boss, raise the damage
if boss == 9:
if weapon != 3:
# Atomic Fire and Crash Bomber cannot be Picopico-kun's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
elif boss == 11:
if weapon == 1:
# Atomic Fire cannot be Boobeam Trap's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 6, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
else:
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
starting = world.options.starting_robot_master.value
world.weapon_damage[0][starting] = 1
for p_boss in world.options.plando_weakness:
for p_weapon in world.options.plando_weakness[p_boss]:
if world.options.plando_weakness[p_boss][p_weapon] < minimum_weakness_requirement[p_weapon] \
and not any(w != p_weapon
and world.weapon_damage[w][bosses[p_boss]] > minimum_weakness_requirement[w]
for w in world.weapon_damage):
# we need to replace this weakness
weakness = world.random.choice([key for key in world.weapon_damage if key != p_weapon])
world.weapon_damage[weakness][bosses[p_boss]] = minimum_weakness_requirement[weakness]
world.weapon_damage[weapons_to_id[p_weapon]][bosses[p_boss]] \
= world.options.plando_weakness[p_boss][p_weapon]
if world.weapon_damage[0][world.options.starting_robot_master.value] < 1:
world.weapon_damage[0][world.options.starting_robot_master.value] = weapon_damage[0][world.options.starting_robot_master.value]
# final special case
# There's a vanilla crash if Time Stopper kills Wily phase 1
# There's multiple fixes, but ensuring Wily cannot take Time Stopper damage is best
if world.weapon_damage[8][12] > 0:
world.weapon_damage[8][12] = 0
# weakness validation, it is better to confirm a completable seed than respect plando
boss_health = {boss: 0x1C if boss != 12 else 0x1C * 2 for boss in [*range(8), 12]}
weapon_energy = {key: float(0x1C) for key in weapon_costs}
weapon_boss = {boss: {weapon: world.weapon_damage[weapon][boss] for weapon in world.weapon_damage}
for boss in [*range(8), 12]}
flexibility = {
boss: (
sum(damage_value > 0 for damage_value in
weapon_damages.values()) # Amount of weapons that hit this boss
* sum(weapon_damages.values()) # Overall damage that those weapons do
)
for boss, weapon_damages in weapon_boss.items() if boss != 12
}
flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value
used_weapons = {i: set() for i in [*range(8), 12]}
for boss in [*flexibility, 12]:
boss_damage = weapon_boss[boss]
weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in
boss_damage.items() if weapon_energy[weapon] > 0}
if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight:
# We get exactly one use of Time Stopper during the rush
# So we want to make sure that use is absolutely needed
weapon_weight[8] = min(weapon_weight[8], 0.001)
while boss_health[boss] > 0:
if boss_damage[0] > 0:
boss_health[boss] = 0 # if we can buster, we should buster
continue
highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys()))
uses = weapon_energy[wp] // weapon_costs[wp]
used_weapons[boss].add(wp)
if int(uses * boss_damage[wp]) > boss_health[boss]:
used = ceil(boss_health[boss] / boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * used
boss_health[boss] = 0
elif highest <= 0:
# we are out of weapons that can actually damage the boss
# so find the weapon that has the most uses, and apply that as an additional weakness
# it should be impossible to be out of energy, simply because even if every boss took 1 from
# Quick Boomerang and no other, it would only be 28 off from defeating all 9, which Metal Blade should
# be able to cover
wp, max_uses = max((weapon, weapon_energy[weapon] // weapon_costs[weapon]) for weapon in weapon_weight
if weapon != 0)
world.weapon_damage[wp][boss] = minimum_weakness_requirement[wp]
used = min(int(weapon_energy[wp] // weapon_costs[wp]),
ceil(boss_health[boss] // minimum_weakness_requirement[wp]))
weapon_energy[wp] -= weapon_costs[wp] * used
boss_health[boss] -= int(used * minimum_weakness_requirement[wp])
weapon_weight.pop(wp)
else:
# drain the weapon and continue
boss_health[boss] -= int(uses * boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * uses
weapon_weight.pop(wp)
world.wily_5_weapons = {boss: sorted(used_weapons[boss]) for boss in used_weapons}
for i, boss_locations in enumerate([
heat_man_locations,
air_man_locations,
wood_man_locations,
bubble_man_locations,
quick_man_locations,
flash_man_locations,
metal_man_locations,
crash_man_locations,
wily_1_locations,
wily_2_locations,
wily_3_locations,
wily_4_locations,
wily_5_locations,
wily_6_locations
]):
if world.weapon_damage[0][i] > 0:
continue # this can always be in logic
weapons = []
for weapon in range(1, 9):
if world.weapon_damage[weapon][i] > 0:
if world.weapon_damage[weapon][i] < minimum_weakness_requirement[weapon]:
continue # Atomic Fire can only be considered logical for bosses it can kill in 2 hits
weapons.append(weapons_to_name[weapon])
if not weapons:
raise Exception(f"Attempted to have boss {i} with no weakness! Seed: {world.multiworld.seed}")
for location in boss_locations:
if i == 12:
add_rule(world.get_location(location),
lambda state, weps=tuple(weapons): state.has_all(weps, world.player))
# TODO: when has_list gets added, check for a subset of possible weaknesses
else:
add_rule(world.get_location(location),
lambda state, weps=tuple(weapons): state.has_any(weps, world.player))
# Always require Crash Bomber for Boobeam Trap
add_rule(world.get_location(names.wily_4),
lambda state: state.has(names.crash_bomber, world.player))
add_rule(world.get_location(names.wily_stage_4),
lambda state: state.has(names.crash_bomber, world.player))
# Need to defeat x amount of robot masters for Wily 5
add_rule(world.get_location(names.wily_5),
lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value,
world.wily_5_weapons))
add_rule(world.get_location(names.wily_stage_5),
lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value,
world.wily_5_weapons))
if not world.options.yoku_jumps:
add_rule(world.get_entrance("To Heat Man Stage"),
lambda state: state.has(names.item_2, world.player))
if not world.options.enable_lasers:
add_rule(world.get_entrance("To Quick Man Stage"),
lambda state: state.has(names.time_stopper, world.player))
if world.options.consumables in (Consumables.option_1up_etank,
Consumables.option_all):
add_rule(world.get_location(names.flash_man_c2),
lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player))
add_rule(world.get_location(names.quick_man_c1),
lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player))
add_rule(world.get_location(names.metal_man_c2),
lambda state: state.has_any([names.item_1, names.item_2], world.player))
add_rule(world.get_location(names.metal_man_c3),
lambda state: state.has_any([names.item_1, names.item_2], world.player))
add_rule(world.get_location(names.crash_man_c3),
lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player))
add_rule(world.get_location(names.wily_2_c5),
lambda state: state.has(names.crash_bomber, world.player))
add_rule(world.get_location(names.wily_2_c6),
lambda state: state.has(names.crash_bomber, world.player))
add_rule(world.get_location(names.wily_3_c2),
lambda state: state.has(names.crash_bomber, world.player))
if world.options.consumables in (Consumables.option_weapon_health,
Consumables.option_all):
add_rule(world.get_location(names.flash_man_c3),
lambda state: state.has(names.crash_bomber, world.player))
add_rule(world.get_location(names.flash_man_c4),
lambda state: state.has(names.crash_bomber, world.player))
add_rule(world.get_location(names.wily_3_c1),
lambda state: state.has(names.crash_bomber, world.player))

View File

@ -0,0 +1,861 @@
norom
!headersize = 16
!controller_mirror = $23
!controller_flip = $27 ; only on first frame of input, used by crash man, etc
!current_stage = $2A
!received_stages = $8A
!completed_stages = $8B
!received_item_checks = $8C
!last_wily = $8D
!deathlink = $8F
!energylink_packet = $90
!rbm_strobe = $91
!received_weapons = $9A
!received_items = $9B
!current_weapon = $A9
!stage_completion = $0F70
!consumable_checks = $0F80
!CONTROLLER_SELECT = #$04
!CONTROLLER_SELECT_START = #$0C
!CONTROLLER_ALL_BUTTON = #$0F
!PpuControl_2000 = $2000
!PpuMask_2001 = $2001
!PpuAddr_2006 = $2006
!PpuData_2007 = $2007
!LOAD_BANK = $C000
macro org(address,bank)
if <bank> == $0F
org <address>-$C000+($4000*<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
org <address>-$8000+($4000*<bank>)+!headersize
base <address>
endif
endmacro
%org($8400, $08)
incbin "mm2font.dat"
%org($A900, $09)
incbin "mm2titlefont.dat"
%org($807E, $0B)
FlashFixes:
CMP #$FF
BEQ FlashFixTarget1
CMP #$FF
BNE FlashFixTarget2
%org($8086, $0B)
FlashFixTarget1:
%org($808D, $0B)
FlashFixTarget2:
%org($8015, $0D)
ClearRefreshHook:
; if we're already doing a fresh load of the stage select
; we don't need to immediately refresh it
JSR ClearRefresh
NOP
%org($802B, $0D)
PatchFaceTiles:
LDA !received_stages
%org($8072, $0D)
PatchFaceSprites:
LDA !received_stages
%org($80CC, $0D)
CheckItemsForWily:
LDA !received_items
CMP #$07
%org($80D2, $0D)
LoadWily:
JSR GoToMostRecentWily
NOP
%org($80DC, $0D)
CheckAccessCodes:
LDA !received_stages
%org($8312, $0D)
HookStageSelect:
JSR RefreshRBMTiles
NOP
%org($A315, $0D)
RemoveWeaponClear:
NOP
NOP
NOP
NOP
;Adjust Password select flasher
%org($A32A, $0D)
LDX #$68
;Block password input
%org($A346, $0D)
EOR #$00
;Remove password text
%org($AF3A, $0D)
StartHeight:
db $AC ; set Start to center
%org($AF49, $0D)
PasswordText:
db $40, $40, $40, $40, $40, $40, $40, $40
%org($AF6C, $0D)
ContinueHeight:
db $AB ; split height between 2 remaining options
%org($AF77, $0D)
StageSelectHeight:
db $EB ; split between 2 remaining options
%org($AF88, $0D)
GameOverPasswordText:
db $40, $40, $40, $40, $40, $40, $40, $40
%org($AFA5, $0D)
GetEquippedPasswordText:
db $40, $40, $40, $40, $40, $40, $40, $40
%org($AFAE, $0D)
GetEquippedStageSelect:
db $26, $EA
%org($B195, $0D)
GameOverPasswordUp:
LDA #$01 ; originally 02, removing last option
%org($B19F, $0D)
GameOverPassword:
CMP #$02 ; originally 03, remove the last option
%org($B1ED, $0D)
FixupGameOverArrows:
db $68, $78
%org($BB74, $0D)
GetEquippedStage:
JSR StageGetEquipped
NOP #13
%org($BBD9, $0D)
GetEquippedDefault:
LDA #$01
%org($BC01, $0D)
GetEquippedPasswordRemove:
ORA #$01 ; originally EOR #$01, we always want 1 here
%org($BCF1, $0D)
GetEquippedItem:
ADC #$07
JSR ItemGetEquipped
JSR LoadItemsColor
NOP ; !!!! This is a load-bearing NOP. It gets branched to later in the function
LDX $FF
%org($BB08, $0D)
WilyProgress:
JSR StoreWilyProgress
NOP
%org($BF6F, $0D)
GetEquippedStageSelectHeight:
db $B8
%org($805B, $0E)
InitalizeStartingRBM:
LDA #$FF ; this does two things
STA !received_stages ; we're overwriting clearing e-tanks and setting RBM available to none
%org($8066, $0E)
BlockStartupAutoWily:
; presumably this would be called from password?
LDA #$00
%org($80A7, $0E)
StageLoad:
JMP CleanWily5
NOP
%org($8178, $0E)
Main1:
JSR MainLoopHook
NOP
%org($81DE, $0E)
Wily5Teleporter:
LDA $99
CMP #$01
BCC SkipSpawn
%org($81F9, $0E)
SkipSpawn:
; just present to fix the branch, if we try to branch raw it'll get confused
%org($822D, $0E)
Main2:
; believe used in the wily 5 refights?
JSR MainLoopHook
NOP
%org($842F, $0E)
Wily5Hook:
JMP Wily5Requirement
NOP
%org($C10D, $0F)
Deathlink:
JSR KillMegaMan
%org($C1BC, $0F)
RemoveETankLoss:
NOP
NOP
%org($C23C, $0F)
WriteStageComplete:
ORA !completed_stages
STA !completed_stages
%org($C243, $0F)
WriteReceiveItem:
ORA !received_item_checks
STA !received_item_checks
%org($C254, $0F)
BlockAutoWily:
; and this one is on return from stage?
LDA #$00
%org($C261, $0F)
WilyStageCompletion:
JSR StoreWilyStageCompletion
NOP
%org($E5AC, $0F)
NullDeathlink:
STA $8F ; we null his HP later in the process
NOP
%org($E5D1, $0F)
EnergylinkHook:
JSR Energylink
NOP #2 ; comment this out to enable item giving their usual reward alongside EL
%org($E5E8, $0F)
ConsumableHook:
JSR CheckConsumable
%org($F2E3, $0F)
CheckConsumable:
STA $0140, Y
TXA
PHA
LDA $AD ; the consumable value
CMP #$7C
BPL .Store
print "Consumables (replace 7a): ", hex(realbase())
CMP #$76
BMI .Store
LDA #$00
.Store:
STA $AD
LDA $2A
ASL
ASL
TAX
TYA
.LoopHead:
CMP #$08
BMI .GetFlag
INX
SBC #$08
BNE .LoopHead
.GetFlag:
TAY
LDA #$01
.Loop2Head:
CPY #$00
BEQ .Apply
ASL
DEY
BNE .Loop2Head
.Apply:
ORA !consumable_checks, X
STA !consumable_checks, X
PLA
TAX
RTS
GoToMostRecentWily:
LDA !controller_mirror
CMP !CONTROLLER_SELECT_START
BEQ .Default
LDA !last_wily
BNE .Store
.Default:
LDA #$08 ; wily stage 1
.Store:
STA !current_stage
RTS
StoreWilyStageCompletion:
LDA #$01
STA !stage_completion, X
INC !current_stage
LDA !current_stage
STA !last_wily
RTS
ReturnToGameOver:
LDA #$10
STA !PpuControl_2000
LDA #$06
STA !PpuMask_2001
JMP $C1BE ; specific code that loads game over
MainLoopHook:
LDA !controller_mirror
CMP !CONTROLLER_ALL_BUTTON
BNE .Next
JMP ReturnToGameOver
.Next:
LDA !deathlink
CMP #$01
BNE .Next2
JMP $E5A8 ; this kills the Mega Man
.Next2:
print "Quickswap:", hex(realbase())
LDA #$00 ; slot data, write in enable for quickswap
CMP #$01
BNE .Finally
LDA !controller_flip
AND !CONTROLLER_SELECT
BEQ .Finally
JMP Quickswap
.Finally:
LDA !controller_flip
AND #$08 ; this is checking for menu
RTS
StoreWilyProgress:
STA !current_stage
TXA
PHA
LDX !current_stage
LDA #$01
STA !stage_completion, X
PLA
TAX
print "Get Equipped Music: ", hex(realbase())
LDA #$17
RTS
KillMegaMan:
JSR $C051 ; this kills the mega man
LDA #$00
STA $06C0 ; set HP to zero so client can actually detect he died
RTS
Wily5Requirement:
LDA #$01
LDX #$08
LDY #$00
.LoopHead:
BIT $BC
BEQ .Skip
INY
.Skip:
DEX
ASL
CPX #$00
BNE .LoopHead
print "Wily 5 Requirement:", hex(realbase())
CPY #$08
BCS .SpawnTeleporter
JMP $8450
.SpawnTeleporter:
LDA #$FF
STA $BC
LDA #$01
STA $99
JMP $8433
CleanWily5:
LDA #$00
STA $BC
STA $99
JMP $80AB
LoadString:
STY $00
ASL
ASL
ASL
ASL
TAY
LDA $DB
ADC #$00
STA $C8
LDA #$40
STA $C9
LDA #$F6
CLC
ADC $C8
STA $CA
LDA ($C9), Y
STA $03B6
TYA
CLC
ADC #$01
TAY
LDA $CA
ADC #$00
STA $CA
LDA ($C9), Y
STA $03B7
TYA
CLC
ADC #$01
TAY
LDA $CA
ADC #$00
STA $CA
STY $FE
LDA #$0E
STA $FD
.LoopHead:
JSR $BD34
LDY $FE
CPY #$40
BNE .NotEqual
LDA $0420
BNE .Skip
.NotEqual:
LDA ($C9), Y
.Skip:
STA $03B8
INC $47
INC $03B7
LDA $FE
CLC
ADC #$01
STA $FE
LDA $CA
ADC #$00
STA $CA
DEC $FD
BNE .LoopHead
LDY $00
JSR $C0AB
RTS
StageGetEquipped:
LDA !current_stage
LDX #$00
BCS LoadGetEquipped
ItemGetEquipped:
LDX #$02
LoadGetEquipped:
STX $DB
ASL
ASL
PHA
SEC
JSR LoadString
PLA
ADC #$00
PHA
SEC
JSR LoadString
PLA
ADC #$00
PHA
SEC
JSR LoadString
LDA #$00
SEC
JSR $BD3E
PLA
ADC #$00
SEC
JSR LoadString
RTS
LoadItemsColor:
LDA #$7D
STA $FD
LDA $0420
AND #$0F
ASL
SEC
ADC #$1A
STA $FF
RTS
Energylink:
LSR $0420, X
print "Energylink: ", hex(realbase())
LDA #$00
BEQ .ApplyDrop
LDA $04E0, X
BEQ .ApplyDrop ; This is a stage pickup, and not an enemy drop
STY !energylink_packet
SEC
BCS .Return
.ApplyDrop:
STY $AD
.Return:
RTS
Quickswap:
LDX #$0F
.LoopHead:
LDA $0420, X
BMI .Return1 ; return if we have any weapon entities spawned
DEX
CPX #$01
BNE .LoopHead
LDX !current_weapon
BNE .DoQuickswap
LDX #$00
.DoQuickswap:
TYA
PHA
LDX !current_weapon
INX
CPX #$09
BPL .Items
LDA #$01
.Loop2Head:
DEX
BEQ .FoundTarget
ASL
CPX #$00
BNE .Loop2Head
.FoundTarget:
LDX !current_weapon
INX
.Loop3Head:
PHA
AND !received_weapons
BNE .CanSwap
PLA
INX
CPX #$09
BPL .Items
ASL
BNE .Loop3Head
.CanSwap:
PLA
SEC
BCS .ApplySwap
.Items:
TXA
PHA
SEC
SBC #$08
TAX
LDA #$01
.Loop4Head:
DEX
BEQ .CheckItem
ASL
CPX #$00
BNE .Loop4Head
.CheckItem:
TAY
PLA
TAX
TYA
.Loop5Head:
PHA
AND !received_items
BNE .CanSwap
PLA
INX
ASL
BNE .Loop5Head
LDX #$00
SEC
BCS .ApplySwap
.Return1:
RTS
.ApplySwap: ; $F408 on old rom
LDA #$0D
JSR !LOAD_BANK
; this is a bunch of boiler plate to make the swap work
LDA $B5
PHA
LDA $B6
PHA
LDA $B7
PHA
LDA $B8
PHA
LDA $B9
PHA
LDA $20
PHA
LDA $1F
PHA
;but wait, there's more
STX !current_weapon
JSR $CC6C
LDA $1A
PHA
LDX #$00
.Loop6Head:
STX $FD
CLC
LDA $52
ADC $957F, X
STA $08
LDA $53
ADC #$00
STA $09
LDA $08
LSR $09
ROR
LSR $09
ROR
STA $08
AND #$3F
STA $1A
CLC
LDA $09
ADC #$85
STA $09
LDA #$00
STA $1B
LDA $FD
CMP #$08
BCS .Past8
LDX $A9
LDA $9664, X
TAY
CPX #$09
BCC .LessThanNine
LDX #$00
BEQ .Apply
.LessThanNine:
LDX #$05
BNE .Apply
.Past8:
LDY #$90
LDX #$00
.Apply:
JSR $C760
JSR $C0AB ; iirc this is loading graphics?
LDX $FD
INX
CPX #$0F
BNE .Loop6Head
STX $FD
LDY #$90
LDX #$00
JSR $C760
JSR $D2ED
; two sections redacted here, might need to look at what they actually do?
PLA
STA $1A
PLA
STA $1F
PLA
STA $20
PLA
STA $B9
PLA
STA $B8
PLA
STA $B7
PLA
STA $B6
PLA
STA $B5
LDA #$00
STA $AC
STA $2C
STA $0680
STA $06A0
LDA #$1A
STA $0400
LDA #$03
STA $AA
LDA #$30
JSR $C051
.Finally:
LDA #$0E
JSR !LOAD_BANK
PLA
TAY
.Return:
RTS
RefreshRBMTiles:
; primarily just a copy of the startup RBM setup, we just do it again
; can't jump to it as it leads into the main loop
LDA !rbm_strobe
BNE .Update
JMP .NoUpdate
.Update:
LDA #$00
STA !rbm_strobe
LDA #$10
STA $F7
STA !PpuControl_2000
LDA #$06
STA $F8
STA !PpuMask_2001
JSR $847E
JSR $843C
LDX #$00
LDA $8A
STA $01
.TileLoop:
STX $00
LSR $01
BCC .SkipTile
LDA $8531,X
STA $09
LDA $8539,X
STA $08
LDX #$04
LDA #$00
.ClearBody:
LDA $09
STA !PpuAddr_2006
LDA $08
STA !PpuAddr_2006
LDY #$04
LDA #$00
.ClearLine:
STA !PpuData_2007
DEY
BNE .ClearLine
CLC
LDA $08
ADC #$20
STA $08
DEX
BNE .ClearBody
.SkipTile:
LDX $00
INX
CPX #$08
BNE .TileLoop
LDX #$1F
JSR $829E
JSR $8473
LDX #$00
LDA $8A
STA $02
LDY #$00
.SpriteLoop:
STX $01
LSR $02
BCS .SkipRBM
LDA $8605,X
STA $00
LDA $85FD,X
TAX
.WriteSprite:
LDA $8541,X
STA $0200,Y
INY
INX
DEC $00
BNE .WriteSprite
.SkipRBM:
LDX $01
INX
CPX #$08
BNE .SpriteLoop
JSR $A51D
LDA #$0C
JSR $C051
LDA #$00
STA $2A
STA $FD
JSR $C0AB
.NoUpdate:
LDA $1C
AND #$08
RTS
ClearRefresh:
LDA #$00
STA !rbm_strobe
LDA #$10
STA $F7
RTS
assert realbase() <= $03F650 ; This is the start of our text data, and we absolutely cannot go past this point (text takes too much room).
%org($F640, $0F)
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
db $25, $4B, "PLACEHOLDER_L1"
db $25, $6B, "PLACEHOLDER_L2"
db $25, $8B, "PLACEHOLDER_L3"
db $25, $EB, "PLACEHOLDER_PL"
%org($FFB0, $0F)
db "MM2_BASEPATCH_ARCHI "

BIN
worlds/mm2/src/mm2font.dat Normal file

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -0,0 +1,47 @@
from . import MM2TestBase
from ..locations import (quick_man_locations, heat_man_locations, wily_1_locations, wily_2_locations,
wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations,
energy_pickups, etank_1ups)
from ..names import *
class TestAccess(MM2TestBase):
options = {
"consumables": "all"
}
def test_time_stopper(self) -> None:
"""Optional based on Enable Lasers setting, confirm these are the locations affected"""
locations = [*quick_man_locations, *energy_pickups["Quick Man Stage"], *etank_1ups["Quick Man Stage"]]
items = [["Time Stopper"]]
self.assertAccessDependency(locations, items)
def test_item_2(self) -> None:
"""Optional based on Yoku Block setting, confirm these are the locations affected"""
locations = [*heat_man_locations, *etank_1ups["Heat Man Stage"]]
items = [["Item 2 - Rocket"]]
self.assertAccessDependency(locations, items, True)
def test_any_item(self) -> None:
locations = [flash_man_c2, quick_man_c1, crash_man_c3]
items = [["Item 1 - Propeller"], ["Item 2 - Rocket"], ["Item 3 - Bouncy"]]
self.assertAccessDependency(locations, items, True)
locations = [metal_man_c2, metal_man_c3]
items = [["Item 1 - Propeller"], ["Item 2 - Rocket"]]
self.assertAccessDependency(locations, items, True)
def test_all_items(self) -> None:
locations = [flash_man_c2, quick_man_c1, crash_man_c3, metal_man_c2, metal_man_c3, *heat_man_locations,
*etank_1ups["Heat Man Stage"], *wily_1_locations, *wily_2_locations, *wily_3_locations,
*wily_4_locations, *wily_5_locations, *wily_6_locations, *etank_1ups["Wily Stage 1"],
*etank_1ups["Wily Stage 2"], *etank_1ups["Wily Stage 3"], *etank_1ups["Wily Stage 4"],
*energy_pickups["Wily Stage 1"], *energy_pickups["Wily Stage 2"], *energy_pickups["Wily Stage 3"],
*energy_pickups["Wily Stage 4"]]
items = [["Item 1 - Propeller", "Item 2 - Rocket", "Item 3 - Bouncy"]]
self.assertAccessDependency(locations, items)
def test_crash_bomber(self) -> None:
locations = [flash_man_c3, flash_man_c4, wily_2_c5, wily_2_c6, wily_3_c1, wily_3_c2,
wily_4, wily_stage_4]
items = [["Crash Bomber"]]
self.assertAccessDependency(locations, items)

View File

@ -0,0 +1,93 @@
from math import ceil
from . import MM2TestBase
from ..options import bosses
# Need to figure out how this test should work
def validate_wily_5(base: MM2TestBase) -> None:
world = base.multiworld.worlds[base.player]
weapon_damage = world.weapon_damage
boss_health = {boss: 0x1C for boss in [*list(range(8)), 12]}
weapon_costs = {
0: 0,
1: 10,
2: 2,
3: 3,
4: 0.5,
5: 0.125,
6: 4,
7: 0.25,
8: 7,
}
weapon_energy = {key: float(0x1C * 2) if key == 12 else float(0x1C) for key in weapon_costs}
weapon_boss = {boss: {weapon: weapon_damage[weapon][boss] for weapon in weapon_damage}
for boss in [*list(range(8)), 12]}
flexibility = [(sum(1 if weapon_boss[boss][weapon] > 0 else 0 for weapon in range(9)) *
sum(weapon_boss[boss].values()), boss) for boss in weapon_boss if boss != 12]
for _, boss in [*sorted(flexibility), (0, 12)]:
boss_damage = weapon_boss[boss]
weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in
boss_damage.items() if weapon_energy[weapon]}
if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight:
# We get exactly one use of Time Stopper during the rush
# So we want to make sure that use is absolutely needed
weapon_weight[8] = min(weapon_weight[8], 0.001)
while boss_health[boss] > 0:
if boss_damage[0]:
boss_health[boss] = 0 # if we can buster, we should buster
continue
highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys()))
uses = weapon_energy[wp] // weapon_costs[wp]
if int(uses * boss_damage[wp]) > boss_health[boss]:
used = ceil(boss_health[boss] / boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * used
boss_health[boss] = 0
elif highest <= 0:
# we are out of weapons that can actually damage the boss
base.fail(f"Ran out of weapon energy to damage "
f"{next(name for name in bosses if bosses[name] == boss)}\n"
f"Seed: {base.multiworld.seed}\n"
f"Damage Table: {weapon_damage}")
else:
# drain the weapon and continue
boss_health[boss] -= int(uses * boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * uses
weapon_weight.pop(wp)
class StrictWeaknessTests(MM2TestBase):
options = {
"strict_weakness": True,
"yoku_jumps": True,
"enable_lasers": True
}
def test_that_every_boss_has_a_weakness(self) -> None:
world = self.multiworld.worlds[self.player]
weapon_damage = world.weapon_damage
for boss in range(14):
if not any(weapon_damage[weapon][boss] for weapon in range(9)):
self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}")
def test_wily_5(self) -> None:
validate_wily_5(self)
class RandomStrictWeaknessTests(MM2TestBase):
options = {
"strict_weakness": True,
"random_weakness": "randomized",
"yoku_jumps": True,
"enable_lasers": True
}
def test_that_every_boss_has_a_weakness(self) -> None:
world = self.multiworld.worlds[self.player]
weapon_damage = world.weapon_damage
for boss in range(14):
if not any(weapon_damage[weapon][boss] for weapon in range(9)):
self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}")
def test_wily_5(self) -> None:
validate_wily_5(self)

90
worlds/mm2/text.py Normal file
View File

@ -0,0 +1,90 @@
from typing import DefaultDict
from collections import defaultdict
MM2_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda x: 0x6F, {
' ': 0x40,
'A': 0x41,
'B': 0x42,
'C': 0x43,
'D': 0x44,
'E': 0x45,
'F': 0x46,
'G': 0x47,
'H': 0x48,
'I': 0x49,
'J': 0x4A,
'K': 0x4B,
'L': 0x4C,
'M': 0x4D,
'N': 0x4E,
'O': 0x4F,
'P': 0x50,
'Q': 0x51,
'R': 0x52,
'S': 0x53,
'T': 0x54,
'U': 0x55,
'V': 0x56,
'W': 0x57,
'X': 0x58,
'Y': 0x59,
'Z': 0x5A,
# 0x5B is the small r in Dr Light
'.': 0x5C,
',': 0x5D,
'\'': 0x5E,
'!': 0x5F,
'(': 0x60,
')': 0x61,
'#': 0x62,
'$': 0x63,
'%': 0x64,
'&': 0x65,
'*': 0x66,
'+': 0x67,
'/': 0x68,
'\\': 0x69,
':': 0x6A,
';': 0x6B,
'<': 0x6C,
'>': 0x6D,
'=': 0x6E,
'?': 0x6F,
'@': 0x70,
'[': 0x71,
']': 0x72,
'^': 0x73,
'_': 0x74,
'`': 0x75,
'{': 0x76,
'}': 0x77,
'|': 0x78,
'~': 0x79,
'\"': 0x92,
'-': 0x94,
'0': 0xA0,
'1': 0xA1,
'2': 0xA2,
'3': 0xA3,
'4': 0xA4,
'5': 0xA5,
'6': 0xA6,
'7': 0xA7,
'8': 0xA8,
'9': 0xA9,
})
class MM2TextEntry:
def __init__(self, text: str = "", coords: int = 0x0B):
self.target_area: int = 0x25 # don't change
self.coords: int = coords # 0xYX, Y can only be increments of 0x20
self.text: str = text
def resolve(self) -> bytes:
data = bytearray()
data.append(self.target_area)
data.append(self.coords)
data.extend([MM2_WEAPON_ENCODING[x] for x in self.text.upper()])
data.extend([0x40] * (14 - len(self.text)))
return bytes(data)