Decode AFINIT and CTINIT data tables

This commit is contained in:
gamer147
2026-07-23 19:42:32 -04:00
parent 825b1b08fd
commit 7bec889eed
9 changed files with 771 additions and 29 deletions

View File

@@ -599,6 +599,33 @@ ALCHEMY_RECIPE_RECORD_SPAN = 1000
ALCHEMY_RECIPE_STORY_FLAG_STRIDE = 2
ALCHEMY_RECIPE_INGREDIENT_STRIDE = 4
AFFINITY_ATTACK_ELEMENT_NAME_BASE = 0x2690
AFFINITY_DEFENSE_ELEMENT_NAME_BASE = 0x26A4
AFFINITY_ELEMENT_NAME_SPAN = 20
AFFINITY_EFFECTIVENESS_BASE = 0xAB5BA
AFFINITY_EFFECTIVENESS_STRIDE = 20
AFFINITY_EFFECTIVENESS_ROW_COUNT = 13
AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS = 18
ITEM_TUNING_BONUS_CURVE_BASE = 0xAB6FA
ITEM_TUNING_COST_CURVE_BASE = 0xAB7D6
ITEM_TUNING_CURVE_STRIDE = 11
ITEM_TUNING_CURVE_COUNT = 19
ITEM_TUNING_AUTHORED_LEVELS = 10
FACILITY_LEVEL_THRESHOLD_BASE = 0xAB8B2
FACILITY_LEVEL_THRESHOLD_STRIDE = 7
FACILITY_LEVEL_THRESHOLD_ROW_COUNT = 3
FACILITY_LEVEL_THRESHOLD_AUTHORED_LEVELS = 6
NAME_ENTRY_CHARACTER_PALETTE_BASE = 0x43DD
NAME_ENTRY_CHARACTER_PALETTE_STRIDE = 70
NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES = (
"hiragana",
"katakana",
"latin",
"numerals",
"symbols",
)
RECOVER_CURRENT_ENTITY = 0x152616
RECOVER_EFFECTIVE_STATS = 0x4E11B
RECOVER_CURRENT_RESOURCES = 0x4E085
@@ -2257,6 +2284,424 @@ def extract_alchemy_recipes(scr):
}
def extract_affinity_definitions(scr):
"""Extract AFINIT's element, tuning-curve, and facility-threshold tables."""
attack_names = {}
defense_names = {}
effectiveness_rows = {}
tuning_bonus_rows = {}
tuning_cost_rows = {}
facility_threshold_rows = {}
string_write_count = 0
footer_array_count = 0
exit_count = 0
def signed_values(values):
return [
value - 0x100000000 if value >= 0x80000000 else value
for value in values
]
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]
for base, target in (
(AFFINITY_ATTACK_ELEMENT_NAME_BASE, attack_names),
(AFFINITY_DEFENSE_ELEMENT_NAME_BASE, defense_names),
):
element_id = destination - base
if 0 <= element_id < AFFINITY_ELEMENT_NAME_SPAN:
_store_unique(target, element_id, text, element_id)
string_write_count += 1
break
else:
raise ValueError(
f"{scr.path.name}: unexpected string destination "
f"0x{destination:x}"
)
continue
if (
ins.opcode == COPY_LOCAL_ARRAY
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_INT
and ins.args[1][0] == T_IMM
):
destination = ins.args[0][1]
footer_off = ins.args[1][1]
values = read_footer_array(scr, footer_off)
if values is None:
raise ValueError(
f"{scr.path.name}: invalid footer array 0x{footer_off:x}"
)
values = signed_values(values)
classified = False
relative = destination - AFFINITY_EFFECTIVENESS_BASE
if (
relative % AFFINITY_EFFECTIVENESS_STRIDE == 0
and 0 <= relative
< AFFINITY_EFFECTIVENESS_ROW_COUNT
* AFFINITY_EFFECTIVENESS_STRIDE
):
row = relative // AFFINITY_EFFECTIVENESS_STRIDE
if len(values) != AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS:
raise ValueError(
f"{scr.path.name}: effectiveness row {row} has "
f"{len(values)} values"
)
_store_unique(
effectiveness_rows, row, (footer_off, values), row
)
classified = True
if not classified:
for base, target in (
(ITEM_TUNING_BONUS_CURVE_BASE, tuning_bonus_rows),
(ITEM_TUNING_COST_CURVE_BASE, tuning_cost_rows),
):
relative = destination - base
if (
relative % ITEM_TUNING_CURVE_STRIDE == 0
and ITEM_TUNING_CURVE_STRIDE
<= relative
<= ITEM_TUNING_CURVE_COUNT
* ITEM_TUNING_CURVE_STRIDE
):
curve_id = relative // ITEM_TUNING_CURVE_STRIDE
if len(values) != ITEM_TUNING_AUTHORED_LEVELS:
raise ValueError(
f"{scr.path.name}: tuning curve {curve_id} has "
f"{len(values)} values"
)
_store_unique(
target, curve_id, (footer_off, values), curve_id
)
classified = True
break
if not classified:
relative = destination - FACILITY_LEVEL_THRESHOLD_BASE
if (
relative % FACILITY_LEVEL_THRESHOLD_STRIDE == 0
and 0 <= relative
< FACILITY_LEVEL_THRESHOLD_ROW_COUNT
* FACILITY_LEVEL_THRESHOLD_STRIDE
):
row = relative // FACILITY_LEVEL_THRESHOLD_STRIDE
if len(values) != FACILITY_LEVEL_THRESHOLD_AUTHORED_LEVELS:
raise ValueError(
f"{scr.path.name}: facility row {row} has "
f"{len(values)} values"
)
_store_unique(
facility_threshold_rows, row, (footer_off, values), row
)
classified = True
if not classified:
raise ValueError(
f"{scr.path.name}: unexpected footer destination "
f"0x{destination:x}"
)
footer_array_count += 1
continue
if sys4load.display_label(ins.opcode) == "exit":
exit_count += 1
else:
raise ValueError(
f"{scr.path.name}: unexpected opcode "
f"{sys4load.display_label(ins.opcode)} at 0x{ins.offset:x}"
)
expected_effectiveness_rows = set(range(AFFINITY_EFFECTIVENESS_ROW_COUNT))
expected_tuning_curves = set(range(1, ITEM_TUNING_CURVE_COUNT + 1))
expected_facility_rows = set(range(FACILITY_LEVEL_THRESHOLD_ROW_COUNT))
if set(effectiveness_rows) != expected_effectiveness_rows:
raise ValueError(f"{scr.path.name}: incomplete effectiveness matrix")
if (
set(tuning_bonus_rows) != expected_tuning_curves
or set(tuning_cost_rows) != expected_tuning_curves
):
raise ValueError(f"{scr.path.name}: incomplete tuning curves")
if set(facility_threshold_rows) != expected_facility_rows:
raise ValueError(f"{scr.path.name}: incomplete facility thresholds")
if exit_count != 1:
raise ValueError(f"{scr.path.name}: expected one exit, got {exit_count}")
records = []
for defense_element_id in sorted(effectiveness_rows):
footer_off, values = effectiveness_rows[defense_element_id]
record = {
"id": defense_element_id,
"name": defense_names.get(defense_element_id, ""),
"defense_element_id": defense_element_id,
"footer_arrays": {
(
f"0x{AFFINITY_EFFECTIVENESS_BASE:x}/"
f"{defense_element_id * AFFINITY_EFFECTIVENESS_STRIDE}"
): {
"footer_off": f"0x{footer_off:x}",
"values": values,
}
},
"attack_effectiveness": [
{
"attack_element_id": attack_element_id,
"attack_element_name": attack_names.get(
attack_element_id, ""
),
"percent": percent,
}
for attack_element_id, percent in enumerate(values)
],
}
if record["name"]:
record["string_fields"] = {
f"0x{AFFINITY_DEFENSE_ELEMENT_NAME_BASE:x}": record["name"]
}
records.append(record)
tuning_curves = []
for curve_id in sorted(tuning_bonus_rows):
bonus_footer_off, bonuses = tuning_bonus_rows[curve_id]
cost_footer_off, costs = tuning_cost_rows[curve_id]
tuning_curves.append({
"curve_id": curve_id,
"level_bonuses": bonuses,
"level_costs": costs,
"bonus_raw_key": (
f"0x{ITEM_TUNING_BONUS_CURVE_BASE:x}/"
f"{curve_id * ITEM_TUNING_CURVE_STRIDE}"
),
"bonus_footer_off": f"0x{bonus_footer_off:x}",
"cost_raw_key": (
f"0x{ITEM_TUNING_COST_CURVE_BASE:x}/"
f"{curve_id * ITEM_TUNING_CURVE_STRIDE}"
),
"cost_footer_off": f"0x{cost_footer_off:x}",
})
facility_names = ("item_tuning", "alchemy", "magic")
facility_thresholds = []
for row in sorted(facility_threshold_rows):
footer_off, thresholds = facility_threshold_rows[row]
facility_thresholds.append({
"system_id": row,
"system": facility_names[row],
"level_progress_thresholds": thresholds,
"raw_key": (
f"0x{FACILITY_LEVEL_THRESHOLD_BASE:x}/"
f"{row * FACILITY_LEVEL_THRESHOLD_STRIDE}"
),
"footer_off": f"0x{footer_off:x}",
})
return records, {
"schema": "affinity-and-progression-tables",
"attack_element_names": [
{"id": element_id, "name": name}
for element_id, name in sorted(attack_names.items())
],
"defense_element_names": [
{"id": element_id, "name": name}
for element_id, name in sorted(defense_names.items())
],
"effectiveness_matrix": {
"base": f"0x{AFFINITY_EFFECTIVENESS_BASE:x}",
"reserved_shape": [
AFFINITY_EFFECTIVENESS_STRIDE,
AFFINITY_EFFECTIVENESS_STRIDE,
],
"authored_rows": AFFINITY_EFFECTIVENESS_ROW_COUNT,
"authored_columns": AFFINITY_EFFECTIVENESS_AUTHORED_COLUMNS,
},
"item_tuning_curves": tuning_curves,
"usable_item_tuning_curve_ids": [
curve["curve_id"]
for curve in tuning_curves
if any(curve["level_bonuses"])
],
"reserved_item_tuning_curve_ids": [
curve["curve_id"]
for curve in tuning_curves
if not any(curve["level_bonuses"])
and not any(curve["level_costs"])
],
"facility_level_thresholds": facility_thresholds,
"string_write_count": string_write_count,
"footer_array_count": footer_array_count,
"exit_count": exit_count,
"classified_instruction_count": (
string_write_count + footer_array_count + exit_count
),
"array_layouts": {
f"0x{AFFINITY_EFFECTIVENESS_BASE:x}": {
"length": (
AFFINITY_EFFECTIVENESS_STRIDE
* AFFINITY_EFFECTIVENESS_STRIDE
),
"stride": AFFINITY_EFFECTIVENESS_STRIDE,
"rows": AFFINITY_EFFECTIVENESS_STRIDE,
},
f"0x{ITEM_TUNING_BONUS_CURVE_BASE:x}": {
"length": (
(ITEM_TUNING_CURVE_COUNT + 1)
* ITEM_TUNING_CURVE_STRIDE
),
"stride": ITEM_TUNING_CURVE_STRIDE,
"rows": ITEM_TUNING_CURVE_COUNT + 1,
},
f"0x{ITEM_TUNING_COST_CURVE_BASE:x}": {
"length": (
(ITEM_TUNING_CURVE_COUNT + 1)
* ITEM_TUNING_CURVE_STRIDE
),
"stride": ITEM_TUNING_CURVE_STRIDE,
"rows": ITEM_TUNING_CURVE_COUNT + 1,
},
f"0x{FACILITY_LEVEL_THRESHOLD_BASE:x}": {
"length": (
FACILITY_LEVEL_THRESHOLD_ROW_COUNT
* FACILITY_LEVEL_THRESHOLD_STRIDE
),
"stride": FACILITY_LEVEL_THRESHOLD_STRIDE,
"rows": FACILITY_LEVEL_THRESHOLD_ROW_COUNT,
},
},
"consumer_contract": {
"affinity": (
"CALCBTPARAM and AI providers index the effectiveness matrix "
"by defense element then attack element; INFOAF displays the "
"consumer-selected rows and the eight shipped attack elements."
),
"item_tuning": (
"TUNE, IMPROVE, DRAWTIP, and CALCREVISE combine each ITINIT "
"curve id with a zero-based tuning level to obtain the stat "
"bonus and point cost."
),
"facility_progression": (
"IMPROVE, ALCHEMY, and MAGIC/USEMAGIC index rows 0, 1, and 2 "
"respectively by current facility level."
),
},
}
def extract_name_entry_palette(scr):
"""Extract CTINIT's five-page, 70-cell name-entry character palette."""
rows = [
[None] * NAME_ENTRY_CHARACTER_PALETTE_STRIDE
for _ in NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES
]
string_write_count = 0
exit_count = 0
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]
relative = destination - NAME_ENTRY_CHARACTER_PALETTE_BASE
if not (
0 <= relative
< len(rows) * NAME_ENTRY_CHARACTER_PALETTE_STRIDE
):
raise ValueError(
f"{scr.path.name}: unexpected character destination "
f"0x{destination:x}"
)
row, column = divmod(
relative, NAME_ENTRY_CHARACTER_PALETTE_STRIDE
)
if rows[row][column] is not None:
raise ValueError(
f"{scr.path.name}: duplicate character cell {row}/{column}"
)
rows[row][column] = scr.strings.get(
ins.args[1][1], (None,)
)[0]
string_write_count += 1
continue
if sys4load.display_label(ins.opcode) == "exit":
exit_count += 1
else:
raise ValueError(
f"{scr.path.name}: unexpected opcode "
f"{sys4load.display_label(ins.opcode)} at 0x{ins.offset:x}"
)
if exit_count != 1:
raise ValueError(f"{scr.path.name}: expected one exit, got {exit_count}")
records = []
for row_id, (name, characters) in enumerate(zip(
NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES, rows
)):
populated = [
{"slot": slot, "character": character}
for slot, character in enumerate(characters)
if character is not None
]
records.append({
"id": row_id,
"name": name,
"characters": characters,
"populated_characters": populated,
"string_fields": {
(
f"0x{NAME_ENTRY_CHARACTER_PALETTE_BASE:x}/"
f"{NAME_ENTRY_CHARACTER_PALETTE_STRIDE}/{entry['slot']}"
): entry["character"]
for entry in populated
},
})
return records, {
"schema": "name-entry-character-palette",
"palette_base": f"0x{NAME_ENTRY_CHARACTER_PALETTE_BASE:x}",
"reserved_shape": [
len(NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES),
NAME_ENTRY_CHARACTER_PALETTE_STRIDE,
],
"row_names": list(NAME_ENTRY_CHARACTER_PALETTE_ROW_NAMES),
"string_write_count": string_write_count,
"exit_count": exit_count,
"classified_instruction_count": string_write_count + exit_count,
"populated_cells_per_row": [
sum(character is not None for character in row) for row in rows
],
"empty_slots_per_row": [
[
slot
for slot, character in enumerate(row)
if character is None
]
for row in rows
],
"consumer_contract": {
"script": "INPUTNAME.BIN",
"lookup": (
"INPUTNAME selects one of five palette pages, indexes its "
"70-cell row by cursor slot, rejects empty cells, and copies "
"a selected character into the seven-character name buffer."
),
"page_selection": (
"Cursor slots 70..74 select palette rows 0..4."
),
},
}
@cache
def gallery_thumbnail_sheet_assets() -> dict[int, int]:
"""Read CGMODE's enabled thumbnail-sheet assets from INIT2."""
@@ -2990,6 +3435,14 @@ def write_data_index(data_dir: Path) -> None:
"point cost, required/forbidden story flags, and four fixed ingredient slots retain",
"their raw parallel-array and row-table coordinates.",
"",
"AFINIT's dedicated affinity/progression schema exposes its attack and defense",
"element vocabularies, signed effectiveness matrix, eighteen usable item-tuning",
"bonus/cost curves plus a reserved zero row, and three facility progression rows.",
"",
"CTINIT's dedicated name-entry schema exposes INPUTNAME's five 70-cell palette",
"pages (hiragana, katakana, Latin, numerals, and symbols), preserving all reserved",
"empty slots beside the 273 authored characters.",
"",
"Mixed-mode tables preserve the sparse selector id, branch offset, condition strings,",
"scalar fields, cells within preallocated buffers, and length-prefixed footer arrays.",
"STINIT additionally joins confirmed parallel buffers into per-slot `object_placements`",
@@ -3065,6 +3518,10 @@ def main() -> int:
extractor = extract_magic_actions
elif mode == "name" and name == "ILINIT":
extractor = extract_condition_definitions
elif mode == "name" and name == "AFINIT":
extractor = extract_affinity_definitions
elif mode == "name" and name == "CTINIT":
extractor = extract_name_entry_palette
elif mode == "numeric" and name == "CGINIT":
extractor = extract_gallery_definitions
elif mode == "numeric" and name == "ALINIT":

