Files
Sylpheed/docs/re/structures/isl-builtins.md
Sylpheed RE agent a35812bd26 re: the trigger-count watchpoint fires, but the writer is JIT guest code
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.
2026-08-25 18:52:35 +00:00

404 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 0x410x44, 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]` |
| **132134** | player gauges | speed/boost ratios and a player byte |
| **73, 123127** | 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
(`0x8226DF000x8226F200`) 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.