Decode CDINIT2 card effects

This commit is contained in:
gamer147
2026-07-23 22:58:01 -04:00
parent 21b7751085
commit a74a1469ce
12 changed files with 835 additions and 28 deletions

View File

@@ -38,6 +38,10 @@ generation lists populate a parallel card-id array and three-column weight
schedule. FIELD filters the joined CDINIT2 definitions by story flags and uses
the current stage turn to grow each candidate's weighted-selection share.
CDINIT2 is a special name-mode registry: 81 cards occupy one contiguous
100-row definition block with story gates, item/event/point rewards, ranged
HP/SP/FS and spirit effects, conditions, warp behavior, and visual assets.
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.
@@ -717,9 +721,39 @@ CARD_GENERATION_WEIGHT_STRIDE = 3
CARD_GENERATION_CARD_ID_BASE = 0x1525B2
CARD_GENERATION_SCAN_CAPACITY = 100
CARD_GENERATION_CLEAR_COUNT = 50
CARD_DEFINITION_NAME_BASE = 0x4315
CARD_DEFINITION_RESULT_BASE = 0x4379
CARD_REQUIRED_STORY_FLAG_BASE = 0x151A5D
CARD_FORBIDDEN_STORY_FLAG_BASE = 0x151B89
CARD_STORY_FLAG_STRIDE = 3
CARD_DEFINITION_COUNT = 81
CARD_DEFINITION_CAPACITY = 100
CARD_DEFINITION_ARRAYS = {
"type_id": (0x1519F9, 1),
"required_story_flag_ids": (0x151A5D, 3),
"forbidden_story_flag_ids": (0x151B89, 3),
"awarded_item_id": (0x151CB5, 1),
"event_story_flag_id": (0x151D19, 1),
"stage_clear_point_bonus": (0x151D7D, 1),
"minimum_resource_recovery": (0x151DE1, 3),
"maximum_resource_recovery": (0x151F0D, 3),
"minimum_spirit_recovery": (0x152039, 1),
"maximum_spirit_recovery": (0x15209D, 1),
"minimum_resource_damage": (0x152101, 3),
"maximum_resource_damage": (0x15222D, 3),
"condition_id": (0x152359, 1),
"condition_level": (0x1523BD, 1),
"visual_asset_id": (0x152421, 1),
}
CARD_TYPE_NAMES = {
1: "story_event",
2: "item_award",
3: "stage_clear_point_bonus",
4: "resource_recovery",
5: "trap",
6: "random_warp",
}
CARD_RESOURCE_COLUMNS = ("hp", "sp", "fs")
def resolve(name: str) -> Path:
@@ -4238,6 +4272,398 @@ def extract_training_actions(scr):
}
def extract_card_definitions(scr):
"""Extract CDINIT2's complete 81-card definition and effect registry."""
string_cells = {}
numeric_cells = {
field_name: {}
for field_name in CARD_DEFINITION_ARRAYS
}
classified_offsets = set()
string_write_count = 0
static_write_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]
if (
CARD_DEFINITION_NAME_BASE
< destination
<= CARD_DEFINITION_NAME_BASE + CARD_DEFINITION_COUNT
):
card_id = destination - CARD_DEFINITION_NAME_BASE
field_name = "name"
elif (
CARD_DEFINITION_RESULT_BASE
< destination
<= CARD_DEFINITION_RESULT_BASE + CARD_DEFINITION_COUNT
):
card_id = destination - CARD_DEFINITION_RESULT_BASE
field_name = "result_message"
else:
raise ValueError(
f"{scr.path.name}: unclassified string destination "
f"0x{destination:x} at 0x{ins.offset:x}"
)
_store_unique(
string_cells,
(card_id, field_name),
scr.strings[ins.args[1][1]][0],
card_id,
)
classified_offsets.add(ins.offset)
string_write_count += 1
continue
write = _static_global_write(ins)
if write is not None:
static_write_count += 1
destination, value = write
if not isinstance(value, int):
raise ValueError(
f"{scr.path.name}: non-static card value "
f"at 0x{ins.offset:x}"
)
for field_name, (base, stride) in (
CARD_DEFINITION_ARRAYS.items()
):
index = destination - base
if not (
0 <= index < CARD_DEFINITION_CAPACITY * stride
):
continue
card_id, column = divmod(index, stride)
if not 1 <= card_id <= CARD_DEFINITION_COUNT:
raise ValueError(
f"{scr.path.name}: write to reserved card row "
f"{card_id} at 0x{ins.offset:x}"
)
_store_unique(
numeric_cells[field_name],
(card_id, column),
value,
card_id,
)
classified_offsets.add(ins.offset)
break
else:
raise ValueError(
f"{scr.path.name}: unclassified numeric destination "
f"0x{destination:x} at 0x{ins.offset:x}"
)
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)
)
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"))
)
event_dispatch = {
record["id"]: record for record in dispatch_records
}
condition_records, _ = extract_condition_definitions(
sys4load.load(resolve("ILINIT"))
)
condition_names = {
record["id"]: record["condition"]
for record in condition_records
}
asset_names = callscript_names()
def values(field_name: str, card_id: int) -> list[int]:
_, stride = CARD_DEFINITION_ARRAYS[field_name]
return [
numeric_cells[field_name].get((card_id, column), 0)
for column in range(stride)
]
def scalar(field_name: str, card_id: int) -> int:
return values(field_name, card_id)[0]
records = []
for card_id in range(1, CARD_DEFINITION_COUNT + 1):
raw_fields = {}
raw_record_fields = {}
for field_name, (base, stride) in (
CARD_DEFINITION_ARRAYS.items()
):
for column in range(stride):
cell = (card_id, column)
if cell not in numeric_cells[field_name]:
continue
value = numeric_cells[field_name][cell]
if stride == 1:
raw_fields[f"0x{base:x}"] = value
else:
raw_record_fields[
f"0x{base:x}/{stride}/{column}"
] = value
required_flags = values(
"required_story_flag_ids", card_id
)
forbidden_flags = values(
"forbidden_story_flag_ids", card_id
)
eligibility = {
"required_story_flag_ids": [
value for value in required_flags[:2] if value
],
"forbidden_story_flag_ids": [
value for value in forbidden_flags[:2] if value
],
}
if required_flags[2]:
eligibility["ignored_required_story_flag_ids"] = [
required_flags[2]
]
effects = {}
awarded_item_id = scalar("awarded_item_id", card_id)
if awarded_item_id:
effects.update({
"awarded_item_id": awarded_item_id,
"awarded_item_name": item_names.get(
awarded_item_id, ""
),
})
event_id = scalar("event_story_flag_id", card_id)
if event_id:
dispatch = event_dispatch.get(event_id, {})
effects.update({
"event_story_flag_id": event_id,
"event_script_resource_id": dispatch.get(
"script_resource_id", 0
),
"event_script_name": dispatch.get("script_name", ""),
})
point_bonus = scalar("stage_clear_point_bonus", card_id)
if point_bonus:
effects["stage_clear_spendable_point_bonus"] = point_bonus
for prefix, minimum_field, maximum_field in (
(
"resource_recovery",
"minimum_resource_recovery",
"maximum_resource_recovery",
),
(
"resource_damage",
"minimum_resource_damage",
"maximum_resource_damage",
),
):
minimums = values(minimum_field, card_id)
maximums = values(maximum_field, card_id)
ranges = {
resource: {
"minimum": minimums[column],
"maximum_exclusive": maximums[column],
}
for column, resource in enumerate(
CARD_RESOURCE_COLUMNS
)
if minimums[column] or maximums[column]
}
if ranges:
effects[prefix] = ranges
minimum_spirit = scalar(
"minimum_spirit_recovery", card_id
)
maximum_spirit = scalar(
"maximum_spirit_recovery", card_id
)
if minimum_spirit or maximum_spirit:
effects["spirit_recovery"] = {
"minimum": minimum_spirit,
"maximum_exclusive": maximum_spirit,
}
condition_id = scalar("condition_id", card_id)
if condition_id:
effects.update({
"condition_id": condition_id,
"condition": condition_names.get(condition_id, ""),
"condition_level": scalar(
"condition_level", card_id
),
})
type_id = scalar("type_id", card_id)
if type_id == 6:
effects["random_warp"] = True
visual_asset_id = scalar("visual_asset_id", card_id)
records.append({
"id": card_id,
"name": string_cells[(card_id, "name")],
"result_message": string_cells[
(card_id, "result_message")
],
"type_id": type_id,
"type": CARD_TYPE_NAMES.get(type_id, ""),
"eligibility": eligibility,
"effects": effects,
"visual_asset_id": visual_asset_id,
"visual_asset_name": asset_names.get(visual_asset_id, ""),
"fields": raw_fields,
"record_fields": raw_record_fields,
"string_fields": {
f"0x{CARD_DEFINITION_NAME_BASE:x}": (
string_cells[(card_id, "name")]
),
f"0x{CARD_DEFINITION_RESULT_BASE:x}": (
string_cells[(card_id, "result_message")]
),
},
})
array_layouts = {
f"0x{base:x}": {"stride": stride}
for base, stride in CARD_DEFINITION_ARRAYS.values()
if stride > 1
}
schema_field_semantics = {
f"0x{CARD_DEFINITION_NAME_BASE:x}": "card_definition_names",
f"0x{CARD_DEFINITION_RESULT_BASE:x}": (
"card_definition_result_messages"
),
}
semantic_names = {
"type_id": "card_definition_type_ids",
"required_story_flag_ids": (
"card_definition_required_story_flag_ids"
),
"forbidden_story_flag_ids": (
"card_definition_forbidden_story_flag_ids"
),
"awarded_item_id": "card_definition_awarded_item_ids",
"event_story_flag_id": (
"card_definition_event_story_flag_ids"
),
"stage_clear_point_bonus": (
"card_definition_stage_clear_point_bonuses"
),
"minimum_resource_recovery": (
"card_definition_minimum_resource_recovery"
),
"maximum_resource_recovery": (
"card_definition_maximum_resource_recovery"
),
"minimum_spirit_recovery": (
"card_definition_minimum_spirit_recovery"
),
"maximum_spirit_recovery": (
"card_definition_maximum_spirit_recovery"
),
"minimum_resource_damage": (
"card_definition_minimum_resource_damage"
),
"maximum_resource_damage": (
"card_definition_maximum_resource_damage"
),
"condition_id": "card_definition_condition_ids",
"condition_level": "card_definition_condition_levels",
"visual_asset_id": "card_definition_visual_asset_ids",
}
for field_name, (base, stride) in (
CARD_DEFINITION_ARRAYS.items()
):
key = f"0x{base:x}"
if stride == 1:
schema_field_semantics[key] = semantic_names[field_name]
continue
columns = (
("required_flag_1", "required_flag_2",
"engine_dead_required_flag_3")
if field_name == "required_story_flag_ids"
else
("forbidden_flag_1", "forbidden_flag_2",
"reserved_forbidden_flag_3")
if field_name == "forbidden_story_flag_ids"
else CARD_RESOURCE_COLUMNS
)
for column, column_name in enumerate(columns):
schema_field_semantics[
f"{key}/{stride}/{column}"
] = f"{semantic_names[field_name]}.{column_name}"
return records, {
"schema": "card-definitions",
"reserved_record_count": CARD_DEFINITION_CAPACITY,
"authored_record_count": CARD_DEFINITION_COUNT,
"numeric_block_start": "0x1519f9",
"numeric_block_end_exclusive": "0x152485",
"array_layouts": array_layouts,
"schema_field_semantics": schema_field_semantics,
"semantic_array_names": {
f"0x{base:x}": semantic_names[field_name]
for field_name, (base, _) in (
CARD_DEFINITION_ARRAYS.items()
)
},
"string_write_count": string_write_count,
"static_write_count": static_write_count,
"classified_instruction_count": len(classified_offsets),
"type_counts": dict(sorted(collections.Counter(
record["type"] for record in records
).items())),
"awarded_item_join_count": sum(
bool(record["effects"].get("awarded_item_name"))
for record in records
),
"event_dispatch_join_count": sum(
bool(record["effects"].get("event_script_name"))
for record in records
),
"condition_join_count": sum(
bool(record["effects"].get("condition"))
for record in records
),
"visual_asset_join_count": sum(
bool(record["visual_asset_name"]) for record in records
),
"ignored_required_story_flag_count": sum(
bool(record["eligibility"].get(
"ignored_required_story_flag_ids"
))
for record in records
),
"consumer_contract": {
"FIELD.BIN": (
"filters required/forbidden story flags, applies randomized "
"HP/SP/FS recovery or damage, spirit recovery, item awards, "
"stage-clear spendable-point bonuses, conditions, event "
"dispatch, or random warp by card type, draws the visual "
"asset, and presents the name/result text"
),
"STAGECLEAR.BIN": (
"adds FIELD's accumulated card point bonus to the ordinary "
"stage-clear award before increasing shared_spendable_points"
),
},
}
def extract_card_generation_lists(scr):
"""Extract CDINIT's selector-dispatched weighted card candidate lists."""
instructions = scr.instructions
@@ -5274,6 +5700,11 @@ def write_data_index(data_dir: Path) -> None:
"gates, and links the seven used selectors back to STINIT type-28 stage objects.",
"FIELD's turn-scaled weight formula and 100-slot selection scan remain explicit.",
"",
"CDINIT2's dedicated card-definition schema exposes all 81 cards in the reserved",
"100-row registry. Names, result messages, effective and engine-dead story gates,",
"six effect types, and raw array coordinates remain together; item, event, condition,",
"and visual ids join through ITINIT, SCINIT, ILINIT, and SYS4INI resources.",
"",
"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",
@@ -5379,6 +5810,8 @@ def main() -> int:
extractor = extract_terrain_definitions
elif mode == "name" and name == "TRINIT":
extractor = extract_training_actions
elif mode == "name" and name == "CDINIT2":
extractor = extract_card_definitions
elif mode == "numeric" and name == "CDINIT":
extractor = extract_card_generation_lists
elif mode == "numeric" and name == "SPINIT":

