diff --git a/docs/re/data/isl-stage02.txt b/docs/re/data/isl-stage02.txt index 08e49dca..3e192778 100644 --- a/docs/re/data/isl-stage02.txt +++ b/docs/re/data/isl-stage02.txt @@ -1,5 +1,7 @@ # Stage02.ssb — ISL disassembly artefacts +Generated by `tools/re-capture/isl_report.py calls`. + 2846 call sites, 73 distinct built-ins builtin 11 372 site(s) builtin 69 255 site(s) @@ -81,40 +83,47 @@ 39 MARK_LAST_PHASE 8: 0x4dfc 0x52c8 0x60fc 0x19258 0x19724 0x2b584 0x2ba50 0x2c054 40 mark_not_last 4: 0x5a10 0x1a270 0x2c578 0x2d06c -## condition vocabulary actually used by Stage 02 - 11 yield 372 - 69 unit_state 255 - 1 start_coroutine 216 - 30 objective_marker 179 - 20 hp_pct_test 167 - 18 dist_lt 92 - 5 await_label 83 - 70 unit_alive 71 - 56 unit_relation 52 - 4 wait_s 34 - 26 damage_unit 33 - 3 move_order 20 - 124 timer_stop 15 - 120 wait_cmds_drained 14 - 8 set_flag 12 - 100 push_trigger 12 - 93 clear_flag 12 - 59 fade_sound 12 - 6 END_PHASE 12 - 9 read_freg 12 - 95 unit_hp_pct 9 - 115 named_event 9 - 39 MARK_LAST_PHASE 8 - 90 camera_at_route 8 - 17 wait_frames 6 - 10 random 6 - 123 timer_resume 5 - 40 mark_not_last 4 - 137 wait_units_ready 3 - 52 play_stream 3 - 14 play_bgm 3 - 62 FORCE_END_PHASE 3 - 127 timer_set 1 +## named built-ins used, by traffic + 11 end_coroutine 372 + 69 unit_state 255 + 1 start_coroutine 216 + 64 request_script_message 213 + 30 objective_marker 179 + 20 hp_pct_test 167 + 47 squadron_attack 137 + 18 dist_lt 92 + 5 kill_coroutine 83 + 70 unit_alive 71 + 56 unit_relation 52 + 4 wait_s 34 + 26 damage_unit 33 + 3 move_order 20 + 124 timer_stop 15 + 120 wait_cmds_drained 14 + 8 set_flag 12 + 100 reset_phase_threads 12 + 93 clear_flag 12 + 59 fade_sound 12 + 6 END_PHASE 12 + 9 read_freg 12 + 95 unit_hp_pct 9 + 115 named_event 9 + 48 squadron_escort 8 + 39 MARK_LAST_PHASE 8 + 90 camera_at_route 8 + 17 wait_frames 6 + 10 random 6 + 123 timer_resume 5 + 40 mark_not_last 4 + 137 wait_units_ready 3 + 52 play_stream 3 + 14 play_bgm 3 + 62 FORCE_END_PHASE 3 + 127 timer_set 1 + +## phase code bases +Each phase has its OWN base; the file header offset is not it. + 0xe4 0x14aa8 0x24b4c ## disassembly into the first END_PHASE resync from 0x5058 @@ -131,7 +140,7 @@ resync from 0x5058 0050CC: 02030C00 set.i len=12 k=02,03 00000010 00000000 local[16] = special[0] 0050D8: 01021001 set.f len=16 k=01,02 00000000 BFF00000 00000000 special[0] = imm -1 0050E8: 02030C01 set.f len=12 k=02,03 00000018 00000000 local[24] = special[0] -0050F4: 00000C13 call len=12 k=00,00 00000040 00000245 builtin64(0x42, 0x2, 0x1, 0x9, 0x1, -1) +0050F4: 00000C13 call len=12 k=00,00 00000040 00000245 request_script_message(0x42, 0x2, 0x1, 0x9, 0x1, -1) 005100: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0) 005108: 0000080C jmp len=8 k=00,00 0000507C -> code+0x507C (file 0x50A0) 005110: 01020C0A op10 len=12 k=01,02 00000000 00000000 @@ -153,13 +162,32 @@ resync from 0x5058 0051CC: 02030C01 set.f len=12 k=02,03 00000000 00000000 local[0] = special[0] 0051D8: 00000C13 call len=12 k=00,00 00000004 0000024C wait_s(3) 0051E4: 00000C13 call len=12 k=00,00 00000006 0000024D END_PHASE -0051F0: 00000C13 call len=12 k=00,00 0000000B 0000024E yield +0051F0: 00000C13 call len=12 k=00,00 0000000B 0000024E end_coroutine 0051FC: 01030C00 set.i len=12 k=01,03 00000000 00000001 local[0] = imm 0x1 005208: 01030C00 set.i len=12 k=01,03 00000004 00000056 local[4] = imm 0x56 005214: 01021001 set.f len=16 k=01,02 00000000 00000000 00000000 special[0] = imm 0 005224: 02030C01 set.f len=12 k=02,03 00000008 00000000 local[8] = special[0] -005230: 00000C13 call len=12 k=00,00 00000014 00000252 hp_pct_test(0) +005230: 00000C13 call len=12 k=00,00 00000014 00000252 hp_pct_test(0x1, TCN004, 0) 00523C: 01020C0A op10 len=12 k=01,02 00000000 00000001 005248: 0000080E op14 len=8 k=00,00 0000575C 005250: 01020C00 set.i len=12 k=01,02 00000000 00000000 special[0] = imm 0x0 00525C: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] +005268: 00000C13 call len=12 k=00,00 00000074 00000253 builtin116(0x0) +005274: 00000C13 call len=12 k=00,00 00000065 00000254 builtin101 +005280: 00000C13 call len=12 k=00,00 00000064 00000255 reset_phase_threads +00528C: 00000C13 call len=12 k=00,00 0000007C 00000256 timer_stop +005298: 01020C00 set.i len=12 k=01,02 00000000 FFFFFFFF special[0] = imm 0xFFFFFFFF +0052A4: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] +0052B0: 00000C13 call len=12 k=00,00 0000005D 00000257 clear_flag(0xFFFFFFFF) +0052BC: 00000C13 call len=12 k=00,00 00000076 00000258 builtin118 +0052C8: 00000C13 call len=12 k=00,00 00000027 0000025A MARK_LAST_PHASE +0052D4: 00000C13 call len=12 k=00,00 00000052 0000025B builtin82 +0052E0: 01021001 set.f len=16 k=01,02 00000000 40100000 00000000 special[0] = imm 4 +0052F0: 02030C01 set.f len=12 k=02,03 00000000 00000000 local[0] = special[0] +0052FC: 00000C13 call len=12 k=00,00 00000004 0000025C wait_s(4) +005308: 01020C00 set.i len=12 k=01,02 00000000 00000005 special[0] = imm 0x5 +005314: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] +005320: 00000C13 call len=12 k=00,00 0000000A 0000025E random(0x5) +00532C: 0000080C jmp len=8 k=00,00 00005648 -> code+0x5648 (file 0x566C) +005334: 01020C00 set.i len=12 k=01,02 00000000 00000043 special[0] = imm 0x43 +005340: 02030C00 set.i len=12 k=02,03 00000000 00000000 local[0] = special[0] diff --git a/docs/re/mission-objective-vocabulary.md b/docs/re/mission-objective-vocabulary.md index 71a20cc5..37557c48 100644 --- a/docs/re/mission-objective-vocabulary.md +++ b/docs/re/mission-objective-vocabulary.md @@ -75,7 +75,8 @@ squadron lost anybody at all?"* — an escort-casualty test. **The game has no | `is_engaged` | **S05 only**, 3 sites, all on `TCN004` | **S18–S23, the tutorials, are the outliers by a distance.** No flag/trigger -machinery at all — zero `read_freg`, `clear_flag`, `push_trigger`, +machinery at all — zero `read_freg`, `clear_flag`, `reset_phase_threads` +(built-in 100, formerly mis-named `push_trigger`), `named_event`, `MARK_LAST_PHASE`, `FORCE_END_PHASE`, and none of the timer family. One `set_flag` and one `END_PHASE` each. They are linear scripted lessons, not missions. diff --git a/docs/re/structures/isl-builtins.md b/docs/re/structures/isl-builtins.md index f07c6f56..f866df2e 100644 --- a/docs/re/structures/isl-builtins.md +++ b/docs/re/structures/isl-builtins.md @@ -22,9 +22,28 @@ Arguments do **not** live in the instruction. Every handler starts **packed operand blob**, which is what the `local[]` staging in [isl-bytecode](isl-bytecode.md) fills. -Return codes: **0** continue, **2** yield (re-execute next frame), **3** -coroutine control. Five built-ins skip the pc advance on 2 and so genuinely -**block**: 97, 120, 137, 142, 143. +### ✅ Return codes — read off `ScriptPhase::Update` (`0x82263830`) + +The dispatcher switches on `r3` exactly four ways: + +| r3 | address | effect | +|---|---|---| +| **0** | `0x82263850` | continue to the next thread in the same frame | +| **1** | `0x82263878` | `[thread+4] = [thread+0]` — **restart this coroutine from its entry** | +| **2** | `0x82263888` | `[thread+4] = saved pc` — resume next frame | +| **3** | `0x82263894` | `sub_8226EA20` erases the thread from the active list `[+216]`, then `sub_8226EAB8` returns it to the free list `[+204]` — **terminate this coroutine** | + +Two tails do the pc bookkeeping: `0x822724F8` is `li r3,0` then advance; +`0x822724FC` advances only, preserving `r3`. Advance is `pc += [insn+2]`, the +length byte — the same field [isl-bytecode](isl-bytecode.md) decodes. So **every +handler that ends `b 0x822724F0` returns 0**, and its only output is +`[phase+164]`/`[phase+176]`. + +❌ **CORRECTED — the blocking set was wrong in two places.** The blocking form is +`bctrl ; cmpwi r3,2 ; bne 0x822724FC`, and it appears at **102, 120, 137, 142, +143**. This file previously listed **97**, which does not block: its handler +`0x8227313C` ends `b 0x822724F8`, so it *always* returns 0. And **102** was +missing. All six handlers re-read to confirm. ## ✅ `ScriptPhase` state layout @@ -65,7 +84,7 @@ two tables already parsed in [mission-script-ssb](mission-script-ssb.md). | **132–134** | player gauges | speed/boost ratios and a player byte | | **73, 123–127** | timer family | start / resume / stop / reset / read elapsed / read limit | | **8 / 9 / 93** | `set_flag` / `read_freg` / `clear_flag` | latch a result into the 32-entry files | -| **100 / 115** | `push_trigger` / `named_event` | the engine→script edge | +| **100 / 115** | `reset_phase_threads` / `named_event` | ❌ 100 is **not** `push_trigger` — see below | **The state machine is therefore:** a trigger fires a coroutine → the coroutine tests one of the predicates → it latches the answer with `set_flag` → some later @@ -80,9 +99,63 @@ Two spot-checks I ran against the disassembly rather than taking on trust: a NULL object and state 1, then calls `823011B0` (initial, packed `hi<<16|lo`) and `82301118` (current). Exactly as described. +## ❌ Three built-in names WITHDRAWN + +Each re-read twice — the handler, and the thing it calls — because all three had +been named from their shape rather than their effect. + +| id | was | **is** | evidence | +|---|---|---|---| +| **11** | `yield` | **`end_coroutine`** | `0x82272624` is `li r11,1 ; li r3,3 ; stw r11,164(r31)`. Return 3 **destroys the thread**. It is the single most-used built-in in the game — 2945 sites, 372 in Stage 02 alone — so this was the most load-bearing wrong name in the file. | +| **5** | `await_label` | **`kill_coroutine(label)`** | `sub_82273B08` computes `target = [phase+232] + blob[0]`; if that equals the **caller's own** pc it returns 3 (kill self), otherwise it finds the thread parked at `target` in `[phase+220]` and moves it to the free list. It does not wait for anything. | +| **100** | `push_trigger` | **`reset_phase_threads`** | the handler calls vtable slot 2 (clears the trigger container at `[phase+272]`) and then `sub_82273BE8`, which walks `[phase+220]` and frees **every thread whose pc differs from the caller's**. It drops queued triggers and terminates every *other* coroutine — the opposite of pushing one. | + +One name is newly **recovered**, from the game's own text: interpreter opcode 992 +prints `★RequestScriptMessage %s` (`0x820A5700`), so **id 64 is +`request_script_message`** — 2683 sites, and the second most-used built-in. + +## ✅ Which operands are unit indices — settled from the data + +`tools/re-capture/isl.py` resolved a symbol-table-2 name only for 11 built-ins, +at slot 4. The real set is much larger, and it was established by **measurement +over all 28 stages** rather than by reading 147 handlers: + +> a slot qualifies only if every observed value is a valid symtab-2 index, it +> takes ≥15 distinct values, **and its maximum reaches most of the table**. + +That last clause is what makes the test work. Symbol table 2 tops out at 122 +entries, so a slot carrying something else overruns it; plain range-checking +cannot separate an index from a bool, because every small integer is in range. + +* **unit index at slot 4** — 2, 3, 7, 12, 15, 16, 18, 19, 20, 24, 25, 26, 28, + 29, 30, 47, 48, 56, 57, 58, 63, 69, 70, 79, 91, 92, 95, 105, 108, 128, 143 +* **a second at slot 12** — 2, 18, 47, 48, 56, 79, 95, 128 +* **a third at slot 20** — 128 + +Every one of these is 100.0 % in range across its call sites (the largest, id 20, +over 2930 of them). + +The test also **refuted** a tempting entry: `set_flag`'s slot 0 passes the range +and spread checks but its maximum *exceeds* the table — flag indices run 0..31 +against symbol tables as small as 40 — so it is excluded, and the disassembler +now declines to resolve it rather than printing an invented name. + +❔ **New, unexplained:** symbol table 2 holds **two types**, 2 (1160 entries +disc-wide) and 8 (249), and they are not interchangeable. Built-ins **95** and +**128** take a type-2 unit at slot 4 and, at slot 12, an operand that is type 8 +in **100 %** of its 90 and 152 call sites. What separates the two classes is not +established. + ## ✅ What Stage 02 actually uses — and it settles a standing question -Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`): +Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`, regenerated by +`tools/re-capture/isl_report.py calls`): + +⚠️ The sibling artefact `data/isl-stage02-conditions.txt` **predates the name +corrections above** — it still prints `yield`, `await_label` and `push_trigger`, +and its operand rendering predates the staging fix. It has no committed +generator; reproducing it needs the coroutine entry points, which +`start_coroutine`'s operand carries and the tool does not yet follow. | built-in | sites | |---|---| @@ -91,7 +164,7 @@ Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`): | `dist_lt` | **92** | | `unit_alive` | **71** | | `unit_relation` | **52** | -| `set_flag` / `clear_flag` / `push_trigger` | 12 each | +| `set_flag` / `clear_flag` / `reset_phase_threads` | 12 each | | `END_PHASE` / `MARK_LAST_PHASE` / `FORCE_END_PHASE` | 12 / 8 / 3 | **Not used at all in Stage 02:** `squad_survival_pct`, `group_ratio_pct`, @@ -285,9 +358,16 @@ Read from a running Stage 02 mission (`ScriptPhase 0xBE14DD80`, container at ``` **`+20` moves, 0 → 1 → 2**, while the phase ordinal stays 1. So it is a real -counter of **currently registered triggers** — the script arming watches as it -goes (Stage 02 has 12 `push_trigger` sites) — and it is readable live with no -debugger. That is the first direct view of *what the script is waiting for*. +counter of **currently registered triggers**, readable live with no debugger — +the first direct view of *what the script is waiting for*. + +⚠️ The *measurement* stands; its attribution did not. This paragraph used to add +"the script arming watches as it goes (Stage 02 has 12 `push_trigger` sites)", +pointing at built-in 100. Built-in 100 is `reset_phase_threads` — it **clears** +the trigger container, it does not arm one. The 12 sites are real, but they are +12 places where Stage 02 *tears the trigger set down*, which is close to the +opposite reading. ❔ What actually arms a trigger is now open again; built-ins +19 and 25 both queue into `[phase+272]` and are the first place to look. 🟡 **`+12` is not a list head after all**, or not only that: it reads `0x000A0009`, which is not a pointer. The `addi r31, r30, 12` in the push made diff --git a/tools/re-capture/isl.py b/tools/re-capture/isl.py index d6c8a156..d97d2b0d 100755 --- a/tools/re-capture/isl.py +++ b/tools/re-capture/isl.py @@ -78,18 +78,18 @@ KIND = {0: 'global', 1: 'imm', 2: 'special', 3: 'local'} # guess. See docs/re/structures/isl-builtins.md. BUILTIN = { 1: 'start_coroutine', 2: 'deploy_squadron', 3: 'move_order', 4: 'wait_s', - 5: 'await_label', 6: 'END_PHASE', 8: 'set_flag', 9: 'read_freg', - 10: 'random', 11: 'yield', 13: 'play_se', 14: 'play_bgm', + 5: 'kill_coroutine', 6: 'END_PHASE', 8: 'set_flag', 9: 'read_freg', + 10: 'random', 11: 'end_coroutine', 13: 'play_se', 14: 'play_bgm', 17: 'wait_frames', 18: 'dist_lt', 20: 'hp_pct_test', 24: 'squad_survival_pct', 26: 'damage_unit', 30: 'objective_marker', 31: 'objective_marker_at_route', 33: 'global_counter0', 34: 'global_counter1', 36: 'screen_fade', 39: 'MARK_LAST_PHASE', 40: 'mark_not_last', 43: 'play_voice', 45: 'play_voice_vol', 46: 'squadron_trace', 47: 'squadron_attack', 48: 'squadron_escort', 52: 'play_stream', 53: 'sound_busy', 54: 'stop_sound', - 56: 'unit_relation', 59: 'fade_sound', 62: 'FORCE_END_PHASE', + 56: 'unit_relation', 59: 'fade_sound', 62: 'FORCE_END_PHASE', 64: 'request_script_message', 69: 'unit_state', 70: 'unit_alive', 72: 'group_ratio_pct', 73: 'timer_start', 74: 'timer_limit', 88: 'camera_at', 90: 'camera_at_route', - 93: 'clear_flag', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'push_trigger', + 93: 'clear_flag', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'reset_phase_threads', 102: 'prompt_yes_no', 109: 'set_unit_flags', 115: 'named_event', 120: 'wait_cmds_drained', 123: 'timer_resume', 124: 'timer_stop', 125: 'timer_reset', 126: 'timer_elapsed', 127: 'timer_set', @@ -135,10 +135,30 @@ def symbols(b, which): return out -UNIT_ARG = {18, 20, 24, 26, 56, 69, 70, 94, 95, 105, 109} # unit idx at blob[4] -# Built-ins 71 and 72 carry a SECOND unit index at blob[12] (handler 0x8226E568 -# indexes [phase+324] with both), so their second operand printed as a bare -# integer until this was noticed. 56 may be the same shape -- unverified. +# Built-ins whose operand blob carries a symbol-table-2 (unit) index, by slot. +# +# Derived from the DATA, not from reading 147 handlers: across all 28 stages a +# slot qualifies only if every observed value is a valid symtab-2 index, it takes +# >=15 distinct values, and its maximum reaches most of the table (symtab-2 tops +# out at 122 entries, so a non-index slot overruns). That last clause is what +# makes the test discriminating -- plain range-checking cannot separate an index +# from a bool, because every small integer is "in range". +# +# It also refutes one tempting entry: `set_flag`'s slot 0 passes the range and +# spread tests but its maximum EXCEEDS the table (flag indices run 0..31 against +# tables as small as 40), so it is excluded. Slots are only listed here when the +# ratio stayed below 1.0. +UNIT_ARG = {2, 3, 7, 12, 15, 16, 18, 19, 20, 24, 25, 26, 28, 29, 30, 47, 48, + 56, 57, 58, 63, 69, 70, 79, 91, 92, 95, 105, 108, 128, 143} +UNIT_ARG2 = {2, 18, 47, 48, 56, 79, 95, 128} # a SECOND unit index at blob[12] +UNIT_ARG3 = {128} # and a third at blob[20] +UNIT_SLOTS = {4: UNIT_ARG, 12: UNIT_ARG2, 20: UNIT_ARG3} + +# Symbol table 2 holds TWO entity types: type 2 (1160 entries disc-wide) and +# type 8 (249). They are not interchangeable -- built-ins 95 and 128 take a +# type-2 unit at slot 4 and, at slot 12, an operand that is type 8 in 100% of +# its 90 and 152 call sites respectively. What distinguishes the two classes is +# not yet established. def dis(b, off, count=40, code_base=0x24, args=True, sym2=None): @@ -203,7 +223,11 @@ def dis(b, off, count=40, code_base=0x24, args=True, sym2=None): parts = [] for slot, v in sorted(staged.items()): txt = ('0x%X' % v) if isinstance(v, int) else v - if (sym2 and slot == 4 and words[0] in UNIT_ARG + # Resolve only a slot that is declared a unit index AND + # whose value really is one -- a resolver that invents a + # name for a non-index is worse than one that prints the + # raw number. + if (sym2 and words[0] in UNIT_SLOTS.get(slot, ()) and isinstance(v, int) and v in sym2): txt = sym2[v][1] parts.append(txt) diff --git a/tools/re-capture/isl_report.py b/tools/re-capture/isl_report.py new file mode 100644 index 00000000..8bafdc14 --- /dev/null +++ b/tools/re-capture/isl_report.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Regenerate the committed ISL artefacts under `docs/re/data/`. + + isl_report.py calls -> the call-site census + a listing + +The artefact was produced by an uncommitted one-off, so it drifted out of date +twice: once when operand staging was fixed (calls printed with too few +arguments) and once when three built-in names were corrected. Keeping the +generator in the tree is the point of this file. + +`data/isl-stage02-conditions.txt` still has no generator here. A first attempt +is not committed because it printed most sites as bare `builtinN`: neither +`isl.resync` (it gives up far from a valid start) nor a naive linear decode from +the phase base reaches every call site, so producing that listing faithfully +needs the coroutine entry points, which `start_coroutine`'s operand carries and +this tool does not yet follow. +""" +import collections +import sys + +import isl + +def census(b): + cs = isl.call_sites(b) + return cs, collections.Counter(bid for _, bid, _ in cs) + + +def emit_calls(b, path): + cs, h = census(b) + s2 = isl.symbols(b, 2) + print('# %s — ISL disassembly artefacts' % path) + print() + print('Generated by `tools/re-capture/isl_report.py calls`.') + print() + print('%d call sites, %d distinct built-ins' % (len(cs), len(h))) + for bid, n in h.most_common(): + print(' builtin %-4d %5d site(s)' % (bid, n)) + print() + print('## phase-control sites') + for bid, nm in ((6, 'END_PHASE'), (62, 'FORCE_END_PHASE'), + (39, 'MARK_LAST_PHASE'), (40, 'mark_not_last')): + offs = [off for off, b2, _ in cs if b2 == bid] + print('%-3d %-18s %2d: %s' + % (bid, nm, len(offs), ' '.join('0x%x' % o for o in offs))) + print() + print('## named built-ins used, by traffic') + for bid, n in h.most_common(): + nm = isl.BUILTIN.get(bid) + if nm: + print(' %-3d %-24s %4d' % (bid, nm, n)) + print() + print('## phase code bases') + print('Each phase has its OWN base; the file header offset is not it.') + print(' ' + ' '.join('0x%x' % x for x in isl.phase_bases(b))) + print() + print('## disassembly into the first END_PHASE') + target = [off for off, bid, _ in cs if bid == 6][0] + start = isl.resync(b, target) + print('resync from 0x%X' % start) + for line in isl.dis(b, start, 64, code_base=0x24, sym2=s2): + print(line) + + +def main(): + path = sys.argv[1] + b = isl.load(path) + name = path.replace('\\', '/').split('/')[-1] + {'calls': emit_calls}[sys.argv[2]](b, name) + + +if __name__ == '__main__': + main()