Last active
July 14, 2022 14:23
-
-
Save Dragorn421/b91a3a9367454de46859705bd80ebdac to your computer and use it in GitHub Desktop.
OoT64 decomp: (wip) tie cutscene cmd ids for npc actions to appropriate actors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import os | |
import re | |
def failimpl(*args): | |
print("! fail() vvvvv") | |
print(*args) | |
print("! fail() ^^^^^") | |
def fail(*args): | |
failimpl(*args) | |
exit() | |
def raisefail(*args): | |
failimpl(*args) | |
raise Exception() | |
pattern_actor_entry = re.compile( | |
r"(ACTOR_[^,]*),\s*" | |
+ r"\{[^}]*\},\s*" | |
+ r"\{[^}]*\},\s*" | |
+ r"(0x[0-9A-Fa-f]+|[0-9]+)" | |
) | |
def get_actors_used_in_scene(assets_scene_dir): | |
"""Returns a list of tuples ("ACTOR_...", params)""" | |
actors = [] | |
for root, dirs, files in os.walk(assets_scene_dir): | |
for name in files: | |
if "room" in name and name.endswith(".c"): | |
file = os.path.join(root, name) | |
with open(file) as f: | |
contents = f.read() | |
for m in pattern_actor_entry.finditer(contents): | |
actor_id = m.group(1) | |
params_str = m.group(2) | |
params_int = int(params_str, 0) | |
actors.append((actor_id, params_int)) | |
return actors | |
# eg: ACTOR_DEMO_KANKYO -> DEMO_KANKYO | |
def get_actor_name(actor_id): | |
if not actor_id.startswith("ACTOR_"): | |
raisefail('not actor_id.startswith("ACTOR_")', "actor_id =", actor_id) | |
actor_name = actor_id[len("ACTOR_") :] | |
return actor_name | |
# eg: ACTOR_DEMO_KANKYO -> ovl_Demo_Kankyo | |
def get_actor_ovl_name(actor_id): | |
actor_name = get_actor_name(actor_id) | |
actor_name_parts = actor_name.split("_") | |
for i in range(len(actor_name_parts)): | |
actor_name_part = actor_name_parts[i] | |
upper_part_length = 1 | |
# a guess at why ovl_Demo_6K is not ovl_Demo_6k | |
while actor_name_part[upper_part_length - 1] in "0123456789": | |
upper_part_length += 1 | |
actor_name_part = ( | |
actor_name_part[:upper_part_length].upper() | |
+ actor_name_part[upper_part_length:].lower() | |
) | |
actor_name_parts[i] = actor_name_part | |
actor_ovl_name = "ovl_" + "_".join(actor_name_parts) | |
return actor_ovl_name | |
# eg: ACTOR_DEMO_KANKYO -> z_demo_kankyo | |
def get_actor_z_name(actor_id): | |
actor_name = get_actor_name(actor_id) | |
actor_z_name = "z_" + actor_name.lower() | |
return actor_z_name | |
# eg: ACTOR_DEMO_KANKYO -> src/overlays/actors/ovl_Demo_Kankyo/z_demo_kankyo.c | |
def get_actor_source_path(actor_id): | |
if actor_id == "ACTOR_EN_A_OBJ": | |
return "src/code/z_en_a_keep.c" | |
elif actor_id == "ACTOR_EN_ITEM00": | |
return "src/code/z_en_item00.c" | |
else: | |
actor_ovl_name = get_actor_ovl_name(actor_id) | |
actor_z_name = get_actor_z_name(actor_id) | |
return f"src/overlays/actors/{actor_ovl_name}/{actor_z_name}.c" | |
pattern_npc_actions_indexing = re.compile(r"npcActions\[([^\]]*)\]") | |
pattern_npc_actions_used_no_indexing = re.compile(r"npcActions[^\[]") | |
def get_actor_action_indices_used(actor_id, params): | |
actor_source_path = get_actor_source_path(actor_id) | |
with open(actor_source_path) as f: | |
contents = f.read() | |
npc_actions_used_no_indexing_lines = [] | |
for m in pattern_npc_actions_used_no_indexing.finditer(contents): | |
line_start_index = contents.rfind("\n", 0, m.start()) + 1 | |
line_end_index = contents.find("\n", m.start()) | |
if line_end_index == -1: | |
line_end_index = len(contents) | |
line = contents[line_start_index:line_end_index] | |
npc_actions_used_no_indexing_lines.append(line) | |
if npc_actions_used_no_indexing_lines: | |
fail( | |
"The actor uses npcActions without indexing into it", | |
"\nactor_id =", | |
actor_id, | |
"\nactor_source_path =", | |
actor_source_path, | |
"\nnpc_actions_used_no_indexing_lines =", | |
npc_actions_used_no_indexing_lines, | |
) | |
if actor_id == "ACTOR_DEMO_KANKYO": | |
# DEMOKANKYO_ROCK_1 - DEMOKANKYO_ROCK_5 | |
if params >= 2 and params <= 6: | |
return {params - 2} | |
else: | |
return set() | |
if actor_id == "ACTOR_DEMO_EFFECT": | |
effect_type_to_action_index = { | |
0x13: 1, # DEMO_EFFECT_JEWEL_KOKIRI | |
0x14: 1, # DEMO_EFFECT_JEWEL_GORON | |
0x15: 1, # DEMO_EFFECT_JEWEL_ZORA | |
0x09: 6, # DEMO_EFFECT_MEDAL_FIRE | |
0x0A: 6, # DEMO_EFFECT_MEDAL_WATER | |
0x0B: 6, # DEMO_EFFECT_MEDAL_FOREST | |
0x0C: 6, # DEMO_EFFECT_MEDAL_SPIRIT | |
0x0D: 6, # DEMO_EFFECT_MEDAL_SHADOW | |
0x0E: 6, # DEMO_EFFECT_MEDAL_LIGHT | |
0x17: 6, # DEMO_EFFECT_LIGHTARROW | |
0x12: 7, # DEMO_EFFECT_LIGHT | |
0x04: 0, # DEMO_EFFECT_GOD_LGT_DIN | |
0x05: 1, # DEMO_EFFECT_GOD_LGT_NAYRU | |
0x06: 2, # DEMO_EFFECT_GOD_LGT_FARORE | |
0x11: 4, # DEMO_EFFECT_LIGHTRING_TRIFORCE | |
0x08: 3, # DEMO_EFFECT_TRIFORCE_SPOT | |
0x16: 2, # DEMO_EFFECT_DUST | |
} | |
effect_type = params & 0xFF | |
if effect_type not in effect_type_to_action_index: | |
return set() | |
else: | |
return {effect_type_to_action_index[effect_type]} | |
if actor_id == "ACTOR_EN_ZL2": | |
return {0} | |
if actor_id == "ACTOR_EN_XC": | |
# FIXME not sure if the actor uses both at the same time, EnXc is complicated | |
return {0, 4} | |
if actor_id == "ACTOR_DEMO_6K": | |
if params == 0: | |
return {1} | |
elif params == 1 or params in {3, 4, 5, 6, 7, 8}: | |
return {6} | |
elif params in {14, 15, 16, 17, 18, 19}: | |
return {params - 14} | |
else: | |
return set() | |
if actor_id == "ACTOR_EN_OWL": | |
# FIXME EnOwl only uses 7 but I'm not sure it always uses npcActions regardless of params | |
# (this is also true for any npcActions found with pattern_npc_actions_indexing) | |
return {7} | |
if actor_id == "ACTOR_DEMO_IM": | |
# FIXME same as EnXc. code also looks somewhat copypasted | |
return {5, 6} | |
if actor_id == "ACTOR_EN_RU2": | |
# FIXME same as EnXc. | |
return {3, 6} | |
if actor_id == "ACTOR_EN_IK": | |
# FIXME same as EnOwl. | |
return {4} | |
if actor_id == "ACTOR_EN_NB": | |
# FIXME same as EnXc. | |
return {1, 6} | |
if actor_id == "ACTOR_EN_RL": | |
# FIXME same as EnXc. | |
return {0, 6} | |
if actor_id == "ACTOR_DEMO_SA": | |
# FIXME same as EnXc. | |
return {1, 4, 6} | |
if actor_id == "ACTOR_DEMO_DU": | |
# FIXME same as EnXc. | |
return {2, 6} | |
if actor_id == "ACTOR_DEMO_GO": | |
if params == 0: | |
return {3} | |
elif params == 1: | |
return {4} | |
else: | |
return {5} | |
if actor_id == "ACTOR_EN_RU1": | |
# FIXME same as EnOwl. | |
return {3} | |
if actor_id == "ACTOR_DEMO_EC": | |
# FIXME same as EnXc. | |
return {6, 7} | |
if actor_id == "ACTOR_BG_SPOT01_IDOHASHIRA": | |
# FIXME same as EnOwl. | |
return {2} | |
if actor_id == "ACTOR_OBJECT_KANKYO": | |
# FIXME same as EnXc. | |
return {0, 1, 2, 3, 4, 5, 6} | |
if actor_id == "ACTOR_EN_TR": | |
if params == 0: # Koume | |
return {3} | |
elif params == 1: # Kotake | |
return {2} | |
else: # crash | |
return set() | |
if actor_id == "ACTOR_DEMO_EXT": | |
return {5} | |
if actor_id == "ACTOR_DEMO_GT": | |
# FIXME same as EnXc. | |
return {1, 2, 3, 4, 5, 6, 7, 9} | |
if actor_id == "ACTOR_DEMO_IK": | |
# FIXME same as EnXc. | |
return {4, 5, 6, 7} | |
actor_action_indices = set() | |
for m in pattern_npc_actions_indexing.finditer(contents): | |
actor_action_index_str = m.group(1) | |
try: | |
actor_action_index_int = int(actor_action_index_str, 0) | |
except ValueError: | |
fail( | |
"npcActions isn't indexed with an integer", | |
"\nactor_id =", | |
actor_id, | |
"\nactor_source_path =", | |
actor_source_path, | |
"\nactor_action_index_str =", | |
actor_action_index_str, | |
) | |
actor_action_indices.add(actor_action_index_int) | |
return actor_action_indices | |
# from the switch in Cutscene_ProcessCommands | |
# key is npcActions index, values are cases in the switch that use that npcActionsIndex | |
cutscene_npc_action_command_ids_by_actor_action_index = { | |
0: [ | |
# 15, # apparently unused | |
# 17, # apparently unused | |
18, | |
23, | |
34, | |
39, | |
46, | |
76, | |
85, | |
93, | |
105, | |
107, | |
110, | |
119, | |
123, | |
138, | |
139, | |
144, | |
], | |
} | |
if __name__ == "__main__": | |
for ( | |
actor_action_index, | |
cutscene_npc_action_command_ids, | |
) in cutscene_npc_action_command_ids_by_actor_action_index.items(): | |
for cutscene_npc_action_command_id in cutscene_npc_action_command_ids: | |
cutscene_command_searches = [ | |
f"CS_NPC_ACTION_LIST({cutscene_npc_action_command_id},", | |
f"CS_NPC_ACTION_LIST(0x{cutscene_npc_action_command_id:03X},", | |
] | |
relevant_actor_ids = None | |
for top in ("assets", "src"): | |
for root, dirs, files in os.walk(top): | |
for name in files: | |
if name.endswith(".c"): | |
file = os.path.join(root, name) | |
with open(file) as f: | |
lines = f.readlines() | |
found_cutscene_command_searches = [ | |
cutscene_command_search | |
for cutscene_command_search in cutscene_command_searches | |
if any( | |
cutscene_command_search in line for line in lines | |
) | |
] | |
has_cs_command = len(found_cutscene_command_searches) > 0 | |
if has_cs_command: | |
if file.startswith("assets/scenes/"): | |
actors_used_in_scene = get_actors_used_in_scene( | |
root | |
) | |
if relevant_actor_ids is None: | |
print( | |
"Initializing relevant_actor_ids =", | |
relevant_actor_ids, | |
"from actors_used_in_scene =", | |
actors_used_in_scene, | |
) | |
relevant_actor_ids = set() | |
for actor_id, params in actors_used_in_scene: | |
actor_action_indices = ( | |
get_actor_action_indices_used( | |
actor_id, params | |
) | |
) | |
print( | |
f"{actor_id}:0x{params:04X} {actor_action_indices}", | |
end="", | |
) | |
if ( | |
actor_action_index | |
in actor_action_indices | |
): | |
relevant_actor_ids.add(actor_id) | |
print(" x") | |
else: | |
print("") | |
print( | |
"relevant_actor_ids =", relevant_actor_ids | |
) | |
else: | |
# TODO intersect relevant_actor_ids and actors_used_in_scene but do so in a readable way to make debugging easier | |
print("xxx") | |
elif file in { | |
"src/overlays/actors/ovl_En_Zl1/z_en_zl1_cutscene_data.c", | |
}: | |
print( | |
"Found found_cutscene_command_searches =", | |
found_cutscene_command_searches, | |
"\nin file =", | |
file, | |
"\nignoring!", | |
) | |
else: | |
fail( | |
"Found found_cutscene_command_searches =", | |
found_cutscene_command_searches, | |
"\nin file =", | |
file, | |
"\nbut what to do with it?", | |
) | |
if relevant_actor_ids is None: | |
fail( | |
"relevant_actor_ids is None", | |
"\nactor_action_index =", | |
actor_action_index, | |
"\ncutscene_npc_action_command_id =", | |
cutscene_npc_action_command_id, | |
) | |
if len(relevant_actor_ids) == 0: | |
fail( | |
"len(relevant_actor_ids) == 0", | |
"\nactor_action_index =", | |
actor_action_index, | |
"\ncutscene_npc_action_command_id =", | |
cutscene_npc_action_command_id, | |
) | |
if len(relevant_actor_ids) > 1: | |
fail( | |
"len(relevant_actor_ids) > 1", | |
"\nactor_action_index =", | |
actor_action_index, | |
"\ncutscene_npc_action_command_id =", | |
cutscene_npc_action_command_id, | |
"\nrelevant_actor_ids =", | |
relevant_actor_ids, | |
) | |
print( | |
"actor_action_index =", | |
actor_action_index, | |
"\ncutscene_npc_action_command_id =", | |
cutscene_npc_action_command_id, | |
"\nrelevant_actor_ids = ", | |
relevant_actor_ids, | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# TODO this doesn't handle cutscene data in actor overlays | |
import os | |
from pprint import pprint | |
# | |
# | |
# | |
rooms_by_scene = { | |
"assets/scenes/overworld/spot00/spot00_scene.c": [ | |
"assets/scenes/overworld/spot00/spot00_room_0.c", | |
], | |
} | |
def make_rooms_by_scene(): | |
rooms_by_scene = dict() | |
for root, dirs, files in os.walk("assets/scenes"): | |
if not files: | |
continue | |
if root == "assets/scenes/test_levels/syotes": | |
# early map, the room has no header, there's no cutscene data there anyway | |
continue | |
scene_c_files = [file for file in files if file.endswith("_scene.c")] | |
assert ( | |
len(scene_c_files) == 1 | |
), f"Expected exactly one scene .c file, found {scene_c_files}" | |
scene_c_file = os.path.join(root, scene_c_files[0]) | |
rooms_c_files = [ | |
os.path.join(root, file) | |
for file in files | |
if file.endswith(".c") and "_room_" in file | |
] | |
assert len(rooms_c_files) > 0, "Expected at least one room .c file, found none" | |
# Note: correct order isn't guaranteed, but doesn't matter here | |
rooms_by_scene[scene_c_file] = rooms_c_files | |
return rooms_by_scene | |
rooms_by_scene = make_rooms_by_scene() | |
pprint(rooms_by_scene) | |
# | |
# | |
# | |
layers_by_scene = { | |
"assets/scenes/overworld/spot00/spot00_scene.c": [ | |
"spot00_sceneCommands", # main header | |
"spot00_sceneSet_011690", # first alternate header | |
"spot00_sceneSet_0112D0", | |
None, # NULL | |
"spot00_sceneSet_011AD0", | |
"spot00_sceneSet_011B70", | |
"spot00_sceneSet_011CC0", | |
"spot00_sceneSet_011D60", | |
"spot00_sceneSet_011E60", | |
"spot00_sceneSet_011F20", | |
"spot00_sceneSet_012020", | |
"spot00_sceneSet_0120E0", | |
"spot00_sceneSet_012180", | |
], | |
} | |
cutscene_by_layer_by_scene = { | |
"assets/scenes/overworld/spot00/spot00_scene.c": { | |
"spot00_sceneSet_011B70": "spot00_sceneCutsceneData_008494", | |
}, | |
} | |
cutscene_npc_action_list_command_ids_by_cutscene = { | |
"spot00_sceneCutsceneData_008494": { | |
0x010, | |
0x012, | |
}, | |
} | |
def make_scene_data(): | |
layers_by_scene = dict() | |
cutscene_by_layer_by_scene = dict() | |
cutscene_npc_action_list_command_ids_by_cutscene = dict() | |
for scene in rooms_by_scene.keys(): | |
with open(scene) as f: | |
lines = f.readlines() | |
# layers_by_scene | |
alternate_headers_start_line_i = None | |
alternate_headers_end_line_i = None | |
""" | |
SceneCmd* hakaana_ouke_sceneAlternateHeaders0x000060[] = { | |
NULL, | |
NULL, | |
NULL, | |
hakaana_ouke_sceneSet_002280, | |
hakaana_ouke_sceneSet_002390, | |
}; | |
""" | |
for i, line in enumerate(lines): | |
if "SceneCmd*" in line and "_sceneAlternateHeaders" in line: | |
alternate_headers_start_line_i = i | |
if alternate_headers_start_line_i is not None and ";" in line: | |
alternate_headers_end_line_i = i | |
break | |
if alternate_headers_start_line_i is None: | |
alternate_headers = [] | |
else: | |
assert alternate_headers_end_line_i is not None | |
alternate_headers = [ | |
None if header == "NULL" else header | |
for header in ( | |
line.strip().removesuffix(",") | |
for line in lines[ | |
alternate_headers_start_line_i | |
+ 1 : alternate_headers_end_line_i | |
] | |
) | |
] | |
assert len(alternate_headers) < 100 # sanity check | |
headers = [] | |
""" | |
SceneCmd hakaana_ouke_sceneCommands[] = { | |
SCENE_CMD_ALTERNATE_HEADER_LIST(hakaana_ouke_sceneAlternateHeaders0x000060), | |
SCENE_CMD_SOUND_SETTINGS(3, 19, 24), | |
SCENE_CMD_ROOM_LIST(3, hakaana_ouke_sceneRoomList0x0000B4), | |
SCENE_CMD_TRANSITION_ACTOR_LIST(2, hakaana_ouke_sceneTransitionActorList_000094), | |
SCENE_CMD_MISC_SETTINGS(0x00, 0x00000002), | |
SCENE_CMD_COL_HEADER(&hakaana_ouke_sceneCollisionHeader_002250), | |
SCENE_CMD_ENTRANCE_LIST(hakaana_ouke_sceneEntranceList0x0000CC), | |
SCENE_CMD_SPAWN_LIST(2, hakaana_ouke_sceneStartPositionList0x000074), | |
SCENE_CMD_SKYBOX_SETTINGS(0, 0, true), | |
SCENE_CMD_EXIT_LIST(hakaana_ouke_sceneExitList_0000D0), | |
SCENE_CMD_ENV_LIGHT_SETTINGS(4, hakaana_ouke_sceneLightSettings0x0000D4), | |
SCENE_CMD_END(), | |
}; | |
""" | |
for line in lines: | |
if "SceneCmd " in line: | |
header = line.split(" ")[1].removesuffix("[]") | |
if header not in alternate_headers: | |
headers.append(header) | |
assert ( | |
len(headers) == 1 | |
), f"Expected exactly one non-alternate header, found {headers}" | |
layers = headers + alternate_headers | |
layers_by_scene[scene] = layers | |
# cutscene_by_layer_by_scene | |
cutscene_by_layer_by_scene[scene] = dict() | |
for layer in layers: | |
if not layer: | |
continue | |
""" | |
SceneCmd hakaana_ouke_sceneSet_002280[] = { | |
SCENE_CMD_SOUND_SETTINGS(3, 19, 24), | |
SCENE_CMD_ROOM_LIST(3, hakaana_ouke_sceneRoomList0x002310), | |
SCENE_CMD_TRANSITION_ACTOR_LIST(2, hakaana_ouke_sceneTransitionActorList_0022F0), | |
SCENE_CMD_MISC_SETTINGS(0x00, 0x00000000), | |
SCENE_CMD_COL_HEADER(&hakaana_ouke_sceneCollisionHeader_002250), | |
SCENE_CMD_ENTRANCE_LIST(hakaana_ouke_sceneEntranceList0x002328), | |
SCENE_CMD_SPAWN_LIST(1, hakaana_ouke_sceneStartPositionList0x0022E0), | |
SCENE_CMD_SKYBOX_SETTINGS(0, 0, false), | |
SCENE_CMD_EXIT_LIST(hakaana_ouke_sceneExitList_00232C), | |
SCENE_CMD_ENV_LIGHT_SETTINGS(4, hakaana_ouke_sceneLightSettings0x002330), | |
SCENE_CMD_CUTSCENE_DATA(gSunSongGraveSunSongTeachCs), | |
SCENE_CMD_END(), | |
}; | |
""" | |
found_layer_header = False | |
cutscene = None | |
for i, line in enumerate(lines): | |
if "SceneCmd " in line and layer in line: | |
found_layer_header = True | |
if found_layer_header: | |
if "SCENE_CMD_CUTSCENE_DATA" in line: | |
assert cutscene is None, "Duplicate SCENE_CMD_CUTSCENE_DATA" | |
cutscene = ( | |
line.strip() | |
.removeprefix("SCENE_CMD_CUTSCENE_DATA(") | |
.removesuffix("),") | |
) | |
if ";" in line: | |
break | |
assert found_layer_header | |
if cutscene: | |
cutscene_by_layer_by_scene[scene][layer] = cutscene | |
# cutscene_npc_action_list_command_ids_by_cutscene | |
""" | |
CutsceneData bdan_sceneCutsceneData_0130A0[] = { | |
CS_BEGIN_CUTSCENE(14, 1299), | |
CS_PLAYER_ACTION_LIST(3), | |
CS_PLAYER_ACTION(5, 0, 272, 0x0000, 0xC000, 0x0000, -1085, -1025, -3347, -1085, -1025, -3347, | |
1.13930371975e-29f, 0.00000000000e+00f, 1.40129846432e-45f), | |
CS_PLAYER_ACTION(3, 272, 292, 0x0000, 0xC000, 0x0000, -1085, -1025, -3347, -1085, -1025, -3347, | |
1.13930371975e-29f, 0.00000000000e+00f, 1.40129846432e-45f), | |
CS_PLAYER_ACTION(5, 292, 777, 0x0000, 0xC000, 0x0000, -1085, -1025, -3347, -1085, -1025, -3347, | |
1.13930371975e-29f, 0.00000000000e+00f, 1.40129846432e-45f), | |
CS_MISC_LIST(1), | |
CS_MISC(0x000C, 330, 627, 0, 0, 0, -64, 50, 0, -64, 50, 0, 0, 0), | |
CS_NPC_ACTION_LIST(0x042, 3), | |
CS_NPC_ACTION(1, 0, 40, 0x0000, 0x4000, 0x0000, -1352, -969, -3341, -1352, -969, -3341, 0.00000000000e+00f, | |
0.00000000000e+00f, 1.40129846432e-45f), | |
CS_NPC_ACTION(2, 40, 213, 0x0000, 0x4000, 0x0000, -1352, -969, -3341, -1360, -969, -3343, 0.00000000000e+00f, | |
0.00000000000e+00f, 1.40129846432e-45f), | |
CS_NPC_ACTION(3, 213, 1000, 0x0000, 0x4000, 0x0000, -1360, -969, -3343, -1360, -969, -3343, 0.00000000000e+00f, | |
0.00000000000e+00f, 1.40129846432e-45f), | |
CS_NPC_ACTION_LIST(0x030, 1), | |
CS_NPC_ACTION(2, 0, 90, 0x0000, 0x0000, 0x0000, -1360, -963, -3343, -1360, -963, -3343, 0.00000000000e+00f, | |
0.00000000000e+00f, 0.00000000000e+00f), | |
CS_NPC_ACTION_LIST(0x030, 2), | |
CS_NPC_ACTION(2, 90, 211, 0x0000, 0x0000, 0x0000, -1352, -922, -3341, -1352, -922, -3341, 0.00000000000e+00f, | |
0.00000000000e+00f, 0.00000000000e+00f), | |
CS_NPC_ACTION(6, 211, 311, 0x0000, 0x0000, 0x0000, -1352, -922, -3341, -1352, -922, -3341, 0.00000000000e+00f, | |
0.00000000000e+00f, 0.00000000000e+00f), | |
CS_NPC_ACTION_LIST(0x03E, 3), | |
CS_NPC_ACTION(4, 0, 210, 0x0000, 0x0000, 0x0000, -1065, -972, -3305, -1065, -978, -3305, 0.00000000000e+00f, | |
-2.85714287311e-02f, 0.00000000000e+00f), | |
CS_NPC_ACTION(4, 210, 220, 0x8000, 0x0000, 0x0000, -1065, -978, -3305, -1065, -973, -3344, 0.00000000000e+00f, | |
5.00000000000e-01f, 0.00000000000e+00f), | |
CS_NPC_ACTION(4, 220, 410, 0x0000, 0x0000, 0x0000, -1065, -973, -3344, -1065, -976, -3344, 0.00000000000e+00f, | |
-1.57894734293e-02f, 0.00000000000e+00f), | |
CS_TEXT_LIST(6), | |
CS_TEXT_NONE(0, 162), | |
CS_TEXT_DISPLAY_TEXTBOX(0x4050, 162, 211, 0, 0xFFFF, 0xFFFF), | |
CS_TEXT_NONE(211, 232), | |
CS_TEXT_DISPLAY_TEXTBOX(0x4051, 232, 241, 0, 0xFFFF, 0xFFFF), | |
CS_TEXT_NONE(241, 247), | |
CS_TEXT_DISPLAY_TEXTBOX(0x4052, 247, 299, 0, 0xFFFF, 0xFFFF), | |
CS_PLAY_BGM_LIST(1), | |
CS_PLAY_BGM(35, 112, 113, 0, 0, 0, -57, 177, 0, -57, 177), | |
CS_CAM_POS_LIST(0, 1176), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 41.906601f, -1390, -948, -3339, 0x00C6), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 40.706596f, -1390, -948, -3339, 0x00C8), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 40.706596f, -1390, -948, -3339, 0x00D7), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 40.706596f, -1418, -938, -3337, 0x00E8), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 45.106609f, -1418, -938, -3337, 0x00EA), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 45.106609f, -1418, -938, -3337, 0x013D), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 45.106609f, -1418, -938, -3337, 0x013F), | |
CS_CAM_POS(CS_CMD_STOP, 0x00, 0, 45.106609f, -1418, -938, -3337, 0x006D), | |
CS_CAM_POS_LIST(91, 1270), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 44.906612f, -1319, -934, -3343, 0x00C6), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 44.706612f, -1319, -936, -3344, 0x00C8), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 44.706612f, -1319, -936, -3344, 0x00D7), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 44.706612f, -1319, -936, -3344, 0x00E8), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 44.706612f, -1326, -904, -3342, 0x00EA), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 60.906673f, -1326, -904, -3342, 0x013D), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 60.906673f, -1326, -904, -3342, 0x013F), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 60.906673f, -1326, -904, -3342, 0x014E), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 60.906673f, -1326, -904, -3342, 0x015F), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 60.906673f, -1326, -904, -3342, 0x0161), | |
CS_CAM_POS(CS_CMD_STOP, 0x00, 0, 60.906673f, -1326, -1024, -3342, 0x652E), | |
CS_CAM_POS_LIST(211, 332), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 30.306555f, -1471, -819, -3149, 0x00C6), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 30.306555f, -1471, -819, -3149, 0x00C8), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 30.306555f, -1471, -819, -3149, 0x00D7), | |
CS_CAM_POS(CS_CMD_CONTINUE, 0x00, 0, 30.306555f, -1471, -819, -3149, 0x00E8), | |
CS_CAM_POS(CS_CMD_STOP, 0x00, 0, 30.306555f, -1471, -819, -3149, 0x00EA), | |
CS_CAM_FOCUS_POINT_LIST(0, 1205), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 40.706596f, -1295, -1003, -3352, 0x00C6), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 50, 40.706596f, -1296, -1003, -3352, 0x00C8), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 20, 40.706596f, -1296, -1003, -3352, 0x00D7), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 15, 45.106609f, -1314, -969, -3346, 0x00E8), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 45.106609f, -1313, -970, -3346, 0x00EA), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 1000, 45.106609f, -1313, -969, -3346, 0x013D), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 45.106609f, -1313, -970, -3346, 0x013F), | |
CS_CAM_FOCUS_POINT(CS_CMD_STOP, 0x00, 30, 45.106609f, -1313, -970, -3346, 0x006D), | |
CS_CAM_FOCUS_POINT_LIST(91, 1299), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 44.706612f, -1405, -988, -3343, 0x00C6), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 7, 44.706612f, -1406, -989, -3344, 0x00C8), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 7, 44.706612f, -1406, -989, -3344, 0x00D7), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 7, 44.706612f, -1406, -989, -3344, 0x00E8), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 7, 60.906673f, -1393, -978, -3342, 0x00EA), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 60.906673f, -1393, -977, -3342, 0x013D), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 60.906673f, -1393, -977, -3342, 0x013F), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 1000, 60.906673f, -1393, -977, -3342, 0x014E), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 60.906673f, -1393, -977, -3342, 0x015F), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 60.906673f, -1393, -977, -3342, 0x0161), | |
CS_CAM_FOCUS_POINT(CS_CMD_STOP, 0x00, 30, 60.906673f, -1401, -1094, -3347, 0x652E), | |
CS_CAM_FOCUS_POINT_LIST(211, 361), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 30.306555f, -1426, -857, -3190, 0x00C6), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 30.306555f, -1426, -857, -3190, 0x00C8), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 30.306555f, -1426, -857, -3190, 0x00D7), | |
CS_CAM_FOCUS_POINT(CS_CMD_CONTINUE, 0x00, 30, 30.306555f, -1426, -857, -3190, 0x00E8), | |
CS_CAM_FOCUS_POINT(CS_CMD_STOP, 0x00, 30, 30.306555f, -1426, -857, -3190, 0x00EA), | |
CS_END(), | |
}; | |
""" | |
cutscene_npc_action_list_command_ids_by_cutscene[cutscene] = set() | |
found_cutscene = False | |
for i, line in enumerate(lines): | |
if "CutsceneData " in line and cutscene in line: | |
found_cutscene = True | |
if found_cutscene: | |
if "CS_NPC_ACTION_LIST" in line: | |
cmd_id_str = ( | |
line.strip() | |
.removeprefix("CS_NPC_ACTION_LIST(") | |
.split(",")[0] | |
) | |
cmd_id = int(cmd_id_str, 0) | |
cutscene_npc_action_list_command_ids_by_cutscene[ | |
cutscene | |
].add(cmd_id) | |
if ";" in line: | |
break | |
assert found_cutscene | |
return ( | |
layers_by_scene, | |
cutscene_by_layer_by_scene, | |
cutscene_npc_action_list_command_ids_by_cutscene, | |
) | |
( | |
layers_by_scene, | |
cutscene_by_layer_by_scene, | |
cutscene_npc_action_list_command_ids_by_cutscene, | |
) = make_scene_data() | |
pprint(layers_by_scene) | |
pprint(cutscene_by_layer_by_scene) | |
pprint(cutscene_npc_action_list_command_ids_by_cutscene) | |
# | |
# | |
# | |
layers_by_room = { | |
"assets/scenes/overworld/spot00/spot00_room_0.c": [ | |
"spot00_room_0Commands", | |
"spot00_room_0Set_000C50", | |
"spot00_room_0Set_000770", | |
None, | |
"spot00_room_0Set_001120", | |
"spot00_room_0Set_0011F0", | |
"spot00_room_0Set_0012C0", | |
"spot00_room_0Set_001360", | |
"spot00_room_0Set_001570", | |
"spot00_room_0Set_0018D0", | |
"spot00_room_0Set_001920", | |
"spot00_room_0Set_001C80", | |
"spot00_room_0Set_001CF0", | |
] | |
} | |
actors_by_layer_by_room = { | |
"assets/scenes/overworld/spot00/spot00_room_0.c": { | |
"spot00_room_0Set_0011F0": [ | |
("ACTOR_BG_SPOT00_HANEBASI", 0xFFFF), | |
("ACTOR_EN_VIEWER", 0x0000), | |
("ACTOR_EN_VIEWER", 0x0101), | |
("ACTOR_EN_VIEWER", 0x0202), | |
("ACTOR_EN_VIEWER", 0x0303), | |
("ACTOR_EN_VIEWER", 0x0404), | |
("ACTOR_EN_RIVER_SOUND", 0x0001), | |
], | |
}, | |
} | |
def make_room_data(): | |
layers_by_room = dict() | |
actors_by_layer_by_room = dict() | |
for room in (room for rooms in rooms_by_scene.values() for room in rooms): | |
with open(room) as f: | |
lines = f.readlines() | |
# layers_by_room | |
alternate_headers_start_line_i = None | |
alternate_headers_end_line_i = None | |
""" | |
SceneCmd* hakaana_ouke_room_0AlternateHeaders0x000048[] = { | |
NULL, | |
NULL, | |
NULL, | |
hakaana_ouke_room_0Set_000130, | |
hakaana_ouke_room_0Set_0001B0, | |
}; | |
""" | |
for i, line in enumerate(lines): | |
if "SceneCmd*" in line and "AlternateHeaders" in line: | |
alternate_headers_start_line_i = i | |
if alternate_headers_start_line_i is not None and ";" in line: | |
alternate_headers_end_line_i = i | |
break | |
if alternate_headers_start_line_i is None: | |
alternate_headers = [] | |
else: | |
assert alternate_headers_end_line_i is not None | |
alternate_headers = [ | |
None if header == "NULL" else header | |
for header in ( | |
line.strip().removesuffix(",") | |
for line in lines[ | |
alternate_headers_start_line_i | |
+ 1 : alternate_headers_end_line_i | |
] | |
) | |
] | |
assert len(alternate_headers) < 100 # sanity check | |
headers = [] | |
""" | |
SceneCmd hakaana_ouke_room_0Commands[] = { | |
SCENE_CMD_ALTERNATE_HEADER_LIST(hakaana_ouke_room_0AlternateHeaders0x000048), | |
SCENE_CMD_ECHO_SETTINGS(4), | |
SCENE_CMD_ROOM_BEHAVIOR(0x00, 0x01, false, false), | |
SCENE_CMD_SKYBOX_DISABLES(true, true), | |
SCENE_CMD_TIME_SETTINGS(255, 255, 0), | |
SCENE_CMD_MESH(&hakaana_ouke_room_0PolygonType0_000110), | |
SCENE_CMD_OBJECT_LIST(7, hakaana_ouke_room_0ObjectList_00005C), | |
SCENE_CMD_ACTOR_LIST(10, hakaana_ouke_room_0ActorList_00006C), | |
SCENE_CMD_END(), | |
}; | |
""" | |
for line in lines: | |
if "SceneCmd " in line: | |
header = line.split(" ")[1].removesuffix("[]") | |
if header not in alternate_headers: | |
headers.append(header) | |
assert ( | |
len(headers) == 1 | |
), f"Expected exactly one non-alternate header, found {headers}" | |
layers = headers + alternate_headers | |
layers_by_room[room] = layers | |
# actors_by_layer_by_room | |
actors_by_layer_by_room[room] = dict() | |
for layer in layers: | |
if not layer: | |
continue | |
""" | |
SceneCmd hakaana_ouke_room_0Set_0001B0[] = { | |
SCENE_CMD_ECHO_SETTINGS(0), | |
SCENE_CMD_ROOM_BEHAVIOR(0x00, 0x01, false, false), | |
SCENE_CMD_SKYBOX_DISABLES(false, false), | |
SCENE_CMD_TIME_SETTINGS(255, 255, 0), | |
SCENE_CMD_MESH(&hakaana_ouke_room_0PolygonType0_000110), | |
SCENE_CMD_OBJECT_LIST(4, hakaana_ouke_room_0ObjectList_0001F0), | |
SCENE_CMD_ACTOR_LIST(3, hakaana_ouke_room_0ActorList_0001F8), | |
SCENE_CMD_END(), | |
}; | |
""" | |
found_layer_header = False | |
actor_list_length = None | |
actor_list = None | |
for i, line in enumerate(lines): | |
if "SceneCmd " in line and layer in line: | |
found_layer_header = True | |
if found_layer_header: | |
if "SCENE_CMD_ACTOR_LIST" in line: | |
assert actor_list is None, "Duplicate SCENE_CMD_ACTOR_LIST" | |
parts = ( | |
line.strip() | |
.removeprefix("SCENE_CMD_ACTOR_LIST(") | |
.removesuffix("),") | |
.split(",") | |
) | |
actor_list_length_str = parts[0].strip().removesuffix(",") | |
actor_list_length = int(actor_list_length_str) | |
actor_list = parts[1].strip() | |
if ";" in line: | |
break | |
assert found_layer_header | |
if actor_list: | |
actors_by_layer_by_room[room][layer] = [] | |
""" | |
ActorEntry spot00_room_0ActorList_001178[] = { | |
{ ACTOR_BG_SPOT00_HANEBASI, { 0, -10, 670 }, { 0, 0, 0 }, 0xFFFF }, | |
{ ACTOR_EN_VIEWER, { 2313, -11, 2013 }, { 0, 0, 0 }, 0x0000 }, | |
{ ACTOR_EN_VIEWER, { 2398, -12, 2017 }, { 0, 0, 0 }, 0x0101 }, | |
{ ACTOR_EN_VIEWER, { 2313, -7, 2098 }, { 0, 0, 0 }, 0x0202 }, | |
{ ACTOR_EN_VIEWER, { 2395, -2, 2098 }, { 0, 0, 0 }, 0x0303 }, | |
{ ACTOR_EN_VIEWER, { 2361, -9, 2062 }, { 0, 0, 0 }, 0x0404 }, | |
{ ACTOR_EN_RIVER_SOUND, { -2862, -315, -500 }, { 0, 0, 0 }, 0x0001 }, | |
}; | |
""" | |
found_actor_list = False | |
count = None | |
for i, line in enumerate(lines): | |
if "ActorEntry " in line and actor_list in line: | |
assert not found_actor_list | |
found_actor_list = True | |
count = 0 | |
elif found_actor_list: | |
if ";" in line: | |
break | |
count += 1 | |
if count <= actor_list_length: | |
parts = ( | |
line.strip() | |
.removeprefix("{") | |
.removesuffix("},") | |
.strip() | |
.split() | |
) | |
actor_id = parts[0].removesuffix(",") | |
actor_params_str = parts[-1] | |
actor_params = int(actor_params_str, 0) | |
actors_by_layer_by_room[room][layer].append( | |
(actor_id, actor_params) | |
) | |
assert found_actor_list | |
return (layers_by_room, actors_by_layer_by_room) | |
(layers_by_room, actors_by_layer_by_room) = make_room_data() | |
pprint(layers_by_room) | |
pprint(actors_by_layer_by_room) | |
# | |
# | |
# | |
# from the switch in Cutscene_ProcessCommands | |
# key is npcActions index, values are cases in the switch that use that npcActionsIndex | |
cutscene_npc_action_list_command_ids_by_npc_actions_index = { | |
0: {15, 17, 18, 23, 34, 39, 46, 76, 85, 93, 105, 107, 110, 119, 123, 138, 139, 144}, | |
1: {14, 16, 24, 35, 40, 48, 64, 68, 70, 78, 80, 94, 116, 118, 120, 125, 131, 141}, | |
2: {25, 36, 41, 50, 67, 69, 72, 74, 81, 106, 117, 121, 126, 132}, | |
3: {29, 37, 42, 51, 53, 63, 65, 66, 75, 82, 108, 127, 133}, | |
4: {30, 38, 43, 47, 54, 79, 83, 128, 135}, | |
5: {44, 55, 77, 84, 90, 129, 136}, | |
6: {31, 52, 57, 58, 88, 115, 130, 137}, | |
7: {49, 60, 89, 111, 114, 134, 142}, | |
8: {62}, | |
9: {143}, | |
} | |
npc_actions_index_by_cutscene_npc_action_list_command_id = { | |
cutscene_npc_action_list_command_id: npc_actions_index | |
for ( | |
npc_actions_index, | |
cutscene_npc_action_list_command_ids, | |
) in cutscene_npc_action_list_command_ids_by_npc_actions_index.items() | |
for cutscene_npc_action_list_command_id in cutscene_npc_action_list_command_ids | |
} | |
""" | |
npc_actions_indices_by_actor_id = { | |
"ACTOR_EN_ZL2": {0}, | |
"ACTOR_EN_VIEWER": {0, 1}, | |
} | |
def make_npc_actions_indices_by_actor_id(): | |
npc_actions_indices_by_actor_id = dict() | |
from . import cs_actor_action_doc_v2 | |
cs_actor_action_doc_v2. | |
return get_actor_action_indices_used | |
npc_actions_indices_by_actor_id = make_npc_actions_indices_by_actor_id() | |
pprint(npc_actions_indices_by_actor_id) | |
""" | |
# TODO obviously... | |
def get_actor_action_indices_used(actor_id, params): | |
import cs_actor_action_doc_v2 | |
return cs_actor_action_doc_v2.get_actor_action_indices_used(actor_id, params) | |
""" | |
actor_ids_by_npc_actions_index = dict() | |
for actor, npc_actions_indices in npc_actions_indices_by_actor_id.items(): | |
for npc_actions_index in npc_actions_indices: | |
if npc_actions_index not in actor_ids_by_npc_actions_index: | |
actor_ids_by_npc_actions_index[npc_actions_index] = set() | |
actor_ids_by_npc_actions_index[npc_actions_index].add(actor) | |
""" | |
actors_by_cutscene_npc_action_list_command_id = { | |
cutscene_npc_action_list_command_id: list() | |
for cutscene_npc_action_list_command_id in npc_actions_index_by_cutscene_npc_action_list_command_id.keys() | |
} | |
print() | |
# Tie the data together to fill actors_by_cutscene_npc_action_list_command_id | |
for scene, rooms in rooms_by_scene.items(): | |
""" | |
if "spot00" not in scene: | |
continue | |
""" | |
for layer_index, scene_layer in enumerate(layers_by_scene[scene]): | |
if scene_layer is None: | |
# scene has no layer, nothing to do for this layer index | |
continue | |
cutscene_by_layer = cutscene_by_layer_by_scene[scene] | |
if scene_layer not in cutscene_by_layer: | |
# scene layer has no cutscene, nothing to do for this layer | |
continue | |
cutscene = cutscene_by_layer[scene_layer] | |
cutscene_npc_action_list_command_ids = ( | |
cutscene_npc_action_list_command_ids_by_cutscene[cutscene] | |
) | |
for room in rooms: | |
room_layer = layers_by_room[room][layer_index] | |
assert ( | |
room_layer is not None | |
), f"Scene {scene} has layer at index {layer_index} but room {room} doesn't" | |
actors_by_layer = actors_by_layer_by_room[room] | |
if room_layer not in actors_by_layer: | |
# room layer has no actors, nothing to do for this room (for this layer) | |
continue | |
actors = actors_by_layer[room_layer] | |
for actor_id, actor_params in actors: | |
npc_actions_indices = get_actor_action_indices_used( | |
actor_id, actor_params | |
) | |
for npc_action_index in npc_actions_indices: | |
assert ( | |
npc_action_index | |
in cutscene_npc_action_list_command_ids_by_npc_actions_index | |
), f"Unknown npcActions index {npc_action_index}" | |
common_cutscene_npc_action_list_command_ids = ( | |
cutscene_npc_action_list_command_ids | |
& cutscene_npc_action_list_command_ids_by_npc_actions_index[ | |
npc_action_index | |
] | |
) | |
if common_cutscene_npc_action_list_command_ids: | |
print( | |
scene, | |
layer_index, | |
scene_layer, | |
room, | |
room_layer, | |
actor_id, | |
npc_action_index, | |
common_cutscene_npc_action_list_command_ids, | |
) | |
for ( | |
common_cutscene_npc_action_list_command_id | |
) in common_cutscene_npc_action_list_command_ids: | |
# TODO don't stringify actor_params, temporary for debugging | |
actors_by_cutscene_npc_action_list_command_id[ | |
common_cutscene_npc_action_list_command_id | |
].append( | |
( | |
actor_id, | |
f"0x{actor_params:04X}", | |
os.path.join(*scene.split(os.path.sep)[2:4]), | |
npc_action_index, | |
) | |
) | |
pprint(actors_by_cutscene_npc_action_list_command_id) | |
import csv | |
with open("cs_npc_action_cmd_ids.csv", "w") as f: | |
csvw = csv.writer(f, dialect="unix") | |
csvw.writerow(["Cs Cmd ID", "Actor ID", "Params", "Scene", "npcActions index"]) | |
for cmd_id, actors in sorted( | |
actors_by_cutscene_npc_action_list_command_id.items(), | |
key=lambda item: item[0], | |
): | |
if not actors: | |
csvw.writerow([cmd_id, "(unused)"]) | |
header = cmd_id | |
for (actor_id, actor_params, scene, npc_action_index,) in sorted( | |
set(actors), | |
key=lambda actor: (actor[2], actor[0], actor[3], actor[1]), | |
): | |
csvw.writerow([header, actor_id, actor_params, scene, npc_action_index]) | |
header = "" | |
csvw.writerow([]) | |
... |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment