Files
Grinch-AP/worlds/oot/N64Patch.py
espeon65536 51c38fc628 Ocarina of Time (#64)
* first commit (not including OoT data files yet)

* added some basic options

* rule parser works now at least

* make sure to commit everything this time

* temporary change to BaseClasses for oot

* overworld location graph builds mostly correctly

* adding oot data files

* commenting out world options until later since they only existed to make the RuleParser work

* conversion functions between AP ids and OOT ids

* world graph outputs

* set scrub prices

* itempool generates, entrances connected, way too many options added

* fixed set_rules and set_shop_rules

* temp baseclasses changes

* Reaches the fill step now, old event-based system retained in case the new way breaks

* Song placements and misc fixes everywhere

* temporary changes to make oot work

* changed root exits for AP fill framework

* prevent infinite recursion due to OoT sharing usage of the address field

* age reachability works hopefully, songs are broken again

* working spoiler log generation on beatable-only

* Logic tricks implemented

* need this for logic tricks

* fixed map/compass being placed on Serenade location

* kill unreachable events before filling the world

* add a bunch of utility functions to prepare for rom patching

* move OptionList into generic options

* fixed some silly bugs with OptionList

* properly seed all random behavior (so far)

* ROM generation working

* fix hints trying to get alttp dungeon hint texts

* continue fixing hints

* add oot to network data package

* change item and location IDs to 66000 and 67000 range respectively

* push removed items to precollected items

* fixed various issues with cross-contamination with multiple world generation

* reenable glitched logic (hopefully)

* glitched world files age-check fix

* cleaned up some get_locations calls

* added token shuffle and scrub shuffle, modified some options slightly to make the parsing work

* reenable MQ dungeons

* fix forest mq exception

* made targeting style an option for now, will be cosmetic later

* reminder to move targeting to cosmetics

* some oot option maintenance

* enabled starting time of day

* fixed issue breaking shop slots in multiworld generation

* added "off" option for text shuffle and hints

* shopsanity functionality restored

* change patch file extension

* remove unnecessary utility functions + imports

* update MIT license

* change option to "patch_uncompressed_rom" instead of "compress_rom"

* compliance with new AutoWorld systems

* Kill only internal events, remove non-internal big poe event in code

* re-add the big poe event and handle it correctly

* remove extra method in Range option

* fix typo

* Starting items, starting with consumables option

* do not remove nonexistent item

* move set_shop_rules to after shop items are placed

* some cleanup

* add retries for song placement

* flagged Skull Mask and Mask of Truth as advancement items

* update OoT to use LogicMixin

* Fixed trying to assign starting items from the wrong players

* fixed song retry step

* improved option handling, comments, and starting item replacements

* DefaultOnToggle writes Yes or No to spoiler

* enable compression of output if Compress executable is present

* clean up compression

* check whether (de)compressor exists before running the process

* allow specification of rom path in host.yaml

* check if decompressed file already exists before decompressing again

* fix triforce hunt generation

* rename all the oot state functions with prefix

* OoT: mark triforce pieces as completion goal for triforce hunt

* added overworld and any-dungeon shuffle for dungeon items

* Hide most unshuffled locations and events from the list of locations in spoiler

* build oot option ranges with a generic function instead of defining each separately

* move oot output-type control to host.yaml instead of individual yamls

* implement dungeon song shuffle

* minor improvements to overworld dungeon item shuffle

* remove random ice trap names in shops, mostly to avoid maintaining a massive censor list

* always output patch file to folder, remove option to generate ROM in preparation for removal

* re-add the fix for infinite recursion due to not being light or dark world

* change AP-sendable to Ocarina of Time model, since the triforce piece has some extra code apparently

* oot: remove item_names and location_names

* oot: minor fixes

* oot: comment out ROM patching

* oot: only add CollectionState objects on creation if actually needed

* main entrance shuffle method and entrances-based rules

* fix entrances based rules

* disable master quest and big poe count options for client compatibility

* use get_player_name instead of get_player_names

* fix OptionList

* fix oot options for new option system

* new coop section in oot rom: expand player names to 16 bytes, write AP_PLAYER_NAME at end of PLAYER_NAMES

* fill AP player name in oot rom with 0 instead of 0xDF

* encode player name with ASCII for fixed-width

* revert oot player name array to 8 bytes per name

* remove Pierre location if fast scarecrow is on

* check player name length

* "free_scarecrow" not "fast_scarecrow"

* OoT locations now properly store the AP ID instead of the oot internal ID

* oot __version__ updates in lockstep with AP version

* pull in unmodified oot cosmetic files

* also grab JSONDump since it's needed apparently

* gather extra needed methods, modify imports

* delete cosmetics log, replace all instances of SettingsList with OOTWorld

* cosmetic options working, except for sound effects (due to ear-safe issues)

* SFX, Music, and Fanfare randomization reenabled

* move OoT data files into the worlds folder

* move Compress and Decompress into oot data folder

* Replace get_all_state with custom method to avoid the cache

* OoT ROM: increment item counter before setting incoming item/player values to 0, preventing desync issues

* set data_version to 0

* make Kokiri Sword shuffle off by default

* reenable "Random Choice" for various cosmetic options

* kill Ruto's Letter turnin if open fountain
also fix for shopsanity

* place Buy Goron/Zora Tunic first in shop shuffle

* make ice traps appear as other items instead of breaking generation

* managed to break ice traps on non-major-only

* only handle ice traps if they are on

* fix shopsanity for non-oot games, and write player name instead of player number

* light arrows hint uses player name instead of player number

* Reenable "skip child zelda" option

* fix entrances_based_rules

* fix ganondorf hint if starting with light arrows

* fix dungeonitem shuffle and shopsanity interaction

* remove has_all_of, has_any_of, count_of in BaseClasses, replace usage with has_all, has_any, has_group

* force local giveable item on ZL if skip_child_zelda and shuffle_song_items is any

* keep bosses and bombchu bowling chus out of data package

* revert workaround for infinite recursion and fix it properly

* fix shared shop id caches during patching process

* fix shop text box overflows, as much as possible

* add default oot host.yaml option

* add .apz5, .n64, .z64 to gitignore

* Properly document and name all (functioning) OOT options

* clean up some imports

* remove unnecessary files from oot's data

* fix typo in gitignore

* readd the Compress and Decompress utilities, since they are needed for generation

* cleanup of imports and some minor optimizations

* increase shop offset for item IDs to 0xCB

* remove shop item AP ids entirely

* prevent triforce pieces for other players from being received by yourself

* add "excluded" property to Location

* Hint system adapted and reenabled; hints still unseeded

* make hints deterministic with lists instead of sets

* do not allow hints to point to Light Arrows on non-vanilla bridge

* foreign locations hint as their full name in OoT rather than their region

* checkedLocations now stores hint names by player ID, so that the same location in different worlds can have hints associated

* consolidate versioning in Utils

* ice traps appear as major items rather than any progression item

* set prescription and claim check as defaults for adult trade item settings

* add oot options to playerSettings

* allow case-insensitive logic tricks in yaml

* fix oot shopsanity option formatting

* Write OoT override info even if local item, enabling local checks to show up immediately in the client

* implement CollectionState.can_live_dmg for oot glitched logic

* filter item names for invalid characters when patching shops

* make ice traps appear according to the settings of the world they are shuffled into, rather than the original world

* set hidden-spoiler items and locations with Shop items to events

* make GF carpenters, Gerudo Card, Malon, ZL, and Impa events if the relevant settings are enabled, preventing them from appearing in the client on game start

* Fix oot Glitched and No Logic generation

* fix indenting

* Greatly reduce displayed cosmetic options

* Change oot data version to 1

* add apz5 distribution to webhost

* print player name if an ALttP dungeon contains a good item for OoT world

* delete unneeded commented code

* remove OcarinaSongs import to satisfy lint
2021-09-02 14:35:05 +02:00

272 lines
10 KiB
Python

import struct
import random
import io
import array
import zlib
import copy
import zipfile
from .ntype import BigStream
# get the next XOR key. Uses some location in the source rom.
# This will skip of 0s, since if we hit a block of 0s, the
# patch data will be raw.
def key_next(rom, key_address, address_range):
key = 0
while key == 0:
key_address += 1
if key_address > address_range[1]:
key_address = address_range[0]
key = rom.original.buffer[key_address]
return key, key_address
# creates a XOR block for the patch. This might break it up into
# multiple smaller blocks if there is a concern about the XOR key
# or if it is too long.
def write_block(rom, xor_address, xor_range, block_start, data, patch_data):
new_data = []
key_offset = 0
continue_block = False
for b in data:
if b == 0:
# Leave 0s as 0s. Do not XOR
new_data += [0]
else:
# get the next XOR key
key, xor_address = key_next(rom, xor_address, xor_range)
# if the XOR would result in 0, change the key.
# This requires breaking up the block.
if b == key:
write_block_section(block_start, key_offset, new_data, patch_data, continue_block)
new_data = []
key_offset = 0
continue_block = True
# search for next safe XOR key
while b == key:
key_offset += 1
key, xor_address = key_next(rom, xor_address, xor_range)
# if we aren't able to find one quickly, we may need to break again
if key_offset == 0xFF:
write_block_section(block_start, key_offset, new_data, patch_data, continue_block)
new_data = []
key_offset = 0
continue_block = True
# XOR the key with the byte
new_data += [b ^ key]
# Break the block if it's too long
if (len(new_data) == 0xFFFF):
write_block_section(block_start, key_offset, new_data, patch_data, continue_block)
new_data = []
key_offset = 0
continue_block = True
# Save the block
write_block_section(block_start, key_offset, new_data, patch_data, continue_block)
return xor_address
# This saves a sub-block for the XOR block. If it's the first part
# then it will include the address to write to. Otherwise it will
# have a number of XOR keys to skip and then continue writing after
# the previous block
def write_block_section(start, key_skip, in_data, patch_data, is_continue):
if not is_continue:
patch_data.append_int32(start)
else:
patch_data.append_bytes([0xFF, key_skip])
patch_data.append_int16(len(in_data))
patch_data.append_bytes(in_data)
# This will create the patch file. Which can be applied to a source rom.
# xor_range is the range the XOR key will read from. This range is not
# too important, but I tried to choose from a section that didn't really
# have big gaps of 0s which we want to avoid.
def create_patch_file(rom, file, xor_range=(0x00B8AD30, 0x00F029A0)):
dma_start, dma_end = rom.get_dma_table_range()
# add header
patch_data = BigStream([])
patch_data.append_bytes(list(map(ord, 'ZPFv1')))
patch_data.append_int32(dma_start)
patch_data.append_int32(xor_range[0])
patch_data.append_int32(xor_range[1])
# get random xor key. This range is chosen because it generally
# doesn't have many sections of 0s
xor_address = random.Random().randint(*xor_range)
patch_data.append_int32(xor_address)
new_buffer = copy.copy(rom.original.buffer)
# write every changed DMA entry
for dma_index, (from_file, start, size) in rom.changed_dma.items():
patch_data.append_int16(dma_index)
patch_data.append_int32(from_file)
patch_data.append_int32(start)
patch_data.append_int24(size)
# We don't trust files that have modified DMA to have their
# changed addresses tracked correctly, so we invalidate the
# entire file
for address in range(start, start + size):
rom.changed_address[address] = rom.buffer[address]
# Simulate moving the files to know which addresses have changed
if from_file >= 0:
old_dma_start, old_dma_end, old_size = rom.original.get_dmadata_record_by_key(from_file)
copy_size = min(size, old_size)
new_buffer[start:start+copy_size] = rom.original.read_bytes(from_file, copy_size)
new_buffer[start+copy_size:start+size] = [0] * (size - copy_size)
else:
# this is a new file, so we just fill with null data
new_buffer[start:start+size] = [0] * size
# end of DMA entries
patch_data.append_int16(0xFFFF)
# filter down the addresses that will actually need to change.
# Make sure to not include any of the DMA table addresses
changed_addresses = [address for address,value in rom.changed_address.items() \
if (address >= dma_end or address < dma_start) and \
(address in rom.force_patch or new_buffer[address] != value)]
changed_addresses.sort()
# Write the address changes. We'll store the data with XOR so that
# the patch data won't be raw data from the patched rom.
data = []
block_start = None
BLOCK_HEADER_SIZE = 7 # this is used to break up gaps
for address in changed_addresses:
# if there's a block to write and there's a gap, write it
if block_start:
block_end = block_start + len(data) - 1
if address > block_end + BLOCK_HEADER_SIZE:
xor_address = write_block(rom, xor_address, xor_range, block_start, data, patch_data)
data = []
block_start = None
block_end = None
# start a new block
if not block_start:
block_start = address
block_end = address - 1
# save the new data
data += rom.buffer[block_end+1:address+1]
# if there was any left over blocks, write them out
if block_start:
xor_address = write_block(rom, xor_address, xor_range, block_start, data, patch_data)
# compress the patch file
patch_data = bytes(patch_data.buffer)
patch_data = zlib.compress(patch_data)
# save the patch file
with open(file, 'wb') as outfile:
outfile.write(patch_data)
# This will apply a patch file to a source rom to generate a patched rom.
def apply_patch_file(rom, file, sub_file=None):
# load the patch file and decompress
if sub_file:
with zipfile.ZipFile(file, 'r') as patch_archive:
try:
with patch_archive.open(sub_file, 'r') as stream:
patch_data = stream.read()
except KeyError as ex:
raise FileNotFoundError('Patch file missing from archive. Invalid Player ID.')
else:
with open(file, 'rb') as stream:
patch_data = stream.read()
patch_data = BigStream(zlib.decompress(patch_data))
# make sure the header is correct
if patch_data.read_bytes(length=4) != b'ZPFv':
raise Exception("File is not in a Zelda Patch Format")
if patch_data.read_byte() != ord('1'):
# in the future we might want to have revisions for this format
raise Exception("Unsupported patch version.")
# load the patch configuration info. The fact that the DMA Table is
# included in the patch is so that this might be able to work with
# other N64 games.
dma_start = patch_data.read_int32()
xor_range = (patch_data.read_int32(), patch_data.read_int32())
xor_address = patch_data.read_int32()
# Load all the DMA table updates. This will move the files around.
# A key thing is that some of these entries will list a source file
# that they are from, so we know where to copy from, no matter where
# in the DMA table this file has been moved to. Also important if a file
# is copied. This list is terminated with 0xFFFF
while True:
# Load DMA update
dma_index = patch_data.read_int16()
if dma_index == 0xFFFF:
break
from_file = patch_data.read_int32()
start = patch_data.read_int32()
size = patch_data.read_int24()
# Save new DMA Table entry
dma_entry = dma_start + (dma_index * 0x10)
end = start + size
rom.write_int32(dma_entry, start)
rom.write_int32(None, end)
rom.write_int32(None, start)
rom.write_int32(None, 0)
if from_file != 0xFFFFFFFF:
# If a source file is listed, copy from there
old_dma_start, old_dma_end, old_size = rom.original.get_dmadata_record_by_key(from_file)
copy_size = min(size, old_size)
rom.write_bytes(start, rom.original.read_bytes(from_file, copy_size))
rom.buffer[start+copy_size:start+size] = [0] * (size - copy_size)
else:
# if it's a new file, fill with 0s
rom.buffer[start:start+size] = [0] * size
# Read in the XOR data blocks. This goes to the end of the file.
block_start = None
while not patch_data.eof():
is_new_block = patch_data.read_byte() != 0xFF
if is_new_block:
# start writing a new block
patch_data.seek_address(delta=-1)
block_start = patch_data.read_int32()
block_size = patch_data.read_int16()
else:
# continue writing from previous block
key_skip = patch_data.read_byte()
block_size = patch_data.read_int16()
# skip specified XOR keys
for _ in range(key_skip):
key, xor_address = key_next(rom, xor_address, xor_range)
# read in the new data
data = []
for b in patch_data.read_bytes(length=block_size):
if b == 0:
# keep 0s as 0s
data += [0]
else:
# The XOR will always be safe and will never produce 0
key, xor_address = key_next(rom, xor_address, xor_range)
data += [b ^ key]
# Save the new data to rom
rom.write_bytes(block_start, data)
block_start = block_start+block_size