Decode stage definition tables

This commit is contained in:
gamer147
2026-07-23 23:36:02 -04:00
parent 35080a086e
commit 796b0e850e
12 changed files with 959 additions and 47 deletions

View File

@@ -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":

View File

@@ -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
)),

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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",