diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40cea61..69efe75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,5 +14,12 @@ repos: rev: v1.4.1 hooks: - id: jsonschema-to-typeddict - files: src/mars_patcher/data/schema.json - args: [ --output-path, src/mars_patcher/auto_generated_types.py, --root-name, MarsSchema ] + files: src/mars_patcher/mf/data/schema.json + args: [ --output-path, src/mars_patcher/mf/auto_generated_types.py, --root-name, MarsSchemaMF ] + +- repo: https://github.com/henriquegemignani/jsonschema-to-typeddict + rev: v1.4.1 + hooks: + - id: jsonschema-to-typeddict + files: src/mars_patcher/zm/data/schema.json + args: [ --output-path, src/mars_patcher/zm/auto_generated_types.py, --root-name, MarsSchemaZM ] diff --git a/pull-assembly-patches.py b/pull-assembly-patches.py index e77462a..5b91d8a 100644 --- a/pull-assembly-patches.py +++ b/pull-assembly-patches.py @@ -11,7 +11,7 @@ DESTINATION_ASSEMBLY_PATH = ( Path(__file__) .parent.resolve() - .joinpath("src", "mars_patcher", "data", "patches", "mf_u", "asm") + .joinpath("src", "mars_patcher", "mf", "data", "patches", "mf_u", "asm") ) diff --git a/src/mars_patcher/cli.py b/src/mars_patcher/cli.py index 7642918..a7b5486 100644 --- a/src/mars_patcher/cli.py +++ b/src/mars_patcher/cli.py @@ -1,7 +1,7 @@ import argparse import json -from mars_patcher.patcher import patch, validate_patch_data +from mars_patcher.patcher import patch def main() -> None: @@ -11,13 +11,13 @@ def main() -> None: parser.add_argument("patch_data_path", type=str, help="Path to patch data json file") args = parser.parse_args() - # Load patch data file and validate + # Load patch data file with open(args.patch_data_path, encoding="utf-8") as f: patch_data = json.load(f) patch( args.rom_path, args.out_path, - validate_patch_data(patch_data), + patch_data, lambda message, progress: print(message), ) diff --git a/src/mars_patcher/data.py b/src/mars_patcher/data.py index 5b55ed7..70c0149 100644 --- a/src/mars_patcher/data.py +++ b/src/mars_patcher/data.py @@ -3,4 +3,4 @@ def get_data_path(*path: str | os.PathLike) -> str: - return os.fspath(Path(__file__).parent.joinpath("data", *path)) + return os.fspath(Path(__file__).parent.joinpath("mf", "data", *path)) diff --git a/src/mars_patcher/mf/auto_generated_types.py b/src/mars_patcher/mf/auto_generated_types.py new file mode 100644 index 0000000..1a78eae --- /dev/null +++ b/src/mars_patcher/mf/auto_generated_types.py @@ -0,0 +1,647 @@ +# This file is generated. Manual changes will be lost +# fmt: off +# ruff: noqa +# mypy: disable-error-code="misc" +from __future__ import annotations + +import typing_extensions as typ + + +# Definitions +Seed: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 2147483647'] +Typeu4: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 15'] +Typeu5: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 31'] +Typeu8: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 255'] +Typeu10: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 1023'] +Areaid: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 6'] +Areaidkey = typ.Literal[ + '0', + '1', + '2', + '3', + '4', + '5', + '6' +] +Minimapidkey = typ.Literal[ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10' +] +Sectorid: typ.TypeAlias = typ.Annotated[int, '1 <= value <= 6'] +Shortcutsectorlist: typ.TypeAlias = typ.Annotated[list[Sectorid], 'len() == 6'] +Huerotation: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 360'] +Validsources = typ.Literal[ + 'MainDeckData', + 'Arachnus', + 'ChargeCoreX', + 'Level1', + 'TroData', + 'Zazabi', + 'Serris', + 'Level2', + 'PyrData', + 'MegaX', + 'Level3', + 'ArcData1', + 'WideCoreX', + 'ArcData2', + 'Yakuza', + 'Nettori', + 'Nightmare', + 'Level4', + 'AqaData', + 'WaveCoreX', + 'Ridley', + 'Boiler', + 'Animals', + 'AuxiliaryPower' +] +Validitems = typ.Literal[ + 'None', + 'Level0', + 'Missiles', + 'MorphBall', + 'ChargeBeam', + 'Level1', + 'Bombs', + 'HiJump', + 'SpeedBooster', + 'Level2', + 'SuperMissiles', + 'VariaSuit', + 'Level3', + 'IceMissiles', + 'WideBeam', + 'PowerBombs', + 'SpaceJump', + 'PlasmaBeam', + 'GravitySuit', + 'Level4', + 'DiffusionMissiles', + 'WaveBeam', + 'ScrewAttack', + 'IceBeam', + 'MissileTank', + 'EnergyTank', + 'PowerBombTank', + 'IceTrap', + 'InfantMetroid' +] +Validitemsprites = typ.Literal[ + 'Empty', + 'Missiles', + 'Level0', + 'MorphBall', + 'ChargeBeam', + 'Level1', + 'Bombs', + 'HiJump', + 'SpeedBooster', + 'Level2', + 'SuperMissiles', + 'VariaSuit', + 'Level3', + 'IceMissiles', + 'WideBeam', + 'PowerBombs', + 'SpaceJump', + 'PlasmaBeam', + 'GravitySuit', + 'Level4', + 'DiffusionMissiles', + 'WaveBeam', + 'ScrewAttack', + 'IceBeam', + 'MissileTank', + 'EnergyTank', + 'PowerBombTank', + 'Anonymous', + 'ShinyMissileTank', + 'ShinyPowerBombTank', + 'InfantMetroid' +] +Validabilities = typ.Literal[ + 'Missiles', + 'MorphBall', + 'ChargeBeam', + 'Bombs', + 'HiJump', + 'SpeedBooster', + 'SuperMissiles', + 'VariaSuit', + 'IceMissiles', + 'WideBeam', + 'PowerBombs', + 'SpaceJump', + 'PlasmaBeam', + 'GravitySuit', + 'DiffusionMissiles', + 'WaveBeam', + 'ScrewAttack', + 'IceBeam' +] +Validelevatortops = typ.Literal[ + 'OperationsDeckTop', + 'MainHubToSector1', + 'MainHubToSector2', + 'MainHubToSector3', + 'MainHubToSector4', + 'MainHubToSector5', + 'MainHubToSector6', + 'MainHubTop', + 'HabitationDeckTop', + 'Sector1ToRestrictedLab' +] +Validelevatorbottoms = typ.Literal[ + 'OperationsDeckBottom', + 'MainHubBottom', + 'RestrictedLabToSector1', + 'HabitationDeckBottom', + 'Sector1ToMainHub', + 'Sector2ToMainHub', + 'Sector3ToMainHub', + 'Sector4ToMainHub', + 'Sector5ToMainHub', + 'Sector6ToMainHub' +] +Validlanguages = typ.Literal[ + 'JapaneseKanji', + 'JapaneseHiragana', + 'English', + 'German', + 'French', + 'Italian', + 'Spanish' +] +Messagelanguages: typ.TypeAlias = dict[Validlanguages, str] + +class Itemmessages(typ.TypedDict, total=False): + Kind: typ.Required[Itemmessageskind] + Languages: Messagelanguages + Centered: bool = True + MessageID: typ.Annotated[int, '0 <= value <= 56'] + """The Message ID, will display one of the predefined messages in the ROM""" + +Itemmessageskind = typ.Literal[ + 'CustomMessage', + 'MessageID' +] +Jingle = typ.Literal[ + 'Minor', + 'Major' +] + +class BlocklayerItem(typ.TypedDict, total=False): + X: Typeu8 + """The X position in the room that should get edited.""" + + Y: Typeu8 + """The Y position in the room that should get edited.""" + + Value: Typeu10 + """The value that should be used to edit the room. For backgrounds, this is calculated via `((Row-1) * ColumnsInTileset) + (Column-1)`.""" + +Blocklayer: typ.TypeAlias = typ.Annotated[list[BlocklayerItem], 'Unique items'] +Hintlocks = typ.Literal[ + 'OPEN', + 'LOCKED', + 'GREY', + 'BLUE', + 'GREEN', + 'YELLOW', + 'RED' +] + +# Schema entries + +class MarsschemamfLocationsMajorlocationsItem(typ.TypedDict): + Source: Validsources + """Valid major locations.""" + + Item: Validitems + """Valid items for shuffling.""" + + ItemMessages: typ.NotRequired[Itemmessages] + Jingle: Jingle + +class MarsschemamfLocationsMinorlocationsItem(typ.TypedDict): + Area: Areaid + """The area ID where this item is located.""" + + Room: Typeu8 + """The room ID where this item is located.""" + + BlockX: Typeu8 + """The X-coordinate in the room where this item is located.""" + + BlockY: Typeu8 + """The Y-coordinate in the room where this item is located.""" + + Item: Validitems + """Valid items for shuffling.""" + + ItemSprite: typ.NotRequired[Validitemsprites] + """Valid graphics for minor location items.""" + + ItemMessages: typ.NotRequired[Itemmessages] + Jingle: Jingle + +class MarsschemamfLocations(typ.TypedDict): + """Specifies how the item locations in the game should be changed.""" + + MajorLocations: typ.Annotated[list[MarsschemamfLocationsMajorlocationsItem], 'len() == 23', 'Unique items'] + """Specifies how the major item locations should be changed. A major item location is a location where an item is obtained by defeating a boss or interacting with a device.""" + + MinorLocations: typ.Annotated[list[MarsschemamfLocationsMinorlocationsItem], 'len() == 103', 'Unique items'] + """Specifies how the minor item locations should be changed. A minor item location is a location where an item is obtained by touching a tank block.""" + + +class MarsschemamfStartinglocation(typ.TypedDict): + """The location the player should spawn at the start of the game.""" + + Area: Areaid + """The area ID of the starting location.""" + + Room: Typeu8 + """The room ID of the starting location.""" + + BlockX: Typeu8 + """The X-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration.""" + + BlockY: Typeu8 + """The Y-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration.""" + +MarsschemamfStartingitemsSecuritylevelsItem = typ.Literal[ + 0, + 1, + 2, + 3, + 4 +] + +class MarsschemamfStartingitems(typ.TypedDict, total=False): + Energy: typ.Annotated[int, '1 <= value <= 2099'] = 99 + """How much energy the player should start with on a new save file.""" + + Missiles: typ.Annotated[int, '0 <= value <= 999'] = 10 + """How many missiles the player should start with on a new save file (the amount unlocked by collecting missile data).""" + + PowerBombs: typ.Annotated[int, '0 <= value <= 99'] = 10 + """How many power bombs the player should start with on a new save file (the amount unlocked by collecting power bomb data).""" + + Abilities: typ.Annotated[list[Validabilities], 'Unique items'] = [] + """Which abilities the player should start with on a new save file.""" + + SecurityLevels: typ.Annotated[list[MarsschemamfStartingitemsSecuritylevelsItem], 'Unique items'] = [0] + """Which security levels will be unlocked from the start.""" + + DownloadedMaps: typ.Annotated[list[Areaid], 'Unique items'] = [] + """Which area maps will be downloaded from the start.""" + + +class MarsschemamfTankincrements(typ.TypedDict): + """How much ammo/health tanks provide when collected.""" + + MissileTank: typ.Annotated[int, '-1000 <= value <= 1000'] = 5 + """How much ammo missile tanks provide when collected.""" + + EnergyTank: typ.Annotated[int, '-2100 <= value <= 2100'] = 100 + """How much health energy tanks provide when collected.""" + + PowerBombTank: typ.Annotated[int, '-100 <= value <= 100'] = 2 + """How much ammo power bomb tanks provide when collected.""" + + MissileData: typ.NotRequired[typ.Annotated[int, '0 <= value <= 1000']] = 10 + """How much ammo Missile Launcher Data provides when collected.""" + + PowerBombData: typ.NotRequired[typ.Annotated[int, '0 <= value <= 100']] = 10 + """How much ammo Power Bomb Data provides when collected.""" + + +class MarsschemamfElevatorconnections(typ.TypedDict): + """Defines the elevator that each elevator connects to.""" + + ElevatorTops: typ.Annotated[dict[Validelevatortops, Validelevatorbottoms], 'len() >= 10'] + """Defines the bottom elevator that each top elevator connects to.""" + + ElevatorBottoms: typ.Annotated[dict[Validelevatorbottoms, Validelevatortops], 'len() >= 10'] + """Defines the top elevator that each bottom elevator connects to.""" + + +class MarsschemamfSectorshortcuts(typ.TypedDict): + """Defines the sector that each sector shortcut connects to.""" + + LeftAreas: Shortcutsectorlist + """Destination areas on the left side of sectors.""" + + RightAreas: Shortcutsectorlist + """Destination areas on the right side of sectors""" + +MarsschemamfDoorlocksItemLocktype = typ.Literal[ + 'Open', + 'Level0', + 'Level1', + 'Level2', + 'Level3', + 'Level4', + 'Locked' +] + +class MarsschemamfDoorlocksItem(typ.TypedDict): + Area: Areaid + """The area ID where this door is located.""" + + Door: Typeu8 + """The door ID of this door.""" + + LockType: MarsschemamfDoorlocksItemLocktype + """The type of cover on the hatch.""" + +MarsschemamfPalettesRandomizeKey = typ.Literal[ + 'Tilesets', + 'Enemies', + 'Samus', + 'Beams' +] + +@typ.final +class MarsschemamfPalettesRandomize(typ.TypedDict, total=False): + """The range to use for rotating palette hues.""" + + HueMin: Huerotation = None + """The minimum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.""" + + HueMax: Huerotation = None + """The maximum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.""" + + +MarsschemamfPalettesColorspace = typ.Literal[ + 'HSV', + 'Oklab' +] + +@typ.final +class MarsschemamfPalettes(typ.TypedDict, total=False): + """Properties for randomized in-game palettes.""" + + Seed: Seed = None + """A number used to initialize the random number generator for palettes. If not specified, the patcher will randomly generate one.""" + + Randomize: typ.Required[dict[MarsschemamfPalettesRandomizeKey, MarsschemamfPalettesRandomize]] + """What kind of palettes should be randomized.""" + + ColorSpace: MarsschemamfPalettesColorspace = 'Oklab' + """The color space to use for rotating palette hues.""" + + Symmetric: bool = True + """Randomly rotates hues in the positive or negative direction true.""" + + +class MarsschemamfNavigationtextNavigationterminals(typ.TypedDict, total=False): + """Assigns each navigation room a specific text.""" + + MainDeckWest: str + """Specifies what text should appear at the west Navigation Terminal in Main Deck.""" + + MainDeckEast: str + """Specifies what text should appear at the east Navigation Terminal in Main Deck.""" + + OperationsDeck: str + """Specifies what text should appear at the Navigation Terminal in Operations Deck.""" + + Sector1Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 1.""" + + Sector2Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 2.""" + + Sector3Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 3.""" + + Sector4Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 4.""" + + Sector5Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 5.""" + + Sector6Entrance: str + """Specifies what text should appear at the Navigation Terminal in Sector 6.""" + + AuxiliaryPower: str + """Specifies what text should appear at the Navigation Terminal near the Auxiliary Power Station.""" + + RestrictedLabs: str + """Specifies what text should appear at the Navigation Terminal in the Restricted Labs.""" + + +class MarsschemamfNavigationtextShiptext(typ.TypedDict, total=False): + """Assigns the ship specific text.""" + + InitialText: str + """Specifies what text should appear at the initial ship communication.""" + + ConfirmText: str + """Specifies what text should appear at the ship after confirming 'No' on subsequent ship communications.""" + + +@typ.final +class MarsschemamfNavigationtext(typ.TypedDict, total=False): + """Specifies text for a specific language.""" + + NavigationTerminals: MarsschemamfNavigationtextNavigationterminals + """Assigns each navigation room a specific text.""" + + ShipText: MarsschemamfNavigationtextShiptext + """Assigns the ship specific text.""" + + + +class MarsschemamfTitletextItem(typ.TypedDict, total=False): + Text: typ.Annotated[str, '/^[ -~]{0,30}$/'] + """The ASCII text for this line""" + + LineNum: typ.Annotated[int, '0 <= value <= 14'] +MarsschemamfCreditstextItemLinetype = typ.Literal[ + 'Blank', + 'Blue', + 'Red', + 'White1', + 'White2' +] + +class MarsschemamfCreditstextItem(typ.TypedDict, total=False): + LineType: typ.Required[MarsschemamfCreditstextItemLinetype] + """The color and line height of the text (or blank).""" + + Text: typ.Annotated[str, '/^[ -~]{0,34}$/'] + """The ASCII text for this line.""" + + BlankLines: Typeu8 = 0 + """Inserts the provided number of blank lines after the text line.""" + + Centered: bool = True + """Centers the text horizontally when true.""" + +MarsschemamfNavstationlocksKey = typ.Literal[ + 'MainDeckWest', + 'MainDeckEast', + 'OperationsDeck', + 'RestrictedLabs', + 'AuxiliaryPower', + 'Sector1Entrance', + 'Sector2Entrance', + 'Sector3Entrance', + 'Sector4Entrance', + 'Sector5Entrance', + 'Sector6Entrance' +] + + +@typ.final +class MarsschemamfLeveledits(typ.TypedDict, total=False): + """Specifies the Room ID.""" + + BG1: Blocklayer + """The BG1 layer that should be edited.""" + + BG2: Blocklayer + """The BG2 layer that should be edited.""" + + Clipdata: Blocklayer + """The Clipdata layer that should be edited.""" + + + + +class MarsschemamfMinimapeditsItem(typ.TypedDict, total=False): + X: Typeu5 + """The X position in the minimap that should get edited.""" + + Y: Typeu5 + """The Y position in the minimap that should get edited.""" + + Tile: Typeu10 + """The tile value that should be used to edit the minimap.""" + + Palette: Typeu4 + """The palette row to use for the tile.""" + + HFlip: bool = False + """Whether the tile should be horizontally flipped or not.""" + + VFlip: bool = False + """Whether the tile should be vertically flipped or not.""" + + + +class MarsschemamfRoomnamesItem(typ.TypedDict): + Area: Areaid + """The area ID where this room is located.""" + + Room: Typeu8 = 3 + """The room ID.""" + + Name: typ.Annotated[str, 'len() <= 112'] + """Specifies what text should appear for this room. Two lines are available, with an absolute maximum of 56 characters per line, if all characters used are small. Text will auto-wrap if the next word doesn't fit on the line. If the text is too long, it will be truncated. Use + to force a line break. If not provided, will display 'Unknown Room'.""" + + +class Marsschemamf(typ.TypedDict, total=False): + """ + Metroid Fusion patching schema + + A json schema describing the input for patching Metroid Fusion via mars_patcher. + """ + + SeedHash: typ.Required[typ.Annotated[str, '/^[0-9A-Z]{8}$/']] + """A seed hash that will be displayed on the file select screen.""" + + Locations: typ.Required[MarsschemamfLocations] + """Specifies how the item locations in the game should be changed.""" + + RequiredMetroidCount: typ.Required[typ.Annotated[int, '0 <= value <= 20']] + """The number of infant Metroids that must be collected to beat the game.""" + + StartingLocation: MarsschemamfStartinglocation + """The location the player should spawn at the start of the game.""" + + StartingItems: MarsschemamfStartingitems = None + TankIncrements: MarsschemamfTankincrements = None + """How much ammo/health tanks provide when collected.""" + + ElevatorConnections: MarsschemamfElevatorconnections + """Defines the elevator that each elevator connects to.""" + + SectorShortcuts: MarsschemamfSectorshortcuts + """Defines the sector that each sector shortcut connects to.""" + + DoorLocks: list[MarsschemamfDoorlocksItem] + """List of all lockable doors and their lock type.""" + + Palettes: MarsschemamfPalettes = None + """Properties for randomized in-game palettes.""" + + NavigationText: dict[Validlanguages, MarsschemamfNavigationtext] = None + """Specifies text to be displayed at navigation rooms and the ship.""" + + TitleText: list[MarsschemamfTitletextItem] = None + """Lines of ascii text to write to the title screen.""" + + CreditsText: list[MarsschemamfCreditstextItem] + """Lines of text to insert into the credits.""" + + NavStationLocks: dict[MarsschemamfNavstationlocksKey, Hintlocks] + """Sets the required Security Levels for accessing Navigation Terminals.""" + + DisableDemos: bool = False + """Disables title screen demos when true.""" + + SkipDoorTransitions: bool = False + """Makes all door transitions instant when true.""" + + StereoDefault: bool = True + """Forces stereo sound by default when true.""" + + DisableMusic: bool = False + """Disables all music tracks when true.""" + + DisableSoundEffects: bool = False + """Disables all sound effects when true.""" + + MissileLimit: Typeu8 = 3 + """Changes how many missiles can be on-screen at a time. The vanilla game has it set to 2, the randomizer changes it to 3 by default. Zero Mission uses 4.""" + + UnexploredMap: bool = False + """When enabled, starts you with a map where all unexplored items and non-visited tiles have a gray background. This is different from the downloaded map stations where there, the full tile is gray.""" + + PowerBombsWithoutBombs: bool = False + """When enabled, lets you use Power Bombs without needing to collect Bomb Data.""" + + AccessibilityPatches: bool = False + """Whether to apply patches for better accessibility.""" + + LevelEdits: dict[Areaidkey, dict[str, MarsschemamfLeveledits]] + """Specifies room edits that should be done. These will be applied last.""" + + MinimapEdits: dict[Minimapidkey, list[MarsschemamfMinimapeditsItem]] + """Specifies minimap edits that should be done.""" + + HideDoorsOnMinimap: bool = False + """When enabled, hides doors on the minimap. This is automatically enabled when the 'DoorLocks' field is provided.""" + + RoomNames: typ.Annotated[list[MarsschemamfRoomnamesItem], 'Unique items'] + """Specifies a name to be displayed when the A Button is pressed on the pause menu.""" + + RevealHiddenTiles: bool = False + """When enabled, reveals normally hidden blocks that are breakable by upgrades. Hidden pickup tanks are not revealed regardless of this setting.""" + +MarsSchemaMF: typ.TypeAlias = Marsschemamf diff --git a/src/mars_patcher/data/base_minimap_edits.json b/src/mars_patcher/mf/data/base_minimap_edits.json similarity index 100% rename from src/mars_patcher/data/base_minimap_edits.json rename to src/mars_patcher/mf/data/base_minimap_edits.json diff --git a/src/mars_patcher/data/char_map_mf.json b/src/mars_patcher/mf/data/char_map_mf.json similarity index 100% rename from src/mars_patcher/data/char_map_mf.json rename to src/mars_patcher/mf/data/char_map_mf.json diff --git a/src/mars_patcher/data/locations.json b/src/mars_patcher/mf/data/locations.json similarity index 100% rename from src/mars_patcher/data/locations.json rename to src/mars_patcher/mf/data/locations.json diff --git a/src/mars_patcher/data/main_hub.gfx.lz b/src/mars_patcher/mf/data/main_hub.gfx.lz similarity index 100% rename from src/mars_patcher/data/main_hub.gfx.lz rename to src/mars_patcher/mf/data/main_hub.gfx.lz diff --git a/src/mars_patcher/data/main_hub_tilemap.bin b/src/mars_patcher/mf/data/main_hub_tilemap.bin similarity index 100% rename from src/mars_patcher/data/main_hub_tilemap.bin rename to src/mars_patcher/mf/data/main_hub_tilemap.bin diff --git a/src/mars_patcher/data/patches/mf_u/asm/.gitignore b/src/mars_patcher/mf/data/patches/mf_u/asm/.gitignore similarity index 100% rename from src/mars_patcher/data/patches/mf_u/asm/.gitignore rename to src/mars_patcher/mf/data/patches/mf_u/asm/.gitignore diff --git a/src/mars_patcher/data/patches/mf_u/free_space.txt b/src/mars_patcher/mf/data/patches/mf_u/free_space.txt similarity index 100% rename from src/mars_patcher/data/patches/mf_u/free_space.txt rename to src/mars_patcher/mf/data/patches/mf_u/free_space.txt diff --git a/src/mars_patcher/data/patches/mf_u/stereo_default.ips b/src/mars_patcher/mf/data/patches/mf_u/stereo_default.ips similarity index 100% rename from src/mars_patcher/data/patches/mf_u/stereo_default.ips rename to src/mars_patcher/mf/data/patches/mf_u/stereo_default.ips diff --git a/src/mars_patcher/data/schema.json b/src/mars_patcher/mf/data/schema.json similarity index 100% rename from src/mars_patcher/data/schema.json rename to src/mars_patcher/mf/data/schema.json diff --git a/src/mars_patcher/mf/patcher.py b/src/mars_patcher/mf/patcher.py new file mode 100644 index 0000000..af7dcbf --- /dev/null +++ b/src/mars_patcher/mf/patcher.py @@ -0,0 +1,181 @@ +from collections.abc import Callable +from os import PathLike + +from mars_patcher.connections import Connections +from mars_patcher.credits import write_credits +from mars_patcher.door_locks import set_door_locks +from mars_patcher.item_patcher import ItemPatcher, set_required_metroid_count, set_tank_increments +from mars_patcher.level_edits import apply_level_edits +from mars_patcher.locations import LocationSettings +from mars_patcher.mf.auto_generated_types import MarsSchemaMF +from mars_patcher.minimap import apply_base_minimap_edits, apply_minimap_edits +from mars_patcher.misc_patches import ( + apply_accessibility_patch, + apply_base_patch, + apply_pbs_without_bombs, + apply_reveal_hidden_tiles, + apply_reveal_unexplored_doors, + apply_unexplored_map, + change_missile_limit, + disable_demos, + disable_music, + disable_sound_effects, + skip_door_transitions, + stereo_default, +) +from mars_patcher.navigation_text import NavigationText +from mars_patcher.random_palettes import PaletteRandomizer, PaletteSettings +from mars_patcher.rom import Rom +from mars_patcher.room_names import write_room_names +from mars_patcher.starting import set_starting_items, set_starting_location +from mars_patcher.text import write_seed_hash +from mars_patcher.titlescreen_text import write_title_text + + +def patch_mf( + rom: Rom, + output_path: str | PathLike[str], + patch_data: MarsSchemaMF, + status_update: Callable[[str, float], None], +) -> None: + """ + Creates a new randomized Fusion game, based off of an input path, an output path, + a dictionary defining how the game should be randomized, and a status update function. + + Args: + rom: Rom object for an unmodified Metroid Fusion (U) ROM. + output_path: The path where the randomized Fusion ROM should be saved to. + patch_data: A dictionary defining how the game should be randomized. + This function assumes that it satisfies the needed schema. To validate it, use + validate_patch_data_mf(). + status_update: A function taking in a message (str) and a progress value (float). + """ + + # Apply base asm patch first + apply_base_patch(rom) + + # Randomize palettes - palettes are randomized first in case the item + # patcher needs to copy tilesets + if "Palettes" in patch_data: + status_update("Randomizing palettes...", -1) + pal_settings = PaletteSettings.from_json(patch_data["Palettes"]) + pal_randomizer = PaletteRandomizer(rom, pal_settings) + pal_randomizer.randomize() + + # Load locations and set assignments + status_update("Writing item assignments...", -1) + loc_settings = LocationSettings.initialize() + loc_settings.set_assignments(patch_data["Locations"]) + item_patcher = ItemPatcher(rom, loc_settings) + item_patcher.write_items() + + # Required metroid count + set_required_metroid_count(rom, patch_data["RequiredMetroidCount"]) + + # Starting location + if "StartingLocation" in patch_data: + status_update("Writing starting location...", -1) + set_starting_location(rom, patch_data["StartingLocation"]) + + # Starting items + if "StartingItems" in patch_data: + status_update("Writing starting items...", -1) + set_starting_items(rom, patch_data["StartingItems"]) + + # Tank increments + if "TankIncrements" in patch_data: + status_update("Writing tank increments...", -1) + set_tank_increments(rom, patch_data["TankIncrements"]) + + # Elevator connections + conns = None + if "ElevatorConnections" in patch_data: + status_update("Writing elevator connections...", -1) + conns = Connections(rom) + conns.set_elevator_connections(patch_data["ElevatorConnections"]) + + # Sector shortcuts + if "SectorShortcuts" in patch_data: + status_update("Writing sector shortcuts...", -1) + if conns is None: + conns = Connections(rom) + conns.set_shortcut_connections(patch_data["SectorShortcuts"]) + + # Hints + if nav_text := patch_data.get("NavigationText", {}): + status_update("Writing navigation text...", -1) + navigation_text = NavigationText.from_json(nav_text) + navigation_text.write(rom) + + if nav_locks := patch_data.get("NavStationLocks", {}): + status_update("Writing navigation locks...", -1) + NavigationText.apply_hint_security(rom, nav_locks) + + # Room Names + if room_names := patch_data.get("RoomNames", []): + status_update("Writing room names...", -1) + write_room_names(rom, room_names) + + # Credits + if credits_text := patch_data.get("CreditsText", []): + status_update("Writing credits text...", -1) + write_credits(rom, credits_text) + + # Misc patches + if patch_data.get("AccessibilityPatches"): + apply_accessibility_patch(rom) + + if patch_data.get("DisableDemos"): + disable_demos(rom) + + if patch_data.get("SkipDoorTransitions"): + skip_door_transitions(rom) + + if patch_data.get("StereoDefault", True): + stereo_default(rom) + + if patch_data.get("DisableMusic"): + disable_music(rom) + + if patch_data.get("DisableSoundEffects"): + disable_sound_effects(rom) + + if "MissileLimit" in patch_data: + change_missile_limit(rom, patch_data["MissileLimit"]) + + if patch_data.get("PowerBombsWithoutBombs"): + apply_pbs_without_bombs(rom) + + if patch_data.get("UnexploredMap"): + apply_unexplored_map(rom) + + if not patch_data.get("HideDoorsOnMinimap", False): + apply_reveal_unexplored_doors(rom) + + if patch_data.get("RevealHiddenTiles"): + apply_reveal_hidden_tiles(rom) + + if "LevelEdits" in patch_data: + apply_level_edits(rom, patch_data["LevelEdits"]) + + # Apply base minimap edits + apply_base_minimap_edits(rom) + + # Apply JSON minimap edits + if "MinimapEdits" in patch_data: + apply_minimap_edits(rom, patch_data["MinimapEdits"]) + + # Door locks + if door_locks := patch_data.get("DoorLocks", []): + status_update("Writing door locks...", -1) + set_door_locks(rom, door_locks) + + write_seed_hash(rom, patch_data["SeedHash"]) + + # Title-screen text + if title_screen_text := patch_data.get("TitleText"): + status_update("Writing title screen text...", -1) + write_title_text(rom, title_screen_text) + + rom.save(output_path) + status_update(f"Output written to {output_path}", -1) diff --git a/src/mars_patcher/patcher.py b/src/mars_patcher/patcher.py index d6d4e24..df6ad24 100644 --- a/src/mars_patcher/patcher.py +++ b/src/mars_patcher/patcher.py @@ -5,198 +5,64 @@ from jsonschema import validate -from mars_patcher.auto_generated_types import MarsSchema -from mars_patcher.connections import Connections -from mars_patcher.credits import write_credits -from mars_patcher.data import get_data_path -from mars_patcher.door_locks import set_door_locks -from mars_patcher.item_patcher import ItemPatcher, set_required_metroid_count, set_tank_increments -from mars_patcher.level_edits import apply_level_edits -from mars_patcher.locations import LocationSettings -from mars_patcher.minimap import apply_base_minimap_edits, apply_minimap_edits -from mars_patcher.misc_patches import ( - apply_accessibility_patch, - apply_base_patch, - apply_pbs_without_bombs, - apply_reveal_hidden_tiles, - apply_reveal_unexplored_doors, - apply_unexplored_map, - change_missile_limit, - disable_demos, - disable_music, - disable_sound_effects, - skip_door_transitions, - stereo_default, -) -from mars_patcher.navigation_text import NavigationText -from mars_patcher.random_palettes import PaletteRandomizer, PaletteSettings +import mars_patcher.data as data_mf +import mars_patcher.zm.data as data_zm +from mars_patcher.mf.auto_generated_types import MarsSchemaMF +from mars_patcher.mf.patcher import patch_mf from mars_patcher.rom import Rom -from mars_patcher.room_names import write_room_names -from mars_patcher.starting import set_starting_items, set_starting_location -from mars_patcher.text import write_seed_hash -from mars_patcher.titlescreen_text import write_title_text +from mars_patcher.zm.auto_generated_types import MarsSchemaZM +from mars_patcher.zm.patcher import patch_zm -def validate_patch_data(patch_data: dict) -> MarsSchema: +def validate_patch_data_mf(patch_data: dict) -> MarsSchemaMF: """ Validates whether the specified patch_data satisfies the schema for it. Raises: ValidationError: If the patch data does not satisfy the schema. """ - with open(get_data_path("schema.json")) as f: + with open(data_mf.get_data_path("schema.json")) as f: schema = json.load(f) validate(patch_data, schema) - return typing.cast("MarsSchema", patch_data) + return typing.cast("MarsSchemaMF", patch_data) + + +def validate_patch_data_zm(patch_data: dict) -> MarsSchemaZM: + """ + Validates whether the specified patch_data satisfies the schema for it. + + Raises: + ValidationError: If the patch data does not satisfy the schema. + """ + with open(data_zm.get_data_path("schema.json")) as f: + schema = json.load(f) + validate(patch_data, schema) + return typing.cast("MarsSchemaZM", patch_data) def patch( input_path: str | PathLike[str], output_path: str | PathLike[str], - patch_data: MarsSchema, + patch_data: dict, status_update: Callable[[str, float], None], ) -> None: """ - Creates a new randomized Fusion game, based off of an input path, an output path, + Creates a new randomized GBA Metroid game, based off of an input path, an output path, a dictionary defining how the game should be randomized, and a status update function. Args: - input_path: The path to an unmodified Metroid Fusion (U) ROM. - output_path: The path where the randomized Fusion ROM should be saved to. + input_path: The path to an unmodified GBA Metroid (U) ROM. + output_path: The path where the randomized GBA Metroid ROM should be saved to. patch_data: A dictionary defining how the game should be randomized. - This function assumes that it satisfies the needed schema. To validate it, use - validate_patch_data(). status_update: A function taking in a message (str) and a progress value (float). """ # Load input rom rom = Rom(input_path) - # Apply base asm patch first - apply_base_patch(rom) - - # Randomize palettes - palettes are randomized first in case the item - # patcher needs to copy tilesets - if "Palettes" in patch_data: - status_update("Randomizing palettes...", -1) - pal_settings = PaletteSettings.from_json(patch_data["Palettes"]) - pal_randomizer = PaletteRandomizer(rom, pal_settings) - pal_randomizer.randomize() - - # Load locations and set assignments - status_update("Writing item assignments...", -1) - loc_settings = LocationSettings.initialize() - loc_settings.set_assignments(patch_data["Locations"]) - item_patcher = ItemPatcher(rom, loc_settings) - item_patcher.write_items() - - # Required metroid count - set_required_metroid_count(rom, patch_data["RequiredMetroidCount"]) - - # Starting location - if "StartingLocation" in patch_data: - status_update("Writing starting location...", -1) - set_starting_location(rom, patch_data["StartingLocation"]) - - # Starting items - if "StartingItems" in patch_data: - status_update("Writing starting items...", -1) - set_starting_items(rom, patch_data["StartingItems"]) - - # Tank increments - if "TankIncrements" in patch_data: - status_update("Writing tank increments...", -1) - set_tank_increments(rom, patch_data["TankIncrements"]) - - # Elevator connections - conns = None - if "ElevatorConnections" in patch_data: - status_update("Writing elevator connections...", -1) - conns = Connections(rom) - conns.set_elevator_connections(patch_data["ElevatorConnections"]) - - # Sector shortcuts - if "SectorShortcuts" in patch_data: - status_update("Writing sector shortcuts...", -1) - if conns is None: - conns = Connections(rom) - conns.set_shortcut_connections(patch_data["SectorShortcuts"]) - - # Hints - if nav_text := patch_data.get("NavigationText", {}): - status_update("Writing navigation text...", -1) - navigation_text = NavigationText.from_json(nav_text) - navigation_text.write(rom) - - if nav_locks := patch_data.get("NavStationLocks", {}): - status_update("Writing navigation locks...", -1) - NavigationText.apply_hint_security(rom, nav_locks) - - # Room Names - if room_names := patch_data.get("RoomNames", []): - status_update("Writing room names...", -1) - write_room_names(rom, room_names) - - # Credits - if credits_text := patch_data.get("CreditsText", []): - status_update("Writing credits text...", -1) - write_credits(rom, credits_text) - - # Misc patches - if patch_data.get("AccessibilityPatches"): - apply_accessibility_patch(rom) - - if patch_data.get("DisableDemos"): - disable_demos(rom) - - if patch_data.get("SkipDoorTransitions"): - skip_door_transitions(rom) - - if patch_data.get("StereoDefault", True): - stereo_default(rom) - - if patch_data.get("DisableMusic"): - disable_music(rom) - - if patch_data.get("DisableSoundEffects"): - disable_sound_effects(rom) - - if "MissileLimit" in patch_data: - change_missile_limit(rom, patch_data["MissileLimit"]) - - if patch_data.get("PowerBombsWithoutBombs"): - apply_pbs_without_bombs(rom) - - if patch_data.get("UnexploredMap"): - apply_unexplored_map(rom) - - if not patch_data.get("HideDoorsOnMinimap", False): - apply_reveal_unexplored_doors(rom) - - if patch_data.get("RevealHiddenTiles"): - apply_reveal_hidden_tiles(rom) - - if "LevelEdits" in patch_data: - apply_level_edits(rom, patch_data["LevelEdits"]) - - # Apply base minimap edits - apply_base_minimap_edits(rom) - - # Apply JSON minimap edits - if "MinimapEdits" in patch_data: - apply_minimap_edits(rom, patch_data["MinimapEdits"]) - - # Door locks - if door_locks := patch_data.get("DoorLocks", []): - status_update("Writing door locks...", -1) - set_door_locks(rom, door_locks) - - write_seed_hash(rom, patch_data["SeedHash"]) - - # Title-screen text - if title_screen_text := patch_data.get("TitleText"): - status_update("Writing title screen text...", -1) - write_title_text(rom, title_screen_text) - - rom.save(output_path) - status_update(f"Output written to {output_path}", -1) + if rom.is_mf(): + patch_mf(rom, output_path, validate_patch_data_mf(patch_data), status_update) + elif rom.is_zm(): + patch_zm(rom, output_path, validate_patch_data_zm(patch_data), status_update) + else: + raise ValueError(rom) diff --git a/src/mars_patcher/zm/auto_generated_types.py b/src/mars_patcher/zm/auto_generated_types.py new file mode 100644 index 0000000..0d9268c --- /dev/null +++ b/src/mars_patcher/zm/auto_generated_types.py @@ -0,0 +1,520 @@ +# This file is generated. Manual changes will be lost +# fmt: off +# ruff: noqa +# mypy: disable-error-code="misc" +from __future__ import annotations + +import typing_extensions as typ + + +# Definitions +Seed: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 2147483647'] +TypeU4: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 15'] +TypeU5: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 31'] +TypeU8: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 255'] +TypeU10: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 1023'] +AreaId: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 6'] +AreaIdKey = typ.Literal[ + '0', + '1', + '2', + '3', + '4', + '5', + '6' +] +MinimapIdKey = typ.Literal[ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10' +] +HueRotation: typ.TypeAlias = typ.Annotated[int, '0 <= value <= 360'] +ValidSources = typ.Literal[ + 'LONG_BEAM', + 'CHARGE_BEAM', + 'ICE_BEAM', + 'WAVE_BEAM', + 'PLASMA_BEAM', + 'BOMBS', + 'VARIA_SUIT', + 'GRAVITY_SUIT', + 'MORPH_BALL', + 'SPEED_BOOSTER', + 'HI_JUMP', + 'SCREW_ATTACK', + 'SPACE_JUMP', + 'POWER_GRIP', + 'FULLY_POWERED', + 'ZIPLINES' +] +ValidItems = typ.Literal[ + 'NONE', + 'ENERGY_TANK', + 'MISSILE_TANK', + 'SUPER_MISSILE_TANK', + 'POWER_BOMB_TANK', + 'LONG_BEAM', + 'CHARGE_BEAM', + 'ICE_BEAM', + 'WAVE_BEAM', + 'PLASMA_BEAM', + 'BOMBS', + 'VARIA_SUIT', + 'GRAVITY_SUIT', + 'MORPH_BALL', + 'SPEED_BOOSTER', + 'HI_JUMP', + 'SCREW_ATTACK', + 'SPACE_JUMP', + 'POWER_GRIP', + 'FULLY_POWERED', + 'ZIPLINES', + 'ICE_TRAP' +] +ValidItemSprites = typ.Literal[ + 'DEFAULT', + 'EMPTY', + 'ENERGY_TANK', + 'MISSILE_TANK', + 'SUPER_MISSILE_TANK', + 'POWER_BOMB_TANK', + 'LONG_BEAM', + 'CHARGE_BEAM', + 'ICE_BEAM', + 'WAVE_BEAM', + 'PLASMA_BEAM', + 'BOMBS', + 'VARIA_SUIT', + 'GRAVITY_SUIT', + 'MORPH_BALL', + 'SPEED_BOOSTER', + 'HI_JUMP', + 'SCREW_ATTACK', + 'SPACE_JUMP', + 'POWER_GRIP', + 'FULLY_POWERED', + 'ZIPLINES', + 'ANONYMOUS', + 'SHINY_MISSILE_TANK', + 'SHINY_POWER_BOMB_TANK' +] +ValidAbilities = typ.Literal[ + 'LONG_BEAM', + 'CHARGE_BEAM', + 'ICE_BEAM', + 'WAVE_BEAM', + 'PLASMA_BEAM', + 'BOMBS', + 'VARIA_SUIT', + 'GRAVITY_SUIT', + 'MORPH_BALL', + 'SPEED_BOOSTER', + 'HI_JUMP', + 'SCREW_ATTACK', + 'SPACE_JUMP', + 'POWER_GRIP' +] +ValidElevatorTops = typ.Literal[ + 'BRINSTAR_TO_KRAID', + 'BRINSTAR_TO_NORFAIR', + 'BRINSTAR_TO_TOURIAN', + 'NORFAIR_TO_RIDLEY', + 'CRATERIA_TO_TOURIAN' +] +ValidElevatorBottoms = typ.Literal[ + 'KRAID_TO_BRINSTAR', + 'NORFAIR_TO_BRINSTAR', + 'TOURIAN_TO_BRINSTAR', + 'RIDLEY_TO_NORFAIR', + 'TOURIAN_TO_CRATERIA' +] +ValidLanguages = typ.Literal[ + 'JAPANESE_KANJI', + 'JAPANESE_HIRAGANA', + 'ENGLISH', + 'GERMAN', + 'FRENCH', + 'ITALIAN', + 'SPANISH' +] +MessageLanguages: typ.TypeAlias = dict[ValidLanguages, str] + +class ItemMessages(typ.TypedDict, total=False): + kind: typ.Required[ItemMessagesKind] + languages: MessageLanguages + centered: bool = True + message_id: typ.Annotated[int, '0 <= value <= 56'] + """The Message ID, will display one of the predefined messages in the ROM""" + +ItemMessagesKind = typ.Literal[ + 'CUSTOM_MESSAGE', + 'MESSAGE_ID' +] +Jingle = typ.Literal[ + 'DEFAULT', + 'MINOR', + 'MAJOR', + 'UNKNOWN', + 'FULLY_POWERED' +] +HintLocations = typ.Literal[ + 'NONE', + 'LONG_BEAM', + 'BOMBS', + 'ICE_BEAM', + 'SPEED_BOOSTER', + 'HIGH_JUMP', + 'VARIA_SUIT', + 'WAVE_BEAM', + 'SCREW_ATTACK' +] + +class BlockLayerItem(typ.TypedDict, total=False): + x: TypeU8 + """The X position in the room that should get edited.""" + + y: TypeU8 + """The Y position in the room that should get edited.""" + + value: TypeU10 + """The value that should be used to edit the room. For backgrounds, this is calculated via `((Row-1) * ColumnsInTileset) + (Column-1)`.""" + +BlockLayer: typ.TypeAlias = typ.Annotated[list[BlockLayerItem], 'Unique items'] + +# Schema entries + +class MarsschemazmLocationsMajorLocationsItem(typ.TypedDict): + source: ValidSources + """Valid major locations.""" + + item: ValidItems + """Valid items for shuffling.""" + + item_messages: typ.NotRequired[ItemMessages] + jingle: Jingle + +class MarsschemazmLocationsMinorLocationsItem(typ.TypedDict): + area: AreaId + """The area ID where this item is located.""" + + room: TypeU8 + """The room ID where this item is located.""" + + block_x: TypeU8 + """The X-coordinate in the room where this item is located.""" + + block_y: TypeU8 + """The Y-coordinate in the room where this item is located.""" + + item: ValidItems + """Valid items for shuffling.""" + + item_sprite: typ.NotRequired[ValidItemSprites] + """Valid graphics for item tanks/sprites.""" + + item_messages: typ.NotRequired[ItemMessages] + jingle: Jingle + hinted_by: typ.NotRequired[HintLocations] + """The hint location (Chozo statue) that hints to this item's location ('None' if not hinted by anything).""" + + +class MarsschemazmLocations(typ.TypedDict): + """Specifies how the item locations in the game should be changed.""" + + major_locations: typ.Annotated[list[MarsschemazmLocationsMajorLocationsItem], 'len() == 16', 'Unique items'] + """Specifies how the major item locations should be changed. A major item location is a location where an item is obtained from a sprite or interacting with a device.""" + + minor_locations: typ.Annotated[list[MarsschemazmLocationsMinorLocationsItem], 'len() == 100', 'Unique items'] + """Specifies how the minor item locations should be changed. A minor item location is a location where an item is obtained by touching a tank block. _tank clipdata is required at each location, the patcher does not modify any clipdata for minor locations.""" + + +class MarsschemazmStartingLocation(typ.TypedDict): + """The location the player should spawn at the start of the game.""" + + area: AreaId + """The area ID of the starting location.""" + + room: TypeU8 + """The room ID of the starting location.""" + + block_x: TypeU8 + """The X-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration.""" + + block_y: TypeU8 + """The Y-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration.""" + +MarsschemazmStartingItemsSuitType = typ.Literal[ + 'SUITLESS', + 'NORMAL', + 'FULLY_POWERED' +] + +class MarsschemazmStartingItems(typ.TypedDict, total=False): + energy: typ.Annotated[int, '1 <= value <= 1299'] = 99 + """How much energy the player should start with on a new save file.""" + + missiles: typ.Annotated[int, '0 <= value <= 999'] = 0 + """How many missiles the player should start with on a new save file.""" + + super_missiles: typ.Annotated[int, '0 <= value <= 99'] = 0 + """How many missiles the player should start with on a new save file.""" + + power_bombs: typ.Annotated[int, '0 <= value <= 99'] = 0 + """How many power bombs the player should start with on a new save file.""" + + abilities: typ.Annotated[list[ValidAbilities], 'Unique items'] = [] + """Which abilities the player should start with on a new save file.""" + + downloaded_maps: typ.Annotated[list[AreaId], 'Unique items'] = [] + """Which area maps will be downloaded from the start.""" + + suit_type: MarsschemazmStartingItemsSuitType = 'normal' + """Which suit type the player should start with.""" + + ziplines_activated: bool = False + """Whether the ziplines should be activated from the start.""" + + +class MarsschemazmTankIncrements(typ.TypedDict): + """How much ammo/health tanks provide when collected.""" + + energy_tank: typ.Annotated[int, '-1300 <= value <= 1300'] = 100 + """How much health energy tanks provide when collected.""" + + missile_tank: typ.Annotated[int, '-1000 <= value <= 1000'] = 5 + """How much ammo missile tanks provide when collected.""" + + super_missile_tank: typ.Annotated[int, '-100 <= value <= 100'] = 2 + """How much ammo super missile tanks provide when collected.""" + + power_bomb_tank: typ.Annotated[int, '-100 <= value <= 100'] = 2 + """How much ammo power bomb tanks provide when collected.""" + + +class MarsschemazmElevatorConnections(typ.TypedDict): + """Defines the elevator that each elevator connects to.""" + + elevator_tops: typ.Annotated[dict[ValidElevatorTops, ValidElevatorBottoms], 'len() >= 10'] + """Defines the bottom elevator that each top elevator connects to.""" + + elevator_bottoms: typ.Annotated[dict[ValidElevatorBottoms, ValidElevatorTops], 'len() >= 10'] + """Defines the top elevator that each bottom elevator connects to.""" + +MarsschemazmDoorLocksItemLockType = typ.Literal[ + 'OPEN', + 'NORMAL', + 'MISSILE', + 'SUPER_MISSILE', + 'POWER_BOMB', + 'LOCKED' +] + +class MarsschemazmDoorLocksItem(typ.TypedDict): + area: AreaId + """The area ID where this door is located.""" + + door: TypeU8 + """The door ID of this door.""" + + lock_type: MarsschemazmDoorLocksItemLockType + """The type of cover on the hatch.""" + +MarsschemazmPalettesRandomizeKey = typ.Literal[ + 'tilesets', + 'enemies', + 'samus', + 'beams' +] + +@typ.final +class MarsschemazmPalettesRandomize(typ.TypedDict, total=False): + """The range to use for rotating palette hues.""" + + hue_min: HueRotation = None + """The minimum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.""" + + hue_max: HueRotation = None + """The maximum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.""" + + +MarsschemazmPalettesColorSpace = typ.Literal[ + 'HSV', + 'OKLAB' +] + +@typ.final +class MarsschemazmPalettes(typ.TypedDict, total=False): + """Properties for randomized in-game palettes.""" + + seed: Seed = None + """A number used to initialize the random number generator for palettes. If not specified, the patcher will randomly generate one.""" + + randomize: typ.Required[dict[MarsschemazmPalettesRandomizeKey, MarsschemazmPalettesRandomize]] + """What kind of palettes should be randomized.""" + + color_space: MarsschemazmPalettesColorSpace = 'OKLAB' + """The color space to use for rotating palette hues.""" + + symmetric: bool = True + """Randomly rotates hues in the positive or negative direction true.""" + + +class MarsschemazmTitleTextItem(typ.TypedDict, total=False): + text: typ.Annotated[str, '/^[ -~]{0,30}$/'] + """The ASCII text for this line""" + + line_num: typ.Annotated[int, '0 <= value <= 14'] +MarsschemazmCreditsTextItemLineType = typ.Literal[ + 'BLANK', + 'BLUE', + 'RED', + 'WHITE1', + 'WHITE2' +] + +class MarsschemazmCreditsTextItem(typ.TypedDict, total=False): + line_type: typ.Required[MarsschemazmCreditsTextItemLineType] + """The color and line height of the text (or blank).""" + + text: typ.Annotated[str, '/^[ -~]{0,34}$/'] + """The ASCII text for this line.""" + + blank_lines: TypeU8 = 0 + """Inserts the provided number of blank lines after the text line.""" + + centered: bool = True + """Centers the text horizontally when true.""" + + +@typ.final +class MarsschemazmLevelEdits(typ.TypedDict, total=False): + """Specifies the Room ID.""" + + bg1: BlockLayer + """The BG1 layer that should be edited.""" + + bg2: BlockLayer + """The BG2 layer that should be edited.""" + + clipdata: BlockLayer + """The Clipdata layer that should be edited.""" + + + + +class MarsschemazmMinimapEditsItem(typ.TypedDict, total=False): + x: TypeU5 + """The X position in the minimap that should get edited.""" + + y: TypeU5 + """The Y position in the minimap that should get edited.""" + + tile: TypeU10 + """The tile value that should be used to edit the minimap.""" + + palette: TypeU4 + """The palette row to use for the tile.""" + + h_flip: bool = False + """Whether the tile should be horizontally flipped or not.""" + + v_flip: bool = False + """Whether the tile should be vertically flipped or not.""" + + + +class MarsschemazmRoomNamesItem(typ.TypedDict): + area: AreaId + """The area ID where this room is located.""" + + room: TypeU8 = 0 + """The room ID.""" + + name: typ.Annotated[str, 'len() <= 112'] + """Specifies what text should appear for this room. Two lines are available, with an absolute maximum of 56 characters per line, if all characters used are small. Text will auto-wrap if the next word doesn't fit on the line. If the text is too long, it will be truncated. Use + to force a line break. If not provided, will display 'Unknown Room'.""" + + +class Marsschemazm(typ.TypedDict, total=False): + """ + Metroid Zero Mission patching schema + + A json schema describing the input for patching Metroid Zero Mission via mars_patcher. + """ + + seed_hash: typ.Required[typ.Annotated[str, '/^[0-9A-Z]{8}$/']] + """A seed hash that will be displayed on the file select screen.""" + + locations: typ.Required[MarsschemazmLocations] + """Specifies how the item locations in the game should be changed.""" + + starting_location: MarsschemazmStartingLocation + """The location the player should spawn at the start of the game.""" + + starting_items: MarsschemazmStartingItems = None + tank_increments: MarsschemazmTankIncrements = None + """How much ammo/health tanks provide when collected.""" + + elevator_connections: MarsschemazmElevatorConnections + """Defines the elevator that each elevator connects to.""" + + door_locks: list[MarsschemazmDoorLocksItem] + """List of all lockable doors and their lock type.""" + + palettes: MarsschemazmPalettes = None + """Properties for randomized in-game palettes.""" + + intro_text: dict[ValidLanguages, str] = None + """Specifies what text should appear during the new game intro.""" + + title_text: list[MarsschemazmTitleTextItem] = None + """Lines of ascii text to write to the title screen.""" + + credits_text: list[MarsschemazmCreditsTextItem] + """Lines of text to insert into the credits.""" + + disable_demos: bool = False + """Disables title screen demos when true.""" + + skip_door_transitions: bool = False + """Makes all door transitions instant when true.""" + + stereo_default: bool = True + """Forces stereo sound by default when true.""" + + disable_music: bool = False + """Disables all music tracks when true.""" + + disable_sound_effects: bool = False + """Disables all sound effects when true.""" + + unexplored_map: bool = False + """When enabled, starts you with a map where all unexplored items and non-visited tiles have a gray background. This is different from the downloaded map stations where there, the full tile is gray.""" + + accessibility_patches: bool = False + """Whether to apply patches for better accessibility.""" + + level_edits: dict[AreaIdKey, dict[str, MarsschemazmLevelEdits]] + """Specifies room edits that should be done. These will be applied last.""" + + minimap_edits: dict[MinimapIdKey, list[MarsschemazmMinimapEditsItem]] + """Specifies minimap edits that should be done.""" + + hide_doors_on_minimap: bool = False + """When enabled, hides doors on the minimap. This is automatically enabled when the 'DoorLocks' field is provided.""" + + room_names: typ.Annotated[list[MarsschemazmRoomNamesItem], 'Unique items'] + """Specifies a name to be displayed when the A Button is pressed on the pause menu.""" + + reveal_hidden_tiles: bool = False + """When enabled, reveals normally hidden blocks that are breakable by upgrades. Hidden pickup tanks are not revealed regardless of this setting.""" + +MarsSchemaZM: typ.TypeAlias = Marsschemazm diff --git a/src/mars_patcher/zm/data.py b/src/mars_patcher/zm/data.py new file mode 100644 index 0000000..145f897 --- /dev/null +++ b/src/mars_patcher/zm/data.py @@ -0,0 +1,6 @@ +import os +from pathlib import Path + + +def get_data_path(*path: str | os.PathLike) -> str: + return os.fspath(Path(__file__).parent.joinpath("zm", "data", *path)) diff --git a/src/mars_patcher/zm/data/schema.json b/src/mars_patcher/zm/data/schema.json new file mode 100644 index 0000000..afe7bb3 --- /dev/null +++ b/src/mars_patcher/zm/data/schema.json @@ -0,0 +1,883 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Metroid Zero Mission patching schema", + "description": "A json schema describing the input for patching Metroid Zero Mission via mars_patcher.", + "type": "object", + "properties": { + "seed_hash": { + "description": "A seed hash that will be displayed on the file select screen.", + "type": "string", + "pattern": "^[0-9A-Z]{8}$" + }, + "locations": { + "type": "object", + "description": "Specifies how the item locations in the game should be changed.", + "properties": { + "major_locations": { + "type": "array", + "description": "Specifies how the major item locations should be changed. A major item location is a location where an item is obtained from a sprite or interacting with a device.", + "minItems": 16, + "maxItems": 16, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/valid_sources" + }, + "item": { + "$ref": "#/$defs/valid_items" + }, + "item_messages": { + "$ref": "#/$defs/item_messages" + }, + "jingle": { + "$ref": "#/$defs/jingle" + } + }, + "required": [ + "source", + "item", + "jingle" + ] + } + }, + "minor_locations": { + "type": "array", + "description": "Specifies how the minor item locations should be changed. A minor item location is a location where an item is obtained by touching a tank block. _tank clipdata is required at each location, the patcher does not modify any clipdata for minor locations.", + "minItems": 100, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "area": { + "$ref": "#/$defs/area_id", + "description": "The area ID where this item is located." + }, + "room": { + "$ref": "#/$defs/type_u8", + "description": "The room ID where this item is located." + }, + "block_x": { + "$ref": "#/$defs/type_u8", + "description": "The X-coordinate in the room where this item is located." + }, + "block_y": { + "$ref": "#/$defs/type_u8", + "description": "The Y-coordinate in the room where this item is located." + }, + "item": { + "$ref": "#/$defs/valid_items" + }, + "item_sprite": { + "$ref": "#/$defs/valid_item_sprites" + }, + "item_messages": { + "$ref": "#/$defs/item_messages" + }, + "jingle": { + "$ref": "#/$defs/jingle" + }, + "hinted_by": { + "$ref": "#/$defs/hint_locations", + "description": "The hint location (Chozo statue) that hints to this item's location ('None' if not hinted by anything)." + } + }, + "required": [ + "area", + "room", + "block_x", + "block_y", + "item", + "jingle" + ] + } + } + }, + "required": [ + "minor_locations", + "major_locations" + ] + }, + "starting_location": { + "type": "object", + "description": "The location the player should spawn at the start of the game.", + "properties": { + "area": { + "$ref": "#/$defs/area_id", + "description": "The area ID of the starting location." + }, + "room": { + "$ref": "#/$defs/type_u8", + "description": "The room ID of the starting location." + }, + "block_x": { + "$ref": "#/$defs/type_u8", + "description": "The X-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration." + }, + "block_y": { + "$ref": "#/$defs/type_u8", + "description": "The Y-coordinate in the room where the player should spawn. If the room contains a save station, then this value will not be taken into consideration." + } + }, + "required": [ + "area", + "room", + "block_x", + "block_y" + ] + }, + "starting_items": { + "type": "object", + "properties": { + "energy": { + "type": "integer", + "description": "How much energy the player should start with on a new save file.", + "minimum": 1, + "maximum": 1299, + "default": 99 + }, + "missiles": { + "type": "integer", + "description": "How many missiles the player should start with on a new save file.", + "minimum": 0, + "maximum": 999, + "default": 0 + }, + "super_missiles": { + "type": "integer", + "description": "How many missiles the player should start with on a new save file.", + "minimum": 0, + "maximum": 99, + "default": 0 + }, + "power_bombs": { + "type": "integer", + "description": "How many power bombs the player should start with on a new save file.", + "minimum": 0, + "maximum": 99, + "default": 0 + }, + "abilities": { + "type": "array", + "description": "Which abilities the player should start with on a new save file.", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/valid_abilities" + }, + "default": [] + }, + "downloaded_maps": { + "type": "array", + "description": "Which area maps will be downloaded from the start.", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/area_id" + }, + "default": [] + }, + "suit_type": { + "type": "string", + "description": "Which suit type the player should start with.", + "enum": [ + "SUITLESS", + "NORMAL", + "FULLY_POWERED" + ], + "default": "normal" + }, + "ziplines_activated": { + "type": "boolean", + "description": "Whether the ziplines should be activated from the start.", + "default": false + } + }, + "default": null + }, + "tank_increments": { + "type": "object", + "description": "How much ammo/health tanks provide when collected.", + "properties": { + "energy_tank": { + "type": "integer", + "description": "How much health energy tanks provide when collected.", + "minimum": -1300, + "maximum": 1300, + "default": 100 + }, + "missile_tank": { + "type": "integer", + "description": "How much ammo missile tanks provide when collected.", + "minimum": -1000, + "maximum": 1000, + "default": 5 + }, + "super_missile_tank": { + "type": "integer", + "description": "How much ammo super missile tanks provide when collected.", + "minimum": -100, + "maximum": 100, + "default": 2 + }, + "power_bomb_tank": { + "type": "integer", + "description": "How much ammo power bomb tanks provide when collected.", + "minimum": -100, + "maximum": 100, + "default": 2 + } + }, + "required": [ + "energy_tank", + "missile_tank", + "super_missile_tank", + "power_bomb_tank" + ], + "default": null + }, + "elevator_connections": { + "type": "object", + "description": "Defines the elevator that each elevator connects to.", + "properties": { + "elevator_tops": { + "type": "object", + "description": "Defines the bottom elevator that each top elevator connects to.", + "propertyNames": { + "$ref": "#/$defs/valid_elevator_tops" + }, + "additionalProperties": { + "$ref": "#/$defs/valid_elevator_bottoms" + }, + "minProperties": 10 + }, + "elevator_bottoms": { + "type": "object", + "description": "Defines the top elevator that each bottom elevator connects to.", + "propertyNames": { + "$ref": "#/$defs/valid_elevator_bottoms" + }, + "additionalProperties": { + "$ref": "#/$defs/valid_elevator_tops" + }, + "minProperties": 10 + } + }, + "required": [ + "elevator_tops", + "elevator_bottoms" + ] + }, + "door_locks": { + "type": "array", + "description": "List of all lockable doors and their lock type.", + "items": { + "type": "object", + "properties": { + "area": { + "$ref": "#/$defs/area_id", + "description": "The area ID where this door is located." + }, + "door": { + "$ref": "#/$defs/type_u8", + "description": "The door ID of this door." + }, + "lock_type": { + "type": "string", + "description": "The type of cover on the hatch.", + "enum": [ + "OPEN", + "NORMAL", + "MISSILE", + "SUPER_MISSILE", + "POWER_BOMB", + "LOCKED" + ] + } + }, + "required": [ + "area", + "door", + "lock_type" + ] + } + }, + "palettes": { + "type": "object", + "description": "Properties for randomized in-game palettes.", + "properties": { + "seed": { + "$ref": "#/$defs/seed", + "description": "A number used to initialize the random number generator for palettes. If not specified, the patcher will randomly generate one.", + "default": null + }, + "randomize": { + "type": "object", + "description": "What kind of palettes should be randomized.", + "propertyNames": { + "type": "string", + "enum": [ + "tilesets", + "enemies", + "samus", + "beams" + ] + }, + "additionalProperties": { + "type": "object", + "description": "The range to use for rotating palette hues.", + "properties": { + "hue_min": { + "$ref": "#/$defs/hue_rotation", + "description": "The minimum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.", + "default": null + }, + "hue_max": { + "$ref": "#/$defs/hue_rotation", + "description": "The maximum value to use for rotating palette hues. If not specified, the patcher will randomly generate one.", + "default": null + } + }, + "additionalProperties": false + } + }, + "color_space": { + "type": "string", + "description": "The color space to use for rotating palette hues.", + "enum": [ + "HSV", + "OKLAB" + ], + "default": "OKLAB" + }, + "symmetric": { + "type": "boolean", + "description": "Randomly rotates hues in the positive or negative direction true.", + "default": true + } + }, + "additionalProperties": false, + "required": [ + "randomize" + ], + "default": null + }, + "intro_text": { + "type": "object", + "description": "Specifies what text should appear during the new game intro.", + "propertyNames": { + "$ref": "#/$defs/valid_languages" + }, + "additionalProperties": { + "type": "string", + "description": "The language specific text used for the intro" + }, + "default": null + }, + "title_text": { + "type": "array", + "description": "Lines of ascii text to write to the title screen.", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The ASCII text for this line", + "pattern": "^[ -~]{0,30}$" + }, + "line_num": { + "type": "integer", + "minimum": 0, + "maximum": 14 + } + } + }, + "default": null + }, + "credits_text": { + "type": "array", + "description": "Lines of text to insert into the credits.", + "items": { + "type": "object", + "properties": { + "line_type": { + "type": "string", + "description": "The color and line height of the text (or blank).", + "enum": [ + "BLANK", + "BLUE", + "RED", + "WHITE1", + "WHITE2" + ] + }, + "text": { + "type": "string", + "description": "The ASCII text for this line.", + "pattern": "^[ -~]{0,34}$" + }, + "blank_lines": { + "$ref": "#/$defs/type_u8", + "description": "Inserts the provided number of blank lines after the text line.", + "default": 0 + }, + "centered": { + "type": "boolean", + "description": "Centers the text horizontally when true.", + "default": true + } + }, + "required": [ + "line_type" + ] + } + }, + "disable_demos": { + "type": "boolean", + "description": "Disables title screen demos when true.", + "default": false + }, + "skip_door_transitions": { + "type": "boolean", + "description": "Makes all door transitions instant when true.", + "default": false + }, + "stereo_default": { + "type": "boolean", + "description": "Forces stereo sound by default when true.", + "default": true + }, + "disable_music": { + "type": "boolean", + "description": "Disables all music tracks when true.", + "default": false + }, + "disable_sound_effects": { + "type": "boolean", + "description": "Disables all sound effects when true.", + "default": false + }, + "unexplored_map": { + "type": "boolean", + "description": "When enabled, starts you with a map where all unexplored items and non-visited tiles have a gray background. This is different from the downloaded map stations where there, the full tile is gray.", + "default": false + }, + "accessibility_patches": { + "type": "boolean", + "description": "Whether to apply patches for better accessibility.", + "default": false + }, + "level_edits": { + "type": "object", + "description": "Specifies room edits that should be done. These will be applied last.", + "propertyNames": { + "description": "Specifies the Area ID.", + "$ref": "#/$defs/area_id_key" + }, + "additionalProperties": { + "type": "object", + "patternProperties": { + "[0-9]+": { + "type": "object", + "description": "Specifies the Room ID.", + "properties": { + "bg1": { + "description": "The BG1 layer that should be edited.", + "$ref": "#/$defs/block_layer" + }, + "bg2": { + "description": "The BG2 layer that should be edited.", + "$ref": "#/$defs/block_layer" + }, + "clipdata": { + "description": "The Clipdata layer that should be edited.", + "$ref": "#/$defs/block_layer" + } + }, + "additionalProperties": false + } + } + } + }, + "minimap_edits": { + "type": "object", + "description": "Specifies minimap edits that should be done.", + "propertyNames": { + "description": "Specifies the Area ID.", + "$ref": "#/$defs/minimap_id_key" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "$ref": "#/$defs/type_u5", + "description": "The X position in the minimap that should get edited." + }, + "y": { + "$ref": "#/$defs/type_u5", + "description": "The Y position in the minimap that should get edited." + }, + "tile": { + "$ref": "#/$defs/type_u10", + "description": "The tile value that should be used to edit the minimap." + }, + "palette": { + "$ref": "#/$defs/type_u4", + "description": "The palette row to use for the tile." + }, + "h_flip": { + "type": "boolean", + "default": false, + "description": "Whether the tile should be horizontally flipped or not." + }, + "v_flip": { + "type": "boolean", + "default": false, + "description": "Whether the tile should be vertically flipped or not." + } + } + } + } + }, + "hide_doors_on_minimap": { + "type": "boolean", + "description": "When enabled, hides doors on the minimap. This is automatically enabled when the 'DoorLocks' field is provided.", + "default": false + }, + "room_names": { + "type": "array", + "description": "Specifies a name to be displayed when the A Button is pressed on the pause menu.", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "area": { + "$ref": "#/$defs/area_id", + "description": "The area ID where this room is located." + }, + "room": { + "$ref": "#/$defs/type_u8", + "description": "The room ID." + }, + "name": { + "type": "string", + "description": "Specifies what text should appear for this room. Two lines are available, with an absolute maximum of 56 characters per line, if all characters used are small. Text will auto-wrap if the next word doesn't fit on the line. If the text is too long, it will be truncated. Use \n to force a line break. If not provided, will display 'Unknown Room'.", + "maxLength": 112 + } + }, + "required": ["area", "room", "name"] + } + }, + "reveal_hidden_tiles": { + "type": "boolean", + "description": "When enabled, reveals normally hidden blocks that are breakable by upgrades. Hidden pickup tanks are not revealed regardless of this setting.", + "default": false + } + }, + "required": [ + "seed_hash", + "locations" + ], + "$defs": { + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "type_u4": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "type_u5": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "type_u8": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "type_u10": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "area_id": { + "type": "integer", + "minimum": 0, + "maximum": 6 + }, + "area_id_key": { + "type": "string", + "enum": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6" + ] + }, + "minimap_id_key": { + "type": "string", + "enum": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + "hue_rotation": { + "type": "integer", + "minimum": 0, + "maximum": 360 + }, + "valid_sources": { + "type": "string", + "description": "Valid major locations.", + "enum": [ + "LONG_BEAM", + "CHARGE_BEAM", + "ICE_BEAM", + "WAVE_BEAM", + "PLASMA_BEAM", + "BOMBS", + "VARIA_SUIT", + "GRAVITY_SUIT", + "MORPH_BALL", + "SPEED_BOOSTER", + "HI_JUMP", + "SCREW_ATTACK", + "SPACE_JUMP", + "POWER_GRIP", + "FULLY_POWERED", + "ZIPLINES" + ] + }, + "valid_items": { + "type": "string", + "description": "Valid items for shuffling.", + "enum": [ + "NONE", + "ENERGY_TANK", + "MISSILE_TANK", + "SUPER_MISSILE_TANK", + "POWER_BOMB_TANK", + "LONG_BEAM", + "CHARGE_BEAM", + "ICE_BEAM", + "WAVE_BEAM", + "PLASMA_BEAM", + "BOMBS", + "VARIA_SUIT", + "GRAVITY_SUIT", + "MORPH_BALL", + "SPEED_BOOSTER", + "HI_JUMP", + "SCREW_ATTACK", + "SPACE_JUMP", + "POWER_GRIP", + "FULLY_POWERED", + "ZIPLINES", + "ICE_TRAP" + ] + }, + "valid_item_sprites": { + "type": "string", + "description": "Valid graphics for item tanks/sprites.", + "enum": [ + "DEFAULT", + "EMPTY", + "ENERGY_TANK", + "MISSILE_TANK", + "SUPER_MISSILE_TANK", + "POWER_BOMB_TANK", + "LONG_BEAM", + "CHARGE_BEAM", + "ICE_BEAM", + "WAVE_BEAM", + "PLASMA_BEAM", + "BOMBS", + "VARIA_SUIT", + "GRAVITY_SUIT", + "MORPH_BALL", + "SPEED_BOOSTER", + "HI_JUMP", + "SCREW_ATTACK", + "SPACE_JUMP", + "POWER_GRIP", + "FULLY_POWERED", + "ZIPLINES", + "ANONYMOUS", + "SHINY_MISSILE_TANK", + "SHINY_POWER_BOMB_TANK" + ] + }, + "valid_abilities": { + "type": "string", + "description": "Valid abilities to start with.", + "enum": [ + "LONG_BEAM", + "CHARGE_BEAM", + "ICE_BEAM", + "WAVE_BEAM", + "PLASMA_BEAM", + "BOMBS", + "VARIA_SUIT", + "GRAVITY_SUIT", + "MORPH_BALL", + "SPEED_BOOSTER", + "HI_JUMP", + "SCREW_ATTACK", + "SPACE_JUMP", + "POWER_GRIP" + ] + }, + "valid_elevator_tops": { + "type": "string", + "description": "Valid elevators at the top of elevator shafts.", + "enum": [ + "BRINSTAR_TO_KRAID", + "BRINSTAR_TO_NORFAIR", + "BRINSTAR_TO_TOURIAN", + "NORFAIR_TO_RIDLEY", + "CRATERIA_TO_TOURIAN" + ] + }, + "valid_elevator_bottoms": { + "type": "string", + "description": "Valid elevators at the bottom of elevator shafts.", + "enum": [ + "KRAID_TO_BRINSTAR", + "NORFAIR_TO_BRINSTAR", + "TOURIAN_TO_BRINSTAR", + "RIDLEY_TO_NORFAIR", + "TOURIAN_TO_CRATERIA" + ] + }, + "valid_languages": { + "type": "string", + "description": "Valid languages supported by the game.", + "enum": [ + "JAPANESE_KANJI", + "JAPANESE_HIRAGANA", + "ENGLISH", + "GERMAN", + "FRENCH", + "ITALIAN", + "SPANISH" + ] + }, + "message_languages": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/valid_languages" + }, + "required": ["ENGLISH"], + "additionalProperties": { + "type": "string", + "description": "Specifies what text should appear for a 2 line message. Text will auto-wrap if the next word doesn't fit on the line. If the text is too long, it will be truncated. Use \n to force a line break. If not provided, a message based on the Item will be shown. If a language is not provided, it will use the provided English message." + } + }, + "item_messages": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/item_messages_kind" + } + }, + "required": ["kind"], + "if": { + "properties": { + "kind": {"const": "CUSTOM_MESSAGE" } + } + }, + "then": { + "properties": { + "kind": { + "$ref": "#/$defs/item_messages_kind" + }, + "languages": { + "$ref": "#/$defs/message_languages" + }, + "centered": { + "type": "boolean", + "default": true + } + }, + "required": ["languages"], + "additionalProperties": false + }, + "else": { + "properties": { + "kind": { + "$ref": "#/$defs/item_messages_kind" + }, + "message_id": { + "type": "integer", + "minimum": 0, + "maximum": 56, + "description": "The Message ID, will display one of the predefined messages in the ROM" + } + }, + "required": ["message_id"], + "additionalProperties": false + } + }, + "item_messages_kind": { + "type": "string", + "enum": ["CUSTOM_MESSAGE", "MESSAGE_ID"] + }, + "jingle": { + "type": "string", + "enum": ["DEFAULT", "MINOR", "MAJOR", "UNKNOWN", "FULLY_POWERED"] + }, + "hint_locations": { + "type": "string", + "enum": [ + "NONE", + "LONG_BEAM", + "BOMBS", + "ICE_BEAM", + "SPEED_BOOSTER", + "HIGH_JUMP", + "VARIA_SUIT", + "WAVE_BEAM", + "SCREW_ATTACK" + ] + }, + "block_layer": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "properties": { + "x": { + "$ref": "#/$defs/type_u8", + "description": "The X position in the room that should get edited." + }, + "y": { + "$ref": "#/$defs/type_u8", + "description": "The Y position in the room that should get edited." + }, + "value": { + "$ref": "#/$defs/type_u10", + "description": "The value that should be used to edit the room. For backgrounds, this is calculated via `((Row-1) * ColumnsInTileset) + (Column-1)`." + } + } + } + } + } +} diff --git a/src/mars_patcher/zm/patcher.py b/src/mars_patcher/zm/patcher.py new file mode 100644 index 0000000..6ca789b --- /dev/null +++ b/src/mars_patcher/zm/patcher.py @@ -0,0 +1,134 @@ +from collections.abc import Callable +from os import PathLike + +from mars_patcher.rom import Rom +from mars_patcher.zm.auto_generated_types import MarsSchemaZM + + +def patch_zm( + rom: Rom, + output_path: str | PathLike[str], + patch_data: MarsSchemaZM, + status_update: Callable[[str, float], None], +) -> None: + """ + Creates a new randomized Zero Mission game, based off of an input path, an output path, + a dictionary defining how the game should be randomized, and a status update function. + + Args: + input_path: The path to an unmodified Metroid Zero Mission (U) ROM. + output_path: The path where the randomized Zero Mission ROM should be saved to. + patch_data: A dictionary defining how the game should be randomized. + This function assumes that it satisfies the needed schema. To validate it, use + validate_patch_data_zm(). + status_update: A function taking in a message (str) and a progress value (float). + """ + + # Apply base patch first + # apply_base_patch(rom) + + # Randomize palettes - palettes are randomized first since the item + # patcher needs to copy tilesets + # if "Palettes" in patch_data: + # status_update("Randomizing palettes...", -1) + # pal_settings = PaletteSettings.from_json(patch_data["Palettes"]) + # pal_randomizer = PaletteRandomizer(rom, pal_settings) + # pal_randomizer.randomize() + + # Load locations and set assignments + # status_update("Writing item assignments...", -1) + # loc_settings = LocationSettings.initialize() + # loc_settings.set_assignments(patch_data["Locations"]) + # item_patcher = ItemPatcher(rom, loc_settings) + # item_patcher.write_items() + + # Required metroid count + # set_required_metroid_count(rom, patch_data["RequiredMetroidCount"]) + + # Starting location + # if "StartingLocation" in patch_data: + # status_update("Writing starting location...", -1) + # set_starting_location(rom, patch_data["StartingLocation"]) + + # Starting items + # if "StartingItems" in patch_data: + # status_update("Writing starting items...", -1) + # set_starting_items(rom, patch_data["StartingItems"]) + + # Tank increments + # if "TankIncrements" in patch_data: + # status_update("Writing tank increments...", -1) + # set_tank_increments(rom, patch_data["TankIncrements"]) + + # Elevator connections + # conns = None + # if "ElevatorConnections" in patch_data: + # status_update("Writing elevator connections...", -1) + # conns = Connections(rom) + # conns.set_elevator_connections(patch_data["ElevatorConnections"]) + + # Hints + # TODO + + # Room Names + # if room_names := patch_data.get("RoomNames", []): + # status_update("Writing room names...", -1) + # write_room_names(rom, room_names) + + # Credits + # if credits_text := patch_data.get("CreditsText", []): + # status_update("Writing credits text...", -1) + # write_credits(rom, credits_text) + + # Misc patches + # if patch_data.get("AccessibilityPatches"): + # apply_accessibility_patch(rom) + + # if patch_data.get("DisableDemos"): + # disable_demos(rom) + + # if patch_data.get("SkipDoorTransitions"): + # skip_door_transitions(rom) + + # if patch_data.get("StereoDefault", True): + # stereo_default(rom) + + # if patch_data.get("DisableMusic"): + # disable_music(rom) + + # if patch_data.get("DisableSoundEffects"): + # disable_sound_effects(rom) + + # if patch_data.get("UnexploredMap"): + # apply_unexplored_map(rom) + + # if not patch_data.get("HideDoorsOnMinimap", False): + # apply_reveal_unexplored_doors(rom) + + # if patch_data.get("RevealHiddenTiles"): + # apply_reveal_hidden_tiles(rom) + + # if "LevelEdits" in patch_data: + # apply_level_edits(rom, patch_data["LevelEdits"]) + + # Apply base minimap edits + # apply_base_minimap_edits(rom) + + # Apply JSON minimap edits + # if "MinimapEdits" in patch_data: + # apply_minimap_edits(rom, patch_data["MinimapEdits"]) + + # Door locks + # if door_locks := patch_data.get("DoorLocks", []): + # status_update("Writing door locks...", -1) + # set_door_locks(rom, door_locks) + + # write_seed_hash(rom, patch_data["SeedHash"]) + + # Title-screen text + # if title_screen_text := patch_data.get("TitleText"): + # status_update("Writing title screen text...", -1) + # write_title_text(rom, title_screen_text) + + rom.save(output_path) + status_update(f"Output written to {output_path}", -1)