The three unnamed built-ins are one family, and the chain from the dispatch
table to the field write is now followed for all of them:
builtin -> ScriptPhase vtable slot -> interpreter command word 0xAB<op>BA
-> command-table thunk -> opcode handler -> unit message 0xED08nnDE
-> the GROUP pump sub_8232C4C0, which rebroadcasts to each child as
0xED09nnDE
-> the entity base handler sub_82398CC0, which writes the field.
26 -> [unit+532] = min(max(def.HP * pct, 0), def.HP) = set_unit_hp_pct
29 -> [unit+676] = pct, a multiplier on damage TAKEN = set_unit_damage_taken_pct
28 -> [unit+672] = pct, default 1.0 = 🟡 damage DEALT
101 -> the same message as 29 with a hard-wired 0.0, broadcast to every unit
= all_units_invulnerable
`damage_unit` is WITHDRAWN for 26. The handler sets an absolute value rather
than subtracting one, and 100 heals to full -- which no damage primitive does.
It is pinned three ways: [unit+496] is the unit definition (the constructor
sub_82393868 fills it from the same std::map::find built-in 15 uses), [def+84]
is HP in unit_definition_layout.txt and the constructor seeds [unit+532] from
it, and crossing zero loads [def+584] = Delay and raises a flag, i.e. the
destruction sequence. So 0 destroys, with the datasheet's own death delay.
29 is the strongest of the three: [unit+676] has three independent readers
(sub_8237B020, sub_823800A8, sub_82398CC0) and every one multiplies a damage
amount immediately before it reduces [unit+532].
28 is deliberately left 🟡. The write and the 1.0 default are certain, but the
field has exactly ONE reader in the whole image -- the craft update's projectile
spawn, where it ends up as a multiplicative term in the damage message. That is
the mirror of 29 and it is tidy, which is exactly the shape that produced the
wrong names this file has already had to withdraw. What is not established is
that it reaches every weapon; the sibling damage sender sub_82388FF8 has no
+672 term at all.
Three usage tests, all measured over the 28 stages:
* operand ceilings -- 26 is 97/97 inside [0,100] and 29 is 164/164, while 28
(identical signature, identical x0.01 conversion) reaches 2000;
* the craft cross-tab -- 26 splits cleanly into disposable props at 0,
warships at 30-80 and the tutorial player craft at 100; 29 lands on the
player, the tutorial boxes and the escorted TCAF hulls; 28 orders
boss > ace > elite > line > prop;
* the setup idiom -- activate_unit, then 15/29/28 as a speed/toughness/
firepower trio, with 26 added wherever a unit must arrive pre-damaged.
Refutations attempted are recorded, including the two that turned into
confirmations (Stage 28 makes each tutorial box invulnerable with 29 and then
removes it with 26) and the offset-search trap that produced three false
readers, because projectiles have their own fields at 672 and 676.
Also corrected: the state guards. 26 rejects states 3 and 4; 28 and 29 reject
1, 3 and 4. This file said otherwise for both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
1285 lines
63 KiB
Markdown
1285 lines
63 KiB
Markdown
# The 147 ISL built-ins
|
||
|
||
Status: ✅ table encoding, calling convention and the `ScriptPhase` state layout;
|
||
✅ ~135 of 147 handlers characterised from the disassembly; 🟡 three resolved
|
||
only partially; ❔ the interpreter-command table is only partly recovered.
|
||
✅ built-ins **26 / 29 / 101** are now named end to end (HP %, damage-taken %,
|
||
broadcast invulnerability); 🟡 **28** writes the matching damage-**dealt** field
|
||
but its one consumer is a single weapon path — see below.
|
||
|
||
Companion to [isl-bytecode](isl-bytecode.md) (the instruction encoding) and
|
||
[mission-phase-advance](../mission-phase-advance.md) (why phases hinge on these).
|
||
|
||
## ✅ Table and calling convention
|
||
|
||
`0x8227226C … 0x822724B7` is **147 big-endian absolute VAs** — no base-relative
|
||
offsets. Verified structurally: the table starts immediately after the `bctr` at
|
||
`0x82272268`, `0x8227226C + 147·4 = 0x822724B8` is exactly where the first
|
||
handler begins, and every target lies inside `sub_82272220`.
|
||
|
||
Six ids are **unused defaults** (0, 0x41–0x44, 0x7A) and about ten more are
|
||
deliberate stubs returning a constant.
|
||
|
||
Arguments do **not** live in the instruction. Every handler starts
|
||
`addi r3,r31,20 ; bl 0x82454A40` — `std::string::c_str()` — so `[phase+20]` is a
|
||
**packed operand blob**, which is what the `local[]` staging in
|
||
[isl-bytecode](isl-bytecode.md) fills.
|
||
|
||
### ✅ 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
|
||
|
||
| offset | meaning |
|
||
|---|---|
|
||
| `+88` | **32-entry float register file** |
|
||
| `+120` | **32-entry flag register file** |
|
||
| `+160` | frame-wait counter |
|
||
| `+164` / `+176` | **int** / **double result register** |
|
||
| `+196` | phase-finished flag |
|
||
| `+232` / `+236` | code base / end-event offset |
|
||
| `+244` | **symbol table 1** base (route + message names) |
|
||
| `+272` | trigger queue |
|
||
| `+300` | 1 = not last phase, 2 = last |
|
||
| `+304…+320` | mission timer (elapsed, t0, limit, running, enabled) |
|
||
| `+324` | **runtime unit array**, indexed by **symbol table 2** index |
|
||
|
||
Per-unit record: `+4` live object (NULL = absent), `+16` state (2 = active;
|
||
1/3/4 = gone/dead/invalid), `+32/40/48` position, `+128/132` HP / max HP,
|
||
`+140` flag bitmask.
|
||
|
||
**That is the hook into the data**: blob fields indexing `[phase+244]` are
|
||
symtab-1 indices and fields indexing `[phase+324]` are symtab-2 indices — the
|
||
two tables already parsed in [mission-script-ssb](mission-script-ssb.md).
|
||
|
||
## ✅ The conditions a phase can test
|
||
|
||
| id | name | what it tests |
|
||
|---|---|---|
|
||
| **6 / 62** | `END_PHASE` / `FORCE_END_PHASE` | sets `[+196]`, with / without the end event |
|
||
| **39 / 40** | `MARK_LAST_PHASE` / `mark_not_last` | `[+300] = 2` / `1` |
|
||
| **69 / 70** | `unit_state` / `unit_alive` | a **named unit's** lifecycle state; state == 2 |
|
||
| **20 / 95** | `hp_pct_test` / `unit_hp_pct` | unit HP as a percentage of max |
|
||
| **18** | `dist_lt` | 3-D distance between two named units below a threshold |
|
||
| **24 / 72** | `squad_survival_pct` / `group_ratio_pct` | current ÷ initial squadron members × 100 |
|
||
| **56 / 94** | `unit_relation` / `is_engaged` | relation between units; is anything engaging this one |
|
||
| **33 / 34** | `global_counter0/1` | two global counters read straight into `[+164]` |
|
||
| **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** | `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
|
||
thread reaches `END_PHASE`.
|
||
|
||
Two spot-checks I ran against the disassembly rather than taking on trust:
|
||
|
||
* **id 4 (`wait_s`)** — `c_str()`, `li r3,2` (yield), `lfd f0,0(r11)`,
|
||
`stfd f0,8(r30)`: a **double** seconds value into the thread countdown. Exactly
|
||
as described.
|
||
* **id 24 (`squad_survival_pct`)** — indexes `[phase+324]` by `[arg+4]`, rejects
|
||
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.
|
||
|
||
### ✅ The interpreter's command table, recovered
|
||
|
||
`sub_822FE040` fills 1023 eight-byte slots at `table+32` with a default and then
|
||
overwrites individual ones; slot = `(N - 32) / 8` from each `std r9, N(r31)`.
|
||
Symbolically executing it yields **57 populated slots** — the full opcode →
|
||
handler map, committed as [`data/isl-command-table.txt`](../data/isl-command-table.txt)
|
||
and regenerable with `tools/re-capture/isl_cmdtab.py`.
|
||
|
||
Nine opcodes point at `0x82391BA8`, which is `li r3,1 ; blr` — accept-and-discard.
|
||
**768, 769, 774, 775, 776, 791, 792, 793 and 805 are dead in this build**, which
|
||
is why the built-ins that post them do nothing.
|
||
|
||
### ✅ Opcodes 800–802 send unit messages `0xED08 nn DE`
|
||
|
||
Their table entries are thunks 8 bytes apart into handlers `0x823008C8`,
|
||
`0x823009B8`, `0x82300AA8` — each 60 instructions, and **differing in exactly two
|
||
words**: a descriptor offset and the message id.
|
||
|
||
| opcode | built-in | unit message |
|
||
|---|---|---|
|
||
| 800 | 26 | **`0xED0802DE`** |
|
||
| 801 | **28** | **`0xED0803DE`** |
|
||
| 802 | **29**, 101 | **`0xED0804DE`** |
|
||
|
||
That fixes the id format as `0xED08 nn DE`, and the ids already known from other
|
||
work fit it: opcode 514 → `00DE`, 803 → `07DE`, 999 → `0FDE`.
|
||
|
||
✅ **The pump's arm for `0xED0802DE` applies no effect itself** — it walks the
|
||
unit's child list at `[unit+320]`/`[unit+324]` and **rebroadcasts** to each child
|
||
as `0xED0902DE`. So `0xED08…` is the to-group family and `0xED09…` the
|
||
to-individual one. That link is now followed all the way to the effect — see
|
||
below.
|
||
|
||
### ✅ Built-ins 26 / 28 / 29 RESOLVED — HP, damage dealt, damage taken
|
||
|
||
The chain was followed end to end for all three, from the dispatch table to the
|
||
field write. Nothing here is inferred from shape; every hop was read.
|
||
|
||
| built-in | vtable slot | method | opcode | cmd word | interp thunk | opcode handler | to-group msg | to-child msg | arm | writes |
|
||
|---|---|---|---|---|---|---|---|---|---|---|
|
||
| **26** | 76 | `sub_8226ACD0` | 800 | `0xAB0320BA` | `0x822FF118` | `sub_823008C8` | `0xED0802DE` | `0xED0902DE` | `0x82398E8C` | `[unit+532]` = **HP** |
|
||
| **28** | 84 | `sub_82268F98` | 801 | `0xAB0321BA` | `0x822FF120` | `sub_823009B8` | `0xED0803DE` | `0xED0903DE` | `0x8239926C` | `[unit+672]` |
|
||
| **29** | 88 | `sub_822690B0` | 802 | `0xAB0322BA` | `0x822FF128` | `sub_82300AA8` | `0xED0804DE` | `0xED0904DE` | `0x823994C0` | `[unit+676]` |
|
||
|
||
The cmd word decodes as `0xAB` `opcode:16` `0xBA`, which is the same encoding
|
||
already recorded for 256 (`0xAB0100BA`), 513 (`0xAB0201BA`) and 996
|
||
(`0xAB03E4BA`) — `0x320/0x321/0x322` = 800/801/802.
|
||
|
||
Two links that the previous attempt did not have:
|
||
|
||
* the **to-group pump is `sub_8232C4C0`** (vtable `0x820AF1FC` slot 6), whose
|
||
arms at `0x8232C824` / `0x8232CB48` / `0x8232CC40` each walk the child list and
|
||
re-post with the `0xED09…` id, copying the float from `[msg+36]` unchanged;
|
||
* the **to-individual handler is `sub_82398CC0`** (vtable `0x820B192C` slot 6),
|
||
the entity base class. Its `0xED0902DE` arm is reached by a `sub.`/`cmplwi 0x8200`
|
||
pair rather than a `lis`+`ori` compare, which is why a constant scan for
|
||
`0xED0902DE` finds only the *sender*. That is worth remembering: **a
|
||
subtract-based compare hides the constant from any scan that pairs
|
||
`lis` with `ori`.**
|
||
|
||
#### ✅ 26 is `set_unit_hp_pct` — NOT `damage_unit`
|
||
|
||
`sub_8226ACD0` multiplies the double operand by the constant at `0x820C5688`,
|
||
which is **0.01** — so the operand is a percentage and the message carries a
|
||
fraction. `0x82398E8C` then does, in its own terms:
|
||
|
||
```
|
||
new = min( max(def.HP * frac, 0.0), def.HP ) ; def = [unit+496]
|
||
[unit+532] = new
|
||
if new < FLT_EPSILON and old > 0: ; crossed to zero
|
||
[unit+688] = 0.0 ; [unit+1340] = 1 ; [unit+692] = def.Delay
|
||
```
|
||
|
||
Three independent facts pin the field:
|
||
|
||
* `[unit+496]` is the **unit definition** — the entity constructor
|
||
`sub_82393868` sets it from `sub_82348830` (the per-member definition
|
||
`std::map::find` already identified for built-in 15) at `0x82393BE4`;
|
||
* `[def+84]` is **`HP`** in
|
||
`crates/sylpheed-formats/data/unit_definition_layout.txt`, and the same
|
||
constructor immediately does `[unit+532] = [def+84]` at `0x82393BF0` — so
|
||
`+532` *is* current HP, seeded from the datasheet maximum;
|
||
* the crossing branch loads **`[def+584]` = `Delay`** into `[unit+692]` and sets
|
||
a flag — the destruction sequence. So a percentage of **0 destroys the unit**,
|
||
with the definition's own death delay.
|
||
|
||
So the operand is *percent of maximum HP*, the write is absolute (a **set**, not
|
||
a subtract), and `damage_unit` is withdrawn: 100 heals to full, which no
|
||
"damage" primitive does.
|
||
|
||
#### ✅ 29 is `set_unit_damage_taken_pct` — `[unit+676]` scales incoming damage
|
||
|
||
`[unit+672]` and `[unit+676]` are **both initialised to 1.0** by the entity
|
||
constructor (`0x8239395C`/`0x82393964`, from the float at `0x8208583C`), so both
|
||
are multipliers whose neutral value is 1.0 and which built-ins 28/29 set to
|
||
`operand/100`.
|
||
|
||
`[unit+676]` has **three independent readers**, and every one of them multiplies
|
||
a damage amount immediately before it is subtracted from `[unit+532]`:
|
||
|
||
| reader | what it computes |
|
||
|---|---|
|
||
| `0x8237B20C` in `sub_8237B020` (craft subclass `0x820B0A3C` slot 6, the `0xED0200DE` damage arm) | `dmg = [msg+36] × def.ResistanceToPlayer × [unit+676]` |
|
||
| `0x823803A4` in `sub_823800A8` (another subclass's damage arm) | the same product |
|
||
| `0x82399FFC` in `sub_82398CC0` (the base class's continuous-damage arm) | `dmg = def.HP × globalRate × t × [unit+676]`, then `[unit+532] -= dmg` |
|
||
|
||
`def+128` is `ResistanceToPlayer` in the layout file, which is the second
|
||
corroboration that the object is the entity and the quantity is damage.
|
||
|
||
#### 🟡 28 sets `[unit+672]`, and its one consumer scales damage DEALT
|
||
|
||
The write is certain and the field's default (1.0) is certain. The **label** is
|
||
one link weaker than 29's, and this is the honest state:
|
||
|
||
`[unit+672]` is read in **exactly one place** in the image —
|
||
`0x8237A44C`, inside `sub_823785A0`, which is vtable `0x820B0A3C` **slot 1**,
|
||
i.e. the craft subclass's per-frame update. There it is copied into a 240-byte
|
||
projectile spawn record at `+100`; `sub_8238F4D0` turns that record into a
|
||
projectile with `[proj+300] = [rec+100]`; and `sub_8238FE10` builds the
|
||
`0xED0200DE` damage message with **`[msg+36] = [proj+300] × charge × globalScale`**
|
||
— the same `[msg+36]` that the readers above multiply by the *target's* `+676`.
|
||
|
||
So the two fields are the two ends of one damage product: **`+672` on the
|
||
shooter, `+676` on the target.** That is a tidy story and it is exactly the
|
||
shape that has produced wrong names in this file before, so it is 🟡 and not ✅.
|
||
|
||
❔ **What is not established**: that `+672` reaches *every* weapon. The one read
|
||
site is on a charged-shot path (`[unit+2244]` is a charge accumulator compared
|
||
against thresholds, `[unit+2268]` the resulting power), and the other damage
|
||
sender in the same family, `sub_82388FF8`, computes
|
||
`dmg = ammo.base × [proj+268] × k` with **no `+672` term at all**. Either the
|
||
craft update funnels all firing through this one spawn, or `+672` scales only
|
||
some weapons. Not settled.
|
||
|
||
#### ✅ The operand-range test discriminates the three
|
||
|
||
All three built-ins have the identical signature `(unit, double)` and the
|
||
identical `× 0.01` conversion, so an operand ceiling is a real measurement rather
|
||
than a coincidence:
|
||
|
||
| built-in | n | distinct | min | max | inside [0, 100] |
|
||
|---|---|---|---|---|---|
|
||
| 26 | 97 | 7 | 0 | **100** | **97 / 97** |
|
||
| 29 | 164 | 6 | 0 | **100** | **164 / 164** |
|
||
| 28 | 410 | 13 | 0.1 | **2000** | 42 / 410 |
|
||
|
||
26 and 29 respect a hard 100 ceiling — they are percentages of something with a
|
||
natural maximum (HP; and "no more damage than normal"). 28 does not: it is a
|
||
free multiplier that reaches 20×. 🟡 Note that 29 therefore **only ever makes a
|
||
unit tougher**, never more fragile.
|
||
|
||
#### ✅ The craft cross-tab — the same test that settled built-in 15
|
||
|
||
Joining every call site to its squadron's craft through `UnitGroup_S<NN>.tbl`
|
||
resolves **671 of 671** sites (26 + 28 + 29), none unknown. Full dump in
|
||
[`data/isl-builtins-26-28-29-sites.txt`](../data/isl-builtins-26-28-29-sites.txt).
|
||
|
||
**26** splits into three groups with nothing in between:
|
||
|
||
| group | craft (sites) | values |
|
||
|---|---|---|
|
||
| disposable objects | `e201_ISCMissile` 17, `n001_TTRL_Box` 16, `f202_Cargo` 9, `e015_Puppy(_2)` 10, `mn500_FloatingMine` 3, `e011_Attacker_B` 2 | **0, and only 0** |
|
||
| warships | `e106_Destroyer` 8, `f106_Destroyer` 8, `f105_Cruiser` 5, `e108_ASFrigate` 4, `e105_Cruiser` 2, `f101_Acropolis` 1 | 30 / 50 / 60 / 80 |
|
||
| the player's craft in a tutorial | `..._Player_Ttrl1/2`, `..._T_Ttrl` (8) | **100** ×7, 40 ×1 |
|
||
|
||
Scripted removal of props, pre-damaged capital-ship spawns, and healing the
|
||
player to full at the start of a tutorial section — one field, three uses, and
|
||
no other reading covers all three.
|
||
|
||
**29** is dominated by the ships the mission must protect:
|
||
|
||
| craft (sites) | values |
|
||
|---|---|
|
||
| player + wingman `DeltaSaber` variants (`_T`, `_W`, `_A`, `_Player`) 113 | **0** and **100**, alternating |
|
||
| `n001_TTRL_Box` 9 | **0** only |
|
||
| escorted TCAF hulls — `f202_Cargo` 14, `f106_Destroyer` 2, `f105_Cruiser` 2, `f104_Battleship` 1, `f102_LightCarrier` 1, `f101_Acropolis` 1 | 50 / 75 |
|
||
| named ADAN aces — `e001_Elan_GR`, `_GR_Violeta` 6; `e010_Attacker_S` 18 | 40 / 50 |
|
||
|
||
**28** stratifies by combat class and is applied almost only to ADAN craft:
|
||
|
||
| class | craft (sites) | value |
|
||
|---|---|---|
|
||
| named bosses | `e003_ElanPlus_Margras` 4, `e005_ElanTypeQ_Margras` 1 | **2000** |
|
||
| named aces | `e001_Elan_GR(_Violeta)` 6, `e013_ElanPlus_Taskent` 2 | **1000** |
|
||
| elite fighters | `e004_ElanPlus_N` 19, `e009_Phantom` 16, `e002_Elan_N` 9, `e011_Attacker_B_HF` 3 | **500–600** |
|
||
| line fighters | `e001_Elan` 24, `e010_Attacker_S` 21, `e011_Attacker_B` 6 | 200–300 |
|
||
| capital hulls | `e106_Destroyer` 105, `e104_Carrier` 55, `e105_CruiserEX` 52, `e102_BattleshipEX` 26 | 100–300 |
|
||
| background props | `f106_Destroyer_Inv`, `f102_LightCarrier_Inv`, `f101_Acropolis`, `f105_Cruiser` (5) | **0.1** |
|
||
|
||
A monotone boss > ace > elite > line > prop ordering is what a firepower knob
|
||
looks like. 🟡 It is also what several other knobs would look like, which is why
|
||
the name stays PROBABLE and rests on the disassembly link above rather than on
|
||
this table.
|
||
|
||
#### ✅ The idiom that ties all three together
|
||
|
||
The per-unit setup block that follows every deployment reads, e.g. Stage 06
|
||
phase 3 at `0x0119AC` (from
|
||
[`data/isl-builtins-26-28-29-sites.txt`](../data/isl-builtins-26-28-29-sites.txt)):
|
||
|
||
```
|
||
activate_unit(TCN001) set_group_speed(TCN001, 400) 29(TCN001, 0)
|
||
activate_unit(TCN002) set_group_speed(TCN002, 400) 29(TCN002, 0) 28(TCN002, 50)
|
||
activate_unit(TCN003) set_group_speed(TCN003, 400)
|
||
... 26(TCN306, 50)
|
||
```
|
||
|
||
`15 / 29 / 28` is a **speed / toughness / firepower trio**, each a percentage
|
||
override of the craft's datasheet, applied immediately after `activate_unit`;
|
||
`26` joins it wherever a unit should arrive pre-damaged.
|
||
|
||
And the scripted-drama use is unambiguous. Stage 06 makes the wingman flight
|
||
TCN002 invulnerable at phase start (`29(TCN002, 0)`), then walks its HP down as
|
||
the voice lines fire:
|
||
|
||
```
|
||
0x01A7B0 26(TCN002, 80) -> request_script_message(MSG_VOICE_D_065)
|
||
0x01AAA0 26(TCN002, 50) -> request_script_message(MSG_VOICE_D_069)
|
||
0x01ACEC 26(TCN002, 30)
|
||
```
|
||
|
||
You would only combine those two built-ins that way if they meant exactly "this
|
||
unit cannot be hurt by combat" and "set this unit's HP to N %".
|
||
|
||
#### ✅ Refutation attempts, and what they found
|
||
|
||
Recorded because two of them turned into confirmations.
|
||
|
||
* **"26 kills a unit the mission still needs."** Over all 28 stages there are
|
||
200 (stage, unit) pairs where a predicate on `u` follows a `26(u, 0)` in file
|
||
order. Every one inspected is a `unit_alive` / `unit_state` / `hp_pct_test`
|
||
**poll waiting for that death** — which is what a scripted kill implies, not a
|
||
contradiction. Not a counterexample, but the count is recorded rather than
|
||
hidden.
|
||
* **"29 makes a unit invulnerable that the player is required to destroy."**
|
||
The nine `n001_TTRL_Box` targets in Stage 28 do get `29(box, 0)`. They are
|
||
then destroyed by **`26(box, 0)`** later in the same file — nine boxes, nine
|
||
pairs, invulnerability first (offsets 0x2E4C–0x48C8) and the scripted removal
|
||
second (0x50F8–0x5FA0). The tutorial target cannot be shot down; the script
|
||
removes it when the lesson ends. The counterexample became a confirmation.
|
||
* **"28 is applied to something with no weapons."** Of the 35 craft classes it
|
||
touches, none is an unarmed prop — no asteroid, box, cargo or mine ever
|
||
receives 28, while 26 and 29 both do. The only near-miss is the four
|
||
`…_Inv` / background hulls at 0.1, i.e. deliberately harmless.
|
||
* **"`+672`/`+676` have another consumer that contradicts damage."** Searched
|
||
every `lfs`/`stfs`/`addi`/`lfsx` reference to offsets 672 and 676 across the
|
||
whole `.text`, then filtered by base register. **No further entity-side reader
|
||
of either exists.** ⚠️ The search also produced the trap this file keeps
|
||
warning about: `sub_8238E0F0` writes `676(r3)` and `sub_82389558` reads
|
||
`672(r31)`, but in both `r3`/`r31` is a **projectile**, which has its own
|
||
fields at those offsets. Three of the five "extra readers" the raw offset
|
||
search returned were that mistake.
|
||
* **What I could not refute and could not confirm**: that `+672` reaches every
|
||
weapon (see the ❔ above).
|
||
|
||
#### ✅ Bonus: built-in 101 is `all_units_invulnerable`
|
||
|
||
`sub_822691C8` is 29's **broadcast** twin — same descriptor `0x820A8D20`, same
|
||
opcode 802 — but it takes **no operand**: the float it sends is the constant at
|
||
`0x8209FD28`, which is **0.0**, and it loops over the phase's whole unit array
|
||
posting `0xED0804DE` to each live entry.
|
||
|
||
The usage confirms it. All **133** sites sit in one fixed phase-teardown idiom,
|
||
with no exceptions:
|
||
|
||
```
|
||
builtin116(0) -> builtin101 -> reset_phase_threads -> timer_stop -> clear_flag(-1) -> builtin118
|
||
```
|
||
|
||
133/133 preceded by 116 and 133/133 followed by 100. Freezing all damage is
|
||
exactly the first step of tearing a phase down, and no other reading of "post
|
||
0.0 to every unit" fits a teardown.
|
||
|
||
#### ❌ Two corrections to this file's earlier entries
|
||
|
||
* **The state guards were mis-stated.** Read directly: **26 rejects states 3 and
|
||
4**; **28 and 29 reject 1, 3 and 4**. This file said 26 rejects "only state 3"
|
||
and 28/29 "states 1 and 3". So 26 alone will still act on a state-1 unit.
|
||
* **"28 is an absolute quantity, 26 and 29 are percentage-shaped"** was half
|
||
right for the wrong reason. All three are percentages of *something*; only 26
|
||
and 29 are percentages of a thing with a ceiling.
|
||
|
||
#### 🗒️ The route that failed, kept
|
||
|
||
The three commands' descriptors at `0x820A8D10` / `+8` / `+16` are **vtables**,
|
||
not data: slot 0 of each is `0x82301C20` and slot 1 points into `0x8210E5xx`,
|
||
below the disassembly DB's range. Chasing them still leads nowhere. The route
|
||
that works is the one this file already recommended — the **interpreter command
|
||
table** → `0x822FF118/120/128` → the three opcode handlers — and from there the
|
||
**unit-message pump**, which is the part that was missing.
|
||
|
||
### ✅ Built-in 108 is `deploy_squadron_ex` — `deploy_squadron` plus a `1 << n` selector
|
||
|
||
1146 sites in 22 stages, the second-largest unnamed built-in. Its method
|
||
(`sub_822646B8`, vtable slot 300) and built-in 2's (`sub_822642E0`, slot 12) are
|
||
190 and 199 instructions and **differ in one block**. Diffed instruction by
|
||
instruction, 108 adds:
|
||
|
||
```
|
||
lwz r11, 16(r29) ; the blob's slot-16 int
|
||
cmpwi cr6, r11, 0
|
||
blt cr6, … ; n < 0 -> default
|
||
cmpwi cr6, r11, 31
|
||
bgt cr6, … ; n > 31 -> default
|
||
li r25, 1
|
||
slw r21, r25, r11 ; r21 = 1 << n <- a 32-bit selector
|
||
b …
|
||
li r25, 1
|
||
mr r21, r25 ; default: 1
|
||
```
|
||
|
||
Everywhere built-in 2 passes its `r21`, 108 passes `r20` and reserves `r21` for
|
||
the mask — so the bit is an **extra argument to the same call**, not a
|
||
replacement. And both post the identical command word **`AB0100BA`, opcode 256**.
|
||
|
||
#### ✅ The operand is always a valid bit index
|
||
|
||
Over all 1146 sites the slot-16 value is in **[0, 31] — 1146 / 1146, none
|
||
outside**, so the out-of-range default never fires in shipped content. Fifteen
|
||
distinct values, clustered at 16 (531×), 31 (165×), 20 (161×) and 2 (90×);
|
||
21 of the 22 stages use more than one.
|
||
|
||
❔ **What the bit selects is not established.** There is a 32-bit space here —
|
||
built-in 92 reportedly allocates a *free* bit by OR-ing over live units, which
|
||
would make 108 the "place this squadron in a named slot" counterpart — but I have
|
||
not verified that, so the name says only what is proven: the same deploy as
|
||
built-in 2, with an extra selector.
|
||
|
||
### ✅ Built-in 12 is `activate_unit` — 1197 sites, all 28 stages
|
||
|
||
The highest-traffic unnamed built-in. `sub_822659F0`, read directly:
|
||
|
||
* indexes `[phase+324]`'s record array by the slot-4 symbol;
|
||
* **returns 0 immediately when the live object `[record+4]` is NULL** — so it
|
||
registers an object that already exists; it does **not** spawn one;
|
||
* sets **`[record+16] = 2`**, the documented *active* state that every unit
|
||
predicate tests;
|
||
* stores `sub_82301118`'s packed result into `[record+20]` (low 16 bits) and
|
||
`[record+24]` (high 16) — member counts;
|
||
* posts interpreter opcode **513** (`0xAB0201BA`) either way; the slot-8 mode
|
||
(1 in 999 sites, 0 in 198) only decides whether `cmd+20` is also set to 1.
|
||
|
||
#### ✅ The ordering test — 517 / 517
|
||
|
||
If this activates a unit for the script, no predicate should ever test a unit
|
||
before it. Over all 28 stages, for every (stage, unit) pair having both an
|
||
`activate_unit` call and a predicate (`unit_state`, `hp_pct_test`, `unit_alive`,
|
||
`unit_hp_pct`, `dist_lt`, `unit_relation`) on that same unit:
|
||
|
||
| | count |
|
||
|---|---|
|
||
| `activate_unit` comes first | **517** |
|
||
| a predicate comes first | **0** |
|
||
|
||
🟡 That is *file* order, not proven execution order — coroutines mean the two can
|
||
in principle interleave. But 517 with zero exceptions across 28 stages is not a
|
||
coincidence.
|
||
|
||
344 units are tested without ever being activated (they are live from mission
|
||
start) and 203 are activated without being tested.
|
||
|
||
#### ❌ It is NOT the survival-percentage baseline
|
||
|
||
The tempting closure: `squad_survival_pct` (id 24) reports current ÷ initial, and
|
||
`activate_unit` snapshots counts, so the snapshot must be the baseline. **It is
|
||
not.** Built-in 24 reads `[record+16]` for the state and then calls
|
||
`sub_823011B0` and `sub_82301118` on the **live object** — it never touches
|
||
`+20`/`+24`. ❔ What reads those two fields is unidentified.
|
||
|
||
### ✅ Built-in 15 is `set_group_speed` — the group's commanded speed
|
||
|
||
`15` is the biggest unnamed built-in: **1360 sites across 27 of 28 stages**. Its
|
||
operand shape is invariant — `(unit, double)` — and joining every call site's
|
||
unit to its craft type through `stage\UnitGroup_S<NN>.tbl` resolves **1360 of
|
||
1360, none unknown**. The values stratify hard by class:
|
||
|
||
| class | craft (sites) | values |
|
||
|---|---|---|
|
||
| capital hulls | `e106_Destroyer` (159), `e104_Carrier` (62), `e105_CruiserEX` (53), `f105_Cruiser` (40), `f101_Acropolis` (30), `e102_BattleshipEX` (27), `e108_ASFrigate` (33) | **0–100** |
|
||
| mobile craft | `e001_Elan` (73) 500, `f003_ArrowHead` (54) 400, `f001_DeltaSaber_T` (28) 400, `f002_DeltaSaber_W` (36) **600**, `e010_Attacker_S` (54) 250–400, `e009_Phantom` (16) 300 | **250–600** |
|
||
| asteroid | `mn040_Asteroid_Big` (74) | **0, and only 0** |
|
||
|
||
Capital ships crawl, fighters run, the player's own craft is fastest at 600, and
|
||
a rock never moves. That is a speed's signature and it is hard to explain any
|
||
other way.
|
||
|
||
#### ❌ Except that turrets break it
|
||
|
||
| craft | sites | values |
|
||
|---|---|---|
|
||
| `UN_e007_ADAN_Turret` | 112 | 400 ×89, 280 ×17, 380 ×4, 250 ×2 |
|
||
| `UN_e008_ADAN_TurretPlus` | 64 | 450 ×62, 500 ×2 |
|
||
|
||
**176 of the 1360 sites — 13 % — are turrets, and they carry fighter-class
|
||
values.** A turret does not move, so a literal hull speed cannot be what this
|
||
sets for them. Either the field means something else (a projectile speed, a
|
||
tracking rate, an FCS target-speed cap), or it means different things by class.
|
||
|
||
The asteroid is the control that makes this sharp: a genuinely immobile object
|
||
gets **0 every time**, so "immobile things get a meaningless value" does not
|
||
explain the turrets either.
|
||
|
||
#### ✅ What the members contribute — and why the turrets stop being a problem
|
||
|
||
`sub_82348830` is a `std::map::find`, and the object it returns is the per-member
|
||
**unit definition**. That identification is not a guess: the same spawn loop
|
||
builds *two* aggregates, and each lands on a semantically apt field with the apt
|
||
reducer —
|
||
|
||
| group field | reducer | member field (`unit_definition_layout.txt`) |
|
||
|---|---|---|
|
||
| `+192` | **min**, seeded `FLT_MAX` | `+164` = **`CruisingVelocity`** |
|
||
| `+472` | **sum** | `+84` = **`HP`** |
|
||
|
||
A wrong struct would have to make both offsets land on apt fields *and* pair each
|
||
with the apt reducer. Minimum of a speed, sum of hit points: that is a formation's
|
||
cruise limit and its total health.
|
||
|
||
#### ✅ The quantitative test, over all 1360 sites
|
||
|
||
If the script is commanding a speed, its value should respect the craft's own
|
||
limits. Joining every call site to its craft's definition:
|
||
|
||
| bound | holds | fails |
|
||
|---|---|---|
|
||
| value ≤ the craft's **`MaximumVelocity`** | **1355 / 1360 = 99.6 %** | 5 |
|
||
| value ≤ the craft's `CruisingVelocity` | 1042 / 1360 = 76.6 % | **318** |
|
||
|
||
The test discriminates: the cruise bound is broken 318 times, the hull maximum
|
||
just 5. So the script sets a **commanded speed**, free to exceed the cruise
|
||
default and bounded by what the hull can do.
|
||
|
||
🟡 The five exceptions are small overshoots on three craft —
|
||
`UN_e106_ADAN_Destroyer` 200 against a 150 maximum (×2) and
|
||
`UN_e011_ADAN_Attacker_B_HF`/`_Wayne` 500 against 450 (×3). Designer overrides,
|
||
or the engine clamps; not established.
|
||
|
||
#### ✅ The turret anomaly dissolves
|
||
|
||
`UN_e007_ADAN_Turret`'s definition carries `MaximumVelocity` **500** and
|
||
`CruisingVelocity` 280 — the data models turrets as if mobile. So a script value
|
||
of 400 is perfectly legal in the data model; it simply never manifests, because a
|
||
turret does not translate. The 13 % of sites that looked like a refutation were
|
||
an artefact of assuming turrets have no velocity fields.
|
||
|
||
**Named `set_group_speed`.** Default = the slowest member's `CruisingVelocity`;
|
||
mode 1 restores it, mode 3 sets it, mode 2 hands it a global constant.
|
||
|
||
#### ✅ The write side, read directly
|
||
|
||
`0x8232C7CC` in the unit message pump switches on `[msg+36]` three ways:
|
||
|
||
| mode | effect |
|
||
|---|---|
|
||
| 1 | `[unit+196] = [unit+192]` — restore the unit's stored default |
|
||
| 2 | `[unit+196] = [r27+13912]` — a global constant |
|
||
| 3 | `[unit+196] = [msg+40]` — the value the script passed |
|
||
|
||
So the field has a **per-unit default at `+192`** and a distinguished global
|
||
value, and built-in 15 either sets it, restores it, or hands it the constant.
|
||
The shipped scripts only ever use mode 3: slot 8 is the double tag in
|
||
**1961/1961** call sites, so the two defaulting modes are never exercised.
|
||
|
||
#### ✅ `+192` is a MIN over the group's members — seeded with `FLT_MAX`
|
||
|
||
The spawn routine `sub_8232B538` settles what the field *is*, without needing its
|
||
consumer:
|
||
|
||
```
|
||
8232B5B0 lfs f0, 25184(r11) ; r11 = 0x820B0000 -> 0x820B6260 = 3.4028235e38
|
||
8232B5B4 stfs f0, 192(r30) ; seed +192 with FLT_MAX
|
||
… per member …
|
||
8232B674 lfs f13, 164(r3) ; the member's own value
|
||
8232B67C lfs f0, 192(r30)
|
||
8232B680 fsubs f11, f13, f0
|
||
8232B688 fsel f0, f11, f0, f13 ; f0 = min(f0, f13)
|
||
8232B68C stfs f0, 192(r30)
|
||
… later …
|
||
8232BA10 lfs f0, 192(r30)
|
||
8232BA28 stfs f0, 196(r30) ; +196 starts equal to +192
|
||
```
|
||
|
||
The seed being **`FLT_MAX`** is what makes this unambiguous: `+192` is the
|
||
**minimum of `[member+164]` across the members**, and `+196` starts there.
|
||
|
||
🔑 **So built-in 15's target is a GROUP, not an individual unit** — this file and
|
||
my earlier notes both called it a unit. `sub_82348830` hands back a per-member
|
||
object and the reduction runs over all of them.
|
||
|
||
#### 🟡 That reading survives the turret anomaly
|
||
|
||
A minimum over members is exactly how a **formation limit** works: the group can
|
||
only go as fast as its slowest ship. On that reading `+192` is the natural cap and
|
||
`+196` the effective one, with built-in 15 either lowering it, restoring it
|
||
(mode 1), or handing it a global constant (mode 2).
|
||
|
||
And a *cap* explains the turrets, where a commanded speed could not. Capping a
|
||
static object at 400 is a **no-op** — nothing makes it move — so a designer can
|
||
set it uniformly from a unit template without consequence. The asteroid's
|
||
invariant `0` is consistent with the same reading.
|
||
|
||
❔ I have **not** shown mode 2's constant is `FLT_MAX`. It is
|
||
`[r27 + 13912]` where `r27` is loaded from a runtime pointer at `0x8232C718`, not
|
||
a static base, so it cannot be resolved from the image alone. If it is `FLT_MAX`
|
||
then mode 2 is literally "uncapped" and the three modes are
|
||
set / uncap / restore — tidy, and unproven.
|
||
|
||
#### ❌ The consumer is still NOT identified — three filters failed
|
||
|
||
Recorded so the next attempt does not repeat them:
|
||
|
||
* Searching the flight/AI range for `196(rN)` gives **170** hits, almost all on
|
||
unrelated structs — the same `N(rM)` trap this file already warns about.
|
||
* Narrowing to functions that touch **both** `+192` and `+196` still leaves
|
||
**50**. The pair is not distinctive either.
|
||
* `crates/sylpheed-formats/data/unit_definition_layout.txt` names offsets 192 and
|
||
196 as `AV_PitchMinus_Max` / `AV_PitchMinus_Min` — **but that is the unit
|
||
*definition* object** (vtable `0x820af844`), not the spawned entity built-in 15
|
||
writes to. It does not apply here, and it would be an easy wrong turn.
|
||
|
||
The object's identity is now partly pinned — it is a group with a member list,
|
||
not a single craft — but the struct is not bounded: the two constructors that
|
||
write vtable `0x820AF030` are 28 and 30 instructions and touch neither field, and
|
||
the image has **no RTTI at all** (0 of 1150 vtables), so class names are
|
||
synthetic. Bounding the group struct is the remaining prerequisite.
|
||
|
||
### ✅ Four built-ins are TUTORIAL-ONLY — 85 sites, every one in S18–S23
|
||
|
||
`96`, `97`, `98` and `104` were unnamed. Measured over all 28 stages, they form a
|
||
family that appears **nowhere outside the six tutorials**:
|
||
|
||
| id | sites | stages | shape |
|
||
|---|---|---|---|
|
||
| **96** | 8 | S18–S23 only | one call per tutorial *section* |
|
||
| **97** | 38 | S18–S23 only | followed by `start_coroutine` (27/38) |
|
||
| **98** | **0** | — | never called anywhere |
|
||
| **104** | 39 | S18–S23 only | **followed by `wait_s` 39/39**, preceded by `end_coroutine` 37/39 |
|
||
|
||
`104`'s adjacency is a textbook poll loop: a coroutine starts, tests the
|
||
predicate, waits, and goes round again — 39 sites, no exceptions.
|
||
|
||
`96`'s operand is the giveaway. Its eight payloads are, in stage order:
|
||
|
||
```
|
||
Stage18 → 101, 102, 103 Stage19 → 201 Stage20 → 301
|
||
Stage21 → 401 Stage22 → 501 Stage23 → 601
|
||
```
|
||
|
||
That is `(stage − 17) * 100 + section`: tutorial 1 has three sections, the other
|
||
five have one each. The tutorial index is encoded in the argument.
|
||
|
||
🟡 **Names are NOT applied.** `tutorial_begin` / `tutorial_end` /
|
||
`tutorial_message_pending` fit the shape, and `sub_82260710` reportedly suspends
|
||
while `[phase+340] == 2` with the payload latched at `[phase+344]` — but I have
|
||
not read that myself, and this file has already had to withdraw five names
|
||
guessed from shape. What is established here is the **distribution and the
|
||
argument encoding**, which is what a port actually needs; the labels can wait for
|
||
someone to read the handler.
|
||
|
||
### ✅ Five built-ins are the mission banners — named from usage, not from a guess
|
||
|
||
`77`, `78`, `81`, `82` and `135` were unnamed. The engine has five contiguous
|
||
strings — `MISSION_START_PRT` (`0x820A83F0`), `_END_`, `_UPDATE_`, `_FAILED_`,
|
||
`_RESTART_` — and five sequential `ScriptPhase` fields at `+388/+392/+396/+400/
|
||
+404`, stored in ascending order by one constructor region
|
||
(`0x82262D30 … 0x82263340`). Five names, five fields, five unnamed built-ins.
|
||
|
||
What decides *which is which* is the call-site structure, and it is exact:
|
||
|
||
| built-in | sites | stages | preceded by | followed by |
|
||
|---|---|---|---|---|
|
||
| **39** `MARK_LAST_PHASE` | 89 | 22 | 118 (89/89) | **82 (89/89)** |
|
||
| **82** → `banner_mission_failed` | 89 | 22 | **39 (89/89)** | `wait_s` (89/89) |
|
||
| **40** `mark_not_last` | 50 | 28 | 118 (43) | **78 (27) + 81 (17) + `END_PHASE` (6) = 50** |
|
||
| **78** → `banner_mission_complete` | 27 | 22 | **40 (27/27)** | `wait_s` (27/27) |
|
||
| **81** → `banner_objective_update` | 17 | 12 | **40 (17/17)** | `wait_s` (17/17) |
|
||
| **77** → `banner_mission_start` | 22 | **22 — one per stage** | `play_bgm` (15) | `end_coroutine` (21) |
|
||
| **135** → `banner_mission_restart` | 16 | 12 | `play_bgm` (16/16) | `end_coroutine` (16/16) |
|
||
|
||
`39 → 82` is a perfect 89/89 pairing, and `40`'s 50 sites partition *exactly*
|
||
into 78 / 81 / `END_PHASE`. One banner per stage after the music starts is a
|
||
mission-start banner; one after `mark_not_last` in a non-final phase is an
|
||
objective update.
|
||
|
||
🟡 The string↔field pairing itself is **inferred from ordering** — both sequences
|
||
ascend in the same order — not read directly; my operand tracker did not catch
|
||
the string loads in that constructor. The *roles* above do not depend on it.
|
||
|
||
❔ **76 is left unnamed**, deliberately. It has **38 sites = 22 + 16**, exactly
|
||
`77`'s count plus `135`'s, and it precedes them; its body sets `[phase+332] = 1`
|
||
and nothing in the image reads that field. The arithmetic is suggestive but a
|
||
name would be a guess.
|
||
|
||
🟡 **A consequence worth flagging:** `MARK_LAST_PHASE` is followed by the
|
||
**FAILED** banner in 89 of 89 sites, and `mark_not_last` by the END or UPDATE
|
||
banner. So `[phase+300] = 2` reads less like "this is the last phase" and more
|
||
like "end the mission now, unsuccessfully". The names in this file are the
|
||
original ones and may be mis-framing that pair.
|
||
|
||
### ✅ A symbol operand is a two-word pair: a **tag**, then the index
|
||
|
||
This is why the unit indices sit at slots 4/12/20 and never at 0/8/16 — the even
|
||
slot in front of each is a tag word holding the constant **1**, and it is not an
|
||
argument. Measured over all 28 stages:
|
||
|
||
* slot 0 is the integer **1 in 19 899 / 19 899** calls whose slot 4 is a unit;
|
||
* slot 8 is tag-shaped in **100 %** of calls for every built-in taking a second
|
||
unit, and slot 16 is the constant 1 in **152/152** for built-in 128, the only
|
||
one taking a third;
|
||
* **24 built-ins have a slot 0 that is not the constant** — and every one of them
|
||
takes no symbol there (`start_coroutine` a code offset, `wait_s` a double,
|
||
`set_flag` an index). The tag appears exactly where a symbol does.
|
||
|
||
⚠️ It does **not** generalise to "every even slot is a tag". Slot 8 is a *bare
|
||
double* for built-ins 4, 20, 24, 26, 28, 29, 90, 106 and 127, and built-in 75
|
||
carries five bare symbol indices at 0/4/8/12/16 with no tags at all. Each
|
||
built-in has a fixed signature and is **100 % consistent with itself** — not one
|
||
of the 34 built-ins with ≥20 sites mixes the two shapes. The disassembler now
|
||
drops the tag word, so `hp_pct_test(0x1, TCN004, 0)` reads `hp_pct_test(TCN004, 0)`.
|
||
|
||
### ✅ Symbol table 1 has three types, and its slots are type-pure
|
||
|
||
| type | entries (28 stages) | what |
|
||
|---|---|---|
|
||
| 1 | 1362 | `Route_*` names |
|
||
| 6 | 2247 | message / objective names |
|
||
| 7 | 81 | `eff_*` effect names |
|
||
|
||
Measured the same way as the unit slots — every observed value resolves, ≥5
|
||
distinct values, resolved type pure:
|
||
|
||
* **type 1** — 2@12, 3@12, 7@12, 16@12, 19@12, 25@12, 48@24, 90@20, 108@12,
|
||
128@28, 136@4, 143@12
|
||
* **type 6** — 64@0 (2683 sites), 75@0/4/8/12/16
|
||
* **type 7** — 115@0 (84/84)
|
||
|
||
Built-ins **24@4, 46@12 and 114@4** resolve 100 % but **mix type 6 and type 1**,
|
||
so the slot does not mean one thing; they are deliberately left unresolved.
|
||
|
||
Resolving these makes the listings say what the script means:
|
||
`request_script_message(MSG_VOICE_D_257, …)` — which is a fourth, independent
|
||
confirmation of that name, since its first operand is literally a `MSG_VOICE_*`.
|
||
|
||
🟡 **`115 named_event` is now suspect.** Its only symbol operand is an `eff_*`
|
||
**effect** name in 84/84 sites. The name is left alone pending a handler read,
|
||
but "named event" is probably not what it does.
|
||
|
||
### ❌ `camera_at` and `camera_at_route` WITHDRAWN
|
||
|
||
`isl.py` named built-ins 88 and 90. Both names are unsupported:
|
||
|
||
* **88 has zero call sites** in all 28 stages, so the name was never testable.
|
||
* **90 has exactly 8**, all in Stage 02 phase 3 — the nine-cruise-missile act —
|
||
and its first operand resolves to symbol-table-1 **type 7, `eff_n0071`, an
|
||
effect name**, in 8/8, with a per-missile `Route_ADT301..308_p3M` at slot 20.
|
||
|
||
Whatever 90 does, it is not aimed at a camera. Both are left **unnamed** rather
|
||
than renamed: replacing one guessed name with another is how the three names
|
||
above got wrong in the first place.
|
||
|
||
❔ **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`, 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 |
|
||
|---|---|
|
||
| `unit_state` | **255** |
|
||
| `hp_pct_test` | **167** |
|
||
| `dist_lt` | **92** |
|
||
| `unit_alive` | **71** |
|
||
| `unit_relation` | **52** |
|
||
| `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`,
|
||
`global_counter0/1`, `is_engaged`, `player_gauge*`, `prompt_yes_no`,
|
||
`deploy_and_wait`.
|
||
|
||
🔑 **So Stage 02's phases are gated on named-unit tests — destroyed / HP /
|
||
proximity — and not on any aggregate count.** The kill-counter primitives exist
|
||
in the VM (33, 34) and this mission never calls them.
|
||
|
||
That is a direct answer to the standing "does the next wave start after N kills
|
||
or after an event?" question, at least for Stage 02: **specific units, not a
|
||
number.** "Certain objectives shot down" is right; "a certain number shot down"
|
||
is not.
|
||
|
||
⚠️ Scoped to Stage 02. Other stages may well use `squad_survival_pct` — the
|
||
counting is per-file and cheap to repeat.
|
||
|
||
## ✅ A real Stage 02 condition, read end to end
|
||
|
||
With the symbol tables resolved (unit arguments are symbol-table-2 indices), the
|
||
bytecode reads as mission logic. From `Stage02.ssb` at `0xF524`
|
||
(`data/isl-stage02-conditions.txt`):
|
||
|
||
```
|
||
unit_state(1, ADN110) objective_marker(1, 0x01, 0, 8, 0)
|
||
unit_state(1, ADN111) objective_marker(1, 0x02, 0, 8, 0)
|
||
unit_state(1, ADN112) objective_marker(1, 0x05, 0, 8, 0)
|
||
objective_marker(1, 0x3A, 1, 8, 0)
|
||
set_flag(8)
|
||
```
|
||
|
||
Three **named ADAN squadrons** are polled for lifecycle state, each with its
|
||
objective marker updated, and then **flag 8** is latched. That is the shape
|
||
[mission-phase-advance](../mission-phase-advance.md) predicted from the
|
||
disassembly alone — trigger → predicate → `set_flag` → (later) `END_PHASE` — now
|
||
seen in the mission's own code with the squadron names the roster tables already
|
||
gave us.
|
||
|
||
The 12 `END_PHASE` sites are, by contrast, **outro sequences**:
|
||
`wait_cmds_drained` → `fade_sound(3)` → `builtin85(3)` → `wait_s(3)` →
|
||
`END_PHASE` → `yield`. The *decision* is not there; the terminator is.
|
||
|
||
### ⚠️ A decode bug that hid every argument
|
||
|
||
The first version of the argument tracker only followed
|
||
`local[i] = special[0]`. But the common form is
|
||
**`set.i k=01,03` — an immediate written straight into `local[i]`** — and
|
||
missing it meant every unit predicate printed with **no arguments at all**
|
||
(`unit_state` rather than `unit_state(1, ADN110)`). The disassembly looked
|
||
complete and was silently empty where it mattered most. Both staging forms are
|
||
now handled.
|
||
|
||
## ✅ Correction: the script reads its own flags — no engine reader needed
|
||
|
||
Last iteration ended with "what reads the flag file is unknown", after an offset
|
||
search failed and a promising hit in `sub_8226D740` turned out to be a trigger
|
||
record. **The framing was wrong.** I was looking for an *engine-side* reader;
|
||
the consumer is the **script itself**, through built-in **9** (`read_freg`),
|
||
which loads `[phase+88][i]` into the double result register `[phase+176]`.
|
||
|
||
`Stage02.ssb` calls it **12 times** — the same count as `set_flag` (12) and
|
||
`clear_flag` (12). So the latch is symmetric and entirely inside the VM:
|
||
|
||
```
|
||
set_flag(i) -> [phase+88][i] = 1.0 , [phase+120][i] = 1
|
||
read_freg(i) -> [phase+176] = [phase+88][i]
|
||
clear_flag(i) -> zero entry i, or all 32 when the argument is -1
|
||
```
|
||
|
||
That closes the middle of the `set_flag → … → END_PHASE` chain: a condition
|
||
coroutine latches a flag, and another coroutine reads it back with `read_freg`
|
||
and branches on it.
|
||
|
||
## 🟡 `op10` + `op13` look like a switch
|
||
|
||
Seen repeatedly, e.g. at `0x5774`:
|
||
|
||
```
|
||
op13 -> 0x5448
|
||
op10 imm 4
|
||
op13 -> 0x54F0
|
||
op10 imm 5
|
||
op13 -> 0x5598
|
||
```
|
||
|
||
Consecutive small immediates each paired with their own code offset is the shape
|
||
of a **case/branch dispatch**, and `op12` is already confirmed as the
|
||
unconditional jump. **Not confirmed** — the handlers (`0x82271598` for op10,
|
||
`0x82271830` for op13) have not been read, and I am not going to name them from
|
||
a pattern alone.
|
||
|
||
## 🔴 Correction: `unit_state` does NOT read `+16` — it reads `+4` and `+104`
|
||
|
||
Disassembling built-in 69's handler (`0x8226ADF0`) rather than trusting the
|
||
one-line summary:
|
||
|
||
```
|
||
lwz r10, 324(r30) ; the unit array
|
||
lwz r11, 4(r31) ; arg blob +4 = the symbol-table-2 index
|
||
lwz r10, 4(r10) ; records base
|
||
lwzx r9, r11, r10 ; rec = base[idx]
|
||
lwz r9, 4(r9) ; <-- rec+4
|
||
cmplwi r9, 0
|
||
beq 0x8226AF44 ; rec+4 == 0 -> early exit, "absent"
|
||
lwz r4, 4(r11) ; rec+4 again
|
||
bl 0x82301240 ; lifecycle lookup ON rec+4
|
||
...
|
||
lbz r11, 104(r11) ; rec+104, a BYTE, compared against 1
|
||
li r11, 2 ; -> result 2
|
||
```
|
||
|
||
**`rec+16` is never touched on this path.** The predicate reads the **handle at
|
||
`rec+4`** — which is why it holds small consecutive integers (26/27/28) rather
|
||
than pointers; `sub_82301240` resolves it — plus the byte at `rec+104`.
|
||
|
||
That is exactly why poking `+16` to 4 changed nothing
|
||
([script-runtime-probe](../script-runtime-probe.md)): the value was written into
|
||
a field the condition does not consult. `+16` still tracks
|
||
deployed/active/destroyed faithfully as an *observable* — the arrival and death
|
||
transitions were real — but it is a **readout, not the input**.
|
||
|
||
**The corrected way to simulate "this squadron is gone" is `rec+4 = 0`**, which
|
||
takes the documented early exit. That is the next experiment.
|
||
|
||
⚠️ General lesson for this table: it was assembled by a subagent from handler
|
||
behaviour, and this is the second field description that did not survive contact
|
||
with the disassembly. Treat the per-offset meanings as leads to verify, not as
|
||
facts — the *identifications* (which built-in does what) have held up well.
|
||
|
||
## 🟡 Not settled
|
||
|
||
* **Three handlers resisted**: id 55 (`vt35`, 411 instructions, returns a float),
|
||
id 75 (`vt52`, message/HUD-ish), id 105 (`vt73`, meaning of unit field `+600`).
|
||
* The **1024-slot interpreter-command table** is only partly recovered — 57
|
||
slots, by simulating the constant/stack dataflow of `sub_822FE040`.
|
||
* Names here are from handler behaviour, not from symbols; `isl.py` prints a bare
|
||
`builtinN` for anything unread rather than guessing.
|
||
|
||
## ✅ The trigger queue at `phase+272` — layout, and a readable pending count
|
||
|
||
Chasing what makes the phase-1 condition re-evaluate (the polls do **not** run
|
||
continuously — see [script-runtime-probe](../script-runtime-probe.md)).
|
||
|
||
**Two method corrections first**, because both nearly sent me the wrong way:
|
||
|
||
* Searching the VM's address range for `272(rN)` returns mostly **vtable slot
|
||
offsets**, not accesses to the phase field. `0x82273174 lwz r11,272(r11)` is
|
||
followed by `mtctr; bctrl` — it is a virtual call through slot 68, nothing to
|
||
do with `[phase+272]`.
|
||
* `[phase+272]` is **not a pointer to a queue** — it is an **embedded container**.
|
||
`vt2` (`sub_82265DD0`) is literally `addi r3,r3,272 ; b 0x8226E3B8`, i.e. it
|
||
passes `phase+272` as `this`.
|
||
|
||
### ❌ `sub_8226E3B8` is a CLEAR, not a push
|
||
|
||
It was labelled "push" here, which is what made built-in 100 look like
|
||
`push_trigger`. Read directly, it is the opposite:
|
||
|
||
```
|
||
lwz r11,20(r30) ; the element count
|
||
cntlzw / extrwi ; == 0 ?
|
||
bne -> 0x8226E450 ; count == 0 -> nothing to do, return
|
||
addi r31,r30,12 ; else walk the node list…
|
||
stw r11,0(r10) ; stw r10,4(r11) ; …unlinking each node
|
||
```
|
||
|
||
A push allocates and links **one** node; this runs only when the container is
|
||
**non-empty** and splices nodes **out** until it is empty. So it is
|
||
`clear()`. That is a third independent line for the rename above — the handler,
|
||
the usage (all 12 Stage 02 sites sit in the phase terminator), and now the
|
||
callee.
|
||
|
||
The **append** is `sub_8226E160`, reached from built-ins 19 and 25: it takes the
|
||
record fields as arguments (including a `double` in `f1`, matching built-in 19's
|
||
`+24 dbl`) and is guarded on `[container+8]`.
|
||
|
||
### Container layout, from the clear/pop pair
|
||
|
||
`sub_8226E3B8` (clear) and `sub_8226E220` (pop, called every frame from
|
||
`sub_8226D740`):
|
||
|
||
| offset in the container | meaning |
|
||
|---|---|
|
||
| `+12` | list head/sentinel (`addi r31, r30, 12`) |
|
||
| `+16` | current node pointer |
|
||
| `+20` | **element count — zero means empty**; the pop tests it first and returns 0 |
|
||
| `+24` | scratch: the popped node is stashed here |
|
||
|
||
The pop hands the record out through **out-parameters**, reading from
|
||
`node+8`: `+0`, `+4`, `+8` as `u32`s, `+16` as a `double`, `+24` as another
|
||
`u32` — which matches `sub_8226D740` passing six pointers into local slots.
|
||
|
||
### 🎯 `[phase + 272 + 20]` is a live "pending triggers" counter
|
||
|
||
That is the useful part: a single `u32` that says how many triggers are queued,
|
||
readable from `/dev/shm` with no debugger. Watching it alongside
|
||
`[ScriptMission+40]` should show **when** the engine hands the script an event —
|
||
which is exactly the moment the condition coroutines get started, and the thing
|
||
every phase experiment so far has been blind to.
|
||
|
||
### ✅ Verified live
|
||
|
||
Read from a running Stage 02 mission (`ScriptPhase 0xBE14DD80`, container at
|
||
`0xBE14DE90`):
|
||
|
||
```
|
||
+272+12 = 0x000A0009 +272+16 = 0xBC28E620 (a node pointer)
|
||
+272+20 = 0 +272+24 = 0
|
||
|
||
[ 0.0s] pending=0 phase=1 finished=0
|
||
[ 68.0s] pending=1 phase=1 finished=0
|
||
[ 108.1s] pending=2 phase=1 finished=0
|
||
```
|
||
|
||
**`+20` moves, 0 → 1 → 2**, while the phase ordinal stays 1. So it is a real
|
||
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
|
||
"list head" the obvious reading and the value does not support it. Recorded as
|
||
unresolved rather than quietly kept.
|
||
|
||
|
||
## 🟡 Walking the trigger queue live — structure confirmed, contents not
|
||
|
||
Walked the container's linked list from `+16` for 200 s of a Stage 02 mission:
|
||
|
||
```
|
||
[ 0s] count=0 head=0xBC28E610
|
||
[ 80s] count=1 head=0xBC28E610
|
||
node 0 @0xBC28E610: f0=0xBC63.. f4=0xBC40.. f8=0xBC25.. dbl=-0.000 f24=0xBC25..
|
||
[120s] count=2 head=0xBC28E610
|
||
node 1 @0xBC28E630: f0=0xBC65.. f4=0 f8=0xBC25.. dbl=-0.000 f24=0xBC25..
|
||
```
|
||
|
||
✅ **The structure holds**: the count at `+20` tracks the number of nodes, the
|
||
nodes chain through their first word, and new entries appear as the mission runs
|
||
(0 → 1 → 2, stable thereafter).
|
||
|
||
🔴 **The record layout does not.** I expected `node+8` to hold small **symbol
|
||
indices** — the pop's out-parameters made that the natural reading. Every field
|
||
is a **guest heap pointer** (`0xBC…`). So the trigger record references objects,
|
||
not table indices, and what those objects are is unidentified.
|
||
|
||
### ⚠️ A false resolution I introduced myself
|
||
|
||
The `[120s]` line first printed `f4=0(ADN101)` — because the raw value is **0**
|
||
and my formatter mapped index 0 to symbol-table-2's first entry. `ADN101` is not
|
||
in that record; it is my own pretty-printer inventing a name for a null. A
|
||
resolver must refuse to resolve values that were never indices, and this one had
|
||
no such guard. Recorded because it is exactly the sort of plausible label that
|
||
would survive into a conclusion.
|
||
|
||
## 🔴 Correction: `sub_8226E3B8` is a CLEAR, not a push
|
||
|
||
The previous section called it the push, reached from built-in 100 via `vt2`.
|
||
Its tail refutes that: it decrements a counter, calls an erase helper
|
||
(`sub_8226EAB8`), and **loops while `[+20] != 0`** (`beq 0x8226E3E4`). That is a
|
||
**drain-the-whole-queue** routine.
|
||
|
||
So built-in 100 *clears* the trigger queue and then rebuilds the thread list via
|
||
`sub_82273BE8` — consistent with the built-in table's own description
|
||
("push the argument record ... then drain/rebuild"), and the "push" label was
|
||
mine, not the disassembly's. `xrefs` gives it two callers: `0x82265DD4` (vt2,
|
||
the script side) and **`0x8226D420`, an engine site** — so the engine clears it
|
||
too. What actually *appends* a node is still unidentified.
|
||
|
||
|
||
## ✅ FOUND: the appender is `sub_8226EAB8`, and the count lives at `inner+8`
|
||
|
||
The watchpoint plus Canary's own source settles it. At the moment of the write
|
||
the guest context (`%rsi`, per `x64_emitter.cc:881`) contains **`0x8226EAE0`**,
|
||
which is inside `sub_8226EAB8` — so that is the guest code doing it.
|
||
|
||
`sub_8226EAB8` is a **generic list-node insert**:
|
||
|
||
```
|
||
8226eae8 lwz r11, 8(r30) ; current count
|
||
8226eaf0 cmplwi r10, 0x1 ; overflow guard against 0x3FFFFFFF
|
||
8226eb30 addi r11, r11, 1
|
||
8226eb34 stw r11, 8(r30) ; count += 1
|
||
8226eb38 stw r3, 4(r29) ; link the new node
|
||
8226eb40 stw r3, 0(r11)
|
||
```
|
||
|
||
**It increments a count at `+8` of the container it is handed** — and it has
|
||
**16 callers**, so it is a shared container helper, not trigger-specific.
|
||
|
||
### ✅ Why the static search missed it, and what `+12` really is
|
||
|
||
The trigger container at `phase+272` **embeds an inner list object at `+12`**
|
||
(which is why the push does `addi r31, r30, 12`). That inner object keeps its own
|
||
count at **its** `+8`:
|
||
|
||
```
|
||
phase + 272 + 12 + 8 = phase + 272 + 20
|
||
```
|
||
|
||
— exactly the word the watchpoint was set on. So the write really is
|
||
`stw r11, 8(r30)` with `r30 = phase+284`, and searching for `stw rN, 20(rM)`
|
||
could never have found it. That also resolves the earlier 🟡: **`+12` is the
|
||
embedded list object**, not a list head pointer, which is why it read
|
||
`0x000A0009` rather than an address.
|
||
|
||
**Method note worth keeping:** the static hunt failed because it assumed the
|
||
field's offset in the *outer* object would appear in the writing instruction. A
|
||
watchpoint does not care about the addressing form, which is exactly why it was
|
||
the right tool once the offset search came up empty twice.
|
||
|
||
## ~~🔴 What appends a trigger node — NOT FOUND~~ (superseded above)
|
||
|
||
Three approaches, none of which produced the appender:
|
||
|
||
* **`sub_8226E160`**, flagged earlier as "enqueue a pending trigger", takes a
|
||
`double` plus several pointers, rejects `arg == -1`, and has **exactly one
|
||
caller** (`0x8226A044`). It is a specific operation, not the general append.
|
||
* ~~**Writes to the count at `+20`** … are part of a block initialisation …
|
||
Those are **constructors**~~ — **WRONG, withdrawn.** Verified at
|
||
`0x8226E86C`–`0x8226E8E0`: those functions do `li r3,28 ; bl 0x8230C160`
|
||
(allocate 28 bytes), then `lis r10,0xAB03 ; ori r7,r10,0xE4BA ;
|
||
stw r7,4(r3)`. They are building an **interpreter command record** stamped
|
||
`0xAB03E4BA` = opcode **996**, and pushing it into the interpreter queue —
|
||
`sub_8226E7D8` = **AddSelector**, `sub_8226E930` = **RemoveSelector**, with a
|
||
32-entry cap (`cmpwi r8, 32`). The `stw … 20(r3)` I read as "the container's
|
||
count" is the *command record's* `+20`, a different object entirely. The
|
||
analysis was wrong twice over: not constructors, and not that container.
|
||
* So the increment that takes the count 0 → 1 → 2 — which is **measured, live**
|
||
— does not appear as a plain `stw rN, 20(rM)` anywhere in the container's own
|
||
code. It is either inlined into a caller, uses a different addressing form
|
||
(`stwx`), or the node count is maintained somewhere I have not looked.
|
||
|
||
**Honest state:** the queue's structure, its live count and its node chaining are
|
||
verified; what writes a node into it is not identified, and I do not have a
|
||
candidate I believe. Guessing from the shape of nearby functions is what produced
|
||
the "push" mislabel last iteration, so I am not repeating it.
|
||
|
||
**The approach that would settle it** costs more but is unambiguous: a **gdb
|
||
watchpoint on the count word** during a live mission. The address is known at
|
||
runtime (`ScriptPhase + 272 + 20`), the count demonstrably changes within ~2
|
||
minutes of flight, and the watchpoint reports the writing instruction directly
|
||
instead of inferring it from static shape.
|
||
|
||
## 🟡 The watchpoint fired — the writer is JIT-compiled GUEST code, not host code
|
||
|
||
`tools/re-capture/trigger_watch.sh` + `host_addr.py` translate the guest VA into
|
||
a host address and set a gdb watchpoint on it:
|
||
|
||
```
|
||
mission 0xBC7A2A20 phase 0xBE14DD80 va 0xBE14DEA4 off 0x11E14DEA4 -> host 0x1BE14DEA4
|
||
Hardware watchpoint 1: *(unsigned int*)0x1BE14DEA4
|
||
Thread 50 "Main XThread" hit it: Old value = 0 New value = 16777216
|
||
```
|
||
|
||
✅ **Two things confirmed.** `16777216` is `0x01000000` — big-endian `1` read
|
||
little-endian, so this is exactly the count going **0 → 1**, independently
|
||
confirming that `[ScriptPhase+272+20]` is the field. And the write happens on
|
||
the **guest's own Main XThread**, not on an emulator worker.
|
||
|
||
🔴 **But the writer cannot be named from the host stack.** The faulting PC is
|
||
`0xa0c65f23`, with no symbol, and the instruction is
|
||
`mov 0x110(%rsi),%rbx` — this is **Xenia's JIT-compiled guest code**. The
|
||
backtrace above it is garbage (`0x45e0000000`, `0x100000000`), because JIT frames
|
||
are not host-unwindable.
|
||
|
||
So the host watchpoint answers *when* and *which guest thread*, but **not which
|
||
guest function** — the thing I actually wanted. The method has a ceiling here,
|
||
and it is worth recording rather than re-attempting the same way.
|
||
|
||
**What would get past it:** the JIT keeps the guest context in a register
|
||
(`%rsi` here, given `mov 0x110(%rsi),%rbx`), so the **guest PC is recoverable
|
||
from the context block** at the moment of the write. Reading the right offset out
|
||
of `$rsi` would name the guest instruction. That needs Xenia's context layout —
|
||
which is in the xenia-rs sources on this box — and is a separate, tractable
|
||
piece of work rather than another blind run.
|
||
|
||
## 🟡 `sub_8226E458` is a splice — but I have not shown it touches the trigger queue
|
||
|
||
Chasing which of `sub_8226EAB8`'s 16 callers grows the trigger count, the
|
||
promising one is `sub_8226E458`:
|
||
|
||
```
|
||
8226e504 lwz r11, 8(r30) ; source count
|
||
8226e508 subi r11, r11, 1
|
||
8226e50c stw r11, 8(r30) ; source -= 1
|
||
8226e51c bl 0x8226EAB8 ; ... then insert into the destination (+1)
|
||
```
|
||
|
||
Remove from one list, insert into another — a **splice**. And it has exactly one
|
||
caller, `0x8226D780`, **inside `sub_8226D740`**, the per-frame engine→script
|
||
drain. That is a tidy story: the engine moves records into the phase's queue each
|
||
frame, and the count I watched rises as it does.
|
||
|
||
🔴 **The tidy story is not supported by the call site.** At `0x8226D780` the
|
||
argument is `lwz r4, 324(r29)` — `[ScriptPhase+324]`, the **unit array**, not the
|
||
trigger container. So whatever `sub_8226E458` splices between, I have **not**
|
||
shown it is the trigger queue, and the "engine feeds triggers each frame"
|
||
reading is mine rather than the disassembly's.
|
||
|
||
Recording it unresolved. The same over-reach — taking a function's shape as its
|
||
purpose — produced the "push" mislabel on `sub_8226E3B8` and the
|
||
`ADN110`-for-null pretty-print, both of which cost an iteration to undo.
|
||
|
||
**What is solid** and does not depend on this: `sub_8226EAB8` increments a count
|
||
at `+8` of the container it is handed; the trigger container embeds its list at
|
||
`+12`; and the watched word at `phase+272+20` is therefore that inner list's
|
||
count. The guest was executing inside `sub_8226EAB8` at the moment of the write.
|
||
|
||
**Next:** rather than guessing among 16 callers, set the watchpoint again and
|
||
read the **guest LR** out of the context (`%rsi`) at the hit — the same technique
|
||
that named `sub_8226EAB8` will name its caller.
|
||
|
||
## ✅ The interpreter command table, recovered in full
|
||
|
||
`sub_822FE040` is a **fully unrolled** registration sequence — no loop, no
|
||
`.rdata` copy. It fills **1023** slots (not 1024: `32 + 8·1023 = 8216`, and
|
||
`this+8216` is a `std::map`) with a default, then writes **57** explicit slots.
|
||
Of those, **48 are real handlers**; nine point at a shared `li r3,1 ; blr`
|
||
accept-and-discard stub.
|
||
|
||
* default `0x82674028` = `li r3,0 ; blr` → the 966 unpopulated slots always
|
||
return 0, so an unknown command is retried 10× and dropped.
|
||
* Return convention is **nonzero = consumed**, 0 = retry — which is how the
|
||
interpreter *waits*: several handlers return 0 until a named unit exists.
|
||
* ⚠️ The dispatcher masks the opcode to 10 bits, so **opcode 1023 would fetch the
|
||
map's first word as a handler** — a latent OOB nothing constructs.
|
||
|
||
🔑 **Opcode 995 is the only handler that touches the phase mirror
|
||
`[*(0x828F35F8)+236]`** — the sole read *and* the sole write in the entire table.
|
||
That independently confirms why polling that mirror saw nothing during phase 1.
|
||
|
||
🔑 **No handler spawns or despawns a unit.** 518/519 destroy *order* objects;
|
||
1014 broadcasts to every mission unit. Opcode **256** is the strongest deploy
|
||
candidate (two name→definition lookups, a 52-byte request, message
|
||
`0xFE0018EF`) but is **unconfirmed** — the message ids are write-only in this
|
||
image, so the consumer cannot be reached statically.
|
||
|
||
🟡 **`sub_8230C398` may be Stage 16's script, compiled in C++.** It posts
|
||
256/513/514/784/803/896/998/1011 with hard-coded literals (`Route_TCN001_p1F`,
|
||
`TCN001`, `SUBOBJ_001`, …) and is **gated on `*(0x82899CE0) == 16`** at both call
|
||
sites. `mission-phase-advance.md` calls those literals "debug defaults" — that
|
||
does not survive an `== 16` gate, especially as the `.ssb` loader explicitly
|
||
refuses mission 16 (`if (n == 16 || n > 32) return`) and S16 is already the
|
||
corpus outlier with no unit predicates. **Strongly indicated, not proven**: no
|
||
writer for `0x82899CE0` was found.
|