Archipelago/worlds/mmbn3/Rom.py

348 lines
15 KiB
Python
Raw Normal View History

Mega Man Battle Network 3: Implement New Game (#1198) * Initializes MMBN3 world with empty files * Adds item names to item dict * Adds locations and names * Adds skeleton of MMBN3Client. Mostly copy pasta from OOT * Fixed some style and formatting * More incremental Lua tests * Adds all locations and checking to Lua connector * Made class definitions for TextPet Parser * Begun connecting item delivery system through lua and textpet * Lua Connection can now send test items * Item Delivery is now parameterized. Test command can send any chip * Adds the ability to send non-chip items * Fixes name errors in python client * Fixes count for zenny, attempts to fix bugfrags * Fixes an issue where you always received 255 bugfrags * Converts zenny and bugfrag amounts to little endian bytecode * Checks game state before sending chips Adds debug option to display information overlayed on rom Fixes chip indexing issue for chips with ids over 255 Minor text fixes * Adds in some animation reset instructions during item get message * Stores previously collected item index in save, re-sends missing items * Adds title screen check before sending locations Loading items from save could not be done via RAM. Had to be added in assembly * Adds progressive undernet check * Added library for lzss decoding bits of rom * More progress on parsing text events from ROM * Adds a way to inject messages into ScriptArchive data structure and generate bytecode * Adds Item definitions, passes to client * Adds regions and item collection rules * Touched up a few names and values that have changed in preparation for the final patching * Modifying messages via item is now successful * Added generate_output hook to generate ROM data * Generates ROM successfully * Fixes navi cust give index * Whoops forgot to wrap this in brackets * Injects extra scripts for undernet rankings * Programs had ammount and color swapped * Prompts the user for their username when connecting * Adds flagClear to the list of commands to avoid overwriting * Fixes message box crashes and several other multiworld issues * Fixes IDs and names of several items and locations * Added .gba to gitignore * Fixes compatibility after recent rebase * Fixes some locations and items that are otherwise unobtainable * Attempts to make a working launcher in the installer * Creates installer and fixes several inaccessible locations * Many minor changes to items, locations, and requirements made during testing * Adds an info page for MMBN3 * Fixes failing tests by removing duplicate IDs and properly marking progression items * Accidentally forgot to un-remove the thing * Whoops, changed this by accident * Updates self.world references to self.multiworld * Fixes imports to use from imports instead of using the namespace * Removed some leftover merge artifacts from inno setup * Puts back that darned signtool line again * Adds Overworld Metro keys as items * Adds TamaCode and puts shortcuts behind cyber passes * Fixes Numberman code 16 check * Fixes metro access logic and adds text to metro * Reworks Lua to fix crashing when many items are queued * Items for other BN3 games for different players are no longer given in the main player's ROM as well * Fixes incorrect Item ID for ACDC Metro * Fixes multi-box text messages * Adds timer before sending an item * Forgot to remove the second box of SubMems * Updates patch and lua to prevent softlocks and crashes * Adds options for extra undernet ranks, exclude jobs * Extra GigFreez now gives 20 bugfrags * Additional Progressive Undernets can no longer appear on the WWW Base * Moves item signal byte to empty area of flags instead of end of RAM * Adds Chocolate Shop locations and navi chips to fill them * Fixes save crash, and added chocolates to lua * Fixes chocolate stand selling out text, removes DrillMan cube in Undernet * Replaces old messaging system with direct memory manipulation for receiving items * Removes NDSPY requirements from MMBN3 by manually adapting the GBA's lz10 algorithm * Fixes the names of Hospital-1 Locations * Adds Canary Bit to avoid sending checks when title screen check fails * Gaining a cybermetro pass will now open the shortcut immediately * Randomizes the two accessible areas of Undernet 7, adds Hammer as item * Adds new locations to connector lua * Injects the name of the item into trade quests * Fixes copy-paste error in docs * Fixes merge artifacts and depracated code * Nut-wafer stand now faces Lan the right way after buying * Removes unused Goal Option and updates the readme to include most recent changes * Touch-ups and formatting changes * The Great Fillerization update. Dozens of items changed to Filler * Replaces instances of Mega Man with MegaMan * Update worlds/mmbn3/docs/en_MegaMan Battle Network 3.md Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> * Update worlds/mmbn3/__init__.py Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com> * Changes code ordering to suit base class's * assert_generate now checks for roms. Minor text fixes * Makes player specific frequency and excluded location options * Apply suggestions from code review Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> * Addresses suggested changes from PR review * Replaces ndspy lz10 with MIT-compliant nlzss lz10 * apworld compatibility fix for mmbn3_options from utils * Addressing more comments by el-u * APworld will now pull patch from zip folder * Apply suggestions from code review Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> * Cleaned up comments for progressive undernet ROM function, moved index list to field to avoid re-initializing * Removes improper player-indexed location/item dicts, replaces with world member variables * Avoids redefining list in progressive undernet ROM function * Filler items can no longer be generated beyond their specified amounts * Fixes list copying issue with item frequencies * Adds BN3 Client Generation back into Launcher settings * Fixes typos causing huge problems * Fixed non-relative import for apworld * Removes custom enum implementation that broke pickle * Displays message when attempting to load an incorrect ROM, will not attempt to patch it * Filler items can now only be placed once * Changes path in setup doc to match Lua path changes * Fixes file extension for MMBN3 file * Replaces magic number with reference to value in NetUtils * Moves victory rules to set_rules. Removes commented out code * Rewrites Lua script to send block of memory * Fixes off-by-one error in sending bytes for locations * Fixes issue with invalid characters in text parsing, and WWW monitor text box parsing * Moves trade text injection to init so it has access to options * Attempts to split the text boxes for hinted items * Trade checks now provide hints if the option is set for them * Fixes escape character issue for BizHawk 2.9.1 Something in Bizhawk lua parsing changed to dislike the escaped tilde. I'm not even entirely sure why it was escaped in the first place, but this should fix the compatibility of it. * Re-adds desk check that it turns out actually does exist * Updates requirements to mention bizhawk 2.7 instead of 2.3.1 * Fixes off-by-one error in command byte counts * Fixes program color indices * Fixes newline PEP violations * Reverts an accidental whitespace change made to launcher.py * Fixes URL formatting on link to settings from setup guide Co-authored-by: Zach Parks <zach@alliware.com> * Splits several lines in the readme to avoid excessive length * Fixes formatting and (hopefully) reduces cringe of joke in setup doc * Removes unnecessary constructor * Changes item frequency generation to avoid reusing the same references Co-authored-by: Zach Parks <zach@alliware.com> --------- Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com> Co-authored-by: Zach Parks <zach@alliware.com>
2023-06-29 18:36:01 +00:00
from BaseClasses import ItemClassification
from Patch import APDeltaPatch
import Utils
import os
import hashlib
import bsdiff4
from .lz10 import gba_decompress, gba_compress
from .BN3RomUtils import ArchiveToReferences, read_u16_le, read_u32_le, int16_to_byte_list_le, int32_to_byte_list_le,\
generate_progressive_undernet, ArchiveToSizeComp, ArchiveToSizeUncomp, generate_item_message, \
generate_external_item_message, generate_text_bytes
from .Items import ItemType
CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442"
def list_contains_subsequence(lst, sublist) -> bool:
sub_index = 0
for index, item in enumerate(lst):
if item == sublist[sub_index]:
sub_index += 1
if sub_index >= len(sublist):
return True
else:
sub_index = 0
return False
class ArchiveScript:
def __init__(self, index, message_bytes):
self.index = index
self.messageBoxes = []
self.set_bytes(message_bytes)
def get_bytes(self):
data = []
for message in self.messageBoxes:
data.extend(message)
return data
def set_bytes(self, message_bytes):
self.messageBoxes = []
message_box = []
command_index = 0
for byte in message_bytes:
if command_index <= 0 and (byte == 0xE9 or byte == 0xE7):
if byte == 0xE9: # More textboxes to come, don't end it yet
message_box.append(byte)
self.messageBoxes.append(message_box)
else: # It's the end of the script, add another message to end it after this one
self.messageBoxes.append(message_box)
self.messageBoxes.append([0xE7])
message_box = []
else:
if command_index <- 0:
# We can hit a command that might contain an E9 or an E7. If we do, skip checking the next few bytes
if byte == 0xF6: # CheckItem
command_index = 7
if byte == 0xF3: # CheckFlag
command_index = 7
if byte == 0xF2: # FlagSet
command_index = 4
command_index -= 1
message_box.append(byte)
# If there's still bytes left over, add them even if we didn't hit an end
if len(message_box) > 0:
self.messageBoxes.append(message_box)
def __str__(self):
s = str(self.index)+' - \n'
for messageBox in self.messageBoxes:
s += ' '+str(["{:02x}".format(x) for x in messageBox])+'\n'
class TextArchive:
def __init__(self, data, offset, size, compressed=True):
self.startOffset = offset
self.compressed = compressed
self.scripts = {}
self.scriptCount = 0xFF
self.references = ArchiveToReferences[offset]
self.unused_indices = [] # A list of places it's okay to inject new scripts
self.progressive_undernet_indices = [] # If this archive has progressive undernet, here they are in order
self.text_changed = False
if compressed:
self.compressedSize = size
self.compressedData = data
self.uncompressedData = gba_decompress(self.compressedData)
self.uncompressedSize = len(self.uncompressedData)
else:
self.uncompressedSize = size
self.uncompressedData = data
self.compressedData = gba_compress(self.uncompressedData)
self.compressedSize = len(self.compressedData)
self.scriptCount = (read_u16_le(self.uncompressedData, 0)) >> 1
for i in range(0, self.scriptCount):
start_offset = read_u16_le(self.uncompressedData, i * 2)
next_offset = read_u16_le(self.uncompressedData, (i + 1) * 2)
if start_offset != next_offset:
message_bytes = list(self.uncompressedData[start_offset:next_offset])
message = ArchiveScript(i, message_bytes)
self.scripts[i] = message
else:
self.unused_indices.append(i)
def generate_data(self, compressed=True):
header = []
scripts = []
byte_offset = self.scriptCount * 2
for i in range(0, self.scriptCount):
header.extend(int16_to_byte_list_le(byte_offset))
if i in self.scripts:
script = self.scripts[i]
scriptbytes = script.get_bytes()
scripts.extend(scriptbytes)
byte_offset += len(scriptbytes)
data = []
data.extend(header)
data.extend(scripts)
byte_data = bytes(data)
if compressed:
byte_data = gba_compress(byte_data)
return bytearray(byte_data)
def inject_item_message(self, script_index, message_indices, new_bytes):
# First step, if the old message had any flag sets or flag clears, we need to keep them.
# Mystery data has a flag set to actually remove the mystery data, and jobs often have a completion flag
for message_index in message_indices:
# print(hex(self.startOffset) + ": " + str(script_index) + " " + str(message_indices))
oldbytes = self.scripts[script_index].messageBoxes[message_index]
for i in range(len(oldbytes)-3):
# F2 00 is the code for "flagSet", with the two bytes after it being the flag to set.
# F2 04 is the code for "flagClear", which also needs to come along for the ride
# Add those to the message box after the other text.
if oldbytes[i] == 0xF2 and (oldbytes[i+1] == 0x00 or oldbytes[i+1] == 0x04):
flag = oldbytes[i:i+4]
new_bytes.extend(flag)
first_message_index = message_indices[0]
# Then, overwrite the existing script with the new one
self.scripts[script_index].messageBoxes[first_message_index] = new_bytes
for index in message_indices[1:]:
self.scripts[script_index].messageBoxes[index] = []
def inject_into_rom(self, modified_rom_data):
working_data = self.generate_data(self.compressed)
# It needs to start on a byte divisible by 4. If the rom data is not, add an FF
while len(modified_rom_data) % 4 != 0:
modified_rom_data.append(0xFF)
new_start_offset = 0x08000000 + len(modified_rom_data)
offset_byte = int32_to_byte_list_le(new_start_offset)
modified_rom_data.extend(working_data)
for offset in self.references:
modified_rom_data[offset:offset+4] = offset_byte
return modified_rom_data
def add_progression_scripts(self):
if len(self.unused_indices) < 9:
# As far as I know, this should literally not be possible.
# Every script I've looked at has dozens of unused indices, so finding 9 (8 plus one "ending" script)
# should be no problem. We re-use these so we don't have to worry about an area getting tons of these
raise AssertionError("Error in generation -- not enough room for progressive undernet in archive "+self.startOffset)
for i in range(9): # There are 8 progressive undernet ranks
new_script_index = self.unused_indices[i]
new_script = ArchiveScript(new_script_index, generate_progressive_undernet(i, self.unused_indices[i+1]))
self.scripts[new_script_index] = new_script
self.progressive_undernet_indices.append(new_script_index)
self.unused_indices = self.unused_indices[9:] # Remove the first eight elements
def inject_item_text(self, item_text, next_message=""):
item_text_bytes = generate_text_bytes(item_text)
next_message_bytes = generate_text_bytes(next_message)
for script_index in self.scripts:
script = self.scripts[script_index]
# Loop through the bytes
for message_index in range(0, len(script.messageBoxes)):
oldbytes = self.scripts[script_index].messageBoxes[message_index]
for i in range(0, len(oldbytes)-1):
if oldbytes[i] == 0x68 and oldbytes[i+1] == 0x68:
oldbytes[i:i+2] = item_text_bytes
self.text_changed = True
# If there's another text box to display, add it to the message bytes before setting them back
if len(next_message) > 0:
oldbytes.extend(next_message_bytes)
# TODO append end message nextline etc.
# I think this is "wait for button press" then "clearmessage"
oldbytes.extend([0xEB, 0xE9])
self.scripts[script_index].messageBoxes[message_index] = oldbytes
class LocalRom:
def __init__(self, file, name=None):
self.name = name
self.changed_archives = {}
self.rom_data = bytearray(get_patched_rom_bytes(file))
def get_data_chunk(self, start_offset, size):
if start_offset+size > len(self.rom_data):
print("Attempting to get data chunk beyond the size of the ROM: "+hex(start_offset)+", ROM size ends at: "+hex(len(self.rom_data)))
return self.rom_data[start_offset:start_offset + size]
def replace_item(self, location, item):
offset = location.text_archive_address
# If the archive is already loaded, use that
if offset in self.changed_archives:
archive = self.changed_archives[offset]
else:
is_compressed = offset in ArchiveToSizeComp.keys()
size = ArchiveToSizeComp[offset] if is_compressed\
else ArchiveToSizeUncomp[offset]
data = self.get_data_chunk(offset, size)
# Check if the archive we want to load has been moved by the patch. This is indicated by a 0xFF 0xFF
# as the first two bytes of the chunk
if data[0] == 0xFF and data[1] == 0xFF:
new_size_bytes = data[2:4]
new_address_le = data[4:8]
# Last byte should be zero since we're dealing with purely ROM address space
new_address_le[3] = 0x0
size = read_u16_le(new_size_bytes, 0)
data = self.get_data_chunk(read_u32_le(new_address_le, 0), size)
archive = TextArchive(data, offset, size, is_compressed)
self.changed_archives[offset] = archive
if item.type == ItemType.Undernet:
if len(archive.progressive_undernet_indices) == 0:
archive.add_progression_scripts() # Generate the new scripts
# Replace the item text box as normal. We just also add a new jump at the end of the script
item_bytes = generate_item_message(item)
changed_script = archive.scripts[location.text_script_index]
# There isn't a "Jump unconditional", so we fake one. Check flag 0 and jump
# to the start of our progression regardless of outcome
jump_to_first_undernet_bytes = [0xF3, 0x00,
0x00, 0x00,
archive.progressive_undernet_indices[0],
archive.progressive_undernet_indices[0]]
# Insert the new message second-to-last (the last index should be an end all by itself)
changed_script.messageBoxes.insert(-1, jump_to_first_undernet_bytes)
# item_bytes = jump_to_first_undernet_bytes
elif item.type == ItemType.External:
item_bytes = generate_external_item_message(item.itemName, item.recipient)
else:
item_bytes = generate_item_message(item)
archive.inject_item_message(location.text_script_index, location.text_box_indices,
item_bytes)
def insert_hint_text(self, location, short_text, long_text = ""):
"""
Replaces the placeholder text in this location's archive with short_text,
gives another text box for long_text if it's present
"""
# Replace item name placeholders
if location.inject_name:
offset = location.text_archive_address
# If the archive is already loaded, use that
if offset in self.changed_archives:
archive = self.changed_archives[offset]
else:
# It should be theoretically impossible to call insert_hint_text before actually injecting the item.
raise AssertionError("Inserting a hint at a location that doesn't have an item!")
archive.inject_item_text(short_text, long_text)
def inject_name(self, player):
authname = player
authname = authname+('\x00' * (63 - len(player)))
self.rom_data[0x7FFFC0:0x7FFFFF] = bytes(authname, 'utf8')
def write_changed_rom(self):
for archive in self.changed_archives.values():
self.rom_data = archive.inject_into_rom(self.rom_data)
def write_to_file(self, out_path):
with open(out_path, "wb") as rom:
rom.write(self.rom_data)
class MMBN3DeltaPatch(APDeltaPatch):
hash = CHECKSUM_BLUE
game = "MegaMan Battle Network 3"
patch_file_ending = ".apbn3"
result_file_ending = ".gba"
@classmethod
def get_source_data(cls) -> bytes:
return get_base_rom_bytes()
def get_base_rom_path(file_name: str = "") -> str:
options = Utils.get_options()
if not file_name:
bn3_options = options.get("mmbn3_options", None)
if bn3_options is None:
file_name = "Mega Man Battle Network 3 - Blue Version (USA).gba"
else:
file_name = bn3_options["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.local_path(file_name)
return file_name
def get_base_rom_bytes(file_name: str = "") -> bytes:
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
file_name = get_base_rom_path(file_name)
base_rom_bytes = bytes(open(file_name, "rb").read())
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if CHECKSUM_BLUE != basemd5.hexdigest():
raise Exception('Supplied Base Rom does not match US GBA Blue Version.'
'Please provide the correct ROM version')
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
return base_rom_bytes
def get_patched_rom_bytes(file_name: str = "") -> bytes:
"""
Gets the patched ROM data generated from applying the ap-patch diff file to the provided ROM.
Diff patch generated by https://github.com/digiholic/bn3-ap-patch
Which should contain all changed text banks and assembly code
"""
import pkgutil
base_rom_bytes = get_base_rom_bytes(file_name)
patch_bytes = pkgutil.get_data(__name__, "data/bn3-ap-patch.bsdiff")
patched_rom_bytes = bsdiff4.patch(base_rom_bytes, patch_bytes)
return patched_rom_bytes