diff --git a/docs/global-reference.md b/docs/global-reference.md index 8016364..76cc011 100644 --- a/docs/global-reference.md +++ b/docs/global-reference.md @@ -1,7 +1,7 @@ # Global Variable Reference (generated) -5033 globals (197 curated, 4836 auto shape-inferred). Source of truth: `vm-map/globals.toml`. +5035 globals (210 curated, 4825 auto shape-inferred). Source of truth: `vm-map/globals.toml`. ## choice-output @@ -32,9 +32,14 @@ | `0x65ce` | skill_acquired_flags | high | investigation | Persistent acquired-skill flags. ADDSKILL sets the selected skill after resolving the unit's equipped-skill slots; FORT checks the flag before granting a skill; CHMENU combines it with skill_change_catalog_eligible to build the available skill-change catalog. | | `0x673c` | party_slot_flags | high | investigation | Per-party-slot state flags for slots 0..99. UNITECH creates the initial unit by setting slot 2 to 0x13; CALCARR counts slots whose flags intersect 0x6, and CHMENU includes slots with bit 1 set. Exact meanings of the remaining bits are not yet classified. | | `0x67a0` | party_slot_character_id | high | investigation | Character/unit definition id stored for each party slot. UNITECH writes character id 2 into initial slot 2 on a natural New Game; CHMENU reads this table for every active party_slot_flags entry when constructing its roster. | +| `0x6930` | unit_current_levels | high | investigation | Persistent current level by playable-unit id. CCINIT compares current_unit_id's row with each promotion threshold; ADDEXP increments and reports the same value. | +| `0x69f8` | unit_current_stats | high | investigation | Persistent fourteen-stat row for each playable unit, using the same column order as unit_base_stats. CALCCC adds class_change_stat_bonuses to the current unit's row and clamps each result to the shared stat caps. Columns: 0=accuracy, 1=evasion, 2=physical_attack, 3=physical_defense, 4=magic_attack, 5=magic_defense, 6=speed, 7=luck, 8=critical_chance, 9=capture_power, 10=movement, 11=max_hp, 12=max_sp, 13=max_fs. | +| `0x74e8` | unit_skill_ids | high | investigation | Persistent four-skill row for each playable unit. CALCCC copies positive class_change_skill_awards into the first three slots after a promotion; ADDEXP compares the before/after row to report learned or replaced skills. Columns: 0=skill_slot_1, 1=skill_slot_2, 2=skill_slot_3, 3=skill_slot_4. | | `0x7684` | stage_clear_state | high | investigation | Persistent per-stage completion state indexed by stage id. STAGECLEAR sets the current stage cell to 1, while FORT, SELSTAGE, and FIELD use zero versus one to distinguish an uncleared mission from a cleared replay. | | `0x141a4` | stage_object_runtime_state | high | investigation | Per-stage, per-object-slot runtime state. FIELD initializes mode-1 objects from STINIT's first tagged payload on a fresh stage and updates capturable-object ownership; DRAWOBJ uses it as the vertical sprite-row index. Other consumers interpret it by object type. | | `0x5660b` | skill_info_revealed_flags | high | investigation | Persistent skill-information visibility flags. ADDSKILL sets the selected skill, BTL marks every equipped skill when it is observed in combat, and INFOIT suppresses a skill's icon/handler-driven details until this flag is nonzero. This is broader than skill_acquired_flags. | +| `0x57357` | unit_deployment_cost_adjustments | high | investigation | Persistent per-unit delta added to unit_deployment_cost_base. CALCCC increments it by class_change_deployment_cost_delta, while SETCH, SETEN, ADDEXP, and deployment scripts include it when constructing or updating runtime command cost. | +| `0x573bb` | unit_class_change_state | high | investigation | Persistent ten-slot class-change state for each playable unit. CALCCC copies the current unit's row to class_change_state_work before invoking the rule scripts and persists the updated row; CCINIT requires the rule's slot to be zero so each promotion is applied once. Columns: 0=promotion_slot_1, 1=promotion_slot_2, 2=promotion_slot_3, 3=promotion_slot_4, 4=promotion_slot_5, 5=promotion_slot_6, 6=promotion_slot_7, 7=promotion_slot_8, 8=promotion_slot_9, 9=promotion_slot_10. | | `0x66716` | unit_voice_asset_ids | high | investigation | EBINIT per-unit voice bank for 116 voiced characters and variants. FIELD directly selects column 0 before WARPD, column 1 when an acting unit takes chest/treasure contents, and column 3 after occupying, losing, or sealing a stage objective. BTL selects the acting unit's columns 7..9 for ordinary attacks, 10 for an ordinary critical, odd columns 11/13/15 for skill uses, and even columns 12/14/16 for critical skills; the three variants follow its six-step exchange selector. If the target survives positive damage, BTL selects target columns 19/20/21 with 60/30/10 percent weights; column 22 belongs to a target reduced to zero HP, while actor column 23 is selected for a finishing blow. SHOWGROW selects column 24 after level gain. Populated columns 4..6 and 17..18 have no reachable selector in the shipped script corpus and are retained as explicit unused authoring slots; columns 17 and 18 duplicate columns 15 and 16 in all 116 populated rows. Columns: 0=warp, 1=treasure_capture, 3=objective_interaction, 4=unused_slot_4, 5=unused_slot_5, 6=unused_slot_6, 7=normal_attack_1, 8=normal_attack_2, 9=normal_attack_3, 10=critical_normal_attack, 11=skill_use_1, 12=critical_skill_1, 13=skill_use_2, 14=critical_skill_2, 15=skill_use_3, 16=critical_skill_3, 17=unused_slot_17, 18=unused_slot_18, 19=damage_reaction_1, 20=damage_reaction_2, 21=damage_reaction_3, 22=defeated, 23=finishing_blow, 24=level_up. | | `0x6dc46` | unit_status_art_asset_ids | high | investigation | Three-variant status/menu illustration table for 25 principal characters. The ids resolve to 456x420 CS character art; DRAWCHP selects column 0 above 50% HP, column 1 at 26..50%, and column 2 at 25% or below, then loads the art into the status-panel texture slot. Columns: 0=healthy, 1=wounded, 2=critical. | | `0x6e7fe` | unit_map_sprite_asset_ids | high | investigation | Five-context unit sprite-sheet table for 251 units. Even columns 0/2/4 are compact CP*AA-style presentations for normal, alternate-condition, and special-condition states; columns 1/3 are the matching normal/alternate full directional CP*AB sheets used by FIELD. INFOCH/INFOEN and DRAWENP reuse the compact representation. Columns: 0=normal_compact, 1=normal_directional, 2=alternate_compact, 3=alternate_directional, 4=special_compact. | @@ -106,6 +111,7 @@ | `0xaa8f2` | skill_proc_chance_percent | high | investigation | Probability for 14 passive skills. CALCDMG compares random-modulo 100 against this value; examples include Re-action 20, Double Action 100, Counter 10, and Resurrection 50. | | `0xaaa1e` | skill_battle_animation_id | high | investigation | Populated for 101 combat skills. BTL and CALCDMG place this value in the battle-animation selector before calling BTANINIT; most skills reuse their own id, while related skills deliberately share an animation and passive reactions use ids 801..808. | | `0xaab4a` | skill_handler_script_id | high | investigation | SKINIT field for all 131 skills. CHMENU and INFOIT look it up and pass it directly to call-script; packed id 0x31ca resolves to SKMES.BIN, the shared per-skill text/behavior dispatcher. | +| `0xab8c7` | class_change_rule_script_ids | high | investigation | CALCCC iterates these 32 cells and call-scripts every positive entry to evaluate class-change providers. CCINIT is the shipped rule program decoded into build/data/CCINIT.json. | | `0xe6dee` | object_sprite_state_row_mode | high | investigation | OBINIT metadata indexed by object type. FIELD copies the type-tagged initial payload into stage_object_runtime_state only for mode 1; DRAWOBJ likewise multiplies that runtime state by the object sprite height to select source Y only for mode 1. | | `0xe7302` | stage_bgm_id | high | investigation | STINIT's per-stage scalar loaded for all 74 records. FIELD passes the value directly to play-bgm when starting the stage. | | `0xe7303` | stage_target_clear_turns | high | investigation | STINIT's target/par turn count. STAGECLEAR divides elapsed turns by this value to derive a performance multiplier; when replaying an already-cleared ordinary stage, FIELD also uses it as the forced-retreat turn limit. | @@ -144,7 +150,6 @@ | `0xd7` | — | low | auto-shape | array | | `0x1a4` | — | low | auto-shape | array | | `0x1a9` | — | low | auto-shape | array | -| `0x27e` | — | low | auto-shape | array | | `0x2e2` | — | low | auto-shape | array | | `0x315` | — | low | auto-shape | array | | `0x41f` | — | low | auto-shape | array | @@ -178,7 +183,6 @@ | `0x6727` | — | low | auto-shape | array | | `0x6804` | — | low | auto-shape | array | | `0x6868` | — | low | auto-shape | array | -| `0x6930` | — | low | auto-shape | array | | `0x6994` | — | low | auto-shape | array | | `0x7a6c` | — | low | auto-shape | array | | `0x204fb` | — | low | auto-shape | array | @@ -206,7 +210,6 @@ | `0x56b52` | — | low | auto-shape | array | | `0x56b85` | — | low | auto-shape | array | | `0x56f6d` | — | low | auto-shape | array | -| `0x57357` | — | low | auto-shape | array | | `0x577a3` | — | low | auto-shape | array | | `0x5f0bb` | — | low | auto-shape | array | | `0x5f0ed` | — | low | auto-shape | array | @@ -244,9 +247,6 @@ | `0xaac96` | — | low | auto-shape | array | | `0xaacb4` | — | low | auto-shape | array | | `0xaacd2` | — | low | auto-shape | array | -| `0xab8c7` | — | low | auto-shape | array | -| `0xab8e9` | — | low | auto-shape | array | -| `0xab8f7` | — | low | auto-shape | array | | `0xaba3b` | — | low | auto-shape | array | | `0xaba43` | — | low | auto-shape | array | | `0xaba4b` | — | low | auto-shape | array | @@ -368,6 +368,7 @@ |---|---|---|---|---| | `0x32f1` | difficulty_index | high | investigation | GAMESTART's three-way difficulty selection. SETEN uses the zero-based index to select difficulty stat adjustments; FIELD indexes three-column stage-enemy arrays with it, and SETOBJ tests it against each object's difficulty mask. | | `0x6718` | selected_party_slot | high | investigation | Current/selected slot in the 100-entry party-unit arrays. UNITECH chooses a free slot here before populating it; CHMENU replaces it with the selected sorted roster slot, then uses it to index party_slot_flags, party_slot_character_id, and companion per-slot tables. A natural New Game enters SC0000 with slot 2 selected. | +| `0x66715` | current_unit_id | high | investigation | Shared current-unit selector used by character growth and setup scripts. CCINIT keys every class-change rule on this value; CALCCC, ADDEXP, SETEN, SALLY, and related scripts use it to index unit definitions and persistent per-unit state. | | `0x8c877` | current_item_id | high | investigation | Shared item-id argument/selection slot. Item menus and gameplay scripts write a chosen item id, use it to index ITINIT arrays, and dispatch through item_handler_script_id; ITMES compares it against all 287 item ids to select the matching player-facing title and description. | | `0xa6e59` | current_skill_id | high | investigation | Shared skill-id argument/selection slot. Skill menus and combat scripts write the chosen skill id and use it to index SKINIT arrays; SKMES compares it against all 131 skill ids to select the matching player-facing title and description. | | `0xeff75` | current_entity_index_hi | med | inference | High-purity current-entity row index (purity 0.95 in the auto shape map); dominant 2D-table row selector. | @@ -414,7 +415,6 @@ | `0x665d6` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against []; writers=['MES.BIN', 'SBUNKI.BIN']. | | `0x665d7` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [1, 3]; writers=['ADDSKILL.BIN', 'SBUNKI.BIN']. | | `0x66714` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against []; writers=['DEBUGBTL.BIN', 'FIELD.BIN', 'RTN_M051.BIN', 'RTN_M052.BIN']. | -| `0xab8e7` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [0, 10, 15, 20, 25, 30, 35, 40]; writers=['CALCCC.BIN', 'CCINIT.BIN']. | | `0xaba5c` | — | med | auto-shape | TODO: confirm. Branch-read in 136 scenes / 149 scripts; compared against [0, 1]; writers=['DEBUGADV.BIN', 'DEBUGADV2.BIN', 'SC0000.BIN', 'SC0010.BIN']. | | `0xaba5e` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [2]; writers=['DEBUGMAP.BIN', 'DEBUGMAP2.BIN', 'DEBUGMAP3.BIN']. | | `0xcc9f3` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against []; writers=['CALCSCOPE.BIN']. | @@ -428,6 +428,8 @@ | address | name | conf | source | usage | |---|---|---|---|---| +| `0x27e` | unit_class_titles | high | investigation | Persistent per-unit class/title string table. CALCCC writes the selected class_change_title_output into the current unit's cell; character and status presentation scripts read the resulting title. | +| `0x26b4` | class_change_title_output | high | investigation | CCINIT writes the title selected by each eligible class-change rule. CALCCC copies it to unit_class_titles for a successful promotion, and ADDEXP includes the same string in the level-up notification. | | `0x26f1` | object_type_names | high | investigation | OBINIT writes the authoritative object names. FIELD, SETOBJ, and DRAWOBJ use STINIT's object type id to select these definitions; extract_init joins the names to stage object placements. | | `0x2755` | object_type_descriptions | high | investigation | OBINIT writes the short object descriptions displayed by the field object-information path. extract_init joins populated descriptions to STINIT object placements by type id. | | `0x27b9` | stage_victory_condition_1 | high | investigation | STINIT writes one value for each of its 74 stage records. AIM renders this line first in the victory-condition section, and FIELD copies it into the current mission-condition display. | @@ -2543,7 +2545,6 @@ | `0x26ae` | — | med | auto-shape | string-table (written by AFINIT) | | `0x26af` | — | med | auto-shape | string-table (written by AFINIT) | | `0x26b0` | — | med | auto-shape | string-table (written by AFINIT) | -| `0x26b4` | — | med | auto-shape | string-table (written by CCINIT) | | `0x26b6` | — | med | auto-shape | string-table (written by LAINIT) | | `0x26b7` | — | med | auto-shape | string-table (written by LAINIT) | | `0x26b8` | — | med | auto-shape | string-table (written by LAINIT) | @@ -3651,6 +3652,11 @@ | `0x3304` | — | low | inference | Config/settings global (CONFIG.BIN/INITCONFIG.BIN writer, scene-reach 0) — NOT a scene story flag; miner over-tagged it. Branch-read in 13 scripts; compared against [1]. | | `0x204f4` | — | low | inference | Map/field display state (FIELD.BIN writer; used in DRAWCHP draw call u00422460 and `div 0x204f4/3`). A numeric display parameter, NOT a story flag. Branch-read in 2 scenes. | | `0x6249e` | — | low | inference | Graphics-subsystem state in the 0x624xx gfx-object range (set via `mov 0x6249e,` alongside `mov 0x62450,`). Likely a draw/slot parameter, NOT a story flag. Branch-read in 78 scenes. | +| `0xab8e7` | class_change_selected_level | high | investigation | CALCCC clears this accumulator before invoking the class-change rule scripts. Each eligible CCINIT rule replaces it only when its threshold is higher than the current selection; nonzero then signals CALCCC and ADDEXP to apply and report the chosen promotion. | +| `0xab8e8` | class_change_deployment_cost_delta | high | investigation | Shared class-change output initialized by CALCCC and incremented by the selected CCINIT rule. CALCCC adds it to unit_deployment_cost_adjustments and ADDEXP reports the signed cost change. | +| `0xab8e9` | class_change_stat_bonuses | high | investigation | Fourteen-cell class-change accumulator using the unit stat column order. CALCCC zeroes it, CCINIT adds the selected rule's bonuses, and CALCCC adds the result to unit_current_stats with per-column caps. EVOLVE separately clears it and calls CCINIT to preview the next Lily form's movement bonus. Columns: 0=accuracy, 1=evasion, 2=physical_attack, 3=physical_defense, 4=magic_attack, 5=magic_defense, 6=speed, 7=luck, 8=critical_chance, 9=capture_power, 10=movement, 11=max_hp, 12=max_sp, 13=max_fs. | +| `0xab8f7` | class_change_skill_awards | high | investigation | Four-cell class-change skill output. CALCCC zeroes the buffer, CCINIT writes awarded skill ids, and CALCCC copies positive values from the first three cells into unit_skill_ids before ADDEXP reports the changes. Columns: 0=skill_slot_1, 1=skill_slot_2, 2=skill_slot_3, 3=skill_slot_4. | +| `0xab8fb` | class_change_state_work | high | investigation | Ten-cell working copy of the current unit's unit_class_change_state row. CCINIT sets the slot belonging to an awarded rule; CALCCC manages the block copy between this buffer and persistent per-unit state. Columns: 0=promotion_slot_1, 1=promotion_slot_2, 2=promotion_slot_3, 3=promotion_slot_4, 4=promotion_slot_5, 5=promotion_slot_6, 6=promotion_slot_7, 7=promotion_slot_8, 8=promotion_slot_9, 9=promotion_slot_10. | | `0x15261c` | battle_hit_result | high | investigation | CALCDMG initializes this to miss, changes it to hit after the accuracy gate, and then to critical after the critical-chance gate. BTL uses the same result to choose presentation, cut-ins, and critical voice columns; -1 is written only by a synthetic pre-battle path. | | `0x15261d` | battle_hp_delta | high | investigation | CALCDMG's signed HP result. BTL subtracts it from the target's current HP, so positive values deal damage and negative values heal; zero and result state gate damage reactions and defeat handling. | | `0x84a` | — | high | auto-shape | unit-name-table | @@ -3667,9 +3673,7 @@ | `0x3239` | — | med | auto-shape | record-table[stride 3] | | `0x32f0` | — | med | auto-shape | current-entity-index? | | `0x3ebe` | — | med | auto-shape | record-table[stride 10] | -| `0x69f8` | — | med | auto-shape | record-table[stride 14] | | `0x6f70` | — | med | auto-shape | record-table[stride 14] | -| `0x74e8` | — | med | auto-shape | record-table[stride 4] | | `0x7e54` | — | med | auto-shape | record-table[stride 50] | | `0x20543` | — | med | auto-shape | record-table[stride 27] | | `0x341ab` | — | med | auto-shape | record-table[stride 53] | @@ -3692,7 +3696,6 @@ | `0x53ef7` | — | med | auto-shape | record-table[stride 8] | | `0x56738` | — | med | auto-shape | record-table[stride 20] | | `0x57356` | — | med | auto-shape | current-entity-index? | -| `0x573bb` | — | med | auto-shape | record-table[stride 10] | | `0x62436` | — | low | auto-shape | index/counter? | | `0x62450` | — | low | auto-shape | index/counter? | | `0x62ccb` | — | low | auto-shape | index/counter? | @@ -5003,7 +5006,6 @@ | `0x64cde` | — | med | auto-shape | cg-gallery-field | | `0x6638b` | — | med | auto-shape | record-table[stride 15] | | `0x66713` | — | low | auto-shape | index/counter? | -| `0x66715` | — | med | auto-shape | current-entity-index? | | `0x81c96` | — | med | auto-shape | record-table[stride 3] | | `0x8284e` | — | med | auto-shape | record-table[stride 3] | | `0xaac76` | — | low | auto-shape | index/counter? | diff --git a/docs/name-resolution.md b/docs/name-resolution.md index 6f31557..194e1c6 100644 --- a/docs/name-resolution.md +++ b/docs/name-resolution.md @@ -375,6 +375,35 @@ object-linked placement data when present, difficulty and story gates, level rul weight, movement/battle routine rows, and `first_clear_only` replay gating. Raw footer metadata stays in `footer_arrays`, while its `semantic_fields` value is the copied row itself. +### CCINIT conditional class-change rules (2026-07-23) + +CCINIT is not a parallel INIT database. It is a source-ordered program of 71 conditional promotion rules +covering 33 EBINIT unit ids. Each rule tests `current_unit_id`, normally requires the unit's current level +to meet a threshold, and requires one of ten persistent class-change slots to be clear. The rule then +selects a level/title, adds a deployment-cost delta and fourteen-column stat bonuses, optionally awards +SKINIT skills, and sets the applied slot. Sixty-nine rules award named titles; the two empty-title, +level-independent rules are Lily's girl/adult form adjustments and are also queried directly by EVOLVE +when it previews the next form's movement. + +CALCCC establishes the surrounding protocol. It clears the selection, cost, stat, and skill outputs, +copies the current unit's ten persistent state cells into a working buffer, then call-scripts up to 32 +providers from `class_change_rule_script_ids`. Eligible rules retain the highest selected level. CALCCC +copies the successful title to `unit_class_titles`, adds the cost and stat outputs to persistent unit +state with stat caps, installs positive skill awards into the first three skill slots, persists the +updated applied-state row, and reveals the awarded skills. ADDEXP calls CALCCC after level growth and +uses the same outputs to construct title, cost, and learned/replaced-skill notifications. + +`extract_init.py` detects this fifth shape as `rules` and writes `build/data/CCINIT.json`. Each source-order +record keeps its bytecode guard offset and raw output keys alongside `unit_id`/EBINIT `unit_name`, +`minimum_level`, zero-based `class_change_slot_index`, `title`, named `stat_bonuses`, +`deployment_cost_delta`, joined one-based `skill_awards`, and `state_flag_indices_set`. The common +`field_semantics`/`semantic_fields` join resolves the +raw title, selected-level, cost, stat, skill, and state-work addresses through `vm-map/globals.toml`; +the raw keys remain provenance. The shipped profile reports 69 titled rules, two level-independent rules, +30 skill awards, three used promotion slots, and 19 populated output fields. The underlying input, +working, and persistent globals are now named there as one coherent class-change ABI rather than as +unrelated addresses. + ### The curated registry — `vm-map/globals.toml` (2026-07-07) The v1 auto map (`build/global-var-map.json`) infers *shapes* but cannot recover branch-flag @@ -419,13 +448,12 @@ are *not* story flags — the miner over-tags them; they are recategorized `unkn The v1 map labels *shapes and tables*; the next increments add *meaning*, cheapest first: -1. **Continue INIT semantics by evidence density.** ITINIT/SKINIT, the confirmed EBINIT row layouts, and - STINIT's mixed stage records now have machine-readable investigation surfaces, including joined object - placements and enemy spawns. STINIT's universal object schedule, consumer-proven tagged payload - families, faction-gated hazards/barriers, initialized object states, OBINIT definition join, and - first-clear enemy gate are decoded, and its last three populated tagged cells are proven ignored by the - type-27 special-spawn path. Isolate EBINIT's remaining voice/action - slots under the same rule. Preserve explicit joins and do not infer meaning from column position alone. +1. **Continue INIT semantics by evidence density.** ITINIT/SKINIT, EBINIT, STINIT, and CCINIT now have + machine-readable investigation surfaces and semantic joins; EBINIT's populated schema is fully named, + STINIT's joined object/enemy payloads are decoded, and CCINIT's 71 class-change rules expose predicates + and effects. Inventory RTINIT and SCINIT next, starting with whichever has the clearest repeated + structural boundary and runtime consumers. Preserve explicit joins and do not infer meaning from + column position alone. 2. **Extend message-table joins beyond the completed ITMES/SKMES pair** (`VIMES`, other id dispatchers, …) and fold in other `set-string`/`copy-to-global` writers not covered by the `*INIT` set. 3. **Label 2D record tables by their readers** — cross-reference which scripts read each diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 622c5ac..96ebc99 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -775,6 +775,16 @@ floor/cap/scaling, weighted selection, and three difficulty-specific movement/ba generated view assembles all 1,378 populated enemy slots across 66 stages, including 485 enemies suppressed after the stage's first clear. +CCINIT is now the fifth extracted shape rather than a failed name-table parse. Its 71 conditional +class-change rules cover 33 EBINIT units and expose unit/level/applied-slot predicates plus the selected +title, deployment-cost delta, fourteen-stat bonuses, SKINIT skill awards, and state slot set. The shipped +program contains 69 named promotions, 30 skill awards, and two level-independent empty-title Lily +form-adjustment rules; EVOLVE directly queries those form effects while CALCCC/ADDEXP establish the normal +promotion apply/report protocol. The input, working-output, and persistent destination globals are named +as one class-change ABI in `vm-map/globals.toml`, and generated rules carry both raw address provenance and +the shared `semantic_fields` join. The next data-semantics inventory target is RTINIT or SCINIT, chosen by +which provides the clearer repeated boundary and consumer evidence. + Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: - Remaining title/configuration/load/save branches. diff --git a/docs/script-inventory.md b/docs/script-inventory.md index 605ee4d..b357a10 100644 --- a/docs/script-inventory.md +++ b/docs/script-inventory.md @@ -41,13 +41,13 @@ in the root: `SYS4INI.BIN` (272 KB) and `SYS4AB.BIN` (1.08 MB), for 51 root BINs disassembly target), `INITCONFIG`, `LOADCONFIG`, `CONFIG`, `TUNE`, `LOGO`, `OP`, `ED`, `TITLE`, `GAMESTART`, `GAMECLEAR`, `STAGECLEAR` -### Data-table INIT scripts (likely static game data, not logic) +### INIT data and rule scripts Large, table-like scripts — prime candidates for data extraction: - `STINIT` (579 KB) — stages/scenario tables - `EBINIT` (338 KB) — enemy battle data - `MPINIT` (330 KB) — maps - `SCINIT` (88 KB), `CGINIT` (79 KB — CG gallery), `ITINIT` (70 KB — items), - `RTINIT` (67 KB), `CCINIT` (41 KB), `SKINIT` (37 KB — skills), `CDINIT` (31 KB), + `RTINIT` (67 KB), `CCINIT` (41 KB — class-change rule program), `SKINIT` (37 KB — skills), `CDINIT` (31 KB), `BTANINIT` (105 KB — battle animations) - Smaller: `AFINIT`, `ALINIT`, `CIINIT`, `CNINIT`, `CTINIT`, `CVINIT`, `ILINIT`, `LAINIT`, `MAINIT`, `OBINIT` (46 object definitions), `SPINIT`, `TRINIT`, `VIINIT` @@ -82,6 +82,13 @@ assembles all 1,378 populated enemy slots across the same 66 stages, including unit/faction, placement, difficulty/story gates, level rules, weighted alternatives, difficulty-specific movement/battle routine sets, and 485 first-clear-only replay gates. +CCINIT is the important exception to the static-table heuristic. Its 71 source-ordered branches are +class-change rules over 33 EBINIT unit ids, not address-indexed database rows. CALCCC evaluates CCINIT +through a 32-script provider list and applies the selected rule's title, deployment-cost delta, fourteen +stat bonuses, skill awards, and persistent applied-state slot; EVOLVE directly queries the two untitled +Lily form rules for movement preview. `extract_init.py` emits this as `rules` mode with raw guards and +output addresses joined to EBINIT unit names, SKINIT skill names, and the curated class-change ABI. + ### Message/string tables (`*MES`) `ITMES` (64 KB — item text), `VIMES` (43 KB), `EIMES` (37 KB), `SKMES` (31 KB — skill text), `CIMES` (15 KB), `MAMES`, `INFOMES`, `MES` — where most translatable text diff --git a/docs/tools-reference.md b/docs/tools-reference.md index bf2c1c1..069af0b 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -55,9 +55,9 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited * |---|---|---|---| | `extract_phase2.py` | Batch: disassembly + text corpora for every script. | `extract_phase2.py` | corpus → `build/disasm/*.asm`, `build/text/{dialogue.jsonl,strings.jsonl,*.strings.txt}`, `build/manifest.json` | | `extract_message_table.py` | Discover a repeated global-id dispatch chain such as ITMES/SKMES, reconstruct player-facing title/description lines (including furigana surface text and readings), and emit an ID-keyed message table with bytecode provenance. | `extract_message_table.py [OUTNAME]` | `.BIN` → `build/data/.json` | -| `extract_init.py` | Parse a `*INIT` data table (auto-detects name / numeric / footer / mixed shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate direct and negative-value writes, and separate parallel `fields` from linked row-major `record_fields`; when flat global ranges overlap, a parallel base established by other records wins over a coincidental row-table cell calculation. Mixed tables recover selector-dispatched records, condition strings, scalars, preallocated buffer cells, consumer-confirmed strides, and length-prefixed footer arrays; STINIT additionally assembles confirmed buffers into `object_placements` and `enemy_spawns`, joins object type names and available descriptions from OBINIT, and exposes consumer-proven tagged payloads (teleport, treasure, card, initial faction, non-triggering hazard/barrier faction, and OBINIT-gated initial object state), preserves engine-dead type-27 writes under `ignored_payload_fields`, and exposes reinforcement schedules and first-clear-only enemy gates. ITINIT and SKINIT join ITMES/SKMES messages. Top-level `field_semantics` maps raw keys to canonical global/column names, while each record's `semantic_fields` is the generated name-keyed join; complete footer copies map to a row and expose its values without the raw provenance wrapper. Raw keys and unresolved tagged payload cells remain provenance. OUTNAME accepts a stem or one `.json` suffix and rejects paths. Refreshes the generated data index. | `extract_init.py [OUTNAME] [--mode …]` | `
.BIN` plus OBINIT for STINIT, matching `.BIN` when supported, and `build/globals.json` → `build/data/.json`, `build/data/README.md` | -| `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric/mixed table: message coverage, per-scalar/string/array-cell/footer-array population and value distributions, representative records, and direct opcode/script consumers. `--message-query REGEX` shows every matching name/message beside all populated fields for semantic correlation. Findings are evidence only; confirmed meanings go in `vm-map/globals.toml`. | `init_table_profile.py
[--build] [--limit N] [--message-query REGEX]` | `build/data/
.json` + corpus → stdout; with `--build`, `build/data/
-field-profile.{json,md}` | -| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based and mixed selector-dispatched INIT extraction, MES reconstruction/joins, footer-array accounting and semantic projection, OBINIT object-definition extraction, STINIT object/enemy joins and tagged-payload behavior, and field/message profiling. | run each directly | — | +| `extract_init.py` | Parse a `*INIT` data source (auto-detects name / numeric / footer / mixed / rules shape). Name tables infer their reserved record span, preserve sparse one-based runtime ids, distinguish lookup bases from first written cells, statically evaluate direct and negative-value writes, and separate parallel `fields` from linked row-major `record_fields`; when flat global ranges overlap, a parallel base established by other records wins over a coincidental row-table cell calculation. Mixed tables recover selector-dispatched records, condition strings, scalars, preallocated buffer cells, consumer-confirmed strides, and length-prefixed footer arrays; STINIT additionally assembles confirmed buffers into `object_placements` and `enemy_spawns`, joins object type names and available descriptions from OBINIT, and exposes consumer-proven tagged payloads (teleport, treasure, card, initial faction, non-triggering hazard/barrier faction, and OBINIT-gated initial object state), preserves engine-dead type-27 writes under `ignored_payload_fields`, and exposes reinforcement schedules and first-clear-only enemy gates. Rules mode decodes CCINIT's source-ordered class-change predicates and output accumulators, joining EBINIT unit names, SKINIT skill names, titles, named stat bonuses, cost deltas, and applied-state slots. ITINIT and SKINIT join ITMES/SKMES messages. Top-level `field_semantics` maps raw keys to canonical global/column names, while each record's `semantic_fields` is the generated name-keyed join; complete footer copies map to a row and expose its values without the raw provenance wrapper. Raw keys and unresolved tagged payload cells remain provenance. OUTNAME accepts a stem or one `.json` suffix and rejects paths. Refreshes the generated data index. | `extract_init.py
[OUTNAME] [--mode …]` | `
.BIN` plus EBINIT/SKINIT for CCINIT, OBINIT for STINIT, matching `.BIN` when supported, and `build/globals.json` → `build/data/.json`, `build/data/README.md` | +| `init_table_profile.py` | Build the static investigation surface for an extracted name/numeric/mixed/rules table: per-scalar/string/array-cell/footer-array population and value distributions, representative records, and direct opcode/script consumers. Name tables also report message coverage; rule tables summarize covered units, titled and level-independent rules, threshold/slot distributions, and skill awards. `--message-query REGEX` shows every matching name/message beside all populated fields for semantic correlation. Findings are evidence only; confirmed meanings go in `vm-map/globals.toml`. | `init_table_profile.py
[--build] [--limit N] [--message-query REGEX]` | `build/data/
.json` + corpus → stdout; with `--build`, `build/data/
-field-profile.{json,md}` | +| `test_extract_init.py`, `test_init_table_profile.py` | Regression checks for sparse one-based, mixed selector-dispatched, and CCINIT rule extraction; MES reconstruction/joins; footer-array accounting and semantic projection; EBINIT/SKINIT/OBINIT definition joins; STINIT object/enemy joins and tagged-payload behavior; and field/message/rule profiling. | run each directly | — | | `global_map.py` | Build the partial global-variable name map from static evidence. | `global_map.py` | corpus + `build/data/` → `build/global-var-map.{json,md}` | ## VM diff --git a/docs/vm-mapping-plan.md b/docs/vm-mapping-plan.md index 4be431d..eccbf22 100644 --- a/docs/vm-mapping-plan.md +++ b/docs/vm-mapping-plan.md @@ -53,8 +53,9 @@ - [x] **2.0 — Project structure.** Established `docs/`, `build/{disasm,text,data,scripts-json}/`, `godot/`; game install stays read-only in place. Also relaxed the loader magic check to the `SYS4` family (`SYS4424` patch scripts now parse — was silently skipping 5 scripts). - [x] **2.1 — Text corpora.** `tools/extract_phase2.py` → 481/481 scripts: full disassembly (`build/disasm/*.asm`), per-script strings, `build/text/dialogue.jsonl` (**30,057 show-text lines** — the translation corpus), `build/text/strings.jsonl` (38,449 strings tagged by source opcode), `build/manifest.json`. -- [x] **2.2 — `*INIT` data tables → JSON.** `tools/extract_init.py` auto-detects table shape (`name`/`numeric`/`footer`/`mixed`) → **SKINIT (131 skills), ITINIT (287 items), EBINIT (277 units), OBINIT (46 object definitions)** [name: sparse one-based name/description/fields], **CGINIT (379 CG entries)** [numeric: index-keyed columns], **MPINIT (1472 map records)** [footer: length-prefixed arrays], and **STINIT (74 stages)** [mixed: selector-dispatched strings/scalars/buffer cells/footer arrays]. Validated; see `build/data/README.md`. Raw addresses remain bytecode provenance; confirmed semantics come from `vm-map/globals.toml`. +- [x] **2.2 — `*INIT` data and rule sources → JSON.** `tools/extract_init.py` auto-detects shape (`name`/`numeric`/`footer`/`mixed`/`rules`) → **SKINIT (131 skills), ITINIT (287 items), EBINIT (277 units), OBINIT (46 object definitions)** [name: sparse one-based name/description/fields], **CGINIT (379 CG entries)** [numeric: index-keyed columns], **MPINIT (1472 map records)** [footer: length-prefixed arrays], **STINIT (74 stages)** [mixed: selector-dispatched strings/scalars/buffer cells/footer arrays], and **CCINIT (71 class-change rules over 33 units)** [rules: unit/level/state predicates plus title/cost/stat/skill effects]. Validated; see `build/data/README.md`. Raw addresses remain bytecode provenance; confirmed semantics come from `vm-map/globals.toml`. - [x] **2.3 — Extract `STINIT`'s 74 sparse stage records.** The mixed mode identifies the dominant `scjump_progress_a` dispatch, recovers 29 preallocated buffer layouts (including six consumer-confirmed row strides), and keeps four condition strings, six scalars, fixed-buffer writes, and all 1,396 footer-array copies separated by stage id. Profiles supply population/value and direct-consumer evidence. Header/map semantics cover conditions, BGM, turn/replay/clear-reward settings, and map texture overrides. The object join assembles 2,312 placements with type/position/gates, OBINIT names and available descriptions, 604 reinforcement schedules, and typed initial-faction, teleport, treasure, card-list, non-triggering-faction, and initial-object-state payloads; FIELD's dedicated special-spawn path proves the final three populated type-27 tagged writes are engine-dead, so they remain visible as ignored provenance rather than unresolved semantics. The 30-cell enemy family contributes 1,378 joined spawns with unit/faction, placement, difficulty/story gates, level scaling, weighted selection, difficulty-specific movement/battle routine sets, and 485 first-clear-only gates. Raw address views remain alongside generated `semantic_fields`. +- [x] **2.3a — Extract `CCINIT`'s 71 class-change rules.** Rules mode preserves source-order guards and detects the unit id, minimum level, clear applied-state slot, selected title/level, deployment-cost delta, named fourteen-stat bonuses, awarded SKINIT skills, and state slot set by each rule. EBINIT/SKINIT definition joins and the common global semantic projection coexist with raw addresses. CALCCC/ADDEXP establish the apply/report ABI; EVOLVE explains the two level-independent empty-title Lily rules. The generated profile covers 33 units, 69 titled rules, 30 skill awards, three used promotion slots, and 19 populated output fields. - [x] **2.4 — Partial global-var map BUILT + wired into the disassembler.** `tools/global_map.py` → `build/global-var-map.{json,md}` (16,354/49,435 globals labelled: string tables, `*INIT` field arrays, 122 record tables w/ strides, current-entity index pointers). `sys4load` renders the labels inline (`=rec[s30]`, `=current-entity-index?`). See `docs/name-resolution.md`. - [ ] **2.5 — Grow the global-var map (future, incremental).** Static first: fold in `*MES` writers; label 2D record tables by their reader scripts. Then Frida to name *which stat* each field is. Full detail: `docs/name-resolution.md` → "Future step — growing the map". Also deferred: `call-script` id→name resolution (engine-level — SCJUMP.BIN decode or Frida; see `docs/name-resolution.md` #1). diff --git a/tools/extract_init.py b/tools/extract_init.py index 1358b53..82e8e6b 100644 --- a/tools/extract_init.py +++ b/tools/extract_init.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Extract a *INIT data table to JSON. Auto-detects the table's shape. -*INIT scripts populate global arrays and work buffers with static game data. Four shapes seen: +*INIT scripts populate global arrays and work buffers with static game data. Five shapes seen: name — records keyed by a name string. Each record: set-string(name), static field writes, set-string(desc). Arrays indexed by record id in lockstep (+1/record). @@ -12,12 +12,14 @@ footer into per-record global arrays. The data lives in the footer. (MPINIT maps) mixed — a sparse selector dispatch writes strings, scalars, fixed-buffer cells, and footer arrays for one runtime record. (STINIT stages) + rules — conditional blocks select a unit promotion and add effects to shared output + buffers. (CCINIT class changes) Records are {id, name?, desc?, fields:{"0x": 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. -Usage: py -3.11 -X utf8 tools/extract_init.py
[OUTNAME] [--mode name|numeric|footer|mixed] +Usage: py -3.11 -X utf8 tools/extract_init.py
[OUTNAME] [--mode name|numeric|footer|mixed|rules] """ from __future__ import annotations import json @@ -41,6 +43,22 @@ T_GLOBAL_STRING = 5 T_IMM = 0 T_LOCAL_INT = 9 +CURRENT_UNIT_ID = 0x66715 +CURRENT_UNIT_LEVELS = 0x6930 +UNIT_CLASS_CHANGE_STATE = 0x573BB +CLASS_CHANGE_TITLE_OUT = 0x26B4 +CLASS_CHANGE_LEVEL_OUT = 0xAB8E7 +CLASS_CHANGE_COST_OUT = 0xAB8E8 +CLASS_CHANGE_STATS_OUT = 0xAB8E9 +CLASS_CHANGE_SKILLS_OUT = 0xAB8F7 +CLASS_CHANGE_FLAGS_OUT = 0xAB8FB + +UNIT_STAT_COLUMNS = ( + "accuracy", "evasion", "physical_attack", "physical_defense", + "magic_attack", "magic_defense", "speed", "luck", "critical_chance", + "capture_power", "movement", "max_hp", "max_sp", "max_fs", +) + MESSAGE_TABLES = { "ITINIT": "ITMES", "SKINIT": "SKMES", @@ -130,10 +148,35 @@ def _mixed_guards(scr): return [guard for guard in candidates if guard["selector"] == selector] +def _class_change_guards(scr) -> list[dict]: + """Find CCINIT's source-ordered `current_unit_id == immediate` rule guards.""" + guards = [] + for index, ins in enumerate(scr.instructions): + if (sys4load.display_label(ins.opcode) == "eq" + and len(ins.args) >= 3 + and ins.args[0][0] == T_LOCAL_INT + and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID) + and ins.args[2][0] == T_IMM): + guards.append({ + "index": index, + "offset": ins.offset, + "unit_id": ins.args[2][1], + }) + return guards + + def detect_mode(scr): ops = [ins.opcode for ins in scr.instructions] has_str = any(ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING for ins in scr.instructions) + has_class_change_title = any( + ins.opcode == SET_STRING + and ins.args + and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT) + for ins in scr.instructions + ) + if has_class_change_title and len(_class_change_guards(scr)) >= 4: + return "rules" if has_str and len(_mixed_guards(scr)) >= 4: return "mixed" if has_str: @@ -143,6 +186,157 @@ def detect_mode(scr): return "footer" if n_footer >= max(4, n_int) else "numeric" +@cache +def unit_definition_names() -> dict[int, str]: + """Load EBINIT's authoritative unit names by definition id.""" + records, _ = extract_name(sys4load.load(resolve("EBINIT"))) + return {record["id"]: record["name"] for record in records} + + +@cache +def skill_definition_names() -> dict[int, str]: + """Load SKINIT's authoritative skill names by skill id.""" + records, _ = extract_name(sys4load.load(resolve("SKINIT"))) + return {record["id"]: record["name"] for record in records} + + +def extract_class_change_rules(scr): + """Extract CCINIT's promotion predicates and accumulator effects. + + CALCCC initializes the output block, invokes CCINIT, and applies the selected + title, cost delta, fourteen stat deltas, and up to three skills to the unit. + Each CCINIT block is therefore a rule rather than a row in a static table. + """ + guards = _class_change_guards(scr) + if not guards: + return [], {} + + unit_names = unit_definition_names() + skill_names = skill_definition_names() + records = [] + instructions = scr.instructions + for rule_index, guard in enumerate(guards): + end = guards[rule_index + 1]["index"] if rule_index + 1 < len(guards) else len(instructions) + block = instructions[guard["index"]:end] + record = { + "id": rule_index + 1, + "guard_offset": f"0x{guard['offset']:x}", + "unit_id": guard["unit_id"], + "unit_name": unit_names.get(guard["unit_id"], ""), + "fields": {}, + "string_fields": {}, + "array_fields": {}, + } + + for ins in block: + label = sys4load.display_label(ins.opcode) + if (label == "lookup-array" + and len(ins.args) >= 3 + and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_LEVELS) + and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID)): + record["level_table"] = f"0x{CURRENT_UNIT_LEVELS:x}" + elif (label == "gre" + and len(ins.args) >= 3 + and ins.args[1][0] == 12 + and ins.args[2][0] == T_IMM + and "level_table" in record): + record["minimum_level"] = ins.args[2][1] + elif (label == "lookup-array-2d" + and len(ins.args) >= 5 + and ins.args[1] == (T_GLOBAL_INT, UNIT_CLASS_CHANGE_STATE) + and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID) + and ins.args[3] == (T_IMM, 10) + and ins.args[4][0] == T_IMM): + record["class_change_slot_index"] = ins.args[4][1] + elif (label == "ne" + and len(ins.args) >= 3 + and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID) + and ins.args[2][0] == T_GLOBAL_INT): + record["excluded_when_unit_equals_global"] = f"0x{ins.args[2][1]:x}" + elif (ins.opcode == SET_STRING + and len(ins.args) >= 2 + and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT)): + title = scr.strings.get(ins.args[1][1], ("",))[0] + record["title"] = title + record["name"] = title + record["string_fields"][f"0x{CLASS_CHANGE_TITLE_OUT:x}"] = title + elif (write := _static_global_write(ins)) is not None: + destination, value = write + if destination == CLASS_CHANGE_LEVEL_OUT: + record["selected_level"] = value + record["fields"][f"0x{destination:x}"] = value + elif CLASS_CHANGE_SKILLS_OUT <= destination < CLASS_CHANGE_SKILLS_OUT + 4: + record["array_fields"][ + f"0x{CLASS_CHANGE_SKILLS_OUT:x}/{destination - CLASS_CHANGE_SKILLS_OUT}" + ] = value + elif CLASS_CHANGE_FLAGS_OUT <= destination < CLASS_CHANGE_FLAGS_OUT + 10: + record["array_fields"][ + f"0x{CLASS_CHANGE_FLAGS_OUT:x}/{destination - CLASS_CHANGE_FLAGS_OUT}" + ] = value + elif (label == "add" + and len(ins.args) >= 3 + and ins.args[0][0] == T_GLOBAL_INT + and ins.args[0] == ins.args[1] + and ins.args[2][0] == T_IMM): + destination = ins.args[0][1] + value = ins.args[2][1] + if destination == CLASS_CHANGE_COST_OUT: + record["fields"][f"0x{destination:x}"] = value + elif CLASS_CHANGE_STATS_OUT <= destination < CLASS_CHANGE_STATS_OUT + 14: + record["array_fields"][ + f"0x{CLASS_CHANGE_STATS_OUT:x}/{destination - CLASS_CHANGE_STATS_OUT}" + ] = value + + stat_bonuses = { + UNIT_STAT_COLUMNS[int(key.split("/")[1])]: value + for key, value in record["array_fields"].items() + if key.startswith(f"0x{CLASS_CHANGE_STATS_OUT:x}/") + } + if stat_bonuses: + record["stat_bonuses"] = stat_bonuses + record["deployment_cost_delta"] = record["fields"].get( + f"0x{CLASS_CHANGE_COST_OUT:x}", 0 + ) + skill_awards = [] + for key, skill_id in record["array_fields"].items(): + if not key.startswith(f"0x{CLASS_CHANGE_SKILLS_OUT:x}/") or skill_id <= 0: + continue + skill_awards.append({ + "skill_slot": int(key.split("/")[1]) + 1, + "skill_id": skill_id, + "skill_name": skill_names.get(skill_id, ""), + }) + if skill_awards: + record["skill_awards"] = skill_awards + record["state_flag_indices_set"] = [ + int(key.split("/")[1]) + for key, value in record["array_fields"].items() + if key.startswith(f"0x{CLASS_CHANGE_FLAGS_OUT:x}/") and value + ] + records.append(record) + + array_columns = sorted({ + key for record in records for key in record["array_fields"] + }, key=lambda key: tuple(int(part, 0) for part in key.split("/"))) + string_columns = sorted({ + key for record in records for key in record["string_fields"] + }, key=lambda key: int(key, 0)) + return records, { + "rule_kind": "unit-class-change", + "selector_global": f"0x{CURRENT_UNIT_ID:x}", + "unit_level_table": f"0x{CURRENT_UNIT_LEVELS:x}", + "persistent_state_table": f"0x{UNIT_CLASS_CHANGE_STATE:x}", + "selection_policy": "highest selected_level among eligible unapplied rules", + "array_layouts": { + f"0x{CLASS_CHANGE_STATS_OUT:x}": {"length": 14}, + f"0x{CLASS_CHANGE_SKILLS_OUT:x}": {"length": 4}, + f"0x{CLASS_CHANGE_FLAGS_OUT:x}": {"length": 10}, + }, + "string_field_columns": string_columns, + "array_field_columns": array_columns, + } + + def _eval_static_arg(arg, locals_: dict[int, int]): arg_type, value = arg if arg_type == T_IMM: @@ -820,6 +1014,12 @@ def write_data_index(data_dir: Path) -> None: "available descriptions; consumer-proven tagged payload variants receive semantic names while", "engine-dead tagged writes remain in `ignored_payload_fields` and unresolved", "type-specific/mode parameters remain in `unknown_fields`.", + "", + "Rule-mode tables preserve source-order rule ids and bytecode guard offsets while", + "joining their predicates and shared-buffer effects. CCINIT exposes unit/level/applied-slot-index", + "eligibility, titles, deployment-cost and named stat deltas, awarded SKINIT skills, and", + "the persistent state slot set by each class change. Raw output addresses remain beside", + "the joined EBINIT unit and SKINIT skill names.", "Use `tools/init_table_profile.py
--build` to generate value/population and", "direct-consumer evidence.", "", @@ -858,6 +1058,7 @@ def main() -> int: "numeric": extract_numeric, "footer": extract_footer, "mixed": extract_mixed, + "rules": extract_class_change_rules, }[mode] recs, meta = extractor(scr) if mode == "name" and name in MESSAGE_TABLES: diff --git a/tools/init_table_profile.py b/tools/init_table_profile.py index e7d4fe7..d285465 100644 --- a/tools/init_table_profile.py +++ b/tools/init_table_profile.py @@ -37,8 +37,8 @@ def load_table(name: str) -> dict: if not path.exists(): raise SystemExit(f"missing extracted table: {path}") data = json.loads(path.read_text(encoding="utf8")) - if data.get("mode") not in {"name", "numeric", "mixed"}: - raise SystemExit(f"{name}: field profiling requires name/numeric/mixed mode") + if data.get("mode") not in {"name", "numeric", "mixed", "rules"}: + raise SystemExit(f"{name}: unsupported field-profiling mode {data.get('mode')!r}") return data @@ -83,7 +83,11 @@ def profile_columns(data: dict) -> list[dict]: key = f"0x{base:x}" add(key, { "key": key, - "kind": "scalar-field" if data.get("mode") == "mixed" else "parallel-array", + "kind": ( + "scalar-field" if data.get("mode") == "mixed" + else "rule-output" if data.get("mode") == "rules" + else "parallel-array" + ), "base": key, "stride": None, "column": None, "semantic_name": field_semantics.get(key), @@ -156,6 +160,31 @@ def profile_columns(data: dict) -> list[dict]: return rows +def profile_rules(data: dict) -> dict: + """Summarize predicates and joined effects for conditional rule programs.""" + if data.get("mode") != "rules": + return {} + records = data["records"] + return { + "unit_count": len({record["unit_id"] for record in records}), + "titled_rule_count": sum(bool(record.get("title")) for record in records), + "level_independent_rule_count": sum( + "minimum_level" not in record for record in records + ), + "minimum_levels": dict(sorted(collections.Counter( + str(record["minimum_level"]) + for record in records if "minimum_level" in record + ).items(), key=lambda item: int(item[0]))), + "class_change_slot_indices": dict(sorted(collections.Counter( + str(record["class_change_slot_index"]) + for record in records if "class_change_slot_index" in record + ).items(), key=lambda item: int(item[0]))), + "skill_award_count": sum( + len(record.get("skill_awards", [])) for record in records + ), + } + + def profile_messages(data: dict) -> dict: """Summarize the joined player-facing message evidence.""" records = data["records"] @@ -270,14 +299,26 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str: "", f"- records: {data['record_count']}", f"- populated fields: {len(rows)}", - f"- player-facing messages: {message_profile['population']}/{data['record_count']} " - f"({message_profile['coverage']:.0%})", - f"- messages with furigana spans: {message_profile['furigana_records']}", + ] + if rule_profile := profile_rules(data): + lines.extend([ + f"- covered units: {rule_profile['unit_count']}", + f"- titled rules: {rule_profile['titled_rule_count']}/{data['record_count']}", + f"- level-independent rules: {rule_profile['level_independent_rule_count']}", + f"- awarded skills: {rule_profile['skill_award_count']}", + ]) + else: + lines.extend([ + f"- player-facing messages: {message_profile['population']}/{data['record_count']} " + f"({message_profile['coverage']:.0%})", + f"- messages with furigana spans: {message_profile['furigana_records']}", + ]) + lines.extend([ f"- rows shown: {len(shown)} (ranked by record coverage, then consumer references)", "", "| field | meaning | populated | distinct | range | direct refs | readers | common values | examples |", "|---|---|---:|---:|---|---:|---|---|---|", - ] + ]) for row in shown: value_range = "—" if row["min"] is None else f"{row['min']}..{row['max']}" readers = ", ".join(entry["script"].removesuffix(".BIN") @@ -325,7 +366,9 @@ def main() -> int: "string_field_count": sum(row["kind"] == "string-field" for row in rows), "array_cell_count": sum(row["kind"] == "array-cell" for row in rows), "footer_array_count": sum(row["kind"] == "footer-array" for row in rows), + "rule_output_count": sum(row["kind"] == "rule-output" for row in rows), "message_profile": messages, + "rule_profile": profile_rules(data), "columns": sorted(rows, key=lambda row: ( int(row["base"], 16), row["stride"] or 0, row["column"] or 0 )), diff --git a/tools/test_extract_init.py b/tools/test_extract_init.py index 16daa46..14fe27f 100644 --- a/tools/test_extract_init.py +++ b/tools/test_extract_init.py @@ -263,6 +263,52 @@ def test_real_mixed_table() -> None: ) == 485, "STINIT exposes every first-clear-only enemy gate") +def test_real_class_change_rules() -> None: + script = sys4load.load(extract_init.resolve("CCINIT")) + check(extract_init.detect_mode(script) == "rules", + "CCINIT auto-detects as a conditional rule program") + records, meta = extract_init.extract_class_change_rules(script) + check(len(records) == 71, "CCINIT extracts all 71 class-change rules") + check(len({record["unit_id"] for record in records}) == 33, + "CCINIT rules cover 33 unit definitions") + check(meta["selector_global"] == "0x66715" + and meta["persistent_state_table"] == "0x573bb", + "CCINIT exposes its unit selector and persistent state table") + check(records[0]["unit_name"] == "リリィ:少女時代" + and records[0]["selected_level"] == -1 + and records[0]["excluded_when_unit_equals_global"] == "0x32f0", + "CCINIT preserves Lily's level-independent form rule") + sylphine = records[2] + check(sylphine["unit_id"] == 5 + and sylphine["minimum_level"] == 50 + and sylphine["class_change_slot_index"] == 0 + and sylphine["title"] == "聖王女", + "CCINIT decodes unit, level, slot, and awarded title") + check(sylphine["deployment_cost_delta"] == 2 + and sylphine["stat_bonuses"]["physical_attack"] == 3, + "CCINIT decodes cost and named stat bonuses") + check(sylphine["skill_awards"] == [{ + "skill_slot": 3, "skill_id": 202, "skill_name": "光燐衝撃", + }], "CCINIT joins awarded skill ids to SKINIT names") + semantics = extract_init.field_semantics(records, meta["array_layouts"]) + check(semantics["0x26b4"] == "class_change_title_output" + and semantics["0xab8e9/2"] == "class_change_stat_bonuses.physical_attack" + and semantics["0xab8f7/2"] == "class_change_skill_awards.skill_slot_3", + "CCINIT raw outputs join to canonical global and column names") + extract_init.attach_semantic_fields(records, semantics) + check(sylphine["semantic_fields"]["class_change_title_output"] == "聖王女" + and sylphine["semantic_fields"][ + "class_change_stat_bonuses.physical_attack" + ] == 3, + "CCINIT rules expose a single semantic field view") + check(all( + record["minimum_level"] == record["selected_level"] + for record in records[2:] + ), "CCINIT normal promotion thresholds match their selected levels") + check(sum(len(record.get("skill_awards", [])) for record in records) == 30, + "CCINIT accounts for all 30 awarded skills") + + def test_real_message_tables() -> None: scripts = paths.scripts() expected = { @@ -436,6 +482,7 @@ if __name__ == "__main__": test_static_negative_write() test_output_name_validation() test_real_mixed_table() + test_real_class_change_rules() test_real_message_tables() test_message_join() test_field_semantics() diff --git a/tools/test_init_table_profile.py b/tools/test_init_table_profile.py index cea1d4c..22815e6 100644 --- a/tools/test_init_table_profile.py +++ b/tools/test_init_table_profile.py @@ -70,6 +70,31 @@ def main() -> int: assert mixed["0x100/6"]["common"][0]["value"] == "[1, 2, 3]" assert mixed["0x40"]["examples"][0]["name"] == "Win" + rule_fixture = { + "table": "RULES", + "mode": "rules", + "records": [ + { + "id": 1, "unit_id": 3, "title": "", "fields": {"0x10": -1}, + "class_change_slot_index": 0, "skill_awards": [{"skill_id": 2}], + }, + { + "id": 2, "unit_id": 5, "title": "Promoted", + "minimum_level": 50, "class_change_slot_index": 1, + "fields": {"0x10": 50}, + }, + ], + } + rule_rows = {row["key"]: row for row in profile.profile_columns(rule_fixture)} + assert rule_rows["0x10"]["kind"] == "rule-output" + rule_summary = profile.profile_rules(rule_fixture) + assert rule_summary["unit_count"] == 2 + assert rule_summary["titled_rule_count"] == 1 + assert rule_summary["level_independent_rule_count"] == 1 + assert rule_summary["minimum_levels"] == {"50": 1} + assert rule_summary["class_change_slot_indices"] == {"0": 1, "1": 1} + assert rule_summary["skill_award_count"] == 1 + messages = profile.profile_messages(fixture) assert messages["population"] == 1 assert messages["coverage"] == 1 / 3 diff --git a/vm-map/globals.toml b/vm-map/globals.toml index 8764d8f..4106750 100644 --- a/vm-map/globals.toml +++ b/vm-map/globals.toml @@ -1039,6 +1039,99 @@ usage = "Selected skill for each battle side. BTL displays a positive entry thro source = "investigation" confidence = "high" +[[global]] +address = "0x26b4" +name = "class_change_title_output" +category = "string-table" +type = "string" +value_domain = "CCINIT-awarded Japanese title; empty for Lily form-transition rules" +usage = "CCINIT writes the title selected by each eligible class-change rule. CALCCC copies it to unit_class_titles for a successful promotion, and ADDEXP includes the same string in the level-up notification." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x27e" +name = "unit_class_titles" +category = "string-table" +type = "string[100]" +value_domain = "CCINIT class title by playable-unit id" +usage = "Persistent per-unit class/title string table. CALCCC writes the selected class_change_title_output into the current unit's cell; character and status presentation scripts read the resulting title." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x57357" +name = "unit_deployment_cost_adjustments" +category = "data-table" +type = "int[100]" +value_domain = "signed cumulative class-change cost adjustment" +usage = "Persistent per-unit delta added to unit_deployment_cost_base. CALCCC increments it by class_change_deployment_cost_delta, while SETCH, SETEN, ADDEXP, and deployment scripts include it when constructing or updating runtime command cost." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x573bb" +name = "unit_class_change_state" +category = "data-table" +type = "int[100][10]" +columns = { "0" = "promotion_slot_1", "1" = "promotion_slot_2", "2" = "promotion_slot_3", "3" = "promotion_slot_4", "4" = "promotion_slot_5", "5" = "promotion_slot_6", "6" = "promotion_slot_7", "7" = "promotion_slot_8", "8" = "promotion_slot_9", "9" = "promotion_slot_10" } +value_domain = "0 unapplied, 1 applied" +usage = "Persistent ten-slot class-change state for each playable unit. CALCCC copies the current unit's row to class_change_state_work before invoking the rule scripts and persists the updated row; CCINIT requires the rule's slot to be zero so each promotion is applied once." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x66715" +name = "current_unit_id" +category = "index-pointer" +type = "int" +value_domain = "EBINIT unit definition id / playable-unit row id" +usage = "Shared current-unit selector used by character growth and setup scripts. CCINIT keys every class-change rule on this value; CALCCC, ADDEXP, SETEN, SALLY, and related scripts use it to index unit definitions and persistent per-unit state." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x6930" +name = "unit_current_levels" +category = "data-table" +type = "int[100]" +value_domain = "current playable-unit level" +usage = "Persistent current level by playable-unit id. CCINIT compares current_unit_id's row with each promotion threshold; ADDEXP increments and reports the same value." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x69f8" +name = "unit_current_stats" +category = "data-table" +type = "int[100][14]" +columns = { "0" = "accuracy", "1" = "evasion", "2" = "physical_attack", "3" = "physical_defense", "4" = "magic_attack", "5" = "magic_defense", "6" = "speed", "7" = "luck", "8" = "critical_chance", "9" = "capture_power", "10" = "movement", "11" = "max_hp", "12" = "max_sp", "13" = "max_fs" } +value_domain = "nonnegative current stat values" +usage = "Persistent fourteen-stat row for each playable unit, using the same column order as unit_base_stats. CALCCC adds class_change_stat_bonuses to the current unit's row and clamps each result to the shared stat caps." +source = "investigation" +confidence = "high" + +[[global]] +address = "0x74e8" +name = "unit_skill_ids" +category = "data-table" +type = "int[100][4]" +columns = { "0" = "skill_slot_1", "1" = "skill_slot_2", "2" = "skill_slot_3", "3" = "skill_slot_4" } +value_domain = "SKINIT skill id; zero means empty" +usage = "Persistent four-skill row for each playable unit. CALCCC copies positive class_change_skill_awards into the first three slots after a promotion; ADDEXP compares the before/after row to report learned or replaced skills." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xab8c7" +name = "class_change_rule_script_ids" +category = "data-table" +type = "int[32]" +value_domain = "packed SYS4 script ids; zero means unused" +usage = "CALCCC iterates these 32 cells and call-scripts every positive entry to evaluate class-change providers. CCINIT is the shipped rule program decoded into build/data/CCINIT.json." +source = "investigation" +confidence = "high" + [[global]] address = "0x6be" name = "" @@ -1525,14 +1618,56 @@ depends_on = [] [[global]] address = "0xab8e7" -name = "" -category = "story-flag" +name = "class_change_selected_level" +category = "unknown" type = "int" -value_domain = "one of {0, 10, 15, 20, 25, 30, 35, 40}" -usage = "TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [0, 10, 15, 20, 25, 30, 35, 40]; writers=['CALCCC.BIN', 'CCINIT.BIN']." -source = "auto-shape" -confidence = "med" -depends_on = [] +value_domain = "-1 Lily form transition, 0 no promotion, otherwise selected threshold 10..90" +usage = "CALCCC clears this accumulator before invoking the class-change rule scripts. Each eligible CCINIT rule replaces it only when its threshold is higher than the current selection; nonzero then signals CALCCC and ADDEXP to apply and report the chosen promotion." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xab8e8" +name = "class_change_deployment_cost_delta" +category = "unknown" +type = "int" +value_domain = "nonnegative deployment-cost increment" +usage = "Shared class-change output initialized by CALCCC and incremented by the selected CCINIT rule. CALCCC adds it to unit_deployment_cost_adjustments and ADDEXP reports the signed cost change." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xab8e9" +name = "class_change_stat_bonuses" +category = "unknown" +type = "int[14]" +columns = { "0" = "accuracy", "1" = "evasion", "2" = "physical_attack", "3" = "physical_defense", "4" = "magic_attack", "5" = "magic_defense", "6" = "speed", "7" = "luck", "8" = "critical_chance", "9" = "capture_power", "10" = "movement", "11" = "max_hp", "12" = "max_sp", "13" = "max_fs" } +value_domain = "nonnegative promotion deltas" +usage = "Fourteen-cell class-change accumulator using the unit stat column order. CALCCC zeroes it, CCINIT adds the selected rule's bonuses, and CALCCC adds the result to unit_current_stats with per-column caps. EVOLVE separately clears it and calls CCINIT to preview the next Lily form's movement bonus." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xab8f7" +name = "class_change_skill_awards" +category = "unknown" +type = "int[4]" +columns = { "0" = "skill_slot_1", "1" = "skill_slot_2", "2" = "skill_slot_3", "3" = "skill_slot_4" } +value_domain = "SKINIT skill id; zero means no award" +usage = "Four-cell class-change skill output. CALCCC zeroes the buffer, CCINIT writes awarded skill ids, and CALCCC copies positive values from the first three cells into unit_skill_ids before ADDEXP reports the changes." +source = "investigation" +confidence = "high" + +[[global]] +address = "0xab8fb" +name = "class_change_state_work" +category = "unknown" +type = "int[10]" +columns = { "0" = "promotion_slot_1", "1" = "promotion_slot_2", "2" = "promotion_slot_3", "3" = "promotion_slot_4", "4" = "promotion_slot_5", "5" = "promotion_slot_6", "6" = "promotion_slot_7", "7" = "promotion_slot_8", "8" = "promotion_slot_9", "9" = "promotion_slot_10" } +value_domain = "0 unapplied, 1 applied" +usage = "Ten-cell working copy of the current unit's unit_class_change_state row. CCINIT sets the slot belonging to an awarded rule; CALCCC manages the block copy between this buffer and persistent per-unit state." +source = "investigation" +confidence = "high" [[global]] address = "0xaba5c"