@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
@@ -8,6 +8,7 @@ import type { ContextGlobals } from '../proto-context';
8
8
  import type { SymbolMap } from '../symbols';
9
9
  import type { TargetDescription } from '../target';
10
10
  import type { AsmData } from './asmdata';
11
+ import type { CalleeBodyBound, CalleeStackWords, EntryStackWords } from './callee-body';
11
12
  import type { AsmTextFormat } from './format';
12
13
 
13
14
  export interface Frontend {
@@ -34,6 +35,10 @@ export interface Frontend {
34
35
  context?: ContextGlobals,
35
36
  options?: LiftOptions,
36
37
  ): Fn;
38
+ /** how many incoming stack words the straight-line entry of a callee, in a text of this
39
+ * frontend's input format, loads (frontend/callee-body.ts `CalleeStackWords`), asked of a callee
40
+ * whose body does not lift; absent where this frontend reads no entry */
41
+ entryStackWords?: EntryStackWords;
37
42
  }
38
43
 
39
44
  /** THE FUNCTION'S OWN DECLARATION, as a call lowering reads it (frontend/call-plan.ts
@@ -90,4 +95,19 @@ export interface LiftOptions {
90
95
  /** Called when the lift met such a slot, whichever way it answered: what rank.ts asks before
91
96
  * enumerating `/kept-slot` at all. */
92
97
  onTailSlot?: () => void;
98
+ /** The argument words a callee's own body in the same input can read at most
99
+ * (frontend/callee-body.ts), asked of a call whose arity no declaration states. pipeline.ts
100
+ * `liftStamped` supplies it on every lift; absent, nothing is bounded. */
101
+ calleeBodyBound?: CalleeBodyBound;
102
+ /** The incoming stack words a callee's own body, in the input or handed in, is proven to load
103
+ * (frontend/callee-body.ts `CalleeStackWords`), asked of a call no declaration lays out that
104
+ * stages words in the outgoing area. pipeline.ts `liftStamped` supplies it on every lift. */
105
+ calleeStackWords?: CalleeStackWords;
106
+ /** Told of each call nothing declares that the lift laid out at every argument register and the
107
+ * stack words its callee's body loads (`calleeStackWords`), with that word count. */
108
+ stackSized?: (callee: string, words: number) => void;
109
+ /** The callees whose calls are taken at every argument register that may still hold this
110
+ * function's own incoming value at the call, where no declaration lays the call out: how
111
+ * frontend/callee-body.ts lifts a callee's body to bound what it reads. Absent, none is. */
112
+ entryReadCalls?: ReadonlySet<string>;
93
113
  }
