Decode stage definition tables
This commit is contained in:
@@ -47,6 +47,11 @@ occupy three reserved 1,000-row arrays for six effect ids, six start delays,
|
||||
and one total duration. BTANINIT dispatches 202 effect ids into BTL's six-slot
|
||||
visual/audio/hit-pulse work record.
|
||||
|
||||
STINIT2 is a special name-mode registry: 74 stages occupy a sparse 1,000-row
|
||||
catalog with six description lines (three before clear and three after),
|
||||
availability/story gates, map and minimap geometry, entry/clear/failure
|
||||
SCJUMP decisions, clear rewards, and a shared STINIT stage-loader reference.
|
||||
|
||||
Records are {id, name?, desc?, fields:{"0x<col_base>": value}} or, for footer tables,
|
||||
{id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals;
|
||||
confirmed names come from the generated engine global registry while raw keys remain provenance.
|
||||
@@ -678,6 +683,40 @@ MAP_STAGE_MAX_X = 0xEC8C5
|
||||
MAP_STAGE_MIN_Y = 0xECCAD
|
||||
MAP_STAGE_MAX_Y = 0xED095
|
||||
|
||||
STAGE_DEFINITION_NAME_BASE = 0x27BD
|
||||
STAGE_DESCRIPTION_BASE = 0x2BA5
|
||||
STAGE_DEFINITION_CAPACITY = 1000
|
||||
STAGE_DESCRIPTION_STRIDE = 6
|
||||
STAGE_DESCRIPTION_COLUMNS = (
|
||||
"uncleared_line_1",
|
||||
"uncleared_line_2",
|
||||
"uncleared_line_3",
|
||||
"cleared_line_1",
|
||||
"cleared_line_2",
|
||||
"cleared_line_3",
|
||||
)
|
||||
STAGE_DEFINITION_ARRAYS = {
|
||||
"unlock_group_id": (0xE7E8D, 1),
|
||||
"main_progression_flag": (0xE8275, 1),
|
||||
"forbidden_story_flag_ids": (0xE865D, 7),
|
||||
"required_story_flag_ids": (0xEA1B5, 7),
|
||||
"display_number_major": (0xEBD0D, 1),
|
||||
"display_number_minor": (0xEC0F5, 1),
|
||||
"map_min_tile_x": (MAP_STAGE_MIN_X, 1),
|
||||
"map_max_tile_x": (MAP_STAGE_MAX_X, 1),
|
||||
"map_min_tile_y": (MAP_STAGE_MIN_Y, 1),
|
||||
"map_max_tile_y": (MAP_STAGE_MAX_Y, 1),
|
||||
"minimap_atlas_origin_y": (0xED47D, 1),
|
||||
"clear_base_spendable_point_reward": (0xED865, 1),
|
||||
"unresolved_parameter_0xedc4d": (0xEDC4D, 1),
|
||||
"scjump_decision_ids": (0xEE035, 3),
|
||||
"extra_dungeon_flag": (0xEEBED, 1),
|
||||
"clear_coin_quantities": (0xEEFD5, 3),
|
||||
"stage_loader_script_id": (0xEFB8D, 1),
|
||||
}
|
||||
STAGE_SCJUMP_COLUMNS = ("entry", "clear", "failure")
|
||||
STAGE_CLEAR_COIN_ITEM_IDS = (91, 92, 93)
|
||||
|
||||
TERRAIN_NAME_BASE = 0x26B5
|
||||
TERRAIN_EFFECT_DESCRIPTION_BASE = 0x26D3
|
||||
TERRAIN_TEXTURE_SLOT_BASE = 0xE6AA4
|
||||
@@ -5804,38 +5843,465 @@ def extract_card_generation_lists(scr):
|
||||
}
|
||||
|
||||
|
||||
def extract_stage_definitions(scr):
|
||||
"""Extract STINIT2's sparse stage catalog and six-line text matrix."""
|
||||
records_by_id: dict[int, dict] = {}
|
||||
numeric_cells = {
|
||||
field_name: {}
|
||||
for field_name in STAGE_DEFINITION_ARRAYS
|
||||
}
|
||||
classified_offsets = set()
|
||||
string_write_count = 0
|
||||
static_write_count = 0
|
||||
|
||||
def record_for(stage_id: int) -> dict:
|
||||
if not (1 <= stage_id < STAGE_DEFINITION_CAPACITY):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: stage id {stage_id} outside reserved "
|
||||
f"1..{STAGE_DEFINITION_CAPACITY - 1} range"
|
||||
)
|
||||
return records_by_id.setdefault(stage_id, {
|
||||
"id": stage_id,
|
||||
"name": "",
|
||||
"string_fields": {},
|
||||
"fields": {},
|
||||
"record_fields": {},
|
||||
})
|
||||
|
||||
for ins in scr.instructions:
|
||||
if (
|
||||
ins.opcode == SET_STRING
|
||||
and len(ins.args) >= 2
|
||||
and ins.args[0][0] == T_GLOBAL_STRING
|
||||
):
|
||||
destination = ins.args[0][1]
|
||||
text = scr.strings.get(ins.args[1][1], (None,))[0]
|
||||
if (
|
||||
STAGE_DEFINITION_NAME_BASE < destination
|
||||
< STAGE_DEFINITION_NAME_BASE
|
||||
+ STAGE_DEFINITION_CAPACITY
|
||||
):
|
||||
stage_id = destination - STAGE_DEFINITION_NAME_BASE
|
||||
record = record_for(stage_id)
|
||||
if record["name"]:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: duplicate stage name for id "
|
||||
f"{stage_id}"
|
||||
)
|
||||
record["name"] = text
|
||||
else:
|
||||
relative = destination - STAGE_DESCRIPTION_BASE
|
||||
if not (
|
||||
STAGE_DESCRIPTION_STRIDE
|
||||
<= relative
|
||||
< STAGE_DEFINITION_CAPACITY
|
||||
* STAGE_DESCRIPTION_STRIDE
|
||||
):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unexpected string write "
|
||||
f"0x{destination:x}"
|
||||
)
|
||||
stage_id, column = divmod(
|
||||
relative, STAGE_DESCRIPTION_STRIDE
|
||||
)
|
||||
record = record_for(stage_id)
|
||||
_store_unique(
|
||||
record["string_fields"],
|
||||
(
|
||||
f"0x{STAGE_DESCRIPTION_BASE:x}/"
|
||||
f"{STAGE_DESCRIPTION_STRIDE}/{column}"
|
||||
),
|
||||
text,
|
||||
stage_id,
|
||||
)
|
||||
string_write_count += 1
|
||||
classified_offsets.add(ins.offset)
|
||||
continue
|
||||
|
||||
write = _static_global_write(ins)
|
||||
if write is not None:
|
||||
destination, value = write
|
||||
matches = []
|
||||
for field_name, (base, stride) in (
|
||||
STAGE_DEFINITION_ARRAYS.items()
|
||||
):
|
||||
relative = destination - base
|
||||
if (
|
||||
stride <= relative
|
||||
< STAGE_DEFINITION_CAPACITY * stride
|
||||
):
|
||||
stage_id, column = divmod(relative, stride)
|
||||
matches.append(
|
||||
(field_name, base, stride, stage_id, column)
|
||||
)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: numeric destination "
|
||||
f"0x{destination:x} matched {matches}"
|
||||
)
|
||||
field_name, base, stride, stage_id, column = matches[0]
|
||||
record = record_for(stage_id)
|
||||
numeric_cells[field_name][(stage_id, column)] = value
|
||||
key = (
|
||||
f"0x{base:x}"
|
||||
if stride == 1
|
||||
else f"0x{base:x}/{stride}/{column}"
|
||||
)
|
||||
target = (
|
||||
record["fields"]
|
||||
if stride == 1
|
||||
else record["record_fields"]
|
||||
)
|
||||
_store_unique(target, key, value, stage_id)
|
||||
static_write_count += 1
|
||||
classified_offsets.add(ins.offset)
|
||||
continue
|
||||
|
||||
if sys4load.display_label(ins.opcode) == "exit":
|
||||
classified_offsets.add(ins.offset)
|
||||
|
||||
unclassified = [
|
||||
f"0x{ins.offset:x}"
|
||||
for ins in scr.instructions
|
||||
if ins.offset not in classified_offsets
|
||||
]
|
||||
if unclassified:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified instructions "
|
||||
+ ", ".join(unclassified)
|
||||
)
|
||||
|
||||
unnamed = sorted(
|
||||
stage_id
|
||||
for stage_id, record in records_by_id.items()
|
||||
if not record["name"]
|
||||
)
|
||||
if unnamed:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: numeric/text rows without names: {unnamed}"
|
||||
)
|
||||
|
||||
item_records, _ = extract_name(
|
||||
sys4load.load(resolve("ITINIT"))
|
||||
)
|
||||
item_names = {
|
||||
record["id"]: record["name"] for record in item_records
|
||||
}
|
||||
dispatch_records, _ = extract_dispatch(
|
||||
sys4load.load(resolve("SCINIT"))
|
||||
)
|
||||
dispatch_by_id = {
|
||||
record["id"]: record for record in dispatch_records
|
||||
}
|
||||
resource_names = callscript_names()
|
||||
|
||||
def values(field_name: str, stage_id: int) -> list[int]:
|
||||
_, stride = STAGE_DEFINITION_ARRAYS[field_name]
|
||||
return [
|
||||
numeric_cells[field_name].get((stage_id, column), 0)
|
||||
for column in range(stride)
|
||||
]
|
||||
|
||||
def scalar(field_name: str, stage_id: int) -> int:
|
||||
return values(field_name, stage_id)[0]
|
||||
|
||||
records = []
|
||||
scjump_reference_count = 0
|
||||
resolved_scjump_reference_count = 0
|
||||
resolved_loader_script_count = 0
|
||||
description_line_count = 0
|
||||
for stage_id in sorted(records_by_id):
|
||||
record = records_by_id[stage_id]
|
||||
descriptions = [
|
||||
record["string_fields"].get(
|
||||
(
|
||||
f"0x{STAGE_DESCRIPTION_BASE:x}/"
|
||||
f"{STAGE_DESCRIPTION_STRIDE}/{column}"
|
||||
),
|
||||
"",
|
||||
)
|
||||
for column in range(STAGE_DESCRIPTION_STRIDE)
|
||||
]
|
||||
description_line_count += sum(bool(text) for text in descriptions)
|
||||
record["descriptions"] = {
|
||||
"uncleared": descriptions[:3],
|
||||
"cleared": descriptions[3:],
|
||||
}
|
||||
|
||||
major = scalar("display_number_major", stage_id)
|
||||
minor = scalar("display_number_minor", stage_id)
|
||||
if major < 0:
|
||||
display_kind = "extra"
|
||||
elif not major and not minor:
|
||||
display_kind = "event"
|
||||
else:
|
||||
display_kind = "numbered"
|
||||
record["display_number"] = {
|
||||
"kind": display_kind,
|
||||
"major": major,
|
||||
"minor": minor,
|
||||
}
|
||||
|
||||
required_flags = [
|
||||
value
|
||||
for value in values(
|
||||
"required_story_flag_ids", stage_id
|
||||
)
|
||||
if value
|
||||
]
|
||||
forbidden_flags = [
|
||||
value
|
||||
for value in values(
|
||||
"forbidden_story_flag_ids", stage_id
|
||||
)
|
||||
if value
|
||||
]
|
||||
record["availability"] = {
|
||||
"main_progression": bool(
|
||||
scalar("main_progression_flag", stage_id)
|
||||
),
|
||||
"extra_dungeon": bool(
|
||||
scalar("extra_dungeon_flag", stage_id)
|
||||
),
|
||||
"unlock_group_id": scalar(
|
||||
"unlock_group_id", stage_id
|
||||
),
|
||||
"required_story_flag_ids": required_flags,
|
||||
"forbidden_story_flag_ids": forbidden_flags,
|
||||
}
|
||||
|
||||
bounds = {
|
||||
"min_x": scalar("map_min_tile_x", stage_id),
|
||||
"max_x": scalar("map_max_tile_x", stage_id),
|
||||
"min_y": scalar("map_min_tile_y", stage_id),
|
||||
"max_y": scalar("map_max_tile_y", stage_id),
|
||||
}
|
||||
if all(bounds.values()):
|
||||
record["map"] = {
|
||||
"tile_bounds": bounds,
|
||||
"grid_bounds": {
|
||||
key: value * MAP_TILE_TO_GRID_SCALE
|
||||
for key, value in bounds.items()
|
||||
},
|
||||
"minimap_atlas_origin_y": scalar(
|
||||
"minimap_atlas_origin_y", stage_id
|
||||
),
|
||||
}
|
||||
|
||||
scjump_ids = values("scjump_decision_ids", stage_id)
|
||||
flow = {}
|
||||
for column, role in enumerate(STAGE_SCJUMP_COLUMNS):
|
||||
decision_id = scjump_ids[column]
|
||||
if not decision_id:
|
||||
continue
|
||||
joined = dispatch_by_id.get(decision_id, {})
|
||||
flow[f"{role}_scjump_decision_id"] = decision_id
|
||||
flow[f"{role}_script_name"] = joined.get(
|
||||
"script_name", ""
|
||||
)
|
||||
scjump_reference_count += 1
|
||||
if joined:
|
||||
resolved_scjump_reference_count += 1
|
||||
loader_id = scalar("stage_loader_script_id", stage_id)
|
||||
if loader_id:
|
||||
loader_name = resource_names.get(loader_id, "")
|
||||
flow["stage_loader_script_id"] = loader_id
|
||||
flow["stage_loader_script_name"] = loader_name
|
||||
if loader_name:
|
||||
resolved_loader_script_count += 1
|
||||
record["flow"] = flow
|
||||
|
||||
coin_quantities = values(
|
||||
"clear_coin_quantities", stage_id
|
||||
)
|
||||
record["clear_rewards"] = {
|
||||
"base_spendable_points": scalar(
|
||||
"clear_base_spendable_point_reward", stage_id
|
||||
),
|
||||
"coins": [
|
||||
{
|
||||
"item_id": item_id,
|
||||
"item_name": item_names.get(item_id, ""),
|
||||
"quantity": coin_quantities[column],
|
||||
}
|
||||
for column, item_id in enumerate(
|
||||
STAGE_CLEAR_COIN_ITEM_IDS
|
||||
)
|
||||
if coin_quantities[column]
|
||||
],
|
||||
}
|
||||
unresolved = scalar(
|
||||
"unresolved_parameter_0xedc4d", stage_id
|
||||
)
|
||||
if unresolved:
|
||||
record["unresolved_parameter_0xedc4d"] = unresolved
|
||||
records.append(record)
|
||||
|
||||
field_counts = {
|
||||
field_name: len(cells)
|
||||
for field_name, cells in numeric_cells.items()
|
||||
}
|
||||
schema_semantics = {
|
||||
f"0x{STAGE_DESCRIPTION_BASE:x}/"
|
||||
f"{STAGE_DESCRIPTION_STRIDE}/{column}": (
|
||||
f"stage_description_{column_name}"
|
||||
)
|
||||
for column, column_name in enumerate(
|
||||
STAGE_DESCRIPTION_COLUMNS
|
||||
)
|
||||
}
|
||||
semantic_names = {
|
||||
"unlock_group_id": "stage_unlock_group_ids",
|
||||
"main_progression_flag": "stage_main_progression_flags",
|
||||
"forbidden_story_flag_ids": (
|
||||
"stage_forbidden_story_flag_ids"
|
||||
),
|
||||
"required_story_flag_ids": "stage_required_story_flag_ids",
|
||||
"display_number_major": "stage_display_number_major",
|
||||
"display_number_minor": "stage_display_number_minor",
|
||||
"map_min_tile_x": "stage_map_min_tile_x",
|
||||
"map_max_tile_x": "stage_map_max_tile_x",
|
||||
"map_min_tile_y": "stage_map_min_tile_y",
|
||||
"map_max_tile_y": "stage_map_max_tile_y",
|
||||
"minimap_atlas_origin_y": "stage_minimap_atlas_origin_y",
|
||||
"clear_base_spendable_point_reward": (
|
||||
"stage_clear_base_spendable_point_rewards"
|
||||
),
|
||||
"scjump_decision_ids": "stage_scjump_decision_ids",
|
||||
"extra_dungeon_flag": "stage_extra_dungeon_flags",
|
||||
"clear_coin_quantities": "stage_clear_coin_quantities",
|
||||
"stage_loader_script_id": "stage_loader_script_ids",
|
||||
}
|
||||
semantic_columns = {
|
||||
"forbidden_story_flag_ids": tuple(
|
||||
f"forbidden_flag_{column + 1}"
|
||||
for column in range(
|
||||
STAGE_DEFINITION_ARRAYS[
|
||||
"forbidden_story_flag_ids"
|
||||
][1]
|
||||
)
|
||||
),
|
||||
"required_story_flag_ids": tuple(
|
||||
f"required_flag_{column + 1}"
|
||||
for column in range(
|
||||
STAGE_DEFINITION_ARRAYS[
|
||||
"required_story_flag_ids"
|
||||
][1]
|
||||
)
|
||||
),
|
||||
"scjump_decision_ids": STAGE_SCJUMP_COLUMNS,
|
||||
"clear_coin_quantities": tuple(
|
||||
f"{coin_name}_coin_item_{item_id}"
|
||||
for coin_name, item_id in zip(
|
||||
("bronze", "silver", "gold"),
|
||||
STAGE_CLEAR_COIN_ITEM_IDS,
|
||||
)
|
||||
),
|
||||
}
|
||||
for field_name, semantic_name in semantic_names.items():
|
||||
base, stride = STAGE_DEFINITION_ARRAYS[field_name]
|
||||
if stride == 1:
|
||||
schema_semantics[f"0x{base:x}"] = semantic_name
|
||||
else:
|
||||
column_names = semantic_columns[field_name]
|
||||
for column, column_name in enumerate(column_names):
|
||||
schema_semantics[
|
||||
f"0x{base:x}/{stride}/{column}"
|
||||
] = f"{semantic_name}.{column_name}"
|
||||
|
||||
return records, {
|
||||
"schema": "stage-definitions",
|
||||
"reserved_record_count": STAGE_DEFINITION_CAPACITY,
|
||||
"name_array_base": f"0x{STAGE_DEFINITION_NAME_BASE:x}",
|
||||
"description_array_base": (
|
||||
f"0x{STAGE_DESCRIPTION_BASE:x}"
|
||||
),
|
||||
"description_columns": list(STAGE_DESCRIPTION_COLUMNS),
|
||||
"record_field_columns": sorted({
|
||||
key
|
||||
for record in records
|
||||
for key in record["record_fields"]
|
||||
}, key=lambda key: tuple(
|
||||
int(part, 0) for part in key.split("/")
|
||||
)),
|
||||
"string_field_columns": sorted({
|
||||
key
|
||||
for record in records
|
||||
for key in record["string_fields"]
|
||||
}, key=lambda key: tuple(
|
||||
int(part, 0) for part in key.split("/")
|
||||
)),
|
||||
"schema_field_semantics": schema_semantics,
|
||||
"string_write_count": string_write_count,
|
||||
"static_write_count": static_write_count,
|
||||
"classified_instruction_count": len(classified_offsets),
|
||||
"authored_numeric_cell_counts": field_counts,
|
||||
"description_line_count": description_line_count,
|
||||
"mapped_stage_count": sum("map" in record for record in records),
|
||||
"event_only_stage_count": sum(
|
||||
record["display_number"]["kind"] == "event"
|
||||
for record in records
|
||||
),
|
||||
"main_progression_stage_count": sum(
|
||||
record["availability"]["main_progression"]
|
||||
for record in records
|
||||
),
|
||||
"extra_dungeon_stage_count": sum(
|
||||
record["availability"]["extra_dungeon"]
|
||||
for record in records
|
||||
),
|
||||
"story_flag_gated_stage_count": sum(
|
||||
bool(record["availability"]["required_story_flag_ids"])
|
||||
or bool(
|
||||
record["availability"]["forbidden_story_flag_ids"]
|
||||
)
|
||||
for record in records
|
||||
),
|
||||
"scjump_reference_count": scjump_reference_count,
|
||||
"resolved_scjump_reference_count": (
|
||||
resolved_scjump_reference_count
|
||||
),
|
||||
"resolved_loader_script_count": resolved_loader_script_count,
|
||||
"clear_coin_reward_cell_count": field_counts[
|
||||
"clear_coin_quantities"
|
||||
],
|
||||
"unresolved_parameter_population": field_counts[
|
||||
"unresolved_parameter_0xedc4d"
|
||||
],
|
||||
"consumer_contract": {
|
||||
"FORT.BIN": (
|
||||
"enumerates named rows, applies required/forbidden story "
|
||||
"flags, auto-selects available main-progression stages, "
|
||||
"renders stage numbers and coin rewards, and dispatches the "
|
||||
"entry SCJUMP decision"
|
||||
),
|
||||
"FIELD.BIN": (
|
||||
"calls the selected row's STINIT loader, initializes map and "
|
||||
"minimap geometry, propagates unlock groups, awards base "
|
||||
"spendable points, and dispatches clear/failure decisions"
|
||||
),
|
||||
"SELSTAGE.BIN": (
|
||||
"renders the six description lines selected by clear state, "
|
||||
"draws the numbered/EVENT/EX labels and clear rewards, and "
|
||||
"uses the minimap atlas origin to crop the selected map"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _map_stage_definitions() -> list[dict]:
|
||||
"""Read the STINIT2 records that own all four terrain-atlas bounds."""
|
||||
stage_scr = sys4load.load(resolve("STINIT2"))
|
||||
stage_records, _ = extract_name(stage_scr)
|
||||
bounds = (
|
||||
MAP_STAGE_MIN_X,
|
||||
MAP_STAGE_MAX_X,
|
||||
MAP_STAGE_MIN_Y,
|
||||
MAP_STAGE_MAX_Y,
|
||||
)
|
||||
stage_records, _ = extract_stage_definitions(stage_scr)
|
||||
definitions = []
|
||||
for record in stage_records:
|
||||
fields = record.get("fields", {})
|
||||
keys = [f"0x{address:x}" for address in bounds]
|
||||
if not all(key in fields for key in keys):
|
||||
if "map" not in record:
|
||||
continue
|
||||
min_x, max_x, min_y, max_y = (fields[key] for key in keys)
|
||||
definitions.append({
|
||||
"id": record["id"],
|
||||
"name": record.get("name", ""),
|
||||
"tile_bounds": {
|
||||
"min_x": min_x,
|
||||
"max_x": max_x,
|
||||
"min_y": min_y,
|
||||
"max_y": max_y,
|
||||
},
|
||||
"grid_bounds": {
|
||||
"min_x": min_x * MAP_TILE_TO_GRID_SCALE,
|
||||
"max_x": max_x * MAP_TILE_TO_GRID_SCALE,
|
||||
"min_y": min_y * MAP_TILE_TO_GRID_SCALE,
|
||||
"max_y": max_y * MAP_TILE_TO_GRID_SCALE,
|
||||
},
|
||||
"tile_bounds": record["map"]["tile_bounds"],
|
||||
"grid_bounds": record["map"]["grid_bounds"],
|
||||
})
|
||||
return definitions
|
||||
|
||||
@@ -6439,6 +6905,13 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"duration. BTANINIT's paired schema decodes 202 effect ids into BTL's six-slot",
|
||||
"movie/sprite, blend, geometry, audio, and hit-pulse work record.",
|
||||
"",
|
||||
"STINIT2's dedicated stage-definition schema exposes 74 sparse rows in a",
|
||||
"reserved 1,000-stage catalog. It preserves six pre/post-clear description",
|
||||
"slots, progression and story gates, numbered/EVENT/EX presentation, map and",
|
||||
"minimap geometry, point/coin rewards, and all 174 SCINIT-resolved entry, clear,",
|
||||
"and failure decisions. All rows resolve their shared STINIT loader reference;",
|
||||
"the unconsumed 0xedc4d column remains explicit rather than receiving a guess.",
|
||||
"",
|
||||
"MPINIT's dedicated terrain-atlas schema exposes 1,472 authored rows of a sparse",
|
||||
"53-column half-tile grid. It joins STINIT2's doubled tile-bound rectangles to 66",
|
||||
"stage definitions, preserves implicit-zero rows and raw footer provenance, and",
|
||||
@@ -6552,6 +7025,8 @@ def main() -> int:
|
||||
extractor = extract_battle_effect_definitions
|
||||
elif mode == "numeric" and name == "BTANINIT2":
|
||||
extractor = extract_battle_animations
|
||||
elif mode == "name" and name == "STINIT2":
|
||||
extractor = extract_stage_definitions
|
||||
elif mode == "numeric" and name == "SPINIT":
|
||||
extractor = extract_h_scene_gallery
|
||||
elif mode == "footer" and name == "MPINIT":
|
||||
|
||||
@@ -578,6 +578,49 @@ def profile_battle_animations(data: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def profile_stage_definitions(data: dict) -> dict:
|
||||
"""Summarize STINIT2's stage catalog, text, gates, and flow joins."""
|
||||
if data.get("schema") != "stage-definitions":
|
||||
return {}
|
||||
return {
|
||||
"stage_count": data.get("record_count", 0),
|
||||
"reserved_record_count": data.get(
|
||||
"reserved_record_count", 0
|
||||
),
|
||||
"description_line_count": data.get(
|
||||
"description_line_count", 0
|
||||
),
|
||||
"mapped_stage_count": data.get("mapped_stage_count", 0),
|
||||
"event_only_stage_count": data.get(
|
||||
"event_only_stage_count", 0
|
||||
),
|
||||
"main_progression_stage_count": data.get(
|
||||
"main_progression_stage_count", 0
|
||||
),
|
||||
"extra_dungeon_stage_count": data.get(
|
||||
"extra_dungeon_stage_count", 0
|
||||
),
|
||||
"story_flag_gated_stage_count": data.get(
|
||||
"story_flag_gated_stage_count", 0
|
||||
),
|
||||
"scjump_reference_count": data.get(
|
||||
"scjump_reference_count", 0
|
||||
),
|
||||
"resolved_scjump_reference_count": data.get(
|
||||
"resolved_scjump_reference_count", 0
|
||||
),
|
||||
"resolved_loader_script_count": data.get(
|
||||
"resolved_loader_script_count", 0
|
||||
),
|
||||
"clear_coin_reward_cell_count": data.get(
|
||||
"clear_coin_reward_cell_count", 0
|
||||
),
|
||||
"unresolved_parameter_population": data.get(
|
||||
"unresolved_parameter_population", 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def profile_messages(data: dict) -> dict:
|
||||
"""Summarize the joined player-facing message evidence."""
|
||||
records = data["records"]
|
||||
@@ -752,6 +795,32 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
|
||||
f"- unjoined authored rows: "
|
||||
f"{animation_profile['unjoined_authored_animation_ids']}",
|
||||
])
|
||||
elif stage_profile := profile_stage_definitions(data):
|
||||
lines.extend([
|
||||
f"- stage definitions: {stage_profile['stage_count']}/"
|
||||
f"{stage_profile['reserved_record_count']} rows",
|
||||
f"- description lines: "
|
||||
f"{stage_profile['description_line_count']} across six "
|
||||
f"pre/post-clear slots",
|
||||
f"- mapped/event-only stages: "
|
||||
f"{stage_profile['mapped_stage_count']}/"
|
||||
f"{stage_profile['event_only_stage_count']}",
|
||||
f"- main-progression/EX stages: "
|
||||
f"{stage_profile['main_progression_stage_count']}/"
|
||||
f"{stage_profile['extra_dungeon_stage_count']}",
|
||||
f"- story-flag-gated stages: "
|
||||
f"{stage_profile['story_flag_gated_stage_count']}",
|
||||
f"- SCJUMP joins: "
|
||||
f"{stage_profile['resolved_scjump_reference_count']}/"
|
||||
f"{stage_profile['scjump_reference_count']}",
|
||||
f"- stage-loader joins: "
|
||||
f"{stage_profile['resolved_loader_script_count']}/"
|
||||
f"{stage_profile['stage_count']}",
|
||||
f"- clear coin reward cells: "
|
||||
f"{stage_profile['clear_coin_reward_cell_count']}",
|
||||
f"- unresolved 0xedc4d cells: "
|
||||
f"{stage_profile['unresolved_parameter_population']}",
|
||||
])
|
||||
elif definition_profile := profile_card_definitions(data):
|
||||
lines.extend([
|
||||
f"- card definitions: {definition_profile['card_count']}/"
|
||||
@@ -989,6 +1058,7 @@ def main() -> int:
|
||||
"training_action_profile": profile_training_actions(data),
|
||||
"card_generation_profile": profile_card_generation_lists(data),
|
||||
"card_definition_profile": profile_card_definitions(data),
|
||||
"stage_definition_profile": profile_stage_definitions(data),
|
||||
"columns": sorted(rows, key=lambda row: (
|
||||
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
|
||||
)),
|
||||
|
||||
@@ -1973,6 +1973,118 @@ def test_battle_animations() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_stage_definitions() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["STINIT2.BIN"])
|
||||
check(
|
||||
extract_init.detect_mode(script) == "name",
|
||||
"STINIT2 remains compatible with name-mode auto-detection",
|
||||
)
|
||||
records, meta = extract_init.extract_stage_definitions(script)
|
||||
by_id = {record["id"]: record for record in records}
|
||||
check(
|
||||
len(records) == 74
|
||||
and meta["reserved_record_count"] == 1000
|
||||
and meta["mapped_stage_count"] == 66
|
||||
and meta["event_only_stage_count"] == 8,
|
||||
"STINIT2 separates 74 stage rows into mapped and event-only records",
|
||||
)
|
||||
check(
|
||||
meta["string_write_count"] == 370
|
||||
and meta["description_line_count"] == 296
|
||||
and meta["static_write_count"] == 1263
|
||||
and meta["classified_instruction_count"] == 1634,
|
||||
"STINIT2 classifies every name, description, numeric, and exit instruction",
|
||||
)
|
||||
check(
|
||||
by_id[1]["descriptions"]["uncleared"]
|
||||
== [
|
||||
"庭園につながっている地下空洞。",
|
||||
"浅い階層なので魔物も少ないようだ。",
|
||||
"",
|
||||
]
|
||||
and by_id[1]["descriptions"]["cleared"]
|
||||
== [
|
||||
"庭園につながっている地下空洞。",
|
||||
"浅い階層なので魔物も少ないようだ。",
|
||||
"",
|
||||
],
|
||||
"STINIT2 preserves the six pre/post-clear description slots",
|
||||
)
|
||||
check(
|
||||
by_id[1]["display_number"]
|
||||
== {"kind": "numbered", "major": 0, "minor": 1}
|
||||
and by_id[3]["display_number"]["kind"] == "event"
|
||||
and by_id[160]["display_number"]["kind"] == "extra",
|
||||
"STINIT2 distinguishes numbered, event-only, and EX display labels",
|
||||
)
|
||||
check(
|
||||
by_id[3]["flow"]["entry_scjump_decision_id"] == 150
|
||||
and by_id[3]["flow"]["entry_script_name"] == "SC0150.BIN"
|
||||
and by_id[1]["flow"]["clear_script_name"] == "SC0010.BIN"
|
||||
and by_id[1]["flow"]["failure_script_name"] == "SC0000.BIN"
|
||||
and meta["resolved_scjump_reference_count"] == 174,
|
||||
"STINIT2 resolves every entry, clear, and failure SCJUMP reference",
|
||||
)
|
||||
check(
|
||||
by_id[1]["flow"]["stage_loader_script_name"] == "STINIT.BIN"
|
||||
and meta["resolved_loader_script_count"] == 74,
|
||||
"STINIT2 resolves every shared stage-loader reference",
|
||||
)
|
||||
check(
|
||||
by_id[32]["availability"]["unlock_group_id"] == 32
|
||||
and by_id[33]["availability"]["unlock_group_id"] == 32
|
||||
and by_id[34]["availability"]["unlock_group_id"] == 32
|
||||
and meta["main_progression_stage_count"] == 49
|
||||
and meta["extra_dungeon_stage_count"] == 8,
|
||||
"STINIT2 exposes shared unlock groups and progression/EX flags",
|
||||
)
|
||||
check(
|
||||
by_id[3]["availability"]["required_story_flag_ids"]
|
||||
== [151, 23, 1743, 1886]
|
||||
and by_id[3]["availability"]["forbidden_story_flag_ids"]
|
||||
== [21]
|
||||
and meta["story_flag_gated_stage_count"] == 74,
|
||||
"STINIT2 preserves all seven-column story eligibility rows",
|
||||
)
|
||||
check(
|
||||
by_id[32]["map"]["tile_bounds"]
|
||||
== {"min_x": 1, "max_x": 25, "min_y": 116, "max_y": 136}
|
||||
and by_id[32]["map"]["grid_bounds"]
|
||||
== {"min_x": 2, "max_x": 50, "min_y": 232, "max_y": 272}
|
||||
and by_id[32]["map"]["minimap_atlas_origin_y"] == 66,
|
||||
"STINIT2 joins tile, doubled-grid, and minimap atlas geometry",
|
||||
)
|
||||
check(
|
||||
by_id[167]["clear_rewards"]["base_spendable_points"] == 60
|
||||
and by_id[167]["clear_rewards"]["coins"]
|
||||
== [
|
||||
{
|
||||
"item_id": 91,
|
||||
"item_name": "ブロンズコイン",
|
||||
"quantity": 3,
|
||||
},
|
||||
{
|
||||
"item_id": 92,
|
||||
"item_name": "シルバーコイン",
|
||||
"quantity": 2,
|
||||
},
|
||||
{
|
||||
"item_id": 93,
|
||||
"item_name": "ゴールドコイン",
|
||||
"quantity": 1,
|
||||
},
|
||||
]
|
||||
and meta["clear_coin_reward_cell_count"] == 64,
|
||||
"STINIT2 resolves the spendable-point and three coin reward columns",
|
||||
)
|
||||
check(
|
||||
meta["unresolved_parameter_population"] == 66
|
||||
and by_id[167]["unresolved_parameter_0xedc4d"] == 8,
|
||||
"STINIT2 preserves the one still-unresolved populated stage column",
|
||||
)
|
||||
|
||||
|
||||
def test_condition_definitions() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["ILINIT.BIN"])
|
||||
@@ -2194,6 +2306,7 @@ if __name__ == "__main__":
|
||||
test_card_definitions()
|
||||
test_battle_effect_definitions()
|
||||
test_battle_animations()
|
||||
test_stage_definitions()
|
||||
test_map_terrain_atlas()
|
||||
test_condition_definitions()
|
||||
test_field_semantics()
|
||||
|
||||
@@ -113,6 +113,25 @@ def test_load_and_lint():
|
||||
and entries[0x155b7a]["name"] == "battle_effect_atlas_row_counts"
|
||||
and entries[0x155baa]["columns"]["2"] == "pulse_3",
|
||||
"BTANINIT/BTANINIT2 battle-animation state is curated")
|
||||
check(entries[0x27bd]["name"] == "stage_display_names"
|
||||
and entries[0x2ba5]["columns"]["5"] == "cleared_line_3"
|
||||
and entries[0xe7e8d]["name"] == "stage_unlock_group_ids"
|
||||
and entries[0xe8275]["name"] == "stage_main_progression_flags"
|
||||
and entries[0xe865d]["columns"]["6"] == "forbidden_flag_7"
|
||||
and entries[0xea1b5]["columns"]["6"] == "required_flag_7",
|
||||
"STINIT2 stage text and availability state is curated")
|
||||
check(entries[0xebd0d]["name"] == "stage_display_number_major"
|
||||
and entries[0xec0f5]["name"] == "stage_display_number_minor"
|
||||
and entries[0xed47d]["name"] == "stage_minimap_atlas_origin_y"
|
||||
and entries[0xed865]["name"]
|
||||
== "stage_clear_base_spendable_point_rewards",
|
||||
"STINIT2 stage numbering, minimap, and point rewards are curated")
|
||||
check(entries[0xee035]["columns"]
|
||||
== {"0": "entry", "1": "clear", "2": "failure"}
|
||||
and entries[0xeebed]["name"] == "stage_extra_dungeon_flags"
|
||||
and entries[0xeefd5]["columns"]["2"] == "gold_coin_item_93"
|
||||
and entries[0xefb8d]["name"] == "stage_loader_script_ids",
|
||||
"STINIT2 flow, EX, coin, and loader columns are curated")
|
||||
check(entries[0x15a095]["name"] == "information_tab_index"
|
||||
and entries[0x15a096]["name"] == "information_message_handled"
|
||||
and entries[0x15a097]["name"]
|
||||
|
||||
@@ -414,6 +414,35 @@ def main() -> int:
|
||||
assert "- battle animations: 3/1000 rows" in rendered_animations
|
||||
assert "- full/auxiliary timelines: 2/1" in rendered_animations
|
||||
|
||||
stage_fixture = {
|
||||
"table": "STAGES",
|
||||
"mode": "name",
|
||||
"schema": "stage-definitions",
|
||||
"record_count": 5,
|
||||
"reserved_record_count": 1000,
|
||||
"description_line_count": 18,
|
||||
"mapped_stage_count": 4,
|
||||
"event_only_stage_count": 1,
|
||||
"main_progression_stage_count": 3,
|
||||
"extra_dungeon_stage_count": 1,
|
||||
"story_flag_gated_stage_count": 5,
|
||||
"scjump_reference_count": 12,
|
||||
"resolved_scjump_reference_count": 12,
|
||||
"resolved_loader_script_count": 5,
|
||||
"clear_coin_reward_cell_count": 4,
|
||||
"unresolved_parameter_population": 4,
|
||||
"records": [{}, {}, {}, {}, {}],
|
||||
}
|
||||
stage_summary = profile.profile_stage_definitions(stage_fixture)
|
||||
assert stage_summary["mapped_stage_count"] == 4
|
||||
assert stage_summary["resolved_scjump_reference_count"] == 12
|
||||
rendered_stages = profile.render_markdown(
|
||||
stage_fixture, [], 40
|
||||
)
|
||||
assert "- stage definitions: 5/1000 rows" in rendered_stages
|
||||
assert "- mapped/event-only stages: 4/1" in rendered_stages
|
||||
assert "- SCJUMP joins: 12/12" in rendered_stages
|
||||
|
||||
training_fixture = {
|
||||
"table": "TRAINING",
|
||||
"mode": "name",
|
||||
|
||||
Reference in New Issue
Block a user