Files
dockipelago/worlds/cv_dos/Rom.py
Jonathan Tinney 7971961166
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
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

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