@asmlift/core 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/src/backend/c.ts +1 -0
  3. package/src/backend/cfamily.ts +33 -12
  4. package/src/backend/cpp.ts +1 -0
  5. package/src/backend/lines.ts +10 -0
  6. package/src/backend/pascal.ts +17 -5
  7. package/src/cdecl/parse.ts +21 -0
  8. package/src/contracts.ts +73 -20
  9. package/src/frontend/arm-body.ts +155 -0
  10. package/src/frontend/asmdata.ts +2 -1
  11. package/src/frontend/call-plan.ts +197 -45
  12. package/src/frontend/callee-body.ts +370 -0
  13. package/src/frontend/device-pins.ts +18 -8
  14. package/src/frontend/frame-objects.ts +504 -61
  15. package/src/frontend/frontend.ts +20 -0
  16. package/src/frontend/jump-table.ts +35 -20
  17. package/src/frontend/mips.ts +226 -8
  18. package/src/frontend/ppc.ts +26 -2
  19. package/src/frontend/register-struct-call.ts +1 -86
  20. package/src/frontend/ssa.ts +263 -1
  21. package/src/frontend/stackargs.ts +95 -17
  22. package/src/frontend/thumb-entry.ts +340 -0
  23. package/src/frontend/thumb.ts +410 -61
  24. package/src/ir/alias.ts +29 -8
  25. package/src/ir/bits.ts +1081 -66
  26. package/src/ir/core.ts +154 -4
  27. package/src/ir/entry.ts +3 -5
  28. package/src/ir/opcodes.ts +28 -0
  29. package/src/ir/parse.ts +1 -0
  30. package/src/ir/simplify.ts +61 -10
  31. package/src/ir/verify.ts +16 -0
  32. package/src/l3/argbase.ts +2 -0
  33. package/src/l3/ast.ts +294 -2
  34. package/src/l3/basecse.ts +35 -10
  35. package/src/l3/caseendgoto.ts +223 -0
  36. package/src/l3/coalesce.ts +42 -14
  37. package/src/l3/constinit.ts +34 -6
  38. package/src/l3/dce.ts +52 -8
  39. package/src/l3/ptrcell.ts +2 -0
  40. package/src/l3/regspell.ts +2 -0
  41. package/src/l3/reindex.ts +13 -4
  42. package/src/l3/scopebase.ts +2 -0
  43. package/src/l3/tailmerge.ts +2 -0
  44. package/src/pipeline.ts +507 -23
  45. package/src/proto-context.ts +135 -1
  46. package/src/raise/const.ts +25 -0
  47. package/src/raise/declared-volatile.ts +1 -1
  48. package/src/raise/divpow2.ts +192 -0
  49. package/src/raise/offsetnames.ts +412 -10
  50. package/src/raise/pre-recovery.ts +8 -5
  51. package/src/raise/pre-structure.ts +24 -5
  52. package/src/raise/reroll.ts +4 -24
  53. package/src/raise/shortcircuit.ts +192 -23
  54. package/src/raise/unpre.ts +594 -0
  55. package/src/rank-variations.ts +97 -4
  56. package/src/rank.ts +258 -43
  57. package/src/structure/analysis.ts +56 -10
  58. package/src/structure/cross-jumped-tail.ts +216 -0
  59. package/src/structure/globalaccess.ts +46 -1
  60. package/src/structure/structure.ts +4221 -368
  61. package/src/structure/switch-recover.ts +345 -26
  62. package/src/structure/unfolded-branch-constant.ts +90 -0
  63. package/src/symbols.ts +52 -15
  64. package/src/target.ts +269 -8
  65. package/src/trace.ts +28 -14
  66. package/src/variation-definitions.ts +207 -6
  67. package/src/variation-gates.ts +2 -0
  68. package/src/variation-tokens.ts +63 -2
@@ -22,12 +22,20 @@ import { ownArgumentWordsOn } from '../aggregate';
22
22
  import { Block, Fn, Successor, Value, mergeClasses, mkOp, mkValue } from '../ir/core';
23
23
  import type { Opcode } from '../ir/opcodes';
24
24
  import { T } from '../ir/types';
25
- import { type FnProto, type Prototypes, ownLeadingWordRegisters, wordsOf } from '../proto';
25
+ import {
26
+ type FnProto,
27
+ type Prototypes,
28
+ declaresVoidReturn,
29
+ ownLeadingWordRegisters,
30
+ ownReturnWidth,
31
+ wordsOf,
32
+ } from '../proto';
26
33
  import type { ContextGlobals } from '../proto-context';
27
34
  import { type SymbolMap, lookupInterior, lookupSymbol } from '../symbols';
28
35
  import { ARM_REGISTER_ALIASES, type TargetDescription } from '../target';
29
36
  import type { AsmData } from './asmdata';
30
37
  import { type CallDeclarations, type CallLowering, callDeclarations, foldByValueArgs } from './call-plan';
38
+ import { type CalleeStackWords, asksCalleeBody } from './callee-body';
31
39
  import { pinDeadReads, unreadPairHalf } from './dead-reads';
32
40
  import { pinDeviceAccesses } from './device-pins';
33
41
  import { pushSwitchBr } from './emit';
