diff --git a/worlds/cvcotm/__init__.py b/worlds/cvcotm/__init__.py index 2ca5bf67e8dd..5021ae268c17 100644 --- a/worlds/cvcotm/__init__.py +++ b/worlds/cvcotm/__init__.py @@ -180,6 +180,13 @@ def generate_output(self, output_directory: str) -> None: patch.write(rom_path) + def fill_slot_data(self) -> dict: + return {"death_link": self.options.death_link.value, + "break_iron_maidens": self.options.break_iron_maidens.value, + "ignore_cleansing": self.options.ignore_cleansing.value, + "required_last_keys": self.required_last_keys, + "completion_goal": self.options.completion_goal.value} + def get_filler_item_name(self) -> str: return self.random.choice(filler_item_names) diff --git a/worlds/cvcotm/client.py b/worlds/cvcotm/client.py index d9ee9e0649e6..c48631f266cc 100644 --- a/worlds/cvcotm/client.py +++ b/worlds/cvcotm/client.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Set from .locations import base_id, get_location_names_to_ids from .text import cvcotm_string_to_bytearray +from .options import CompletionGoal, DeathLink from NetUtils import ClientStatus import worlds._bizhawk as bizhawk @@ -17,10 +18,13 @@ class CastlevaniaCotMClient(BizHawkClient): patch_suffix = ".apcvcotm" local_dss = {} self_induced_death = False - received_deathlinks = 0 local_checked_locations: Set[int] sent_message_queue = [] + death_causes = [] + currently_dead = False killed_drac_2 = False + won_battle_arena = False + saw_arena_win_message = False def __init__(self) -> None: super().__init__() @@ -38,7 +42,7 @@ async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: logger.info("ERROR: You appear to be running an unpatched version of Castlevania: Circle of the Moon. " "You need to generate a patch file and use it to create a patched ROM.") return False - if game_names[1].decode("ascii") != "ARCHIPELAG01": + if game_names[1].decode("ascii") != "ARCHIPELAG02": logger.info("ERROR: The patch file used to create this ROM is not compatible with " "this client. Double check your client version against the version being " "used by the generator.") @@ -50,7 +54,7 @@ async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: ctx.game = self.game ctx.items_handling = 0b101 - ctx.want_slot_data = False + ctx.want_slot_data = True ctx.watcher_timeout = 0.125 return True @@ -64,9 +68,23 @@ def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None: if "tags" not in args: return if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name: - self.received_deathlinks += 1 + if "cause" in args["data"]: + cause = args["data"]["cause"] + if len(cause) > 300: + cause = cause[0x00:0x89] + else: + cause = f"{args['data']['source']} killed you!" + + # Highlight the player that killed us in the game's orange text. + if args['data']['source'] in cause: + words = cause.split(args['data']['source'], 1) + cause = words[0] + "「" + args['data']['source'] + "」" + words[1] + + self.death_causes += [cause] async def game_watcher(self, ctx: "BizHawkClientContext") -> None: + if ctx.server is None or ctx.server.socket.closed or ctx.slot_data is None: + return try: read_state = await bizhawk.read(ctx.bizhawk_ctx, [(0x45D8, 1, "EWRAM"), @@ -77,7 +95,10 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: (0x2572F, 8, "EWRAM"), (0x25300, 2, "EWRAM"), (0x25308, 2, "EWRAM"), - (0x26000, 1, "EWRAM")]) + (0x26000, 1, "EWRAM"), + (0x50, 1, "EWRAM"), + (0x2562C, 4, "EWRAM"), + (0x253FC, 1, "EWRAM")]) game_state = int.from_bytes(read_state[0], "little") flag_bytes = read_state[1] @@ -88,8 +109,13 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: queued_textbox = int.from_bytes(bytearray(read_state[6]), "little") delay_timer = int.from_bytes(bytearray(read_state[7]), "little") cutscene = int.from_bytes(bytearray(read_state[8]), "little") + nathan_state = int.from_bytes(bytearray(read_state[9]), "little") + health = int.from_bytes(bytearray(read_state[10]), "little") + area = int.from_bytes(bytearray(read_state[11]), "little") - ok_to_inject = not queued_textbox and not delay_timer and not cutscene + # If there's no textbox already queued, the delay timer is 0, we are not in a cutscene, and Nathan's current + # state value is not 0x34 (using a save room), we can safely inject a textbox message. + ok_to_inject = not queued_textbox and not delay_timer and not cutscene and nathan_state != 0x34 # Make sure we are in the Gameplay or Credits states before detecting sent locations. # If we are in any other state, such as the Game Over state, reset the textbox buffers back to 0 so that we @@ -98,9 +124,19 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: # If the intro cutscene floor broken flag is not set, then assume we are in the demo; at no point during # regular gameplay will this flag not be set. if game_state not in [0x6, 0x21] or not flag_bytes[6] & 0x02: + self.currently_dead = False await bizhawk.write(ctx.bizhawk_ctx, [(0x25300, [0 for _ in range(12)], "EWRAM")]) return + # Enable DeathLink if it's in our slot_data. + if "DeathLink" not in ctx.tags and ctx.slot_data["death_link"]: + await ctx.update_death_link(True) + + # Send a DeathLink if we died on our own independently of receiving another one. + if "DeathLink" in ctx.tags and health == 0 and not self.currently_dead: + self.currently_dead = True + await ctx.send_death(f"{ctx.player_names[ctx.slot]} perished. Dracula has won!") + # Scout all Locations and capture the ones with local DSS Cards. if ctx.locations_info == {}: await ctx.send_msgs([{ @@ -108,7 +144,7 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: "locations": [code for name, code in get_location_names_to_ids().items()], "create_as_hint": 0 }]) - # Some later parts of this need the scouted Location info, so return now. + # Some other parts of this need the scouted Location info, so return now. return # Capture all the Locations with local DSS Cards, so we can trigger the Location check off the Card being @@ -117,8 +153,46 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: self.local_dss = {loc.item & 0xFF: location_id for location_id, loc in ctx.locations_info.items() if loc.player == ctx.slot and (loc.item >> 8) & 0xFF == 0xE6} + # If we won the Battle Arena, haven't seen the win message yet, and are in the Arena at the moment, pop up + # the win message while playing the game's unused Theme of Simon Belmont fanfare. + if self.won_battle_arena and not self.saw_arena_win_message and area == 0x0E and ok_to_inject: + win_message = cvcotm_string_to_bytearray(" A 「WINNER」 IS 「YOU」!▶", "little middle", 0, + wrap=False) + await bizhawk.write(ctx.bizhawk_ctx, [(0x25300, [0x1D, 0x82], "EWRAM"), + (0x25306, [0x04], "EWRAM"), + (0x7CEB00, win_message, "ROM")]) + self.saw_arena_win_message = True + + # If we have any queued death causes, handle DeathLink giving here. + elif self.death_causes and ok_to_inject and not self.currently_dead: + + # Inject the oldest cause as a textbox message and play the Dracula charge attack sound. + death_text = self.death_causes[0] + death_writes = [(0x25300, [0x1D, 0x82], "EWRAM"), + (0x25306, [0xAB, 0x01], "EWRAM")] + + # If we are in the Battle Arena and are not using the On Including Arena DeathLink option, extend the + # DeathLink message and don't actually kill Nathan. + if ctx.slot_data["death_link"] != DeathLink.option_on_including_arena and area == 0x0E: + death_text += "◊The Battle Arena nullified the DeathLink. Go fight fair and square!" + else: + # Otherwise, kill Nathan by giving him a 9999 damage-dealing poison status that hurts him as soon as + # the death cause textbox is dismissed. + death_writes += [(0xD0, [0x02], "EWRAM"), + (0xD8, [0x38], "EWRAM"), + (0xDE, [0x0F, 0x27], "EWRAM")] + + # Add the final death text and write the whole shebang. + death_writes += [(0x7CEB00, cvcotm_string_to_bytearray(death_text + "◊", "big middle", 0), "ROM")] + await bizhawk.write(ctx.bizhawk_ctx, death_writes) + + # Delete the oldest death cause that we just wrote and set currently_dead to True so the client doesn't + # think we just died on our own on the subsequent frames before the Game Over state. + del(self.death_causes[0]) + self.currently_dead = True + # If we have a queue of Locations to inject "sent" messages with, do so before giving any subsequent Items. - if self.sent_message_queue and ok_to_inject: + elif self.sent_message_queue and ok_to_inject: loc = self.sent_message_queue[0] # Truncate the Item name at 300 characters. ArchipIDLE's FFXIV Item is 214 characters, for comparison. item_name = ctx.item_names[ctx.locations_info[loc].item] @@ -133,9 +207,11 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: sent_text = cvcotm_string_to_bytearray(f"「{item_name}」 sent to 「{player_name}」◊", "big middle", 0) # Set the correct sound to play depending on the Item's classification. - if ctx.locations_info[loc].flags & 0b011: + if ctx.locations_info[loc].flags & 0b011: # Progression or Useful mssg_sfx_id = 0x1B4 - else: + elif ctx.locations_info[loc].flags & 0b100: # Trap + mssg_sfx_id = 0x7A + else: # Filler mssg_sfx_id = 0x1B3 await bizhawk.write(ctx.bizhawk_ctx, [(0x25300, [0x1D, 0x82], "EWRAM"), @@ -145,12 +221,11 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: del(self.sent_message_queue[0]) - # If the game hasn't received all items yet, the received item struct doesn't contain an item, the - # current number of received items still matches what we read before, the delay timer is 0, and we are - # not in a cutscene, then write the next incoming item into the inventory and, separately, the textbox ID - # to trigger the multiworld textbox, sound effect to play when the textbox opens, number to increment - # the received items count by, and the text to go into the multiworld textbox. The game will then do the - # rest when it's able to. + # If the game hasn't received all items yet, it's ok to inject, and the current number of received items + # still matches what we read before, then write the next incoming item into the inventory and, separately, + # the textbox ID to trigger the multiworld textbox, sound effect to play when the textbox opens, number to + # increment the received items count by, and the text to go into the multiworld textbox. The game will then + # do the rest when it's able to. elif num_received_items < len(ctx.items_received) and ok_to_inject: next_item = ctx.items_received[num_received_items] @@ -208,6 +283,11 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: if flag_id == 0xBC: self.killed_drac_2 = True + # Detect the Shinning Armor pickup flag at the end of the Battle Arena for the purposes of + # sending the game clear. + if flag_id == 0xB2: + self.won_battle_arena = True + # Check for acquired local DSS Cards. for byte_index, byte in enumerate(cards_array): if byte and byte_index in self.local_dss: @@ -228,8 +308,19 @@ async def game_watcher(self, ctx: "BizHawkClientContext") -> None: "locations": list(locs_to_send) }]) - # Send game clear if we're in the credits state or the Dracula II kill was detected. - if not ctx.finished_game and (game_state == 0x21 or self.killed_drac_2): + # Check the win condition depending on what our completion goal is. + # The Dracula option requires the "killed Dracula II" flag to be set or being in the credits state. + # The Battle Arena option requires the Shinning Armor pickup flag to be set. + # Otherwise, the Battle Arena and Dracula option requires both of the above to be satisfied simultaneously. + if ctx.slot_data["completion_goal"] == CompletionGoal.option_dracula: + win_condition = self.killed_drac_2 + elif ctx.slot_data["completion_goal"] == CompletionGoal.option_battle_arena: + win_condition = self.won_battle_arena + else: + win_condition = self.killed_drac_2 and self.won_battle_arena + + # Send game clear if we've satisfied the win condition. + if not ctx.finished_game and win_condition: await ctx.send_msgs([{ "cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL diff --git a/worlds/cvcotm/data/iname.py b/worlds/cvcotm/data/iname.py index 88533b58ec8d..e235d04f347b 100644 --- a/worlds/cvcotm/data/iname.py +++ b/worlds/cvcotm/data/iname.py @@ -32,4 +32,6 @@ pluto = "Pluto Card" ironmaidens = "Iron maidens broken" -victory = "The Count Downed" +dracula = "The Count Downed" + +shinning_armor = "Where's My Super Suit?" diff --git a/worlds/cvcotm/data/lname.py b/worlds/cvcotm/data/lname.py index 8167e590a61e..dfc682a56caf 100644 --- a/worlds/cvcotm/data/lname.py +++ b/worlds/cvcotm/data/lname.py @@ -122,6 +122,6 @@ ot16 = "Observation Tower: Near warp room fake wall" ot20 = "Observation Tower: Behind Hugh" cr1 = "Ceremonial Room: Fake floor" -ba24 = "Battle Arena: End reward" +ba24 = "Battle Arena" -victory = "Dracula" +dracula = "Dracula" diff --git a/worlds/cvcotm/data/patches.py b/worlds/cvcotm/data/patches.py index 378e323adcd2..935b299514ff 100644 --- a/worlds/cvcotm/data/patches.py +++ b/worlds/cvcotm/data/patches.py @@ -101,6 +101,28 @@ 0x1C, 0xCE, 0x06, 0x08, ] +map_sfx_preventer = [ + # Prevents the Magic Item pickup sound from playing if the Magic Item being picked up is the Map specifically. + # In these cases, the appropriate sound is played by the written remote textbox instead. + 0x70, 0x68, # ldr r0, [r6, #4] + 0xC0, 0x88, # ldrh r0, [r0, #6] + 0x05, 0x21, # mov r1, #5 + 0x88, 0x42, # cmp r0, r1 + 0x06, 0xD0, # beq 0x87FFE68 + 0xDA, 0x20, # movs r0, #0xDA + 0x40, 0x00, # lsls r0, r0, #1 + 0x03, 0x4A, # ldr r2, =0x8005E80 + 0x7B, 0x46, # mov r3, r15 + 0x05, 0x33, # adds r3, #5 + 0x9E, 0x46, # mov r14, r3 + 0x97, 0x46, # mov r15, r2 + 0x01, 0x48, # ldr r0, =0x8095BEC + 0x87, 0x46, # mov r15, r0 + # LDR number pool + 0x80, 0x5E, 0x00, 0x08, + 0xEC, 0x5B, 0x09, 0x08, +] + missing_char_data = { # The data for all missing ASCII characters from the game's dialogue textbox font. diff --git a/worlds/cvcotm/items.py b/worlds/cvcotm/items.py index 4698d25be6ae..94eb2770b071 100644 --- a/worlds/cvcotm/items.py +++ b/worlds/cvcotm/items.py @@ -21,40 +21,41 @@ class CVCotMItem(Item): # by default, unless I deliberately override it (as is the case for some Special1s). # "textbox id" = The ID of the textbox in-game that announces the item's receival. item_info = { - iname.heart_max: {"code": 0xE400, "default classification": "filler"}, - iname.hp_max: {"code": 0xE401, "default classification": "filler"}, - iname.mp_max: {"code": 0xE402, "default classification": "filler"}, - iname.salamander: {"code": 0xE600, "default classification": "useful"}, - iname.serpent: {"code": 0xE601, "default classification": "progression"}, - iname.mandragora: {"code": 0xE602, "default classification": "useful"}, - iname.golem: {"code": 0xE603, "default classification": "useful"}, - iname.cockatrice: {"code": 0xE604, "default classification": "progression"}, - iname.manticore: {"code": 0xE605, "default classification": "useful"}, - iname.griffin: {"code": 0xE606, "default classification": "useful"}, - iname.thunderbird: {"code": 0xE607, "default classification": "useful"}, - iname.unicorn: {"code": 0xE608, "default classification": "useful"}, - iname.black_dog: {"code": 0xE609, "default classification": "useful"}, - iname.mercury: {"code": 0xE60A, "default classification": "progression"}, - iname.venus: {"code": 0xE60B, "default classification": "useful"}, - iname.jupiter: {"code": 0xE60C, "default classification": "useful"}, - iname.mars: {"code": 0xE60D, "default classification": "progression"}, - iname.diana: {"code": 0xE60E, "default classification": "useful"}, - iname.apollo: {"code": 0xE60F, "default classification": "useful"}, - iname.neptune: {"code": 0xE610, "default classification": "useful"}, - iname.saturn: {"code": 0xE611, "default classification": "useful"}, - iname.uranus: {"code": 0xE612, "default classification": "useful"}, - iname.pluto: {"code": 0xE613, "default classification": "useful"}, + iname.heart_max: {"code": 0xE400, "default classification": "filler"}, + iname.hp_max: {"code": 0xE401, "default classification": "filler"}, + iname.mp_max: {"code": 0xE402, "default classification": "filler"}, + iname.salamander: {"code": 0xE600, "default classification": "useful"}, + iname.serpent: {"code": 0xE601, "default classification": "progression"}, + iname.mandragora: {"code": 0xE602, "default classification": "useful"}, + iname.golem: {"code": 0xE603, "default classification": "useful"}, + iname.cockatrice: {"code": 0xE604, "default classification": "progression"}, + iname.manticore: {"code": 0xE605, "default classification": "useful"}, + iname.griffin: {"code": 0xE606, "default classification": "useful"}, + iname.thunderbird: {"code": 0xE607, "default classification": "useful"}, + iname.unicorn: {"code": 0xE608, "default classification": "useful"}, + iname.black_dog: {"code": 0xE609, "default classification": "useful"}, + iname.mercury: {"code": 0xE60A, "default classification": "progression"}, + iname.venus: {"code": 0xE60B, "default classification": "useful"}, + iname.jupiter: {"code": 0xE60C, "default classification": "useful"}, + iname.mars: {"code": 0xE60D, "default classification": "progression"}, + iname.diana: {"code": 0xE60E, "default classification": "useful"}, + iname.apollo: {"code": 0xE60F, "default classification": "useful"}, + iname.neptune: {"code": 0xE610, "default classification": "useful"}, + iname.saturn: {"code": 0xE611, "default classification": "useful"}, + iname.uranus: {"code": 0xE612, "default classification": "useful"}, + iname.pluto: {"code": 0xE613, "default classification": "useful"}, # Dash Boots - iname.double: {"code": 0xE801, "default classification": "progression"}, - iname.tackle: {"code": 0xE802, "default classification": "progression"}, - iname.kick_boots: {"code": 0xE803, "default classification": "progression"}, - iname.heavy_ring: {"code": 0xE804, "default classification": "progression"}, + iname.double: {"code": 0xE801, "default classification": "progression"}, + iname.tackle: {"code": 0xE802, "default classification": "progression"}, + iname.kick_boots: {"code": 0xE803, "default classification": "progression"}, + iname.heavy_ring: {"code": 0xE804, "default classification": "progression"}, # Map - iname.cleansing: {"code": 0xE806, "default classification": "progression"}, - iname.roc_wing: {"code": 0xE807, "default classification": "progression"}, - iname.last_key: {"code": 0xE808, "default classification": "progression_skip_balancing"}, - iname.ironmaidens: {"default classification": "progression"}, - iname.victory: {"default classification": "progression"} + iname.cleansing: {"code": 0xE806, "default classification": "progression"}, + iname.roc_wing: {"code": 0xE807, "default classification": "progression"}, + iname.last_key: {"code": 0xE808, "default classification": "progression_skip_balancing"}, + iname.ironmaidens: {"default classification": "progression"}, + iname.dracula: {"default classification": "progression"}, + iname.shinning_armor: {"default classification": "progression"}, } action_cards = {iname.mercury, iname.venus, iname.jupiter, iname.mars, iname.diana, iname.apollo, iname.neptune, diff --git a/worlds/cvcotm/locations.py b/worlds/cvcotm/locations.py index 8e4f432e5f4e..84b267fff271 100644 --- a/worlds/cvcotm/locations.py +++ b/worlds/cvcotm/locations.py @@ -1,6 +1,6 @@ from BaseClasses import Location from .data import lname, iname -from .options import CVCotMOptions +from .options import CVCotMOptions, CompletionGoal from typing import Dict, Union, List, Tuple, Optional, Set @@ -105,8 +105,8 @@ class CVCotMLocation(Location): lname.ct18: {"code": 0x74, "offset": 0xD47C8, "room gfx": 0xD8BE6, "countdown": 7}, lname.ct_switch: {"event": iname.ironmaidens}, lname.ct22: {"code": 0x71, "offset": 0xD3CF4, "room gfx": 0xD89B6, "countdown": 7, "type": "max up boss"}, - lname.ct26: {"code": 0x9C, "offset": 0xD6ACC, "room gfx": 0xD941A, "countdown": 11, "rule": "Push AND Roc"}, - lname.ct26b: {"code": 0x9B, "offset": 0xD6AC0, "room gfx": 0xD941A, "countdown": 11, "rule": "Push AND Roc"}, + lname.ct26: {"code": 0x9C, "offset": 0xD6ACC, "room gfx": 0xD941A, "countdown": 11}, + lname.ct26b: {"code": 0x9B, "offset": 0xD6AC0, "room gfx": 0xD941A, "countdown": 11}, # Underground Gallery lname.ug0: {"code": 0x82, "offset": 0xD5944, "room gfx": 0xD9046, "countdown": 9}, lname.ug1: {"code": 0x83, "offset": 0xD5890, "room gfx": 0xD902A, "countdown": 9, "rule": "Push"}, @@ -161,9 +161,9 @@ class CVCotMLocation(Location): lname.ot20: {"code": 0xB0, "offset": 0xD6E20, "room gfx": 0xD94A6, "countdown": 12, "type": "boss"}, # Ceremonial Room lname.cr1: {"code": 0xA7, "offset": 0xD7690, "room gfx": 0xD972A, "countdown": 13, "rule": "Kick"}, - lname.victory: {"event": iname.victory, "rule": "Roc"} + lname.dracula: {"event": iname.dracula, "rule": "Roc"}, # Battle Arena - # lname.ba24: {"code": 0xB2, "offset": 0xD7D20, "room gfx": 0xD99E6, "countdown": 15}, + lname.ba24: {"event": iname.shinning_armor}, } @@ -209,6 +209,14 @@ def get_named_locations_data(locations: List[str], options: CVCotMOptions) -> \ if loc == lname.ct_switch and options.break_iron_maidens: continue + # Don't place the Dracula Location if our Completion Goal is the Battle Arena only. + if loc == lname.dracula and options.completion_goal == CompletionGoal.option_battle_arena: + continue + + # Don't place the Battle Arena Location if our Completion Goal is Dracula only. + if loc == lname.ba24 and options.completion_goal == CompletionGoal.option_dracula: + continue + loc_code = get_location_info(loc, "code") # If we are looking at an event Location, add its associated event Item to the events' dict. diff --git a/worlds/cvcotm/options.py b/worlds/cvcotm/options.py index 4dece25607ea..c0fc3a896d5d 100644 --- a/worlds/cvcotm/options.py +++ b/worlds/cvcotm/options.py @@ -105,6 +105,28 @@ class EarlyDouble(DefaultOnToggle): display_name = "Early Double" +class DeathLink(Choice): + """When you die, everyone dies. Of course the reverse is true too. + Will be ignored in the Battle Arena unless On Including Arena is chosen.""" + display_name = "DeathLink" + option_off = 0 + alias_no = 0 + alias_true = 1 + alias_yes = 1 + option_on = 1 + option_on_including_arena = 2 + default = 0 + + +class CompletionGoal(Choice): + """The goal for game completion. Whether it be defeating Dracula, winning in the Battle Arena, or both.""" + display_name = "Completion Goal" + option_dracula = 0 + option_battle_arena = 1 + option_battle_arena_and_dracula = 2 + default = 0 + + @dataclass class CVCotMOptions(PerGameCommonOptions): ignore_cleansing: IgnoreCleansing @@ -125,3 +147,5 @@ class CVCotMOptions(PerGameCommonOptions): require_all_bosses: RequireAllBosses early_double: EarlyDouble start_inventory_from_pool: StartInventoryPool + death_link: DeathLink + completion_goal: CompletionGoal diff --git a/worlds/cvcotm/regions.py b/worlds/cvcotm/regions.py index ef02612c49df..bd150639f8f8 100644 --- a/worlds/cvcotm/regions.py +++ b/worlds/cvcotm/regions.py @@ -98,11 +98,14 @@ lname.ct16, lname.ct18, lname.ct_switch, - lname.ct22, - lname.ct26, - lname.ct26b], + lname.ct22], "entrances": ["Into the Corridor Pit", - "Dip Into Waterway End"]}, + "Dip Into Waterway End", + "Arena Passage"]}, + + "Battle Arena": {"locations": [lname.ct26, + lname.ct26b, + lname.ba24]}, "Underground Gallery Upper": {"locations": [lname.ug0, lname.ug1, @@ -166,7 +169,7 @@ lname.ot20]}, "Ceremonial Room": {"locations": [lname.cr1, - lname.victory]}, + lname.dracula]}, } # # # KEY # # # @@ -187,6 +190,7 @@ "Ceremonial Door": {"destination": "Ceremonial Room", "rule": "Last Keys"}, "Machine Bottom to Top": {"destination": "Machine Tower Top"}, "Corridor to Gallery": {"destination": "Underground Gallery Upper", "rule": "Iron Maiden"}, + "Arena Passage": {"destination": "Battle Arena", "rule": "Push AND Roc"}, "Into the Corridor Pit": {"destination": "Eternal Corridor Pit"}, "Dip Into Waterway End": {"destination": "Underground Waterway End", "rule": "Roc"}, "Gallery to Corridor": {"destination": "Eternal Corridor Pit"}, diff --git a/worlds/cvcotm/rom.py b/worlds/cvcotm/rom.py index 97aa550383d1..cbd5ebee6ded 100644 --- a/worlds/cvcotm/rom.py +++ b/worlds/cvcotm/rom.py @@ -149,14 +149,18 @@ def apply_ips_patches(caller: APProcedurePatch, rom: bytes, options_file: str) - if options["disable_battle_arena_mp_drain"]: rom_data.apply_ips("NoMPDrain.ips") - # Write the textbox messaging system code + # Write the textbox messaging system code. rom_data.write_bytes(0x7D60, [0x00, 0x48, 0x87, 0x46, 0x20, 0xFF, 0x7F, 0x08]) rom_data.write_bytes(0x7FFF20, patches.remote_textbox_shower) - # Write the code that sets the screen transition delay timer + # Write the code that sets the screen transition delay timer. rom_data.write_bytes(0x6CE14, [0x00, 0x4A, 0x97, 0x46, 0xC0, 0xFF, 0x7F, 0x08]) rom_data.write_bytes(0x7FFFC0, patches.transition_textbox_delayer) + # Write the code that prevents the Map from playing its normal pickup sound. + rom_data.write_bytes(0x95BE4, [0x00, 0x4A, 0x97, 0x46, 0x50, 0xFE, 0x7F, 0x08]) + rom_data.write_bytes(0x7FFE50, patches.map_sfx_preventer) + # Change the pointer to the DSS tutorial text to instead point to our AP messaging text location. rom_data.write_bytes(0x6710BC, [0x00, 0xEB, 0x7C, 0x08]) @@ -191,8 +195,16 @@ def apply_ips_patches(caller: APProcedurePatch, rom: bytes, options_file: str) - # KCEK didn't program hardcoded checks for these, thankfully! rom_data.write_byte(0xBF3BC, 0xB4) - # Nuke the DSS tutorial - rom_data.write_byte(0x5EB55, 0xE0) + # Nuke all the item tutorials + rom_data.write_byte(0x5EB55, 0xE0) # DSS + rom_data.write_byte(0x393B8C, 0x00) # Dash Boots + rom_data.write_byte(0x393BDD, 0x00) # Double + rom_data.write_byte(0x393C33, 0x00) # Tackle + rom_data.write_byte(0x393CC2, 0x00) # Kick Boots + rom_data.write_byte(0x393D41, 0x00) # Heavy Ring + rom_data.write_byte(0x393D86, 0x00) # Cleansing + rom_data.write_byte(0x393DF5, 0x00) # Roc Wing + rom_data.write_byte(0x393E65, 0x00) # Last Key # Shorten Hugh's post-battle dialogue to give players more time to pick up his item. rom_data.write_bytes(0x393114, cvcotm_string_to_bytearray("Ok! You win!◊", "big top", 4, 2)) @@ -241,8 +253,8 @@ class CVCotMProcedurePatch(APProcedurePatch, APTokenMixin): game = "Castlevania - Circle of the Moon" procedure = [ - ("apply_tokens", ["token_data.bin"]), ("apply_ips_patches", ["options.json"]), + ("apply_tokens", ["token_data.bin"]), ("fix_item_graphics", []) ] @@ -258,7 +270,7 @@ def patch_rom(world: "CVCotMWorld", patch: CVCotMProcedurePatch, offset_data: Di patch.write_token(APTokenTypes.WRITE, offset, data) # Write the secondary name the client will use to distinguish a vanilla ROM from an AP one. - patch.write_token(APTokenTypes.WRITE, 0x7FFF00, "ARCHIPELAG01".encode("utf-8")) + patch.write_token(APTokenTypes.WRITE, 0x7FFF00, "ARCHIPELAG02".encode("utf-8")) # Write the slot authentication patch.write_token(APTokenTypes.WRITE, 0x7FFF10, bytes(world.auth)) diff --git a/worlds/cvcotm/rules.py b/worlds/cvcotm/rules.py index 2e903b942686..2898599bdc43 100644 --- a/worlds/cvcotm/rules.py +++ b/worlds/cvcotm/rules.py @@ -5,6 +5,7 @@ from .regions import get_entrance_info from .locations import get_location_info from .data import iname +from .options import CompletionGoal if TYPE_CHECKING: from . import CVCotMWorld @@ -17,6 +18,7 @@ class CVCotMRules: required_last_keys: int break_iron_maidens: int ignore_cleansing: int + completion_goal: int def __init__(self, world: "CVCotMWorld") -> None: self.player = world.player @@ -24,6 +26,7 @@ def __init__(self, world: "CVCotMWorld") -> None: self.required_last_keys = world.required_last_keys self.break_iron_maidens = world.options.break_iron_maidens.value self.ignore_cleansing = world.options.ignore_cleansing.value + self.completion_goal = world.options.completion_goal.value self.rules = { "Roc": lambda state: state.has(iname.roc_wing, self.player), @@ -86,4 +89,11 @@ def set_cvcotm_rules(self) -> None: if loc_rule is not None: loc.access_rule = self.rules[loc_rule] - multiworld.completion_condition[self.player] = lambda state: state.has(iname.victory, self.player) + # Set the World's completion condition depending on what its Completion Goal option is. + if self.completion_goal == CompletionGoal.option_dracula: + multiworld.completion_condition[self.player] = lambda state: state.has(iname.dracula, self.player) + elif self.completion_goal == CompletionGoal.option_battle_arena: + multiworld.completion_condition[self.player] = lambda state: state.has(iname.shinning_armor, self.player) + else: + multiworld.completion_condition[self.player] = \ + lambda state: state.has_all({iname.dracula, iname.shinning_armor}, self.player) diff --git a/worlds/cvcotm/text.py b/worlds/cvcotm/text.py index 7772b97569b8..b5c89c72ef0e 100644 --- a/worlds/cvcotm/text.py +++ b/worlds/cvcotm/text.py @@ -25,7 +25,7 @@ def cvcotm_string_to_bytearray(cvcotm_text: str, textbox_type: Literal["big top", "big middle", "little middle"], - speed: int, portrait: int = 0xFF) -> bytearray: + speed: int, portrait: int = 0xFF, wrap: bool = True) -> bytearray: """Converts a string into a textbox bytearray following CVCotM's string format.""" text_bytes = bytearray(0) if portrait == 0xFF and textbox_type != "little middle": @@ -53,8 +53,11 @@ def cvcotm_string_to_bytearray(cvcotm_text: str, textbox_type: Literal["big top" total_lines = 4 len_limit = 23 - # Wrap or truncate the text. - refined_text = cvcotm_text_wrap(cvcotm_text, len_limit, total_lines) + # Wrap the text if we are opting to do so. + if wrap: + refined_text = cvcotm_text_wrap(cvcotm_text, len_limit, total_lines) + else: + refined_text = cvcotm_text text_bytes.extend([0x1D, main_control_start_param + (speed & 0xF)]) # Speed should be a value between 0 and 15. @@ -66,7 +69,7 @@ def cvcotm_string_to_bytearray(cvcotm_text: str, textbox_type: Literal["big top" if char in cvcotm_char_dict: text_bytes.extend([cvcotm_char_dict[char]]) # If we're pressing A to advance, add the text clear and reset alignment characters. - if char == "▶": + if char in ["▶", "◊"]: text_bytes.extend([0x01, 0x0A]) else: text_bytes.extend([0x48])