This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/isl-builtins.md
Sylpheed RE agent 9f2692eadd re: correction -- the script reads its own flags via built-in 9
Last iteration ended with 'what reads the flag file is unknown' after an offset
search failed. The framing was wrong: I was hunting an engine-side reader, but
the consumer is the script itself, through built-in 9 (read_freg), which loads
[phase+88][i] into the double result register.

Stage02.ssb calls read_freg 12 times -- the same count as set_flag (12) and
clear_flag (12) -- so the latch is symmetric and entirely inside the VM. That
closes the middle of the set_flag -> ... -> END_PHASE chain: one coroutine
latches, another reads it back and branches.

Also records an unconfirmed observation: op10 + op13 pairs with consecutive
small immediates and their own code offsets look like a case/branch dispatch.
Flagged as a pattern, not named -- neither handler has been read.
2026-08-25 13:47:56 +00:00

195 lines
8.7 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.
## 🟡 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.