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
This commit is contained in:
Sylpheed RE agent
2026-08-25 22:09:05 +00:00
parent 723fc9b890
commit 1ba5d0a4a3
5 changed files with 261 additions and 56 deletions

View File

@@ -78,18 +78,18 @@ KIND = {0: 'global', 1: 'imm', 2: 'special', 3: 'local'}
# guess. See docs/re/structures/isl-builtins.md.
BUILTIN = {
1: 'start_coroutine', 2: 'deploy_squadron', 3: 'move_order', 4: 'wait_s',
5: 'await_label', 6: 'END_PHASE', 8: 'set_flag', 9: 'read_freg',
10: 'random', 11: 'yield', 13: 'play_se', 14: 'play_bgm',
5: 'kill_coroutine', 6: 'END_PHASE', 8: 'set_flag', 9: 'read_freg',
10: 'random', 11: 'end_coroutine', 13: 'play_se', 14: 'play_bgm',
17: 'wait_frames', 18: 'dist_lt', 20: 'hp_pct_test', 24: 'squad_survival_pct',
26: 'damage_unit', 30: 'objective_marker', 31: 'objective_marker_at_route',
33: 'global_counter0', 34: 'global_counter1', 36: 'screen_fade',
39: 'MARK_LAST_PHASE', 40: 'mark_not_last', 43: 'play_voice',
45: 'play_voice_vol', 46: 'squadron_trace', 47: 'squadron_attack',
48: 'squadron_escort', 52: 'play_stream', 53: 'sound_busy', 54: 'stop_sound',
56: 'unit_relation', 59: 'fade_sound', 62: 'FORCE_END_PHASE',
56: 'unit_relation', 59: 'fade_sound', 62: 'FORCE_END_PHASE', 64: 'request_script_message',
69: 'unit_state', 70: 'unit_alive', 72: 'group_ratio_pct', 73: 'timer_start',
74: 'timer_limit', 88: 'camera_at', 90: 'camera_at_route',
93: 'clear_flag', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'push_trigger',
93: 'clear_flag', 94: 'is_engaged', 95: 'unit_hp_pct', 100: 'reset_phase_threads',
102: 'prompt_yes_no', 109: 'set_unit_flags', 115: 'named_event',
120: 'wait_cmds_drained', 123: 'timer_resume', 124: 'timer_stop',
125: 'timer_reset', 126: 'timer_elapsed', 127: 'timer_set',
@@ -135,10 +135,30 @@ def symbols(b, which):
return out
UNIT_ARG = {18, 20, 24, 26, 56, 69, 70, 94, 95, 105, 109} # unit idx at blob[4]
# Built-ins 71 and 72 carry a SECOND unit index at blob[12] (handler 0x8226E568
# indexes [phase+324] with both), so their second operand printed as a bare
# integer until this was noticed. 56 may be the same shape -- unverified.
# Built-ins whose operand blob carries a symbol-table-2 (unit) index, by slot.
#
# Derived from the DATA, not from reading 147 handlers: across all 28 stages a
# slot qualifies only if every observed value is a valid symtab-2 index, it takes
# >=15 distinct values, and its maximum reaches most of the table (symtab-2 tops
# out at 122 entries, so a non-index slot overruns). That last clause is what
# makes the test discriminating -- plain range-checking cannot separate an index
# from a bool, because every small integer is "in range".
#
# It also refutes one tempting entry: `set_flag`'s slot 0 passes the range and
# spread tests but its maximum EXCEEDS the table (flag indices run 0..31 against
# tables as small as 40), so it is excluded. Slots are only listed here when the
# ratio stayed below 1.0.
UNIT_ARG = {2, 3, 7, 12, 15, 16, 18, 19, 20, 24, 25, 26, 28, 29, 30, 47, 48,
56, 57, 58, 63, 69, 70, 79, 91, 92, 95, 105, 108, 128, 143}
UNIT_ARG2 = {2, 18, 47, 48, 56, 79, 95, 128} # a SECOND unit index at blob[12]
UNIT_ARG3 = {128} # and a third at blob[20]
UNIT_SLOTS = {4: UNIT_ARG, 12: UNIT_ARG2, 20: UNIT_ARG3}
# Symbol table 2 holds TWO entity types: type 2 (1160 entries disc-wide) and
# type 8 (249). They are not interchangeable -- built-ins 95 and 128 take a
# type-2 unit at slot 4 and, at slot 12, an operand that is type 8 in 100% of
# its 90 and 152 call sites respectively. What distinguishes the two classes is
# not yet established.
def dis(b, off, count=40, code_base=0x24, args=True, sym2=None):
@@ -203,7 +223,11 @@ def dis(b, off, count=40, code_base=0x24, args=True, sym2=None):
parts = []
for slot, v in sorted(staged.items()):
txt = ('0x%X' % v) if isinstance(v, int) else v
if (sym2 and slot == 4 and words[0] in UNIT_ARG
# Resolve only a slot that is declared a unit index AND
# whose value really is one -- a resolver that invents a
# name for a non-index is worse than one that prints the
# raw number.
if (sym2 and words[0] in UNIT_SLOTS.get(slot, ())
and isinstance(v, int) and v in sym2):
txt = sym2[v][1]
parts.append(txt)

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Regenerate the committed ISL artefacts under `docs/re/data/`.
isl_report.py <StageNN.ssb> calls -> the call-site census + a listing
The artefact was produced by an uncommitted one-off, so it drifted out of date
twice: once when operand staging was fixed (calls printed with too few
arguments) and once when three built-in names were corrected. Keeping the
generator in the tree is the point of this file.
`data/isl-stage02-conditions.txt` still has no generator here. A first attempt
is not committed because it printed most sites as bare `builtinN`: neither
`isl.resync` (it gives up far from a valid start) nor a naive linear decode from
the phase base reaches every call site, so producing that listing faithfully
needs the coroutine entry points, which `start_coroutine`'s operand carries and
this tool does not yet follow.
"""
import collections
import sys
import isl
def census(b):
cs = isl.call_sites(b)
return cs, collections.Counter(bid for _, bid, _ in cs)
def emit_calls(b, path):
cs, h = census(b)
s2 = isl.symbols(b, 2)
print('# %s — ISL disassembly artefacts' % path)
print()
print('Generated by `tools/re-capture/isl_report.py calls`.')
print()
print('%d call sites, %d distinct built-ins' % (len(cs), len(h)))
for bid, n in h.most_common():
print(' builtin %-4d %5d site(s)' % (bid, n))
print()
print('## phase-control sites')
for bid, nm in ((6, 'END_PHASE'), (62, 'FORCE_END_PHASE'),
(39, 'MARK_LAST_PHASE'), (40, 'mark_not_last')):
offs = [off for off, b2, _ in cs if b2 == bid]
print('%-3d %-18s %2d: %s'
% (bid, nm, len(offs), ' '.join('0x%x' % o for o in offs)))
print()
print('## named built-ins used, by traffic')
for bid, n in h.most_common():
nm = isl.BUILTIN.get(bid)
if nm:
print(' %-3d %-24s %4d' % (bid, nm, n))
print()
print('## phase code bases')
print('Each phase has its OWN base; the file header offset is not it.')
print(' ' + ' '.join('0x%x' % x for x in isl.phase_bases(b)))
print()
print('## disassembly into the first END_PHASE')
target = [off for off, bid, _ in cs if bid == 6][0]
start = isl.resync(b, target)
print('resync from 0x%X' % start)
for line in isl.dis(b, start, 64, code_base=0x24, sym2=s2):
print(line)
def main():
path = sys.argv[1]
b = isl.load(path)
name = path.replace('\\', '/').split('/')[-1]
{'calls': emit_calls}[sys.argv[2]](b, name)
if __name__ == '__main__':
main()