@@ -215,11 +215,12 @@ export interface ElidedDispatch {
215
215
 
216
216
  /** A MARKER ({@link staleMarker}): what the idiom left in `reg` that no C expression spells, and
217
217
  * whether the dispatch that left it was kept (its block lifted, holding the code the compiler
218
- * placed in it) or elided. A read of one declines ({@link refuseStaleIdiomReads}). */
218
+ * placed in it) or elided — or, `narrow`, the copy of sp a Thumb narrow slot reload read through,
219
+ * which is not lifted either. A read of one declines ({@link refuseStaleIdiomReads}). */
219
220
  export interface StaleMarker {
220
221
  kind: 'marker';
221
222
  reg: string;
222
- form: 'kept' | 'elided';
223
+ form: 'kept' | 'elided' | 'narrow';
223
224
  /** Whether the marker stands for the register on EVERY path that brings it ({@link parkedIn}): a
224
225
  * kept dispatch's, and an EXACT target's. A MIXED target's marker also replaced what the other
225
226
  * paths into it brought, so it is read like any value, and its read declines. */
@@ -388,7 +389,10 @@ export const scaledIndexIn =
388
389
  * address, none of which a C expression spells. Run on the filled SSA, before `finish` collapses a
389
390
  * join of an undefined value into its other argument: a marker some operand demands, directly or
390
391
  * through the joins that carry it, declines. A `ret` operand demands nothing under a declared
391
- * `void` return, which drops it (pipeline.ts `dropVoidReturnOperands`). A MIXED target's scaled
392
+ * `void` return, which drops it (pipeline.ts `dropVoidReturnOperands`). A narrow reload's marker
393
+ * (form `narrow`) is held to the drop itself, `dropsVoidReturn`: pipeline.ts drops the operand only
394
+ * for a STATED void entry, and under the context's own `void` alone the lifted `ret` still reads
395
+ * the register, so the marker would print as `return uninit_r0`. A MIXED target's scaled
392
396
  * index is settled first ({@link unsettledScaledIndices}): one that does not stand is a marker,
393
397
  * read or not. `fail` is the frontend's own refusal. Last, `calls` refuses a guessed arity's
394
398
  * trailing pass-through where the function lifts only through this modelling: a parked ceiling that
@@ -402,6 +406,7 @@ export function refuseStaleIdiomReads(
402
406
  own: FnProto | undefined,
403
407
  fail: FrontendRefusal,
404
408
  calls: Pick<CallDeclarations, 'refuseDispatchUnlockedPassThrough'>,
409
+ dropsVoidReturn: boolean = declaresVoidReturn(own),
405
410
  ): void {
406
411
  if (stale.size === 0) {
407
412
  return;
@@ -418,32 +423,42 @@ export function refuseStaleIdiomReads(
418
423
  }
419
424
  }
420
425
  const unsettled = unsettledScaledIndices(irBlocks, stale, incoming, ssa);
421
- const demanded = new Set<Value>();
422
- const work: Value[] = [];
423
- const demand = (v: Value) => {
424
- if (!demanded.has(v)) {
425
- demanded.add(v);
426
- work.push(v);
426
+ // what the ops demand, a void return's operand aside; then what that operand alone demands
427
+ const demandedBy = (roots: (op: Op) => boolean): Set<Value> => {
428
+ const demanded = new Set<Value>();
429
+ const work: Value[] = [];
430
+ const demand = (v: Value) => {
431
+ if (!demanded.has(v)) {
432
+ demanded.add(v);
433
+ work.push(v);
434
+ }
435
+ };
436
+ irBlocks.forEach((blk) => blk.ops.filter(roots).forEach((op) => op.operands.forEach(demand)));
437
+ while (work.length > 0) {
438
+ (incoming.get(work.pop()!) ?? []).forEach(demand);
427
439
  }
440
+ return demanded;
428
441
  };
429
442
  const voidReturn = declaresVoidReturn(own);
430
- for (const blk of irBlocks) {
431
- for (const op of blk.ops) {
432
- if (!(op.opcode === 'ret' && voidReturn)) {
433
- op.operands.forEach(demand);
434
- }
435
- }
436
- }
437
- while (work.length > 0) {
438
- (incoming.get(work.pop()!) ?? []).forEach(demand);
439
- }
443
+ const demanded = demandedBy((op) => !(op.opcode === 'ret' && voidReturn));
444
+ const keptRet =
445
+ voidReturn && !dropsVoidReturn && [...stale.values()].some((s) => s.kind === 'marker' && s.form === 'narrow')
446
+ ? demandedBy((op) => op.opcode === 'ret')
447
+ : new Set<Value>();
440
448
  const read = [...stale].flatMap(([v, s]) =>
441
- demanded.has(v) && (s.kind === 'marker' || unsettled.has(v))
449
+ (demanded.has(v) || (s.kind === 'marker' && s.form === 'narrow' && keptRet.has(v))) &&
450
+ (s.kind === 'marker' || unsettled.has(v))
442
451
  ? [s.kind === 'marker' ? s : { reg: s.reg, form: 'elided' as const }]
443
452
  : [],
444
453
  )[0];
445
454
  if (read !== undefined) {
446
455
  const { reg, form } = read;
456
+ if (form === 'narrow') {
457
+ fail(
458
+ `cannot lift '${name}': address-taken stack local — ${reg} is read past the narrow slot reload that read ` +
459
+ 'through it, and the lift does not model the copy of sp it holds there',
460
+ );
461
+ }
447
462
  const why =
448
463
  "which the unlifted dispatch idiom wrote — the lift would read the value from before the dispatch, not the table's " +
449
464
  'index, address or target';
@@ -28,8 +28,9 @@ import { type Prototypes, declaresVoidReturn, ownReturnWidth, wordsOf } from '..
28
28
  import type { ContextGlobals } from '../proto-context';
29
29
  import type { SymbolMap } from '../symbols';
30
30
  import { O32_REGISTER_ALIASES, type TargetDescription } from '../target';
31
- import { type AsmData, readJumpTable, textRelocAt } from './asmdata';
31
+ import { type AsmData, readJumpTable, readU32, textRelocAt } from './asmdata';
32
32
  import { callDeclarations, foldByValueArgs } from './call-plan';
33
+ import { asksCalleeBody } from './callee-body';
33
34
  import { pinDeadReads } from './dead-reads';
34
35
  import { pinDeviceAccesses } from './device-pins';
35
36
  import {
@@ -61,6 +62,7 @@ import {
61
62
  type FrameObjectRelift,
62
63
  type FrameRange,
63
64
  areaAboveOutgoing,
65
+ keptInRelift,
64
66
  landsOnArea,
65
67
  liftWithRelift,
66
68
  nameMergedSretObjects,
@@ -94,7 +96,14 @@ import {
94
96
  stackArgSlots,
95
97
  stackSlotKey,
96
98
  } from './ssa';
97
- import { makeSsaBuilder, refuseSavedRegisterLeaks, registerLiveIns, unifyRegisterSpellings } from './ssa';
99
+ import {
100
+ makeSsaBuilder,
101
+ recordAsmLayout,
102
+ refuseSavedRegisterLeaks,
103
+ registerLiveIns,
104
+ unifyRegisterSpellings,
105
+ } from './ssa';
106
+ import { bodyBlockAgrees } from './stackargs';
98
107
 
99
108
  type Instr = DisasmInstr;
100
109
 
@@ -313,8 +322,31 @@ function attachMipsRelocs(name: string, instrs: Instr[], ad: AsmData, freed: Rea
313
322
  `0x${ins.addr.toString(16)}): '${ins.reloc.type} ${ins.reloc.sym}' and '${r.type} ${r.sym}'`,
314
323
  );
315
324
  }
316
- ins.reloc = { type: r.type, sym: r.sym, addend: 0 };
325
+ ins.reloc = {
326
+ type: r.type,
327
+ sym: ins.mnemonic === 'jal' ? (textCallee(r.sym, r.offset, ad) ?? r.sym) : r.sym,
328
+ addend: 0,
329
+ };
330
+ }
331
+ }
332
+
333
+ /** THE FUNCTION A `jal` RELOCATED AGAINST `.text` ITSELF ENTERS, by name. gcc 2.7.2 and KMC relocate a
334
+ * call to a function of the same object against the section, not the function's symbol (`jal 0
335
+ * <rnd>` over `R_MIPS_26 .text`), so the record names no callee. MIPS objects are REL: the jump's
336
+ * 26-bit field holds the addend, the target's offset in the section shifted right by two. The
337
+ * symbol table names the function at that offset. Undefined, and the call keeps its section
338
+ * callee, which refuses: where `.text`'s bytes or the word are not in the dump, or where no one
339
+ * name, or more than one, starts there. Only a `jal`'s: gcc 2.7.2 and KMC relocate a `j` against
340
+ * `.text` too, and a leaf loop whose head is the entry jumps to its own function's first word, so
341
+ * naming that `j` would read the loop's back edge as a tail call. */
342
+ function textCallee(sym: string, offset: number, ad: AsmData): string | undefined {
343
+ const text = ad.sections.get('.text');
344
+ if (sym !== '.text' || text === undefined || offset + 4 > text.length) {
345
+ return undefined;
317
346
  }
347
+ const at = (readU32(text, offset, ad.bigEndian) & 0x03ffffff) * 4;
348
+ const names = [...ad.symbols].filter(([n, s]) => s.section === '.text' && s.value === at && /^[A-Za-z_]\w*$/.test(n));
349
+ return names.length === 1 ? names[0][0] : undefined;
318
350
  }
319
351
 
320
352
  interface MipsBlock {
@@ -1628,6 +1660,16 @@ function liftOnce(
1628
1660
  liveIns.globalRegs,
1629
1661
  );
1630
1662
  const { irBlocks, readVar, writeVar, paramReg } = ssa;
1663
+ // Where the compiler put each block (ir/core.ts `AsmLayout`). A delay slot runs in its branch's
1664
+ // block, so it is part of the body; a branch-likely's slot is a block of its own already.
1665
+ ssa.fn.asmLayout = recordAsmLayout(
1666
+ irBlocks,
1667
+ blocks.map((b) => ({
1668
+ body: b.delay ? [...b.body, b.delay] : b.body,
1669
+ endsInJump: b.branch !== null && isUncond(b.branch),
1670
+ isCall: (ins) => isCallInstr(ins as Instr),
1671
+ })),
1672
+ );
1631
1673
  const RET = target.returnReg;
1632
1674
  // Whether this function's own declaration says its return fits one word (`v1MayHoldLowWord`):
1633
1675
  // void, or a scalar or pointer no wider than 32 bits. An aggregate says nothing here. A declared
@@ -1717,6 +1759,9 @@ function liftOnce(
1717
1759
  { pairs: false, memoryReturn: true, stackArgs: true, voidReturn: true, argRegisterBoundsArity: false },
1718
1760
  fail,
1719
1761
  () => ownDeclarationOf(name, prototypes, options.contextOwnProto),
1762
+ options.calleeBodyBound,
1763
+ options.entryReadCalls,
1764
+ options.stackSized,
1720
1765
  );
1721
1766
  // The first instruction naming the FPU's file, a data register or the control register. A call
1722
1767
  // in a function that has one refuses: which floating-point registers a callee reads, returns in
@@ -1767,6 +1812,63 @@ function liftOnce(
1767
1812
  // epilogue restore in any block.
1768
1813
  const saveSlots = new Map<number, string>();
1769
1814
  const valueSlots = new Set<number>();
1815
+ // WHETHER EACH OUTGOING-AREA WORD IS PENDING, per block, as lowered so far: a store to it
1816
+ // (`emitStore`) makes it pending, and a call that took it as a stack argument (`lowerJal`'s
1817
+ // `stackWord`) ends it, so it is pending for no later call until it is stored again. Kept per
1818
+ // STORE, not per value: a value one call took and that is stored again for a later call, at the
1819
+ // same word or another, is staged for that call all the same, and keyed by the value it would
1820
+ // hide a word the call is handed (frontend/stackargs.ts ends its pending words per offset too).
1821
+ const outgoingWordState: Map<number, 'stored' | 'taken'>[] = blocks.map(() => new Map());
1822
+ // Whether the state of word `off` some path brings into this point of block `b` is `want`: the
1823
+ // walk `ssa.hasReachingDef` makes, over this state, so a block not filled yet is passed through.
1824
+ // `here` is `b`'s state at this point (its state so far, by default); met again round a loop, `b`
1825
+ // is its state at its end, which a back edge brings.
1826
+ const outgoingWordReaches = (
1827
+ off: number,
1828
+ b: number,
1829
+ want: 'stored' | 'taken',
1830
+ here: ReadonlyMap<number, 'stored' | 'taken'> = outgoingWordState[b],
1831
+ ): boolean => {
1832
+ const seen = new Set<number>();
1833
+ const walk = (at: number, own: 'stored' | 'taken' | undefined): boolean => {
1834
+ if (own !== undefined) {
1835
+ return own === want;
1836
+ }
1837
+ if (seen.has(at)) {
1838
+ return false;
1839
+ }
1840
+ seen.add(at);
1841
+ return preds[at].some((p) => walk(p, outgoingWordState[p].get(off)));
1842
+ };
1843
+ return walk(b, here.get(off));
1844
+ };
1845
+ // EACH CALL ITS CALLEE'S BODY LICENSED, judged again once every block is filled: while a call is
1846
+ // lowered, a block not filled yet — a loop's latch, or the call's own block met round a back
1847
+ // edge — holds no state, so a word the call itself takes is not seen reaching its loop head and a
1848
+ // word staged once before the loop passes for every iteration's. Judged again on the finished
1849
+ // state (frontend/stackargs.ts reaches the same answer by its fixpoint), a word of the block some
1850
+ // path brings in taken, or a word past it some path brings in stored, refuses the lift.
1851
+ const bodyLicences = new Map<
1852
+ Instr,
1853
+ { bi: number; here: ReadonlyMap<number, 'stored' | 'taken'>; n: number; callee: string }
1854
+ >();
1855
+ // EACH CALL A DECLARATION SIZED, AND THE STACK WORDS IT TOOK, judged again the same way: its
1856
+ // arity is the declaration's, so a word past its block says nothing, but a word it takes that
1857
+ // some path brings in already taken by an earlier call — licensed or declared — is a word the
1858
+ // earlier callee owned and may have written, not the value stored there (frontend/stackargs.ts
1859
+ // ends a pending word at the call that takes it for either kind of call).
1860
+ const declaredTakes = new Map<
1861
+ Instr,
1862
+ { bi: number; here: ReadonlyMap<number, 'stored' | 'taken'>; offs: number[]; callee: string }
1863
+ >();
1864
+ const refuseTakenWord = (callee: string, addr: number, off: number): never => {
1865
+ throw new FrontendUnsupportedError(
1866
+ `cannot lift '${name}': outgoing stack arguments not modelled — the call to '${callee}' at ` +
1867
+ `0x${addr.toString(16)} takes the word at ${off}(sp) as a stack argument, but it reaches the call on ` +
1868
+ 'some path already taken by an earlier call and not stored again, so what the earlier callee left there ' +
1869
+ 'is not known',
1870
+ );
1871
+ };
1770
1872
  const restores = new Map<Value, { reg: string; addr: number }>();
1771
1873
  // Each `lw` that restores a save, and the register it restores: a save holds only while every path
1772
1874
  // restores it after its last write (`refuseSavedRegisterLeaks`).
@@ -1863,7 +1965,12 @@ function liftOnce(
1863
1965
  `local area, sp+${frame.area.from} up to sp+${frame.area.to} — part memory, part slot`,
1864
1966
  );
1865
1967
  }
1866
- return true;
1968
+ // …and in a relift, only the bytes the first audit asked to keep as one object are memory: a
1969
+ // word of the area outside them is one it handed back to the slot model (`keptInRelift`)
1970
+ return (
1971
+ oneObject === undefined ||
1972
+ keptInRelift(oneObject, off, width, () => `'${ins.mnemonic}' at 0x${ins.addr.toString(16)}`, name)
1973
+ );
1867
1974
  };
1868
1975
 
1869
1976
  // SOUNDNESS GUARD. The word stack-slot model (emitLoad/emitStore) is safe ONLY when every
@@ -1905,11 +2012,21 @@ function liftOnce(
1905
2012
  // must run at the frame's own depth, save the incoming stack words that are keyed `@sarg<k>`.
1906
2013
  // The caller's home `[frameSize, frameSize + 16)` is in no range, so a def-less read there still
1907
2014
  // refuses. Sub-word frame traffic turns the slot model off (`spSlotSafe`) and claims nothing.
2015
+ //
2016
+ // A RELIFT takes a frame address by construction, and claims the local area alone: there the
2017
+ // bytes kept as one object are memory and key no slot, and every word outside them is one the
2018
+ // first audit handed back (`reliftRange`) only where no escape is unbounded, no copy reaches it
2019
+ // and no index runs, so nothing but its own direct accesses reaches it. kmc homes `u32 r; if (i)
2020
+ // r = gA[9]; … if (i) gA[10] = r;` in such a word beside two copied arrays, and reads it on the
2021
+ // path that never stored it.
1908
2022
  let ownedMemo: { range: { from: number; to: number } | undefined } | undefined;
1909
2023
  const ownedFrame = (): { from: number; to: number } | undefined =>
1910
2024
  (ownedMemo ??= { range: measureOwnedFrame() }).range;
1911
2025
  const measureOwnedFrame = (): { from: number; to: number } | undefined => {
1912
- if (frameSize === undefined || depthAt === null || !spSlotSafe || code.some(takesFrameAddress)) {
2026
+ if (frameSize === undefined || depthAt === null || !spSlotSafe) {
2027
+ return undefined;
2028
+ }
2029
+ if (oneObject === undefined ? code.some(takesFrameAddress) : frame === null) {
1913
2030
  return undefined;
1914
2031
  }
1915
2032
  for (const ins of code) {
@@ -1935,7 +2052,9 @@ function liftOnce(
1935
2052
  }
1936
2053
  throw e;
1937
2054
  }
1938
- return outgoing.widest < frameSize ? { from: outgoing.widest, to: frameSize } : undefined;
2055
+ const from = Math.max(outgoing.widest, oneObject === undefined || frame === null ? 0 : frame.area.from);
2056
+ const to = oneObject === undefined || frame === null ? frameSize : Math.min(frameSize, frame.area.to);
2057
+ return from < to ? { from, to } : undefined;
1939
2058
  };
1940
2059
  ownedFrameOf = ownedFrame;
1941
2060
 
@@ -1950,6 +2069,8 @@ function liftOnce(
1950
2069
  // The frame offset each `laddr` this lift emitted names, and the deferred `sp + index` sums
1951
2070
  // (`emitLaddr`, `materialize`) — function-scoped, since a register holding one crosses blocks.
1952
2071
  const laddrAt = new Map<Value, number>();
2072
+ // …and of those, the ones a direct `off(sp)` access in the local area was lowered to, not a capture
2073
+ const directAccesses = new Set<Value>();
1953
2074
  const frameSums = new Map<Value, FrameSum>();
1954
2075
  // Every capture this lift moved into another (`addiu rD,rCapture,k` re-minted as the `laddr` of
1955
2076
  // the sum, or `addu` deferring one into a `FrameSum`), handed to the audit as Thumb hands its
@@ -2714,7 +2835,9 @@ function liftOnce(
2714
2835
  `cannot lift '${name}': address-taken stack local — '${ins.mnemonic}' at 0x${ins.addr.toString(16)} saves the caller's ${stored} inside the local area, a frame this does not describe`,
2715
2836
  );
2716
2837
  }
2717
- return emitLaddr(off);
2838
+ const at = emitLaddr(off);
2839
+ directAccesses.add(at);
2840
+ return at;
2718
2841
  }
2719
2842
  const raw = rawFrame(base);
2720
2843
  const sum = raw === undefined ? undefined : frameSums.get(raw);
@@ -2845,7 +2968,11 @@ function liftOnce(
2845
2968
  refuseMixedSlot(ins, mem, off, 'value');
2846
2969
  valueSlots.add(off);
2847
2970
  // an assignment to an incoming stack parameter is that parameter's SSA variable
2848
- writeVar(incomingStackKey(ins, off) ?? stackSlot(off), bi, read(srcReg));
2971
+ const incoming = incomingStackKey(ins, off);
2972
+ writeVar(incoming ?? stackSlot(off), bi, read(srcReg));
2973
+ if (incoming === undefined) {
2974
+ outgoingWordState[bi].set(off, 'stored');
2975
+ }
2849
2976
  return;
2850
2977
  }
2851
2978
  ops.push(mkOp('store', { operands: [read(base), read(srcReg)], attrs: { off, width } }));
@@ -2857,6 +2984,8 @@ function liftOnce(
2857
2984
  // argument k ≥ 4 lives, and the guess that cannot see past a3.
2858
2985
  const lowerJal = (ins: Instr) => {
2859
2986
  const at = `0x${ins.addr.toString(16)}`;
2987
+ // this block's outgoing words as the call is reached, before it takes any
2988
+ const wordsHere = new Map(outgoingWordState[bi]);
2860
2989
  const callee = jalCallee(ins, splat);
2861
2990
  if (callee?.startsWith('.')) {
2862
2991
  throw new FrontendUnsupportedError(
@@ -2883,12 +3012,64 @@ function liftOnce(
2883
3012
  'where its outgoing stack arguments end is not known',
2884
3013
  );
2885
3014
  }
3015
+ // A CALL NOTHING DECLARES TAKES THE WORDS ITS CALLEE'S BODY LOADS, where exactly those reach
3016
+ // it staged (frontend/stackargs.ts, "THE CALLEE'S BODY": the same rule, `bodyBlockAgrees`,
3017
+ // asked here of SSA's reaching definitions, which O32 has when its calls are lowered):
3018
+ // the body loads incoming word n-1, so the call takes at least n stack words, and a word past
3019
+ // them that nothing staged is no argument. Licensed only where the words `[16, 16+4n)` are
3020
+ // written on every path to the call beneath the loaded-word bound and inside the frame, and
3021
+ // no other word of the area there reaches it; asked only once a word does. KNOWN GAP: a
3022
+ // frame with a local area (`frame`) is not licensed, since that area is partitioned off the
3023
+ // DECLARED widest call before any call is lowered (`stackWordsOf` above), so such a call keeps
3024
+ // the refusals below.
3025
+ // A word an earlier call took as its stack argument is pending for no later one until it is
3026
+ // stored again (`outgoingWordState`), as frontend/stackargs.ts's dataflow ends it at the call
3027
+ // whose block held it. A block not filled yet holds no state, so each licence is judged again
3028
+ // once every block is (`bodyLicences`).
3029
+ const licensedStackWords = (): number | undefined => {
3030
+ if (frame !== null || !asksCalleeBody(Object.hasOwn(prototypes, callee) ? prototypes[callee] : undefined)) {
3031
+ return undefined;
3032
+ }
3033
+ const reaching = [...valueSlots].filter(
3034
+ (off) =>
3035
+ off >= STACK_ARGS_AT &&
3036
+ off < frameSize &&
3037
+ off < loadedWordBound &&
3038
+ ssa.hasReachingDef(stackSlot(off), bi) &&
3039
+ outgoingWordReaches(off, bi, 'stored', wordsHere),
3040
+ );
3041
+ if (reaching.length === 0) {
3042
+ return undefined;
3043
+ }
3044
+ // O32 can skip an argument register for an 8-aligned argument, so the plan's count — every
3045
+ // register, then the block — holds only where each register is one this call sets up: a
3046
+ // register still at its entry value on some path may be the skipped word, and taken as an
3047
+ // argument it is a parameter this function does not have. (The callee's half of the same
3048
+ // proof is frontend/callee-body.ts `readsEveryArgReg`.)
3049
+ if (ARG_REGS.some((r) => !ssa.writtenOnEveryPath(r, bi, () => false))) {
3050
+ return undefined;
3051
+ }
3052
+ const n = options.calleeStackWords?.(callee) ?? 0;
3053
+ return bodyBlockAgrees(
3054
+ n,
3055
+ reaching.map((off) => off - STACK_ARGS_AT),
3056
+ (o) =>
3057
+ ssa.writtenOnEveryPath(stackSlot(STACK_ARGS_AT + o), bi, () => false) &&
3058
+ !outgoingWordReaches(STACK_ARGS_AT + o, bi, 'taken', wordsHere),
3059
+ )
3060
+ ? (bodyLicences.set(ins, { bi, here: wordsHere, n, callee }), n)
3061
+ : undefined;
3062
+ };
3063
+ const took: number[] = [];
2886
3064
  calls.lower({
2887
3065
  callee,
2888
3066
  ssa,
2889
3067
  bi,
2890
3068
  read,
2891
3069
  write,
3070
+ get stackWords() {
3071
+ return licensedStackWords();
3072
+ },
2892
3073
  // ARGUMENT WORD k ≥ 4 travels in `16+4(k-4)(sp)`, inside the one frame this function pushed
2893
3074
  stackWord: (k) => {
2894
3075
  const off = STACK_ARGS_AT + 4 * (k - ARG_REGS.length);
@@ -2904,6 +3085,13 @@ function liftOnce(
2904
3085
  `${off}(sp), and no value stored there reaches the call`,
2905
3086
  );
2906
3087
  }
3088
+ // a word an earlier call took is the earlier callee's, not the value stored there, on the
3089
+ // blocks filled so far; the rest are judged once every block is (`declaredTakes`)
3090
+ if (outgoingWordReaches(off, bi, 'taken', wordsHere)) {
3091
+ refuseTakenWord(callee, ins.addr, off);
3092
+ }
3093
+ took.push(off);
3094
+ outgoingWordState[bi].set(off, 'taken');
2907
3095
  return readVar(stackSlot(off), bi);
2908
3096
  },
2909
3097
  // a pending `%hi` half and a table base are no argument
@@ -2970,6 +3158,9 @@ function liftOnce(
2970
3158
  },
2971
3159
  },
2972
3160
  });
3161
+ if (took.length > 0 && !bodyLicences.has(ins)) {
3162
+ declaredTakes.set(ins, { bi, here: wordsHere, offs: took, callee });
3163
+ }
2973
3164
  // hi and lo are the callee's to overwrite
2974
3165
  divState = null;
2975
3166
  mulState = null;
@@ -3126,6 +3317,32 @@ function liftOnce(
3126
3317
  fillBlock(b, bi);
3127
3318
  ssa.markFilled(bi);
3128
3319
  });
3320
+ for (const [ins, { bi, here, n, callee }] of bodyLicences) {
3321
+ for (const off of valueSlots) {
3322
+ if (off < STACK_ARGS_AT || off >= (frameSize ?? 0) || off >= loadedWordBound) {
3323
+ continue;
3324
+ }
3325
+ const taken = off < STACK_ARGS_AT + 4 * n;
3326
+ if (outgoingWordReaches(off, bi, taken ? 'taken' : 'stored', here)) {
3327
+ throw new FrontendUnsupportedError(
3328
+ `cannot lift '${name}': outgoing stack arguments not modelled — the call to '${callee}' at ` +
3329
+ `0x${ins.addr.toString(16)} takes the ${n} stack ` +
3330
+ `word(s) its callee's body loads, but ${
3331
+ taken
3332
+ ? `the word at ${off}(sp) reaches it on some path already taken by a call and not stored again`
3333
+ : `the word at ${off}(sp), past them, reaches it stored on some path`
3334
+ } — the block staged for it is not the same on every path, so its arguments are not known`,
3335
+ );
3336
+ }
3337
+ }
3338
+ }
3339
+ for (const [ins, { bi, here, offs, callee }] of declaredTakes) {
3340
+ for (const off of offs) {
3341
+ if (outgoingWordReaches(off, bi, 'taken', here)) {
3342
+ refuseTakenWord(callee, ins.addr, off);
3343
+ }
3344
+ }
3345
+ }
3129
3346
  refuseSavedRegisterLeaks(
3130
3347
  name,
3131
3348
  blocks.map((b) => ({
@@ -3197,6 +3414,7 @@ function liftOnce(
3197
3414
  loadedWords: loadedFrom === undefined ? undefined : { from: loadedFrom, bound: loadedWordBound },
3198
3415
  capturedObjectIsTheWholeFrame: false,
3199
3416
  movedCaptures,
3417
+ directAccesses,
3200
3418
  returnsWithoutHiddenPointer: calls.returnsWithoutHiddenPointer,
3201
3419
  symbols,
3202
3420
  target,
@@ -70,6 +70,7 @@ import type { SymbolMap } from '../symbols';
70
70
  import type { TargetDescription } from '../target';
71
71
  import { type AsmData, readJumpTable } from './asmdata';
72
72
  import { type CallDeclarations, type ParamClass, callDeclarations } from './call-plan';
73
+ import type { CalleeBodyBound } from './callee-body';
73
74
  import {
74
75
  type Declarations,
75
76
  computedOnlyToReturn,
@@ -1335,6 +1336,8 @@ function liftPrelude(
1335
1336
  prototypes: Prototypes,
1336
1337
  jts: Map<number, PpcJT>,
1337
1338
  ownDeclaration?: () => OwnDeclaration | undefined,
1339
+ calleeBodyBound?: CalleeBodyBound,
1340
+ entryReadCalls?: ReadonlySet<string>,
1338
1341
  ) {
1339
1342
  const recoveredBctr = new Set([...jts.values()].map((j) => j.bctrAddr));
1340
1343
  // TRUSTWORTHINESS: fail loud on an unmodelled control transfer rather than dropping it (which
@@ -1383,7 +1386,16 @@ function liftPrelude(
1383
1386
  }
1384
1387
  const r1At = r1Displacements(blocks, succIdx);
1385
1388
  const saves = prologueSavedRegs(entryBody, target, r1At, blocks);
1386
- const calls = callDeclarations(name, target, prototypes, PPC_CALL_LOWERING, ppcRefusal, ownDeclaration);
1389
+ const calls = callDeclarations(
1390
+ name,
1391
+ target,
1392
+ prototypes,
1393
+ PPC_CALL_LOWERING,
1394
+ ppcRefusal,
1395
+ ownDeclaration,
1396
+ calleeBodyBound,
1397
+ entryReadCalls,
1398
+ );
1387
1399
  // THE LOCAL AREA, where a function takes a frame address, reaches its frame below a word, or
1388
1400
  // keeps a dead local there (`measurePpcFrame`, `compilerBehaviors.linkageLocalArea`): every r1
1389
1401
  // access inside it, any width, is MEMORY through an `laddr` the shared frame-object audit then
@@ -1435,7 +1447,12 @@ function liftOnce(
1435
1447
  asmData: AsmData | undefined,
1436
1448
  declarations: Declarations,
1437
1449
  oneObject: FrameRange | undefined,
1438
- { contextOwnWords, contextOwnProto }: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto'>,
1450
+ {
1451
+ contextOwnWords,
1452
+ contextOwnProto,
1453
+ calleeBodyBound,
1454
+ entryReadCalls,
1455
+ }: Pick<LiftOptions, 'contextOwnWords' | 'contextOwnProto' | 'calleeBodyBound' | 'entryReadCalls'>,
1439
1456
  ): Fn | FrameObjectRelift {
1440
1457
  assertInputFormat('ppc', 'objdump', asm);
1441
1458
  const instrs = parseDisasm(sliceSymbol(asm, name)); // ONE function only — an absent symbol declines loud
@@ -1516,6 +1533,8 @@ function liftOnce(
1516
1533
  prototypes,
1517
1534
  jts,
1518
1535
  () => (!ownDeclared ? undefined : ownDeclarationOf(name, prototypes, contextOwnProto)),
1536
+ calleeBodyBound,
1537
+ entryReadCalls,
1519
1538
  );
1520
1539
  const lrUnproven = linkRegisterProof(blocks, succIdx, r1At, target);
1521
1540
 
@@ -2770,6 +2789,11 @@ function liftOnce(
2770
2789
  // of the parameter area (mwcc 2.3.3 builds `g(1, 2, 3, 4, 5, 6, 7, x)` with `long long x`
2771
2790
  // that way). A single word there cannot be an argument after seven: the eighth word would
2772
2791
  // have gone in r10.
2792
+ //
2793
+ // KNOWN GAP: the callee's body is not asked how many stack words it loads, as the
2794
+ // Thumb and O32 frontends ask it (frontend/callee-body.ts `CalleeStackWords`): this
2795
+ // frontend passes no stack word to any call, and `measurePpcFrame` refuses even a
2796
+ // declared one that takes some, so a body would have nothing to license here.
2773
2797
  refuse: (argc) => {
2774
2798
  if (argc < ARG_REGS.length - 1) {
2775
2799
  return;