Commit Graph

16 Commits

Author SHA1 Message Date
Sylpheed RE agent
142b8d7200 re: close the ISL operand chain -- built-in result is special[0], via a vtable
Answers what the previous commit left open: naming the branches did not give a
clear condition, because that needs the operand chain feeding each compare.

First, a correction to my own work.  isl-bytecode.md -- which OWNS the opcode table
-- already named ops 21-24 push.i/push.f/pop.i/pop.f.  isl-branches.md, which I
wrote last iteration, said op21 and op23 were unread.  The stale file was mine.
Verified from the thunks rather than accepted: 21 pushes [phase+168] onto the deque
at phase+44, 22 pushes [phase+184] onto phase+64, and the 23/24 handlers touch only
r3+168 and r3+184.  So pop.i lands in special[1].

New: the 147-entry built-in table is a thin DISPATCH LAYER, not implementations.
Each stub resolves the local[] argument base and tail-calls a fixed ScriptPhase
vtable slot.  112 of 147 dispatch that way; 17 write [phase+164] inline; 0 write
+184.  Every named predicate is in the vtable group -- unit_state 184, unit_alive
188, hp_pct_test 64, dist_lt 56, is_engaged 252, timer_elapsed 372 -- which is the
control that the split separates engine queries from script bookkeeping.

The vtable is 0x820A84BC, derived from a known implementation rather than a stride:
MARK_LAST_PHASE is documented as [phase+300]=2; the function 0x8226B498 is exactly
that stub; it appears as a data word at exactly one address, 0x820A8570; built-in
39 uses slot 180.  The check NOT used in the derivation: built-in 40 mark_not_last
uses slot 176, and slot 176 holds the [phase+300]=1 stub.  Predicted and confirmed.
The db's own vptr_writes independently lists 0x820A84BC, written at 0x82261B80.

unit_state = slot 184 = 0x8226ADF0, which indexes [phase+324] by local[4] and writes
its answer to [phase+164] = special[0] at both exits.  The phase-3 poll loop now
reads end to end: unit_state(ADT308) -> special[0]; pop.i -> special[1]; cmp.i; beq.

isl.py names ops 21-24; the calls artefact regenerates with NO diff.

Left open and said so: the other 111 vtable slots, which comparand each site pushes,
the 35 non-vtable built-ins, and the vtable's length.
2026-08-27 05:21:10 +00:00
Sylpheed RE agent
bad96eb54a re: read the ISL branch handlers -- it is a condition-code machine
Closes the backlog item that was the last thing between the flat decode and a
per-phase clear condition, and closes isl-builtins.md's standing "op10 + op13 look
like a switch -- NOT confirmed".

op10 resolves two operands, issues a SIGNED cmp, and writes three condition bits to
a bitset at phase+24: bit 0 = EQ, bit 1 = GT, bit 2 = LT.  op11 is the same machine
for floats via fcmpu.  op13-op18 branch on those bits to [phase+232] + word@+4 --
the same phase-relative target form as the unconditional op12:

  13 bit0 set    beq      16 bits 2 then 0   ble
  14 bit0 clear  bne      18 bits 1 then 0   bge
  15 bit2 set    blt      17 bit1 set        bgt

13/14/15/17 are byte-identical apart from the bit index and the polarity.  All six
relations are present and each appears exactly once; that completeness is the check
that the reading is right, rather than the usage pattern -- which the item
explicitly warned against.

Operand order recorded because it is easy to reverse: LHS = (kind byte[1], word@+4),
RHS = (kind byte[0], word@+8).

Method note in the doc: the jump table at 0x822635FC holds THUNKS, and the handler
is the bl target inside each.  My first pass guessed handler addresses at a fixed
stride, landed mid-function, and produced a 20-line "difference" that was pure
misalignment.

