re: ISL built-ins 26 / 29 / 101 named end to end; 28's field pinned, label 🟡

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
This commit is contained in:
Sylpheed RE agent
2026-08-26 03:43:56 +00:00
parent 67c9ddc736
commit db6b096cb4
2 changed files with 254 additions and 37 deletions

View File

@@ -3,6 +3,9 @@
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).
@@ -167,56 +170,269 @@ words**: a descriptor offset and the message id.
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` does **not** apply an effect — it walks the
**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-unit family and `0xED09…` the to-child
one, and the terminal effect is one link further on. ❌ Not followed; 26/28/29
remain unnamed.
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 are one family — and `damage_unit` looks mis-named
### Built-ins 26 / 28 / 29 RESOLVED — HP, damage dealt, damage taken
Method-diffing put the structure beyond doubt but did not reach the semantics.
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 | sites |
|---|---|---|---|---|
| 26 `damage_unit` | 76 | `sub_8226ACD0` (67) | **800** | 97 |
| **28** | 84 | `sub_82268F98` (69) | **801** | 410 |
| **29** | 88 | `sub_822690B0` (69) | **802** | 164 |
| 101 | 276 | `sub_822691C8` (78) | 802 (broadcast) | 133 |
| 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]` |
**28 and 29 differ in two words only** — the opcode (`0x21BA` vs `0x22BA`) and a
descriptor pointer 8 bytes apart. Otherwise instruction-identical. All three take
`(unit, double)`.
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.
26 differs from both by one guard: it rejects only state 3, while **28 and 29
reject states 1 and 3** (2 = active, 1/3/4 = gone/dead/invalid).
Two links that the previous attempt did not have:
#### The operand distributions separate them
* 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`.**
| built-in | n | distinct | range | most common |
|---|---|---|---|---|
| 26 | 97 | 7 | **[0, 100]** | **0 ×69**, 80 ×10, 100 ×6 |
| 28 | 410 | 13 | **[0, 2000]** | 200 ×116, 120 ×76, 300 ×74 |
| 29 | 164 | 6 | **[0, 100]** | **0 ×64**, 100 ×53, 50 ×38 |
#### ✅ 26 is `set_unit_hp_pct` — NOT `damage_unit`
26 and 29 are percentage-shaped; 28 is an absolute quantity an order of magnitude
larger.
`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:
#### 🟡 `damage_unit` (26) is doubtful
```
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
```
**69 of its 97 calls pass 0.** Dealing zero damage is a no-op, so 71 % of the
call sites would do nothing. *Setting* a percentage-valued property to 0 is a
perfectly natural thing to do 69 times, and 29 has the same shape (0 ×64 of 164).
The existing name predates this session and is not withdrawn, but it should not
be relied on.
Three independent facts pin the field:
#### ❌ Where this stopped
* `[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.
The three commands' descriptors sit at `0x820A8D10` / `+8` / `+16`. Following
them lands on data pointing into `0x8210E5xx`, which is **below the disassembly
DB's range** (it starts at `0x82150000`) and contains no code — so that route
does not reach an execute method. Reaching opcodes 800802's semantics needs the
interpreter's command table, not the command objects.
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 | **500600** |
| line fighters | `e001_Elan` 24, `e010_Attacker_S` 21, `e011_Attacker_B` 6 | 200300 |
| capital hulls | `e106_Destroyer` 105, `e104_Carrier` 55, `e105_CruiserEX` 52, `e102_BattleshipEX` 26 | 100300 |
| 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 0x2E4C0x48C8) and the scripted removal
second (0x50F80x5FA0). 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