Files
dockipelago/worlds/cvlod/cvlod_text.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

361 lines
19 KiB
Python

import logging
CVLOD_CHAR_WIDTHS = {" ": 8, "!": 4, '"': 7, "#": 10, "$": 10, "%": 14, "&": 10, "'": 5, "(": 6, ")": 6, "*": 8,
"+": 10, ",": 5, "-": 6, ".": 5, "/": 10, "0": 8, "1": 7, "2": 8, "3": 9, "4": 9, "5": 9, "6": 10,
"7": 9, "8": 10, "9": 9, ":": 5, ";": 6, "<": 10, "=": 10, ">": 10, "?": 11, "@": 13, "A": 14,
"B": 11, "C": 12, "D": 12, "E": 10, "F": 8, "G": 13, "H": 14, "I": 5, "J": 7, "K": 12, "L": 9,
"M": 16, "N": 13, "O": 13, "P": 10, "Q": 14, "R": 11, "S": 9, "T": 10, "U": 14, "V": 11, "W": 16,
"X": 12, "Y": 10, "Z": 11, "[": 6, "\\": 9, "]": 7, "^": 9, "_": 10, "`": 6, "a": 9, "b": 9,
"c": 8, "d": 9, "e": 9, "f": 7, "g": 9, "h": 9, "i": 6, "j": 5, "k": 9, "l": 6, "m": 13, "n": 10,
"o": 10, "p": 9, "q": 9, "r": 7, "s": 8, "t": 6, "u": 8, "v": 10, "w": 14, "x": 10, "y": 9, "z": 9,
"{": 7, "|": 5, "}": 8, "~": 9, "": 7, "": 7, "": 7, "": 5, "": 5, "": 9, "": 6, "": 6,
"": 4, "": 6, "": 6, "": 6, "": 6, "": 6, "": 6, "": 6, "": 6, "": 6, "°": 5, "": 16,
"": 7, "": 12}
CVLOD_DEFAULT_CHAR = "?"
CVLOD_ASCII_DIFFERENCE = 0x20
CVLOD_STRING_END_CHARACTER = b"\xFF\xFF"
CVLOD_TEXT_POOL_END_CHARACTER = b"\xB5\x00"
CVLOD_COMMAND_CHARS = {"": 0xA0, # Jump ahead to a corresponding 0xA1 character in the text pool. Arg = Which one.
"": 0xA1, # Spot to jump to from a corresponding 0xA0 character. Arg = Which one.
"": 0xA2, # Toggle yellow text.
"🅰": 0xA3, # Make the player press A to advance past here. Arg = Instead auto-advance after
# that number of frames if not 0.
"": 0xA7, # Pin a string on the current text line, typically a speaking character's name.
# Arg = Pin if 0, unpin if 1.
"": 0xAA, # Start set of selectable options text. Arg = Default option ID.
"": 0xAB, # Start of selectable option. Arg = option ID.
"": 0xAC, # End set of selectable options text.
"👉": 0xAD, # Start player character-exclusive text. Arg = character ID.
"👈": 0xAE, # End player character-exclusive text.
"": 0xAF, # Pause text and insert newline. Arg = How many frames to wait on.
"\f": 0xB2, # Clear the text currently in the textbox (without closing it).
"\r": 0xB5, # End of the ENTIRE text pool. There should be no more text string after this.
"\n": 0xB6, # Insert newline (without pausing).
" ": 0xB7} # Insert space.
CVLOD_COMMAND_CHARS_INV = {value: key for key, value in CVLOD_COMMAND_CHARS.items()}
ARG_CHARS = {"", "", "🅰", "", "", "", "", "👉"}
ARG_END_CHAR = "/"
NON_ASCII_MAPPINGS = {"": 0x5F, "": 0x60, "": 0x61, "": 0x62, "": 0x63, "": 0x64, "": 0x65, "": 0x66,
"": 0x67, "": 0x68, "": 0x69, "": 0x6A, "": 0x6B, "": 0x6C, "": 0x6D, "": 0x6E, "": 0x6F,
"": 0x70, "°": 0x71, "": 0x72, "": 0x73}
NON_ASCII_MAPPINGS_INV = {value: key for key, value in NON_ASCII_MAPPINGS.items()}
LINE_RESET_CHARS = {"\n", "\r", "\f", "", ""}
UNICODE_ASCII_START = 0x21
UNICODE_ASCII_END = 0x7E
LEN_LIMIT_MAP_TEXT = 254
LEN_LIMIT_MULTIWORLD_TEXT = 190
MAP_TEXT_DISPLAY_LINES = 4
def cvlod_string_to_bytearray(cvlod_text: str, len_limit: int = LEN_LIMIT_MAP_TEXT,
max_lines: int = 0, wrap: bool = True, textbox_a_advance: bool = False,
add_end_char: bool = False) -> bytearray:
"""
Converts a string into a bytearray following Castlevania: Legacy of Darkness's text format.
With some exceptions, most of the text character in this game are in standard ASCII order, albeit 0x20 less than
what its ASCII encoding would be and consisting of two bytes instead of one.
"""
# Wrap the text if we are opting to do so.
if wrap:
refined_text = cvlod_text_wrap(cvlod_text, len_limit, max_lines, textbox_a_advance)
else:
refined_text = cvlod_text
text_bytes = bytearray(0)
ctrl_arg_mode = False
arg_number = "0"
# Convert the string into CVLoD's text string format.
for i, char in enumerate(refined_text):
# If ctrl arg mode is on, then we should currently be iterating through a command character's argument number.
# Handle this appropriately.
if ctrl_arg_mode:
# If the current character is a number digit, add it to the arg number string.
if refined_text[i].isnumeric():
arg_number += refined_text[i]
continue
# If the current character is the arg end character, and number digits were added to the arg number
# string, convert the arg number string into a 1-byte number, add it to the text bytearray, disable ctrl
# arg mode, and reset the arg number string.
if refined_text[i] == ARG_END_CHAR and int(arg_number) <= 0xFF:
text_bytes.extend(int.to_bytes(int(arg_number), 1, "big"))
ctrl_arg_mode = False
arg_number = "0"
continue
# If we made it here, then there is something off about the argument. Make the argument 00 by default,
# throw an error explaining what went wrong, disable ctrl arg mode, and reset the arg number string.
text_bytes.extend([0x00])
# If the arg number is higher than 0xFF, throw an error explaining that it can't be that high.
if int(arg_number) > 0xFF:
logging.error(f"{arg_number} is too high to be a CVLoD control character argument. "
f"It needs to be 255 or less.")
# Otherwise, throw an error explaining that the argument was incorrectly formatted with characters other
# than number digits or the arg end character.
else:
logging.error("CVLoD control character argument is incorrectly formatted. It must be numbers "
"followed by a \"/\".")
ctrl_arg_mode = False
arg_number = "0"
continue
# If the current character is a command character, append that character's mapping in the command characters'
# dict.
if char in CVLOD_COMMAND_CHARS:
text_bytes.extend([CVLOD_COMMAND_CHARS[char]])
# If it's a command character followed by an argument, turn on ctrl arg mode for the next few loops.
if char in ARG_CHARS:
ctrl_arg_mode = True
# Otherwise, consider the control arg byte 00.
else:
text_bytes.extend([0x00])
continue
# If the current character has a mapping in the non-ASCII characters dict, append that character's mapping said
# dict.
if char in NON_ASCII_MAPPINGS:
text_bytes.extend(int.to_bytes(NON_ASCII_MAPPINGS[char], 2, "big"))
continue
# If the character didn't have a mapping in either dict, check to see if it's within the game's ASCII mappings
# range. If it is, subtract the ASCII difference to get the actual byte the game recognizes for that character.
if UNICODE_ASCII_START <= ord(char) <= UNICODE_ASCII_END:
# NOTE: The ASCII space actually has its own control character in this game, which is already accounted for
# by the control characters check.
text_bytes.extend(int.to_bytes(ord(char) - CVLOD_ASCII_DIFFERENCE, 2, "big"))
continue
# If we make it all the way here, meaning it didn't get caught by any of the above checks, consider it an
# unsupported character that we will replace with the default character.
text_bytes.extend(int.to_bytes(ord(CVLOD_DEFAULT_CHAR) - CVLOD_ASCII_DIFFERENCE, 2, "big"))
# Return the final in-game bytes string with or without the end character depending on whether we opted to add it.
if add_end_char:
text_bytes.extend(CVLOD_STRING_END_CHARACTER)
return text_bytes
def cvlod_strings_to_pool(cvlod_texts: [str], len_limit: int = LEN_LIMIT_MAP_TEXT,
max_lines: int = 0, wrap: bool = True, textbox_a_advance: bool = False) -> bytearray:
"""
Converts a list of strings into an entire text pool bytearray following Castlevania: Legacy of Darkness's text format.
Supports wrapping each line in the same way as the single string function.
"""
text_pool_bytes = bytearray(0)
for pool_text in cvlod_texts:
text_pool_bytes += (cvlod_string_to_bytearray(pool_text, len_limit=len_limit, max_lines=max_lines, wrap=wrap,
textbox_a_advance=textbox_a_advance, add_end_char=True))
# Add the character indicating the end of the entire text pool.
text_pool_bytes += CVLOD_TEXT_POOL_END_CHARACTER
# Pad the text data to be 4-aligned.
if len(text_pool_bytes) % 4:
text_pool_bytes += b'\x00\x00'
# Return the final result
return text_pool_bytes
def cvlod_bytes_to_string(cvlod_str_bytes: bytes) -> str:
"""Converts a given bytes sequence following CVLoD's string format (probably one extracted from the game itself)
into a usable Python string."""
converted_str = ""
for char_start in range(0, len(cvlod_str_bytes), 2):
# Check the remaining string length to see if there are at least two bytes ahead. If there's not, meaning
# there's only one, then throw an error and break the loop; the input string bytes should REALLY be an even
# length!
if len(cvlod_str_bytes[char_start:]) % 2:
logging.error(f"The following CVLoD string bytes are of an odd length when they should be even: "
f"{cvlod_str_bytes}")
break
char_bytes = cvlod_str_bytes[char_start: char_start + 2]
# Check if the upper byte is a command character. If it is, get the Python string character that we are using
# for that command character.
if char_bytes[0] in CVLOD_COMMAND_CHARS_INV:
command_char = CVLOD_COMMAND_CHARS_INV[char_bytes[0]]
# If the command character has an argument, add the lower byte number followed by the arg end character.
if command_char in ARG_CHARS:
command_char += f"{char_bytes[1]}{ARG_END_CHAR}"
converted_str += command_char
continue
# If the upper byte is 0, then it's not a command character. In which case, see if we can determine which
# standard character it is.
if not char_bytes[0]:
# If the lower byte is a non-ASCII character, take that mapping.
if char_bytes[1] in NON_ASCII_MAPPINGS_INV:
converted_str += NON_ASCII_MAPPINGS_INV[char_bytes[1]]
continue
# If the lower byte is in the game's ASCII range, add the game's ASCII difference to get the actual ASCII
# encoding for that character.
if UNICODE_ASCII_START <= char_bytes[1] + CVLOD_ASCII_DIFFERENCE <= UNICODE_ASCII_END:
converted_str += chr(char_bytes[1] + CVLOD_ASCII_DIFFERENCE)
continue
# If the character is the end character, return early because we've reached the end of the string.
if char_bytes == CVLOD_STRING_END_CHARACTER:
return converted_str
# If we made it all the way here, then we could not figure out what character it was supposed to be at all.
# In which case, use the default character.
converted_str += CVLOD_DEFAULT_CHAR
# Return the final Python string.
return converted_str
def cvlod_text_wrap(cvlod_text: str, textbox_len_limit: int = LEN_LIMIT_MAP_TEXT, max_lines: int = 0,
textbox_a_advance: bool = False) -> str:
"""Rebuilds a string with some of its spaces replaced with newlines to ensure the text wraps properly in an in-game
textbox of a given length."""
num_lines = 1
new_text = []
current_line_len = 0
current_word_len = 0
last_space_index = -1
prev_character = ""
ctrl_arg_mode = False
for i in range(len(cvlod_text)):
# Reset the newline insertion index to -1 to indicate no newline placement was decided for this loop (yet).
newline_insertion_index = -1
# If we are in ctrl param mode, add the character and continue to the next loop.
if ctrl_arg_mode:
new_text += cvlod_text[i]
# If the character is the param end character, turn off ctrl param mode for the subsequent loops because
# we've reached the end of the parameter.
if cvlod_text[i] == ARG_END_CHAR:
ctrl_arg_mode = False
continue
# Determine how much width to increase the word and line length counters by. If the character has a mapping in
# the widths dict, use its defined width from that.
if cvlod_text[i] in CVLOD_CHAR_WIDTHS:
width_to_add = CVLOD_CHAR_WIDTHS[cvlod_text[i]]
# If it's not in the widths dict, then check to see if it's in the command characters' dict. If it isn't,
# it's an unknown character we will substitute with the default character with its default width.
elif cvlod_text[i] not in CVLOD_COMMAND_CHARS:
width_to_add = CVLOD_CHAR_WIDTHS[CVLOD_DEFAULT_CHAR]
# If it was, however, then it's a special command character with no width at all. Neither the current line nor
# word length counters should increase on this loop.
else:
width_to_add = 0
# Check to see if it's one of the command characters followed by an arg. If it is, turn on ctrl arg mode
# for the next few loops until we have made it past the param.
if cvlod_text[i] in ARG_CHARS:
ctrl_arg_mode = True
# If the character we are adding is a space that would put us over the line limit, and the previously-placed
# character was also a space, don't change anything on this loop and continue to the next one.
if cvlod_text[i] == " " and current_line_len + width_to_add > textbox_len_limit and prev_character == " ":
continue
# Otherwise, add the character to the output now and record that character for the next loop.
new_text += cvlod_text[i]
prev_character = cvlod_text[i]
# Add the width we selected to the current line length.
current_line_len += width_to_add
# If the character is not a space, add the width to the current word length as well.
if cvlod_text[i] != " ":
current_word_len += width_to_add
# Otherwise, if the character is a space, record its position in the output for later and reset the current word
# length to 0.
else:
last_space_index = i + (len(new_text) - 1 - i)
current_word_len = 0
# If the character we placed is a manually-placeable newline character, record its insertion index now and set
# the current word and line lengths to the chosen width.
if cvlod_text[i] in LINE_RESET_CHARS:
newline_insertion_index = len(new_text) - 1
current_word_len = width_to_add
current_line_len = width_to_add
# If we're not looking at a manually-inserted newline character and adding the width from the character did not
# put us over the line length limit, continue to the next loop.
if current_line_len <= textbox_len_limit and newline_insertion_index < 0:
continue
# If a newline character wasn't manually inserted, decide where it should be auto-inserted here.
if newline_insertion_index < 0:
# If a space character was recorded for this line, choose that space to place the newline over and set the
# current line length to the current word length.
if last_space_index >= 0:
newline_insertion_index = last_space_index
current_line_len = current_word_len
# Otherwise, choose the end of the output string-list to insert the newline at, insert a dummy character
# right before the recently-placed plain character, and set the current word and line lengths to the chosen
# width.
else:
newline_insertion_index = len(new_text) - 1
new_text.insert(len(new_text) - 1, "")
current_word_len = width_to_add
current_line_len = width_to_add
# Increase the line counter, reset the last space index to -1, and choose the regular newline character to
# insert by default.
num_lines += 1
last_space_index = -1
newline_char = "\n"
# If the current character is a line reset character, use that character as the newline character instead.
if cvlod_text[i] in LINE_RESET_CHARS:
newline_char = cvlod_text[i]
# If this wrap puts us over the line limit and there is a line limit greater than Unlimited (indicated by it
# being 0 or negative), or a clear text character was manually placed, handle the situation here.
if (max_lines and num_lines > max_lines) or cvlod_text[i] == "\f":
# If we opted to auto "A" advance textboxes upon hitting the max lines or manually placed a clear textbox
# character, reset the line count back to 1 and choose the next textbox character(s) to insert instead.
if textbox_a_advance or cvlod_text[i] == "\f":
# If the previous character was not the A advance character AND the current character is not the clear
# textbox character, place the A advance character followed by the clear textbox character. Otherwise,
# place only the next textbox character.
if prev_character != "🅰" and cvlod_text[i] != "\f":
newline_char = "🅰0/\f"
else:
newline_char = "\f"
num_lines = 1
# Otherwise, if we did not opt to auto "A" advance textboxes (and as such are confined to just that one),
# return the final joined string now.
else:
return "".join(new_text)
# Place the chosen newline character at the chosen index.
new_text[newline_insertion_index] = newline_char
# Return the final joined, wrapped string.
return "".join(new_text)
def cvlod_command_scrubber(cvlod_text: str) -> str:
"""Scrubs all command characters from a given text string, replacing them with the default text character.
Good for any Archipelago player/item names that might try to be sneaky here..."""
new_text = ""
for char in cvlod_text:
if char in CVLOD_COMMAND_CHARS:
new_text += CVLOD_DEFAULT_CHAR
else:
new_text += char
return new_text