They are start / read / stop of one of 32 per-phase stopwatches, not flag operations. 123-127 keep timer_* -- that is the mission timer, five scalars at [phase+304..320], a different clock. Artefact check: 84 lines changed across 5 files and all 84 pair exactly with their old-name partners once column padding is normalised (0 removed lines without an old name, 0 added lines without a new one). data/isl-timers.txt reproduces the same 675/675, 11.2 % control, 82/1 and identical histograms, which is what shows the rename is cosmetic. Also withdraws a label from the previous commit: sub_8230C398 is NOT the message pump. It runs every frame but drains nothing -- a state machine on [0x828E1F8C] that only allocates, builds strings, looks up and PUSHES. And bus+8216 is weak evidence: sub_82254A08 is a generic map find with ~120 sites, and the key looked up is a pointer, not a tag. The open handle is now the ring buffer at bus+4, not bus+8216.
128 lines
6.3 KiB
Markdown
128 lines
6.3 KiB
Markdown
# ✅ A ScriptPhase owns 32 STOPWATCHES, and they count SECONDS
|
||
|
||
`read_freg`'s unit was the open question: the corpus knew the built-in returns a
|
||
float out of `[phase+88]`, and that *seconds* was the unit had only ever been
|
||
**inferred** from the values a mission compares it against (210, 300, 600, 1200).
|
||
It is now read, end to end, from the code that advances it.
|
||
|
||
## The three parallel arrays
|
||
|
||
The phase initialiser `sub_82270DF8` clears three arrays in one unrolled loop
|
||
(`0x82270EC0`–`0x82270FAC`), eight words per iteration, base `12, 44, 76, 108`
|
||
— **32 entries each, offsets 0…127**:
|
||
|
||
| field | type | init | meaning |
|
||
|---|---|---|---|
|
||
| `[phase+88][i]` | f32 | 0.0 | the timer's **current** value |
|
||
| `[phase+104][i]` | f32 | 0.0 | its value **last frame** |
|
||
| `[phase+120][i]` | int | 0 | **1 = running** |
|
||
|
||
`sub_822700C0` clears the same three the same way (the phase reset).
|
||
|
||
## What advances them — `sub_822710D0(phase, dt)`
|
||
|
||
Called from **`ScriptPhase::Update` (`sub_82263408`) at `0x82263480`**, with
|
||
Update's own float argument passed straight through (`fmr f30,f1` … `fmr f1,f30`).
|
||
The body is the same eight-way unrolled shape, and per entry it is exactly:
|
||
|
||
```
|
||
82271100 stfsx f0, r11, r9 ; prev[i] = cur[i] ([+104] <- [+88])
|
||
82271104 lwz r10, 120(r31)
|
||
8227110C cmpi cr6, 0, r10, 1
|
||
82271110 bc 4, eq, ... ; skip unless running[i] == 1
|
||
8227111C fadds f0, f31, f0 ; cur[i] += dt
|
||
82271120 stfsx f0, r11, r10
|
||
```
|
||
|
||
## The three built-ins are one family — start / read / stop
|
||
|
||
`isl-builtins.md` groups 8, 9 and 93 together; that grouping is **confirmed**, but
|
||
the names are wrong. All three are inline in the dispatch switch `sub_82272220`,
|
||
and all three bounds-check `0 <= local[0] < 32` — the array length above.
|
||
|
||
| built-in | corpus name | what it actually does |
|
||
|---|---|---|
|
||
| 8 | `set_flag` | `cur[i] = 0.0` (the constant at `0x8209FD28` is literally `0.0`) **and** `running[i] = 1` — *start / restart timer i*; returns 1 |
|
||
| 9 | `read_freg` | returns `cur[i]` — as a **double** into `[phase+176]`, not `[phase+164]` |
|
||
| 93 | `clear_flag` | `running[i] = 0`; **`i == -1` clears all 32** (`0x82273078`, loop to 128 step 4) — *stop*, without resetting the value |
|
||
|
||
`clear_flag` is called **133 times disc-wide and every one passes −1**, always in
|
||
the sequence `reset_phase_threads ; timer_stop ; clear_flag(-1)` — a phase
|
||
teardown. So `[phase+120]` is not "a different 32-entry array" belonging to some
|
||
other family, as the previous iteration's note allowed for: it is the *running*
|
||
column of this one.
|
||
|
||
## ✅ The unit: SECONDS, by four constants
|
||
|
||
`dt` comes from a 48-byte timing singleton at `[0x828F35B4]` (constructed by
|
||
`sub_8231A830`), field `+8`. Its value is built in the frame loop
|
||
`sub_821AA1B0` and in `sub_821A49A8`:
|
||
|
||
```
|
||
821AA2F8 lfs f0, -24328(r11) ; 0x8289A0F8 = 0.016666668 == 1/60
|
||
821AA300 fmuls f13, f31, f0 ; f31 = frames elapsed -> SECONDS
|
||
821AA308 lfs f0, 5772(r25) ; 0x820A13B4 = 10000.0
|
||
821AA30C fmuls f0, f13, f0
|
||
821AA310 fctiwz f0, f0 ; ticks = round(seconds * 10000) [100 us]
|
||
821AA32C cmpi cr6, 0, r29, 3200 ; clamp -> 0.32 s max frame
|
||
821AA390 lfs f0, -24324(r11) ; 0x8289A0FC = 1e-4
|
||
821AA398 fmuls f0, f13, f0
|
||
821AA39C stfs f0, 12(r11) ; [obj+12] = ticks * 1e-4 -> SECONDS again
|
||
```
|
||
|
||
and `sub_821A49A8` writes the two scaled copies the game actually reads —
|
||
`[obj+16] = ticks·s24·1e-4·s44` and **`[obj+8] = ticks·s40·1e-4`**, where
|
||
`s24 = s40 = s44 = 1.0` at construction (`0x8208583C`). `[obj+16]` is the field
|
||
the flight/physics code reads (≈40 sites in `0x8238…`–`0x823B…`); `[obj+8]` is
|
||
the one the script VM gets.
|
||
|
||
The round-trip is what makes this **non-circular**: the value is produced as
|
||
`seconds × 10000` and consumed as `× 1e-4`. It is seconds on both ends, and the
|
||
clamp is `3200` ticks = **0.32 s** — a frame-time ceiling, which is only a
|
||
sensible number in seconds.
|
||
|
||
> ⇒ **`read_freg(i)` returns the number of SECONDS since `set_flag(i)`.**
|
||
|
||
## ✅ The refutation test — and it passes 675 / 675
|
||
|
||
If these really are stopwatches that only run once started, then every
|
||
**timeline** entry ([isl-schedule](isl-schedule.md)) must name a timer its own
|
||
phase starts — otherwise `cur` and `prev` both stay `0.0` and the walker's fire
|
||
test `prev <= t < cur` can never be true, and the entry would be dead.
|
||
|
||
| | |
|
||
|---|---|
|
||
| timeline entries whose `kind` is started by `set_flag` **in the same phase** | **675 / 675 = 100 %** |
|
||
| entry-weighted control (a random index 0…31) | **11.2 %** |
|
||
| `read_freg` indices started in the same phase | 82 / 83 |
|
||
|
||
Every phase opens with `set_flag(5)` and `set_flag(0)` — which is why `kind` only
|
||
ever takes the values 0 and 5. Indices used disc-wide run 0…21, inside the
|
||
bounds check.
|
||
|
||
⚠️ **The first run of this test scored 433/675 and was wrong.** `isl.dis`
|
||
defaults to `stop_at_ret=True`, so a `set_flag` sitting at the *start* of a
|
||
coroutine lost its operand staging to the preceding `ret` and came back
|
||
unresolved. With `stop_at_ret=False` all 172 `set_flag` arguments resolve. The
|
||
failure looked exactly like a real refutation (the misses were all `kind = 0`,
|
||
in a coherent block of stages) — checked before believing.
|
||
|
||
## 🟡 What this leaves
|
||
|
||
* ~~The **naming**.~~ ✅ **RENAMED (2026-08-27).** `isl.py` now emits
|
||
**`stopwatch_start` (8) / `stopwatch_elapsed` (9) / `stopwatch_stop` (93)**;
|
||
built-ins 123–127 keep `timer_*` for the *other* clock
|
||
([isl-mission-timer](isl-mission-timer.md)), which acts on five scalar fields
|
||
rather than a 32-entry bank. The rename touched **84 lines across 5 artefacts
|
||
and every one pairs exactly** with its old-name partner once column padding is
|
||
normalised — nothing but the three names changed, and this file's own artefact
|
||
reproduces the same 675/675, 11.2 %, 82/1 numbers under the new labels.
|
||
⚠️ Older prose in the corpus still uses the old names where it is quoting what
|
||
was believed at the time; that is deliberate.
|
||
* The **one exception**: `read_freg(15)` in Stage 06 phase 1 is not started by a
|
||
`set_flag(15)` in that phase. Cross-phase carry-over or a start in the
|
||
unreached code — not chased.
|
||
* `s24` / `s40` / `s44` are never written after construction *in any site read
|
||
here*; a game-speed or slow-motion setter was not searched for.
|
||
* The 31 `read_freg` sites whose index is computed rather than immediate.
|