mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

* - SM now displays message when getting an item outside for someone else (fills ROM item table) This is dependant on modifications done to sm_randomizer_rom project * First working MultiWorld SM * some missing things: - player name inject in ROM and get in client - end game get from ROM in client - send self item to server - add player names table in ROM * replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better) * - reenabled balancing * post rebase fixes * updated SmClient.py * + added VariaRandomizer LICENSE * + added sm_randomizer_rom project (which builds sm.ips) * Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning * properly revert change made to CollectionState and more cleaning * Fixed multiworld support patch not working with VariaRandomizer's * missing file commit * Fixed syntax error in unused code to satisfy Linter * Revert "Fixed multiworld support patch not working with VariaRandomizer's" This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b. * many fixes and improovement - fixed seeded generation - fixed broken logic when more than one SM world - added missing rules for inter-area transitions - added basic patch presence for logic - added DoorManager init call to reflect present patches for logic - moved CollectionState addition out of BaseClasses into SM world - added condition to apply progitempool presorting only if SM world is present - set Bosses item id to None to prevent them going into multidata - now use get_game_players * first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions) * first working single-world randomized SM rom patches * - SM now displays message when getting an item outside for someone else (fills ROM item table) This is dependant on modifications done to sm_randomizer_rom project * First working MultiWorld SM * some missing things: - player name inject in ROM and get in client - end game get from ROM in client - send self item to server - add player names table in ROM * replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better) * - reenabled balancing * post rebase fixes * updated SmClient.py * + added VariaRandomizer LICENSE * + added sm_randomizer_rom project (which builds sm.ips) * Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning * properly revert change made to CollectionState and more cleaning * Fixed multiworld support patch not working with VariaRandomizer's * missing file commit * Fixed syntax error in unused code to satisfy Linter * Revert "Fixed multiworld support patch not working with VariaRandomizer's" This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b. * many fixes and improovement - fixed seeded generation - fixed broken logic when more than one SM world - added missing rules for inter-area transitions - added basic patch presence for logic - added DoorManager init call to reflect present patches for logic - moved CollectionState addition out of BaseClasses into SM world - added condition to apply progitempool presorting only if SM world is present - set Bosses item id to None to prevent them going into multidata - now use get_game_players * Fixed multiworld support patch not working with VariaRandomizer's Added stage_fill_hook to set morph first in progitempool Added back VariaRandomizer's standard patches * + added missing files from variaRandomizer project * + added missing variaRandomizer files (custom sprites) + started integrating VariaRandomizer options (WIP) * Some fixes for player and server name display - fixed player name of 16 characters reading too far in SM client - fixed 12 bytes SM player name limit (now 16) - fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO) - request: temporarly changed default seed names displayed in SM main menu to OWTCH * Fixed Goal completion not triggering in smClient * integrated VariaRandomizer's options into AP (WIP) - startAP is working - door rando is working - skillset is working * - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off") * skillset are now instanced per player instead of being a singleton class * RomPatches are now instanced per player instead of being a singleton class * DoorManager is now instanced per player instead of being a singleton class * - fixed the last bugs that prevented generation of >1 SM world * fixed crash when no skillset preset is specified in randoPreset (default to "casual") * maxDifficulty support and itemsounds removal - added support for maxDifficulty - removed itemsounds patch as its always applied from multiworld patch for now * Fixed bad merge * Post merge adaptation * fixed player name length fix that got lost with the merge * fixed generation with other game type than SM * added default randoPreset json for SM in playerSettings.yaml * fixed broken SM client following merge * beautified json skillset presets * Fixed ArchipelagoSmClient not building * Fixed conflict between mutliworld patch and beam_doors_plms patch - doorsColorsRando now working * SM generation now outputs APBP - Fixed paths for patches and presets when frozen * added missing file and fixed multithreading issue * temporarily set data_version = 0 * more work - added support for AP starting items - fixed client crash with gamemode being None - patch.py "compatible_version" is now 3 * commited missing asm files fixed start item reserve breaking game (was using bad write offset when patching) * Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it). fixed crash in SMClient when loosing connection to SNI * fixed No Energy Item missing its ID fixed Plando * merge post fixes * fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color) * fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses) * fixed start item x-ray HUD display * Fixed start items being sent by the server (is all handled in ROM) Start items are now not removed from itempool anymore Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though. Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified * fixed settings that could be applied to any SM players * fixed auth to server only using player name (now does as ALTTP to authenticate) * - fixed End Credits broken text * added non SM item name display * added all supported SM options in playerSettings.yaml * fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region did some cleaning (mainly reverts on unnecessary core classes * minor setting fixes and tweaks - merged Area and lightArea settings - made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating - fixed inverted layoutPatch setting * added option start_inventory_removes_from_pool fixed option names formatting fixed lint errors small code and repo cleanup * Hopefully fixed ROR2 that could not send any items * - fixed missing required change to ROR2 * fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum) * fixed typo with doors_colors_rando * fixed checksum * added custom sprites for off-world items (progression or not) the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu * - added missing change following upstream merge - changed patch filename extension from apbp to apm3 so patch can be used with the new client * added morph placement options: early means local and sphere 1 * fixed failing unit tests * - fixed broken custom_preset options * - big cleanup to remove unnecessary or unsupported features * - more cleanup * - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips - small cleanup * - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch) * fixed g4_skip patch that can be not applied if hud is enabled * - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette) * - updated basepatch to reflect g4_skip removal - moved more asm files to SMBasepatch project * - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed) * fixed wrong path if using built as exe * - cleaned exposed maxDifficulty options - removed always enabled Knows * Merged LttPClient and SMClient into SNIClient * added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service * small doc precision * - added death_link support - fixed broken Goal Completion - post merge fix * - removed now useless presets * - fixed bad internal mapping with maxDiff - increases maxDiff if only Bosses is preventing beating the game * - added support for lowercase custom preset sections (knows, settings and controller) - fixed controller settings not applying to ROM * - fixed death loop when dying with Door rando, bomb or speed booster as starting items - varia's backup save should now be usable (automatically enabled when doing door rando) * -added docstring for generated yaml * fixed bad merge * fixed broken infinity max difficulty * commented debug prints * adjusted credits to mark progression speed and difficulty as Non Available * added support for more than 255 players (will print Archipelago for higher player number) * fixed missing cleanup * added support for 65535 different player names in ROM * fixed generations failing when only bosses are unreachable * - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish * fixed failling generations when using 'fun' settings Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings * fixed debug logger * removed unsupported "suits_restriction" option * fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP) * - fixed deathlink emptying reserves - added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves * - merged death_link and death_link_survive options * fixed death_link * added a fallback default starting location instead of failing generation if an invalid one was chosen * added Nothing and NoEnergy as hint blacklist added missing NoEnergy as local items and removed it from progression * SM Varia can now generate without ROM * removed stage_assert_generate
1442 lines
62 KiB
Python
1442 lines
62 KiB
Python
import os, random, re, json
|
|
from math import ceil
|
|
from enum import IntFlag
|
|
from ..rando.Items import ItemManager
|
|
from ..rom.ips import IPS_Patch
|
|
from ..utils.doorsmanager import DoorsManager, IndicatorFlag
|
|
from ..utils.objectives import Objectives
|
|
from ..graph.graph_utils import GraphUtils, getAccessPoint, locIdsByAreaAddresses, graphAreas
|
|
from ..logic.logic import Logic
|
|
from ..rom.rom import FakeROM, snes_to_pc, pc_to_snes
|
|
from ..rom.addresses import Addresses
|
|
from ..rom.rom_patches import RomPatches
|
|
from ..patches.patchaccess import PatchAccess
|
|
from ..utils.parameters import appDir
|
|
from ..utils import log
|
|
|
|
def getWord(w):
|
|
return (w & 0x00FF, (w & 0xFF00) >> 8)
|
|
|
|
class RomPatcher:
|
|
# possible patches. see patches asm source if applicable and available for more information
|
|
IPSPatches = {
|
|
# applied on all seeds
|
|
'Standard': [
|
|
# faster MB cutscene transitions
|
|
'Mother_Brain_Cutscene_Edits',
|
|
# "Balanced" suit mode
|
|
'Removes_Gravity_Suit_heat_protection',
|
|
# new PLMs for indicating the color of the door on the other side
|
|
'door_indicators_plms.ips'
|
|
],
|
|
# VARIA tweaks
|
|
'VariaTweaks' : ['WS_Etank', 'LN_Chozo_SpaceJump_Check_Disable', 'ln_chozo_platform.ips', 'bomb_torizo.ips'],
|
|
# anti-softlock/game opening layout patches
|
|
'Layout': ['dachora.ips', 'early_super_bridge.ips', 'high_jump.ips', 'moat.ips', 'spospo_save.ips',
|
|
'nova_boost_platform.ips', 'red_tower.ips', 'spazer.ips',
|
|
'brinstar_map_room.ips', 'kraid_save.ips', 'mission_impossible.ips'],
|
|
# base patchset+optional layout for area rando
|
|
'Area': ['area_rando_layout.ips', 'door_transition.ips', 'area_rando_doors.ips',
|
|
'Sponge_Bath_Blinking_Door', 'east_ocean.ips', 'area_rando_warp_door.ips', 'aqueduct_bomb_blocks.ips',
|
|
'crab_shaft.ips', 'Save_Crab_Shaft', 'Save_Main_Street', 'no_demo.ips'],
|
|
# patches for boss rando
|
|
'Bosses': ['door_transition.ips', 'no_demo.ips'],
|
|
# patches for escape rando
|
|
'Escape' : ['rando_escape.ips', 'rando_escape_ws_fix.ips', 'door_transition.ips'],
|
|
# patches for minimizer with fast Tourian
|
|
'MinimizerTourian': ['minimizer_tourian.ips', 'open_zebetites.ips'],
|
|
# patches for door color rando
|
|
'DoorsColors': ['beam_doors_plms.ips', 'beam_doors_gfx.ips', 'red_doors.ips']
|
|
}
|
|
|
|
def __init__(self, settings=None, romFileName=None, magic=None, player=0):
|
|
self.log = log.get('RomPatcher')
|
|
self.settings = settings
|
|
#self.romFileName = romFileName
|
|
self.patchAccess = PatchAccess()
|
|
self.race = None
|
|
self.romFile = FakeROM()
|
|
#if magic is not None:
|
|
# from rom.race_mode import RaceModePatcher
|
|
# self.race = RaceModePatcher(self, magic)
|
|
# IPS_Patch objects list
|
|
self.ipsPatches = []
|
|
# loc name to alternate address. we still write to original
|
|
# address to help the RomReader.
|
|
self.altLocsAddresses = {}
|
|
# specific fixes for area rando connections
|
|
self.roomConnectionSpecific = {
|
|
# fix scrolling sky when transitioning to west ocean
|
|
0x93fe: self.patchWestOcean
|
|
}
|
|
self.doorConnectionSpecific = {
|
|
# get out of kraid room: reload CRE
|
|
0x91ce: self.forceRoomCRE,
|
|
# get out of croc room: reload CRE
|
|
0x93ea: self.forceRoomCRE
|
|
}
|
|
self.player = player
|
|
|
|
def patchRom(self):
|
|
self.applyIPSPatches()
|
|
self.commitIPS()
|
|
|
|
def end(self):
|
|
self.romFile.fillToNextBank()
|
|
self.romFile.close()
|
|
|
|
def writeItemCode(self, item, visibility, address):
|
|
itemCode = ItemManager.getItemTypeCode(item, visibility)
|
|
self.writePlmWord(itemCode, address)
|
|
|
|
def writePlmWord(self, word, address):
|
|
if self.race is None:
|
|
self.romFile.writeWord(word, address)
|
|
else:
|
|
self.race.writePlmWord(word, address)
|
|
|
|
def getLocAddresses(self, loc):
|
|
ret = [loc.Address]
|
|
if loc.Name in self.altLocsAddresses:
|
|
ret.append(self.altLocsAddresses[loc.Name])
|
|
return ret
|
|
|
|
def writeItem(self, itemLoc):
|
|
loc = itemLoc.Location
|
|
if loc.isBoss():
|
|
raise ValueError('Cannot write Boss location')
|
|
#print('write ' + itemLoc.Item.Type + ' at ' + loc.Name)
|
|
for addr in self.getLocAddresses(loc):
|
|
self.writeItemCode(itemLoc.Item, loc.Visibility, addr)
|
|
|
|
def writeItemsLocs(self, itemLocs):
|
|
self.nItems = 0
|
|
for itemLoc in itemLocs:
|
|
loc = itemLoc.Location
|
|
item = itemLoc.Item
|
|
if loc.isBoss():
|
|
continue
|
|
self.writeItem(itemLoc)
|
|
if item.Category != 'Nothing':
|
|
if not loc.restricted:
|
|
self.nItems += 1
|
|
if loc.Name == 'Morphing Ball':
|
|
self.patchMorphBallEye(item)
|
|
|
|
def writeSplitLocs(self, split, itemLocs, progItemLocs):
|
|
majChozoCheck = lambda itemLoc: itemLoc.Item.Class == split and itemLoc.Location.isClass(split)
|
|
fullCheck = lambda itemLoc: itemLoc.Location.Id is not None and itemLoc.Location.BossItemType is None
|
|
splitChecks = {
|
|
'Full': fullCheck,
|
|
'Scavenger': fullCheck,
|
|
'Major': majChozoCheck,
|
|
'Chozo': majChozoCheck,
|
|
'FullWithHUD': lambda itemLoc: itemLoc.Item.Category not in ['Energy', 'Ammo', 'Boss', 'MiniBoss']
|
|
}
|
|
itemLocCheck = lambda itemLoc: itemLoc.Item.Category != "Nothing" and splitChecks[split](itemLoc)
|
|
for area,addr in locIdsByAreaAddresses.items():
|
|
locs = [il.Location for il in itemLocs if itemLocCheck(il) and il.Location.GraphArea == area and not il.Location.restricted]
|
|
self.log.debug("writeSplitLocs. area="+area)
|
|
self.log.debug(str([loc.Name for loc in locs]))
|
|
self.romFile.seek(addr)
|
|
for loc in locs:
|
|
self.romFile.writeByte(loc.Id)
|
|
self.romFile.writeByte(0xff)
|
|
if split == "Scavenger":
|
|
# write required major item order
|
|
self.romFile.seek(Addresses.getOne('scavengerOrder'))
|
|
for itemLoc in progItemLocs:
|
|
self.romFile.writeWord((itemLoc.Location.Id << 8) | itemLoc.Location.HUD)
|
|
# bogus loc ID | "HUNT OVER" index
|
|
self.romFile.writeWord(0xff11)
|
|
# fill remaining list with 0xFFFF to avoid issue with plandomizer having less items than in the base seed
|
|
for i in range(18-len(progItemLocs)):
|
|
self.romFile.writeWord(0xffff)
|
|
|
|
# trigger morph eye enemy on whatever item we put there,
|
|
# not just morph ball
|
|
def patchMorphBallEye(self, item):
|
|
# print('Eye item = ' + item.Type)
|
|
isAmmo = item.Category == 'Ammo'
|
|
# category to check
|
|
if ItemManager.isBeam(item):
|
|
cat = 0xA8 # collected beams
|
|
elif item.Type == 'ETank':
|
|
cat = 0xC4 # max health
|
|
elif item.Type == 'Reserve':
|
|
cat = 0xD4 # max reserves
|
|
elif item.Type == 'Missile':
|
|
cat = 0xC8 # max missiles
|
|
elif item.Type == 'Super':
|
|
cat = 0xCC # max supers
|
|
elif item.Type == 'PowerBomb':
|
|
cat = 0xD0 # max PBs
|
|
else:
|
|
cat = 0xA4 # collected items
|
|
# comparison/branch instruction
|
|
# the branch is taken if we did NOT collect item yet
|
|
if item.Category == 'Energy' or isAmmo:
|
|
comp = 0xC9 # CMP (immediate)
|
|
branch = 0x30 # BMI
|
|
else:
|
|
comp = 0x89 # BIT (immediate)
|
|
branch = 0xF0 # BEQ
|
|
# what to compare to
|
|
if item.Type == 'ETank':
|
|
operand = 0x65 # < 100
|
|
elif item.Type == 'Reserve' or isAmmo:
|
|
operand = 0x1 # < 1
|
|
elif ItemManager.isBeam(item):
|
|
operand = item.BeamBits
|
|
else:
|
|
operand = item.ItemBits
|
|
self.patchMorphBallCheck(snes_to_pc(0xa890e6), cat, comp, operand, branch) # eye main AI
|
|
self.patchMorphBallCheck(snes_to_pc(0xa8e8b2), cat, comp, operand, branch) # head main AI
|
|
|
|
def patchMorphBallCheck(self, offset, cat, comp, operand, branch):
|
|
# actually patch enemy AI
|
|
self.romFile.writeByte(cat, offset)
|
|
self.romFile.writeByte(comp, offset+2)
|
|
self.romFile.writeWord(operand)
|
|
self.romFile.writeByte(branch)
|
|
|
|
def writeItemsNumber(self):
|
|
# write total number of actual items for item percentage patch (patch the patch)
|
|
for addr in Addresses.getAll('totalItems'):
|
|
self.romFile.writeByte(self.nItems, addr)
|
|
|
|
# for X% collected items objectives, precompute values and write them in objectives functions
|
|
for percent, addr in zip([25, 50, 75, 100], Addresses.getAll('totalItemsPercent')):
|
|
self.romFile.writeWord(ceil((self.nItems * percent)/100), addr)
|
|
|
|
def addIPSPatches(self, patches):
|
|
for patchName in patches:
|
|
self.applyIPSPatch(patchName)
|
|
|
|
def applyIPSPatches(self):
|
|
try:
|
|
# apply standard patches
|
|
stdPatches = []
|
|
plms = []
|
|
|
|
stdPatches += RomPatcher.IPSPatches['Standard'][:]
|
|
if not self.settings["layout"]:
|
|
# when disabling anti softlock protection also disable doors indicators
|
|
stdPatches.remove('door_indicators_plms.ips')
|
|
if self.race is not None:
|
|
stdPatches.append('race_mode_post.ips')
|
|
if self.settings["suitsMode"] != "Balanced":
|
|
stdPatches.remove('Removes_Gravity_Suit_heat_protection')
|
|
if self.settings["suitsMode"] == "Progressive":
|
|
stdPatches.append('progressive_suits.ips')
|
|
if self.settings["nerfedCharge"] == True:
|
|
stdPatches.append('nerfed_charge.ips')
|
|
if self.settings["nerfedRainbowBeam"] == True:
|
|
stdPatches.append('nerfed_rainbow_beam.ips')
|
|
if self.settings["boss"] == True or self.settings["area"] == True:
|
|
stdPatches += ["WS_Main_Open_Grey", "WS_Save_Active"]
|
|
plms.append('WS_Save_Blinking_Door')
|
|
if self.settings["boss"] == True:
|
|
stdPatches.append("Phantoon_Eye_Door")
|
|
# rolling saves is not required anymore since the addition of fast_save_reload
|
|
# also, both arent completely compatible as-is
|
|
#if (self.settings["area"] == True
|
|
# or self.settings["doorsColorsRando"] == True
|
|
# or not GraphUtils.isStandardStart(self.settings["startLocation"])):
|
|
# stdPatches.append("Enable_Backup_Saves")
|
|
if 'varia_hud.ips' in self.settings["optionalPatches"]:
|
|
# varia hud can make demos glitch out
|
|
self.applyIPSPatch("no_demo.ips")
|
|
for patchName in stdPatches:
|
|
self.applyIPSPatch(patchName)
|
|
|
|
if not self.settings["vanillaObjectives"]:
|
|
self.applyIPSPatch("Objectives_sfx")
|
|
# show objectives and Tourian status in a shortened intro sequence
|
|
# if not full vanilla objectives+tourian
|
|
if not self.settings["vanillaObjectives"] or self.settings["tourian"] != "Vanilla":
|
|
self.applyIPSPatch("Restore_Intro") # important to apply this after new_game.ips
|
|
self.applyIPSPatch("intro_text.ips")
|
|
if self.settings["layout"]:
|
|
# apply layout patches
|
|
for patchName in RomPatcher.IPSPatches['Layout']:
|
|
self.applyIPSPatch(patchName)
|
|
if self.settings["variaTweaks"]:
|
|
# VARIA tweaks
|
|
for patchName in RomPatcher.IPSPatches['VariaTweaks']:
|
|
self.applyIPSPatch(patchName)
|
|
if (self.settings["majorsSplit"] == 'Scavenger'
|
|
and any(il for il in self.settings["progItemLocs"] if il.Location.Name == "Ridley")):
|
|
# ridley as scav loc
|
|
self.applyIPSPatch("Blinking[RidleyRoomIn]")
|
|
|
|
# apply optional patches
|
|
for patchName in self.settings["optionalPatches"]:
|
|
self.applyIPSPatch(patchName)
|
|
|
|
# random escape
|
|
if self.settings["escapeAttr"] is not None:
|
|
for patchName in RomPatcher.IPSPatches['Escape']:
|
|
self.applyIPSPatch(patchName)
|
|
# animals and timer
|
|
self.applyEscapeAttributes(self.settings["escapeAttr"], plms)
|
|
|
|
# apply area patches
|
|
if self.settings["area"] == True:
|
|
areaPatches = list(RomPatcher.IPSPatches['Area'])
|
|
if not self.settings["areaLayout"]:
|
|
for p in ['area_rando_layout.ips', 'Sponge_Bath_Blinking_Door', 'east_ocean.ips', 'aqueduct_bomb_blocks.ips']:
|
|
areaPatches.remove(p)
|
|
areaPatches.append('area_rando_layout_base.ips')
|
|
for patchName in areaPatches:
|
|
self.applyIPSPatch(patchName)
|
|
else:
|
|
self.applyIPSPatch('area_ids_alt.ips')
|
|
if self.settings["boss"] == True:
|
|
for patchName in RomPatcher.IPSPatches['Bosses']:
|
|
self.applyIPSPatch(patchName)
|
|
if self.settings["minimizerN"] is not None:
|
|
self.applyIPSPatch('minimizer_bosses.ips')
|
|
if self.settings["tourian"] == "Fast":
|
|
for patchName in RomPatcher.IPSPatches['MinimizerTourian']:
|
|
self.applyIPSPatch(patchName)
|
|
elif self.settings["tourian"] == "Disabled":
|
|
self.applyIPSPatch("Escape_Trigger")
|
|
doors = self.getStartDoors(plms, self.settings["area"], self.settings["minimizerN"])
|
|
if self.settings["doorsColorsRando"] == True:
|
|
for patchName in RomPatcher.IPSPatches['DoorsColors']:
|
|
self.applyIPSPatch(patchName)
|
|
self.writeDoorsColor(doors, self.player)
|
|
if self.settings["layout"]:
|
|
self.writeDoorIndicators(plms, self.settings["area"], self.settings["doorsColorsRando"])
|
|
self.applyStartAP(self.settings["startLocation"], plms, doors)
|
|
self.applyPLMs(plms)
|
|
except Exception as e:
|
|
raise Exception("Error patching. ({})".format(e))
|
|
|
|
def applyIPSPatch(self, patchName, patchDict=None, ipsDir=None):
|
|
if patchDict is None:
|
|
patchDict = self.patchAccess.getDictPatches()
|
|
# print("Apply patch {}".format(patchName))
|
|
if patchName in patchDict:
|
|
patch = IPS_Patch(patchDict[patchName])
|
|
else:
|
|
# look for ips file
|
|
if ipsDir is None:
|
|
patch = IPS_Patch.load(self.patchAccess.getPatchPath(patchName))
|
|
else:
|
|
patch = IPS_Patch.load(os.path.join(appDir, ipsDir, patchName))
|
|
self.ipsPatches.append(patch)
|
|
|
|
def applyIPSPatchDict(self, patchDict):
|
|
for patchName in patchDict.keys():
|
|
# print("Apply patch {}".format(patchName))
|
|
patch = IPS_Patch(patchDict[patchName])
|
|
self.ipsPatches.append(patch)
|
|
|
|
def getStartDoors(self, plms, area, minimizerN):
|
|
doors = [0x10] # red brin elevator
|
|
def addBlinking(name):
|
|
key = 'Blinking[{}]'.format(name)
|
|
if key in self.patchAccess.getDictPatches():
|
|
self.applyIPSPatch(key)
|
|
if key in self.patchAccess.getAdditionalPLMs():
|
|
plms.append(key)
|
|
if area == True:
|
|
plms += ['Maridia Sand Hall Seal', "Save_Main_Street", "Save_Crab_Shaft"]
|
|
for accessPoint in Logic.accessPoints:
|
|
if accessPoint.Internal == True or accessPoint.Boss == True:
|
|
continue
|
|
addBlinking(accessPoint.Name)
|
|
addBlinking("West Sand Hall Left")
|
|
addBlinking("Below Botwoon Energy Tank Right")
|
|
if minimizerN is not None:
|
|
# add blinking doors inside and outside boss rooms
|
|
for accessPoint in Logic.accessPoints:
|
|
if accessPoint.Boss == True:
|
|
addBlinking(accessPoint.Name)
|
|
return doors
|
|
|
|
def applyStartAP(self, apName, plms, doors):
|
|
ap = getAccessPoint(apName)
|
|
# if start loc is not Ceres or Landing Site, or the ceiling loc picked up before morph loc,
|
|
# Zebes will be awake and morph loc item will disappear.
|
|
# this PLM ensures the item will be here whenever zebes awakes
|
|
plms.append('Morph_Zebes_Awake')
|
|
(w0, w1) = getWord(ap.Start['spawn'])
|
|
if 'doors' in ap.Start:
|
|
doors += ap.Start['doors']
|
|
doors.append(0x0)
|
|
addr = Addresses.getOne('startAP')
|
|
patch = [w0, w1] + doors
|
|
assert (addr + len(patch)) < addr + 0x10, "Stopped before new_game overwrite"
|
|
patchDict = {
|
|
'StartAP': {
|
|
addr: patch
|
|
},
|
|
}
|
|
self.applyIPSPatch('StartAP', patchDict)
|
|
# handle custom saves
|
|
if 'save' in ap.Start:
|
|
self.applyIPSPatch(ap.Start['save'])
|
|
plms.append(ap.Start['save'])
|
|
# handle optional rom patches
|
|
if 'rom_patches' in ap.Start:
|
|
for patch in ap.Start['rom_patches']:
|
|
self.applyIPSPatch(patch)
|
|
|
|
def applyEscapeAttributes(self, escapeAttr, plms):
|
|
# timer
|
|
escapeTimer = escapeAttr['Timer']
|
|
if escapeTimer is not None:
|
|
patchDict = { 'Escape_Timer': {} }
|
|
timerPatch = patchDict["Escape_Timer"]
|
|
def getTimerBytes(t):
|
|
minute = int(t / 60)
|
|
second = t % 60
|
|
minute = int(minute / 10) * 16 + minute % 10
|
|
second = int(second / 10) * 16 + second % 10
|
|
return [second, minute]
|
|
timerPatch[Addresses.getOne('escapeTimer')] = getTimerBytes(escapeTimer)
|
|
# timer table for Disabled Tourian escape
|
|
if 'TimerTable' in escapeAttr:
|
|
tableBytes = []
|
|
timerPatch[Addresses.getOne('escapeTimerTable')] = tableBytes
|
|
for area in graphAreas[1:-1]: # no Ceres or Tourian
|
|
t = escapeAttr['TimerTable'][area]
|
|
tableBytes += getTimerBytes(t)
|
|
self.applyIPSPatch('Escape_Timer', patchDict)
|
|
# animals door to open
|
|
if escapeAttr['Animals'] is not None:
|
|
escapeOpenPatches = {
|
|
'Green Brinstar Main Shaft Top Left':'Escape_Animals_Open_Brinstar',
|
|
'Business Center Mid Left':"Escape_Animals_Open_Norfair",
|
|
'Crab Hole Bottom Right':"Escape_Animals_Open_Maridia",
|
|
}
|
|
if escapeAttr['Animals'] in escapeOpenPatches:
|
|
plms.append("WS_Map_Grey_Door")
|
|
self.applyIPSPatch(escapeOpenPatches[escapeAttr['Animals']])
|
|
else:
|
|
plms.append("WS_Map_Grey_Door_Openable")
|
|
else:
|
|
plms.append("WS_Map_Grey_Door")
|
|
# optional patches (enemies, scavenger)
|
|
for patch in escapeAttr['patches']:
|
|
self.applyIPSPatch(patch)
|
|
|
|
# adds ad-hoc "IPS patches" for additional PLM tables
|
|
def applyPLMs(self, plms):
|
|
# compose a dict (room, state, door) => PLM array
|
|
# 'PLMs' being a 6 byte arrays
|
|
plmDict = {}
|
|
# we might need to update locations addresses on the fly
|
|
plmLocs = {} # room key above => loc name
|
|
additionalPLMs = self.patchAccess.getAdditionalPLMs()
|
|
for p in plms:
|
|
plm = additionalPLMs[p]
|
|
room = plm['room']
|
|
state = 0
|
|
if 'state' in plm:
|
|
state = plm['state']
|
|
door = 0
|
|
if 'door' in plm:
|
|
door = plm['door']
|
|
k = (room, state, door)
|
|
if k not in plmDict:
|
|
plmDict[k] = []
|
|
plmDict[k] += plm['plm_bytes_list']
|
|
if 'locations' in plm:
|
|
locList = plm['locations']
|
|
for locName, locIndex in locList:
|
|
plmLocs[(k, locIndex)] = locName
|
|
# make two patches out of this dict
|
|
plmTblAddr = Addresses.getOne('plmSpawnTable') # moves downwards
|
|
plmPatchData = []
|
|
roomTblAddr = Addresses.getOne('plmSpawnRoomTable') # moves upwards
|
|
roomPatchData = []
|
|
plmTblOffset = plmTblAddr
|
|
def appendPlmBytes(bytez):
|
|
nonlocal plmPatchData, plmTblOffset
|
|
plmPatchData += bytez
|
|
plmTblOffset += len(bytez)
|
|
def addRoomPatchData(bytez):
|
|
nonlocal roomPatchData, roomTblAddr
|
|
roomPatchData = bytez + roomPatchData
|
|
roomTblAddr -= len(bytez)
|
|
for roomKey, plmList in plmDict.items():
|
|
entryAddr = plmTblOffset
|
|
roomData = []
|
|
for i in range(len(plmList)):
|
|
plmBytes = plmList[i]
|
|
assert len(plmBytes) == 6, "Invalid PLM entry for roomKey " + str(roomKey) + ": PLM list len is " + str(len(plmBytes))
|
|
if (roomKey, i) in plmLocs:
|
|
self.altLocsAddresses[plmLocs[(roomKey, i)]] = plmTblOffset
|
|
appendPlmBytes(plmBytes)
|
|
appendPlmBytes([0x0, 0x0]) # list terminator
|
|
def appendRoomWord(w, data):
|
|
(w0, w1) = getWord(w)
|
|
data += [w0, w1]
|
|
for i in range(3):
|
|
appendRoomWord(roomKey[i], roomData)
|
|
appendRoomWord(entryAddr, roomData)
|
|
addRoomPatchData(roomData)
|
|
# write room table terminator
|
|
addRoomPatchData([0x0] * 8)
|
|
assert plmTblOffset < roomTblAddr, "Spawn PLM table overlap. PLM table offset is 0x%x, Room table address is 0x%x" % (plmTblOffset,roomTblAddr)
|
|
patchDict = {
|
|
"PLM_Spawn_Tables" : {
|
|
plmTblAddr: plmPatchData,
|
|
roomTblAddr: roomPatchData
|
|
}
|
|
}
|
|
self.applyIPSPatch("PLM_Spawn_Tables", patchDict)
|
|
|
|
def commitIPS(self):
|
|
self.romFile.ipsPatch(self.ipsPatches)
|
|
self.ipsPatches = []
|
|
|
|
def writeSeed(self, seed):
|
|
random.seed(seed)
|
|
seedInfo = random.randint(0, 0xFFFF)
|
|
seedInfo2 = random.randint(0, 0xFFFF)
|
|
self.romFile.writeWord(seedInfo, snes_to_pc(0xdfff00))
|
|
self.romFile.writeWord(seedInfo2)
|
|
|
|
def writeMagic(self):
|
|
if self.race is not None:
|
|
self.race.writeMagic()
|
|
|
|
def writeMajorsSplit(self, majorsSplit):
|
|
address = Addresses.getOne('majorsSplit')
|
|
splits = {
|
|
'Chozo': 'Z',
|
|
'Major': 'M',
|
|
'FullWithHUD': 'H',
|
|
'Scavenger': 'S'
|
|
}
|
|
char = splits.get(majorsSplit, 'F')
|
|
self.romFile.writeByte(ord(char), address)
|
|
|
|
def getItemQty(self, itemLocs, itemType):
|
|
return len([il for il in itemLocs if il.Accessible and il.Item.Type == itemType])
|
|
|
|
def getMinorsDistribution(self, itemLocs):
|
|
dist = {}
|
|
minQty = 100
|
|
minors = ['Missile', 'Super', 'PowerBomb']
|
|
for m in minors:
|
|
# in vcr mode if the seed has stuck we may not have these items, return at least 1
|
|
q = float(max(self.getItemQty(itemLocs, m), 1))
|
|
dist[m] = {'Quantity' : q }
|
|
if q < minQty:
|
|
minQty = q
|
|
for m in minors:
|
|
dist[m]['Proportion'] = dist[m]['Quantity']/minQty
|
|
|
|
return dist
|
|
|
|
def getAmmoPct(self, minorsDist):
|
|
q = 0
|
|
for m,v in minorsDist.items():
|
|
q += v['Quantity']
|
|
return 100*q/66
|
|
|
|
def writeRandoSettings(self, settings, itemLocs):
|
|
dist = self.getMinorsDistribution(itemLocs)
|
|
totalAmmo = sum(d['Quantity'] for ammo,d in dist.items())
|
|
totalItemLocs = sum(1 for il in itemLocs if il.Accessible and not il.Location.isBoss())
|
|
totalNothing = sum(1 for il in itemLocs if il.Accessible and il.Item.Category == 'Nothing')
|
|
totalEnergy = self.getItemQty(itemLocs, 'ETank')+self.getItemQty(itemLocs, 'Reserve')
|
|
totalMajors = max(totalItemLocs - totalEnergy - totalAmmo - totalNothing, 0)
|
|
address = snes_to_pc(0xceb6c0)
|
|
value = "{:>2}".format(totalItemLocs)
|
|
line = " ITEM LOCATIONS %s " % value
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
|
|
line = " item locations ............ %s " % value
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
address += 0x40
|
|
|
|
maj = "{:>2}".format(int(totalMajors))
|
|
htanks = "{:>2}".format(int(totalEnergy))
|
|
ammo = "{:>2}".format(int(totalAmmo))
|
|
blank = "{:>2}".format(int(totalNothing))
|
|
line = " MAJ %s EN %s AMMO %s BLANK %s " % (maj, htanks, ammo, blank)
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
line = " maj %s en %s ammo %s blank %s " % (maj, htanks, ammo, blank)
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
address += 0x40
|
|
|
|
pbs = "{:>2}".format(int(dist['PowerBomb']['Quantity']))
|
|
miss = "{:>2}".format(int(dist['Missile']['Quantity']))
|
|
supers = "{:>2}".format(int(dist['Super']['Quantity']))
|
|
line = " AMMO PACKS MI %s SUP %s PB %s " % (miss, supers, pbs)
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
|
|
line = " ammo packs mi %s sup %s pb %s " % (miss, supers, pbs)
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
address += 0x40
|
|
|
|
etanks = "{:>2}".format(int(self.getItemQty(itemLocs, 'ETank')))
|
|
reserves = "{:>2}".format(int(self.getItemQty(itemLocs, 'Reserve')))
|
|
line = " HEALTH TANKS E %s R %s " % (etanks, reserves)
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
|
|
line = " health tanks ...... e %s r %s " % (etanks, reserves)
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
address += 0x80
|
|
|
|
value = " "+"NA" # settings.progSpeed.upper()
|
|
line = " PROGRESSION SPEED ....%s " % value.rjust(8, '.')
|
|
self.writeCreditsString(address, 0x04, line)
|
|
address += 0x40
|
|
|
|
line = " PROGRESSION DIFFICULTY %s " % value.rjust(7, '.') # settings.progDiff.upper()
|
|
self.writeCreditsString(address, 0x04, line)
|
|
address += 0x80 # skip item distrib title
|
|
|
|
param = (' SUITS RESTRICTION ........%s', 'Suits')
|
|
line = param[0] % ('. ON' if settings.restrictions[param[1]] == True else ' OFF')
|
|
self.writeCreditsString(address, 0x04, line)
|
|
address += 0x40
|
|
|
|
value = " "+settings.restrictions['Morph'].upper()
|
|
line = " MORPH PLACEMENT .....%s" % value.rjust(9, '.')
|
|
self.writeCreditsString(address, 0x04, line)
|
|
address += 0x40
|
|
|
|
for superFun in [(' SUPER FUN COMBAT .........%s', 'Combat'),
|
|
(' SUPER FUN MOVEMENT .......%s', 'Movement'),
|
|
(' SUPER FUN SUITS ..........%s', 'Suits')]:
|
|
line = superFun[0] % ('. ON' if superFun[1] in settings.superFun else ' OFF')
|
|
self.writeCreditsString(address, 0x04, line)
|
|
address += 0x40
|
|
|
|
value = "%.1f %.1f %.1f" % (dist['Missile']['Proportion'], dist['Super']['Proportion'], dist['PowerBomb']['Proportion'])
|
|
line = " AMMO DISTRIBUTION %s " % value
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
|
|
line = " ammo distribution %s " % value
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
address += 0x40
|
|
|
|
# write ammo/energy pct
|
|
address = snes_to_pc(0xcebc40)
|
|
(ammoPct, energyPct) = (int(self.getAmmoPct(dist)), int(100*totalEnergy/18))
|
|
line = " AVAILABLE AMMO {:>3}% ENERGY {:>3}%".format(ammoPct, energyPct)
|
|
self.writeCreditsStringBig(address, line, top=True)
|
|
address += 0x40
|
|
line = " available ammo {:>3}% energy {:>3}%".format(ammoPct, energyPct)
|
|
self.writeCreditsStringBig(address, line, top=False)
|
|
|
|
def writeSpoiler(self, itemLocs, progItemLocs=None):
|
|
# keep only majors
|
|
fItemLocs = [il for il in itemLocs if il.Item.Category not in ['Ammo', 'Nothing', 'Energy', 'Boss']]
|
|
# add location of the first instance of each minor
|
|
for t in ['Missile', 'Super', 'PowerBomb']:
|
|
itLoc = None
|
|
if progItemLocs is not None:
|
|
itLoc = next((il for il in progItemLocs if il.Item.Type == t), None)
|
|
if itLoc is None:
|
|
itLoc = next((il for il in itemLocs if il.Item.Type == t), None)
|
|
if itLoc is not None: # in vcr mode if the seed has stucked we may not have these minors
|
|
fItemLocs.append(itLoc)
|
|
regex = re.compile(r"[^A-Z0-9\.,'!: ]+")
|
|
|
|
itemLocs = {}
|
|
for iL in fItemLocs:
|
|
itemLocs[iL.Item.Name] = iL.Location.Name
|
|
|
|
def prepareString(s, isItem=True):
|
|
s = s.upper()
|
|
# remove chars not displayable
|
|
s = regex.sub('', s)
|
|
# remove space before and after
|
|
s = s.strip()
|
|
# limit to 30 chars, add one space before
|
|
# pad to 32 chars
|
|
if isItem is True:
|
|
s = " " + s[0:30]
|
|
s = s.ljust(32)
|
|
else:
|
|
s = " " + s[0:30] + " "
|
|
s = " " + s.rjust(31, '.')
|
|
|
|
return s
|
|
|
|
isRace = self.race is not None
|
|
startCreditAddress = snes_to_pc(0xded240)
|
|
address = startCreditAddress
|
|
if isRace:
|
|
addr = address - 0x40
|
|
data = [0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x1008, 0x1013, 0x1004, 0x100c, 0x007f, 0x100b, 0x100e, 0x1002, 0x1000, 0x1013, 0x1008, 0x100e, 0x100d, 0x1012, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f, 0x007f]
|
|
for i in range(0x20):
|
|
w = data[i]
|
|
self.romFile.seek(addr)
|
|
self.race.writeWordMagic(w)
|
|
addr += 0x2
|
|
# standard item order
|
|
items = ["Missile", "Super Missile", "Power Bomb",
|
|
"Charge Beam", "Ice Beam", "Wave Beam", "Spazer", "Plasma Beam",
|
|
"Varia Suit", "Gravity Suit",
|
|
"Morph Ball", "Bomb", "Spring Ball", "Screw Attack",
|
|
"Hi-Jump Boots", "Space Jump", "Speed Booster",
|
|
"Grappling Beam", "X-Ray Scope"]
|
|
displayNames = {}
|
|
if progItemLocs is not None:
|
|
# reorder it with progression indices
|
|
prog = ord('A')
|
|
idx = 0
|
|
progNames = [il.Item.Name for il in progItemLocs if il.Item.Category != 'Boss']
|
|
for i in range(len(progNames)):
|
|
item = progNames[i]
|
|
if item in items and item not in displayNames:
|
|
items.remove(item)
|
|
items.insert(idx, item)
|
|
displayNames[item] = chr(prog + i) + ": " + item
|
|
idx += 1
|
|
for item in items:
|
|
# super fun removes items
|
|
if item not in itemLocs:
|
|
continue
|
|
display = item
|
|
if item in displayNames:
|
|
display = displayNames[item]
|
|
itemName = prepareString(display)
|
|
locationName = prepareString(itemLocs[item], isItem=False)
|
|
|
|
self.writeCreditsString(address, 0x04, itemName, isRace)
|
|
self.writeCreditsString((address + 0x40), 0x18, locationName, isRace)
|
|
|
|
address += 0x80
|
|
|
|
# we need 19 items displayed, if we've removed majors, add some blank text
|
|
while address < startCreditAddress + len(items)*0x80:
|
|
self.writeCreditsString(address, 0x04, prepareString(""), isRace)
|
|
self.writeCreditsString((address + 0x40), 0x18, prepareString(""), isRace)
|
|
|
|
address += 0x80
|
|
|
|
self.patchBytes(address, [0, 0, 0, 0], isRace)
|
|
|
|
def writeCreditsString(self, address, color, string, isRace=False):
|
|
array = [self.convertCreditsChar(color, char) for char in string]
|
|
self.patchBytes(address, array, isRace)
|
|
|
|
def writeCreditsStringBig(self, address, string, top=True):
|
|
array = [self.convertCreditsCharBig(char, top) for char in string]
|
|
self.patchBytes(address, array)
|
|
|
|
def convertCreditsChar(self, color, byte):
|
|
if byte == ' ':
|
|
ib = 0x7f
|
|
elif byte == '!':
|
|
ib = 0x1F
|
|
elif byte == ':':
|
|
ib = 0x1E
|
|
elif byte == '\\':
|
|
ib = 0x1D
|
|
elif byte == '_':
|
|
ib = 0x1C
|
|
elif byte == ',':
|
|
ib = 0x1B
|
|
elif byte == '.':
|
|
ib = 0x1A
|
|
else:
|
|
ib = ord(byte) - 0x41
|
|
|
|
if ib == 0x7F:
|
|
return 0x007F
|
|
else:
|
|
return (color << 8) + ib
|
|
|
|
def convertCreditsCharBig(self, byte, top=True):
|
|
# from: https://jathys.zophar.net/supermetroid/kejardon/TextFormat.txt
|
|
# 2-tile high characters:
|
|
# A-P = $XX20-$XX2F(TOP) and $XX30-$XX3F(BOTTOM)
|
|
# Q-Z = $XX40-$XX49(TOP) and $XX50-$XX59(BOTTOM)
|
|
# ' = $XX4A, $XX7F
|
|
# " = $XX4B, $XX7F
|
|
# . = $XX7F, $XX5A
|
|
# 0-9 = $XX60-$XX69(TOP) and $XX70-$XX79(BOTTOM)
|
|
# % = $XX6A, $XX7A
|
|
|
|
if byte == ' ':
|
|
ib = 0x7F
|
|
elif byte == "'":
|
|
if top == True:
|
|
ib = 0x4A
|
|
else:
|
|
ib = 0x7F
|
|
elif byte == '"':
|
|
if top == True:
|
|
ib = 0x4B
|
|
else:
|
|
ib = 0x7F
|
|
elif byte == '.':
|
|
if top == True:
|
|
ib = 0x7F
|
|
else:
|
|
ib = 0x5A
|
|
elif byte == '%':
|
|
if top == True:
|
|
ib = 0x6A
|
|
else:
|
|
ib = 0x7A
|
|
|
|
byte = ord(byte)
|
|
if byte >= ord('A') and byte <= ord('P'):
|
|
ib = byte - 0x21
|
|
elif byte >= ord('Q') and byte <= ord('Z'):
|
|
ib = byte - 0x11
|
|
elif byte >= ord('a') and byte <= ord('p'):
|
|
ib = byte - 0x31
|
|
elif byte >= ord('q') and byte <= ord('z'):
|
|
ib = byte - 0x21
|
|
elif byte >= ord('0') and byte <= ord('9'):
|
|
if top == True:
|
|
ib = byte + 0x30
|
|
else:
|
|
ib = byte + 0x40
|
|
|
|
return ib
|
|
|
|
def patchBytes(self, address, array, isRace=False):
|
|
self.romFile.seek(address)
|
|
for w in array:
|
|
if not isRace:
|
|
self.romFile.writeWord(w)
|
|
else:
|
|
self.race.writeWordMagic(w)
|
|
|
|
def writeDoorTransition(self, roomPtr):
|
|
if self.race is None:
|
|
self.romFile.writeWord(roomPtr)
|
|
else:
|
|
self.race.writeDoorTransition(roomPtr)
|
|
|
|
# write area randomizer transitions to ROM
|
|
# doorConnections : a list of connections. each connection is a dictionary describing
|
|
# - where to write in the ROM :
|
|
# DoorPtr : door pointer to write to
|
|
# - what to write in the ROM :
|
|
# RoomPtr, direction, bitflag, cap, screen, distanceToSpawn : door properties
|
|
# * if SamusX and SamusY are defined in the dict, custom ASM has to be written
|
|
# to reposition samus, and call doorAsmPtr if non-zero. The written Door ASM
|
|
# property shall point to this custom ASM.
|
|
# * if not, just write doorAsmPtr as the door property directly.
|
|
def writeDoorConnections(self, doorConnections):
|
|
asmAddress = Addresses.getOne('customDoorsAsm')
|
|
for conn in doorConnections:
|
|
# write door ASM for transition doors (code and pointers)
|
|
# print('Writing door connection ' + conn['ID'] + ". doorPtr="+hex(doorPtr))
|
|
doorPtr = conn['DoorPtr']
|
|
roomPtr = conn['RoomPtr']
|
|
if doorPtr in self.doorConnectionSpecific:
|
|
self.doorConnectionSpecific[doorPtr](roomPtr)
|
|
if roomPtr in self.roomConnectionSpecific:
|
|
self.roomConnectionSpecific[roomPtr](doorPtr)
|
|
self.romFile.seek(0x10000 + doorPtr)
|
|
|
|
# write room ptr
|
|
self.writeDoorTransition(roomPtr & 0xFFFF)
|
|
|
|
# write bitflag (if area switch we have to set bit 0x40, and remove it if same area)
|
|
self.romFile.writeByte(conn['bitFlag'])
|
|
|
|
# write direction
|
|
self.romFile.writeByte(conn['direction'])
|
|
|
|
# write door cap x
|
|
self.romFile.writeByte(conn['cap'][0])
|
|
|
|
# write door cap y
|
|
self.romFile.writeByte(conn['cap'][1])
|
|
|
|
# write screen x
|
|
self.romFile.writeByte(conn['screen'][0])
|
|
|
|
# write screen y
|
|
self.romFile.writeByte(conn['screen'][1])
|
|
|
|
# write distance to spawn
|
|
self.romFile.writeWord(conn['distanceToSpawn'] & 0xFFFF)
|
|
|
|
# write door asm
|
|
asmPatch = []
|
|
# call original door asm ptr if needed
|
|
if conn['doorAsmPtr'] != 0x0000:
|
|
# endian convert
|
|
(D0, D1) = (conn['doorAsmPtr'] & 0x00FF, (conn['doorAsmPtr'] & 0xFF00) >> 8)
|
|
asmPatch += [ 0x20, D0, D1 ] # JSR $doorAsmPtr
|
|
# special ASM hook point for VARIA needs when taking the door (used for animals)
|
|
if 'exitAsmPtr' in conn:
|
|
# endian convert
|
|
(D0, D1) = (conn['exitAsmPtr'] & 0x00FF, (conn['exitAsmPtr'] & 0xFF00) >> 8)
|
|
asmPatch += [ 0x20, D0, D1 ] # JSR $exitAsmPtr
|
|
# incompatible transition
|
|
if 'SamusX' in conn:
|
|
# endian convert
|
|
(X0, X1) = (conn['SamusX'] & 0x00FF, (conn['SamusX'] & 0xFF00) >> 8)
|
|
(Y0, Y1) = (conn['SamusY'] & 0x00FF, (conn['SamusY'] & 0xFF00) >> 8)
|
|
# force samus position
|
|
# see door_transition.asm. assemble it to print routines SNES addresses.
|
|
asmPatch += [ 0x20, 0x00, 0xF6 ] # JSR incompatible_doors
|
|
asmPatch += [ 0xA9, X0, X1 ] # LDA #$SamusX ; fixed Samus X position
|
|
asmPatch += [ 0x8D, 0xF6, 0x0A ] # STA $0AF6 ; update Samus X position in memory
|
|
asmPatch += [ 0xA9, Y0, Y1 ] # LDA #$SamusY ; fixed Samus Y position
|
|
asmPatch += [ 0x8D, 0xFA, 0x0A ] # STA $0AFA ; update Samus Y position in memory
|
|
else:
|
|
# still give I-frames
|
|
asmPatch += [ 0x20, 0x40, 0xF6 ] # JSR giveiframes
|
|
# return
|
|
asmPatch += [ 0x60 ] # RTS
|
|
self.romFile.writeWord(asmAddress & 0xFFFF)
|
|
|
|
self.romFile.seek(asmAddress)
|
|
for byte in asmPatch:
|
|
self.romFile.writeByte(byte)
|
|
# print("asmAddress=%x" % asmAddress)
|
|
# print("asmPatch=" + str(["%02x" % b for b in asmPatch]))
|
|
|
|
asmAddress += len(asmPatch)
|
|
# update room state header with song changes
|
|
# TODO just do an IPS patch for this as it is completely static
|
|
# this would get rid of both 'song' and 'songs' fields
|
|
# as well as this code
|
|
if 'song' in conn:
|
|
for addr in conn["songs"]:
|
|
self.romFile.seek(0x70000 + addr)
|
|
self.romFile.writeByte(conn['song'])
|
|
self.romFile.writeByte(0x5)
|
|
|
|
# change BG table to avoid scrolling sky bug when transitioning to west ocean
|
|
def patchWestOcean(self, doorPtr):
|
|
self.romFile.writeWord(doorPtr, snes_to_pc(0x8fb7bb))
|
|
|
|
# forces CRE graphics refresh when exiting kraid's or croc room
|
|
def forceRoomCRE(self, roomPtr, creFlag=0x2):
|
|
# Room ptr in bank 8F + CRE flag offset
|
|
offset = 0x70000 + roomPtr + 0x8
|
|
self.romFile.writeByte(creFlag, offset)
|
|
|
|
buttons = {
|
|
"Select" : [0x00, 0x20],
|
|
"A" : [0x80, 0x00],
|
|
"B" : [0x00, 0x80],
|
|
"X" : [0x40, 0x00],
|
|
"Y" : [0x00, 0x40],
|
|
"L" : [0x20, 0x00],
|
|
"R" : [0x10, 0x00],
|
|
"None" : [0x00, 0x00]
|
|
}
|
|
|
|
controls = {
|
|
"Shoot" : [0xb331, 0x1722d],
|
|
"Jump" : [0xb325, 0x17233],
|
|
"Dash" : [0xb32b, 0x17239],
|
|
"Item Select" : [0xb33d, 0x17245],
|
|
"Item Cancel" : [0xb337, 0x1723f],
|
|
"Angle Up" : [0xb343, 0x1724b],
|
|
"Angle Down" : [0xb349, 0x17251]
|
|
}
|
|
|
|
# write custom contols to ROM.
|
|
# controlsDict : possible keys are "Shot", "Jump", "Dash", "ItemSelect", "ItemCancel", "AngleUp", "AngleDown"
|
|
# possible values are "A", "B", "X", "Y", "L", "R", "Select", "None"
|
|
def writeControls(self, controlsDict):
|
|
for ctrl, button in controlsDict.items():
|
|
if ctrl not in RomPatcher.controls:
|
|
raise ValueError("Invalid control name : " + str(ctrl))
|
|
if button not in RomPatcher.buttons:
|
|
raise ValueError("Invalid button name : " + str(button))
|
|
for addr in RomPatcher.controls[ctrl]:
|
|
self.romFile.writeByte(RomPatcher.buttons[button][0], addr)
|
|
self.romFile.writeByte(RomPatcher.buttons[button][1])
|
|
|
|
def writePlandoAddresses(self, locations):
|
|
self.romFile.seek(Addresses.getOne('plandoAddresses'))
|
|
for loc in locations:
|
|
self.romFile.writeWord(loc.Address & 0xFFFF)
|
|
|
|
# fill remaining addresses with 0xFFFF
|
|
maxLocsNumber = 128
|
|
for i in range(0, maxLocsNumber-len(locations)):
|
|
self.romFile.writeWord(0xFFFF)
|
|
|
|
def writePlandoTransitions(self, transitions, doorsPtrs, maxTransitions):
|
|
self.romFile.seek(Addresses.getOne('plandoTransitions'))
|
|
|
|
for (src, dest) in transitions:
|
|
self.romFile.writeWord(doorsPtrs[src])
|
|
self.romFile.writeWord(doorsPtrs[dest])
|
|
|
|
# fill remaining addresses with 0xFFFF
|
|
for i in range(0, maxTransitions-len(transitions)):
|
|
self.romFile.writeWord(0xFFFF)
|
|
self.romFile.writeWord(0xFFFF)
|
|
|
|
def enableMoonWalk(self):
|
|
# replace STZ with STA since A is non-zero at this point
|
|
self.romFile.writeByte(0x8D, Addresses.getOne('moonwalk'))
|
|
|
|
def writeAdditionalETanks(self, additionalETanks):
|
|
self.romFile.writeByte(additionalETanks, Addresses.getOne("additionalETanks"))
|
|
|
|
def writeHellrunRate(self, hellrunRatePct):
|
|
hellrunRateVal = min(int(0x40*float(hellrunRatePct)/100.0), 0xff)
|
|
self.romFile.writeByte(hellrunRateVal, Addresses.getOne("hellrunRate"))
|
|
|
|
def setOamTile(self, nth, middle, newTile, y=0xFC):
|
|
# an oam entry is made of five bytes: (s000000 xxxxxxxxx) (yyyyyyyy) (YXpp000t tttttttt)
|
|
|
|
# after and before the middle of the screen is not handle the same
|
|
if nth >= middle:
|
|
x = (nth - middle) * 0x08
|
|
else:
|
|
x = 0x200 - (0x08 * (middle - nth))
|
|
|
|
self.romFile.writeWord(x)
|
|
self.romFile.writeByte(y)
|
|
self.romFile.writeWord(0x3100+newTile)
|
|
|
|
def writeVersion(self, version, addRotation=False):
|
|
# max 32 chars
|
|
|
|
# new oamlist address in free space at the end of bank 8C
|
|
self.romFile.writeWord(0xF3E9, snes_to_pc(0x8ba0e3))
|
|
self.romFile.writeWord(0xF3E9, snes_to_pc(0x8ba0e9))
|
|
|
|
# string length
|
|
versionLength = len(version)
|
|
if addRotation:
|
|
rotationLength = len('rotation')
|
|
length = versionLength + rotationLength
|
|
else:
|
|
length = versionLength
|
|
self.romFile.writeWord(length, snes_to_pc(0x8cf3e9))
|
|
versionMiddle = int(versionLength / 2) + versionLength % 2
|
|
|
|
# oams
|
|
for (i, char) in enumerate(version):
|
|
self.setOamTile(i, versionMiddle, char2tile[char])
|
|
|
|
if addRotation:
|
|
rotationMiddle = int(rotationLength / 2) + rotationLength % 2
|
|
for (i, char) in enumerate('rotation'):
|
|
self.setOamTile(i, rotationMiddle, char2tile[char], y=0x8e)
|
|
|
|
def writeDoorsColor(self, doorsStart, player):
|
|
if self.race is None:
|
|
DoorsManager.writeDoorsColor(self.romFile, doorsStart, player, self.romFile.writeWord)
|
|
else:
|
|
DoorsManager.writeDoorsColor(self.romFile, doorsStart, player, self.writePlmWord)
|
|
|
|
def writeDoorIndicators(self, plms, area, door):
|
|
indicatorFlags = IndicatorFlag.Standard | (IndicatorFlag.AreaRando if area else 0) | (IndicatorFlag.DoorRando if door else 0)
|
|
patchDict = self.patchAccess.getDictPatches()
|
|
additionalPLMs = self.patchAccess.getAdditionalPLMs()
|
|
def updateIndicatorPLM(door, doorType):
|
|
nonlocal additionalPLMs, patchDict
|
|
plmName = 'Indicator[%s]' % door
|
|
addPlm = False
|
|
if plmName in patchDict:
|
|
for addr,bytez in patchDict[plmName].items():
|
|
plmBytes = bytez
|
|
break
|
|
else:
|
|
plmBytes = additionalPLMs[plmName]['plm_bytes_list'][0]
|
|
addPlm = True
|
|
w = getWord(doorType)
|
|
plmBytes[0] = w[0]
|
|
plmBytes[1] = w[1]
|
|
return plmName, addPlm
|
|
indicatorPLMs = DoorsManager.getIndicatorPLMs(self.player, indicatorFlags)
|
|
for doorName,plmType in indicatorPLMs.items():
|
|
plmName,addPlm = updateIndicatorPLM(doorName, plmType)
|
|
if addPlm:
|
|
plms.append(plmName)
|
|
else:
|
|
self.applyIPSPatch(plmName)
|
|
|
|
def writeObjectives(self, itemLocs, tourian):
|
|
objectives = Objectives.objDict[self.player]
|
|
objectives.writeGoals(self.romFile)
|
|
objectives.writeIntroObjectives(self.romFile, tourian)
|
|
self.writeItemsMasks(itemLocs)
|
|
# hack bomb_torizo.ips to wake BT in all cases if necessary, ie chozo bots objective is on, and nothing at bombs
|
|
if objectives.isGoalActive("activate chozo robots") and RomPatches.has(self.player, RomPatches.BombTorizoWake):
|
|
bomb = next((il for il in itemLocs if il.Location.Name == "Bomb"), None)
|
|
if bomb is not None and bomb.Item.Category == "Nothing":
|
|
for addrName in ["BTtweaksHack1", "BTtweaksHack2"]:
|
|
self.romFile.seek(Addresses.getOne(addrName))
|
|
for b in [0xA9,0x00,0x00]: # LDA #$0000 ; set zero flag to wake BT
|
|
self.romFile.writeByte(b)
|
|
|
|
def writeItemsMasks(self, itemLocs):
|
|
# write items/beams masks for "collect all major" objective
|
|
itemsMask = 0
|
|
beamsMask = 0
|
|
for il in itemLocs:
|
|
if not il.Location.restricted:
|
|
item = il.Item
|
|
itemsMask |= item.ItemBits
|
|
beamsMask |= item.BeamBits
|
|
self.romFile.writeWord(itemsMask, Addresses.getOne('itemsMask'))
|
|
self.romFile.writeWord(beamsMask, Addresses.getOne('beamsMask'))
|
|
|
|
# tile number in tileset
|
|
char2tile = {
|
|
'-': 207,
|
|
'a': 208,
|
|
'.': 243,
|
|
'0': 244
|
|
}
|
|
for i in range(1, ord('z')-ord('a')+1):
|
|
char2tile[chr(ord('a')+i)] = char2tile['a']+i
|
|
for i in range(1, ord('9')-ord('0')+1):
|
|
char2tile[chr(ord('0')+i)] = char2tile['0']+i
|
|
|
|
class MessageBox(object):
|
|
def __init__(self, rom):
|
|
self.rom = rom
|
|
|
|
# in message boxes the char a is at offset 0xe0 in the tileset
|
|
self.char2tile = {'1': 0x00, '2': 0x01, '3': 0x02, '4': 0x03, '5': 0x04, '6': 0x05, '7': 0x06, '8': 0x07, '9': 0x08, '0': 0x09,
|
|
' ': 0x4e, '-': 0xcf, 'a': 0xe0, '.': 0xfa, ',': 0xfb, '`': 0xfc, "'": 0xfd, '?': 0xfe, '!': 0xff}
|
|
for i in range(1, ord('z')-ord('a')+1):
|
|
self.char2tile[chr(ord('a')+i)] = self.char2tile['a']+i
|
|
|
|
# add 0x0c/0x06 to offsets as there's 12/6 bytes before the strings, string length is either 0x13/0x1a
|
|
self.offsets = {
|
|
'ETank': (snes_to_pc(0x85877f)+0x0c, 0x13),
|
|
'Missile': (0x287bf+0x06, 0x1a),
|
|
'Super': (0x288bf+0x06, 0x1a),
|
|
'PowerBomb': (0x289bf+0x06, 0x1a),
|
|
'Grapple': (0x28abf+0x06, 0x1a),
|
|
'XRayScope': (0x28bbf+0x06, 0x1a),
|
|
'Varia': (0x28cbf+0x0c, 0x13),
|
|
'SpringBall': (0x28cff+0x0c, 0x13),
|
|
'Morph': (0x28d3f+0x0c, 0x13),
|
|
'ScrewAttack': (0x28d7f+0x0c, 0x13),
|
|
'HiJump': (0x28dbf+0x0c, 0x13),
|
|
'SpaceJump': (0x28dff+0x0c, 0x13),
|
|
'SpeedBooster': (0x28e3f+0x06, 0x1a),
|
|
'Charge': (0x28f3f+0x0c, 0x13),
|
|
'Ice': (0x28f7f+0x0c, 0x13),
|
|
'Wave': (0x28fbf+0x0c, 0x13),
|
|
'Spazer': (0x28fff+0x0c, 0x13),
|
|
'Plasma': (0x2903f+0x0c, 0x13),
|
|
'Bomb': (0x2907f+0x06, 0x1a),
|
|
'Reserve': (0x294ff+0x0c, 0x13),
|
|
'Gravity': (0x2953f+0x0c, 0x13)
|
|
}
|
|
|
|
def updateMessage(self, box, message, vFlip=False, hFlip=False):
|
|
(address, oldLength) = self.offsets[box]
|
|
newLength = len(message)
|
|
assert newLength <= oldLength, "string '{}' is too long, max {}".format(message, oldLength)
|
|
padding = oldLength - newLength
|
|
paddingLeft = int(padding / 2)
|
|
paddingRight = int(padding / 2)
|
|
paddingRight += padding % 2
|
|
|
|
attr = self.getAttr(vFlip, hFlip)
|
|
|
|
# write spaces for padding left
|
|
for i in range(paddingLeft):
|
|
self.writeChar(address, ' ')
|
|
address += 0x02
|
|
# write message
|
|
for char in message:
|
|
self.writeChar(address, char)
|
|
address += 0x01
|
|
self.updateAttr(attr, address)
|
|
address += 0x01
|
|
# write spaces for padding right
|
|
for i in range(paddingRight):
|
|
self.writeChar(address, ' ')
|
|
address += 0x02
|
|
|
|
def writeChar(self, address, char):
|
|
self.rom.writeByte(self.char2tile[char], address)
|
|
|
|
def getAttr(self, vFlip, hFlip):
|
|
# vanilla is 0x28:
|
|
byte = 0x28
|
|
if vFlip:
|
|
byte |= 0b10000000
|
|
if hFlip:
|
|
byte |= 0b01000000
|
|
return byte
|
|
|
|
def updateAttr(self, byte, address):
|
|
self.rom.writeByte(byte, address)
|
|
|
|
class RomTypeForMusic(IntFlag):
|
|
VariaSeed = 1
|
|
AreaSeed = 2
|
|
BossSeed = 4
|
|
|
|
class MusicPatcher(object):
|
|
# rom: ROM object to patch
|
|
# romType: 0 if not varia seed, or bitwise or of RomTypeForMusic enum
|
|
# baseDir: directory containing all music data/descriptors/constraints
|
|
# constraintsFile: file to constraints JSON descriptor, relative to baseDir/constraints.
|
|
# if None, will be determined automatically from romType
|
|
def __init__(self, rom, romType,
|
|
baseDir=os.path.join(appDir, 'varia_custom_sprites', 'music'),
|
|
constraintsFile=None):
|
|
self.rom = rom
|
|
self.baseDir = baseDir
|
|
variaSeed = bool(romType & RomTypeForMusic.VariaSeed)
|
|
self.area = variaSeed and bool(romType & RomTypeForMusic.AreaSeed)
|
|
self.boss = variaSeed and bool(romType & RomTypeForMusic.BossSeed)
|
|
metaDir = os.path.join(baseDir, "_metadata")
|
|
constraintsDir = os.path.join(baseDir, "_constraints")
|
|
if constraintsFile is None:
|
|
constraintsFile = 'varia.json' if variaSeed else 'vanilla.json'
|
|
with open(os.path.join(constraintsDir, constraintsFile), 'r') as f:
|
|
self.constraints = json.load(f)
|
|
nspcInfoPath = os.path.join(baseDir, "nspc_metadata.json")
|
|
with open(nspcInfoPath, "r") as f:
|
|
nspcInfo = json.load(f)
|
|
self.nspcInfo = {}
|
|
for nspc,info in nspcInfo.items():
|
|
self.nspcInfo[self._nspc_path(nspc)] = info
|
|
self.allTracks = {}
|
|
self.vanillaTracks = None
|
|
for metaFile in os.listdir(metaDir):
|
|
metaPath = os.path.join(metaDir, metaFile)
|
|
if not metaPath.endswith(".json"):
|
|
continue
|
|
with open(metaPath, 'r') as f:
|
|
meta = json.load(f)
|
|
# will silently overwrite entries with same name, so avoid
|
|
# conflicting descriptor files ...
|
|
self.allTracks.update(meta)
|
|
if metaFile == "vanilla.json":
|
|
self.vanillaTracks = meta
|
|
assert self.vanillaTracks is not None, "MusicPatcher: missing vanilla JSON descriptor"
|
|
self.replaceableTracks = [track for track in self.vanillaTracks if track not in self.constraints['preserve'] and track not in self.constraints['discard']]
|
|
self.musicDataTableAddress = snes_to_pc(0x8FE7E4)
|
|
self.musicDataTableMaxSize = 45 # to avoid overwriting useful data in bank 8F
|
|
|
|
# tracks: dict with track name to replace as key, and replacing track name as value
|
|
# updateReferences: change room state headers and special tracks. may be False if you're patching a rom hack or something
|
|
# output: if not None, dump a JSON file with what was done
|
|
# replaced tracks must be in
|
|
# replaceableTracks, and new tracks must be in allTracks
|
|
# tracks not in the dict will be kept vanilla
|
|
# raise RuntimeError if not possible
|
|
def replace(self, tracks, updateReferences=True, output=None):
|
|
for track in tracks:
|
|
if track not in self.replaceableTracks:
|
|
raise RuntimeError("Cannot replace track %s" % track)
|
|
trackList = self._getTrackList(tracks)
|
|
replacedVanilla = [t for t in self.replaceableTracks if t in trackList and t not in tracks]
|
|
for van in replacedVanilla:
|
|
tracks[van] = van
|
|
# print("trackList="+str(trackList))
|
|
musicData = self._getMusicData(trackList)
|
|
# print("musicData="+str(musicData))
|
|
if len(musicData) > self.musicDataTableMaxSize:
|
|
raise RuntimeError("Music data table too long. %d entries, max is %d" % (len(musicData, self.musicDataTableMaxSize)))
|
|
musicDataAddresses = self._getMusicDataAddresses(musicData)
|
|
self._writeMusicData(musicDataAddresses)
|
|
self._writeMusicDataTable(musicData, musicDataAddresses)
|
|
if updateReferences == True:
|
|
self._updateReferences(trackList, musicData, tracks)
|
|
if output is not None:
|
|
self._dump(output, trackList, musicData, musicDataAddresses)
|
|
|
|
# compose a track list from vanilla tracks, replaced tracks, and constraints
|
|
def _getTrackList(self, replacedTracks):
|
|
trackList = set()
|
|
for track in self.vanillaTracks:
|
|
if track in replacedTracks:
|
|
trackList.add(replacedTracks[track])
|
|
elif track not in self.constraints['discard']:
|
|
trackList.add(track)
|
|
return list(trackList)
|
|
|
|
def _nspc_path(self, nspc_path):
|
|
return os.path.join(self.baseDir, nspc_path)
|
|
|
|
# get list of music data files to include in the ROM
|
|
# can contain empty entries, marked with a None, to account
|
|
# for fixed place data ('preserve' constraint)
|
|
def _getMusicData(self, trackList):
|
|
# first, make musicData the minimum size wrt preserved tracks
|
|
preservedTracks = {trackName:self.vanillaTracks[trackName] for trackName in self.constraints['preserve']}
|
|
preservedDataIndexes = [track['data_index'] for trackName,track in preservedTracks.items()]
|
|
musicData = [None]*(max(preservedDataIndexes)+1)
|
|
# fill preserved spots
|
|
for track in self.constraints['preserve']:
|
|
idx = self.vanillaTracks[track]['data_index']
|
|
nspc = self._nspc_path(self.vanillaTracks[track]['nspc_path'])
|
|
if nspc not in musicData:
|
|
musicData[idx] = nspc
|
|
# print("stored " + nspc + " at "+ str(idx))
|
|
# then fill data in remaining spots
|
|
idx = 0
|
|
for track in trackList:
|
|
previdx = idx
|
|
if track not in self.constraints['preserve']:
|
|
nspc = self._nspc_path(self.allTracks[track]['nspc_path'])
|
|
if nspc not in musicData:
|
|
for i in range(idx, len(musicData)):
|
|
# print("at " + str(i) + ": "+str(musicData[i]))
|
|
if musicData[i] is None:
|
|
musicData[i] = nspc
|
|
idx = i+1
|
|
break
|
|
if idx == previdx:
|
|
idx += 1
|
|
musicData.append(nspc)
|
|
# print("stored " + nspc + " at "+ str(idx))
|
|
return musicData
|
|
|
|
# get addresses to store each data file to. raise RuntimeError if not possible
|
|
# pretty dumb algorithm for now, just store data wherever possible,
|
|
# prioritizing first areas in usableSpace
|
|
# store data from end of usable space to make room for other data (for hacks for instance)
|
|
def _getMusicDataAddresses(self, musicData):
|
|
usableSpace = self.constraints['usable_space_ranges_pc']
|
|
musicDataAddresses = {}
|
|
for dataFile in musicData:
|
|
if dataFile is None:
|
|
continue
|
|
sz = os.path.getsize(dataFile)
|
|
blocks = self.nspcInfo[dataFile]['block_headers_offsets']
|
|
for r in usableSpace:
|
|
# find a suitable address so header words are not split across banks (header is 2 words)
|
|
addr = r['end'] - sz
|
|
def isCrossBank(off):
|
|
nonlocal addr
|
|
endBankOffset = pc_to_snes(addr+off+4) & 0x7fff
|
|
return endBankOffset == 1 or endBankOffset == 3
|
|
while addr >= r['start'] and any(isCrossBank(off) for off in blocks):
|
|
addr -= 1
|
|
if addr >= r['start']:
|
|
musicDataAddresses[dataFile] = addr
|
|
r['end'] = addr
|
|
break
|
|
if dataFile not in musicDataAddresses:
|
|
raise RuntimeError("Cannot find enough space to store music data file "+dataFile)
|
|
return musicDataAddresses
|
|
|
|
def _writeMusicData(self, musicDataAddresses):
|
|
for dataFile, addr in musicDataAddresses.items():
|
|
self.rom.seek(addr)
|
|
with open(dataFile, 'rb') as f:
|
|
self.rom.write(f.read())
|
|
|
|
def _writeMusicDataTable(self, musicData, musicDataAddresses):
|
|
self.rom.seek(self.musicDataTableAddress)
|
|
for dataFile in musicData:
|
|
addr = pc_to_snes(musicDataAddresses[dataFile]) if dataFile in musicDataAddresses else 0
|
|
self.rom.writeLong(addr)
|
|
|
|
def _getDataId(self, musicData, track):
|
|
return (musicData.index(self._nspc_path(self.allTracks[track]['nspc_path']))+1)*3
|
|
|
|
def _getTrackId(self, track):
|
|
return self.allTracks[track]['track_index'] + 5
|
|
|
|
def _updateReferences(self, trackList, musicData, replacedTracks):
|
|
trackAddresses = {}
|
|
def addAddresses(track, vanillaTrackData, prio=False):
|
|
nonlocal trackAddresses
|
|
addrs = []
|
|
prioAddrs = []
|
|
if 'pc_addresses' in vanillaTrackData:
|
|
addrs += vanillaTrackData['pc_addresses']
|
|
if self.area and 'pc_addresses_area' in vanillaTrackData:
|
|
prioAddrs += vanillaTrackData['pc_addresses_area']
|
|
if self.boss and 'pc_addresses_boss' in vanillaTrackData:
|
|
prioAddrs += vanillaTrackData['pc_addresses_boss']
|
|
if track not in trackAddresses:
|
|
trackAddresses[track] = []
|
|
# if prioAddrs are somewhere else, remove if necessary
|
|
prioSet = set(prioAddrs)
|
|
for t,tAddrs in trackAddresses.items():
|
|
trackAddresses[t] = list(set(tAddrs) - prioSet)
|
|
# if some of addrs are somewhere else, remove them from here
|
|
for t,tAddrs in trackAddresses.items():
|
|
addrs = list(set(addrs) - set(tAddrs))
|
|
trackAddresses[track] += prioAddrs + addrs
|
|
for track in trackList:
|
|
if track in replacedTracks.values():
|
|
for van,rep in replacedTracks.items():
|
|
if rep == track:
|
|
addAddresses(track, self.vanillaTracks[van])
|
|
else:
|
|
addAddresses(track, self.vanillaTracks[track])
|
|
for track in trackList:
|
|
dataId = self._getDataId(musicData, track)
|
|
trackId = self._getTrackId(track)
|
|
for addr in trackAddresses[track]:
|
|
self.rom.seek(addr)
|
|
self.rom.writeByte(dataId)
|
|
self.rom.writeByte(trackId)
|
|
self._writeSpecialReferences(replacedTracks, musicData)
|
|
|
|
# write special (boss) data
|
|
def _writeSpecialReferences(self, replacedTracks, musicData, static=True, dynamic=True):
|
|
for track,replacement in replacedTracks.items():
|
|
# static patches are needed only when replacing tracks
|
|
if track != replacement:
|
|
staticPatches = self.vanillaTracks[track].get("static_patches", None)
|
|
else:
|
|
staticPatches = None
|
|
# dynamic patches are similar to pc_addresses*, and must be written also
|
|
# when track is vanilla, as music data table is changed
|
|
dynamicPatches = self.vanillaTracks[track].get("dynamic_patches", None)
|
|
if static and staticPatches:
|
|
for addr,bytez in staticPatches.items():
|
|
self.rom.seek(int(addr))
|
|
for b in bytez:
|
|
self.rom.writeByte(b)
|
|
if dynamic and dynamicPatches:
|
|
dataId = self._getDataId(musicData, replacement)
|
|
trackId = self._getTrackId(replacement)
|
|
dataIdAddrs = dynamicPatches.get("data_id", [])
|
|
trackIdAddrs = dynamicPatches.get("track_id", [])
|
|
for addr in dataIdAddrs:
|
|
self.rom.writeByte(dataId, addr)
|
|
for addr in trackIdAddrs:
|
|
self.rom.writeByte(trackId, addr)
|
|
|
|
def _dump(self, output, trackList, musicData, musicDataAddresses):
|
|
music={}
|
|
no=0
|
|
for md in musicData:
|
|
if md is None:
|
|
music["NoData_%d" % no] = None
|
|
no += 1
|
|
else:
|
|
tracks = []
|
|
h,t=os.path.split(md)
|
|
md=os.path.join(os.path.split(h)[1], t)
|
|
for track,trackData in self.allTracks.items():
|
|
if trackData['nspc_path'] == md:
|
|
tracks.append(track)
|
|
music[md] = tracks
|
|
musicSnesAddresses = {}
|
|
for nspc, addr in musicDataAddresses.items():
|
|
h,t=os.path.split(nspc)
|
|
nspc=os.path.join(os.path.split(h)[1], t)
|
|
musicSnesAddresses[nspc] = "$%06x" % pc_to_snes(addr)
|
|
dump = {
|
|
"track_list": sorted(trackList),
|
|
"music_data": music,
|
|
"music_data_addresses": musicSnesAddresses
|
|
}
|
|
with open(output, 'w') as f:
|
|
json.dump(dump, f, indent=4) |