@asmlift/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +25 -24
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +149 -0
  4. package/src/backend/c.ts +2 -0
  5. package/src/backend/cfamily.ts +197 -61
  6. package/src/backend/cpp.ts +3 -1
  7. package/src/backend/lines.ts +10 -0
  8. package/src/backend/pascal.ts +47 -16
  9. package/src/cdecl/parse.ts +21 -0
  10. package/src/contracts.ts +246 -36
  11. package/src/declare.ts +69 -20
  12. package/src/frontend/arm-body.ts +155 -0
  13. package/src/frontend/asmdata.ts +9 -2
  14. package/src/frontend/call-plan.ts +1117 -61
  15. package/src/frontend/callee-body.ts +370 -0
  16. package/src/frontend/dead-reads.ts +628 -0
  17. package/src/frontend/device-pins.ts +33 -4
  18. package/src/frontend/emit.ts +4 -3
  19. package/src/frontend/fpu.ts +224 -17
  20. package/src/frontend/frame-objects.ts +1689 -206
  21. package/src/frontend/frontend.ts +84 -3
  22. package/src/frontend/high-half.ts +53 -4
  23. package/src/frontend/jump-table.ts +545 -0
  24. package/src/frontend/local-object.ts +158 -32
  25. package/src/frontend/mips.ts +2296 -217
  26. package/src/frontend/own-struct-return.ts +500 -0
  27. package/src/frontend/ppc.ts +2748 -245
  28. package/src/frontend/register-struct-call.ts +399 -0
  29. package/src/frontend/reloc-symbol.ts +22 -7
  30. package/src/frontend/ssa.ts +1721 -43
  31. package/src/frontend/stackargs.ts +199 -72
  32. package/src/frontend/thumb-entry.ts +340 -0
  33. package/src/frontend/thumb.ts +2400 -325
  34. package/src/ir/alias.ts +65 -2
  35. package/src/ir/bits.ts +1699 -4
  36. package/src/ir/core.ts +254 -9
  37. package/src/ir/discipline.ts +86 -21
  38. package/src/ir/entry.ts +157 -0
  39. package/src/{structure → ir}/loops.ts +6 -2
  40. package/src/ir/opcodes.ts +137 -9
  41. package/src/ir/parse.ts +1 -0
  42. package/src/ir/simplify.ts +61 -10
  43. package/src/ir/types.ts +86 -2
  44. package/src/ir/verify.ts +96 -11
  45. package/src/l3/argbase.ts +2 -0
  46. package/src/l3/ast.ts +582 -18
  47. package/src/l3/basecse.ts +68 -15
  48. package/src/l3/caseendgoto.ts +223 -0
  49. package/src/l3/coalesce.ts +42 -14
  50. package/src/l3/constinit.ts +410 -0
  51. package/src/l3/dce.ts +53 -9
  52. package/src/l3/initfirst.ts +42 -11
  53. package/src/l3/intcell.ts +2 -1
  54. package/src/l3/narrowdecl.ts +77 -36
  55. package/src/l3/pollguard.ts +8 -2
  56. package/src/l3/ptrcell.ts +33 -34
  57. package/src/l3/ptrfield.ts +123 -8
  58. package/src/l3/regspell.ts +11 -4
  59. package/src/l3/reindex.ts +295 -99
  60. package/src/l3/scopebase.ts +2 -0
  61. package/src/l3/storage.ts +98 -1
  62. package/src/l3/symbol-refs.ts +84 -6
  63. package/src/l3/tailmerge.ts +6 -2
  64. package/src/l3/typing.ts +108 -15
  65. package/src/l3/unreduce.ts +6 -5
  66. package/src/l3/volatileptr.ts +17 -54
  67. package/src/mangle.ts +30 -0
  68. package/src/pattern/engine.ts +361 -73
  69. package/src/pipeline.ts +1039 -38
  70. package/src/proto-context.ts +1039 -45
  71. package/src/proto.ts +555 -40
  72. package/src/raise/aggmembers.ts +290 -0
  73. package/src/raise/arrays.ts +5 -1
  74. package/src/raise/const.ts +25 -0
  75. package/src/raise/declared-volatile.ts +207 -31
  76. package/src/raise/divpow2.ts +192 -0
  77. package/src/raise/floathelpers.ts +254 -33
  78. package/src/raise/gvn.ts +3 -3
  79. package/src/raise/latch.ts +162 -6
  80. package/src/raise/memberarrays.ts +10 -4
  81. package/src/raise/offsetnames.ts +422 -36
  82. package/src/raise/pairparams.ts +33 -0
  83. package/src/raise/paramwidth.ts +196 -6
  84. package/src/raise/pre-recovery.ts +107 -21
  85. package/src/raise/pre-structure.ts +73 -0
  86. package/src/raise/recover.ts +12 -5
  87. package/src/raise/reroll.ts +674 -0
  88. package/src/raise/shortcircuit.ts +217 -34
  89. package/src/raise/softdiv.ts +1 -1
  90. package/src/raise/storesign.ts +153 -0
  91. package/src/raise/struct-arrays.ts +30 -11
  92. package/src/raise/structs.ts +396 -33
  93. package/src/raise/truncload.ts +4 -3
  94. package/src/raise/unpre.ts +594 -0
  95. package/src/raise/unthread.ts +400 -0
  96. package/src/rank-declare.ts +118 -12
  97. package/src/rank-variations.ts +186 -35
  98. package/src/rank.ts +536 -68
  99. package/src/runtime-helpers.ts +140 -22
  100. package/src/structure/analysis.ts +217 -8
  101. package/src/structure/arith-ops.ts +23 -0
  102. package/src/structure/cross-jumped-tail.ts +216 -0
  103. package/src/structure/globalaccess.ts +157 -10
  104. package/src/structure/hazards.ts +16 -64
  105. package/src/structure/local-statics.ts +145 -9
  106. package/src/structure/namecoalesce.ts +1 -1
  107. package/src/structure/pointer-spelling.ts +1106 -0
  108. package/src/structure/redundant-test.ts +1 -1
  109. package/src/structure/structure.ts +6192 -1213
  110. package/src/structure/switch-recover.ts +583 -120
  111. package/src/structure/unfolded-branch-constant.ts +90 -0
  112. package/src/symbols.ts +58 -15
  113. package/src/target.ts +1028 -54
  114. package/src/trace.ts +49 -17
  115. package/src/variation-definitions.ts +333 -20
  116. package/src/variation-gates.ts +4 -0
  117. package/src/variation-tokens.ts +68 -3
@@ -18,33 +18,82 @@
18
18
  // incoming stack argument at `[sp, #N]` locatable at all. Because agbcc may
19
19
  // copy a callee-saved argument (e.g. into r4) before touching r0, entry parameters are
20
20
  // ordered by ABI register (r0, r1, …), not by the order they were first read.
21
+ import { ownArgumentWordsOn } from '../aggregate';
21
22
  import { Block, Fn, Successor, Value, mergeClasses, mkOp, mkValue } from '../ir/core';
22
23
  import type { Opcode } from '../ir/opcodes';
23
- import { T, typeToString } from '../ir/types';
24
- import { type Prototypes, wordsOf } from '../proto';
24
+ import { T } from '../ir/types';
25
+ import {
26
+ type FnProto,
27
+ type Prototypes,
28
+ declaresVoidReturn,
29
+ ownLeadingWordRegisters,
30
+ ownReturnWidth,
31
+ wordsOf,
32
+ } from '../proto';
33
+ import type { ContextGlobals } from '../proto-context';
25
34
  import { type SymbolMap, lookupInterior, lookupSymbol } from '../symbols';
26
- import type { TargetDescription } from '../target';
35
+ import { ARM_REGISTER_ALIASES, type TargetDescription } from '../target';
27
36
  import type { AsmData } from './asmdata';
28
- import { type CallDeclarations, type CallLowering, callDeclarations } from './call-plan';
37
+ import { type CallDeclarations, type CallLowering, callDeclarations, foldByValueArgs } from './call-plan';
38
+ import { type CalleeStackWords, asksCalleeBody } from './callee-body';
39
+ import { pinDeadReads, unreadPairHalf } from './dead-reads';
29
40
  import { pinDeviceAccesses } from './device-pins';
30
41
  import { pushSwitchBr } from './emit';
31
42
  import { FrontendUnsupportedError } from './errors';
32
43
  import { inheritFlags } from './flags-edge';
33
44
  import { assertInputFormat } from './format';
34
- import { FRAME_OBJECT_AUDIT, type FrameObjectAudit, type FrameObjectRelift, type FrameRange } from './frame-objects';
35
- import type { Frontend } from './frontend';
36
- import { type LocalStatics, gasPoolReferrers, makeLocalStatics, readGasLocalObject } from './local-object';
45
+ import {
46
+ FRAME_OBJECT_AUDIT,
47
+ type FrameObjectAudit,
48
+ type FrameObjectRelift,
49
+ type FrameRange,
50
+ liftWithRelift,
51
+ nameMergedSretObjects,
52
+ refuseSretOutsideFrame,
53
+ } from './frame-objects';
54
+ import { type Frontend, type LiftOptions, type OwnDeclaration, ownDeclarationOf } from './frontend';
55
+ import {
56
+ type CaseEntry,
57
+ type IdiomLeft,
58
+ type StaleIdiomRead,
59
+ caseEntries,
60
+ enterCaseTarget,
61
+ idiomLeaves,
62
+ parkedIn,
63
+ refuseStaleIdiomReads,
64
+ staleMarker,
65
+ } from './jump-table';
66
+ import {
67
+ type LocalStatics,
68
+ gasPoolReferrers,
69
+ makeLocalStatics,
70
+ readGasAnonymousObject,
71
+ readGasLocalObject,
72
+ } from './local-object';
37
73
  import { opaqueDest } from './opaque';
38
74
  import { classifyRelocSymbol, unspellableReason } from './reloc-symbol';
39
75
  import {
40
- type ArgSlots,
41
76
  type SsaBuilder,
42
77
  abiSortEntryParams,
78
+ assertScratchRegsPartitioned,
43
79
  makeSsaBuilder,
44
80
  mintArgSlotHoles,
81
+ recordAsmLayout,
82
+ registerArgSlots,
83
+ registerLiveIns,
84
+ stackArgKey,
85
+ stackArgSlots,
45
86
  stackSlotKey,
87
+ unifyRegisterSpellings,
46
88
  } from './ssa';
47
- import { type OutgoingArgs, type StackArgsEvent, analyzeOutgoingArgs } from './stackargs';
89
+ import {
90
+ type OutgoingArgs,
91
+ type StackArgsBlock,
92
+ type StackArgsEvent,
93
+ analyzeOutgoingArgs,
94
+ loadedWordBound,
95
+ } from './stackargs';
96
+ import { thumbEntryWordsFromText } from './thumb-entry';
48
97
 
