@asmlift/core 0.8.1 → 0.9.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 (105) hide show
  1. package/README.md +34 -30
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +248 -0
  4. package/src/backend/c.ts +5 -2
  5. package/src/backend/cfamily.ts +236 -37
  6. package/src/backend/cpp.ts +114 -9
  7. package/src/backend/pascal.ts +39 -0
  8. package/src/cdecl/constant.ts +59 -0
  9. package/src/cdecl/lex.ts +209 -0
  10. package/src/cdecl/parse.ts +975 -0
  11. package/src/cdecl/spell.ts +69 -0
  12. package/src/codegen-flags.ts +38 -6
  13. package/src/compiler-diagnostics.ts +194 -0
  14. package/src/contracts.ts +615 -79
  15. package/src/declare.ts +75 -5
  16. package/src/frontend/asmdata.ts +19 -5
  17. package/src/frontend/call-plan.ts +686 -0
  18. package/src/frontend/device-pins.ts +277 -0
  19. package/src/frontend/errors.ts +5 -0
  20. package/src/frontend/flags-edge.ts +101 -0
  21. package/src/frontend/fpu.ts +167 -0
  22. package/src/frontend/frame-objects.ts +2131 -0
  23. package/src/frontend/high-half.ts +2 -1
  24. package/src/frontend/local-object.ts +490 -0
  25. package/src/frontend/mips.ts +363 -40
  26. package/src/frontend/opaque.ts +97 -18
  27. package/src/frontend/ppc.ts +520 -129
  28. package/src/frontend/reloc-symbol.ts +94 -13
  29. package/src/frontend/splat.ts +108 -5
  30. package/src/frontend/ssa.ts +669 -76
  31. package/src/frontend/stackargs.ts +120 -39
  32. package/src/frontend/thumb.ts +2764 -2018
  33. package/src/ir/alias.ts +29 -0
  34. package/src/ir/core.ts +101 -3
  35. package/src/ir/discipline.ts +248 -0
  36. package/src/ir/float-bits.ts +47 -0
  37. package/src/ir/opcodes.ts +84 -112
  38. package/src/ir/parse.ts +1 -0
  39. package/src/ir/types.ts +118 -2
  40. package/src/ir/verify.ts +150 -3
  41. package/src/l3/address.ts +5 -0
  42. package/src/l3/argcopy.ts +38 -6
  43. package/src/l3/ast.ts +186 -14
  44. package/src/l3/coalesce.ts +21 -13
  45. package/src/l3/dce.ts +32 -10
  46. package/src/l3/gates.ts +9 -3
  47. package/src/l3/hoist.ts +8 -7
  48. package/src/l3/initfirst.ts +27 -9
  49. package/src/l3/intcell.ts +97 -0
  50. package/src/l3/mentions.ts +10 -4
  51. package/src/l3/narrowdecl.ts +218 -0
  52. package/src/l3/nearbase.ts +10 -5
  53. package/src/l3/parkfirst.ts +8 -5
  54. package/src/l3/pollguard.ts +3 -2
  55. package/src/l3/ptrcell.ts +357 -0
  56. package/src/l3/regspell.ts +17 -3
  57. package/src/l3/reindex.ts +10 -7
  58. package/src/l3/symbol-refs.ts +104 -23
  59. package/src/l3/tailmerge.ts +6 -19
  60. package/src/l3/typing.ts +137 -25
  61. package/src/l3/unreduce.ts +4 -4
  62. package/src/l3/volstore.ts +96 -7
  63. package/src/mangle.ts +11 -1
  64. package/src/pattern/engine.ts +19 -16
  65. package/src/pipeline.ts +57 -12
  66. package/src/proto-context.ts +788 -0
  67. package/src/proto.ts +689 -50
  68. package/src/raise/arrays.ts +4 -3
  69. package/src/raise/declared-volatile.ts +131 -0
  70. package/src/raise/divpow2.ts +2 -2
  71. package/src/raise/errors.ts +17 -0
  72. package/src/raise/floathelpers.ts +243 -0
  73. package/src/raise/globalshape.ts +11 -5
  74. package/src/raise/gvn.ts +26 -5
  75. package/src/raise/narrow.ts +29 -2
  76. package/src/raise/narrowlocal.ts +11 -6
  77. package/src/raise/offsetnames.ts +510 -0
  78. package/src/raise/pairparams.ts +81 -0
  79. package/src/raise/paramwidth.ts +7 -0
  80. package/src/raise/pre-recovery.ts +78 -4
  81. package/src/raise/recover.ts +14 -10
  82. package/src/raise/retsink.ts +1 -1
  83. package/src/raise/shortcircuit.ts +41 -19
  84. package/src/raise/softdiv.ts +21 -23
  85. package/src/raise/struct-arrays.ts +5 -4
  86. package/src/raise/structs.ts +311 -98
  87. package/src/raise/truncload.ts +366 -0
  88. package/src/raise/widehelpers.ts +106 -0
  89. package/src/rank-declare.ts +30 -5
  90. package/src/rank-variations.ts +36 -8
  91. package/src/rank.ts +267 -79
  92. package/src/runtime-helpers.ts +287 -0
  93. package/src/stillborn.ts +289 -0
  94. package/src/structure/analysis.ts +1285 -448
  95. package/src/structure/hazards.ts +633 -58
  96. package/src/structure/local-statics.ts +381 -0
  97. package/src/structure/namecoalesce.ts +1 -1
  98. package/src/structure/redundant-test.ts +99 -0
  99. package/src/structure/structure.ts +1772 -395
  100. package/src/structure/switch-recover.ts +159 -78
  101. package/src/symbols.ts +38 -3
  102. package/src/target.ts +662 -59
  103. package/src/trace.ts +44 -24
  104. package/src/variation-definitions.ts +119 -1
  105. package/src/variation-tokens.ts +20 -6
