diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 2c3356d1..e43d3c6f 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -97,6 +97,7 @@ files, which is how the same ground got covered twice. | [`structures/sound-pak-contents.md`](structures/sound-pak-contents.md) | Census of `sound.pak`, and the limit of the leading-region rule | ✅ CONFIRMED, 5 135/5 135 names hash into the TOC and 9 517/9 519 entries accounted for; ⚠️ leading-region rule holds for 1 571/4 382 eng and 0/5 100 jpn | | [`structures/sound-cue-table.md`](structures/sound-cue-table.md) | The cue index in `tables.pak` — message id -> cue -> sound id -> `.slb` bank | ✅ CONFIRMED, 1 326/1 338 script message ids bind to a bank; SOUNDS and FILES agree on the same 12 absentees, 0 orphan files | | [`structures/cutscene-message-table.md`](structures/cutscene-message-table.md) | Cutscene dialogue — speaker, portrait, on-screen seconds, audio cue per page | ✅ CONFIRMED, field count = 9·PageCount+2 for all 7 PageCounts, 1 252/1 252 caption keys match, 138 ids close both ways | +| [`structures/isl-builtins.md`](structures/isl-builtins.md) | The 147 ISL built-ins — dispatch table, calling convention, and which one does what | ✅ for the table/ABI and ~135 handlers; ✅ 26 `set_unit_hp_pct`, 29 `set_unit_damage_taken_pct`, 101 `all_units_invulnerable`; 🟡 28 | | [`structures/isl-message-dialogue-link.md`](structures/isl-message-dialogue-link.md) | Mission scripts as dialogue — built-in 64 -> message id -> caption text | ✅ CONFIRMED total, 2 683/2 683 call sites across all 28 stages resolve, no residue | | [`structures/mission-objective-counter.md`](structures/mission-objective-counter.md) | `REMAINING OB` — the mission's own objective counter, in RAM | ✅ CONFIRMED for one Stage 02 run: a big-endian u32 whose value | | [`structures/movie-subtitles.md`](structures/movie-subtitles.md) | Movie subtitles & the movie ↔ mission ↔ text chain | — | diff --git a/docs/re/structures/isl-builtins.md b/docs/re/structures/isl-builtins.md index 8f1f9fc0..9684afd1 100644 --- a/docs/re/structures/isl-builtins.md +++ b/docs/re/structures/isl-builtins.md @@ -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 800–802'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.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