Decode SPINIT H-scene gallery
This commit is contained in:
@@ -672,6 +672,11 @@ MAP_TEXTURE_SLOT_COUNT = 20
|
||||
TERRAIN_DEFINITION_SPAN = 30
|
||||
TERRAIN_SHIPPED_ID_MAX = 19
|
||||
|
||||
H_SCENE_GALLERY_SCRIPT_BASE = 0x6638B
|
||||
H_SCENE_GALLERY_PAGE_COUNT = 8
|
||||
H_SCENE_GALLERY_SLOTS_PER_PAGE = 15
|
||||
H_SCENE_GALLERY_THUMBNAIL_BASE = 0x66421
|
||||
|
||||
|
||||
def resolve(name: str) -> Path:
|
||||
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
|
||||
@@ -3630,6 +3635,155 @@ def _terrain_definitions(max_terrain_id: int) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def extract_h_scene_gallery(scr):
|
||||
"""Extract SPINIT's eight-page, fifteen-slot HMODE script registry."""
|
||||
values: dict[tuple[int, int], int] = {}
|
||||
classified_offsets = set()
|
||||
static_write_count = 0
|
||||
for ins in scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None:
|
||||
continue
|
||||
static_write_count += 1
|
||||
destination, value = write
|
||||
if not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: non-static H-gallery value "
|
||||
f"at 0x{ins.offset:x}"
|
||||
)
|
||||
index = destination - H_SCENE_GALLERY_SCRIPT_BASE
|
||||
capacity = (
|
||||
H_SCENE_GALLERY_PAGE_COUNT
|
||||
* H_SCENE_GALLERY_SLOTS_PER_PAGE
|
||||
)
|
||||
if not 0 <= index < capacity:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: H-gallery write 0x{destination:x} "
|
||||
f"outside the {capacity}-cell registry"
|
||||
)
|
||||
page, slot = divmod(index, H_SCENE_GALLERY_SLOTS_PER_PAGE)
|
||||
_store_unique(values, (page, slot), value, page)
|
||||
classified_offsets.add(ins.offset)
|
||||
|
||||
exit_offsets = {
|
||||
ins.offset
|
||||
for ins in scr.instructions
|
||||
if sys4load.display_label(ins.opcode) == "exit"
|
||||
}
|
||||
classified_offsets.update(exit_offsets)
|
||||
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)
|
||||
)
|
||||
if len(exit_offsets) != 1:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: expected one exit, found {len(exit_offsets)}"
|
||||
)
|
||||
|
||||
init_scr = sys4load.load(resolve("INIT2"))
|
||||
thumbnail_assets = {}
|
||||
for ins in init_scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None:
|
||||
continue
|
||||
destination, value = write
|
||||
page = destination - H_SCENE_GALLERY_THUMBNAIL_BASE
|
||||
if 0 <= page < H_SCENE_GALLERY_PAGE_COUNT:
|
||||
_store_unique(thumbnail_assets, page, value, page)
|
||||
expected_pages = set(range(H_SCENE_GALLERY_PAGE_COUNT))
|
||||
if set(thumbnail_assets) != expected_pages:
|
||||
raise ValueError(
|
||||
f"INIT2.BIN: H-gallery thumbnail pages are "
|
||||
f"{sorted(thumbnail_assets)}, expected "
|
||||
f"0..{H_SCENE_GALLERY_PAGE_COUNT - 1}"
|
||||
)
|
||||
|
||||
names = callscript_names()
|
||||
table_key = f"0x{H_SCENE_GALLERY_SCRIPT_BASE:x}"
|
||||
records = []
|
||||
empty_cells = []
|
||||
for page in range(H_SCENE_GALLERY_PAGE_COUNT):
|
||||
script_ids = []
|
||||
scenes = []
|
||||
raw_fields = {}
|
||||
for slot in range(H_SCENE_GALLERY_SLOTS_PER_PAGE):
|
||||
script_id = values.get((page, slot), 0)
|
||||
script_ids.append(script_id)
|
||||
if not script_id:
|
||||
empty_cells.append({"page": page, "slot": slot})
|
||||
continue
|
||||
script_name = names.get(script_id, "")
|
||||
scenes.append({
|
||||
"slot": slot,
|
||||
"script_resource_id": script_id,
|
||||
"script_name": script_name,
|
||||
})
|
||||
raw_fields[
|
||||
f"{table_key}/{H_SCENE_GALLERY_SLOTS_PER_PAGE}/{slot}"
|
||||
] = script_id
|
||||
thumbnail_asset_id = thumbnail_assets[page]
|
||||
records.append({
|
||||
"id": page,
|
||||
"name": f"page_{page}",
|
||||
"thumbnail_sheet_asset_id": thumbnail_asset_id,
|
||||
"thumbnail_sheet_asset_name": names.get(
|
||||
thumbnail_asset_id, ""
|
||||
),
|
||||
"script_resource_ids": script_ids,
|
||||
"scenes": scenes,
|
||||
"record_fields": raw_fields,
|
||||
})
|
||||
|
||||
return records, {
|
||||
"schema": "h-scene-gallery-pages",
|
||||
"page_count": H_SCENE_GALLERY_PAGE_COUNT,
|
||||
"slots_per_page": H_SCENE_GALLERY_SLOTS_PER_PAGE,
|
||||
"registry_capacity": (
|
||||
H_SCENE_GALLERY_PAGE_COUNT
|
||||
* H_SCENE_GALLERY_SLOTS_PER_PAGE
|
||||
),
|
||||
"populated_scene_count": len(values),
|
||||
"empty_cells": empty_cells,
|
||||
"script_registry_base": table_key,
|
||||
"thumbnail_sheet_array_base": (
|
||||
f"0x{H_SCENE_GALLERY_THUMBNAIL_BASE:x}"
|
||||
),
|
||||
"thumbnail_sheet_source": "INIT2.BIN",
|
||||
"array_layouts": {
|
||||
table_key: {"stride": H_SCENE_GALLERY_SLOTS_PER_PAGE},
|
||||
},
|
||||
"schema_field_semantics": {
|
||||
table_key: "h_scene_gallery_script_ids",
|
||||
},
|
||||
"static_write_count": static_write_count,
|
||||
"classified_static_write_count": len(values),
|
||||
"classified_instruction_count": len(classified_offsets),
|
||||
"resolved_scene_script_count": sum(
|
||||
bool(scene["script_name"])
|
||||
for record in records
|
||||
for scene in record["scenes"]
|
||||
),
|
||||
"resolved_thumbnail_sheet_count": sum(
|
||||
bool(record["thumbnail_sheet_asset_name"])
|
||||
for record in records
|
||||
),
|
||||
"consumer_contract": {
|
||||
"HMODE.BIN": (
|
||||
"compact the eight configured INIT2 thumbnail pages, scan "
|
||||
"their fifteen SPINIT script slots, filter each populated "
|
||||
"resource through opcode 0x19d, and call-script the selected "
|
||||
"available scene"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _map_stage_definitions() -> list[dict]:
|
||||
"""Read the STINIT2 records that own all four terrain-atlas bounds."""
|
||||
stage_scr = sys4load.load(resolve("STINIT2"))
|
||||
@@ -4241,6 +4395,10 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"effect descriptions, four parallel topology/rendering arrays, the ten-column",
|
||||
"combat-stat matrix, SKINIT traversal-skill joins, and shared texture fallbacks.",
|
||||
"",
|
||||
"SPINIT's dedicated H-scene gallery schema exposes eight fifteen-slot pages,",
|
||||
"joins every page to its INIT2 SO027 thumbnail sheet, resolves all 118 populated",
|
||||
"scene resources, and retains the two implicit empty cells in the final page.",
|
||||
"",
|
||||
"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",
|
||||
@@ -4333,6 +4491,8 @@ def main() -> int:
|
||||
extractor = extract_voice_configuration
|
||||
elif mode == "name" and name == "LAINIT":
|
||||
extractor = extract_terrain_definitions
|
||||
elif mode == "numeric" and name == "SPINIT":
|
||||
extractor = extract_h_scene_gallery
|
||||
elif mode == "footer" and name == "MPINIT":
|
||||
extractor = extract_map_terrain_atlas
|
||||
recs, meta = extractor(scr)
|
||||
|
||||
@@ -346,6 +346,25 @@ def profile_terrain_definitions(data: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def profile_h_scene_gallery(data: dict) -> dict:
|
||||
"""Summarize SPINIT's HMODE page/slot script registry."""
|
||||
if data.get("schema") != "h-scene-gallery-pages":
|
||||
return {}
|
||||
return {
|
||||
"page_count": data.get("page_count", 0),
|
||||
"slots_per_page": data.get("slots_per_page", 0),
|
||||
"registry_capacity": data.get("registry_capacity", 0),
|
||||
"populated_scene_count": data.get("populated_scene_count", 0),
|
||||
"empty_cells": data.get("empty_cells", []),
|
||||
"resolved_scene_script_count": data.get(
|
||||
"resolved_scene_script_count", 0
|
||||
),
|
||||
"resolved_thumbnail_sheet_count": data.get(
|
||||
"resolved_thumbnail_sheet_count", 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def profile_messages(data: dict) -> dict:
|
||||
"""Summarize the joined player-facing message evidence."""
|
||||
records = data["records"]
|
||||
@@ -473,7 +492,22 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
|
||||
f"- records: {data['record_count']}",
|
||||
f"- populated fields: {len(rows)}",
|
||||
]
|
||||
if terrain_profile := profile_terrain_definitions(data):
|
||||
if h_gallery_profile := profile_h_scene_gallery(data):
|
||||
lines.extend([
|
||||
f"- geometry: {h_gallery_profile['page_count']} pages × "
|
||||
f"{h_gallery_profile['slots_per_page']} slots",
|
||||
f"- populated scenes: "
|
||||
f"{h_gallery_profile['populated_scene_count']}/"
|
||||
f"{h_gallery_profile['registry_capacity']}",
|
||||
f"- resolved scripts: "
|
||||
f"{h_gallery_profile['resolved_scene_script_count']}/"
|
||||
f"{h_gallery_profile['populated_scene_count']}",
|
||||
f"- resolved thumbnail sheets: "
|
||||
f"{h_gallery_profile['resolved_thumbnail_sheet_count']}/"
|
||||
f"{h_gallery_profile['page_count']}",
|
||||
f"- empty cells: {h_gallery_profile['empty_cells']}",
|
||||
])
|
||||
elif terrain_profile := profile_terrain_definitions(data):
|
||||
lines.extend([
|
||||
f"- shipped terrain ids: "
|
||||
f"{terrain_profile['shipped_record_count']} inside a "
|
||||
@@ -632,6 +666,7 @@ def main() -> int:
|
||||
"banked_profile": profile_banked(data),
|
||||
"map_atlas_profile": profile_map_atlas(data),
|
||||
"terrain_definition_profile": profile_terrain_definitions(data),
|
||||
"h_scene_gallery_profile": profile_h_scene_gallery(data),
|
||||
"columns": sorted(rows, key=lambda row: (
|
||||
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
|
||||
)),
|
||||
|
||||
@@ -1523,6 +1523,55 @@ def test_terrain_definitions() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_h_scene_gallery() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["SPINIT.BIN"])
|
||||
check(
|
||||
extract_init.detect_mode(script) == "numeric",
|
||||
"SPINIT remains compatible with numeric-mode auto-detection",
|
||||
)
|
||||
records, meta = extract_init.extract_h_scene_gallery(script)
|
||||
check(
|
||||
len(records) == 8
|
||||
and meta["page_count"] == 8
|
||||
and meta["slots_per_page"] == 15
|
||||
and meta["registry_capacity"] == 120,
|
||||
"SPINIT exposes HMODE's eight-by-fifteen page geometry",
|
||||
)
|
||||
check(
|
||||
meta["static_write_count"] == 118
|
||||
and meta["classified_static_write_count"] == 118
|
||||
and meta["classified_instruction_count"] == 119
|
||||
and meta["empty_cells"]
|
||||
== [{"page": 7, "slot": 13}, {"page": 7, "slot": 14}],
|
||||
"SPINIT classifies every scene cell and the two implicit empty slots",
|
||||
)
|
||||
check(
|
||||
records[0]["thumbnail_sheet_asset_name"] == "SO027A.AGF"
|
||||
and records[7]["thumbnail_sheet_asset_name"] == "SO027H.AGF"
|
||||
and meta["resolved_thumbnail_sheet_count"] == 8,
|
||||
"SPINIT pages join to all eight INIT2 HMODE thumbnail sheets",
|
||||
)
|
||||
check(
|
||||
records[0]["scenes"][0]
|
||||
== {
|
||||
"slot": 0,
|
||||
"script_resource_id": 0x151E,
|
||||
"script_name": "SP0800.BIN",
|
||||
}
|
||||
and records[2]["scenes"][3]["script_name"] == "SP1200.BIN"
|
||||
and records[7]["scenes"][-1]["script_name"] == "SP0179.BIN"
|
||||
and meta["resolved_scene_script_count"] == 118,
|
||||
"SPINIT resolves every populated cell to its call-script resource",
|
||||
)
|
||||
check(
|
||||
len(records[7]["script_resource_ids"]) == 15
|
||||
and records[7]["script_resource_ids"][-2:] == [0, 0]
|
||||
and len(records[7]["record_fields"]) == 13,
|
||||
"SPINIT preserves complete rows beside authored raw-cell provenance",
|
||||
)
|
||||
|
||||
|
||||
def test_condition_definitions() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["ILINIT.BIN"])
|
||||
@@ -1738,6 +1787,7 @@ if __name__ == "__main__":
|
||||
test_name_entry_palette()
|
||||
test_voice_configuration()
|
||||
test_terrain_definitions()
|
||||
test_h_scene_gallery()
|
||||
test_map_terrain_atlas()
|
||||
test_condition_definitions()
|
||||
test_field_semantics()
|
||||
|
||||
@@ -245,6 +245,28 @@ def main() -> int:
|
||||
assert "- shipped terrain ids: 20 inside a 30-row table" in rendered_terrain
|
||||
assert "- combat-stat cells: 13" in rendered_terrain
|
||||
|
||||
h_gallery_fixture = {
|
||||
"table": "SP",
|
||||
"mode": "numeric",
|
||||
"schema": "h-scene-gallery-pages",
|
||||
"record_count": 8,
|
||||
"page_count": 8,
|
||||
"slots_per_page": 15,
|
||||
"registry_capacity": 120,
|
||||
"populated_scene_count": 118,
|
||||
"empty_cells": [{"page": 7, "slot": 13}, {"page": 7, "slot": 14}],
|
||||
"resolved_scene_script_count": 118,
|
||||
"resolved_thumbnail_sheet_count": 8,
|
||||
"records": [],
|
||||
}
|
||||
h_gallery_summary = profile.profile_h_scene_gallery(h_gallery_fixture)
|
||||
assert h_gallery_summary["page_count"] == 8
|
||||
assert h_gallery_summary["populated_scene_count"] == 118
|
||||
assert h_gallery_summary["resolved_thumbnail_sheet_count"] == 8
|
||||
rendered_h_gallery = profile.render_markdown(h_gallery_fixture, [], 40)
|
||||
assert "- geometry: 8 pages × 15 slots" in rendered_h_gallery
|
||||
assert "- populated scenes: 118/120" in rendered_h_gallery
|
||||
|
||||
messages = profile.profile_messages(fixture)
|
||||
assert messages["population"] == 1
|
||||
assert messages["coverage"] == 1 / 3
|
||||
|
||||
Reference in New Issue
Block a user