Files
OpenMaidEngine/docs/name-resolution.md
2026-07-28 12:25:24 -04:00

810 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Name resolution — recovering what the compiler stripped
The disassembler reads the SYS4 bytecode's **operations and control flow** cleanly (see any
`build/disasm/*.asm`). What it can't show is the two kinds of *names* the AGE compiler
discarded: **which function a call targets** (#1) and **what a global variable means** (#2).
Both are data-labeling problems, not decoding problems. This note records what each is, what
we found, and how tractable it is.
Motivating example: `RECOVER.BIN` translates to correct pseudocode today, but reads as
`call-script 0x329d` (#1) and `C[unit][s] = E[unit][s]` over raw addresses (#2). Naming those
would make it read like source.
---
## #1 — `call-script` target resolution (naming the call graph) — ✅ SOLVED (2026-07-07)
**RESOLVED via native-RE.** `call-script <id>` is a **direct RAW index into the SYS4INI file table**
the very asset index we already parsed. No hidden engine registry: SYS4INI *is* the registry. Cracked
by decompiling the handler chain in Ghidra (op 0x03 → `FUN_0041bc90` → loader `FUN_0040e980`
resolver `FUN_0044f390`, which does `record = table_base + id*0x50` over the 80-byte SYS4INI records).
**Statically confirmed:** all 297/297 distinct corpus `call-script` ids resolve to a `.BIN` script with
a semantically-exact name (`0x1ab→ADDITEM`, `0x2ae7→MES`, `0x143→BUNKI`), 0 out-of-range. Full
mechanism in `engine-re.md` (“op 0x03 (call-script)…”). Tooling: `parse_sys4ini.py`
`build/callscript-names.json` (id→name); `sys4load` renders `call-script 0x1ab =ADDITEM.BIN`; the
`build/disasm/*.asm` call graph now reads by name. The one caveat: index the RAW SYS4INI records
(*including* the 2 `@` placeholders) — `asset-index.json` carries each entry's `raw_index` (= the id)
for exactly this. **Runtime (VFS-A):** `Sys4AssetCatalog` now reads that raw table directly and
`Sys4ScriptProvider` opens the selected record through loose-first/bounded-ALF storage; generated JSON is
only the disassembler annotation and parity oracle. The VM executes the loaded target as a nested frame.
The original analysis (kept below for provenance) had concluded this was engine-level and deferred — it
was, and the Ghidra loop is what resolved it.
**What it is (original framing).** `call-script N` (Kelebek opcode 0x03) carries a bare number —
`0x329d`, `0x2ade` — the id of an engine entry point. To render `call RECOVER` instead of
`call-script 0x329d` you need a table `id → (script, entry)`.
**Findings (inspected 2026-07-06):**
- `SYSTEM4.BIN` is **not** an index — it's a small SYS4 script (375 instrs) titled
"SYSTEM4 INIT", the engine boot/init routine (ADV mode, fonts, error text).
- `SYS4INI.BIN` (`S4IC422`) is the **ALF asset index** — archive filenames for extraction
(`SYSTEM4.BIN`, `M002.OGG`, `EV049A.AGF`…), not a script-call registry.
- The ids are large and sparse (`0x329d` = 12,957 ≫ 481 scripts), so the number is an index
into a global **entry-point registry** the engine builds, not a script-file index.
- Even Kelebek's reference decompiler leaves these numeric (its comment only says "param =
SYSTEM4.bin index"). So this is genuinely **unresolved upstream**, not merely unfinished.
**Why it's engine-level (harder than a file lookup).** There is no `id → name` table sitting
on disk to read. Resolving it needs one of:
- ~~Decode `SCJUMP.BIN`~~ **RULED OUT as the registry (recon 2026-07-06).** `SCJUMP.BIN` (29,796
instrs) is a **progression state machine**, not an id→code table: it switches on `global 0x3234`
(mode 19) then nested `eq`/`ne`/`and`/`jcc` on flags, ending in `mov`s to output globals. It
decides *what comes next* via state; it barely uses `call-script`. Useful for game-flow logic, not
for resolving `call-script` ids. So the id→code registry is genuinely engine-level.
- **Watch the engine resolve one (Frida)** — breakpoint the `call-script` handler in the running
game, log `id → resolved address/script`. Ground truth; Phase-3 (live-tools) work.
- **Find the registration path** — if a boot script assigns ids to entry points, extract it
statically (SYSTEM4.BIN is far too small to hold ~13k, so it's cumulative or lives in AGE.EXE).
**Status: ✅ SOLVED** (see the banner at the top of this section). It did belong with the
engine/dispatch work — the Ghidra + MCP loop resolved it via the opcode-dispatch table.
**Updates (2026-07-07 through 2026-07-23):** SCJUMP's *decision logic* is decoded as
`(chapter_mode, guards) → decision value`; `call-script` ids are raw SYS4INI file indices; and the
remaining join is now closed. SCINIT writes `G[0x87a57 + decision] = packed SCxxxx resource id`, which
SYSTEM4, FIELD, SALLY, and TRAIN consume. Its parallel `G[0x8a167 + decision]` column is authored
chapter metadata. The `u00428010` guess for this hop was disproven via Ghidra: that operation persists
a selected global cell and is unrelated to dispatch. See `docs/scjump-progression.md` for the full join.
---
## #2 — The global-variable map (naming the data)
**What it is.** The VM has one flat **global memory bank**; the bytecode addresses it by raw
offset (`global-int 0x152616`, `global-int 0x52383`). Each offset is a specific piece of game
state (a unit's HP, the current-unit index, a stat table). The map we want is
`offset → (name, type, structure)`.
**Why it's opaque.** No symbol table exists anywhere; meaning lives in how AGE.EXE and the
scripts *use* each global. Nothing declares "0x152616 is the current unit."
**Why a big chunk is recoverable statically (the tractable one).** Unlike #1, #2 has strong
free handholds — several of which we've already built:
1. **The `*INIT` scripts are the writers, and we already extracted them.** `EBINIT`/`ITINIT`/
`SKINIT`/`CGINIT`/`MPINIT` populate global arrays with names and data (`build/data/*.json`).
The base address `EBINIT` writes 277 unit names into *is* the unit-name table. Each JSON's
`name_array_base`, `desc_array_bases`, `field_columns`, and `record_field_columns` are literally
global addresses and access shapes we can label by which table wrote them.
2. **Strings anchor the string side for free.** `set-string` writes skill names to
`global-string 0x23a3…` → that array is the skill-name table. `*MES` tables likewise.
3. **Access shape reveals structure without names.** A global read as `base[unit*stride + col]`
exposes a per-unit record and its width (RECOVER showed 14-, 3-, 30-column tables). A global
used as the loop-invariant row index everywhere (`0x152616`) is a "current X" pointer.
Constants-compared → mode/flag; only-incremented → counter.
4. **Frida for the ambiguous ones (heavy, ground truth).** Do a known action in-game (take
damage, gain a level), watch which global changes → definitive labels. Reserve for leftovers.
**Feasibility.** A *partial* map — enough to make most gameplay scripts readable — is achievable
now, statically, from methods 13. A *complete* map needs Frida for the tail. It's incremental:
label the ~dozen hottest globals first (biggest readability payoff), grow the rest on demand.
**Partial map — BUILT (v1, refreshed 2026-07-22).** `tools/global_map.py``build/global-var-map.json`
(all evidence) + `build/global-var-map.md` (labelled subset). It ingests `build/data/*.json`
(name/desc/field bases), scans the 481-script corpus for each global's **access shape**
(2D-table base + stride, 1D-array base, row-index, scalar), and ranks "current entity" index
pointers by purity. **Current result: 4,960 of 41,611 distinct globals labelled**
| kind | count | example |
|---|---|---|
| string tables (names/descs/messages) | 3,206 | `0x23a2` = skill-name lookup base |
| per-entity data-field arrays (from *INIT) | 1,353 | dense = shared fields, `?` = sparse per-entity |
| row-major record tables (from access shape) | 122 | `0x52383` = record-table[stride 30] |
| 1D arrays | 253 | |
| index / "current entity" pointers | 26 | `0x152616` (purity 0.51), `0xeff75` (0.95) |
**Validated against `RECOVER`:** the map independently reproduces its hand-traced layout —
`0x4e11b`→stride 14, `0x52383`→stride 30, `0xaacb4`→1D array, `0x152616`→current-entity index.
**Wired into the disassembler.** `sys4load` annotates global operands with the map's high/medium
-confidence labels (low-confidence tail omitted for readability), e.g. RECOVER now renders
`lookup-array-2d p0 (global-int 0x4e11b =rec[s14]) (global-int 0x152616 =current-entity-index?) …`.
Labels are prefixed `=` to mark them as inferred aliases. Regenerate the `.asm` corpus with
`tools/extract_phase2.py` after refreshing the map. Turn it off by deleting/renaming
`build/global-var-map.json` (the loader degrades gracefully).
Confidence is marked per entry; labels ending `?` are low-confidence guesses.
### INIT field-semantics workflow and initial item/skill/unit mappings (2026-07-22)
The old name-mode extractor's boundary rule was wrong for sparse tables: it treated any increasing
`global-string` destination as another description. ITINIT begins with 101 consecutive name-only records,
so the generated JSON collapsed them into item zero and fabricated 67 description columns. Static consumer
evidence also proves the tables are one-based: scripts look up item names from `0x1bd2 + item_id`, while the
first populated name is written to `0x1bd3`. `extract_init.py` now infers the parallel-array record span from
the dominant name-to-description delta (SKINIT 300; ITINIT/EBINIT 1000), recognizes column-zero names inside
that span, emits the one-based runtime id, and distinguishes the lookup base from the first written cell.
Corrected counts are **131 skills, 287 items, and 277 units**. Name-mode INIT scripts also encode negative
constants as `sub destination, 0, magnitude`; the extractor now evaluates that static form as well as `mov`,
recovering 113 negative item cells, 212 negative skill cells, and 86 negative unit cells.
Semantic recovery is an evidence ladder, cheapest and strongest first:
1. Profile each write base across named records (population, value domain, common values and examples).
2. Mine every direct corpus consumer of that base and identify its role from the consuming operation/script.
3. Cross-resolve enums and foreign keys against other INIT/MES tables and visible descriptions.
4. Curate only supported names in `vm-map/globals.toml`; retain uncertainty in the profile rather than
promoting guesses. Use dynamic observation only for fields that remain ambiguous after static consumers.
`tools/init_table_profile.py ITINIT --build` materializes steps 12 in
`build/data/ITINIT-field-profile.{json,md}`. The initial pass names thirteen parallel arrays: catalog sort
key, random-item tier, item category, icon id, shared ITMES handler id, attack and defense elements, weapon
class, granted skill id, minimum/maximum range, essence recovery, and an equipment sex mask. The strongest
joins are independently human-readable: attack/defense values index AFINIT's Japanese attribute strings,
granted-skill values resolve to SKINIT, all handler values resolve to ITMES.BIN, and every min/max-range
record says `range 2` in its item description.
The apparent per-record ITINIT field bases were a structural artifact, not hundreds of sparse arrays. For
each write, subtracting `item_id * stride` and comparing the destination with corpus-observed
`lookup-array-2d` consumers assigns all **877** writes (764 positive/direct writes plus 113 recovered negative
writes) unambiguously to six row-major tables and 44 populated columns:
| base | stride | populated writes | semantic role |
|---|---:|---:|---|
| `0x8e7b9` | 5 | 20 | character-id equipment whitelist |
| `0x906f9` | 30 | 47 | signed condition/drain deltas (positive inflicts, `-5` cures) |
| `0x97c29` | 30 | 11 | equipped/passive condition levels |
| `0x9f541` | 14 | 403 | signed additive equipment stat modifiers |
| `0xa2bf1` | 10 | 379 | per-stat tuning curve ids |
| `0xa5301` | 3 | 17 | HP/SP/FS recovery amounts |
`extract_init.py` now records these as `record_fields["base/stride/column"]` rather than inventing a
one-off `fields` base for every row. Applying the same rule exposes 18 linked SKINIT columns and 82 linked
EBINIT columns. This correction reduces the auto map's false INIT-field labels from 12,311 to 1,353; the raw
write addresses were valid, but their former ownership model and omission of negative writes were not.
The first SKINIT pass names the stable catalog and combat surface: sort key, seven-way category, icon and
SKMES handler, encoded minimum/maximum range, attack element, condition strengths, signed combat-stat deltas,
HP recovery/SP cost, proc chance, and battle-animation id. The negative-write fix is essential here: all 95
active-skill SP costs are stored as `0 - cost`, so the old JSON omitted the cost column entirely.
The first EBINIT pass names the unit schema shared by setup, menus, and combat: sort key, icon, sex category,
provisional species category, defense element, natural-attack and starting-equipment item ids, allowed weapon
item category, canonical variant id, four starting-skill slots, deployment cost, starting level, level cap,
fourteen-column base stats, and matching per-level stat-growth rates. These joins are structural rather than
positional guesses: item/skill ids resolve into ITINIT/SKINIT, SETEN/UNITECH/SALLY copy complete records into
runtime unit state, ADDEXP performs the growth-rate divide/modulo-100 calculation, and SALLY checks deployment
cost against the live party-capacity aggregate.
The follow-up pass resolves three more coherent sub-schemas. First, SYS4INI joins and decoded dimensions/
pixels identify six presentation tables: CP map sprite sheets, CA battle portraits, CB full-body battle
figures, CS status illustrations, CIC/CIN battle cut-ins, and a 30-slot OGG voice bank. Second, BTL exposes
base experience, eight item-drop ids, and their paired percentage rolls; INFOEN independently renders the
same drop-item ids. Third, explicit menu messages and state updates identify capture eligibility, enemy-info
listing, summon unlock indices/knowledge thresholds/point costs, essence yield, automatic enemy level
scaling, and the large-battle-sprite layout flag. The signed `unit_boss_class` remains medium-confidence as
an authoring vocabulary, but its runtime split is now concrete. Every nonzero value receives the shared
boss damage adjustment, condition immunity, targeting exclusions, and boss battle treatment. Its yellow
map flag is also a separate boss marker rather than a fifth faction: FIELD draws the ordinary banner from
the runtime faction id, then adds the yellow badge for a nonzero class ±1..±3. Class ±4 suppresses both
standard markers for the final boss's special presentation. DRAWMINIMAP continues to color its 3×3 unit
dot solely from the four-valued runtime faction id. FIELD's
`stage_clear_rule == -2` path scans only living enemy units whose class is positive, so a positive class is
a required defeat-boss target while the matching negative class is a boss-treated add, decoy, or hazard
that does not delay victory. STINIT confirms the distinction in the same encounters: Bridget is positive
while Octavia and the boss orc are negative in stage 11; Deirdre is positive while her four shadows and
Laumakar are negative in stages 92/98; Tiamat is positive while the EX-8 boss roster is negative in stage
167; and the final heart is positive class 4 while its three organs are negative class 4. Absolute classes
1, 2, and 3 group named story characters, monster/special bosses, and demon-lord-class bosses respectively,
but the shipped scripts do not branch differently among those three values. Either sign of class 4 alone
selects the final-boss BGM and FIELD's special tactical-map presentation. AI and the remaining sparse flags
stay unnamed until comparable consumer evidence exists.
The roster/event follow-up resolves five more EBINIT tables through SALLY's complete action path. A
four-cell persistent-state block records recruitment/removal outcomes for seven heroines; a four-column
requirement table gates actions against the shared flag bank; and an eight-column event table feeds
`scjump_decision_out` before SCJUMP resolves the next script. A two-column unit-id table selects normal and
explicitly named brainwashed variants, while the final item-id field is passed to USEITEM under SALLY's
literal “sex magic bonus” message. This is roster and event routing data rather than enemy AI. The adjacent
`0x7843e` enum remains unnamed because no non-EBINIT script references it, directly or through a detected
table operation.
The same consumer trace closes the last unnamed item/skill combat-stat column. CALCBTPARAM adds stat column
7 (luck) and column 8 into a clamped percentage; CALCDMG compares it with `random-modulo 100` immediately
after the hit check and selects the critical-result state on success. Column 8 is therefore critical chance
for both `item_stat_modifiers` and `skill_combat_stat_deltas`; the skill descriptions and matching item
columns also confirm evasion, magic defense, and speed.
ITMES, SKMES, VIMES, EIMES, and CIMES are now joined back to their INIT records by a reusable id-dispatch
extractor. It recognizes both the ITMES/SKMES fallthrough layout and the VIMES/EIMES/CIMES layout whose
compact guard block branches forward to separately stored message bodies. All 287 item ids, 131 skill ids,
65 glossary-topic ids, and 24 character-profile ids match their INIT definitions exactly; all 192 sparse
EIMES ids resolve to EBINIT unit definitions. `init_table_profile.py --message-query REGEX` puts the
complete player-facing text beside
every populated field, which confirms item/skill condition, resource, range, combat-stat, and restriction
mappings without relying on column position. The same CHMENU trace identifies SKINIT `0xa70b2` as
`skill_change_catalog_eligible`, distinguishes persistent `skill_acquired_flags` from broader
`skill_info_revealed_flags`, and the explicit ITMES “female-only” record raises
`item_sex_restriction_mask` to high confidence.
VIINIT is a sparse 200-row glossary schema with 65 populated topic ids. Its global-string array supplies
the list title, while a three-column row table supplies up to three one-based scene-decision prerequisites;
the shipped data populates the first column for every topic and the second for thirteen topics. INFOVO
subtracts one from each positive prerequisite and exposes a topic when any referenced
`scene_decision_seen_flags` cell is set. VIMES supplies the full title/description body for every one of
those 65 ids. EIMES instead supplies two untitled lines—`summary` and `strategy`—for 192 EBINIT unit ids.
INFOEN lists the 183 definitions whose `unit_enemy_info_listed` field is set, masks details until the
corresponding `enemy_encyclopedia_revealed_flags` cell is set, and uses the selected EBINIT id as EIMES's
dispatch key. Nine authored EIMES boss variants are valid definitions but are not enabled in that shipped
list.
CIINIT is the character-information definition table. Its 100-cell reserved columns contain 24 populated
profile ids: a displayed name, a backing EBINIT unit id for unit metadata and fallback art, an optional
portrait resource for 19 profiles, and reserved x/y portrait offsets which the shipped initializer leaves
at zero. INFOCH enumerates the nonzero unit-id rows, uses profile reveal state to mask names and details,
draws the selected unit metadata and optional portrait, and then reaches CIMES through INFOMES's handler
router. The selected `current_character_profile_id` is therefore the exact join key, not the backing unit
id. CIMES supplies one untitled multiline `biography` for every one of the 24 CIINIT profiles.
MAINIT is the corresponding 30-cell magic-action registry. Eleven one-based rows name six field magic
actions, three research actions, and two growth rituals, with ten parallel integer columns (two entirely
zero in the shipped initializer). The dedicated extractor preserves the whole reserved layout rather than
mistaking the one consecutive name column for an eleven-cell table. Its final column contains packed
information-handler script ids; every populated row points to `MAMES.BIN`. MAMES dispatches on
`current_magic_action_id` and supplies one untitled `description` for ids 1 through 9. The growth rituals
at ids 10 and 11 intentionally have no MAMES branch, so the generated join records nine matches and
`init_ids_without_message: [10, 11]` instead of fabricating text.
The remaining similarly named scripts are infrastructure, not definition-linked message tables.
INFOMES has no inline strings: it clears `information_message_handled`, scans a 32-by-4 packed-script
registry at `information_message_handler_script_ids` for the current INFO tab, calls each nonzero entry,
and stops when a handler claims the request. INIT2 installs CIMES, EIMES, and VIMES in row zero's
character/enemy/glossary columns; column 3 and rows 1 through 31 are reserved extension capacity. MES is
the generic non-selecting modal renderer used throughout menus and gameplay. Callers append strings to
`modal_message_lines` and increment `modal_message_line_count`; MES measures and draws those strings,
waits for dismissal, and clears the buffer. MES and SBUNKI also share an optional 100-entry annotation
text/horizontal-anchor/row-offset ABI, although no direct producer populates that secondary buffer in the
shipped scripts.
Confirmed row-column meanings are no longer prose-only. The relevant `globals.toml` entries carry a
machine-readable `columns` map; `globals_build.py` preserves it in `build/globals.json`, and
`extract_init.py` emits a top-level `field_semantics` mapping while retaining raw address/stride/column keys
as provenance. Generated profiles therefore render names such as
`item_stat_modifiers.critical_chance` and `skill_status_levels.paralysis` directly.
The same structured metadata now covers the confirmed EBINIT layouts. The 14-column base-stat and growth
records use the shared accuracy-through-max-FS vocabulary; starting skills, drop items/chances, normal versus
brainwashed roster forms, battle portraits/cut-ins, and health-selected status art all expose named fields.
Consumer control flow further divides the five CP sprite assets into normal/alternate compact and directional
sheets plus the special compact sheet, and SHOWGROW proves voice column 24 is the level-up reaction. Of
EBINIT's 108 genuine populated profile fields, 107 now have specific semantic names. SALLY's SO012 button
atlas and action dispatch resolve all four unlock columns as contract, brainwash, a reserved/unreachable
slot, and sex magic. The paired eight event columns are contract, brainwash, the same reserved slot, three
form-dependent Lily sex-magic events, sacrifice, and release. The reserved slot has a switch arm but is
deliberately skipped by both drawing and input; its event ids also lack SCJUMP mappings, so it is recorded
as unreachable rather than assigned a speculative action.
The voice bank's remaining 23 populated columns are also consumer-resolved. FIELD supplies warp, treasure-
capture, and objective-interaction call sites. CALCDMG establishes BTL's miss/hit/critical result and
actor/target ownership, allowing BTL's selectors to separate ordinary attack, critical, skill-use,
damage-reaction, defeated, and finishing-blow voices. The five populated slots that no shipped selector can
reach remain explicit `unused_slot_*` authoring fields rather than generic address fallbacks; the two battle-
adjacent unused slots duplicate the final normal/critical skill pair in all 116 populated rows.
The last broad EBINIT field, `0x7843e`, is an authoring-only seven-value power tier. Its 243 rows do not
partition by species, sex, defense element, or boss class, but values rise strongly with deployment cost,
essence yield, level cap, and base statistics. Lily's three forms are exactly tiers 2/4/6, and recurring
heroine boss definitions generally rise with their later, stronger appearances. The static corpus contains
no read of this array, and the `/v2` native image contains neither its global index as an instruction operand
nor as a little-endian constant. `unit_power_tier` is therefore curated at medium confidence as descriptive
authoring metadata, not a runtime behavior claim.
The apparent final two anonymous EBINIT writes were address-ownership collisions, not new fields. The flat
global range occupied by stride-300 runtime table `0x4e693` overlaps established EBINIT parallel arrays.
Address `0x6fd4e` can be expressed as row 456, column 91 of that table, but it is also exactly
`unit_battle_sprite_asset_id[456]`; its value 12585 resolves to `CB456A.AGF`. Likewise `0x7a5d6` can be
expressed as row 600, column 35, but is exactly `unit_starting_level[600] = 80`. The extractor now gives an
already-established parallel base precedence over a coincidental row-table range match. EBINIT therefore
has 82 genuine linked columns and specific semantic names for all 108 populated profile fields.
### STINIT mixed stage records (2026-07-23)
STINIT is not a name table. Its preamble allocates 29 fixed global work buffers, then 74 sparse branches
compare `scjump_progress_a` with stage ids 1 through 170. Each selected branch populates the same current-
stage buffer with four strings, six scalar globals, sparse cells inside the fixed buffers, and
length-prefixed arrays copied from the script footer. `extract_init.py` now detects this shape as `mixed`,
evaluates preamble length arithmetic, attaches writes to their containing buffer, and preserves the branch
offset and footer offset as provenance. The extraction accounts for all 296 string writes and all 1,396
`copy-local-array` operations. Six buffers also inherit exact strides from independent
`lookup-array-2d` consumers.
`init_table_profile.py STINIT --build` profiles the four string slots, six scalars, 932 distinct buffer
cell destinations, and 37 footer-array destinations across the 74 records. The record label falls back to
the first nonempty victory-condition string, making consumer/value correlations readable without inventing
a stage-name field.
The strongest header meanings are curated in `globals.toml`: `0x27b9..0x27bc` are the two victory and two
defeat-condition lines rendered by AIM/FIELD; `0xe7302` is passed by FIELD to `play-bgm`; `0xe730c` is the
turn limit displayed by DRAWCHP and checked by FIELD; and `0xe730d` selects defeat versus forced-retreat
clear when that limit expires. STAGECLEAR establishes `0xe7303` as the target/par turn count and scales
`0xe7304`'s persistent reward increment by performance against that target. FIELD establishes `0xe730b` as
the gate that disables its already-cleared-stage retreat/replay conversion.
The first map/object pass resolves seven more buffer families. FIELD loads `0xe7311[1..19]` into tiled
surface slots and DRAWMAP selects those surfaces through terrain metadata, proving it is the current stage's
map-texture override list: positive values are SYS4INI resource ids, zero disables a slot, and -1 selects
the shared fallback. DRAWOBJ converts `0xe7325` and `0xe7357` to map-space coordinates, while
SETOBJ/DRAWOBJ/FIELD use `0xe7389` to index shared object definitions. They are object tile X, tile Y, and
type id. SETOBJ tests the `{3,4,7}` masks in `0xe7483` against GAMESTART's three-way `difficulty_index`,
then applies seven required and five forbidden one-based ids from `0xe74b5`/`0xe7613` against the shared
`story_event_flags` bank. FIELD's turn loop establishes `0xe741f` and `0xe7451` as each object's
reinforcement interval and spawn limit. Type 27 uses the same pair for a one-shot special spawn.
The intervening `0xe73bb`/`0xe73ed` pair is deliberately not assigned one global name: FIELD dispatches it
by `stage_object_type_id`, making it a tagged payload. The generated join decodes only consumer-proven
variants:
- types 1--4: `initial_faction_id` in the first cell;
- types 6 and 36: teleport `destination_tile_x` / `destination_tile_y`;
- types 7 and 8: treasure `item_id` / `item_quantity`, passed to ADDITEM;
- type 28: `card_generation_list_id`, passed to CDINIT.
- types 18--25: `non_triggering_faction_id` in the first cell when populated. FIELD suppresses the
hazard/barrier interaction when the entering unit's faction equals this value.
This accounts for 220 initial-owner values, 229 teleport destinations, 626 treasure pairs, and 246 card
list ids. The faction-gate branch resolves another 104 cells on populated types 18--21 and 25. Type 17
(`針`, spikes) is explicitly outside FIELD's faction comparison, so it does not borrow the neighboring
hazard meaning.
A separate initialization/render path resolves the remaining state-row payloads. On a fresh stage, FIELD
copies the first tagged payload into `stage_object_runtime_state[stage][slot]` only when the object's
OBINIT `object_sprite_state_row_mode` equals 1. DRAWOBJ applies the same mode check and multiplies that
runtime state by the object's sprite height to select its vertical source row. The join therefore exposes
78 cells as `initial_object_state_id`: one door (type 11), 16 spikes (type 17), and 61 deployment flags
(type 26, `出撃の旗`). All deployment-flag values are 2, and all 63 enemies linked to those flags are also
faction 2, consistent with OBINIT's `敵の増援地点` description; because the spawn branch accepts type 26
without comparing those values, the field remains the directly proven object state rather than a guessed
faction id.
The final three populated tagged cells are understood as engine-dead authoring data rather than a hidden
type-27 (`異界の門`) parameter. STINIT explicitly writes `2` to the first payload cell for object slot 22
in stages 52--54. Type 27 has no OBINIT state-row mode, so FIELD's fresh-stage initializer does not copy
that value to runtime state; the generic interaction dispatch also has no type-27 branch. Its dedicated
turn path reads only the object's active flag, type, reinforcement interval/limit, and coordinates, then
forces ADDEN enemy slot 0. ADDEN hardcodes EBINIT unit 465 (`漂着した異界の姫/BOSS`) into runtime entity
slot 49; on success FIELD changes to BGM 10 and reports `異界の姫が漂着した!`. Neither `0xe73bb` nor
`0xe73ed` participates. The join therefore preserves the three explicit writes under
`ignored_payload_fields` instead of inventing a semantic name or leaving them unresolved.
OBINIT is the authoritative object-definition table: 46 one-based records provide the type names, and 34
provide short player-facing effect descriptions used by the field object-information path. The STINIT join
now adds `type_name` to every placement and `type_description` when populated while retaining `type_id`;
top-level `object_definition_table: "OBINIT"` records the join provenance. This is intentionally separate
from payload decoding: a known display label does not by itself establish the meaning of a tagged cell.
The enemy pass follows the separate 30-cell family through FIELD, SETEN, ADDEN, MVRTN, and BTRTN. Slot
zero is reserved for ADDEN's synthesized special-unit path; the stage table populates slots 1 through 29.
`0xe7811` selects the EBINIT unit, `0xe7799` is its faction, `0xe773f`/`0xe775d` are direct tile
coordinates, and `0xe777b` optionally anchors the unit to a stage-object slot. FIELD checks the
three-bit difficulty mask in `0xe77b7`, uses `0xe77f3` as a weighted-random alternative value, and applies
the seven required plus five forbidden story flags in `0xe793d`/`0xe7a0f`. SETEN proves `0xe782f`,
`0xe784d`, and `0xe786b` are the scenario level floor, cap, and party-level scaling divisor. Finally,
the three-value footer rows in `0xe7889` and optional `0xe78e3` become difficulty-specific movement and
battle routine-set ids selected by MVRTN/BTRTN. The final `0xe77d5` gate is also resolved:
STAGECLEAR writes `stage_clear_state[current_stage] = 1`, and FIELD suppresses a spawn when that state is
set and the spawn's cell equals 2. The joined view exposes all 485 populated cases as
`first_clear_only: true`.
Generated INIT records now retain their raw `fields`/`record_fields`/buffer keys and additionally expose a
flat `semantic_fields` projection joined through the top-level `field_semantics` map. For STINIT, the four
confirmed parallel buffers plus both prerequisite tables are also assembled into 2,312
`object_placements` across 66 stages. Each placement contains its slot, numeric type plus OBINIT
name/available description, tile coordinates, difficulty mask, populated positive/negative story
prerequisites, optional reinforcement schedule, and the decoded type-tagged payload variants above. The
three engine-dead type-27 payload writes stay attached under `ignored_payload_fields`; there are no
remaining populated object payloads under `unknown_fields`, so this convenience view loses no evidence or
invents names.
The same records now contain 1,378 joined `enemy_spawns` across 66 stages, with unit/faction, direct or
object-linked placement data when present, difficulty and story gates, level rules, random-selection
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.
### SCINIT scene-dispatch registry (2026-07-23)
SCINIT is a paired sparse registry, not the malformed 372-row numeric table produced by the generic
numeric heuristic. Each source assignment writes a decision-indexed packed script resource id at
`0x87a57` and an authored chapter tag at the parallel base `0x8a167`, exactly 10,000 cells later.
All 135 distinct resource ids resolve through SYS4INI to `SCxxxx.BIN`. The source contains 2,179
assignments and 1,209 final decision ids; 710 ids are assigned more than once, so extraction preserves
the complete offset-tagged assignment history as well as final values.
The chapter meaning is independently supported rather than inferred from the small integer domain:
SCINIT's source order consists of contiguous 1-through-9 runs followed by a `-1` unassigned run, and
844 of the 847 decision ids emitted by decoded SCJUMP end with the same chapter. The three mismatches
are retained as legacy/stale authoring metadata. `extract_init.py` detects this sixth shape as
`dispatch`; `build/data/SCINIT.json` is the single join from decision id to packed resource id,
resolved script name, authored chapter, SCJUMP chapters, overwrite history, and raw column keys.
`field_semantics`/`semantic_fields` resolves those raw keys through `vm-map/globals.toml`.
### RTINIT movement/battle routine banks (2026-07-23)
RTINIT is a banked sparse program registry, not the generic numeric extractor's former 14-record result.
Its 3,336 static writes address twenty parallel banks separated by exactly 20,000 cells. Direct
`lookup-array-2d` consumers establish that every bank is `int[1000][20]`: `current_routine_set_id`
selects a one-based row and `routine_step_index` iterates slots 0 through 19. Rows 1..176 are populated
except 150..153, yielding 172 routine sets and 3,307 final cells. Twenty-nine cells are written twice;
eleven of those overwrites change the value, all retained in source order.
Banks 0..9 form the movement family. MVRTN reads bank 0 as a provider selector, resolves it through
RTN_M001..018/051..053/061, applies bank 1 as a random-modulo-100 activation percentage, gates the step
on a per-entity progress count plus required/forbidden story flags in banks 7..9, and call-scripts the
provider. Banks 2..5 are provider-tagged parameters and bank 6 is reserved/empty. This produces 1,043
final movement steps.
Banks 10..19 form the battle family. BTRTN dispatches selectors 1..4 to RTN_B001..004, compares each
entity's pre-rolled 0..99 step value against bank 11's activation percentage, and applies the same
required/forbidden story-flag convention in banks 18/19. Bank 12 is an RTN_B004 parameter; banks 13..17
are reserved/empty. Only fourteen shipped battle steps are populated.
`extract_init.py` detects this seventh shape as `banked` and writes `build/data/RTINIT.json`. Each record
keeps its raw `base/20/slot` fields and complete offset-tagged assignment history while adding joined
`movement_steps`/`battle_steps` with provider script names. The bank layout explicitly includes all six
empty reserved banks. Structural and consumer-proven meanings live in `vm-map/globals.toml`; the generic
parameter names remain as raw provenance while each RTN_M/RTN_B consumer proves its tagged schema.
The provider-specific join now covers
all nineteen used providers,
RTN_M001/002/003/004/005/006/007/008/009/010/011/012/013/014/015/017/051/052/061:
all 1,043 movement steps, 974 semantically
consumed populated parameters, thirteen explicit zero defaults, and three authored-but-unread
parameter cells. RTN_M005/011/012 read banks 2/3 as
`destination_tile_x` / `destination_tile_y` and approach that exact map tile, incrementing the current
step's progress counter after arrival. Their alternate completion test recognizes a type-6 stage object
at the authored destination and also accepts the object's linked exit tile. RTN_M012 is byte-for-byte
equivalent to M005 except for its MVSEEK mode: mode 2 masks the doubled-coordinate terrain cell of every
active foreign-faction entity before the flood fill, so the generated behavior distinguishes this
foreign-entity-avoiding route from ordinary M005.
RTN_M004's bank-2 value is a zero-based `stage_object_slot_index`. It approaches that current-stage
object and uses the same exact-tile/type-6-linked-exit completion contract as the coordinate providers.
Three of its 70 steps leave the cell unwritten and therefore receive an explicit semantic slot-0 default;
the generic raw bank remains absent in those rows.
RTN_M006 is the nearest-enemy form. Bank 2 is an inclusive `maximum_target_route_steps`, authored from
1 through 7 plus 10 and 20. It requires the normal-attack bit at encoded range 0, selects the nearest
active entity of another faction which remains eligible after SETMVWORK's offensive-action filtering,
randomizes equal-distance ties, and approaches a reachable tile nearest that enemy. Producing a valid
destination increments the step's progress counter.
RTN_M011 is the cyclic-waypoint form. Bank 4 is a one-based `waypoint_ordinal`; only the step whose
ordinal minus one matches `entity_patrol_waypoint_indices[current_entity]` executes. Arrival advances
that runtime index modulo the largest RTN_M011 ordinal in the selected routine set. Bank 5 is an optional
`path_cost_limit_override`; zero or an unwritten cell falls back to the entity's current FS.
RTN_M007 is the injured-ally form. Bank 2 is `maximum_target_route_steps` (authored as 5 or 10) and bank
3 is an inclusive `maximum_target_hp_percent` (50, 70, or 80). It runs MVSEEK from the acting entity,
keeps active non-self entities of the same faction below the HP cutoff and within the route-step radius,
selects randomly among the nearest tied allies, then chooses a reachable movement tile nearest that
ally. Producing a valid destination increments the step's progress counter.
RTN_M010 is the Healing Feather form. Bank 2 is a `resource_index` into current HP/SP/FS and their
max-stat columns; all seven shipped cells are unwritten, so the generated join explicitly projects the
zero/HP default without fabricating a raw assignment. Bank 3 is an inclusive
`maximum_resource_percent`, authored as 30 or 50. When the selected current/max percentage passes, the
provider chooses the nearest active OBINIT type 15 or 16 object (`治癒の羽`, full status recovery, or its
single-use red variant) and approaches a reachable tile nearest it.
RTN_M015 is the Magic Pillar form. It chooses the nearest active OBINIT type 2/3/4 object (small,
medium, or large `魔力の柱`) whose runtime ownership state differs from the acting entity's faction.
Bank 2 is an inclusive `maximum_target_route_steps`, authored from 2 through 6; the provider produces
movement only when the chosen foreign-controlled pillar is within that radius.
RTN_M008 is the unrestricted-radius form of that Magic Pillar search: it chooses and approaches the
nearest active foreign-controlled type-2/3/4 object without consulting a parameter bank. Its sole
authored bank-2 value is therefore retained under `ignored_movement_parameters`, not presented as a
behavior input. RTN_M001 likewise reads no parameter bank; it advances the current routine step's
per-entity progress counter and sets the execution state. Its two authored cells are preserved as unread
residue. These three cells are the complete authored-but-unread movement-parameter set.
RTN_M013 approaches terrain permitted to a selected faction. Bank 2 is `target_faction_filter`: a
nonzero value selects that faction's bit, while zero/unwritten means any faction except the actor's own.
The provider requires the current tile not already to match that mask and selects the nearest reachable
tile whose `tile_faction_traversal_masks` cell overlaps it. Three of its four steps use the explicit
zero default.
RTN_M014 retreats from nearby enemies. Bank 2 is an inclusive
`maximum_threat_route_steps`, authored as 3 or 6. It collects active foreign-faction entities within
that route radius, sums a proximity surface from all collected threats, removes occupied and
movement-unreachable tiles, and randomly chooses among the lowest-positive-score tiles. This makes the
destination the reachable tile farthest from the nearby threat set rather than merely farthest from one
enemy.
RTN_M002 is the parameterless randomized-roaming provider. It builds the actor's movement-limited
reachability grid, applies SETMVWORK, retains tiles with positive filtered route scores whose movement
cost fits current FS, randomizes their order, and accepts the first destination for which SETROUTE
constructs a path. Success advances progress and returns ordinary movement state 1.
RTN_M009 is the parameterless treasure seeker. It considers active unopened OBINIT type-7 chests only
when the actor has SKINIT skill 22 (`開錠`, Unlock), while type-8 treasure has no skill gate. A target
also requires one of the actor's two carried-item slots to be empty or already hold the object's item
id. M009 selects the nearest eligible object, then uses the same filtered reachability/cost pipeline to
approach a reachable tile nearest it; success advances progress and returns movement state 1.
RTN_M003 is a parameterless normal-attack routing provider. It requires the normal-attack bit, builds
the actor's movement-limited MVSEEK grid, and runs SETMVWORK so active foreign-faction entity tiles
remain eligible only when the normal attack's element has positive effectiveness. It then requires the
target tile's movement cost to fit current FS, ranks candidates by descending remaining-route score
with randomized ties, and accepts the first candidate for which SETROUTE constructs a path. Success
advances progress and returns state 1, so FIELD performs movement rather than battle.
RTN_M017 is the low-HP finisher variant of M003. It uses the same normal-attack, MVSEEK, SETMVWORK,
current-FS, and SETROUTE eligibility pipeline, but orders the surviving foreign targets by ascending
current HP instead of descending remaining-route score. It attempts routes in that order and returns
movement state 1 for the first target that yields a path.
RTN_M051 is a parameterless immediate-attack selector. ATSEEK supplies a one-based range band for each
enemy tile; CALCSCOPE supplies the normal-attack/equipped-skill bits and their attack elements at each
band. M051 keeps active foreign targets for which at least one allowed action has positive effectiveness
against the target's equipped or unit defense element. Encountering a closer band clears the accumulated
candidate list, after which a target is randomized from the retained entries. It then randomizes among
the effective actions in the tracked closest band, writing zero for a normal attack or an equipped skill
id. Success stores `target_entity_index` and `entity_selected_action_ids[acting_entity_index]`, advances
progress, and returns state 2 so FIELD enters battle without producing a movement route.
RTN_M052 is M051's low-HP immediate-attack variant. It applies the same active-foreign-target,
range-mask, target-defense-element, and positive-effectiveness tests, but keeps only targets tied at the
lowest current HP and randomizes among those ties. After choosing the target, it reloads that target's
actual ATSEEK range band, randomizes among the effective normal attack/equipped skills enabled at that
band, stores the target and action, and returns immediate-battle state 2 without movement.
RTN_M061 is the parameterless immediate-healing provider. CALCSCOPE places usable category-7 healing
skills in a separate range-band mask. M061 keeps active same-faction entities inside ATSEEK whose band
enables healing, retains only allies tied at the lowest current-HP percentage, and randomizes among
those ties. It then compares the chosen ally's current HP plus each range-enabled equipped spell's
recovery against max HP, stores the selected ally and healing skill, advances progress, and returns
support-action state 3 without movement. FIELD handles that state through its dedicated healing path.
MVSEEK's mode contract is also now explicit. Mode 0 replaces the input coordinate with the current
entity tile and seeds the origin with movement+1, producing the movement-limited reachability grid.
Modes 1/2 retain the caller coordinate and seed it with 9999, producing a broad target-distance grid;
mode 2 additionally applies the foreign-entity terrain mask described above. Every traversed edge
decrements `pathfinding_remaining_route_steps`, so origin minus target is the route-step distance used
by M006/007/015.
The formerly unnamed `G[0xc6077]` stride-27 table closes the other half of that pathfinding ABI. It is
the reserved `1000 × 27` `selected_movement_route_steps` grid, not another search surface. MVRTN,
FIELD, and SETROUTE each clear all 27,000 cells. SETROUTE begins at `map_target_tile_x/y`, copies the
matching `pathfinding_remaining_route_steps` value, then walks the four `cardinal_tile_delta_x/y`
neighbors until it finds the cell whose score is one greater; it copies that score and repeats until
reaching the acting entity. The result is a one-cell-wide monotone route from actor to destination.
FIELD reads that trail both to draw direction-specific route markers and to drive movement along
successive scores. SELACT checks whether an action target follows movement, while every provider from
RTN_M002 through RTN_M018 tests its candidate endpoint for a nonzero selected-route cell. Across the
corpus the grid has exactly 51 two-dimensional lookups and five clears in 21 scripts. INIT2 proves the
shared cardinal vectors as `[0, 0, -1, 0, 1]` for X and `[0, 1, 0, -1, 0]` for Y. The target coordinate
pair is broader than routing: 55 scripts also pass scripted map locations through it to LOOK, which
centers the battlefield camera on the selected tile.
`movement_steps` now carry these selector-scoped semantic fields beside the original
`movement_parameter_1..4`, and top-level `movement_provider_parameter_schemas` records the reusable
mapping, target-selection rules, zero/unwritten behavior, and authored fields proven unread by their
provider. All 977 populated movement-parameter cells are now accounted for: 974 semantic inputs and
three explicit residue cells. All nineteen providers used by the shipped table now have behavior
schemas; the three additionally dispatchable but unused providers remain outside the generated join.
The supporting runtime joins are now curated too:
`entity_runtime_flags`,
`entity_faction_ids`, `entity_tile_x`/`entity_tile_y`, the fourteen-column `entity_effective_stats`,
current HP/SP/FS, `entity_skill_flags`, the paired carried-item id/count slots,
`stage_object_runtime_flags`, `movement_search_mode`,
`offensive_action_scope_masks`, `pathfinding_remaining_route_steps`,
`selected_movement_route_steps`, `pathfinding_filtered_route_scores`, `pathfinding_movement_costs`,
the map-target coordinate pair and cardinal neighbor vectors, and the per-entity patrol waypoint index.
The faction-specific terrain masks used by M013 are curated as
`tile_faction_traversal_masks`. M051's shared action-selection ABI also names `acting_entity_index`,
`target_entity_index`, `action_range_distance_grid`, the shared usable-action range bounds, the
offensive and healing range masks, the offensive element tables,
`attack_element_effectiveness_percent`, equipped active-skill slots, and the per-entity selected action.
### 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
*meaning* — and is sometimes wrong (it labels `0xa57`, the Lily form-A story flag, as a
"string-table"). The curated registry fixes this, modelled exactly on `vm-map/opcodes.toml`:
- **`vm-map/globals.toml`** — the only hand-edited source. One `[[global]]` per known address:
`name`, `category` (`story-flag`/`index-pointer`/`data-table`/`string-table`/`ui-toggle`/
`choice-output`/`counter`/`unknown`), `type`, optional row-table `columns`, `value_domain`, `usage`, and provenance
(`source`/`confidence`/`depends_on`).
- **`tools/globals_build.py --build`** merges curated entries *over* the auto map →
`build/globals.json` (machine) + `docs/global-reference.md` (generated human view). `--lint`
checks vocabulary, the auto-shape≠high rule, and dangling `depends_on`. `sys4load` reads
`build/globals.json` for operand labels (curated names win, shown as `name(category)`; the auto
tail is kept only at high/med confidence). Regenerate the `.asm` corpus with
`tools/extract_phase2.py` to pick up new labels.
#### Story-state flags (the first populated category)
Story flags are scalar globals that ADV/progression logic *branches on* (chapter, character
forms, choices, routes) — a category the auto shape map never enumerated. **`tools/story_flags.py`**
is a 100% static miner: it flags a global as a candidate when it feeds a comparison (`eq`/`ne`/
`lt`/`lte`/`gr`/`gre`), a logical (`and`/`or`), or a `jcc` condition, and is not a genuine table/
index in the shape map. Per candidate it records compared-against constants (→ value domain), the
writer set (progression-written but scene-read = strong story flag), total- and scene-reach, and
near-universal (ADV-chrome) status → an auto category + confidence. Output:
`build/story-flags-candidates.json` (review surface: 1261 branch-read globals, **205 story-flag
candidates**); `--bootstrap` seeds high-signal skeletons (med-confidence, non-chrome) into
`globals.toml` for human naming. Dynamic confirmation of a flag's reach stays separate —
`Age.Cli sweep 0xADDR=VAL`.
**Reading the catalog:** `reach_scenes > 0` = the flag changes SC/SP scene dialogue directly (e.g.
`0xa57` Lily form, scene-reach 78). `reach_scenes = 0` with progression writers = a
progression/menu-layer flag read by the game-flow scripts, not scenes (e.g. `0x3234` chapter, read
by SCJUMP/FIELD). **Known/named anchors:** `0x3234` `chapter_mode` (enum 1..9), `0x3231`
`game_mode` (adjacent mode selector), `0xa57/8/9` Lily forms A/B/C (boolean, externally set),
`0x62ccf/0x62ccc` SCJUMP decision outputs, `0x6642c` `route_branch` (BUNKI = 分岐 writer),
`0x6c90x6cd` UI toggles. Config/settings globals written by `CONFIG`/`INITCONFIG` (scene-reach 0)
are *not* story flags — the miner over-tags them; they are recategorized `unknown` when curated.
### Future step — growing the map
The v1 map labels *shapes and tables*; the next increments add *meaning*, cheapest first:
1. **Continue INIT semantics by evidence density.** ITINIT/SKINIT, EBINIT, STINIT, CCINIT, SCINIT, RTINIT, MAINIT, ILINIT, CGINIT, ALINIT, AFINIT, CTINIT, CVINIT, and MPINIT 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. SCINIT closes the progression decision-to-scene join, and RTINIT's twenty movement/battle
banks are structurally decoded with every populated movement-parameter cell classified and all 1,043
shipped movement steps joined to provider behavior. ILINIT/RECOVER closes the condition ABI, CGINIT
closes the gallery registry, ALINIT closes the 107-recipe alchemy registry with complete ITINIT
joins, AFINIT closes the signed affinity/tuning/facility tables, CTINIT closes INPUTNAME's
five-page character palette, and CVINIT closes CONFIG's thirteen preview clips plus the
bidirectional character-to-suppression-setting join. MPINIT closes the sparse 53-column
doubled-coordinate terrain atlas and joins 66 STINIT2 stage definitions to 53 unique rectangles,
while LAINIT closes all twenty shipped terrain definitions: names/effects, rendering/topology
columns, ten combat-stat columns, traversal-skill requirements, and shared map-texture assets.
SPINIT closes HMODE's eight-by-fifteen thumbnail-page-to-scene registry, with all 118 scripts and
eight INIT2 sheets resolved. TRINIT closes TRAIN's 21 training/sexual-magic actions: six text
slots, all eligibility/cost/stat/alignment/progress/award families, and 75 event cells joined
through SCINIT and GAMESTART's restored story flags. CDINIT/CDINIT2 close FIELD's nine weighted
generation lists and 81 typed card effects, including story gates and item/event/condition/visual
joins. BTANINIT/BTANINIT2 close 122 battle-animation timelines and 202 visual/audio/hit-pulse
effects, with skill and weapon-class selection joined. STINIT2 closes 74 real stage rows (correcting
247 description strings formerly misread as records), six pre/post-clear text slots, progression and
story gates, map/minimap geometry, rewards, and 174 resolved entry/clear/failure transitions. With
RTINIT's used-provider surface closed, ITMES/SKMES/VIMES/EIMES/CIMES/MAMES joined, INFOMES/MES
classified, STINIT2's engine-dead authoring tier retained at medium confidence, and the
309-script ADV primary/alternate/transition surface-slot registry named, the selected
movement-route grid reconstructed, CALCDMG/BTL's two-side-by-300 triggered-passive
matrix plus actor HP-recovery output closed, and the persistent 100-by-14 unit stat-growth
fraction table joined to level-up, training, catch-up, save/load, and unit-copy consumers,
continue with consumer-led tables that still have shipped readers;
never assign one universal meaning to a parameter bank whose meaning varies by provider selector.
2. **Label remaining 2D record tables by their readers** — cross-reference which scripts read each
`rec[sN]` table and infer purpose from context, preserving reserved rows and sparse cells. RECOVER's
30-wide status tables and ALINIT's paired recipe tables are completed examples. Static, medium effort.
3. **Classify other `set-string`/`copy-to-global` writers** not covered by the completed INIT/MES
set when their data flow reaches a current implementation need.
4. **Name *which stat* each field is (Frida).** The one step needing live tools: change a
known value in-game (take damage, gain XP), watch which global moves → definitive
`field@X = "HP"`. Reserve for the fields that matter; this is the last mile.
**Append semantic coverage (2026-07-24).** The ordinary `paths.scripts()` corpus still represents DATA1
plus loose root overrides, so generated `build/data/EBINIT.json` remains the base/loose table. For focused
inspection, `extract_init.py EBINIT --packed-id 0x01000001` now loads `$1$EBINIT.BIN` directly through its
AAI/ALF catalog coordinates and writes the separate `build/data/APPEND01-EBINIT.json`. It applies the
canonical base EBINIT geometry rather than re-inferring a misleading seven-row layout, preserves packed-id
and fragment-layout provenance, and gives all additive rows the existing EBINIT semantic field names.
`init_table_profile.py APPEND01-EBINIT --record ID_OR_NAME` renders one row as semantic
field/value/raw-coordinate triples. Regression coverage fixes the seven row ids (81 and 900..905) and
representative unit-81 level, stat, growth, and selector-keyed asset values.
This is deliberately a fragment view, not a synthesized boot image. `$1$CNINIT.BIN` supplies companion
display-name/voice-family writes and other INIT fragments add related definitions. The launch order is now
proven: INIT2 op `0x143` executes each mounted selector's record zero serially after base initialization
and before TUNE. Whole-pack extraction still needs explicit ordered merge/provenance support before this
focused fragment should be presented as a synthesized runtime image. The AAI format and verified sequence
are canonical in `docs/asset-resolution-re.md`; command usage is canonical in `docs/tools-reference.md`.
### Battle experience pipeline (2026-07-24)
BTL awards experience after every completed combat exchange, not only after a kill and not once per
individual strike in a multi-hit animation. It first chooses the surviving faction-1 participant, reads
the opposing runtime entity's EBINIT definition id through `entity_unit_definition_ids`, and loads that
definition's `unit_experience_reward`. The level difference is:
`opponent entity level - faction-1 entity level`
BTL selects a percentage from one of two integer tables:
| level difference | opponent survives | opponent defeated |
|---:|---:|---:|
| `>= 3` | 40% | 250% |
| `2` | 30% | 200% |
| `1` | 20% | 150% |
| `0` | 10% | 100% |
| `-1` | 10% | 75% |
| `-2` | 10% | 40% |
| `<= -3` | 10% | 20% |
The award is `unit_experience_reward * percentage / 100` with integer truncation. BTL derives the
opponent-survived branch from `battle_outcome_flags`: bit 0 means the target was defeated, bit 1 means
the actor was defeated, and bit 2 means both survived. The surviving player-side participant receives
the result through ADDEXP. ADDEXP rejects zero awards or units already at `unit_level_cap`, adds the
award to persistent `unit_experience_progress`, and carries any remainder through successive 100-point
level-up boundaries. Normally cleared configuration/debug bits `0x10`, `0x20`, and `0x40` respectively
suppress, double, or multiply the award by ten; no shipped base or append script enables them during
ordinary play.
An omitted EBINIT reward write is not a general fragment-level declaration of zero: additive INIT
fragments can intentionally leave an earlier value unchanged. For append unit 900
`ヘタレアースマン`, however, the base EBINIT has no unit-900 row and no base or append writer targets
its reward cell. The zero-initialized global therefore remains zero, so every level bracket awards
zero. The append profile correctly leaves `unit_experience_reward` absent because that is the authored
write provenance; the effective value is zero only after considering the complete writer history.
Append stage 200 explains `キングプテテット`'s observed 610 experience per exchange without a
special battle flag. Unit 901 (`キングプテテット本体/BOSS`) has base reward 50, while unit 905
(`キングプテテット分身/BOSS`) has base reward 20. The stage places one body and two splits and clamps
all three to levels 70..99. Consequently, a surviving body awards 20/15/10/5 at level differences
`>=3`/`2`/`1`/`<=0`, while a surviving split awards 8/6/4/2. The commonly seen 10 can therefore be
the body one level above the attacker, and 6 can be a split two levels above.
FIELD computes `party_reference_level` as the integer average of every eligible party member when
there are at most five, or the five highest levels otherwise. SETEN starts an enemy from
`unit_starting_level`, raises scalable enemies toward that party reference using the stage/unit divisor,
then applies the stage minimum and maximum clamps. Difficulty separately contributes -5/0/+5 stat-growth
iterations to enemy stats; it does not alter the stored `entity_levels` value used by BTL and therefore
does not directly select an experience percentage.
Re-run `tools/global_map.py` after each increment; `sys4load` picks up the new labels
automatically (it reads `build/global-var-map.json` at load).
---
## How the two relate
#1 names **functions** (the call graph); #2 names **data** (game state). In `RECOVER`, #1 turns
`call-script 0x329d` into `CALCREVISE.BIN`; #2 turns `C[unit][s] = E[unit][s]` into `unit.hp[s] =
unit.maxHp[s]`. Both are now largely in hand: **#1 is SOLVED** (the SYS4INI-index dispatch reverse —
turned out to need the engine, and the Ghidra loop delivered it), and **#2 has a partial static map**
(the `*INIT` handholds) that grows on demand.