@@ -70,6 +78,7 @@ import {
70
78
  assertScratchRegsPartitioned,
71
79
  makeSsaBuilder,
72
80
  mintArgSlotHoles,
81
+ recordAsmLayout,
73
82
  registerArgSlots,
74
83
  registerLiveIns,
75
84
  stackArgKey,
@@ -84,6 +93,7 @@ import {
84
93
  analyzeOutgoingArgs,
85
94
  loadedWordBound,
86
95
  } from './stackargs';
96
+ import { thumbEntryWordsFromText } from './thumb-entry';
87
97
 
88
98
  interface Instr {
89
99
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -1357,9 +1367,17 @@ function decode(
1357
1367
  if (funcLabels.length > 0) {
1358
1368
  const fi = funcLabels.indexOf(name);
1359
1369
  if (fi !== -1) {
1360
- sliceStart = allFlat.findIndex((f) => f.label === name);
1370
+ // each label's first item, read in one pass: a search per function label costs labels × lines
1371
+ // on every lift, and a callee body bound lifts the whole unit once per callee it asks
1372
+ const firstAt = new Map<string, number>();
1373
+ allFlat.forEach((f, k) => {
1374
+ if (f.label !== undefined && !firstAt.has(f.label)) {
1375
+ firstAt.set(f.label, k);
1376
+ }
1377
+ });
1378
+ sliceStart = firstAt.get(name) ?? -1;
1361
1379
  const starts = funcLabels
1362
- .map((l) => allFlat.findIndex((f) => f.label === l))
1380
+ .map((l) => firstAt.get(l) ?? -1)
1363
1381
  .filter((s) => s > sliceStart)
1364
1382
  .sort((a, b) => a - b);
1365
1383
  boundaries = [...starts, allFlat.length];
@@ -2524,10 +2542,10 @@ function recoverJumpTable(
2524
2542
  // - a store, a call, a transfer, a flag READ (`adc`/`sbc`), or any other mnemonic;
2525
2543
  // - a write to the loaded target (it is what `mov pc` jumps through), or to sp/pc/lr. A write to
2526
2544
  // 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;
2545
+ // read what the extras left there on the hardware too. lr is refused as the register a case's
2546
+ // `bx lr` returns through: a write to it here makes that `bx lr` a jump, which only the return
2547
+ // proof (`refuseReturnsNotThroughCallersLr`) would otherwise have to catch. PRE never inserts
2548
+ // an lr write, so the refusal costs no compiled dispatch;
2531
2549
  // - a read of a register the idiom wrote (index, table pointer, target) before an extra rewrote it:
2532
2550
  // the idiom is not lifted, so such a read would see the value from before the dispatch.
2533
2551
  // The reason is returned so the decline can name it (`readThumbText`'s indirect-jump refusal).
@@ -2580,8 +2598,8 @@ export function lift(
2580
2598
  context?: ContextGlobals,
2581
2599
  options: LiftOptions = {},
2582
2600
  ): Fn {
2583
- return liftWithRelift(name, (oneObject) =>
2584
- liftOnce(name, asm, target, prototypes, asmData, symbols, context, oneObject, options),
2601
+ return liftWithRelift(name, (oneObject, homesReleased) =>
2602
+ liftOnce(name, asm, target, prototypes, asmData, symbols, context, oneObject, homesReleased, options),
2585
2603
  );
2586
2604
  }
2587
2605
 
@@ -2603,9 +2621,19 @@ function liftOnce(
2603
2621
  symbols: SymbolMap | undefined,
2604
2622
  context: ContextGlobals | undefined,
2605
2623
  oneObject: FrameRange | undefined,
2606
- options: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto' | 'ownMemoryReturn'>,
2624
+ homesReleased: boolean,
2625
+ options: Pick<
2626
+ LiftOptions,
2627
+ | 'contextOwnWords'
2628
+ | 'contextOwnProto'
2629
+ | 'ownMemoryReturn'
2630
+ | 'calleeBodyBound'
2631
+ | 'calleeStackWords'
2632
+ | 'stackSized'
2633
+ | 'entryReadCalls'
2634
+ >,
2607
2635
  ): Fn | FrameObjectRelift {
2608
- const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject, options);
2636
+ const fill = thumbFillOf(name, asm, target, prototypes, symbols, oneObject, options, homesReleased);
2609
2637
  const { cfg, frame, ssa, pairs, statics } = fill;
2610
2638
  const { fn, irBlocks, paramReg } = ssa;
2611
2639
 
@@ -2624,6 +2652,7 @@ function liftOnce(
2624
2652
  throw new FrontendUnsupportedError(m);
2625
2653
  },
2626
2654
  fill.calls,
2655
+ declaresVoidReturn(prototypes[name]),
2627
2656
  );
2628
2657
  // After the fill, so a block's own refusal (the frame's, the operands') keeps its place in the
2629
2658
  // refusal order: the proof reads only the text and the CFG, and changes nothing.
@@ -2649,6 +2678,20 @@ function liftOnce(
2649
2678
  const argSlots = stackArgSlots(registerArgSlots(target.argRegs), target.argRegs);
2650
2679
  mintArgSlotHoles(ssa, cfg.preds[0].length > 0, argSlots);
2651
2680
  ssa.finish();
2681
+ // A scratch register a `pop` fills with another word is a copy the fill never makes: `push {r1};
2682
+ // pop {r0}; bx lr` returns a1, and read by name it would lift `return a0`. Asked after the SSA's own
2683
+ // refusals, which name a register misused across its save before the copy that misuse makes. A
2684
+ // return hands r0 back unless the function's own declaration says it returns nothing.
2685
+ const declaredVoid = (prototypes[name] ?? options.contextOwnProto?.proto)?.returnsVoid === true;
2686
+ const [copy] = [...fill.poppedCopies, ...(declaredVoid ? [] : fill.poppedReturns)];
2687
+ if (copy !== undefined) {
2688
+ const [reg, at] = copy;
2689
+ throw new FrontendUnsupportedError(
2690
+ `cannot lift '${name}': '${at.mnemonic} ${at.ops.join(', ')}' loads a word that is not ${reg}'s own ` +
2691
+ `into ${reg}, which is then used as a value — a pop is read as restoring each register it names, ` +
2692
+ 'so the copy it makes is not modelled',
2693
+ );
2694
+ }
2652
2695
 
2653
2696
  // A struct passed by value becomes the one object its words hold (call-plan.ts `foldByValueArgs`)
2654
2697
  // before the audit judges the frame objects, that one among them.
@@ -2671,6 +2714,7 @@ function liftOnce(
2671
2714
  symbols,
2672
2715
  target,
2673
2716
  oneObject,
2717
+ releasableHomes: frame.releasableHomes,
2674
2718
  });
2675
2719
  if (verdict !== undefined && 'oneObject' in verdict) {
2676
2720
  return verdict;
@@ -2803,16 +2847,23 @@ interface LrState {
2803
2847
  * far-jump relay's lowering needs and does not itself prove (`lowerFarJumpRelays`): with every
2804
2848
  * return reading the CALLER's lr, the lr a relay writes cannot be what a return jumps to.
2805
2849
  *
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.
2850
+ * `bx lr` and `mov pc, lr` are asked the same question: lr itself must still hold the caller's lr,
2851
+ * so an instruction that writes lr by name before one (`mov lr, r4`, `pop {lr}`, `ldr lr, …`)
2852
+ * declines. Read by the register's name instead, `push {r4, lr}; ldr r4, =g; mov lr, r4; pop {r4};
2853
+ * bx lr` — a tail jump to g with every argument register live — would lift as a return, and a
2854
+ * caller whose call to it no declaration lays out would take that lift's one parameter as the
2855
+ * callee's whole arity (frontend/callee-body.ts). A compiler returns through the word it saved lr in, so no
2856
+ * compiled function reaches the refusal.
2857
+ *
2858
+ * NOT ASKED: a CALL's write of lr. `bl g; bx lr` returns to the instruction after the call on the
2859
+ * hardware, and lifts as `g(); return;`. No compiler emits it (one saves lr before it calls), and
2860
+ * many call tests' fixtures are written so, so a call leaves lr held. The far-jump relay leans on
2861
+ * neither: its lowering declines any function in which an instruction other than the entry `push`
2862
+ * names lr.
2810
2863
  *
2811
2864
  * Blocks no path from the entry reaches are not asked: they never run. */
2812
2865
  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)));
2866
+ const { asmBlocks, succs, entryReachable } = cfg;
2816
2867
  const LR = 'r14';
2817
2868
  const join = (a: LrState, b: LrState): LrState => {
2818
2869
  const depth = a.depth !== null && a.depth === b.depth ? a.depth : null;
@@ -2863,10 +2914,9 @@ function refuseReturnsNotThroughCallersLr(name: string, cfg: ThumbCfg): void {
2863
2914
  }
2864
2915
  }
2865
2916
  } 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).
2917
+ // `bx rN`, `bx lr` and `mov pc, lr` alike: the register must still hold the caller's lr.
2868
2918
  const via = canonReg(mn === 'bx' ? (ins.ops[0] ?? '') : (ins.ops[1] ?? ''));
2869
- if (via !== LR && (via === null || !regs.has(via))) {
2919
+ if (via === null || !regs.has(via)) {
2870
2920
  refuse(ins);
2871
2921
  }
2872
2922
  } else if (mn === 'push') {
@@ -2887,7 +2937,12 @@ function refuseReturnsNotThroughCallersLr(name: string, cfg: ThumbCfg): void {
2887
2937
  } else if (!isSpReg(ins.ops[0])) {
2888
2938
  const writes = regWritesOf(ins);
2889
2939
  if (writes === 'all') {
2940
+ // a call's own write of lr is not asked (see the doc comment)
2941
+ const kept = /^(bl|blx|swi)$/.test(mn) && regs.has(LR);
2890
2942
  regs.clear();
2943
+ if (kept) {
2944
+ regs.add(LR);
2945
+ }
2891
2946
  } else {
2892
2947
  const src = /^movs?$/.test(mn) ? canonReg(ins.ops[1] ?? '') : null;
2893
2948
  const copies = src !== null && regs.has(src);
@@ -3196,6 +3251,8 @@ interface ThumbCfg {
3196
3251
  readonly labelIndex: ReadonlyMap<string, number>;
3197
3252
  /** Each block's predecessors, one entry per edge. */
3198
3253
  readonly preds: number[][];
3254
+ /** Each block's successors, `preds` turned around. */
3255
+ readonly succs: readonly (readonly number[])[];
3199
3256
  readonly entryReachable: ReadonlySet<number>;
3200
3257
  }
3201
3258
 
@@ -3238,7 +3295,9 @@ function thumbCfg(name: string, blocks: readonly AsmBlock[], tables: ReadonlyMap
3238
3295
  }
3239
3296
  return live;
3240
3297
  })();
3241
- return { asmBlocks, labelIndex, preds, entryReachable };
3298
+ const succs: number[][] = asmBlocks.map(() => []);
3299
+ preds.forEach((ps, at) => ps.forEach((p) => succs[p].push(at)));
3300
+ return { asmBlocks, labelIndex, preds, succs, entryReachable };
3242
3301
  }
3243
3302
 
3244
3303
  // --- CFG (successors per block) as label lists; fallthrough + branch targets (via classifyXfer) ---
@@ -3332,6 +3391,9 @@ interface ThumbFrame {
3332
3391
  * (`narrowSlotReloads`), and the copies those reloads consume, which name no object */
3333
3392
  readonly narrowReloadOf: ReadonlyMap<Instr, number>;
3334
3393
  readonly narrowReloadCopies: ReadonlySet<Instr>;
3394
+ /** the argument homes whose narrow reloads were kept as captures for the one-object answer alone
3395
+ * (`argumentHomes`), which the audit may ask to release (`FrameObjectAudit.releasableHomes`) */
3396
+ readonly releasableHomes: ReadonlySet<number>;
3335
3397
  }
3336
3398
 
3337
3399
  /** The frame offset of each word of a declared call's outgoing block, or null where it stages none.
@@ -3377,18 +3439,31 @@ function calleeRegister(ins: Instr, target: TargetDescription): string | null |
3377
3439
  function measureThumbFrame({
3378
3440
  name,
3379
3441
  target,
3380
- cfg: { asmBlocks, preds, entryReachable },
3442
+ cfg: { asmBlocks, preds, succs, entryReachable },
3381
3443
  calls,
3382
3444
  oneObject,
3445
+ homesReleased = false,
3383
3446
  own,
3447
+ dropsVoidReturn,
3448
+ stackWordsOf,
3384
3449
  }: {
3385
3450
  name: string;
3386
3451
  target: TargetDescription;
3387
3452
  cfg: ThumbCfg;
3388
3453
  calls: CallDeclarations;
3389
3454
  oneObject: FrameRange | undefined;
3455
+ /** whether a first audit released the argument homes to the slot model (`releasableHomes`) */
3456
+ homesReleased?: boolean;
3390
3457
  /** the function's own declaration (`ownDeclarationOf`), read only where it `proves` */
3391
3458
  own: OwnDeclaration | undefined;
3459
+ /** whether the lift's `ret` reads nothing: a STATED void entry (`prototypes[name]`), the one
3460
+ * pipeline.ts `raiseRecovered` drops ret operands for. The context's own entry, proving or not,
3461
+ * leaves the operand in place. */
3462
+ dropsVoidReturn: boolean;
3463
+ /** the incoming stack words a callee nothing declares loads, off its own body
3464
+ * (`LiftOptions.calleeStackWords`): the outgoing-argument analysis's `bodyReads`, which asks it
3465
+ * only where the code stages a block */
3466
+ stackWordsOf?: CalleeStackWords;
3392
3467
  }): ThumbFrame {
3393
3468
  // THE FRAME BASE PASSED TO A CALLEE. What this computes is exactly what its name says and nothing
3394
3469
  // more: somewhere in entry-reachable code a bare `mov rD, sp` copies the frame base into a
@@ -3469,7 +3544,7 @@ function measureThumbFrame({
3469
3544
  // wider frame the publish escape admits exactly the shape the callee escape's table warns
3470
3545
  // about; at one word it cannot occur. A wider frame's publish is read by the outgoing-argument
3471
3546
  // `escape` event instead (below), per program point, which is the reading that survives a block
3472
- // copy: see "ONE DECISION, TWO LICENCES" above `capturedObjectIsTheWholeFrame`.
3547
+ // copy: see "ONE DECISION, FOUR LICENCES" above `capturedObjectIsTheWholeFrame`.
3473
3548
  //
3474
3549
  // THE WALK IS `heldFrameWalk`, the same one the callee escape is spelled with, because the
3475
3550
  // question is the same one and only the consuming event differs. Here that event is a WORD store
@@ -3606,7 +3681,7 @@ function measureThumbFrame({
3606
3681
  // `localArea >= 4` (measured). Nothing about the frame size moves it either way — which is what
3607
3682
  // makes it the wrong function to reason about a wider licence from.
3608
3683
  //
3609
- // ONE DECISION, TWO LICENCES. This gate and the outgoing-argument `escape` event (fed below,
3684
+ // ONE DECISION, FOUR LICENCES. This gate and the outgoing-argument `escape` event (fed below,
3610
3685
  // under `compilerBehaviors.escapedFrameAddressNamesALocal`) read the same escapes
3611
3686
  // (`frameAddressReceivers`) and differ only in flow-sensitivity. This gate is flow-INSENSITIVE —
3612
3687
  // one escape anywhere and no frame word is any call's argument — which is sound only where no
@@ -3614,6 +3689,17 @@ function measureThumbFrame({
3614
3689
  // leaves, at any frame size; that field says why that survives a block copy. So what this gate
3615
3690
  // adds is a store that reaches an undeclared call BEFORE the address leaves (the CONTROL of "a
3616
3691
  // capture in an unreachable block proves nothing"), and every "not widened" above is about it.
3692
+ //
3693
+ // The THIRD is the [sp,#0] struct-return capture (`frameBaseIsReturnStorage`, below): flow-
3694
+ // insensitive at any frame size, but only for the hidden pointer of a callee declared to return a
3695
+ // struct through memory, which a block copy's base never is (why: the field's comment). It
3696
+ // matters only where some call may take a stack word: the hidden pointer is r0, which
3697
+ // `frameAddressReceivers` hands over, so such a base always escapes and the fourth licence
3698
+ // admits it everywhere else.
3699
+ //
3700
+ // The FOURTH is the [sp,#0] capture where no call takes a stack word (`noCallTakesAStackWord &&
3701
+ // frameBaseEscapes`, below): flow-insensitive at any frame size too, and sound for a block copy's
3702
+ // base as well, because with no outgoing area no frame word is any call's argument at all.
3617
3703
  const capturedObjectIsTheWholeFrame =
3618
3704
  target.compilerBehaviors.oneWordFrameIsTheCapturedObject === true &&
3619
3705
  (frameBasePassedToCallee || frameBasePublishedToMemory) &&
@@ -3661,22 +3747,35 @@ function measureThumbFrame({
3661
3747
  // offsets either: a store there reaching a call unread is the object being filled for the
3662
3748
  // callee that is handed it.
3663
3749
  //
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.
3750
+ // THE FRAME BASE, ONLY WHERE ITS ADDRESS ESCAPES AND NO CALL TAKES A STACK WORD, OR AS A
3751
+ // DECLARED CALL'S RETURN STORAGE. A bare `mov rD, sp` is also the base of a by-value argument's
3752
+ // block copy into the outgoing area, and [sp,#0] is then a call's fifth argument word (the
3753
+ // producer table above `capturedObjectIsTheWholeFrame`, which keeps its own licence for a
3754
+ // one-word frame). Where every call's declaration places all of its arguments in registers — no
3755
+ // call at all included — there is no outgoing area on any compiler, so a frame base handed out
3756
+ // (`escapedOffsetOf`: to a call, or stored as a word to memory) is a local's address whoever
3757
+ // receives it: a struct-return temp (`mov r0, sp / bl mk`), or a DMA fill's source published to
3758
+ // the device's registers (`dmafill_wh`). A direct `str rX, [sp]` is then that local's store, as
3759
+ // one at [sp,#4] beside `add rD, sp, #4` is. A frame base only ADDRESSED THROUGH (`mov r3, sp /
3760
+ // strh r1, [r3, #4]`, Thumb's sole spelling of a sub-word frame access) hands nothing out and
3761
+ // leaves [sp,#0] a slot. A call nothing declares, a call through a register, or a declaration
3762
+ // nothing sizes may take a stack word, and keeps offset 0 out — unless the base is a
3763
+ // struct-return callee's hidden pointer and `compilerBehaviors.escapedFrameAddressNamesALocal`
3764
+ // holds (its second reader).
3765
+ //
3766
+ // THUMB ONLY: the MIPS and PPC frontends (`inLocalArea`) route every local-area access through
3767
+ // memory once any frame address is taken, so a direct store at the frame base is already the
3768
+ // object's there.
3769
+ const frameBaseEscapes = [...escapedOffsetOf.values()].includes(0);
3673
3770
  const noCallTakesAStackWord = [...entryReachable].every((b) =>
3674
3771
  asmBlocks[b].instrs.every((ins) => {
3675
3772
  if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3676
3773
  return true;
3677
3774
  }
3678
3775
  const declared = calleeRegister(ins, target) === null ? calls.declaredCall(ins.ops[0] ?? '?') : null;
3679
- return declared !== null && outgoingBlock(declared.widths, target) === null;
3776
+ // the declaration alone, not `outgoingBlock`: a call whose arguments run past the registers
3777
+ // takes a stack word whether or not the target claims agbcc's staging premise
3778
+ return declared !== null && wordsOf(declared.widths, target) <= target.argRegs.length;
3680
3779
  }),
3681
3780
  );
3682
3781
 
@@ -3738,12 +3837,67 @@ function measureThumbFrame({
3738
3837
  // `ownLeadingWordRegisters`): a `u32 k` spilled on entry and read back as `(u8)k` is no struct's
3739
3838
  // home, and lifts as the slot narrowed. A copy kept here is no capture, so its offset leaves
3740
3839
  // `constantCaptureOffsets` and the slot's own `str`/`ldr` stay slot accesses; one dropped stays a
3741
- // capture.
3840
+ // capture. A home is held for the one-object answer alone, so the audit may hand it back: where
3841
+ // that answer is withheld (a struct-return temp escapes beside the home) and the audit declines
3842
+ // with the home held, the second lift reads it as any other slot (`releasableHomes`,
3843
+ // `homesReleased`). The hold decides how the LIFT reads a home's reloads, not whether the word
3844
+ // is reloaded: the stack-argument analysis takes a held home's narrow reload as that word's
3845
+ // reload (`capturedReloadOf`) in every lift, as it takes a narrow reload inside the bytes a
3846
+ // first audit asked to keep as one object, which that relift keeps as a capture too. Read as no
3847
+ // reload, a home at the frame's bottom would decline as a possible outgoing argument, and agbcc
3848
+ // compiles that shape: reload spills a DImode struct's registers above the locals, but
3849
+ // `assign_parms` stores a BLKmode one (over 8 bytes) at the frame's bottom, below any temp
3850
+ // (`str r0, [sp] / … / add r0, sp, #0xc / bl mk0 / … / mov r0, sp / ldrb r0, [r0]`). Below an
3851
+ // escaping temp the first lift would decline before any audit could release the home; with a
3852
+ // call and no temp (`g(); return p.m0 + p.m8;`) the one-object relift would.
3853
+ //
3854
+ // A COPY THAT OUTLIVES ITS LOAD. Where reload hands the load another register (`add r3, sp, #0x28
3855
+ // / ldrb r1, [r3]`, the left operand of a `?:` join whose hard register is r1), rD still holds the
3856
+ // address after it. The copy is not lifted, so rD must be DEAD there: no path from the load reads
3857
+ // it before writing it (`readsBeforeWrite`), a return that may hand it back counting as a read.
3858
+ // The lift holds to that proof: the dropped copy leaves a marker in rD (`writeNarrowCopyMarker`),
3859
+ // and a read of it declines. A CALL READS WHAT IT IS HANDED, the one receiver rule
3860
+ // (`frameAddressReceivers`: a declared callee's argument words, every argument register where
3861
+ // nothing declares it), plus the register it calls through (`calleeRegister`). Any other register
3862
+ // it clobbers, r0 among them, is dead past it, and a callee-saved one still holds the copy. Read
3863
+ // as `readsBeforeWrite` reads a call for the save analysis (every register but r12), kleod
3864
+ // sub_08016EEC keeps two copies live, in r3 and in r6.
3865
+ //
3866
+ // A RETURN HANDS BACK WHAT THE OWN DECLARATION RETURNS. Where a STATED entry declares a void
3867
+ // return, a return reads neither r0 nor r1 (pipeline.ts `dropVoidReturnOperands`, the exemption
3868
+ // `refuseStaleIdiomReads` makes for the marker), and where the own declaration proves a return no
3869
+ // wider than a word, it reads r0 alone: `add r1, sp, #4 / ldrb r0, [r1] / … / pop {r0} / bx r0` in
3870
+ // a `void f(u32)` leaves no read of r1. Undeclared, a return may hand back the pair r0:r1. The void
3871
+ // reading of r0 is the stated entry's alone because the drop is: under the context's `void f(u32)`
3872
+ // the lifted `ret` still reads r0, and an r0 copy dropped there would print as `return uninit_r0`.
3873
+ // The reading of r1 needs no such care, under a void or a one-word declaration from either source,
3874
+ // as the lifted `ret` reads r1 only through a pair the asm builds (`wideReturn`), where the marker
3875
+ // declines.
3876
+ const narrowCallReads = (call: Instr): readonly string[] => {
3877
+ const through = calleeRegister(call, target);
3878
+ return typeof through === 'string' ? [...handedOutRegs(call), through] : handedOutRegs(call);
3879
+ };
3880
+ // `callClobbers` leaves out r0, which the lift writes with what the call returns
3881
+ const narrowCallClobbers = [...calls.callClobbers, target.returnReg];
3882
+ const ownProven = own?.proves === true ? own.proto : undefined;
3883
+ const returnMayHand = (r: string): boolean =>
3884
+ !dropsVoidReturn &&
3885
+ !(
3886
+ r !== target.returnReg &&
3887
+ (declaresVoidReturn(ownProven) || (ownReturnWidth(ownProven) ?? Number.POSITIVE_INFINITY) <= 32)
3888
+ );
3742
3889
  const narrowCandidates = narrowSlotReloads(
3743
3890
  asmBlocks,
3744
3891
  entryReachable,
3745
3892
  localArea,
3746
3893
  target.capabilities.endianness === 'little',
3894
+ (b, from, r) => {
3895
+ const use = readsBeforeWrite(asmBlocks, succs, b, from, r, {
3896
+ reads: narrowCallReads,
3897
+ clobbers: narrowCallClobbers,
3898
+ });
3899
+ return use === false || (use === 'returned' && !returnMayHand(r));
3900
+ },
3747
3901
  );
3748
3902
  const candidateCopies = new Set([...narrowCandidates.values()].map((n) => n.copy));
3749
3903
  const otherCaptureOffsets = new Set(
@@ -3783,15 +3937,21 @@ function measureThumbFrame({
3783
3937
  }
3784
3938
  }
3785
3939
  const narrowReloadOf = new Map<Instr, number>();
3940
+ const releasableHomes = new Set<number>();
3941
+ // the narrow reloads the lift keeps as captures (a held home's, and every one inside the bytes
3942
+ // a first audit asked to keep as one object), which are still reloads of that word for the
3943
+ // outgoing-argument analysis below
3944
+ const capturedReloadOf = new Map<Instr, number>();
3786
3945
  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);
3946
+ if (oneObject !== undefined && slot < oneObject.to && slot + 4 > oneObject.from) {
3947
+ capturedReloadOf.set(load, slot);
3948
+ } else if (wordStoredSlots.has(slot) && !otherCaptureOffsets.has(slot) && !inByValueBlock(slot, 4)) {
3949
+ if (argumentHomes.has(slot) && !homesReleased) {
3950
+ releasableHomes.add(slot);
3951
+ capturedReloadOf.set(load, slot);
3952
+ } else {
3953
+ narrowReloadOf.set(load, slot);
3954
+ }
3795
3955
  }
3796
3956
  }
3797
3957
  const narrowReloadCopies: ReadonlySet<Instr> = new Set(
@@ -3801,7 +3961,13 @@ function measureThumbFrame({
3801
3961
  [...captureOffsetOf]
3802
3962
  .filter(([ins]) => !narrowReloadCopies.has(ins))
3803
3963
  .map(([, o]) => o)
3804
- .filter((o) => o > 0 || (o === 0 && frameBaseIsReturnStorage && noCallTakesAStackWord)),
3964
+ .filter(
3965
+ (o) =>
3966
+ o > 0 ||
3967
+ (o === 0 &&
3968
+ ((noCallTakesAStackWord && frameBaseEscapes) ||
3969
+ (frameBaseIsReturnStorage && target.compilerBehaviors.escapedFrameAddressNamesALocal === true))),
3970
+ ),
3805
3971
  );
3806
3972
 
3807
3973
  // THE OUTGOING STACK-ARGUMENT AREA. `frontend/stackargs.ts` holds the licence and every refusal;
@@ -3825,14 +3991,18 @@ function measureThumbFrame({
3825
3991
  const off = escapedOffsetOf.get(ins);
3826
3992
  return escapesHere && off !== undefined ? [{ kind: 'escape', off }] : [];
3827
3993
  };
3994
+ const stagesOutgoing = target.compilerBehaviors.stagesOutgoingArgsInFrame === true;
3828
3995
  const eventBlocks = asmBlocks.map((ab) => ({
3829
3996
  events: ab.instrs.flatMap((ins): StackArgsEvent<Instr>[] => {
3830
3997
  const escaped = escapeEvent(ins);
3831
3998
  if (escaped.length > 0 && ins.mnemonic === 'str') {
3832
3999
  return escaped;
3833
4000
  }
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;
4001
+ // …and a narrow reload of a word slot is that slot's reload, as its word reload would be,
4002
+ // one the lift keeps as a capture included (a held argument home's, or one inside the
4003
+ // one-object bytes): that decides how the lift reads the load, not whether the word is
4004
+ // loaded, and a word this function loads is no outgoing argument
4005
+ const off = slotAcc(ins) ?? narrowReloadOf.get(ins) ?? capturedReloadOf.get(ins) ?? null;
3836
4006
  if (off !== null && !constantCaptureOffsets.has(off) && !inByValueBlock(off, 4)) {
3837
4007
  return [{ kind: /^str/.test(ins.mnemonic) ? 'store' : 'load', off }];
3838
4008
  }
@@ -3847,6 +4017,10 @@ function measureThumbFrame({
3847
4017
  callee,
3848
4018
  declared: declared === null ? null : outgoingBlock(declared.widths, target),
3849
4019
  params: declared?.params,
4020
+ // where nothing declares a named call, what its callee's body loads of the stack words
4021
+ ...(declared === null && calleeRegister(ins, target) === null && stagesOutgoing
4022
+ ? { bodyReads: () => stackWordsOf?.(callee) }
4023
+ : {}),
3850
4024
  },
3851
4025
  ];
3852
4026
  }
@@ -4021,6 +4195,7 @@ function measureThumbFrame({
4021
4195
  isOwnFrameWordSlot,
4022
4196
  narrowReloadOf,
4023
4197
  narrowReloadCopies,
4198
+ releasableHomes,
4024
4199
  };
4025
4200
  }
4026
4201
 
@@ -4138,10 +4313,11 @@ function frameAddressDefined(ins: Instr, held: ReadonlyMap<string, number>): [st
4138
4313
  //
4139
4314
  // What this finds is a shape and not yet a licence; the copy is a capture everywhere it fails:
4140
4315
  // * 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.
4316
+ // by exactly one instruction, a zero-extending `ldrb`/`ldrh` at an immediate offset from rD,
4317
+ // after which the address is dead: the load writes rD itself, or it writes another register
4318
+ // and the caller's `diesAfter` proves no path from it reads rD before writing it. Anything else
4319
+ // that mentions rD before the load (a second load, a store through it, an escape), a call, a
4320
+ // control transfer or a write of sp ends the search, and the copy stays a capture.
4145
4321
  // * the load's bytes are the low-order part of a whole aligned word of the reserved local area.
4146
4322
  // A load at any other byte of the word is not that word's value narrowed, whatever it reads.
4147
4323
  // * `ldrsb`/`ldrsh` exist only register-offset in Thumb-1, and agbcc's signed use reloads a word
@@ -4157,6 +4333,7 @@ function narrowSlotReloads(
4157
4333
  entryReachable: ReadonlySet<number>,
4158
4334
  localArea: number,
4159
4335
  littleEndian: boolean,
4336
+ diesAfter: (b: number, from: number, r: string) => boolean,
4160
4337
  ): Map<Instr, { copy: Instr; slot: number }> {
4161
4338
  const found = new Map<Instr, { copy: Instr; slot: number }>();
4162
4339
  for (const b of entryReachable) {
@@ -4172,7 +4349,8 @@ function narrowSlotReloads(
4172
4349
  return;
4173
4350
  }
4174
4351
  const rD = reg(d);
4175
- for (const ins of instrs.slice(at + 1)) {
4352
+ for (let li = at + 1; li < instrs.length; li++) {
4353
+ const ins = instrs[li];
4176
4354
  if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx' || classifyXfer(ins) !== null || modifiesSp(ins)) {
4177
4355
  return;
4178
4356
  }
@@ -4181,7 +4359,10 @@ function narrowSlotReloads(
4181
4359
  }
4182
4360
  const width = ins.mnemonic === 'ldrb' ? 1 : ins.mnemonic === 'ldrh' ? 2 : 0;
4183
4361
  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) {
4362
+ if (width === 0 || addr === null || reg(addr.base) !== rD || addr.regOff) {
4363
+ return;
4364
+ }
4365
+ if (reg(ins.ops[0] ?? '') !== rD && !diesAfter(b, li + 1, rD)) {
4185
4366
  return;
4186
4367
  }
4187
4368
  const slot = from + addr.off - (littleEndian ? 0 : 4 - width);
@@ -4638,7 +4819,14 @@ const ALL_REGS = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8', 'r9', 'r
4638
4819
  function bodySavedRegs(
4639
4820
  blocks: readonly AsmBlock[],
4640
4821
  preds: readonly (readonly number[])[],
4641
- ): { saved: ReadonlySet<string>; misSaved: ReadonlySet<string>; leaked: ReadonlyMap<string, Instr> } {
4822
+ succs: readonly (readonly number[])[],
4823
+ ): {
4824
+ saved: ReadonlySet<string>;
4825
+ misSaved: ReadonlySet<string>;
4826
+ leaked: ReadonlyMap<string, Instr>;
4827
+ copied: ReadonlyMap<string, Instr>;
4828
+ returned: ReadonlyMap<string, Instr>;
4829
+ } {
4642
4830
  // What each instruction saves, restores and otherwise names; the staging `mov`s first.
4643
4831
  const savesHi = new Map<Instr, string>(); // `mov rLow, rHi` ⇒ rHi, where rLow is pushed unchanged
4644
4832
  const restoresHi = new Set<Instr>(); // `mov rHi, rLow` where rLow was popped unchanged
@@ -4711,6 +4899,8 @@ function bodySavedRegs(
4711
4899
  i === 0 ? { cur: new Set(), open: new Set(), depth: 0, stack: [], clobbered: new Map(), dirty: new Set() } : null,
4712
4900
  );
4713
4901
  const leaked = new Map<string, Instr>();
4902
+ const copied = new Map<string, Instr>();
4903
+ const returned = new Map<string, Instr>();
4714
4904
  const word = (r: string) => canonReg(r) ?? r;
4715
4905
  const walk = (b: number, record: boolean): State => {
4716
4906
  const cur = new Set(stateIn[b]!.cur);
@@ -4721,7 +4911,7 @@ function bodySavedRegs(
4721
4911
  let stack: (string | null)[] | null = stateIn[b]!.stack === null ? null : [...stateIn[b]!.stack!];
4722
4912
  const holds = new Map<string, string>(); // low register ← the high one a save's staging `mov` copied
4723
4913
  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) {
4914
+ for (const [i, x] of blocks[b].instrs.entries()) {
4725
4915
  const before = frame.depthNow();
4726
4916
  frame.step(x);
4727
4917
  const after = frame.depthNow();
@@ -4815,6 +5005,13 @@ function bodySavedRegs(
4815
5005
  cur.delete(r);
4816
5006
  if (/^r([4-9]|1[01])$/.test(word(r))) {
4817
5007
  clobbered.set(word(r), x);
5008
+ } else if (record && own !== null && /^r([0-3]|12)$/.test(word(r))) {
5009
+ // a scratch register: the fill never makes the copy, so a later use of it is refused
5010
+ const use = readsBeforeWrite(blocks, succs, b, i + 1, word(r));
5011
+ const into = use === 'read' ? copied : use === 'returned' ? returned : undefined;
5012
+ if (into !== undefined && !into.has(word(r))) {
5013
+ into.set(word(r), x);
5014
+ }
4818
5015
  }
4819
5016
  });
4820
5017
  // Only a return the frame is balanced at: one with the `push` still on the stack is an
@@ -4871,9 +5068,79 @@ function bodySavedRegs(
4871
5068
  saved: new Set([...savedSomewhere].filter((r) => !mis(r))),
4872
5069
  misSaved: new Set([...savedSomewhere, ...restoredSomewhere].filter(mis)),
4873
5070
  leaked,
5071
+ copied,
5072
+ returned,
4874
5073
  };
4875
5074
  }
4876
5075
 
5076
+ /** Whether some path from instruction `from` of block `b` uses what scratch register `reg` holds
5077
+ * there as a VALUE before writing it: 'read' for an operand that reads it (a `push` of it among
5078
+ * them) or a call (which may be handed r0-r3), else 'returned' where a return may hand it back (r0
5079
+ * or r1), which is a use only in a function not declared to return nothing. A `bx` through it is
5080
+ * the return idiom `pop {r1}; bx r1` (agbcc's `pop {r3}; add sp, #0x10; bx r3` after a variadic
5081
+ * save), which uses the word as where to go; a write that reads no `reg` ends the path. Any
5082
+ * spelling of the register counts (`ip` is r12).
5083
+ *
5084
+ * `calls`, where a caller passes it, reads a `bl`/`blx` by what it is handed instead: a call that
5085
+ * names `reg` or whose `reads` include it reads it, one that clobbers it (`clobbers`) ends the
5086
+ * path, and past one that does neither the callee preserved it and the walk goes on. A `swi` is
5087
+ * read as without it. */
5088
+ function readsBeforeWrite(
5089
+ blocks: readonly AsmBlock[],
5090
+ succs: readonly (readonly number[])[],
5091
+ b: number,
5092
+ from: number,
5093
+ reg: string,
5094
+ calls?: { readonly reads: (call: Instr) => readonly string[]; readonly clobbers: readonly string[] },
5095
+ ): 'read' | 'returned' | false {
5096
+ const names = (x: Instr): boolean =>
5097
+ expandRegList(
5098
+ x.ops
5099
+ .join(' ')
5100
+ .toLowerCase()
5101
+ .replace(/[[\]{}!#]/g, ' ')
5102
+ .split(/[,\s]+/)
5103
+ .filter(Boolean),
5104
+ ).some((t) => canonReg(t) === reg);
5105
+ const seen = new Set<number>();
5106
+ let returned = false;
5107
+ // true where this path reads it; a path that returns it notes so and reads nothing
5108
+ const path = (at: number, start: number): boolean => {
5109
+ for (const x of blocks[at].instrs.slice(start)) {
5110
+ const mn = x.mnemonic;
5111
+ if (classifyXfer(x) === 'return') {
5112
+ returned ||= !(mn === 'bx' && canonReg(x.ops[0] ?? '') === reg) && (reg === 'r0' || reg === 'r1');
5113
+ return false;
5114
+ }
5115
+ if (calls !== undefined && (mn === 'bl' || mn === 'blx')) {
5116
+ if (names(x) || calls.reads(x).some((r) => canonReg(r) === reg)) {
5117
+ return true;
5118
+ }
5119
+ if (calls.clobbers.some((r) => canonReg(r) === reg)) {
5120
+ return false;
5121
+ }
5122
+ continue;
5123
+ }
5124
+ if (/^(bl|blx|swi)$/.test(mn)) {
5125
+ return reg !== 'r12';
5126
+ }
5127
+ if (names(x)) {
5128
+ const dest = canonReg(x.ops[0] ?? '');
5129
+ // Thumb's three-operand forms (`add rD, rN, #i`, `add rD, sp, #k`, `lsl rD, rM, #s`, `sub rD,
5130
+ // rN, rM`) read only their sources; the two-operand ones (`add rD, #i`, `mul rD, rM`) read rD.
5131
+ const writesOnly =
5132
+ mn === 'pop' ||
5133
+ ((DEST_ONLY.test(mn) || SINGLE_LOAD.test(mn) || (WRITES_OPERAND_0.test(mn) && x.ops.length === 3)) &&
5134
+ dest === reg &&
5135
+ !names({ ...x, ops: x.ops.slice(1) }));
5136
+ return !writesOnly;
5137
+ }
5138
+ }
5139
+ return succs[at].some((s) => !seen.has(s) && (seen.add(s), path(s, 0)));
5140
+ };
5141
+ return path(b, from) ? 'read' : returned ? 'returned' : false;
5142
+ }
5143
+
4877
5144
  /** How the fill reads and writes an operand: the guards that keep sp, pc and a data label from
4878
5145
  * being read or written as a register, and the function-scope statics a pool word names. */
4879
5146
  interface ThumbOperands {
@@ -5361,6 +5628,13 @@ interface ThumbFill {
5361
5628
  /** Each callee-saved register a `pop` loads another word into and that reaches a balanced return
5362
5629
  * so (`bodySavedRegs`'s `leaked`), with that pop: refused once the return proof has run. */
5363
5630
  readonly poppedForeign: ReadonlyMap<string, Instr>;
5631
+ /** Each caller-saved register a `pop` loads another word into and some path then reads as a value
5632
+ * before writing it (`bodySavedRegs`'s `copied`, `readsBeforeWrite`), with that pop: refused once
5633
+ * the return proof has run. */
5634
+ readonly poppedCopies: ReadonlyMap<string, Instr>;
5635
+ /** …and each one a return may hand back so (`bodySavedRegs`'s `returned`): refused unless the
5636
+ * function's own declaration returns nothing. */
5637
+ readonly poppedReturns: ReadonlyMap<string, Instr>;
5364
5638
  }
5365
5639
 
5366
5640
  /** EVERY STAGE BEFORE THE FILL, in their refusal order: the text, the CFG, the target's scratch
@@ -5378,7 +5652,21 @@ function thumbFillOf(
5378
5652
  contextOwnWords,
5379
5653
  contextOwnProto,
5380
5654
  ownMemoryReturn,
5381
- }: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto' | 'ownMemoryReturn'> = {},
5655
+ calleeBodyBound,
5656
+ calleeStackWords,
5657
+ stackSized,
5658
+ entryReadCalls,
5659
+ }: Pick<
5660
+ LiftOptions,
5661
+ | 'contextOwnWords'
5662
+ | 'contextOwnProto'
5663
+ | 'ownMemoryReturn'
5664
+ | 'calleeBodyBound'
5665
+ | 'calleeStackWords'
5666
+ | 'stackSized'
5667
+ | 'entryReadCalls'
5668
+ > = {},
5669
+ homesReleased = false,
5382
5670
  ): ThumbFill {
5383
5671
  const text = readThumbText(name, asm, target.compilerBehaviors.farJumpRelay?.comment);
5384
5672
  const cfg = thumbCfg(name, text.blocks, text.tables);
@@ -5392,6 +5680,9 @@ function thumbFillOf(
5392
5680
  throw new FrontendUnsupportedError(message);
5393
5681
  },
5394
5682
  () => ownDeclarationOf(name, prototypes, contextOwnProto),
5683
+ calleeBodyBound,
5684
+ entryReadCalls,
5685
+ stackSized,
5395
5686
  );
5396
5687
  const frame = measureThumbFrame({
5397
5688
  name,
@@ -5399,7 +5690,13 @@ function thumbFillOf(
5399
5690
  cfg,
5400
5691
  calls,
5401
5692
  oneObject,
5693
+ homesReleased,
5402
5694
  own: ownDeclarationOf(name, prototypes, contextOwnProto),
5695
+ dropsVoidReturn: declaresVoidReturn(prototypes[name]),
5696
+ stackWordsOf: (callee) =>
5697
+ asksCalleeBody(Object.hasOwn(prototypes, callee) ? prototypes[callee] : undefined)
5698
+ ? calleeStackWords?.(callee)
5699
+ : undefined,
5403
5700
  });
5404
5701
 
5405
5702
  // --- ISA-neutral SSA construction (shared Braun builder) ---
@@ -5416,7 +5713,7 @@ function thumbFillOf(
5416
5713
  // a two-parameter one.
5417
5714
  // A prologue save that some path uses past its restore, or carries to a return unrestored, is
5418
5715
  // `misSaved` and no save: the prologue's reading of it sees only the `push`.
5419
- const body = bodySavedRegs(cfg.asmBlocks, cfg.preds);
5716
+ const body = bodySavedRegs(cfg.asmBlocks, cfg.preds, cfg.succs);
5420
5717
  const liveIns = registerLiveIns(
5421
5718
  target,
5422
5719
  new Set([...frame.savedRegs, ...body.saved].filter((r) => !body.misSaved.has(r))),
@@ -5431,6 +5728,21 @@ function thumbFillOf(
5431
5728
  undefined,
5432
5729
  liveIns.globalRegs,
5433
5730
  );
5731
+ // Where agbcc put each block (ir/core.ts `AsmLayout`), read off the text the CFG was cut from:
5732
+ // the transfer `classifyXfer` names is the block's branch, and everything before it its body.
5733
+ ssa.fn.asmLayout = recordAsmLayout(
5734
+ ssa.irBlocks,
5735
+ cfg.asmBlocks.map((ab) => {
5736
+ const xfer = ab.instrs.length ? classifyXfer(ab.instrs[ab.instrs.length - 1]) : null;
5737
+ const branch = xfer === 'uncond' || xfer === 'cond';
5738
+ return {
5739
+ body: branch ? ab.instrs.slice(0, -1) : ab.instrs,
5740
+ endsInJump: xfer === 'uncond',
5741
+ // a far-jump relay wears `bl` and is a branch (`classifyXfer`)
5742
+ isCall: (ins) => (ins.mnemonic === 'bl' && !(ins as Instr).farJump) || ins.mnemonic === 'blx',
5743
+ };
5744
+ }),
5745
+ );
5434
5746
  const statics = makeLocalStatics();
5435
5747
  const operands = thumbOperands({
5436
5748
  name,
@@ -5471,6 +5783,8 @@ function thumbFillOf(
5471
5783
  staleIdiomValues: new Map(),
5472
5784
  caseEntries: elidedCaseEntries(cfg, text.tables),
5473
5785
  poppedForeign: body.leaked,
5786
+ poppedCopies: body.copied,
5787
+ poppedReturns: body.returned,
5474
5788
  };
5475
5789
  }
5476
5790
 
@@ -5999,6 +6313,7 @@ function lowerMov(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
5999
6313
  if (frame.slotsOk && frame.localArea > 0) {
6000
6314
  // a copy only a narrow slot reload reads names no object; the reload reads the slot
6001
6315
  if (frame.narrowReloadCopies.has(ins)) {
6316
+ writeNarrowCopyMarker(fill, cur, reg(a));
6002
6317
  return;
6003
6318
  }
6004
6319
  const res = mkValue(T.unk(32));
@@ -6012,6 +6327,18 @@ function lowerMov(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
6012
6327
  writeData(reg(a), bi, v);
6013
6328
  }
6014
6329
 
6330
+ /** What a narrow reload's unlifted copy of sp leaves in `r`: an address no C spells.
6331
+ * `measureThumbFrame` accepts the copy only where `r` is dead past its load, and the marker holds
6332
+ * the lift to that: a read of it declines (`refuseStaleIdiomReads`) rather than reading what `r`
6333
+ * held before the copy. */
6334
+ function writeNarrowCopyMarker(fill: ThumbFill, cur: BlockCursor, r: string): void {
6335
+ fill.operands.writeData(
6336
+ r,
6337
+ cur.bi,
6338
+ staleMarker(cur.irb.ops, r, { reg: r, form: 'narrow', parks: false }, fill.staleIdiomValues),
6339
+ );
6340
+ }
6341
+
6015
6342
  /** `add`: a frame adjustment, a 64-bit pair with the `adc` after it, a frame capture,
6016
6343
  * the copy idiom, or an add. */
6017
6344
  function lowerAdd(fill: ThumbFill, cur: BlockCursor, ins: Instr, ii: number): void {
@@ -6036,7 +6363,8 @@ function lowerAdd(fill: ThumbFill, cur: BlockCursor, ins: Instr, ii: number): vo
6036
6363
  if (isSpReg(b ?? '') && !isSpReg(a ?? '') && c !== undefined && IMM_LITERAL.test(c)) {
6037
6364
  if (frame.slotsOk && frame.localArea > 0) {
6038
6365
  if (frame.narrowReloadCopies.has(ins)) {
6039
- return; // see lowerMov
6366
+ writeNarrowCopyMarker(fill, cur, reg(a)); // see lowerMov
6367
+ return;
6040
6368
  }
6041
6369
  const res = mkValue(T.unk(32));
6042
6370
  irb.ops.push(mkOp('laddr', { results: [res], attrs: { off: imm(c) } }));
@@ -6287,7 +6615,16 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
6287
6615
  irb.ops.push(
6288
6616
  mkOp('gaddr', {
6289
6617
  results: [res],
6290
- attrs: { sym: si.name, ...(si.kind === 'code' ? { code: true } : {}) },
6618
+ // `fromMap`: the NAME is asmlift's reading of a numeric word, not the asm's own
6619
+ // spelling (raise/offsetnames.ts `cellSpelledTwice` asks which), and `mapAddr` the
6620
+ // word itself — the one address of a name the map places at several that this
6621
+ // spelling denotes. A macro expands to the numeric literal the asm holds, so it is
6622
+ // not marked.
6623
+ attrs: {
6624
+ sym: si.name,
6625
+ ...(si.kind === 'code' ? { code: true } : {}),
6626
+ ...(si.macroBody === undefined ? { fromMap: true, mapAddr: pr.value >>> 0 } : {}),
6627
+ },
6291
6628
  }),
6292
6629
  );
6293
6630
  writeData(reg(a), bi, res);
@@ -6304,7 +6641,12 @@ function lowerLoad(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
6304
6641
  const g = mkValue(T.unk(32));
6305
6642
  const k = mkValue(T.unk(32));
6306
6643
  const res = mkValue(T.unk(32));
6307
- irb.ops.push(mkOp('gaddr', { results: [g], attrs: { sym: interior.info.name } }));
6644
+ irb.ops.push(
6645
+ mkOp('gaddr', {
6646
+ results: [g],
6647
+ attrs: { sym: interior.info.name, fromMap: true, mapAddr: (pr.value - interior.offset) >>> 0 },
6648
+ }),
6649
+ );
6308
6650
  irb.ops.push(mkOp('const', { results: [k], attrs: { value: interior.offset } }));
6309
6651
  irb.ops.push(mkOp('add', { operands: [g, k], results: [res] }));
6310
6652
  writeData(reg(a), bi, res);
@@ -6541,6 +6883,8 @@ function lowerCall(fill: ThumbFill, cur: BlockCursor, ins: Instr): void {
6541
6883
  usedSlotOffsets.add(off);
6542
6884
  return ssa.readVar(stackSlotKey(off), bi);
6543
6885
  },
6886
+ // …and that block is the arity of a call nothing declares, where its callee's body licensed it
6887
+ stackWords: frame.slotsOk ? frame.outgoingArgs.blocks.get(ins)?.length : undefined,
6544
6888
  // A FRAME ADDRESS AN UNDECLARED ARITY LEAVES OUT IS AN ARGUMENT ALL THE SAME. The frame walks
6545
6889
  // read the call as handed every argument register it holds (`frameAddressReceivers`), and the
6546
6890
  // lift reads the object as written by that receiver (`isFrameObjectAccess`, with no reaching-
@@ -6735,7 +7079,12 @@ function fallLabel(asmBlocks: readonly AsmBlock[], name: string, bi: number): st
6735
7079
  }
6736
7080
 
6737
7081
  /** The ARMv4T / Thumb (agbcc) frontend, registered for the `armv4t` target. */
6738
- export const thumbFrontend: Frontend = { id: 'thumb', inputFormat: 'gnu-as', lift };
7082
+ export const thumbFrontend: Frontend = {
7083
+ id: 'thumb',
7084
+ inputFormat: 'gnu-as',
7085
+ lift,
7086
+ entryStackWords: thumbEntryWordsFromText,
7087
+ };
6739
7088
 
6740
7089
  /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
6741
7090
  * every source path under `./*`, so a plain `export` here would put the pad table, the pad predicate, the witness serialisation and the lift's stages into the