isl.py names the ops; data/isl-stage02.txt is regenerated and every diff line pairs
exactly, only the op-name column changing (op10->cmp.i x5, op13->beq x4,
op14->bne x1).  data/isl-stage02-phase-ends.txt now shows the phase-3 poll loop
reading as one: unit_state(ADT308) -> op23 -> cmp.i -> beq back to 0xFEB4.

Left unnamed on purpose: op23 (0x82271C30) and op21 (0x82175C20).
2026-08-27 05:10:50 +00:00
Sylpheed RE agent
f41847701c re: the ISL stream is flat -- refute the "needs coroutine entry points" blocker
Two files (isl_report.py's docstring and structures/isl-builtins.md) recorded the
same blocker on a faithful per-phase condition listing: that it needs the coroutine
entry points from start_coroutine's operand.  Measured against isl.call_sites(),
which enumerates by scanning the encoding rather than by decoding and so is an
independent denominator:

  linear + jumps, stopping at ret (what the tool did)   133 / 2846 =   4.7%
  linear + jumps, continuing past ret                  2275 / 2846 =  79.9%
  ... + following start_coroutine (the recorded fix)   2355 / 2846 =  82.7%
  plain linear decode, no control flow at all          2846 / 2846 = 100.0%

Following the coroutine entries buys 2.8 points.  Disc-wide, a plain linear decode
from the first phase base reaches 25705/25705 call sites over all 28 stages, and
28/28 decode clean to code_end with no desync.

The real bug was isl.dis ending on `if op == 20: break`.  Op 20 is `ret`, but this
is a coroutine VM -- the thread suspends and resumes at the FOLLOWING instruction,
so code continues past it.  dis() now takes stop_at_ret (default True, preserving
the old output: data/isl-stage02.txt regenerates byte-identical) and
isl.linear_offsets() is the correct walk.

By-product, kept with its control: start_coroutine's target is staged slot 0 --
73/83 phase-1 sites land on a valid instruction, against a 38.7% chance rate for an
arbitrary 4-aligned offset.

New artefact data/isl-stage02-phase-ends.txt with a committed generator
(isl_report.py phase-ends).  It shows END_PHASE's call site is the WRONG place to
read a clear condition: all 12 Stage-02 sites sit in one stereotyped outro.  Not
settled, and stated as such: op10/op13/op14/op21/op23 are unread handlers, so the
condition in the poll loop upstream cannot be named yet.
2026-08-27 05:00:49 +00:00
Sylpheed RE agent
679812f1c9 re: built-in 108 is deploy_squadron_ex — deploy plus a 1<<n selector
1146 sites in 22 stages, the second-largest unnamed built-in. Its method
sub_822646B8 (vtable slot 300) and built-in 2's sub_822642E0 (slot 12) are
190 and 199 instructions and differ in exactly one block. Diffed instruction
by instruction, 108 adds:

  lwz   r11, 16(r29)     the blob's slot-16 int
  cmpwi/blt/cmpwi/bgt    range-check n to [0, 31]
  slw   r21, r25, r11    r21 = 1 << n     <- a 32-bit selector
  ...                    default 1 when out of range

Everywhere built-in 2 passes its r21, 108 passes r20 and reserves r21 for the
mask, so the bit is an EXTRA argument to the same call rather than a
replacement. Both post the identical command word AB0100BA, opcode 256.

The operand is always a valid bit index: over all 1146 sites slot 16 is in
[0, 31], 1146 of 1146, none outside, so the out-of-range default never fires
in shipped content. Fifteen distinct values clustered at 16 (531x), 31
(165x), 20 (161x) and 2 (90x); 21 of 22 stages use more than one.

What the bit SELECTS is not established and the name does not claim it. There
is plainly a 32-bit space -- built-in 92 reportedly allocates a free bit by
OR-ing over live units, which would make 108 its "place in a named slot"
counterpart -- but I have not verified that, so the name says only what is
proven: the same deploy as built-in 2, with an extra selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:31:39 +00:00
Sylpheed RE agent
4764e3d5f2 re: built-in 12 is activate_unit — 517/517 on the ordering test
The highest-traffic unnamed built-in: 1197 sites across all 28 stages.
sub_822659F0 read directly:

* indexes [phase+324]'s record array by the slot-4 symbol;
* returns 0 immediately when the live object [record+4] is NULL, so it
  registers an object that already exists rather than spawning one;
* sets [record+16] = 2, the documented "active" state every unit predicate
  tests;
* stores sub_82301118's packed result into [record+20] (low 16) and
  [record+24] (high 16) -- member counts;
* posts opcode 513 (0xAB0201BA) either way; the slot-8 mode (1 in 999 sites,
  0 in 198) only decides whether cmd+20 is also set to 1.

The ordering test: if this activates a unit for the script, no predicate
should test a unit before it. Over all 28 stages, for every (stage, unit)
pair having both, activate_unit comes first 517 times and a predicate first
0 times. Recorded as file order rather than proven execution order --
coroutines can interleave -- but 517 with no exceptions is not a coincidence.
344 units are tested without ever being activated (live from mission start)
and 203 activated without being tested.

Also recorded: a tidy closure that FAILS. squad_survival_pct reports current
over initial and activate_unit snapshots counts, so the snapshot looks like
the baseline. It is not -- built-in 24 reads [record+16] for the state then
calls sub_823011B0 and sub_82301118 on the LIVE object, never touching
+20/+24. What reads those two fields is unidentified.

Named coverage is now 57 of 108 distinct ids and ~80% of call-site traffic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:25:31 +00:00
Sylpheed RE agent
a0cfa68ed8 re: built-in 15 IS set_group_speed — and the turret anomaly was my own artefact
The member object sub_82348830 returns is the per-member unit DEFINITION, and
that identification is not a guess: the same spawn loop builds two aggregates
and each lands on a semantically apt field with the apt reducer.

  group +192   min, seeded FLT_MAX   member +164 = CruisingVelocity
  group +472   sum                   member  +84 = HP

A wrong struct would have to make both offsets land on apt fields AND pair
each with the apt reducer. Minimum of a speed, sum of hit points: a
formation's cruise limit and its total health.

The quantitative test over all 1360 sites, joining each to its craft's
definition:

  value <= the craft's MaximumVelocity    1355 / 1360 = 99.6%   (5 fail)
  value <= the craft's CruisingVelocity   1042 / 1360 = 76.6%   (318 fail)

The test discriminates -- the cruise bound breaks 318 times, the hull maximum
5 -- so the script sets a COMMANDED SPEED, free to exceed the cruise default
and bounded by what the hull can do.

The turret anomaly that stopped me naming this two iterations ago was my own
artefact. UN_e007_ADAN_Turret's definition carries MaximumVelocity 500 and
CruisingVelocity 280: the data models turrets as if mobile, so a script value
of 400 is legal and simply never manifests. I had assumed turrets have no
velocity fields and treated 13% of the traffic as a refutation.

Recorded as unsettled: the five overshoots are UN_e106_ADAN_Destroyer 200 vs
a 150 maximum (x2) and UN_e011_ADAN_Attacker_B_HF/_Wayne 500 vs 450 (x3).
Designer overrides or an engine clamp; not established.

Named set_group_speed. Default = the slowest member's CruisingVelocity;
mode 1 restores it, mode 3 sets it, mode 2 hands it a global constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:18:30 +00:00
Sylpheed RE agent
cb830e233e re: name the five mission-banner built-ins from call-site structure
77, 78, 81, 82 and 135 were unnamed. The engine has five contiguous strings
-- MISSION_START_PRT at 0x820A83F0, then _END_, _UPDATE_, _FAILED_,
_RESTART_ -- and five sequential ScriptPhase fields at +388/+392/+396/+400/
+404, stored in ascending order by one constructor region. Five names, five
fields, five unnamed built-ins.

Which is which is decided by call-site structure, measured over all 28
stages, and it is exact:

  39 MARK_LAST_PHASE   89 sites  -> 82 in 89/89
  82 banner_mission_failed        <- 39 in 89/89, then wait_s 89/89
  40 mark_not_last     50 sites  -> 78 (27) + 81 (17) + END_PHASE (6) = 50
  78 banner_mission_complete      <- 40 in 27/27
  81 banner_objective_update      <- 40 in 17/17
  77 banner_mission_start         22 sites in 22 stages, one per stage,
                                  after play_bgm
  135 banner_mission_restart      16 sites, after play_bgm, phase >= 2

39 -> 82 is a perfect pairing and 40's sites partition exactly three ways.

Stated as inferred rather than read: the string-to-field pairing itself comes
from both sequences ascending in the same order; my operand tracker did not
catch the string loads in that constructor. The ROLES above do not depend on
it.

76 is left unnamed on purpose. It has 38 sites = 22 + 16, exactly 77's count
plus 135's, and precedes them; its body sets [phase+332] = 1 and nothing in
the image reads that field. Suggestive arithmetic is not a name.

Flagged as a consequence: MARK_LAST_PHASE is followed by the FAILED banner in
89 of 89 sites and mark_not_last by END or UPDATE. So [phase+300] = 2 reads
less like "this is the last phase" than "end the mission now,
unsuccessfully" -- the existing names for that pair may be mis-framing it.

Artifact regenerated: docs/re/data/isl-stage02.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 00:44:38 +00:00
Sylpheed RE agent
75f0664bfe re: an ISL symbol operand is a (tag, index) pair — and two more names withdrawn
Verified rather than adopted: a subagent proposed that every even operand
slot is a type tag. Measured, the strong form is false and a precise form is
true.

TRUE: a SYMBOL operand is two words, a tag holding the constant 1 followed
by the index. Slot 0 is the integer 1 in 19899/19899 calls whose slot 4 is a
unit; slot 8 is tag-shaped in 100% of calls for every built-in taking a
second unit; slot 16 is 1 in 152/152 for built-in 128, the only one taking a
third. The 24 built-ins whose slot 0 is NOT the constant are exactly those
taking no symbol there. This explains the unit slots 4/12/20 rather than
replacing them.

FALSE as stated: slot 8 is a bare double for built-ins 4, 20, 24, 26, 28,
29, 90, 106 and 127, and built-in 75 carries five bare indices at 0/4/8/12/16
with no tags at all. Each built-in has a fixed signature and is 100%
self-consistent; none of the 34 with >=20 sites mixes the two.

Symbol table 1 has three types -- 1 routes (1362), 6 messages (2247), 7
effects (81) -- and its operand slots are type-pure, measured the same way.
Resolving them makes listings say what the script means:
`request_script_message(MSG_VOICE_D_257, ...)`, a fourth independent
confirmation of that name. Slots 24@4, 46@12 and 114@4 resolve 100% but MIX
types 6 and 1, so they are left unresolved rather than guessed.

Two more names withdrawn, neither replaced:
* 88 `camera_at` -- ZERO call sites in all 28 stages; never testable.
* 90 `camera_at_route` -- 8 sites, all Stage 02 phase 3, first operand is
  symtab-1 type 7 `eff_n0071`, an EFFECT name, in 8/8, with a per-missile
  Route_ADT301..308_p3M at slot 20. Not aimed at a camera.

Left unnamed on purpose: replacing a guessed name with another guess is how
the three names corrected earlier today went wrong.

Also flagged: 115 `named_event`'s only symbol operand is an eff_* name in
84/84 sites, so that name is suspect too. Not renamed pending a handler read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-25 22:51:07 +00:00
Sylpheed RE agent
9a9921b759 re: three ISL built-in names were wrong, including the most-used one
All re-read twice — the handler, and the thing it calls — because each had
been named from its shape rather than its effect.

* id 11 `yield` -> `end_coroutine`. 0x82272624 is li r11,1 ; li r3,3 ;
  stw r11,164(r31), and the dispatcher's r3==3 arm erases the thread from
  the active list and returns it to the free list. It destroys the thread.
  2945 sites game-wide, 372 in Stage 02 — the most-used built-in there was.
* id 5 `await_label` -> `kill_coroutine(label)`. sub_82273B08 kills the
  thread parked at the target pc, or itself if the target is its own pc.
  It waits for nothing.
* id 100 `push_trigger` -> `reset_phase_threads`. It clears the trigger
  container and then frees every thread whose pc differs from the caller's
  — the opposite of pushing a trigger. Corroborated by usage: its 12 Stage
  02 sites all sit in the phase terminator, next to timer_stop,
  clear_flag(-1) and MARK_LAST_PHASE.

One name recovered from the game's own text: opcode 992 prints
"RequestScriptMessage %s" at 0x820A5700, so id 64 is request_script_message
(2683 sites).

Return codes documented properly: 1 = restart the coroutine from its entry
(previously not recorded at all), 3 = terminate. And the blocking set was
wrong in two places — it is 102, 120, 137, 142, 143. Id 97 does NOT block;
its handler ends `b 0x822724F8`, so it always returns 0.

Unit-operand resolution settled from DATA over all 28 stages rather than by
reading 147 handlers: a slot qualifies only if every value is a valid
symtab-2 index, it takes >=15 distinct values, AND its maximum reaches most
of the table — that last clause is what discriminates, since every small
integer is trivially "in range". 31 built-ins at slot 4, 8 at slot 12, one
at slot 20. It also refutes set_flag's slot 0, whose maximum overruns the
table, and the resolver now declines rather than inventing a name.

New and unexplained: symtab-2 holds two types, 2 and 8, and built-ins 95 and
128 take type 8 at slot 12 in 100% of their sites.

A downstream inference is withdrawn with it: the note reading the live
trigger counter attributed it to "the script arming watches as it goes" via
built-in 100. The measurement stands; the attribution does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-25 22:09:05 +00:00
Sylpheed RE agent
770ef2ea2b re: CONFIRMED -- the poke produced 'absent', not 'destroyed'
Settled from the disassembly, no run needed. Built-in 69's tail maps the
lifecycle lookup into [phase+164]: handle == 0 takes the early exit at
0x8226AF44 and returns 0, while the destroyed states return 2, 3 or 4 (and two
of those also normalise the record's +16). Those are different values.

So poking rec+4 = 0 made the predicate report ABSENT -- the same answer an
undeployed unit gives -- and never the answer the script branches on. The
condition was polling at 5 Hz throughout and correctly saw 'not here'.

Both null results are now fully explained, and neither was evidence about the
condition: the first poke wrote a field nobody reads, the second wrote the wrong
value into the right field. Simulating a kill needs the handle to stay valid
while the lifecycle lookup returns 3/4/5, i.e. the write belongs in whatever
sub_82301240 reads, not in the script's own record.

Also names built-ins 46/47/48 as squadron_trace / squadron_attack /
squadron_escort in isl.py.
2026-08-25 20:55:49 +00:00
Sylpheed RE agent
c50ab7275f re: survey all 28 mission scripts -- kill counters are dead game-wide
All 28 StageNN.ssb decoded: 2,085,628 bytes, 25,705 call sites, 108 of 147
built-ins used. Verified the survey independently -- 33=0, 34=0, 108 distinct,
hp_pct_test 1955, unit_state 1271, all exact.

global_counter0/1 have ZERO call sites in all 28 stages. The handlers exist and
are wired; no mission calls them. So 'does a wave start after N kills?' is
answered for the whole game, not just Stage 02: no mission counts kills.

Two idioms change how the counts read. hp_pct_test(unit, 0.0) IS a destruction
test -- 1786 of 1955 calls (91%) pass 0.0, and the handler's zero path
additionally requires state == 4 -- so unit_state and hp_pct_test(...,0) are
interchangeable and stages just pick one. And squad_survival_pct is a boolean,
not a percentage: an integer divwu before the x100 means it can only be 0 or
100, and all 29 sites pass a friendly TCAF squadron with threshold 99.9, making
it 'has this escort lost anybody'. The game has no destroy-N%-of-a-squadron
objective.

Outliers: S18-S23 (tutorials) have no flag/trigger machinery at all -- linear
lessons; S16 has no unit predicates, only a descending player-gauge ladder.

Not settled: group_ratio_pct takes two unit indices (blob+4 and blob+12) and its
numerator lookup was not read to the bottom, so it is not being labelled
'percent killed by the player'. isl.py's UNIT_ARG omitted 71/72.
2026-08-25 18:53:56 +00:00
Sylpheed RE agent
28a4b1ead1 re: ISL opcodes decoded; the branch base is PER PHASE and isl.py was wrong
All 25 opcodes now have meanings. Ops 2/4/6/8 are integer compound assignment
(+= -= *= /=) and 3/5/7/9 the float versions; 10 and 11 are integer and float
compare writing three condition bits; 13-18 are je/jne/jl/jle/jg/jge; 21-24 are
push.i/push.f/pop.i/pop.f over deques at phase+44 and phase+64.

The shared-handler question is answered: the dispatcher leaves the opcode in r4
and the shared thunks never overwrite it, so those helpers take an extra opcode
argument and index a secondary table (0x82271448, 0x8227152C).

CORRECTION to my own tool and note: the branch/jump base is [phase+232], which
the phase initialiser sets to 0x24 + the phase's entry from the mission-level
stream -- 0xE4 / 0x14AA8 / 0x24B4C for Stage 02's three phases, not the file's
0x24. Measured on phase 1: base 0xE4 puts 525 of 525 branch targets on an
instruction boundary; base 0x24 manages 188. isl.py had been using 0x24 for
every phase, so its jump targets were wrong throughout. Fixed via
isl.phase_bases().

That also settles two things mission-script-ssb.md left open: offsets ARE
code-base-relative, and 0x1883's operand IS a code pointer -- the earlier worry
that some 'land on IEEE floats' was an artefact of adding the wrong base.
2026-08-25 18:52:09 +00:00
Sylpheed RE agent
dcf37bcf93 re: a Stage 02 clear condition read end to end, with squadron names
Resolving symbol-table-2 indices turns the bytecode into mission logic. At
0xF524 Stage02.ssb polls unit_state on ADN110, ADN111 and ADN112, updates each
one's objective marker, then latches set_flag(8) -- exactly the
trigger/predicate/set_flag/END_PHASE shape predicted from the disassembly, now
observed in the mission's own code with names the roster tables already gave.

The 12 END_PHASE sites are outro sequences (wait_cmds_drained / fade_sound(3) /
builtin85(3) / wait_s(3) / END_PHASE / yield) -- the terminator, not the
decision.

Fixes a decode bug that hid every argument: the tracker only followed
local[i] = special[0], but the common form is an immediate written straight into
local[i] (k=01,03), so every unit predicate printed with NO arguments. The
disassembly looked complete while being empty exactly where it mattered.

Also records the live probe result: the phase mirror at [*(0x828F35F8)+236]
stayed 0 for ~530s of actively-hunting flight, no advance observed -- which is
what the static analysis predicts for phase 1, since ChangePhase only posts once
the ordinal exceeds 1.
2026-08-25 12:42:50 +00:00
Sylpheed RE agent
70cff9ca21 re: the 147 ISL built-ins characterised; Stage 02 gates on units, not counts
Table at 0x8227226C is 147 big-endian absolute VAs (verified structurally: it
ends exactly where the first handler begins, all targets inside sub_82272220).
Arguments are not in the instruction -- every handler does c_str() on
[phase+20], a packed blob, which is what the local[] staging fills. Return 2 =
yield; five built-ins block by skipping the pc advance.

Recovered the ScriptPhase state layout: 32-entry float and flag register files,
int/double result registers, the timer block, and the runtime unit array at
+324 indexed by symbol-table-2 index -- a direct hook from bytecode call sites
to the two .ssb symbol tables.

Spot-checked two claims against the disassembly rather than trusting them: id 4
loads a DOUBLE into the thread countdown and returns 2 (wait_s), and id 24 reads
current/initial squadron member counts (squad_survival_pct). Both exact.

Counting Stage02.ssb: unit_state 255, hp_pct_test 167, dist_lt 92, unit_alive
71, unit_relation 52 -- and squad_survival_pct, group_ratio_pct and the two
global counters are NOT called at all. So Stage 02's phases are gated on named
units (destroyed / HP / proximity), never on an aggregate count, even though the
kill-counter primitives exist in the VM. That answers the standing 'next wave
after N kills or after an event?' question for this stage: specific units, not a
number.

isl.py now names the built-ins, so the run-up to the first END_PHASE reads
wait_cmds_drained / fade_sound(3) / builtin85(3) / wait_s(3) / END_PHASE.

Not settled: 3 handlers unresolved (55, 75, 105); the 1024-slot interpreter
command table is only partly recovered.
2026-08-25 12:36:02 +00:00
Sylpheed RE agent
3f13a8ceef re: ISL operand kinds decoded; arguments are staged through local[]
Resolver table 0x82271D74 gives four kinds: 0 global[i], 1 immediate,
2 special[i] ([phase+164]/[phase+168]), 3 local[i] ([phase+20+i]). Byte[0] is
the rvalue kind, byte[1] the lvalue kind, so the recurring instruction pair is
argument staging -- values land in local[] at offsets 0,4,8,0xC and the next
call consumes them. A built-in's arguments are not in its own instruction.

Fixed a decode that would have been believed: immediates in set.f are DOUBLES
carried as two words (op 1 stores with stfd). Reading the high word as a float
gives 2.125 where the script means 3.0.

isl.py now tracks staging and prints call arguments, so the run-up to the first
END PHASE in Stage02 reads as builtin=64(0x42,2,1,9,1,-1) / 120 / 59(3) / 85(3)
/ 4(3) / 6. Three built-ins taking 3 just before the phase ends look like a
wait-seconds family -- flagged as unconfirmed until the built-in table is read.
2026-08-25 12:28:04 +00:00
Sylpheed RE agent
7b445916bc re: ISL bytecode encoding decoded; phase-end call sites located in Stage02
Read the encoding off the interpreter rather than guessing: instruction is a
big-endian u32 whose LOW byte is the opcode (25 of them, table 0x822635FC),
byte[2] is the instruction length -- every handler advances the pc by it -- and
bytes[0..1] are operand kinds. Op 12 is a jump whose operand is relative to the
code base [phase+232], which settles that offsets are code-base-relative for
this opcode. Op 19 is the built-in call: id in word@+4, and word@+8 is a
monotonically increasing STATEMENT id (0x245, 0x248, 0x24A, ...).

Confirmed by disassembling Stage02.ssb: the stream decodes cleanly from the code
base and routines terminate on ret exactly where expected.

Scanning the code region on the call encoding: 2846 call sites, 73 of the 147
built-ins used. The phase-control ones are located -- built-in 6 (end phase) at
12 sites, 62 at 3, 39 (mark last phase) at 8 -- so a phase has several exit
paths, as a mission with win and lose branches should.

New tool tools/re-capture/isl.py with --calls and --to (resync-into-target,
needed because instructions are variable-length so you cannot walk backwards).

Not settled: the 147 built-ins are uncharacterised, so this is structure without
meaning -- we can see THAT a phase ends, not WHAT was tested.
2026-08-25 12:21:44 +00:00