forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
230 lines
8.3 KiB
Python
230 lines
8.3 KiB
Python
"""
|
|
Classes and functions related to creating a ROM patch
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
import json
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
from BaseClasses import Location
|
|
import Utils
|
|
from worlds.Files import APPatchExtension, APProcedurePatch
|
|
|
|
from .items import item_data_table, tank_data_table, major_item_data_table
|
|
from .locations import full_location_table as location_table
|
|
from .options import ChozodiaAccess, DisplayNonLocalItems, GameDifficulty, Goal, LayoutPatches
|
|
from .patcher import MD5_US, patch_rom
|
|
from .patcher.text import LINE_WIDTH, SPACE, Message, get_width_of_encoded_character
|
|
from .item_sprites import Sprite, get_zero_mission_sprite, unknown_item_alt_sprites
|
|
|
|
if TYPE_CHECKING:
|
|
from . import MZMWorld
|
|
|
|
|
|
class MZMPatchExtensions(APPatchExtension):
|
|
game = "Metroid: Zero Mission"
|
|
|
|
@staticmethod
|
|
def apply_json(caller: APProcedurePatch, rom: bytes, file_name: str) -> bytes:
|
|
return patch_rom(rom, json.loads(caller.get_file(file_name).decode()))
|
|
|
|
|
|
class MZMProcedurePatch(APProcedurePatch):
|
|
game = "Metroid: Zero Mission"
|
|
hash = MD5_US
|
|
patch_file_ending = ".apmzm"
|
|
result_file_ending = ".gba"
|
|
procedure = [("apply_json", ["patch.json"])]
|
|
|
|
@classmethod
|
|
def get_source_data(cls) -> bytes:
|
|
with open(get_base_rom_path(), "rb") as stream:
|
|
return stream.read()
|
|
|
|
|
|
def get_base_rom_path(file_name: str = "") -> Path:
|
|
from . import MZMWorld
|
|
if not file_name:
|
|
file_name = MZMWorld.settings.rom_file
|
|
|
|
file_path = Path(file_name)
|
|
if file_path.exists():
|
|
return file_path
|
|
else:
|
|
return Path(Utils.user_path(file_name))
|
|
|
|
|
|
goal_texts = {
|
|
Goal.option_mecha_ridley: "Infiltrate and destroy\nthe Space Pirates' mother ship.",
|
|
Goal.option_bosses: "Exterminate all Metroid\norganisms and defeat Mother Brain.",
|
|
Goal.option_metroid_dna: "Locate Metroid DNA\nsamples and destroy the mother ship.",
|
|
}
|
|
|
|
|
|
def get_item_sprite(location: Location, world: MZMWorld) -> str:
|
|
player = world.player
|
|
nonlocal_item_handling = world.options.display_nonlocal_items
|
|
item = location.item
|
|
|
|
if location.native_item and (nonlocal_item_handling != DisplayNonLocalItems.option_none or item.player == player):
|
|
other_world = cast("MZMWorld", world.multiworld.worlds[item.player])
|
|
sprite = item_data_table[item.name].game_data.sprite
|
|
if (item.name in unknown_item_alt_sprites and other_world.options.fully_powered_suit.use_alt_unknown_sprites()):
|
|
sprite = unknown_item_alt_sprites[item.name]
|
|
return sprite
|
|
|
|
if nonlocal_item_handling == DisplayNonLocalItems.option_match_series:
|
|
sprite = get_zero_mission_sprite(item)
|
|
if sprite is not None:
|
|
return sprite
|
|
|
|
if item.advancement or item.trap:
|
|
sprite = Sprite.APLogoProgression
|
|
elif item.useful:
|
|
sprite = Sprite.APLogoUseful
|
|
else:
|
|
sprite = Sprite.APLogo
|
|
return sprite
|
|
|
|
|
|
space_width = get_width_of_encoded_character(SPACE)
|
|
|
|
def split_text(text: str):
|
|
lines = [""]
|
|
i = 0
|
|
width = 0
|
|
while i < len(text):
|
|
next_space = text.find(" ", i)
|
|
if next_space == -1:
|
|
next_space = len(text)
|
|
next_word = text[i:next_space]
|
|
next_word_width = Message(next_word).display_width()
|
|
if width + space_width + next_word_width <= LINE_WIDTH:
|
|
lines[-1] = f"{lines[-1]}{next_word} "
|
|
width += space_width + next_word_width
|
|
else:
|
|
lines[-1] = lines[-1][:-1]
|
|
lines.append(text[i:next_space] + " ")
|
|
width = next_word_width
|
|
i = next_space + 1
|
|
lines[-1] = lines[-1][:-1]
|
|
return lines
|
|
|
|
|
|
GOAL_TO_CONFIG_NAME = {
|
|
Goal.option_mecha_ridley: "vanilla",
|
|
Goal.option_bosses: "bosses",
|
|
Goal.option_metroid_dna: "metroid_dna"
|
|
}
|
|
|
|
DIFFICULTY_TO_CONFIG_NAME = {
|
|
GameDifficulty.option_normal: "normal",
|
|
GameDifficulty.option_hard: "hard",
|
|
GameDifficulty.option_either: "either",
|
|
}
|
|
|
|
def write_json_data(world: MZMWorld, patch: MZMProcedurePatch):
|
|
multiworld = world.multiworld
|
|
player = world.player
|
|
data = {
|
|
"player_name": world.player_name,
|
|
"seed_name": multiworld.seed_name,
|
|
}
|
|
|
|
config = {
|
|
"goal": GOAL_TO_CONFIG_NAME[world.options.goal.value],
|
|
"difficulty": DIFFICULTY_TO_CONFIG_NAME[world.options.game_difficulty.value],
|
|
"remove_gravity_heat_resistance": True,
|
|
"power_bombs_without_bomb": True,
|
|
"buff_power_bomb_drops": bool(world.options.buff_pb_drops),
|
|
"separate_hijump_springball": bool(world.options.spring_ball),
|
|
"skip_chozodia_stealth": bool(world.options.skip_chozodia_stealth),
|
|
"chozodia_requires_mother_brain": world.options.chozodia_access.value == ChozodiaAccess.option_closed,
|
|
"start_with_maps": bool(world.options.start_with_maps),
|
|
"reveal_maps": bool(world.options.start_with_maps),
|
|
"reveal_hidden_blocks": bool(world.options.reveal_hidden_blocks),
|
|
"skip_tourian_opening_cutscenes": bool(world.options.skip_tourian_opening_cutscenes),
|
|
"elevator_speed": world.options.elevator_speed.value,
|
|
}
|
|
if world.options.goal.value == Goal.option_metroid_dna:
|
|
config["metroid_dna_required"] = world.options.metroid_dna_required.value
|
|
data["config"] = config
|
|
|
|
locations = []
|
|
for location in multiworld.get_locations(player):
|
|
item = location.item
|
|
if item.code is None:
|
|
continue
|
|
|
|
sprite = get_item_sprite(location, world)
|
|
if item.player == player:
|
|
item_name = item.name
|
|
message = None
|
|
else:
|
|
item_name = "Nothing"
|
|
message = f"{item.name}\nSent to {multiworld.player_name[item.player]}"
|
|
|
|
location_data = location_table[location.name]
|
|
assert location_data.id is not None
|
|
locations.append({
|
|
"id": location_data.id,
|
|
"item": item_name,
|
|
"sprite": sprite,
|
|
"message": message,
|
|
})
|
|
data["locations"] = locations
|
|
|
|
precollected_items = Counter(item.name for item in multiworld.precollected_items[player])
|
|
starting_inventory: dict[str, int | bool] = {}
|
|
for item, count in precollected_items.items():
|
|
if item == "Missile Tank":
|
|
starting_inventory[item] = min(count, 999)
|
|
elif item in tank_data_table:
|
|
starting_inventory[item] = min(count, 99)
|
|
elif item in major_item_data_table:
|
|
starting_inventory[item] = count > 0
|
|
data["start_inventory"] = starting_inventory
|
|
|
|
text = {"Story": {}}
|
|
|
|
ap_version = Utils.version_tuple.as_simple_string()
|
|
world_version = world.world_version.as_simple_string()
|
|
text["Story"]["Intro"] = (f"AP {multiworld.seed_name}\n"
|
|
f"P{player} - {world.player_name}\n"
|
|
f"AP {ap_version} / World version: {world_version}\n"
|
|
"\n"
|
|
f"YOUR MISSION: {goal_texts[world.options.goal.value]}")
|
|
|
|
plasma_beam = world.create_item("Plasma Beam")
|
|
if world.options.plasma_beam_hint.value and plasma_beam not in multiworld.precollected_items[player]:
|
|
zss_text = ("With Mother Brain taken down, I needed\n"
|
|
"to get my suit back in the ruins.\n")
|
|
location = multiworld.find_item(plasma_beam.name, player)
|
|
if location.native_item:
|
|
location_text = location.parent_region.hint_text
|
|
else:
|
|
location_text = f"at {location.name}"
|
|
if location.player != player:
|
|
player_text = f" in {multiworld.player_name[location.player]}'s world"
|
|
else:
|
|
player_text = ""
|
|
lines = split_text(f"Could I find the Plasma Beam {location_text}{player_text}?")
|
|
while len(lines) > 4:
|
|
location_text = location_text[:location_text.rfind(" ")]
|
|
lines = split_text(f"Could I find the Plasma Beam {location_text}{player_text}?")
|
|
if len(lines) < 4:
|
|
zss_text += "\n"
|
|
zss_text += "\n".join(lines)
|
|
text["Story"]["Escape 2"] = zss_text
|
|
|
|
data["text"] = text
|
|
|
|
if world.options.layout_patches.value == LayoutPatches.option_true:
|
|
data["layout_patches"] = "all"
|
|
else:
|
|
data["layout_patches"] = world.enabled_layout_patches
|
|
|
|
patch.write_file("patch.json", json.dumps(data).encode())
|