@@ -0,0 +1,2131 @@
1
+ /** THE FRAME-OBJECT AUDIT, over finished IR and the frame partition a frontend measured.
2
+ *
3
+ * NOTHING HERE DECODES. A frontend lowers each address it can name as a frame offset to an
4
+ * `laddr` (Thumb: `mov rD, sp`, `add rD, sp, #k`, a capture moved by a constant) and hands over
5
+ * what only it can measure — the frame partition, as the same two ranges its `LiveInModel`
6
+ * carries, the offsets its slot model keyed, the one licence it grants on the text
7
+ * (`capturedObjectIsTheWholeFrame`), and the captures it moved. The rules that decide whether
8
+ * those `laddr`s are objects the emitted C can declare are asked of the IR here, the same for
9
+ * every ISA — the level-tower split for this frame model: the frontend supplies the PARTITION,
10
+ * the shared pass applies the rule. Messages name the frame base `sp`.
11
+ *
12
+ * Thumb is the only caller today; the worked examples below are agbcc's, because agbcc is the
13
+ * compiler every rule was measured against. */
14
+ import { type Block, type Op, type Value, mkOp, mkValue } from '../ir/core';
15
+ import { type IrType, T, typeEquals, typeToString } from '../ir/types';
16
+ import type { Gate } from '../l3/gates';
17
+ import type { SymbolMap } from '../symbols';
18
+ import { type TargetDescription, blockTransferRead, sourceControlRead, sourceReach } from '../target';
19
+ import type { CallDeclarations } from './call-plan';
20
+ import { type DevicePins, type LiteralAddresses, literalAddresses } from './device-pins';
21
+ import { FrontendUnsupportedError } from './errors';
22
+ import { type LiveInModel, slotKeyOffset } from './ssa';
23
+
24
+ export interface FrameObjectAudit {
25
+ name: string;
26
+ irBlocks: Block[];
27
+ /** the frame this function owns (`LiveInModel.ownedLocals`): every local lies inside it, and
28
+ * every word of it is an object, a slot, or storage nothing here describes */
29
+ ownedLocals: NonNullable<LiveInModel['ownedLocals']>;
30
+ /** where its locals are declared (`LiveInModel.declaredLocals`): `ownedLocals` less the outgoing
31
+ * stack-argument block staged at its bottom, which is where an untyped object claims to start */
32
+ declaredLocals: NonNullable<LiveInModel['declaredLocals']>;
33
+ usedSlotOffsets: ReadonlySet<number>;
34
+ capturedObjectIsTheWholeFrame: boolean;
35
+ /** every capture the add arm re-minted at a constant offset from it */
36
+ movedCaptures: ReadonlySet<Value>;
37
+ /** whether a callee's declaration rules out a struct returned through a hidden pointer at
38
+ * argument 0 (aggregate.ts `returnsWithoutHiddenPointer`), asked of the frontend's call
39
+ * declarations */
40
+ returnsWithoutHiddenPointer: CallDeclarations['returnsWithoutHiddenPointer'];
41
+ symbols: SymbolMap | undefined;
42
+ target: TargetDescription;
43
+ /** the retraction rules an escape is judged by; `FRAME_ESCAPE_GATES` when absent, and a
44
+ * census or an ablation hands in its own (`FRAME_OBJECT_AUDIT`) */
45
+ gates?: readonly Gate<FrameEscape>[];
46
+ /** the bytes a first audit asked to have kept as ONE object in memory (`FrameObjectRelift`):
47
+ * the frontend routes every access inside them through an `laddr`, and the audit declares
48
+ * them one object rather than judging each offset on its own */
49
+ oneObject?: FrameRange;
50
+ }
51
+
52
+ export interface FrameRange {
53
+ readonly from: number;
54
+ readonly to: number;
55
+ }
56
+
57
+ /** What an audit answers instead of a refusal when a device may read the frame, or a callee write
58
+ * it, without bound: lift again with `oneObject` set to these bytes. */
59
+ export interface FrameObjectRelift {
60
+ readonly oneObject: FrameRange;
61
+ }
62
+
63
+ /** What an audit answers: lift again (`FrameObjectRelift`), the device pin the accepted function
64
+ * owes (`DevicePins`, applied by `pinDeviceAccesses`), or nothing, when no frame address was
65
+ * taken. */
66
+ export type FrameObjectVerdict = FrameObjectRelift | DevicePins | undefined;
67
+
68
+ /** One escaped object, as the rules an escape retracts read it: what it may reach, `[lo, hi)`
69
+ * from its own offset, whether it may write, and the first other object, keyed slot and
70
+ * unaccounted frame word inside that reach — so a refusal names the fact its gate tested. */
71
+ export interface FrameEscape {
72
+ readonly off: number;
73
+ readonly lo: number;
74
+ readonly hi: number;
75
+ readonly writes: boolean;
76
+ /** how the address left, as the refusal words it */
77
+ readonly how: string;
78
+ readonly objectReached: number | undefined;
79
+ readonly slotReached: { readonly slot: number; readonly above: boolean } | undefined;
80
+ /** the lowest frame slot inside that reach the function holds an `undef` of */
81
+ readonly undefReached: number | undefined;
82
+ /** the lowest owned word inside that reach that no object and no slot accounts for */
83
+ readonly unaccountedWord: number | undefined;
84
+ }
85
+
86
+ // WHAT AN ESCAPE COSTS. The audit bounds what WE access through an object, never what a callee
87
+ // does with the address it was handed — and a callee may write any offset from it. So an escape
88
+ // retracts four claims, each over the bytes it may reach (`readWindow`): the whole frame for a
89
+ // writer nothing bounds. They are
90
+ // a table (`l3/gates.ts`), taken as `FrameObjectAudit.gates` so a census or an ablation can reach
91
+ // them; the rest of the audit — the split, the use classification, the premise re-check, the
92
+ // shape and overlap checks — decides what the objects ARE, in the stages `auditFrameObjects` runs.
93
+ export const FRAME_ESCAPE_GATES: readonly Gate<FrameEscape>[] = [
94
+ // The first is that the other ADDRESS-TAKEN objects are private, and it keys on the REACH, not
95
+ // on `mayWrite`. Its argument is about LAYOUT, and layout is symmetric: two objects are two
96
+ // separate C locals with no guaranteed adjacency, so a device that READS past the one it was
97
+ // given is as wrong as a callee that writes past it. `DmaCopy` with a count of two halfwords
98
+ // off `&sp0` transfers `[sp,#2]` too, and the emitted source transfers whatever the recompiler
99
+ // put after `sp0`, and the second object's own store is whatever the recompiler made of it.
100
+ // Marking both volatile would not repair that: the locals are still placed independently. A
101
+ // read the control word bounds to the object's own bytes reaches no neighbour, whatever its
102
+ // placement: two fixed-source DMA fills in one function, each off its own `volatile` local.
103
+ //
104
+ // It counts `laddr` objects. A neighbour SPILLED to an SSA slot is the slot rule's below.
105
+ {
106
+ id: 'reaches-another-object',
107
+ why: 'two locals have no guaranteed adjacency, so a reach past one lands on whatever the recompile put there',
108
+ sound: true,
109
+ guardedBy: 'thumb-frontend.test.ts: an incrementing fill reads the object above its own',
110
+ rejects: (e) => e.objectReached !== undefined,
111
+ },
112
+ // The second is `undef`, which rests on this function's own stores being the ONLY writer of
113
+ // its frame. A wider real object (`struct P p; g(&p);` where only `p.x` is read here) has its
114
+ // later words written by `g` and read back at a slot no store of ours reaches — declaring
115
+ // those uninitialised spells the callee's value as garbage. The extents here are inferred
116
+ // from OUR accesses, which is the number that is too small in this shape.
117
+ //
118
+ // On an escape and not on "a laddr exists": an address dereferenced only in-function cannot
119
+ // be written by anyone else, and the audit's overlap checks cover its aliasing.
120
+ //
121
+ // FRAME undefs only. A register-keyed one says a local lives in a register the ABI does not
122
+ // pass arguments in, and no address reaches a register — the escape this retraction is about
123
+ // cannot touch it, and counting it would refuse the whole function for an unrelated escape.
124
+ //
125
+ // …and only the frame slots inside the writer's reach. A callee handed only its own return
126
+ // storage writes the struct it returns and nothing else, so a slot outside that struct has no
127
+ // writer but this function: `if (a) x = gF(h); r = mk8(b); g4(i, j, x, a);` spills `x` beside
128
+ // `r`'s temp, unstored on one path, and that slot is still uninitialised there.
129
+ {
130
+ id: 'writer-over-undef',
131
+ why: 'an unstored frame slot is uninitialised only while this function is its sole writer',
132
+ sound: true,
133
+ guardedBy: 'thumb-frontend.test.ts: an ESCAPED frame address retracts the undef argument',
134
+ rejects: (e) => e.writes && e.undefReached !== undefined,
135
+ },
136
+ // …and the SLOT MODEL is the third claim an escape retracts — the undef rule's argument
137
+ // taken one step further. The audit's extents are inferred from OUR accesses, so an object
138
+ // wider in the SOURCE than the bytes this function touches has its later words written by
139
+ // the callee — and any of those modelled as an SSA slot is a value the slot model forwards
140
+ // ACROSS the call that overwrote it.
141
+ //
142
+ // The object has to be reached ONLY through the captured pointer (an `[sp,#0]` access of its own collides with the slot model and
143
+ // declines at the overlap check), which is what four corpus functions do:
144
+ //
145
+ // mov r2, sp / str r0, [r2] @ the object, written through the captured address
146
+ // str r1, [sp, #0x4] @ a word the slot model keys
147
+ // mov r0, r2 / bl g @ the base escapes; `g` may write [sp,#4]
148
+ // ldr r0, [sp, #0x4] @ …and the machine RELOADS it after the call
149
+ //
150
+ // and lifted, the reload is the value from BEFORE the call, the callee's write dropped, no
151
+ // diagnostic. Exactly the silent-wrong-answer trade the sp guards exist to prevent, so it refuses.
152
+ //
153
+ // WHAT IT COSTS: it refuses every word slot the escape may reach, which is blunter than the
154
+ // hazard it names. Narrowing it needs the object's real extent, and this model does not carry
155
+ // one: `extent` is a single width from a single access. The asm sometimes cannot
156
+ // supply it either — the compiled twin at `capturedObjectIsTheWholeFrame` is exactly this
157
+ // rule's shape, a slot THIS FUNCTION stores and reloads, undecidable between a spill and a
158
+ // member.
159
+ //
160
+ // A callee is not the only writer. `struct M { u8 b; u8 pad[3]; s32 t; }; gp = &m; g2();
161
+ // use2(m.t);` PUBLISHES the base to an ordinary global and the machine reloads [sp,#4] after
162
+ // `bl g2` — `g2` writes through `gp`, which points here: the same hazard as the call shape, one
163
+ // escape over.
164
+ //
165
+ // …and a writer is not the only hazard, so the rule keys on `escaped`, not `mayWrite`. A
166
+ // device that only READS through the address reads the slot's bytes from memory, where the
167
+ // slot model never put them: `struct P { u32 a, b; } s; s.a = x; s.b = y; REG_DMA3SAD =
168
+ // (u32)&s;` above an outgoing block is `str r4, [sp, #0x4] / str r5, [sp, #0x8] / add r1, sp,
169
+ // #0x4 / str r1, [DMA3SAD]`, where the slot model would keep `s.b` in a register and drop its
170
+ // store as dead while the DMA reads that word. The asm cannot tell that second word from a
171
+ // neighbour spilled beside the object, so what bounds the read is the transfer's own control
172
+ // word, `readWindow`.
173
+ //
174
+ // …and BELOW the object as well as above it. A C object extends upward from its base, but a
175
+ // captured address need not BE a base: `add r0, sp, #0x4` is `&buf[1]` as readily as `&b`, and
176
+ // `buf[0]` at [sp,#0] is then read or written through `p[-1]`. Only down to the outgoing
177
+ // block, whose words are the callee's arguments and never part of a local.
178
+ {
179
+ id: 'reaches-a-slot',
180
+ why: 'the slot model keeps a slot in a register, so a write or read through the frame misses it',
181
+ sound: true,
182
+ guardedBy: 'thumb-frontend.test.ts: above: a word a device may read past the object',
183
+ rejects: (e) => e.slotReached !== undefined,
184
+ },
185
+ // …and the FOURTH claim an escape retracts is the object's TOP, which the three rules above
186
+ // leave to whatever this function happened to touch. `extent` is one width from one access,
187
+ // so an object wider in the SOURCE than those bytes is declared too small — and a callee
188
+ // holding its address writes frame bytes the emitted C never allocated. Compiled:
189
+ //
190
+ // u8 buf[12]; buf[0] = x; garr(buf); use2(buf[0]);
191
+ // → add sp,sp,#-0xc / mov r1,sp / strb r0,[r1] / mov r0,sp / bl garr
192
+ //
193
+ // lifted as `u8 sp0; garr(&sp0); use2(sp0)` — a 12-byte object declared one byte, in a frame
194
+ // the recompile makes 4 bytes wide, with `garr` writing the other 8 into the caller's. The
195
+ // three rules above all pass it: one object, no `undef` op, no slot above it.
196
+ //
197
+ // What licenses an answer is the frame being ACCOUNTED FOR, word by word. Every word of the
198
+ // reserved local area the escape reaches has to be an object this audit modelled or a slot the
199
+ // slot model keys; a word that is neither is storage nothing here describes, so the emitted C
200
+ // reserves less than the machine did and the writer reaches past what it allocated. A writer
201
+ // that may write anywhere through the address reaches the whole local area, below the object as
202
+ // well as above it; a callee handed only its own return storage writes the struct it returns
203
+ // and nothing else (`readWindow`), so a word outside that struct is one it never reaches. Word
204
+ // granularity, not byte — the stack is word-aligned, so a halfword object owns its word and the
205
+ // padding beside it is not a second local.
206
+ //
207
+ // …and on a READER as far as it reads, since a read past the object copies frame bytes the
208
+ // source reserved and the recompile does not. `u16 buf[8]; buf[0] = x; CpuSet(buf, gDst, 8);`
209
+ // reads the sixteen bytes its control word names, and a DMA copy incrementing from `buf` reads
210
+ // every word above it; lifted as `u16 sp0`, the recompile's frame is four bytes wide and the
211
+ // transfer copies the saved `lr` and the caller's frame out with it. A read unbounded both ways,
212
+ // and a callee's write, meet this rule only beside a writer the one-object answer cannot hold
213
+ // (`writerNotKept`): otherwise the audit keeps the local area as one object instead.
214
+ //
215
+ // WHAT IT LEAVES, since this is the extent question the gate comment above is about: an escape
216
+ // is accepted only where the modelled objects and the keyed slots tile the reserved area it
217
+ // reaches — a word above the object is a slot (refused above), a second object (refused above),
218
+ // or unaccounted (refused here). That is not a wider extent model; it is the same one-scalar
219
+ // `extent`, made to say when it does not fit. An object of two words cannot be built here at
220
+ // all — the second access that would reach it is a `[+4]` the `scalar()` guard refuses — so no
221
+ // widening of the frame licence admits a shape this rule would then have to judge.
222
+ //
223
+ // AND IT IS THE SCALAR ARM THIS BOUNDS. An UNTYPED object is the whole DECLARED area by
224
+ // construction — `notTheWholeArea` accepts nothing else — and every word below the declared
225
+ // area is the licensed outgoing block, whose argument words the slot model keys. The object and
226
+ // those slots together account for every word this walk asks about, so no input makes the rule
227
+ // fire on that path; `accountedWords` needs its slot term for that as much as its object term.
228
+ // What bounds THAT path is `notTheWholeArea`'s own live clauses — a second object, an object
229
+ // that does not start at the bottom of the declared area, an address that reaches memory rather
230
+ // than a callee, and the callee's declared return — and `overSlot`, which refuses a slot keyed
231
+ // inside the declared area.
232
+ {
233
+ id: 'reaches-an-unaccounted-word',
234
+ why: 'an escape reaching a frame word no declaration covers reaches past what the recompile allocates',
235
+ sound: true,
236
+ guardedBy: [
237
+ 'thumb-frontend.test.ts: an array whose top nothing bounds declines rather than shrinking the frame',
238
+ 'thumb-frontend.test.ts: a copy that reads past its object into a word nothing accounts for declines',
239
+ ],
240
+ rejects: (e) => e.unaccountedWord !== undefined,
241
+ },
242
+ ];
243
+
244
+ /** FRAME-OBJECT AUDIT. Every `laddr` the frontend emitted is only a CLAIM that the address it
245
+ * names is used as "the address of one local object"; this proves it, over the finished function,
246
+ * the same boundary-total style as the slot-escape assert in finish(). The address may flow
247
+ * anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — and it is
248
+ * judged in one of two models, chosen before any shape is:
249
+ * - PER OBJECT, the default: every MEMORY access through it must be at offset 0, with one agreed
250
+ * width and one agreed extension, or a byte read or written through a runtime index into
251
+ * storage nothing else types, and its bytes must belong to nothing else in the frame;
252
+ * - ONE OBJECT, where a device reads or a callee writes without bound and every writer is one
253
+ * that answer can hold (`writerNotKept`; `oneObject`, which this answers and the frontend
254
+ * lifts again with): the local area is one `u8` array in memory, every fixed-offset access in
255
+ * it is a member at its own offset, a runtime index may reach a byte of it, and two widths at
256
+ * one byte refuse.
257
+ * Any use the audit cannot vouch for declines the whole function loudly. Nothing here guesses: a
258
+ * scalar's declared type is exactly the access type the machine used, and an object NO access
259
+ * reaches is sized by the frame reservation and left untyped.
260
+ *
261
+ * Takes its inputs explicitly rather than closing over `lift`. Every one of them is READ, none is
262
+ * reassigned, and the only mutations are to the ops reachable through `irBlocks`: an `add` of a
263
+ * capture and a constant is re-minted as the `laddr` it names, a moved-from capture nothing reads
264
+ * is dropped, a capture addressed through at fixed offsets is split into the objects its accesses
265
+ * name, and each surviving `laddr` is stamped with its width, signedness, count and `volatile`.
266
+ * One object mutates more: every access through a member is re-based onto one minted `laddr`,
267
+ * and the member `laddr`s are deleted or re-minted as that one moved by a `const`. An accepted
268
+ * function is answered with the device pin it owes, under the model it was accepted in
269
+ * (frontend/device-pins.ts).
270
+ *
271
+ * STAGED, and the order the stages run in is the refusal order: the first stage to refuse names
272
+ * the decline. Three things cross stages besides their returned records: `irBlocks`, rewritten by
273
+ * the fold, the split, the one-object answer and the stamp; `foldedHere`, which the split adds to
274
+ * and the slot-overlap refusal reads; and `ModelChoice.refused`, which the model choice and the
275
+ * shape stage set and the relift test reads. */
276
+ export function auditFrameObjects({
277
+ name,
278
+ irBlocks,
279
+ ownedLocals: owned,
280
+ declaredLocals: declared,
281
+ usedSlotOffsets,
282
+ capturedObjectIsTheWholeFrame,
283
+ movedCaptures,
284
+ returnsWithoutHiddenPointer,
285
+ symbols,
286
+ target,
287
+ gates = FRAME_ESCAPE_GATES,
288
+ oneObject,
289
+ }: FrameObjectAudit): FrameObjectVerdict {
290
+ const { laddrs: captures, foldedHere } = foldMovedCaptures(irBlocks, movedCaptures);
291
+ // …and the audit runs for a licensed acceptance with no object at all, so the premise re-check
292
+ // is total rather than resting on "the capture always survives into the IR".
293
+ if (captures.length === 0 && !capturedObjectIsTheWholeFrame) {
294
+ return undefined;
295
+ }
296
+ const fail = frameRefusal(name);
297
+ const facts = frameAddressFacts(irBlocks, symbols, target);
298
+ const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere);
299
+ const objects = frameObjects(laddrs, owned, fail);
300
+ const flow = addressFlow(irBlocks, objects, facts, fail);
301
+ const uses = classifyFrameUses({ irBlocks, objects, flow, facts, splitRefusal, oneObject, target, fail });
302
+ const windows = escapeWindows(irBlocks, uses, flow, facts, target);
303
+ const model = chooseFrameModel(oneObject, uses, windows, returnsWithoutHiddenPointer, fail);
304
+ if (capturedObjectIsTheWholeFrame) {
305
+ recheckWholeFramePremise({ uses, returnsWithoutHiddenPointer, fail });
306
+ }
307
+ // The device pin's trigger: the `readOnlyAddressSinks` registers a frame address was stored to.
308
+ const sinks = [...new Set([...uses.sourceStores.values()].flat().map((s) => s.sink))];
309
+ if (oneObject !== undefined) {
310
+ keepAsOneObject({ oneObject, irBlocks, objects, uses, usedSlotOffsets, returnsWithoutHiddenPointer, fail });
311
+ return { policy: 'one-object', sinks };
312
+ }
313
+ const shapes = objectShapes({
314
+ objects,
315
+ uses,
316
+ owned,
317
+ declared,
318
+ usedSlotOffsets,
319
+ returnsWithoutHiddenPointer,
320
+ model,
321
+ fail,
322
+ });
323
+ const escapes = frameEscapes({ irBlocks, uses, windows, shapes, owned, declared, usedSlotOffsets });
324
+ // THE ONE-OBJECT ANSWER, where it is on offer (one escape reaches without bound, and every
325
+ // writer is one it can hold) and the per-object model does not describe the frame: it refused a
326
+ // shape, an object sits over a slot, or the unbounded reach meets another object, a slot or a
327
+ // word nothing accounts for. Then what the device may read or the callee may write is kept
328
+ // rather than refused: lift again with the local area as one object in memory
329
+ // (`keepAsOneObject`). Below the local area are the outgoing arguments and above it the saved
330
+ // registers, neither of them an object. Where the per-object model does describe the frame —
331
+ // one object and nothing else in reach — it stands, and the object keeps its own type.
332
+ const answered = (e: FrameEscape): boolean =>
333
+ e.lo === -Infinity &&
334
+ e.hi === Infinity &&
335
+ (e.objectReached !== undefined || e.slotReached !== undefined || e.unaccountedWord !== undefined);
336
+ if (model.onOffer && (model.refused || shapes.overSlot.length > 0 || escapes.some(answered))) {
337
+ return { oneObject: { from: declared.from, to: declared.to } };
338
+ }
339
+ const failWithheld: Refuse = (why) => fail(model.withheld(why));
340
+ for (const [off, width] of shapes.overSlot) {
341
+ failIfSlotKeysIt(off, width, { usedSlotOffsets, objects, foldedHere, fail: failWithheld });
342
+ }
343
+ // RULE-MAJOR, not escape-major: every escape is asked a rule before any is asked the next, so
344
+ // the refusal a function reports does not turn on the order its escapes were found in.
345
+ for (const gate of gates) {
346
+ const hit = escapes.find((e) => gate.rejects(e));
347
+ if (hit !== undefined) {
348
+ (answered(hit) ? failWithheld : fail)(escapeRefusal(gate.id, hit));
349
+ }
350
+ }
351
+ stampObjects(objects, shapes, uses);
352
+ return { policy: 'per-object', sinks };
353
+ }
354
+
355
+ /** Refuses the whole function, naming the address-taken local as what it cannot lift. */
356
+ type Refuse = (why: string) => never;
357
+
358
+ function frameRefusal(name: string): Refuse {
359
+ return (why) => {
360
+ throw new FrontendUnsupportedError(`cannot lift '${name}': address-taken stack local — ${why}`);
361
+ };
362
+ }
363
+
364
+ /** A CAPTURE MOVED BY A CONSTANT IS THE CAPTURE AT THE SUM, and here the constant is exact: an
365
+ * `add` of a `laddr` and a `const` — a register the lift could not see through, `mov r0, sp /
366
+ * movs r2, #0 / ldrsh r1, [r0, r2]`, or a move the pre-lift walk does not follow — is re-minted
367
+ * as the `laddr` it names. The walk still decides which `[sp,#k]` words are routed to an object
368
+ * rather than keyed as slots, so a fold it did not make is remembered (`foldedHere`): an object it
369
+ * lands on a keyed slot is refused as the move, the capability that is missing
370
+ * (`failIfSlotKeysIt`).
371
+ *
372
+ * Returns every `laddr` that survives, in block order. */
373
+ function foldMovedCaptures(
374
+ irBlocks: Block[],
375
+ movedCaptures: ReadonlySet<Value>,
376
+ ): { laddrs: Op[]; foldedHere: Set<Op> } {
377
+ const moved = new Set<Value>(movedCaptures);
378
+ const foldedHere = new Set<Op>();
379
+ const constOf = new Map<Value, number>();
380
+ const laddrOf = new Map<Value, Op>();
381
+ for (const blk of irBlocks) {
382
+ for (const op of blk.ops) {
383
+ if (op.opcode === 'const') {
384
+ constOf.set(op.results[0], op.attrs.value as number);
385
+ } else if (op.opcode === 'laddr') {
386
+ laddrOf.set(op.results[0], op);
387
+ }
388
+ }
389
+ }
390
+ for (let changed = true; changed;) {
391
+ changed = false;
392
+ for (const blk of irBlocks) {
393
+ blk.ops = blk.ops.map((op) => {
394
+ const [x, y] = op.operands;
395
+ const base = laddrOf.get(x) ?? laddrOf.get(y);
396
+ const by = constOf.get(laddrOf.has(x) ? y : x);
397
+ if (op.opcode !== 'add' || op.operands.length !== 2 || base === undefined || by === undefined) {
398
+ return op;
399
+ }
400
+ const off = ((base.attrs.off as number) + by) | 0;
401
+ const object = mkOp('laddr', { results: op.results, attrs: { off } });
402
+ laddrOf.set(op.results[0], object);
403
+ moved.add(base.results[0]);
404
+ foldedHere.add(object);
405
+ changed = true;
406
+ return object;
407
+ });
408
+ }
409
+ }
410
+ // A capture MOVED by a constant and nothing else reads names no object — the moved one does —
411
+ // so it is dropped rather than judged as an object with no use, and so is a fold nothing reads:
412
+ // the write-back of `stmia r1!, {r2, r3, r4}` through a capture is `add r1, r1, #12`, a pointer
413
+ // to the end of what was copied that the machine never uses. Only those: an unused `mov rD, sp`
414
+ // of the machine's own is still a capture, and is judged.
415
+ const read = new Set<Value>();
416
+ for (const blk of irBlocks) {
417
+ for (const op of blk.ops) {
418
+ op.operands.forEach((v) => read.add(v));
419
+ (op.successors ?? []).forEach((s) => s.args.forEach((v) => read.add(v)));
420
+ }
421
+ }
422
+ const laddrs: Op[] = [];
423
+ for (const blk of irBlocks) {
424
+ blk.ops = blk.ops.filter(
425
+ (op) => op.opcode !== 'laddr' || read.has(op.results[0]) || !(moved.has(op.results[0]) || foldedHere.has(op)),
426
+ );
427
+ for (const op of blk.ops) {
428
+ if (op.opcode === 'laddr') {
429
+ laddrs.push(op);
430
+ }
431
+ }
432
+ }
433
+ return { laddrs, foldedHere };
434
+ }
435
+
436
+ /** What the audit reads a value as: its defining op, its constant, the literal address it denotes,
437
+ * and the read-only device registers a store hands it to. Read off the IR as the fold left it, so
438
+ * `defOf` names no `laddr` the split mints after. */
439
+ interface FrameAddressFacts extends LiteralAddresses {
440
+ /** the registers this store hands the WHOLE address to, when each is one a device only reads
441
+ * through, else none */
442
+ readsThrough(op: Op): readonly number[];
443
+ }
444
+
445
+ function frameAddressFacts(
446
+ irBlocks: readonly Block[],
447
+ symbols: SymbolMap | undefined,
448
+ target: TargetDescription,
449
+ ): FrameAddressFacts {
450
+ const readOnlySinks = new Set(target.capabilities.readOnlyAddressSinks ?? []);
451
+ const addresses = literalAddresses(irBlocks, symbols);
452
+ const { defOf, literalAddrOf } = addresses;
453
+ // The spacing of the read-only registers: a runtime index that is a multiple of it, from one
454
+ // of them, names one of them and never a register between — the channel stride.
455
+ const sinkSpacing = [...readOnlySinks].reduce((g, s, _, all) => gcd(g, s - all[0]), 0);
456
+ // Word stores only: a `strh` to a source register hands over half an address, so the device's
457
+ // source is not this object. A literal base names one register; a literal plus a runtime index —
458
+ // `(vu32 *)(0x40000B0 + ch * 12)`, a channel chosen at run time — names every read-only register
459
+ // its stride reaches, and only when that stride is a multiple of their spacing, so no index lands
460
+ // between them. A base this cannot resolve — computed, register-offset, merged by a phi — is the
461
+ // conservative answer.
462
+ const readsThrough = (op: Op): readonly number[] => {
463
+ if (readOnlySinks.size === 0 || (op.attrs.width as number) !== 4) {
464
+ return [];
465
+ }
466
+ const off = op.attrs.off as number;
467
+ const base = literalAddrOf(op.operands[0]);
468
+ if (base !== undefined) {
469
+ return readOnlySinks.has(base + off) ? [base + off] : [];
470
+ }
471
+ const d = defOf.get(op.operands[0]);
472
+ if (d?.opcode !== 'add' || d.operands.length !== 2 || sinkSpacing === 0) {
473
+ return [];
474
+ }
475
+ for (const [lit, index] of [d.operands, [...d.operands].reverse()]) {
476
+ const from = literalAddrOf(lit);
477
+ const stride = from === undefined ? 0 : factorOf(addresses, index);
478
+ if (from !== undefined && readOnlySinks.has(from + off) && stride > 0 && stride % sinkSpacing === 0) {
479
+ return [...readOnlySinks].filter((s) => (s - (from + off)) % stride === 0);
480
+ }
481
+ }
482
+ return [];
483
+ };
484
+ return { ...addresses, readsThrough };
485
+ }
486
+
487
+ // `v` as a sum of runtime values times constants, plus a constant (the `1` key): agbcc's `ch *
488
+ // 12` is `((ch << 1) + ch) << 2`, which is 12 times `ch` only once the two `ch` terms are added.
489
+ // Undefined past what this can read.
490
+ function linearOf(facts: LiteralAddresses, v: Value, depth = 0): Map<Value | 1, number> | undefined {
491
+ const c = facts.constOf(v);
492
+ if (c !== undefined) {
493
+ return new Map([[1, c]]);
494
+ }
495
+ const d = facts.defOf.get(v);
496
+ if (d === undefined || depth > 8) {
497
+ return new Map([[v, 1]]);
498
+ }
499
+ const scaled = (x: Value, by: number) => {
500
+ const l = linearOf(facts, x, depth + 1);
501
+ return l && new Map([...l].map(([t, k]) => [t, k * by] as const));
502
+ };
503
+ const k = d.operands.length === 1 ? (d.attrs.imm as number | undefined) : facts.constOf(d.operands[1]);
504
+ if (d.opcode === 'shl' && k !== undefined && k >= 0 && k < 31) {
505
+ return scaled(d.operands[0], 2 ** k);
506
+ }
507
+ if (d.opcode === 'mul' && d.operands.length === 2 && k !== undefined) {
508
+ return scaled(d.operands[0], k);
509
+ }
510
+ if ((d.opcode === 'add' || d.opcode === 'sub') && d.operands.length === 2) {
511
+ const x = linearOf(facts, d.operands[0], depth + 1);
512
+ const y = scaled(d.operands[1], d.opcode === 'add' ? 1 : -1);
513
+ if (x === undefined || y === undefined) {
514
+ return undefined;
515
+ }
516
+ y.forEach((n, t) => x.set(t, (x.get(t) ?? 0) + n));
517
+ return x;
518
+ }
519
+ return new Map([[v, 1]]);
520
+ }
521
+
522
+ // A number every value of `v` is a multiple of, or 0 when this cannot name one.
523
+ function factorOf(facts: LiteralAddresses, v: Value): number {
524
+ const l = linearOf(facts, v);
525
+ return l === undefined ? 0 : [...l.values()].reduce(gcd, 0);
526
+ }
527
+
528
+ /** A FRAME BASE ADDRESSED THROUGH IS NOT A CAPTURE. Thumb-1 gives `ldr`/`str` an `[sp,#imm]`
529
+ * encoding and gives the sub-word forms none, so a byte or halfword spill can only be spelled
530
+ * by copying sp into a register and addressing through the copy:
531
+ *
532
+ * mov r2, sp
533
+ * strh r3, [r2, #0x30]
534
+ *
535
+ * That is an ADDRESSING MODE. The copy never becomes a value, and the access is the
536
+ * `[sp,#0x30]` the instruction set cannot spell — so what the machine named is one object at
537
+ * frame offset 48, not a `[+48]` reach through the frame base.
538
+ *
539
+ * A captured address whose every use is a fixed-offset sub-word ACCESS is that shape, and
540
+ * each of its accesses names its own object: re-root them onto a `laddr` at their own offset,
541
+ * read at 0, and the rest of this audit judges the objects. A capture with ANY other use is a
542
+ * real capture and keeps the frame base.
543
+ *
544
+ * What makes that judgement total is that the walk below enumerates every ROLE a value can
545
+ * appear in — every operand of every op, and every edge argument — instead of asking what an
546
+ * instruction looks like. One instruction can hold two roles: `str rD, [rD, #k]` stores the
547
+ * frame address through itself, a base use AND an escape, and the escape is what stops the
548
+ * split.
549
+ *
550
+ * Returns the `laddr`s left to judge, and why a capture was NOT split, when the reason is one no
551
+ * later message carries — the `slotsOffReason` idiom: a refusal reported as the wrong capability
552
+ * sends the improvement loop to build the wrong thing. Per CAPTURE and not per offset: every
553
+ * direct `[sp,#k]` word access is a capture of its own at k, refused here for its width, and a
554
+ * different capture at k whose access is refused for another reason must not be handed that one. */
555
+ function splitAddressingCopies(
556
+ irBlocks: Block[],
557
+ laddrs: Op[],
558
+ foldedHere: Set<Op>,
559
+ ): { laddrs: Op[]; splitRefusal: Map<Value, string> } {
560
+ const splitRefusal = new Map<Value, string>();
561
+ const uses = new Map<Value, { op: Op; idx: number; blk: Block }[]>();
562
+ const record = (v: Value, op: Op, idx: number, blk: Block) =>
563
+ (uses.get(v) ?? uses.set(v, []).get(v)!).push({ op, idx, blk });
564
+ for (const blk of irBlocks) {
565
+ for (const op of blk.ops) {
566
+ op.operands.forEach((v, idx) => record(v, op, idx, blk));
567
+ // An EDGE ARGUMENT is a use role too, and never an access: a capture that reaches a
568
+ // block parameter is live past this block, so the taint closure (`addressFlow`) is what
569
+ // judges it. Recorded at index -1 so it can never be counted as an access — a split there
570
+ // would delete a capture the successor argument still names.
571
+ for (const succ of op.successors ?? []) {
572
+ for (const a of succ.args) {
573
+ record(a, op, -1, blk);
574
+ }
575
+ }
576
+ }
577
+ }
578
+ const minted: Op[] = [];
579
+ const consumed = new Set<Op>();
580
+ for (const capture of laddrs) {
581
+ const at = uses.get(capture.results[0]) ?? [];
582
+ const accesses = at.filter((u) => (u.op.opcode === 'load' || u.op.opcode === 'store') && u.idx === 0);
583
+ // SUB-WORD ONLY, because that is the whole of what the encoding gap forces: `ldr`/`str` DO
584
+ // have an `[sp,#imm]` form, so a WORD access through a copy is some other shape and must
585
+ // not be read as this one. It is also what keeps the outgoing-argument area safe — that
586
+ // guard reads `[sp,#k]` accesses (spMemAccess), which an access through a copy is not, and
587
+ // agbcc stages arguments 5+ there with `str`.
588
+ const subWord = accesses.every((u) => (u.op.attrs.width as number) < 4);
589
+ // A use that is not an access leaves the capture naming the frame base, and the judgement
590
+ // after reports that use itself — an escape, a phi, arithmetic — so it needs no reason
591
+ // here. The WIDTH does: nothing downstream mentions it, so a refused word access would be
592
+ // reported as "a store at [+4]" and the histogram would be asked for the wrong capability.
593
+ if (at.length === 0 || accesses.length !== at.length) {
594
+ continue;
595
+ }
596
+ if (!subWord) {
597
+ splitRefusal.set(capture.results[0], 'a WORD access through the copy, and `ldr`/`str` have an `[sp,#imm]` form');
598
+ continue;
599
+ }
600
+ // Nothing to split when the capture already names ONE object: every access at offset 0 is
601
+ // the frame base itself, which is what the DMA-fill idiom captures.
602
+ if (accesses.every((u) => u.op.attrs.off === 0)) {
603
+ continue;
604
+ }
605
+ for (const u of accesses) {
606
+ const res = mkValue(T.unk(32));
607
+ const at = (capture.attrs.off as number) + (u.op.attrs.off as number);
608
+ const object = mkOp('laddr', { results: [res], attrs: { off: at } });
609
+ u.blk.ops.splice(u.blk.ops.indexOf(u.op), 0, object);
610
+ minted.push(object);
611
+ if (foldedHere.has(capture)) {
612
+ foldedHere.add(object);
613
+ }
614
+ // The ADDRESS operand only — the stored value (operand 1) is passed through
615
+ // untouched, so no slot home moves (ir/core.ts `SlotHomes`). These accesses go through
616
+ // a COPY of `sp` rather than the `[sp,#k]` keys the stamp reads, so none of them
617
+ // carried one to begin with.
618
+ u.op.operands = [res, ...u.op.operands.slice(1)];
619
+ u.op.attrs = { ...u.op.attrs, off: 0 };
620
+ }
621
+ consumed.add(capture);
622
+ }
623
+ if (consumed.size === 0) {
624
+ return { laddrs, splitRefusal };
625
+ }
626
+ for (const blk of irBlocks) {
627
+ blk.ops = blk.ops.filter((op) => !consumed.has(op));
628
+ }
629
+ return { laddrs: [...laddrs.filter((op) => !consumed.has(op)), ...minted], splitRefusal };
630
+ }
631
+
632
+ /** ONE OBJECT PER FRAME OFFSET. Two `laddr` at the same offset name the same storage; two at
633
+ * different offsets are different objects, so width, signedness, escape and the overlap
634
+ * window are decided per offset — one width shared by every capture in the function would
635
+ * declare a halfword spill and a word spill as one object. */
636
+ function frameObjects(laddrs: readonly Op[], owned: FrameRange, fail: Refuse): Map<number, Op[]> {
637
+ const objects = new Map<number, Op[]>();
638
+ for (const op of laddrs) {
639
+ const off = op.attrs.off as number;
640
+ (objects.get(off) ?? objects.set(off, []).get(off)!).push(op);
641
+ }
642
+ // A LOCAL STARTS INSIDE THE RESERVED AREA. Above it are the registers the prologue saved and
643
+ // then the caller's outgoing block, so an address there is an incoming stack argument's or a
644
+ // saved register's — `g(&e)` for a fifth parameter `e` is `add r0, sp, #0x8` over a one-word
645
+ // local area — and no other rule of the audit is about that gap. The TOP itself is also C's
646
+ // one-past-the-end pointer, `g(buf, buf + 8)` over an 8-byte array, and the refusal names both
647
+ // readings.
648
+ for (const off of objects.keys()) {
649
+ if (off === owned.to) {
650
+ fail(
651
+ `the captured address at [sp,#${off}) is the top of the reserved local area of ${owned.to - owned.from} bytes — ` +
652
+ 'one past the end of a local array, or an incoming stack argument, neither of which is modelled',
653
+ );
654
+ }
655
+ if (off > owned.to) {
656
+ fail(
657
+ `the captured address at [sp,#${off}) is above the reserved local area of ${owned.to - owned.from} bytes — ` +
658
+ 'an incoming stack argument or a saved register, whose address is not modelled',
659
+ );
660
+ }
661
+ }
662
+ return objects;
663
+ }
664
+
665
+ /** Where each frame address may flow: `taint`, the object a value may hold the address of on SOME
666
+ * path, and `frameOnEveryPath`, the values that hold a frame address on EVERY path. */
667
+ interface AddressFlow {
668
+ readonly taint: ReadonlyMap<Value, number>;
669
+ readonly frameOnEveryPath: ReadonlySet<Value>;
670
+ }
671
+
672
+ function addressFlow(
673
+ irBlocks: readonly Block[],
674
+ objects: ReadonlyMap<number, readonly Op[]>,
675
+ facts: LiteralAddresses,
676
+ fail: Refuse,
677
+ ): AddressFlow {
678
+ const { defOf, constOf } = facts;
679
+ // Taint maps a value to the OBJECT whose address it may hold, closed over phis: a tainted
680
+ // edge arg taints the receiving block param. A phi that merges two objects has no single
681
+ // answer, and picking one would put an access on the wrong storage: `get(c ? &a : &b)` is
682
+ // `add r0, sp, #0x4 / beq / add r0, sp, #0x8 / bl get`, and refuses here.
683
+ const taint = new Map<Value, number>();
684
+ for (const [off, ops] of objects) {
685
+ for (const op of ops) {
686
+ taint.set(op.results[0], off);
687
+ }
688
+ }
689
+ for (let changed = true; changed;) {
690
+ changed = false;
691
+ for (const blk of irBlocks) {
692
+ for (const op of blk.ops) {
693
+ for (const s of op.successors ?? []) {
694
+ s.args.forEach((arg, i) => {
695
+ const from = taint.get(arg);
696
+ const param = s.block.params[i];
697
+ if (from === undefined || param === undefined) {
698
+ return;
699
+ }
700
+ const had = taint.get(param);
701
+ if (had === from) {
702
+ return;
703
+ }
704
+ if (had !== undefined) {
705
+ fail(`a phi merges the frame objects at [sp,#${had}] and [sp,#${from}] — one value, two objects`);
706
+ }
707
+ taint.set(param, from);
708
+ changed = true;
709
+ });
710
+ }
711
+ }
712
+ }
713
+ }
714
+ // …and the values that hold a frame address on EVERY path, where `taint` answers "on some
715
+ // path": a `laddr`, a phi whose every incoming value is one, or one of those moved by a
716
+ // constant. `p = cnt ? cnt : &o` is tainted and not in here: it may be `cnt`, which may be
717
+ // anything, and reading it as this frame is how a store through it goes unseen.
718
+ const frameOnEveryPath = new Set<Value>(taint.keys());
719
+ const incoming = new Map<Value, Value[]>();
720
+ for (const blk of irBlocks) {
721
+ for (const op of blk.ops) {
722
+ if (op.opcode === 'add' && op.operands.length === 2 && op.operands.some((v) => constOf(v) !== undefined)) {
723
+ frameOnEveryPath.add(op.results[0]);
724
+ }
725
+ for (const s of op.successors ?? []) {
726
+ s.args.forEach((arg, i) => {
727
+ const param = s.block.params[i];
728
+ if (param !== undefined) {
729
+ (incoming.get(param) ?? incoming.set(param, []).get(param)!).push(arg);
730
+ }
731
+ });
732
+ }
733
+ }
734
+ }
735
+ const objectValues = new Set([...objects.values()].flat().map((op) => op.results[0]));
736
+ const holdsFrame = (v: Value): boolean => {
737
+ const d = defOf.get(v);
738
+ if (objectValues.has(v)) {
739
+ return true;
740
+ }
741
+ if (d?.opcode === 'add') {
742
+ return d.operands.some((x) => constOf(x) === undefined && frameOnEveryPath.has(x));
743
+ }
744
+ const ins = incoming.get(v);
745
+ return d === undefined && ins !== undefined && ins.every((a) => frameOnEveryPath.has(a));
746
+ };
747
+ for (let changed = true; changed;) {
748
+ changed = false;
749
+ for (const v of frameOnEveryPath) {
750
+ if (!holdsFrame(v)) {
751
+ frameOnEveryPath.delete(v);
752
+ changed = true;
753
+ }
754
+ }
755
+ }
756
+ return { taint, frameOnEveryPath };
757
+ }
758
+
759
+ /** One fixed-offset access at an object's own address. */
760
+ interface FrameAccess {
761
+ readonly width: number;
762
+ readonly signed: boolean;
763
+ readonly isLoad: boolean;
764
+ }
765
+
766
+ /** A load or store judged an access to the object at `off`, and the address value it reaches the
767
+ * object through: a capture, or a phi that carries one — for a runtime index, the indexed one. */
768
+ interface FrameAccessOp {
769
+ readonly off: number;
770
+ readonly through: Value;
771
+ }
772
+
773
+ /** Every use of every frame object, by offset — what each later stage reads. Each field is
774
+ * declared, with the question it answers, where `classifyFrameUses` builds it. */
775
+ interface FrameUses {
776
+ readonly accesses: ReadonlyMap<number, readonly FrameAccess[]>;
777
+ readonly accessOps: ReadonlyMap<Op, FrameAccessOp>;
778
+ readonly escaped: ReadonlySet<number>;
779
+ readonly sourceStores: ReadonlyMap<number, readonly { readonly op: Op; readonly sink: number }[]>;
780
+ readonly calleeReads: ReadonlyMap<number, { readonly lo: number; readonly hi: number }>;
781
+ readonly filledFrom: ReadonlySet<number>;
782
+ readonly passedToCallee: ReadonlySet<number>;
783
+ readonly passedToWriter: ReadonlySet<number>;
784
+ readonly published: ReadonlySet<number>;
785
+ readonly publishedOutward: ReadonlySet<number>;
786
+ readonly publishedToWriter: ReadonlySet<number>;
787
+ readonly arg0Callees: ReadonlyMap<number, ReadonlySet<string | null>>;
788
+ readonly returnTemps: ReadonlyMap<number, readonly Op[]>;
789
+ readonly useCount: ReadonlyMap<number, number>;
790
+ readonly indexed: ReadonlyMap<number, readonly { readonly width: number; readonly signed: boolean }[]>;
791
+ readonly members: readonly { readonly at: number; readonly width: number }[];
792
+ readonly memberRefusal: string | undefined;
793
+ }
794
+
795
+ /** Did the address at `off` reach something that may write the frame back: a callee that is not a
796
+ * bounded block transfer reading through it, or memory a writer may hold. */
797
+ function mayWrite(uses: FrameUses, off: number): boolean {
798
+ return uses.passedToWriter.has(off) || uses.publishedToWriter.has(off);
799
+ }
800
+
801
+ /** Judges every use of a tainted value, against the object it names. */
802
+ function classifyFrameUses({
803
+ irBlocks,
804
+ objects,
805
+ flow: { taint, frameOnEveryPath },
806
+ facts,
807
+ splitRefusal,
808
+ oneObject,
809
+ target,
810
+ fail,
811
+ }: {
812
+ irBlocks: readonly Block[];
813
+ objects: ReadonlyMap<number, readonly Op[]>;
814
+ flow: AddressFlow;
815
+ facts: FrameAddressFacts;
816
+ splitRefusal: ReadonlyMap<Value, string>;
817
+ oneObject: FrameRange | undefined;
818
+ target: TargetDescription;
819
+ fail: Refuse;
820
+ }): FrameUses {
821
+ const { defOf } = facts;
822
+ const accesses = new Map<number, FrameAccess[]>();
823
+ // …and every load and store judged an access, by the object it reaches: through a capture, a
824
+ // phi that carries one, or a runtime index into one. A later stage that rewrites or qualifies
825
+ // "the accesses to the object" reads this list rather than recognizing them again, since a
826
+ // narrower recognizer misses exactly the phi-carried ones `taint` admits.
827
+ const accessOps = new Map<Op, FrameAccessOp>();
828
+ // TWO QUESTIONS, not one. `escaped` asks whether the address LEFT the function, in the order the
829
+ // escapes were found — the order windows and refusals are read in. Whether it reached something
830
+ // that could write the frame BACK — what every "a callee may write any frame offset" refusal
831
+ // rests on — is `mayWrite`, read off the splits below. A store into a device's SOURCE register
832
+ // answers yes to the first and no to the second: the hardware reads the object, and the
833
+ // DMA-fill idiom this capability was built for (`vu16 tmp; DmaSet(n, &tmp, …)`) is exactly
834
+ // that shape.
835
+ const escaped = new Set<number>();
836
+ // …and for the others, the stores that handed the address to a source register, which is
837
+ // where `readWindow` reads how far the device reads
838
+ const sourceStores = new Map<number, { op: Op; sink: number }[]>();
839
+ // …and a CALL that only reads is the same answer: the address handed to a block transfer
840
+ // (`blockTransferCalls`) as its source, with a literal control word, is read `[lo, hi)` from
841
+ // the object and written by nobody. `CPU_FILL`'s `vu32 tmp = v; CpuSet(&tmp, dest, …)` is the
842
+ // shape. A control word this cannot read, or any other argument position, is a callee that
843
+ // may write.
844
+ const calleeReads = new Map<number, { lo: number; hi: number }>();
845
+ // …and of those, the objects a transfer FILLS from — a fixed source, the `tmp` of every fill
846
+ // macro — which the `volatile` stamp keys on beside `published`
847
+ const filledFrom = new Set<number>();
848
+ // …and the two escapes SPLIT, because each decides something the other does not.
849
+ // `passedToCallee` is the address handed to a callee as an argument — the one escape whose
850
+ // writer this frontend can name, which is what the struct-return premise re-check rests on, and
851
+ // what tells a refusal message which escape it is talking about. `published` is the address
852
+ // WRITTEN TO MEMORY, how the DMA idiom hands the object to hardware, and what `volatile` at the
853
+ // stamp keys on. Reading either off `escaped` gets the other one wrong.
854
+ const passedToCallee = new Set<number>();
855
+ // …and of those, the callees that may WRITE through it: every one but a bounded block transfer
856
+ // reading its source (`calleeReads`), which names no writer
857
+ const passedToWriter = new Set<number>();
858
+ const published = new Set<number>();
859
+ // …and `published` SPLITS AGAIN, because it answers two questions of different strengths and
860
+ // the weaker one may not be read as the stronger. "Did the address reach memory at all" is
861
+ // what `volatile` keys on: a halfword of it written anywhere is still a write this function
862
+ // does not own, and the qualifier has to survive it. "Did the address reach memory OUTSIDE
863
+ // this frame, whole" is what the licence is re-proven against (`recheckWholeFramePremise`), and
864
+ // it is strictly narrower — a store back into the object's own bytes publishes the address to
865
+ // nobody, and half an address is not the address. The pre-lift scan that grants the licence
866
+ // (`frameBasePublishedToMemory`) admits exactly the narrow one, so the re-proof must too: an
867
+ // audit that re-proves a WEAKER premise than the licence it audits is not a containment, it
868
+ // is a second, wider door into the same acceptance.
869
+ const publishedOutward = new Set<number>();
870
+ // …and the third question `published` answers, of what HOLDS the address it wrote: anything but
871
+ // a device's read-only source register (`facts.readsThrough`) may write through it — an
872
+ // interrupt reading a global, a device writing its destination — at any point in this function,
873
+ // so a re-read of the object is a read the recompile must make too.
874
+ const publishedToWriter = new Set<number>();
875
+ // …and WHICH CALLEE took it at ARGUMENT 0, because that is the one position a hidden
876
+ // struct-return pointer can occupy and that callee's declared RETURN TYPE is the one fact that
877
+ // tells an out-parameter from one. A `call`'s operand index IS the argument index here (the
878
+ // `bl` arm reads r0..r<argc-1> in order), so an address that appears in no entry of this map
879
+ // was handed over at r1 or above every time — an argument the source wrote. `null` is the
880
+ // narrowing of an unstamped `target` attr — the `bl`/`blx` lowering always stamps one — and
881
+ // reads as a callee nothing can be declared about, so it refuses.
882
+ const arg0Callees = new Map<number, Set<string | null>>();
883
+ // …except where the call's own declaration says argument 0 IS the hidden pointer (the frontend
884
+ // stamps `sret` on a call to a callee declared to return a struct through memory): that object
885
+ // is the callee's return storage, a local of the declared type. `useCount` counts every use of
886
+ // each object, so a return temp this function does anything else with is told apart.
887
+ const returnTemps = new Map<number, Op[]>();
888
+ const useCount = new Map<number, number>();
889
+ // The accesses through a runtime-indexed address, per object — kept apart from `accesses`,
890
+ // which types the object at its own offset: `a[i]` says what one ELEMENT is, not what sits at
891
+ // `a`.
892
+ const indexed = new Map<number, { width: number; signed: boolean }[]>();
893
+ for (const off of objects.keys()) {
894
+ accesses.set(off, []);
895
+ }
896
+ // …and with `oneObject`, every fixed-offset access as the frame bytes it touches, since then an
897
+ // access at [+k] through a capture is a member of the one object rather than a second object
898
+ const members: { at: number; width: number }[] = [];
899
+ // …and without it, the first such access, which the per-object model refuses once the model is
900
+ // chosen (`chooseFrameModel`)
901
+ let memberRefusal: string | undefined;
902
+ const scalar = (kind: string, op: Op, off: number, v: Value): void => {
903
+ if (oneObject !== undefined) {
904
+ members.push({ at: off + (op.attrs.off as number), width: op.attrs.width as number });
905
+ return;
906
+ }
907
+ if ((op.attrs.off as number) !== 0) {
908
+ memberRefusal ??=
909
+ `a ${kind} at [+${op.attrs.off}] through the captured address — ` +
910
+ (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled');
911
+ }
912
+ };
913
+ for (const blk of irBlocks) {
914
+ for (const op of blk.ops) {
915
+ for (const [idx, v] of op.operands.entries()) {
916
+ const off = taint.get(v);
917
+ if (off === undefined) {
918
+ continue;
919
+ }
920
+ useCount.set(off, (useCount.get(off) ?? 0) + 1);
921
+ if (op.opcode === 'load' && idx === 0) {
922
+ scalar('load', op, off, v);
923
+ accessOps.set(op, { off, through: v });
924
+ accesses.get(off)!.push({
925
+ width: op.attrs.width as number,
926
+ signed: (op.attrs.signed as boolean) ?? false,
927
+ isLoad: true,
928
+ });
929
+ continue;
930
+ }
931
+ if (op.opcode === 'store' && idx === 0) {
932
+ scalar('store', op, off, v);
933
+ accessOps.set(op, { off, through: v });
934
+ accesses.get(off)!.push({ width: op.attrs.width as number, signed: false, isLoad: false });
935
+ continue;
936
+ }
937
+ if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
938
+ escaped.add(off); // the address ESCAPES as a value — the point of the capability
939
+ const handedTo = op.opcode === 'store' ? facts.readsThrough(op) : [];
940
+ const read = op.opcode === 'call' ? transferRead(op, idx, off, target, facts) : undefined;
941
+ if (read !== undefined) {
942
+ const had = calleeReads.get(off) ?? read;
943
+ calleeReads.set(off, { lo: Math.min(had.lo, read.lo), hi: Math.max(had.hi, read.hi) });
944
+ if (read.fill) {
945
+ filledFrom.add(off);
946
+ }
947
+ } else if (handedTo.length === 0) {
948
+ if (op.opcode === 'store') {
949
+ publishedToWriter.add(off);
950
+ }
951
+ } else {
952
+ const stores = sourceStores.get(off) ?? sourceStores.set(off, []).get(off)!;
953
+ handedTo.forEach((sink) => stores.push({ op, sink }));
954
+ }
955
+ if (op.opcode === 'call') {
956
+ passedToCallee.add(off);
957
+ if (read === undefined) {
958
+ passedToWriter.add(off);
959
+ }
960
+ if (idx === 0 && op.attrs.sret === true) {
961
+ (returnTemps.get(off) ?? returnTemps.set(off, []).get(off)!).push(op);
962
+ } else if (idx === 0) {
963
+ const t = op.attrs.target;
964
+ const cs = arg0Callees.get(off) ?? new Set<string | null>();
965
+ cs.add(typeof t === 'string' ? t : null);
966
+ arg0Callees.set(off, cs);
967
+ }
968
+ } else {
969
+ published.add(off); // written to memory — the DMA idiom's `*dmaReg = &tmp`
970
+ // The licence's two conditions, asked of the IR: the WHOLE address (a word), and a
971
+ // destination that is not this frame. `taint` answers the second exactly — it holds
972
+ // every value that may carry one of this function's frame addresses, which is both
973
+ // spellings the pre-lift scan excludes by name (`str rS, [sp]` and a store through
974
+ // another held capture, since at a one-word frame [sp,#0] IS the object).
975
+ if ((op.attrs.width as number) === 4 && taint.get(op.operands[0]) === undefined) {
976
+ publishedOutward.add(off);
977
+ }
978
+ }
979
+ continue;
980
+ }
981
+ // A RUNTIME INDEX into the object: `mov r1, sp / add r0, r1, r4 / ldrb r0, [r0]` is
982
+ // agbcc's `a[i]` on a `u8 a[n]` local. The sum names no fixed offset, so it is not a
983
+ // capture of its own; what it may do is judged here, whole, and the object it indexes is
984
+ // judged with the rest (`objectShapes`).
985
+ const other = op.opcode === 'add' && op.operands.length === 2 ? op.operands[1 - idx] : undefined;
986
+ if (other !== undefined && taint.get(other) === undefined && defOf.get(other)?.opcode !== 'const') {
987
+ indexedAccess(irBlocks, { indexed, accessOps }, { off, through: v }, op.results[0], fail);
988
+ continue;
989
+ }
990
+ // A CONSTANT move left after the fold (`foldMovedCaptures`) moves a capture a phi carried.
991
+ // When the phi also carries a pointer from outside the frame — `if (!info) info = &local;
992
+ // info->x` — the missing capability is that merge, not a walk through the frame.
993
+ if (other !== undefined && defOf.get(other)?.opcode === 'const' && !frameOnEveryPath.has(v)) {
994
+ fail(
995
+ `the captured address at [sp,#${off}) reaches a phi that merges it with a pointer from outside the ` +
996
+ 'frame, and is then moved by a constant — a field of what may or may not be a local is not modelled',
997
+ );
998
+ }
999
+ // …and when every value the phi carries is this frame's, it is a pointer stepped through
1000
+ // the frame, which names a different object on each trip.
1001
+ if (other !== undefined && defOf.get(other)?.opcode === 'const') {
1002
+ fail(
1003
+ `the captured address at [sp,#${off}) reaches a phi and is then moved by a constant — ` +
1004
+ 'a pointer stepped through the frame names no one object',
1005
+ );
1006
+ }
1007
+ fail(`the captured address flows into \`${op.opcode}\` — not an access, an escape, or a phi`);
1008
+ }
1009
+ }
1010
+ }
1011
+ return {
1012
+ accesses,
1013
+ accessOps,
1014
+ escaped,
1015
+ sourceStores,
1016
+ calleeReads,
1017
+ filledFrom,
1018
+ passedToCallee,
1019
+ passedToWriter,
1020
+ published,
1021
+ publishedOutward,
1022
+ publishedToWriter,
1023
+ arg0Callees,
1024
+ returnTemps,
1025
+ useCount,
1026
+ indexed,
1027
+ members,
1028
+ memberRefusal,
1029
+ };
1030
+ }
1031
+
1032
+ // The bytes a block transfer that took the address at `idx` reads, when `idx` is its source and
1033
+ // its control word is a literal.
1034
+ function transferRead(
1035
+ op: Op,
1036
+ idx: number,
1037
+ off: number,
1038
+ target: TargetDescription,
1039
+ facts: LiteralAddresses,
1040
+ ): { lo: number; hi: number; fill: boolean } | undefined {
1041
+ const callee = op.attrs.target;
1042
+ const calls = target.capabilities.blockTransferCalls;
1043
+ const call = typeof callee === 'string' && calls && Object.hasOwn(calls, callee) ? calls[callee] : undefined;
1044
+ const control = call === undefined || idx !== call.source ? undefined : op.operands[call.control];
1045
+ const word = control === undefined ? undefined : facts.constOf(control);
1046
+ if (call === undefined || word === undefined) {
1047
+ return undefined;
1048
+ }
1049
+ const read = blockTransferRead(call, word);
1050
+ return { ...sourceReach(read, off), fill: read.walk === 'fixed' };
1051
+ }
1052
+
1053
+ // Records every access through `sum`, a runtime index into the object at `off`.
1054
+ function indexedAccess(
1055
+ irBlocks: readonly Block[],
1056
+ {
1057
+ indexed,
1058
+ accessOps,
1059
+ }: { indexed: Map<number, { width: number; signed: boolean }[]>; accessOps: Map<Op, FrameAccessOp> },
1060
+ object: FrameAccessOp,
1061
+ sum: Value,
1062
+ fail: Refuse,
1063
+ ): void {
1064
+ const { off } = object;
1065
+ const got = indexed.get(off) ?? indexed.set(off, []).get(off)!;
1066
+ for (const blk of irBlocks) {
1067
+ for (const op of blk.ops) {
1068
+ const roles = [
1069
+ ...op.operands.flatMap((v, i) => (v === sum ? [i] : [])),
1070
+ ...(op.successors ?? []).flatMap((s) => (s.args.includes(sum) ? [-1] : [])),
1071
+ ];
1072
+ for (const i of roles) {
1073
+ if ((op.opcode !== 'load' && op.opcode !== 'store') || i !== 0 || (op.attrs.off as number) !== 0) {
1074
+ fail(
1075
+ `a runtime index into the object at [sp,#${off}) flows into \`${i === -1 ? 'a phi' : op.opcode}\` — ` +
1076
+ 'only a load or store at the indexed address is modelled',
1077
+ );
1078
+ }
1079
+ got.push({ width: op.attrs.width as number, signed: op.opcode === 'load' && op.attrs.signed === true });
1080
+ accessOps.set(op, object);
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ /** The bytes an escape may reach, `[lo, hi)` from the object's own offset, and how it reads them
1087
+ * as a refusal words it. */
1088
+ interface ReadWindow {
1089
+ readonly lo: number;
1090
+ readonly hi: number;
1091
+ readonly why: string;
1092
+ }
1093
+
1094
+ /** Each op's block and index, over the IR as the classification judged it. */
1095
+ function opPositions(irBlocks: readonly Block[]): Map<Op, { blk: Block; i: number }> {
1096
+ const at = new Map<Op, { blk: Block; i: number }>();
1097
+ for (const blk of irBlocks) {
1098
+ blk.ops.forEach((op, i) => at.set(op, { blk, i }));
1099
+ }
1100
+ return at;
1101
+ }
1102
+
1103
+ /** The read window of every escaped object, in the order the classification found the escapes. */
1104
+ function escapeWindows(
1105
+ irBlocks: readonly Block[],
1106
+ uses: FrameUses,
1107
+ flow: AddressFlow,
1108
+ facts: FrameAddressFacts,
1109
+ target: TargetDescription,
1110
+ ): Map<number, ReadWindow> {
1111
+ const at = opPositions(irBlocks);
1112
+ return new Map([...uses.escaped].map((off) => [off, readWindow(off, { at, uses, flow, facts, target })] as const));
1113
+ }
1114
+
1115
+ const unbounded = (why: string): ReadWindow => ({ lo: -Infinity, hi: Infinity, why });
1116
+
1117
+ /** THE BYTES AN ESCAPE MAY REACH, as `[lo, hi)` relative to the object's own offset. Anything
1118
+ * that may write, and anything this cannot bound, reaches the whole frame — save a callee
1119
+ * handed only its own return storage, which writes the struct it returns (`returnTemps`). A
1120
+ * device that only reads is bounded by its channel's control halfword (`readSourceControl`),
1121
+ * per TRANSFER: the device reads the object on every arm of the channel from the store that
1122
+ * handed it the address until a later word store to the same source register replaces it, so
1123
+ * the control stores that bound it are the ones reachable in between. Each has to be a literal
1124
+ * the target decodes. A store through a pointer this cannot resolve — one that is this frame's
1125
+ * on only some paths included (`frameOnEveryPath`) — may BE the control halfword, so it leaves
1126
+ * the read unbounded — as does a transfer never armed here at all. An unbounded device read
1127
+ * says which of those it met (`why`): each is a different capability to build. A block transfer
1128
+ * that took the address as its source adds the bytes its control word reads (`calleeReads`).
1129
+ *
1130
+ * A NAMED SYMBOL PLUS A CONSTANT IS RESOLVED WHERE THE SYMBOL MAP PLACES THE NAME, and then
1131
+ * exactly, by the overlap test below. agbcc's alias model never lets a C identifier plus a
1132
+ * constant meet a literal address (alias.c:812-819, 1064-1068), but nothing in the asm says a
1133
+ * name is a C identifier: a disassembly spells an I/O register the way it spells a global
1134
+ * (`.word REG_VCOUNT`). So a name the map does not place is an unresolved pointer here.
1135
+ *
1136
+ * THE PREMISE, stated once and not checkable from one function: a callee or an interrupt
1137
+ * handler arms only a transfer it set up itself, source register first. So a call on the path
1138
+ * does not unbound the read, and neither does an interrupt at any instruction: whatever re-arms
1139
+ * the channel with this frame's address still in the source register is this function's own
1140
+ * store, and the walk sees every one of those. */
1141
+ function readWindow(
1142
+ off: number,
1143
+ {
1144
+ at,
1145
+ uses,
1146
+ flow,
1147
+ facts,
1148
+ target,
1149
+ }: {
1150
+ at: ReadonlyMap<Op, { blk: Block; i: number }>;
1151
+ uses: FrameUses;
1152
+ flow: AddressFlow;
1153
+ facts: FrameAddressFacts;
1154
+ target: TargetDescription;
1155
+ },
1156
+ ): ReadWindow {
1157
+ // a callee handed only its own return storage writes the struct it returns and nothing else
1158
+ const temps = uses.returnTemps.get(off);
1159
+ if (temps !== undefined && uses.useCount.get(off) === temps.length) {
1160
+ return { lo: 0, hi: returnedSize(temps[0]), why: 'that returns its struct into it' };
1161
+ }
1162
+ const control = target.capabilities.readSourceControl;
1163
+ const stores = uses.sourceStores.get(off);
1164
+ const called = uses.calleeReads.get(off);
1165
+ if (!mayWrite(uses, off) && stores === undefined && called !== undefined) {
1166
+ return { lo: called.lo, hi: called.hi, why: 'that reads through it' };
1167
+ }
1168
+ if (mayWrite(uses, off) || stores === undefined || control === undefined) {
1169
+ return unbounded('that reads through it');
1170
+ }
1171
+ const little = target.capabilities.endianness === 'little';
1172
+ const halves: number[] = [];
1173
+ for (const { op: handed, sink } of stores) {
1174
+ const cnt = sink + control.offset;
1175
+ let armed = false;
1176
+ const entered = new Set<Block>();
1177
+ const work: [Block, number][] = [[at.get(handed)!.blk, at.get(handed)!.i + 1]];
1178
+ walk: while (work.length > 0) {
1179
+ const [blk, from] = work.pop()!;
1180
+ for (let i = from; i < blk.ops.length; i++) {
1181
+ const op = blk.ops[i];
1182
+ if (op.opcode !== 'store') {
1183
+ continue;
1184
+ }
1185
+ const base = facts.literalAddrOf(op.operands[0]);
1186
+ if (base === undefined) {
1187
+ if (!flow.frameOnEveryPath.has(op.operands[0])) {
1188
+ return unbounded('a later store through an unresolved pointer may re-arm');
1189
+ }
1190
+ continue;
1191
+ }
1192
+ const a = base + (op.attrs.off as number);
1193
+ const w = op.attrs.width as number;
1194
+ if (a === sink && w === 4) {
1195
+ continue walk;
1196
+ }
1197
+ if (a + w <= cnt || a >= cnt + 2) {
1198
+ continue;
1199
+ }
1200
+ const v = facts.defOf.get(op.operands[1]);
1201
+ if (v?.opcode !== 'const' || a > cnt || a + w < cnt + 2) {
1202
+ return unbounded('whose control word is not a literal');
1203
+ }
1204
+ const shift = 8 * (little ? cnt - a : a + w - cnt - 2);
1205
+ halves.push(((v.attrs.value as number) >>> shift) & 0xffff);
1206
+ armed = true;
1207
+ }
1208
+ for (const s of blk.ops[blk.ops.length - 1]?.successors ?? []) {
1209
+ if (!entered.has(s.block)) {
1210
+ entered.add(s.block);
1211
+ work.push([s.block, 0]);
1212
+ }
1213
+ }
1214
+ }
1215
+ if (!armed) {
1216
+ return unbounded('this function never arms');
1217
+ }
1218
+ }
1219
+ let { lo, hi } = called ?? { lo: 0, hi: 0 };
1220
+ for (const h of halves) {
1221
+ const read = sourceControlRead(control, h);
1222
+ if (read === null) {
1223
+ return unbounded('whose control word bounds nothing');
1224
+ }
1225
+ const reach = sourceReach(read, off);
1226
+ lo = Math.min(lo, reach.lo);
1227
+ hi = Math.max(hi, reach.hi);
1228
+ }
1229
+ return { lo, hi, why: 'that reads through it' };
1230
+ }
1231
+
1232
+ /** Which model the audit judges the frame in. `refused` is the one mutable fact the audit
1233
+ * carries across stages: set when a shape the per-object model refuses is met while the
1234
+ * one-object answer is on offer, and read by the relift test. */
1235
+ interface ModelChoice {
1236
+ readonly onOffer: boolean;
1237
+ refused: boolean;
1238
+ /** refuses `why` where it stands, or, with the one-object answer on offer, asks for it instead */
1239
+ shapeRefused(why: string): void;
1240
+ /** `why`, naming the writer that withheld the one-object answer where one did — for a refusal
1241
+ * that answer would otherwise have taken */
1242
+ withheld(why: string): string;
1243
+ }
1244
+
1245
+ /** Why a writer of the frame keeps the one-object answer from holding it, or null. The one object
1246
+ * is every byte of the declared area, so what a holder writes through any address inside it is
1247
+ * bytes one declaration owns, at offsets one array makes adjacent — below them is the outgoing
1248
+ * block and above them the saved registers, neither a local. That is the whole-area argument the
1249
+ * untyped object is sized by, and it holds the same escapes here (`wholeAreaEscapeRefusal`). Not
1250
+ * kept besides is a callee's RETURN STORAGE: the struct it returns through a hidden pointer is its
1251
+ * declared type, not bytes of an array. */
1252
+ function writerNotKept(uses: FrameUses, returnsWithoutHiddenPointer: (callee: string) => boolean): string | null {
1253
+ for (const off of [...uses.escaped].filter((o) => mayWrite(uses, o))) {
1254
+ const at = `the captured address at [sp,#${off})`;
1255
+ const temp = uses.returnTemps.get(off)?.[0];
1256
+ if (temp !== undefined) {
1257
+ return `${at} is where \`${temp.attrs.target}\` returns its struct`;
1258
+ }
1259
+ const why = wholeAreaEscapeRefusal(off, uses, returnsWithoutHiddenPointer);
1260
+ if (why !== null) {
1261
+ return `${at}: ${why}`;
1262
+ }
1263
+ }
1264
+ return null;
1265
+ }
1266
+
1267
+ /** THE MODEL IS CHOSEN BEFORE ANY SHAPE IS JUDGED. Where one escape reaches without bound and the
1268
+ * one-object answer can hold every writer (`writerNotKept`), that answer is on offer — two
1269
+ * out-parameters handed to one callee as readily as a device read — and the shapes the
1270
+ * per-object model refuses are ones it can hold: a member at [+k] through a capture, two widths
1271
+ * or two signednesses at one address, a runtime index, overlapping objects, a second object
1272
+ * beside an untyped one, an object over a slot. So there each of those refusals asks for that
1273
+ * answer instead of declining (`shapeRefused`), and the second audit judges the bytes as one
1274
+ * object by its own rules — two types at one byte still refuse there. Where the answer is not on offer, each refuses where it stands. */
1275
+ function chooseFrameModel(
1276
+ oneObject: FrameRange | undefined,
1277
+ uses: FrameUses,
1278
+ windows: ReadonlyMap<number, ReadWindow>,
1279
+ returnsWithoutHiddenPointer: (callee: string) => boolean,
1280
+ fail: Refuse,
1281
+ ): ModelChoice {
1282
+ const unbounded =
1283
+ oneObject === undefined && [...windows.values()].some((w) => w.lo === -Infinity && w.hi === Infinity);
1284
+ const writer = unbounded ? writerNotKept(uses, returnsWithoutHiddenPointer) : null;
1285
+ const onOffer = unbounded && writer === null;
1286
+ const model: ModelChoice = {
1287
+ onOffer,
1288
+ refused: false,
1289
+ shapeRefused: (why) => {
1290
+ if (!onOffer) {
1291
+ fail(model.withheld(why));
1292
+ }
1293
+ model.refused = true;
1294
+ },
1295
+ // A refusal the one-object answer would have taken names the writer that withheld it: the
1296
+ // shape alone is one the answer holds, so the shape alone does not say why this declined.
1297
+ withheld: (why) => (writer === null ? why : `${why} — and the one object cannot hold every writer: ${writer}`),
1298
+ };
1299
+ if (uses.memberRefusal !== undefined) {
1300
+ model.shapeRefused(uses.memberRefusal);
1301
+ }
1302
+ return model;
1303
+ }
1304
+
1305
+ /** THE ACCEPTANCE'S PREMISE, RE-ASKED OF THE IR. `capturedObjectIsTheWholeFrame` is a reading
1306
+ * of the TEXT and it is the one thing in this file that switches a refusal OFF, so the two
1307
+ * facts it claims are re-proven here, where they are exact, rather than left to the
1308
+ * approximation that licensed them. Neither is a second opinion on the same evidence: the
1309
+ * scan asks what a REGISTER holds at a `bl`; this asks what the finished function does with
1310
+ * the OBJECT.
1311
+ *
1312
+ * THE ADDRESS ESCAPED. The whole licence is "something outside this function is holding the
1313
+ * address of this frame", and the pre-lift scan can say that of a register whose value never
1314
+ * reaches a call operand or a store — a declared arity trims it away, or the register is dead
1315
+ * by the time the call is built. When it does, [sp,#0] has been re-modelled as an addressable
1316
+ * object on no evidence at all, and the outgoing argument that really lived there is gone
1317
+ * from the call. EITHER escape re-proves it, because either one licensed it: a call taking
1318
+ * the address, or a store publishing it outward. They are asked as one question because they
1319
+ * license one thing.
1320
+ *
1321
+ * AND EACH ARM IS AT MOST AS WIDE AS THE LICENCE IT RE-PROVES, which is what makes this a
1322
+ * containment rather than a second door. The callee arm is narrower for free — a `call`
1323
+ * operand is the address reaching a callee, which is what the scan approximated. The publish
1324
+ * arm is `publishedOutward` and not `published` for the same reason spelled out there: read
1325
+ * off the wider set, this re-proof accepts a halfword store and a store back into the
1326
+ * object's own bytes, both of which the licence refuses by name — so a frame the pre-lift
1327
+ * scan would never have licensed passes the check that exists to re-prove the licence.
1328
+ *
1329
+ * THE CONTAINMENT IS STRICT AND THE SLACK IS ON THIS SIDE. `taint` is whole-function where the
1330
+ * pre-lift walk is block-local, so a base captured in an earlier block is refused HERE and
1331
+ * accepted THERE. That is the only direction the two can differ in without a wrong answer
1332
+ * reaching a caller, and it is structural rather than lucky: this set is built from a superset
1333
+ * of the facts the walk has. `packages/core/test/thumb-frontend.test.ts` carries the input.
1334
+ *
1335
+ * NOT A STRUCT-RETURN TEMP, which `hiddenReturnPointerStands` asks. */
1336
+ function recheckWholeFramePremise({
1337
+ uses,
1338
+ returnsWithoutHiddenPointer,
1339
+ fail,
1340
+ }: {
1341
+ uses: FrameUses;
1342
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1343
+ fail: Refuse;
1344
+ }): void {
1345
+ if (!uses.passedToCallee.has(0) && !uses.publishedOutward.has(0)) {
1346
+ fail(
1347
+ 'the one-word-frame proof licensed this lift on the frame base escaping, and in the ' +
1348
+ 'lifted function no call takes it and no word store publishes it outside this frame — ' +
1349
+ 'so nothing rules out an outgoing stack argument at [sp,#0]',
1350
+ );
1351
+ }
1352
+ const writtenHere = uses.accesses.get(0)?.some((a) => !a.isLoad) === true;
1353
+ const whyItStands = writtenHere ? null : hiddenReturnPointerStands(0, uses.arg0Callees, returnsWithoutHiddenPointer);
1354
+ if (whyItStands !== null) {
1355
+ fail(`the one-word frame is never written here, and ${whyItStands}`);
1356
+ }
1357
+ }
1358
+
1359
+ /** NOT A STRUCT-RETURN TEMP. A one-word frame rules out agbcc's block-copy bases (each needs
1360
+ * two words) but NOT the hidden return pointer of a <=4-byte non-integer-like struct, which is
1361
+ * exactly one word: `struct S4 { char a,b,c,d; }; struct S4 s = mk(x);` compiles to `add
1362
+ * sp,#-4 / mov r0,sp / bl mk / ldr r0,[sp]`, instruction for instruction an out-parameter
1363
+ * call. Left alone that lifted as `mk(&sp0, a0)` — a call the real prototype rejects.
1364
+ *
1365
+ * TWO facts rule it out and either will do, because a return temp is storage the CALLEE owns
1366
+ * outright: it is written only by the callee, and the callee RETURNS the struct THROUGH IT.
1367
+ * So a store of our own says the object is one this function fills; and a callee whose RETURN
1368
+ * is known to need no hidden pointer says the same by the ABI — a function that returns
1369
+ * nothing, or returns in a register, has no such pointer to be given, whatever sits in r0.
1370
+ *
1371
+ * AND THE QUESTION IS PER-CALL, which is what bounds how far it has to be asked: the pointer
1372
+ * is argument 0, always (compiled — `struct S4 mk3(int,int,int)` puts sp in r0 and shifts all
1373
+ * three real arguments up), so an address NO call takes at argument 0 cannot be one, whatever
1374
+ * else the function does with it. That is the whole of this function and it is why the
1375
+ * block-copy idiom `memcpy(dst, buf, sizeof buf)` — buffer at argument 1 — needs no declaration
1376
+ * at all. Per-call and not per-object: an address handed over at argument 1 somewhere leaves the
1377
+ * call that takes it at argument 0 exactly as ambiguous as before, so position acquits a call
1378
+ * rather than an object.
1379
+ *
1380
+ * THE THIRD IS A DECLARATION, NOT AN INFERENCE, and so is its converse: a callee declared to
1381
+ * return a struct through memory, whose call the frontend stamps `sret`, makes the object that
1382
+ * call's return storage (`returnTemps`). Those two are the only answers here read off something
1383
+ * other than the instruction stream. The frontend's call declarations answer the third
1384
+ * (`FrameObjectAudit.returnsWithoutHiddenPointer`, which aggregate.ts's
1385
+ * `returnsWithoutHiddenPointer` decides), from the project's own `returnsVoid`, from a struct its
1386
+ * declaration returns that the target hands back in a register, or from the `returns` of a
1387
+ * signature the C standard fixes — the same table whose `params` decide a call's arity. It is
1388
+ * asked of EVERY callee that took the address at argument 0, because the object gets one
1389
+ * decision: one callee about whose return nothing is known leaves the ambiguity standing and the
1390
+ * refusal fires.
1391
+ *
1392
+ * AN ARITY CANNOT ANSWER IT, which is worth saying because the count is right there and looks
1393
+ * like evidence: a hidden pointer does set one argument register more than the callee
1394
+ * declares, but the register count is what the machine WROTE, and a register already holding
1395
+ * this function's own incoming parameter is written by nobody. Compiled: `void f(const void
1396
+ * *a, const void *b){ struct Blob64 s = makeblob(b); }` emits `add sp,#-0x40 / mov r0,sp /
1397
+ * bl makeblob` — one written register against one declared parameter — so counting registers
1398
+ * reads a real struct return as an out-parameter and declares the callee's own storage as a
1399
+ * local. The question is about the RETURN and only a statement about the return decides it.
1400
+ *
1401
+ * WHAT IT COSTS WHEN THE DECLARATION IS WRONG, measured rather than compared. On the `sret`
1402
+ * shape above, with `mk` (which really returns `struct S4`) declared `params: 1,
1403
+ * returnsVoid: true`, the lift succeeds and emits `s32 sret(s32 a0) { s32 sp0; mk(&sp0);
1404
+ * return (u8)sp0; }` — a compiling, plausible, WRONG program with the real argument dropped,
1405
+ * where a loud decline stood. Not a smaller cost than a wrong ARITY, either: the same entry
1406
+ * supplies both facts, so a wrong `returnsVoid` drops the argument too, and the frame re-model
1407
+ * is the silent half. The trade is accepted because there IS no other discriminator: compiled
1408
+ * through
1409
+ * the benchmark's own agbcc command, the hidden struct return and the out-parameter emit the
1410
+ * same instructions in the same order, the slot is read back at a scalar width in both, and
1411
+ * in both the value read back is what the function returns — so an asm-side corroboration
1412
+ * would be a rule with no discriminating input. The mitigation is that under-declaring is the
1413
+ * safe direction (a callee whose return nothing describes still declines) and that `FnProto`
1414
+ * says so at the field.
1415
+ *
1416
+ * The residual cost is stated rather than hidden: an OUTPUT-only parameter taken at argument
1417
+ * 0 of a callee the project has NOT declared is still byte-for-byte a struct return, and
1418
+ * still declines with it.
1419
+ *
1420
+ * ONE SOURCE FOR THE DECISION AND ITS REASON, because every arm of this audit asks it and a
1421
+ * predicate beside a message is two things that can disagree. Returns why the pointer is not
1422
+ * ruled out — the caller frames it for its own arm — or null. */
1423
+ function hiddenReturnPointerStands(
1424
+ off: number,
1425
+ arg0Callees: FrameUses['arg0Callees'],
1426
+ returnsWithoutHiddenPointer: (callee: string) => boolean,
1427
+ ): string | null {
1428
+ const cs = arg0Callees.get(off);
1429
+ if (cs === undefined || cs.size === 0) {
1430
+ return null;
1431
+ }
1432
+ const unknown = [...cs].filter((c) => c === null || !returnsWithoutHiddenPointer(c));
1433
+ if (unknown.length === 0) {
1434
+ return null;
1435
+ }
1436
+ return (
1437
+ `\`${unknown.map((c) => c ?? '?').join('`, `')}\` takes it at argument 0 and nothing says ` +
1438
+ 'what that callee returns — a struct returned through a hidden pointer is handed this same frame'
1439
+ );
1440
+ }
1441
+
1442
+ // TWO MODELS FOR ONE BYTE is a silent disagreement: the slot model keeps an SSA slot in a
1443
+ // register, so a store through an object over the same bytes would never be seen there.
1444
+ function overlaps(a: number, aw: number, b: number, bw: number): boolean {
1445
+ return a < b + bw && b < a + aw;
1446
+ }
1447
+
1448
+ function failIfSlotKeysIt(
1449
+ off: number,
1450
+ width: number,
1451
+ {
1452
+ usedSlotOffsets,
1453
+ objects,
1454
+ foldedHere,
1455
+ fail,
1456
+ }: {
1457
+ usedSlotOffsets: ReadonlySet<number>;
1458
+ objects: ReadonlyMap<number, readonly Op[]>;
1459
+ foldedHere: ReadonlySet<Op>;
1460
+ fail: Refuse;
1461
+ },
1462
+ ): void {
1463
+ for (const slot of usedSlotOffsets) {
1464
+ if (overlaps(off, width, slot, 4)) {
1465
+ fail(
1466
+ objects.get(off)?.some((op) => foldedHere.has(op))
1467
+ ? `the capture moved by a constant to [sp,#${off}) is a move the pre-lift walk does not follow, ` +
1468
+ `so the slot model keys [sp,#${slot}] too — one byte, two models`
1469
+ : `the object at [sp,#${off}) overlaps the SSA slot at [sp,#${slot}] — one byte, two models`,
1470
+ );
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ /** ONE OBJECT IN MEMORY, the answer a device read or a callee write nothing bounds is given
1476
+ * instead of a refusal (`oneObject`, requested by the relift test where the escapes are judged).
1477
+ * Every byte of `[from, to)` is declared one `u8` array whose address the device or the callee
1478
+ * holds (`writerNotKept` says which writers it can hold), and every access inside it is a
1479
+ * cast-spelled access to that array — so each store the machine made there is a store the
1480
+ * recompile makes too. That is what agbcc does for an object whose address escaped: it keeps
1481
+ * every store to it, in order (flow.c deletes a memory store only when an identical later
1482
+ * store in the same block overwrites it). And it is right whichever the source had there: a
1483
+ * member of the escaped object, which the device may read, or a spill, which no one reads —
1484
+ * the asm spells both as a store and a reload, and keeping a spill in memory changes no value. */
1485
+ function keepAsOneObject({
1486
+ oneObject: { from, to },
1487
+ irBlocks,
1488
+ objects,
1489
+ uses,
1490
+ usedSlotOffsets,
1491
+ returnsWithoutHiddenPointer,
1492
+ fail,
1493
+ }: {
1494
+ oneObject: FrameRange;
1495
+ irBlocks: Block[];
1496
+ objects: ReadonlyMap<number, readonly Op[]>;
1497
+ uses: FrameUses;
1498
+ usedSlotOffsets: ReadonlySet<number>;
1499
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1500
+ fail: Refuse;
1501
+ }): void {
1502
+ const kept = `the bytes [sp,#${from}) to [sp,#${to}) kept as one object`;
1503
+ for (const off of objects.keys()) {
1504
+ if (off < from || off >= to) {
1505
+ fail(`the object at [sp,#${off}) lies outside ${kept}`);
1506
+ }
1507
+ }
1508
+ for (const slot of usedSlotOffsets) {
1509
+ if (overlaps(slot, 4, from, to - from)) {
1510
+ fail(`the SSA slot at [sp,#${slot}] lies inside ${kept} — one byte, two models`);
1511
+ }
1512
+ }
1513
+ const writer = writerNotKept(uses, returnsWithoutHiddenPointer);
1514
+ if (writer !== null) {
1515
+ fail(`${kept} cannot hold every writer — ${writer}`);
1516
+ }
1517
+ // A runtime index names no byte, so no access type can be checked against the others at the
1518
+ // bytes it reaches — except a byte access, which needs none: character types alias every type.
1519
+ if ([...uses.indexed.values()].some((xs) => xs.some((x) => x.width !== 1))) {
1520
+ fail(
1521
+ `a runtime index into ${kept} accesses more than a byte, and names no byte whose type it can be checked against`,
1522
+ );
1523
+ }
1524
+ // ONE TYPE PER BYTE. agbcc at -O2 turns on type-based alias analysis (toplev.c:3616), and
1525
+ // compiled, a `u32` read through a cast is served the earlier `u32` store straight past a
1526
+ // `u16` store to the same bytes — a stale value. So a byte two accesses of different widths
1527
+ // reach cannot be spelled through casts. A byte access is exempt, since character types
1528
+ // alias everything, and so is a signedness difference, since the signed and unsigned types
1529
+ // of one width share an alias set (c-common.c:1962-1974). The first audit asks for this
1530
+ // answer rather than refusing two widths — an object read at both, or a `strh` member through
1531
+ // the captured address over a word the slot model had — so this is where they refuse.
1532
+ const widthAt = new Map<number, number>();
1533
+ for (const m of uses.members) {
1534
+ if (m.at < from || m.at + m.width > to) {
1535
+ fail(`an access of ${m.width} bytes at [sp,#${m.at}) reaches past ${kept}`);
1536
+ }
1537
+ if (m.width === 1) {
1538
+ continue;
1539
+ }
1540
+ for (let b = m.at; b < m.at + m.width; b++) {
1541
+ const had = widthAt.get(b);
1542
+ if (had !== undefined && had !== m.width) {
1543
+ fail(
1544
+ `the byte at [sp,#${b}] of ${kept} is accessed ${had} and ${m.width} bytes wide, and agbcc at -O2 ` +
1545
+ 'lets a read of one type pass a store of the other (strict aliasing) — a cast spelling would read stale',
1546
+ );
1547
+ }
1548
+ widthAt.set(b, m.width);
1549
+ }
1550
+ }
1551
+ // Rewritten onto one `laddr` at `from`: an access through a member at `k` becomes an access
1552
+ // at `k - from` off the object, and a member address used any other way becomes the object's
1553
+ // address moved by that constant.
1554
+ //
1555
+ // `volatile` goes on each ACCESS where the address was stored somewhere a writer may hold it
1556
+ // (`publishedToWriter`): unqualified, a spin on a byte a holder of the address sets compiles to
1557
+ // a loop that never reads it again. Never on the array: every member is spelled through a cast,
1558
+ // whose pointee drops the array's qualifier, so it would reach no access and only make agbcc
1559
+ // warn `discards qualifiers` wherever the bare array is named. A reader alone — a DMA source, a
1560
+ // transfer's fill source (`filledFrom`) — changes no byte a re-read returns. KNOWN GAP: a
1561
+ // callee that keeps the address it was handed, for an interrupt to write later, is a holder
1562
+ // nothing here sees, so a spin on such a byte compiles unqualified; the per-object stamp has the
1563
+ // same gap (`stampObjects`: not on an ordinary `&local` argument).
1564
+ const reread = uses.publishedToWriter.size > 0;
1565
+ const object = mkOp('laddr', {
1566
+ results: [mkValue(T.unk(32))],
1567
+ attrs: { off: from, width: 1, signed: false, count: to - from },
1568
+ });
1569
+ const base = object.results[0];
1570
+ const memberAt = new Map<Value, number>();
1571
+ for (const op of [...objects.values()].flat()) {
1572
+ memberAt.set(op.results[0], op.attrs.off as number);
1573
+ }
1574
+ // The qualifier goes on every access the classification judged one (`accessOps`) — through a
1575
+ // member, a runtime index into one, or a phi that carries one (`p = c ? buf : gOther; while
1576
+ // (*p == 0);`). Only an access through a member is rebased: a phi's incoming member is
1577
+ // rewritten below, where it is an edge argument.
1578
+ const stillUsed = new Set<Value>();
1579
+ for (const blk of irBlocks) {
1580
+ for (const op of blk.ops) {
1581
+ const at = memberAt.get(op.operands[0]);
1582
+ const access = op.opcode === 'load' || op.opcode === 'store';
1583
+ if (reread && uses.accessOps.has(op)) {
1584
+ op.attrs = { ...op.attrs, volatile: true };
1585
+ }
1586
+ if (access && at !== undefined) {
1587
+ op.operands = [base, ...op.operands.slice(1)];
1588
+ op.attrs = { ...op.attrs, off: (op.attrs.off as number) + at - from };
1589
+ }
1590
+ op.operands.forEach((v) => memberAt.has(v) && stillUsed.add(v));
1591
+ (op.successors ?? []).forEach((sx) => sx.args.forEach((v) => memberAt.has(v) && stillUsed.add(v)));
1592
+ }
1593
+ }
1594
+ const atBase = (v: Value): Value => (memberAt.get(v) === from ? base : v);
1595
+ for (const blk of irBlocks) {
1596
+ blk.ops = blk.ops.flatMap((op) => {
1597
+ const at = op.opcode === 'laddr' ? memberAt.get(op.results[0]) : undefined;
1598
+ if (at === undefined) {
1599
+ op.operands = op.operands.map(atBase);
1600
+ (op.successors ?? []).forEach((sx) => (sx.args = sx.args.map(atBase)));
1601
+ return [op];
1602
+ }
1603
+ if (!stillUsed.has(op.results[0]) || at === from) {
1604
+ return [];
1605
+ }
1606
+ const by = mkValue(T.unk(32));
1607
+ return [
1608
+ mkOp('const', { results: [by], attrs: { value: at - from } }),
1609
+ mkOp('add', { operands: [base, by], results: op.results }),
1610
+ ];
1611
+ });
1612
+ }
1613
+ irBlocks[0].ops.unshift(object);
1614
+ }
1615
+
1616
+ /** One object's shape: `count` elements of `width` bytes. */
1617
+ interface ObjectShape {
1618
+ readonly width: number;
1619
+ readonly count: number;
1620
+ }
1621
+
1622
+ /** The per-object model's reading of every object. */
1623
+ interface ObjectShapes {
1624
+ readonly extent: ReadonlyMap<number, ObjectShape>;
1625
+ /** the declared struct each return temp is, by offset */
1626
+ readonly aggregateAt: ReadonlyMap<number, IrType>;
1627
+ /** each object over a slot the slot model keys, as `[off, width]`, refused last (`failIfSlotKeysIt`) */
1628
+ readonly overSlot: readonly (readonly [number, number])[];
1629
+ }
1630
+
1631
+ const span = (o: ObjectShape) => o.width * o.count;
1632
+
1633
+ function slotKeys(usedSlotOffsets: ReadonlySet<number>, off: number, width: number): boolean {
1634
+ return [...usedSlotOffsets].some((slot) => overlaps(off, width, slot, 4));
1635
+ }
1636
+
1637
+ /** The SHAPE of each object — `count` elements of `width` bytes, spanning `width * count` —
1638
+ * and then that its bytes belong to nothing else.
1639
+ *
1640
+ * AN OBJECT OVER A SLOT IS REFUSED LAST, after the one-object answer is asked: that answer
1641
+ * routes every word of the local area through `laddr`, so no slot is left for the object to
1642
+ * overlap, and refusing first would decline exactly the frames it exists for — an object at
1643
+ * [sp,#0] whose first member is a word stored `str rN, [sp]`. The other per-object refusals here
1644
+ * go through `shapeRefused`, for the same reason. */
1645
+ function objectShapes({
1646
+ objects,
1647
+ uses,
1648
+ owned,
1649
+ declared,
1650
+ usedSlotOffsets,
1651
+ returnsWithoutHiddenPointer,
1652
+ model,
1653
+ fail,
1654
+ }: {
1655
+ objects: ReadonlyMap<number, readonly Op[]>;
1656
+ uses: FrameUses;
1657
+ owned: FrameRange;
1658
+ declared: FrameRange;
1659
+ usedSlotOffsets: ReadonlySet<number>;
1660
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1661
+ model: ModelChoice;
1662
+ fail: Refuse;
1663
+ }): ObjectShapes {
1664
+ const extent = new Map<number, ObjectShape>();
1665
+ const aggregateAt = new Map<number, IrType>();
1666
+ const overSlot: [number, number][] = [];
1667
+ for (const [off, acc] of uses.accesses) {
1668
+ const byIndex = uses.indexed.get(off) ?? [];
1669
+ const temps = uses.returnTemps.get(off);
1670
+ if (temps !== undefined) {
1671
+ // The callee writes the whole struct and this function names it once, as the call's
1672
+ // destination. A read of a member, or any other use of the address, is not modelled —
1673
+ // and most such reads never arrive here: the Thumb slot model refuses a word of the temp
1674
+ // read at a constant offset first ("stack pointer used as data"), a copy of the whole
1675
+ // struct into a global (`gS8 = mk8(x)`, which agbcc stages through the temp) included. So
1676
+ // modelling member reads starts with the slot model handing this audit a read return temp.
1677
+ const callee = temps[0].attrs.target as string;
1678
+ const type = temps[0].results[0].type;
1679
+ const spelling = type.kind === 'struct' ? (type.declared ?? `struct ${type.name}`) : typeToString(type);
1680
+ if (temps.some((t) => !typeEquals(t.results[0].type, type))) {
1681
+ fail(`the object at [sp,#${off}) is the struct-return storage of calls declared to return different types`);
1682
+ }
1683
+ if (acc.length > 0 || byIndex.length > 0) {
1684
+ fail(
1685
+ `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}, and this function also reads or ` +
1686
+ 'writes it — a member of a returned struct is not modelled',
1687
+ );
1688
+ }
1689
+ if (uses.useCount.get(off) !== temps.length) {
1690
+ fail(
1691
+ `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}, and its address is also used ` +
1692
+ 'another way — only the call it is returned by is modelled',
1693
+ );
1694
+ }
1695
+ extent.set(off, { width: 1, count: returnedSize(temps[0]) });
1696
+ aggregateAt.set(off, type);
1697
+ continue;
1698
+ }
1699
+ if (acc.length === 0) {
1700
+ // An object with no access of its own has no declared type and no extent, and the two
1701
+ // ways it gets there are two different gaps. Its bytes may already be keyed by the slot
1702
+ // model, which one byte is enough to decide; otherwise nothing in-function pins it at
1703
+ // all, and a guessed declaration is the plausible-but-wrong class.
1704
+ if (slotKeys(usedSlotOffsets, off, 1)) {
1705
+ overSlot.push([off, 1]);
1706
+ continue;
1707
+ }
1708
+ const why = notTheWholeArea(off, byIndex.length > 0, {
1709
+ objects,
1710
+ uses,
1711
+ declared,
1712
+ returnsWithoutHiddenPointer,
1713
+ });
1714
+ if (why !== null) {
1715
+ model.shapeRefused(
1716
+ (byIndex.length > 0
1717
+ ? 'the captured address is addressed only through a runtime index'
1718
+ : 'the captured address is never dereferenced in this function') +
1719
+ `, so nothing pins the local object type — and ${why}`,
1720
+ );
1721
+ // …asked for the one object instead, which sizes no object of its own
1722
+ continue;
1723
+ }
1724
+ // An indexed access reads ONE ELEMENT of the storage declared below, so it must be one:
1725
+ // an unsigned byte. A wider element is a different array over the same bytes, and a
1726
+ // sign-extending read is not what `u8` spells.
1727
+ for (const a of byIndex) {
1728
+ if (a.width !== 1) {
1729
+ model.shapeRefused(
1730
+ `a runtime index into the object at [sp,#${off}) accesses ${a.width} bytes, and the storage ` +
1731
+ 'nothing else types is declared as bytes — only a byte element is modelled',
1732
+ );
1733
+ }
1734
+ if (a.signed) {
1735
+ model.shapeRefused(
1736
+ `a runtime index into the object at [sp,#${off}) sign-extends, and the storage is declared unsigned`,
1737
+ );
1738
+ }
1739
+ }
1740
+ // STORAGE, NOT A TYPE. The reservation says how many bytes the frame holds and the
1741
+ // conjuncts above say they are all this object's, so the declaration commits to an EXTENT
1742
+ // and to nothing else: `u8 name[n]` over the declared range, unsigned bytes because no
1743
+ // access named an element type and inventing one is the guess this refuses everywhere else.
1744
+ //
1745
+ // THE EXTENT IS A ROUNDED ONE, stated because it is not a defect. agbcc reserves the
1746
+ // local area in whole words, so a source object of 13, 14, 15 or 16 bytes all reserve
1747
+ // sixteen — compiled and diffed at the row's own flags, `u8 x[0xD]` and `u8 x[0x10]`
1748
+ // reach the same object where `u8 x[0xC]` and `u8 x[0x11]` do not. What is declared is
1749
+ // the RESERVATION, which is the thing the asm carries; every source extent inside one
1750
+ // word of it emits the same object, so no member of that class is more right than this.
1751
+ //
1752
+ // NO COMPILER TERM, unlike `capturedObjectIsTheWholeFrame`, whose one-word reading is a
1753
+ // fact about what agbcc puts in a four-byte frame. This argument needs only that the
1754
+ // reservation ROUNDS UP to some granularity, which is a property of every stack ABI, and
1755
+ // it declares the reservation rather than a guess inside it — so a coarser rounding makes
1756
+ // the declared extent coarser too, never wrong about the bytes the machine reserved.
1757
+ extent.set(off, { width: 1, count: declared.to - declared.from });
1758
+ continue;
1759
+ }
1760
+ if (byIndex.length > 0) {
1761
+ model.shapeRefused(
1762
+ `a runtime index into the object at [sp,#${off}), which an access of its own types as one ` +
1763
+ 'scalar — only the untyped storage of the whole declared area is indexed',
1764
+ );
1765
+ }
1766
+ const widths = new Set(acc.map((a) => a.width));
1767
+ if (widths.size > 1) {
1768
+ model.shapeRefused(`the accesses through the captured address disagree on width (${[...widths].join(' vs ')})`);
1769
+ }
1770
+ // …and on SIGNEDNESS, over the loads, for the same reason: one declared type extends one
1771
+ // way, so an object read by both `ldrsb` and `ldrb` has no faithful declaration —
1772
+ // `sp4 - sp4` would fold to 0 where the machine computes sext(b) - zext(b). Loads only: a
1773
+ // store extends nothing, and `strb` beside `ldrsb` is not a disagreement.
1774
+ const signs = new Set(acc.filter((a) => a.isLoad).map((a) => a.signed));
1775
+ if (signs.size > 1) {
1776
+ model.shapeRefused(
1777
+ 'the loads through the captured address disagree on signedness — one declared type extends one way',
1778
+ );
1779
+ }
1780
+ // …and a scalar this function only READS is the callee's to fill, which is what a struct
1781
+ // return's hidden temp is: agbcc spells `s = mk(x)` above an outgoing block as `add r0, sp,
1782
+ // #0x4 / bl mk / ldr r0, [sp, #0x4]`, instruction for instruction an out-parameter call. The
1783
+ // question the whole-frame and untyped arms ask, asked of every object.
1784
+ const whyItStands = acc.some((a) => !a.isLoad)
1785
+ ? null
1786
+ : hiddenReturnPointerStands(off, uses.arg0Callees, returnsWithoutHiddenPointer);
1787
+ if (whyItStands !== null) {
1788
+ fail(`the object at [sp,#${off}) is never written here, and ${whyItStands}`);
1789
+ }
1790
+ extent.set(off, { width: acc[0].width, count: 1 });
1791
+ }
1792
+ // Each object must own its bytes outright: inside the reserved local area, clear of every SSA
1793
+ // slot (`overSlot`, refused last), and clear of every other object.
1794
+ const objs = [...extent].sort((x, y) => x[0] - y[0]);
1795
+ for (const [off, obj] of objs) {
1796
+ if (off < owned.from || off + span(obj) > owned.to) {
1797
+ fail(`the object at [sp,#${off}) of width ${span(obj)} lies outside the reserved local area`);
1798
+ }
1799
+ if (slotKeys(usedSlotOffsets, off, span(obj))) {
1800
+ overSlot.push([off, span(obj)]);
1801
+ }
1802
+ }
1803
+ for (let i = 1; i < objs.length; i++) {
1804
+ const [off, obj] = objs[i];
1805
+ const [prev, prevObj] = objs[i - 1];
1806
+ if (overlaps(prev, span(prevObj), off, span(obj))) {
1807
+ model.shapeRefused(`the objects at [sp,#${prev}) and [sp,#${off}) overlap — one byte, two models`);
1808
+ }
1809
+ }
1810
+ return { extent, aggregateAt, overSlot };
1811
+ }
1812
+
1813
+ /** THE FRAME RESERVATION IS AN EXTENT, when the declared local area is provably one object's alone.
1814
+ * `add sp, sp, #-0x10` reserves sixteen bytes; if exactly one address-taken object sits at the
1815
+ * bottom of the DECLARED part of them and the slot model keys none of that part (`overSlot`, which
1816
+ * `objectShapes` asks of every extent), then there is nothing else those bytes can be — a
1817
+ * compiler does not reserve frame for nothing. That is the frame-accounting equation the escape
1818
+ * rules (`FRAME_ESCAPE_GATES`) already solve word by word, asked in the other direction: they
1819
+ * check that the objects and slots TILE the reserved area, this reads the declared range off as
1820
+ * the one object's size.
1821
+ *
1822
+ * THE DECLARED RANGE, NOT THE OWNED ONE. Below `declared.from` lies the outgoing stack-argument
1823
+ * block `analyzeOutgoingArgs` licensed — arguments 5+ of this function's calls, which C stages
1824
+ * and never declares — so those bytes are not this object's, and the slots keyed there are those
1825
+ * arguments. The escape gates read the frame the same way: `reaches-a-slot` looks no lower than
1826
+ * `declared.from`, and `reaches-an-unaccounted-word` counts the argument words as accounted.
1827
+ * agbcc lays its locals out like this (ACCUMULATE_OUTGOING_ARGS 1, STARTING_FRAME_OFFSET 0,
1828
+ * thumb.h): `struct S t; f(&t, b, c, d, e, g, h, i)` with an 8-byte `S` reserves 0x18, stages
1829
+ * the four argument words at [sp,#0..#0xc] and puts `t` at [sp,#0x10]. The block is LICENSED by
1830
+ * construction: one not stored on every path is `analyzeOutgoingArgs`'s own blocker, which turns
1831
+ * the slot model off, and every `laddr` mint is behind `slotsOk`.
1832
+ *
1833
+ * Returns why it does not apply, or null. Every clause refuses in its own words: the reason an
1834
+ * acceptance did not fire is as much an attribution as the reason a lift declined, and one
1835
+ * sentence covering all of them is how several gaps come to look like one.
1836
+ *
1837
+ * FOUR CLAUSES BOUND THIS PATH — a second object, an object that does not start at the bottom of
1838
+ * the declared area, an address that reaches memory rather than a callee, and the callee's
1839
+ * declared return — and each has a test that fails without it. A fifth return, the precautionary
1840
+ * one marked where it sits, has none.
1841
+ *
1842
+ * The last two are about an ESCAPE (`wholeAreaEscapeRefusal`), and they are asked only of an
1843
+ * object that escapes or that nothing in this function addresses. An object this function indexes
1844
+ * and never lets go of is written and read by its own indexed accesses alone, so the reservation
1845
+ * is all there is to size it by: `u8 a[8]; for (j = 0; j < 8; j++) a[j] = tbl[j]; return a[i];`. */
1846
+ function notTheWholeArea(
1847
+ off: number,
1848
+ indexedHere: boolean,
1849
+ {
1850
+ objects,
1851
+ uses,
1852
+ declared,
1853
+ returnsWithoutHiddenPointer,
1854
+ }: {
1855
+ objects: ReadonlyMap<number, readonly Op[]>;
1856
+ uses: FrameUses;
1857
+ declared: FrameRange;
1858
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1859
+ },
1860
+ ): string | null {
1861
+ if (objects.size !== 1) {
1862
+ return 'another address-taken object shares the frame, so the reservation is not this one alone';
1863
+ }
1864
+ if (off !== declared.from || declared.to <= declared.from) {
1865
+ return 'the object does not start at the bottom of the declared area, so something below it is unaccounted for';
1866
+ }
1867
+ // PRECAUTIONARY for an object nothing indexes: an address that neither accesses nor escapes
1868
+ // already declines where the audit classifies its uses ("flows into `ret`"), so it never arrives
1869
+ // here unescaped. Kept because storage declared with no writer is a SILENT wrong answer.
1870
+ if (!uses.escaped.has(off)) {
1871
+ return indexedHere
1872
+ ? null
1873
+ : 'the address never leaves this function, so there is no writer of the storage to size it for';
1874
+ }
1875
+ return wholeAreaEscapeRefusal(off, uses, returnsWithoutHiddenPointer);
1876
+ }
1877
+
1878
+ /** Why the escape of the address at `off` is not one the WHOLE-AREA argument holds, or null. That
1879
+ * argument — the object is every byte of the declared area, so whatever a holder of the address
1880
+ * does through it lands on bytes one declaration owns — is made twice: for one untyped object
1881
+ * (`notTheWholeArea`) and for the one object several captures are members of (`writerNotKept`).
1882
+ * It needs a callee to have been handed the address as an argument, the one holder this frontend
1883
+ * can name; an address that only reached memory has none. A store of the address beside that
1884
+ * call is held with it, whether to an ordinary global or into a device's source register — and
1885
+ * where the store hands the address to something that may write through it (`publishedToWriter`),
1886
+ * only when that callee may write through it too: a bounded block transfer reading its source
1887
+ * names no writer.
1888
+ *
1889
+ * The frame's SIZE changes nothing about whose storage it is, so the hidden-pointer question is
1890
+ * the one the one-word arm asks, asked here of the same callees. */
1891
+ function wholeAreaEscapeRefusal(
1892
+ off: number,
1893
+ uses: FrameUses,
1894
+ returnsWithoutHiddenPointer: (callee: string) => boolean,
1895
+ ): string | null {
1896
+ if (!uses.passedToCallee.has(off)) {
1897
+ return 'the address is published rather than passed as an argument, and nothing declares what reads it';
1898
+ }
1899
+ if (uses.publishedToWriter.has(off) && !uses.passedToWriter.has(off)) {
1900
+ return (
1901
+ 'the address is published, and the only callee handed it is a block transfer that reads through it, ' +
1902
+ 'so nothing declares what reads it'
1903
+ );
1904
+ }
1905
+ return hiddenReturnPointerStands(off, uses.arg0Callees, returnsWithoutHiddenPointer);
1906
+ }
1907
+
1908
+ /** Every escaped object as the rules an escape retracts read it (`FRAME_ESCAPE_GATES`), computed
1909
+ * ONCE, with whether the escape may write and how it left, so a new bound — a callee's declared
1910
+ * extent — has one place to go. */
1911
+ function frameEscapes({
1912
+ irBlocks,
1913
+ uses,
1914
+ windows,
1915
+ shapes: { extent },
1916
+ owned,
1917
+ declared,
1918
+ usedSlotOffsets,
1919
+ }: {
1920
+ irBlocks: readonly Block[];
1921
+ uses: FrameUses;
1922
+ windows: ReadonlyMap<number, ReadWindow>;
1923
+ shapes: ObjectShapes;
1924
+ owned: FrameRange;
1925
+ declared: FrameRange;
1926
+ usedSlotOffsets: ReadonlySet<number>;
1927
+ }): FrameEscape[] {
1928
+ const undefSlots = irBlocks
1929
+ .flatMap((blk) => blk.ops.filter((op) => op.opcode === 'undef'))
1930
+ .map((op) => slotKeyOffset(op.attrs.key as string))
1931
+ .filter((slot) => slot !== null)
1932
+ .sort((x, y) => x - y);
1933
+ const accountedWords = new Set<number>();
1934
+ for (const [off, obj] of extent) {
1935
+ for (let w = off - (off % 4); w < off + span(obj); w += 4) {
1936
+ accountedWords.add(w);
1937
+ }
1938
+ }
1939
+ for (const slot of usedSlotOffsets) {
1940
+ accountedWords.add(slot - (slot % 4));
1941
+ }
1942
+ const unaccountedIn = (lo: number, hi: number): number | undefined => {
1943
+ for (let w = owned.from; w < owned.to; w += 4) {
1944
+ if (!accountedWords.has(w) && w < hi && w + 4 > lo) {
1945
+ return w;
1946
+ }
1947
+ }
1948
+ return undefined;
1949
+ };
1950
+ const escapes: FrameEscape[] = [];
1951
+ for (const off of uses.escaped) {
1952
+ const { lo, hi, why } = windows.get(off)!;
1953
+ const writes = mayWrite(uses, off);
1954
+ const objectReached = [...extent].find(([o, obj]) => o !== off && o < off + hi && off + lo < o + span(obj));
1955
+ let slotReached: FrameEscape['slotReached'];
1956
+ for (const slot of usedSlotOffsets) {
1957
+ if (slot > off && slot < off + hi) {
1958
+ slotReached = { slot, above: true };
1959
+ break;
1960
+ }
1961
+ if (slot < off && slot >= declared.from && slot + 4 > off + lo) {
1962
+ slotReached = { slot, above: false };
1963
+ break;
1964
+ }
1965
+ }
1966
+ escapes.push({
1967
+ off,
1968
+ lo,
1969
+ hi,
1970
+ writes,
1971
+ how: uses.passedToCallee.has(off)
1972
+ ? 'is passed to a callee'
1973
+ : writes
1974
+ ? 'is stored to memory'
1975
+ : `is handed to a device ${why}`,
1976
+ objectReached: objectReached?.[0],
1977
+ slotReached,
1978
+ undefReached: undefSlots.find((slot) => slot < off + hi && slot + 4 > off + lo),
1979
+ unaccountedWord: unaccountedIn(off + lo, off + hi),
1980
+ });
1981
+ }
1982
+ return escapes;
1983
+ }
1984
+
1985
+ function escapeRefusal(id: string, e: FrameEscape): string {
1986
+ const at = `the captured address at [sp,#${e.off}) ${e.how}`;
1987
+ switch (id) {
1988
+ case 'reaches-another-object':
1989
+ return e.lo === -Infinity && e.hi === Infinity
1990
+ ? 'the captured address escapes, so something outside this function reaches the whole frame — including another object'
1991
+ : `${at}, which may read the object at [sp,#${e.objectReached})`;
1992
+ case 'writer-over-undef':
1993
+ return e.lo === -Infinity && e.hi === Infinity
1994
+ ? 'the captured address escapes, so a callee may write any frame offset and an unstored slot is not provably uninitialised'
1995
+ : `${at}, which may write the slot at [sp,#${e.undefReached}] — an unstored slot it reaches is not provably uninitialised`;
1996
+ case 'reaches-a-slot':
1997
+ return !e.slotReached!.above
1998
+ ? `${at}, and it may point INTO an object that starts lower — the slot at [sp,#${e.slotReached!.slot}] below it is kept in a register, not the frame`
1999
+ : e.writes
2000
+ ? `${at}, which may write the slot at [sp,#${e.slotReached!.slot}] — this function's own store there would be forwarded past the write`
2001
+ : `${at}, which may read the slot at [sp,#${e.slotReached!.slot}] — this function's own store there is kept in a register, not the frame`;
2002
+ default:
2003
+ return (
2004
+ `the word at [sp,#${e.unaccountedWord}] is neither an object this lift models nor a slot it keys, ` +
2005
+ `and the captured address reaches something that may ${e.writes ? 'write' : 'read'} it — nothing accounts for the ` +
2006
+ "rest of the frame, so nothing bounds the captured object's extent"
2007
+ );
2008
+ }
2009
+ }
2010
+
2011
+ /** Proven. Stamp the MACHINE FACTS the audit established — width and signedness are what the
2012
+ * accesses used, so the declaration downstream is a fact, not a guess. The C-level NAME is
2013
+ * deliberately NOT chosen here: identifiers live in the structurer's namespace (params,
2014
+ * locals, globals, the symbol map), which the frontend cannot see — a frontend-chosen `sp0`
2015
+ * silently shadowed a project global of the same name.
2016
+ * `volatile` iff the address is PUBLISHED — written to memory, rather than handed to a
2017
+ * callee. That is the DMA idiom this rule was written for and it IS the source's own
2018
+ * spelling there: klonoa's `DMA_FILL` writes `vu##bit tmp` outright, sa3's does under
2019
+ * `PLATFORM_GBA`, pokeemerald's inside `DMA_FILL_UNCHECKED`, and the address goes to a device
2020
+ * register through a store. Reproducing that source means reproducing the qualifier.
2021
+ *
2022
+ * …and iff it is the FIXED source of a block transfer (`filledFrom`), the same idiom through a
2023
+ * BIOS call: every fill macro writes `vu##bit tmp = value; CpuSet((void *)&tmp, …)` (sa3's
2024
+ * cpuset_macros.h, pokeemerald's macro.h). Compiled, the plain spelling is not the same
2025
+ * program: a `u16` parameter stored to a plain `u16 tmp` loses its `lsl`/`lsr` pair, and the
2026
+ * pool loads around the call reorder.
2027
+ *
2028
+ * NOT on an ordinary `&local` ARGUMENT, where no source in the corpus writes one and the
2029
+ * qualifier is not free. `void f(u32 i){ s32 w; w = gEnts[i].h; use(&w); four(w,w,w,w); }`
2030
+ * compiles to one `ldr` reloaded into four registers by copies; the structurer emits one C
2031
+ * read per USE rather than per machine load, so `volatile` forbids the CSE and makes it four
2032
+ * `ldr`s — a byte-exact candidate turned into a four-instruction nonmatch (compiled, agbcc
2033
+ * 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm -fprologue-bugfix`). It is free
2034
+ * only where the object is read at most once, which is all the rows that first shipped it
2035
+ * did. agbcc also warns `discards qualifiers` at every such call.
2036
+ *
2037
+ * NOT because gcc would otherwise delete the store. That claim was here for several releases
2038
+ * and does not reproduce: taking `&tmp` makes the local addressable, so gcc-2.9 keeps the
2039
+ * store with or without the qualifier, measured on store-then-escape, publish-then-fill, and
2040
+ * a loop that stores and escapes each iteration. What the qualifier does change is register
2041
+ * ALLOCATION — the same function compiled `vu16` and `u16` is 98 instructions either way and
2042
+ * differs in three register assignments — which is why it still has to be right. asmlift's
2043
+ * OWN dead-store pass keys on address-taken, not on the qualifier (l3/dce.ts), so dropping the
2044
+ * qualifier here cannot cost a store.
2045
+ *
2046
+ * An object whose address never leaves the function needs no volatile and must not pay it.
2047
+ *
2048
+ * AN UNTYPED OBJECT REACHES THIS RULE TOO, where the address is both published and handed to
2049
+ * a callee whose return is declared. It cannot pay the price above — that price is a read the
2050
+ * compiler may no longer fold, and an object with no access in this function has none —
2051
+ * compiled at the corpus's flags, the qualified and plain spellings are byte-identical and
2052
+ * differ in two `discards qualifiers` warnings. So the rule is the same rule, and the reason
2053
+ * it is free here is not the reason it is free on a scalar read once. */
2054
+ function stampObjects(
2055
+ objects: ReadonlyMap<number, readonly Op[]>,
2056
+ { extent, aggregateAt }: ObjectShapes,
2057
+ uses: FrameUses,
2058
+ ): void {
2059
+ for (const [off, ops] of objects) {
2060
+ const { width, count } = extent.get(off)!;
2061
+ const signed = uses.accesses.get(off)!.some((a) => a.signed);
2062
+ const aggregate = aggregateAt.get(off);
2063
+ for (const op of ops) {
2064
+ const qualified = uses.published.has(off) || uses.filledFrom.has(off);
2065
+ op.attrs = {
2066
+ ...op.attrs,
2067
+ width,
2068
+ signed,
2069
+ count,
2070
+ ...(qualified ? { volatile: true } : {}),
2071
+ ...(aggregate !== undefined ? { aggregate: true } : {}),
2072
+ };
2073
+ // a return temp's address points at the struct the call returns
2074
+ if (aggregate !== undefined) {
2075
+ op.results[0].type = T.ptr(aggregate);
2076
+ }
2077
+ }
2078
+ }
2079
+ // The declaration carries the qualifier to an access spelled through the object's own name, and
2080
+ // not to one through a phi that carries its address: that phi is a pointer local typed from the
2081
+ // access, so `p = c ? &b : gOther; while (*p == 0);` would compile to a loop that never reads
2082
+ // `b` again. Such an access carries the qualifier itself.
2083
+ for (const [op, { off, through }] of uses.accessOps) {
2084
+ const own = objects.get(off)!.some((o) => o.results[0] === through);
2085
+ if (!own && (uses.published.has(off) || uses.filledFrom.has(off))) {
2086
+ op.attrs = { ...op.attrs, volatile: true };
2087
+ }
2088
+ }
2089
+ }
2090
+
2091
+ function gcd(a: number, b: number): number {
2092
+ return b === 0 ? Math.abs(a) : gcd(b, a % b);
2093
+ }
2094
+
2095
+ /** The size of the struct a call stamped `sret` returns through its argument 0 (frontend/thumb.ts
2096
+ * types the call's result as that struct). */
2097
+ function returnedSize(call: Op): number {
2098
+ const t = call.results[0].type;
2099
+ if (t.kind !== 'struct' || t.size === undefined) {
2100
+ throw new Error(`a call stamped sret returns ${typeToString(t)}, not a laid-out struct`);
2101
+ }
2102
+ return t.size;
2103
+ }
2104
+
2105
+ /** THE CALLER-SIDE SEAM. A frontend calls the audit THROUGH this record rather than through the
2106
+ * binding above, so a process outside core can put a wrapped `gates` table in front of a real
2107
+ * lift (`pnpm bench gates --pass frame-objects`): a module-namespace binding is read-only and
2108
+ * cannot be swapped (`apps/benchmark/src/run/gate-census.ts`, WHAT PUTS A PASS IN THE REGISTRY). */
2109
+ export const FRAME_OBJECT_AUDIT: { run: (audit: FrameObjectAudit) => FrameObjectVerdict } = {
2110
+ run: (audit) => auditFrameObjects(audit),
2111
+ };
2112
+
2113
+ /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
2114
+ * every source path under `./*`, so a plain `export` here would put the audit's stages into the
2115
+ * package's public API, where no consumer should be able to depend on them. The name says what it
2116
+ * is; nothing in `src` reads it. */
2117
+ export const __testing = {
2118
+ frameRefusal,
2119
+ foldMovedCaptures,
2120
+ frameAddressFacts,
2121
+ splitAddressingCopies,
2122
+ frameObjects,
2123
+ addressFlow,
2124
+ classifyFrameUses,
2125
+ opPositions,
2126
+ escapeWindows,
2127
+ readWindow,
2128
+ chooseFrameModel,
2129
+ objectShapes,
2130
+ frameEscapes,
2131
+ };