forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
434 lines
19 KiB
Python
434 lines
19 KiB
Python
import hashlib
|
|
import os
|
|
import Utils
|
|
import typing
|
|
import struct
|
|
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension
|
|
from typing import Sequence
|
|
from .in_game_data import (global_weapon_table, base_weapons, valid_random_starting_weapons, global_soul_table,
|
|
base_check_address_table, easter_egg_table, warp_room_bits, world_version, global_item_table, common_filler_pool,
|
|
boss_list, enemy_table, button_item_table)
|
|
from .modules.music_randomizer import area_music_randomizer, boss_music_randomizer
|
|
from .modules.boss_randomizer import write_bosses
|
|
from .modules.synthesis_randomizer import write_synthesis
|
|
from .modules.bullet_wall_randomizer import apply_souls_and_gfx
|
|
from Options import OptionError
|
|
from .Options import StartingWeapon, SoulRandomizer, SoulsanityLevel, GateItems
|
|
from .Items import soul_filler_table
|
|
from .modules.seal_shuffle import write_seals, randomize_seal_patterns
|
|
from .set_goals import write_goal_triggers
|
|
from BaseClasses import ItemClassification
|
|
|
|
hash_us = "cc0f25b8783fb83cb4588d1c111bdc18"
|
|
|
|
base_enemy_address = 0x7CCAC
|
|
soul_check_table = 0x2F6DC50
|
|
button_check_table = 0x2F6DE0C
|
|
|
|
|
|
class LocalRom(object):
|
|
|
|
def __init__(self, file: bytes, name: str | None = None) -> None:
|
|
self.file = bytearray(file)
|
|
self.name = name
|
|
|
|
def read_byte(self, offset: int) -> int:
|
|
return self.file[offset]
|
|
|
|
def read_bytes(self, offset: int, length: int) -> bytes:
|
|
return self.file[offset:offset + length]
|
|
|
|
def write_bytes(self, offset: int, values: Sequence[int]) -> None:
|
|
self.file[offset:offset + len(values)] = values
|
|
|
|
def get_bytes(self) -> bytes:
|
|
return bytes(self.file)
|
|
|
|
|
|
def patch_rom(world, rom, player: int, code_patch):
|
|
# This is the entirety of the patched code
|
|
rom.write_bytes(0x2F6DC50, code_patch)
|
|
|
|
write_goal_triggers(world, rom)
|
|
|
|
weapon = world.options.starting_weapon.value
|
|
|
|
|
|
if isinstance(weapon, str):
|
|
if weapon not in global_weapon_table:
|
|
raise OptionError(f"Error generating for player {world.player_name}. Attempted to set an invalid starting weapon: {weapon}.")
|
|
else:
|
|
if weapon == StartingWeapon.option_random_base:
|
|
weapon = world.random.choice(base_weapons)
|
|
else:
|
|
weapon = world.random.choice(valid_random_starting_weapons)
|
|
|
|
starting_weapon = global_weapon_table.index(weapon)
|
|
|
|
# Options handling
|
|
rom.write_bytes(0x122E88, bytearray([starting_weapon]))
|
|
|
|
warp_room = warp_room_bits[world.starting_warp_room]
|
|
rom.write_bytes(0x2F6DD4E, struct.pack("H", warp_room)) # The initial warp room bit
|
|
|
|
if world.options.replace_menace_with_soma:
|
|
rom.copy_bytes(0x158C3C, 8, 0x158C34) # Replace the menace warp coords with soma's
|
|
|
|
if world.options.remove_money_gates:
|
|
rom.write_bytes(0xAD661, bytearray([0x00]))
|
|
rom.write_bytes(0xB0A2D, bytearray([0x00]))
|
|
rom.write_bytes(0xBD135, bytearray([0x00]))
|
|
|
|
if world.options.disable_boss_seals:
|
|
rom.write_bytes(0x11EA18, bytearray([0x00]))
|
|
rom.write_bytes(0x140A24, bytearray([0x01, 0x00, 0xA0, 0xE3]))
|
|
|
|
if world.options.reveal_map:
|
|
rom.write_bytes(0x260C7, bytearray([0xE1, 0x00, 0x00, 0xA0, 0xE1]))
|
|
rom.write_bytes(0x28BE8, bytearray([0x00, 0x00, 0xE0, 0xE3, 0x1E, 0xFF, 0x2F]))
|
|
|
|
if world.options.open_drawbridge:
|
|
rom.write_bytes(0x0CF046, bytearray([0xA0, 0xE1])) # Make the drawbridge always be down
|
|
|
|
if world.options.fix_luck:
|
|
rom.write_bytes(0xF087D, bytearray([0x22]))
|
|
rom.write_bytes(0xF0888, bytearray([0x02, 0x70]))
|
|
rom.write_bytes(0xF088D, bytearray([0x71]))
|
|
rom.write_bytes(0xF0890, bytearray([0x00, 0x00]))
|
|
rom.write_bytes(0xF0893, bytearray([0xE1]))
|
|
rom.write_bytes(0xF089A, bytearray([0xA0, 0xE3]))
|
|
rom.write_bytes(0xF08BE, bytearray([0x87, 0xE0]))
|
|
rom.write_bytes(0xF09A0, bytearray([0x00, 0x00, 0xA0, 0xE1]))
|
|
rom.write_bytes(0xF09C8, bytearray([0x02, 0x0A]))
|
|
rom.write_bytes(0xF09CB, bytearray([0xE3]))
|
|
rom.write_bytes(0xF09F0, bytearray([0x47, 0x91, 0x80, 0xE0]))
|
|
rom.write_bytes(0xF0A00, bytearray([0x89]))
|
|
rom.write_bytes(0xF0A04, bytearray([0x02, 0x0A]))
|
|
rom.write_bytes(0xF0A07, bytearray([0xE3]))
|
|
|
|
if world.options.reveal_hidden_walls:
|
|
rom.write_bytes(0xA5231, bytearray([0x00]))
|
|
rom.write_bytes(0xA57AD, bytearray([0x00]))
|
|
rom.write_bytes(0xAA45D, bytearray([0x00]))
|
|
rom.write_bytes(0xAD3E5, bytearray([0x00]))
|
|
rom.write_bytes(0xB0199, bytearray([0x00]))
|
|
rom.write_bytes(0xBEE21, bytearray([0x00]))
|
|
rom.write_bytes(0xBEE8D, bytearray([0x00]))
|
|
rom.write_bytes(0xBEFC5, bytearray([0x00]))
|
|
rom.write_bytes(0xB84A9, bytearray([0x00]))
|
|
|
|
if not world.options.goal: # Remove the better ending trigger and replace Dario with Menace
|
|
rom.write_bytes(0xBD508, bytearray([0x60, 0xDC])) # ???
|
|
rom.write_bytes(0xBD50E, bytearray([0xFF, 0xFE, 0xD0, 0xFF]))
|
|
rom.write_bytes(0xC1C30, bytearray([0xD4, 0x94]))
|
|
rom.write_bytes(0xC1C38, bytearray([0xD0]))
|
|
#rom.write_bytes(0xB05A1, bytearray([0x00]))
|
|
|
|
#### Wall off the final boss door in the Abyss
|
|
rom.write_bytes(0x2DE0DC, bytearray([0x2F]))
|
|
rom.write_bytes(0x2DE11C, bytearray([0x3F]))
|
|
rom.write_bytes(0x2DE15C, bytearray([0x4F]))
|
|
rom.write_bytes(0x2DE19C, bytearray([0x5F]))
|
|
rom.write_bytes(0x2DE1DC, bytearray([0x5F]))
|
|
rom.write_bytes(0x2DE21C, bytearray([0x41]))
|
|
######
|
|
|
|
rom.write_bytes(0x2F6DDFD, bytearray([0xFF])) # Remove Death, Abaddon, and Aguni from the Soulstiary
|
|
rom.write_bytes(0x2F6DDFE, bytearray([0xFF])) # IF MINE IS REMOVED!!!!
|
|
rom.write_bytes(0x2F6DE02, bytearray([0xFF]))
|
|
|
|
#if world.options.goal == 2:
|
|
#rom.write_bytes(0x2F6DD48, bytearray([0x01])) # Abyss plus mode, flags the Garden event as requiring Aguni to be defeated
|
|
|
|
if world.options.one_screen_mode:
|
|
rom.write_bytes(0x2F6DD4C, bytearray([0x01]))
|
|
|
|
if world.options.boost_speed:
|
|
rom.write_bytes(0x15B2A9, bytearray([0x20]))
|
|
|
|
if world.options.death_link:
|
|
rom.write_bytes(0x2F6DD8D, bytearray([0x01]))
|
|
|
|
if world.options.no_mp_bat:
|
|
rom.write_bytes(0xA1782, bytearray([0x00])) # Zero the Bat's MP cost
|
|
|
|
if world.options.randomize_seal_patterns:
|
|
randomize_seal_patterns(world, rom)
|
|
|
|
rom.write_bytes(0x2F6DD8E, struct.pack("H", world.options.experience_percentage))
|
|
|
|
rom.write_bytes(0x2F6DD90, struct.pack("H", world.options.soul_drop_percentage))
|
|
soul_total = set(world.common_souls)
|
|
if world.options.soulsanity_level:
|
|
soul_total |= world.uncommon_souls
|
|
|
|
if world.options.soulsanity_level == SoulsanityLevel.option_rare:
|
|
soul_total |= world.rare_souls
|
|
soul_total = list(soul_total)
|
|
|
|
for i, soul in enumerate(soul_total): # Fill IDs of souls in the loc pool
|
|
rom.write_bytes(0x2F6DD94 + i, bytearray([global_soul_table.index(soul)]))
|
|
|
|
if world.options.soul_randomizer == SoulRandomizer.option_shuffled:
|
|
vanilla_souls = [soul for soul in world.important_souls if soul not in world.excluded_static_souls]
|
|
|
|
shuffled_keys = [item for item in soul_filler_table.copy() if item not in vanilla_souls] # Will this break with Aguni/Abaddon since they're not filler?
|
|
souls_output = {key: key for key in soul_filler_table.copy()} # this is assuming all vanilla souls are in soul_filler_table
|
|
shuffled_vals = world.random.sample(shuffled_keys, k=len(shuffled_keys))
|
|
for key, val in zip(shuffled_keys, shuffled_vals):
|
|
souls_output[key] = val
|
|
|
|
for soul in souls_output:
|
|
soul_data = bytearray([global_soul_table.index(souls_output[soul]), 0x05])
|
|
rom.write_bytes(soul_check_table + (global_soul_table.index(soul) * 2), soul_data)
|
|
|
|
elif world.options.soul_randomizer == SoulRandomizer.option_soulsanity:
|
|
rom.write_bytes(0x2F6DD49, bytearray([0x01]))
|
|
|
|
if world.options.shop_randomizer:
|
|
shop_pool = common_filler_pool.copy()
|
|
shop_pool = [item for item in shop_pool if item not in ["Potion", "Mind Up", "Claymore"]]
|
|
for i in range(10):
|
|
# Shop pool 2
|
|
item = world.random.choice(shop_pool)
|
|
rom.write_bytes(0xA1F14 + i, bytearray([global_item_table.index(item) + 1]))
|
|
shop_pool.remove(item)
|
|
|
|
for i in range(18):
|
|
# Shop pool 1
|
|
item = world.random.choice(shop_pool)
|
|
rom.write_bytes(0xA1F38 + i, bytearray([global_item_table.index(item) + 1]))
|
|
shop_pool.remove(item)
|
|
|
|
for i in range(19):
|
|
# Starting shop
|
|
item = world.random.choice(shop_pool)
|
|
rom.write_bytes(0xA1F4F + i, bytearray([global_item_table.index(item) + 1]))
|
|
shop_pool.remove(item)
|
|
|
|
# Claymore should always be available for breakable walls
|
|
rom.write_bytes(0xA1F4E, bytearray([global_item_table.index("Claymore") + 1]))
|
|
|
|
if world.options.shuffle_enemy_drops:
|
|
drop_pool = common_filler_pool.copy()
|
|
for enemy in enemy_table:
|
|
if enemy in boss_list: # We don't want to shuffle drops for bosses
|
|
continue
|
|
|
|
index = (base_enemy_address + (enemy_table.index(enemy) * 0x24))
|
|
common_drop_address = index + 8
|
|
rare_drop_address = index + 10
|
|
if world.random.randint(0, 99) < 45:
|
|
# Common drop
|
|
item = world.random.choice(drop_pool)
|
|
common_item = global_item_table.index(item) + 1
|
|
else:
|
|
common_item = 0
|
|
|
|
if world.random.randint(0, 99) < 29:
|
|
# Rare drop
|
|
item = world.random.choice(drop_pool)
|
|
rare_item = global_item_table.index(item) + 1
|
|
else:
|
|
rare_item = 0
|
|
|
|
rom.write_bytes(common_drop_address, bytearray([common_item]))
|
|
rom.write_bytes(rare_drop_address, bytearray([rare_item]))
|
|
|
|
write_synthesis(world, rom)
|
|
write_seals(world, rom)
|
|
|
|
if world.options.boss_shuffle:
|
|
write_bosses(world, rom)
|
|
|
|
if world.options.area_music_randomizer:
|
|
area_music_randomizer(world, rom)
|
|
|
|
if world.options.boss_music_randomizer:
|
|
boss_music_randomizer(world, rom)
|
|
|
|
if world.options.randomize_red_soul_walls:
|
|
rom.write_bytes(0x2F6DE08, bytearray([0x01])) # Tell the rom we have this on
|
|
|
|
rom.write_bytes(0x158BC0, bytearray([global_soul_table.index(world.red_soul_walls[0])]))
|
|
rom.write_bytes(0x158BBA, bytearray([global_soul_table.index(world.red_soul_walls[1])]))
|
|
rom.write_bytes(0x158BB4, bytearray([global_soul_table.index(world.red_soul_walls[2])]))
|
|
rom.write_bytes(0x158BC6, bytearray([global_soul_table.index(world.red_soul_walls[3])]))
|
|
|
|
if world.options.gate_items == GateItems.option_buttonsanity:
|
|
rom.write_bytes(0x2F6DE09, bytearray([0x01])) # Enables Button Check Mode
|
|
|
|
if world.options.hard_mode:
|
|
rom.write_bytes(0x2F6DE0A, bytearray([0x01])) # Hard mode set
|
|
|
|
if world.options.passive_soul_eater_ring:
|
|
rom.write_bytes(0x2F6DE0B, bytearray([0x01])) # Passive souls
|
|
|
|
for location in world.multiworld.get_locations(player):
|
|
item_type = 0
|
|
item_id = 0
|
|
|
|
if location.address:
|
|
if location.item.player == world.player: # If this is an item for the player, we need to extract it's Type and ID
|
|
item_type = (location.item.code & 0xFF00) >> 8
|
|
item_id = location.item.code & 0x00FF
|
|
else: # AP items are item type 2 and then use ID for progression.
|
|
item_type = 2
|
|
if ItemClassification.progression in location.item.classification:
|
|
item_id = 0x0C3B
|
|
elif ItemClassification.useful in location.item.classification:
|
|
item_id = 0x073A
|
|
elif ItemClassification.trap in location.item.classification:
|
|
item_id = 0x063A
|
|
else:
|
|
item_id = 0x093A
|
|
|
|
if location.address: # Filter out events
|
|
if location.name in global_soul_table:
|
|
if location.item.player != world.player:
|
|
# AP items on souls can use the Type as the color
|
|
item_type = (item_id & 0xFF00) >> 8
|
|
item_id = item_id & 0xFF
|
|
item_struct = (item_type << 8) | item_id
|
|
index = (global_soul_table.index(location.name) * 2)
|
|
rom.write_bytes(soul_check_table + index, struct.pack("H", item_struct))
|
|
elif location.name in easter_egg_table:
|
|
if item_id > 0xFF: # If this has a color
|
|
item_type = (item_id & 0xFF00) >> 8 # Replace the type with the color if it has one
|
|
item_id = item_id & 0xFF
|
|
rom.write_bytes(easter_egg_table[location.name][0], bytearray([item_type]))
|
|
rom.write_bytes(easter_egg_table[location.name][1], bytearray([item_id]))
|
|
elif location.name in button_item_table:
|
|
address = button_check_table + (button_item_table.index(location.name) * 4) # Set the address
|
|
item_color = item_id >> 8
|
|
item_id = item_id & 0xFF
|
|
rom.write_bytes(address, bytearray([item_type, item_id, item_color]))
|
|
|
|
else:
|
|
# Regular item checks
|
|
address = base_check_address_table[location.name]
|
|
if location.item.name in global_soul_table and location.item.player == world.player:
|
|
rom.write_bytes(address + 9, bytearray([item_id])) # High byte of the flag is used as Soul /Color ID
|
|
rom.write_bytes(address + 10, bytearray([0x3C]))
|
|
item_type = 2
|
|
else:
|
|
rom.write_bytes(address + 9, struct.pack(">H", item_id))
|
|
rom.write_bytes(address + 6, bytearray([item_type]))
|
|
|
|
rom.name = f"{world.player}_{world.auth_id}"
|
|
patch_name = rom.name + "\0"
|
|
patch_name = bytearray(rom.name, "utf8")[:0x14]
|
|
rom.write_bytes(0x2F6DD50, patch_name)
|
|
rom.write_bytes(0x2F6DD7C, world_version.encode("ascii"))
|
|
|
|
rom.write_file("token_patch.bin", rom.get_token_binary())
|
|
|
|
|
|
class DoSProcPatch(APProcedurePatch, APTokenMixin):
|
|
hash = hash_us
|
|
game = "Castlevania: Dawn of Sorrow"
|
|
patch_file_ending = ".apcvdos"
|
|
result_file_ending = ".nds"
|
|
name: bytearray
|
|
procedure = [
|
|
("apply_bsdiff4", ["dos_base.bsdiff4"]),
|
|
("apply_tokens", ["token_patch.bin"]),
|
|
("adjust_item_positions", []),
|
|
("apply_modifiers", []),
|
|
("modify_soulwall_gfx", [])
|
|
]
|
|
|
|
@classmethod
|
|
def get_source_data(cls) -> bytes:
|
|
return get_base_rom_bytes()
|
|
|
|
def write_bytes(self, offset: int, value: typing.Iterable[int]) -> None:
|
|
self.write_token(APTokenTypes.WRITE, offset, bytes(value))
|
|
|
|
def copy_bytes(self, source: int, amount: int, destination: int) -> None:
|
|
self.write_token(APTokenTypes.COPY, destination, (amount, source))
|
|
|
|
|
|
class DoSPatchExtensions(APPatchExtension):
|
|
game = "Castlevania: Dawn of Sorrow"
|
|
|
|
@staticmethod
|
|
def adjust_item_positions(caller: APProcedurePatch, rom: bytes) -> bytes:
|
|
rom = LocalRom(rom)
|
|
version_check = rom.read_bytes(0x2F6DD7C, 15)
|
|
version = version_check.rstrip(b"\x69")
|
|
version = version.decode("ascii")
|
|
if version != world_version: # Installed world is different from generated world
|
|
raise Exception(f"Error! this patch was generated on Dawn of Sorrow APworld version: {version}, but installed APworld is version: {world_version}. " +
|
|
f"Please use APWorld version {version} to patch your game.")
|
|
|
|
for check in base_check_address_table:
|
|
address = base_check_address_table[check]
|
|
item_type = int.from_bytes(rom.read_bytes(address + 6, 1))
|
|
item_id = int.from_bytes(rom.read_bytes(address + 10, 1))
|
|
if (item_type == 0x01 and item_id < 4) or (item_type == 0x02 and item_id >= 0x3D):
|
|
# Coins and Magic Seals spawn slightly in the ground, so we need to raise them up a little bit
|
|
y_pos = int.from_bytes(rom.read_bytes(address + 2, 2), byteorder="little")
|
|
y_pos -= 10
|
|
rom.write_bytes(address + 2, struct.pack("H", y_pos))
|
|
|
|
return rom.get_bytes()
|
|
|
|
@staticmethod
|
|
def apply_modifiers(caller: APProcedurePatch, rom: bytes) -> bytes:
|
|
rom = LocalRom(rom)
|
|
exp_multiplier = struct.unpack("H", rom.read_bytes(0x2F6DD8E, 2))[0] # Read the multiplier
|
|
exp_multiplier = exp_multiplier / 100
|
|
|
|
soul_chance_multiplier = struct.unpack("H", rom.read_bytes(0x2F6DD90, 2))[0]
|
|
soul_chance_multiplier = soul_chance_multiplier / 100
|
|
|
|
for enemy in enemy_table:
|
|
address = (base_enemy_address + (enemy_table.index(enemy) * 0x24))
|
|
exp_address = address + 18 # Offset where EXP is stored
|
|
exp = rom.read_bytes(exp_address, 2)
|
|
exp = struct.unpack("H", exp)[0]
|
|
exp = int(min(0xFFFF, (exp * exp_multiplier)))
|
|
rom.write_bytes(exp_address, struct.pack("H", exp))
|
|
|
|
soul_chance_address = address + 20
|
|
soul_chance = int.from_bytes(rom.read_bytes(soul_chance_address, 1))
|
|
if soul_chance: # Only modify non-guaranteed Souls
|
|
soul_chance = int(min(0xFF, (soul_chance * soul_chance_multiplier)))
|
|
rom.write_bytes(soul_chance_address, bytearray([soul_chance]))
|
|
|
|
return rom.get_bytes()
|
|
|
|
@staticmethod
|
|
def modify_soulwall_gfx(caller: APProcedurePatch, rom: bytes) -> bytes:
|
|
rom = LocalRom(rom)
|
|
soul_wall_randomizer = int.from_bytes(rom.read_bytes(0x2F6DE08, 1))
|
|
if soul_wall_randomizer:
|
|
apply_souls_and_gfx(rom)
|
|
return rom.get_bytes()
|
|
|
|
|
|
|
|
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(Utils.read_snes_rom(open(file_name, "rb")))
|
|
|
|
basemd5 = hashlib.md5()
|
|
basemd5.update(base_rom_bytes)
|
|
if hash_us != basemd5.hexdigest():
|
|
raise Exception('Supplied Base Rom does not match known MD5 for US release. '
|
|
'Get the correct game and version, then dump it')
|
|
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
|
|
return base_rom_bytes
|
|
|
|
|
|
def get_base_rom_path(file_name: str = "") -> str:
|
|
from worlds.cv_dos import DoSWorld
|
|
if not file_name:
|
|
file_name = DoSWorld.settings.rom_file
|
|
if not os.path.exists(file_name):
|
|
file_name = Utils.user_path(file_name)
|
|
return file_name
|