re: timer_set's second argument is a SECOND countdown, not a warning threshold

mission-phase-timers.md left 180 open as "a limit and a warning
threshold is the obvious reading, but not established".  Reading
sub_822639B8 -- ScriptPhase::Update 0x82263528, same dt as the
stopwatch bank -- settles it the other way:

    if running: [+304] += dt
    if armed:   [+308] -= dt   while [+308] > 0
                else [+312] -= dt, clamped at 0

[+312] is never compared with [+308]; it is decremented, and only in
the A<=0 arm.  Two sequential countdowns.  Disc-wide the second
argument is 180 in all 29 timer_set sites while the first varies
(600 x19, 1200 x8, 900, 1800).

Built-ins 123-127 are vtable slots 90-94 on five scalars at
[phase+304..320].  125 and 126 have ZERO call sites in all 28 scripts:
the script arms, starts and stops this clock but never reads it.

Corrects mission-phase-timers.md, which merged this clock with
stopwatch 0 -- timer_resume starts [+304], set_flag(0) one instruction
later starts the stopwatch the timeline's kind=0 reads.

Docs only; all seven ISL artefacts regenerate byte-identical.
This commit is contained in:
Sylpheed RE agent
2026-08-27 09:03:18 +00:00
parent dde62c9afe
commit a09c9e6517
6 changed files with 192 additions and 7 deletions

View File

@@ -293,6 +293,49 @@ unknown, what evidence exists, and what the first step would be. Move an item in
no writer searched for — a game-speed setter would be one; and 31 `read_freg`
sites index by a computed value rather than an immediate.
* ✅🔴 **(2026-08-27) `timer_set`'s SECOND ARGUMENT IS A SECOND COUNTDOWN — the
"warning threshold" reading is REFUTED. [structures/isl-mission-timer](structures/isl-mission-timer.md).**
`mission-phase-timers.md` left it open as "a limit and a warning threshold is
the obvious reading, but it is **not** established". Reading the updater
settles it the other way. The **mission timer** is five scalars on the
ScriptPhase — `[+304]` elapsed (up), `[+308]` countdown A, `[+312]` countdown
B, `[+316]` running, `[+320]` armed — zeroed together by `sub_8225FEF8`, and
driven by **`sub_822639B8`, called from `ScriptPhase::Update` at `0x82263528`
with `fmr f1, f30` — the SAME `dt` the stopwatch bank gets**, so it counts the
same seconds. Its body: `if running: [+304] += dt`, and `if armed:` **`[+308]
-= dt` while `[+308] > 0`, ELSE `[+312] -= dt` clamped at 0.** 🔑 A threshold
would be *compared*; `[+312]` is never compared with `[+308]` anywhere — it is
decremented, and the `bc 12, gt` at `0x822639FC` jumps **away** to the A arm,
so B only runs once A is spent. **Two sequential countdowns.** Disc-wide, 29
`timer_set` sites: first argument 600 ×19 / 1200 ×8 / 900 / 1800, **second
argument 180 in all 29** — a per-mission limit then a fixed 3-minute stage.
(Stated as consistent-with, not as the proof; the disassembly is the proof.)
**The five built-ins are vtable slots 9094** (`0x8226C690`): 127
`timer_set(a,b)` arms and loads A/B (as **doubles** from `local[0]`/`local[1]`),
123 `timer_resume` starts the elapsed counter, 124 `timer_stop` stops it and
disarms, 125 `timer_reset` zeroes all three floats, 126 `timer_elapsed` returns
`[+304]`. 🔑 **125 and 126 have ZERO call sites in all 28 scripts** — the script
arms, starts and stops this clock but **never reads it**. Its only consumer read
here is a 32-byte message (`0xAB03E5BA` → the bus at `[0x828F35DC]`) carrying
elapsed, B, running, and `expired = armed && A <= 0`.
🔴 **CORRECTION to `mission-phase-timers.md`:** "timer 0 is started by
`timer_set(1200,180)` then `timer_resume`, with a 1200-second limit" **merges
two clocks**. `timer_set`/`timer_resume` arm and start THIS object;
`set_flag(0)`, one instruction later, starts **stopwatch 0**, which is what the
timeline's `kind = 0` reads. They start together and so read almost the same
value — which is why the merge was invisible. The 1200 s is the mission timer's,
not stopwatch 0's.
✅ This also unblocks the naming collision noted in
[isl-timers](structures/isl-timers.md): 123127 act on five *scalars*, 8/9/93
on a 32-entry *bank*. Not renamed here — that is its own change and its own
artefact diff; everything regenerates byte-identical as it stands.
🟡 Not settled: **who subscribes to `0xAB03E5BA`** — the literal appears at
exactly one site, the producer, but `0xAB03` is the high half of many message
tags here and dispatch is not by literal compare, so one occurrence is **not**
evidence of no consumer. So whether running out of time ends the mission is
unread, as is what B reaching zero does, and whether all 170 `timer_stop` sites
are phase teardown.
## ✅✅ SOLVED — the mission freeze was a modal sign-in dialog (2026-08-26)
`XamShowSigninUI` opens a modal dialog and `xeXamDispatchDialog` blocks the

