@asmlift/core 0.9.0 → 0.10.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 (101) 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 +1 -0
  5. package/src/backend/cfamily.ts +165 -50
  6. package/src/backend/cpp.ts +2 -1
  7. package/src/backend/pascal.ts +31 -12
  8. package/src/contracts.ts +173 -16
  9. package/src/declare.ts +69 -20
  10. package/src/frontend/asmdata.ts +7 -1
  11. package/src/frontend/call-plan.ts +964 -60
  12. package/src/frontend/dead-reads.ts +628 -0
  13. package/src/frontend/device-pins.ts +23 -4
  14. package/src/frontend/emit.ts +4 -3
  15. package/src/frontend/fpu.ts +224 -17
  16. package/src/frontend/frame-objects.ts +1232 -192
  17. package/src/frontend/frontend.ts +64 -3
  18. package/src/frontend/high-half.ts +53 -4
  19. package/src/frontend/jump-table.ts +530 -0
  20. package/src/frontend/local-object.ts +158 -32
  21. package/src/frontend/mips.ts +2076 -215
  22. package/src/frontend/own-struct-return.ts +500 -0
  23. package/src/frontend/ppc.ts +2711 -232
  24. package/src/frontend/register-struct-call.ts +484 -0
  25. package/src/frontend/reloc-symbol.ts +22 -7
  26. package/src/frontend/ssa.ts +1460 -44
  27. package/src/frontend/stackargs.ts +106 -57
  28. package/src/frontend/thumb.ts +2044 -318
  29. package/src/ir/alias.ts +42 -0
  30. package/src/ir/bits.ts +681 -1
  31. package/src/ir/core.ts +100 -5
  32. package/src/ir/discipline.ts +86 -21
  33. package/src/ir/entry.ts +159 -0
  34. package/src/{structure → ir}/loops.ts +6 -2
  35. package/src/ir/opcodes.ts +109 -9
  36. package/src/ir/types.ts +86 -2
  37. package/src/ir/verify.ts +80 -11
  38. package/src/l3/ast.ts +290 -18
  39. package/src/l3/basecse.ts +33 -5
  40. package/src/l3/constinit.ts +382 -0
  41. package/src/l3/dce.ts +2 -2
  42. package/src/l3/initfirst.ts +42 -11
  43. package/src/l3/intcell.ts +2 -1
  44. package/src/l3/narrowdecl.ts +77 -36
  45. package/src/l3/pollguard.ts +8 -2
  46. package/src/l3/ptrcell.ts +31 -34
  47. package/src/l3/ptrfield.ts +123 -8
  48. package/src/l3/regspell.ts +9 -4
  49. package/src/l3/reindex.ts +283 -96
  50. package/src/l3/storage.ts +98 -1
  51. package/src/l3/symbol-refs.ts +84 -6
  52. package/src/l3/tailmerge.ts +4 -2
  53. package/src/l3/typing.ts +108 -15
  54. package/src/l3/unreduce.ts +6 -5
  55. package/src/l3/volatileptr.ts +17 -54
  56. package/src/mangle.ts +30 -0
  57. package/src/pattern/engine.ts +361 -73
  58. package/src/pipeline.ts +541 -24
  59. package/src/proto-context.ts +905 -45
  60. package/src/proto.ts +555 -40
  61. package/src/raise/aggmembers.ts +290 -0
  62. package/src/raise/arrays.ts +5 -1
  63. package/src/raise/declared-volatile.ts +207 -31
  64. package/src/raise/floathelpers.ts +254 -33
  65. package/src/raise/gvn.ts +3 -3
  66. package/src/raise/latch.ts +162 -6
  67. package/src/raise/memberarrays.ts +10 -4
  68. package/src/raise/offsetnames.ts +10 -26
  69. package/src/raise/pairparams.ts +33 -0
  70. package/src/raise/paramwidth.ts +196 -6
  71. package/src/raise/pre-recovery.ts +102 -19
  72. package/src/raise/pre-structure.ts +54 -0
  73. package/src/raise/recover.ts +12 -5
  74. package/src/raise/reroll.ts +694 -0
  75. package/src/raise/shortcircuit.ts +26 -12
  76. package/src/raise/softdiv.ts +1 -1
  77. package/src/raise/storesign.ts +153 -0
  78. package/src/raise/struct-arrays.ts +30 -11
  79. package/src/raise/structs.ts +396 -33
  80. package/src/raise/truncload.ts +4 -3
  81. package/src/raise/unthread.ts +400 -0
  82. package/src/rank-declare.ts +118 -12
  83. package/src/rank-variations.ts +89 -31
  84. package/src/rank.ts +307 -54
  85. package/src/runtime-helpers.ts +140 -22
  86. package/src/structure/analysis.ts +165 -2
  87. package/src/structure/arith-ops.ts +23 -0
  88. package/src/structure/globalaccess.ts +111 -9
  89. package/src/structure/hazards.ts +16 -64
  90. package/src/structure/local-statics.ts +145 -9
  91. package/src/structure/namecoalesce.ts +1 -1
  92. package/src/structure/pointer-spelling.ts +1106 -0
  93. package/src/structure/redundant-test.ts +1 -1
  94. package/src/structure/structure.ts +2180 -1054
  95. package/src/structure/switch-recover.ts +244 -100
  96. package/src/symbols.ts +6 -0
  97. package/src/target.ts +765 -52
  98. package/src/trace.ts +26 -8
  99. package/src/variation-definitions.ts +126 -14
  100. package/src/variation-gates.ts +2 -0
  101. package/src/variation-tokens.ts +5 -1
@@ -18,33 +18,72 @@
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 { type FnProto, type Prototypes, ownLeadingWordRegisters, wordsOf } from '../proto';
26
+ import type { ContextGlobals } from '../proto-context';
25
27
  import { type SymbolMap, lookupInterior, lookupSymbol } from '../symbols';
26
- import type { TargetDescription } from '../target';
28
+ import { ARM_REGISTER_ALIASES, type TargetDescription } from '../target';
27
29
  import type { AsmData } from './asmdata';
28
- import { type CallDeclarations, type CallLowering, callDeclarations } from './call-plan';
30
+ import { type CallDeclarations, type CallLowering, callDeclarations, foldByValueArgs } from './call-plan';
31
+ import { pinDeadReads, unreadPairHalf } from './dead-reads';
29
32
  import { pinDeviceAccesses } from './device-pins';
30
33
  import { pushSwitchBr } from './emit';
31
34
  import { FrontendUnsupportedError } from './errors';
32
35
  import { inheritFlags } from './flags-edge';
33
36
  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';
37
+ import {
38
+ FRAME_OBJECT_AUDIT,
39
+ type FrameObjectAudit,
40
+ type FrameObjectRelift,
41
+ type FrameRange,
42
+ liftWithRelift,
43
+ nameMergedSretObjects,
44
+ refuseSretOutsideFrame,
45
+ } from './frame-objects';
46
+ import { type Frontend, type LiftOptions, type OwnDeclaration, ownDeclarationOf } from './frontend';
47
+ import {
48
+ type CaseEntry,
49
+ type IdiomLeft,
50
+ type StaleIdiomRead,
51
+ caseEntries,
52
+ enterCaseTarget,
53
+ idiomLeaves,
54
+ parkedIn,
55
+ refuseStaleIdiomReads,
56
+ staleMarker,
57
+ } from './jump-table';
58
+ import {
59
+ type LocalStatics,
60
+ gasPoolReferrers,
61
+ makeLocalStatics,
62
+ readGasAnonymousObject,
63
+ readGasLocalObject,
64
+ } from './local-object';
37
65
  import { opaqueDest } from './opaque';
38
66
  import { classifyRelocSymbol, unspellableReason } from './reloc-symbol';
39
67
  import {
40
- type ArgSlots,
41
68
  type SsaBuilder,
42
69
  abiSortEntryParams,
70
+ assertScratchRegsPartitioned,
43
71
  makeSsaBuilder,
44
72
  mintArgSlotHoles,
73
+ registerArgSlots,
74
+ registerLiveIns,
75
+ stackArgKey,
76
+ stackArgSlots,
45
77
  stackSlotKey,
78
+ unifyRegisterSpellings,
46
79
  } from './ssa';
47
- import { type OutgoingArgs, type StackArgsEvent, analyzeOutgoingArgs } from './stackargs';
80
+ import {
81
+ type OutgoingArgs,
82
+ type StackArgsBlock,
83
+ type StackArgsEvent,
84
+ analyzeOutgoingArgs,
85
+ loadedWordBound,
86
+ } from './stackargs';
48
87
 
