trigger_watch.sh + host_addr.py translate the guest VA to a host address (0xBE14DEA4 -> host 0x1BE14DEA4) and watch it. It fires: Thread 50 'Main XThread', old 0, new 16777216 -- which is big-endian 1 read little-endian, so the count going 0 -> 1, independently confirming the field. The write happens on the guest's own main thread, not an emulator worker. But the writer cannot be named from the host stack: the faulting PC is 0xa0c65f23 with no symbol, executing 'mov 0x110(%rsi),%rbx', i.e. Xenia's JIT-compiled guest code, and the frames above it are not host-unwindable. So the watchpoint answers when and which thread, not which guest function. Recorded as a ceiling of the method rather than retried blindly. The way past it is that the JIT holds the guest context in a register (%rsi here), so the guest PC is recoverable from the context block -- which needs Xenia's context layout from the xenia-rs sources on this box, a separate tractable piece of work.
404 lines
19 KiB
Markdown
404 lines
19 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.
|
||
|
||
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: **0** continue, **2** yield (re-execute next frame), **3**
|
||
coroutine control. Five built-ins skip the pc advance on 2 and so genuinely
|
||
**block**: 97, 120, 137, 142, 143.
|
||
|
||
## ✅ `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** | `push_trigger` / `named_event` | the engine→script edge |
|
||
|
||
**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.
|
||
|
||
## ✅ What Stage 02 actually uses — and it settles a standing question
|
||
|
||
Counting call sites in `Stage02.ssb` (`data/isl-stage02.txt`):
|
||
|
||
| built-in | sites |
|
||
|---|---|
|
||
| `unit_state` | **255** |
|
||
| `hp_pct_test` | **167** |
|
||
| `dist_lt` | **92** |
|
||
| `unit_alive` | **71** |
|
||
| `unit_relation` | **52** |
|
||
| `set_flag` / `clear_flag` / `push_trigger` | 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` into the push.
|
||
|
||
### Container layout, from the push/pop pair
|
||
|
||
`sub_8226E3B8` (push, reached from built-in **100**) 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** — the script arming watches as it
|
||
goes (Stage 02 has 12 `push_trigger` sites) — and it is readable live with no
|
||
debugger. That is the first direct view of *what the script is waiting for*.
|
||
|
||
🟡 **`+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.
|
||
|
||
|
||
## 🔴 What appends a trigger node — NOT FOUND this iteration
|
||
|
||
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`** inside the container code
|
||
(`0x8226DF00–0x8226F200`) are only **four**, and all four are part of a
|
||
*block initialisation* — `stw` to `0, 8, 12, 16, 20, 24` in consecutive
|
||
instructions, in `sub_8226E7D8` and `sub_8226E930`. Those are **constructors**
|
||
(callers `0x8226E560` and `0x822608A0`, the latter inside `ScriptMission`'s
|
||
own constructor), not increments.
|
||
* 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.
|