View File

@@ -469,6 +469,31 @@ def profile_card_generation_lists(data: dict) -> dict:
}
def profile_card_definitions(data: dict) -> dict:
"""Summarize CDINIT2's card categories, effects, and joins."""
if data.get("schema") != "card-definitions":
return {}
records = data.get("records", [])
return {
"card_count": len(records),
"reserved_record_count": data.get("reserved_record_count", 0),
"type_counts": data.get("type_counts", {}),
"awarded_item_join_count": data.get(
"awarded_item_join_count", 0
),
"event_dispatch_join_count": data.get(
"event_dispatch_join_count", 0
),
"condition_join_count": data.get("condition_join_count", 0),
"visual_asset_join_count": data.get(
"visual_asset_join_count", 0
),
"ignored_required_story_flag_count": data.get(
"ignored_required_story_flag_count", 0
),
}
def profile_messages(data: dict) -> dict:
"""Summarize the joined player-facing message evidence."""
records = data["records"]
@@ -596,7 +621,22 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
f"- records: {data['record_count']}",
f"- populated fields: {len(rows)}",
]
if card_profile := profile_card_generation_lists(data):
if definition_profile := profile_card_definitions(data):
lines.extend([
f"- card definitions: {definition_profile['card_count']}/"
f"{definition_profile['reserved_record_count']} rows",
f"- types: {definition_profile['type_counts']}",
f"- joined effects: "
f"{definition_profile['awarded_item_join_count']} items, "
f"{definition_profile['event_dispatch_join_count']} events, "
f"{definition_profile['condition_join_count']} conditions",
f"- visual assets resolved: "
f"{definition_profile['visual_asset_join_count']}/"
f"{definition_profile['card_count']}",
f"- engine-dead third required flags: "
f"{definition_profile['ignored_required_story_flag_count']}",
])
elif card_profile := profile_card_generation_lists(data):
lines.extend([
f"- card-generation lists: {card_profile['list_count']}",
f"- weighted entries: {card_profile['entry_count']} across "
@@ -817,6 +857,7 @@ def main() -> int:
"h_scene_gallery_profile": profile_h_scene_gallery(data),
"training_action_profile": profile_training_actions(data),
"card_generation_profile": profile_card_generation_lists(data),
"card_definition_profile": profile_card_definitions(data),
"columns": sorted(rows, key=lambda row: (
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
)),

View File

@@ -1737,6 +1737,79 @@ def test_card_generation_lists() -> None:
)
def test_card_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["CDINIT2.BIN"])
records, meta = extract_init.extract_card_definitions(script)
by_id = {record["id"]: record for record in records}
check(
len(records) == 81
and meta["reserved_record_count"] == 100
and meta["numeric_block_start"] == "0x1519f9"
and meta["numeric_block_end_exclusive"] == "0x152485",
"CDINIT2 exposes 81 cards in one contiguous reserved 100-row block",
)
check(
meta["string_write_count"] == 162
and meta["static_write_count"] == 395
and meta["classified_instruction_count"] == 558,
"CDINIT2 classifies every name, result, numeric, and exit instruction",
)
check(
meta["type_counts"]
== {
"item_award": 24,
"random_warp": 1,
"resource_recovery": 6,
"stage_clear_point_bonus": 3,
"story_event": 40,
"trap": 7,
},
"CDINIT2 partitions every card into its FIELD behavior type",
)
check(
by_id[1]["effects"]["resource_recovery"]["hp"]
== {"minimum": 2, "maximum_exclusive": 5}
and by_id[4]["effects"]["spirit_recovery"]
== {"minimum": 2, "maximum_exclusive": 5}
and by_id[7]["effects"]["stage_clear_spendable_point_bonus"]
== 5,
"CDINIT2 exposes recovery ranges and stage-clear point bonuses",
)
check(
by_id[10]["effects"]["event_story_flag_id"] == 490
and by_id[10]["effects"]["event_script_name"] == "SC0490.BIN"
and by_id[58]["effects"]["awarded_item_name"]
== "ブロンズコイン",
"CDINIT2 resolves event and item rewards through SCINIT and ITINIT",
)
check(
by_id[52]["effects"]["condition"] == "paralysis"
and by_id[52]["effects"]["condition_level"] == 1
and by_id[55]["effects"]["resource_damage"]["sp"]
== {"minimum": 10, "maximum_exclusive": 10}
and by_id[57]["effects"]["random_warp"],
"CDINIT2 decodes trap conditions, fixed damage, and random warp",
)
check(
by_id[18]["eligibility"]["required_story_flag_ids"]
== [901, 863]
and by_id[18]["eligibility"][
"ignored_required_story_flag_ids"
] == [51]
and meta["ignored_required_story_flag_count"] == 18,
"CDINIT2 separates FIELD's live gates from the engine-dead third flag",
)
check(
meta["awarded_item_join_count"] == 24
and meta["event_dispatch_join_count"] == 40
and meta["condition_join_count"] == 3
and meta["visual_asset_join_count"] == 81
and by_id[81]["visual_asset_name"] == "MVS107.AGF",
"CDINIT2 resolves every item, event, condition, and visual join",
)
def test_condition_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["ILINIT.BIN"])
@@ -1955,6 +2028,7 @@ if __name__ == "__main__":
test_h_scene_gallery()
test_training_actions()
test_card_generation_lists()
test_card_definitions()
test_map_terrain_atlas()
test_condition_definitions()
test_field_semantics()

