Skip to content

Commit

Permalink
Add DeathLink and Battle Arena goal options.
Browse files Browse the repository at this point in the history
  • Loading branch information
LiquidCat64 committed May 9, 2024
1 parent 172ca0d commit 36d846e
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 74 deletions.
7 changes: 7 additions & 0 deletions worlds/cvcotm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
127 changes: 109 additions & 18 deletions worlds/cvcotm/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__()
Expand All @@ -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.")
Expand All @@ -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

Expand All @@ -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"),
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -98,17 +124,27 @@ 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([{
"cmd": "LocationScouts",
"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
Expand All @@ -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]
Expand All @@ -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"),
Expand All @@ -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]

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion worlds/cvcotm/data/iname.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?"
4 changes: 2 additions & 2 deletions worlds/cvcotm/data/lname.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
22 changes: 22 additions & 0 deletions worlds/cvcotm/data/patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading

0 comments on commit 36d846e

Please sign in to comment.