View File

@@ -1134,6 +1134,144 @@ def test_alchemy_recipes() -> None:
)
def test_affinity_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["AFINIT.BIN"])
check(
extract_init.detect_mode(script) == "name",
"AFINIT remains compatible with name-mode auto-detection",
)
records, meta = extract_init.extract_affinity_definitions(script)
by_id = {record["id"]: record for record in records}
check(
len(records) == 13
and meta["string_write_count"] == 27
and meta["footer_array_count"] == 54
and meta["classified_instruction_count"] == 82,
"AFINIT classifies every vocabulary, footer-array, and exit instruction",
)
check(
[entry["id"] for entry in meta["attack_element_names"]]
== [*range(1, 9), *range(11, 18)]
and [entry["id"] for entry in meta["defense_element_names"]]
== list(range(1, 13)),
"AFINIT preserves its sparse attack and defense element vocabularies",
)
check(
by_id[3]["name"] == "火炎"
and by_id[3]["attack_effectiveness"][3]["percent"] == -100
and by_id[3]["attack_effectiveness"][4]["percent"] == 150
and by_id[11]["attack_effectiveness"][1]["percent"] == 1
and by_id[11]["attack_effectiveness"][7]["percent"] == 200,
"AFINIT exposes signed elemental immunities, weaknesses, and resistances",
)
tuning = {
curve["curve_id"]: curve for curve in meta["item_tuning_curves"]
}
check(
len(tuning) == 19
and tuning[1]["level_bonuses"] == [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]
and tuning[9]["level_bonuses"] == list(range(1, 11))
and tuning[18]["level_bonuses"] == list(range(3, 31, 3))
and tuning[18]["level_costs"]
== [10, 25, 45, 70, 100, 140, 190, 250, 320, 400]
and tuning[19]["level_bonuses"] == [0] * 10
and tuning[19]["level_costs"] == [0] * 10
and meta["usable_item_tuning_curve_ids"] == list(range(1, 19))
and meta["reserved_item_tuning_curve_ids"] == [19],
"AFINIT pairs all nineteen item-tuning bonus and point-cost curves",
)
check(
[
row["level_progress_thresholds"]
for row in meta["facility_level_thresholds"]
] == [
[40, 80, 120, 160, 200, 300],
[20, 40, 60, 90, 120, 200],
[20, 50, 100, 150, 200, 400],
],
"AFINIT exposes the tuning, alchemy, and magic progression rows",
)
semantics = extract_init.field_semantics(
records, meta["array_layouts"]
)
check(
len(semantics) == 14
and semantics["0x26a4"] == "defense_element_names"
and semantics["0xab5ba/0"]
== "attack_element_effectiveness_percent.row_0"
and semantics["0xab5ba/240"]
== "attack_element_effectiveness_percent.row_12",
"AFINIT raw vocabulary and matrix rows join to canonical semantics",
)
extract_init.attach_semantic_fields(records, semantics)
check(
by_id[3]["semantic_fields"]["defense_element_names"] == "火炎"
and by_id[3]["semantic_fields"][
"attack_element_effectiveness_percent.row_3"
][3] == -100,
"AFINIT retains raw footer provenance beside signed semantic rows",
)
def test_name_entry_palette() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["CTINIT.BIN"])
check(
extract_init.detect_mode(script) == "name",
"CTINIT remains compatible with name-mode auto-detection",
)
records, meta = extract_init.extract_name_entry_palette(script)
by_id = {record["id"]: record for record in records}
check(
len(records) == 5
and meta["reserved_shape"] == [5, 70]
and meta["string_write_count"] == 273
and meta["classified_instruction_count"] == 274,
"CTINIT classifies all five reserved palette pages and every instruction",
)
check(
meta["row_names"]
== ["hiragana", "katakana", "latin", "numerals", "symbols"]
and meta["populated_cells_per_row"] == [56, 56, 52, 40, 69],
"CTINIT names each page and preserves its authored cell population",
)
check(
by_id[0]["characters"][0] == ""
and by_id[0]["characters"][17] is None
and by_id[1]["characters"][50] == ""
and by_id[2]["characters"][0] == ""
and by_id[2]["characters"][30] == ""
and by_id[3]["characters"][20] == ""
and by_id[3]["characters"][30] == ""
and by_id[4]["characters"][68] == "ω"
and by_id[4]["characters"][69] is None,
"CTINIT retains representative characters and intentional empty slots",
)
semantics = extract_init.field_semantics(records)
check(
len(semantics) == 69
and semantics["0x43dd/70/0"]
== "name_entry_character_palette.column_0"
and semantics["0x43dd/70/68"]
== "name_entry_character_palette.column_68",
"CTINIT raw palette slots join to one canonical table name",
)
extract_init.attach_semantic_fields(records, semantics)
check(
by_id[0]["semantic_fields"][
"name_entry_character_palette.column_0"
] == ""
and by_id[4]["semantic_fields"][
"name_entry_character_palette.column_68"
] == "ω",
"CTINIT retains raw cells beside the semantic palette view",
)
def test_condition_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["ILINIT.BIN"])
@@ -1345,6 +1483,8 @@ if __name__ == "__main__":
test_character_names()
test_gallery_definitions()
test_alchemy_recipes()
test_affinity_definitions()
test_name_entry_palette()
test_condition_definitions()
test_field_semantics()
if FAILS: