Files
Sylpheed/docs/re/structures/isl-mission-timer.md
Sylpheed RE agent 3fa898093f isl: rename built-ins 8/9/93 to stopwatch_start/_elapsed/_stop
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.
2026-08-27 09:21:04 +00:00

7.9 KiB
Raw Permalink Blame History

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; it is five separate scalar fields on the same ScriptPhase, driven by its own updater.

The five fields

Zeroed together by sub_8225FEF8 (0x822600840x82260098):

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 (0x8226C6900x8226C6F0):

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), 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.

🔴 The message bus: what the "one occurrence" of the tag is worth — nothing

The obvious next move was to search for a second site building 0xAB03E5BA and conclude from its absence that nothing consumes the timer message. That reasoning is dead, and the control kills it:

instructions building 0xAB03E5BA (addis 0xAB03 + ori 0xE5BA) 1 — the producer
0xAB03E5BA as a 4-byte word anywhere in the image 0
any 4-aligned word beginning 0xAB03 anywhere in the image 0
distinct 0xAB03xxxx tags built by an addis/ori pair 40
of those, built exactly once 25 (62.5 %)

Single-site construction is the majority case, so it says nothing about this tag in particular. And because no tag is ever stored as a static word, there is no table of subscribed tags to find either.

Two structural reasons it cannot be a compare or a virtual call

  • The post is a QUEUE PUSH, not a dispatch. sub_82175C20(bus+4, &msg) is a ring-buffer append — capacity at +8, head at +12, count at +16, element array at +4, growing through sub_8228E208. It never looks at the message.
  • The message class has a ONE-METHOD vtable. 0x820A8D180x820A8DA0 is a run of 2-word records { sub_82301C20, 0x8210Exxx }. The timer message's vptr is 0x820A8D68, so vtable[0] = sub_82301C20 — a scalar deleting destructor (it stores 0x820A7014 over the vptr and conditionally frees) — and vtable[-1] = 0x8210E288, an MSVC RTTI locator (0,0,0, &typeDescriptor 0x8289D094, &classHierarchy 0x8210E29C, numBaseClasses = 3). Not code: that address is data, and the disassembly does not even reach it (sylpheed.db starts at 0x82150000). The message carries no handler of its own.

🟡 Where the subscriber actually lives — located, not read

The bus object at [0x828F35DC] carries a map at +8216, looked up through sub_82254A08 from sub_823001E8 — whose only caller is sub_8230C398.

🔴 Correction to my own label from the previous pass. I called sub_8230C398 "the per-frame message pump". It is called every frame (from sub_821A6470 at 0x821A654C and from the mission loop sub_821AA1B0 at 0x821AA7DC, both with the frame dt), but it drains nothing: its whole body is a state machine on the counter at [0x828E1F8C], and across 466 instructions it calls only an allocator (sub_8230C160), string building (sub_8217FA08), a map lookup (sub_823001E8) and a push (sub_8225FEA0). It is a boot/loading sequencer that posts messages, not the consumer of them.

Two further cautions the search turned up:

  • sub_82254A08 is a generic map find with ~120 call sites image-wide, and +8216 is a common offset — the pair alone identifies nothing.
  • The key sub_823001E8 looks up is a pointer (lwz r4, 13780(r11) stored and passed by address), not a message tag. So bus+8216 may not be the subscriber registry at all.

So the drain of the queue at bus+4 is still unlocated, and with it the answer to whether running out of time ends the mission.

🟡 Not settled

  • Who subscribes to 0xAB03E5BA — see above. Located to the registry at bus+8216; the registration path is unread, so whether running out of time ends the mission is still unknown.
  • 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.