Decode CDINIT card generation lists
This commit is contained in:
@@ -33,6 +33,11 @@ TRINIT is a special name-mode registry: 21 training/sexual-magic actions each
|
||||
own six display-text slots and a contiguous block of eligibility, cost, effect,
|
||||
award, and ten-slot event arrays consumed by TRAIN and restored by GAMESTART.
|
||||
|
||||
CDINIT is a special numeric-mode registry: nine selector-dispatched card
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -706,6 +711,16 @@ TRAINING_ACTION_ARRAYS = {
|
||||
"event_story_flag_ids": (0x156015, 10),
|
||||
}
|
||||
|
||||
CARD_GENERATION_SELECTOR = 0x152485
|
||||
CARD_GENERATION_WEIGHT_BASE = 0x152486
|
||||
CARD_GENERATION_WEIGHT_STRIDE = 3
|
||||
CARD_GENERATION_CARD_ID_BASE = 0x1525B2
|
||||
CARD_GENERATION_SCAN_CAPACITY = 100
|
||||
CARD_GENERATION_CLEAR_COUNT = 50
|
||||
CARD_REQUIRED_STORY_FLAG_BASE = 0x151A5D
|
||||
CARD_FORBIDDEN_STORY_FLAG_BASE = 0x151B89
|
||||
CARD_STORY_FLAG_STRIDE = 3
|
||||
|
||||
|
||||
def resolve(name: str) -> Path:
|
||||
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
|
||||
@@ -4223,6 +4238,417 @@ def extract_training_actions(scr):
|
||||
}
|
||||
|
||||
|
||||
def extract_card_generation_lists(scr):
|
||||
"""Extract CDINIT's selector-dispatched weighted card candidate lists."""
|
||||
instructions = scr.instructions
|
||||
classified_offsets = set()
|
||||
cursor = 0
|
||||
|
||||
expected_prelude = (
|
||||
(
|
||||
"mul",
|
||||
[
|
||||
(T_LOCAL_INT, 0),
|
||||
(T_IMM, CARD_GENERATION_CLEAR_COUNT),
|
||||
(T_IMM, CARD_GENERATION_WEIGHT_STRIDE),
|
||||
],
|
||||
),
|
||||
(
|
||||
"copy-to-global",
|
||||
[
|
||||
(T_GLOBAL_INT, CARD_GENERATION_WEIGHT_BASE),
|
||||
(T_LOCAL_INT, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"copy-to-global",
|
||||
[
|
||||
(T_GLOBAL_INT, CARD_GENERATION_CARD_ID_BASE),
|
||||
(T_IMM, CARD_GENERATION_CLEAR_COUNT),
|
||||
],
|
||||
),
|
||||
)
|
||||
for expected_label, expected_args in expected_prelude:
|
||||
if cursor >= len(instructions):
|
||||
raise ValueError(f"{scr.path.name}: truncated CDINIT prelude")
|
||||
ins = instructions[cursor]
|
||||
label = sys4load.display_label(ins.opcode)
|
||||
if label != expected_label or ins.args != expected_args:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unexpected prelude instruction "
|
||||
f"at 0x{ins.offset:x}: {label} {ins.args}"
|
||||
)
|
||||
classified_offsets.add(ins.offset)
|
||||
cursor += 1
|
||||
|
||||
list_cells: dict[int, dict[int, dict[str, int]]] = {}
|
||||
branch_offsets: dict[int, int] = {}
|
||||
while cursor < len(instructions):
|
||||
ins = instructions[cursor]
|
||||
if sys4load.display_label(ins.opcode) != "eq":
|
||||
break
|
||||
if (
|
||||
len(ins.args) != 3
|
||||
or ins.args[0] != (T_LOCAL_INT, 0)
|
||||
or ins.args[1] != (T_GLOBAL_INT, CARD_GENERATION_SELECTOR)
|
||||
or ins.args[2][0] != T_IMM
|
||||
):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: malformed selector test at 0x{ins.offset:x}"
|
||||
)
|
||||
selector = ins.args[2][1]
|
||||
if selector in list_cells:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: duplicate selector {selector}"
|
||||
)
|
||||
list_cells[selector] = {}
|
||||
branch_offsets[selector] = ins.offset
|
||||
classified_offsets.add(ins.offset)
|
||||
cursor += 1
|
||||
|
||||
if cursor >= len(instructions):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} lacks a branch"
|
||||
)
|
||||
branch = instructions[cursor]
|
||||
if (
|
||||
sys4load.display_label(branch.opcode) != "jcc"
|
||||
or branch.args[:2]
|
||||
!= [(T_LOCAL_INT, 0), (T_IMM, 0xFFFFFFFF)]
|
||||
):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: malformed selector branch "
|
||||
f"at 0x{branch.offset:x}"
|
||||
)
|
||||
classified_offsets.add(branch.offset)
|
||||
cursor += 1
|
||||
|
||||
while cursor < len(instructions):
|
||||
write_ins = instructions[cursor]
|
||||
if sys4load.display_label(write_ins.opcode) != "mov":
|
||||
break
|
||||
write = _static_global_write(write_ins)
|
||||
if write is None or not isinstance(write[1], int):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: non-static list write "
|
||||
f"at 0x{write_ins.offset:x}"
|
||||
)
|
||||
destination, value = write
|
||||
if (
|
||||
CARD_GENERATION_CARD_ID_BASE
|
||||
< destination
|
||||
< CARD_GENERATION_CARD_ID_BASE
|
||||
+ CARD_GENERATION_SCAN_CAPACITY
|
||||
):
|
||||
slot = destination - CARD_GENERATION_CARD_ID_BASE
|
||||
field_name = "card_id"
|
||||
elif (
|
||||
CARD_GENERATION_WEIGHT_BASE
|
||||
<= destination
|
||||
< CARD_GENERATION_WEIGHT_BASE
|
||||
+ CARD_GENERATION_SCAN_CAPACITY
|
||||
* CARD_GENERATION_WEIGHT_STRIDE
|
||||
):
|
||||
index = destination - CARD_GENERATION_WEIGHT_BASE
|
||||
slot, column = divmod(
|
||||
index, CARD_GENERATION_WEIGHT_STRIDE
|
||||
)
|
||||
field_name = (
|
||||
"base_weight",
|
||||
"growth_interval_turns",
|
||||
"growth_weight",
|
||||
)[column]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified list write "
|
||||
f"0x{destination:x} at 0x{write_ins.offset:x}"
|
||||
)
|
||||
if slot == 0:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} writes reserved "
|
||||
f"slot zero at 0x{write_ins.offset:x}"
|
||||
)
|
||||
entry = list_cells[selector].setdefault(slot, {})
|
||||
if field_name in entry:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} slot {slot} "
|
||||
f"overwrites {field_name}"
|
||||
)
|
||||
entry[field_name] = value
|
||||
classified_offsets.add(write_ins.offset)
|
||||
cursor += 1
|
||||
|
||||
if cursor >= len(instructions):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} lacks terminal jump"
|
||||
)
|
||||
terminal = instructions[cursor]
|
||||
if sys4load.display_label(terminal.opcode) != "jmp":
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} lacks terminal jump "
|
||||
f"at 0x{terminal.offset:x}"
|
||||
)
|
||||
classified_offsets.add(terminal.offset)
|
||||
cursor += 1
|
||||
|
||||
if not list_cells:
|
||||
raise ValueError(f"{scr.path.name}: no card-generation selectors")
|
||||
|
||||
fallback_comment = ""
|
||||
while cursor < len(instructions):
|
||||
ins = instructions[cursor]
|
||||
label = sys4load.display_label(ins.opcode)
|
||||
if label == "comment":
|
||||
fallback_comment = scr.strings[ins.args[0][1]][0]
|
||||
elif label not in ("instruction-marker-noop", "exit"):
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified trailing instruction "
|
||||
f"{label} at 0x{ins.offset:x}"
|
||||
)
|
||||
classified_offsets.add(ins.offset)
|
||||
cursor += 1
|
||||
|
||||
unclassified = [
|
||||
f"0x{ins.offset:x}"
|
||||
for ins in instructions
|
||||
if ins.offset not in classified_offsets
|
||||
]
|
||||
if unclassified:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: unclassified instructions "
|
||||
+ ", ".join(unclassified)
|
||||
)
|
||||
|
||||
required_fields = {
|
||||
"card_id",
|
||||
"base_weight",
|
||||
"growth_interval_turns",
|
||||
"growth_weight",
|
||||
}
|
||||
for selector, cells in list_cells.items():
|
||||
expected_slots = list(range(1, len(cells) + 1))
|
||||
if sorted(cells) != expected_slots:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} has non-contiguous "
|
||||
f"slots {sorted(cells)}"
|
||||
)
|
||||
for slot, entry in cells.items():
|
||||
if set(entry) != required_fields:
|
||||
raise ValueError(
|
||||
f"{scr.path.name}: selector {selector} slot {slot} "
|
||||
f"has fields {sorted(entry)}, expected "
|
||||
f"{sorted(required_fields)}"
|
||||
)
|
||||
|
||||
card_scr = sys4load.load(resolve("CDINIT2"))
|
||||
card_records, _ = extract_name(card_scr)
|
||||
card_definitions = {
|
||||
record["id"]: {
|
||||
"name": record.get("name") or "",
|
||||
"description": record.get("desc") or "",
|
||||
"required_story_flag_ids": [],
|
||||
"forbidden_story_flag_ids": [],
|
||||
"ignored_required_story_flag_ids": [],
|
||||
}
|
||||
for record in card_records
|
||||
}
|
||||
flag_cells = {
|
||||
"required_story_flag_ids": {},
|
||||
"forbidden_story_flag_ids": {},
|
||||
}
|
||||
for ins in card_scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is None or not isinstance(write[1], int):
|
||||
continue
|
||||
destination, value = write
|
||||
for field_name, base in (
|
||||
("required_story_flag_ids", CARD_REQUIRED_STORY_FLAG_BASE),
|
||||
("forbidden_story_flag_ids", CARD_FORBIDDEN_STORY_FLAG_BASE),
|
||||
):
|
||||
index = destination - base
|
||||
if not (
|
||||
0
|
||||
<= index
|
||||
< CARD_GENERATION_SCAN_CAPACITY * CARD_STORY_FLAG_STRIDE
|
||||
):
|
||||
continue
|
||||
card_id, column = divmod(index, CARD_STORY_FLAG_STRIDE)
|
||||
flag_cells[field_name][(card_id, column)] = value
|
||||
break
|
||||
for card_id, definition in card_definitions.items():
|
||||
for field_name in flag_cells:
|
||||
definition[field_name] = [
|
||||
flag_cells[field_name].get((card_id, column), 0)
|
||||
for column in range(2)
|
||||
if flag_cells[field_name].get((card_id, column), 0)
|
||||
]
|
||||
ignored_required = flag_cells[
|
||||
"required_story_flag_ids"
|
||||
].get((card_id, 2), 0)
|
||||
if ignored_required:
|
||||
definition["ignored_required_story_flag_ids"] = [
|
||||
ignored_required
|
||||
]
|
||||
|
||||
stages, _ = extract_mixed(sys4load.load(resolve("STINIT")))
|
||||
attach_stage_object_placements(stages)
|
||||
references_by_selector: dict[int, dict[int, list[int]]] = (
|
||||
collections.defaultdict(lambda: collections.defaultdict(list))
|
||||
)
|
||||
for stage in stages:
|
||||
for obj in stage.get("object_placements", []):
|
||||
selector = obj.get("card_generation_list_id")
|
||||
if selector is None:
|
||||
continue
|
||||
references_by_selector[selector][stage["id"]].append(
|
||||
obj["slot"]
|
||||
)
|
||||
|
||||
records = []
|
||||
all_card_ids = set()
|
||||
resolved_card_reference_count = 0
|
||||
for selector in sorted(list_cells):
|
||||
entries = []
|
||||
for slot, raw_entry in sorted(list_cells[selector].items()):
|
||||
card_id = raw_entry["card_id"]
|
||||
all_card_ids.add(card_id)
|
||||
definition = card_definitions.get(card_id, {})
|
||||
if definition.get("name"):
|
||||
resolved_card_reference_count += 1
|
||||
entries.append({
|
||||
"slot": slot,
|
||||
"card_id": card_id,
|
||||
"card_name": definition.get("name", ""),
|
||||
"card_description": definition.get("description", ""),
|
||||
"base_weight": raw_entry["base_weight"],
|
||||
"growth_interval_turns": raw_entry[
|
||||
"growth_interval_turns"
|
||||
],
|
||||
"growth_weight": raw_entry["growth_weight"],
|
||||
"required_story_flag_ids": definition.get(
|
||||
"required_story_flag_ids", []
|
||||
),
|
||||
"forbidden_story_flag_ids": definition.get(
|
||||
"forbidden_story_flag_ids", []
|
||||
),
|
||||
"ignored_required_story_flag_ids": definition.get(
|
||||
"ignored_required_story_flag_ids", []
|
||||
),
|
||||
"source_addresses": {
|
||||
"card_id": (
|
||||
f"0x{CARD_GENERATION_CARD_ID_BASE + slot:x}"
|
||||
),
|
||||
"base_weight": (
|
||||
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3:x}"
|
||||
),
|
||||
"growth_interval_turns": (
|
||||
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 1:x}"
|
||||
),
|
||||
"growth_weight": (
|
||||
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 2:x}"
|
||||
),
|
||||
},
|
||||
})
|
||||
stage_references = [
|
||||
{
|
||||
"stage_id": stage_id,
|
||||
"object_slots": sorted(object_slots),
|
||||
}
|
||||
for stage_id, object_slots
|
||||
in sorted(references_by_selector.get(selector, {}).items())
|
||||
]
|
||||
records.append({
|
||||
"id": selector,
|
||||
"name": f"card_generation_list_{selector}",
|
||||
"branch_offset": f"0x{branch_offsets[selector]:x}",
|
||||
"entry_count": len(entries),
|
||||
"stage_object_references": stage_references,
|
||||
"entries": entries,
|
||||
"fields": {},
|
||||
})
|
||||
|
||||
used_selectors = sorted(
|
||||
selector
|
||||
for selector in list_cells
|
||||
if selector in references_by_selector
|
||||
)
|
||||
entry_count = sum(len(cells) for cells in list_cells.values())
|
||||
return records, {
|
||||
"schema": "card-generation-lists",
|
||||
"selector_global": f"0x{CARD_GENERATION_SELECTOR:x}",
|
||||
"card_id_array_base": f"0x{CARD_GENERATION_CARD_ID_BASE:x}",
|
||||
"weight_schedule_table_base": (
|
||||
f"0x{CARD_GENERATION_WEIGHT_BASE:x}"
|
||||
),
|
||||
"weight_schedule_stride": CARD_GENERATION_WEIGHT_STRIDE,
|
||||
"runtime_scan_capacity": CARD_GENERATION_SCAN_CAPACITY,
|
||||
"cleared_entry_prefix": CARD_GENERATION_CLEAR_COUNT,
|
||||
"selector_ids": sorted(list_cells),
|
||||
"used_selector_ids": used_selectors,
|
||||
"unreferenced_selector_ids": sorted(
|
||||
set(list_cells) - set(used_selectors)
|
||||
),
|
||||
"entry_count": entry_count,
|
||||
"distinct_card_ids": sorted(all_card_ids),
|
||||
"resolved_card_reference_count": resolved_card_reference_count,
|
||||
"ignored_required_story_flag_definition_count": sum(
|
||||
bool(definition["ignored_required_story_flag_ids"])
|
||||
for definition in card_definitions.values()
|
||||
),
|
||||
"ignored_required_story_flag_entry_count": sum(
|
||||
bool(entry["ignored_required_story_flag_ids"])
|
||||
for record in records
|
||||
for entry in record["entries"]
|
||||
),
|
||||
"stage_definition_reference_count": sum(
|
||||
len(stage_map)
|
||||
for stage_map in references_by_selector.values()
|
||||
),
|
||||
"stage_object_reference_count": sum(
|
||||
len(object_slots)
|
||||
for stage_map in references_by_selector.values()
|
||||
for object_slots in stage_map.values()
|
||||
),
|
||||
"fallback_comment": fallback_comment,
|
||||
"classified_instruction_count": len(classified_offsets),
|
||||
"semantic_array_names": {
|
||||
f"0x{CARD_GENERATION_SELECTOR:x}": (
|
||||
"current_card_generation_list_id"
|
||||
),
|
||||
f"0x{CARD_GENERATION_CARD_ID_BASE:x}": (
|
||||
"card_generation_card_ids"
|
||||
),
|
||||
f"0x{CARD_GENERATION_WEIGHT_BASE:x}": (
|
||||
"card_generation_weight_schedules"
|
||||
),
|
||||
},
|
||||
"weight_formula": (
|
||||
"base_weight + floor(current_stage_turn / "
|
||||
"growth_interval_turns) * growth_weight; when "
|
||||
"growth_interval_turns is zero, use base_weight"
|
||||
),
|
||||
"consumer_contract": {
|
||||
"FIELD.BIN": (
|
||||
"load the STINIT type-28 object's card-generation list, "
|
||||
"scan 100 candidate slots, discard empty card ids and "
|
||||
"CDINIT2 definitions whose required/forbidden story flags "
|
||||
"fail, compute the current-turn-adjusted weight, and select "
|
||||
"one surviving card by cumulative weighted random choice"
|
||||
),
|
||||
"CDINIT2.BIN": (
|
||||
"provides card names, result text, effects, graphics, and "
|
||||
"the required/forbidden story-flag rows used by FIELD; "
|
||||
"FIELD tests only columns zero and one, leaving the eighteen "
|
||||
"authored required-flag values in column two engine-dead"
|
||||
),
|
||||
"STINIT.BIN": (
|
||||
"type-28 stage objects supply the selector id consumed by "
|
||||
"CDINIT"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _map_stage_definitions() -> list[dict]:
|
||||
"""Read the STINIT2 records that own all four terrain-atlas bounds."""
|
||||
stage_scr = sys4load.load(resolve("STINIT2"))
|
||||
@@ -4843,6 +5269,11 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"Item and skill ids join to ITINIT/SKINIT; all 75 event slots join through",
|
||||
"SCINIT, and GAMESTART's restored-story-flag contract remains explicit.",
|
||||
"",
|
||||
"CDINIT's dedicated card-generation schema exposes nine sparse selector lists,",
|
||||
"joins their 383 weighted candidate slots to CDINIT2 card names and story-flag",
|
||||
"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.",
|
||||
"",
|
||||
"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",
|
||||
@@ -4909,6 +5340,10 @@ def main() -> int:
|
||||
# TRINIT's six-column sparse string matrix is not the generic
|
||||
# one-name-per-record layout expected by name-mode auto-detection.
|
||||
mode = "name"
|
||||
elif name == "CDINIT":
|
||||
# CDINIT's selector branches look like one fragmented numeric table
|
||||
# to the generic parallel-array detector.
|
||||
mode = "numeric"
|
||||
else:
|
||||
mode = detect_mode(scr)
|
||||
extractor = {
|
||||
@@ -4944,6 +5379,8 @@ def main() -> int:
|
||||
extractor = extract_terrain_definitions
|
||||
elif mode == "name" and name == "TRINIT":
|
||||
extractor = extract_training_actions
|
||||
elif mode == "numeric" and name == "CDINIT":
|
||||
extractor = extract_card_generation_lists
|
||||
elif mode == "numeric" and name == "SPINIT":
|
||||
extractor = extract_h_scene_gallery
|
||||
elif mode == "footer" and name == "MPINIT":
|
||||
|
||||
@@ -426,6 +426,49 @@ def profile_training_actions(data: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def profile_card_generation_lists(data: dict) -> dict:
|
||||
"""Summarize CDINIT's weighted, selector-dispatched card lists."""
|
||||
if data.get("schema") != "card-generation-lists":
|
||||
return {}
|
||||
entries = [
|
||||
entry
|
||||
for record in data.get("records", [])
|
||||
for entry in record.get("entries", [])
|
||||
]
|
||||
return {
|
||||
"list_count": len(data.get("records", [])),
|
||||
"entry_count": data.get("entry_count", 0),
|
||||
"distinct_card_count": len(data.get("distinct_card_ids", [])),
|
||||
"resolved_card_reference_count": data.get(
|
||||
"resolved_card_reference_count", 0
|
||||
),
|
||||
"used_selector_count": len(data.get("used_selector_ids", [])),
|
||||
"unreferenced_selector_ids": data.get(
|
||||
"unreferenced_selector_ids", []
|
||||
),
|
||||
"stage_definition_reference_count": data.get(
|
||||
"stage_definition_reference_count", 0
|
||||
),
|
||||
"stage_object_reference_count": data.get(
|
||||
"stage_object_reference_count", 0
|
||||
),
|
||||
"story_flag_gated_entry_count": sum(
|
||||
bool(entry.get("required_story_flag_ids"))
|
||||
or bool(entry.get("forbidden_story_flag_ids"))
|
||||
for entry in entries
|
||||
),
|
||||
"ignored_required_story_flag_entry_count": data.get(
|
||||
"ignored_required_story_flag_entry_count", 0
|
||||
),
|
||||
"runtime_scan_capacity": data.get("runtime_scan_capacity", 0),
|
||||
"cleared_entry_prefix": data.get("cleared_entry_prefix", 0),
|
||||
"list_entry_counts": {
|
||||
str(record["id"]): record.get("entry_count", 0)
|
||||
for record in data.get("records", [])
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def profile_messages(data: dict) -> dict:
|
||||
"""Summarize the joined player-facing message evidence."""
|
||||
records = data["records"]
|
||||
@@ -553,7 +596,29 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
|
||||
f"- records: {data['record_count']}",
|
||||
f"- populated fields: {len(rows)}",
|
||||
]
|
||||
if training_profile := profile_training_actions(data):
|
||||
if 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 "
|
||||
f"{card_profile['distinct_card_count']} distinct cards "
|
||||
f"({card_profile['resolved_card_reference_count']} CDINIT2 "
|
||||
f"references resolved)",
|
||||
f"- selector usage: {card_profile['used_selector_count']} used; "
|
||||
f"unreferenced {card_profile['unreferenced_selector_ids']}",
|
||||
f"- STINIT joins: "
|
||||
f"{card_profile['stage_object_reference_count']} type-28 objects "
|
||||
f"across {card_profile['stage_definition_reference_count']} "
|
||||
f"stage definitions",
|
||||
f"- story-flag-gated entries: "
|
||||
f"{card_profile['story_flag_gated_entry_count']} effective; "
|
||||
f"{card_profile['ignored_required_story_flag_entry_count']} "
|
||||
f"carry an engine-dead third required flag",
|
||||
f"- runtime scan/clear prefix: "
|
||||
f"{card_profile['runtime_scan_capacity']}/"
|
||||
f"{card_profile['cleared_entry_prefix']} slots",
|
||||
f"- entries by selector: {card_profile['list_entry_counts']}",
|
||||
])
|
||||
elif training_profile := profile_training_actions(data):
|
||||
lines.extend([
|
||||
f"- training actions: {training_profile['action_count']}",
|
||||
f"- display text lines: "
|
||||
@@ -751,6 +816,7 @@ def main() -> int:
|
||||
"terrain_definition_profile": profile_terrain_definitions(data),
|
||||
"h_scene_gallery_profile": profile_h_scene_gallery(data),
|
||||
"training_action_profile": profile_training_actions(data),
|
||||
"card_generation_profile": profile_card_generation_lists(data),
|
||||
"columns": sorted(rows, key=lambda row: (
|
||||
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
|
||||
)),
|
||||
|
||||
@@ -1656,6 +1656,87 @@ def test_training_actions() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_card_generation_lists() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["CDINIT.BIN"])
|
||||
check(
|
||||
extract_init.detect_mode(script) == "numeric",
|
||||
"CDINIT remains compatible with numeric-mode auto-detection",
|
||||
)
|
||||
records, meta = extract_init.extract_card_generation_lists(script)
|
||||
by_id = {record["id"]: record for record in records}
|
||||
check(
|
||||
list(by_id) == [1, 11, 31, 41, 55, 61, 71, 94, 160]
|
||||
and [record["entry_count"] for record in records]
|
||||
== [11, 36, 38, 51, 26, 52, 64, 30, 75],
|
||||
"CDINIT exposes all nine selector branches and their candidate counts",
|
||||
)
|
||||
check(
|
||||
meta["entry_count"] == 383
|
||||
and len(meta["distinct_card_ids"]) == 81
|
||||
and meta["resolved_card_reference_count"] == 383
|
||||
and meta["classified_instruction_count"] == 1565,
|
||||
"CDINIT classifies every instruction and resolves every card reference",
|
||||
)
|
||||
first = by_id[1]["entries"][0]
|
||||
check(
|
||||
first["slot"] == 1
|
||||
and first["card_id"] == 1
|
||||
and first["card_name"] == "癒しのカード・小"
|
||||
and first["base_weight"] == 25
|
||||
and first["growth_interval_turns"] == 5
|
||||
and first["growth_weight"] == 1
|
||||
and first["source_addresses"]
|
||||
== {
|
||||
"card_id": "0x1525b3",
|
||||
"base_weight": "0x152489",
|
||||
"growth_interval_turns": "0x15248a",
|
||||
"growth_weight": "0x15248b",
|
||||
},
|
||||
"CDINIT retains the parallel card-id and weight-table provenance",
|
||||
)
|
||||
gated = next(
|
||||
entry
|
||||
for entry in by_id[11]["entries"]
|
||||
if entry["card_id"] == 14
|
||||
)
|
||||
check(
|
||||
gated["card_name"] == "使い魔のカード"
|
||||
and gated["required_story_flag_ids"] == [901]
|
||||
and gated["forbidden_story_flag_ids"] == [861],
|
||||
"CDINIT entries join CDINIT2 names and FIELD story-flag gates",
|
||||
)
|
||||
ignored_gate = next(
|
||||
entry
|
||||
for entry in by_id[41]["entries"]
|
||||
if entry["card_id"] == 18
|
||||
)
|
||||
check(
|
||||
meta["ignored_required_story_flag_definition_count"] == 18
|
||||
and meta["ignored_required_story_flag_entry_count"] == 54
|
||||
and ignored_gate["required_story_flag_ids"] == [901, 863]
|
||||
and ignored_gate["ignored_required_story_flag_ids"] == [51],
|
||||
"CDINIT distinguishes FIELD's two live required flags from column three",
|
||||
)
|
||||
check(
|
||||
meta["used_selector_ids"] == [1, 11, 31, 41, 61, 71, 160]
|
||||
and meta["unreferenced_selector_ids"] == [55, 94]
|
||||
and meta["stage_definition_reference_count"] == 50
|
||||
and meta["stage_object_reference_count"] == 246
|
||||
and by_id[1]["stage_object_references"]
|
||||
== [{"stage_id": 1, "object_slots": [8]}],
|
||||
"CDINIT joins every used list back to STINIT type-28 stage objects",
|
||||
)
|
||||
check(
|
||||
meta["runtime_scan_capacity"] == 100
|
||||
and meta["cleared_entry_prefix"] == 50
|
||||
and by_id[160]["entry_count"] == 75
|
||||
and meta["fallback_comment"]
|
||||
== "カード発生リストの設定が不足しています",
|
||||
"CDINIT preserves its 100-slot scan, 50-slot clear, and fallback warning",
|
||||
)
|
||||
|
||||
|
||||
def test_condition_definitions() -> None:
|
||||
scripts = paths.scripts()
|
||||
script = sys4load.load(scripts["ILINIT.BIN"])
|
||||
@@ -1873,6 +1954,7 @@ if __name__ == "__main__":
|
||||
test_terrain_definitions()
|
||||
test_h_scene_gallery()
|
||||
test_training_actions()
|
||||
test_card_generation_lists()
|
||||
test_map_terrain_atlas()
|
||||
test_condition_definitions()
|
||||
test_field_semantics()
|
||||
|
||||
@@ -86,6 +86,14 @@ def test_load_and_lint():
|
||||
and entries[0x6722]["name"] == "familiar_alignment"
|
||||
and entries[0x6727]["name"] == "training_action_execution_counts",
|
||||
"TRINIT/TRAIN action state is curated")
|
||||
check(entries[0x152485]["name"] == "current_card_generation_list_id"
|
||||
and entries[0x152486]["columns"]["2"] == "growth_weight"
|
||||
and entries[0x1525b2]["name"] == "card_generation_card_ids"
|
||||
and entries[0x1519f8]["name"] == "current_card_id"
|
||||
and entries[0x151a5d]["columns"]["2"]
|
||||
== "engine_dead_required_flag_3"
|
||||
and entries[0x204f4]["name"] == "current_stage_turn",
|
||||
"CDINIT/FIELD card-generation state is curated")
|
||||
check(entries[0x15a095]["name"] == "information_tab_index"
|
||||
and entries[0x15a096]["name"] == "information_message_handled"
|
||||
and entries[0x15a097]["name"]
|
||||
|
||||
@@ -267,6 +267,59 @@ def main() -> int:
|
||||
assert "- geometry: 8 pages × 15 slots" in rendered_h_gallery
|
||||
assert "- populated scenes: 118/120" in rendered_h_gallery
|
||||
|
||||
card_fixture = {
|
||||
"table": "CARDS",
|
||||
"mode": "numeric",
|
||||
"schema": "card-generation-lists",
|
||||
"record_count": 2,
|
||||
"entry_count": 3,
|
||||
"distinct_card_ids": [1, 14],
|
||||
"resolved_card_reference_count": 3,
|
||||
"used_selector_ids": [1],
|
||||
"unreferenced_selector_ids": [55],
|
||||
"stage_definition_reference_count": 1,
|
||||
"stage_object_reference_count": 2,
|
||||
"runtime_scan_capacity": 100,
|
||||
"cleared_entry_prefix": 50,
|
||||
"records": [
|
||||
{
|
||||
"id": 1,
|
||||
"entry_count": 2,
|
||||
"entries": [
|
||||
{
|
||||
"card_id": 1,
|
||||
"required_story_flag_ids": [],
|
||||
"forbidden_story_flag_ids": [],
|
||||
},
|
||||
{
|
||||
"card_id": 14,
|
||||
"required_story_flag_ids": [901],
|
||||
"forbidden_story_flag_ids": [861],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 55,
|
||||
"entry_count": 1,
|
||||
"entries": [
|
||||
{
|
||||
"card_id": 1,
|
||||
"required_story_flag_ids": [],
|
||||
"forbidden_story_flag_ids": [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
card_summary = profile.profile_card_generation_lists(card_fixture)
|
||||
assert card_summary["list_count"] == 2
|
||||
assert card_summary["entry_count"] == 3
|
||||
assert card_summary["story_flag_gated_entry_count"] == 1
|
||||
assert card_summary["list_entry_counts"] == {"1": 2, "55": 1}
|
||||
rendered_cards = profile.render_markdown(card_fixture, [], 40)
|
||||
assert "- card-generation lists: 2" in rendered_cards
|
||||
assert "- runtime scan/clear prefix: 100/50 slots" in rendered_cards
|
||||
|
||||
training_fixture = {
|
||||
"table": "TRAINING",
|
||||
"mode": "name",
|
||||
|
||||
Reference in New Issue
Block a user