View File

@@ -98,6 +98,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, **9 519/9 519** entries accounted for, and a full 4 114-bank manifest (408.3 min of audio) computed from PsuedoBytesPerSec without decoding; ⚠️ 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-mission-timer.md`](structures/isl-mission-timer.md) | The mission timer — two sequential countdowns, not a limit and a warning | ✅ CONFIRMED — read from `sub_822639B8`; the threshold reading is |
| [`structures/isl-timers.md`](structures/isl-timers.md) | A ScriptPhase owns 32 stopwatches, and they count seconds | ✅ CONFIRMED — the advance is read from `sub_822710D0`, the unit from |
| [`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 |

View File

@@ -115,5 +115,21 @@ table, and the running game — agree.
## 🟡 Not settled
* Whether a coroutine started this way can **re-arm** its own trigger.
* `timer_set`'s second argument (`180`) — a limit and a warning threshold is the
obvious reading, but it is **not** established.
* ~~`timer_set`'s second argument (`180`) — a limit and a warning threshold is the
obvious reading, but it is **not** established.~~
✅ **SETTLED (2026-08-27), and the obvious reading is REFUTED —
[structures/isl-mission-timer](structures/isl-mission-timer.md).** `180` is a
**second countdown that starts only after the first reaches zero**, not a
threshold on the first: `sub_822639B8` never compares `[phase+312]` with
`[phase+308]`, it *decrements* it, and only in the `A <= 0` arm. It is 180 in
all 29 `timer_set` sites on the disc while the first argument varies
(600 ×19, 1200 ×8, 900, 1800).
🔴 **And this page conflates two clocks.** "timer 0 is started by
`timer_set(1200, 180)` then `timer_resume`" merges the **mission timer**
(`[phase+304…320]`, armed by 127 and started by 123) with **stopwatch 0**
(`[phase+88][0]`, started by `set_flag(0)` — [structures/isl-timers](structures/isl-timers.md)).
The three calls sit one instruction apart in the same intro coroutine, so the
two clocks read almost the same value and the merge was invisible. The
timeline's `kind = 0` reads the **stopwatch**; the 1200-second limit belongs to
the **mission timer** and is not stopwatch 0's limit.

View File

