mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 20:21:32 -06:00
- fixed wrong condition in Collect to assign lastAP - fixed possible infinite loop in generating output when many SM worlds are present - fixed new VARIA code that changed a list used for every SM worlds and would throw if many SM worlds uses Aea rando and not AreaLayout
1437 lines
62 KiB
Python
1437 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 RealROM, 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'
|
|
],
|
|
# 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 = RealROM(romFileName)
|
|
#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")
|
|
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(self.romFileName, 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)
|
|
|
|
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(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) |