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