@@ -84,6 +84,7 @@ two tables already parsed in [mission-script-ssb](mission-script-ssb.md).
| **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` | start / read / stop one of 32 **stopwatches** — [isl-timers](isl-timers.md); the names predate the reading |
| **123 / 124 / 125 / 126 / 127** | `timer_resume` / `timer_stop` / `timer_reset` / `timer_elapsed` / `timer_set` | the **mission timer** — five scalars at `[phase+304…320]`, vtable slots 9094 — [isl-mission-timer](isl-mission-timer.md). 125 and 126 have **zero** call sites on the disc |
| **100 / 115** | `reset_phase_threads` / `named_event` | ❌ 100 is **not** `push_trigger` — see below |
**The state machine is therefore:** a trigger fires a coroutine → the coroutine

View File

@@ -0,0 +1,122 @@
# ✅ The MISSION timer is a second, separate clock — and `timer_set`'s second argument is a second COUNTDOWN
`mission-phase-timers.md` left this open: "`timer_set`'s second argument (`180`)
— a limit and a warning threshold is the obvious reading, but it is **not**
established." It is established now, and **the obvious reading is wrong**.
This clock has nothing to do with the 32 stopwatches in
[isl-timers](isl-timers.md); it is five separate scalar fields on the same
ScriptPhase, driven by its own updater.
## The five fields
Zeroed together by `sub_8225FEF8` (`0x82260084``0x82260098`):
| field | type | meaning |
|---|---|---|
| `[phase+304]` | f32 | **elapsed**, counts **up** |
| `[phase+308]` | f32 | **countdown A**, counts **down** |
| `[phase+312]` | f32 | **countdown B**, counts down *only after A reaches 0* |
| `[phase+316]` | int | **running** — gates the elapsed counter |
| `[phase+320]` | int | **armed** — gates the two countdowns |
## The five built-ins
All are stubs into the ScriptPhase vtable, slots 9094
(`0x8226C690``0x8226C6F0`):
| built-in | slot | what it does | calls disc-wide |
|---|---|---|---|
| 127 `timer_set(a, b)` | 94 | `armed = 1; A = (f32)local[0]; B = (f32)local[1]` | 29 |
| 123 `timer_resume` | 90 | `running = 1` | 40 |
| 124 `timer_stop` | 91 | `running = 0; armed = 0` | 170 |
| 125 `timer_reset` | 92 | `elapsed = A = B = 0.0; armed = 0` | **0** |
| 126 `timer_elapsed` | 93 | returns `elapsed` as a double in `[phase+176]` | **0** |
`timer_set` reads its two arguments as **doubles** at `0(r4)` and `8(r4)`, `r4`
being the converted locals array — so `local[0]` and `local[1]`.
## ✅ The update — `sub_822639B8(phase, dt)`
Called from **`ScriptPhase::Update` at `0x82263528`**, with **`fmr f1, f30`** —
the *same* `dt` the stopwatch bank gets at `0x82263480`. So this clock counts
the same **seconds** ([isl-timers](isl-timers.md)), and `600 / 900 / 1200 / 1800`
and `180` are 10 / 15 / 20 / 30 minutes and 3 minutes.
```
822639C8 if [+316] != 0: [+304] += dt ; elapsed counts UP
822639E4 if [+320] != 0:
822639F4 if [+308] > 0.0: [+308] -= dt ; A counts down ...
82263A00 else: [+312] -= dt ; ... then B does
82263A10 if [+312] < 0.0: [+312] = 0.0
```
### 🔴 That refutes "a limit and a warning threshold"
A threshold would be **compared** against the countdown. `[+312]` is never
compared with `[+308]` anywhere. It is *decremented*, and the `bc 12, gt`
at `0x822639FC` jumps **away** to the `A -= dt` arm — so `B -= dt` runs **only**
in the `A <= 0` branch. The two are **sequential countdowns**: a per-mission
period, then a fixed second period.
## ✅ Every mission agrees, and the second value never varies
29 `timer_set` sites across the disc:
| first argument | sites |
|---|---|
| 600 | 19 |
| 1200 | 8 |
| 900 | 1 (Stage 26) |
| 1800 | 1 (Stage 24) |
| **second argument** | **180 in all 29** |
A per-mission limit that varies, followed by a fixed 3-minute stage. Note this
constancy is *consistent* with the sequential reading and does not by itself
establish it — the disassembly above is what does.
## ✅ What it feeds — and what it does not
Each frame the updater allocates a 32-byte message (type tag `0xAB03E5BA`,
vtable `0x820A8D68`) and posts it to the bus at `[0x828F35DC]`, carrying:
```
+24 = [phase+304] elapsed
+28 = [phase+312] countdown B
+16 = running ([+316] != 0)
+20 = expired ([+320] == 1 && [+308] <= 0.0)
```
`expired` is exactly "countdown A has run out" — the point at which B starts.
🔑 **The script never reads this clock.** `timer_elapsed` and `timer_reset` have
**zero call sites in all 28 scripts**; the script only *arms* (127), *starts*
(123) and *stops* (124) it. Its only consumer read here is the posted message.
## 🔴 A conflation in `mission-phase-timers.md`, corrected
That page says timer 0 "is started by `timer_set(1200, 180)` then
`timer_resume`, with a 1200-second limit". Those are **two different clocks
armed back to back**:
```
timer_set(1200, 180) -> this object: A = 1200, B = 180, armed
timer_resume -> this object: elapsed starts counting
set_flag(0) -> STOPWATCH 0 starts (isl-timers.md)
```
The timeline's `kind = 0` reads stopwatch 0, not `[phase+304]`. The two start in
the same coroutine one instruction apart, which is why they read almost the same
value and the conflation was invisible. **The 1200-second limit belongs to the
mission timer; it is not stopwatch 0's limit.**
## 🟡 Not settled
* **Who subscribes to `0xAB03E5BA`.** The literal appears at exactly one site —
the producer — but `0xAB03` is the high half of *many* message tags in this
image and dispatch is not by literal compare, so a single occurrence is **not**
evidence that nothing consumes it. Whether running out of time ends the mission
is therefore unread.
* What B counting to zero does. Nothing observed acts on it.
* Whether `timer_stop`'s 170 sites are all phase teardown, or some are a real
mid-mission stop.

View File

@@ -109,11 +109,13 @@ in a coherent block of stages) — checked before believing.
## 🟡 What this leaves
* The **naming**. `set_flag` / `read_freg` / `clear_flag` describe none of this;
`timer_start` / `timer_elapsed` / `timer_stop` would. They are not renamed here
because built-ins 127/123/124 already hold `timer_set` / `timer_resume` /
`timer_stop` in the corpus for a *different* timer, and untangling the two is
its own change. Every artefact regenerates byte-identical as a result.
* The **naming**. `set_flag` / `read_freg` / `clear_flag` describe none of this.
✅ The other timer is now read — [isl-mission-timer](isl-mission-timer.md) —
so the collision is understood: 123/124/125/126/127 act on five *scalar*
fields (`[phase+304…320]`), this family on a 32-entry *bank*. `stopwatch_start`
/ `stopwatch_elapsed` / `stopwatch_stop` would separate them. Still not renamed
here, so every artefact regenerates byte-identical; the rename is its own
change and its own artefact diff.
* 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.