View File

@@ -94,6 +94,15 @@ def test_load_and_lint():
== "engine_dead_required_flag_3"
and entries[0x204f4]["name"] == "current_stage_turn",
"CDINIT/FIELD card-generation state is curated")
check(entries[0x1519f9]["name"] == "card_definition_type_ids"
and entries[0x151de1]["columns"]["2"] == "fs"
and entries[0x15222d]["name"]
== "card_definition_maximum_resource_damage"
and entries[0x152359]["name"] == "card_definition_condition_ids"
and entries[0x152421]["name"] == "card_definition_visual_asset_ids"
and entries[0x4dfbb]["name"]
== "stage_card_spendable_point_bonus",
"CDINIT2/FIELD card-effect state is curated")
check(entries[0x15a095]["name"] == "information_tab_index"
and entries[0x15a096]["name"] == "information_message_handled"
and entries[0x15a097]["name"]

View File

@@ -196,7 +196,7 @@ def main() -> int:
{"stage_ids": [32, 33, 34]},
{"stage_ids": [35, 36]},
],
"records": [],
"records": [{}, {}, {}],
}
map_summary = profile.profile_map_atlas(map_fixture)
assert map_summary["row_stride"] == 53
@@ -320,6 +320,36 @@ def main() -> int:
assert "- card-generation lists: 2" in rendered_cards
assert "- runtime scan/clear prefix: 100/50 slots" in rendered_cards
definition_fixture = {
"table": "CARDDEFS",
"mode": "name",
"schema": "card-definitions",
"record_count": 3,
"reserved_record_count": 100,
"type_counts": {
"item_award": 1,
"resource_recovery": 1,
"trap": 1,
},
"awarded_item_join_count": 1,
"event_dispatch_join_count": 0,
"condition_join_count": 1,
"visual_asset_join_count": 3,
"ignored_required_story_flag_count": 1,
"records": [{}, {}, {}],
}
definition_summary = profile.profile_card_definitions(
definition_fixture
)
assert definition_summary["card_count"] == 3
assert definition_summary["reserved_record_count"] == 100
assert definition_summary["visual_asset_join_count"] == 3
rendered_definitions = profile.render_markdown(
definition_fixture, [], 40
)
assert "- card definitions: 3/100 rows" in rendered_definitions
assert "- engine-dead third required flags: 1" in rendered_definitions
training_fixture = {
"table": "TRAINING",
"mode": "name",