49
88
  interface Instr {
50
89
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -55,7 +94,16 @@ interface Instr {
55
94
  * Messages must use this: a decline naming `ldrsh` for a file containing `ldsh` sends the
56
95
  * reader looking for an instruction that is not there. */
57
96
  asWritten?: string;
97
+ /** set only on a `bl <label>` whose line carried the target's far-jump mark — agbcc's
98
+ * `@far jump` / `@ far jump`, the relay it spells a branch past Thumb's ±2 KB reach with (see
99
+ * `compilerBehaviors.farJumpRelay`); never on a target that declares no mark. The
100
+ * comment is the ONLY text that separates it from a call: everything else on the line is
101
+ * dropped at tokenizing, and a call thunk is the same `bl` to a label of this function. */
102
+ farJump?: true;
58
103
  }
104
+ /** The label a block the .s does not label is given — one no .s spells (`thumbCallLocator`). */
105
+ const ANON_BLOCK_PREFIX = '.L_anon';
106
+
59
107
  interface AsmBlock {
60
108
  label: string;
61
109
  instrs: Instr[];
@@ -224,15 +272,21 @@ const controlContinuesPast = (ins: Instr) => {
224
272
  * `mov pc, rN`.) */
225
273
  function classifyXfer(ins: Instr): XferKind | null {
226
274
  const mn = ins.mnemonic;
227
- if (mn === 'b') {
275
+ // agbcc's far-jump relay is a branch wearing the call mnemonic. Classified here so the layout
276
+ // questions asked BEFORE it is lowered to `b` (`readThumbText`: `lowerFarJumpRelays`) — whether
277
+ // a fill after it is sealed, whether the block before a pool falls into data — read it as the
278
+ // transfer it is; it keeps its 4 bytes there because the mnemonic is still `bl`.
279
+ if (mn === 'b' || (mn === 'bl' && ins.farJump)) {
228
280
  return 'uncond';
229
281
  }
230
282
  if (COND_OPCODE[mn]) {
231
283
  return 'cond';
232
284
  }
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.)
285
+ // `bx rN`: agbcc's return is `bx lr` or `pop {…, rN}; bx rN` (rN holds the restored LR) — a return.
286
+ // This sees one instruction, so it cannot tell what rN holds; once the CFG exists, every return
287
+ // of the selected function is asked whether it reads the caller's lr from the frame word the
288
+ // prologue saved it in (`refuseReturnsNotThroughCallersLr`), and any other declines as the
289
+ // indirect jump it is. (agbcc emits jump tables via `mov pc`, NOT `bx`.)
236
290
  if (mn === 'bx') {
237
291
  return 'return';
238
292
  }
@@ -332,8 +386,9 @@ const REG_NUM: Record<string, number> = Object.assign(Object.create(null), { sp:
332
386
 
333
387
  // Registers Thumb-1's `push` cannot name, in every spelling this ISA's asm uses for them. Saving
334
388
  // 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.
389
+ // the `mov` as well as the list (see `savedRegs`). Spellings, not numbers: a register keeps the
390
+ // name it was written with, and only a function that writes one two ways is rewritten to one of them
391
+ // (`unifyRegisterSpellings` in `readThumbText`), so `sl` and `r10` are never both keys of one lift.
337
392
  const HIGH_REGS: ReadonlySet<string> = new Set(['r8', 'r9', 'r10', 'r11', 'r12', 'sb', 'sl', 'fp', 'ip']);
338
393
 
339
394
  /** The operands of a `cmp` whose flags are still live — the whole of the condition state this
@@ -352,16 +407,25 @@ const HIGH_REGS: ReadonlySet<string> = new Set(['r8', 'r9', 'r10', 'r11', 'r12',
352
407
  * the edges of a conditional branch (mwcc's switch dispatch reads one `cmpwi` from a `beq`
353
408
  * and then from the `bge` on its fall-through), and Thumb, with no row inhabiting that shape,
354
409
  * does not. */
355
- type PendingCmp = { lhs: Value; rhs: Value };
410
+ type PendingCmp = {
411
+ lhs: Value;
412
+ rhs: Value;
413
+ /** The flags came from `cmn lhs, rhs`, which stands for `cmp lhs, -rhs`. Nothing is minted for
414
+ * the negation until a branch reads it: the tag travels with the compare, across an edge too
415
+ * (`inheritFlags` carries the record whole), and the terminator mints the `neg` in the READING
416
+ * block with the attrs that branch needs, or refuses. See `CMN_EXACT`. */
417
+ cmn?: true;
418
+ };
356
419
 
357
420
  // Thumb-1 data-processing mnemonics that write the condition flags when their destination is a LOW
358
421
  // register — which is all of them on this ISA, `s`-suffix or not (the assembler picks the encoding).
359
422
  // 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
423
+ // flags alone. Three kinds of writer that DO write them are absent, each for its own reason, and lumping
361
424
  // 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;
425
+ // * `cmp` and `cmn` seed the pending compare instead of invalidating it — they are the modelled
426
+ // ones (`cmn` as `cmp rA, -rB`, `lowerCmn`);
427
+ // * `tst` writes the flags and defines no register, so the membership test below (which reads a
428
+ // destination) cannot judge it — `FLAG_ONLY` does;
365
429
  // * a CALL is not an instruction's flag write at all: `bl` writes none itself, but the function
366
430
  // it enters retires compares of its own, and AAPCS lets it. Invalidated separately.
367
431
  const FLAG_SETTING = new Set([
@@ -401,20 +465,45 @@ const FLAG_SETTING = new Set([
401
465
  'sbcs',
402
466
  ]);
403
467
 
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.
468
+ // The flag writer with no destination to read. `tst` computes a value it throws away and keeps
469
+ // only the flags, so `FLAG_SETTING`'s destination test cannot see it — yet it takes the flags from
470
+ // a pending compare exactly as `sub` does, and the branch that follows tests ITS.
407
471
  //
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.
472
+ // Unconditional, unlike `FLAG_SETTING`: it has no destination whose width could pick a
473
+ // non-flag-setting encoding, and Thumb-1 encodes it on low registers only.
410
474
  //
411
- // They still decode to an `opaque` (the default arm), so a function containing one declines either
475
+ // It still decodes to an `opaque` (the default arm), so a function containing one declines either
412
476
  // way and this changes no verdict — only which decline, and the terminator's is the true one.
413
477
  // Without it `cmp r0,r1 / tst r2,r3 / bge` FOLDED the stale compare into the condition and was
414
478
  // 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']);
479
+ // accident of an unrelated model. Modelling `tst` is a capability with no inhabitant: agbcc's
480
+ // `tstsi` prints `cmp %0, #0` (gcc/thumb.md:818-821), so every `tst` in the corpus is hand-written.
481
+ //
482
+ // `cmn` is not here: it is a compare (the decode loop's `cmn` arm). agbcc's `cmnsi`
483
+ // (thumb.md:823-827) is `(compare rA (neg rB))`, which combine makes of `a == -b` and `a <= -b`.
484
+ const FLAG_ONLY = new Set(['tst']);
485
+
486
+ // The branches that read a `cmn`'s flags as the compare it stands for, and why the others cannot.
487
+ // `cmn rA, rB` sets NZCV from rA + rB, and `cmp rA, -rB` from rA - (-rB):
488
+ // * N and Z come from the same 32-bit result, so `beq`/`bne` read the WRAPPED a + b == 0, which
489
+ // the plain wrapping negation spells: exactly for an unsigned or narrower `b`, and for an `s32`
490
+ // one everywhere but b == INT_MIN, where C's `-b` overflows — the spelling every wrapping `neg`
491
+ // in this frontend already has;
492
+ // * V differs only at rB == INT_MIN, where cmn reports the true sign of rA + 2^31 and the
493
+ // compare's `-rB` wraps back to INT_MIN. The lift spells the negation SIGNED (`neg_s`), so its
494
+ // C differs from the bytes at that one input only, where its `-b` is undefined. agbcc folds the
495
+ // defined `a > (int)(0u - b)` into `cmn; bgt` too, and miscompiles it there, so the claim is
496
+ // narrower than "exact for every source": a C spelling that recompiles to the same bytes AND
497
+ // diverges from them only where it is itself undefined. When rB always holds INT_MIN that one
498
+ // input is every input, and the structurer refuses (structure.ts, `neg_s`);
499
+ // * C is the carry out of rA + rB, which is NOT rA >=u -rB: at rB == 0 cmn's carry is clear and
500
+ // the compare's is set. agbcc folds unsigned `a < -b` into `cmn; bcs` regardless — it
501
+ // miscompiles at b == 0 — and the C that is exact there (`a + b >= a`) compiles to
502
+ // `add; cmp; bcc` instead. Unsigned C defines every input, so no spelling both matches the
503
+ // bytes and leaves the divergence undefined, and the unsigned branches refuse. The sentence
504
+ // stays literal at its throw in `lowerTerminator`: the decline taxonomy (`cmn-carry`) is
505
+ // checked against throw texts as written.
506
+ const CMN_EXACT = new Set(['beq', 'bne', 'blt', 'ble', 'bgt', 'bge']);
418
507
  const regNum = (r: string) => (r[0] === 'r' ? Number(r.slice(1)) : REG_NUM[r]);
419
508
  function expandRegList(tokens: string[]): string[] {
420
509
  const out: string[] = [];
@@ -629,25 +718,15 @@ const isFrameAdjust = (
629
718
  (base === undefined || isSpReg(base)) &&
630
719
  (off?.startsWith('#') ?? false);
631
720
 
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
721
  // INCOMING STACK ARGUMENTS (AAPCS). Args 1-4 arrive in r0-r3; args 5+ are pushed by the CALLER,
645
722
  // so the callee reads them at `[sp, #N]` where N is at or above its own frame. Those are
646
723
  // PARAMETERS, not locals — declining them as "sp used as data" refuses a calling convention.
647
724
  //
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.
725
+ // The frame depth is tracked by a linear walk of ONE BLOCK, from a depth that is exact where the
726
+ // block starts: 0 at the entry block (when nothing branches back to it), and for every other block
727
+ // the depth the entry block leaves, but only where the function is proven never to move sp outside
728
+ // its prologue and its returning tails (`frameDepthBlocker`). Within a straight-line block the depth
729
+ // at each instruction is then exact. Every case that would need more declines.
651
730
  //
652
731
  // `push {a,b,c}` deepens by 4 per register; `sub sp, #N` and `add sp, sp, #-N` deepen by N.
653
732
  //
@@ -660,12 +739,12 @@ const stackArgIndex = (key: string): number | null => {
660
739
  // preserve is written next to the state it is about.
661
740
  //
662
741
  // `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 }) => {
742
+ // count, and the depth this block starts at (null where none is proven) — so the walk itself reads
743
+ // nothing but the instructions it is stepped over.
744
+ const makeFrameWalk = (deps: { argRegs: readonly string[]; start: number | null }) => {
666
745
  // Bytes the frame has grown since function entry, along this block's linear order only.
667
- let depth = 0;
668
- let depthKnown = true;
746
+ let depth = deps.start ?? 0;
747
+ let depthKnown = deps.start !== null;
669
748
 
670
749
  // Returns null whenever the depth cannot be computed exactly — including for any write to sp
671
750
  // this does not model. The capability rests on the depth being EXACT, so an approximation is
@@ -730,6 +809,11 @@ const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolea
730
809
  }
731
810
  },
732
811
 
812
+ /** The depth the instructions stepped so far leave, or null where it is not exact. */
813
+ depthNow(): number | null {
814
+ return depthKnown ? depth : null;
815
+ },
816
+
733
817
  // Is `[sp, #off]` an incoming stack argument, and which one? `null` means NO — and every null is
734
818
  // a DECLINE, because the caller falls through to readData's sp guard.
735
819
  //
@@ -753,19 +837,16 @@ const makeFrameWalk = (deps: { argRegs: readonly string[]; entryHasPreds: boolea
753
837
  // reads. `step` enforces it, and never un-poisons: once sp has been above the line, a push during
754
838
  // the excursion may have written the later slots too. This is a PREMISE, not a detail — leaving
755
839
  // 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 {
840
+ argIndex(addr: { base: string; off: number; regOff?: string }, width: number): number | null {
757
841
  const { base, off, regOff } = addr;
758
842
  if (!isSpReg(base) || regOff !== undefined) {
759
843
  return null; // not sp, or `[sp, rX]` — not a fixed argument slot
760
844
  }
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
845
  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
846
+ return null; // no exact start (`start`), an unmeasurable frame, or none established: a
847
+ // headerless FRAGMENT whose prologue was sliced off looks identical to a frameless
848
+ // function, and there the slots are locals — minting one would be the silent-wrong trade
849
+ // this frontend refuses
769
850
  }
770
851
  if (width !== 4 || off < depth || (off - depth) % 4 !== 0) {
771
852
  return null; // the argument area is word-granular; BELOW the top is a local, which is the
@@ -966,11 +1047,14 @@ const sayFact = (f: LayoutFact): string => {
966
1047
  function decode(
967
1048
  name: string,
968
1049
  asm: string,
1050
+ farJumpMark?: RegExp,
969
1051
  ): {
970
1052
  blocks: AsmBlock[];
971
1053
  dataWords: Map<string, string[]>;
972
1054
  nonWordData: Map<string, string>;
973
1055
  funcLabels: Set<string>;
1056
+ /** Each label that shares its instruction with a later one, to the label whose block was kept. */
1057
+ aliasOf: ReadonlyMap<string, string>;
974
1058
  } {
975
1059
  // Flatten to (label | instr | data) items, then split into blocks at labels / after branches.
976
1060
  // `.word LABEL` directives are captured into dataWords keyed by the most recent label (the
@@ -1022,10 +1106,19 @@ function decode(
1022
1106
  // `.Letext0`) that no code line names. A label starts a block, and a block split at a
1023
1107
  // lexical-scope marker restructures the loop around it: kept, it lifts the pair's `gcd` loop as
1024
1108
  // an `if` around a `do` instead of a `while`.
1109
+ //
1110
+ // Every `@` comment is dropped here save ONE fact read from it: that a `bl` line carried the
1111
+ // target's far-jump mark (`farJumpMark`, from `compilerBehaviors.farJumpRelay`). Kept as a set of
1112
+ // line positions, not as text, so nothing downstream can read any other comment.
1025
1113
  const codeLines: string[] = [];
1114
+ const farJumpLines = new Set<number>();
1026
1115
  for (const rawLine of withoutDebugSections(asm).split('\n')) {
1027
- const line = rawLine.split('@')[0].trim();
1116
+ const at = rawLine.indexOf('@');
1117
+ const line = (at === -1 ? rawLine : rawLine.slice(0, at)).trim();
1028
1118
  if (line) {
1119
+ if (at !== -1 && farJumpMark?.test(rawLine.slice(at))) {
1120
+ farJumpLines.add(codeLines.length);
1121
+ }
1029
1122
  codeLines.push(line);
1030
1123
  }
1031
1124
  }
@@ -1037,7 +1130,8 @@ function decode(
1037
1130
  }
1038
1131
  }
1039
1132
  const isDebugMarker = (label: string) => /^\.L(?:FB|FE|M|BB|BE|etext)\d+$/.test(label) && !namedByCode.has(label);
1040
- for (let rest of codeLines) {
1133
+ for (const [lineNo, line] of codeLines.entries()) {
1134
+ let rest = line;
1041
1135
  // A label may share the line with what follows it (pret pools: `_08x: .4byte 0x…`) — peel it.
1042
1136
  const lm = rest.match(/^([A-Za-z_.$][\w.$]*):\s*(.*)$/);
1043
1137
  if (lm && isDebugMarker(lm[1]) && lm[2] === '') {
@@ -1234,6 +1328,7 @@ function decode(
1234
1328
  mnemonic: canon,
1235
1329
  ops,
1236
1330
  ...(canon === m[1] ? {} : { asWritten: m[1] }),
1331
+ ...(canon === 'bl' && ops.length === 1 && farJumpLines.has(lineNo) ? { farJump: true as const } : {}),
1237
1332
  },
1238
1333
  });
1239
1334
  }
@@ -1738,7 +1833,7 @@ function decode(
1738
1833
  continue;
1739
1834
  }
1740
1835
  if (!cur) {
1741
- cur = { label: first ? name : `.L_anon${anon++}`, instrs: [] };
1836
+ cur = { label: first ? name : `${ANON_BLOCK_PREFIX}${anon++}`, instrs: [] };
1742
1837
  blocks.push(cur);
1743
1838
  first = false;
1744
1839
  }
@@ -1787,7 +1882,9 @@ function decode(
1787
1882
  for (const b of aliasOf.size ? blocks : []) {
1788
1883
  for (const ins of b.instrs) {
1789
1884
  const k = ins.ops.length - 1;
1790
- if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic]) && k >= 0) {
1885
+ // A marked relay is a branch here too (classifyXfer), though still spelt `bl` until
1886
+ // `lowerFarJumpRelays`: a relay to a dropped alias must name the label it stands for.
1887
+ if ((ins.mnemonic === 'b' || COND_OPCODE[ins.mnemonic] || ins.farJump) && k >= 0) {
1791
1888
  ins.ops[k] = aliasOf.get(ins.ops[k]) ?? ins.ops[k];
1792
1889
  }
1793
1890
  }
@@ -1870,7 +1967,7 @@ function decode(
1870
1967
  boundaryIdx++;
1871
1968
  continue;
1872
1969
  }
1873
- return { blocks: live, dataWords, nonWordData, funcLabels: new Set(funcLabels) };
1970
+ return { blocks: live, dataWords, nonWordData, funcLabels: new Set(funcLabels), aliasOf };
1874
1971
  }
1875
1972
  }
1876
1973
 
@@ -1994,6 +2091,7 @@ type PoolRef =
1994
2091
  | { kind: 'const'; value: number }
1995
2092
  | { kind: 'gaddr'; sym: string; addend: number }
1996
2093
  | { kind: 'local-static'; symbol: string; addend: number }
2094
+ | { kind: 'anonymous'; label: string }
1997
2095
  | { kind: 'unmodelled'; why: string };
1998
2096
 
1999
2097
  /** Classify a word-load operand `LABEL[+N]` against the captured literal pools. Returns null when
@@ -2126,6 +2224,13 @@ function poolRef(
2126
2224
  why: `pool word '${w}' has a leading-zero magnitude, which is octal to the assembler`,
2127
2225
  };
2128
2226
  }
2227
+ // A bare `.L` label in a pool word is the address of something the compiler placed and the
2228
+ // source never named (reloc-symbol.ts `anon-pool`): agbcc's `.LC0` is the constant image a local
2229
+ // aggregate's initializer is copied from. The caller reads it as an anonymous object, or refuses
2230
+ // naming why it is not one.
2231
+ if (classifyRelocSymbol(w) === 'anon-pool') {
2232
+ return { kind: 'anonymous', label: w };
2233
+ }
2129
2234
  return { kind: 'unmodelled', why: `pool word '${w}' is not a symbol, symbol±offset, or number` };
2130
2235
  }
2131
2236
 
@@ -2181,7 +2286,8 @@ function poolNamesASymbol(
2181
2286
  // and its unique bounds predecessor `bounds` ending in `cmp rX,#(N-1); bhi DEF`, verify the exact
2182
2287
  // idiom and read the inline table — else return null (→ the indirect-jump loud-fail fires). The
2183
2288
  // recovered switch REPLACES both blocks: `bounds` emits a `switch_br` (scrutinee rX; successors =
2184
- // case blocks + DEF).
2289
+ // case blocks + DEF) — unless the dispatch carries code of its own (`extras`), which keeps it as
2290
+ // the block that emits the switch, with the bounds test left as an ordinary branch.
2185
2291
  //
2186
2292
  // bounds: cmp rX,#(N-1); bhi DEF disp: lsl rY,rX,#2 ; ldr rP,=PTR ; add rA,rY,rP
2187
2293
  // ; ldr rV,[rA] ; mov pc,rV
@@ -2193,6 +2299,13 @@ interface JumpTable {
2193
2299
  scrutReg: string;
2194
2300
  caseLabels: string[];
2195
2301
  defaultLabel: string;
2302
+ /** What runs between the table load and the `mov pc` ({@link dispatchExtraRefusal}). */
2303
+ extras: Instr[];
2304
+ /** What the dispatch idiom leaves in each register it writes (index, table pointer, target;
2305
+ * jump-table.ts `idiomLeaves`). The `add` rewrites the index the `lsl` scaled, so every one is a
2306
+ * marker here: an elided dispatch writes them at the entry of each case target (`caseEntries`), a
2307
+ * kept one at its own block's end, save the ones its extras rewrote. */
2308
+ leaves: ReadonlyMap<string, IdiomLeft>;
2196
2309
  }
2197
2310
 
2198
2311
  // Accept a data-processing mnemonic in either the pre-UAL (`lsl`, `add`) or UAL (`lsls`, `adds`)
@@ -2235,9 +2348,10 @@ interface JumpTable {
2235
2348
  // Inside the dispatch block the distinction is additionally UNOBSERVABLE: the block computes
2236
2349
  // `table_base + index*4`, loads the target and writes `pc`. Nothing between the `lsl` and the
2237
2350
  // `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.
2351
+ // idiom is never lifted (the block is elided, or keeps only the code `dispatchExtraRefusal` admits)
2352
+ // — the bounds test that does feed a conditional branch lives in `bounds`, whose `cmp`/`bhi`/`bls`
2353
+ // this function matches by exact name. Doubly moot in fact, since `lsl rD, rS, #imm` is
2354
+ // low-register-only, so the add here is always the low-register form.
2241
2355
  const isDataOp = (mn: string, base: 'lsl' | 'add'): boolean => mn === base || mn === `${base}s`;
2242
2356
 
2243
2357
  function recoverJumpTable(
@@ -2246,6 +2360,8 @@ function recoverJumpTable(
2246
2360
  dataWords: Map<string, string[]>,
2247
2361
  nonWordData: Map<string, string>,
2248
2362
  blockLabels: Set<string>,
2363
+ aliasOf: ReadonlyMap<string, string>,
2364
+ refused: (why: string) => void,
2249
2365
  longDefault?: string,
2250
2366
  ): JumpTable | null {
2251
2367
  // bounds: last two instrs are `cmp rX,#M` then the out-of-range guard, in one of two spellings.
@@ -2290,12 +2406,15 @@ function recoverJumpTable(
2290
2406
  return null; // a bound that admits no case at all is not a dispatch — fail closed
2291
2407
  }
2292
2408
 
2293
- // disp: exactly the 5-op idiom, threading a single index register from `lsl rY,rX,#2`.
2409
+ // disp: the 5-op idiom, threading a single index register from `lsl rY,rX,#2`, with any
2410
+ // instructions between the table load and the `mov pc` (`extras`, judged below).
2294
2411
  const d = disp.instrs;
2295
- if (d.length !== 5) {
2412
+ if (d.length < 5) {
2296
2413
  return null;
2297
2414
  }
2298
- const [lsl, ldrP, add, ldrV, movpc] = d;
2415
+ const [lsl, ldrP, add, ldrV] = d;
2416
+ const movpc = d[d.length - 1];
2417
+ const extras = d.slice(4, -1);
2299
2418
  if (!isDataOp(lsl.mnemonic, 'lsl') || lsl.ops[1] !== scrutReg || !immEq(lsl.ops[2], 2)) {
2300
2419
  return null;
2301
2420
  }
@@ -2344,12 +2463,13 @@ function recoverJumpTable(
2344
2463
  if (movpc.mnemonic !== 'mov' || movpc.ops[0] !== 'pc') {
2345
2464
  return null;
2346
2465
  }
2466
+ const extraRefusal = dispatchExtraRefusal(extras, ldrV.ops[0], [idxReg, ptrReg, ldrV.ops[0]]);
2467
+ if (extraRefusal !== null) {
2468
+ refused(extraRefusal);
2469
+ return null;
2470
+ }
2347
2471
 
2348
2472
  // 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
2473
  //
2354
2474
  // The pointer word is addressed the same way every other pool load in this frontend is —
2355
2475
  // `LABEL[+N]`, selecting word N/4 — because a literal pool is a POOL: agbcc packs the dispatch
@@ -2373,16 +2493,71 @@ function recoverJumpTable(
2373
2493
  if (ptrOff % 4 !== 0 || ptrOff / 4 >= ptrWords.length) {
2374
2494
  return null; // misaligned or past the end of the pool — not a word this pool holds
2375
2495
  }
2376
- const caseLabels = recordedWords(ptrWords[ptrOff / 4].trim(), dataWords, nonWordData);
2377
- if (!caseLabels || caseLabels.length !== n) {
2496
+ const written = recordedWords(ptrWords[ptrOff / 4].trim(), dataWords, nonWordData);
2497
+ if (!written || written.length !== n) {
2378
2498
  return null;
2379
2499
  } // table length must equal the bound
2500
+ // An entry naming a label that shares its instruction with a later one dispatches to that one, as
2501
+ // a branch to it does (`decode`'s `aliasOf`): jump.c cross-jumps a case arm identical to the
2502
+ // default's onto the default's first instruction and leaves the case's label standing on it. Only
2503
+ // the TABLE's entries are read through it — a pool word naming a code label elsewhere is an
2504
+ // address constant, not a transfer — and `aliasOf` already holds no data label on either side, so
2505
+ // an entry naming a label that fronts data, or one left trailing, still declines below.
2506
+ const caseLabels = written.map((l) => aliasOf.get(l) ?? l);
2380
2507
  // Every case target and the default must resolve to a real decoded block; a label that is an
2381
2508
  // expression (`.L4+4`) or points outside the function would otherwise crash later — decline cleanly.
2382
2509
  if (!blockLabels.has(defaultLabel) || caseLabels.some((l) => !blockLabels.has(l))) {
2383
2510
  return null;
2384
2511
  }
2385
- return { scrutReg, caseLabels, defaultLabel };
2512
+ const leaves = idiomLeaves([idxReg, ptrReg, ldrV.ops[0]].map((reg) => ({ reg, scaled: false })));
2513
+ return { scrutReg, caseLabels, defaultLabel, extras, leaves };
2514
+ }
2515
+
2516
+ // The instructions an agbcc dispatch may carry between the table load and the `mov pc`. gcse's
2517
+ // PRE (`insert_insn_end_bb`, gcse.c:4231-4236) places an expression a later block needs "just before
2518
+ // the tablejump" — thumb.md's `tablejump` is the `mov pc` alone, so after the load. They run on every
2519
+ // dispatch and on nothing else, so they stay in a block of their own between the bounds test and the
2520
+ // switch (`readThumbText`), which switches on the scrutinee as the dispatch's first instruction read
2521
+ // it (`lowerTerminator`). PRE inserts `reg = expr` and nothing more, so the instructions accepted are
2522
+ // the ones `mayWriteReg` knows write their first operand — the data-processing ops and the single
2523
+ // loads — less the two that read the carry; anything else refuses:
2524
+ // - a store, a call, a transfer, a flag READ (`adc`/`sbc`), or any other mnemonic;
2525
+ // - a write to the loaded target (it is what `mov pc` jumps through), or to sp/pc/lr. A write to
2526
+ // the scrutinee is admitted: the switch does not read it after the extras, and the case blocks
2527
+ // read what the extras left there on the hardware too. lr is refused because the lifter reads
2528
+ // `bx lr` as a return by the register's name (`refuseReturnsNotThroughCallersLr`'s NOT ASKED):
2529
+ // a case's `bx lr` after `mov lr, r3` would lift as `return`, where the hardware jumps to r3.
2530
+ // PRE never inserts an lr write, so the refusal costs no compiled dispatch;
2531
+ // - a read of a register the idiom wrote (index, table pointer, target) before an extra rewrote it:
2532
+ // the idiom is not lifted, so such a read would see the value from before the dispatch.
2533
+ // The reason is returned so the decline can name it (`readThumbText`'s indirect-jump refusal).
2534
+ const READS_CARRY = /^(?:adc|sbc)s?$/;
2535
+ /** Besides a load (`SINGLE_LOAD`), the forms that write their destination without reading it. */
2536
+ const DEST_ONLY = /^(?:mov|mvn|neg)s?$/;
2537
+ function dispatchExtraRefusal(
2538
+ extras: readonly Instr[],
2539
+ targetReg: string,
2540
+ idiomRegs: readonly string[],
2541
+ ): string | null {
2542
+ const stale = new Set(idiomRegs);
2543
+ for (const ins of extras) {
2544
+ const mn = ins.mnemonic;
2545
+ const say = `'${mn} ${ins.ops.join(', ')}'`;
2546
+ if (!(SINGLE_LOAD.test(mn) || WRITES_OPERAND_0.test(mn)) || READS_CARRY.test(mn) || ins.ops.length < 2) {
2547
+ return `${say} is not a register computation or a load`;
2548
+ }
2549
+ const dest = ins.ops[0];
2550
+ if ([targetReg, 'sp', 'r13', 'pc', 'r15', 'lr', 'r14'].includes(dest)) {
2551
+ return `${say} writes the register the dispatch needs, '${dest}'`;
2552
+ }
2553
+ const reads = ins.ops.length > 2 || SINGLE_LOAD.test(mn) || DEST_ONLY.test(mn) ? ins.ops.slice(1) : ins.ops;
2554
+ const read = [...stale].find((r) => mentionsReg({ ops: reads }, r));
2555
+ if (read !== undefined) {
2556
+ return `${say} reads '${read}', which the unlifted dispatch idiom wrote`;
2557
+ }
2558
+ stale.delete(dest);
2559
+ }
2560
+ return null;
2386
2561
  }
2387
2562
 
2388
2563
  /** Lift decoded asm → an L1 Fn with block-argument SSA. `prototypes` supplies each callee's
@@ -2402,16 +2577,12 @@ export function lift(
2402
2577
  prototypes: Prototypes = {},
2403
2578
  asmData?: AsmData,
2404
2579
  symbols?: SymbolMap,
2580
+ context?: ContextGlobals,
2581
+ options: LiftOptions = {},
2405
2582
  ): 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;
2583
+ return liftWithRelift(name, (oneObject) =>
2584
+ liftOnce(name, asm, target, prototypes, asmData, symbols, context, oneObject, options),
2585
+ );
2415
2586
  }
2416
2587
 
2417
2588
  /** ONE LIFT, AS STAGES RUN IN THEIR REFUSAL ORDER: every stage before the fill (`thumbFillOf`: the
@@ -2430,9 +2601,11 @@ function liftOnce(
2430
2601
  prototypes: Prototypes,
2431
2602
  _asmData: AsmData | undefined,
2432
2603
  symbols: SymbolMap | undefined,
2604
+ context: ContextGlobals | undefined,
2433
2605
  oneObject: FrameRange | undefined,
2606
+ options: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto' | 'ownMemoryReturn'>,
2434
2607
  ): Fn | FrameObjectRelift {
2435
- const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject);
2608
+ const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject, options);
2436
2609
  const { cfg, frame, ssa, pairs, statics } = fill;
2437
2610
  const { fn, irBlocks, paramReg } = ssa;
2438
2611
 
@@ -2441,26 +2614,47 @@ function liftOnce(
2441
2614
  fillThumbBlock(fill, ab, bi);
2442
2615
  ssa.markFilled(bi);
2443
2616
  });
2617
+ refuseStaleIdiomReads(
2618
+ name,
2619
+ irBlocks,
2620
+ fill.staleIdiomValues,
2621
+ ssa,
2622
+ prototypes[name] ?? options.contextOwnProto?.proto,
2623
+ (m) => {
2624
+ throw new FrontendUnsupportedError(m);
2625
+ },
2626
+ fill.calls,
2627
+ );
2628
+ // After the fill, so a block's own refusal (the frame's, the operands') keeps its place in the
2629
+ // refusal order: the proof reads only the text and the CFG, and changes nothing.
2630
+ refuseReturnsNotThroughCallersLr(name, cfg);
2631
+ // The fill reads a `pop` as transparent, so a callee-saved register one fills with another
2632
+ // register's word is a write no op shows: refused here, after the return proof, which judges the
2633
+ // same words for lr and has its own answer for a pop that takes lr's.
2634
+ const [foreign] = fill.poppedForeign;
2635
+ if (foreign !== undefined) {
2636
+ const [reg, at] = foreign;
2637
+ throw new FrontendUnsupportedError(
2638
+ `cannot lift '${name}': '${at.mnemonic} ${at.ops.join(', ')}' loads a word that is not ${reg}'s own save ` +
2639
+ `into callee-saved ${reg}, and ${reg} reaches a return so, unrestored — the caller sees the write, which ` +
2640
+ 'no C statement spells',
2641
+ );
2642
+ }
2444
2643
 
2445
2644
  pairs.refuseWordReturns();
2446
2645
  // An argument register by its place in `argRegs`, an incoming stack word by its index after them.
2447
2646
  // One word per argument, which is what this frontend assumes everywhere: an 8-byte argument,
2448
2647
  // which AAPCS may align into r1 or straddle across r3 and the stack, would break the index-slot
2449
2648
  // 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
- };
2649
+ const argSlots = stackArgSlots(registerArgSlots(target.argRegs), target.argRegs);
2461
2650
  mintArgSlotHoles(ssa, cfg.preds[0].length > 0, argSlots);
2462
2651
  ssa.finish();
2463
2652
 
2653
+ // A struct passed by value becomes the one object its words hold (call-plan.ts `foldByValueArgs`)
2654
+ // before the audit judges the frame objects, that one among them.
2655
+ foldByValueArgs(name, irBlocks, fill.calls, { symbols, context });
2656
+ nameMergedSretObjects(irBlocks);
2657
+
2464
2658
  // Prove every `laddr` this function emitted really does name storage of this function's own, at
2465
2659
  // a shape the machine states, or decline (auditFrameObjects); an accepted function then gets the
2466
2660
  // device pin the audit answers (pinDeviceAccesses).
@@ -2469,7 +2663,9 @@ function liftOnce(
2469
2663
  irBlocks,
2470
2664
  ...frame.partition,
2471
2665
  usedSlotOffsets: fill.frameUses.usedSlotOffsets,
2666
+ loadedWords: frame.loadedWords,
2472
2667
  capturedObjectIsTheWholeFrame: frame.capturedObjectIsTheWholeFrame,
2668
+ escapesReliedOn: frame.escapesReliedOn,
2473
2669
  movedCaptures: fill.frameUses.movedCaptures,
2474
2670
  returnsWithoutHiddenPointer: fill.calls.returnsWithoutHiddenPointer,
2475
2671
  symbols,
@@ -2482,21 +2678,31 @@ function liftOnce(
2482
2678
  if (verdict !== undefined) {
2483
2679
  pinDeviceAccesses(irBlocks, verdict, target, symbols);
2484
2680
  }
2485
- refuseSretOutsideFrame(name, irBlocks);
2681
+ refuseSretOutsideFrame(name, irBlocks, target);
2682
+ pinDeadReads(irBlocks, target, {
2683
+ name,
2684
+ own: prototypes[name],
2685
+ atWordReturn: pairs.unreadHigh,
2686
+ atUndeclaredCall: fill.calls.heldAtUndeclaredCall,
2687
+ compared: fill.flags.compared,
2688
+ spilled: ssa.frameWrites,
2689
+ declarations: { symbols, context },
2690
+ });
2486
2691
 
2487
2692
  abiSortEntryParams(irBlocks[0], cfg.preds[0].length > 0, paramReg, argSlots);
2488
2693
  fn.localObjects = statics.finish();
2489
2694
  return fn;
2490
2695
  }
2491
2696
 
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. */
2697
+ /** THE TEXT A LIFT READS: the decoded blocks with every recovered jump table's dispatch elided
2698
+ * (or, where it carries code of its own, cut down to that code), and the data its pool loads
2699
+ * read. Every control transfer the CFG cannot model is refused by the time this exists. */
2495
2700
  interface ThumbText {
2496
2701
  /** The blocks the CFG is built from — read by `thumbCfg` alone; every later stage indexes
2497
2702
  * `ThumbCfg.asmBlocks`. */
2498
2703
  readonly blocks: readonly AsmBlock[];
2499
- /** A recovered jump table, by its bounds block. */
2704
+ /** A recovered jump table, by the block that emits its switch: the bounds block, or a kept
2705
+ * dispatch block. */
2500
2706
  readonly tables: ReadonlyMap<AsmBlock, JumpTable>;
2501
2707
  readonly dataWords: ReadonlyMap<string, string[]>;
2502
2708
  readonly nonWordData: ReadonlyMap<string, string>;
@@ -2505,14 +2711,308 @@ interface ThumbText {
2505
2711
  readonly poolNamesSymbols: boolean;
2506
2712
  }
2507
2713
 
2508
- function readThumbText(name: string, asm: string): ThumbText {
2714
+ // One spelling per register for the return proof below, which tracks registers by identity:
2715
+ // `r14` and `lr` are one register, and so are `ip` and `r12`.
2716
+ const REG_ALIAS: Readonly<Record<string, string>> = {
2717
+ sp: 'r13',
2718
+ lr: 'r14',
2719
+ pc: 'r15',
2720
+ sb: 'r9',
2721
+ sl: 'r10',
2722
+ fp: 'r11',
2723
+ ip: 'r12',
2724
+ };
2725
+ const canonReg = (r: string): string | null => {
2726
+ const t = r.replace(/!$/, '');
2727
+ return /^r(\d|1[0-5])$/.test(t) ? t : (REG_ALIAS[t] ?? null);
2728
+ };
2729
+ const regIndex = (r: string): number => Number(r.slice(1));
2730
+ /** A push/pop/ldm list in the order the machine moves it, canonical names (`canonReg`): by register
2731
+ * number, lowest address first. Null where a name is not a register. */
2732
+ const byRegNumber = (list: readonly string[]): string[] | null => {
2733
+ const regs = list.map(canonReg);
2734
+ return regs.some((r) => r === null) ? null : (regs as string[]).sort((x, y) => regIndex(x) - regIndex(y));
2735
+ };
2736
+ // Registers `ins` may write, besides sp and besides a `pop`'s list (the proof reads both itself).
2737
+ // OVER-approximate: naming a register here that the instruction does not write costs a decline,
2738
+ // and missing one costs a wrong return. 'all' for a call, and for anything this cannot read.
2739
+ const NO_REG_WRITE: ReadonlySet<string> = new Set([
2740
+ 'str',
2741
+ 'strb',
2742
+ 'strh',
2743
+ 'push',
2744
+ 'cmp',
2745
+ 'cmn',
2746
+ 'tst',
2747
+ 'nop',
2748
+ 'b',
2749
+ 'bx',
2750
+ ]);
2751
+ function regWritesOf(ins: Instr): readonly string[] | 'all' {
2752
+ const mn = ins.mnemonic;
2753
+ if (NO_REG_WRITE.has(mn) || COND_OPCODE[mn]) {
2754
+ return [];
2755
+ }
2756
+ if (mn === 'bl' || mn === 'blx' || mn === 'swi') {
2757
+ return 'all';
2758
+ }
2759
+ if (/^(ldm|stm)/.test(mn)) {
2760
+ const base = canonReg(ins.ops[0] ?? '');
2761
+ const list = /^ldm/.test(mn) ? regListOf(ins.ops.slice(1)) : [];
2762
+ if (base === null || list === null) {
2763
+ return 'all';
2764
+ }
2765
+ const listed = list.map(canonReg);
2766
+ return listed.some((r) => r === null) ? 'all' : [base, ...(listed as string[])];
2767
+ }
2768
+ const dest = canonReg(ins.ops[0] ?? '');
2769
+ return dest === null ? 'all' : [dest];
2770
+ }
2771
+
2772
+ /** What the return proof knows at one point of the function: the frame depth `makeFrameWalk`
2773
+ * measures, which word of the frame holds the caller's lr (`slot`, as the depth at which it sits:
2774
+ * the word at entry sp − slot), and which registers hold it. */
2775
+ interface LrState {
2776
+ readonly depth: number | null;
2777
+ readonly slot: number | null;
2778
+ readonly regs: ReadonlySet<string>;
2779
+ }
2780
+
2781
+ /** EVERY RETURN OF THIS FUNCTION RETURNS THROUGH THE CALLER'S lr, or the lift declines.
2782
+ *
2783
+ * `classifyXfer` reads `bx rN`, `pop {…, pc}` and `mov pc, lr` as returns from the instruction
2784
+ * alone, and each is one only when the word it transfers to is the lr this function was called
2785
+ * with: in lr itself, in the frame word the prologue's `push` saved it to, or in a register a
2786
+ * `pop` loaded from that word. Which word a `pop` reads is a question about the FRAME, not about
2787
+ * register names — a `pop` fills its list in register-number order from the lowest address up —
2788
+ * so this walks the frame (`makeFrameWalk`) over the CFG and tracks where the caller's lr is:
2789
+ * • a `push` that saves a register holding it records the word it went to;
2790
+ * • a `pop` whose k-th register (by number) reads that word puts it in that register, and every
2791
+ * other register it fills loses it;
2792
+ * • the word stops holding it when sp rises past it (a `pop` or `add sp` releases it), a
2793
+ * `str` through sp overwrites it, or the depth stops being one number;
2794
+ * • a register stops holding it when anything writes the register (a call writes them all);
2795
+ * a `mov` copies it.
2796
+ * Paths meet by agreement: a depth or a word the predecessors disagree on is unknown, and a
2797
+ * register holds lr only if it does on every path in.
2798
+ *
2799
+ * So `push {r4, lr}; pop {r0}; bx r0` (the saved r4), `push {lr}; sub sp, #8; pop {r1}; bx r1` (a
2800
+ * local), `pop {r4, r1}` after `push {r4, lr}` (r1 is filled first, from r4's word) and
2801
+ * `mov r0, #7; bx r3` all decline, where agbcc's `pop {r4}; pop {r0}; bx r0` and its variadic
2802
+ * `push {r0-r3}; push {lr}; … pop {r3}; add sp, sp, #0x10; bx r3` are returns. It is also what the
2803
+ * far-jump relay's lowering needs and does not itself prove (`lowerFarJumpRelays`): with every
2804
+ * return reading the CALLER's lr, the lr a relay writes cannot be what a return jumps to.
2805
+ *
2806
+ * NOT ASKED: `bx lr` and `mov pc, lr` are returns by the register's name, whether or not a call
2807
+ * has since overwritten lr. A compiler saves lr before it calls, so no compiled function reaches
2808
+ * that case. The far-jump relay does not lean on it: its lowering declines any function in which
2809
+ * an instruction other than the entry `push` names lr.
2810
+ *
2811
+ * Blocks no path from the entry reaches are not asked: they never run. */
2812
+ function refuseReturnsNotThroughCallersLr(name: string, cfg: ThumbCfg): void {
2813
+ const { asmBlocks, preds, entryReachable } = cfg;
2814
+ const succs: number[][] = asmBlocks.map(() => []);
2815
+ preds.forEach((ps, b) => ps.forEach((p) => succs[p].push(b)));
2816
+ const LR = 'r14';
2817
+ const join = (a: LrState, b: LrState): LrState => {
2818
+ const depth = a.depth !== null && a.depth === b.depth ? a.depth : null;
2819
+ return {
2820
+ depth,
2821
+ slot: depth !== null && a.slot !== null && a.slot === b.slot ? a.slot : null,
2822
+ regs: new Set([...a.regs].filter((r) => b.regs.has(r))),
2823
+ };
2824
+ };
2825
+ const same = (a: LrState, b: LrState) =>
2826
+ a.depth === b.depth && a.slot === b.slot && a.regs.size === b.regs.size && [...a.regs].every((r) => b.regs.has(r));
2827
+ const refuse = (ins: Instr): never => {
2828
+ throw new FrontendUnsupportedError(
2829
+ `cannot lift '${name}': indirect/computed jump '${ins.mnemonic} ${ins.ops.join(', ')}' is not a return ` +
2830
+ `through the caller's lr — on some path what it jumps to is not lr, nor the frame word the prologue ` +
2831
+ `saved lr in — jump tables / computed gotos / register tail calls not supported`,
2832
+ );
2833
+ };
2834
+
2835
+ // Run one block from its in-state, asking each return as it is reached; the out-state.
2836
+ const run = (b: number, inState: LrState): LrState => {
2837
+ const walk = makeFrameWalk({ argRegs: [], start: inState.depth });
2838
+ let slot = inState.slot;
2839
+ const regs = new Set(inState.regs);
2840
+ for (const ins of asmBlocks[b].instrs) {
2841
+ const mn = ins.mnemonic;
2842
+ const depth = walk.depthNow();
2843
+ const xfer = classifyXfer(ins);
2844
+ const isPop =
2845
+ mn === 'pop' || ((mn === 'ldmia' || mn === 'ldmfd') && isSpReg((ins.ops[0] ?? '').replace(/!$/, '')));
2846
+ if (isPop) {
2847
+ const list = byRegNumber(regListOf(mn === 'pop' ? ins.ops : ins.ops.slice(1)) ?? []);
2848
+ if (list === null || list.length === 0) {
2849
+ if (xfer === 'return') {
2850
+ refuse(ins);
2851
+ }
2852
+ regs.clear();
2853
+ } else {
2854
+ list.forEach((r, k) => {
2855
+ if (depth !== null && slot !== null && depth - 4 * k === slot) {
2856
+ regs.add(r);
2857
+ } else {
2858
+ regs.delete(r);
2859
+ }
2860
+ });
2861
+ if (xfer === 'return' && !regs.has('r15')) {
2862
+ refuse(ins); // `pop {…, pc}` that does not load pc from the saved lr's word
2863
+ }
2864
+ }
2865
+ } else if (xfer === 'return') {
2866
+ // `bx rN`: the register itself must hold the caller's lr. `bx lr` and `mov pc, lr` are
2867
+ // returns by name (see the doc comment).
2868
+ const via = canonReg(mn === 'bx' ? (ins.ops[0] ?? '') : (ins.ops[1] ?? ''));
2869
+ if (via !== LR && (via === null || !regs.has(via))) {
2870
+ refuse(ins);
2871
+ }
2872
+ } else if (mn === 'push') {
2873
+ const list = byRegNumber(regListOf(ins.ops) ?? []);
2874
+ const k = list?.findIndex((r) => regs.has(r)) ?? -1;
2875
+ if (list !== null && k >= 0 && depth !== null) {
2876
+ slot = depth + 4 * list.length - 4 * k;
2877
+ }
2878
+ } else if (/^str[bh]?$/.test(mn)) {
2879
+ const { base, off, regOff } = parseAddr(ins.ops[1] ?? '');
2880
+ if (isSpReg(base) && slot !== null) {
2881
+ const width = mn === 'strb' ? 1 : mn === 'strh' ? 2 : 4;
2882
+ const at = depth === null || regOff !== undefined ? null : depth - off; // the word's depth
2883
+ if (at === null || (at - width < slot && at > slot - 4)) {
2884
+ slot = null;
2885
+ }
2886
+ }
2887
+ } else if (!isSpReg(ins.ops[0])) {
2888
+ const writes = regWritesOf(ins);
2889
+ if (writes === 'all') {
2890
+ regs.clear();
2891
+ } else {
2892
+ const src = /^movs?$/.test(mn) ? canonReg(ins.ops[1] ?? '') : null;
2893
+ const copies = src !== null && regs.has(src);
2894
+ writes.forEach((r) => regs.delete(r));
2895
+ if (copies) {
2896
+ regs.add(writes[0]);
2897
+ }
2898
+ }
2899
+ }
2900
+ walk.step(ins);
2901
+ const after = walk.depthNow();
2902
+ if (slot !== null && (after === null || slot > after)) {
2903
+ slot = null; // released, or the frame is no longer one number
2904
+ }
2905
+ }
2906
+ return { depth: walk.depthNow(), slot, regs };
2907
+ };
2908
+
2909
+ const inStates = new Map<number, LrState>([[0, { depth: 0, slot: null, regs: new Set([LR]) }]]);
2910
+ const work = [0];
2911
+ while (work.length > 0) {
2912
+ const b = work.pop()!;
2913
+ const out = run(b, inStates.get(b)!);
2914
+ for (const s of succs[b]) {
2915
+ if (!entryReachable.has(s)) {
2916
+ continue;
2917
+ }
2918
+ const prev = inStates.get(s);
2919
+ const next = prev === undefined ? out : join(prev, out);
2920
+ if (prev === undefined || !same(prev, next)) {
2921
+ inStates.set(s, next);
2922
+ work.push(s);
2923
+ }
2924
+ }
2925
+ }
2926
+ }
2927
+
2928
+ /** Lower every far-jump relay the target marks (`bl .Lx @far jump`, agbcc's; see
2929
+ * `compilerBehaviors.farJumpRelay`) in the selected function to the `b .Lx` it stands for, in
2930
+ * place, or decline. Only a target that declares the mark gets here with one: `decode` sets
2931
+ * `farJump` from that declaration alone.
2932
+ *
2933
+ * A relay and a call differ in one effect: `bl` writes lr, `b` does not. So the lowering is sound
2934
+ * exactly when nothing can observe the lr the relay wrote. What the mark MEANS is the compiler's
2935
+ * fact, declared with it. That nothing observes lr is checked here on the function's own text,
2936
+ * not assumed from agbcc's habit (thumb.c `far_jump_used_p` makes it push lr in any function with
2937
+ * a far jump, which is why every relay in practice passes):
2938
+ * • the entry block saves lr with a `push`, and no branch of this function names the entry
2939
+ * label, so that push runs once, before any relay, and saves the CALLER's lr. A recovered
2940
+ * jump table is the other way back to the entry, and `readThumbText` asks that once the
2941
+ * tables exist (`relayed`). A `.word` naming the entry is not one: `dataWords` is the whole
2942
+ * file's, and a function stored as a callback in another function's pool is a pointer;
2943
+ * • no other instruction of the function names lr at all — no `bx lr`, no `mov pc, lr`, no
2944
+ * `mov rX, lr`, no second `push`. So the lr the relay writes never reaches a register, and no
2945
+ * code can return to it: `b` dropping that write is unobservable.
2946
+ * That every RETURN jumps through the caller's lr — not through a word the relay's lr could have
2947
+ * reached, nor through a saved callee register — is the other half of the argument, and it is
2948
+ * asked of every Thumb function once the CFG exists (`refuseReturnsNotThroughCallersLr`), where
2949
+ * the frame walk says which word each `pop` reads.
2950
+ * The mark itself is the only thing that makes a `bl` a relay: a call thunk (`bl call_r3`, in a
2951
+ * function that pushes lr too) has the same shape and no mark, and declines below.
2952
+ *
2953
+ * The relay must also target a code label of THIS function that is not a declared function start:
2954
+ * agbcc only relays within a function, and a marked `bl` anywhere else is not its relay. */
2955
+ function lowerFarJumpRelays(name: string, blocks: readonly AsmBlock[], funcLabels: ReadonlySet<string>): Instr | null {
2956
+ const instrs = blocks.flatMap((b) => b.instrs);
2957
+ const relays = instrs.filter((ins) => ins.farJump);
2958
+ if (relays.length === 0) {
2959
+ return null;
2960
+ }
2961
+ const blockLabels = new Set(blocks.map((b) => b.label));
2962
+ // The compiler is the target's declaration, not a name this function knows: "a far-jump relay".
2963
+ const say = (ins: Instr) => `'${ins.asWritten ?? ins.mnemonic} ${ins.ops.join(', ')}' (a far-jump relay)`;
2964
+ const refuse = (ins: Instr, why: string) => {
2965
+ throw new FrontendUnsupportedError(
2966
+ `cannot lift '${name}': ${say(ins)} ${why} — lowering it to a branch would drop the lr write a \`bl\` makes`,
2967
+ );
2968
+ };
2969
+ for (const relay of relays) {
2970
+ const target = relay.ops[0];
2971
+ if (!blockLabels.has(target) || funcLabels.has(target)) {
2972
+ throw new FrontendUnsupportedError(
2973
+ `cannot lift '${name}': ${say(relay)} does not target a code label of this function`,
2974
+ );
2975
+ }
2976
+ }
2977
+ const namesLr = (ins: Instr) => mentionsReg(ins, 'lr') || mentionsReg(ins, 'r14');
2978
+ const entry = blocks[0];
2979
+ const save = entry?.instrs.find(namesLr);
2980
+ if (!save || save.mnemonic !== 'push' || !(regListOf(save.ops) ?? []).some((r) => r === 'lr' || r === 'r14')) {
2981
+ refuse(relays[0], 'is in a function whose entry block does not save lr with a `push`');
2982
+ }
2983
+ if (instrs.some((ins) => classifyXfer(ins) !== null && ins.ops.includes(entry.label))) {
2984
+ refuse(relays[0], `is in a function that branches back to its entry '${entry.label}', past the lr save`);
2985
+ }
2986
+ const reader = instrs.find((ins) => ins !== save && namesLr(ins));
2987
+ if (reader) {
2988
+ refuse(relays[0], `is in a function that names lr after saving it ('${reader.mnemonic} ${reader.ops.join(', ')}')`);
2989
+ }
2990
+ for (const relay of relays) {
2991
+ relay.mnemonic = 'b';
2992
+ relay.asWritten = 'bl';
2993
+ }
2994
+ return relays[0];
2995
+ }
2996
+
2997
+ /** `farJumpMark` is the target's `compilerBehaviors.farJumpRelay` comment; absent, no `bl` is a
2998
+ * relay, and one to a label of this function declines below. */
2999
+ function readThumbText(name: string, asm: string, farJumpMark?: RegExp): ThumbText {
2509
3000
  assertInputFormat('thumb', 'gnu-as', asm);
2510
- const { blocks: rawBlocks, dataWords, nonWordData, funcLabels } = decode(name, asm);
3001
+ const { blocks: rawBlocks, dataWords, nonWordData, funcLabels, aliasOf } = decode(name, asm, farJumpMark);
3002
+ // Everything below keys a register by its spelling, so a function that writes one two ways
3003
+ // (`sl` and `r10`) is read in one of them.
3004
+ unifyRegisterSpellings(
3005
+ rawBlocks.flatMap((b) => b.instrs),
3006
+ ARM_REGISTER_ALIASES,
3007
+ );
3008
+ // Before anything below reads a `b`: the jump-table recovery counts branch references, and a
3009
+ // relay is one.
3010
+ const relayed = lowerFarJumpRelays(name, rawBlocks, funcLabels);
2511
3011
 
2512
3012
  // Regime B: recover agbcc jump tables. A dispatch block (`mov pc, rN`) plus its bounds
2513
3013
  // 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.
3014
+ // dispatch block is ELIDED from the CFG — or kept, when it carries code (below). A `mov pc` that
3015
+ // is NOT a recognised table falls through to the loud-fail below.
2516
3016
  const blockLabels = new Set(rawBlocks.map((b) => b.label));
2517
3017
  const poolNamesSymbols = poolNamesASymbol(dataWords, nonWordData, blockLabels);
2518
3018
  // Any label referenced as a branch target (so we can tell if an elided dispatch block has a SECOND
@@ -2528,8 +3028,25 @@ function readThumbText(name: string, asm: string): ThumbText {
2528
3028
  }
2529
3029
  }
2530
3030
  }
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
3031
+ const tables = new Map<AsmBlock, JumpTable>(); // the block emitting the switch → recovered table
3032
+ const elided = new Set<AsmBlock>(); // dispatch blocks removed from the CFG
3033
+ const relays = new Set<AsmBlock>(); // long-jump `b DEF` blocks removed with an elided dispatch
3034
+ // A dispatch carrying code of its own (`JumpTable.extras`) is not elided: it stays as the block the
3035
+ // bounds test leads to, holding only that code, and the switch is emitted from it. The bounds test
3036
+ // and the long-jump `b DEF` keep their ordinary edges, so the code runs on the dispatched path and
3037
+ // on no other.
3038
+ const kept = new Map<AsmBlock, AsmBlock>();
3039
+ const keepDispatch = (d: AsmBlock, jt: JumpTable): boolean => {
3040
+ if (jt.extras.length === 0) {
3041
+ return false;
3042
+ }
3043
+ const own = { ...d, instrs: jt.extras };
3044
+ kept.set(d, own);
3045
+ tables.set(own, jt);
3046
+ return true;
3047
+ };
3048
+ // Why a dispatch's table was recognised and then refused, for the indirect-jump decline below.
3049
+ const tableRefusals = new Map<AsmBlock, string>();
2533
3050
  rawBlocks.forEach((d, i) => {
2534
3051
  const last = d.instrs[d.instrs.length - 1];
2535
3052
  if (!last || last.mnemonic !== 'mov' || last.ops[0] !== 'pc' || last.ops[1] === 'lr') {
@@ -2540,7 +3057,12 @@ function readThumbText(name: string, asm: string): ThumbText {
2540
3057
  // Direct form: the dispatch is reached ONLY by falling through from its bounds predecessor. A
2541
3058
  // `b disp` from anywhere else would leave a dangling edge after elision, so decline (→ loud-fail).
2542
3059
  if (prev && refs === 0) {
2543
- const jt = recoverJumpTable(prev, d, dataWords, nonWordData, blockLabels);
3060
+ const jt = recoverJumpTable(prev, d, dataWords, nonWordData, blockLabels, aliasOf, (why) =>
3061
+ tableRefusals.set(d, why),
3062
+ );
3063
+ if (jt && keepDispatch(d, jt)) {
3064
+ return;
3065
+ }
2544
3066
  if (jt) {
2545
3067
  tables.set(prev, jt);
2546
3068
  elided.add(d);
@@ -2548,29 +3070,71 @@ function readThumbText(name: string, asm: string): ThumbText {
2548
3070
  }
2549
3071
  }
2550
3072
  // 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).
3073
+ // dispatch is entered by exactly that one `bls` and nothing else. When the dispatch is elided,
3074
+ // the `b DEF` block — named by no branch, so unreachable once the bounds block dispatches — is
3075
+ // elided WITH it unless a table entry names it. Leaving an unreachable one would make it a
3076
+ // parameterless predecessor of the default block, and wiring a phi through it fabricates an
3077
+ // entry parameter (the phantom-param miscompile). A kept dispatch keeps it as the bounds test's
3078
+ // fall-through.
2555
3079
  const boundsB = rawBlocks[i - 2];
2556
3080
  const prevNamed = prev ? (branchRefs.get(prev.label) ?? 0) > 0 : false;
2557
3081
  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) {
3082
+ const jt = recoverJumpTable(
3083
+ boundsB,
3084
+ d,
3085
+ dataWords,
3086
+ nonWordData,
3087
+ blockLabels,
3088
+ aliasOf,
3089
+ (why) => tableRefusals.set(d, why),
3090
+ prev.instrs[0].ops[0],
3091
+ );
3092
+ if (jt && !keepDispatch(d, jt)) {
2560
3093
  tables.set(boundsB, jt);
2561
3094
  elided.add(d);
2562
- elided.add(prev);
3095
+ relays.add(prev);
2563
3096
  }
2564
3097
  }
2565
3098
  });
2566
- const asmBlocks = rawBlocks.filter((b) => !elided.has(b));
3099
+ // A table entry naming a relay is an edge to the default through it, so that relay stays: it is
3100
+ // reachable, and the kept form's relay is the same block.
3101
+ const named = new Set([...tables.values()].flatMap((t) => [...t.caseLabels, t.defaultLabel]));
3102
+ const asmBlocks = rawBlocks
3103
+ .filter((b) => !elided.has(b) && !(relays.has(b) && !named.has(b.label)))
3104
+ .map((b) => kept.get(b) ?? b);
3105
+ // The branch counts above vouch that a recovered dispatch is entered only from its bounds test, but
3106
+ // a TABLE entry is an edge too, and no table existed while they were counted — the rule MIPS and
3107
+ // PowerPC check on their built CFG (jump-table.ts `refuseReachableDispatch`). An entry naming a
3108
+ // dispatch block jumps through the code address the idiom loaded; a kept dispatch would instead
3109
+ // switch again on the unscaled scrutinee, and an elided one names no block at all. Both decline.
3110
+ const dispatchLabels = new Set([...elided, ...kept.keys()].map((b) => b.label));
3111
+ for (const [at, t] of tables) {
3112
+ const entered = [...t.caseLabels, t.defaultLabel].find((l) => dispatchLabels.has(l));
3113
+ if (entered !== undefined) {
3114
+ const movpc = rawBlocks.find((b) => b.label === entered)!.instrs.at(-1)!;
3115
+ throw new FrontendUnsupportedError(
3116
+ `cannot lift '${name}': the recovered jump table's '${movpc.mnemonic} ${movpc.ops.join(', ')}' at '${entered}' ` +
3117
+ `is still reachable — a case of the table dispatched at '${at.label}' enters its dispatch block past the bounds check`,
3118
+ );
3119
+ }
3120
+ }
3121
+ // The far-jump lowering's last question, askable only now: a recovered table dispatching to the
3122
+ // entry would re-run the lr save after a relay clobbered lr (see `lowerFarJumpRelays`).
3123
+ const entryLabel = rawBlocks[0]?.label;
3124
+ if (relayed && [...tables.values()].some((t) => [...t.caseLabels, t.defaultLabel].includes(entryLabel))) {
3125
+ throw new FrontendUnsupportedError(
3126
+ `cannot lift '${name}': '${relayed.asWritten} ${relayed.ops.join(', ')}' (a far-jump relay) is in a function ` +
3127
+ `whose jump table dispatches back to its entry '${entryLabel}', past the lr save — lowering it to a branch ` +
3128
+ `would drop the lr write a \`bl\` makes`,
3129
+ );
3130
+ }
2567
3131
 
2568
3132
  // TRUSTWORTHINESS: loud-fail on a control transfer this frontend cannot model, rather than
2569
3133
  // silently dropping it. A computed/loaded PC write (`mov pc, rN`, `ldr pc, …`, `add/sub pc`,
2570
3134
  // `ldmia rN!,{…,pc}` with rN≠sp) is a jump table / computed goto / register tail call — decode
2571
3135
  // ends the block at it, but it has no static successor, so it must be a catchable "out of scope"
2572
3136
  // 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.)
3137
+ // dispatch block is already elided or cut to its extras above, so it is not scanned here.)
2574
3138
  //
2575
3139
  // The same rule for a `bl` whose target is inside this function's own text. agbcc relays a
2576
3140
  // conditional branch past Thumb's ±256-byte reach through an unconditional one, and past THAT
@@ -2579,13 +3143,14 @@ function readThumbText(name: string, asm: string): ThumbText {
2579
3143
  // lift emits a call to a block label (`.L3(a0, a1, a2)`): not a branch that vanished but a
2580
3144
  // transfer turned into something that RETURNS, and syntactically not C.
2581
3145
  //
2582
- // The refusal is wider than that one shape because the asm does not separate it from a
3146
+ // A relay agbcc MARKED (`@far jump`) never reaches here: `lowerFarJumpRelays` has already lowered
3147
+ // it to `b`, behind a proof that lr is dead. What is left is a `bl` to a label of this function
3148
+ // with no mark, and the shape does not separate a relay whose comment a splitter dropped from a
2583
3149
  // locally-defined call thunk — `call_r3: bx r3`, ARMv4T's stand-in for the `blx rN` it has no
2584
3150
  // encoding for, which pokeemerald's m4a_1.s calls four times from inside MPlayMain's own slice.
2585
3151
  // 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.
3152
+ // skips them, both leave `lr` clobbered, and the thunk's function pushes lr too (`push {r0, lr}`),
3153
+ // so no liveness, layout or prologue fact tells them apart. Only the mark does.
2589
3154
  //
2590
3155
  // DECLARED function starts are excluded: a `.thumb_func`/`thumb_func_start` label is a function
2591
3156
  // by declaration, so `bl` to one is a call however the slice came to contain it — the entry
@@ -2602,9 +3167,12 @@ function readThumbText(name: string, asm: string): ThumbText {
2602
3167
  );
2603
3168
  }
2604
3169
  if (classifyXfer(ins) === 'indirect') {
3170
+ const why = tableRefusals.get(ab);
3171
+ const detail =
3172
+ why === undefined ? '' : ` — this one is a jump table whose dispatch carries code it refuses: ${why}`;
2605
3173
  throw new FrontendUnsupportedError(
2606
3174
  `cannot lift '${name}': indirect/computed jump '${ins.mnemonic} ${ins.ops.join(', ')}' ` +
2607
- `— jump tables / computed gotos / register tail calls not supported`,
3175
+ `— jump tables / computed gotos / register tail calls not supported${detail}`,
2608
3176
  );
2609
3177
  }
2610
3178
  const callee = (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') && ins.ops.length === 1 ? ins.ops[0] : undefined;
@@ -2682,8 +3250,9 @@ function buildCfg(
2682
3250
  const labelIndex = new Map<string, number>();
2683
3251
  blocks.forEach((b, i) => labelIndex.set(b.label, i));
2684
3252
  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.
3253
+ // The block emitting a recovered table's switch dispatches to its case blocks + default. For a
3254
+ // BOUNDS block (the dispatch elided) that replaces its `bhi`/fall-through successors entirely; a
3255
+ // kept dispatch block had no static successor of its own.
2687
3256
  const jt = tables.get(b);
2688
3257
  if (jt) {
2689
3258
  return [...jt.caseLabels, jt.defaultLabel];
@@ -2719,19 +3288,6 @@ function buildCfg(
2719
3288
  return { labelIndex, preds };
2720
3289
  }
2721
3290
 
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
3291
  /** A `bl` passes a pair in two argument words, a struct-return pointer in r0, and the words past r3
2736
3292
  * in the outgoing block `analyzeOutgoingArgs` licensed; a call through rN passes r0..r(N-1). */
2737
3293
  const THUMB_CALL_LOWERING: CallLowering = {
@@ -2746,16 +3302,36 @@ const THUMB_CALL_LOWERING: CallLowering = {
2746
3302
  interface ThumbFrame {
2747
3303
  readonly localArea: number;
2748
3304
  readonly savedRegs: ReadonlySet<string>;
3305
+ /** the prologue's `mov rLow, rHi` that only stage a high register for its `push` (see
3306
+ * `prologueSavedRegs`); the fill does not lift them */
3307
+ readonly saveCarriers: ReadonlySet<Instr>;
2749
3308
  readonly capturedObjectIsTheWholeFrame: boolean;
3309
+ /** the offsets of the escaped frame addresses the outgoing-argument verdict leans on, which the
3310
+ * audit re-proves of the lifted IR; empty where the verdict leans on none */
3311
+ readonly escapesReliedOn: ReadonlySet<number>;
3312
+ /** the calls that hand out an escape the verdict leans on; empty where it leans on none */
3313
+ readonly callsReliedOn: ReadonlySet<Instr>;
2750
3314
  /** the frame offset each instruction leaves a register holding, where `heldFrameWalk` follows it */
2751
3315
  readonly captureOffsetOf: ReadonlyMap<Instr, number>;
3316
+ /** at each call, the frame addresses its argument registers may and must hold (`frameAddressesAtCalls`) */
3317
+ readonly frameAddressesAtCall: ReadonlyMap<Instr, FrameAddressesAtCall>;
2752
3318
  readonly outgoingArgs: OutgoingArgs<Instr>;
3319
+ /** the loaded-word bound both `outgoingArgs` and the frame-object audit take
3320
+ * (`FrameObjectAudit.loadedWords`); absent where the target states no start offset */
3321
+ readonly loadedWords: FrameObjectAudit['loadedWords'];
2753
3322
  /** why the word-slot model is off, or null when it is on (`slotModelBlocker`) */
2754
3323
  readonly slotsOffReason: string | null;
2755
3324
  readonly slotsOk: boolean;
3325
+ /** the frame depth at every instruction outside the entry block, or null where it is not proven
3326
+ * one number (`frameDepthBlocker`) */
3327
+ readonly settledDepth: number | null;
2756
3328
  readonly partition: Readonly<Pick<FrameObjectAudit, 'ownedLocals' | 'declaredLocals'>>;
2757
3329
  isFrameObjectAccess(base: string, off: number, regOff: string | undefined, width: number): boolean;
2758
3330
  isOwnFrameWordSlot(base: string, off: number, regOff: string | undefined, width: number): boolean;
3331
+ /** the word slot each narrow reload through a copy of sp reads the low-order part of
3332
+ * (`narrowSlotReloads`), and the copies those reloads consume, which name no object */
3333
+ readonly narrowReloadOf: ReadonlyMap<Instr, number>;
3334
+ readonly narrowReloadCopies: ReadonlySet<Instr>;
2759
3335
  }
2760
3336
 
2761
3337
  /** The frame offset of each word of a declared call's outgoing block, or null where it stages none.
@@ -2775,7 +3351,7 @@ interface ThumbFrame {
2775
3351
  *
2776
3352
  * Null for an arity that fits in registers, where there IS no outgoing block. */
2777
3353
  function outgoingBlock(widths: readonly number[], target: TargetDescription): number[] | null {
2778
- const words = wordsOf(widths) - target.argRegs.length;
3354
+ const words = wordsOf(widths, target) - target.argRegs.length;
2779
3355
  return words > 0 && target.compilerBehaviors.stagesOutgoingArgsInFrame === true
2780
3356
  ? Array.from({ length: words }, (_, i) => 4 * i)
2781
3357
  : null;
@@ -2799,15 +3375,20 @@ function calleeRegister(ins: Instr, target: TargetDescription): string | null |
2799
3375
  }
2800
3376
 
2801
3377
  function measureThumbFrame({
3378
+ name,
2802
3379
  target,
2803
3380
  cfg: { asmBlocks, preds, entryReachable },
2804
3381
  calls,
2805
3382
  oneObject,
3383
+ own,
2806
3384
  }: {
3385
+ name: string;
2807
3386
  target: TargetDescription;
2808
3387
  cfg: ThumbCfg;
2809
3388
  calls: CallDeclarations;
2810
3389
  oneObject: FrameRange | undefined;
3390
+ /** the function's own declaration (`ownDeclarationOf`), read only where it `proves` */
3391
+ own: OwnDeclaration | undefined;
2811
3392
  }): ThumbFrame {
2812
3393
  // THE FRAME BASE PASSED TO A CALLEE. What this computes is exactly what its name says and nothing
2813
3394
  // more: somewhere in entry-reachable code a bare `mov rD, sp` copies the frame base into a
@@ -2817,15 +3398,19 @@ function measureThumbFrame({
2817
3398
  // feeds (`capturedObjectIsTheWholeFrame`, below `localArea`), not here.
2818
3399
  //
2819
3400
  // 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.
3401
+ // passed to one, and for `blx rN` the TARGET register is not an argument either. Which registers
3402
+ // a call reads, and which stores send a word outside the frame, are ONE rule for every escape
3403
+ // this function asks about — `frameAddressReceivers` — so the three walks that ask differ only
3404
+ // in the kill rule, and `heldFrameWalk` says why that one differs.
3405
+ const mayHoldFrameAddress = registersThatMayHoldAFrameAddress(asmBlocks, entryReachable);
3406
+ const handedOutRegs = (ins: Instr): readonly string[] =>
3407
+ frameAddressReceivers(ins, target, calls, mayHoldFrameAddress);
2821
3408
  const frameBasePassedToCallee = heldFrameWalk(asmBlocks, entryReachable, mentionsReg, (ins, held) => {
2822
3409
  if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
2823
3410
  return false;
2824
3411
  }
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);
3412
+ const handed = handedOutRegs(ins);
3413
+ return [...held].some(([r, off]) => off === 0 && handed.includes(r));
2829
3414
  });
2830
3415
 
2831
3416
  // THE FRAME BASE PUBLISHED TO MEMORY — the other way an agbcc frame address escapes, and the
@@ -2882,7 +3467,9 @@ function measureThumbFrame({
2882
3467
  // (u32)&b;` compiles to `add sp,#-0x44 / mov r0,sp / mov r2,#0x44 / bl memcpy / ldr r0,.L9+4 /
2883
3468
  // mov r1,sp / str r1,[r0]` — a block-copy base that IS published to memory (measured). At a
2884
3469
  // wider frame the publish escape admits exactly the shape the callee escape's table warns
2885
- // about; at one word it cannot occur.
3470
+ // about; at one word it cannot occur. A wider frame's publish is read by the outgoing-argument
3471
+ // `escape` event instead (below), per program point, which is the reading that survives a block
3472
+ // copy: see "ONE DECISION, TWO LICENCES" above `capturedObjectIsTheWholeFrame`.
2886
3473
  //
2887
3474
  // THE WALK IS `heldFrameWalk`, the same one the callee escape is spelled with, because the
2888
3475
  // question is the same one and only the consuming event differs. Here that event is a WORD store
@@ -2898,21 +3485,21 @@ function measureThumbFrame({
2898
3485
  // refusal it produces stops being the two-models-for-one-byte one and becomes the audit's. A
2899
3486
  // clause without a row is decoration until an input says otherwise.
2900
3487
  //
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
- });
3488
+ // THE AUDIT'S RE-PROOF IS NOT THIS PREDICATE RE-SPELLED, but its BASE clause is the same
3489
+ // whole-function fact on both sides. The stored VALUE must be held here (block-local); the BASE
3490
+ // must be no register that holds a frame address ANYWHERE in the function
3491
+ // (`registersThatMayHoldAFrameAddress`), the audit's whole-function `taint` read off the
3492
+ // instructions. So a base captured in an earlier block is refused by the licence itself, before
3493
+ // the audit sees it. Pinned as a row.
3494
+ const frameBasePublishedToMemory = heldFrameWalk(
3495
+ asmBlocks,
3496
+ entryReachable,
3497
+ mentionsReg,
3498
+ (ins, held) => ins.mnemonic === 'str' && handedOutRegs(ins).some((r) => held.get(r) === 0),
3499
+ );
2913
3500
 
2914
3501
  const localArea = reservedLocalArea(asmBlocks[0]);
2915
- const savedRegs = prologueSavedRegs(asmBlocks[0]);
3502
+ const { saved: savedRegs, carriers: saveCarriers } = prologueSavedRegs(asmBlocks[0]);
2916
3503
 
2917
3504
  // …AND THE FRAME IS THAT OBJECT. The two escape scans above are facts about a REGISTER — that a
2918
3505
  // bare `mov rD, sp` is still held when a callee is entered, or when it is stored to memory.
@@ -2950,7 +3537,8 @@ function measureThumbFrame({
2950
3537
  // block-copy base — each needs two frame words before agbcc names its base with a register at
2951
3538
  // all — and the two-word struct returns with them. Any larger frame has bytes this model does not
2952
3539
  // 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.
3540
+ // argument slots. Neither is provable from the frame alone, so THIS licence stays at one word,
3541
+ // and a larger frame is left to the `escape` event, which does not have to decide it.
2954
3542
  //
2955
3543
  // What the frame size does NOT exclude is the one-word struct return in the table above, which is
2956
3544
  // instruction-for-instruction an out-parameter call. That one is settled after the lift, by the
@@ -2962,8 +3550,8 @@ function measureThumbFrame({
2962
3550
  // handed to a callee arrives there through a frame this conjunct never looks at, and what bounds
2963
3551
  // its extent is the audit's frame-accounting rule rather than anything here.
2964
3552
  //
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
3553
+ // WHY THIS FLOW-INSENSITIVE LICENCE IS NOT WIDENED, since a wider frame is the obvious next
3554
+ // 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
3555
  // -fprologue-bugfix`, and only the first is about extent at all:
2968
3556
  //
2969
3557
  // UNDECIDABLE — a slot THIS FUNCTION stores and reloads. Two sources that disagree about who
@@ -3017,6 +3605,15 @@ function measureThumbFrame({
3017
3605
  // modelled as `volatile u16 sp0`, and its lift is byte-identical with this conjunct widened to
3018
3606
  // `localArea >= 4` (measured). Nothing about the frame size moves it either way — which is what
3019
3607
  // makes it the wrong function to reason about a wider licence from.
3608
+ //
3609
+ // ONE DECISION, TWO LICENCES. This gate and the outgoing-argument `escape` event (fed below,
3610
+ // under `compilerBehaviors.escapedFrameAddressNamesALocal`) read the same escapes
3611
+ // (`frameAddressReceivers`) and differ only in flow-sensitivity. This gate is flow-INSENSITIVE —
3612
+ // one escape anywhere and no frame word is any call's argument — which is sound only where no
3613
+ // block-copy base fits, a one-word frame. The event ends only the stores pending where the address
3614
+ // leaves, at any frame size; that field says why that survives a block copy. So what this gate
3615
+ // adds is a store that reaches an undeclared call BEFORE the address leaves (the CONTROL of "a
3616
+ // capture in an unreachable block proves nothing"), and every "not widened" above is about it.
3020
3617
  const capturedObjectIsTheWholeFrame =
3021
3618
  target.compilerBehaviors.oneWordFrameIsTheCapturedObject === true &&
3022
3619
  (frameBasePassedToCallee || frameBasePublishedToMemory) &&
@@ -3028,20 +3625,184 @@ function measureThumbFrame({
3028
3625
  // What the walk cannot follow (a move in another block than the capture) the lift does not
3029
3626
  // fold either; the audit folds it from the IR, and refuses it by that name where the object
3030
3627
  // lands on a word this left keyed as a slot.
3628
+ //
3629
+ // …AND THE LOWEST FRAME ADDRESS EACH INSTRUCTION HANDS OUT, off the same walk: one held in an
3630
+ // argument register the callee's declaration reads at a call (every one, where nothing declares
3631
+ // it), or stored as a word to memory outside the frame — the two escapes above, at any offset.
3632
+ // This walk keeps a capture addressed through (`mov r0, sp / strb r3, [r0, #8] / bl use`, sa3's
3633
+ // `sub_8060940`), because the question here is what the receiver CAN read, and the outgoing-
3634
+ // argument analysis only lets it end a word's pending store (`escapeEvent`, below). Its receivers
3635
+ // are `handedOutRegs`, the rule the two licences above read too.
3031
3636
  const captureOffsetOf = new Map<Instr, number>();
3637
+ const escapedOffsetOf = new Map<Instr, number>();
3638
+ let frameBaseIsReturnStorage = false;
3032
3639
  heldFrameWalk(asmBlocks, entryReachable, mayWriteReg, (ins, held) => {
3640
+ if (
3641
+ ins.mnemonic === 'bl' &&
3642
+ held.get(target.argRegs[0]) === 0 &&
3643
+ calleeRegister(ins, target) === null &&
3644
+ typeof calls.declaredCall(ins.ops[0] ?? '?')?.returned === 'object'
3645
+ ) {
3646
+ frameBaseIsReturnStorage = true;
3647
+ }
3648
+ const handed = handedOutRegs(ins);
3649
+ const offs = [...held].filter(([r, off]) => handed.includes(r) && off >= 0).map(([, off]) => off);
3650
+ if (offs.length > 0) {
3651
+ escapedOffsetOf.set(ins, Math.min(...offs));
3652
+ }
3033
3653
  const defined = frameAddressDefined(ins, held);
3034
- if (defined !== null) {
3654
+ if (defined !== null && Number.isFinite(defined[1])) {
3035
3655
  captureOffsetOf.set(ins, defined[1]);
3036
3656
  }
3037
3657
  return false;
3038
3658
  });
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 —
3659
+ // …and the offsets themselves. A word whose address is taken is not an outgoing argument —
3041
3660
  // C gives an argument no address — so the outgoing-argument analysis below does not see these
3042
3661
  // offsets either: a store there reaching a call unread is the object being filled for the
3043
3662
  // callee that is handed it.
3044
- const constantCaptureOffsets: ReadonlySet<number> = new Set([...captureOffsetOf.values()].filter((o) => o > 0));
3663
+ //
3664
+ // THE FRAME BASE, ONLY AS A DECLARED CALL'S RETURN STORAGE AND ONLY WHERE NO CALL TAKES A STACK
3665
+ // WORD. A bare `mov rD, sp` is also the base of a by-value argument's block copy into the
3666
+ // outgoing area, and [sp,#0] is then a call's fifth argument word (the producer table above
3667
+ // `capturedObjectIsTheWholeFrame`, which keeps its own licence for a one-word frame). Where every
3668
+ // call is to a callee whose declaration places all of its arguments in registers there is no
3669
+ // outgoing area, and where the base is handed as the hidden pointer of a callee declared to
3670
+ // return a struct through memory it is that struct's storage: two struct-return temps,
3671
+ // `mov r0, sp / bl mk` and `add r0, sp, #4 / bl mk`. A call nothing declares, a call through a
3672
+ // register, or a declaration nothing sizes may take a stack word, and keeps offset 0 out.
3673
+ const noCallTakesAStackWord = [...entryReachable].every((b) =>
3674
+ asmBlocks[b].instrs.every((ins) => {
3675
+ if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3676
+ return true;
3677
+ }
3678
+ const declared = calleeRegister(ins, target) === null ? calls.declaredCall(ins.ops[0] ?? '?') : null;
3679
+ return declared !== null && outgoingBlock(declared.widths, target) === null;
3680
+ }),
3681
+ );
3682
+
3683
+ // A STRUCT PASSED BY VALUE, LOADED OFF THE FRAME A WORD AT A TIME. agbcc builds the argument in
3684
+ // the frame — through a copy of sp, Thumb having no sp-relative halfword or byte store — and
3685
+ // then loads its words into the argument registers the declaration places it in (`struct A
3686
+ // {u16 a,b,c,d; s8 e,f;} a; a.a = x; … take2(a);` is `mov r2, sp / strh r0, [r2] / … / ldr r0,
3687
+ // [sp] / ldr r1, [sp, #0x4] / ldr r2, [sp, #0x8] / bl take2`). Those words are the object's, not
3688
+ // slots: each `[sp,#k]` inside such a block is routed through an `laddr` (`isFrameObjectAccess`),
3689
+ // and the loads are folded into the object passed whole or refused (call-plan.ts
3690
+ // `foldByValueArgs`). A block is read only where every one of its registers is
3691
+ // last written, in the call's own block, by a word load at the next word of one frame range.
3692
+ const byValueBlocks: FrameRange[] = [];
3693
+ for (const b of entryReachable) {
3694
+ const instrs = asmBlocks[b].instrs;
3695
+ instrs.forEach((call, at) => {
3696
+ if (call.mnemonic !== 'bl' || calleeRegister(call, target) !== null) {
3697
+ return;
3698
+ }
3699
+ const declared = calls.declaredCall(call.ops[0] ?? '?');
3700
+ for (const arg of declared?.byValue ?? []) {
3701
+ // `word` indexes the call's widths, where a 64-bit argument is one entry of two registers
3702
+ const reg = wordsOf(declared!.widths.slice(0, arg.word), target);
3703
+ const offs = target.argRegs.slice(reg, reg + arg.words).map((r) => {
3704
+ const def = instrs
3705
+ .slice(0, at)
3706
+ .reverse()
3707
+ .find((ins) => mayWriteReg(ins, r));
3708
+ const acc = def !== undefined && def.mnemonic === 'ldr' && def.ops[0] === r ? spMemAccess(def) : null;
3709
+ return acc !== null && !acc.regOff && acc.width === 4 ? acc.off : null;
3710
+ });
3711
+ if (offs.every((o, i) => o !== null && o === offs[0]! + 4 * i)) {
3712
+ byValueBlocks.push({ from: offs[0]!, to: offs[0]! + 4 * arg.words });
3713
+ }
3714
+ }
3715
+ });
3716
+ }
3717
+ const inByValueBlock = (off: number, width: number): boolean =>
3718
+ byValueBlocks.some((r) => off >= r.from && off + width <= r.to);
3719
+
3720
+ // THE NARROW RELOADS OF A WORD SLOT (`narrowSlotReloads`), kept only where the word is a slot
3721
+ // and no object's: a word `str` to [sp,#slot] in live code writes it — without one the bytes are
3722
+ // a sub-word local's (`mov r3, sp / strh r2, [r3, #4]`), which the object model owns — no other
3723
+ // copy of sp names the slot's offset, and neither a by-value block nor the one object covers it.
3724
+ // The one-word frame object (`capturedObjectIsTheWholeFrame`) needs no test of its own: its
3725
+ // escape is a copy of sp at offset 0 handed out, which no candidate's copy can be (that one dies
3726
+ // in its load), so it is another capture at the slot's offset.
3727
+ //
3728
+ // …AND THE WORD IS NOT AN INCOMING ARGUMENT'S HOME. A by-value struct parameter is the other
3729
+ // producer of these instructions: agbcc stores its argument registers word by word on entry and
3730
+ // reads each member at its width through a copy of sp (`struct P { u16 a, b, c, d; s8 e; }; int
3731
+ // b2(struct P p){ return p.a + p.c - p.e; }` is `str r0, [sp] / … / mov r0, sp / ldrh r0, [r0]`),
3732
+ // and there the frame is the parameter, one object whose union recompiles byte-exact
3733
+ // (cli/test/matching/frame-union.test.ts) where the words narrowed recompile with no frame at
3734
+ // all. A word the entry block stores straight from an argument register it has not written is
3735
+ // that home, and its reloads keep the object model. A spilled promoted local is stored from the
3736
+ // value it was computed as. That test reads the asm alone, and the function's own declaration
3737
+ // overrules it where it proves the register holds one integer word (`proto.ts`
3738
+ // `ownLeadingWordRegisters`): a `u32 k` spilled on entry and read back as `(u8)k` is no struct's
3739
+ // home, and lifts as the slot narrowed. A copy kept here is no capture, so its offset leaves
3740
+ // `constantCaptureOffsets` and the slot's own `str`/`ldr` stay slot accesses; one dropped stays a
3741
+ // capture.
3742
+ const narrowCandidates = narrowSlotReloads(
3743
+ asmBlocks,
3744
+ entryReachable,
3745
+ localArea,
3746
+ target.capabilities.endianness === 'little',
3747
+ );
3748
+ const candidateCopies = new Set([...narrowCandidates.values()].map((n) => n.copy));
3749
+ const otherCaptureOffsets = new Set(
3750
+ [...captureOffsetOf].filter(([ins]) => !candidateCopies.has(ins)).map(([, off]) => off),
3751
+ );
3752
+ const wordStoredSlots = new Set(
3753
+ [...entryReachable].flatMap((b) =>
3754
+ asmBlocks[b].instrs.flatMap((ins) => {
3755
+ const a = spMemAccess(ins);
3756
+ return ins.mnemonic === 'str' && a !== null && !a.regOff ? [a.off] : [];
3757
+ }),
3758
+ ),
3759
+ );
3760
+ const argumentHomes = new Set<number>();
3761
+ {
3762
+ const proving = own?.proves === true ? own.proto : undefined;
3763
+ const wordArgRegs = new Set(target.argRegs.slice(0, ownLeadingWordRegisters(name, proving)));
3764
+ const written = new Set<string>();
3765
+ for (const ins of asmBlocks[0].instrs) {
3766
+ const a = spMemAccess(ins);
3767
+ const src = reg(ins.ops[0] ?? '');
3768
+ if (
3769
+ ins.mnemonic === 'str' &&
3770
+ a !== null &&
3771
+ !a.regOff &&
3772
+ target.argRegs.includes(src) &&
3773
+ !wordArgRegs.has(src) &&
3774
+ !written.has(src)
3775
+ ) {
3776
+ argumentHomes.add(a.off);
3777
+ }
3778
+ for (const r of target.argRegs) {
3779
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx' || mayWriteReg(ins, r)) {
3780
+ written.add(r);
3781
+ }
3782
+ }
3783
+ }
3784
+ }
3785
+ const narrowReloadOf = new Map<Instr, number>();
3786
+ for (const [load, { slot }] of narrowCandidates) {
3787
+ if (
3788
+ wordStoredSlots.has(slot) &&
3789
+ !argumentHomes.has(slot) &&
3790
+ !otherCaptureOffsets.has(slot) &&
3791
+ !inByValueBlock(slot, 4) &&
3792
+ !(oneObject !== undefined && slot < oneObject.to && slot + 4 > oneObject.from)
3793
+ ) {
3794
+ narrowReloadOf.set(load, slot);
3795
+ }
3796
+ }
3797
+ const narrowReloadCopies: ReadonlySet<Instr> = new Set(
3798
+ [...narrowCandidates].filter(([load]) => narrowReloadOf.has(load)).map(([, n]) => n.copy),
3799
+ );
3800
+ const constantCaptureOffsets: ReadonlySet<number> = new Set(
3801
+ [...captureOffsetOf]
3802
+ .filter(([ins]) => !narrowReloadCopies.has(ins))
3803
+ .map(([, o]) => o)
3804
+ .filter((o) => o > 0 || (o === 0 && frameBaseIsReturnStorage && noCallTakesAStackWord)),
3805
+ );
3045
3806
 
3046
3807
  // THE OUTGOING STACK-ARGUMENT AREA. `frontend/stackargs.ts` holds the licence and every refusal;
3047
3808
  // what belongs HERE is the decoding it deliberately does not do — which accesses are whole frame
@@ -3055,40 +3816,102 @@ function measureThumbFrame({
3055
3816
  const a = spMemAccess(ins);
3056
3817
  return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off + 4 <= localArea ? a.off : null;
3057
3818
  };
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
- });
3819
+ //
3820
+ // AN ESCAPED FRAME ADDRESS IS READ BY ITS RECEIVER, and the event says so just before the call or
3821
+ // the store that hands it over — but only where the target declares the layout premise that
3822
+ // makes the words from that address up a local's (`escapedFrameAddressNamesALocal`).
3823
+ const escapesHere = target.compilerBehaviors.escapedFrameAddressNamesALocal === true;
3824
+ const escapeEvent = (ins: Instr): StackArgsEvent<Instr>[] => {
3825
+ const off = escapedOffsetOf.get(ins);
3826
+ return escapesHere && off !== undefined ? [{ kind: 'escape', off }] : [];
3827
+ };
3828
+ const eventBlocks = asmBlocks.map((ab) => ({
3829
+ events: ab.instrs.flatMap((ins): StackArgsEvent<Instr>[] => {
3830
+ const escaped = escapeEvent(ins);
3831
+ if (escaped.length > 0 && ins.mnemonic === 'str') {
3832
+ return escaped;
3833
+ }
3834
+ // …and a narrow reload of a word slot is that slot's reload, as its word reload would be
3835
+ const off = slotAcc(ins) ?? narrowReloadOf.get(ins) ?? null;
3836
+ if (off !== null && !constantCaptureOffsets.has(off) && !inByValueBlock(off, 4)) {
3837
+ return [{ kind: /^str/.test(ins.mnemonic) ? 'store' : 'load', off }];
3838
+ }
3839
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
3840
+ const callee = ins.ops[0] ?? '?';
3841
+ const declared = calleeRegister(ins, target) === null ? calls.declaredCall(callee) : null;
3842
+ return [
3843
+ ...escaped,
3844
+ {
3845
+ kind: 'call',
3846
+ call: ins,
3847
+ callee,
3848
+ declared: declared === null ? null : outgoingBlock(declared.widths, target),
3849
+ params: declared?.params,
3850
+ },
3851
+ ];
3852
+ }
3853
+ return [];
3854
+ }),
3855
+ }));
3856
+ // THE LOADED-WORD BOUND, read once (`outgoingAreaNeverLoadedFrom`, 0 on agbcc): the outgoing
3857
+ // argument analysis and the frame-object audit both take this number.
3858
+ const loadedFrom = target.compilerBehaviors.outgoingAreaNeverLoadedFrom;
3859
+ const loadedBound = loadedWordBound(eventBlocks, entryReachable, loadedFrom);
3860
+ const outgoingArgsOver = (blocks: readonly StackArgsBlock<Instr>[]): OutgoingArgs<Instr> =>
3861
+ analyzeOutgoingArgs<Instr>({
3862
+ blocks,
3863
+ preds,
3864
+ live: entryReachable,
3865
+ localArea,
3866
+ argRegs: target.argRegs.length,
3867
+ capturedWholeFrame: capturedObjectIsTheWholeFrame,
3868
+ loadedWordBound: loadedBound,
3869
+ });
3870
+ const outgoingArgs = outgoingArgsOver(eventBlocks);
3871
+ // THE ESCAPES THE VERDICT LEANS ON, re-asked of the lifted IR (`auditFrameObjects`'
3872
+ // `escapesReliedOn`). The `escape` event is read off what a REGISTER holds at a `bl` or a `str`,
3873
+ // and it switches refusals OFF; the lift then decides what the call is really handed, and an
3874
+ // arity that shrinks after the event was read (a guess, then the trim) can leave the address out
3875
+ // of the call while its stores stay excused. The one-word licence has this re-check
3876
+ // (`recheckWholeFramePremise`); this is the same one at any frame size. Only where the analysis
3877
+ // would refuse WITHOUT the escapes, so an escape that excused nothing asks nothing of the IR.
3878
+ const escapeOffsets = new Set(
3879
+ [...entryReachable].flatMap((b) => eventBlocks[b].events.flatMap((ev) => (ev.kind === 'escape' ? [ev.off] : []))),
3880
+ );
3881
+ const escapesReliedOn: ReadonlySet<number> =
3882
+ outgoingArgs.blocker === null &&
3883
+ escapeOffsets.size > 0 &&
3884
+ outgoingArgsOver(eventBlocks.map((b) => ({ events: b.events.filter((ev) => ev.kind !== 'escape') }))).blocker !==
3885
+ null
3886
+ ? escapeOffsets
3887
+ : new Set();
3888
+ const callsReliedOn: ReadonlySet<Instr> = new Set(
3889
+ escapesReliedOn.size === 0
3890
+ ? []
3891
+ : [...escapedOffsetOf.keys()].filter((ins) => ins.mnemonic === 'bl' || ins.mnemonic === 'blx'),
3892
+ );
3088
3893
 
3089
3894
  const slotsOffReason = slotModelBlocker(asmBlocks, localArea, outgoingArgs);
3090
3895
  const slotsOk = slotsOffReason === null;
3091
3896
 
3897
+ // THE DEPTH EVERY OTHER BLOCK RUNS AT: what the entry block leaves, where `frameDepthBlocker`
3898
+ // proves no block but a returning one moves sp again. Not where the entry is itself a loop
3899
+ // target (its prologue would run twice) or returns (no block runs after it).
3900
+ const entry = asmBlocks[0];
3901
+ const entryWalk = makeFrameWalk({ argRegs: target.argRegs, start: 0 });
3902
+ for (const ins of entry.instrs) {
3903
+ if (!classifyXfer(ins)) {
3904
+ entryWalk.step(ins);
3905
+ }
3906
+ }
3907
+ const entryEnd = entry.instrs[entry.instrs.length - 1];
3908
+ const settledDepth =
3909
+ frameDepthBlocker(asmBlocks) === null &&
3910
+ preds[0].length === 0 &&
3911
+ !(entryEnd !== undefined && classifyXfer(entryEnd) === 'return')
3912
+ ? entryWalk.depthNow()
3913
+ : null;
3914
+
3092
3915
  // …AND THE ONE OFFSET THAT MUST NOT BE A SLOT. When `capturedObjectIsTheWholeFrame` holds, the
3093
3916
  // frame is one word and a callee is being handed its address, so an `[sp,#0]` access is an access
3094
3917
  // 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 +3944,7 @@ function measureThumbFrame({
3121
3944
  width === 4 &&
3122
3945
  ((capturedObjectIsTheWholeFrame && off === 0) ||
3123
3946
  (constantCaptureOffsets.has(off) && off >= outgoingArgs.area && off + 4 <= localArea) ||
3947
+ (inByValueBlock(off, 4) && off >= outgoingArgs.area && off + 4 <= localArea) ||
3124
3948
  (oneObject !== undefined && off >= oneObject.from && off + 4 <= oneObject.to));
3125
3949
 
3126
3950
  // LOCAL STACK SLOTS. A spill or a local that never has its address taken is transparent to
@@ -3181,14 +4005,22 @@ function measureThumbFrame({
3181
4005
  return {
3182
4006
  localArea,
3183
4007
  savedRegs,
4008
+ saveCarriers,
3184
4009
  capturedObjectIsTheWholeFrame,
4010
+ escapesReliedOn,
4011
+ callsReliedOn,
3185
4012
  captureOffsetOf,
4013
+ frameAddressesAtCall: frameAddressesAtCalls(asmBlocks, preds, entryReachable, calls.callClobbers),
3186
4014
  outgoingArgs,
4015
+ loadedWords: loadedFrom === undefined ? undefined : { from: loadedFrom, bound: loadedBound },
3187
4016
  slotsOffReason,
3188
4017
  slotsOk,
4018
+ settledDepth,
3189
4019
  partition,
3190
4020
  isFrameObjectAccess,
3191
4021
  isOwnFrameWordSlot,
4022
+ narrowReloadOf,
4023
+ narrowReloadCopies,
3192
4024
  };
3193
4025
  }
3194
4026
 
@@ -3216,6 +4048,13 @@ function measureThumbFrame({
3216
4048
  // question, and there the capture has to reach the consumer UNTOUCHED (`mentionsReg`): a copy
3217
4049
  // addressed through and still live at a call is an addressing copy, not an argument —
3218
4050
  // `EReader_Reset` is `mov r1, sp / strh r0, [r1] / … / bl` into callees that take nothing.
4051
+ // WHAT THE RECEIVER CAN READ is the third question, the outgoing-argument analysis's `escape`
4052
+ // event, and it reads the first rule: sa3's `sub_8060940` fills its object through the very copy it
4053
+ // passes (`mov r0, sp / strb r3, [r0, #8] / bl use`). What keeps `EReader_Reset` out there is the
4054
+ // receiver rule both share (`frameAddressReceivers`): a callee declared to take nothing is handed
4055
+ // no register. And an undeclared callee that took a stack word needs all four argument registers
4056
+ // written for it first, which the write rule sees, so a capture still held in one at the `bl` is
4057
+ // passed to it.
3219
4058
  //
3220
4059
  // A `bl` CLEARS EVERY HELD REGISTER, and for the callee escape that is the ABI — the argument
3221
4060
  // registers are the only ones it tests and the callee clobbers them. The publish escape can hold
@@ -3256,6 +4095,13 @@ function heldFrameWalk(
3256
4095
  // null. The shapes that carry one: the capture itself (`mov rD, sp`, `add rD, sp, #k`), a copy
3257
4096
  // of a held register (`mov rD, rS`, and agbcc's `add rD, rS, #0`), and a held register moved by
3258
4097
  // a constant (`add rD, rS, #c`, `add rD, #c`). Everything else only kills.
4098
+ //
4099
+ // …AND A FRAME ADDRESS MOVED BY A REGISTER, at an offset nothing here knows (NaN): sp or a held
4100
+ // register plus a runtime index, `add r2, r2, sp` — agbcc's `&buf[i]` — or `mov r1, sp / add r0,
4101
+ // r1, r4`. It names no one word, so no walk that asks for an offset can credit it (NaN equals
4102
+ // none, and an escape at NaN is no escape); what it is carried for is the "may hold a frame
4103
+ // address" questions: without it an undeclared call's guessed arity drops `&buf[i]` with no
4104
+ // refusal (`g3(a, b, &buf[i])` as `g3()`).
3259
4105
  function frameAddressDefined(ins: Instr, held: ReadonlyMap<string, number>): [string, number] | null {
3260
4106
  const [d, s1, s2] = ins.ops;
3261
4107
  if (d === undefined || isSpReg(d)) {
@@ -3269,15 +4115,235 @@ function frameAddressDefined(ins: Instr, held: ReadonlyMap<string, number>): [st
3269
4115
  }
3270
4116
  if (/^adds?$/.test(ins.mnemonic)) {
3271
4117
  const [src, by] = s2 === undefined ? [d, s1] : [s1, s2];
3272
- if (src === undefined || by === undefined || !IMM_LITERAL.test(by)) {
4118
+ if (src === undefined || by === undefined) {
3273
4119
  return null;
3274
4120
  }
3275
- const from = isSpReg(src) ? 0 : held.get(reg(src));
4121
+ const at = (r: string): number | undefined => (isSpReg(r) ? 0 : held.get(reg(r)));
4122
+ if (!IMM_LITERAL.test(by)) {
4123
+ return at(src) !== undefined || at(by) !== undefined ? [reg(d), NaN] : null;
4124
+ }
4125
+ const from = at(src);
3276
4126
  return from === undefined ? null : [reg(d), from + imm(by)];
3277
4127
  }
3278
4128
  return null;
3279
4129
  }
3280
4130
 
4131
+ // A NARROW RELOAD OF A WORD SLOT, read through a copy of sp. agbcc promotes a `u8`/`u16` local to
4132
+ // a word (thumb.h PROMOTE_MODE), spills it with `str`, and where a use is narrow it reloads only
4133
+ // the narrow part. Thumb has no sp-relative `ldrb`/`ldrh`, so that reload goes through a copy of
4134
+ // sp made for it alone: `add r3, sp, #0x28 / ldrb r3, [r3]` (a byte slot past the `ldrb` offset
4135
+ // range), `mov r2, sp / ldrh r2, [r2, #0x4]`. The narrow bytes are the slot's LOW-ORDER part —
4136
+ // on a little-endian target its own address, on a big-endian one its last `width` bytes — so the
4137
+ // load reads the stored word's value narrowed, `zext(sp@<slot>)`, and the copy names no object.
4138
+ //
4139
+ // What this finds is a shape and not yet a licence; the copy is a capture everywhere it fails:
4140
+ // * the copy is `mov rD, sp` or `add rD, sp, #c`, entry-reachable, and in ONE block it is read
4141
+ // by exactly one instruction, a zero-extending `ldrb`/`ldrh` at an immediate offset from rD
4142
+ // that writes rD itself, so the address dies at that load. Anything else that mentions rD
4143
+ // before it (a second load, a store through it, an escape), a call, a control transfer or a
4144
+ // write of sp ends the search, and the copy stays a capture.
4145
+ // * the load's bytes are the low-order part of a whole aligned word of the reserved local area.
4146
+ // A load at any other byte of the word is not that word's value narrowed, whatever it reads.
4147
+ // * `ldrsb`/`ldrsh` exist only register-offset in Thumb-1, and agbcc's signed use reloads a word
4148
+ // and shifts (measured), so no signed shape is taken.
4149
+ // What the caller then adds, because it needs the frame model: a word `str` to the slot in live
4150
+ // code, the slot named by no other capture, no argument's home, outside a by-value block and
4151
+ // outside the one object (`measureThumbFrame`), and at the lowering a slot the word reload would
4152
+ // read (`lowerLoad` `readsOwnSlot`). A narrow STORE into
4153
+ // the slot through any other address is the frame-object audit's: it sees that slot used and the
4154
+ // object overlapping it, the "one byte, two models" refusal.
4155
+ function narrowSlotReloads(
4156
+ asmBlocks: readonly AsmBlock[],
4157
+ entryReachable: ReadonlySet<number>,
4158
+ localArea: number,
4159
+ littleEndian: boolean,
4160
+ ): Map<Instr, { copy: Instr; slot: number }> {
4161
+ const found = new Map<Instr, { copy: Instr; slot: number }>();
4162
+ for (const b of entryReachable) {
4163
+ const instrs = asmBlocks[b].instrs;
4164
+ instrs.forEach((copy, at) => {
4165
+ const [d, s1, s2] = copy.ops;
4166
+ const from = capturesSp(copy)
4167
+ ? 0
4168
+ : /^adds?$/.test(copy.mnemonic) && isSpReg(s1) && s2 !== undefined && IMM_LITERAL.test(s2)
4169
+ ? imm(s2)
4170
+ : null;
4171
+ if (from === null || d === undefined || isSpReg(d)) {
4172
+ return;
4173
+ }
4174
+ const rD = reg(d);
4175
+ for (const ins of instrs.slice(at + 1)) {
4176
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx' || classifyXfer(ins) !== null || modifiesSp(ins)) {
4177
+ return;
4178
+ }
4179
+ if (!mentionsReg(ins, rD)) {
4180
+ continue;
4181
+ }
4182
+ const width = ins.mnemonic === 'ldrb' ? 1 : ins.mnemonic === 'ldrh' ? 2 : 0;
4183
+ const addr = ins.ops[1] === undefined ? null : parseAddr(ins.ops[1]);
4184
+ if (width === 0 || reg(ins.ops[0] ?? '') !== rD || addr === null || reg(addr.base) !== rD || addr.regOff) {
4185
+ return;
4186
+ }
4187
+ const slot = from + addr.off - (littleEndian ? 0 : 4 - width);
4188
+ if (slot % 4 === 0 && slot >= 0 && slot + 4 <= localArea) {
4189
+ found.set(ins, { copy, slot });
4190
+ }
4191
+ return;
4192
+ }
4193
+ });
4194
+ }
4195
+ return found;
4196
+ }
4197
+
4198
+ // Every register that holds a frame address at some instruction of live code — the shapes
4199
+ // `frameAddressDefined` carries, closed over every block at once rather than along one. So it is
4200
+ // a SUPERSET of every block's `held`, and what it EXCLUDES is the only thing it is used for: a
4201
+ // store through one of these is not a store outside the frame. A frame address reloaded from
4202
+ // memory is not followed; the store through it then counts as leaving the frame, which is the
4203
+ // imprecision `frameAddressReceivers` states.
4204
+ function registersThatMayHoldAFrameAddress(
4205
+ asmBlocks: readonly AsmBlock[],
4206
+ entryReachable: ReadonlySet<number>,
4207
+ ): ReadonlySet<string> {
4208
+ const held = new Map<string, number>();
4209
+ for (let grew = true; grew;) {
4210
+ grew = false;
4211
+ for (const b of entryReachable) {
4212
+ for (const ins of asmBlocks[b].instrs) {
4213
+ // CONSTANT OFFSETS ONLY. Closed over every block at once, an index sum (NaN) would carry
4214
+ // the frame base through every `add rD, rS, rT` in the function — measured: klonoa's
4215
+ // `DmaControllerInit` then held its DMA register's base, and its publish stopped counting
4216
+ // as one. A store through `&buf[i]` stays the counted-as-leaving imprecision stated above.
4217
+ const defined = frameAddressDefined(ins, held);
4218
+ if (defined !== null && Number.isFinite(defined[1]) && !held.has(defined[0])) {
4219
+ held.set(defined[0], 0); // the offset is not the question here, only the register
4220
+ grew = true;
4221
+ }
4222
+ }
4223
+ }
4224
+ }
4225
+ return new Set(held.keys());
4226
+ }
4227
+
4228
+ // THE FRAME ADDRESSES ARGUMENT REGISTERS HOLD AT EACH CALL, two ways. The shapes carried are
4229
+ // `frameAddressDefined`'s, and a write of anything else ends one, as in `heldFrameWalk`.
4230
+ // - `may`: on SOME path from the entry, ACROSS blocks, joined by union — the question is a
4231
+ // refusal's, so a capture made in one block and handed over in the next (`mov r5, sp` / … /
4232
+ // `.L3: add r3, r5, #0` / `bl`) must count, and a call ends only what it clobbers, because a
4233
+ // capture in r4-r7 outlives it. Two paths that disagree on the offset leave it unknown (NaN).
4234
+ // The entry block starts empty, which keeps an incoming argument register from counting:
4235
+ // nothing arrives in it but the caller's value.
4236
+ // - `must`: defined in the call's own block, which every path to the call runs.
4237
+ interface FrameAddressesAtCall {
4238
+ readonly may: ReadonlyMap<string, number>;
4239
+ readonly must: ReadonlyMap<string, number>;
4240
+ }
4241
+ function frameAddressesAtCalls(
4242
+ asmBlocks: readonly AsmBlock[],
4243
+ preds: readonly (readonly number[])[],
4244
+ entryReachable: ReadonlySet<number>,
4245
+ clobbers: readonly string[],
4246
+ ): ReadonlyMap<Instr, FrameAddressesAtCall> {
4247
+ const atCall = new Map<Instr, FrameAddressesAtCall>();
4248
+ const out = asmBlocks.map(() => new Map<string, number>());
4249
+ const step = (ins: Instr, held: Map<string, number>): void => {
4250
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
4251
+ clobbers.forEach((r) => held.delete(r));
4252
+ return;
4253
+ }
4254
+ const carried = frameAddressDefined(ins, held);
4255
+ for (const r of [...held.keys()]) {
4256
+ if (mayWriteReg(ins, r)) {
4257
+ held.delete(r);
4258
+ }
4259
+ }
4260
+ if (carried !== null) {
4261
+ held.set(carried[0], carried[1]);
4262
+ }
4263
+ };
4264
+ const flow = (b: number, record: boolean): Map<string, number> => {
4265
+ const may = new Map<string, number>();
4266
+ for (const p of preds[b] ?? []) {
4267
+ for (const [r, off] of out[p]) {
4268
+ may.set(r, !may.has(r) || may.get(r) === off ? off : NaN);
4269
+ }
4270
+ }
4271
+ const must = new Map<string, number>();
4272
+ for (const ins of asmBlocks[b].instrs) {
4273
+ if (record && (ins.mnemonic === 'bl' || ins.mnemonic === 'blx')) {
4274
+ atCall.set(ins, { may: new Map(may), must: new Map(must) });
4275
+ }
4276
+ step(ins, may);
4277
+ step(ins, must);
4278
+ }
4279
+ return may;
4280
+ };
4281
+ const same = (a: ReadonlyMap<string, number>, b: ReadonlyMap<string, number>) =>
4282
+ a.size === b.size && [...a].every(([r, off]) => b.has(r) && Object.is(b.get(r), off));
4283
+ for (let changed = true; changed;) {
4284
+ changed = false;
4285
+ for (const b of entryReachable) {
4286
+ const next = flow(b, false);
4287
+ if (!same(next, out[b])) {
4288
+ out[b] = next;
4289
+ changed = true;
4290
+ }
4291
+ }
4292
+ }
4293
+ for (const b of entryReachable) {
4294
+ flow(b, true);
4295
+ }
4296
+ return atCall;
4297
+ }
4298
+
4299
+ // THE REGISTERS AN INSTRUCTION HANDS TO CODE OUTSIDE THIS FUNCTION — the one definition of a frame
4300
+ // address escaping, which every escape walk in `measureThumbFrame` reads, so which registers a
4301
+ // callee reads and which stores leave the frame are decided once.
4302
+ //
4303
+ // A CALL hands over the argument registers its callee's DECLARATION reads — one per 32-bit word,
4304
+ // so `g(void)` is handed nothing — and every argument register where nothing declares it. A call
4305
+ // through a register (`blx r3`, a `_call_via_r3` thunk) branches THROUGH that register; it does not
4306
+ // pass it. "Every register" is wider than what the lift passes an undeclared callee — a guessed
4307
+ // count, or the bound of a call through an argument register — and the two agree on a frame address
4308
+ // only because the lowering refuses to leave one out (`lowerCall`'s `leftOut`, over
4309
+ // `frameAddressesAtCalls`, which holds at each call a superset of what the walks here hold) — of
4310
+ // the registers its guess leaves out, of those the trim cuts afterwards (`GuessedCallSite.
4311
+ // trimmed`), and of those the call's own block wrote that the ranked `/setup-args` reading would
4312
+ // cut, which is withheld for that site instead (it runs in the ranker, after the audit below). Where
4313
+ // the outgoing-argument verdict leans on an escape, the audit re-proves it of the lifted IR as well
4314
+ // (`escapesReliedOn`), which catches a shrink inside the lift that bypassed the question, and
4315
+ // `/setup-args` is not offered at a call that hands out such an escape (`lowerCall`'s
4316
+ // `withholdsSetupArgs`), since this definition read every argument register it holds as handed
4317
+ // over; at the function's other calls the reading asks `leftOut` of every register it cuts
4318
+ // (`asksEveryCut`), an earlier block's frame address included. Elsewhere that a value an EARLIER
4319
+ // block left is dead is the whole guess of that reading, and agbcc does leave a dead `mov r3, sp`
4320
+ // 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
4321
+ // it addresses with no reader (`frontend/ssa.ts` cutsUnreadFrameObjects).
4322
+ //
4323
+ // A WORD `str` hands its source register to whoever reads the memory it writes, unless the base is
4324
+ // sp or any register that may hold a frame address (`registersThatMayHoldAFrameAddress`, whole-
4325
+ // function): then the address is written INTO this frame, and nothing outside has been handed it.
4326
+ // A frame address the base reached through memory is the one this misses, and it counts as
4327
+ // leaving. A `strh`/`strb` hands over part of an address, which no reader can follow.
4328
+ function frameAddressReceivers(
4329
+ ins: Instr,
4330
+ target: TargetDescription,
4331
+ calls: CallDeclarations,
4332
+ mayHoldFrameAddress: ReadonlySet<string>,
4333
+ ): readonly string[] {
4334
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
4335
+ const targetReg = calleeRegister(ins, target);
4336
+ const declared = targetReg === null ? calls.declaredCall(ins.ops[0] ?? '?') : null;
4337
+ const words = declared?.widths.reduce((n, w) => n + Math.ceil(w / 32), 0) ?? target.argRegs.length;
4338
+ return target.argRegs.slice(0, words).filter((r) => r !== targetReg);
4339
+ }
4340
+ if (ins.mnemonic !== 'str') {
4341
+ return [];
4342
+ }
4343
+ const { base } = parseAddr(ins.ops[1] ?? '');
4344
+ return isSpReg(base) || mayHoldFrameAddress.has(reg(base)) ? [] : [reg(ins.ops[0] ?? '')];
4345
+ }
4346
+
3281
4347
  // Is the word-slot model safe for THIS function? Every disqualifier below leaves every `[sp,#k]`
3282
4348
  // access on the old path, which declines — so the answer to "not sure" is the loud one.
3283
4349
  // Returns null when the word-slot model is SAFE for this function, else the reason it is off —
@@ -3309,13 +4375,54 @@ function slotModelBlocker(
3309
4375
  // frameless function taking sp's address has nothing to model and refuses in those arms.
3310
4376
  }
3311
4377
  }
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.
4378
+ const depthBlocker = frameDepthBlocker(asmBlocks);
4379
+ if (depthBlocker !== null) {
4380
+ return depthBlocker;
4381
+ }
4382
+ // OUTGOING ARGUMENTS — one analysis, and its refusals are this guard's. `analyzeOutgoingArgs`
4383
+ // decides per call whether the words staged at the bottom of the frame are a callee's
4384
+ // arguments; everything it cannot license refuses here, so a `[sp,#k]` access in such a
4385
+ // function still declines loud.
4386
+ if (outgoing.blocker !== null) {
4387
+ return outgoing.blocker;
4388
+ }
4389
+ // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
4390
+ // taken while the local area is still reserved reads a slot this model has retargeted into SSA
4391
+ // — the load simply disagrees with the store. A real epilogue releases the locals first
4392
+ // (`add sp,#N; pop {…}`), which is what this requires; `pop {r1}` mid-frame does not.
4393
+ for (const ab of asmBlocks) {
4394
+ let released = 0;
4395
+ for (const ins of ab.instrs) {
4396
+ if ((ins.mnemonic === 'pop' || ins.mnemonic === 'ldmia') && released < localArea) {
4397
+ const base = ins.mnemonic === 'pop' ? 'sp' : (ins.ops[0] ?? '').replace(/!$/, '');
4398
+ if (isSpReg(base)) {
4399
+ return 'a pop reads the frame while the local area is still reserved';
4400
+ }
4401
+ }
4402
+ if ((ins.mnemonic === 'add' || ins.mnemonic === 'sub') && isSpReg(ins.ops[0])) {
4403
+ const d = spAdjust(ins);
4404
+ if (d !== null && d > 0) {
4405
+ released += d;
4406
+ }
4407
+ }
4408
+ }
4409
+ }
4410
+ return null;
4411
+ }
4412
+
4413
+ // Is sp ONE VALUE at every frame access of this function, the one its prologue leaves? Null when it
4414
+ // is, else why not. The slot model needs it because a slot's key IS the raw offset, and an incoming
4415
+ // stack argument read outside the entry block needs it because its index is the offset less the
4416
+ // depth (`makeFrameWalk`).
4417
+ //
4418
+ // Two shapes are legitimate and everything else refuses: a PROLOGUE (sp moves before the block
4419
+ // touches the frame) and an EPILOGUE (sp moves after it has finished touching it, and the block
4420
+ // returns, so nothing downstream can key a slot against the changed sp). A modification BETWEEN two
4421
+ // accesses moves the frame under a slot already keyed, and only the entry block may deepen —
4422
+ // elsewhere a pre-access modification would put the block at a different depth from the one the
4423
+ // prologue established. So every block but the entry starts at the depth the entry leaves, and
4424
+ // only a block that returns moves sp.
4425
+ function frameDepthBlocker(asmBlocks: readonly AsmBlock[]): string | null {
3319
4426
  for (let bi = 0; bi < asmBlocks.length; bi++) {
3320
4427
  const ins = asmBlocks[bi].instrs;
3321
4428
  const mems = ins.map((x, i) => (touchesFrame(x) ? i : -1)).filter((i) => i >= 0);
@@ -3346,34 +4453,6 @@ function slotModelBlocker(
3346
4453
  }
3347
4454
  }
3348
4455
  }
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
4456
  return null;
3378
4457
  }
3379
4458
 
@@ -3437,14 +4516,18 @@ function reservedLocalArea(entry: AsmBlock): number {
3437
4516
  return Math.max(0, reserved);
3438
4517
  }
3439
4518
 
4519
+ /** the low registers agbcc's callers expect back intact (r0-r3 are argument and scratch) */
4520
+ const CALLEE_SAVED_LOW_REGS: ReadonlySet<string> = new Set(['r4', 'r5', 'r6', 'r7']);
4521
+
3440
4522
  // WHAT THE PROLOGUE SAVED — the second half of the register partition (frontend/ssa.ts,
3441
4523
  // LiveInModel.uninitRegs), in operand spellings. "The ABI does not pass arguments here" is a fact
3442
4524
  // about the CALLER and cannot on its own make a def-less read an uninitialised local; what does is
3443
4525
  // that the compiler homed a local in the register, which it may only do after saving it. Asm that
3444
4526
  // saves nothing follows no such convention, and some of it really is handed live values there:
3445
4527
  // 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.
4528
+ // reads r4/r5 with no prologue at all — classified by the ABI alone it stored through reads of
4529
+ // registers nothing ever wrote. Unsaved, they are global register variables
4530
+ // (`LiveInModel.globalRegs`), which is what agbcc compiles `register u8 *p asm("r4")` to.
3448
4531
  //
3449
4532
  // PER REGISTER, because saving r5 says nothing about r4 — a mid-function fragment reached by
3450
4533
  // agbcc's `bl`-as-a-long-branch saves what it uses and is handed the rest.
@@ -3453,9 +4536,30 @@ function reservedLocalArea(entry: AsmBlock): number {
3453
4536
  // the set. `HIGH_REGS` have no `push` encoding, so agbcc saves them as `mov rLow, rHi; push
3454
4537
  // {rLow}` and the source of such a `mov` joins the set when its low register is pushed — every
3455
4538
  // high-register inhabitant in the corpus goes through that idiom.
3456
- function prologueSavedRegs(entry: AsmBlock): ReadonlySet<string> {
4539
+ //
4540
+ // THAT `mov` IS THE SAVE'S, NOT THE FUNCTION'S, and is returned as `carriers` for the fill to
4541
+ // skip. Lifted, it leaves the low register holding the high one's entry value, so where a local
4542
+ // homed in each is left uninitialised on one path (`synthetic:dma_fill_uninit`: a switch with no
4543
+ // default, ten locals spread over r5-r7 and r8-sl) the two read as one undefined value, and
4544
+ // frontend/ssa.ts `markCopiedUndefs` spells that relation as an assignment between two locals the
4545
+ // source never related. What the machine relates there is the CALLER's register to its own save
4546
+ // copy; no statement of this function computed it. Skipped, the low register's def-less read is
4547
+ // its own undefined value, which the push has saved (so it is in `saved`). The blind side, which
4548
+ // the relation this drops is: a body that reads the low register before writing it and compares
4549
+ // it with the high one sees two unrelated values where the machine holds one — agbcc only does
4550
+ // this for two distinct uninitialised locals, which no statement of the source relates.
4551
+ //
4552
+ // ONLY A CALLEE-SAVED LOW REGISTER THAT AN EARLIER `push` ALREADY SAVED IS A CARRIER. That is the
4553
+ // shape agbcc emits (`push {r4-r7, lr}; mov r7, sl; mov r6, r8; push {r6, r7}`): the low register's
4554
+ // own entry value is safe in the first save, so the `mov` destroys nothing the function could
4555
+ // read. Hand-written asm may stage through anything — `mov r3, r8; push {r3}; add r0, r0, r3`
4556
+ // reads r3 as the high register's value, and skipping that `mov` there would turn r3 into the
4557
+ // caller's fourth argument. Such a `mov` is lifted as the function's own; the high register still
4558
+ // joins `saved`, as it always has.
4559
+ function prologueSavedRegs(entry: AsmBlock): { saved: ReadonlySet<string>; carriers: ReadonlySet<Instr> } {
3457
4560
  const saved = new Set<string>();
3458
- const carries = new Map<string, string>(); // low register ← the high one moved into it
4561
+ const carriers = new Set<Instr>();
4562
+ const carries = new Map<string, { mov: Instr; carrier: boolean }>(); // low register ← the `mov` that moved a high one into it
3459
4563
  for (const x of entry.instrs) {
3460
4564
  if (x.mnemonic === 'push') {
3461
4565
  const list = regListOf(x.ops);
@@ -3464,24 +4568,317 @@ function prologueSavedRegs(entry: AsmBlock): ReadonlySet<string> {
3464
4568
  }
3465
4569
  for (const r of list) {
3466
4570
  saved.add(r);
3467
- const hi = carries.get(r);
3468
- if (hi !== undefined) {
3469
- saved.add(hi);
4571
+ const staged = carries.get(r);
4572
+ if (staged !== undefined) {
4573
+ saved.add(staged.mov.ops[1]);
4574
+ if (staged.carrier) {
4575
+ carriers.add(staged.mov);
4576
+ }
4577
+ carries.delete(r);
3470
4578
  }
3471
4579
  }
3472
4580
  } 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]);
4581
+ carries.set(x.ops[0], { mov: x, carrier: CALLEE_SAVED_LOW_REGS.has(x.ops[0]) && saved.has(x.ops[0]) });
3474
4582
  } else if (spAdjust(x) === null) {
3475
4583
  break;
3476
4584
  }
3477
4585
  }
3478
- return saved;
4586
+ return { saved, carriers };
4587
+ }
4588
+
4589
+ /** WHAT A `push` SAVES AROUND EVERY USE, ON EVERY PATH — the prologue's (`prologueSavedRegs`) and
4590
+ * any other. A register the function names nowhere before a `push` of it, nor after the `pop`
4591
+ * that restores it, along any path from the entry, and that every path restores before it
4592
+ * returns, is saved as surely as one the prologue saves (`saved`): its writes are undone and its
4593
+ * entry value is the caller's, so it is a local's home like the prologue's
4594
+ * (`LiveInModel.uninitRegs`). Hand-written asm saves late: `cmp r0, #0; beq .L1; push {r4}; …;
4595
+ * pop {r4}; .L1: bx lr` uses r4 only between its save and its restore. A `push` is nothing but a
4596
+ * save and a `pop` nothing but a restore, which is what lets this read them anywhere; MIPS's `sw`
4597
+ * and PPC's `stw` also store a value (a global register variable passed on the stack is
4598
+ * `sw s0,16(sp)`), so those frontends read saves only in the entry block.
4599
+ *
4600
+ * A high register is saved through the `mov rLow, rHi` that stages it, and that `mov` is the
4601
+ * save's, provided `rLow` reaches the `push` unchanged — PROLOGUESAVEDREGS' RULE, here past the
4602
+ * leading run too, so the two readings of one idiom agree: `mov r0, r8; add r0, #1; push {r0}`
4603
+ * saves no r8. It is restored by `pop {rLow}; mov rHi, rLow` likewise, and that `pop` restores no
4604
+ * `rLow`: agbcc's `pop {r3, r4}; mov r8, r3; mov sl, r4; pop {r4-r7}` hands r4 sl's word first.
4605
+ *
4606
+ * THE RESTORE ENDS THE SAVE. Past it the register holds the caller's value again, so a read there
4607
+ * is the caller's value and a write there is one the caller sees, neither of them a local's —
4608
+ * and the fill keeps the `pop` transparent, so a read would see the function's last write
4609
+ * (`push {r4}; mov r4, #5; pop {r4}; mov r0, r4` would lift `return 5;`). For the same reason a save
4610
+ * that some path carries unrestored to a return the frame is balanced at — a `push` whose word is
4611
+ * popped into another register or dropped with `add sp` — saved nothing on that path. A return
4612
+ * with the word still on the stack is an input whose epilogue was left out, and is not asked: the
4613
+ * blind side is hand-written asm that returns with its frame unbalanced, which no caller survives.
4614
+ *
4615
+ * A RESTORE IS THE REGISTER'S OWN WORD, not its name. The walk keeps the stack word by word —
4616
+ * whose entry value each pushed word holds — and a `pop` (or a staging pop's `mov rHi, rLow`)
4617
+ * restores a register only when the word it loads is that register's: `push {r4, r5}; pop {r5};
4618
+ * pop {r4}` names both and swaps the caller's r4 and r5, and agbcc's staging with its two `mov`s
4619
+ * crossed swaps r8 and r9. A push labels a word with a register only while that register still
4620
+ * holds its entry value (`dirty`): pushed again after a write, a foreign pop or a save dropped
4621
+ * with `add sp`, it pushes a value of the function's, and the pop of that word restores nothing.
4622
+ * Where paths as deep disagree about a word, the word is no register's. Such a pop is a write the
4623
+ * caller sees that the fill, reading the `pop` as transparent, never shows, so a callee-saved
4624
+ * register one reaches a balanced return with is `leaked`, and refused outright (after the
4625
+ * return proof, `poppedForeign`). The blind side: where the stack is not known — paths that
4626
+ * disagree about its depth, a move of sp the frame walk cannot follow, a store through a
4627
+ * register-indexed sp address — a restore is read by name, and a store through a copy of sp
4628
+ * into a pushed word is not seen. `refuseReturnsNotThroughCallersLr`
4629
+ * asks the same word question of lr alone, on its own walk.
4630
+ *
4631
+ * `misSaved` is every register a save or a restore names that some path uses outside a save, or
4632
+ * returns with still saved: a `pop {r4}` with no `push` before it (`mov r0, r4; pop {r4}; bx lr`),
4633
+ * a `push` past a read, a use past the restore, a staged `push {r3}` of r8 popped into r1. Its
4634
+ * writes are not all the caller's and its entry value is no global's, so `registerLiveIns`
4635
+ * refuses it rather than spell it either way. "Uses" is any naming in an operand, read or write. */
4636
+ const CALL_CLOBBERS = ['r0', 'r1', 'r2', 'r3', 'r12', 'r14'];
4637
+ const ALL_REGS = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8', 'r9', 'r10', 'r11', 'r12', 'r14'];
4638
+ function bodySavedRegs(
4639
+ blocks: readonly AsmBlock[],
4640
+ preds: readonly (readonly number[])[],
4641
+ ): { saved: ReadonlySet<string>; misSaved: ReadonlySet<string>; leaked: ReadonlyMap<string, Instr> } {
4642
+ // What each instruction saves, restores and otherwise names; the staging `mov`s first.
4643
+ const savesHi = new Map<Instr, string>(); // `mov rLow, rHi` ⇒ rHi, where rLow is pushed unchanged
4644
+ const restoresHi = new Set<Instr>(); // `mov rHi, rLow` where rLow was popped unchanged
4645
+ const stagingPops = new Map<Instr, Set<string>>(); // a `pop`'s rLow that a `mov rHi, rLow` restores through
4646
+ for (const b of blocks) {
4647
+ const staged = new Map<string, Instr>(); // low register ← the `mov` that put a high one there
4648
+ const popped = new Map<string, Instr>(); // low register ← the `pop` that filled it
4649
+ for (const x of b.instrs) {
4650
+ const list = x.mnemonic === 'push' || x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : [];
4651
+ if (x.mnemonic === 'push') {
4652
+ list.forEach((r) => staged.has(r) && savesHi.set(staged.get(r)!, staged.get(r)!.ops[1]));
4653
+ }
4654
+ const isMov = /^movs?$/.test(x.mnemonic);
4655
+ const from = isMov && HIGH_REGS.has(x.ops[0] ?? '') ? popped.get(x.ops[1] ?? '') : undefined;
4656
+ if (from !== undefined) {
4657
+ restoresHi.add(x);
4658
+ stagingPops.set(from, (stagingPops.get(from) ?? new Set()).add(x.ops[1]));
4659
+ }
4660
+ const writes = x.mnemonic === 'push' ? [] : x.mnemonic === 'pop' ? list : regWritesOf(x);
4661
+ if (writes === 'all') {
4662
+ staged.clear();
4663
+ popped.clear();
4664
+ } else {
4665
+ for (const w of writes) {
4666
+ staged.delete(w);
4667
+ popped.delete(w);
4668
+ }
4669
+ }
4670
+ if (x.mnemonic === 'pop') {
4671
+ list.forEach((r) => popped.set(r, x));
4672
+ } else if (isMov && HIGH_REGS.has(x.ops[1] ?? '') && !HIGH_REGS.has(x.ops[0] ?? '')) {
4673
+ staged.set(x.ops[0], x);
4674
+ }
4675
+ }
4676
+ }
4677
+ const namedBy = (x: Instr): string[] => [
4678
+ ...(x.ops.join(',').match(/(?<![\w.#])(r1[0-5]|r\d|sb|sl|fp|ip)(?![\w])/g) ?? []),
4679
+ ...(/^(ldm|stm)/.test(x.mnemonic) ? (regListOf(x.ops.slice(1)) ?? []) : []),
4680
+ ];
4681
+ const savedSomewhere = new Set<string>();
4682
+ const restoredSomewhere = new Set<string>();
4683
+ const usedUnsaved = new Set<string>();
4684
+ const unrestored = new Set<string>(); // still saved at a balanced return on some path
4685
+ // Forward dataflow over two sets: the registers saved on EVERY path into each block (`cur`, a
4686
+ // must-set; null: not reached yet) and on SOME path (`open`, a may-set) — a use needs the first,
4687
+ // a return must find the second empty — and the frame depth (`makeFrameWalk`; null where paths
4688
+ // disagree or it is not exact).
4689
+ //
4690
+ // And the STACK, word by word (`stack`, top last; null where paths disagree about its depth or a
4691
+ // 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
4692
+ // word its `pop` loads is that register's own. `pop {r5}; pop {r4}` after `push {r4, r5}` NAMES
4693
+ // both registers and restores neither — it swaps the caller's r4 and r5.
4694
+ type Stack = readonly (string | null)[];
4695
+ // `clobbered` (a may-set) is each callee-saved register a pop has loaded another word into since
4696
+ // its last restore, with that pop: the fill reads a `pop` as transparent, so no write of the body
4697
+ // shows it, and one that reaches a balanced return is refused here (`leaked`).
4698
+ type State = {
4699
+ cur: Set<string>;
4700
+ open: Set<string>;
4701
+ depth: number | null;
4702
+ stack: Stack | null;
4703
+ clobbered: Map<string, Instr>;
4704
+ dirty: Set<string>;
4705
+ };
4706
+ // `dirty` (a may-set) is each register that may no longer hold its entry value: written, or loaded
4707
+ // by a pop from a word not its own, since the entry or its last restore. A push labels a word with
4708
+ // a register only while it is not: `push {r4}; mov r4, r0; add sp, #4; push {r4}; pop {r4}` pushes
4709
+ // a0 the second time, and that pop is no restore — however its push named the register.
4710
+ const stateIn: (State | null)[] = blocks.map((_, i) =>
4711
+ i === 0 ? { cur: new Set(), open: new Set(), depth: 0, stack: [], clobbered: new Map(), dirty: new Set() } : null,
4712
+ );
4713
+ const leaked = new Map<string, Instr>();
4714
+ const word = (r: string) => canonReg(r) ?? r;
4715
+ const walk = (b: number, record: boolean): State => {
4716
+ const cur = new Set(stateIn[b]!.cur);
4717
+ const open = new Set(stateIn[b]!.open);
4718
+ const clobbered = new Map(stateIn[b]!.clobbered);
4719
+ const dirty = new Set(stateIn[b]!.dirty);
4720
+ const frame = makeFrameWalk({ argRegs: [], start: stateIn[b]!.depth });
4721
+ let stack: (string | null)[] | null = stateIn[b]!.stack === null ? null : [...stateIn[b]!.stack!];
4722
+ const holds = new Map<string, string>(); // low register ← the high one a save's staging `mov` copied
4723
+ const loaded = new Map<string, string | null>(); // a staging pop's low register ← whose word it loaded
4724
+ for (const x of blocks[b].instrs) {
4725
+ const before = frame.depthNow();
4726
+ frame.step(x);
4727
+ const after = frame.depthNow();
4728
+ const saves = x.mnemonic === 'push' ? (regListOf(x.ops) ?? []) : savesHi.has(x) ? [savesHi.get(x)!] : [];
4729
+ const named =
4730
+ x.mnemonic === 'pop'
4731
+ ? (regListOf(x.ops) ?? []).filter((r) => !stagingPops.get(x)?.has(r))
4732
+ : restoresHi.has(x)
4733
+ ? [x.ops[0]]
4734
+ : [];
4735
+ // Which of them the word they load really restores: all, by name, where the stack is not known.
4736
+ let own: ((r: string) => boolean) | null = null;
4737
+ const list = x.mnemonic === 'push' || x.mnemonic === 'pop' ? byRegNumber(regListOf(x.ops) ?? []) : null;
4738
+ if (x.mnemonic === 'push') {
4739
+ // a push stores its highest-numbered register deepest
4740
+ // a word is a register's own only while that register holds its entry value (`dirty`)
4741
+ const label = (r: string) => (dirty.has(holds.get(r) ?? r) ? null : (holds.get(r) ?? r));
4742
+ stack = stack === null || list === null ? null : [...stack, ...list.map(label).reverse()];
4743
+ } else if (x.mnemonic === 'pop') {
4744
+ if (stack === null || list === null || stack.length < list.length) {
4745
+ stack = null;
4746
+ } else {
4747
+ const popped = new Map(list.map((r) => [r, stack!.pop()!] as const));
4748
+ own = (r) => popped.get(word(r)) === word(r);
4749
+ stagingPops.get(x)?.forEach((r) => loaded.set(word(r), popped.get(word(r)) ?? null));
4750
+ }
4751
+ } else if (restoresHi.has(x)) {
4752
+ const from = loaded.get(word(x.ops[1]));
4753
+ own = stack === null || from === undefined ? null : (r) => from === word(r);
4754
+ } else if (stack !== null && /^str[bh]?$/.test(x.mnemonic) && isSpReg(parseAddr(x.ops[1] ?? '').base)) {
4755
+ // a store into a pushed word replaces what it holds
4756
+ const { off, regOff } = parseAddr(x.ops[1] ?? '');
4757
+ if (regOff !== undefined) {
4758
+ stack = null;
4759
+ } else if (Math.floor(off / 4) < stack.length) {
4760
+ stack[stack.length - 1 - Math.floor(off / 4)] = null;
4761
+ }
4762
+ } else if (stack !== null && before !== after) {
4763
+ // any other move of sp (`sub sp, #8`, `add sp, #4`) pushes or drops whole anonymous words
4764
+ const words = before === null || after === null || (after - before) % 4 !== 0 ? null : (after - before) / 4;
4765
+ stack =
4766
+ words === null || stack.length + words < 0
4767
+ ? null
4768
+ : words > 0
4769
+ ? [...stack, ...Array<null>(words).fill(null)]
4770
+ : stack.slice(0, stack.length + words);
4771
+ }
4772
+ const writes = x.mnemonic === 'push' ? [] : x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : regWritesOf(x);
4773
+ if (writes === 'all') {
4774
+ holds.clear();
4775
+ // a call leaves the registers the ABI has it preserve; anything else unread may write any
4776
+ (/^(bl|blx|swi)$/.test(x.mnemonic) ? CALL_CLOBBERS : ALL_REGS).forEach((r) => dirty.add(r));
4777
+ } else {
4778
+ writes.forEach((r) => {
4779
+ holds.delete(word(r));
4780
+ dirty.add(word(r));
4781
+ });
4782
+ }
4783
+ if (savesHi.has(x)) {
4784
+ holds.set(word(x.ops[0]), word(savesHi.get(x)!));
4785
+ }
4786
+ const restores = own === null ? named : named.filter(own);
4787
+ const uses =
4788
+ x.mnemonic === 'push'
4789
+ ? []
4790
+ : savesHi.has(x)
4791
+ ? [x.ops[0]]
4792
+ : [...named, ...namedBy(x), ...(x.mnemonic === 'pop' ? (regListOf(x.ops) ?? []) : [])];
4793
+ if (record) {
4794
+ saves.forEach((r) => savedSomewhere.add(r));
4795
+ named.forEach((r) => restoredSomewhere.add(r));
4796
+ uses.filter((r) => !cur.has(r)).forEach((r) => usedUnsaved.add(r));
4797
+ }
4798
+ saves
4799
+ .filter((r) => !uses.includes(r))
4800
+ .forEach((r) => {
4801
+ cur.add(r);
4802
+ open.add(r);
4803
+ });
4804
+ restores.forEach((r) => {
4805
+ cur.delete(r);
4806
+ open.delete(r);
4807
+ clobbered.delete(word(r));
4808
+ dirty.delete(word(r));
4809
+ });
4810
+ // A pop that loads another register's word writes this one and restores nothing: it is no
4811
+ // longer saved, and its own save, still open, is unrestored at a balanced return.
4812
+ named
4813
+ .filter((r) => !restores.includes(r))
4814
+ .forEach((r) => {
4815
+ cur.delete(r);
4816
+ if (/^r([4-9]|1[01])$/.test(word(r))) {
4817
+ clobbered.set(word(r), x);
4818
+ }
4819
+ });
4820
+ // Only a return the frame is balanced at: one with the `push` still on the stack is an
4821
+ // input whose epilogue was left out (a fragment, a fixture), which the prologue's reading
4822
+ // has always taken as restored.
4823
+ if (record && classifyXfer(x) === 'return' && frame.depthNow() === 0) {
4824
+ open.forEach((r) => unrestored.add(r));
4825
+ clobbered.forEach((at, r) => leaked.has(r) || leaked.set(r, at));
4826
+ }
4827
+ }
4828
+ return { cur, open, depth: frame.depthNow(), stack, clobbered, dirty };
4829
+ };
4830
+ const sameStack = (x: Stack | null, y: Stack | null) =>
4831
+ x === null || y === null ? x === y : x.length === y.length && x.every((w, i) => w === y[i]);
4832
+ // Paths whose stacks are as deep keep each word they agree on, and the rest is no register's: a
4833
+ // pop of it restores nothing. Paths that disagree about the depth leave the stack unknown.
4834
+ const joinStacks = (xs: readonly (Stack | null)[]): Stack | null =>
4835
+ xs.some((x) => x === null || x.length !== xs[0]!.length)
4836
+ ? null
4837
+ : xs[0]!.map((w, i) => (xs.every((x) => x![i] === w) ? w : null));
4838
+ const sameSet = (x: ReadonlySet<string>, y: ReadonlySet<string>) =>
4839
+ x.size === y.size && [...x].every((r) => y.has(r));
4840
+ for (let changed = true; changed;) {
4841
+ changed = false;
4842
+ for (let b = 1; b < blocks.length; b++) {
4843
+ const ins = preds[b].filter((p) => stateIn[p] !== null).map((p) => walk(p, false));
4844
+ if (ins.length === 0) {
4845
+ continue;
4846
+ }
4847
+ const cur = new Set([...ins[0].cur].filter((r) => ins.every((s) => s.cur.has(r))));
4848
+ const open = new Set(ins.flatMap((s) => [...s.open]));
4849
+ const depth = ins.every((s) => s.depth === ins[0].depth) ? ins[0].depth : null;
4850
+ const stack = joinStacks(ins.map((s) => s.stack));
4851
+ const clobbered = new Map(ins.flatMap((s) => [...s.clobbered]));
4852
+ const dirty = new Set(ins.flatMap((s) => [...s.dirty]));
4853
+ const was = stateIn[b];
4854
+ if (
4855
+ was === null ||
4856
+ was.cur.size !== cur.size ||
4857
+ was.open.size !== open.size ||
4858
+ was.depth !== depth ||
4859
+ !sameStack(was.stack, stack) ||
4860
+ was.clobbered.size !== clobbered.size ||
4861
+ !sameSet(was.dirty, dirty)
4862
+ ) {
4863
+ stateIn[b] = { cur, open, depth, stack, clobbered, dirty };
4864
+ changed = true;
4865
+ }
4866
+ }
4867
+ }
4868
+ blocks.forEach((_, b) => stateIn[b] !== null && walk(b, true));
4869
+ const mis = (r: string) => usedUnsaved.has(r) || unrestored.has(r);
4870
+ return {
4871
+ saved: new Set([...savedSomewhere].filter((r) => !mis(r))),
4872
+ misSaved: new Set([...savedSomewhere, ...restoredSomewhere].filter(mis)),
4873
+ leaked,
4874
+ };
3479
4875
  }
3480
4876
 
3481
4877
  /** How the fill reads and writes an operand: the guards that keep sp, pc and a data label from
3482
4878
  * being read or written as a register, and the function-scope statics a pool word names. */
3483
4879
  interface ThumbOperands {
3484
4880
  localStatic(symbol: string): string;
4881
+ anonymousObject(label: string): string;
3485
4882
  constVal(n: number, b: number): Value;
3486
4883
  spAsDataError(): FrontendUnsupportedError;
3487
4884
  readData(r: string, b: number): Value;
@@ -3523,6 +4920,22 @@ function thumbOperands({
3523
4920
  return statics.define(read);
3524
4921
  };
3525
4922
 
4923
+ /** The compiler's anonymous constant a pool word names (agbcc's `.LC0`), read out of this same
4924
+ * asm and recorded beside the statics — or a decline naming why it is not one. Unlike a static,
4925
+ * it need not be this function's alone: agbcc places one image for every identical constant in
4926
+ * a unit (varasm.c `output_constant_def` hashes them), and each function that copies it spells
4927
+ * its own initializer, which the recompiled unit shares again. */
4928
+ const anonymousObject = (label: string): string => {
4929
+ const read = readGasAnonymousObject(asm, label);
4930
+ if ('refused' in read) {
4931
+ throw new FrontendUnsupportedError(
4932
+ `cannot lift '${name}': literal-pool load of a pool word that names the compiler's anonymous data ` +
4933
+ `('${label}') ${read.refused}`,
4934
+ );
4935
+ }
4936
+ return statics.define(read);
4937
+ };
4938
+
3526
4939
  const constVal = (n: number, b: number): Value => {
3527
4940
  const v = mkValue(T.unk(32));
3528
4941
  irBlocks[b].ops.push(mkOp('const', { results: [v], attrs: { value: n } }));
@@ -3632,7 +5045,7 @@ function thumbOperands({
3632
5045
  }
3633
5046
  writeVar(r, b, v);
3634
5047
  };
3635
- return { localStatic, constVal, spAsDataError, readData, writeData };
5048
+ return { localStatic, anonymousObject, constVal, spAsDataError, readData, writeData };
3636
5049
  }
3637
5050
 
3638
5051
  /** One half of a 64-bit value this lift projected into a register. */
@@ -3647,21 +5060,30 @@ interface PairHalf {
3647
5060
  interface ThumbPairs {
3648
5061
  readonly halfOf: ReadonlyMap<Value, PairHalf>;
3649
5062
  readonly wordReturns: { bi: number; lo: Value; widerEpilogue: boolean }[];
5063
+ /** what r1 may hold at each word return, where no declaration pins the width (dead-reads.ts
5064
+ * `unreadPairHalf`) */
5065
+ readonly unreadHigh: Set<Value>;
3650
5066
  projectHalf(irb: Block, whole: Value, half: 'lo' | 'hi', at: Instr): Value;
3651
5067
  fuseHalves(irb: Block, lo: Value, hi: Value): Value;
3652
5068
  wideReturn(lo: Value, bi: number): Value | null;
5069
+ /** record a return of one word in block `bi`, for `refuseWordReturns` and the dead-read pin */
5070
+ noteWordReturn(bi: number, lo: Value, widerEpilogue: boolean): void;
3653
5071
  refuseWordReturns(): void;
3654
5072
  }
3655
5073
 
3656
5074
  function thumbPairs({
3657
5075
  name,
3658
5076
  target,
5077
+ own,
5078
+ ownMemoryReturn,
3659
5079
  ssa,
3660
5080
  asmBlocks,
3661
5081
  callClobbers,
3662
5082
  }: {
3663
5083
  name: string;
3664
5084
  target: TargetDescription;
5085
+ own: FnProto | undefined;
5086
+ ownMemoryReturn: LiftOptions['ownMemoryReturn'];
3665
5087
  ssa: SsaBuilder;
3666
5088
  asmBlocks: readonly AsmBlock[];
3667
5089
  callClobbers: readonly string[];
@@ -3725,6 +5147,7 @@ function thumbPairs({
3725
5147
  // epilogue says the return type is 5 to 8 bytes — judged by `refuseWordReturns` once every block
3726
5148
  // is filled.
3727
5149
  const wordReturns: ThumbPairs['wordReturns'] = [];
5150
+ const unreadHigh = new Set<Value>();
3728
5151
  /** THE WORD RETURNS THAT WOULD DROP A HIGH HALF, refused. Run after every block is filled and
3729
5152
  * before `ssa.finish()`: a join's phi operands are wired by then, and not yet folded away.
3730
5153
  *
@@ -3741,7 +5164,10 @@ function thumbPairs({
3741
5164
  * eightByteReturnScratch`), over r0:r1 that hold no pair this lift built — a high word computed
3742
5165
  * from shifts, a `double`, or an 8-byte aggregate returned through memory, which agbcc sizes
3743
5166
  * 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. */
5167
+ * epilogue cannot tell apart from them — save where the function's own declaration says it
5168
+ * returns a struct of 5 to 8 bytes through memory (`ownMemoryReturn`): the refusal is then
5169
+ * deferred to the pipeline, which throws it unless the lift proves r0 is only that struct's
5170
+ * storage (frontend/own-struct-return.ts), and that proof is what tells the third case apart. */
3745
5171
  const refuseWordReturns = () => {
3746
5172
  const classes = halfOf.size ? mergeClasses({ blocks: ssa.irBlocks }) : new Map<Value, readonly Value[]>();
3747
5173
  const carries = (v: Value, half: 'lo' | 'hi') => (classes.get(v) ?? [v]).some((m) => halfOf.get(m)?.half === half);
@@ -3758,14 +5184,31 @@ function thumbPairs({
3758
5184
  );
3759
5185
  }
3760
5186
  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
- );
5187
+ const label = asmBlocks[bi].label;
5188
+ // `unproved`: why the declared struct, which would explain the epilogue, is not shown to be it
5189
+ const refuse = (unproved?: string): never => {
5190
+ throw new FrontendUnsupportedError(
5191
+ `cannot lift '${name}': the epilogue of '${label}' says the return type is 5 to 8 bytes ` +
5192
+ '(a 64-bit integer, a double, or an 8-byte aggregate), and no 64-bit pair this lift built is in r0:r1' +
5193
+ (unproved === undefined
5194
+ ? ''
5195
+ : `; the ${ownMemoryReturn!.bytes}-byte struct its own declaration returns through r0 would explain it, ` +
5196
+ `but the lift cannot show r0 is only that struct's storage: ${unproved}`),
5197
+ );
5198
+ };
5199
+ if (ownMemoryReturn !== undefined && ownMemoryReturn.bytes > 4 && ownMemoryReturn.bytes <= 8) {
5200
+ ownMemoryReturn.defer(refuse);
5201
+ continue;
5202
+ }
5203
+ refuse();
3765
5204
  }
3766
5205
  }
3767
5206
  };
3768
- return { halfOf, wordReturns, projectHalf, fuseHalves, wideReturn, refuseWordReturns };
5207
+ const noteWordReturn = (bi: number, lo: Value, widerEpilogue: boolean): void => {
5208
+ wordReturns.push({ bi, lo, widerEpilogue });
5209
+ unreadPairHalf(ssa, own, target.argRegs[1], bi, unreadHigh);
5210
+ };
5211
+ return { halfOf, wordReturns, unreadHigh, noteWordReturn, projectHalf, fuseHalves, wideReturn, refuseWordReturns };
3769
5212
  }
3770
5213
 
3771
5214
  /** Whether, from instruction `from` of block `bi` to its end, nothing puts into the pair's high
@@ -3840,6 +5283,10 @@ function highRegisterHeld(ab: AsmBlock, from: number, hi: string, callClobbers:
3840
5283
  interface ThumbFlagCarry {
3841
5284
  readonly exitCmp: Map<number, PendingCmp | string>;
3842
5285
  inheritedCmp(bi: number): PendingCmp | string;
5286
+ /** every value a `cmp` compared, whether or not a branch reads the flags it set: a compare
5287
+ * nothing reads leaves no op, so this is the only record that the value was consumed (the
5288
+ * dead-read pin's `compared`, frontend/dead-reads.ts) */
5289
+ readonly compared: Set<Value>;
3843
5290
  }
3844
5291
 
3845
5292
  function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmBlock, JumpTable>): ThumbFlagCarry {
@@ -3875,7 +5322,7 @@ function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmB
3875
5322
  return plast !== undefined && classifyXfer(plast) === 'cond';
3876
5323
  },
3877
5324
  });
3878
- return { exitCmp, inheritedCmp };
5325
+ return { exitCmp, inheritedCmp, compared: new Set<Value>() };
3879
5326
  }
3880
5327
 
3881
5328
  /** The two frame facts the fill accumulates and the frame-object audit reads: every offset the
@@ -3885,6 +5332,9 @@ function thumbFlagCarry({ asmBlocks, preds }: ThumbCfg, tables: ReadonlyMap<AsmB
3885
5332
  interface ThumbFrameUses {
3886
5333
  readonly usedSlotOffsets: Set<number>;
3887
5334
  readonly movedCaptures: Set<Value>;
5335
+ /** the blocks other than the entry that read an incoming stack argument
5336
+ * (`ThumbFrame.settledDepth`), which `lowerCall`'s `withholdsSetupArgs` reads */
5337
+ readonly offEntryArgReads: Set<number>;
3888
5338
  }
3889
5339
 
3890
5340
  /** WHAT THE FILL OF A BLOCK READS: the records of the stages before it, the SSA builder it writes
@@ -3904,6 +5354,13 @@ interface ThumbFill {
3904
5354
  /** the function-scope statics the operands define, finished into the lifted function's objects */
3905
5355
  readonly statics: LocalStatics;
3906
5356
  readonly frameUses: ThumbFrameUses;
5357
+ /** What an elided or kept dispatch left in its idiom's registers, by the value standing for each. */
5358
+ readonly staleIdiomValues: Map<Value, StaleIdiomRead>;
5359
+ /** What an elided dispatch left at each case target's entry, by block index. */
5360
+ readonly caseEntries: ReadonlyMap<number, CaseEntry>;
5361
+ /** Each callee-saved register a `pop` loads another word into and that reaches a balanced return
5362
+ * so (`bodySavedRegs`'s `leaked`), with that pop: refused once the return proof has run. */
5363
+ readonly poppedForeign: ReadonlyMap<string, Instr>;
3907
5364
  }
3908
5365
 
3909
5366
  /** EVERY STAGE BEFORE THE FILL, in their refusal order: the text, the CFG, the target's scratch
@@ -3917,33 +5374,63 @@ function thumbFillOf(
3917
5374
  prototypes: Prototypes,
3918
5375
  symbols: SymbolMap | undefined,
3919
5376
  oneObject: FrameRange | undefined,
5377
+ {
5378
+ contextOwnWords,
5379
+ contextOwnProto,
5380
+ ownMemoryReturn,
5381
+ }: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto' | 'ownMemoryReturn'> = {},
3920
5382
  ): ThumbFill {
3921
- const text = readThumbText(name, asm);
5383
+ const text = readThumbText(name, asm, target.compilerBehaviors.farJumpRelay?.comment);
3922
5384
  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);
5385
+ assertScratchRegsPartitioned(target); // first, for the refusal order; `registerLiveIns` asks again
5386
+ const calls = callDeclarations(
5387
+ name,
5388
+ target,
5389
+ prototypes,
5390
+ THUMB_CALL_LOWERING,
5391
+ (message) => {
5392
+ throw new FrontendUnsupportedError(message);
5393
+ },
5394
+ () => ownDeclarationOf(name, prototypes, contextOwnProto),
5395
+ );
5396
+ const frame = measureThumbFrame({
5397
+ name,
5398
+ target,
5399
+ cfg,
5400
+ calls,
5401
+ oneObject,
5402
+ own: ownDeclarationOf(name, prototypes, contextOwnProto),
3926
5403
  });
3927
- const frame = measureThumbFrame({ target, cfg, calls, oneObject });
3928
5404
 
3929
5405
  // --- ISA-neutral SSA construction (shared Braun builder) ---
3930
5406
  // THE LIVE-IN MODEL is the frame's partition (`ThumbFrame.partition`) and a register half.
3931
5407
  // The register half needs both of its facts, and they come from different places. The target says
3932
5408
  // 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
- }));
5409
+ // saved, and so could have homed a local in — in its prologue, or by a `push` ahead of every use
5410
+ // (`bodySavedRegs`). A register in only the first is one the function shares with every other: a
5411
+ // global register variable, or a refusal where the target cannot spell one. The save is asked only
5412
+ // of the registers the ABI requires preserving: `target.scratchRegs` need none, so demanding one
5413
+ // there would refuse a local the compiler was entitled to put in place with no prologue at all.
5414
+ // `registerLiveIns` (frontend/ssa.ts) composes it, as it does for PPC. The function's own
5415
+ // declaration bounds the argument registers (`ownArgumentWordsOn`): agbcc homes a local in r3 past
5416
+ // a two-parameter one.
5417
+ // A prologue save that some path uses past its restore, or carries to a return unrestored, is
5418
+ // `misSaved` and no save: the prologue's reading of it sees only the `push`.
5419
+ const body = bodySavedRegs(cfg.asmBlocks, cfg.preds);
5420
+ const liveIns = registerLiveIns(
5421
+ target,
5422
+ new Set([...frame.savedRegs, ...body.saved].filter((r) => !body.misSaved.has(r))),
5423
+ contextOwnWords ?? ownArgumentWordsOn(name, prototypes[name], target),
5424
+ body.misSaved,
5425
+ );
5426
+ const ssa = makeSsaBuilder(
5427
+ name,
5428
+ cfg.asmBlocks.length,
5429
+ cfg.preds,
5430
+ () => ({ ...frame.partition, ...liveIns, saveSite: '`push` ahead of every use' }),
5431
+ undefined,
5432
+ liveIns.globalRegs,
5433
+ );
3947
5434
  const statics = makeLocalStatics();
3948
5435
  const operands = thumbOperands({
3949
5436
  name,
@@ -3953,7 +5440,15 @@ function thumbFillOf(
3953
5440
  statics,
3954
5441
  slotsOffReason: frame.slotsOffReason,
3955
5442
  });
3956
- const pairs = thumbPairs({ name, target, ssa, asmBlocks: cfg.asmBlocks, callClobbers: calls.callClobbers });
5443
+ const pairs = thumbPairs({
5444
+ name,
5445
+ target,
5446
+ own: prototypes[name],
5447
+ ownMemoryReturn,
5448
+ ssa,
5449
+ asmBlocks: cfg.asmBlocks,
5450
+ callClobbers: calls.callClobbers,
5451
+ });
3957
5452
  const flags = thumbFlagCarry(cfg, text.tables);
3958
5453
  return {
3959
5454
  name,
@@ -3968,10 +5463,36 @@ function thumbFillOf(
3968
5463
  pairs,
3969
5464
  flags,
3970
5465
  statics,
3971
- frameUses: { usedSlotOffsets: new Set(), movedCaptures: new Set() },
5466
+ frameUses: {
5467
+ usedSlotOffsets: new Set(),
5468
+ movedCaptures: new Set(),
5469
+ offEntryArgReads: new Set(),
5470
+ },
5471
+ staleIdiomValues: new Map(),
5472
+ caseEntries: elidedCaseEntries(cfg, text.tables),
5473
+ poppedForeign: body.leaked,
3972
5474
  };
3973
5475
  }
3974
5476
 
5477
+ /** Each case target of an ELIDED dispatch, with what the idiom left at its entry (jump-table.ts
5478
+ * `caseEntries`). A kept dispatch's block is lifted and leaves its markers at its own end
5479
+ * (`lowerTerminator`), where the default path never goes. */
5480
+ function elidedCaseEntries(cfg: ThumbCfg, tables: ReadonlyMap<AsmBlock, JumpTable>): Map<number, CaseEntry> {
5481
+ const at = (label: string) => cfg.labelIndex.get(label)!;
5482
+ return caseEntries(
5483
+ [...tables]
5484
+ .filter(([, jt]) => jt.extras.length === 0)
5485
+ .map(([ab, jt]) => ({
5486
+ at: at(ab.label),
5487
+ scrutReg: jt.scrutReg,
5488
+ cases: jt.caseLabels.map(at),
5489
+ defaultBlock: at(jt.defaultLabel),
5490
+ leaves: jt.leaves,
5491
+ })),
5492
+ cfg.preds,
5493
+ );
5494
+ }
5495
+
3975
5496
  type FrameWalk = ReturnType<typeof makeFrameWalk>;
3976
5497
 
3977
5498
  /** ONE BLOCK BEING FILLED, and the three things that change as its instructions are read: the
@@ -3985,10 +5506,28 @@ interface BlockCursor {
3985
5506
  pendingCmp: PendingCmp | null;
3986
5507
  noCmpWhy: string;
3987
5508
  consumed: Instr | null;
5509
+ /** A kept jump-table dispatch's scrutinee, read at the block's entry (`lowerTerminator`). */
5510
+ readonly dispatchScrutinee: Value | null;
3988
5511
  }
3989
5512
 
3990
- function openBlockCursor({ target, cfg, ssa, flags }: ThumbFill, ab: AsmBlock, bi: number): BlockCursor {
5513
+ function openBlockCursor(fill: ThumbFill, ab: AsmBlock, bi: number): BlockCursor {
5514
+ const { target, text, cfg, ssa, flags, frame } = fill;
3991
5515
  const irb = ssa.irBlocks[bi];
5516
+ // A case target of an elided dispatch is entered with what the idiom left in its registers.
5517
+ const entry = fill.caseEntries.get(bi);
5518
+ if (entry !== undefined) {
5519
+ enterCaseTarget(
5520
+ entry,
5521
+ irb.ops,
5522
+ {
5523
+ key: reg,
5524
+ scrutinee: (r) => ssa.readVar(reg(r), bi),
5525
+ dispatchScrutinee: (r) => ssa.readAtEnd(reg(r), entry.at),
5526
+ write: (r, v) => ssa.writeVar(reg(r), bi, v),
5527
+ },
5528
+ fill.staleIdiomValues,
5529
+ );
5530
+ }
3992
5531
  // Seeded at the block's FIRST instruction, not at its terminator, so an inherited compare is
3993
5532
  // judged by the in-block clear (`retireFlags`) exactly as one this block made itself would be: a
3994
5533
  // block that inherits flags and then writes its own must lose them.
@@ -3998,13 +5537,15 @@ function openBlockCursor({ target, cfg, ssa, flags }: ThumbFill, ab: AsmBlock, b
3998
5537
  // that made a compare and then overwrote it is a different gap from one that never had a
3999
5538
  // compare at all, and a single message for both makes two gaps read as one.
4000
5539
  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 };
5540
+ // Tracks the frame through this block's linear instruction order, from the depth the block starts
5541
+ // at: 0 for an entry block nothing branches back to (its params are parameters, not phis), and
5542
+ // the depth the entry leaves for every other block where the frame is proven settled
5543
+ // (`ThumbFrame.settledDepth`). Elsewhere a `[sp,#N]` access declines.
5544
+ const start = bi === 0 ? (cfg.preds[0].length > 0 ? null : 0) : frame.settledDepth;
5545
+ const walk = makeFrameWalk({ argRegs: target.argRegs, start });
5546
+ const jt = text.tables.get(ab);
5547
+ const dispatchScrutinee = jt && jt.extras.length > 0 ? ssa.readVar(reg(jt.scrutReg), bi) : null;
5548
+ return { ab, bi, irb, walk, pendingCmp, noCmpWhy, consumed: null, dispatchScrutinee };
4008
5549
  }
4009
5550
 
4010
5551
  /** Fill block `bi`: lower each instruction in order, record what the block leaves in the flags,
@@ -4031,6 +5572,9 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4031
5572
  switch (ins.mnemonic) {
4032
5573
  case 'mov':
4033
5574
  case 'movs': {
5575
+ if (fill.frame.saveCarriers.has(ins)) {
5576
+ break; // the save's staging copy, not the function's (`prologueSavedRegs`)
5577
+ }
4034
5578
  lowerMov(fill, cur, ins);
4035
5579
  break;
4036
5580
  }
@@ -4166,6 +5710,11 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4166
5710
  }
4167
5711
  const rhs = b.startsWith('#') ? constVal(imm(b), bi) : readData(reg(b), bi);
4168
5712
  cur.pendingCmp = { lhs: readData(reg(a), bi), rhs };
5713
+ fill.flags.compared.add(cur.pendingCmp.lhs).add(rhs);
5714
+ break;
5715
+ }
5716
+ case 'cmn': {
5717
+ lowerCmn(fill, cur, ins);
4169
5718
  break;
4170
5719
  }
4171
5720
  case 'ldr':
@@ -4209,6 +5758,25 @@ function fillThumbBlock(fill: ThumbFill, ab: AsmBlock, bi: number): void {
4209
5758
  lowerTerminator(fill, cur);
4210
5759
  }
4211
5760
 
5761
+ /** `cmn rA, rB` as the compare it stands for, `cmp rA, -rB`: it records both registers' values and
5762
+ * the terminator mints the negation once it knows which branch reads it (`CMN_EXACT`). Thumb-1
5763
+ * encodes `cmn` on low registers only; hand-written text with any other operand takes the flags
5764
+ * with nothing modelled, exactly as `tst` does. */
5765
+ function lowerCmn(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
5766
+ const { readData } = fill.operands;
5767
+ const [a, b] = ins.ops;
5768
+ if (a === undefined || b === undefined || !/^r[0-7]$/.test(reg(a)) || !/^r[0-7]$/.test(reg(b))) {
5769
+ cur.noCmpWhy = `the flags it tests were written by 'cmn' in '${cur.ab.label}', and only a register 'cmn' is modelled`;
5770
+ cur.pendingCmp = null;
5771
+ emitOpaqueDest(fill, cur, ins);
5772
+ return;
5773
+ }
5774
+ const lhs = readData(reg(a), cur.bi);
5775
+ const rhs = readData(reg(b), cur.bi);
5776
+ cur.pendingCmp = { lhs, rhs, cmn: true };
5777
+ fill.flags.compared.add(lhs).add(rhs);
5778
+ }
5779
+
4212
5780
  /** What an instruction does to the flags the block holds: a writer retires the compare, and
4213
5781
  * `noCmpWhy` names it. */
4214
5782
  function retireFlags(cur: BlockCursor, ins: Instr): void {
@@ -4238,7 +5806,8 @@ function retireFlags(cur: BlockCursor, ins: Instr): void {
4238
5806
  // The inhabitant the guard itself exists for is hand-written asm in the playground, where
4239
5807
  // there is no oracle to catch a lie.
4240
5808
  //
4241
- // `tst`/`cmn` take them with no destination to read at all (`FLAG_ONLY`, above).
5809
+ // `tst` takes them with no destination to read at all (`FLAG_ONLY`, above). `cmn` is a compare
5810
+ // and seeds its own (`lowerCmn`), as `cmp` does.
4242
5811
  //
4243
5812
  // A CALL takes them too, and this is the one flag writer that is not an instruction: `bl`
4244
5813
  // writes no flags, but the function it enters retires compares of its own, and the ABI lets
@@ -4428,6 +5997,10 @@ function lowerMov(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4428
5997
  // a reserved local area for the object to live in.
4429
5998
  if (!b?.startsWith('#') && isSpReg(b ?? '') && !isSpReg(a ?? '')) {
4430
5999
  if (frame.slotsOk && frame.localArea > 0) {
6000
+ // a copy only a narrow slot reload reads names no object; the reload reads the slot
6001
+ if (frame.narrowReloadCopies.has(ins)) {
6002
+ return;
6003
+ }
4431
6004
  const res = mkValue(T.unk(32));
4432
6005
  irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: 0 } }));
4433
6006
  writeData(reg(a), bi, res);
@@ -4462,6 +6035,9 @@ function lowerAdd(fill: ThumbFill, cur: BlockCursor, ins: Instr, ii: number): vo
4462
6035
  // audit; only the offset differs.
4463
6036
  if (isSpReg(b ?? '') && !isSpReg(a ?? '') && c !== undefined && IMM_LITERAL.test(c)) {
4464
6037
  if (frame.slotsOk && frame.localArea > 0) {
6038
+ if (frame.narrowReloadCopies.has(ins)) {
6039
+ return; // see lowerMov
6040
+ }
4465
6041
  const res = mkValue(T.unk(32));
4466
6042
  irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: imm(c) } }));
4467
6043
  writeData(reg(a), bi, res);
@@ -4656,6 +6232,9 @@ function lowerBlockTransfer(fill: ThumbFill, cur: BlockCursor, ins: Instr): void
4656
6232
  const adv = mkValue(T.unk(32));
4657
6233
  irb.ops.push(mkOp('add', { operands: [base0, constVal(4 * list.length, bi)], results: [adv] }));
4658
6234
  writeData(baseReg, bi, adv);
6235
+ // agbcc's block copy leaves this dead in an argument register before a call (`ssa.ts`
6236
+ // `noteIncidentalWrite`), so it proves no argument there.
6237
+ fill.ssa.noteIncidentalWrite(adv);
4659
6238
  }
4660
6239
  }
4661
6240
 
@@ -4665,7 +6244,7 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4665
6244
  const { name, symbols, text, frame, ssa } = fill;
4666
6245
  const { readVar } = ssa;
4667
6246
  const { usedSlotOffsets } = fill.frameUses;
4668
- const { localStatic, readData, writeData } = fill.operands;
6247
+ const { localStatic, anonymousObject, readData, writeData } = fill.operands;
4669
6248
  const { bi, irb, walk } = cur;
4670
6249
  const [a, b] = ins.ops;
4671
6250
  // A word load whose operand NAMES a literal pool is a pool reference, not a memory base:
@@ -4677,7 +6256,11 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4677
6256
  // A function-scope static is the address of an object this function DEFINES: the
4678
6257
  // `gaddr` below under its linker name, with the definition recorded beside the Fn.
4679
6258
  const pr: PoolRef | null =
4680
- ref?.kind === 'local-static' ? { kind: 'gaddr', sym: localStatic(ref.symbol), addend: ref.addend } : ref;
6259
+ ref?.kind === 'local-static'
6260
+ ? { kind: 'gaddr', sym: localStatic(ref.symbol), addend: ref.addend }
6261
+ : ref?.kind === 'anonymous'
6262
+ ? { kind: 'gaddr', sym: anonymousObject(ref.label), addend: 0 }
6263
+ : ref;
4681
6264
  if (pr?.kind === 'const') {
4682
6265
  // Numeric-pool PROMOTION (symbols.ts): a pool-loaded word whose value the
4683
6266
  // project's symbol map knows becomes the NAMED global's address — the same
@@ -4769,12 +6352,58 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4769
6352
  // `argIndex` can make, and the soundness proof that each one is needed for, lives on the
4770
6353
  // predicate itself — restating them here is how the two copies drifted.
4771
6354
  {
4772
- const index = walk.argIndex({ base, off, regOff }, width, bi);
6355
+ const index = walk.argIndex({ base, off, regOff }, width);
4773
6356
  if (index !== null) {
6357
+ if (bi !== 0) {
6358
+ fill.frameUses.offEntryArgReads.add(bi);
6359
+ }
4774
6360
  writeData(reg(a), bi, readVar(stackArgKey(index), bi));
4775
6361
  return;
4776
6362
  }
4777
6363
  }
6364
+ // WHICH RELOADS OF THIS FUNCTION'S OWN FRAME ARE SLOT READS — the dual of the spill in the str
6365
+ // arm, and the whole soundness of it. The narrow reload next and the word reload below both ask
6366
+ // it.
6367
+ //
6368
+ // One a store reaches on some path reads that store, and a path that brings none reads an
6369
+ // uninitialised local: the frame is `ownedLocals`, so `readVar` answers that path with `undef`,
6370
+ // never with a parameter (frontend/ssa.ts `LiveInModel`).
6371
+ //
6372
+ // A slot NO store reaches, on any path, is the same local read before any store — `if (k > 5)
6373
+ // use(v0); … v0 = p[0];`, or a local the source never initialises. Only two kinds of callee
6374
+ // could have written it instead. One that assigned the word as its own stack parameter ran
6375
+ // before the load, so the load is after a call, which the after-call layout claim
6376
+ // (`localsAboveOutgoingArea`) says no word of the outgoing area is; with no call reaching the
6377
+ // load there is no such callee. Without the claim it may be one, and falls through to the
6378
+ // decline. The stronger `outgoingAreaNeverLoadedFrom` adds nothing here: a load BEFORE a call reads
6379
+ // a staged word, and a staged word has a reaching store. The other is a callee holding an
6380
+ // address of the frame, which the frame-object audit refuses: an `undef` slot an escaped writer
6381
+ // reaches (`writer-over-undef`), or one a captured object overlaps.
6382
+ const readsOwnSlot = (at: string, o: number, ro: string | undefined, w: number): boolean =>
6383
+ frame.isOwnFrameWordSlot(at, o, ro, w) &&
6384
+ (ssa.hasReachingDef(stackSlotKey(o), bi) || fill.target.compilerBehaviors.localsAboveOutgoingArea === true);
6385
+ // A NARROW RELOAD OF A WORD SLOT through a copy of sp nothing else reads (`narrowSlotReloads`):
6386
+ // the low-order part of the word the slot holds, so the slot's value narrowed — taken where the
6387
+ // word reload of the slot would be, and the slot is no object's. Its copy was not lifted, so a
6388
+ // reload this cannot take refuses here rather than reading through a register never set.
6389
+ {
6390
+ const slot = frame.narrowReloadOf.get(ins);
6391
+ if (slot !== undefined) {
6392
+ if (!readsOwnSlot('sp', slot, undefined, 4) || frame.isFrameObjectAccess('sp', slot, undefined, 4)) {
6393
+ throw new FrontendUnsupportedError(
6394
+ `cannot lift '${name}': narrow reload of a stack local through a copy of sp — ` +
6395
+ `'${ins.mnemonic} ${ins.ops.join(', ')}' reads the word slot [sp,#${slot}], which the word-slot model does not key here`,
6396
+ );
6397
+ }
6398
+ usedSlotOffsets.add(slot);
6399
+ const res = mkValue(T.unk(32));
6400
+ irb.ops.push(
6401
+ mkOp('zext', { operands: [readVar(stackSlotKey(slot), bi)], results: [res], attrs: { width: 8 * width } }),
6402
+ );
6403
+ writeData(reg(a), bi, res);
6404
+ return;
6405
+ }
6406
+ }
4778
6407
  // An address-taken object comes FIRST: it is memory, not a slot, so it is
4779
6408
  // read with a real `load` through its `laddr` (see isFrameObjectAccess). No
4780
6409
  // reaching-def test — the callee holding the address is a writer this function cannot
@@ -4787,16 +6416,8 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4787
6416
  writeData(reg(a), bi, res);
4788
6417
  return;
4789
6418
  }
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)) {
6419
+ // A word reload from this function's own frame (`readsOwnSlot`, above).
6420
+ if (readsOwnSlot(base, off, regOff, width)) {
4800
6421
  usedSlotOffsets.add(off);
4801
6422
  writeData(reg(a), bi, readVar(stackSlotKey(off), bi));
4802
6423
  return;
@@ -4853,6 +6474,28 @@ function lowerStore(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4853
6474
  irb.ops.push(mkOp('store', { operands: [storeBase, readData(reg(a), bi)], attrs: { off, width } }));
4854
6475
  }
4855
6476
 
6477
+ /** WHERE A READER FINDS A CALL IN THE .s, for a refusal that names it: a Thumb `Instr` carries no
6478
+ * address. A block the .s labels (the function's own entry, a `.L3`) names it by that label and
6479
+ * the call; a block the splitter opened after a branch has only a label it made up (`.L_anon0`),
6480
+ * which appears nowhere in the .s, so the call is named by its ordinal among the calls to the same
6481
+ * callee in the function, in the order the .s lists them. */
6482
+ function thumbCallLocator(blocks: readonly AsmBlock[], bi: number, ins: Instr): string {
6483
+ const call = `'${ins.mnemonic} ${ins.ops[0]}'`;
6484
+ const label = blocks[bi].label;
6485
+ if (!label.startsWith(ANON_BLOCK_PREFIX)) {
6486
+ return `${call} in block '${label}'`;
6487
+ }
6488
+ const same = blocks.flatMap((b) => b.instrs).filter((i) => i.mnemonic === ins.mnemonic && i.ops[0] === ins.ops[0]);
6489
+ const k = same.indexOf(ins);
6490
+ if (k < 0 || same.length === 1) {
6491
+ return `${call} in '${blocks[0].label}'`;
6492
+ }
6493
+ return `${call} in '${blocks[0].label}' (the ${ordinal(k + 1)} of its ${same.length} calls there)`;
6494
+ }
6495
+
6496
+ const ordinal = (n: number): string =>
6497
+ `${n}${n % 100 >= 11 && n % 100 <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th')}`;
6498
+
4856
6499
  /** `bl` / `blx`: the call its callee's declaration plans, lowered by `CallDeclarations.lower`. */
4857
6500
  function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4858
6501
  const { name, target, text, calls, frame, ssa, pairs } = fill;
@@ -4882,6 +6525,8 @@ function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4882
6525
  callee: through === null ? callee : { address: readData(through, bi), reg: through },
4883
6526
  ssa,
4884
6527
  bi,
6528
+ // what an elided or kept dispatch left in an argument register is no argument
6529
+ parked: parkedIn(fill.staleIdiomValues, reg),
4885
6530
  read: (r) => ssa.readVar(r, bi),
4886
6531
  write: (r, v) => writeData(r, bi, v),
4887
6532
  // ARGUMENT WORD `k` past the registers is a word of this frame's outgoing area, at [sp,#0]
@@ -4896,6 +6541,72 @@ function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
4896
6541
  usedSlotOffsets.add(off);
4897
6542
  return ssa.readVar(stackSlotKey(off), bi);
4898
6543
  },
6544
+ // A FRAME ADDRESS AN UNDECLARED ARITY LEAVES OUT IS AN ARGUMENT ALL THE SAME. The frame walks
6545
+ // read the call as handed every argument register it holds (`frameAddressReceivers`), and the
6546
+ // lift reads the object as written by that receiver (`isFrameObjectAccess`, with no reaching-
6547
+ // definition test). A count that leaves out a register holding one lifts the call without the
6548
+ // address, and the object's reads come back as a local nothing wrote (`func(p0, p1, p2, &d[1])`
6549
+ // as `func()`, then `*data = sp1`). A GUESSED count leaves one out only past a register it did
6550
+ // not set, which is a gap the guess refuses first (`guess` below); what reaches here is a call
6551
+ // through r1, which stops at the bound r1 sets and leaves r2 out (`CallDeclarations.lower`'s
6552
+ // KNOWN GAP), and the `/setup-args` cut (`asksEveryCut`). Which count is right the register
6553
+ // file cannot say, so this refuses.
6554
+ //
6555
+ // …UNLESS A REGISTER IT DOES PASS HOLDS THE SAME ADDRESS, in the call's own block: the callee is
6556
+ // handed the object either way, and agbcc leaves a capture it addressed through in r3 beside
6557
+ // the copy it passes in r0 (`mov r3, sp / str r0, [r3] / mov r0, sp / bl g`).
6558
+ leftOut: (regs, passed) => {
6559
+ const at = frame.frameAddressesAtCall.get(ins);
6560
+ // …and an address at an offset nothing knows (NaN, `&buf[i]`) is the same as no other one
6561
+ const handed = new Set(
6562
+ passed.flatMap((p) => (at?.must.has(p) && Number.isFinite(at.must.get(p)) ? [at.must.get(p)] : [])),
6563
+ );
6564
+ const r = regs.find((r) => at?.may.has(r) && !handed.has(at.may.get(r)!));
6565
+ if (r !== undefined) {
6566
+ throw new FrontendUnsupportedError(
6567
+ `cannot lift '${name}': the call '${ins.mnemonic} ${callee}' has no prototype, and the arity lifted ` +
6568
+ `without one leaves out ${r}, which may hold a frame address — so whether the callee is handed the ` +
6569
+ `object is not decidable`,
6570
+ );
6571
+ }
6572
+ },
6573
+ // NO `/setup-args` READING at a call whose escape the outgoing-argument verdict leaned on (an
6574
+ // escaped `sp+k` excuses every frame word from k up as the callee's to read). That verdict read
6575
+ // every argument register the call holds as handed over (`frameAddressReceivers`), so a reading
6576
+ // that cuts one contradicts it, whatever the register holds: `&t` an earlier block formed, a
6577
+ // second `mov r3, sp` the `leftOut` exception excuses beside a passed `sp`, or the `mov r2, #0`
6578
+ // agbcc reuses from the frame's initialisation (`g(a, s, 0, 0)` would narrow to `g(a, s)`).
6579
+ //
6580
+ // …NOR AT ANY CALL OF A FUNCTION WHOSE BLOCK OTHER THAN THE ENTRY READS AN INCOMING STACK
6581
+ // ARGUMENT. That is a conservative guard, not a property of the cut: the licence that lifts such a
6582
+ // read (`ThumbFrame.settledDepth`) reads no argument register. It keeps the guess off the
6583
+ // functions only that licence lifts (`if (a) g(n, w, e)` with `&w` left in r1 by the entry would
6584
+ // narrow to `g(n)`). The same leftover still narrows where agbcc schedules the stack argument's
6585
+ // load into the entry block instead (`w[1] = e; if (a) g(n, w)` as `g(n)`, which assembles to the
6586
+ // same bytes), as it does with four parameters.
6587
+ withholdsSetupArgs: () => frame.callsReliedOn.has(ins) || fill.frameUses.offEntryArgReads.size > 0,
6588
+ // A GAP IN THE ARGUMENT REGISTERS REFUSES (`fallbackArgc`) UNLESS IT IS DECIDED: `logv(s, 1)`
6589
+ // sets r1 alone and leaves `s` in r0, so a count that stops at r0 would lift `logv()`. The gap is
6590
+ // decided where r1 surely is an argument (frontend/call-plan.ts `decideGap`: a value nothing but
6591
+ // the call reads and no memory read produced). agbcc fills argument words in order, so `s` is
6592
+ // then passed on and the call lifts `logv(a0, 1)`. A run of incoming arguments
6593
+ // passed on with no register set up past it (`g2(a, b)`) leaves no gap, is the same machine code
6594
+ // as `g2()`, and is lifted as the shorter call. The instruction carries no address, so the
6595
+ // refusal names the call by where the .s puts it (`thumbCallLocator`).
6596
+ //
6597
+ // No `refuse`: a guess that fills r0..r3 asks nothing of the outgoing area here. A word agbcc
6598
+ // stages at [sp, #k] for the call is an argument only where `analyzeOutgoingArgs` licensed the
6599
+ // block, and the frame model refuses any other store there. agbcc's `g5(1, 2, 3, 4, a)` to an
6600
+ // undeclared `g5` declines as "stack pointer used as data" before any call is lowered.
6601
+ guess: { at: thumbCallLocator(fill.cfg.asmBlocks, bi, ins) },
6602
+ // …AND AT EVERY OTHER CALL OF A FUNCTION WHOSE VERDICT LEANED ON AN ESCAPE, the reading asks
6603
+ // `leftOut` of every register it cuts, not only of those this block wrote. The verdict read every
6604
+ // argument register as possibly handed over, so a frame address an EARLIER block left there is
6605
+ // not the dead base the reading guesses at: `g(b, s)` escapes, then `&t` formed before a branch
6606
+ // reaches `if (a) h(b, a, t); else *q = t[0];` in r2, and the cut `h(b, a)` would drop it while
6607
+ // the else arm still reads `t`. The question refuses only frame addresses, so a call that cuts
6608
+ // none (a sibling `f2()`) still narrows.
6609
+ asksEveryCut: () => frame.escapesReliedOn.size > 0,
4899
6610
  pairs: {
4900
6611
  fuse: (lo, hi) => pairs.fuseHalves(irb, lo, hi),
4901
6612
  project: (whole, half) => pairs.projectHalf(irb, whole, half, ins),
@@ -4915,9 +6626,27 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4915
6626
  const succ = (label: string): Successor => ({ block: irBlocks[cfg.labelIndex.get(label)!], args: [] });
4916
6627
  const jt = text.tables.get(ab);
4917
6628
  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)]);
6629
+ // Regime B: a `switch_br` over the scrutinee — N case blocks (values 0..N-1, dense) followed by
6630
+ // the default block (last successor). From the bounds block, the `cmp`/`bhi` are subsumed. A
6631
+ // kept dispatch (`JumpTable.extras`) switches on the scrutinee its block was entered with: the
6632
+ // idiom read it first, and the extras after it may overwrite the register. Its bounds test stays
6633
+ // a branch and already sent every value past N-1 to the default, so its switch is `exhaustive`.
6634
+ const scrut = cur.dispatchScrutinee ?? readVar(reg(jt.scrutReg), bi);
6635
+ // What the unlifted idiom left in the registers no extra rewrote is no value the lift models: on
6636
+ // the hardware the case blocks see the scaled index, the table's address and the case's own code
6637
+ // address. Each is a marker here, and a read of one declines (`refuseStaleIdiomReads`). The
6638
+ // elided form cannot put them here, since its bounds block's default path never ran the idiom: it
6639
+ // writes them at each case target's entry instead (`openBlockCursor`).
6640
+ if (cur.dispatchScrutinee !== null) {
6641
+ for (const r of [...jt.leaves.keys()].filter((r) => !jt.extras.some((ins) => ins.ops[0] === r))) {
6642
+ ssa.writeVar(
6643
+ reg(r),
6644
+ bi,
6645
+ staleMarker(irb.ops, reg(r), { reg: r, form: 'kept', parks: true }, fill.staleIdiomValues),
6646
+ );
6647
+ }
6648
+ }
6649
+ pushSwitchBr(irb.ops, scrut, [...jt.caseLabels.map(succ), succ(jt.defaultLabel)], cur.dispatchScrutinee !== null);
4921
6650
  } else if (!last) {
4922
6651
  // an EMPTY block is only ever the synthetic entry preheader (decoded blocks are non-empty):
4923
6652
  // fall through to the real entry, whose loop-header phis take their entry operand from here.
@@ -4951,7 +6680,7 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4951
6680
  last.ops[0] === scratch &&
4952
6681
  prev?.mnemonic === 'pop' &&
4953
6682
  regListOf(prev.ops)?.join() === scratch;
4954
- pairs.wordReturns.push({ bi, lo, widerEpilogue });
6683
+ pairs.noteWordReturn(bi, lo, widerEpilogue);
4955
6684
  }
4956
6685
  irb.ops.push(mkOp('ret', { operands: [whole ?? lo] }));
4957
6686
  }
@@ -4965,10 +6694,25 @@ function lowerTerminator(fill: ThumbFill, cur: BlockCursor): void {
4965
6694
  `cannot lift '${name}': conditional branch '${last.mnemonic}' has no reaching compare: ${cur.noCmpWhy}`,
4966
6695
  );
4967
6696
  }
6697
+ const { lhs, cmn } = cur.pendingCmp;
6698
+ let { rhs } = cur.pendingCmp;
6699
+ if (cmn) {
6700
+ if (!CMN_EXACT.has(last.mnemonic)) {
6701
+ throw new FrontendUnsupportedError(
6702
+ `cannot lift '${name}': conditional branch '${last.mnemonic}' tests the carry of a 'cmn', and the carry ` +
6703
+ 'out of a + b is not a >=u -b (they differ at b == 0)',
6704
+ );
6705
+ }
6706
+ // The signed branches read the TRUE sign of a + b, so the negation they compare against is
6707
+ // the mathematical one (`neg_s`, ir/opcodes.ts). `beq`/`bne` read the wrapped sum, which the
6708
+ // plain wrapping `neg` spells (see `CMN_EXACT` for the one `s32` input it leaves undefined).
6709
+ const negated = mkValue(T.unk(32));
6710
+ const signed = last.mnemonic !== 'beq' && last.mnemonic !== 'bne';
6711
+ irb.ops.push(mkOp(signed ? 'neg_s' : 'neg', { operands: [rhs], results: [negated] }));
6712
+ rhs = negated;
6713
+ }
4968
6714
  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
- );
6715
+ irb.ops.push(mkOp(COND_OPCODE[last.mnemonic], { operands: [lhs, rhs], results: [cond] }));
4972
6716
  irb.ops.push(
4973
6717
  mkOp('cond_br', { operands: [cond], successors: [succ(last.ops[0]), succ(fallLabel(cfg.asmBlocks, name, bi))] }),
4974
6718
  );
@@ -4990,25 +6734,6 @@ function fallLabel(asmBlocks: readonly AsmBlock[], name: string, bi: number): st
4990
6734
  return nb.label;
4991
6735
  }
4992
6736
 
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
6737
  /** The ARMv4T / Thumb (agbcc) frontend, registered for the `armv4t` target. */
5013
6738
  export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lift };
5014
6739
 
@@ -5017,6 +6742,7 @@ export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lif
5017
6742
  * package's public API, where no consumer should be able to depend on them. The name says what it
5018
6743
  * is; nothing in `src` reads it. */
5019
6744
  export const __testing = {
6745
+ decode,
5020
6746
  PAD_ENCODINGS,
5021
6747
  isPadInstr,
5022
6748
  factKey,