49
98
  interface Instr {
50
99
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -55,7 +104,16 @@ interface Instr {
55
104
  * Messages must use this: a decline naming `ldrsh` for a file containing `ldsh` sends the
56
105
  * reader looking for an instruction that is not there. */
57
106
  asWritten?: string;
107
+ /** set only on a `bl <label>` whose line carried the target's far-jump mark — agbcc's
108
+ * `@far jump` / `@ far jump`, the relay it spells a branch past Thumb's ±2 KB reach with (see
109
+ * `compilerBehaviors.farJumpRelay`); never on a target that declares no mark. The
110
+ * comment is the ONLY text that separates it from a call: everything else on the line is
111
+ * dropped at tokenizing, and a call thunk is the same `bl` to a label of this function. */
112
+ farJump?: true;
58
113
  }
114
+ /** The label a block the .s does not label is given — one no .s spells (`thumbCallLocator`). */
115
+ const ANON_BLOCK_PREFIX = '.L_anon';
116
+
59
117
  interface AsmBlock {
60
118
  label: string;
61
119
  instrs: Instr[];
@@ -224,15 +282,21 @@ const controlContinuesPast = (ins: Instr) => {
224
282
  * `mov pc, rN`.) */
225
283
  function classifyXfer(ins: Instr): XferKind | null {
226
284
  const mn = ins.mnemonic;
227
- if (mn === 'b') {
285
+ // agbcc's far-jump relay is a branch wearing the call mnemonic. Classified here so the layout
286
+ // questions asked BEFORE it is lowered to `b` (`readThumbText`: `lowerFarJumpRelays`) — whether
287
+ // a fill after it is sealed, whether the block before a pool falls into data — read it as the
288
+ // transfer it is; it keeps its 4 bytes there because the mnemonic is still `bl`.
289
+ if (mn === 'b' || (mn === 'bl' && ins.farJump)) {
228
290
  return 'uncond';
229
291
  }
230
292
  if (COND_OPCODE[mn]) {
231
293
  return 'cond';
232
294
  }
233
- // `bx rN`: agbcc's return is `bx lr` or `pop {rN}; bx rN` (rN holds the restored LR) — a return.
234
- // (agbcc emits jump tables via `mov pc`, NOT `bx`; a computed tail-call `bx rN` is out of scope and
235
- // would need call modelling — accepted limitation, not a jump-table dispatch form.)
295
+ // `bx rN`: agbcc's return is `bx lr` or `pop {…, rN}; bx rN` (rN holds the restored LR) — a return.
296
+ // This sees one instruction, so it cannot tell what rN holds; once the CFG exists, every return
297
+ // of the selected function is asked whether it reads the caller's lr from the frame word the
298
+ // prologue saved it in (`refuseReturnsNotThroughCallersLr`), and any other declines as the
299
+ // indirect jump it is. (agbcc emits jump tables via `mov pc`, NOT `bx`.)
236
300
  if (mn === 'bx') {
237
301
  return 'return';
238
302
  }
@@ -332,8 +396,9 @@ const REG_NUM: Record<string, number> = Object.assign(Object.create(null), { sp:
332
396
 
333
397
  // Registers Thumb-1's `push` cannot name, in every spelling this ISA's asm uses for them. Saving
334
398
  // one takes agbcc's `mov rLow, rHi; push {rLow}`, which is why the prologue's save set has to read
335
- // the `mov` as well as the list (see `savedRegs`). Spellings, not numbers: nothing here normalises
336
- // a register name, so `sl` and `r10` are separate keys everywhere the frontend uses one.
399
+ // the `mov` as well as the list (see `savedRegs`). Spellings, not numbers: a register keeps the
400
+ // name it was written with, and only a function that writes one two ways is rewritten to one of them
401
+ // (`unifyRegisterSpellings` in `readThumbText`), so `sl` and `r10` are never both keys of one lift.
337
402
  const HIGH_REGS: ReadonlySet<string> = new Set(['r8', 'r9', 'r10', 'r11', 'r12', 'sb', 'sl', 'fp', 'ip']);
338
403
 
339
404
  /** The operands of a `cmp` whose flags are still live — the whole of the condition state this
@@ -352,16 +417,25 @@ const HIGH_REGS: ReadonlySet<string> = new Set(['r8', 'r9', 'r10', 'r11', 'r12',
352
417
  * the edges of a conditional branch (mwcc's switch dispatch reads one `cmpwi` from a `beq`
353
418
  * and then from the `bge` on its fall-through), and Thumb, with no row inhabiting that shape,
354
419
  * does not. */
355
- type PendingCmp = { lhs: Value; rhs: Value };
420
+ type PendingCmp = {
421
+ lhs: Value;
422
+ rhs: Value;
423
+ /** The flags came from `cmn lhs, rhs`, which stands for `cmp lhs, -rhs`. Nothing is minted for
424
+ * the negation until a branch reads it: the tag travels with the compare, across an edge too
425
+ * (`inheritFlags` carries the record whole), and the terminator mints the `neg` in the READING
426
+ * block with the attrs that branch needs, or refuses. See `CMN_EXACT`. */
427
+ cmn?: true;
428
+ };
356
429
 
357
430
  // Thumb-1 data-processing mnemonics that write the condition flags when their destination is a LOW
358
431
  // register — which is all of them on this ISA, `s`-suffix or not (the assembler picks the encoding).
359
432
  // Used to invalidate a pending compare: see the decode loop. Loads, stores and push/pop leave the
360
- // flags alone. Three mnemonics that DO write them are absent, each for its own reason, and lumping
433
+ // flags alone. Three kinds of writer that DO write them are absent, each for its own reason, and lumping
361
434
  // them under one was how `cmn`/`tst` came to be described as instructions that set no flags:
362
- // * `cmp` seeds the pending compare instead of invalidating it — it is the modelled one;
363
- // * `cmn`/`tst` write the flags and define no register, so the membership test below (which
364
- // reads a destination) cannot judge them — `FLAG_ONLY` does;
435
+ // * `cmp` and `cmn` seed the pending compare instead of invalidating it — they are the modelled
436
+ // ones (`cmn` as `cmp rA, -rB`, `lowerCmn`);
437
+ // * `tst` writes the flags and defines no register, so the membership test below (which reads a
438
+ // destination) cannot judge it — `FLAG_ONLY` does;
365
439
  // * a CALL is not an instruction's flag write at all: `bl` writes none itself, but the function
366
440
  // it enters retires compares of its own, and AAPCS lets it. Invalidated separately.
367
441
  const FLAG_SETTING = new Set([
@@ -401,20 +475,45 @@ const FLAG_SETTING = new Set([
401
475
  'sbcs',
402
476
  ]);
403
477
 
404
- // The flag writers with no destination to read. `tst`/`cmn` compute a value they throw away and
405
- // keep only the flags, so `FLAG_SETTING`'s destination test cannot see them — yet they take the
406
- // flags from a pending compare exactly as `sub` does, and the branch that follows tests THEIRS.
478
+ // The flag writer with no destination to read. `tst` computes a value it throws away and keeps
479
+ // only the flags, so `FLAG_SETTING`'s destination test cannot see it — yet it takes the flags from
480
+ // a pending compare exactly as `sub` does, and the branch that follows tests ITS.
407
481
  //
408
- // Unconditional, unlike `FLAG_SETTING`: neither has a destination whose width could pick a
409
- // non-flag-setting encoding, and Thumb-1 encodes both on low registers only.
482
+ // Unconditional, unlike `FLAG_SETTING`: it has no destination whose width could pick a
483
+ // non-flag-setting encoding, and Thumb-1 encodes it on low registers only.
410
484
  //
411
- // They still decode to an `opaque` (the default arm), so a function containing one declines either
485
+ // It still decodes to an `opaque` (the default arm), so a function containing one declines either
412
486
  // way and this changes no verdict — only which decline, and the terminator's is the true one.
413
487
  // Without it `cmp r0,r1 / tst r2,r3 / bge` FOLDED the stale compare into the condition and was
414
488
  // loud solely because the opaque it minted is unresolvable: the right answer resting on an
415
- // accident of an unrelated model. Making `tst` transparent instead — it defines nothing, so a
416
- // `tst` no branch reads is dead — is a capability with no row in this corpus to earn it.
417
- const FLAG_ONLY = new Set(['tst', 'cmn']);
489
+ // accident of an unrelated model. Modelling `tst` is a capability with no inhabitant: agbcc's
490
+ // `tstsi` prints `cmp %0, #0` (gcc/thumb.md:818-821), so every `tst` in the corpus is hand-written.
491
+ //
492
+ // `cmn` is not here: it is a compare (the decode loop's `cmn` arm). agbcc's `cmnsi`
493
+ // (thumb.md:823-827) is `(compare rA (neg rB))`, which combine makes of `a == -b` and `a <= -b`.
494
+ const FLAG_ONLY = new Set(['tst']);
495
+
496
+ // The branches that read a `cmn`'s flags as the compare it stands for, and why the others cannot.
497
+ // `cmn rA, rB` sets NZCV from rA + rB, and `cmp rA, -rB` from rA - (-rB):
498
+ // * N and Z come from the same 32-bit result, so `beq`/`bne` read the WRAPPED a + b == 0, which
499
+ // the plain wrapping negation spells: exactly for an unsigned or narrower `b`, and for an `s32`
500
+ // one everywhere but b == INT_MIN, where C's `-b` overflows — the spelling every wrapping `neg`
501
+ // in this frontend already has;
502
+ // * V differs only at rB == INT_MIN, where cmn reports the true sign of rA + 2^31 and the
503
+ // compare's `-rB` wraps back to INT_MIN. The lift spells the negation SIGNED (`neg_s`), so its
504
+ // C differs from the bytes at that one input only, where its `-b` is undefined. agbcc folds the
505
+ // defined `a > (int)(0u - b)` into `cmn; bgt` too, and miscompiles it there, so the claim is
506
+ // narrower than "exact for every source": a C spelling that recompiles to the same bytes AND
507
+ // diverges from them only where it is itself undefined. When rB always holds INT_MIN that one
508
+ // input is every input, and the structurer refuses (structure.ts, `neg_s`);
509
+ // * C is the carry out of rA + rB, which is NOT rA >=u -rB: at rB == 0 cmn's carry is clear and
510
+ // the compare's is set. agbcc folds unsigned `a < -b` into `cmn; bcs` regardless — it
511
+ // miscompiles at b == 0 — and the C that is exact there (`a + b >= a`) compiles to
512
+ // `add; cmp; bcc` instead. Unsigned C defines every input, so no spelling both matches the
513
+ // bytes and leaves the divergence undefined, and the unsigned branches refuse. The sentence
514
+ // stays literal at its throw in `lowerTerminator`: the decline taxonomy (`cmn-carry`) is
515
+ // checked against throw texts as written.
516
+ const CMN_EXACT = new Set(['beq', 'bne', 'blt', 'ble', 'bgt', 'bge']);
418
517
  const regNum = (r: string) => (r[0] === 'r' ? Number(r.slice(1)) : REG_NUM[r]);
419
518
  function expandRegList(tokens: string[]): string[] {
420
519
  const out: string[] = [];
@@ -629,25 +728,15 @@ const isFrameAdjust = (
629
728
  (base === undefined || isSpReg(base)) &&
630
729
  (off?.startsWith('#') ?? false);
631
730
 
632
- // A virtual register key per incoming stack argument. Reading it goes through the ordinary Braun
633
- // live-in path (frontend/ssa.ts), which turns a read with no reaching def into a function
634
- // parameter — so this needs NO new representation, opcode or pass. The `@` cannot appear in a
635
- // real register token, so the key cannot collide with one.
636
- const stackArgKey = (index: number) => `@sarg${index}`;
637
- // Defined beside its mint site on purpose: the format string and its parser drifted 650 lines
638
- // apart in the first version, with the convention explained only at one end.
639
- const stackArgIndex = (key: string): number | null => {
640
- const m = /^@sarg(\d+)$/.exec(key);
641
- return m ? +m[1] : null;
642
- };
643
-
644
731
  // INCOMING STACK ARGUMENTS (AAPCS). Args 1-4 arrive in r0-r3; args 5+ are pushed by the CALLER,
645
732
  // so the callee reads them at `[sp, #N]` where N is at or above its own frame. Those are
646
733
  // PARAMETERS, not locals — declining them as "sp used as data" refuses a calling convention.
647
734
  //
648
- // The frame depth is tracked by a linear walk of the ENTRY BLOCK only, which is
649
- // why the gate is what it is: within one straight-line block the depth at each instruction is
650
- // exact and needs no CFG reasoning. Every case that would need more declines.
735
+ // The frame depth is tracked by a linear walk of ONE BLOCK, from a depth that is exact where the
736
+ // block starts: 0 at the entry block (when nothing branches back to it), and for every other block
737
+ // the depth the entry block leaves, but only where the function is proven never to move sp outside
738
+ // its prologue and its returning tails (`frameDepthBlocker`). Within a straight-line block the depth
739
+ // at each instruction is then exact. Every case that would need more declines.
651
740
  //
652
741
  // `push {a,b,c}` deepens by 4 per register; `sub sp, #N` and `add sp, sp, #-N` deepen by N.
653
742
  //
@@ -660,12 +749,12 @@ const stackArgIndex = (key: string): number | null => {
660
749
  // preserve is written next to the state it is about.
661
750
  //
662
751
  // `deps` carries the only two things about the FUNCTION this needs — the ABI's argument-register
663
- // count, and whether the entry block has predecessors — so the walk itself reads nothing but the
664
- // instructions it is stepped over.
665
- const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolean }) => {
752
+ // count, and the depth this block starts at (null where none is proven) — so the walk itself reads
753
+ // nothing but the instructions it is stepped over.
754
+ const makeFrameWalk = (deps: { argRegs: readonly string[]; start: number | null }) => {
666
755
  // Bytes the frame has grown since function entry, along this block's linear order only.
667
- let depth = 0;
668
- let depthKnown = true;
756
+ let depth = deps.start ?? 0;
757
+ let depthKnown = deps.start !== null;
669
758
 
670
759
  // Returns null whenever the depth cannot be computed exactly — including for any write to sp
671
760
  // this does not model. The capability rests on the depth being EXACT, so an approximation is
@@ -730,6 +819,11 @@ const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolea
730
819
  }
731
820
  },
732
821
 
822
+ /** The depth the instructions stepped so far leave, or null where it is not exact. */
823
+ depthNow(): number | null {
824
+ return depthKnown ? depth : null;
825
+ },
826
+
733
827
  // Is `[sp, #off]` an incoming stack argument, and which one? `null` means NO — and every null is
734
828
  // a DECLINE, because the caller falls through to readData's sp guard.
735
829
  //
@@ -753,19 +847,16 @@ const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolea
753
847
  // reads. `step` enforces it, and never un-poisons: once sp has been above the line, a push during
754
848
  // the excursion may have written the later slots too. This is a PREMISE, not a detail — leaving
755
849
  // it unstated is what let the first version hand back a callee-saved register as an argument.
756
- argIndex(addr: { base: string; off: number; regOff?: string }, width: number, bi: number): number | null {
850
+ argIndex(addr: { base: string; off: number; regOff?: string }, width: number): number | null {
757
851
  const { base, off, regOff } = addr;
758
852
  if (!isSpReg(base) || regOff !== undefined) {
759
853
  return null; // not sp, or `[sp, rX]` — not a fixed argument slot
760
854
  }
761
- if (bi !== 0 || deps.entryHasPreds) {
762
- return null; // depth is exact only along the ENTRY block's linear order, and only when its
763
- // params are parameters rather than phis
764
- }
765
855
  if (!depthKnown || depth <= 0) {
766
- return null; // an unmeasurable frame, or none established: a headerless FRAGMENT whose
767
- // prologue was sliced off looks identical to a frameless function, and there the slots are
768
- // locals — minting one would be the silent-wrong trade this frontend refuses
856
+ return null; // no exact start (`start`), an unmeasurable frame, or none established: a
857
+ // headerless FRAGMENT whose prologue was sliced off looks identical to a frameless
858
+ // function, and there the slots are locals — minting one would be the silent-wrong trade
859
+ // this frontend refuses
769
860
  }
770
861
  if (width !== 4 || off < depth || (off - depth) % 4 !== 0) {
771
862
  return null; // the argument area is word-granular; BELOW the top is a local, which is the
@@ -966,11 +1057,14 @@ const sayFact = (f: LayoutFact): string => {
966
1057
  function decode(
967
1058
  name: string,
968
1059
  asm: string,
1060
+ farJumpMark?: RegExp,
969
1061
  ): {
970
1062
  blocks: AsmBlock[];
971
1063
  dataWords: Map<string, string[]>;
972
1064
  nonWordData: Map<string, string>;
973
1065
  funcLabels: Set<string>;
1066
+ /** Each label that shares its instruction with a later one, to the label whose block was kept. */
1067
+ aliasOf: ReadonlyMap<string, string>;
974
1068
  } {
975
1069
  // Flatten to (label | instr | data) items, then split into blocks at labels / after branches.
976
1070
  // `.word LABEL` directives are captured into dataWords keyed by the most recent label (the
@@ -1022,10 +1116,19 @@ function decode(
1022
1116
  // `.Letext0`) that no code line names. A label starts a block, and a block split at a
1023
1117
  // lexical-scope marker restructures the loop around it: kept, it lifts the pair's `gcd` loop as
1024
1118
  // an `if` around a `do` instead of a `while`.
1119
+ //
1120
+ // Every `@` comment is dropped here save ONE fact read from it: that a `bl` line carried the
1121
+ // target's far-jump mark (`farJumpMark`, from `compilerBehaviors.farJumpRelay`). Kept as a set of
1122
+ // line positions, not as text, so nothing downstream can read any other comment.
1025
1123
  const codeLines: string[] = [];
1124
+ const farJumpLines = new Set<number>();
1026
1125
  for (const rawLine of withoutDebugSections(asm).split('\n')) {
1027
- const line = rawLine.split('@')[0].trim();
1126
+ const at = rawLine.indexOf('@');
1127
+ const line = (at === -1 ? rawLine : rawLine.slice(0, at)).trim();
1028
1128
  if (line) {
1129
+ if (at !== -1 && farJumpMark?.test(rawLine.slice(at))) {
1130
+ farJumpLines.add(codeLines.length);
1131
+ }
1029
1132
  codeLines.push(line);
1030
1133
  }
1031
1134
  }
@@ -1037,7 +1140,8 @@ function decode(
1037
1140
  }
1038
1141
  }
1039
1142
  const isDebugMarker = (label: string) => /^\.L(?:FB|FE|M|BB|BE|etext)\d+$/.test(label) && !namedByCode.has(label);
1040
- for (let rest of codeLines) {
1143
+ for (const [lineNo, line] of codeLines.entries()) {
1144
+ let rest = line;
1041
1145
  // A label may share the line with what follows it (pret pools: `_08x: .4byte 0x…`) — peel it.
1042
1146
  const lm = rest.match(/^([A-Za-z_.$][\w.$]*):\s*(.*)$/);
1043
1147
  if (lm && isDebugMarker(lm[1]) && lm[2] === '') {
@@ -1234,6 +1338,7 @@ function decode(
1234
1338
  mnemonic: canon,
1235
1339
  ops,
1236
1340
  ...(canon === m[1] ? {} : { asWritten: m[1] }),
1341
+ ...(canon === 'bl' && ops.length === 1 && farJumpLines.has(lineNo) ? { farJump: true as const } : {}),
1237
1342
  },
1238
1343
  });
1239
1344
  }
@@ -1262,9 +1367,17 @@ function decode(
1262
1367
  if (funcLabels.length > 0) {
1263
1368
  const fi = funcLabels.indexOf(name);
1264
1369
  if (fi !== -1) {
1265
- sliceStart = allFlat.findIndex((f) => f.label === name);
1370
+ // each label's first item, read in one pass: a search per function label costs labels × lines
1371
+ // on every lift, and a callee body bound lifts the whole unit once per callee it asks
1372
+ const firstAt = new Map<string, number>();
1373
+ allFlat.forEach((f, k) => {
1374
+ if (f.label !== undefined && !firstAt.has(f.label)) {
1375
+ firstAt.set(f.label, k);
1376
+ }
1377
+ });
1378
+ sliceStart = firstAt.get(name) ?? -1;
1266
1379
  const starts = funcLabels
1267
- .map((l) => allFlat.findIndex((f) => f.label === l))
1380
+ .map((l) => firstAt.get(l) ?? -1)
1268
1381
  .filter((s) => s > sliceStart)
1269
1382
  .sort((a, b) => a - b);
1270
1383
  boundaries = [...starts, allFlat.length];
@@ -1738,7 +1851,7 @@ function decode(
1738
1851
  continue;
1739
1852
  }
1740
1853
  if (!cur) {
1741
- cur = { label: first ? name : `.L_anon${anon++}`, instrs: [] };
1854
+ cur = { label: first ? name : `${ANON_BLOCK_PREFIX}${anon++}`, instrs: [] };
1742
1855
  blocks.push(cur);
1743
1856
  first = false;
1744
1857
  }
@@ -1787,7 +1900,9 @@ function decode(
1787
1900
  for (const b of aliasOf.size ? blocks : []) {
1788
1901
  for (const ins of b.instrs) {
1789
1902
  const k = ins.ops.length - 1;
1790
- if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && k >= 0) {
1903
+ // A marked relay is a branch here too (classifyXfer), though still spelt `bl` until
1904
+ // `lowerFarJumpRelays`: a relay to a dropped alias must name the label it stands for.
1905
+ if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic] || ins.farJump) && k >= 0) {
1791
1906
  ins.ops[k] = aliasOf.get(ins.ops[k]) ?? ins.ops[k];
1792
1907
  }
1793
1908
  }
@@ -1870,7 +1985,7 @@ function decode(
1870
1985
  boundaryIdx++;
1871
1986
  continue;
1872
1987
  }
1873
- return { blocks: live, dataWords, nonWordData, funcLabels: new Set(funcLabels) };
1988
+ return { blocks: live, dataWords, nonWordData, funcLabels: new Set(funcLabels), aliasOf };
1874
1989
  }
1875
1990
  }
1876
1991
 
@@ -1994,6 +2109,7 @@ type PoolRef =
1994
2109
  | { kind: 'const'; value: number }
1995
2110
  | { kind: 'gaddr'; sym: string; addend: number }
1996
2111
  | { kind: 'local-static'; symbol: string; addend: number }
2112
+ | { kind: 'anonymous'; label: string }
1997
2113
  | { kind: 'unmodelled'; why: string };
1998
2114
 
1999
2115
  /** Classify a word-load operand `LABEL[+N]` against the captured literal pools. Returns null when
@@ -2126,6 +2242,13 @@ function poolRef(
2126
2242
  why: `pool word '${w}' has a leading-zero magnitude, which is octal to the assembler`,
2127
2243
  };
2128
2244
  }
2245
+ // A bare `.L` label in a pool word is the address of something the compiler placed and the
2246
+ // source never named (reloc-symbol.ts `anon-pool`): agbcc's `.LC0` is the constant image a local
2247
+ // aggregate's initializer is copied from. The caller reads it as an anonymous object, or refuses
2248
+ // naming why it is not one.
2249
+ if (classifyRelocSymbol(w) === 'anon-pool') {
2250
+ return { kind: 'anonymous', label: w };
2251
+ }
2129
2252
  return { kind: 'unmodelled', why: `pool word '${w}' is not a symbol, symbol±offset, or number` };
2130
2253
  }
2131
2254
 
@@ -2181,7 +2304,8 @@ function poolNamesASymbol(
2181
2304
  // and its unique bounds predecessor `bounds` ending in `cmp rX,#(N-1); bhi DEF`, verify the exact
2182
2305
  // idiom and read the inline table — else return null (→ the indirect-jump loud-fail fires). The
2183
2306
  // recovered switch REPLACES both blocks: `bounds` emits a `switch_br` (scrutinee rX; successors =
2184
- // case blocks + DEF).
2307
+ // case blocks + DEF) — unless the dispatch carries code of its own (`extras`), which keeps it as
2308
+ // the block that emits the switch, with the bounds test left as an ordinary branch.
2185
2309
  //
2186
2310
  // bounds: cmp rX,#(N-1); bhi DEF disp: lsl rY,rX,#2 ; ldr rP,=PTR ; add rA,rY,rP
2187
2311
  // ; ldr rV,[rA] ; mov pc,rV
@@ -2193,6 +2317,13 @@ interface JumpTable {
2193
2317
  scrutReg: string;
2194
2318
  caseLabels: string[];
2195
2319
  defaultLabel: string;
2320
+ /** What runs between the table load and the `mov pc` ({@link dispatchExtraRefusal}). */
2321
+ extras: Instr[];
2322
+ /** What the dispatch idiom leaves in each register it writes (index, table pointer, target;
2323
+ * jump-table.ts `idiomLeaves`). The `add` rewrites the index the `lsl` scaled, so every one is a
2324
+ * marker here: an elided dispatch writes them at the entry of each case target (`caseEntries`), a
2325
+ * kept one at its own block's end, save the ones its extras rewrote. */
2326
+ leaves: ReadonlyMap<string, IdiomLeft>;
2196
2327
  }
2197
2328
 
2198
2329
  // Accept a data-processing mnemonic in either the pre-UAL (`lsl`, `add`) or UAL (`lsls`, `adds`)
@@ -2235,9 +2366,10 @@ interface JumpTable {
2235
2366
  // Inside the dispatch block the distinction is additionally UNOBSERVABLE: the block computes
2236
2367
  // `table_base + index*4`, loads the target and writes `pc`. Nothing between the `lsl` and the
2237
2368
  // `mov pc` reads NZCV, no path leaves the block by falling through, and on a successful recovery the
2238
- // block is ELIDED from the CFG entirely — the bounds test that does feed a conditional branch lives
2239
- // in `bounds`, whose `cmp`/`bhi`/`bls` this function matches by exact name. Doubly moot in fact,
2240
- // since `lsl rD, rS, #imm` is low-register-only, so the add here is always the low-register form.
2369
+ // idiom is never lifted (the block is elided, or keeps only the code `dispatchExtraRefusal` admits)
2370
+ // — the bounds test that does feed a conditional branch lives in `bounds`, whose `cmp`/`bhi`/`bls`
2371
+ // this function matches by exact name. Doubly moot in fact, since `lsl rD, rS, #imm` is
2372
+ // low-register-only, so the add here is always the low-register form.
2241
2373
  const isDataOp = (mn: string, base: 'lsl' | 'add'): boolean => mn === base || mn === `${base}s`;
2242
2374
 
2243
2375
  function recoverJumpTable(
@@ -2246,6 +2378,8 @@ function recoverJumpTable(
2246
2378
  dataWords: Map<string, string[]>,
2247
2379
  nonWordData: Map<string, string>,
2248
2380
  blockLabels: Set<string>,
2381
+ aliasOf: ReadonlyMap<string, string>,
2382
+ refused: (why: string) => void,
2249
2383
  longDefault?: string,
2250
2384
  ): JumpTable | null {
2251
2385
  // bounds: last two instrs are `cmp rX,#M` then the out-of-range guard, in one of two spellings.
@@ -2290,12 +2424,15 @@ function recoverJumpTable(
2290
2424
  return null; // a bound that admits no case at all is not a dispatch — fail closed
2291
2425
  }
2292
2426
 
2293
- // disp: exactly the 5-op idiom, threading a single index register from `lsl rY,rX,#2`.
2427
+ // disp: the 5-op idiom, threading a single index register from `lsl rY,rX,#2`, with any
2428
+ // instructions between the table load and the `mov pc` (`extras`, judged below).
2294
2429
  const d = disp.instrs;
2295
- if (d.length !== 5) {
2430
+ if (d.length < 5) {
2296
2431
  return null;
2297
2432
  }
2298
- const [lsl, ldrP, add, ldrV, movpc] = d;
2433
+ const [lsl, ldrP, add, ldrV] = d;
2434
+ const movpc = d[d.length - 1];
2435
+ const extras = d.slice(4, -1);
2299
2436
  if (!isDataOp(lsl.mnemonic, 'lsl') || lsl.ops[1] !== scrutReg || !immEq(lsl.ops[2], 2)) {
2300
2437
  return null;
2301
2438
  }
@@ -2344,12 +2481,13 @@ function recoverJumpTable(
2344
2481
  if (movpc.mnemonic !== 'mov' || movpc.ops[0] !== 'pc') {
2345
2482
  return null;
2346
2483
  }
2484
+ const extraRefusal = dispatchExtraRefusal(extras, ldrV.ops[0], [idxReg, ptrReg, ldrV.ops[0]]);
2485
+ if (extraRefusal !== null) {
2486
+ refused(extraRefusal);
2487
+ return null;
2488
+ }
2347
2489
 
2348
2490
  // Read the table: the ldr loads a POINTER word (PTR: .word TABLE); the table is TABLE: .word C0…
2349
- // Note the case labels are matched against `blockLabels` as WRITTEN: the adjacent-label aliasing in
2350
- // `decode` rewrites branch operands, not `.word` entries, so a table naming an aliased label would
2351
- // decline here rather than dispatch anywhere. Loud, and no corpus instance — left as a known edge
2352
- // rather than fixed speculatively.
2353
2491
  //
2354
2492
  // The pointer word is addressed the same way every other pool load in this frontend is —
2355
2493
  // `LABEL[+N]`, selecting word N/4 — because a literal pool is a POOL: agbcc packs the dispatch
@@ -2373,16 +2511,71 @@ function recoverJumpTable(
2373
2511
  if (ptrOff % 4 !== 0 || ptrOff / 4 >= ptrWords.length) {
2374
2512
  return null; // misaligned or past the end of the pool — not a word this pool holds
2375
2513
  }
2376
- const caseLabels = recordedWords(ptrWords[ptrOff / 4].trim(), dataWords, nonWordData);
2377
- if (!caseLabels || caseLabels.length !== n) {
2514
+ const written = recordedWords(ptrWords[ptrOff / 4].trim(), dataWords, nonWordData);
2515
+ if (!written || written.length !== n) {
2378
2516
  return null;
2379
2517
  } // table length must equal the bound
2518
+ // An entry naming a label that shares its instruction with a later one dispatches to that one, as
2519
+ // a branch to it does (`decode`'s `aliasOf`): jump.c cross-jumps a case arm identical to the
2520
+ // default's onto the default's first instruction and leaves the case's label standing on it. Only
2521
+ // the TABLE's entries are read through it — a pool word naming a code label elsewhere is an
2522
+ // address constant, not a transfer — and `aliasOf` already holds no data label on either side, so
2523
+ // an entry naming a label that fronts data, or one left trailing, still declines below.
2524
+ const caseLabels = written.map((l) => aliasOf.get(l) ?? l);
2380
2525
  // Every case target and the default must resolve to a real decoded block; a label that is an
2381
2526
  // expression (`.L4+4`) or points outside the function would otherwise crash later — decline cleanly.
2382
2527
  if (!blockLabels.has(defaultLabel) || caseLabels.some((l) => !blockLabels.has(l))) {
2383
2528
  return null;
2384
2529
  }
2385
- return { scrutReg, caseLabels, defaultLabel };
2530
+ const leaves = idiomLeaves([idxReg, ptrReg, ldrV.ops[0]].map((reg) => ({ reg, scaled: false })));
2531
+ return { scrutReg, caseLabels, defaultLabel, extras, leaves };
2532
+ }
2533
+
2534
+ // The instructions an agbcc dispatch may carry between the table load and the `mov pc`. gcse's
2535
+ // PRE (`insert_insn_end_bb`, gcse.c:4231-4236) places an expression a later block needs "just before
2536
+ // the tablejump" — thumb.md's `tablejump` is the `mov pc` alone, so after the load. They run on every
2537
+ // dispatch and on nothing else, so they stay in a block of their own between the bounds test and the
2538
+ // switch (`readThumbText`), which switches on the scrutinee as the dispatch's first instruction read
2539
+ // it (`lowerTerminator`). PRE inserts `reg = expr` and nothing more, so the instructions accepted are
2540
+ // the ones `mayWriteReg` knows write their first operand — the data-processing ops and the single
2541
+ // loads — less the two that read the carry; anything else refuses:
2542
+ // - a store, a call, a transfer, a flag READ (`adc`/`sbc`), or any other mnemonic;
2543
+ // - a write to the loaded target (it is what `mov pc` jumps through), or to sp/pc/lr. A write to
2544
+ // the scrutinee is admitted: the switch does not read it after the extras, and the case blocks
2545
+ // read what the extras left there on the hardware too. lr is refused as the register a case's
2546
+ // `bx lr` returns through: a write to it here makes that `bx lr` a jump, which only the return
2547
+ // proof (`refuseReturnsNotThroughCallersLr`) would otherwise have to catch. PRE never inserts
2548
+ // an lr write, so the refusal costs no compiled dispatch;
2549
+ // - a read of a register the idiom wrote (index, table pointer, target) before an extra rewrote it:
2550
+ // the idiom is not lifted, so such a read would see the value from before the dispatch.
2551
+ // The reason is returned so the decline can name it (`readThumbText`'s indirect-jump refusal).
2552
+ const READS_CARRY = /^(?:adc|sbc)s?$/;
2553
+ /** Besides a load (`SINGLE_LOAD`), the forms that write their destination without reading it. */
2554
+ const DEST_ONLY = /^(?:mov|mvn|neg)s?$/;
2555
+ function dispatchExtraRefusal(
2556
+ extras: readonly Instr[],
2557
+ targetReg: string,
2558
+ idiomRegs: readonly string[],
2559
+ ): string | null {
2560
+ const stale = new Set(idiomRegs);
2561
+ for (const ins of extras) {
2562
+ const mn = ins.mnemonic;
2563
+ const say = `'${mn} ${ins.ops.join(', ')}'`;
2564
+ if (!(SINGLE_LOAD.test(mn) || WRITES_OPERAND_0.test(mn)) || READS_CARRY.test(mn) || ins.ops.length < 2) {
2565
+ return `${say} is not a register computation or a load`;
2566
+ }
2567
+ const dest = ins.ops[0];
2568
+ if ([targetReg, 'sp', 'r13', 'pc', 'r15', 'lr', 'r14'].includes(dest)) {
2569
+ return `${say} writes the register the dispatch needs, '${dest}'`;
2570
+ }
2571
+ const reads = ins.ops.length > 2 || SINGLE_LOAD.test(mn) || DEST_ONLY.test(mn) ? ins.ops.slice(1) : ins.ops;
2572
+ const read = [...stale].find((r) => mentionsReg({ ops: reads }, r));
2573
+ if (read !== undefined) {
2574
+ return `${say} reads '${read}', which the unlifted dispatch idiom wrote`;
2575
+ }
2576
+ stale.delete(dest);
2577
+ }
2578
+ return null;
2386
2579
  }
2387
2580
 
2388
2581
  /** Lift decoded asm → an L1 Fn with block-argument SSA. `prototypes` supplies each callee's
@@ -2402,16 +2595,12 @@ export function lift(
2402
2595
  prototypes: Prototypes = {},
2403
2596
  asmData?: AsmData,
2404
2597
  symbols?: SymbolMap,
2598
+ context?: ContextGlobals,
2599
+ options: LiftOptions = {},
2405
2600
  ): Fn {
2406
- const first = liftOnce(name, asm, target, prototypes, asmData, symbols, undefined);
2407
- if (!('oneObject' in first)) {
2408
- return first;
2409
- }
2410
- const again = liftOnce(name, asm, target, prototypes, asmData, symbols, first.oneObject);
2411
- if ('oneObject' in again) {
2412
- throw new Error(`internal: the frame-object audit of '${name}' asked for a second relift`);
2413
- }
2414
- return again;
2601
+ return liftWithRelift(name, (oneObject, homesReleased) =>
2602
+ liftOnce(name, asm, target, prototypes, asmData, symbols, context, oneObject, homesReleased, options),
2603
+ );
2415
2604
  }
2416
2605
 
2417
2606
  /** ONE LIFT, AS STAGES RUN IN THEIR REFUSAL ORDER: every stage before the fill (`thumbFillOf`: the
@@ -2430,9 +2619,21 @@ function liftOnce(
2430
2619
  prototypes: Prototypes,
2431
2620
  _asmData: AsmData | undefined,
2432
2621
  symbols: SymbolMap | undefined,
2622
+ context: ContextGlobals | undefined,
2433
2623
  oneObject: FrameRange | undefined,
2624
+ homesReleased: boolean,
2625
+ options: Pick<
2626
+ LiftOptions,
2627
+ | 'contextOwnWords'
2628
+ | 'contextOwnProto'
2629
+ | 'ownMemoryReturn'
2630
+ | 'calleeBodyBound'
2631
+ | 'calleeStackWords'
2632
+ | 'stackSized'
2633
+ | 'entryReadCalls'
2634
+ >,
2434
2635
  ): Fn | FrameObjectRelift {
2435
- const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject);
2636
+ const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject, options, homesReleased);
2436
2637
  const { cfg, frame, ssa, pairs, statics } = fill;
2437
2638
  const { fn, irBlocks, paramReg } = ssa;
2438
2639
 
@@ -2441,25 +2642,61 @@ function liftOnce(
2441
2642
  fillThumbBlock(fill, ab, bi);
2442
2643
  ssa.markFilled(bi);
2443
2644
  });
2645
+ refuseStaleIdiomReads(
2646
+ name,
2647
+ irBlocks,
2648
+ fill.staleIdiomValues,
2649
+ ssa,
2650
+ prototypes[name] ?? options.contextOwnProto?.proto,
2651
+ (m) => {
2652
+ throw new FrontendUnsupportedError(m);
2653
+ },
2654
+ fill.calls,
2655
+ declaresVoidReturn(prototypes[name]),
2656
+ );
2657
+ // After the fill, so a block's own refusal (the frame's, the operands') keeps its place in the
2658
+ // refusal order: the proof reads only the text and the CFG, and changes nothing.
2659
+ refuseReturnsNotThroughCallersLr(name, cfg);
2660
+ // The fill reads a `pop` as transparent, so a callee-saved register one fills with another
2661
+ // register's word is a write no op shows: refused here, after the return proof, which judges the
2662
+ // same words for lr and has its own answer for a pop that takes lr's.
2663
+ const [foreign] = fill.poppedForeign;
2664
+ if (foreign !== undefined) {
2665
+ const [reg, at] = foreign;
2666
+ throw new FrontendUnsupportedError(
2667
+ `cannot lift '${name}': '${at.mnemonic} ${at.ops.join(', ')}' loads a word that is not ${reg}'s own save ` +
2668
+ `into callee-saved ${reg}, and ${reg} reaches a return so, unrestored — the caller sees the write, which ` +
2669
+ 'no C statement spells',
2670
+ );
2671
+ }
2444
2672
 
2445
2673
  pairs.refuseWordReturns();
2446
2674
  // An argument register by its place in `argRegs`, an incoming stack word by its index after them.
2447
2675
  // One word per argument, which is what this frontend assumes everywhere: an 8-byte argument,
2448
2676
  // which AAPCS may align into r1 or straddle across r3 and the stack, would break the index-slot
2449
2677
  // correspondence, and recovering one is its own capability.
2450
- const slotOf = (key: string): number | null => {
2451
- const i = target.argRegs.indexOf(key);
2452
- return i >= 0 ? i : stackArgIndex(key);
2453
- };
2454
- const argSlots: ArgSlots = {
2455
- slotOf,
2456
- holes: (readKeys) =>
2457
- Array.from({ length: Math.max(0, ...readKeys.map((k) => slotOf(k) ?? -1)) }, (_, k) =>
2458
- k < target.argRegs.length ? target.argRegs[k] : stackArgKey(k),
2459
- ),
2460
- };
2678
+ const argSlots = stackArgSlots(registerArgSlots(target.argRegs), target.argRegs);
2461
2679
  mintArgSlotHoles(ssa, cfg.preds[0].length > 0, argSlots);
2462
2680
  ssa.finish();
2681
+ // A scratch register a `pop` fills with another word is a copy the fill never makes: `push {r1};
2682
+ // pop {r0}; bx lr` returns a1, and read by name it would lift `return a0`. Asked after the SSA's own
2683
+ // refusals, which name a register misused across its save before the copy that misuse makes. A
2684
+ // return hands r0 back unless the function's own declaration says it returns nothing.
2685
+ const declaredVoid = (prototypes[name] ?? options.contextOwnProto?.proto)?.returnsVoid === true;
2686
+ const [copy] = [...fill.poppedCopies, ...(declaredVoid ? [] : fill.poppedReturns)];
2687
+ if (copy !== undefined) {
2688
+ const [reg, at] = copy;
2689
+ throw new FrontendUnsupportedError(
2690
+ `cannot lift '${name}': '${at.mnemonic} ${at.ops.join(', ')}' loads a word that is not ${reg}'s own ` +
2691
+ `into ${reg}, which is then used as a value — a pop is read as restoring each register it names, ` +
2692
+ 'so the copy it makes is not modelled',
2693
+ );
2694
+ }
2695
+
2696
+ // A struct passed by value becomes the one object its words hold (call-plan.ts `foldByValueArgs`)
2697
+ // before the audit judges the frame objects, that one among them.
2698
+ foldByValueArgs(name, irBlocks, fill.calls, { symbols, context });
2699
+ nameMergedSretObjects(irBlocks);
2463
2700
 
2464
2701
  // Prove every `laddr` this function emitted really does name storage of this function's own, at
2465
2702
  // a shape the machine states, or decline (auditFrameObjects); an accepted function then gets the
@@ -2469,12 +2706,15 @@ function liftOnce(
2469
2706
  irBlocks,
2470
2707
  ...frame.partition,
2471
2708
  usedSlotOffsets: fill.frameUses.usedSlotOffsets,
2709
+ loadedWords: frame.loadedWords,
2472
2710
  capturedObjectIsTheWholeFrame: frame.capturedObjectIsTheWholeFrame,
2711
+ escapesReliedOn: frame.escapesReliedOn,
2473
2712
  movedCaptures: fill.frameUses.movedCaptures,
2474
2713
  returnsWithoutHiddenPointer: fill.calls.returnsWithoutHiddenPointer,
2475
2714
  symbols,
2476
2715
  target,
2477
2716
  oneObject,
2717
+ releasableHomes: frame.releasableHomes,
2478
2718
  });
2479
2719
  if (verdict !== undefined && 'oneObject' in verdict) {
2480
2720
  return verdict;
@@ -2482,21 +2722,31 @@ function liftOnce(
2482
2722
  if (verdict !== undefined) {
2483
2723
  pinDeviceAccesses(irBlocks, verdict, target, symbols);
2484
2724
  }
2485
- refuseSretOutsideFrame(name, irBlocks);
2725
+ refuseSretOutsideFrame(name, irBlocks, target);
2726
+ pinDeadReads(irBlocks, target, {
2727
+ name,
2728
+ own: prototypes[name],
2729
+ atWordReturn: pairs.unreadHigh,
2730
+ atUndeclaredCall: fill.calls.heldAtUndeclaredCall,
2731
+ compared: fill.flags.compared,
2732
+ spilled: ssa.frameWrites,
2733
+ declarations: { symbols, context },
2734
+ });
2486
2735
 
2487
2736
  abiSortEntryParams(irBlocks[0], cfg.preds[0].length > 0, paramReg, argSlots);
2488
2737
  fn.localObjects = statics.finish();
2489
2738
  return fn;
2490
2739
  }
2491
2740
 
2492
- /** THE TEXT A LIFT READS: the decoded blocks with every recovered jump table's dispatch elided,
2493
- * and the data its pool loads read. Every control transfer the CFG cannot model is refused by the
2494
- * time this exists. */
2741
+ /** THE TEXT A LIFT READS: the decoded blocks with every recovered jump table's dispatch elided
2742
+ * (or, where it carries code of its own, cut down to that code), and the data its pool loads
2743
+ * read. Every control transfer the CFG cannot model is refused by the time this exists. */
2495
2744
  interface ThumbText {
2496
2745
  /** The blocks the CFG is built from — read by `thumbCfg` alone; every later stage indexes
2497
2746
  * `ThumbCfg.asmBlocks`. */
2498
2747
  readonly blocks: readonly AsmBlock[];
2499
- /** A recovered jump table, by its bounds block. */
2748
+ /** A recovered jump table, by the block that emits its switch: the bounds block, or a kept
2749
+ * dispatch block. */
2500
2750
  readonly tables: ReadonlyMap<AsmBlock, JumpTable>;
2501
2751
  readonly dataWords: ReadonlyMap<string, string[]>;
2502
2752
  readonly nonWordData: ReadonlyMap<string, string>;
@@ -2505,14 +2755,319 @@ interface ThumbText {
2505
2755
  readonly poolNamesSymbols: boolean;
2506
2756
  }
2507
2757
 
2508
- function readThumbText(name: string, asm: string): ThumbText {
2758
+ // One spelling per register for the return proof below, which tracks registers by identity:
2759
+ // `r14` and `lr` are one register, and so are `ip` and `r12`.
2760
+ const REG_ALIAS: Readonly<Record<string, string>> = {
2761
+ sp: 'r13',
2762
+ lr: 'r14',
2763
+ pc: 'r15',
2764
+ sb: 'r9',
2765
+ sl: 'r10',
2766
+ fp: 'r11',
2767
+ ip: 'r12',
2768
+ };
2769
+ const canonReg = (r: string): string | null => {
2770
+ const t = r.replace(/!$/, '');
2771
+ return /^r(\d|1[0-5])$/.test(t) ? t : (REG_ALIAS[t] ?? null);
2772
+ };
2773
+ const regIndex = (r: string): number => Number(r.slice(1));
2774
+ /** A push/pop/ldm list in the order the machine moves it, canonical names (`canonReg`): by register
2775
+ * number, lowest address first. Null where a name is not a register. */
2776
+ const byRegNumber = (list: readonly string[]): string[] | null => {
2777
+ const regs = list.map(canonReg);
2778
+ return regs.some((r) => r === null) ? null : (regs as string[]).sort((x, y) => regIndex(x) - regIndex(y));
2779
+ };
2780
+ // Registers `ins` may write, besides sp and besides a `pop`'s list (the proof reads both itself).
2781
+ // OVER-approximate: naming a register here that the instruction does not write costs a decline,
2782
+ // and missing one costs a wrong return. 'all' for a call, and for anything this cannot read.
2783
+ const NO_REG_WRITE: ReadonlySet<string> = new Set([
2784
+ 'str',
2785
+ 'strb',
2786
+ 'strh',
2787
+ 'push',
2788
+ 'cmp',
2789
+ 'cmn',
2790
+ 'tst',
2791
+ 'nop',
2792
+ 'b',
2793
+ 'bx',
2794
+ ]);
2795
+ function regWritesOf(ins: Instr): readonly string[] | 'all' {
2796
+ const mn = ins.mnemonic;
2797
+ if (NO_REG_WRITE.has(mn) || COND_OPCODE[mn]) {
2798
+ return [];
2799
+ }
2800
+ if (mn === 'bl' || mn === 'blx' || mn === 'swi') {
2801
+ return 'all';
2802
+ }
2803
+ if (/^(ldm|stm)/.test(mn)) {
2804
+ const base = canonReg(ins.ops[0] ?? '');
2805
+ const list = /^ldm/.test(mn) ? regListOf(ins.ops.slice(1)) : [];
2806
+ if (base === null || list === null) {
2807
+ return 'all';
2808
+ }
2809
+ const listed = list.map(canonReg);
2810
+ return listed.some((r) => r === null) ? 'all' : [base, ...(listed as string[])];
2811
+ }
2812
+ const dest = canonReg(ins.ops[0] ?? '');
2813
+ return dest === null ? 'all' : [dest];
2814
+ }
2815
+
2816
+ /** What the return proof knows at one point of the function: the frame depth `makeFrameWalk`
2817
+ * measures, which word of the frame holds the caller's lr (`slot`, as the depth at which it sits:
2818
+ * the word at entry sp − slot), and which registers hold it. */
2819
+ interface LrState {
2820
+ readonly depth: number | null;
2821
+ readonly slot: number | null;
2822
+ readonly regs: ReadonlySet<string>;
2823
+ }
2824
+
2825
+ /** EVERY RETURN OF THIS FUNCTION RETURNS THROUGH THE CALLER'S lr, or the lift declines.
2826
+ *
2827
+ * `classifyXfer` reads `bx rN`, `pop {…, pc}` and `mov pc, lr` as returns from the instruction
2828
+ * alone, and each is one only when the word it transfers to is the lr this function was called
2829
+ * with: in lr itself, in the frame word the prologue's `push` saved it to, or in a register a
2830
+ * `pop` loaded from that word. Which word a `pop` reads is a question about the FRAME, not about
2831
+ * register names — a `pop` fills its list in register-number order from the lowest address up —
2832
+ * so this walks the frame (`makeFrameWalk`) over the CFG and tracks where the caller's lr is:
2833
+ * • a `push` that saves a register holding it records the word it went to;
2834
+ * • a `pop` whose k-th register (by number) reads that word puts it in that register, and every
2835
+ * other register it fills loses it;
2836
+ * • the word stops holding it when sp rises past it (a `pop` or `add sp` releases it), a
2837
+ * `str` through sp overwrites it, or the depth stops being one number;
2838
+ * • a register stops holding it when anything writes the register (a call writes them all);
2839
+ * a `mov` copies it.
2840
+ * Paths meet by agreement: a depth or a word the predecessors disagree on is unknown, and a
2841
+ * register holds lr only if it does on every path in.
2842
+ *
2843
+ * So `push {r4, lr}; pop {r0}; bx r0` (the saved r4), `push {lr}; sub sp, #8; pop {r1}; bx r1` (a
2844
+ * local), `pop {r4, r1}` after `push {r4, lr}` (r1 is filled first, from r4's word) and
2845
+ * `mov r0, #7; bx r3` all decline, where agbcc's `pop {r4}; pop {r0}; bx r0` and its variadic
2846
+ * `push {r0-r3}; push {lr}; … pop {r3}; add sp, sp, #0x10; bx r3` are returns. It is also what the
2847
+ * far-jump relay's lowering needs and does not itself prove (`lowerFarJumpRelays`): with every
2848
+ * return reading the CALLER's lr, the lr a relay writes cannot be what a return jumps to.
2849
+ *
2850
+ * `bx lr` and `mov pc, lr` are asked the same question: lr itself must still hold the caller's lr,
2851
+ * so an instruction that writes lr by name before one (`mov lr, r4`, `pop {lr}`, `ldr lr, …`)
2852
+ * declines. Read by the register's name instead, `push {r4, lr}; ldr r4, =g; mov lr, r4; pop {r4};
2853
+ * bx lr` — a tail jump to g with every argument register live — would lift as a return, and a
2854
+ * caller whose call to it no declaration lays out would take that lift's one parameter as the
2855
+ * callee's whole arity (frontend/callee-body.ts). A compiler returns through the word it saved lr in, so no
2856
+ * compiled function reaches the refusal.
2857
+ *
2858
+ * NOT ASKED: a CALL's write of lr. `bl g; bx lr` returns to the instruction after the call on the
2859
+ * hardware, and lifts as `g(); return;`. No compiler emits it (one saves lr before it calls), and
2860
+ * many call tests' fixtures are written so, so a call leaves lr held. The far-jump relay leans on
2861
+ * neither: its lowering declines any function in which an instruction other than the entry `push`
2862
+ * names lr.
2863
+ *
2864
+ * Blocks no path from the entry reaches are not asked: they never run. */
2865
+ function refuseReturnsNotThroughCallersLr(name: string, cfg: ThumbCfg): void {
2866
+ const { asmBlocks, succs, entryReachable } = cfg;
2867
+ const LR = 'r14';
2868
+ const join = (a: LrState, b: LrState): LrState => {
2869
+ const depth = a.depth !== null && a.depth === b.depth ? a.depth : null;
2870
+ return {
2871
+ depth,
2872
+ slot: depth !== null && a.slot !== null && a.slot === b.slot ? a.slot : null,
2873
+ regs: new Set([...a.regs].filter((r) => b.regs.has(r))),
2874
+ };
2875
+ };
2876
+ const same = (a: LrState, b: LrState) =>
2877
+ a.depth === b.depth && a.slot === b.slot && a.regs.size === b.regs.size && [...a.regs].every((r) => b.regs.has(r));
2878
+ const refuse = (ins: Instr): never => {
2879
+ throw new FrontendUnsupportedError(
2880
+ `cannot lift '${name}': indirect/computed jump '${ins.mnemonic} ${ins.ops.join(', ')}' is not a return ` +
2881
+ `through the caller's lr — on some path what it jumps to is not lr, nor the frame word the prologue ` +
2882
+ `saved lr in — jump tables / computed gotos / register tail calls not supported`,
2883
+ );
2884
+ };
2885
+
2886
+ // Run one block from its in-state, asking each return as it is reached; the out-state.
2887
+ const run = (b: number, inState: LrState): LrState => {
2888
+ const walk = makeFrameWalk({ argRegs: [], start: inState.depth });
2889
+ let slot = inState.slot;
2890
+ const regs = new Set(inState.regs);
2891
+ for (const ins of asmBlocks[b].instrs) {
2892
+ const mn = ins.mnemonic;
2893
+ const depth = walk.depthNow();
2894
+ const xfer = classifyXfer(ins);
2895
+ const isPop =
2896
+ mn === 'pop' || ((mn === 'ldmia' || mn === 'ldmfd') && isSpReg((ins.ops[0] ?? '').replace(/!$/, '')));
2897
+ if (isPop) {
2898
+ const list = byRegNumber(regListOf(mn === 'pop' ? ins.ops : ins.ops.slice(1)) ?? []);
2899
+ if (list === null || list.length === 0) {
2900
+ if (xfer === 'return') {
2901
+ refuse(ins);
2902
+ }
2903
+ regs.clear();
2904
+ } else {
2905
+ list.forEach((r, k) => {
2906
+ if (depth !== null && slot !== null && depth - 4 * k === slot) {
2907
+ regs.add(r);
2908
+ } else {
2909
+ regs.delete(r);
2910
+ }
2911
+ });
2912
+ if (xfer === 'return' && !regs.has('r15')) {
2913
+ refuse(ins); // `pop {…, pc}` that does not load pc from the saved lr's word
2914
+ }
2915
+ }
2916
+ } else if (xfer === 'return') {
2917
+ // `bx rN`, `bx lr` and `mov pc, lr` alike: the register must still hold the caller's lr.
2918
+ const via = canonReg(mn === 'bx' ? (ins.ops[0] ?? '') : (ins.ops[1] ?? ''));
2919
+ if (via === null || !regs.has(via)) {
2920
+ refuse(ins);
2921
+ }
2922
+ } else if (mn === 'push') {
2923
+ const list = byRegNumber(regListOf(ins.ops) ?? []);
2924
+ const k = list?.findIndex((r) => regs.has(r)) ?? -1;
2925
+ if (list !== null && k >= 0 && depth !== null) {
2926
+ slot = depth + 4 * list.length - 4 * k;
2927
+ }
2928
+ } else if (/^str[bh]?$/.test(mn)) {
2929
+ const { base, off, regOff } = parseAddr(ins.ops[1] ?? '');
2930
+ if (isSpReg(base) && slot !== null) {
2931
+ const width = mn === 'strb' ? 1 : mn === 'strh' ? 2 : 4;
2932
+ const at = depth === null || regOff !== undefined ? null : depth - off; // the word's depth
2933
+ if (at === null || (at - width < slot && at > slot - 4)) {
2934
+ slot = null;
2935
+ }
2936
+ }
2937
+ } else if (!isSpReg(ins.ops[0])) {
2938
+ const writes = regWritesOf(ins);
2939
+ if (writes === 'all') {
2940
+ // a call's own write of lr is not asked (see the doc comment)
2941
+ const kept = /^(bl|blx|swi)$/.test(mn) && regs.has(LR);
2942
+ regs.clear();
2943
+ if (kept) {
2944
+ regs.add(LR);
2945
+ }
2946
+ } else {
2947
+ const src = /^movs?$/.test(mn) ? canonReg(ins.ops[1] ?? '') : null;
2948
+ const copies = src !== null && regs.has(src);
2949
+ writes.forEach((r) => regs.delete(r));
2950
+ if (copies) {
2951
+ regs.add(writes[0]);
2952
+ }
2953
+ }
2954
+ }
2955
+ walk.step(ins);
2956
+ const after = walk.depthNow();
2957
+ if (slot !== null && (after === null || slot > after)) {
2958
+ slot = null; // released, or the frame is no longer one number
2959
+ }
2960
+ }
2961
+ return { depth: walk.depthNow(), slot, regs };
2962
+ };
2963
+
2964
+ const inStates = new Map<number, LrState>([[0, { depth: 0, slot: null, regs: new Set([LR]) }]]);
2965
+ const work = [0];
2966
+ while (work.length > 0) {
2967
+ const b = work.pop()!;
2968
+ const out = run(b, inStates.get(b)!);
2969
+ for (const s of succs[b]) {
2970
+ if (!entryReachable.has(s)) {
2971
+ continue;
2972
+ }
2973
+ const prev = inStates.get(s);
2974
+ const next = prev === undefined ? out : join(prev, out);
2975
+ if (prev === undefined || !same(prev, next)) {
2976
+ inStates.set(s, next);
2977
+ work.push(s);
2978
+ }
2979
+ }
2980
+ }
2981
+ }
2982
+
2983
+ /** Lower every far-jump relay the target marks (`bl .Lx @far jump`, agbcc's; see
2984
+ * `compilerBehaviors.farJumpRelay`) in the selected function to the `b .Lx` it stands for, in
2985
+ * place, or decline. Only a target that declares the mark gets here with one: `decode` sets
2986
+ * `farJump` from that declaration alone.
2987
+ *
2988
+ * A relay and a call differ in one effect: `bl` writes lr, `b` does not. So the lowering is sound
2989
+ * exactly when nothing can observe the lr the relay wrote. What the mark MEANS is the compiler's
2990
+ * fact, declared with it. That nothing observes lr is checked here on the function's own text,
2991
+ * not assumed from agbcc's habit (thumb.c `far_jump_used_p` makes it push lr in any function with
2992
+ * a far jump, which is why every relay in practice passes):
2993
+ * • the entry block saves lr with a `push`, and no branch of this function names the entry
2994
+ * label, so that push runs once, before any relay, and saves the CALLER's lr. A recovered
2995
+ * jump table is the other way back to the entry, and `readThumbText` asks that once the
2996
+ * tables exist (`relayed`). A `.word` naming the entry is not one: `dataWords` is the whole
2997
+ * file's, and a function stored as a callback in another function's pool is a pointer;
2998
+ * • no other instruction of the function names lr at all — no `bx lr`, no `mov pc, lr`, no
2999
+ * `mov rX, lr`, no second `push`. So the lr the relay writes never reaches a register, and no
3000
+ * code can return to it: `b` dropping that write is unobservable.
3001
+ * That every RETURN jumps through the caller's lr — not through a word the relay's lr could have
3002
+ * reached, nor through a saved callee register — is the other half of the argument, and it is
3003
+ * asked of every Thumb function once the CFG exists (`refuseReturnsNotThroughCallersLr`), where
3004
+ * the frame walk says which word each `pop` reads.
3005
+ * The mark itself is the only thing that makes a `bl` a relay: a call thunk (`bl call_r3`, in a
3006
+ * function that pushes lr too) has the same shape and no mark, and declines below.
3007
+ *
3008
+ * The relay must also target a code label of THIS function that is not a declared function start:
3009
+ * agbcc only relays within a function, and a marked `bl` anywhere else is not its relay. */
3010
+ function lowerFarJumpRelays(name: string, blocks: readonly AsmBlock[], funcLabels: ReadonlySet<string>): Instr | null {
3011
+ const instrs = blocks.flatMap((b) => b.instrs);
3012
+ const relays = instrs.filter((ins) => ins.farJump);
3013
+ if (relays.length === 0) {
3014
+ return null;
3015
+ }
3016
+ const blockLabels = new Set(blocks.map((b) => b.label));
3017
+ // The compiler is the target's declaration, not a name this function knows: "a far-jump relay".
3018
+ const say = (ins: Instr) => `'${ins.asWritten ?? ins.mnemonic} ${ins.ops.join(', ')}' (a far-jump relay)`;
3019
+ const refuse = (ins: Instr, why: string) => {
3020
+ throw new FrontendUnsupportedError(
3021
+ `cannot lift '${name}': ${say(ins)} ${why} — lowering it to a branch would drop the lr write a \`bl\` makes`,
3022
+ );
3023
+ };
3024
+ for (const relay of relays) {
3025
+ const target = relay.ops[0];
3026
+ if (!blockLabels.has(target) || funcLabels.has(target)) {
3027
+ throw new FrontendUnsupportedError(
3028
+ `cannot lift '${name}': ${say(relay)} does not target a code label of this function`,
3029
+ );
3030
+ }
3031
+ }
3032
+ const namesLr = (ins: Instr) => mentionsReg(ins, 'lr') || mentionsReg(ins, 'r14');
3033
+ const entry = blocks[0];
3034
+ const save = entry?.instrs.find(namesLr);
3035
+ if (!save || save.mnemonic !== 'push' || !(regListOf(save.ops) ?? []).some((r) => r === 'lr' || r === 'r14')) {
3036
+ refuse(relays[0], 'is in a function whose entry block does not save lr with a `push`');
3037
+ }
3038
+ if (instrs.some((ins) => classifyXfer(ins) !== null && ins.ops.includes(entry.label))) {
3039
+ refuse(relays[0], `is in a function that branches back to its entry '${entry.label}', past the lr save`);
3040
+ }
3041
+ const reader = instrs.find((ins) => ins !== save && namesLr(ins));
3042
+ if (reader) {
3043
+ refuse(relays[0], `is in a function that names lr after saving it ('${reader.mnemonic} ${reader.ops.join(', ')}')`);
3044
+ }
3045
+ for (const relay of relays) {
3046
+ relay.mnemonic = 'b';
3047
+ relay.asWritten = 'bl';
3048
+ }
3049
+ return relays[0];
3050
+ }
3051
+
3052
+ /** `farJumpMark` is the target's `compilerBehaviors.farJumpRelay` comment; absent, no `bl` is a
3053
+ * relay, and one to a label of this function declines below. */
3054
+ function readThumbText(name: string, asm: string, farJumpMark?: RegExp): ThumbText {
2509
3055
  assertInputFormat('thumb', 'gnu-as', asm);
2510
- const { blocks: rawBlocks, dataWords, nonWordData, funcLabels } = decode(name, asm);
3056
+ const { blocks: rawBlocks, dataWords, nonWordData, funcLabels, aliasOf } = decode(name, asm, farJumpMark);
3057
+ // Everything below keys a register by its spelling, so a function that writes one two ways
3058
+ // (`sl` and `r10`) is read in one of them.
3059
+ unifyRegisterSpellings(
3060
+ rawBlocks.flatMap((b) => b.instrs),
3061
+ ARM_REGISTER_ALIASES,
3062
+ );
3063
+ // Before anything below reads a `b`: the jump-table recovery counts branch references, and a
3064
+ // relay is one.
3065
+ const relayed = lowerFarJumpRelays(name, rawBlocks, funcLabels);
2511
3066
 
2512
3067
  // Regime B: recover agbcc jump tables. A dispatch block (`mov pc, rN`) plus its bounds
2513
3068
  // predecessor (`cmp; bhi DEF`) collapse into a `switch_br` emitted from the BOUNDS block; the
2514
- // dispatch block is ELIDED from the CFG. A `mov pc` that is NOT a recognised table falls through
2515
- // to the loud-fail below.
3069
+ // dispatch block is ELIDED from the CFG — or kept, when it carries code (below). A `mov pc` that
3070
+ // is NOT a recognised table falls through to the loud-fail below.
2516
3071
  const blockLabels = new Set(rawBlocks.map((b) => b.label));
2517
3072
  const poolNamesSymbols = poolNamesASymbol(dataWords, nonWordData, blockLabels);
2518
3073
  // Any label referenced as a branch target (so we can tell if an elided dispatch block has a SECOND
@@ -2528,8 +3083,25 @@ function readThumbText(name: string, asm: string): ThumbText {
2528
3083
  }
2529
3084
  }
2530
3085
  }
2531
- const tables = new Map<AsmBlock, JumpTable>(); // bounds block → recovered table
2532
- const elided = new Set<AsmBlock>(); // dispatch (and long-jump default) blocks removed from the CFG
3086
+ const tables = new Map<AsmBlock, JumpTable>(); // the block emitting the switch → recovered table
3087
+ const elided = new Set<AsmBlock>(); // dispatch blocks removed from the CFG
3088
+ const relays = new Set<AsmBlock>(); // long-jump `b DEF` blocks removed with an elided dispatch
3089
+ // A dispatch carrying code of its own (`JumpTable.extras`) is not elided: it stays as the block the
3090
+ // bounds test leads to, holding only that code, and the switch is emitted from it. The bounds test
3091
+ // and the long-jump `b DEF` keep their ordinary edges, so the code runs on the dispatched path and
3092
+ // on no other.
3093
+ const kept = new Map<AsmBlock, AsmBlock>();
3094
+ const keepDispatch = (d: AsmBlock, jt: JumpTable): boolean => {
3095
+ if (jt.extras.length === 0) {
3096
+ return false;
3097
+ }
3098
+ const own = { ...d, instrs: jt.extras };
3099
+ kept.set(d, own);
3100
+ tables.set(own, jt);
3101
+ return true;
3102
+ };
3103
+ // Why a dispatch's table was recognised and then refused, for the indirect-jump decline below.
3104
+ const tableRefusals = new Map<AsmBlock, string>();
2533
3105
  rawBlocks.forEach((d, i) => {
2534
3106
  const last = d.instrs[d.instrs.length - 1];
2535
3107
  if (!last || last.mnemonic !== 'mov' || last.ops[0] !== 'pc' || last.ops[1] === 'lr') {
@@ -2540,7 +3112,12 @@ function readThumbText(name: string, asm: string): ThumbText {
2540
3112
  // Direct form: the dispatch is reached ONLY by falling through from its bounds predecessor. A
2541
3113
  // `b disp` from anywhere else would leave a dangling edge after elision, so decline (→ loud-fail).
2542
3114
  if (prev && refs === 0) {
2543
- const jt = recoverJumpTable(prev, d, dataWords, nonWordData, blockLabels);
3115
+ const jt = recoverJumpTable(prev, d, dataWords, nonWordData, blockLabels, aliasOf, (why) =>
3116
+ tableRefusals.set(d, why),
3117
+ );
3118
+ if (jt && keepDispatch(d, jt)) {
3119
+ return;
3120
+ }
2544
3121
  if (jt) {
2545
3122
  tables.set(prev, jt);
2546
3123
  elided.add(d);
@@ -2548,29 +3125,71 @@ function readThumbText(name: string, asm: string): ThumbText {
2548
3125
  }
2549
3126
  }
2550
3127
  // Long-jump form: `bounds` (cmp; bls DISP), then a lone `b DEF` block, then the dispatch. The
2551
- // dispatch is entered by exactly that one `bls` and nothing else, and the `b DEF` block —
2552
- // synthetically labelled, so unnameable and unreachable once the bounds block dispatches — is
2553
- // elided WITH it. Leaving it would make it a parameterless predecessor of the default block,
2554
- // and wiring a phi through it fabricates an entry parameter (the phantom-param miscompile).
3128
+ // dispatch is entered by exactly that one `bls` and nothing else. When the dispatch is elided,
3129
+ // the `b DEF` block — named by no branch, so unreachable once the bounds block dispatches — is
3130
+ // elided WITH it unless a table entry names it. Leaving an unreachable one would make it a
3131
+ // parameterless predecessor of the default block, and wiring a phi through it fabricates an
3132
+ // entry parameter (the phantom-param miscompile). A kept dispatch keeps it as the bounds test's
3133
+ // fall-through.
2555
3134
  const boundsB = rawBlocks[i - 2];
2556
3135
  const prevNamed = prev ? (branchRefs.get(prev.label) ?? 0) > 0 : false;
2557
3136
  if (refs === 1 && prev && boundsB && !prevNamed && prev.instrs.length === 1 && prev.instrs[0].mnemonic === 'b') {
2558
- const jt = recoverJumpTable(boundsB, d, dataWords, nonWordData, blockLabels, prev.instrs[0].ops[0]);
2559
- if (jt) {
3137
+ const jt = recoverJumpTable(
3138
+ boundsB,
3139
+ d,
3140
+ dataWords,
3141
+ nonWordData,
3142
+ blockLabels,
3143
+ aliasOf,
3144
+ (why) => tableRefusals.set(d, why),
3145
+ prev.instrs[0].ops[0],
3146
+ );
3147
+ if (jt && !keepDispatch(d, jt)) {
2560
3148
  tables.set(boundsB, jt);
2561
3149
  elided.add(d);
2562
- elided.add(prev);
3150
+ relays.add(prev);
2563
3151
  }
2564
3152
  }
2565
3153
  });
2566
- const asmBlocks = rawBlocks.filter((b) => !elided.has(b));
3154
+ // A table entry naming a relay is an edge to the default through it, so that relay stays: it is
3155
+ // reachable, and the kept form's relay is the same block.
3156
+ const named = new Set([...tables.values()].flatMap((t) => [...t.caseLabels, t.defaultLabel]));
3157
+ const asmBlocks = rawBlocks
3158
+ .filter((b) => !elided.has(b) && !(relays.has(b) && !named.has(b.label)))
3159
+ .map((b) => kept.get(b) ?? b);
3160
+ // The branch counts above vouch that a recovered dispatch is entered only from its bounds test, but
3161
+ // a TABLE entry is an edge too, and no table existed while they were counted — the rule MIPS and
3162
+ // PowerPC check on their built CFG (jump-table.ts `refuseReachableDispatch`). An entry naming a
3163
+ // dispatch block jumps through the code address the idiom loaded; a kept dispatch would instead
3164
+ // switch again on the unscaled scrutinee, and an elided one names no block at all. Both decline.
3165
+ const dispatchLabels = new Set([...elided, ...kept.keys()].map((b) => b.label));
3166
+ for (const [at, t] of tables) {
3167
+ const entered = [...t.caseLabels, t.defaultLabel].find((l) => dispatchLabels.has(l));
3168
+ if (entered !== undefined) {
3169
+ const movpc = rawBlocks.find((b) => b.label === entered)!.instrs.at(-1)!;
3170
+ throw new FrontendUnsupportedError(
3171
+ `cannot lift '${name}': the recovered jump table's '${movpc.mnemonic} ${movpc.ops.join(', ')}' at '${entered}' ` +
3172
+ `is still reachable — a case of the table dispatched at '${at.label}' enters its dispatch block past the bounds check`,
3173
+ );
3174
+ }
3175
+ }
3176
+ // The far-jump lowering's last question, askable only now: a recovered table dispatching to the
3177
+ // entry would re-run the lr save after a relay clobbered lr (see `lowerFarJumpRelays`).
3178
+ const entryLabel = rawBlocks[0]?.label;
3179
+ if (relayed && [...tables.values()].some((t) => [...t.caseLabels, t.defaultLabel].includes(entryLabel))) {
3180
+ throw new FrontendUnsupportedError(
3181
+ `cannot lift '${name}': '${relayed.asWritten} ${relayed.ops.join(', ')}' (a far-jump relay) is in a function ` +
3182
+ `whose jump table dispatches back to its entry '${entryLabel}', past the lr save — lowering it to a branch ` +
3183
+ `would drop the lr write a \`bl\` makes`,
3184
+ );
3185
+ }
2567
3186
 
2568
3187
  // TRUSTWORTHINESS: loud-fail on a control transfer this frontend cannot model, rather than
2569
3188
  // silently dropping it. A computed/loaded PC write (`mov pc, rN`, `ldr pc, …`, `add/sub pc`,
2570
3189
  // `ldmia rN!,{…,pc}` with rN≠sp) is a jump table / computed goto / register tail call — decode
2571
3190
  // ends the block at it, but it has no static successor, so it must be a catchable "out of scope"
2572
3191
  // signal, not a vanished branch. Mirrors MIPS `jr`/PPC `bctr`. (A RECOGNISED jump table's
2573
- // dispatch block is already elided above, so it is not scanned here.)
3192
+ // dispatch block is already elided or cut to its extras above, so it is not scanned here.)
2574
3193
  //
2575
3194
  // The same rule for a `bl` whose target is inside this function's own text. agbcc relays a
2576
3195
  // conditional branch past Thumb's ±256-byte reach through an unconditional one, and past THAT
@@ -2579,13 +3198,14 @@ function readThumbText(name: string, asm: string): ThumbText {
2579
3198
  // lift emits a call to a block label (`.L3(a0, a1, a2)`): not a branch that vanished but a
2580
3199
  // transfer turned into something that RETURNS, and syntactically not C.
2581
3200
  //
2582
- // The refusal is wider than that one shape because the asm does not separate it from a
3201
+ // A relay agbcc MARKED (`@far jump`) never reaches here: `lowerFarJumpRelays` has already lowered
3202
+ // it to `b`, behind a proof that lr is dead. What is left is a `bl` to a label of this function
3203
+ // with no mark, and the shape does not separate a relay whose comment a splitter dropped from a
2583
3204
  // locally-defined call thunk — `call_r3: bx r3`, ARMv4T's stand-in for the `blx rN` it has no
2584
3205
  // encoding for, which pokeemerald's m4a_1.s calls four times from inside MPlayMain's own slice.
2585
3206
  // Both are a `bl` to a bare label the slice defines, both sit under a conditional branch that
2586
- // skips them, and both leave `lr` clobbered, so no liveness or layout fact tells them apart.
2587
- // Lifting the relay as a `br` is the other half of the gap and needs the same distinction plus a
2588
- // proof that `lr` is dead there, since `bl` overwrites it and a branch must not.
3207
+ // skips them, both leave `lr` clobbered, and the thunk's function pushes lr too (`push {r0, lr}`),
3208
+ // so no liveness, layout or prologue fact tells them apart. Only the mark does.
2589
3209
  //
2590
3210
  // DECLARED function starts are excluded: a `.thumb_func`/`thumb_func_start` label is a function
2591
3211
  // by declaration, so `bl` to one is a call however the slice came to contain it — the entry
@@ -2602,9 +3222,12 @@ function readThumbText(name: string, asm: string): ThumbText {
2602
3222
  );
2603
3223
  }
2604
3224
  if (classifyXfer(ins) === 'indirect') {
3225
+ const why = tableRefusals.get(ab);
3226
+ const detail =
3227
+ why === undefined ? '' : ` — this one is a jump table whose dispatch carries code it refuses: ${why}`;
2605
3228
  throw new FrontendUnsupportedError(
2606
3229
  `cannot lift '${name}': indirect/computed jump '${ins.mnemonic} ${ins.ops.join(', ')}' ` +
2607
- `— jump tables / computed gotos / register tail calls not supported`,
3230
+ `— jump tables / computed gotos / register tail calls not supported${detail}`,
2608
3231
  );
2609
3232
  }
2610
3233
  const callee = (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') && ins.ops.length === 1 ? ins.ops[0] : undefined;
@@ -2628,6 +3251,8 @@ interface ThumbCfg {
2628
3251
  readonly labelIndex: ReadonlyMap<string, number>;
2629
3252
  /** Each block's predecessors, one entry per edge. */
2630
3253
  readonly preds: number[][];
3254
+ /** Each block's successors, `preds` turned around. */
3255
+ readonly succs: readonly (readonly number[])[];
2631
3256
  readonly entryReachable: ReadonlySet<number>;
2632
3257
  }
2633
3258
 
@@ -2670,7 +3295,9 @@ function thumbCfg(name: string, blocks: readonly AsmBlock[], tables: ReadonlyMap
2670
3295
  }
2671
3296
  return live;
2672
3297
  })();
2673
- return { asmBlocks, labelIndex, preds, entryReachable };
3298
+ const succs: number[][] = asmBlocks.map(() => []);
3299
+ preds.forEach((ps, at) => ps.forEach((p) => succs[p].push(at)));
3300
+ return { asmBlocks, labelIndex, preds, succs, entryReachable };
2674
3301
  }
2675
3302
 
2676
3303
  // --- CFG (successors per block) as label lists; fallthrough + branch targets (via classifyXfer) ---
@@ -2682,8 +3309,9 @@ function buildCfg(
2682
3309
  const labelIndex = new Map<string, number>();
2683
3310
  blocks.forEach((b, i) => labelIndex.set(b.label, i));
2684
3311
  const succLabels: string[][] = blocks.map((b, i) => {
2685
- // A recovered jump-table BOUNDS block dispatches to its case blocks + default (the elided
2686
- // dispatch block's targets); its `bhi`/fall-through successors are replaced entirely.
3312
+ // The block emitting a recovered table's switch dispatches to its case blocks + default. For a
3313
+ // BOUNDS block (the dispatch elided) that replaces its `bhi`/fall-through successors entirely; a
3314
+ // kept dispatch block had no static successor of its own.
2687
3315
  const jt = tables.get(b);
2688
3316
  if (jt) {
2689
3317
  return [...jt.caseLabels, jt.defaultLabel];
@@ -2719,19 +3347,6 @@ function buildCfg(
2719
3347
  return { labelIndex, preds };
2720
3348
  }
2721
3349
 
2722
- // A `scratchRegs` entry outside `nonArgRegs` is inert, and inert is how a partition rots: the
2723
- // list would go on reading as if it exempted something. Refused as a target bug, like the
2724
- // argRegs/uninitRegs contradiction, rather than declined as a property of the input.
2725
- function assertScratchRegsPartitioned(target: TargetDescription): ReadonlySet<string> {
2726
- const scratchRegs = new Set(target.scratchRegs ?? []);
2727
- for (const r of scratchRegs) {
2728
- if (!(target.nonArgRegs ?? []).includes(r)) {
2729
- throw new Error(`target '${target.id}': scratch register ${r} is not among the non-argument registers`);
2730
- }
2731
- }
2732
- return scratchRegs;
2733
- }
2734
-
2735
3350
  /** A `bl` passes a pair in two argument words, a struct-return pointer in r0, and the words past r3
2736
3351
  * in the outgoing block `analyzeOutgoingArgs` licensed; a call through rN passes r0..r(N-1). */
2737
3352
  const THUMB_CALL_LOWERING: CallLowering = {
@@ -2746,16 +3361,39 @@ const THUMB_CALL_LOWERING: CallLowering = {
2746
3361
  interface ThumbFrame {
2747
3362
  readonly localArea: number;
2748
3363
  readonly savedRegs: ReadonlySet<string>;
3364
+ /** the prologue's `mov rLow, rHi` that only stage a high register for its `push` (see
3365
+ * `prologueSavedRegs`); the fill does not lift them */
3366
+ readonly saveCarriers: ReadonlySet<Instr>;
2749
3367
  readonly capturedObjectIsTheWholeFrame: boolean;
3368
+ /** the offsets of the escaped frame addresses the outgoing-argument verdict leans on, which the
3369
+ * audit re-proves of the lifted IR; empty where the verdict leans on none */
3370
+ readonly escapesReliedOn: ReadonlySet<number>;
3371
+ /** the calls that hand out an escape the verdict leans on; empty where it leans on none */
3372
+ readonly callsReliedOn: ReadonlySet<Instr>;
2750
3373
  /** the frame offset each instruction leaves a register holding, where `heldFrameWalk` follows it */
2751
3374
  readonly captureOffsetOf: ReadonlyMap<Instr, number>;
3375
+ /** at each call, the frame addresses its argument registers may and must hold (`frameAddressesAtCalls`) */
3376
+ readonly frameAddressesAtCall: ReadonlyMap<Instr, FrameAddressesAtCall>;
2752
3377
  readonly outgoingArgs: OutgoingArgs<Instr>;
3378
+ /** the loaded-word bound both `outgoingArgs` and the frame-object audit take
3379
+ * (`FrameObjectAudit.loadedWords`); absent where the target states no start offset */
3380
+ readonly loadedWords: FrameObjectAudit['loadedWords'];
2753
3381
  /** why the word-slot model is off, or null when it is on (`slotModelBlocker`) */
2754
3382
  readonly slotsOffReason: string | null;
2755
3383
  readonly slotsOk: boolean;
3384
+ /** the frame depth at every instruction outside the entry block, or null where it is not proven
3385
+ * one number (`frameDepthBlocker`) */
3386
+ readonly settledDepth: number | null;
2756
3387
  readonly partition: Readonly<Pick<FrameObjectAudit, 'ownedLocals' | 'declaredLocals'>>;
2757
3388
  isFrameObjectAccess(base: string, off: number, regOff: string | undefined, width: number): boolean;
2758
3389
  isOwnFrameWordSlot(base: string, off: number, regOff: string | undefined, width: number): boolean;
3390
+ /** the word slot each narrow reload through a copy of sp reads the low-order part of
3391
+ * (`narrowSlotReloads`), and the copies those reloads consume, which name no object */
3392
+ readonly narrowReloadOf: ReadonlyMap<Instr, number>;
3393
+ readonly narrowReloadCopies: ReadonlySet<Instr>;
3394
+ /** the argument homes whose narrow reloads were kept as captures for the one-object answer alone
3395
+ * (`argumentHomes`), which the audit may ask to release (`FrameObjectAudit.releasableHomes`) */
3396
+ readonly releasableHomes: ReadonlySet<number>;
2759
3397
  }
2760
3398
 
2761
3399
  /** The frame offset of each word of a declared call's outgoing block, or null where it stages none.
@@ -2775,7 +3413,7 @@ interface ThumbFrame {
2775
3413
  *
2776
3414
  * Null for an arity that fits in registers, where there IS no outgoing block. */
2777
3415
  function outgoingBlock(widths: readonly number[], target: TargetDescription): number[] | null {
2778
- const words = wordsOf(widths) - target.argRegs.length;
3416
+ const words = wordsOf(widths, target) - target.argRegs.length;
2779
3417
  return words > 0 && target.compilerBehaviors.stagesOutgoingArgsInFrame === true
2780
3418
  ? Array.from({ length: words }, (_, i) => 4 * i)
2781
3419
  : null;
@@ -2799,15 +3437,33 @@ function calleeRegister(ins: Instr, target: TargetDescription): string | null |
2799
3437
  }
2800
3438
 
2801
3439
  function measureThumbFrame({
3440
+ name,
2802
3441
  target,
2803
- cfg: { asmBlocks, preds, entryReachable },
3442
+ cfg: { asmBlocks, preds, succs, entryReachable },
2804
3443
  calls,
2805
3444
  oneObject,
3445
+ homesReleased = false,
3446
+ own,
3447
+ dropsVoidReturn,
3448
+ stackWordsOf,
2806
3449
  }: {
3450
+ name: string;
2807
3451
  target: TargetDescription;
2808
3452
  cfg: ThumbCfg;
2809
3453
  calls: CallDeclarations;
2810
3454
  oneObject: FrameRange | undefined;
3455
+ /** whether a first audit released the argument homes to the slot model (`releasableHomes`) */
3456
+ homesReleased?: boolean;
3457
+ /** the function's own declaration (`ownDeclarationOf`), read only where it `proves` */
3458
+ own: OwnDeclaration | undefined;
3459
+ /** whether the lift's `ret` reads nothing: a STATED void entry (`prototypes[name]`), the one
3460
+ * pipeline.ts `raiseRecovered` drops ret operands for. The context's own entry, proving or not,
3461
+ * leaves the operand in place. */
3462
+ dropsVoidReturn: boolean;
3463
+ /** the incoming stack words a callee nothing declares loads, off its own body
3464
+ * (`LiftOptions.calleeStackWords`): the outgoing-argument analysis's `bodyReads`, which asks it
3465
+ * only where the code stages a block */
3466
+ stackWordsOf?: CalleeStackWords;
2811
3467
  }): ThumbFrame {
2812
3468
  // THE FRAME BASE PASSED TO A CALLEE. What this computes is exactly what its name says and nothing
2813
3469
  // more: somewhere in entry-reachable code a bare `mov rD, sp` copies the frame base into a
@@ -2817,15 +3473,19 @@ function measureThumbFrame({
2817
3473
  // feeds (`capturedObjectIsTheWholeFrame`, below `localArea`), not here.
2818
3474
  //
2819
3475
  // ARGUMENT registers only: the frame base merely live across a call is not evidence that it was
2820
- // passed to one, and for `blx rN` the TARGET register is not an argument either.
3476
+ // passed to one, and for `blx rN` the TARGET register is not an argument either. Which registers
3477
+ // a call reads, and which stores send a word outside the frame, are ONE rule for every escape
3478
+ // this function asks about — `frameAddressReceivers` — so the three walks that ask differ only
3479
+ // in the kill rule, and `heldFrameWalk` says why that one differs.
3480
+ const mayHoldFrameAddress = registersThatMayHoldAFrameAddress(asmBlocks, entryReachable);
3481
+ const handedOutRegs = (ins: Instr): readonly string[] =>
3482
+ frameAddressReceivers(ins, target, calls, mayHoldFrameAddress);
2821
3483
  const frameBasePassedToCallee = heldFrameWalk(asmBlocks, entryReachable, mentionsReg, (ins, held) => {
2822
3484
  if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
2823
3485
  return false;
2824
3486
  }
2825
- // A call through a register is no argument of it: `mov r3, sp; blx r3` branches THROUGH the
2826
- // frame base, it does not pass it.
2827
- const targetReg = calleeRegister(ins, target);
2828
- return [...held].some(([r, off]) => off === 0 && target.argRegs.includes(r) && r !== targetReg);
3487
+ const handed = handedOutRegs(ins);
3488
+ return [...held].some(([r, off]) => off === 0 && handed.includes(r));
2829
3489
  });
2830
3490
 
2831
3491
  // THE FRAME BASE PUBLISHED TO MEMORY — the other way an agbcc frame address escapes, and the
@@ -2882,7 +3542,9 @@ function measureThumbFrame({
2882
3542
  // (u32)&b;` compiles to `add sp,#-0x44 / mov r0,sp / mov r2,#0x44 / bl memcpy / ldr r0,.L9+4 /
2883
3543
  // mov r1,sp / str r1,[r0]` — a block-copy base that IS published to memory (measured). At a
2884
3544
  // wider frame the publish escape admits exactly the shape the callee escape's table warns
2885
- // about; at one word it cannot occur.
3545
+ // about; at one word it cannot occur. A wider frame's publish is read by the outgoing-argument
3546
+ // `escape` event instead (below), per program point, which is the reading that survives a block
3547
+ // copy: see "ONE DECISION, FOUR LICENCES" above `capturedObjectIsTheWholeFrame`.
2886
3548
  //
2887
3549
  // THE WALK IS `heldFrameWalk`, the same one the callee escape is spelled with, because the
2888
3550
  // question is the same one and only the consuming event differs. Here that event is a WORD store
@@ -2898,21 +3560,21 @@ function measureThumbFrame({
2898
3560
  // refusal it produces stops being the two-models-for-one-byte one and becomes the audit's. A
2899
3561
  // clause without a row is decoration until an input says otherwise.
2900
3562
  //
2901
- // THE AUDIT'S RE-PROOF IS NOT THIS PREDICATE RE-SPELLED, and the one place they part is worth
2902
- // knowing: this walk is BLOCK-LOCAL while `taint` is whole-function, so a base captured in an
2903
- // earlier block is excluded there and not here. The containment still holds — whole-function
2904
- // taint is a superset of block-local held-ness, so the audit excludes every base this excludes
2905
- // and more — and the difference therefore lands on the refusing side. Pinned as a row.
2906
- const frameBasePublishedToMemory = heldFrameWalk(asmBlocks, entryReachable, mentionsReg, (ins, held) => {
2907
- if (ins.mnemonic !== 'str' || held.get(reg(ins.ops[0] ?? '')) !== 0) {
2908
- return false;
2909
- }
2910
- const { base } = parseAddr(ins.ops[1] ?? '');
2911
- return !isSpReg(base) && !held.has(reg(base));
2912
- });
3563
+ // THE AUDIT'S RE-PROOF IS NOT THIS PREDICATE RE-SPELLED, but its BASE clause is the same
3564
+ // whole-function fact on both sides. The stored VALUE must be held here (block-local); the BASE
3565
+ // must be no register that holds a frame address ANYWHERE in the function
3566
+ // (`registersThatMayHoldAFrameAddress`), the audit's whole-function `taint` read off the
3567
+ // instructions. So a base captured in an earlier block is refused by the licence itself, before
3568
+ // the audit sees it. Pinned as a row.
3569
+ const frameBasePublishedToMemory = heldFrameWalk(
3570
+ asmBlocks,
3571
+ entryReachable,
3572
+ mentionsReg,
3573
+ (ins, held) => ins.mnemonic === 'str' && handedOutRegs(ins).some((r) => held.get(r) === 0),
3574
+ );
2913
3575
 
2914
3576
  const localArea = reservedLocalArea(asmBlocks[0]);
2915
- const savedRegs = prologueSavedRegs(asmBlocks[0]);
3577
+ const { saved: savedRegs, carriers: saveCarriers } = prologueSavedRegs(asmBlocks[0]);
2916
3578
 
2917
3579
  // …AND THE FRAME IS THAT OBJECT. The two escape scans above are facts about a REGISTER — that a
2918
3580
  // bare `mov rD, sp` is still held when a callee is entered, or when it is stored to memory.
@@ -2950,7 +3612,8 @@ function measureThumbFrame({
2950
3612
  // block-copy base — each needs two frame words before agbcc names its base with a register at
2951
3613
  // all — and the two-word struct returns with them. Any larger frame has bytes this model does not
2952
3614
  // describe, and they are either the rest of a wider object the callee writes or another call's
2953
- // argument slots; neither is provable here, so the answer stays the decline.
3615
+ // argument slots. Neither is provable from the frame alone, so THIS licence stays at one word,
3616
+ // and a larger frame is left to the `escape` event, which does not have to decide it.
2954
3617
  //
2955
3618
  // What the frame size does NOT exclude is the one-word struct return in the table above, which is
2956
3619
  // instruction-for-instruction an out-parameter call. That one is settled after the lift, by the
@@ -2962,8 +3625,8 @@ function measureThumbFrame({
2962
3625
  // handed to a callee arrives there through a frame this conjunct never looks at, and what bounds
2963
3626
  // its extent is the audit's frame-accounting rule rather than anything here.
2964
3627
  //
2965
- // WHY IT IS NOT WIDENED anyway, since a wider frame is the obvious next change. Three shapes,
2966
- // each compiled with agbcc 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm
3628
+ // WHY THIS FLOW-INSENSITIVE LICENCE IS NOT WIDENED, since a wider frame is the obvious next
3629
+ // change (the `escape` event reads a wider frame instead). Three shapes, each compiled with agbcc 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm
2967
3630
  // -fprologue-bugfix`, and only the first is about extent at all:
2968
3631
  //
2969
3632
  // UNDECIDABLE — a slot THIS FUNCTION stores and reloads. Two sources that disagree about who
@@ -3017,6 +3680,26 @@ function measureThumbFrame({
3017
3680
  // modelled as `volatile u16 sp0`, and its lift is byte-identical with this conjunct widened to
3018
3681
  // `localArea >= 4` (measured). Nothing about the frame size moves it either way — which is what
3019
3682
  // makes it the wrong function to reason about a wider licence from.
3683
+ //
3684
+ // ONE DECISION, FOUR LICENCES. This gate and the outgoing-argument `escape` event (fed below,
3685
+ // under `compilerBehaviors.escapedFrameAddressNamesALocal`) read the same escapes
3686
+ // (`frameAddressReceivers`) and differ only in flow-sensitivity. This gate is flow-INSENSITIVE —
3687
+ // one escape anywhere and no frame word is any call's argument — which is sound only where no
3688
+ // block-copy base fits, a one-word frame. The event ends only the stores pending where the address
3689
+ // leaves, at any frame size; that field says why that survives a block copy. So what this gate
3690
+ // adds is a store that reaches an undeclared call BEFORE the address leaves (the CONTROL of "a
3691
+ // capture in an unreachable block proves nothing"), and every "not widened" above is about it.
3692
+ //
3693
+ // The THIRD is the [sp,#0] struct-return capture (`frameBaseIsReturnStorage`, below): flow-
3694
+ // insensitive at any frame size, but only for the hidden pointer of a callee declared to return a
3695
+ // struct through memory, which a block copy's base never is (why: the field's comment). It
3696
+ // matters only where some call may take a stack word: the hidden pointer is r0, which
3697
+ // `frameAddressReceivers` hands over, so such a base always escapes and the fourth licence
3698
+ // admits it everywhere else.
3699
+ //
3700
+ // The FOURTH is the [sp,#0] capture where no call takes a stack word (`noCallTakesAStackWord &&
3701
+ // frameBaseEscapes`, below): flow-insensitive at any frame size too, and sound for a block copy's
3702
+ // base as well, because with no outgoing area no frame word is any call's argument at all.
3020
3703
  const capturedObjectIsTheWholeFrame =
3021
3704
  target.compilerBehaviors.oneWordFrameIsTheCapturedObject === true &&
3022
3705
  (frameBasePassedToCallee || frameBasePublishedToMemory) &&
@@ -3028,20 +3711,264 @@ function measureThumbFrame({
3028
3711
  // What the walk cannot follow (a move in another block than the capture) the lift does not
3029
3712
  // fold either; the audit folds it from the IR, and refuses it by that name where the object
3030
3713
  // lands on a word this left keyed as a slot.
3714
+ //
3715
+ // …AND THE LOWEST FRAME ADDRESS EACH INSTRUCTION HANDS OUT, off the same walk: one held in an
3716
+ // argument register the callee's declaration reads at a call (every one, where nothing declares
3717
+ // it), or stored as a word to memory outside the frame — the two escapes above, at any offset.
3718
+ // This walk keeps a capture addressed through (`mov r0, sp / strb r3, [r0, #8] / bl use`, sa3's
3719
+ // `sub_8060940`), because the question here is what the receiver CAN read, and the outgoing-
3720
+ // argument analysis only lets it end a word's pending store (`escapeEvent`, below). Its receivers
3721
+ // are `handedOutRegs`, the rule the two licences above read too.
3031
3722
  const captureOffsetOf = new Map<Instr, number>();
3723
+ const escapedOffsetOf = new Map<Instr, number>();
3724
+ let frameBaseIsReturnStorage = false;
3032
3725
  heldFrameWalk(asmBlocks, entryReachable, mayWriteReg, (ins, held) => {
3726
+ if (
3727
+ ins.mnemonic === 'bl' &&
3728
+ held.get(target.argRegs[0]) === 0 &&
3729
+ calleeRegister(ins, target) === null &&
3730
+ typeof calls.declaredCall(ins.ops[0] ?? '?')?.returned === 'object'
3731
+ ) {
3732
+ frameBaseIsReturnStorage = true;
3733
+ }
3734
+ const handed = handedOutRegs(ins);
3735
+ const offs = [...held].filter(([r, off]) => handed.includes(r) && off >= 0).map(([, off]) => off);
3736
+ if (offs.length > 0) {
3737
+ escapedOffsetOf.set(ins, Math.min(...offs));
3738
+ }
3033
3739
  const defined = frameAddressDefined(ins, held);
3034
- if (defined !== null) {
3740
+ if (defined !== null && Number.isFinite(defined[1])) {
3035
3741
  captureOffsetOf.set(ins, defined[1]);
3036
3742
  }
3037
3743
  return false;
3038
3744
  });
3039
- // …and the offsets themselves, nonzero only — the frame base has its own licence,
3040
- // `capturedObjectIsTheWholeFrame`. A word whose address is taken is not an outgoing argument —
3745
+ // …and the offsets themselves. A word whose address is taken is not an outgoing argument —
3041
3746
  // C gives an argument no address — so the outgoing-argument analysis below does not see these
3042
3747
  // offsets either: a store there reaching a call unread is the object being filled for the
3043
3748
  // callee that is handed it.
3044
- const constantCaptureOffsets: ReadonlySet<number> = new Set([...captureOffsetOf.values()].filter((o) => o > 0));
3749
+ //
3750
+ // THE FRAME BASE, ONLY WHERE ITS ADDRESS ESCAPES AND NO CALL TAKES A STACK WORD, OR AS A
3751
+ // DECLARED CALL'S RETURN STORAGE. A bare `mov rD, sp` is also the base of a by-value argument's
3752
+ // block copy into the outgoing area, and [sp,#0] is then a call's fifth argument word (the
3753
+ // producer table above `capturedObjectIsTheWholeFrame`, which keeps its own licence for a
3754
+ // one-word frame). Where every call's declaration places all of its arguments in registers — no
3755
+ // call at all included — there is no outgoing area on any compiler, so a frame base handed out
3756
+ // (`escapedOffsetOf`: to a call, or stored as a word to memory) is a local's address whoever
3757
+ // receives it: a struct-return temp (`mov r0, sp / bl mk`), or a DMA fill's source published to
3758
+ // the device's registers (`dmafill_wh`). A direct `str rX, [sp]` is then that local's store, as
3759
+ // one at [sp,#4] beside `add rD, sp, #4` is. A frame base only ADDRESSED THROUGH (`mov r3, sp /
3760
+ // strh r1, [r3, #4]`, Thumb's sole spelling of a sub-word frame access) hands nothing out and
3761
+ // leaves [sp,#0] a slot. A call nothing declares, a call through a register, or a declaration
3762
+ // nothing sizes may take a stack word, and keeps offset 0 out — unless the base is a
3763
+ // struct-return callee's hidden pointer and `compilerBehaviors.escapedFrameAddressNamesALocal`
3764
+ // holds (its second reader).
3765
+ //
3766
+ // THUMB ONLY: the MIPS and PPC frontends (`inLocalArea`) route every local-area access through
3767
+ // memory once any frame address is taken, so a direct store at the frame base is already the
3768
+ // object's there.
3769
+ const frameBaseEscapes = [...escapedOffsetOf.values()].includes(0);
3770
+ const noCallTakesAStackWord = [...entryReachable].every((b) =>
3771
+ asmBlocks[b].instrs.every((ins) => {
3772
+ if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3773
+ return true;
3774
+ }
3775
+ const declared = calleeRegister(ins, target) === null ? calls.declaredCall(ins.ops[0] ?? '?') : null;
3776
+ // the declaration alone, not `outgoingBlock`: a call whose arguments run past the registers
3777
+ // takes a stack word whether or not the target claims agbcc's staging premise
3778
+ return declared !== null && wordsOf(declared.widths, target) <= target.argRegs.length;
3779
+ }),
3780
+ );
3781
+
3782
+ // A STRUCT PASSED BY VALUE, LOADED OFF THE FRAME A WORD AT A TIME. agbcc builds the argument in
3783
+ // the frame — through a copy of sp, Thumb having no sp-relative halfword or byte store — and
3784
+ // then loads its words into the argument registers the declaration places it in (`struct A
3785
+ // {u16 a,b,c,d; s8 e,f;} a; a.a = x; … take2(a);` is `mov r2, sp / strh r0, [r2] / … / ldr r0,
3786
+ // [sp] / ldr r1, [sp, #0x4] / ldr r2, [sp, #0x8] / bl take2`). Those words are the object's, not
3787
+ // slots: each `[sp,#k]` inside such a block is routed through an `laddr` (`isFrameObjectAccess`),
3788
+ // and the loads are folded into the object passed whole or refused (call-plan.ts
3789
+ // `foldByValueArgs`). A block is read only where every one of its registers is
3790
+ // last written, in the call's own block, by a word load at the next word of one frame range.
3791
+ const byValueBlocks: FrameRange[] = [];
3792
+ for (const b of entryReachable) {
3793
+ const instrs = asmBlocks[b].instrs;
3794
+ instrs.forEach((call, at) => {
3795
+ if (call.mnemonic !== 'bl' || calleeRegister(call, target) !== null) {
3796
+ return;
3797
+ }
3798
+ const declared = calls.declaredCall(call.ops[0] ?? '?');
3799
+ for (const arg of declared?.byValue ?? []) {
3800
+ // `word` indexes the call's widths, where a 64-bit argument is one entry of two registers
3801
+ const reg = wordsOf(declared!.widths.slice(0, arg.word), target);
3802
+ const offs = target.argRegs.slice(reg, reg + arg.words).map((r) => {
3803
+ const def = instrs
3804
+ .slice(0, at)
3805
+ .reverse()
3806
+ .find((ins) => mayWriteReg(ins, r));
3807
+ const acc = def !== undefined && def.mnemonic === 'ldr' && def.ops[0] === r ? spMemAccess(def) : null;
3808
+ return acc !== null && !acc.regOff && acc.width === 4 ? acc.off : null;
3809
+ });
3810
+ if (offs.every((o, i) => o !== null && o === offs[0]! + 4 * i)) {
3811
+ byValueBlocks.push({ from: offs[0]!, to: offs[0]! + 4 * arg.words });
3812
+ }
3813
+ }
3814
+ });
3815
+ }
3816
+ const inByValueBlock = (off: number, width: number): boolean =>
3817
+ byValueBlocks.some((r) => off >= r.from && off + width <= r.to);
3818
+
3819
+ // THE NARROW RELOADS OF A WORD SLOT (`narrowSlotReloads`), kept only where the word is a slot
3820
+ // and no object's: a word `str` to [sp,#slot] in live code writes it — without one the bytes are
3821
+ // a sub-word local's (`mov r3, sp / strh r2, [r3, #4]`), which the object model owns — no other
3822
+ // copy of sp names the slot's offset, and neither a by-value block nor the one object covers it.
3823
+ // The one-word frame object (`capturedObjectIsTheWholeFrame`) needs no test of its own: its
3824
+ // escape is a copy of sp at offset 0 handed out, which no candidate's copy can be (that one dies
3825
+ // in its load), so it is another capture at the slot's offset.
3826
+ //
3827
+ // …AND THE WORD IS NOT AN INCOMING ARGUMENT'S HOME. A by-value struct parameter is the other
3828
+ // producer of these instructions: agbcc stores its argument registers word by word on entry and
3829
+ // reads each member at its width through a copy of sp (`struct P { u16 a, b, c, d; s8 e; }; int
3830
+ // b2(struct P p){ return p.a + p.c - p.e; }` is `str r0, [sp] / … / mov r0, sp / ldrh r0, [r0]`),
3831
+ // and there the frame is the parameter, one object whose union recompiles byte-exact
3832
+ // (cli/test/matching/frame-union.test.ts) where the words narrowed recompile with no frame at
3833
+ // all. A word the entry block stores straight from an argument register it has not written is
3834
+ // that home, and its reloads keep the object model. A spilled promoted local is stored from the
3835
+ // value it was computed as. That test reads the asm alone, and the function's own declaration
3836
+ // overrules it where it proves the register holds one integer word (`proto.ts`
3837
+ // `ownLeadingWordRegisters`): a `u32 k` spilled on entry and read back as `(u8)k` is no struct's
3838
+ // home, and lifts as the slot narrowed. A copy kept here is no capture, so its offset leaves
3839
+ // `constantCaptureOffsets` and the slot's own `str`/`ldr` stay slot accesses; one dropped stays a
3840
+ // capture. A home is held for the one-object answer alone, so the audit may hand it back: where
3841
+ // that answer is withheld (a struct-return temp escapes beside the home) and the audit declines
3842
+ // with the home held, the second lift reads it as any other slot (`releasableHomes`,
3843
+ // `homesReleased`). The hold decides how the LIFT reads a home's reloads, not whether the word
3844
+ // is reloaded: the stack-argument analysis takes a held home's narrow reload as that word's
3845
+ // reload (`capturedReloadOf`) in every lift, as it takes a narrow reload inside the bytes a
3846
+ // first audit asked to keep as one object, which that relift keeps as a capture too. Read as no
3847
+ // reload, a home at the frame's bottom would decline as a possible outgoing argument, and agbcc
3848
+ // compiles that shape: reload spills a DImode struct's registers above the locals, but
3849
+ // `assign_parms` stores a BLKmode one (over 8 bytes) at the frame's bottom, below any temp
3850
+ // (`str r0, [sp] / … / add r0, sp, #0xc / bl mk0 / … / mov r0, sp / ldrb r0, [r0]`). Below an
3851
+ // escaping temp the first lift would decline before any audit could release the home; with a
3852
+ // call and no temp (`g(); return p.m0 + p.m8;`) the one-object relift would.
3853
+ //
3854
+ // A COPY THAT OUTLIVES ITS LOAD. Where reload hands the load another register (`add r3, sp, #0x28
3855
+ // / ldrb r1, [r3]`, the left operand of a `?:` join whose hard register is r1), rD still holds the
3856
+ // address after it. The copy is not lifted, so rD must be DEAD there: no path from the load reads
3857
+ // it before writing it (`readsBeforeWrite`), a return that may hand it back counting as a read.
3858
+ // The lift holds to that proof: the dropped copy leaves a marker in rD (`writeNarrowCopyMarker`),
3859
+ // and a read of it declines. A CALL READS WHAT IT IS HANDED, the one receiver rule
3860
+ // (`frameAddressReceivers`: a declared callee's argument words, every argument register where
3861
+ // nothing declares it), plus the register it calls through (`calleeRegister`). Any other register
3862
+ // it clobbers, r0 among them, is dead past it, and a callee-saved one still holds the copy. Read
3863
+ // as `readsBeforeWrite` reads a call for the save analysis (every register but r12), kleod
3864
+ // sub_08016EEC keeps two copies live, in r3 and in r6.
3865
+ //
3866
+ // A RETURN HANDS BACK WHAT THE OWN DECLARATION RETURNS. Where a STATED entry declares a void
3867
+ // return, a return reads neither r0 nor r1 (pipeline.ts `dropVoidReturnOperands`, the exemption
3868
+ // `refuseStaleIdiomReads` makes for the marker), and where the own declaration proves a return no
3869
+ // wider than a word, it reads r0 alone: `add r1, sp, #4 / ldrb r0, [r1] / … / pop {r0} / bx r0` in
3870
+ // a `void f(u32)` leaves no read of r1. Undeclared, a return may hand back the pair r0:r1. The void
3871
+ // reading of r0 is the stated entry's alone because the drop is: under the context's `void f(u32)`
3872
+ // the lifted `ret` still reads r0, and an r0 copy dropped there would print as `return uninit_r0`.
3873
+ // The reading of r1 needs no such care, under a void or a one-word declaration from either source,
3874
+ // as the lifted `ret` reads r1 only through a pair the asm builds (`wideReturn`), where the marker
3875
+ // declines.
3876
+ const narrowCallReads = (call: Instr): readonly string[] => {
3877
+ const through = calleeRegister(call, target);
3878
+ return typeof through === 'string' ? [...handedOutRegs(call), through] : handedOutRegs(call);
3879
+ };
3880
+ // `callClobbers` leaves out r0, which the lift writes with what the call returns
3881
+ const narrowCallClobbers = [...calls.callClobbers, target.returnReg];
3882
+ const ownProven = own?.proves === true ? own.proto : undefined;
3883
+ const returnMayHand = (r: string): boolean =>
3884
+ !dropsVoidReturn &&
3885
+ !(
3886
+ r !== target.returnReg &&
3887
+ (declaresVoidReturn(ownProven) || (ownReturnWidth(ownProven) ?? Number.POSITIVE_INFINITY) <= 32)
3888
+ );
3889
+ const narrowCandidates = narrowSlotReloads(
3890
+ asmBlocks,
3891
+ entryReachable,
3892
+ localArea,
3893
+ target.capabilities.endianness === 'little',
3894
+ (b, from, r) => {
3895
+ const use = readsBeforeWrite(asmBlocks, succs, b, from, r, {
3896
+ reads: narrowCallReads,
3897
+ clobbers: narrowCallClobbers,
3898
+ });
3899
+ return use === false || (use === 'returned' && !returnMayHand(r));
3900
+ },
3901
+ );
3902
+ const candidateCopies = new Set([...narrowCandidates.values()].map((n) => n.copy));
3903
+ const otherCaptureOffsets = new Set(
3904
+ [...captureOffsetOf].filter(([ins]) => !candidateCopies.has(ins)).map(([, off]) => off),
3905
+ );
3906
+ const wordStoredSlots = new Set(
3907
+ [...entryReachable].flatMap((b) =>
3908
+ asmBlocks[b].instrs.flatMap((ins) => {
3909
+ const a = spMemAccess(ins);
3910
+ return ins.mnemonic === 'str' && a !== null && !a.regOff ? [a.off] : [];
3911
+ }),
3912
+ ),
3913
+ );
3914
+ const argumentHomes = new Set<number>();
3915
+ {
3916
+ const proving = own?.proves === true ? own.proto : undefined;
3917
+ const wordArgRegs = new Set(target.argRegs.slice(0, ownLeadingWordRegisters(name, proving)));
3918
+ const written = new Set<string>();
3919
+ for (const ins of asmBlocks[0].instrs) {
3920
+ const a = spMemAccess(ins);
3921
+ const src = reg(ins.ops[0] ?? '');
3922
+ if (
3923
+ ins.mnemonic === 'str' &&
3924
+ a !== null &&
3925
+ !a.regOff &&
3926
+ target.argRegs.includes(src) &&
3927
+ !wordArgRegs.has(src) &&
3928
+ !written.has(src)
3929
+ ) {
3930
+ argumentHomes.add(a.off);
3931
+ }
3932
+ for (const r of target.argRegs) {
3933
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx' || mayWriteReg(ins, r)) {
3934
+ written.add(r);
3935
+ }
3936
+ }
3937
+ }
3938
+ }
3939
+ const narrowReloadOf = new Map<Instr, number>();
3940
+ const releasableHomes = new Set<number>();
3941
+ // the narrow reloads the lift keeps as captures (a held home's, and every one inside the bytes
3942
+ // a first audit asked to keep as one object), which are still reloads of that word for the
3943
+ // outgoing-argument analysis below
3944
+ const capturedReloadOf = new Map<Instr, number>();
3945
+ for (const [load, { slot }] of narrowCandidates) {
3946
+ if (oneObject !== undefined && slot < oneObject.to && slot + 4 > oneObject.from) {
3947
+ capturedReloadOf.set(load, slot);
3948
+ } else if (wordStoredSlots.has(slot) && !otherCaptureOffsets.has(slot) && !inByValueBlock(slot, 4)) {
3949
+ if (argumentHomes.has(slot) && !homesReleased) {
3950
+ releasableHomes.add(slot);
3951
+ capturedReloadOf.set(load, slot);
3952
+ } else {
3953
+ narrowReloadOf.set(load, slot);
3954
+ }
3955
+ }
3956
+ }
3957
+ const narrowReloadCopies: ReadonlySet<Instr> = new Set(
3958
+ [...narrowCandidates].filter(([load]) => narrowReloadOf.has(load)).map(([, n]) => n.copy),
3959
+ );
3960
+ const constantCaptureOffsets: ReadonlySet<number> = new Set(
3961
+ [...captureOffsetOf]
3962
+ .filter(([ins]) => !narrowReloadCopies.has(ins))
3963
+ .map(([, o]) => o)
3964
+ .filter(
3965
+ (o) =>
3966
+ o > 0 ||
3967
+ (o === 0 &&
3968
+ ((noCallTakesAStackWord && frameBaseEscapes) ||
3969
+ (frameBaseIsReturnStorage && target.compilerBehaviors.escapedFrameAddressNamesALocal === true))),
3970
+ ),
3971
+ );
3045
3972
 
3046
3973
  // THE OUTGOING STACK-ARGUMENT AREA. `frontend/stackargs.ts` holds the licence and every refusal;
3047
3974
  // what belongs HERE is the decoding it deliberately does not do — which accesses are whole frame
@@ -3055,40 +3982,110 @@ function measureThumbFrame({
3055
3982
  const a = spMemAccess(ins);
3056
3983
  return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off + 4 <= localArea ? a.off : null;
3057
3984
  };
3058
- const outgoingArgs = analyzeOutgoingArgs<Instr>({
3059
- blocks: asmBlocks.map((ab) => ({
3060
- events: ab.instrs.flatMap((ins): StackArgsEvent<Instr>[] => {
3061
- const off = slotAcc(ins);
3062
- if (off !== null && !constantCaptureOffsets.has(off)) {
3063
- return [{ kind: /^str/.test(ins.mnemonic) ? 'store' : 'load', off }];
3064
- }
3065
- if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
3066
- const callee = ins.ops[0] ?? '?';
3067
- const declared = calleeRegister(ins, target) === null ? calls.declaredCall(callee) : null;
3068
- return [
3069
- {
3070
- kind: 'call',
3071
- call: ins,
3072
- callee,
3073
- declared: declared === null ? null : outgoingBlock(declared.widths, target),
3074
- params: declared?.params,
3075
- },
3076
- ];
3077
- }
3078
- return [];
3079
- }),
3080
- })),
3081
- preds,
3082
- live: entryReachable,
3083
- localArea,
3084
- argRegs: target.argRegs.length,
3085
- capturedWholeFrame: capturedObjectIsTheWholeFrame,
3086
- localsAboveOutgoingArea: target.compilerBehaviors.localsAboveOutgoingArea === true,
3087
- });
3985
+ //
3986
+ // AN ESCAPED FRAME ADDRESS IS READ BY ITS RECEIVER, and the event says so just before the call or
3987
+ // the store that hands it over — but only where the target declares the layout premise that
3988
+ // makes the words from that address up a local's (`escapedFrameAddressNamesALocal`).
3989
+ const escapesHere = target.compilerBehaviors.escapedFrameAddressNamesALocal === true;
3990
+ const escapeEvent = (ins: Instr): StackArgsEvent<Instr>[] => {
3991
+ const off = escapedOffsetOf.get(ins);
3992
+ return escapesHere && off !== undefined ? [{ kind: 'escape', off }] : [];
3993
+ };
3994
+ const stagesOutgoing = target.compilerBehaviors.stagesOutgoingArgsInFrame === true;
3995
+ const eventBlocks = asmBlocks.map((ab) => ({
3996
+ events: ab.instrs.flatMap((ins): StackArgsEvent<Instr>[] => {
3997
+ const escaped = escapeEvent(ins);
3998
+ if (escaped.length > 0 && ins.mnemonic === 'str') {
3999
+ return escaped;
4000
+ }
4001
+ // …and a narrow reload of a word slot is that slot's reload, as its word reload would be,
4002
+ // one the lift keeps as a capture included (a held argument home's, or one inside the
4003
+ // one-object bytes): that decides how the lift reads the load, not whether the word is
4004
+ // loaded, and a word this function loads is no outgoing argument
4005
+ const off = slotAcc(ins) ?? narrowReloadOf.get(ins) ?? capturedReloadOf.get(ins) ?? null;
4006
+ if (off !== null && !constantCaptureOffsets.has(off) && !inByValueBlock(off, 4)) {
4007
+ return [{ kind: /^str/.test(ins.mnemonic) ? 'store' : 'load', off }];
4008
+ }
4009
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
4010
+ const callee = ins.ops[0] ?? '?';
4011
+ const declared = calleeRegister(ins, target) === null ? calls.declaredCall(callee) : null;
4012
+ return [
4013
+ ...escaped,
4014
+ {
4015
+ kind: 'call',
4016
+ call: ins,
4017
+ callee,
4018
+ declared: declared === null ? null : outgoingBlock(declared.widths, target),
4019
+ params: declared?.params,
4020
+ // where nothing declares a named call, what its callee's body loads of the stack words
4021
+ ...(declared === null && calleeRegister(ins, target) === null && stagesOutgoing
4022
+ ? { bodyReads: () => stackWordsOf?.(callee) }
4023
+ : {}),
4024
+ },
4025
+ ];
4026
+ }
4027
+ return [];
4028
+ }),
4029
+ }));
4030
+ // THE LOADED-WORD BOUND, read once (`outgoingAreaNeverLoadedFrom`, 0 on agbcc): the outgoing
4031
+ // argument analysis and the frame-object audit both take this number.
4032
+ const loadedFrom = target.compilerBehaviors.outgoingAreaNeverLoadedFrom;
4033
+ const loadedBound = loadedWordBound(eventBlocks, entryReachable, loadedFrom);
4034
+ const outgoingArgsOver = (blocks: readonly StackArgsBlock<Instr>[]): OutgoingArgs<Instr> =>
4035
+ analyzeOutgoingArgs<Instr>({
4036
+ blocks,
4037
+ preds,
4038
+ live: entryReachable,
4039
+ localArea,
4040
+ argRegs: target.argRegs.length,
4041
+ capturedWholeFrame: capturedObjectIsTheWholeFrame,
4042
+ loadedWordBound: loadedBound,
4043
+ });
4044
+ const outgoingArgs = outgoingArgsOver(eventBlocks);
4045
+ // THE ESCAPES THE VERDICT LEANS ON, re-asked of the lifted IR (`auditFrameObjects`'
4046
+ // `escapesReliedOn`). The `escape` event is read off what a REGISTER holds at a `bl` or a `str`,
4047
+ // and it switches refusals OFF; the lift then decides what the call is really handed, and an
4048
+ // arity that shrinks after the event was read (a guess, then the trim) can leave the address out
4049
+ // of the call while its stores stay excused. The one-word licence has this re-check
4050
+ // (`recheckWholeFramePremise`); this is the same one at any frame size. Only where the analysis
4051
+ // would refuse WITHOUT the escapes, so an escape that excused nothing asks nothing of the IR.
4052
+ const escapeOffsets = new Set(
4053
+ [...entryReachable].flatMap((b) => eventBlocks[b].events.flatMap((ev) => (ev.kind === 'escape' ? [ev.off] : []))),
4054
+ );
4055
+ const escapesReliedOn: ReadonlySet<number> =
4056
+ outgoingArgs.blocker === null &&
4057
+ escapeOffsets.size > 0 &&
4058
+ outgoingArgsOver(eventBlocks.map((b) => ({ events: b.events.filter((ev) => ev.kind !== 'escape') }))).blocker !==
4059
+ null
4060
+ ? escapeOffsets
4061
+ : new Set();
4062
+ const callsReliedOn: ReadonlySet<Instr> = new Set(
4063
+ escapesReliedOn.size === 0
4064
+ ? []
4065
+ : [...escapedOffsetOf.keys()].filter((ins) => ins.mnemonic === 'bl' || ins.mnemonic === 'blx'),
4066
+ );
3088
4067
 
3089
4068
  const slotsOffReason = slotModelBlocker(asmBlocks, localArea, outgoingArgs);
3090
4069
  const slotsOk = slotsOffReason === null;
3091
4070
 
4071
+ // THE DEPTH EVERY OTHER BLOCK RUNS AT: what the entry block leaves, where `frameDepthBlocker`
4072
+ // proves no block but a returning one moves sp again. Not where the entry is itself a loop
4073
+ // target (its prologue would run twice) or returns (no block runs after it).
4074
+ const entry = asmBlocks[0];
4075
+ const entryWalk = makeFrameWalk({ argRegs: target.argRegs, start: 0 });
4076
+ for (const ins of entry.instrs) {
4077
+ if (!classifyXfer(ins)) {
4078
+ entryWalk.step(ins);
4079
+ }
4080
+ }
4081
+ const entryEnd = entry.instrs[entry.instrs.length - 1];
4082
+ const settledDepth =
4083
+ frameDepthBlocker(asmBlocks) === null &&
4084
+ preds[0].length === 0 &&
4085
+ !(entryEnd !== undefined && classifyXfer(entryEnd) === 'return')
4086
+ ? entryWalk.depthNow()
4087
+ : null;
4088
+
3092
4089
  // …AND THE ONE OFFSET THAT MUST NOT BE A SLOT. When `capturedObjectIsTheWholeFrame` holds, the
3093
4090
  // frame is one word and a callee is being handed its address, so an `[sp,#0]` access is an access
3094
4091
  // to THAT OBJECT — provisionally, since the audit is what proves the object is a local at all. Keying it as an SSA slot instead moves the value into a
@@ -3121,6 +4118,7 @@ function measureThumbFrame({
3121
4118
  width === 4 &&
3122
4119
  ((capturedObjectIsTheWholeFrame && off === 0) ||
3123
4120
  (constantCaptureOffsets.has(off) && off >= outgoingArgs.area && off + 4 <= localArea) ||
4121
+ (inByValueBlock(off, 4) && off >= outgoingArgs.area && off + 4 <= localArea) ||
3124
4122
  (oneObject !== undefined && off >= oneObject.from && off + 4 <= oneObject.to));
3125
4123
 
3126
4124
  // LOCAL STACK SLOTS. A spill or a local that never has its address taken is transparent to
@@ -3181,14 +4179,23 @@ function measureThumbFrame({
3181
4179
  return {
3182
4180
  localArea,
3183
4181
  savedRegs,
4182
+ saveCarriers,
3184
4183
  capturedObjectIsTheWholeFrame,
4184
+ escapesReliedOn,
4185
+ callsReliedOn,
3185
4186
  captureOffsetOf,
4187
+ frameAddressesAtCall: frameAddressesAtCalls(asmBlocks, preds, entryReachable, calls.callClobbers),
3186
4188
  outgoingArgs,
4189
+ loadedWords: loadedFrom === undefined ? undefined : { from: loadedFrom, bound: loadedBound },
3187
4190
  slotsOffReason,
3188
4191
  slotsOk,
4192
+ settledDepth,
3189
4193
  partition,
3190
4194
  isFrameObjectAccess,
3191
4195
  isOwnFrameWordSlot,
4196
+ narrowReloadOf,
4197
+ narrowReloadCopies,
4198
+ releasableHomes,
3192
4199
  };
3193
4200
  }
3194
4201
 
@@ -3216,6 +4223,13 @@ function measureThumbFrame({
3216
4223
  // question, and there the capture has to reach the consumer UNTOUCHED (`mentionsReg`): a copy
3217
4224
  // addressed through and still live at a call is an addressing copy, not an argument —
3218
4225
  // `EReader_Reset` is `mov r1, sp / strh r0, [r1] / … / bl` into callees that take nothing.
4226
+ // WHAT THE RECEIVER CAN READ is the third question, the outgoing-argument analysis's `escape`
4227
+ // event, and it reads the first rule: sa3's `sub_8060940` fills its object through the very copy it
4228
+ // passes (`mov r0, sp / strb r3, [r0, #8] / bl use`). What keeps `EReader_Reset` out there is the
4229
+ // receiver rule both share (`frameAddressReceivers`): a callee declared to take nothing is handed
4230
+ // no register. And an undeclared callee that took a stack word needs all four argument registers
4231
+ // written for it first, which the write rule sees, so a capture still held in one at the `bl` is
4232
+ // passed to it.
3219
4233
  //
3220
4234
  // A `bl` CLEARS EVERY HELD REGISTER, and for the callee escape that is the ABI — the argument
3221
4235
  // registers are the only ones it tests and the callee clobbers them. The publish escape can hold
@@ -3256,6 +4270,13 @@ function heldFrameWalk(
3256
4270
  // null. The shapes that carry one: the capture itself (`mov rD, sp`, `add rD, sp, #k`), a copy
3257
4271
  // of a held register (`mov rD, rS`, and agbcc's `add rD, rS, #0`), and a held register moved by
3258
4272
  // a constant (`add rD, rS, #c`, `add rD, #c`). Everything else only kills.
4273
+ //
4274
+ // …AND A FRAME ADDRESS MOVED BY A REGISTER, at an offset nothing here knows (NaN): sp or a held
4275
+ // register plus a runtime index, `add r2, r2, sp` — agbcc's `&buf[i]` — or `mov r1, sp / add r0,
4276
+ // r1, r4`. It names no one word, so no walk that asks for an offset can credit it (NaN equals
4277
+ // none, and an escape at NaN is no escape); what it is carried for is the "may hold a frame
4278
+ // address" questions: without it an undeclared call's guessed arity drops `&buf[i]` with no
4279
+ // refusal (`g3(a, b, &buf[i])` as `g3()`).
3259
4280
  function frameAddressDefined(ins: Instr, held: ReadonlyMap<string, number>): [string, number] | null {
3260
4281
  const [d, s1, s2] = ins.ops;
3261
4282
  if (d === undefined || isSpReg(d)) {
@@ -3269,15 +4290,241 @@ function frameAddressDefined(ins: Instr, held: ReadonlyMap<string, number>): [st
3269
4290
  }
3270
4291
  if (/^adds?$/.test(ins.mnemonic)) {
3271
4292
  const [src, by] = s2 === undefined ? [d, s1] : [s1, s2];
3272
- if (src === undefined || by === undefined || !IMM_LITERAL.test(by)) {
4293
+ if (src === undefined || by === undefined) {
3273
4294
  return null;
3274
4295
  }
3275
- const from = isSpReg(src) ? 0 : held.get(reg(src));
4296
+ const at = (r: string): number | undefined => (isSpReg(r) ? 0 : held.get(reg(r)));
4297
+ if (!IMM_LITERAL.test(by)) {
4298
+ return at(src) !== undefined || at(by) !== undefined ? [reg(d), NaN] : null;
4299
+ }
4300
+ const from = at(src);
3276
4301
  return from === undefined ? null : [reg(d), from + imm(by)];
3277
4302
  }
3278
4303
  return null;
3279
4304
  }
3280
4305
 
4306
+ // A NARROW RELOAD OF A WORD SLOT, read through a copy of sp. agbcc promotes a `u8`/`u16` local to
4307
+ // a word (thumb.h PROMOTE_MODE), spills it with `str`, and where a use is narrow it reloads only
4308
+ // the narrow part. Thumb has no sp-relative `ldrb`/`ldrh`, so that reload goes through a copy of
4309
+ // sp made for it alone: `add r3, sp, #0x28 / ldrb r3, [r3]` (a byte slot past the `ldrb` offset
4310
+ // range), `mov r2, sp / ldrh r2, [r2, #0x4]`. The narrow bytes are the slot's LOW-ORDER part —
4311
+ // on a little-endian target its own address, on a big-endian one its last `width` bytes — so the
4312
+ // load reads the stored word's value narrowed, `zext(sp@<slot>)`, and the copy names no object.
4313
+ //
4314
+ // What this finds is a shape and not yet a licence; the copy is a capture everywhere it fails:
4315
+ // * the copy is `mov rD, sp` or `add rD, sp, #c`, entry-reachable, and in ONE block it is read
4316
+ // by exactly one instruction, a zero-extending `ldrb`/`ldrh` at an immediate offset from rD,
4317
+ // after which the address is dead: the load writes rD itself, or it writes another register
4318
+ // and the caller's `diesAfter` proves no path from it reads rD before writing it. Anything else
4319
+ // that mentions rD before the load (a second load, a store through it, an escape), a call, a
4320
+ // control transfer or a write of sp ends the search, and the copy stays a capture.
4321
+ // * the load's bytes are the low-order part of a whole aligned word of the reserved local area.
4322
+ // A load at any other byte of the word is not that word's value narrowed, whatever it reads.
4323
+ // * `ldrsb`/`ldrsh` exist only register-offset in Thumb-1, and agbcc's signed use reloads a word
4324
+ // and shifts (measured), so no signed shape is taken.
4325
+ // What the caller then adds, because it needs the frame model: a word `str` to the slot in live
4326
+ // code, the slot named by no other capture, no argument's home, outside a by-value block and
4327
+ // outside the one object (`measureThumbFrame`), and at the lowering a slot the word reload would
4328
+ // read (`lowerLoad` `readsOwnSlot`). A narrow STORE into
4329
+ // the slot through any other address is the frame-object audit's: it sees that slot used and the
4330
+ // object overlapping it, the "one byte, two models" refusal.
4331
+ function narrowSlotReloads(
4332
+ asmBlocks: readonly AsmBlock[],
4333
+ entryReachable: ReadonlySet<number>,
4334
+ localArea: number,
4335
+ littleEndian: boolean,
4336
+ diesAfter: (b: number, from: number, r: string) => boolean,
4337
+ ): Map<Instr, { copy: Instr; slot: number }> {
4338
+ const found = new Map<Instr, { copy: Instr; slot: number }>();
4339
+ for (const b of entryReachable) {
4340
+ const instrs = asmBlocks[b].instrs;
4341
+ instrs.forEach((copy, at) => {
4342
+ const [d, s1, s2] = copy.ops;
4343
+ const from = capturesSp(copy)
4344
+ ? 0
4345
+ : /^adds?$/.test(copy.mnemonic) && isSpReg(s1) && s2 !== undefined && IMM_LITERAL.test(s2)
4346
+ ? imm(s2)
4347
+ : null;
4348
+ if (from === null || d === undefined || isSpReg(d)) {
4349
+ return;
4350
+ }
4351
+ const rD = reg(d);
4352
+ for (let li = at + 1; li < instrs.length; li++) {
4353
+ const ins = instrs[li];
4354
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx' || classifyXfer(ins) !== null || modifiesSp(ins)) {
4355
+ return;
4356
+ }
4357
+ if (!mentionsReg(ins, rD)) {
4358
+ continue;
4359
+ }
4360
+ const width = ins.mnemonic === 'ldrb' ? 1 : ins.mnemonic === 'ldrh' ? 2 : 0;
4361
+ const addr = ins.ops[1] === undefined ? null : parseAddr(ins.ops[1]);
4362
+ if (width === 0 || addr === null || reg(addr.base) !== rD || addr.regOff) {
4363
+ return;
4364
+ }
4365
+ if (reg(ins.ops[0] ?? '') !== rD && !diesAfter(b, li + 1, rD)) {
4366
+ return;
4367
+ }
4368
+ const slot = from + addr.off - (littleEndian ? 0 : 4 - width);
4369
+ if (slot % 4 === 0 && slot >= 0 && slot + 4 <= localArea) {
4370
+ found.set(ins, { copy, slot });
4371
+ }
4372
+ return;
4373
+ }
4374
+ });
4375
+ }
4376
+ return found;
4377
+ }
4378
+
4379
+ // Every register that holds a frame address at some instruction of live code — the shapes
4380
+ // `frameAddressDefined` carries, closed over every block at once rather than along one. So it is
4381
+ // a SUPERSET of every block's `held`, and what it EXCLUDES is the only thing it is used for: a
4382
+ // store through one of these is not a store outside the frame. A frame address reloaded from
4383
+ // memory is not followed; the store through it then counts as leaving the frame, which is the
4384
+ // imprecision `frameAddressReceivers` states.
4385
+ function registersThatMayHoldAFrameAddress(
4386
+ asmBlocks: readonly AsmBlock[],
4387
+ entryReachable: ReadonlySet<number>,
4388
+ ): ReadonlySet<string> {
4389
+ const held = new Map<string, number>();
4390
+ for (let grew = true; grew;) {
4391
+ grew = false;
4392
+ for (const b of entryReachable) {
4393
+ for (const ins of asmBlocks[b].instrs) {
4394
+ // CONSTANT OFFSETS ONLY. Closed over every block at once, an index sum (NaN) would carry
4395
+ // the frame base through every `add rD, rS, rT` in the function — measured: klonoa's
4396
+ // `DmaControllerInit` then held its DMA register's base, and its publish stopped counting
4397
+ // as one. A store through `&buf[i]` stays the counted-as-leaving imprecision stated above.
4398
+ const defined = frameAddressDefined(ins, held);
4399
+ if (defined !== null && Number.isFinite(defined[1]) && !held.has(defined[0])) {
4400
+ held.set(defined[0], 0); // the offset is not the question here, only the register
4401
+ grew = true;
4402
+ }
4403
+ }
4404
+ }
4405
+ }
4406
+ return new Set(held.keys());
4407
+ }
4408
+
4409
+ // THE FRAME ADDRESSES ARGUMENT REGISTERS HOLD AT EACH CALL, two ways. The shapes carried are
4410
+ // `frameAddressDefined`'s, and a write of anything else ends one, as in `heldFrameWalk`.
4411
+ // - `may`: on SOME path from the entry, ACROSS blocks, joined by union — the question is a
4412
+ // refusal's, so a capture made in one block and handed over in the next (`mov r5, sp` / … /
4413
+ // `.L3: add r3, r5, #0` / `bl`) must count, and a call ends only what it clobbers, because a
4414
+ // capture in r4-r7 outlives it. Two paths that disagree on the offset leave it unknown (NaN).
4415
+ // The entry block starts empty, which keeps an incoming argument register from counting:
4416
+ // nothing arrives in it but the caller's value.
4417
+ // - `must`: defined in the call's own block, which every path to the call runs.
4418
+ interface FrameAddressesAtCall {
4419
+ readonly may: ReadonlyMap<string, number>;
4420
+ readonly must: ReadonlyMap<string, number>;
4421
+ }
4422
+ function frameAddressesAtCalls(
4423
+ asmBlocks: readonly AsmBlock[],
4424
+ preds: readonly (readonly number[])[],
4425
+ entryReachable: ReadonlySet<number>,
4426
+ clobbers: readonly string[],
4427
+ ): ReadonlyMap<Instr, FrameAddressesAtCall> {
4428
+ const atCall = new Map<Instr, FrameAddressesAtCall>();
4429
+ const out = asmBlocks.map(() => new Map<string, number>());
4430
+ const step = (ins: Instr, held: Map<string, number>): void => {
4431
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
4432
+ clobbers.forEach((r) => held.delete(r));
4433
+ return;
4434
+ }
4435
+ const carried = frameAddressDefined(ins, held);
4436
+ for (const r of [...held.keys()]) {
4437
+ if (mayWriteReg(ins, r)) {
4438
+ held.delete(r);
4439
+ }
4440
+ }
4441
+ if (carried !== null) {
4442
+ held.set(carried[0], carried[1]);
4443
+ }
4444
+ };
4445
+ const flow = (b: number, record: boolean): Map<string, number> => {
4446
+ const may = new Map<string, number>();
4447
+ for (const p of preds[b] ?? []) {
4448
+ for (const [r, off] of out[p]) {
4449
+ may.set(r, !may.has(r) || may.get(r) === off ? off : NaN);
4450
+ }
4451
+ }
4452
+ const must = new Map<string, number>();
4453
+ for (const ins of asmBlocks[b].instrs) {
4454
+ if (record && (ins.mnemonic === 'bl' || ins.mnemonic === 'blx')) {
4455
+ atCall.set(ins, { may: new Map(may), must: new Map(must) });
4456
+ }
4457
+ step(ins, may);
4458
+ step(ins, must);
4459
+ }
4460
+ return may;
4461
+ };
4462
+ const same = (a: ReadonlyMap<string, number>, b: ReadonlyMap<string, number>) =>
4463
+ a.size === b.size && [...a].every(([r, off]) => b.has(r) && Object.is(b.get(r), off));
4464
+ for (let changed = true; changed;) {
4465
+ changed = false;
4466
+ for (const b of entryReachable) {
4467
+ const next = flow(b, false);
4468
+ if (!same(next, out[b])) {
4469
+ out[b] = next;
4470
+ changed = true;
4471
+ }
4472
+ }
4473
+ }
4474
+ for (const b of entryReachable) {
4475
+ flow(b, true);
4476
+ }
4477
+ return atCall;
4478
+ }
4479
+
4480
+ // THE REGISTERS AN INSTRUCTION HANDS TO CODE OUTSIDE THIS FUNCTION — the one definition of a frame
4481
+ // address escaping, which every escape walk in `measureThumbFrame` reads, so which registers a
4482
+ // callee reads and which stores leave the frame are decided once.
4483
+ //
4484
+ // A CALL hands over the argument registers its callee's DECLARATION reads — one per 32-bit word,
4485
+ // so `g(void)` is handed nothing — and every argument register where nothing declares it. A call
4486
+ // through a register (`blx r3`, a `_call_via_r3` thunk) branches THROUGH that register; it does not
4487
+ // pass it. "Every register" is wider than what the lift passes an undeclared callee — a guessed
4488
+ // count, or the bound of a call through an argument register — and the two agree on a frame address
4489
+ // only because the lowering refuses to leave one out (`lowerCall`'s `leftOut`, over
4490
+ // `frameAddressesAtCalls`, which holds at each call a superset of what the walks here hold) — of
4491
+ // the registers its guess leaves out, of those the trim cuts afterwards (`GuessedCallSite.
4492
+ // trimmed`), and of those the call's own block wrote that the ranked `/setup-args` reading would
4493
+ // cut, which is withheld for that site instead (it runs in the ranker, after the audit below). Where
4494
+ // the outgoing-argument verdict leans on an escape, the audit re-proves it of the lifted IR as well
4495
+ // (`escapesReliedOn`), which catches a shrink inside the lift that bypassed the question, and
4496
+ // `/setup-args` is not offered at a call that hands out such an escape (`lowerCall`'s
4497
+ // `withholdsSetupArgs`), since this definition read every argument register it holds as handed
4498
+ // over; at the function's other calls the reading asks `leftOut` of every register it cuts
4499
+ // (`asksEveryCut`), an earlier block's frame address included. Elsewhere that a value an EARLIER
4500
+ // block left is dead is the whole guess of that reading, and agbcc does leave a dead `mov r3, sp`
4501
+ // base in a call's argument registers (sa3 sub_805DADC). Those cuts are asked of the lifted IR instead: the reading is withheld where the cut leaves the object
4502
+ // it addresses with no reader (`frontend/ssa.ts` cutsUnreadFrameObjects).
4503
+ //
4504
+ // A WORD `str` hands its source register to whoever reads the memory it writes, unless the base is
4505
+ // sp or any register that may hold a frame address (`registersThatMayHoldAFrameAddress`, whole-
4506
+ // function): then the address is written INTO this frame, and nothing outside has been handed it.
4507
+ // A frame address the base reached through memory is the one this misses, and it counts as
4508
+ // leaving. A `strh`/`strb` hands over part of an address, which no reader can follow.
4509
+ function frameAddressReceivers(
4510
+ ins: Instr,
4511
+ target: TargetDescription,
4512
+ calls: CallDeclarations,
4513
+ mayHoldFrameAddress: ReadonlySet<string>,
4514
+ ): readonly string[] {
4515
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
4516
+ const targetReg = calleeRegister(ins, target);
4517
+ const declared = targetReg === null ? calls.declaredCall(ins.ops[0] ?? '?') : null;
4518
+ const words = declared?.widths.reduce((n, w) => n + Math.ceil(w / 32), 0) ?? target.argRegs.length;
4519
+ return target.argRegs.slice(0, words).filter((r) => r !== targetReg);
4520
+ }
4521
+ if (ins.mnemonic !== 'str') {
4522
+ return [];
4523
+ }
4524
+ const { base } = parseAddr(ins.ops[1] ?? '');
4525
+ return isSpReg(base) || mayHoldFrameAddress.has(reg(base)) ? [] : [reg(ins.ops[0] ?? '')];
4526
+ }
4527
+
3281
4528
  // Is the word-slot model safe for THIS function? Every disqualifier below leaves every `[sp,#k]`
3282
4529
  // access on the old path, which declines — so the answer to "not sure" is the loud one.
3283
4530
  // Returns null when the word-slot model is SAFE for this function, else the reason it is off —
@@ -3309,13 +4556,54 @@ function slotModelBlocker(
3309
4556
  // frameless function taking sp's address has nothing to model and refuses in those arms.
3310
4557
  }
3311
4558
  }
3312
- // sp must be CONSTANT wherever a slot is keyed, because the key IS the raw offset. Two shapes
3313
- // are legitimate and everything else refuses: a PROLOGUE (sp moves before the block touches the
3314
- // frame) and an EPILOGUE (sp moves after it has finished touching it, and the block returns, so
3315
- // nothing downstream can key a slot against the changed sp). A modification BETWEEN two
3316
- // accesses moves the frame under a slot already keyed, and only the entry block may deepen —
3317
- // elsewhere a pre-access modification would put the block at a different depth from the one the
3318
- // prologue established.
4559
+ const depthBlocker = frameDepthBlocker(asmBlocks);
4560
+ if (depthBlocker !== null) {
4561
+ return depthBlocker;
4562
+ }
4563
+ // OUTGOING ARGUMENTS — one analysis, and its refusals are this guard's. `analyzeOutgoingArgs`
4564
+ // decides per call whether the words staged at the bottom of the frame are a callee's
4565
+ // arguments; everything it cannot license refuses here, so a `[sp,#k]` access in such a
4566
+ // function still declines loud.
4567
+ if (outgoing.blocker !== null) {
4568
+ return outgoing.blocker;
4569
+ }
4570
+ // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
4571
+ // taken while the local area is still reserved reads a slot this model has retargeted into SSA
4572
+ // — the load simply disagrees with the store. A real epilogue releases the locals first
4573
+ // (`add sp,#N; pop {…}`), which is what this requires; `pop {r1}` mid-frame does not.
4574
+ for (const ab of asmBlocks) {
4575
+ let released = 0;
4576
+ for (const ins of ab.instrs) {
4577
+ if ((ins.mnemonic === 'pop' || ins.mnemonic === 'ldmia') && released < localArea) {
4578
+ const base = ins.mnemonic === 'pop' ? 'sp' : (ins.ops[0] ?? '').replace(/!$/, '');
4579
+ if (isSpReg(base)) {
4580
+ return 'a pop reads the frame while the local area is still reserved';
4581
+ }
4582
+ }
4583
+ if ((ins.mnemonic === 'add' || ins.mnemonic === 'sub') && isSpReg(ins.ops[0])) {
4584
+ const d = spAdjust(ins);
4585
+ if (d !== null && d > 0) {
4586
+ released += d;
4587
+ }
4588
+ }
4589
+ }
4590
+ }
4591
+ return null;
4592
+ }
4593
+
4594
+ // Is sp ONE VALUE at every frame access of this function, the one its prologue leaves? Null when it
4595
+ // is, else why not. The slot model needs it because a slot's key IS the raw offset, and an incoming
4596
+ // stack argument read outside the entry block needs it because its index is the offset less the
4597
+ // depth (`makeFrameWalk`).
4598
+ //
4599
+ // Two shapes are legitimate and everything else refuses: a PROLOGUE (sp moves before the block
4600
+ // touches the frame) and an EPILOGUE (sp moves after it has finished touching it, and the block
4601
+ // returns, so nothing downstream can key a slot against the changed sp). A modification BETWEEN two
4602
+ // accesses moves the frame under a slot already keyed, and only the entry block may deepen —
4603
+ // elsewhere a pre-access modification would put the block at a different depth from the one the
4604
+ // prologue established. So every block but the entry starts at the depth the entry leaves, and
4605
+ // only a block that returns moves sp.
4606
+ function frameDepthBlocker(asmBlocks: readonly AsmBlock[]): string | null {
3319
4607
  for (let bi = 0; bi < asmBlocks.length; bi++) {
3320
4608
  const ins = asmBlocks[bi].instrs;
3321
4609
  const mems = ins.map((x, i) => (touchesFrame(x) ? i : -1)).filter((i) => i >= 0);
@@ -3346,34 +4634,6 @@ function slotModelBlocker(
3346
4634
  }
3347
4635
  }
3348
4636
  }
3349
- // OUTGOING ARGUMENTS — one analysis, and its refusals are this guard's. `analyzeOutgoingArgs`
3350
- // decides per call whether the words staged at the bottom of the frame are a callee's
3351
- // arguments; everything it cannot license refuses here, so a `[sp,#k]` access in such a
3352
- // function still declines loud.
3353
- if (outgoing.blocker !== null) {
3354
- return outgoing.blocker;
3355
- }
3356
- // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
3357
- // taken while the local area is still reserved reads a slot this model has retargeted into SSA
3358
- // — the load simply disagrees with the store. A real epilogue releases the locals first
3359
- // (`add sp,#N; pop {…}`), which is what this requires; `pop {r1}` mid-frame does not.
3360
- for (const ab of asmBlocks) {
3361
- let released = 0;
3362
- for (const ins of ab.instrs) {
3363
- if ((ins.mnemonic === 'pop' || ins.mnemonic === 'ldmia') && released < localArea) {
3364
- const base = ins.mnemonic === 'pop' ? 'sp' : (ins.ops[0] ?? '').replace(/!$/, '');
3365
- if (isSpReg(base)) {
3366
- return 'a pop reads the frame while the local area is still reserved';
3367
- }
3368
- }
3369
- if ((ins.mnemonic === 'add' || ins.mnemonic === 'sub') && isSpReg(ins.ops[0])) {
3370
- const d = spAdjust(ins);
3371
- if (d !== null && d > 0) {
3372
- released += d;
3373
- }
3374
- }
3375
- }
3376
- }
3377
4637
  return null;
3378
4638
  }
3379
4639
 
@@ -3437,14 +4697,18 @@ function reservedLocalArea(entry: AsmBlock): number {
3437
4697
  return Math.max(0, reserved);
3438
4698
  }
3439
4699
 
4700
+ /** the low registers agbcc's callers expect back intact (r0-r3 are argument and scratch) */
4701
+ const CALLEE_SAVED_LOW_REGS: ReadonlySet<string> = new Set(['r4', 'r5', 'r6', 'r7']);
4702
+
3440
4703
  // WHAT THE PROLOGUE SAVED — the second half of the register partition (frontend/ssa.ts,
3441
4704
  // LiveInModel.uninitRegs), in operand spellings. "The ABI does not pass arguments here" is a fact
3442
4705
  // about the CALLER and cannot on its own make a def-less read an uninitialised local; what does is
3443
4706
  // that the compiler homed a local in the register, which it may only do after saving it. Asm that
3444
4707
  // saves nothing follows no such convention, and some of it really is handed live values there:
3445
4708
  // the MP2K engine's hand-written `ChnVolSetAsm`, vendored in klonoa, sa3 and pokeemerald alike,
3446
- // takes two pointers in r4/r5 and has no prologue at all — classified by the ABI alone it lost its
3447
- // signature and stored through reads of registers nothing ever wrote.
4709
+ // reads r4/r5 with no prologue at all — classified by the ABI alone it stored through reads of
4710
+ // registers nothing ever wrote. Unsaved, they are global register variables
4711
+ // (`LiveInModel.globalRegs`), which is what agbcc compiles `register u8 *p asm("r4")` to.
3448
4712
  //
3449
4713
  // PER REGISTER, because saving r5 says nothing about r4 — a mid-function fragment reached by
3450
4714
  // agbcc's `bl`-as-a-long-branch saves what it uses and is handed the rest.
@@ -3453,9 +4717,30 @@ function reservedLocalArea(entry: AsmBlock): number {
3453
4717
  // the set. `HIGH_REGS` have no `push` encoding, so agbcc saves them as `mov rLow, rHi; push
3454
4718
  // {rLow}` and the source of such a `mov` joins the set when its low register is pushed — every
3455
4719
  // high-register inhabitant in the corpus goes through that idiom.
3456
- function prologueSavedRegs(entry: AsmBlock): ReadonlySet<string> {
4720
+ //
4721
+ // THAT `mov` IS THE SAVE'S, NOT THE FUNCTION'S, and is returned as `carriers` for the fill to
4722
+ // skip. Lifted, it leaves the low register holding the high one's entry value, so where a local
4723
+ // homed in each is left uninitialised on one path (`synthetic:dma_fill_uninit`: a switch with no
4724
+ // default, ten locals spread over r5-r7 and r8-sl) the two read as one undefined value, and
4725
+ // frontend/ssa.ts `markCopiedUndefs` spells that relation as an assignment between two locals the
4726
+ // source never related. What the machine relates there is the CALLER's register to its own save
4727
+ // copy; no statement of this function computed it. Skipped, the low register's def-less read is
4728
+ // its own undefined value, which the push has saved (so it is in `saved`). The blind side, which
4729
+ // the relation this drops is: a body that reads the low register before writing it and compares
4730
+ // it with the high one sees two unrelated values where the machine holds one — agbcc only does
4731
+ // this for two distinct uninitialised locals, which no statement of the source relates.
4732
+ //
4733
+ // ONLY A CALLEE-SAVED LOW REGISTER THAT AN EARLIER `push` ALREADY SAVED IS A CARRIER. That is the
4734
+ // shape agbcc emits (`push {r4-r7, lr}; mov r7, sl; mov r6, r8; push {r6, r7}`): the low register's
4735
+ // own entry value is safe in the first save, so the `mov` destroys nothing the function could
4736
+ // read. Hand-written asm may stage through anything — `mov r3, r8; push {r3}; add r0, r0, r3`
4737
+ // reads r3 as the high register's value, and skipping that `mov` there would turn r3 into the
4738
+ // caller's fourth argument. Such a `mov` is lifted as the function's own; the high register still
4739
+ // joins `saved`, as it always has.
4740
+ function prologueSavedRegs(entry: AsmBlock): { saved: ReadonlySet<string>; carriers: ReadonlySet<Instr> } {
3457
4741
  const saved = new Set<string>();
3458
- const carries = new Map<string, string>(); // low register ← the high one moved into it
4742
+ const carriers = new Set<Instr>();
4743
+ const carries = new Map<string, { mov: Instr; carrier: boolean }>(); // low register ← the `mov` that moved a high one into it
3459
4744
  for (const x of entry.instrs) {
3460
4745
  if (x.mnemonic === 'push') {
3461
4746
  const list = regListOf(x.ops);
@@ -3464,24 +4749,403 @@ function prologueSavedRegs(entry: AsmBlock): ReadonlySet<string> {
3464
4749
  }
3465
4750
  for (const r of list) {
3466
4751
  saved.add(r);
3467
- const hi = carries.get(r);
3468
- if (hi !== undefined) {
3469
- saved.add(hi);
4752
+ const staged = carries.get(r);
4753
+ if (staged !== undefined) {
4754
+ saved.add(staged.mov.ops[1]);
4755
+ if (staged.carrier) {
4756
+ carriers.add(staged.mov);
4757
+ }
4758
+ carries.delete(r);
3470
4759
  }
3471
4760
  }
3472
4761
  } else if (/^movs?$/.test(x.mnemonic) && HIGH_REGS.has(x.ops[1] ?? '') && !HIGH_REGS.has(x.ops[0] ?? '')) {
3473
- carries.set(x.ops[0], x.ops[1]);
4762
+ carries.set(x.ops[0], { mov: x, carrier: CALLEE_SAVED_LOW_REGS.has(x.ops[0]) && saved.has(x.ops[0]) });
3474
4763
  } else if (spAdjust(x) === null) {
3475
4764
  break;
3476
4765
  }
3477
4766
  }
3478
- return saved;
4767
+ return { saved, carriers };
4768
+ }
4769
+
4770
+ /** WHAT A `push` SAVES AROUND EVERY USE, ON EVERY PATH — the prologue's (`prologueSavedRegs`) and
4771
+ * any other. A register the function names nowhere before a `push` of it, nor after the `pop`
4772
+ * that restores it, along any path from the entry, and that every path restores before it
4773
+ * returns, is saved as surely as one the prologue saves (`saved`): its writes are undone and its
4774
+ * entry value is the caller's, so it is a local's home like the prologue's
4775
+ * (`LiveInModel.uninitRegs`). Hand-written asm saves late: `cmp r0, #0; beq .L1; push {r4}; …;
4776
+ * pop {r4}; .L1: bx lr` uses r4 only between its save and its restore. A `push` is nothing but a
4777
+ * save and a `pop` nothing but a restore, which is what lets this read them anywhere; MIPS's `sw`
4778
+ * and PPC's `stw` also store a value (a global register variable passed on the stack is
4779
+ * `sw s0,16(sp)`), so those frontends read saves only in the entry block.
4780
+ *
4781
+ * A high register is saved through the `mov rLow, rHi` that stages it, and that `mov` is the
4782
+ * save's, provided `rLow` reaches the `push` unchanged — PROLOGUESAVEDREGS' RULE, here past the
4783
+ * leading run too, so the two readings of one idiom agree: `mov r0, r8; add r0, #1; push {r0}`
4784
+ * saves no r8. It is restored by `pop {rLow}; mov rHi, rLow` likewise, and that `pop` restores no
4785
+ * `rLow`: agbcc's `pop {r3, r4}; mov r8, r3; mov sl, r4; pop {r4-r7}` hands r4 sl's word first.
4786
+ *
4787
+ * THE RESTORE ENDS THE SAVE. Past it the register holds the caller's value again, so a read there
4788
+ * is the caller's value and a write there is one the caller sees, neither of them a local's —
4789
+ * and the fill keeps the `pop` transparent, so a read would see the function's last write
4790
+ * (`push {r4}; mov r4, #5; pop {r4}; mov r0, r4` would lift `return 5;`). For the same reason a save
4791
+ * that some path carries unrestored to a return the frame is balanced at — a `push` whose word is
4792
+ * popped into another register or dropped with `add sp` — saved nothing on that path. A return
4793
+ * with the word still on the stack is an input whose epilogue was left out, and is not asked: the
4794
+ * blind side is hand-written asm that returns with its frame unbalanced, which no caller survives.
4795
+ *
4796
+ * A RESTORE IS THE REGISTER'S OWN WORD, not its name. The walk keeps the stack word by word —
4797
+ * whose entry value each pushed word holds — and a `pop` (or a staging pop's `mov rHi, rLow`)
4798
+ * restores a register only when the word it loads is that register's: `push {r4, r5}; pop {r5};
4799
+ * pop {r4}` names both and swaps the caller's r4 and r5, and agbcc's staging with its two `mov`s
4800
+ * crossed swaps r8 and r9. A push labels a word with a register only while that register still
4801
+ * holds its entry value (`dirty`): pushed again after a write, a foreign pop or a save dropped
4802
+ * with `add sp`, it pushes a value of the function's, and the pop of that word restores nothing.
4803
+ * Where paths as deep disagree about a word, the word is no register's. Such a pop is a write the
4804
+ * caller sees that the fill, reading the `pop` as transparent, never shows, so a callee-saved
4805
+ * register one reaches a balanced return with is `leaked`, and refused outright (after the
4806
+ * return proof, `poppedForeign`). The blind side: where the stack is not known — paths that
4807
+ * disagree about its depth, a move of sp the frame walk cannot follow, a store through a
4808
+ * register-indexed sp address — a restore is read by name, and a store through a copy of sp
4809
+ * into a pushed word is not seen. `refuseReturnsNotThroughCallersLr`
4810
+ * asks the same word question of lr alone, on its own walk.
4811
+ *
4812
+ * `misSaved` is every register a save or a restore names that some path uses outside a save, or
4813
+ * returns with still saved: a `pop {r4}` with no `push` before it (`mov r0, r4; pop {r4}; bx lr`),
4814
+ * a `push` past a read, a use past the restore, a staged `push {r3}` of r8 popped into r1. Its
4815
+ * writes are not all the caller's and its entry value is no global's, so `registerLiveIns`
4816
+ * refuses it rather than spell it either way. "Uses" is any naming in an operand, read or write. */
4817
+ const CALL_CLOBBERS = ['r0', 'r1', 'r2', 'r3', 'r12', 'r14'];
4818
+ const ALL_REGS = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8', 'r9', 'r10', 'r11', 'r12', 'r14'];
4819
+ function bodySavedRegs(
4820
+ blocks: readonly AsmBlock[],
4821
+ preds: readonly (readonly number[])[],
4822
+ succs: readonly (readonly number[])[],
4823
+ ): {
4824
+ saved: ReadonlySet<string>;
4825
+ misSaved: ReadonlySet<string>;
4826
+ leaked: ReadonlyMap<string, Instr>;
4827
+ copied: ReadonlyMap<string, Instr>;
4828
+ returned: ReadonlyMap<string, Instr>;
4829
+ } {
4830
+ // What each instruction saves, restores and otherwise names; the staging `mov`s first.
4831
+ const savesHi = new Map<Instr, string>(); // `mov rLow, rHi` ⇒ rHi, where rLow is pushed unchanged
4832
+ const restoresHi = new Set<Instr>(); // `mov rHi, rLow` where rLow was popped unchanged
4833
+ const stagingPops = new Map<Instr, Set<string>>(); // a `pop`'s rLow that a `mov rHi, rLow` restores through
4834
+ for (const b of blocks) {
4835
+ const staged = new Map<string, Instr>(); // low register ← the `mov` that put a high one there
4836
+ const popped = new Map<string, Instr>(); // low register ← the `pop` that filled it
4837
+ for (const x of b.instrs) {
4838
+ const list = x.mnemonic === 'push' || x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : [];
4839
+ if (x.mnemonic === 'push') {
4840
+ list.forEach((r) => staged.has(r) && savesHi.set(staged.get(r)!, staged.get(r)!.ops[1]));
4841
+ }
4842
+ const isMov = /^movs?$/.test(x.mnemonic);
4843
+ const from = isMov && HIGH_REGS.has(x.ops[0] ?? '') ? popped.get(x.ops[1] ?? '') : undefined;
4844
+ if (from !== undefined) {
4845
+ restoresHi.add(x);
4846
+ stagingPops.set(from, (stagingPops.get(from) ?? new Set()).add(x.ops[1]));
4847
+ }
4848
+ const writes = x.mnemonic === 'push' ? [] : x.mnemonic === 'pop' ? list : regWritesOf(x);
4849
+ if (writes === 'all') {
4850
+ staged.clear();
4851
+ popped.clear();
4852
+ } else {
4853
+ for (const w of writes) {
4854
+ staged.delete(w);
4855
+ popped.delete(w);
4856
+ }
4857
+ }
4858
+ if (x.mnemonic === 'pop') {
4859
+ list.forEach((r) => popped.set(r, x));
4860
+ } else if (isMov && HIGH_REGS.has(x.ops[1] ?? '') && !HIGH_REGS.has(x.ops[0] ?? '')) {
4861
+ staged.set(x.ops[0], x);
4862
+ }
4863
+ }
4864
+ }
4865
+ const namedBy = (x: Instr): string[] => [
4866
+ ...(x.ops.join(',').match(/(?<![\w.#])(r1[0-5]|r\d|sb|sl|fp|ip)(?![\w])/g) ?? []),
4867
+ ...(/^(ldm|stm)/.test(x.mnemonic) ? (regListOf(x.ops.slice(1)) ?? []) : []),
4868
+ ];
4869
+ const savedSomewhere = new Set<string>();
4870
+ const restoredSomewhere = new Set<string>();
4871
+ const usedUnsaved = new Set<string>();
4872
+ const unrestored = new Set<string>(); // still saved at a balanced return on some path
4873
+ // Forward dataflow over two sets: the registers saved on EVERY path into each block (`cur`, a
4874
+ // must-set; null: not reached yet) and on SOME path (`open`, a may-set) — a use needs the first,
4875
+ // a return must find the second empty — and the frame depth (`makeFrameWalk`; null where paths
4876
+ // disagree or it is not exact).
4877
+ //
4878
+ // And the STACK, word by word (`stack`, top last; null where paths disagree about its depth or a
4879
+ // move of sp is not exact; a word null where they disagree about it): whose entry value each pushed word holds, so that a restore is a restore only when the
4880
+ // word its `pop` loads is that register's own. `pop {r5}; pop {r4}` after `push {r4, r5}` NAMES
4881
+ // both registers and restores neither — it swaps the caller's r4 and r5.
4882
+ type Stack = readonly (string | null)[];
4883
+ // `clobbered` (a may-set) is each callee-saved register a pop has loaded another word into since
4884
+ // its last restore, with that pop: the fill reads a `pop` as transparent, so no write of the body
4885
+ // shows it, and one that reaches a balanced return is refused here (`leaked`).
4886
+ type State = {
4887
+ cur: Set<string>;
4888
+ open: Set<string>;
4889
+ depth: number | null;
4890
+ stack: Stack | null;
4891
+ clobbered: Map<string, Instr>;
4892
+ dirty: Set<string>;
4893
+ };
4894
+ // `dirty` (a may-set) is each register that may no longer hold its entry value: written, or loaded
4895
+ // by a pop from a word not its own, since the entry or its last restore. A push labels a word with
4896
+ // a register only while it is not: `push {r4}; mov r4, r0; add sp, #4; push {r4}; pop {r4}` pushes
4897
+ // a0 the second time, and that pop is no restore — however its push named the register.
4898
+ const stateIn: (State | null)[] = blocks.map((_, i) =>
4899
+ i === 0 ? { cur: new Set(), open: new Set(), depth: 0, stack: [], clobbered: new Map(), dirty: new Set() } : null,
4900
+ );
4901
+ const leaked = new Map<string, Instr>();
4902
+ const copied = new Map<string, Instr>();
4903
+ const returned = new Map<string, Instr>();
4904
+ const word = (r: string) => canonReg(r) ?? r;
4905
+ const walk = (b: number, record: boolean): State => {
4906
+ const cur = new Set(stateIn[b]!.cur);
4907
+ const open = new Set(stateIn[b]!.open);
4908
+ const clobbered = new Map(stateIn[b]!.clobbered);
4909
+ const dirty = new Set(stateIn[b]!.dirty);
4910
+ const frame = makeFrameWalk({ argRegs: [], start: stateIn[b]!.depth });
4911
+ let stack: (string | null)[] | null = stateIn[b]!.stack === null ? null : [...stateIn[b]!.stack!];
4912
+ const holds = new Map<string, string>(); // low register ← the high one a save's staging `mov` copied
4913
+ const loaded = new Map<string, string | null>(); // a staging pop's low register ← whose word it loaded
4914
+ for (const [i, x] of blocks[b].instrs.entries()) {
4915
+ const before = frame.depthNow();
4916
+ frame.step(x);
4917
+ const after = frame.depthNow();
4918
+ const saves = x.mnemonic === 'push' ? (regListOf(x.ops) ?? []) : savesHi.has(x) ? [savesHi.get(x)!] : [];
4919
+ const named =
4920
+ x.mnemonic === 'pop'
4921
+ ? (regListOf(x.ops) ?? []).filter((r) => !stagingPops.get(x)?.has(r))
4922
+ : restoresHi.has(x)
4923
+ ? [x.ops[0]]
4924
+ : [];
4925
+ // Which of them the word they load really restores: all, by name, where the stack is not known.
4926
+ let own: ((r: string) => boolean) | null = null;
4927
+ const list = x.mnemonic === 'push' || x.mnemonic === 'pop' ? byRegNumber(regListOf(x.ops) ?? []) : null;
4928
+ if (x.mnemonic === 'push') {
4929
+ // a push stores its highest-numbered register deepest
4930
+ // a word is a register's own only while that register holds its entry value (`dirty`)
4931
+ const label = (r: string) => (dirty.has(holds.get(r) ?? r) ? null : (holds.get(r) ?? r));
4932
+ stack = stack === null || list === null ? null : [...stack, ...list.map(label).reverse()];
4933
+ } else if (x.mnemonic === 'pop') {
4934
+ if (stack === null || list === null || stack.length < list.length) {
4935
+ stack = null;
4936
+ } else {
4937
+ const popped = new Map(list.map((r) => [r, stack!.pop()!] as const));
4938
+ own = (r) => popped.get(word(r)) === word(r);
4939
+ stagingPops.get(x)?.forEach((r) => loaded.set(word(r), popped.get(word(r)) ?? null));
4940
+ }
4941
+ } else if (restoresHi.has(x)) {
4942
+ const from = loaded.get(word(x.ops[1]));
4943
+ own = stack === null || from === undefined ? null : (r) => from === word(r);
4944
+ } else if (stack !== null && /^str[bh]?$/.test(x.mnemonic) && isSpReg(parseAddr(x.ops[1] ?? '').base)) {
4945
+ // a store into a pushed word replaces what it holds
4946
+ const { off, regOff } = parseAddr(x.ops[1] ?? '');
4947
+ if (regOff !== undefined) {
4948
+ stack = null;
4949
+ } else if (Math.floor(off / 4) < stack.length) {
4950
+ stack[stack.length - 1 - Math.floor(off / 4)] = null;
4951
+ }
4952
+ } else if (stack !== null && before !== after) {
4953
+ // any other move of sp (`sub sp, #8`, `add sp, #4`) pushes or drops whole anonymous words
4954
+ const words = before === null || after === null || (after - before) % 4 !== 0 ? null : (after - before) / 4;
4955
+ stack =
4956
+ words === null || stack.length + words < 0
4957
+ ? null
4958
+ : words > 0
4959
+ ? [...stack, ...Array<null>(words).fill(null)]
4960
+ : stack.slice(0, stack.length + words);
4961
+ }
4962
+ const writes = x.mnemonic === 'push' ? [] : x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : regWritesOf(x);
4963
+ if (writes === 'all') {
4964
+ holds.clear();
4965
+ // a call leaves the registers the ABI has it preserve; anything else unread may write any
4966
+ (/^(bl|blx|swi)$/.test(x.mnemonic) ? CALL_CLOBBERS : ALL_REGS).forEach((r) => dirty.add(r));
4967
+ } else {
4968
+ writes.forEach((r) => {
4969
+ holds.delete(word(r));
4970
+ dirty.add(word(r));
4971
+ });
4972
+ }
4973
+ if (savesHi.has(x)) {
4974
+ holds.set(word(x.ops[0]), word(savesHi.get(x)!));
4975
+ }
4976
+ const restores = own === null ? named : named.filter(own);
4977
+ const uses =
4978
+ x.mnemonic === 'push'
4979
+ ? []
4980
+ : savesHi.has(x)
4981
+ ? [x.ops[0]]
4982
+ : [...named, ...namedBy(x), ...(x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : [])];
4983
+ if (record) {
4984
+ saves.forEach((r) => savedSomewhere.add(r));
4985
+ named.forEach((r) => restoredSomewhere.add(r));
4986
+ uses.filter((r) => !cur.has(r)).forEach((r) => usedUnsaved.add(r));
4987
+ }
4988
+ saves
4989
+ .filter((r) => !uses.includes(r))
4990
+ .forEach((r) => {
4991
+ cur.add(r);
4992
+ open.add(r);
4993
+ });
4994
+ restores.forEach((r) => {
4995
+ cur.delete(r);
4996
+ open.delete(r);
4997
+ clobbered.delete(word(r));
4998
+ dirty.delete(word(r));
4999
+ });
5000
+ // A pop that loads another register's word writes this one and restores nothing: it is no
5001
+ // longer saved, and its own save, still open, is unrestored at a balanced return.
5002
+ named
5003
+ .filter((r) => !restores.includes(r))
5004
+ .forEach((r) => {
5005
+ cur.delete(r);
5006
+ if (/^r([4-9]|1[01])$/.test(word(r))) {
5007
+ clobbered.set(word(r), x);
5008
+ } else if (record && own !== null && /^r([0-3]|12)$/.test(word(r))) {
5009
+ // a scratch register: the fill never makes the copy, so a later use of it is refused
5010
+ const use = readsBeforeWrite(blocks, succs, b, i + 1, word(r));
5011
+ const into = use === 'read' ? copied : use === 'returned' ? returned : undefined;
5012
+ if (into !== undefined && !into.has(word(r))) {
5013
+ into.set(word(r), x);
5014
+ }
5015
+ }
5016
+ });
5017
+ // Only a return the frame is balanced at: one with the `push` still on the stack is an
5018
+ // input whose epilogue was left out (a fragment, a fixture), which the prologue's reading
5019
+ // has always taken as restored.
5020
+ if (record && classifyXfer(x) === 'return' && frame.depthNow() === 0) {
5021
+ open.forEach((r) => unrestored.add(r));
5022
+ clobbered.forEach((at, r) => leaked.has(r) || leaked.set(r, at));
5023
+ }
5024
+ }
5025
+ return { cur, open, depth: frame.depthNow(), stack, clobbered, dirty };
5026
+ };
5027
+ const sameStack = (x: Stack | null, y: Stack | null) =>
5028
+ x === null || y === null ? x === y : x.length === y.length && x.every((w, i) => w === y[i]);
5029
+ // Paths whose stacks are as deep keep each word they agree on, and the rest is no register's: a
5030
+ // pop of it restores nothing. Paths that disagree about the depth leave the stack unknown.
5031
+ const joinStacks = (xs: readonly (Stack | null)[]): Stack | null =>
5032
+ xs.some((x) => x === null || x.length !== xs[0]!.length)
5033
+ ? null
5034
+ : xs[0]!.map((w, i) => (xs.every((x) => x![i] === w) ? w : null));
5035
+ const sameSet = (x: ReadonlySet<string>, y: ReadonlySet<string>) =>
5036
+ x.size === y.size && [...x].every((r) => y.has(r));
5037
+ for (let changed = true; changed;) {
5038
+ changed = false;
5039
+ for (let b = 1; b < blocks.length; b++) {
5040
+ const ins = preds[b].filter((p) => stateIn[p] !== null).map((p) => walk(p, false));
5041
+ if (ins.length === 0) {
5042
+ continue;
5043
+ }
5044
+ const cur = new Set([...ins[0].cur].filter((r) => ins.every((s) => s.cur.has(r))));
5045
+ const open = new Set(ins.flatMap((s) => [...s.open]));
5046
+ const depth = ins.every((s) => s.depth === ins[0].depth) ? ins[0].depth : null;
5047
+ const stack = joinStacks(ins.map((s) => s.stack));
5048
+ const clobbered = new Map(ins.flatMap((s) => [...s.clobbered]));
5049
+ const dirty = new Set(ins.flatMap((s) => [...s.dirty]));
5050
+ const was = stateIn[b];
5051
+ if (
5052
+ was === null ||
5053
+ was.cur.size !== cur.size ||
5054
+ was.open.size !== open.size ||
5055
+ was.depth !== depth ||
5056
+ !sameStack(was.stack, stack) ||
5057
+ was.clobbered.size !== clobbered.size ||
5058
+ !sameSet(was.dirty, dirty)
5059
+ ) {
5060
+ stateIn[b] = { cur, open, depth, stack, clobbered, dirty };
5061
+ changed = true;
5062
+ }
5063
+ }
5064
+ }
5065
+ blocks.forEach((_, b) => stateIn[b] !== null && walk(b, true));
5066
+ const mis = (r: string) => usedUnsaved.has(r) || unrestored.has(r);
5067
+ return {
5068
+ saved: new Set([...savedSomewhere].filter((r) => !mis(r))),
5069
+ misSaved: new Set([...savedSomewhere, ...restoredSomewhere].filter(mis)),
5070
+ leaked,
5071
+ copied,
5072
+ returned,
5073
+ };
5074
+ }
5075
+
5076
+ /** Whether some path from instruction `from` of block `b` uses what scratch register `reg` holds
5077
+ * there as a VALUE before writing it: 'read' for an operand that reads it (a `push` of it among
5078
+ * them) or a call (which may be handed r0-r3), else 'returned' where a return may hand it back (r0
5079
+ * or r1), which is a use only in a function not declared to return nothing. A `bx` through it is
5080
+ * the return idiom `pop {r1}; bx r1` (agbcc's `pop {r3}; add sp, #0x10; bx r3` after a variadic
5081
+ * save), which uses the word as where to go; a write that reads no `reg` ends the path. Any
5082
+ * spelling of the register counts (`ip` is r12).
5083
+ *
5084
+ * `calls`, where a caller passes it, reads a `bl`/`blx` by what it is handed instead: a call that
5085
+ * names `reg` or whose `reads` include it reads it, one that clobbers it (`clobbers`) ends the
5086
+ * path, and past one that does neither the callee preserved it and the walk goes on. A `swi` is
5087
+ * read as without it. */
5088
+ function readsBeforeWrite(
5089
+ blocks: readonly AsmBlock[],
5090
+ succs: readonly (readonly number[])[],
5091
+ b: number,
5092
+ from: number,
5093
+ reg: string,
5094
+ calls?: { readonly reads: (call: Instr) => readonly string[]; readonly clobbers: readonly string[] },
5095
+ ): 'read' | 'returned' | false {
5096
+ const names = (x: Instr): boolean =>
5097
+ expandRegList(
5098
+ x.ops
5099
+ .join(' ')
5100
+ .toLowerCase()
5101
+ .replace(/[[\]{}!#]/g, ' ')
5102
+ .split(/[,\s]+/)
5103
+ .filter(Boolean),
5104
+ ).some((t) => canonReg(t) === reg);
5105
+ const seen = new Set<number>();
5106
+ let returned = false;
5107
+ // true where this path reads it; a path that returns it notes so and reads nothing
5108
+ const path = (at: number, start: number): boolean => {
5109
+ for (const x of blocks[at].instrs.slice(start)) {
5110
+ const mn = x.mnemonic;
5111
+ if (classifyXfer(x) === 'return') {
5112
+ returned ||= !(mn === 'bx' && canonReg(x.ops[0] ?? '') === reg) && (reg === 'r0' || reg === 'r1');
5113
+ return false;
5114
+ }
5115
+ if (calls !== undefined && (mn === 'bl' || mn === 'blx')) {
5116
+ if (names(x) || calls.reads(x).some((r) => canonReg(r) === reg)) {
5117
+ return true;
5118
+ }
5119
+ if (calls.clobbers.some((r) => canonReg(r) === reg)) {
5120
+ return false;
5121
+ }
5122
+ continue;
5123
+ }
5124
+ if (/^(bl|blx|swi)$/.test(mn)) {
5125
+ return reg !== 'r12';
5126
+ }
5127
+ if (names(x)) {
5128
+ const dest = canonReg(x.ops[0] ?? '');
5129
+ // Thumb's three-operand forms (`add rD, rN, #i`, `add rD, sp, #k`, `lsl rD, rM, #s`, `sub rD,
5130
+ // rN, rM`) read only their sources; the two-operand ones (`add rD, #i`, `mul rD, rM`) read rD.
5131
+ const writesOnly =
5132
+ mn === 'pop' ||
5133
+ ((DEST_ONLY.test(mn) || SINGLE_LOAD.test(mn) || (WRITES_OPERAND_0.test(mn) && x.ops.length === 3)) &&
5134
+ dest === reg &&
5135
+ !names({ ...x, ops: x.ops.slice(1) }));
5136
+ return !writesOnly;
5137
+ }
5138
+ }
5139
+ return succs[at].some((s) => !seen.has(s) && (seen.add(s), path(s, 0)));
5140
+ };
5141
+ return path(b, from) ? 'read' : returned ? 'returned' : false;
3479
5142
  }
3480
5143
 
3481
5144
  /** How the fill reads and writes an operand: the guards that keep sp, pc and a data label from
3482
5145
  * being read or written as a register, and the function-scope statics a pool word names. */
3483
5146
  interface ThumbOperands {
3484
5147
  localStatic(symbol: string): string;
5148
+ anonymousObject(label: string): string;
3485
5149
  constVal(n: number, b: number): Value;
3486
5150
  spAsDataError(): FrontendUnsupportedError;
3487
5151
  readData(r: string, b: number): Value;
@@ -3523,6 +5187,22 @@ function thumbOperands({
3523
5187
  return statics.define(read);
3524
5188
  };
3525
5189
 
5190
+ /** The compiler's anonymous constant a pool word names (agbcc's `.LC0`), read out of this same
5191
+ * asm and recorded beside the statics — or a decline naming why it is not one. Unlike a static,
5192
+ * it need not be this function's alone: agbcc places one image for every identical constant in
5193
+ * a unit (varasm.c `output_constant_def` hashes them), and each function that copies it spells
5194
+ * its own initializer, which the recompiled unit shares again. */
5195
+ const anonymousObject = (label: string): string => {
5196
+ const read = readGasAnonymousObject(asm, label);
5197
+ if ('refused' in read) {
5198
+ throw new FrontendUnsupportedError(
5199
+ `cannot lift '${name}': literal-pool load of a pool word that names the compiler's anonymous data ` +
5200
+ `('${label}') ${read.refused}`,
5201
+ );
5202
+ }
5203
+ return statics.define(read);
5204
+ };
5205
+
3526
5206
  const constVal = (n: number, b: number): Value => {
3527
5207
  const v = mkValue(T.unk(32));
3528
5208
  irBlocks[b].ops.push(mkOp('const', { results: [v], attrs: { value: n } }));
@@ -3632,7 +5312,7 @@ function thumbOperands({
3632
5312
  }
3633
5313
  writeVar(r, b, v);
3634
5314
  };
3635
- return { localStatic, constVal, spAsDataError, readData, writeData };
5315
+ return { localStatic, anonymousObject, constVal, spAsDataError, readData, writeData };
3636
5316
  }
3637
5317
 
3638
5318
  /** One half of a 64-bit value this lift projected into a register. */
@@ -3647,21 +5327,30 @@ interface PairHalf {
3647
5327
  interface ThumbPairs {
3648
5328
  readonly halfOf: ReadonlyMap<Value, PairHalf>;
3649
5329
  readonly wordReturns: { bi: number; lo: Value; widerEpilogue: boolean }[];
5330
+ /** what r1 may hold at each word return, where no declaration pins the width (dead-reads.ts
5331
+ * `unreadPairHalf`) */
5332
+ readonly unreadHigh: Set<Value>;
3650
5333
  projectHalf(irb: Block, whole: Value, half: 'lo' | 'hi', at: Instr): Value;
3651
5334
  fuseHalves(irb: Block, lo: Value, hi: Value): Value;
3652
5335
  wideReturn(lo: Value, bi: number): Value | null;
5336
+ /** record a return of one word in block `bi`, for `refuseWordReturns` and the dead-read pin */
5337
+ noteWordReturn(bi: number, lo: Value, widerEpilogue: boolean): void;
3653
5338
  refuseWordReturns(): void;
3654
5339
  }
3655
5340
 
3656
5341
  function thumbPairs({
3657
5342
  name,
3658
5343
  target,
5344
+ own,
5345
+ ownMemoryReturn,
3659
5346
  ssa,
3660
5347
  asmBlocks,
3661
5348
  callClobbers,
3662
5349
  }: {
3663
5350
  name: string;
3664
5351
  target: TargetDescription;
5352
+ own: FnProto | undefined;
5353
+ ownMemoryReturn: LiftOptions['ownMemoryReturn'];
3665
5354
  ssa: SsaBuilder;
3666
5355
  asmBlocks: readonly AsmBlock[];
3667
5356
  callClobbers: readonly string[];
@@ -3725,6 +5414,7 @@ function thumbPairs({
3725
5414
  // epilogue says the return type is 5 to 8 bytes — judged by `refuseWordReturns` once every block
3726
5415
  // is filled.
3727
5416
  const wordReturns: ThumbPairs['wordReturns'] = [];
5417
+ const unreadHigh = new Set<Value>();
3728
5418
  /** THE WORD RETURNS THAT WOULD DROP A HIGH HALF, refused. Run after every block is filled and
3729
5419
  * before `ssa.finish()`: a join's phi operands are wired by then, and not yet folded away.
3730
5420
  *
@@ -3741,7 +5431,10 @@ function thumbPairs({
3741
5431
  * eightByteReturnScratch`), over r0:r1 that hold no pair this lift built — a high word computed
3742
5432
  * from shifts, a `double`, or an 8-byte aggregate returned through memory, which agbcc sizes
3743
5433
  * the same way. A word return drops r1 in the first two, and the third is a false decline the
3744
- * epilogue cannot tell apart from them. */
5434
+ * epilogue cannot tell apart from them — save where the function's own declaration says it
5435
+ * returns a struct of 5 to 8 bytes through memory (`ownMemoryReturn`): the refusal is then
5436
+ * deferred to the pipeline, which throws it unless the lift proves r0 is only that struct's
5437
+ * storage (frontend/own-struct-return.ts), and that proof is what tells the third case apart. */
3745
5438
  const refuseWordReturns = () => {
3746
5439
  const classes = halfOf.size ? mergeClasses({ blocks: ssa.irBlocks }) : new Map<Value, readonly Value[]>();
3747
5440
  const carries = (v: Value, half: 'lo' | 'hi') => (classes.get(v) ?? [v]).some((m) => halfOf.get(m)?.half === half);
@@ -3758,14 +5451,31 @@ function thumbPairs({
3758
5451
  );
3759
5452
  }
3760
5453
  if (widerEpilogue) {
3761
- throw new FrontendUnsupportedError(
3762
- `cannot lift '${name}': the epilogue of '${asmBlocks[bi].label}' says the return type is 5 to 8 bytes ` +
3763
- '(a 64-bit integer, a double, or an 8-byte aggregate), and no 64-bit pair this lift built is in r0:r1',
3764
- );
5454
+ const label = asmBlocks[bi].label;
5455
+ // `unproved`: why the declared struct, which would explain the epilogue, is not shown to be it
5456
+ const refuse = (unproved?: string): never => {
5457
+ throw new FrontendUnsupportedError(
5458
+ `cannot lift '${name}': the epilogue of '${label}' says the return type is 5 to 8 bytes ` +
5459
+ '(a 64-bit integer, a double, or an 8-byte aggregate), and no 64-bit pair this lift built is in r0:r1' +
5460
+ (unproved === undefined
5461
+ ? ''
5462
+ : `; the ${ownMemoryReturn!.bytes}-byte struct its own declaration returns through r0 would explain it, ` +
5463
+ `but the lift cannot show r0 is only that struct's storage: ${unproved}`),
5464
+ );
5465
+ };
5466
+ if (ownMemoryReturn !== undefined && ownMemoryReturn.bytes > 4 && ownMemoryReturn.bytes <= 8) {
5467
+ ownMemoryReturn.defer(refuse);
5468
+ continue;
5469
+ }
5470
+ refuse();
3765
5471
  }
3766
5472
  }
3767
5473
  };
3768
- return { halfOf, wordReturns, projectHalf, fuseHalves, wideReturn, refuseWordReturns };
5474
+ const noteWordReturn = (bi: number, lo: Value, widerEpilogue: boolean): void => {
5475
+ wordReturns.push({ bi, lo, widerEpilogue });
5476
+ unreadPairHalf(ssa, own, target.argRegs[1], bi, unreadHigh);
5477
+ };
5478
+ return { halfOf, wordReturns, unreadHigh, noteWordReturn, projectHalf, fuseHalves, wideReturn, refuseWordReturns };
3769
5479
  }
3770
5480
 
3771
5481
  /** Whether, from instruction `from` of block `bi` to its end, nothing puts into the pair's high
@@ -3840,6 +5550,10 @@ function highRegisterHeld(ab: AsmBlock, from: number, hi: string, callClobbers:
3840
5550
  interface ThumbFlagCarry {
3841
5551
  readonly exitCmp: Map<number, PendingCmp | string>;
3842
5552
  inheritedCmp(bi: number): PendingCmp | string;
5553
+ /** every value a `cmp` compared, whether or not a branch reads the flags it set: a compare
5554
+ * nothing reads leaves no op, so this is the only record that the value was consumed (the
5555
+ * dead-read pin's `compared`, frontend/dead-reads.ts) */
5556
+ readonly compared: Set<Value>;
3843
5557
  }
3844
5558
 
3845
5559
  function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmBlock, JumpTable>): ThumbFlagCarry {
@@ -3875,7 +5589,7 @@ function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmB
3875
5589
  return plast !== undefined && classifyXfer(plast) === 'cond';
3876
5590
  },
3877
5591
  });
3878
- return { exitCmp, inheritedCmp };
5592
+ return { exitCmp, inheritedCmp, compared: new Set<Value>() };
3879
5593
  }
3880
5594
 
3881
5595
  /** The two frame facts the fill accumulates and the frame-object audit reads: every offset the
@@ -3885,6 +5599,9 @@ function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmB
3885
5599
  interface ThumbFrameUses {
3886
5600
  readonly usedSlotOffsets: Set<number>;
3887
5601
  readonly movedCaptures: Set<Value>;
5602
+ /** the blocks other than the entry that read an incoming stack argument
5603
+ * (`ThumbFrame.settledDepth`), which `lowerCall`'s `withholdsSetupArgs` reads */
5604
+ readonly offEntryArgReads: Set<number>;
3888
5605
  }
3889
5606
 
3890
5607
  /** WHAT THE FILL OF A BLOCK READS: the records of the stages before it, the SSA builder it writes
@@ -3904,6 +5621,20 @@ interface ThumbFill {
3904
5621
  /** the function-scope statics the operands define, finished into the lifted function's objects */
3905
5622
  readonly statics: LocalStatics;
3906
5623
  readonly frameUses: ThumbFrameUses;
5624
+ /** What an elided or kept dispatch left in its idiom's registers, by the value standing for each. */
5625
+ readonly staleIdiomValues: Map<Value, StaleIdiomRead>;
5626
+ /** What an elided dispatch left at each case target's entry, by block index. */
5627
+ readonly caseEntries: ReadonlyMap<number, CaseEntry>;
5628
+ /** Each callee-saved register a `pop` loads another word into and that reaches a balanced return
5629
+ * so (`bodySavedRegs`'s `leaked`), with that pop: refused once the return proof has run. */
5630
+ readonly poppedForeign: ReadonlyMap<string, Instr>;
5631
+ /** Each caller-saved register a `pop` loads another word into and some path then reads as a value
5632
+ * before writing it (`bodySavedRegs`'s `copied`, `readsBeforeWrite`), with that pop: refused once
5633
+ * the return proof has run. */
5634
+ readonly poppedCopies: ReadonlyMap<string, Instr>;
5635
+ /** …and each one a return may hand back so (`bodySavedRegs`'s `returned`): refused unless the
5636
+ * function's own declaration returns nothing. */
5637
+ readonly poppedReturns: ReadonlyMap<string, Instr>;
3907
5638
  }
3908
5639
 
3909
5640
  /** EVERY STAGE BEFORE THE FILL, in their refusal order: the text, the CFG, the target's scratch
@@ -3917,33 +5648,101 @@ function thumbFillOf(
3917
5648
  prototypes: Prototypes,
3918
5649
  symbols: SymbolMap | undefined,
3919
5650
  oneObject: FrameRange | undefined,
5651
+ {
5652
+ contextOwnWords,
5653
+ contextOwnProto,
5654
+ ownMemoryReturn,
5655
+ calleeBodyBound,
5656
+ calleeStackWords,
5657
+ stackSized,
5658
+ entryReadCalls,
5659
+ }: Pick<
5660
+ LiftOptions,
5661
+ | 'contextOwnWords'
5662
+ | 'contextOwnProto'
5663
+ | 'ownMemoryReturn'
5664
+ | 'calleeBodyBound'
5665
+ | 'calleeStackWords'
5666
+ | 'stackSized'
5667
+ | 'entryReadCalls'
5668
+ > = {},
5669
+ homesReleased = false,
3920
5670
  ): ThumbFill {
3921
- const text = readThumbText(name, asm);
5671
+ const text = readThumbText(name, asm, target.compilerBehaviors.farJumpRelay?.comment);
3922
5672
  const cfg = thumbCfg(name, text.blocks, text.tables);
3923
- const scratchRegs = assertScratchRegsPartitioned(target);
3924
- const calls = callDeclarations(name, target, prototypes, THUMB_CALL_LOWERING, (message) => {
3925
- throw new FrontendUnsupportedError(message);
5673
+ assertScratchRegsPartitioned(target); // first, for the refusal order; `registerLiveIns` asks again
5674
+ const calls = callDeclarations(
5675
+ name,
5676
+ target,
5677
+ prototypes,
5678
+ THUMB_CALL_LOWERING,
5679
+ (message) => {
5680
+ throw new FrontendUnsupportedError(message);
5681
+ },
5682
+ () => ownDeclarationOf(name, prototypes, contextOwnProto),
5683
+ calleeBodyBound,
5684
+ entryReadCalls,
5685
+ stackSized,
5686
+ );
5687
+ const frame = measureThumbFrame({
5688
+ name,
5689
+ target,
5690
+ cfg,
5691
+ calls,
5692
+ oneObject,
5693
+ homesReleased,
5694
+ own: ownDeclarationOf(name, prototypes, contextOwnProto),
5695
+ dropsVoidReturn: declaresVoidReturn(prototypes[name]),
5696
+ stackWordsOf: (callee) =>
5697
+ asksCalleeBody(Object.hasOwn(prototypes, callee) ? prototypes[callee] : undefined)
5698
+ ? calleeStackWords?.(callee)
5699
+ : undefined,
3926
5700
  });
3927
- const frame = measureThumbFrame({ target, cfg, calls, oneObject });
3928
5701
 
3929
5702
  // --- ISA-neutral SSA construction (shared Braun builder) ---
3930
5703
  // THE LIVE-IN MODEL is the frame's partition (`ThumbFrame.partition`) and a register half.
3931
5704
  // The register half needs both of its facts, and they come from different places. The target says
3932
5705
  // which registers no caller can hand a value over in; `savedRegs` says which ones THIS function
3933
- // saved, and so could have homed a local in. A register in only the first is one the ABI does not
3934
- // describe — hand-written asm with a private convention, or a mid-function fragment — and it keeps
3935
- // the treatment a target claiming no partition gets. The save is asked only of the registers the
3936
- // ABI requires preserving: `target.scratchRegs` need none, so demanding one there would refuse a
3937
- // local the compiler was entitled to put in place with no prologue at all.
3938
- const ssa = makeSsaBuilder(name, cfg.asmBlocks.length, cfg.preds, () => ({
3939
- ...frame.partition,
3940
- ...(target.nonArgRegs
3941
- ? {
3942
- uninitRegs: target.nonArgRegs.filter((r) => scratchRegs.has(r) || frame.savedRegs.has(r)),
3943
- argRegs: target.argRegs,
3944
- }
3945
- : {}),
3946
- }));
5706
+ // saved, and so could have homed a local in — in its prologue, or by a `push` ahead of every use
5707
+ // (`bodySavedRegs`). A register in only the first is one the function shares with every other: a
5708
+ // global register variable, or a refusal where the target cannot spell one. The save is asked only
5709
+ // of the registers the ABI requires preserving: `target.scratchRegs` need none, so demanding one
5710
+ // there would refuse a local the compiler was entitled to put in place with no prologue at all.
5711
+ // `registerLiveIns` (frontend/ssa.ts) composes it, as it does for PPC. The function's own
5712
+ // declaration bounds the argument registers (`ownArgumentWordsOn`): agbcc homes a local in r3 past
5713
+ // a two-parameter one.
5714
+ // A prologue save that some path uses past its restore, or carries to a return unrestored, is
5715
+ // `misSaved` and no save: the prologue's reading of it sees only the `push`.
5716
+ const body = bodySavedRegs(cfg.asmBlocks, cfg.preds, cfg.succs);
5717
+ const liveIns = registerLiveIns(
5718
+ target,
5719
+ new Set([...frame.savedRegs, ...body.saved].filter((r) => !body.misSaved.has(r))),
5720
+ contextOwnWords ?? ownArgumentWordsOn(name, prototypes[name], target),
5721
+ body.misSaved,
5722
+ );
5723
+ const ssa = makeSsaBuilder(
5724
+ name,
5725
+ cfg.asmBlocks.length,
5726
+ cfg.preds,
5727
+ () => ({ ...frame.partition, ...liveIns, saveSite: '`push` ahead of every use' }),
5728
+ undefined,
5729
+ liveIns.globalRegs,
5730
+ );
5731
+ // Where agbcc put each block (ir/core.ts `AsmLayout`), read off the text the CFG was cut from:
5732
+ // the transfer `classifyXfer` names is the block's branch, and everything before it its body.
5733
+ ssa.fn.asmLayout = recordAsmLayout(
5734
+ ssa.irBlocks,
5735
+ cfg.asmBlocks.map((ab) => {
5736
+ const xfer = ab.instrs.length ? classifyXfer(ab.instrs[ab.instrs.length - 1]) : null;
5737
+ const branch = xfer === 'uncond' || xfer === 'cond';
5738
+ return {
5739
+ body: branch ? ab.instrs.slice(0, -1) : ab.instrs,
5740
+ endsInJump: xfer === 'uncond',
5741
+ // a far-jump relay wears `bl` and is a branch (`classifyXfer`)
5742
+ isCall: (ins) => (ins.mnemonic === 'bl' && !(ins as Instr).farJump) || ins.mnemonic === 'blx',
5743
+ };
5744
+ }),
5745
+ );
3947
5746
  const statics = makeLocalStatics();
3948
5747
  const operands = thumbOperands({
3949
5748
  name,
@@ -3953,7 +5752,15 @@ function thumbFillOf(
3953
5752
  statics,
3954
5753
  slotsOffReason: frame.slotsOffReason,
3955
5754
  });
3956
- const pairs = thumbPairs({ name, target, ssa, asmBlocks: cfg.asmBlocks, callClobbers: calls.callClobbers });
5755
+ const pairs = thumbPairs({
5756
+ name,
5757
+ target,
5758
+ own: prototypes[name],
5759
+ ownMemoryReturn,
5760
+ ssa,
5761
+ asmBlocks: cfg.asmBlocks,
5762
+ callClobbers: calls.callClobbers,
5763
+ });
3957
5764
  const flags = thumbFlagCarry(cfg, text.tables);
3958
5765
  return {
3959
5766
  name,
@@ -3968,10 +5775,38 @@ function thumbFillOf(
3968
5775
  pairs,
3969
5776
  flags,
3970
5777
  statics,
3971
- frameUses: { usedSlotOffsets: new Set(), movedCaptures: new Set() },
5778
+ frameUses: {
5779
+ usedSlotOffsets: new Set(),
5780
+ movedCaptures: new Set(),
5781
+ offEntryArgReads: new Set(),
5782
+ },
5783
+ staleIdiomValues: new Map(),
5784
+ caseEntries: elidedCaseEntries(cfg, text.tables),
5785
+ poppedForeign: body.leaked,
5786
+ poppedCopies: body.copied,
5787
+ poppedReturns: body.returned,
3972
5788
  };
3973
5789
  }
3974
5790
 
5791
+ /** Each case target of an ELIDED dispatch, with what the idiom left at its entry (jump-table.ts
5792
+ * `caseEntries`). A kept dispatch's block is lifted and leaves its markers at its own end
5793
+ * (`lowerTerminator`), where the default path never goes. */
5794
+ function elidedCaseEntries(cfg: ThumbCfg, tables: ReadonlyMap<AsmBlock, JumpTable>): Map<number, CaseEntry> {
5795
+ const at = (label: string) => cfg.labelIndex.get(label)!;
5796
+ return caseEntries(
5797
+ [...tables]
5798
+ .filter(([, jt]) => jt.extras.length === 0)
5799
+ .map(([ab, jt]) => ({
5800
+ at: at(ab.label),
5801
+ scrutReg: jt.scrutReg,
5802
+ cases: jt.caseLabels.map(at),
5803
+ defaultBlock: at(jt.defaultLabel),
5804
+ leaves: jt.leaves,
5805
+ })),
5806
+ cfg.preds,
5807
+ );
5808
+ }
5809
+
3975
5810
  type FrameWalk = ReturnType<typeof makeFrameWalk>;
3976
5811
 
3977
5812
  /** ONE BLOCK BEING FILLED, and the three things that change as its instructions are read: the
@@ -3985,10 +5820,28 @@ interface BlockCursor {
3985
5820
  pendingCmp: PendingCmp | null;
3986
5821
  noCmpWhy: string;
3987
5822
  consumed: Instr | null;
5823
+ /** A kept jump-table dispatch's scrutinee, read at the block's entry (`lowerTerminator`). */
5824
+ readonly dispatchScrutinee: Value | null;
3988
5825
  }
3989
5826
 
3990
- function openBlockCursor({ target, cfg, ssa, flags }: ThumbFill, ab: AsmBlock, bi: number): BlockCursor {
5827
+ function openBlockCursor(fill: ThumbFill, ab: AsmBlock, bi: number): BlockCursor {
5828
+ const { target, text, cfg, ssa, flags, frame } = fill;
3991
5829
  const irb = ssa.irBlocks[bi];
5830
+ // A case target of an elided dispatch is entered with what the idiom left in its registers.
5831
+ const entry = fill.caseEntries.get(bi);
5832
+ if (entry !== undefined) {
5833
+ enterCaseTarget(
5834
+ entry,
5835
+ irb.ops,
5836
+ {
5837
+ key: reg,
5838
+ scrutinee: (r) => ssa.readVar(reg(r), bi),
5839
+ dispatchScrutinee: (r) => ssa.readAtEnd(reg(r), entry.at),
5840
+ write: (r, v) => ssa.writeVar(reg(r), bi, v),
5841
+ },
5842
+ fill.staleIdiomValues,
5843
+ );
5844
+ }
3992
5845
  // Seeded at the block's FIRST instruction, not at its terminator, so an inherited compare is
3993
5846
  // judged by the in-block clear (`retireFlags`) exactly as one this block made itself would be: a
3994
5847
  // block that inherits flags and then writes its own must lose them.
@@ -3998,13 +5851,15 @@ function openBlockCursor({ target, cfg, ssa, flags }: ThumbFill, ab: AsmBlock, b
3998
5851
  // that made a compare and then overwrote it is a different gap from one that never had a
3999
5852
  // compare at all, and a single message for both makes two gaps read as one.
4000
5853
  const noCmpWhy = typeof inherited === 'string' ? inherited : '';
4001
- // Tracks the frame through this block's linear instruction order. Meaningful for the entry
4002
- // block; elsewhere a `[sp,#N]` access declines. Both dependencies are read HERE rather than
4003
- // closed over: `preds` is final long before the first `fillThumbBlock` runs, so the boolean is the
4004
- // same for every block, and passing it in makes that a property of this line instead of a
4005
- // property of wherever the walk happens to be declared.
4006
- const walk = makeFrameWalk({ argRegs: target.argRegs, entryHasPreds: cfg.preds[0].length > 0 });
4007
- return { ab, bi, irb, walk, pendingCmp, noCmpWhy, consumed: null };
5854
+ // Tracks the frame through this block's linear instruction order, from the depth the block starts
5855
+ // at: 0 for an entry block nothing branches back to (its params are parameters, not phis), and
5856
+ // the depth the entry leaves for every other block where the frame is proven settled
5857
+ // (`ThumbFrame.settledDepth`). Elsewhere a `[sp,#N]` access declines.
5858
+ const start = bi === 0 ? (cfg.preds[0].length > 0 ? null : 0) : frame.settledDepth;
5859
+ const walk = makeFrameWalk({ argRegs: target.argRegs, start });
5860
+ const jt = text.tables.get(ab);
5861
+ const dispatchScrutinee = jt && jt.extras.length > 0 ? ssa.readVar(reg(jt.scrutReg), bi) : null;
5862
+ return { ab, bi, irb, walk, pendingCmp, noCmpWhy, consumed: null, dispatchScrutinee };
4008
5863
  }
4009
5864
 
4010
5865
  /** Fill block `bi`: lower each instruction in order, record what the block leaves in the flags,
@@ -4031,6 +5886,9 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4031
5886
  switch (ins.mnemonic) {
4032
5887
  case 'mov':
4033
5888
  case 'movs': {
5889
+ if (fill.frame.saveCarriers.has(ins)) {
5890
+ break; // the save's staging copy, not the function's (`prologueSavedRegs`)
5891
+ }
4034
5892
  lowerMov(fill, cur, ins);
4035
5893
  break;
4036
5894
  }
@@ -4166,6 +6024,11 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4166
6024
  }
4167
6025
  const rhs = b.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
4168
6026
  cur.pendingCmp = { lhs: readData(reg(a), bi), rhs };
6027
+ fill.flags.compared.add(cur.pendingCmp.lhs).add(rhs);
6028
+ break;
6029
+ }
6030
+ case 'cmn': {
6031
+ lowerCmn(fill, cur, ins);
4169
6032
  break;
4170
6033
  }
4171
6034
  case 'ldr':
@@ -4209,6 +6072,25 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4209
6072
  lowerTerminator(fill, cur);
4210
6073
  }
4211
6074
 
6075
+ /** `cmn rA, rB` as the compare it stands for, `cmp rA, -rB`: it records both registers' values and
6076
+ * the terminator mints the negation once it knows which branch reads it (`CMN_EXACT`). Thumb-1
6077
+ * encodes `cmn` on low registers only; hand-written text with any other operand takes the flags
6078
+ * with nothing modelled, exactly as `tst` does. */
6079
+ function lowerCmn(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
6080
+ const { readData } = fill.operands;
6081
+ const [a, b] = ins.ops;
6082
+ if (a === undefined || b === undefined || !/^r[0-7]$/.test(reg(a)) || !/^r[0-7]$/.test(reg(b))) {
6083
+ cur.noCmpWhy = `the flags it tests were written by 'cmn' in '${cur.ab.label}', and only a register 'cmn' is modelled`;
6084
+ cur.pendingCmp = null;
6085
+ emitOpaqueDest(fill, cur, ins);
6086
+ return;
6087
+ }
6088
+ const lhs = readData(reg(a), cur.bi);
6089
+ const rhs = readData(reg(b), cur.bi);
6090
+ cur.pendingCmp = { lhs, rhs, cmn: true };
6091
+ fill.flags.compared.add(lhs).add(rhs);
6092
+ }
6093
+
4212
6094
  /** What an instruction does to the flags the block holds: a writer retires the compare, and
4213
6095
  * `noCmpWhy` names it. */
4214
6096
  function retireFlags(cur: BlockCursor, ins: Instr): void {
@@ -4238,7 +6120,8 @@ function retireFlags(cur: BlockCursor, ins: Instr): void {
4238
6120
  // The inhabitant the guard itself exists for is hand-written asm in the playground, where
4239
6121
  // there is no oracle to catch a lie.
4240
6122
  //
4241
- // `tst`/`cmn` take them with no destination to read at all (`FLAG_ONLY`, above).
6123
+ // `tst` takes them with no destination to read at all (`FLAG_ONLY`, above). `cmn` is a compare
6124
+ // and seeds its own (`lowerCmn`), as `cmp` does.
4242
6125
  //
4243
6126
  // A CALL takes them too, and this is the one flag writer that is not an instruction: `bl`
4244
6127
  // writes no flags, but the function it enters retires compares of its own, and the ABI lets
@@ -4428,6 +6311,11 @@ function lowerMov(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4428
6311
  // a reserved local area for the object to live in.
4429
6312
  if (!b?.startsWith('#') && isSpReg(b ?? '') && !isSpReg(a ?? '')) {
4430
6313
  if (frame.slotsOk && frame.localArea > 0) {
6314
+ // a copy only a narrow slot reload reads names no object; the reload reads the slot
6315
+ if (frame.narrowReloadCopies.has(ins)) {
6316
+ writeNarrowCopyMarker(fill, cur, reg(a));
6317
+ return;
6318
+ }
4431
6319
  const res = mkValue(T.unk(32));
4432
6320
  irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: 0 } }));
4433
6321
  writeData(reg(a), bi, res);
@@ -4439,6 +6327,18 @@ function lowerMov(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4439
6327
  writeData(reg(a), bi, v);
4440
6328
  }
4441
6329
 
6330
+ /** What a narrow reload's unlifted copy of sp leaves in `r`: an address no C spells.
6331
+ * `measureThumbFrame` accepts the copy only where `r` is dead past its load, and the marker holds
6332
+ * the lift to that: a read of it declines (`refuseStaleIdiomReads`) rather than reading what `r`
6333
+ * held before the copy. */
6334
+ function writeNarrowCopyMarker(fill: ThumbFill, cur: BlockCursor, r: string): void {
6335
+ fill.operands.writeData(
6336
+ r,
6337
+ cur.bi,
6338
+ staleMarker(cur.irb.ops, r, { reg: r, form: 'narrow', parks: false }, fill.staleIdiomValues),
6339
+ );
6340
+ }
6341
+
4442
6342
  /** `add`: a frame adjustment, a 64-bit pair with the `adc` after it, a frame capture,
4443
6343
  * the copy idiom, or an add. */
4444
6344
  function lowerAdd(fill: ThumbFill, cur: BlockCursor, ins: Instr, ii: number): void {
@@ -4462,6 +6362,10 @@ function lowerAdd(fill: ThumbFill, cur: BlockCursor, ins: Instr, ii: number): vo
4462
6362
  // audit; only the offset differs.
4463
6363
  if (isSpReg(b ?? '') && !isSpReg(a ?? '') && c !== undefined && IMM_LITERAL.test(c)) {
4464
6364
  if (frame.slotsOk && frame.localArea > 0) {
6365
+ if (frame.narrowReloadCopies.has(ins)) {
6366
+ writeNarrowCopyMarker(fill, cur, reg(a)); // see lowerMov
6367
+ return;
6368
+ }
4465
6369
  const res = mkValue(T.unk(32));
4466
6370
  irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: imm(c) } }));
4467
6371
  writeData(reg(a), bi, res);
@@ -4656,6 +6560,9 @@ function lowerBlockTransfer(fill: ThumbFill, cur: BlockCursor, ins: Instr): void
4656
6560
  const adv = mkValue(T.unk(32));
4657
6561
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
4658
6562
  writeData(baseReg, bi, adv);
6563
+ // agbcc's block copy leaves this dead in an argument register before a call (`ssa.ts`
6564
+ // `noteIncidentalWrite`), so it proves no argument there.
6565
+ fill.ssa.noteIncidentalWrite(adv);
4659
6566
  }
4660
6567
  }
4661
6568
 
@@ -4665,7 +6572,7 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4665
6572
  const { name, symbols, text, frame, ssa } = fill;
4666
6573
  const { readVar } = ssa;
4667
6574
  const { usedSlotOffsets } = fill.frameUses;
4668
- const { localStatic, readData, writeData } = fill.operands;
6575
+ const { localStatic, anonymousObject, readData, writeData } = fill.operands;
4669
6576
  const { bi, irb, walk } = cur;
4670
6577
  const [a, b] = ins.ops;
4671
6578
  // A word load whose operand NAMES a literal pool is a pool reference, not a memory base:
@@ -4677,7 +6584,11 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4677
6584
  // A function-scope static is the address of an object this function DEFINES: the
4678
6585
  // `gaddr` below under its linker name, with the definition recorded beside the Fn.
4679
6586
  const pr: PoolRef | null =
4680
- ref?.kind === 'local-static' ? { kind: 'gaddr', sym: localStatic(ref.symbol), addend: ref.addend } : ref;
6587
+ ref?.kind === 'local-static'
6588
+ ? { kind: 'gaddr', sym: localStatic(ref.symbol), addend: ref.addend }
6589
+ : ref?.kind === 'anonymous'
6590
+ ? { kind: 'gaddr', sym: anonymousObject(ref.label), addend: 0 }
6591
+ : ref;
4681
6592
  if (pr?.kind === 'const') {
4682
6593
  // Numeric-pool PROMOTION (symbols.ts): a pool-loaded word whose value the
4683
6594
  // project's symbol map knows becomes the NAMED global's address — the same
@@ -4704,7 +6615,16 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4704
6615
  irb.ops.push(
4705
6616
  mkOp('gaddr', {
4706
6617
  results: [res],
4707
- attrs: { sym: si.name, ...(si.kind === 'code' ? { code: true } : {}) },
6618
+ // `fromMap`: the NAME is asmlift's reading of a numeric word, not the asm's own
6619
+ // spelling (raise/offsetnames.ts `cellSpelledTwice` asks which), and `mapAddr` the
6620
+ // word itself — the one address of a name the map places at several that this
6621
+ // spelling denotes. A macro expands to the numeric literal the asm holds, so it is
6622
+ // not marked.
6623
+ attrs: {
6624
+ sym: si.name,
6625
+ ...(si.kind === 'code' ? { code: true } : {}),
6626
+ ...(si.macroBody === undefined ? { fromMap: true, mapAddr: pr.value >>> 0 } : {}),
6627
+ },
4708
6628
  }),
4709
6629
  );
4710
6630
  writeData(reg(a), bi, res);
@@ -4721,7 +6641,12 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4721
6641
  const g = mkValue(T.unk(32));
4722
6642
  const k = mkValue(T.unk(32));
4723
6643
  const res = mkValue(T.unk(32));
4724
- irb.ops.push(mkOp('gaddr', { results: [g], attrs: { sym: interior.info.name } }));
6644
+ irb.ops.push(
6645
+ mkOp('gaddr', {
6646
+ results: [g],
6647
+ attrs: { sym: interior.info.name, fromMap: true, mapAddr: (pr.value - interior.offset) >>> 0 },
6648
+ }),
6649
+ );
4725
6650
  irb.ops.push(mkOp('const', { results: [k], attrs: { value: interior.offset } }));
4726
6651
  irb.ops.push(mkOp('add', { operands: [g, k], results: [res] }));
4727
6652
  writeData(reg(a), bi, res);
@@ -4769,12 +6694,58 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4769
6694
  // `argIndex` can make, and the soundness proof that each one is needed for, lives on the
4770
6695
  // predicate itself — restating them here is how the two copies drifted.
4771
6696
  {
4772
- const index = walk.argIndex({ base, off, regOff }, width, bi);
6697
+ const index = walk.argIndex({ base, off, regOff }, width);
4773
6698
  if (index !== null) {
6699
+ if (bi !== 0) {
6700
+ fill.frameUses.offEntryArgReads.add(bi);
6701
+ }
4774
6702
  writeData(reg(a), bi, readVar(stackArgKey(index), bi));
4775
6703
  return;
4776
6704
  }
4777
6705
  }
6706
+ // WHICH RELOADS OF THIS FUNCTION'S OWN FRAME ARE SLOT READS — the dual of the spill in the str
6707
+ // arm, and the whole soundness of it. The narrow reload next and the word reload below both ask
6708
+ // it.
6709
+ //
6710
+ // One a store reaches on some path reads that store, and a path that brings none reads an
6711
+ // uninitialised local: the frame is `ownedLocals`, so `readVar` answers that path with `undef`,
6712
+ // never with a parameter (frontend/ssa.ts `LiveInModel`).
6713
+ //
6714
+ // A slot NO store reaches, on any path, is the same local read before any store — `if (k > 5)
6715
+ // use(v0); … v0 = p[0];`, or a local the source never initialises. Only two kinds of callee
6716
+ // could have written it instead. One that assigned the word as its own stack parameter ran
6717
+ // before the load, so the load is after a call, which the after-call layout claim
6718
+ // (`localsAboveOutgoingArea`) says no word of the outgoing area is; with no call reaching the
6719
+ // load there is no such callee. Without the claim it may be one, and falls through to the
6720
+ // decline. The stronger `outgoingAreaNeverLoadedFrom` adds nothing here: a load BEFORE a call reads
6721
+ // a staged word, and a staged word has a reaching store. The other is a callee holding an
6722
+ // address of the frame, which the frame-object audit refuses: an `undef` slot an escaped writer
6723
+ // reaches (`writer-over-undef`), or one a captured object overlaps.
6724
+ const readsOwnSlot = (at: string, o: number, ro: string | undefined, w: number): boolean =>
6725
+ frame.isOwnFrameWordSlot(at, o, ro, w) &&
6726
+ (ssa.hasReachingDef(stackSlotKey(o), bi) || fill.target.compilerBehaviors.localsAboveOutgoingArea === true);
6727
+ // A NARROW RELOAD OF A WORD SLOT through a copy of sp nothing else reads (`narrowSlotReloads`):
6728
+ // the low-order part of the word the slot holds, so the slot's value narrowed — taken where the
6729
+ // word reload of the slot would be, and the slot is no object's. Its copy was not lifted, so a
6730
+ // reload this cannot take refuses here rather than reading through a register never set.
6731
+ {
6732
+ const slot = frame.narrowReloadOf.get(ins);
6733
+ if (slot !== undefined) {
6734
+ if (!readsOwnSlot('sp', slot, undefined, 4) || frame.isFrameObjectAccess('sp', slot, undefined, 4)) {
6735
+ throw new FrontendUnsupportedError(
6736
+ `cannot lift '${name}': narrow reload of a stack local through a copy of sp — ` +
6737
+ `'${ins.mnemonic} ${ins.ops.join(', ')}' reads the word slot [sp,#${slot}], which the word-slot model does not key here`,
6738
+ );
6739
+ }
6740
+ usedSlotOffsets.add(slot);
6741
+ const res = mkValue(T.unk(32));
6742
+ irb.ops.push(
6743
+ mkOp('zext', { operands: [readVar(stackSlotKey(slot), bi)], results: [res], attrs: { width: 8 * width } }),
6744
+ );
6745
+ writeData(reg(a), bi, res);
6746
+ return;
6747
+ }
6748
+ }
4778
6749
  // An address-taken object comes FIRST: it is memory, not a slot, so it is
4779
6750
  // read with a real `load` through its `laddr` (see isFrameObjectAccess). No
4780
6751
  // reaching-def test — the callee holding the address is a writer this function cannot
@@ -4787,16 +6758,8 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4787
6758
  writeData(reg(a), bi, res);
4788
6759
  return;
4789
6760
  }
4790
- // A word reload from this function's own frame — the dual of the spill in the str arm.
4791
- //
4792
- // The reaching-def test is the whole soundness of it, and it mirrors the MIPS guard
4793
- // exactly: a slot that was never STORED holds nothing this function put there, so
4794
- // `readVar` would mint a phantom entry parameter for it and hand back a value the machine
4795
- // never had. Above the frame that reading is right and is the incoming-argument path
4796
- // above; INSIDE the frame it is an uninitialised local (or one whose address escaped
4797
- // through a path the model missed), and the honest answer is the decline this falls
4798
- // through to.
4799
- if (frame.isOwnFrameWordSlot(base, off, regOff, width) && ssa.hasReachingDef(stackSlotKey(off), bi)) {
6761
+ // A word reload from this function's own frame (`readsOwnSlot`, above).
6762
+ if (readsOwnSlot(base, off, regOff, width)) {
4800
6763
  usedSlotOffsets.add(off);
4801
6764
  writeData(reg(a), bi, readVar(stackSlotKey(off), bi));
4802
6765
  return;
@@ -4853,6 +6816,28 @@ function lowerStore(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4853
6816
  irb.ops.push(mkOp('store', { operands: [storeBase, readData(reg(a), bi)], attrs: { off, width } }));
4854
6817
  }
4855
6818
 
6819
+ /** WHERE A READER FINDS A CALL IN THE .s, for a refusal that names it: a Thumb `Instr` carries no
6820
+ * address. A block the .s labels (the function's own entry, a `.L3`) names it by that label and
6821
+ * the call; a block the splitter opened after a branch has only a label it made up (`.L_anon0`),
6822
+ * which appears nowhere in the .s, so the call is named by its ordinal among the calls to the same
6823
+ * callee in the function, in the order the .s lists them. */
6824
+ function thumbCallLocator(blocks: readonly AsmBlock[], bi: number, ins: Instr): string {
6825
+ const call = `'${ins.mnemonic} ${ins.ops[0]}'`;
6826
+ const label = blocks[bi].label;
6827
+ if (!label.startsWith(ANON_BLOCK_PREFIX)) {
6828
+ return `${call} in block '${label}'`;
6829
+ }
6830
+ const same = blocks.flatMap((b) => b.instrs).filter((i) => i.mnemonic === ins.mnemonic && i.ops[0] === ins.ops[0]);
6831
+ const k = same.indexOf(ins);
6832
+ if (k < 0 || same.length === 1) {
6833
+ return `${call} in '${blocks[0].label}'`;
6834
+ }
6835
+ return `${call} in '${blocks[0].label}' (the ${ordinal(k + 1)} of its ${same.length} calls there)`;
6836
+ }
6837
+
6838
+ const ordinal = (n: number): string =>
6839
+ `${n}${n % 100 >= 11 && n % 100 <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th')}`;
6840
+
4856
6841
  /** `bl` / `blx`: the call its callee's declaration plans, lowered by `CallDeclarations.lower`. */
4857
6842
  function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4858
6843
  const { name, target, text, calls, frame, ssa, pairs } = fill;
@@ -4882,6 +6867,8 @@ function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4882
6867
  callee: through === null ? callee : { address: readData(through, bi), reg: through },
4883
6868
  ssa,
4884
6869
  bi,
6870
+ // what an elided or kept dispatch left in an argument register is no argument
6871
+ parked: parkedIn(fill.staleIdiomValues, reg),
4885
6872
  read: (r) => ssa.readVar(r, bi),
4886
6873
  write: (r, v) => writeData(r, bi, v),
4887
6874
  // ARGUMENT WORD `k` past the registers is a word of this frame's outgoing area, at [sp,#0]
@@ -4896,6 +6883,74 @@ function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4896
6883
  usedSlotOffsets.add(off);
4897
6884
  return ssa.readVar(stackSlotKey(off), bi);
4898
6885
  },
6886
+ // …and that block is the arity of a call nothing declares, where its callee's body licensed it
6887
+ stackWords: frame.slotsOk ? frame.outgoingArgs.blocks.get(ins)?.length : undefined,
6888
+ // A FRAME ADDRESS AN UNDECLARED ARITY LEAVES OUT IS AN ARGUMENT ALL THE SAME. The frame walks
6889
+ // read the call as handed every argument register it holds (`frameAddressReceivers`), and the
6890
+ // lift reads the object as written by that receiver (`isFrameObjectAccess`, with no reaching-
6891
+ // definition test). A count that leaves out a register holding one lifts the call without the
6892
+ // address, and the object's reads come back as a local nothing wrote (`func(p0, p1, p2, &d[1])`
6893
+ // as `func()`, then `*data = sp1`). A GUESSED count leaves one out only past a register it did
6894
+ // not set, which is a gap the guess refuses first (`guess` below); what reaches here is a call
6895
+ // through r1, which stops at the bound r1 sets and leaves r2 out (`CallDeclarations.lower`'s
6896
+ // KNOWN GAP), and the `/setup-args` cut (`asksEveryCut`). Which count is right the register
6897
+ // file cannot say, so this refuses.
6898
+ //
6899
+ // …UNLESS A REGISTER IT DOES PASS HOLDS THE SAME ADDRESS, in the call's own block: the callee is
6900
+ // handed the object either way, and agbcc leaves a capture it addressed through in r3 beside
6901
+ // the copy it passes in r0 (`mov r3, sp / str r0, [r3] / mov r0, sp / bl g`).
6902
+ leftOut: (regs, passed) => {
6903
+ const at = frame.frameAddressesAtCall.get(ins);
6904
+ // …and an address at an offset nothing knows (NaN, `&buf[i]`) is the same as no other one
6905
+ const handed = new Set(
6906
+ passed.flatMap((p) => (at?.must.has(p) && Number.isFinite(at.must.get(p)) ? [at.must.get(p)] : [])),
6907
+ );
6908
+ const r = regs.find((r) => at?.may.has(r) && !handed.has(at.may.get(r)!));
6909
+ if (r !== undefined) {
6910
+ throw new FrontendUnsupportedError(
6911
+ `cannot lift '${name}': the call '${ins.mnemonic} ${callee}' has no prototype, and the arity lifted ` +
6912
+ `without one leaves out ${r}, which may hold a frame address — so whether the callee is handed the ` +
6913
+ `object is not decidable`,
6914
+ );
6915
+ }
6916
+ },
6917
+ // NO `/setup-args` READING at a call whose escape the outgoing-argument verdict leaned on (an
6918
+ // escaped `sp+k` excuses every frame word from k up as the callee's to read). That verdict read
6919
+ // every argument register the call holds as handed over (`frameAddressReceivers`), so a reading
6920
+ // that cuts one contradicts it, whatever the register holds: `&t` an earlier block formed, a
6921
+ // second `mov r3, sp` the `leftOut` exception excuses beside a passed `sp`, or the `mov r2, #0`
6922
+ // agbcc reuses from the frame's initialisation (`g(a, s, 0, 0)` would narrow to `g(a, s)`).
6923
+ //
6924
+ // …NOR AT ANY CALL OF A FUNCTION WHOSE BLOCK OTHER THAN THE ENTRY READS AN INCOMING STACK
6925
+ // ARGUMENT. That is a conservative guard, not a property of the cut: the licence that lifts such a
6926
+ // read (`ThumbFrame.settledDepth`) reads no argument register. It keeps the guess off the
6927
+ // functions only that licence lifts (`if (a) g(n, w, e)` with `&w` left in r1 by the entry would
6928
+ // narrow to `g(n)`). The same leftover still narrows where agbcc schedules the stack argument's
6929
+ // load into the entry block instead (`w[1] = e; if (a) g(n, w)` as `g(n)`, which assembles to the
6930
+ // same bytes), as it does with four parameters.
6931
+ withholdsSetupArgs: () => frame.callsReliedOn.has(ins) || fill.frameUses.offEntryArgReads.size > 0,
6932
+ // A GAP IN THE ARGUMENT REGISTERS REFUSES (`fallbackArgc`) UNLESS IT IS DECIDED: `logv(s, 1)`
6933
+ // sets r1 alone and leaves `s` in r0, so a count that stops at r0 would lift `logv()`. The gap is
6934
+ // decided where r1 surely is an argument (frontend/call-plan.ts `decideGap`: a value nothing but
6935
+ // the call reads and no memory read produced). agbcc fills argument words in order, so `s` is
6936
+ // then passed on and the call lifts `logv(a0, 1)`. A run of incoming arguments
6937
+ // passed on with no register set up past it (`g2(a, b)`) leaves no gap, is the same machine code
6938
+ // as `g2()`, and is lifted as the shorter call. The instruction carries no address, so the
6939
+ // refusal names the call by where the .s puts it (`thumbCallLocator`).
6940
+ //
6941
+ // No `refuse`: a guess that fills r0..r3 asks nothing of the outgoing area here. A word agbcc
6942
+ // stages at [sp, #k] for the call is an argument only where `analyzeOutgoingArgs` licensed the
6943
+ // block, and the frame model refuses any other store there. agbcc's `g5(1, 2, 3, 4, a)` to an
6944
+ // undeclared `g5` declines as "stack pointer used as data" before any call is lowered.
6945
+ guess: { at: thumbCallLocator(fill.cfg.asmBlocks, bi, ins) },
6946
+ // …AND AT EVERY OTHER CALL OF A FUNCTION WHOSE VERDICT LEANED ON AN ESCAPE, the reading asks
6947
+ // `leftOut` of every register it cuts, not only of those this block wrote. The verdict read every
6948
+ // argument register as possibly handed over, so a frame address an EARLIER block left there is
6949
+ // not the dead base the reading guesses at: `g(b, s)` escapes, then `&t` formed before a branch
6950
+ // reaches `if (a) h(b, a, t); else *q = t[0];` in r2, and the cut `h(b, a)` would drop it while
6951
+ // the else arm still reads `t`. The question refuses only frame addresses, so a call that cuts
6952
+ // none (a sibling `f2()`) still narrows.
6953
+ asksEveryCut: () => frame.escapesReliedOn.size > 0,
4899
6954
  pairs: {
4900
6955
  fuse: (lo, hi) => pairs.fuseHalves(irb, lo, hi),
4901
6956
  project: (whole, half) => pairs.projectHalf(irb, whole, half, ins),
@@ -4915,9 +6970,27 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4915
6970
  const succ = (label: string): Successor => ({ block: irBlocks[cfg.labelIndex.get(label)!], args: [] });
4916
6971
  const jt = text.tables.get(ab);
4917
6972
  if (jt) {
4918
- // Regime B: the bounds block dispatches a `switch_br` over the scrutinee — N case blocks (values
4919
- // 0..N-1, dense) followed by the default block (last successor). The `cmp`/`bhi` are subsumed.
4920
- pushSwitchBr(irb.ops, readVar(reg(jt.scrutReg), bi), [...jt.caseLabels.map(succ), succ(jt.defaultLabel)]);
6973
+ // Regime B: a `switch_br` over the scrutinee — N case blocks (values 0..N-1, dense) followed by
6974
+ // the default block (last successor). From the bounds block, the `cmp`/`bhi` are subsumed. A
6975
+ // kept dispatch (`JumpTable.extras`) switches on the scrutinee its block was entered with: the
6976
+ // idiom read it first, and the extras after it may overwrite the register. Its bounds test stays
6977
+ // a branch and already sent every value past N-1 to the default, so its switch is `exhaustive`.
6978
+ const scrut = cur.dispatchScrutinee ?? readVar(reg(jt.scrutReg), bi);
6979
+ // What the unlifted idiom left in the registers no extra rewrote is no value the lift models: on
6980
+ // the hardware the case blocks see the scaled index, the table's address and the case's own code
6981
+ // address. Each is a marker here, and a read of one declines (`refuseStaleIdiomReads`). The
6982
+ // elided form cannot put them here, since its bounds block's default path never ran the idiom: it
6983
+ // writes them at each case target's entry instead (`openBlockCursor`).
6984
+ if (cur.dispatchScrutinee !== null) {
6985
+ for (const r of [...jt.leaves.keys()].filter((r) => !jt.extras.some((ins) => ins.ops[0] === r))) {
6986
+ ssa.writeVar(
6987
+ reg(r),
6988
+ bi,
6989
+ staleMarker(irb.ops, reg(r), { reg: r, form: 'kept', parks: true }, fill.staleIdiomValues),
6990
+ );
6991
+ }
6992
+ }
6993
+ pushSwitchBr(irb.ops, scrut, [...jt.caseLabels.map(succ), succ(jt.defaultLabel)], cur.dispatchScrutinee !== null);
4921
6994
  } else if (!last) {
4922
6995
  // an EMPTY block is only ever the synthetic entry preheader (decoded blocks are non-empty):
4923
6996
  // fall through to the real entry, whose loop-header phis take their entry operand from here.
@@ -4951,7 +7024,7 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4951
7024
  last.ops[0] === scratch &&
4952
7025
  prev?.mnemonic === 'pop' &&
4953
7026
  regListOf(prev.ops)?.join() === scratch;
4954
- pairs.wordReturns.push({ bi, lo, widerEpilogue });
7027
+ pairs.noteWordReturn(bi, lo, widerEpilogue);
4955
7028
  }
4956
7029
  irb.ops.push(mkOp('ret', { operands: [whole ?? lo] }));
4957
7030
  }
@@ -4965,10 +7038,25 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4965
7038
  `cannot lift '${name}': conditional branch '${last.mnemonic}' has no reaching compare: ${cur.noCmpWhy}`,
4966
7039
  );
4967
7040
  }
7041
+ const { lhs, cmn } = cur.pendingCmp;
7042
+ let { rhs } = cur.pendingCmp;
7043
+ if (cmn) {
7044
+ if (!CMN_EXACT.has(last.mnemonic)) {
7045
+ throw new FrontendUnsupportedError(
7046
+ `cannot lift '${name}': conditional branch '${last.mnemonic}' tests the carry of a 'cmn', and the carry ` +
7047
+ 'out of a + b is not a >=u -b (they differ at b == 0)',
7048
+ );
7049
+ }
7050
+ // The signed branches read the TRUE sign of a + b, so the negation they compare against is
7051
+ // the mathematical one (`neg_s`, ir/opcodes.ts). `beq`/`bne` read the wrapped sum, which the
7052
+ // plain wrapping `neg` spells (see `CMN_EXACT` for the one `s32` input it leaves undefined).
7053
+ const negated = mkValue(T.unk(32));
7054
+ const signed = last.mnemonic !== 'beq' && last.mnemonic !== 'bne';
7055
+ irb.ops.push(mkOp(signed ? 'neg_s' : 'neg', { operands: [rhs], results: [negated] }));
7056
+ rhs = negated;
7057
+ }
4968
7058
  const cond = mkValue(T.unk(32));
4969
- irb.ops.push(
4970
- mkOp(COND_OPCODE[last.mnemonic], { operands: [cur.pendingCmp.lhs, cur.pendingCmp.rhs], results: [cond] }),
4971
- );
7059
+ irb.ops.push(mkOp(COND_OPCODE[last.mnemonic], { operands: [lhs, rhs], results: [cond] }));
4972
7060
  irb.ops.push(
4973
7061
  mkOp('cond_br', { operands: [cond], successors: [succ(last.ops[0]), succ(fallLabel(cfg.asmBlocks, name, bi))] }),
4974
7062
  );
@@ -4990,33 +7078,20 @@ function fallLabel(asmBlocks: readonly AsmBlock[], name: string, bi: number): st
4990
7078
  return nb.label;
4991
7079
  }
4992
7080
 
4993
- // A struct returned through memory is spelled `local = f(..)`, which hands the callee that local's
4994
- // address: the destination must be a frame object the audit accepted. A global or a pointer handed
4995
- // over directly is another spelling (`gDst = f(..)` needs the global declared as the struct), which
4996
- // nothing here states.
4997
- function refuseSretOutsideFrame(name: string, irBlocks: readonly Block[]): void {
4998
- const laddrs = new Set(
4999
- irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'laddr').map((op) => op.results[0])),
5000
- );
5001
- for (const op of irBlocks.flatMap((b) => b.ops)) {
5002
- if (op.opcode === 'call' && op.attrs.sret === true && !laddrs.has(op.operands[0])) {
5003
- throw new FrontendUnsupportedError(
5004
- `cannot lift '${name}': \`${op.attrs.target as string}\` returns struct ${typeToString(op.results[0].type)} through the pointer in r0, ` +
5005
- 'and that pointer is not the address of a local of this frame — a struct returned into a global or ' +
5006
- 'through a pointer is not modelled',
5007
- );
5008
- }
5009
- }
5010
- }
5011
-
5012
7081
  /** The ARMv4T / Thumb (agbcc) frontend, registered for the `armv4t` target. */
5013
- export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lift };
7082
+ export const thumbFrontend: Frontend = {
7083
+ id: 'thumb',
7084
+ inputFormat: 'gnu-as',
7085
+ lift,
7086
+ entryStackWords: thumbEntryWordsFromText,
7087
+ };
5014
7088
 
5015
7089
  /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
5016
7090
  * every source path under `./*`, so a plain `export` here would put the pad table, the pad predicate, the witness serialisation and the lift's stages into the
5017
7091
  * package's public API, where no consumer should be able to depend on them. The name says what it
5018
7092
  * is; nothing in `src` reads it. */
5019
7093
  export const __testing = {
7094
+ decode,
5020
7095
  PAD_ENCODINGS,
5021
7096
  isPadInstr,
5022
7097
  factKey,