Decode ALINIT alchemy recipes
This commit is contained in:
@@ -588,6 +588,17 @@ GALLERY_VARIANT_ORDINAL_ARRAY_BASE = 0x64C11
|
||||
GALLERY_THUMBNAIL_SHEET_CONFIG_BASE = 0x66381
|
||||
GALLERY_THUMBNAIL_SHEET_CONFIG_SPAN = 10
|
||||
|
||||
ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE = 0x156214
|
||||
ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE = 0x1565FC
|
||||
ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE = 0x1569E4
|
||||
ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE = 0x1571B4
|
||||
ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE = 0x157D6C
|
||||
ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE = 0x158154
|
||||
ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE = 0x1590F4
|
||||
ALCHEMY_RECIPE_RECORD_SPAN = 1000
|
||||
ALCHEMY_RECIPE_STORY_FLAG_STRIDE = 2
|
||||
ALCHEMY_RECIPE_INGREDIENT_STRIDE = 4
|
||||
|
||||
RECOVER_CURRENT_ENTITY = 0x152616
|
||||
RECOVER_EFFECTIVE_STATS = 0x4E11B
|
||||
RECOVER_CURRENT_RESOURCES = 0x4E085
|
||||
@@ -2043,6 +2054,209 @@ def extract_gallery_definitions(scr):
|
||||
}
|
||||
|
||||
|
||||
def extract_alchemy_recipes(scr):
|
||||
"""Extract ALINIT's sparse alchemy recipe registry."""
|
||||
scalar_arrays = {
|
||||
ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE: "output_item_id",
|
||||
ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE: "minimum_alchemy_level",
|
||||
ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE: "point_cost",
|
||||
}
|
||||
row_tables = {
|
||||
ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE: (
|
||||
ALCHEMY_RECIPE_STORY_FLAG_STRIDE,
|
||||
"required_story_flag_id",
|
||||
),
|
||||
ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE: (
|
||||
ALCHEMY_RECIPE_STORY_FLAG_STRIDE,
|
||||
"forbidden_story_flag_id",
|
||||
),
|
||||
ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE: (
|
||||
ALCHEMY_RECIPE_INGREDIENT_STRIDE,
|
||||
"ingredient_item_id",
|
||||
),
|
||||
ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE: (
|
||||
ALCHEMY_RECIPE_INGREDIENT_STRIDE,
|
||||
"ingredient_quantity",
|
||||
),
|
||||
}
|
||||
records_by_id: dict[int, dict] = {}
|
||||
static_write_count = 0
|
||||
classified_write_count = 0
|
||||
|
||||
def record_for(record_id: int) -> dict:
|
||||
if not (1 <= record_id < ALCHEMY_RECIPE_RECORD_SPAN):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: recipe id {record_id} outside reserved span"
|
||||
)
|
||||
return records_by_id.setdefault(record_id, {
|
||||
"id": record_id,
|
||||
"fields": {},
|
||||
"record_fields": {},
|
||||
})
|
||||
|
||||
for ins in scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None:
|
||||
if sys4load.display_label(ins.opcode) != "exit":
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified instruction at 0x{ins.offset:x}"
|
||||
)
|
||||
continue
|
||||
static_write_count += 1
|
||||
destination, value = write
|
||||
if not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: non-static recipe value at 0x{ins.offset:x}"
|
||||
)
|
||||
|
||||
for base in scalar_arrays:
|
||||
record_id = destination - base
|
||||
if 1 <= record_id < ALCHEMY_RECIPE_RECORD_SPAN:
|
||||
_store_unique(
|
||||
record_for(record_id)["fields"],
|
||||
f"0x{base:x}",
|
||||
value,
|
||||
record_id,
|
||||
)
|
||||
classified_write_count += 1
|
||||
break
|
||||
else:
|
||||
for base, (stride, _) in row_tables.items():
|
||||
relative = destination - base
|
||||
if 0 <= relative < ALCHEMY_RECIPE_RECORD_SPAN * stride:
|
||||
record_id, column = divmod(relative, stride)
|
||||
record = record_for(record_id)
|
||||
_store_unique(
|
||||
record["record_fields"],
|
||||
f"0x{base:x}/{stride}/{column}",
|
||||
value,
|
||||
record_id,
|
||||
)
|
||||
classified_write_count += 1
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified recipe write "
|
||||
f"0x{destination:x} at 0x{ins.offset:x}"
|
||||
)
|
||||
|
||||
item_records, _ = extract_name(sys4load.load(resolve("ITINIT")))
|
||||
item_names = {record["id"]: record["name"] for record in item_records}
|
||||
records = [records_by_id[record_id] for record_id in sorted(records_by_id)]
|
||||
required_scalar_keys = {f"0x{base:x}" for base in scalar_arrays}
|
||||
output_key = f"0x{ALCHEMY_RECIPE_OUTPUT_ITEM_ARRAY_BASE:x}"
|
||||
level_key = f"0x{ALCHEMY_RECIPE_MINIMUM_LEVEL_ARRAY_BASE:x}"
|
||||
cost_key = f"0x{ALCHEMY_RECIPE_POINT_COST_ARRAY_BASE:x}"
|
||||
ingredient_reference_count = 0
|
||||
joined_ingredient_reference_count = 0
|
||||
|
||||
for record in records:
|
||||
if set(record["fields"]) != required_scalar_keys:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: recipe id {record['id']} has incomplete scalars"
|
||||
)
|
||||
output_item_id = record["fields"][output_key]
|
||||
if output_item_id not in item_names:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: recipe id {record['id']} has unknown "
|
||||
f"output item {output_item_id}"
|
||||
)
|
||||
record.update({
|
||||
"output_item_id": output_item_id,
|
||||
"output_item_name": item_names[output_item_id],
|
||||
"minimum_alchemy_level": record["fields"][level_key],
|
||||
"point_cost": record["fields"][cost_key],
|
||||
})
|
||||
for kind, base in (
|
||||
("required", ALCHEMY_RECIPE_REQUIRED_FLAGS_BASE),
|
||||
("forbidden", ALCHEMY_RECIPE_FORBIDDEN_FLAGS_BASE),
|
||||
):
|
||||
values = [
|
||||
record["record_fields"][f"0x{base:x}/2/{column}"]
|
||||
for column in range(ALCHEMY_RECIPE_STORY_FLAG_STRIDE)
|
||||
if f"0x{base:x}/2/{column}" in record["record_fields"]
|
||||
]
|
||||
record[f"{kind}_story_flag_ids"] = values
|
||||
|
||||
ingredients = []
|
||||
for slot in range(ALCHEMY_RECIPE_INGREDIENT_STRIDE):
|
||||
item_key = (
|
||||
f"0x{ALCHEMY_RECIPE_INGREDIENT_ITEM_IDS_BASE:x}/"
|
||||
f"{ALCHEMY_RECIPE_INGREDIENT_STRIDE}/{slot}"
|
||||
)
|
||||
quantity_key = (
|
||||
f"0x{ALCHEMY_RECIPE_INGREDIENT_QUANTITIES_BASE:x}/"
|
||||
f"{ALCHEMY_RECIPE_INGREDIENT_STRIDE}/{slot}"
|
||||
)
|
||||
has_item = item_key in record["record_fields"]
|
||||
has_quantity = quantity_key in record["record_fields"]
|
||||
if has_item != has_quantity:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: recipe id {record['id']} has an "
|
||||
f"unpaired ingredient slot {slot}"
|
||||
)
|
||||
if not has_item:
|
||||
continue
|
||||
ingredient_item_id = record["record_fields"][item_key]
|
||||
if ingredient_item_id not in item_names:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: recipe id {record['id']} has unknown "
|
||||
f"ingredient item {ingredient_item_id} in slot {slot}"
|
||||
)
|
||||
ingredient_reference_count += 1
|
||||
ingredient = {
|
||||
"slot": slot,
|
||||
"item_id": ingredient_item_id,
|
||||
"item_name": item_names[ingredient_item_id],
|
||||
"quantity": record["record_fields"][quantity_key],
|
||||
}
|
||||
joined_ingredient_reference_count += 1
|
||||
ingredients.append(ingredient)
|
||||
record["ingredients"] = ingredients
|
||||
|
||||
record_columns = sorted(
|
||||
{
|
||||
key
|
||||
for record in records
|
||||
for key in record.get("record_fields", {})
|
||||
},
|
||||
key=lambda key: tuple(int(part, 0) for part in key.split("/")),
|
||||
)
|
||||
populated_ids = set(records_by_id)
|
||||
return records, {
|
||||
"record_span": ALCHEMY_RECIPE_RECORD_SPAN,
|
||||
"populated_record_ids": sorted(populated_ids),
|
||||
"populated_id_range": [min(populated_ids), max(populated_ids)],
|
||||
"scalar_array_bases": {
|
||||
role: f"0x{base:x}" for base, role in scalar_arrays.items()
|
||||
},
|
||||
"row_tables": {
|
||||
role: {"base": f"0x{base:x}", "stride": stride}
|
||||
for base, (stride, role) in row_tables.items()
|
||||
},
|
||||
"record_field_columns": record_columns,
|
||||
"static_write_count": static_write_count,
|
||||
"classified_static_write_count": classified_write_count,
|
||||
"output_item_join_count": sum(
|
||||
record["output_item_id"] in item_names for record in records
|
||||
),
|
||||
"ingredient_reference_count": ingredient_reference_count,
|
||||
"joined_ingredient_reference_count": joined_ingredient_reference_count,
|
||||
"consumer_contract": {
|
||||
"availability": (
|
||||
"ALCHEMY lists a recipe only when its minimum level, required "
|
||||
"and forbidden story flags, point-capacity threshold, and "
|
||||
"owned ingredient quantities pass."
|
||||
),
|
||||
"synthesis": (
|
||||
"ALCHEMY removes each populated ingredient quantity, adds one "
|
||||
"output item, deducts point_cost from the shared spendable "
|
||||
"point pool, and advances alchemy-level progress."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@cache
|
||||
def gallery_thumbnail_sheet_assets() -> dict[int, int]:
|
||||
"""Read CGMODE's enabled thumbnail-sheet assets from INIT2."""
|
||||
@@ -2771,6 +2985,11 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"atlases, its atlas slot and variant ordinal, and the optional 112-by-84 preview",
|
||||
"used by SAVE and SELSTAGE. Raw global-array provenance remains beside these joins.",
|
||||
"",
|
||||
"ALINIT's dedicated alchemy schema exposes 107 sparse recipes in a reserved 1,000-row",
|
||||
"layout. Output and ingredient item ids join to ITINIT names; minimum alchemy level,",
|
||||
"point cost, required/forbidden story flags, and four fixed ingredient slots retain",
|
||||
"their raw parallel-array and row-table coordinates.",
|
||||
"",
|
||||
"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`",
|
||||
@@ -2848,6 +3067,8 @@ def main() -> int:
|
||||
extractor = extract_condition_definitions
|
||||
elif mode == "numeric" and name == "CGINIT":
|
||||
extractor = extract_gallery_definitions
|
||||
elif mode == "numeric" and name == "ALINIT":
|
||||
extractor = extract_alchemy_recipes
|
||||
recs, meta = extractor(scr)
|
||||
if mode == "name" and name in MESSAGE_TABLES:
|
||||
message_name = MESSAGE_TABLES[name]
|
||||
|
||||
@@ -1034,6 +1034,106 @@ def test_gallery_definitions() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_alchemy_recipes() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["ALINIT.BIN"])
|
||||
check(
|
||||
extract_init.detect_mode(script) == "numeric",
|
||||
"ALINIT remains compatible with numeric-mode auto-detection",
|
||||
)
|
||||
records, meta = extract_init.extract_alchemy_recipes(script)
|
||||
by_id = {record["id"]: record for record in records}
|
||||
check(
|
||||
len(records) == 107
|
||||
and meta["record_span"] == 1000
|
||||
and meta["populated_id_range"] == [1, 467],
|
||||
"ALINIT extracts 107 sparse recipes from its reserved 1,000 ids",
|
||||
)
|
||||
check(
|
||||
meta["static_write_count"] == 914
|
||||
and meta["classified_static_write_count"] == 914
|
||||
and len(meta["record_field_columns"]) == 10,
|
||||
"ALINIT classifies every scalar and populated row-table write",
|
||||
)
|
||||
check(
|
||||
meta["output_item_join_count"] == 107
|
||||
and meta["ingredient_reference_count"] == 286
|
||||
and meta["joined_ingredient_reference_count"] == 286,
|
||||
"ALINIT resolves every output and ingredient reference through ITINIT",
|
||||
)
|
||||
check(
|
||||
by_id[1]["output_item_id"] == 2
|
||||
and by_id[1]["output_item_name"] == "白銀の鍵"
|
||||
and by_id[1]["minimum_alchemy_level"] == 2
|
||||
and by_id[1]["point_cost"] == 30
|
||||
and by_id[1]["required_story_flag_ids"] == [1902]
|
||||
and by_id[1]["forbidden_story_flag_ids"] == [1903],
|
||||
"ALINIT recipe 1 exposes its output, gating, and point cost",
|
||||
)
|
||||
check(
|
||||
[
|
||||
(ingredient["slot"], ingredient["item_id"], ingredient["quantity"])
|
||||
for ingredient in by_id[1]["ingredients"]
|
||||
] == [(1, 608, 1), (2, 625, 1)]
|
||||
and by_id[14]["output_item_name"] == "シルバーコイン"
|
||||
and [
|
||||
(ingredient["slot"], ingredient["item_id"], ingredient["quantity"])
|
||||
for ingredient in by_id[14]["ingredients"]
|
||||
] == [(0, 91, 5)],
|
||||
"ALINIT preserves sparse ingredient slots and their paired quantities",
|
||||
)
|
||||
|
||||
semantics = extract_init.field_semantics(records)
|
||||
check(
|
||||
semantics == {
|
||||
"0x156214": "alchemy_recipe_output_item_ids",
|
||||
"0x1565fc": "alchemy_recipe_minimum_levels",
|
||||
"0x1569e4/2/0": (
|
||||
"alchemy_recipe_required_story_flags.required_flag_1"
|
||||
),
|
||||
"0x1571b4/2/0": (
|
||||
"alchemy_recipe_forbidden_story_flags.forbidden_flag_1"
|
||||
),
|
||||
"0x157d6c": "alchemy_recipe_point_costs",
|
||||
"0x158154/4/0": (
|
||||
"alchemy_recipe_ingredient_item_ids.ingredient_1"
|
||||
),
|
||||
"0x158154/4/1": (
|
||||
"alchemy_recipe_ingredient_item_ids.ingredient_2"
|
||||
),
|
||||
"0x158154/4/2": (
|
||||
"alchemy_recipe_ingredient_item_ids.ingredient_3"
|
||||
),
|
||||
"0x158154/4/3": (
|
||||
"alchemy_recipe_ingredient_item_ids.ingredient_4"
|
||||
),
|
||||
"0x1590f4/4/0": (
|
||||
"alchemy_recipe_ingredient_quantities.ingredient_1"
|
||||
),
|
||||
"0x1590f4/4/1": (
|
||||
"alchemy_recipe_ingredient_quantities.ingredient_2"
|
||||
),
|
||||
"0x1590f4/4/2": (
|
||||
"alchemy_recipe_ingredient_quantities.ingredient_3"
|
||||
),
|
||||
"0x1590f4/4/3": (
|
||||
"alchemy_recipe_ingredient_quantities.ingredient_4"
|
||||
),
|
||||
},
|
||||
"ALINIT raw arrays join to their canonical semantic names",
|
||||
)
|
||||
extract_init.attach_semantic_fields(records, semantics)
|
||||
check(
|
||||
by_id[1]["semantic_fields"][
|
||||
"alchemy_recipe_required_story_flags.required_flag_1"
|
||||
] == 1902
|
||||
and by_id[14]["semantic_fields"][
|
||||
"alchemy_recipe_ingredient_quantities.ingredient_1"
|
||||
] == 5,
|
||||
"ALINIT retains raw cells beside one semantic recipe view",
|
||||
)
|
||||
|
||||
|
||||
def test_condition_definitions() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["ILINIT.BIN"])
|
||||
@@ -1244,6 +1344,7 @@ if __name__ == "__main__":
|
||||
test_message_join()
|
||||
test_character_names()
|
||||
test_gallery_definitions()
|
||||
test_alchemy_recipes()
|
||||
test_condition_definitions()
|
||||
test_field_semantics()
|
||||
if FAILS:
|
||||
|
||||
Reference in New Issue
Block a user