@asmlift/core 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +36 -31
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +397 -0
  4. package/src/backend/c.ts +6 -2
  5. package/src/backend/cfamily.ts +370 -56
  6. package/src/backend/cpp.ts +116 -10
  7. package/src/backend/pascal.ts +69 -11
  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 +777 -84
  15. package/src/declare.ts +132 -13
  16. package/src/frontend/asmdata.ts +26 -6
  17. package/src/frontend/call-plan.ts +1590 -0
  18. package/src/frontend/dead-reads.ts +628 -0
  19. package/src/frontend/device-pins.ts +296 -0
  20. package/src/frontend/emit.ts +4 -3
  21. package/src/frontend/errors.ts +5 -0
  22. package/src/frontend/flags-edge.ts +101 -0
  23. package/src/frontend/fpu.ts +374 -0
  24. package/src/frontend/frame-objects.ts +3171 -0
  25. package/src/frontend/frontend.ts +64 -3
  26. package/src/frontend/high-half.ts +55 -5
  27. package/src/frontend/jump-table.ts +530 -0
  28. package/src/frontend/local-object.ts +616 -0
  29. package/src/frontend/mips.ts +2392 -208
  30. package/src/frontend/opaque.ts +97 -18
  31. package/src/frontend/own-struct-return.ts +500 -0
  32. package/src/frontend/ppc.ts +3137 -267
  33. package/src/frontend/register-struct-call.ts +484 -0
  34. package/src/frontend/reloc-symbol.ts +109 -13
  35. package/src/frontend/splat.ts +108 -5
  36. package/src/frontend/ssa.ts +2098 -89
  37. package/src/frontend/stackargs.ts +172 -42
  38. package/src/frontend/thumb.ts +4563 -2091
  39. package/src/ir/alias.ts +71 -0
  40. package/src/ir/bits.ts +681 -1
  41. package/src/ir/core.ts +199 -6
  42. package/src/ir/discipline.ts +313 -0
  43. package/src/ir/entry.ts +159 -0
  44. package/src/ir/float-bits.ts +47 -0
  45. package/src/{structure → ir}/loops.ts +6 -2
  46. package/src/ir/opcodes.ts +193 -121
  47. package/src/ir/parse.ts +1 -0
  48. package/src/ir/types.ts +202 -2
  49. package/src/ir/verify.ts +219 -3
  50. package/src/l3/address.ts +5 -0
  51. package/src/l3/argcopy.ts +38 -6
  52. package/src/l3/ast.ts +467 -23
  53. package/src/l3/basecse.ts +33 -5
  54. package/src/l3/coalesce.ts +21 -13
  55. package/src/l3/constinit.ts +382 -0
  56. package/src/l3/dce.ts +34 -12
  57. package/src/l3/gates.ts +9 -3
  58. package/src/l3/hoist.ts +8 -7
  59. package/src/l3/initfirst.ts +68 -19
  60. package/src/l3/intcell.ts +98 -0
  61. package/src/l3/mentions.ts +10 -4
  62. package/src/l3/narrowdecl.ts +259 -0
  63. package/src/l3/nearbase.ts +10 -5
  64. package/src/l3/parkfirst.ts +8 -5
  65. package/src/l3/pollguard.ts +11 -4
  66. package/src/l3/ptrcell.ts +354 -0
  67. package/src/l3/ptrfield.ts +123 -8
  68. package/src/l3/regspell.ts +26 -7
  69. package/src/l3/reindex.ts +293 -103
  70. package/src/l3/storage.ts +98 -1
  71. package/src/l3/symbol-refs.ts +182 -23
  72. package/src/l3/tailmerge.ts +9 -20
  73. package/src/l3/typing.ts +237 -32
  74. package/src/l3/unreduce.ts +10 -9
  75. package/src/l3/volatileptr.ts +17 -54
  76. package/src/l3/volstore.ts +96 -7
  77. package/src/mangle.ts +41 -1
  78. package/src/pattern/engine.ts +373 -82
  79. package/src/pipeline.ts +589 -27
  80. package/src/proto-context.ts +1648 -0
  81. package/src/proto.ts +1203 -49
  82. package/src/raise/aggmembers.ts +290 -0
  83. package/src/raise/arrays.ts +8 -3
  84. package/src/raise/declared-volatile.ts +307 -0
  85. package/src/raise/divpow2.ts +2 -2
  86. package/src/raise/errors.ts +17 -0
  87. package/src/raise/floathelpers.ts +464 -0
  88. package/src/raise/globalshape.ts +11 -5
  89. package/src/raise/gvn.ts +29 -8
  90. package/src/raise/latch.ts +162 -6
  91. package/src/raise/memberarrays.ts +10 -4
  92. package/src/raise/narrow.ts +29 -2
  93. package/src/raise/narrowlocal.ts +11 -6
  94. package/src/raise/offsetnames.ts +494 -0
  95. package/src/raise/pairparams.ts +114 -0
  96. package/src/raise/paramwidth.ts +203 -6
  97. package/src/raise/pre-recovery.ts +174 -17
  98. package/src/raise/pre-structure.ts +54 -0
  99. package/src/raise/recover.ts +26 -15
  100. package/src/raise/reroll.ts +694 -0
  101. package/src/raise/retsink.ts +1 -1
  102. package/src/raise/shortcircuit.ts +66 -30
  103. package/src/raise/softdiv.ts +21 -23
  104. package/src/raise/storesign.ts +153 -0
  105. package/src/raise/struct-arrays.ts +34 -14
  106. package/src/raise/structs.ts +674 -98
  107. package/src/raise/truncload.ts +367 -0
  108. package/src/raise/unthread.ts +400 -0
  109. package/src/raise/widehelpers.ts +106 -0
  110. package/src/rank-declare.ts +143 -12
  111. package/src/rank-variations.ts +117 -31
  112. package/src/rank.ts +563 -122
  113. package/src/runtime-helpers.ts +405 -0
  114. package/src/stillborn.ts +289 -0
  115. package/src/structure/analysis.ts +1449 -449
  116. package/src/structure/arith-ops.ts +23 -0
  117. package/src/structure/globalaccess.ts +111 -9
  118. package/src/structure/hazards.ts +641 -114
  119. package/src/structure/local-statics.ts +517 -0
  120. package/src/structure/namecoalesce.ts +2 -2
  121. package/src/structure/pointer-spelling.ts +1106 -0
  122. package/src/structure/redundant-test.ts +99 -0
  123. package/src/structure/structure.ts +3478 -975
  124. package/src/structure/switch-recover.ts +403 -178
  125. package/src/symbols.ts +44 -3
  126. package/src/target.ts +1387 -71
  127. package/src/trace.ts +66 -28
  128. package/src/variation-definitions.ts +243 -13
  129. package/src/variation-gates.ts +2 -0
  130. package/src/variation-tokens.ts +25 -7
@@ -0,0 +1,3171 @@
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
+ * Three frontends call it: Thumb; MIPS, which lowers `addiu rD,sp,k` and `addu rD,sp,rX` to an
13
+ * `laddr` and hands over the O32 local area as both ranges (`frontend/mips.ts` `measureO32Frame`);
14
+ * and PPC, which lowers `addi rD,r1,k` and every r1 access inside mwcc's local area the same way
15
+ * (`frontend/ppc.ts` `measurePpcFrame`).
16
+ * The worked examples below are agbcc's, because agbcc is the compiler every rule was first
17
+ * measured against; IDO, gcc 2.7.2 and mwcc reload past every store of another type (strict
18
+ * aliasing does not reorder there), so the cast and union refusals below are only more cautious on
19
+ * MIPS and PPC. */
20
+ import { linearFactor } from '../ir/bits';
21
+ import { type Block, type Op, type Value, byValueOperands, calleeName, mkOp, mkValue, successorsOf } from '../ir/core';
22
+ import { type IrType, T, structSpelling, typeEquals, typeToString, unionViewSigns } from '../ir/types';
23
+ import type { Gate } from '../l3/gates';
24
+ import { blockCopyRoles } from '../proto';
25
+ import type { SymbolMap } from '../symbols';
26
+ import { type TargetDescription, blockTransferRead, sourceControlRead, sourceReach } from '../target';
27
+ import type { CallDeclarations } from './call-plan';
28
+ import { type DevicePins, type LiteralAddresses, literalAddresses } from './device-pins';
29
+ import { FrontendUnsupportedError } from './errors';
30
+ import { type LiveInModel, slotKeyOffset } from './ssa';
31
+
32
+ export interface FrameObjectAudit {
33
+ name: string;
34
+ irBlocks: Block[];
35
+ /** the frame this function owns (`LiveInModel.ownedLocals`): every local lies inside it, and
36
+ * every word of it is an object, a slot, or storage nothing here describes */
37
+ ownedLocals: NonNullable<LiveInModel['ownedLocals']>;
38
+ /** where its locals are declared (`LiveInModel.declaredLocals`): `ownedLocals` less the outgoing
39
+ * stack-argument block staged at its bottom, which is where an untyped object claims to start */
40
+ declaredLocals: NonNullable<LiveInModel['declaredLocals']>;
41
+ usedSlotOffsets: ReadonlySet<number>;
42
+ /** THE FRONTEND'S LOADED-WORD BOUND, present where the target states `outgoingAreaNeverLoadedFrom`:
43
+ * `from` is that offset and `bound` the lowest frame word, in this audit's offsets, the frontend
44
+ * saw the function load from `from` up, or `Infinity`. The frontend reads it ONCE off its
45
+ * instructions — slot reloads, the epilogue's restores and plain loads alike, which the IR does
46
+ * not all show — and its own stack-argument refusals take the same number, so one lift answers
47
+ * the premise once (`storedNeverRead` adds only the loads it alone sees, through a captured
48
+ * address). Absent where the target states no such offset. */
49
+ loadedWords?: { readonly from: number; readonly bound: number };
50
+ capturedObjectIsTheWholeFrame: boolean;
51
+ /** the frame offsets whose escape the frontend's outgoing-argument verdict leans on — each one
52
+ * re-proven here (`recheckEscapesReliedOn`). Absent or empty where it leans on none. */
53
+ escapesReliedOn?: ReadonlySet<number>;
54
+ /** every capture the add arm re-minted at a constant offset from it */
55
+ movedCaptures: ReadonlySet<Value>;
56
+ /** whether a callee's declaration rules out a struct returned through a hidden pointer at
57
+ * argument 0 (aggregate.ts `returnsWithoutHiddenPointer`), asked of the frontend's call
58
+ * declarations */
59
+ returnsWithoutHiddenPointer: CallDeclarations['returnsWithoutHiddenPointer'];
60
+ symbols: SymbolMap | undefined;
61
+ target: TargetDescription;
62
+ /** the retraction rules an escape is judged by; `FRAME_ESCAPE_GATES` when absent, and a
63
+ * census or an ablation hands in its own (`FRAME_OBJECT_AUDIT`) */
64
+ gates?: readonly Gate<FrameEscape>[];
65
+ /** the bytes a first audit asked to have kept as ONE object in memory (`FrameObjectRelift`):
66
+ * the frontend routes every access inside them through an `laddr`, and the audit declares
67
+ * them one object rather than judging each offset on its own */
68
+ oneObject?: FrameRange;
69
+ }
70
+
71
+ export interface FrameRange {
72
+ readonly from: number;
73
+ readonly to: number;
74
+ }
75
+
76
+ /** THE LOCAL AREA ABOVE A FRAME'S OUTGOING AREA, `[from, to)` in post-push stack-pointer offsets:
77
+ * from where the outgoing words end (`outgoingEnd`, rounded up to `align`) to the lowest register
78
+ * save at or above that, or the frame's top. The rule O32's `above-outgoing-area` compilers
79
+ * (frontend/mips.ts `o32LocalArea`) and mwcc (frontend/ppc.ts, `compilerBehaviors.linkageLocalArea`)
80
+ * both keep; each frontend measures the numbers it is applied to. Null where nothing is left. */
81
+ export function areaAboveOutgoing({
82
+ frameSize,
83
+ outgoingEnd,
84
+ saves,
85
+ align,
86
+ }: {
87
+ frameSize: number;
88
+ outgoingEnd: number;
89
+ saves: readonly number[];
90
+ align: number;
91
+ }): FrameRange | null {
92
+ const from = Math.ceil(outgoingEnd / align) * align;
93
+ const area = { from, to: Math.min(frameSize, ...saves.filter((o) => o >= from)) };
94
+ return area.from < area.to ? area : null;
95
+ }
96
+
97
+ /** Whether a `width`-byte frame access at post-push offset `at` lands on a byte of `area`. A
98
+ * frontend that routes accesses into the area by their post-push offset asks it of every access
99
+ * made at ANOTHER depth, which that routing would misname, and draws no area where one does. */
100
+ export const landsOnArea = (area: FrameRange, at: number, width: number): boolean =>
101
+ at < area.to && at + width > area.from;
102
+
103
+ /** What an audit answers instead of a refusal when a device may read the frame, or a callee write
104
+ * it, without bound: lift again with `oneObject` set to these bytes. */
105
+ export interface FrameObjectRelift {
106
+ readonly oneObject: FrameRange;
107
+ }
108
+
109
+ /** THE RELIFT PROTOCOL, AT MOST TWICE, the one copy every frontend that runs the audit calls: lift,
110
+ * and where the audit answers with bytes to keep as one object (`FrameObjectRelift`), lift once
111
+ * more with them routed through an `laddr`. The second audit judges them as one object and refuses
112
+ * rather than asking again, so a second request is an internal error, not a third lift. */
113
+ export function liftWithRelift<F>(
114
+ name: string,
115
+ liftOnce: (oneObject: FrameRange | undefined) => F | FrameObjectRelift,
116
+ ): F {
117
+ const first = liftOnce(undefined);
118
+ if (!isRelift(first)) {
119
+ return first;
120
+ }
121
+ const again = liftOnce(first.oneObject);
122
+ if (isRelift(again)) {
123
+ throw new Error(`internal: the frame-object audit of '${name}' asked for a second relift`);
124
+ }
125
+ return again;
126
+ }
127
+
128
+ const isRelift = <F>(x: F | FrameObjectRelift): x is FrameObjectRelift =>
129
+ typeof x === 'object' && x !== null && 'oneObject' in x;
130
+
131
+ /** What an audit answers: lift again (`FrameObjectRelift`), the device pin the accepted function
132
+ * owes (`DevicePins`, applied by `pinDeviceAccesses`), or nothing, when no frame address was
133
+ * taken. */
134
+ export type FrameObjectVerdict = FrameObjectRelift | DevicePins | undefined;
135
+
136
+ /** One escaped object, as the rules an escape retracts read it: what it may reach, `[lo, hi)`
137
+ * from its own offset, whether it may write, and the first other object, keyed slot and
138
+ * unaccounted frame word inside that reach — so a refusal names the fact its gate tested. */
139
+ export interface FrameEscape {
140
+ readonly off: number;
141
+ readonly lo: number;
142
+ readonly hi: number;
143
+ readonly writes: boolean;
144
+ /** how the address left, as the refusal words it */
145
+ readonly how: string;
146
+ readonly objectReached: number | undefined;
147
+ readonly slotReached: { readonly slot: number; readonly above: boolean } | undefined;
148
+ /** the lowest frame slot inside that reach the function holds an `undef` of */
149
+ readonly undefReached: number | undefined;
150
+ /** the lowest owned word inside that reach that no object and no slot accounts for */
151
+ readonly unaccountedWord: number | undefined;
152
+ }
153
+
154
+ // WHAT AN ESCAPE COSTS. The audit bounds what WE access through an object, never what a callee
155
+ // does with the address it was handed — and a callee may write any offset from it. So an escape
156
+ // retracts four claims, each over the bytes it may reach (`readWindow`): the whole frame for a
157
+ // writer nothing bounds. They are
158
+ // a table (`l3/gates.ts`), taken as `FrameObjectAudit.gates` so a census or an ablation can reach
159
+ // them; the rest of the audit — the split, the use classification, the premise re-check, the
160
+ // shape and overlap checks — decides what the objects ARE, in the stages `auditFrameObjects` runs.
161
+ export const FRAME_ESCAPE_GATES: readonly Gate<FrameEscape>[] = [
162
+ // The first is that the other ADDRESS-TAKEN objects are private, and it keys on the REACH, not
163
+ // on `mayWrite`. Its argument is about LAYOUT, and layout is symmetric: two objects are two
164
+ // separate C locals with no guaranteed adjacency, so a device that READS past the one it was
165
+ // given is as wrong as a callee that writes past it. `DmaCopy` with a count of two halfwords
166
+ // off `&sp0` transfers `[sp,#2]` too, and the emitted source transfers whatever the recompiler
167
+ // put after `sp0`, and the second object's own store is whatever the recompiler made of it.
168
+ // Marking both volatile would not repair that: the locals are still placed independently. A
169
+ // read the control word bounds to the object's own bytes reaches no neighbour, whatever its
170
+ // placement: two fixed-source DMA fills in one function, each off its own `volatile` local.
171
+ //
172
+ // It counts `laddr` objects. A neighbour SPILLED to an SSA slot is the slot rule's below.
173
+ {
174
+ id: 'reaches-another-object',
175
+ why: 'two locals have no guaranteed adjacency, so a reach past one lands on whatever the recompile put there',
176
+ sound: true,
177
+ guardedBy: 'thumb-frontend.test.ts: an incrementing fill reads the object above its own',
178
+ rejects: (e) => e.objectReached !== undefined,
179
+ },
180
+ // The second is `undef`, which rests on this function's own stores being the ONLY writer of
181
+ // its frame. A wider real object (`struct P p; g(&p);` where only `p.x` is read here) has its
182
+ // later words written by `g` and read back at a slot no store of ours reaches — declaring
183
+ // those uninitialised spells the callee's value as garbage. The extents here are inferred
184
+ // from OUR accesses, which is the number that is too small in this shape.
185
+ //
186
+ // On an escape and not on "a laddr exists": an address dereferenced only in-function cannot
187
+ // be written by anyone else, and the audit's overlap checks cover its aliasing.
188
+ //
189
+ // FRAME undefs only. A register-keyed one says a local lives in a register the ABI does not
190
+ // pass arguments in, and no address reaches a register — the escape this retraction is about
191
+ // cannot touch it, and counting it would refuse the whole function for an unrelated escape.
192
+ //
193
+ // …and only the frame slots inside the writer's reach. A callee handed only its own return
194
+ // storage writes the struct it returns and nothing else, so a slot outside that struct has no
195
+ // writer but this function: `if (a) x = gF(h); r = mk8(b); g4(i, j, x, a);` spills `x` beside
196
+ // `r`'s temp, unstored on one path, and that slot is still uninitialised there.
197
+ {
198
+ id: 'writer-over-undef',
199
+ why: 'an unstored frame slot is uninitialised only while this function is its sole writer',
200
+ sound: true,
201
+ guardedBy: 'thumb-frontend.test.ts: an ESCAPED frame address retracts the undef argument',
202
+ rejects: (e) => e.writes && e.undefReached !== undefined,
203
+ },
204
+ // …and the SLOT MODEL is the third claim an escape retracts — the undef rule's argument
205
+ // taken one step further. The audit's extents are inferred from OUR accesses, so an object
206
+ // wider in the SOURCE than the bytes this function touches has its later words written by
207
+ // the callee — and any of those modelled as an SSA slot is a value the slot model forwards
208
+ // ACROSS the call that overwrote it.
209
+ //
210
+ // The object has to be reached ONLY through the captured pointer (an `[sp,#0]` access of its own collides with the slot model and
211
+ // declines at the overlap check), which is what four corpus functions do:
212
+ //
213
+ // mov r2, sp / str r0, [r2] @ the object, written through the captured address
214
+ // str r1, [sp, #0x4] @ a word the slot model keys
215
+ // mov r0, r2 / bl g @ the base escapes; `g` may write [sp,#4]
216
+ // ldr r0, [sp, #0x4] @ …and the machine RELOADS it after the call
217
+ //
218
+ // and lifted, the reload is the value from BEFORE the call, the callee's write dropped, no
219
+ // diagnostic. Exactly the silent-wrong-answer trade the sp guards exist to prevent, so it refuses.
220
+ //
221
+ // WHAT IT COSTS: it refuses every word slot the escape may reach, which is blunter than the
222
+ // hazard it names. Narrowing it needs the object's real extent, and this model does not carry
223
+ // one: `extent` is a single width from a single access. The asm sometimes cannot
224
+ // supply it either — the compiled twin at `capturedObjectIsTheWholeFrame` is exactly this
225
+ // rule's shape, a slot THIS FUNCTION stores and reloads, undecidable between a spill and a
226
+ // member.
227
+ //
228
+ // A callee is not the only writer. `struct M { u8 b; u8 pad[3]; s32 t; }; gp = &m; g2();
229
+ // use2(m.t);` PUBLISHES the base to an ordinary global and the machine reloads [sp,#4] after
230
+ // `bl g2` — `g2` writes through `gp`, which points here: the same hazard as the call shape, one
231
+ // escape over.
232
+ //
233
+ // …and a writer is not the only hazard, so the rule keys on `escaped`, not `mayWrite`. A
234
+ // device that only READS through the address reads the slot's bytes from memory, where the
235
+ // slot model never put them: `struct P { u32 a, b; } s; s.a = x; s.b = y; REG_DMA3SAD =
236
+ // (u32)&s;` above an outgoing block is `str r4, [sp, #0x4] / str r5, [sp, #0x8] / add r1, sp,
237
+ // #0x4 / str r1, [DMA3SAD]`, where the slot model would keep `s.b` in a register and drop its
238
+ // store as dead while the DMA reads that word. The asm cannot tell that second word from a
239
+ // neighbour spilled beside the object, so what bounds the read is the transfer's own control
240
+ // word, `readWindow`.
241
+ //
242
+ // …and BELOW the object as well as above it. A C object extends upward from its base, but a
243
+ // captured address need not BE a base: `add r0, sp, #0x4` is `&buf[1]` as readily as `&b`, and
244
+ // `buf[0]` at [sp,#0] is then read or written through `p[-1]`. Only down to the outgoing
245
+ // block, whose words are the callee's arguments and never part of a local.
246
+ {
247
+ id: 'reaches-a-slot',
248
+ why: 'the slot model keeps a slot in a register, so a write or read through the frame misses it',
249
+ sound: true,
250
+ guardedBy: 'thumb-frontend.test.ts: above: a word a device may read past the object',
251
+ rejects: (e) => e.slotReached !== undefined,
252
+ },
253
+ // …and the FOURTH claim an escape retracts is the object's TOP, which the three rules above
254
+ // leave to whatever this function happened to touch. `extent` is one width from one access,
255
+ // so an object wider in the SOURCE than those bytes is declared too small — and a callee
256
+ // holding its address writes frame bytes the emitted C never allocated. Compiled:
257
+ //
258
+ // u8 buf[12]; buf[0] = x; garr(buf); use2(buf[0]);
259
+ // → add sp,sp,#-0xc / mov r1,sp / strb r0,[r1] / mov r0,sp / bl garr
260
+ //
261
+ // lifted as `u8 sp0; garr(&sp0); use2(sp0)` — a 12-byte object declared one byte, in a frame
262
+ // the recompile makes 4 bytes wide, with `garr` writing the other 8 into the caller's. The
263
+ // three rules above all pass it: one object, no `undef` op, no slot above it.
264
+ //
265
+ // What licenses an answer is the frame being ACCOUNTED FOR, word by word. Every word of the
266
+ // reserved local area the escape reaches has to be an object this audit modelled or a slot the
267
+ // slot model keys; a word that is neither is storage nothing here describes, so the emitted C
268
+ // reserves less than the machine did and the writer reaches past what it allocated. A writer
269
+ // that may write anywhere through the address reaches the whole local area, below the object as
270
+ // well as above it; a callee handed only its own return storage writes the struct it returns
271
+ // and nothing else (`readWindow`), so a word outside that struct is one it never reaches. Word
272
+ // granularity, not byte — the stack is word-aligned, so a halfword object owns its word and the
273
+ // padding beside it is not a second local.
274
+ //
275
+ // …and on a READER as far as it reads, since a read past the object copies frame bytes the
276
+ // source reserved and the recompile does not. `u16 buf[8]; buf[0] = x; CpuSet(buf, gDst, 8);`
277
+ // reads the sixteen bytes its control word names, and a DMA copy incrementing from `buf` reads
278
+ // every word above it; lifted as `u16 sp0`, the recompile's frame is four bytes wide and the
279
+ // transfer copies the saved `lr` and the caller's frame out with it. A read unbounded both ways,
280
+ // and a callee's write, meet this rule only beside a writer the one-object answer cannot hold
281
+ // (`writerNotKept`): otherwise the audit keeps the local area as one object instead.
282
+ //
283
+ // WHAT IT LEAVES, since this is the extent question the gate comment above is about: an escape
284
+ // is accepted only where the modelled objects and the keyed slots tile the reserved area it
285
+ // reaches — a word above the object is a slot (refused above), a second object (refused above),
286
+ // or unaccounted (refused here). That is not a wider extent model; it is the same one-scalar
287
+ // `extent`, made to say when it does not fit. An object of two words cannot be built here at
288
+ // all — the second access that would reach it is a `[+4]` the `scalar()` guard refuses — so no
289
+ // widening of the frame licence admits a shape this rule would then have to judge.
290
+ //
291
+ // AND IT IS THE SCALAR ARM THIS BOUNDS. An UNTYPED object is the whole DECLARED area by
292
+ // construction — `notTheWholeArea` accepts nothing else — and every word below the declared
293
+ // area is the licensed outgoing block, whose argument words the slot model keys. The object and
294
+ // those slots together account for every word this walk asks about, so no input makes the rule
295
+ // fire on that path; `accountedWords` needs its slot term for that as much as its object term.
296
+ // What bounds THAT path is `notTheWholeArea`'s own live clauses — a second object, an object
297
+ // that does not start at the bottom of the declared area, an address that reaches memory rather
298
+ // than a callee, and the callee's declared return — and `overSlot`, which refuses a slot keyed
299
+ // inside the declared area.
300
+ {
301
+ id: 'reaches-an-unaccounted-word',
302
+ why: 'an escape reaching a frame word no declaration covers reaches past what the recompile allocates',
303
+ sound: true,
304
+ guardedBy: [
305
+ 'thumb-frontend.test.ts: an array whose top nothing bounds declines rather than shrinking the frame',
306
+ 'thumb-frontend.test.ts: a copy that reads past its object into a word nothing accounts for declines',
307
+ ],
308
+ rejects: (e) => e.unaccountedWord !== undefined,
309
+ },
310
+ ];
311
+
312
+ /** FRAME-OBJECT AUDIT. Every `laddr` the frontend emitted is only a CLAIM that the address it
313
+ * names is used as "the address of one local object"; this proves it, over the finished function,
314
+ * the same boundary-total style as the slot-escape assert in finish(). The address may flow
315
+ * anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — and it is
316
+ * judged in one of two models, chosen before any shape is:
317
+ * - PER OBJECT, the default: every MEMORY access through it must be at offset 0, with one agreed
318
+ * width and one agreed extension, or a read or write through a runtime index into storage
319
+ * nothing else types — bytes, or ONE wider width, read through a cast on the bytes
320
+ * (`indexedWidth`) — and its bytes must belong to nothing else in the frame;
321
+ * - ONE OBJECT, where a device reads or a callee writes without bound and every writer is one
322
+ * that answer can hold (`writerNotKept`), or where no address escapes at all and the
323
+ * per-object model refuses (`oneObject`, which this answers and the frontend lifts again
324
+ * with): the local area is one `u8` array in memory, every fixed-offset access in it is a
325
+ * member at its own offset, a runtime index may reach it at a byte or at ONE wider
326
+ * width (`indexedWidth`), and two widths at one byte are the views of one union
327
+ * (`unionViews`), or refuse where no view names them (`unionRefusal`).
328
+ * In either model, a store through a capture that reaches a call unread on every path, beneath
329
+ * every word a load proves a local's, declines: it may be that call's stack argument
330
+ * (`storedNeverRead`). Which load proves it is the compiler's premise: one after a call under
331
+ * `localsAboveOutgoingArea`, and any load from the offset up under `outgoingAreaNeverLoadedFrom`.
332
+ * Any use the audit cannot vouch for declines the whole function loudly. Nothing here guesses: a
333
+ * scalar's declared type is exactly the access type the machine used, and an object NO access
334
+ * reaches is sized by the frame reservation and left untyped.
335
+ *
336
+ * Takes its inputs explicitly rather than closing over `lift`. Every one of them is READ, none is
337
+ * reassigned, and the only mutations are to the ops reachable through `irBlocks`: an `add` of a
338
+ * capture and a constant is re-minted as the `laddr` it names, a moved-from capture nothing reads
339
+ * is dropped, a capture addressed through at fixed offsets is split into the objects its accesses
340
+ * name, and each surviving `laddr` is stamped with its width, signedness, count and `volatile`.
341
+ * One object mutates more: every access through a member is re-based onto one minted `laddr`,
342
+ * and the member `laddr`s are deleted or re-minted as that one moved by a `const`. An accepted
343
+ * function is answered with the device pin it owes, under the model it was accepted in
344
+ * (frontend/device-pins.ts).
345
+ *
346
+ * STAGED, and the order the stages run in is the refusal order: the first stage to refuse names
347
+ * the decline. Three things cross stages besides their returned records: `irBlocks`, rewritten by
348
+ * the fold, the split, the one-object answer and the stamp; `foldedHere`, which the split adds to
349
+ * and the slot-overlap refusal reads; and `ModelChoice.refused`, which the model choice and the
350
+ * shape stage set and the relift test reads. */
351
+ export function auditFrameObjects({
352
+ name,
353
+ irBlocks,
354
+ ownedLocals: owned,
355
+ declaredLocals: declared,
356
+ usedSlotOffsets,
357
+ loadedWords,
358
+ capturedObjectIsTheWholeFrame,
359
+ escapesReliedOn = new Set(),
360
+ movedCaptures,
361
+ returnsWithoutHiddenPointer,
362
+ symbols,
363
+ target,
364
+ gates = FRAME_ESCAPE_GATES,
365
+ oneObject,
366
+ }: FrameObjectAudit): FrameObjectVerdict {
367
+ const { laddrs: captures, foldedHere } = foldMovedCaptures(irBlocks, movedCaptures);
368
+ // …and the audit runs for a licensed acceptance with no object at all, so the premise re-check
369
+ // is total rather than resting on "the capture always survives into the IR".
370
+ if (captures.length === 0 && !capturedObjectIsTheWholeFrame && escapesReliedOn.size === 0) {
371
+ return undefined;
372
+ }
373
+ const fail = frameRefusal(name);
374
+ if (captures.length === 0 && escapesReliedOn.size > 0) {
375
+ recheckEscapesReliedOn(escapesReliedOn, undefined, fail);
376
+ }
377
+ const facts = frameAddressFacts(irBlocks, symbols, target);
378
+ const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere, declaredStructs(irBlocks));
379
+ const objects = frameObjects(laddrs, owned, fail);
380
+ const flow = addressFlow(irBlocks, objects, facts, fail);
381
+ const uses = classifyFrameUses({
382
+ irBlocks,
383
+ objects,
384
+ flow,
385
+ facts,
386
+ splitRefusal,
387
+ oneObject,
388
+ target,
389
+ fail,
390
+ });
391
+ const windows = escapeWindows(irBlocks, uses, flow, facts, target);
392
+ const model = chooseFrameModel({ oneObject, uses, windows, returnsWithoutHiddenPointer, fail });
393
+ if (capturedObjectIsTheWholeFrame) {
394
+ recheckWholeFramePremise({ uses, returnsWithoutHiddenPointer, fail });
395
+ }
396
+ recheckEscapesReliedOn(escapesReliedOn, uses, fail);
397
+ storedNeverRead(irBlocks, uses, loadedWords, target, fail);
398
+ // The device pin's trigger: the `readOnlyAddressSinks` registers a frame address was stored to.
399
+ const sinks = [...new Set([...uses.sourceStores.values()].flat().map((s) => s.sink))];
400
+ if (oneObject !== undefined) {
401
+ keepAsOneObject({
402
+ oneObject,
403
+ irBlocks,
404
+ objects,
405
+ uses,
406
+ usedSlotOffsets,
407
+ returnsWithoutHiddenPointer,
408
+ arrayOrder: target.compilerBehaviors.addressTakenArrayOrder,
409
+ arrayAlignment: target.compilerBehaviors.addressTakenArrayAlignment,
410
+ fail,
411
+ });
412
+ return { policy: 'one-object', sinks };
413
+ }
414
+ const shapes = objectShapes({
415
+ objects,
416
+ uses,
417
+ owned,
418
+ declared,
419
+ usedSlotOffsets,
420
+ returnsWithoutHiddenPointer,
421
+ model,
422
+ fail,
423
+ });
424
+ const escapes = frameEscapes({ irBlocks, uses, windows, shapes, owned, declared, usedSlotOffsets });
425
+ // THE ONE-OBJECT ANSWER, where it is on offer (`chooseFrameModel`: one escape reaches without
426
+ // bound and every writer is one it can hold, or no address escapes at all) and the per-object
427
+ // model does not describe the frame: it refused a shape, an object sits over a slot, or the
428
+ // unbounded reach meets another object, a slot or a word nothing accounts for. Then what the
429
+ // device may read or the callee may write — or, with no escape, only this function's own
430
+ // accesses — is kept rather than refused: lift again with the local area as one object in memory
431
+ // (`keepAsOneObject`). Below the local area are the outgoing arguments and above it the saved
432
+ // registers, neither of them an object. Where the per-object model does describe the frame —
433
+ // one object and nothing else in reach — it stands, and the object keeps its own type.
434
+ // An escape `misses` bounded is asked as the unbounded read it was without that proof
435
+ // (`ModelChoice.unboundedAt`): the proof may add a per-object lift, never take the one-object one.
436
+ const answered = (e: FrameEscape): boolean =>
437
+ model.unboundedAt.has(e.off) &&
438
+ (e.objectReached !== undefined || e.slotReached !== undefined || e.unaccountedWord !== undefined);
439
+ if (model.onOffer && (model.refused || shapes.overSlot.length > 0 || escapes.some(answered))) {
440
+ return { oneObject: { from: declared.from, to: declared.to } };
441
+ }
442
+ const failWithheld: Refuse = (why) => fail(model.withheld(why));
443
+ for (const [off, width] of shapes.overSlot) {
444
+ failIfSlotKeysIt(off, width, { usedSlotOffsets, objects, foldedHere, fail: failWithheld });
445
+ }
446
+ // RULE-MAJOR, not escape-major: every escape is asked a rule before any is asked the next, so
447
+ // the refusal a function reports does not turn on the order its escapes were found in.
448
+ for (const gate of gates) {
449
+ const hit = escapes.find((e) => gate.rejects(e));
450
+ if (hit !== undefined) {
451
+ (answered(hit) ? failWithheld : fail)(escapeRefusal(gate.id, hit));
452
+ }
453
+ }
454
+ stampObjects(objects, shapes, uses);
455
+ return { policy: 'per-object', sinks };
456
+ }
457
+
458
+ /** Refuses the whole function, naming the address-taken local as what it cannot lift. */
459
+ type Refuse = (why: string) => never;
460
+
461
+ function frameRefusal(name: string): Refuse {
462
+ return (why) => {
463
+ throw new FrontendUnsupportedError(`cannot lift '${name}': address-taken stack local — ${why}`);
464
+ };
465
+ }
466
+
467
+ /** THE FRAME BYTES A DECLARATION TYPES AS ONE STRUCT: a call's return storage (`sret`) and a
468
+ * struct passed by value (`byValue`, call-plan.ts `foldByValueArgs`), where the operand is an
469
+ * `laddr`. */
470
+ function declaredStructs(irBlocks: readonly Block[]): FrameRange[] {
471
+ const laddrOff = new Map<Value, number>();
472
+ for (const b of irBlocks) {
473
+ for (const op of b.ops) {
474
+ if (op.opcode === 'laddr') {
475
+ laddrOff.set(op.results[0], op.attrs.off as number);
476
+ }
477
+ }
478
+ }
479
+ const out: FrameRange[] = [];
480
+ for (const b of irBlocks) {
481
+ for (const op of b.ops) {
482
+ if (op.opcode !== 'call') {
483
+ continue;
484
+ }
485
+ const t = op.results[0]?.type;
486
+ const from = laddrOff.get(op.operands[0]);
487
+ if (op.attrs.sret === true && from !== undefined && t?.kind === 'struct' && t.size !== undefined) {
488
+ out.push({ from, to: from + t.size });
489
+ }
490
+ for (const { at } of byValueOperands(op)) {
491
+ const v = op.operands[at];
492
+ const off = laddrOff.get(v);
493
+ const t = v?.type.kind === 'ptr' ? v.type.to : undefined;
494
+ if (off !== undefined && t?.kind === 'struct' && t.size !== undefined) {
495
+ out.push({ from: off, to: off + t.size });
496
+ }
497
+ }
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+
503
+ /** A CAPTURE MOVED BY A CONSTANT IS THE CAPTURE AT THE SUM, and here the constant is exact: an
504
+ * `add` of a `laddr` and a `const` — a register the lift could not see through, `mov r0, sp /
505
+ * movs r2, #0 / ldrsh r1, [r0, r2]`, or a move the pre-lift walk does not follow — is re-minted
506
+ * as the `laddr` it names. The walk still decides which `[sp,#k]` words are routed to an object
507
+ * rather than keyed as slots, so a fold it did not make is remembered (`foldedHere`): an object it
508
+ * lands on a keyed slot is refused as the move, the capability that is missing
509
+ * (`failIfSlotKeysIt`).
510
+ *
511
+ * AN INDEX INTO A CAPTURE, MOVED BY A NONZERO CONSTANT, IS THE SAME INDEX INTO THE CAPTURE AT THE
512
+ * SUM. agbcc spells `a[i]` on a local at [sp,#k) as the frame base plus the index, moved after:
513
+ * `mov r0, sp / add r0, r0, r4 / add r0, #0x4` or `add r0, r0, sp / add r0, #0x4` — the bytes
514
+ * `add r0, sp, #0x4 / add r0, r0, r4` reach, so `add(add(laddr@a, i), k)` is re-minted as
515
+ * `add(laddr@(a+k), i)`, and the inner sum and capture are dropped when nothing else reads them.
516
+ * The object at [sp,#a) is often the outgoing-argument word (a 5-argument call beside
517
+ * the array), which the index never touches. Zero is left alone: `add(sum, 0)` is agbcc's
518
+ * `mov r1, #0x0 / ldrsb r0, [r0, r1]`, which `indexedAccess` judges where the register put it.
519
+ * The rebased object is held to every rule an object at a+k is held to, so a displaced FIELD of a
520
+ * struct element — no object of its own at a+k, the struct's other bytes at a — lands as a second
521
+ * object beside the first and is refused or lifted as one object by the same rules. A load or
522
+ * store's own nonzero displacement on the sum is the same move: `ldrb r0, [r0, #0x1]` (the field
523
+ * agbcc reads unsigned) and `mov r1, #0x1 / ldrsb r0, [r0, r1]` (signed) reach the same byte, so
524
+ * the access is re-pointed at `add(laddr@(a+d), i)` with displacement 0, and the verdict does not
525
+ * depend on where the literal sits.
526
+ *
527
+ * Returns every `laddr` that survives, in block order. */
528
+ function foldMovedCaptures(
529
+ irBlocks: Block[],
530
+ movedCaptures: ReadonlySet<Value>,
531
+ ): { laddrs: Op[]; foldedHere: Set<Op> } {
532
+ const moved = new Set<Value>(movedCaptures);
533
+ const foldedHere = new Set<Op>();
534
+ const constOf = new Map<Value, number>();
535
+ const laddrOf = new Map<Value, Op>();
536
+ for (const blk of irBlocks) {
537
+ for (const op of blk.ops) {
538
+ if (op.opcode === 'const') {
539
+ constOf.set(op.results[0], op.attrs.value as number);
540
+ } else if (op.opcode === 'laddr') {
541
+ laddrOf.set(op.results[0], op);
542
+ }
543
+ }
544
+ }
545
+ // the `add` of a capture and a runtime value that defines each value, for the index rebase
546
+ const indexOf = new Map<Value, { base: Op; index: Value; sum: Op }>();
547
+ const rebased = new Set<Op>();
548
+ for (let changed = true; changed;) {
549
+ changed = false;
550
+ for (const blk of irBlocks) {
551
+ blk.ops = blk.ops.flatMap((op) => {
552
+ const [x, y] = op.operands;
553
+ const sum0 = (op.opcode === 'load' || op.opcode === 'store') && x !== undefined ? indexOf.get(x) : undefined;
554
+ const disp = (op.attrs.off as number | undefined) ?? 0;
555
+ if (sum0 !== undefined && disp !== 0) {
556
+ const object = mkOp('laddr', {
557
+ results: [mkValue(sum0.base.results[0].type)],
558
+ attrs: { off: ((sum0.base.attrs.off as number) + disp) | 0 },
559
+ });
560
+ const moves = mkOp('add', {
561
+ results: [mkValue(x.type)],
562
+ operands: [object.results[0], sum0.index],
563
+ attrs: sum0.sum.attrs,
564
+ });
565
+ laddrOf.set(object.results[0], object);
566
+ indexOf.set(moves.results[0], { base: object, index: sum0.index, sum: moves });
567
+ moved.add(sum0.base.results[0]);
568
+ foldedHere.add(object);
569
+ rebased.add(sum0.sum);
570
+ op.operands[0] = moves.results[0];
571
+ op.attrs = { ...op.attrs, off: 0 };
572
+ changed = true;
573
+ return [object, moves, op];
574
+ }
575
+ if (op.opcode !== 'add' || op.operands.length !== 2) {
576
+ return [op];
577
+ }
578
+ const base = laddrOf.get(x) ?? laddrOf.get(y);
579
+ const by = constOf.get(laddrOf.has(x) ? y : x);
580
+ if (base !== undefined && by !== undefined) {
581
+ const off = ((base.attrs.off as number) + by) | 0;
582
+ const object = mkOp('laddr', { results: op.results, attrs: { off } });
583
+ laddrOf.set(op.results[0], object);
584
+ moved.add(base.results[0]);
585
+ foldedHere.add(object);
586
+ changed = true;
587
+ return [object];
588
+ }
589
+ const index = base === undefined ? undefined : laddrOf.has(x) ? y : x;
590
+ if (base !== undefined && index !== undefined && !laddrOf.has(index)) {
591
+ // a sum first seen here takes another pass: a use laid out in an EARLIER block was
592
+ // visited before the sum was known, and is moved only on the next one
593
+ if (!indexOf.has(op.results[0])) {
594
+ indexOf.set(op.results[0], { base, index, sum: op });
595
+ changed = true;
596
+ }
597
+ return [op];
598
+ }
599
+ const sum = indexOf.get(x) ?? indexOf.get(y);
600
+ const k = constOf.get(indexOf.has(x) ? y : x);
601
+ if (sum === undefined || k === undefined || k === 0) {
602
+ return [op];
603
+ }
604
+ const object = mkOp('laddr', {
605
+ results: [mkValue(sum.base.results[0].type)],
606
+ attrs: { off: ((sum.base.attrs.off as number) + k) | 0 },
607
+ });
608
+ const moves = mkOp('add', { results: op.results, operands: [object.results[0], sum.index], attrs: op.attrs });
609
+ laddrOf.set(object.results[0], object);
610
+ indexOf.set(op.results[0], { base: object, index: sum.index, sum: moves });
611
+ moved.add(sum.base.results[0]);
612
+ foldedHere.add(object);
613
+ rebased.add(sum.sum);
614
+ changed = true;
615
+ return [object, moves];
616
+ });
617
+ }
618
+ }
619
+ // A capture MOVED by a constant and nothing else reads names no object — the moved one does —
620
+ // so it is dropped rather than judged as an object with no use, and so is a fold nothing reads:
621
+ // the write-back of `stmia r1!, {r2, r3, r4}` through a capture is `add r1, r1, #12`, a pointer
622
+ // to the end of what was copied that the machine never uses. Only those: an unused `mov rD, sp`
623
+ // of the machine's own is still a capture, and is judged.
624
+ const readNow = (): Set<Value> => {
625
+ const read = new Set<Value>();
626
+ for (const blk of irBlocks) {
627
+ for (const op of blk.ops) {
628
+ op.operands.forEach((v) => read.add(v));
629
+ (op.successors ?? []).forEach((s) => s.args.forEach((v) => read.add(v)));
630
+ }
631
+ }
632
+ return read;
633
+ };
634
+ // …and an index sum a rebase moved off, once nothing reads it, goes the same way first
635
+ if (rebased.size > 0) {
636
+ const before = readNow();
637
+ for (const blk of irBlocks) {
638
+ blk.ops = blk.ops.filter((op) => !rebased.has(op) || before.has(op.results[0]));
639
+ }
640
+ }
641
+ const read = readNow();
642
+ const laddrs: Op[] = [];
643
+ for (const blk of irBlocks) {
644
+ blk.ops = blk.ops.filter(
645
+ (op) => op.opcode !== 'laddr' || read.has(op.results[0]) || !(moved.has(op.results[0]) || foldedHere.has(op)),
646
+ );
647
+ for (const op of blk.ops) {
648
+ if (op.opcode === 'laddr') {
649
+ laddrs.push(op);
650
+ }
651
+ }
652
+ }
653
+ return { laddrs, foldedHere };
654
+ }
655
+
656
+ /** What the audit reads a value as: its defining op, its constant, the literal address it denotes,
657
+ * and the read-only device registers a store hands it to. Read off the IR as the fold left it, so
658
+ * `defOf` names no `laddr` the split mints after. */
659
+ interface FrameAddressFacts extends LiteralAddresses {
660
+ /** the registers this store hands the WHOLE address to, when each is one a device only reads
661
+ * through, else none */
662
+ readsThrough(op: Op): readonly number[];
663
+ }
664
+
665
+ function frameAddressFacts(
666
+ irBlocks: readonly Block[],
667
+ symbols: SymbolMap | undefined,
668
+ target: TargetDescription,
669
+ ): FrameAddressFacts {
670
+ const readOnlySinks = new Set(target.capabilities.readOnlyAddressSinks ?? []);
671
+ const addresses = literalAddresses(irBlocks, symbols);
672
+ const { defOf, literalAddrOf } = addresses;
673
+ // The spacing of the read-only registers: a runtime index that is a multiple of it, from one
674
+ // of them, names one of them and never a register between — the channel stride.
675
+ const sinkSpacing = [...readOnlySinks].reduce((g, s, _, all) => gcd(g, s - all[0]), 0);
676
+ // Word stores only: a `strh` to a source register hands over half an address, so the device's
677
+ // source is not this object. A literal base names one register; a literal plus a runtime index —
678
+ // `(vu32 *)(0x40000B0 + ch * 12)`, a channel chosen at run time — names every read-only register
679
+ // its stride reaches, and only when that stride is a multiple of their spacing, so no index lands
680
+ // between them. A base this cannot resolve — computed, register-offset, merged by a phi — is the
681
+ // conservative answer.
682
+ const readsThrough = (op: Op): readonly number[] => {
683
+ if (readOnlySinks.size === 0 || (op.attrs.width as number) !== 4) {
684
+ return [];
685
+ }
686
+ const off = op.attrs.off as number;
687
+ const base = literalAddrOf(op.operands[0]);
688
+ if (base !== undefined) {
689
+ return readOnlySinks.has(base + off) ? [base + off] : [];
690
+ }
691
+ const d = defOf.get(op.operands[0]);
692
+ if (d?.opcode !== 'add' || d.operands.length !== 2 || sinkSpacing === 0) {
693
+ return [];
694
+ }
695
+ for (const [lit, index] of [d.operands, [...d.operands].reverse()]) {
696
+ const from = literalAddrOf(lit);
697
+ const stride = from === undefined ? 0 : linearFactor({ defs: defOf, constOf: addresses.constOf }, index);
698
+ if (from !== undefined && readOnlySinks.has(from + off) && stride > 0 && stride % sinkSpacing === 0) {
699
+ return [...readOnlySinks].filter((s) => (s - (from + off)) % stride === 0);
700
+ }
701
+ }
702
+ return [];
703
+ };
704
+ return { ...addresses, readsThrough };
705
+ }
706
+
707
+ /** A FRAME BASE ADDRESSED THROUGH IS NOT A CAPTURE. Thumb-1 gives `ldr`/`str` an `[sp,#imm]`
708
+ * encoding and gives the sub-word forms none, so a byte or halfword spill can only be spelled
709
+ * by copying sp into a register and addressing through the copy:
710
+ *
711
+ * mov r2, sp
712
+ * strh r3, [r2, #0x30]
713
+ *
714
+ * That is an ADDRESSING MODE. The copy never becomes a value, and the access is the
715
+ * `[sp,#0x30]` the instruction set cannot spell — so what the machine named is one object at
716
+ * frame offset 48, not a `[+48]` reach through the frame base.
717
+ *
718
+ * A captured address whose every use is a fixed-offset sub-word ACCESS is that shape, and
719
+ * each of its accesses names its own object: re-root them onto a `laddr` at their own offset,
720
+ * read at 0, and the rest of this audit judges the objects. A capture with ANY other use is a
721
+ * real capture and keeps the frame base.
722
+ *
723
+ * What makes that judgement total is that the walk below enumerates every ROLE a value can
724
+ * appear in — every operand of every op, and every edge argument — instead of asking what an
725
+ * instruction looks like. One instruction can hold two roles: `str rD, [rD, #k]` stores the
726
+ * frame address through itself, a base use AND an escape, and the escape is what stops the
727
+ * split.
728
+ *
729
+ * Returns the `laddr`s left to judge, and why a capture was NOT split, when the reason is one no
730
+ * later message carries — the `slotsOffReason` idiom: a refusal reported as the wrong capability
731
+ * sends the improvement loop to build the wrong thing. Per CAPTURE and not per offset: every
732
+ * direct `[sp,#k]` word access is a capture of its own at k, refused here for its width, and a
733
+ * different capture at k whose access is refused for another reason must not be handed that one. */
734
+ function splitAddressingCopies(
735
+ irBlocks: Block[],
736
+ laddrs: Op[],
737
+ foldedHere: Set<Op>,
738
+ structs: readonly FrameRange[] = [],
739
+ ): { laddrs: Op[]; splitRefusal: Map<Value, string> } {
740
+ const splitRefusal = new Map<Value, string>();
741
+ const uses = new Map<Value, { op: Op; idx: number; blk: Block }[]>();
742
+ const record = (v: Value, op: Op, idx: number, blk: Block) =>
743
+ (uses.get(v) ?? uses.set(v, []).get(v)!).push({ op, idx, blk });
744
+ for (const blk of irBlocks) {
745
+ for (const op of blk.ops) {
746
+ op.operands.forEach((v, idx) => record(v, op, idx, blk));
747
+ // An EDGE ARGUMENT is a use role too, and never an access: a capture that reaches a
748
+ // block parameter is live past this block, so the taint closure (`addressFlow`) is what
749
+ // judges it. Recorded at index -1 so it can never be counted as an access — a split there
750
+ // would delete a capture the successor argument still names.
751
+ for (const succ of op.successors ?? []) {
752
+ for (const a of succ.args) {
753
+ record(a, op, -1, blk);
754
+ }
755
+ }
756
+ }
757
+ }
758
+ const minted: Op[] = [];
759
+ const consumed = new Set<Op>();
760
+ for (const capture of laddrs) {
761
+ const at = uses.get(capture.results[0]) ?? [];
762
+ const accesses = at.filter((u) => (u.op.opcode === 'load' || u.op.opcode === 'store') && u.idx === 0);
763
+ // SUB-WORD ONLY, because that is the whole of what the encoding gap forces: `ldr`/`str` DO
764
+ // have an `[sp,#imm]` form, so a WORD access through a copy is some other shape and must
765
+ // not be read as this one. It is also what keeps the outgoing-argument area safe — that
766
+ // guard reads `[sp,#k]` accesses (spMemAccess), which an access through a copy is not, and
767
+ // agbcc stages arguments 5+ there with `str`.
768
+ const subWord = accesses.every((u) => (u.op.attrs.width as number) < 4);
769
+ // A use that is not an access leaves the capture naming the frame base, and the judgement
770
+ // after reports that use itself — an escape, a phi, arithmetic — so it needs no reason
771
+ // here. The WIDTH does: nothing downstream mentions it, so a refused word access would be
772
+ // reported as "a store at [+4]" and the histogram would be asked for the wrong capability.
773
+ if (at.length === 0 || accesses.length !== at.length) {
774
+ continue;
775
+ }
776
+ // AN ADDRESS STRICTLY INSIDE A DECLARED STRUCT, ONLY ACCESSED, IS A MEMBER ACCESS OF THAT
777
+ // STRUCT. A word store into a struct passed by value goes straight to `[sp,#k]` — `x.c = w` is
778
+ // `str r1, [sp, #0x4]` over `struct F {u16 a,b; u32 c; s8 e,f;}` at [sp,#0) — and the frontend
779
+ // mints that access its own `laddr` at k. The declaration says those bytes are the struct's,
780
+ // so the address is re-rooted on the struct's base with the displacement on the access, and
781
+ // the audit judges one object rather than two that overlap. Any use that is not an access —
782
+ // `&x.c` handed on — is an interior pointer, which is not this shape and is left to refuse.
783
+ const off = capture.attrs.off as number;
784
+ const holders = structs.filter((r) => r.from < off && off < r.to);
785
+ const holder = holders[0];
786
+ const inHolder = (u: { op: Op }): boolean =>
787
+ off + (u.op.attrs.off as number) >= holder!.from &&
788
+ off + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= holder!.to;
789
+ if (
790
+ holder !== undefined &&
791
+ holders.every((r) => r.from === holder.from && r.to === holder.to) &&
792
+ accesses.every(inHolder)
793
+ ) {
794
+ for (const u of accesses) {
795
+ u.op.attrs = { ...u.op.attrs, off: off - holder.from + (u.op.attrs.off as number) };
796
+ }
797
+ capture.attrs = { ...capture.attrs, off: holder.from };
798
+ continue;
799
+ }
800
+ if (!subWord) {
801
+ splitRefusal.set(capture.results[0], 'a WORD access through the copy, and `ldr`/`str` have an `[sp,#imm]` form');
802
+ continue;
803
+ }
804
+ // Nothing to split when the capture already names ONE object: every access at offset 0 is
805
+ // the frame base itself, which is what the DMA-fill idiom captures — or every access lands in
806
+ // the declared struct the capture is the start of (`declaredStructs`), whose members they are.
807
+ const base = capture.attrs.off as number;
808
+ const into = (r: FrameRange): boolean =>
809
+ base === r.from &&
810
+ accesses.every((u) => base + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= r.to);
811
+ if (accesses.every((u) => u.op.attrs.off === 0) || structs.some(into)) {
812
+ continue;
813
+ }
814
+ for (const u of accesses) {
815
+ const res = mkValue(T.unk(32));
816
+ const at = (capture.attrs.off as number) + (u.op.attrs.off as number);
817
+ const object = mkOp('laddr', { results: [res], attrs: { off: at } });
818
+ u.blk.ops.splice(u.blk.ops.indexOf(u.op), 0, object);
819
+ minted.push(object);
820
+ if (foldedHere.has(capture)) {
821
+ foldedHere.add(object);
822
+ }
823
+ // The ADDRESS operand only — the stored value (operand 1) is passed through
824
+ // untouched, so no slot home moves (ir/core.ts `SlotHomes`). These accesses go through
825
+ // a COPY of `sp` rather than the `[sp,#k]` keys the stamp reads, so none of them
826
+ // carried one to begin with.
827
+ u.op.operands = [res, ...u.op.operands.slice(1)];
828
+ u.op.attrs = { ...u.op.attrs, off: 0 };
829
+ }
830
+ consumed.add(capture);
831
+ }
832
+ if (consumed.size === 0) {
833
+ return { laddrs, splitRefusal };
834
+ }
835
+ for (const blk of irBlocks) {
836
+ blk.ops = blk.ops.filter((op) => !consumed.has(op));
837
+ }
838
+ return { laddrs: [...laddrs.filter((op) => !consumed.has(op)), ...minted], splitRefusal };
839
+ }
840
+
841
+ /** ONE OBJECT PER FRAME OFFSET. Two `laddr` at the same offset name the same storage; two at
842
+ * different offsets are different objects, so width, signedness, escape and the overlap
843
+ * window are decided per offset — one width shared by every capture in the function would
844
+ * declare a halfword spill and a word spill as one object. */
845
+ function frameObjects(laddrs: readonly Op[], owned: FrameRange, fail: Refuse): Map<number, Op[]> {
846
+ const objects = new Map<number, Op[]>();
847
+ for (const op of laddrs) {
848
+ const off = op.attrs.off as number;
849
+ (objects.get(off) ?? objects.set(off, []).get(off)!).push(op);
850
+ }
851
+ // A LOCAL STARTS INSIDE THE RESERVED AREA. Above it are the registers the prologue saved and
852
+ // then the caller's outgoing block, so an address there is an incoming stack argument's or a
853
+ // saved register's — `g(&e)` for a fifth parameter `e` is `add r0, sp, #0x8` over a one-word
854
+ // local area — and no other rule of the audit is about that gap. The TOP itself is also C's
855
+ // one-past-the-end pointer, `g(buf, buf + 8)` over an 8-byte array, and the refusal names both
856
+ // readings. BELOW IT is the outgoing block a call's stack words are staged in, and on O32 also
857
+ // the saves IDO keeps under its locals: an address there is no local's either. Every object's
858
+ // extent is held to the area again below; this says why, for the frontend that supplied it.
859
+ for (const off of objects.keys()) {
860
+ if (off < owned.from) {
861
+ fail(
862
+ `the captured address at [sp,#${off}) is below the reserved local area, which starts at [sp,#${owned.from}) — ` +
863
+ 'an outgoing stack argument word or a saved register, whose address is not modelled',
864
+ );
865
+ }
866
+ if (off === owned.to) {
867
+ fail(
868
+ `the captured address at [sp,#${off}) is the top of the reserved local area of ${owned.to - owned.from} bytes — ` +
869
+ 'one past the end of a local array, or an incoming stack argument, neither of which is modelled',
870
+ );
871
+ }
872
+ if (off > owned.to) {
873
+ fail(
874
+ `the captured address at [sp,#${off}) is above the reserved local area of ${owned.to - owned.from} bytes — ` +
875
+ 'an incoming stack argument or a saved register, whose address is not modelled',
876
+ );
877
+ }
878
+ }
879
+ return objects;
880
+ }
881
+
882
+ /** Where each frame address may flow: `taint`, the object a value may hold the address of on SOME
883
+ * path, and `frameOnEveryPath`, the values that hold a frame address on EVERY path. */
884
+ interface AddressFlow {
885
+ readonly taint: ReadonlyMap<Value, number>;
886
+ readonly frameOnEveryPath: ReadonlySet<Value>;
887
+ }
888
+
889
+ function addressFlow(
890
+ irBlocks: readonly Block[],
891
+ objects: ReadonlyMap<number, readonly Op[]>,
892
+ facts: LiteralAddresses,
893
+ fail: Refuse,
894
+ ): AddressFlow {
895
+ const { defOf, constOf } = facts;
896
+ // Taint maps a value to the OBJECT whose address it may hold, closed over phis: a tainted
897
+ // edge arg taints the receiving block param. A phi that merges two objects has no single
898
+ // answer, and picking one would put an access on the wrong storage: `get(c ? &a : &b)` is
899
+ // `add r0, sp, #0x4 / beq / add r0, sp, #0x8 / bl get`, and refuses here.
900
+ const taint = new Map<Value, number>();
901
+ for (const [off, ops] of objects) {
902
+ for (const op of ops) {
903
+ taint.set(op.results[0], off);
904
+ }
905
+ }
906
+ for (let changed = true; changed;) {
907
+ changed = false;
908
+ for (const blk of irBlocks) {
909
+ for (const op of blk.ops) {
910
+ for (const s of op.successors ?? []) {
911
+ s.args.forEach((arg, i) => {
912
+ const from = taint.get(arg);
913
+ const param = s.block.params[i];
914
+ if (from === undefined || param === undefined) {
915
+ return;
916
+ }
917
+ const had = taint.get(param);
918
+ if (had === from) {
919
+ return;
920
+ }
921
+ if (had !== undefined) {
922
+ fail(`a phi merges the frame objects at [sp,#${had}] and [sp,#${from}] — one value, two objects`);
923
+ }
924
+ taint.set(param, from);
925
+ changed = true;
926
+ });
927
+ }
928
+ }
929
+ }
930
+ }
931
+ // …and the values that hold a frame address on EVERY path, where `taint` answers "on some
932
+ // path": a `laddr`, a phi whose every incoming value is one, or one of those moved by a
933
+ // constant. `p = cnt ? cnt : &o` is tainted and not in here: it may be `cnt`, which may be
934
+ // anything, and reading it as this frame is how a store through it goes unseen.
935
+ const frameOnEveryPath = new Set<Value>(taint.keys());
936
+ const incoming = new Map<Value, Value[]>();
937
+ for (const blk of irBlocks) {
938
+ for (const op of blk.ops) {
939
+ if (op.opcode === 'add' && op.operands.length === 2 && op.operands.some((v) => constOf(v) !== undefined)) {
940
+ frameOnEveryPath.add(op.results[0]);
941
+ }
942
+ for (const s of op.successors ?? []) {
943
+ s.args.forEach((arg, i) => {
944
+ const param = s.block.params[i];
945
+ if (param !== undefined) {
946
+ (incoming.get(param) ?? incoming.set(param, []).get(param)!).push(arg);
947
+ }
948
+ });
949
+ }
950
+ }
951
+ }
952
+ const objectValues = new Set([...objects.values()].flat().map((op) => op.results[0]));
953
+ const holdsFrame = (v: Value): boolean => {
954
+ const d = defOf.get(v);
955
+ if (objectValues.has(v)) {
956
+ return true;
957
+ }
958
+ if (d?.opcode === 'add') {
959
+ return d.operands.some((x) => constOf(x) === undefined && frameOnEveryPath.has(x));
960
+ }
961
+ const ins = incoming.get(v);
962
+ return d === undefined && ins !== undefined && ins.every((a) => frameOnEveryPath.has(a));
963
+ };
964
+ for (let changed = true; changed;) {
965
+ changed = false;
966
+ for (const v of frameOnEveryPath) {
967
+ if (!holdsFrame(v)) {
968
+ frameOnEveryPath.delete(v);
969
+ changed = true;
970
+ }
971
+ }
972
+ }
973
+ return { taint, frameOnEveryPath };
974
+ }
975
+
976
+ /** One fixed-offset access at an object's own address. */
977
+ interface FrameAccess {
978
+ readonly width: number;
979
+ readonly signed: boolean;
980
+ readonly isLoad: boolean;
981
+ }
982
+
983
+ /** A load or store judged an access to the object at `off`, and the address value it reaches the
984
+ * object through: a capture, or a phi that carries one — for a runtime index, the indexed one. */
985
+ interface FrameAccessOp {
986
+ readonly off: number;
987
+ readonly through: Value;
988
+ }
989
+
990
+ /** Every use of every frame object, by offset — what each later stage reads. Each field is
991
+ * declared, with the question it answers, where `classifyFrameUses` builds it. */
992
+ interface FrameUses {
993
+ readonly accesses: ReadonlyMap<number, readonly FrameAccess[]>;
994
+ readonly accessOps: ReadonlyMap<Op, FrameAccessOp>;
995
+ readonly escaped: ReadonlySet<number>;
996
+ readonly sourceStores: ReadonlyMap<number, readonly { readonly op: Op; readonly sink: number }[]>;
997
+ readonly calleeReads: ReadonlyMap<number, { readonly lo: number; readonly hi: number }>;
998
+ readonly filledFrom: ReadonlySet<number>;
999
+ readonly passedToCallee: ReadonlySet<number>;
1000
+ readonly passedToWriter: ReadonlySet<number>;
1001
+ readonly published: ReadonlySet<number>;
1002
+ readonly publishedOutward: ReadonlySet<number>;
1003
+ readonly publishedToWriter: ReadonlySet<number>;
1004
+ readonly arg0Callees: ReadonlyMap<number, ReadonlySet<string | null>>;
1005
+ readonly returnTemps: ReadonlyMap<number, readonly Op[]>;
1006
+ /** the calls each object is passed to BY VALUE (`foldByValueArgs`), with the struct each passes */
1007
+ readonly byValueReads: ReadonlyMap<number, readonly { readonly op: Op; readonly type: IrType }[]>;
1008
+ readonly useCount: ReadonlyMap<number, number>;
1009
+ readonly indexed: ReadonlyMap<number, readonly { readonly width: number; readonly signed: boolean }[]>;
1010
+ readonly members: readonly { readonly at: number; readonly width: number }[];
1011
+ readonly memberRefusal: string | undefined;
1012
+ /** every escape of each object's address, as the frame bytes it may reach: `[lo, hi)` where a
1013
+ * literal-count block copy proves them (`boundedCopy`), `null` where nothing does, and `into`
1014
+ * where the copy WRITES them (the address is its destination, not its source). Only the split
1015
+ * of the one object reads this (`provenParts`); the escape windows keep the copy as an
1016
+ * unbounded writer, which is what puts that one object on offer at all (`chooseFrameModel`). */
1017
+ readonly escapeExtents: ReadonlyMap<
1018
+ number,
1019
+ readonly ({ readonly lo: number; readonly hi: number; readonly into: boolean } | null)[]
1020
+ >;
1021
+ readonly fixed: readonly {
1022
+ readonly op: Op;
1023
+ readonly at: number;
1024
+ readonly width: number;
1025
+ readonly isLoad: boolean;
1026
+ readonly frameOnly: boolean;
1027
+ }[];
1028
+ }
1029
+
1030
+ /** Did the address at `off` reach something that may write the frame back: a callee that is not a
1031
+ * bounded block transfer reading through it, or memory a writer may hold. */
1032
+ function mayWrite(uses: FrameUses, off: number): boolean {
1033
+ return uses.passedToWriter.has(off) || uses.publishedToWriter.has(off);
1034
+ }
1035
+
1036
+ /** Judges every use of a tainted value, against the object it names. */
1037
+ function classifyFrameUses({
1038
+ irBlocks,
1039
+ objects,
1040
+ flow: { taint, frameOnEveryPath },
1041
+ facts,
1042
+ splitRefusal,
1043
+ oneObject,
1044
+ target,
1045
+ fail,
1046
+ }: {
1047
+ irBlocks: readonly Block[];
1048
+ objects: ReadonlyMap<number, readonly Op[]>;
1049
+ flow: AddressFlow;
1050
+ facts: FrameAddressFacts;
1051
+ splitRefusal: ReadonlyMap<Value, string>;
1052
+ oneObject: FrameRange | undefined;
1053
+ target: TargetDescription;
1054
+ fail: Refuse;
1055
+ }): FrameUses {
1056
+ const { defOf } = facts;
1057
+ const accesses = new Map<number, FrameAccess[]>();
1058
+ // …and every load and store judged an access, by the object it reaches: through a capture, a
1059
+ // phi that carries one, or a runtime index into one. A later stage that rewrites or qualifies
1060
+ // "the accesses to the object" reads this list rather than recognizing them again, since a
1061
+ // narrower recognizer misses exactly the phi-carried ones `taint` admits.
1062
+ const accessOps = new Map<Op, FrameAccessOp>();
1063
+ // TWO QUESTIONS, not one. `escaped` asks whether the address LEFT the function, in the order the
1064
+ // escapes were found — the order windows and refusals are read in. Whether it reached something
1065
+ // that could write the frame BACK — what every "a callee may write any frame offset" refusal
1066
+ // rests on — is `mayWrite`, read off the splits below. A store into a device's SOURCE register
1067
+ // answers yes to the first and no to the second: the hardware reads the object, and the
1068
+ // DMA-fill idiom this capability was built for (`vu16 tmp; DmaSet(n, &tmp, …)`) is exactly
1069
+ // that shape.
1070
+ const escaped = new Set<number>();
1071
+ // …and for the others, the stores that handed the address to a source register, which is
1072
+ // where `readWindow` reads how far the device reads
1073
+ const sourceStores = new Map<number, { op: Op; sink: number }[]>();
1074
+ // …and a CALL that only reads is the same answer: the address handed to a block transfer
1075
+ // (`blockTransferCalls`) as its source, with a literal control word, is read `[lo, hi)` from
1076
+ // the object and written by nobody. `CPU_FILL`'s `vu32 tmp = v; CpuSet(&tmp, dest, …)` is the
1077
+ // shape. A control word this cannot read, or any other argument position, is a callee that
1078
+ // may write.
1079
+ const calleeReads = new Map<number, { lo: number; hi: number }>();
1080
+ // …and of those, the objects a transfer FILLS from — a fixed source, the `tmp` of every fill
1081
+ // macro — which the `volatile` stamp keys on beside `published`
1082
+ const filledFrom = new Set<number>();
1083
+ // …and the two escapes SPLIT, because each decides something the other does not.
1084
+ // `passedToCallee` is the address handed to a callee as an argument — the one escape whose
1085
+ // writer this frontend can name, which is what the struct-return premise re-check rests on, and
1086
+ // what tells a refusal message which escape it is talking about. `published` is the address
1087
+ // WRITTEN TO MEMORY, how the DMA idiom hands the object to hardware, and what `volatile` at the
1088
+ // stamp keys on. Reading either off `escaped` gets the other one wrong.
1089
+ const passedToCallee = new Set<number>();
1090
+ // …and of those, the callees that may WRITE through it: every one but a bounded block transfer
1091
+ // reading its source (`calleeReads`), which names no writer
1092
+ const passedToWriter = new Set<number>();
1093
+ const published = new Set<number>();
1094
+ // …and `published` SPLITS AGAIN, because it answers two questions of different strengths and
1095
+ // the weaker one may not be read as the stronger. "Did the address reach memory at all" is
1096
+ // what `volatile` keys on: a halfword of it written anywhere is still a write this function
1097
+ // does not own, and the qualifier has to survive it. "Did the address reach memory OUTSIDE
1098
+ // this frame, whole" is what the licence is re-proven against (`recheckWholeFramePremise`), and
1099
+ // it is strictly narrower — a store back into the object's own bytes publishes the address to
1100
+ // nobody, and half an address is not the address. The pre-lift scan that grants the licence
1101
+ // (`frameBasePublishedToMemory`) admits exactly the narrow one, so the re-proof must too: an
1102
+ // audit that re-proves a WEAKER premise than the licence it audits is not a containment, it
1103
+ // is a second, wider door into the same acceptance.
1104
+ const publishedOutward = new Set<number>();
1105
+ // …and the third question `published` answers, of what HOLDS the address it wrote: anything but
1106
+ // a device's read-only source register (`facts.readsThrough`) may write through it — an
1107
+ // interrupt reading a global, a device writing its destination — at any point in this function,
1108
+ // so a re-read of the object is a read the recompile must make too.
1109
+ const publishedToWriter = new Set<number>();
1110
+ // …and WHICH CALLEE took it at ARGUMENT 0, because that is the one position a hidden
1111
+ // struct-return pointer can occupy and that callee's declared RETURN TYPE is the one fact that
1112
+ // tells an out-parameter from one. A `call`'s operand index IS the argument index here (the
1113
+ // `bl` arm reads r0..r<argc-1> in order), so an address that appears in no entry of this map
1114
+ // was handed over at r1 or above every time — an argument the source wrote. `null` is the
1115
+ // narrowing of an unstamped `target` attr — the `bl`/`blx` lowering always stamps one — and
1116
+ // reads as a callee nothing can be declared about, so it refuses.
1117
+ const arg0Callees = new Map<number, Set<string | null>>();
1118
+ // …except where the call's own declaration says argument 0 IS the hidden pointer (the frontend
1119
+ // stamps `sret` on a call to a callee declared to return a struct through memory): that object
1120
+ // is the callee's return storage, a local of the declared type. `useCount` counts every use of
1121
+ // each object, so a return temp this function does anything else with is told apart.
1122
+ const returnTemps = new Map<number, Op[]>();
1123
+ // …and a call's operand its lowering passes BY VALUE (`foldByValueArgs`) is no escape: the
1124
+ // callee receives a copy of the whole struct, so it is a read of the object's bytes at the call
1125
+ const byValueReads = new Map<number, { op: Op; type: IrType }[]>();
1126
+ const useCount = new Map<number, number>();
1127
+ // The accesses through a runtime-indexed address, per object — kept apart from `accesses`,
1128
+ // which types the object at its own offset: `a[i]` says what one ELEMENT is, not what sits at
1129
+ // `a`.
1130
+ const indexed = new Map<number, { width: number; signed: boolean }[]>();
1131
+ for (const off of objects.keys()) {
1132
+ accesses.set(off, []);
1133
+ }
1134
+ // …and with `oneObject`, every fixed-offset access as the frame bytes it touches, since then an
1135
+ // access at [+k] through a capture is a member of the one object rather than a second object
1136
+ const members: { at: number; width: number }[] = [];
1137
+ // …and without it, the first such access, which the per-object model refuses once the model is
1138
+ // chosen (`chooseFrameModel`) — save one of a call's return storage or of a struct passed by
1139
+ // value, whose members are the struct's declared ones (`objectShapes`)
1140
+ const memberRefusals: [number, string][] = [];
1141
+ const escapeExtents = new Map<number, ({ lo: number; hi: number; into: boolean } | null)[]>();
1142
+ // …and every value something reads, since a copy whose returned destination is read hands the
1143
+ // address on to a use nothing here follows
1144
+ const readValues = new Set<Value>(
1145
+ irBlocks.flatMap((b) => b.ops.flatMap((op) => [...op.operands, ...(op.successors ?? []).flatMap((sx) => sx.args)])),
1146
+ );
1147
+ // …and in either model, every fixed-offset access as the op that makes it, the frame bytes it
1148
+ // touches, whether it reads them, and whether its address is this frame's on every path — what
1149
+ // `storedNeverRead` asks of a frame that makes a call
1150
+ const fixed: { op: Op; at: number; width: number; isLoad: boolean; frameOnly: boolean }[] = [];
1151
+ const scalar = (kind: string, op: Op, off: number, v: Value): void => {
1152
+ fixed.push({
1153
+ op,
1154
+ at: off + (op.attrs.off as number),
1155
+ width: op.attrs.width as number,
1156
+ isLoad: kind === 'load',
1157
+ frameOnly: frameOnEveryPath.has(v),
1158
+ });
1159
+ if (oneObject !== undefined) {
1160
+ members.push({ at: off + (op.attrs.off as number), width: op.attrs.width as number });
1161
+ return;
1162
+ }
1163
+ if ((op.attrs.off as number) !== 0) {
1164
+ memberRefusals.push([
1165
+ off,
1166
+ `a ${kind} at [+${op.attrs.off}] through the captured address — ` +
1167
+ (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled'),
1168
+ ]);
1169
+ }
1170
+ };
1171
+ for (const blk of irBlocks) {
1172
+ for (const op of blk.ops) {
1173
+ for (const [idx, v] of op.operands.entries()) {
1174
+ const off = taint.get(v);
1175
+ if (off === undefined) {
1176
+ continue;
1177
+ }
1178
+ useCount.set(off, (useCount.get(off) ?? 0) + 1);
1179
+ if (op.opcode === 'load' && idx === 0) {
1180
+ scalar('load', op, off, v);
1181
+ accessOps.set(op, { off, through: v });
1182
+ accesses.get(off)!.push({
1183
+ width: op.attrs.width as number,
1184
+ signed: (op.attrs.signed as boolean) ?? false,
1185
+ isLoad: true,
1186
+ });
1187
+ continue;
1188
+ }
1189
+ if (op.opcode === 'store' && idx === 0) {
1190
+ scalar('store', op, off, v);
1191
+ accessOps.set(op, { off, through: v });
1192
+ accesses.get(off)!.push({ width: op.attrs.width as number, signed: false, isLoad: false });
1193
+ continue;
1194
+ }
1195
+ if (byValueOperands(op).some((b) => b.at === idx)) {
1196
+ // the fold types the operand a pointer to the struct passed (call-plan.ts `foldByValueArgs`)
1197
+ const type = v.type.kind === 'ptr' ? v.type.to : undefined;
1198
+ if (type?.kind !== 'struct' || type.size === undefined) {
1199
+ throw new Error(`internal: a by-value operand of a call to '${calleeName(op)}' has no declared struct`);
1200
+ }
1201
+ (byValueReads.get(off) ?? byValueReads.set(off, []).get(off)!).push({ op, type });
1202
+ fixed.push({ op, at: off, width: type.size, isLoad: true, frameOnly: frameOnEveryPath.has(v) });
1203
+ continue;
1204
+ }
1205
+ if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
1206
+ escaped.add(off); // the address ESCAPES as a value — the point of the capability
1207
+ const copied = op.opcode === 'call' ? boundedCopy(op, idx, facts, readValues) : undefined;
1208
+ (escapeExtents.get(off) ?? escapeExtents.set(off, []).get(off)!).push(
1209
+ copied === undefined ? null : { lo: off + copied.lo, hi: off + copied.hi, into: copied.into },
1210
+ );
1211
+ const handedTo = op.opcode === 'store' ? facts.readsThrough(op) : [];
1212
+ const read = op.opcode === 'call' ? transferRead(op, idx, off, target, facts) : undefined;
1213
+ if (read !== undefined) {
1214
+ const had = calleeReads.get(off) ?? read;
1215
+ calleeReads.set(off, { lo: Math.min(had.lo, read.lo), hi: Math.max(had.hi, read.hi) });
1216
+ if (read.fill) {
1217
+ filledFrom.add(off);
1218
+ }
1219
+ } else if (handedTo.length === 0) {
1220
+ if (op.opcode === 'store') {
1221
+ publishedToWriter.add(off);
1222
+ }
1223
+ } else {
1224
+ const stores = sourceStores.get(off) ?? sourceStores.set(off, []).get(off)!;
1225
+ handedTo.forEach((sink) => stores.push({ op, sink }));
1226
+ }
1227
+ if (op.opcode === 'call') {
1228
+ passedToCallee.add(off);
1229
+ if (read === undefined) {
1230
+ passedToWriter.add(off);
1231
+ }
1232
+ if (idx === 0 && op.attrs.sret === true) {
1233
+ (returnTemps.get(off) ?? returnTemps.set(off, []).get(off)!).push(op);
1234
+ } else if (idx === 0) {
1235
+ const t = op.attrs.target;
1236
+ const cs = arg0Callees.get(off) ?? new Set<string | null>();
1237
+ cs.add(typeof t === 'string' ? t : null);
1238
+ arg0Callees.set(off, cs);
1239
+ }
1240
+ } else {
1241
+ published.add(off); // written to memory — the DMA idiom's `*dmaReg = &tmp`
1242
+ // The licence's two conditions, asked of the IR: the WHOLE address (a word), and a
1243
+ // destination that is not this frame. `taint` answers the second exactly — it holds
1244
+ // every value that may carry one of this function's frame addresses, which is both
1245
+ // spellings the pre-lift scan excludes by name (`str rS, [sp]` and a store through
1246
+ // another held capture, since at a one-word frame [sp,#0] IS the object).
1247
+ if ((op.attrs.width as number) === 4 && taint.get(op.operands[0]) === undefined) {
1248
+ publishedOutward.add(off);
1249
+ }
1250
+ }
1251
+ continue;
1252
+ }
1253
+ // A RUNTIME INDEX into the object: `mov r1, sp / add r0, r1, r4 / ldrb r0, [r0]` is
1254
+ // agbcc's `a[i]` on a `u8 a[n]` local. The sum names no fixed offset, so it is not a
1255
+ // capture of its own; what it may do is judged here, whole, and the object it indexes is
1256
+ // judged with the rest (`objectShapes`).
1257
+ const other = op.opcode === 'add' && op.operands.length === 2 ? op.operands[1 - idx] : undefined;
1258
+ if (other !== undefined && taint.get(other) === undefined && defOf.get(other)?.opcode !== 'const') {
1259
+ indexedAccess(irBlocks, { indexed, accessOps }, { off, through: v }, op.results[0], facts.constOf, fail);
1260
+ continue;
1261
+ }
1262
+ // A CONSTANT move left after the fold (`foldMovedCaptures`) moves a capture a phi carried.
1263
+ // When the phi also carries a pointer from outside the frame — `if (!info) info = &local;
1264
+ // info->x` — the missing capability is that merge, not a walk through the frame.
1265
+ if (other !== undefined && defOf.get(other)?.opcode === 'const' && !frameOnEveryPath.has(v)) {
1266
+ fail(
1267
+ `the captured address at [sp,#${off}) reaches a phi that merges it with a pointer from outside the ` +
1268
+ 'frame, and is then moved by a constant — a field of what may or may not be a local is not modelled',
1269
+ );
1270
+ }
1271
+ // …and when every value the phi carries is this frame's, it is a pointer stepped through
1272
+ // the frame, which names a different object on each trip.
1273
+ if (other !== undefined && defOf.get(other)?.opcode === 'const') {
1274
+ fail(
1275
+ `the captured address at [sp,#${off}) reaches a phi and is then moved by a constant — ` +
1276
+ 'a pointer stepped through the frame names no one object',
1277
+ );
1278
+ }
1279
+ fail(`the captured address flows into \`${op.opcode}\` — not an access, an escape, or a phi`);
1280
+ }
1281
+ }
1282
+ }
1283
+ return {
1284
+ accesses,
1285
+ accessOps,
1286
+ escaped,
1287
+ sourceStores,
1288
+ calleeReads,
1289
+ filledFrom,
1290
+ passedToCallee,
1291
+ passedToWriter,
1292
+ published,
1293
+ publishedOutward,
1294
+ publishedToWriter,
1295
+ arg0Callees,
1296
+ returnTemps,
1297
+ byValueReads,
1298
+ useCount,
1299
+ indexed,
1300
+ members,
1301
+ memberRefusal: memberRefusals.find(([off]) => !returnTemps.has(off) && !byValueReads.has(off))?.[1],
1302
+ escapeExtents,
1303
+ fixed,
1304
+ };
1305
+ }
1306
+
1307
+ // The bytes, relative to the address at operand `idx`, that a call of a standard block copy with
1308
+ // a literal, positive count reaches through it, and whether it writes them (`idx` is its
1309
+ // destination) — or `undefined` for any other callee, operand or count, which this does not
1310
+ // bound, and for a copy whose result (`memcpy` returns its destination) anything in `read` uses.
1311
+ function boundedCopy(
1312
+ op: Op,
1313
+ idx: number,
1314
+ facts: LiteralAddresses,
1315
+ read: ReadonlySet<Value>,
1316
+ ): { lo: number; hi: number; into: boolean } | undefined {
1317
+ const roles = blockCopyRoles(op.attrs.target);
1318
+ if (
1319
+ roles === undefined ||
1320
+ (idx !== roles.dst && idx !== roles.src) ||
1321
+ op.operands.length <= roles.n ||
1322
+ op.results.some((r) => read.has(r))
1323
+ ) {
1324
+ return undefined;
1325
+ }
1326
+ const n = facts.constOf(op.operands[roles.n]);
1327
+ return n !== undefined && Number.isInteger(n) && n > 0 ? { lo: 0, hi: n, into: idx === roles.dst } : undefined;
1328
+ }
1329
+
1330
+ // The bytes a block transfer that took the address at `idx` reads, when `idx` is its source and
1331
+ // its control word is a literal.
1332
+ function transferRead(
1333
+ op: Op,
1334
+ idx: number,
1335
+ off: number,
1336
+ target: TargetDescription,
1337
+ facts: LiteralAddresses,
1338
+ ): { lo: number; hi: number; fill: boolean } | undefined {
1339
+ const callee = op.attrs.target;
1340
+ const calls = target.capabilities.blockTransferCalls;
1341
+ const call = typeof callee === 'string' && calls && Object.hasOwn(calls, callee) ? calls[callee] : undefined;
1342
+ const control = call === undefined || idx !== call.source ? undefined : op.operands[call.control];
1343
+ const word = control === undefined ? undefined : facts.constOf(control);
1344
+ if (call === undefined || word === undefined) {
1345
+ return undefined;
1346
+ }
1347
+ const read = blockTransferRead(call, word);
1348
+ return { ...sourceReach(read, off), fill: read.walk === 'fixed' };
1349
+ }
1350
+
1351
+ // Records every access through `sum`, a runtime index into the object at `off`.
1352
+ //
1353
+ // A SUM MOVED BY A LITERAL IS THE SAME INDEX, DISPLACED. Thumb has no `ldrsb`/`ldrsh` with an
1354
+ // immediate, so agbcc loads the whole displacement into a register (thumb.md:411-505, `mov %0,%2 ;
1355
+ // ldrs[bh] %0,[%1,%0]`) and the frontend lowers that register-offset load as `add` + load@0:
1356
+ // `add r0, r1, r4 / mov r1, #0x0 / ldrsb r1, [r0, r1]` is `a[i]` on an `s8 a[n]` local. So the
1357
+ // uses of an `add` of the sum and a `const` are judged at that displacement. One `add` deep: agbcc
1358
+ // emits one `mov` per load, and no other compiler emits a literal index register. Only
1359
+ // displacement 0 is the indexed element. A nonzero `const` on a sum whose base is a capture never
1360
+ // arrives here: `foldMovedCaptures` has already moved the index to the object at the sum, which is
1361
+ // what agbcc's `mov r0, sp / add r0, r0, r4 / add r0, #0x4` is (an array above the outgoing word),
1362
+ // and what `mov r1, #0x3 / ldrsb r1, [r0, r1]` and a load's own displacement, `ldrb r0, [r0,
1363
+ // #0x1]` (a field of an element struct, or `a[i + 1]`), reach. What is left nonzero — a constant
1364
+ // computed rather than loaded as a `const`, a literal or a displacement on an index into a capture
1365
+ // a phi carries, or a displacement on top of the zero literal, which no compiler emits — refuses
1366
+ // naming the displacement.
1367
+ //
1368
+ // The element is judged here and left where the register put it, so it is spelled with the
1369
+ // literal: `*(s16 *)((u32)sp0 + (a0 << 1) + 0)`. Re-pointing it at the sum spells
1370
+ // `((s16 *)sp0)[a0]`, an index through a cast on the `u8` storage (`objectShapes`), which agbcc
1371
+ // compiles to `add r4, r4, sp` and the source's `mov r1, sp / add r0, r1, r4` does not: the `u16`
1372
+ // twin `((u16 *)sp0)[a0]` misses the same way. Left raw, the access compiles to the source's
1373
+ // bytes with or without the `+ 0`.
1374
+ function indexedAccess(
1375
+ irBlocks: readonly Block[],
1376
+ {
1377
+ indexed,
1378
+ accessOps,
1379
+ }: { indexed: Map<number, { width: number; signed: boolean }[]>; accessOps: Map<Op, FrameAccessOp> },
1380
+ object: FrameAccessOp,
1381
+ sum: Value,
1382
+ constOf: (v: Value) => number | undefined,
1383
+ fail: Refuse,
1384
+ ): void {
1385
+ const { off } = object;
1386
+ const got = indexed.get(off) ?? indexed.set(off, []).get(off)!;
1387
+ const judge = (at: Value, by: number): void => {
1388
+ for (const blk of irBlocks) {
1389
+ for (const op of blk.ops) {
1390
+ const roles = [
1391
+ ...op.operands.flatMap((v, i) => (v === at ? [i] : [])),
1392
+ ...(op.successors ?? []).flatMap((s) => (s.args.includes(at) ? [-1] : [])),
1393
+ ];
1394
+ for (const i of roles) {
1395
+ const k =
1396
+ at === sum && op.opcode === 'add' && op.operands.length === 2 && i >= 0
1397
+ ? constOf(op.operands[1 - i])
1398
+ : undefined;
1399
+ if (k !== undefined) {
1400
+ judge(op.results[0], k);
1401
+ continue;
1402
+ }
1403
+ const access = (op.opcode === 'load' || op.opcode === 'store') && i === 0;
1404
+ if (!access) {
1405
+ fail(
1406
+ `a runtime index into the object at [sp,#${off}) flows into \`${i === -1 ? 'a phi' : op.opcode}\` — ` +
1407
+ 'only a load or store at the indexed address is modelled',
1408
+ );
1409
+ }
1410
+ const at0 = (by + (op.attrs.off as number)) | 0;
1411
+ if (at0 !== 0) {
1412
+ fail(
1413
+ `a runtime index into the object at [sp,#${off}) is accessed at displacement ${at0} from the indexed element — ` +
1414
+ 'only a load or store at the indexed address is modelled',
1415
+ );
1416
+ }
1417
+ got.push({ width: op.attrs.width as number, signed: op.opcode === 'load' && op.attrs.signed === true });
1418
+ accessOps.set(op, object);
1419
+ }
1420
+ }
1421
+ }
1422
+ };
1423
+ judge(sum, 0);
1424
+ }
1425
+
1426
+ /** Why two access types on one byte refuse, as every refusal of it words it. THE RULE IS AGBCC'S
1427
+ * AND IS HELD ON EVERY TARGET: agbcc at -O2 serves a read of one type past a store of the other
1428
+ * (`indexedWidth` has the compiled cases), while IDO 7.1, KMC gcc and gcc 2.7.2 reload past every such
1429
+ * store, so on MIPS it is only more cautious than it has to be. The text names no compiler of the
1430
+ * input, since the audit is handed none. */
1431
+ const STALE_ACROSS_TYPES =
1432
+ 'and type-based alias analysis can let a read of one type pass a store of the other (agbcc at -O2 does, ' +
1433
+ 'and the audit refuses it on every target) — a cast spelling could read stale';
1434
+
1435
+ /** The one width wider than a byte that runtime-indexed accesses take, or why there is not one.
1436
+ *
1437
+ * A runtime index names no byte, so its access type must agree with every access it may reach,
1438
+ * and "agree" is what stops agbcc reordering two of them. At -O2 it runs type-based alias
1439
+ * analysis (toplev.c:3616), and `c_get_alias_set` (c-common.c:1892-2005) gives an integer type
1440
+ * and its unsigned twin one set, a character type set 0 (conflicting with every set), a
1441
+ * member access its member's type's set, and any other type — a pointer included — a new set.
1442
+ * Compiled at the corpus's flags, `k(sp0); ((int *)sp0)[i] = 1; ((T *)sp0)[j] = 0; return
1443
+ * ((int *)sp0)[i];` reloads the word for T = int, unsigned int and unsigned char, and returns the
1444
+ * stale 1 for T = unsigned short, void * and long.
1445
+ *
1446
+ * So ONE WIDTH IS ONE SET only while every access of that width READS AT one integer type or its
1447
+ * twin: a cast on the storage to `s32`/`u32` (`int`/`unsigned int` in every agbcc project), or a
1448
+ * member of an element struct (raise/struct-arrays) whose FIELD is such an integer — never the pointer an element
1449
+ * may hold. `/ptr-field`'s `over-local-storage` gate keeps such a field an integer, and contracts.ts
1450
+ * `assertLocalStorageStaysInteger` holds every respelled tree to it. The storage stays
1451
+ * `u8 name[n]` whatever the width. */
1452
+ function indexedWidth(accesses: readonly { readonly width: number }[]): {
1453
+ width: number | null;
1454
+ refusal: string | null;
1455
+ } {
1456
+ const widths = [...new Set(accesses.map((x) => x.width))].filter((w) => w !== 1);
1457
+ return {
1458
+ width: widths[0] ?? null,
1459
+ refusal: widths.length > 1 ? `accesses ${widths.join(' and ')} bytes wide, ${STALE_ACROSS_TYPES}` : null,
1460
+ };
1461
+ }
1462
+
1463
+ /** The bytes an escape may reach, `[lo, hi)` from the object's own offset, and how it reads them
1464
+ * as a refusal words it. */
1465
+ interface ReadWindow {
1466
+ readonly lo: number;
1467
+ readonly hi: number;
1468
+ readonly why: string;
1469
+ /** bounded only because a store through a computed pointer provably misses the control
1470
+ * halfword (`misses`): without that proof the read is unbounded, and the one-object answer
1471
+ * that held it then stays on offer (`chooseFrameModel`) */
1472
+ readonly sparedByMiss?: true;
1473
+ }
1474
+
1475
+ /** Each op's block and index, over the IR as the classification judged it. */
1476
+ function opPositions(irBlocks: readonly Block[]): Map<Op, { blk: Block; i: number }> {
1477
+ const at = new Map<Op, { blk: Block; i: number }>();
1478
+ for (const blk of irBlocks) {
1479
+ blk.ops.forEach((op, i) => at.set(op, { blk, i }));
1480
+ }
1481
+ return at;
1482
+ }
1483
+
1484
+ /** The read window of every escaped object, in the order the classification found the escapes. */
1485
+ function escapeWindows(
1486
+ irBlocks: readonly Block[],
1487
+ uses: FrameUses,
1488
+ flow: AddressFlow,
1489
+ facts: FrameAddressFacts,
1490
+ target: TargetDescription,
1491
+ ): Map<number, ReadWindow> {
1492
+ const at = opPositions(irBlocks);
1493
+ return new Map([...uses.escaped].map((off) => [off, readWindow(off, { at, uses, flow, facts, target })] as const));
1494
+ }
1495
+
1496
+ const unbounded = (why: string): ReadWindow => ({ lo: -Infinity, hi: Infinity, why });
1497
+
1498
+ /** THE BYTES AN ESCAPE MAY REACH, as `[lo, hi)` relative to the object's own offset. Anything
1499
+ * that may write, and anything this cannot bound, reaches the whole frame — save a callee
1500
+ * handed only its own return storage, which writes the struct it returns (`returnTemps`). A
1501
+ * device that only reads is bounded by its channel's control halfword (`readSourceControl`),
1502
+ * per TRANSFER: the device reads the object on every arm of the channel from the store that
1503
+ * handed it the address until a later word store to the same source register replaces it, so
1504
+ * the control stores that bound it are the ones reachable in between. Each has to be a literal
1505
+ * the target decodes. A store through a pointer this cannot resolve — one that is this frame's
1506
+ * on only some paths included (`frameOnEveryPath`) — may BE the control halfword, so it leaves
1507
+ * the read unbounded; so does a transfer never armed here at all. A store whose every address
1508
+ * misses that halfword (`misses`) is not such a store. An unbounded device read says which of
1509
+ * those it met (`why`): each is a different capability to build. A block transfer
1510
+ * that took the address as its source adds the bytes its control word reads (`calleeReads`).
1511
+ *
1512
+ * A NAMED SYMBOL PLUS A CONSTANT IS RESOLVED WHERE THE SYMBOL MAP PLACES THE NAME, and then
1513
+ * exactly, by the overlap test below. agbcc's alias model never lets a C identifier plus a
1514
+ * constant meet a literal address (alias.c:812-819, 1064-1068), but nothing in the asm says a
1515
+ * name is a C identifier: a disassembly spells an I/O register the way it spells a global
1516
+ * (`.word REG_VCOUNT`). So a name the map does not place is an unresolved pointer here.
1517
+ *
1518
+ * THE PREMISE, stated once and not checkable from one function: a callee or an interrupt
1519
+ * handler arms only a transfer it set up itself, source register first. So a call on the path
1520
+ * does not unbound the read, and neither does an interrupt at any instruction: whatever re-arms
1521
+ * the channel with this frame's address still in the source register is this function's own
1522
+ * store, and the walk sees every one of those. */
1523
+ function readWindow(
1524
+ off: number,
1525
+ {
1526
+ at,
1527
+ uses,
1528
+ flow,
1529
+ facts,
1530
+ target,
1531
+ }: {
1532
+ at: ReadonlyMap<Op, { blk: Block; i: number }>;
1533
+ uses: FrameUses;
1534
+ flow: AddressFlow;
1535
+ facts: FrameAddressFacts;
1536
+ target: TargetDescription;
1537
+ },
1538
+ ): ReadWindow {
1539
+ // a callee handed only its own return storage writes the struct it returns and nothing else —
1540
+ // and this function's own reads of it hand the address to nobody
1541
+ const temps = uses.returnTemps.get(off);
1542
+ const reads = uses.accesses.get(off)?.filter((a) => a.isLoad).length ?? 0;
1543
+ if (temps !== undefined && uses.useCount.get(off) === temps.length + reads) {
1544
+ return { lo: 0, hi: returnedSize(temps[0]), why: 'that returns its struct into it' };
1545
+ }
1546
+ const control = target.capabilities.readSourceControl;
1547
+ const stores = uses.sourceStores.get(off);
1548
+ const called = uses.calleeReads.get(off);
1549
+ if (!mayWrite(uses, off) && stores === undefined && called !== undefined) {
1550
+ return { lo: called.lo, hi: called.hi, why: 'that reads through it' };
1551
+ }
1552
+ if (mayWrite(uses, off) || stores === undefined || control === undefined) {
1553
+ return unbounded('that reads through it');
1554
+ }
1555
+ const little = target.capabilities.endianness === 'little';
1556
+ const halves: number[] = [];
1557
+ let spared = false;
1558
+ for (const { op: handed, sink } of stores) {
1559
+ const cnt = sink + control.offset;
1560
+ let armed = false;
1561
+ const entered = new Set<Block>();
1562
+ const work: [Block, number][] = [[at.get(handed)!.blk, at.get(handed)!.i + 1]];
1563
+ walk: while (work.length > 0) {
1564
+ const [blk, from] = work.pop()!;
1565
+ for (let i = from; i < blk.ops.length; i++) {
1566
+ const op = blk.ops[i];
1567
+ if (op.opcode !== 'store') {
1568
+ continue;
1569
+ }
1570
+ const base = facts.literalAddrOf(op.operands[0]);
1571
+ if (base === undefined) {
1572
+ if (!flow.frameOnEveryPath.has(op.operands[0])) {
1573
+ if (!misses(op, facts, cnt)) {
1574
+ return unbounded('a later store through an unresolved pointer may re-arm');
1575
+ }
1576
+ spared = true;
1577
+ }
1578
+ continue;
1579
+ }
1580
+ const a = base + (op.attrs.off as number);
1581
+ const w = op.attrs.width as number;
1582
+ if (a === sink && w === 4) {
1583
+ continue walk;
1584
+ }
1585
+ if (a + w <= cnt || a >= cnt + 2) {
1586
+ continue;
1587
+ }
1588
+ const v = facts.defOf.get(op.operands[1]);
1589
+ if (v?.opcode !== 'const' || a > cnt || a + w < cnt + 2) {
1590
+ return unbounded('whose control word is not a literal');
1591
+ }
1592
+ const shift = 8 * (little ? cnt - a : a + w - cnt - 2);
1593
+ halves.push(((v.attrs.value as number) >>> shift) & 0xffff);
1594
+ armed = true;
1595
+ }
1596
+ for (const s of blk.ops[blk.ops.length - 1]?.successors ?? []) {
1597
+ if (!entered.has(s.block)) {
1598
+ entered.add(s.block);
1599
+ work.push([s.block, 0]);
1600
+ }
1601
+ }
1602
+ }
1603
+ if (!armed) {
1604
+ return unbounded('this function never arms');
1605
+ }
1606
+ }
1607
+ let { lo, hi } = called ?? { lo: 0, hi: 0 };
1608
+ for (const h of halves) {
1609
+ const read = sourceControlRead(control, h);
1610
+ if (read === null) {
1611
+ return unbounded('whose control word bounds nothing');
1612
+ }
1613
+ const reach = sourceReach(read, off);
1614
+ lo = Math.min(lo, reach.lo);
1615
+ hi = Math.max(hi, reach.hi);
1616
+ }
1617
+ return { lo, hi, why: 'that reads through it', ...(spared ? { sparedByMiss: true as const } : {}) };
1618
+ }
1619
+
1620
+ /** A store through a computed pointer that provably misses the control halfword at `cnt`: no
1621
+ * address its base may hold (`addressRangeOf`, a strided interval), displaced by the store's own
1622
+ * offset, starts a store whose bytes overlap `[cnt, cnt + 2)`. agbcc's `gEnts[i].f = 1` after a
1623
+ * transfer, `i` a `u8`, writes one byte at `gEnts + 4 + 8 * [0, 255]`, and no `i` makes that the
1624
+ * channel's control register — so it cannot re-arm the channel, and the window stays where the
1625
+ * literal stores put it. The stride decides it where the interval alone cannot: placed at
1626
+ * 0x04000000 that span covers 0x040000DE, and still no `i` lands on it. Not "the transfer is
1627
+ * over": an immediate transfer is, but a later store that sets the enable bit again reloads the
1628
+ * source register and reads the frame anew (GBATEK, DMA control bit 15), which is what this
1629
+ * guards against. A store this cannot bound on every value stays an unresolved pointer. */
1630
+ function misses(op: Op, facts: FrameAddressFacts, cnt: number): boolean {
1631
+ const r = facts.addressRangeOf(op.operands[0]);
1632
+ if (r === undefined) {
1633
+ return false;
1634
+ }
1635
+ const off = op.attrs.off as number;
1636
+ const width = op.attrs.width as number;
1637
+ const lo = r.lo + off;
1638
+ const hi = r.hi + off;
1639
+ if (lo < 0 || hi + width > 2 ** 32) {
1640
+ return false;
1641
+ }
1642
+ // the first start at or above the lowest one that overlaps, against the highest that does
1643
+ const from = cnt - width + 1;
1644
+ const first = from <= lo || r.stride === 0 ? lo : lo + Math.ceil((from - lo) / r.stride) * r.stride;
1645
+ return first > Math.min(hi, cnt + 1) || first < from;
1646
+ }
1647
+
1648
+ /** Which model the audit judges the frame in. `refused` is the one mutable fact the audit
1649
+ * carries across stages: set when a shape the per-object model refuses is met while the
1650
+ * one-object answer is on offer, and read by the relift test. */
1651
+ interface ModelChoice {
1652
+ readonly onOffer: boolean;
1653
+ /** the escapes whose read is unbounded, or was before `misses` bounded it (`sparedByMiss`) — the
1654
+ * one fact both the offer and the relift test (`answered`) read, so the miss proof moves neither */
1655
+ readonly unboundedAt: ReadonlySet<number>;
1656
+ refused: boolean;
1657
+ /** refuses `why` where it stands, or, with the one-object answer on offer, asks for it instead */
1658
+ shapeRefused(why: string): void;
1659
+ /** `why`, naming the writer that withheld the one-object answer where one did — for a refusal
1660
+ * that answer would otherwise have taken */
1661
+ withheld(why: string): string;
1662
+ }
1663
+
1664
+ /** Why a writer of the frame keeps the one-object answer from holding it, or null. The one object
1665
+ * is every byte of the declared area, so what a holder writes through any address inside it is
1666
+ * bytes one declaration owns, at offsets one array makes adjacent — below them is the outgoing
1667
+ * block and above them the saved registers, neither a local. That is the whole-area argument the
1668
+ * untyped object is sized by, and it holds the same escapes here (`wholeAreaEscapeRefusal`). Not
1669
+ * kept besides is a callee's RETURN STORAGE: the struct it returns through a hidden pointer is its
1670
+ * declared type, not bytes of an array. */
1671
+ function writerNotKept(uses: FrameUses, returnsWithoutHiddenPointer: (callee: string) => boolean): string | null {
1672
+ for (const off of [...uses.escaped].filter((o) => mayWrite(uses, o))) {
1673
+ const at = `the captured address at [sp,#${off})`;
1674
+ const temp = uses.returnTemps.get(off)?.[0];
1675
+ if (temp !== undefined) {
1676
+ return `${at} is where \`${temp.attrs.target}\` returns its struct`;
1677
+ }
1678
+ const why = wholeAreaEscapeRefusal(off, uses, returnsWithoutHiddenPointer);
1679
+ if (why !== null) {
1680
+ return `${at}: ${why}`;
1681
+ }
1682
+ }
1683
+ return null;
1684
+ }
1685
+
1686
+ /** THE MODEL IS CHOSEN BEFORE ANY SHAPE IS JUDGED. The one-object answer is on offer in two
1687
+ * frames. Where one escape reaches without bound and that answer can hold every writer
1688
+ * (`writerNotKept`) — two out-parameters handed to one callee as readily as a device read. And
1689
+ * where NO ADDRESS ESCAPES AT ALL: then there is no writer and no reader but this function's own
1690
+ * accesses, so keeping every word of the declared area in memory keeps every store and every load
1691
+ * the machine made there, and a spill kept in memory changes no value. That second frame is the
1692
+ * one a by-value struct parameter leaves: agbcc spills its registers word by word (`str r1, [sp]`,
1693
+ * the slot model's) and, Thumb having no sp-relative halfword or byte load, reads its members
1694
+ * through `mov rD, sp` (the object's) — `int f(struct Q { u8 a[9]; } p){ return p.a[1] + p.a[8]; }`,
1695
+ * or a member read signed and unsigned, or the struct copied out a word at a time. The shapes the
1696
+ * per-object model refuses are ones that answer can hold: a member at [+k] through a capture, two
1697
+ * widths or two signednesses at one address, a runtime index, overlapping objects, a second
1698
+ * object beside an untyped one, an object over a slot. So there each of those refusals asks for
1699
+ * that answer instead of declining (`shapeRefused`), and the second audit judges the bytes as one
1700
+ * object by its own rules — two types at one byte are a union there, or refuse. Where the answer
1701
+ * is not on offer, each refuses where it stands.
1702
+ *
1703
+ * A frame nothing escapes from can still hold a CALL'S STACK ARGUMENT: agbcc builds one it cannot
1704
+ * place with `str rX, [sp,#k]` through `mov rD, sp` — a by-value struct block-copied with
1705
+ * `ldmia`/`stmia`, a compound literal stored a byte or a halfword at a time — and the address
1706
+ * escapes nowhere. Kept here as a local, in either model, those stores outlive the call that took
1707
+ * them. That is `storedNeverRead`'s to refuse, the stage after this one, and it asks it of every
1708
+ * call, whatever a prototype says. */
1709
+ function chooseFrameModel({
1710
+ oneObject,
1711
+ uses,
1712
+ windows,
1713
+ returnsWithoutHiddenPointer,
1714
+ fail,
1715
+ }: {
1716
+ oneObject: FrameRange | undefined;
1717
+ uses: FrameUses;
1718
+ windows: ReadonlyMap<number, ReadWindow>;
1719
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1720
+ fail: Refuse;
1721
+ }): ModelChoice {
1722
+ const unboundedAt = new Set(
1723
+ [...windows]
1724
+ .filter(([, w]) => (w.lo === -Infinity && w.hi === Infinity) || w.sparedByMiss === true)
1725
+ .map(([off]) => off),
1726
+ );
1727
+ const unbounded = oneObject === undefined && unboundedAt.size > 0;
1728
+ const writer = unbounded ? writerNotKept(uses, returnsWithoutHiddenPointer) : null;
1729
+ const escapeFree = oneObject === undefined && uses.escaped.size === 0;
1730
+ // A struct passed by value is ONE object of its declared type, which the call names whole; the
1731
+ // one object is bytes, and a call handed them would pass a pointer.
1732
+ const onOffer = uses.byValueReads.size === 0 && ((unbounded && writer === null) || escapeFree);
1733
+ const model: ModelChoice = {
1734
+ onOffer,
1735
+ unboundedAt,
1736
+ refused: false,
1737
+ shapeRefused: (why) => {
1738
+ if (!onOffer) {
1739
+ fail(model.withheld(why));
1740
+ }
1741
+ model.refused = true;
1742
+ },
1743
+ // A refusal the one-object answer would have taken names the writer that withheld it: the
1744
+ // shape alone is one the answer holds, so the shape alone does not say why this declined.
1745
+ withheld: (why) => (writer === null ? why : `${why} — and the one object cannot hold every writer: ${writer}`),
1746
+ };
1747
+ if (uses.memberRefusal !== undefined) {
1748
+ model.shapeRefused(uses.memberRefusal);
1749
+ }
1750
+ return model;
1751
+ }
1752
+
1753
+ /** A FRAME STORE THAT REACHES A CALL UNREAD, BENEATH EVERY WORD PROVEN A LOCAL, MAY BE THAT
1754
+ * CALL'S STACK ARGUMENT — refuse. agbcc builds an argument it cannot place with `str rX, [sp,#k]`
1755
+ * IN PLACE through a capture: a by-value struct is `mov rD, sp` + `stmia`, a compound literal is
1756
+ * `strb`/`strh` through `mov rD, sp` moved by `add rD, #k`. The stack-argument analysis
1757
+ * (stackargs.ts) sees `[sp,#k]` slot stores alone, so it never weighs these, and no prototype
1758
+ * bounds them: a callee no prototype states counts argument registers alone, and a stated list is
1759
+ * a LOWER bound on the words a call takes — a variadic callee's list is a prefix, and DWARF's
1760
+ * `symbolPrototype` drops the `...`. Whatever model then holds the bytes, a local per capture or
1761
+ * the one object, keeps the stores and the call loses its stack words:
1762
+ * `th(1, 2, 3, 4, (struct S){{x, y}})` lifts as `th(1, 2, 3, 4)`. So this asks EVERY call.
1763
+ *
1764
+ * A store is asked when it reaches a call PENDING — on some path from it to the call nothing loads
1765
+ * a byte it wrote, and on EVERY path from the entry some byte of it is written by such a store and
1766
+ * not loaded since — since a word the callee takes is one the caller wrote for it on every path
1767
+ * to the call and did not read back. A store through a pointer that is the
1768
+ * frame's on only some paths (`*(p ? p : &local) = x`) is not asked: it is not a store the
1769
+ * outgoing block alone can own. Nor is a store a runtime index or an escaped address reaches from
1770
+ * an object at or below it: whoever that hands the bytes to may read them.
1771
+ *
1772
+ * AND ONLY A STORE BENEATH THE LOADED-WORD BOUND. Under `localsAboveOutgoingArea` a compiler keeps
1773
+ * one outgoing area at the frame bottom, below every local, and never reads it back after a call;
1774
+ * so a frame byte loaded after a call before anything re-stores it held a value across that call,
1775
+ * its word is a local's, and so is every word above it. Under the strong form,
1776
+ * `outgoingAreaNeverLoadedFrom`, any load in a word at or above that offset bounds it: from 0 on
1777
+ * agbcc, past the a0..a3 home on O32 (whose compilers load a by-value aggregate's home words back)
1778
+ * and from the parameter area on mwcc (`target.ts` says why). The frontend measures that bound
1779
+ * once, off its instructions, and hands it here (`loadedWords`) — the same number its own
1780
+ * stack-argument refusals take (stackargs' `loadedWordBound` on Thumb).
1781
+ * Every compiled outgoing-argument shape sits beneath any such bound, and a local the source reads
1782
+ * after a call does not: `g(); b[0] = x; b[1] = y; return b[0];` reaches no call, `b[0] = x; g();
1783
+ * return b[0];` is above the bound. What this still refuses that is honest is a local the source
1784
+ * stores, never reads back, and calls past — a dead local and an outgoing argument are the same
1785
+ * machine code, and the lift cannot tell which. The after-call liveness weighs this audit's fixed
1786
+ * accesses alone, so it sits at or above the bound the frontend's slot loads would give, which
1787
+ * only asks more; the strong form adds the frontend's bound (`loadedWords`). Without either
1788
+ * premise the bound proves nothing, and every pending store is asked. */
1789
+ function storedNeverRead(
1790
+ irBlocks: readonly Block[],
1791
+ uses: FrameUses,
1792
+ loadedWords: FrameObjectAudit['loadedWords'],
1793
+ target: TargetDescription,
1794
+ fail: Refuse,
1795
+ ): void {
1796
+ const calls = irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'call'));
1797
+ if (calls.length === 0) {
1798
+ return;
1799
+ }
1800
+ const byOp = new Map(uses.fixed.map((a) => [a.op, a]));
1801
+ const bytes = (a: { at: number; width: number }): number[] => [...Array(a.width).keys()].map((i) => a.at + i);
1802
+ const index = new Map(irBlocks.map((b, i) => [b, i]));
1803
+ const succs = irBlocks.map((b) => successorsOf(b).map((s) => index.get(s)!));
1804
+ const live = new Set(irBlocks.length > 0 ? [0] : []);
1805
+ for (const stack = [...live]; stack.length;) {
1806
+ for (const s of succs[stack.pop()!]) {
1807
+ if (!live.has(s)) {
1808
+ live.add(s);
1809
+ stack.push(s);
1810
+ }
1811
+ }
1812
+ }
1813
+
1814
+ // THE SURVIVOR BOUND: a backward liveness over the frame bytes this function reads at fixed
1815
+ // offsets through an address that is the frame's on every path. A load makes its bytes live, a
1816
+ // store that writes them kills them, and a call is transparent but marks every byte live across
1817
+ // it. A load whose address may be elsewhere proves nothing and a store that may write elsewhere
1818
+ // kills nothing; nor does a block the entry cannot reach.
1819
+ let bound = Infinity;
1820
+ if (target.compilerBehaviors.localsAboveOutgoingArea === true) {
1821
+ const liveIn = irBlocks.map(() => new Set<number>());
1822
+ for (let changed = true; changed;) {
1823
+ changed = false;
1824
+ for (let b = irBlocks.length - 1; b >= 0; b--) {
1825
+ if (!live.has(b)) {
1826
+ continue;
1827
+ }
1828
+ const cur = new Set(succs[b].flatMap((s) => [...liveIn[s]]));
1829
+ for (const op of [...irBlocks[b].ops].reverse()) {
1830
+ const a = byOp.get(op);
1831
+ if (op.opcode === 'call') {
1832
+ for (const o of cur) {
1833
+ bound = Math.min(bound, o - (o % 4));
1834
+ }
1835
+ } else if (a?.frameOnly) {
1836
+ for (const o of bytes(a)) {
1837
+ if (a.isLoad) {
1838
+ cur.add(o);
1839
+ } else {
1840
+ cur.delete(o);
1841
+ }
1842
+ }
1843
+ }
1844
+ }
1845
+ if ([...cur].some((o) => !liveIn[b].has(o))) {
1846
+ liveIn[b] = cur;
1847
+ changed = true;
1848
+ }
1849
+ }
1850
+ }
1851
+ }
1852
+ // …and under the STRONG form (`outgoingAreaNeverLoadedFrom`) the caller loads no word of the
1853
+ // area from that offset up before a call either, so ANY frame byte loaded there bounds it. The
1854
+ // frontend measured that bound off its instructions (`loadedWords`): the slot reloads the IR
1855
+ // does not show, and the prologue's and epilogue's own loads — without them
1856
+ // `mov r3, sp; strh r0, [r3]; … bl sink; ldr r0, [sp]` refuses the `strh` on a word the code
1857
+ // itself proves a local's. What only this audit sees is a load through a captured address, in a
1858
+ // block the entry reaches, and it bounds the same way. A load in a word below the offset proves
1859
+ // nothing: O32 compilers load the a1..a3 home words they stage a by-value aggregate in.
1860
+ if (loadedWords !== undefined) {
1861
+ bound = Math.min(bound, loadedWords.bound);
1862
+ for (const b of live) {
1863
+ for (const op of irBlocks[b].ops) {
1864
+ const a = byOp.get(op);
1865
+ const word = a === undefined ? -1 : a.at - (a.at % 4);
1866
+ if (a?.isLoad && a.frameOnly && word >= loadedWords.from) {
1867
+ bound = Math.min(bound, word);
1868
+ }
1869
+ }
1870
+ }
1871
+ }
1872
+ const from = loadedWords?.from;
1873
+
1874
+ // THE PENDING STORES, two forward dataflows from the entry over the blocks it reaches. The MAY
1875
+ // set is the stores this asks that some path brings to a point unread: a load of any byte a
1876
+ // store wrote ends it — an argument is a word the caller writes and never reads, so a store
1877
+ // with any byte read is a local's — as a frame store covering every byte it wrote does. The
1878
+ // MUST set is the bytes every path brings to a point written by a store this asks and not
1879
+ // loaded since: a load ends the bytes it reads, a frame store the bytes it writes, and a store
1880
+ // that may write elsewhere ends nothing. An argument is staged on EVERY path to its call — a
1881
+ // tail-merged call is staged in each predecessor — so a byte some path reaches the call without
1882
+ // is not this call's to take: a loop's store that the first trip calls past is a local's.
1883
+ const reachedFrom = Math.min(...uses.escaped, ...uses.indexed.keys());
1884
+ type Fixed = FrameUses['fixed'][number];
1885
+ const asked = (a: Fixed): boolean => !a.isLoad && a.frameOnly && a.at + a.width <= reachedFrom && a.at < bound;
1886
+ interface Pending {
1887
+ may: Set<Fixed>;
1888
+ must: Set<number>;
1889
+ }
1890
+ const step = (op: Op, cur: Pending): void => {
1891
+ const a = byOp.get(op);
1892
+ if (a === undefined) {
1893
+ return;
1894
+ }
1895
+ const touched = new Set(bytes(a));
1896
+ for (const p of [...cur.may]) {
1897
+ const mine = bytes(p);
1898
+ if (a.isLoad ? mine.some((o) => touched.has(o)) : a.frameOnly && mine.every((o) => touched.has(o))) {
1899
+ cur.may.delete(p);
1900
+ }
1901
+ }
1902
+ if (a.isLoad || a.frameOnly) {
1903
+ for (const o of touched) {
1904
+ cur.must.delete(o);
1905
+ }
1906
+ }
1907
+ if (asked(a)) {
1908
+ cur.may.add(a);
1909
+ for (const o of touched) {
1910
+ cur.must.add(o);
1911
+ }
1912
+ }
1913
+ };
1914
+ const into: (Pending | undefined)[] = irBlocks.map((_, b) =>
1915
+ b === 0 ? { may: new Set(), must: new Set() } : undefined,
1916
+ );
1917
+ for (let changed = true; changed;) {
1918
+ changed = false;
1919
+ for (let b = 0; b < irBlocks.length; b++) {
1920
+ const at = into[b];
1921
+ if (!live.has(b) || at === undefined) {
1922
+ continue;
1923
+ }
1924
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
1925
+ irBlocks[b].ops.forEach((op) => step(op, cur));
1926
+ for (const s of succs[b]) {
1927
+ const old = into[s];
1928
+ if (s === 0) {
1929
+ continue;
1930
+ }
1931
+ if (old === undefined) {
1932
+ into[s] = { may: new Set(cur.may), must: new Set(cur.must) };
1933
+ changed = true;
1934
+ continue;
1935
+ }
1936
+ const may = new Set([...old.may, ...cur.may]);
1937
+ const must = new Set([...old.must].filter((o) => cur.must.has(o)));
1938
+ if (may.size !== old.may.size || must.size !== old.must.size) {
1939
+ into[s] = { may, must };
1940
+ changed = true;
1941
+ }
1942
+ }
1943
+ }
1944
+ }
1945
+ for (let b = 0; b < irBlocks.length; b++) {
1946
+ const at = into[b];
1947
+ if (!live.has(b) || at === undefined) {
1948
+ continue;
1949
+ }
1950
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
1951
+ for (const op of irBlocks[b].ops) {
1952
+ // a struct a call is passed by value is read by that call (`byValueReads`), not handed over
1953
+ if (op.opcode === 'call' && byOp.has(op)) {
1954
+ step(op, cur);
1955
+ }
1956
+ if (op.opcode === 'call') {
1957
+ const store = [...cur.may].filter((p) => bytes(p).some((o) => cur.must.has(o))).sort((x, y) => x.at - y.at)[0];
1958
+ if (store !== undefined) {
1959
+ const callee = calleeName(op);
1960
+ fail(
1961
+ `the ${store.width}-byte store at [sp,#${store.at}) through a captured address reaches the call to ` +
1962
+ `${callee === undefined ? 'an indirect callee' : `\`${callee}\``} unread on every path, and no ` +
1963
+ `${from === undefined ? 'load after a call' : from === 0 ? 'load at or beneath it' : `load at or beneath it, from [sp,#${from}) up,`} proves it a local's — it may be ` +
1964
+ "that call's stack argument, which the lift would drop",
1965
+ );
1966
+ }
1967
+ }
1968
+ step(op, cur);
1969
+ }
1970
+ }
1971
+ }
1972
+
1973
+ /** THE ACCEPTANCE'S PREMISE, RE-ASKED OF THE IR. `capturedObjectIsTheWholeFrame` is a reading
1974
+ * of the TEXT and it is the one thing in this file that switches a refusal OFF, so the two
1975
+ * facts it claims are re-proven here, where they are exact, rather than left to the
1976
+ * approximation that licensed them. Neither is a second opinion on the same evidence: the
1977
+ * scan asks what a REGISTER holds at a `bl`; this asks what the finished function does with
1978
+ * the OBJECT.
1979
+ *
1980
+ * THE ADDRESS ESCAPED. The whole licence is "something outside this function is holding the
1981
+ * address of this frame", and the pre-lift scan can say that of a register whose value never
1982
+ * reaches a call operand or a store — a declared arity trims it away, or the register is dead
1983
+ * by the time the call is built. When it does, [sp,#0] has been re-modelled as an addressable
1984
+ * object on no evidence at all, and the outgoing argument that really lived there is gone
1985
+ * from the call. EITHER escape re-proves it, because either one licensed it: a call taking
1986
+ * the address, or a store publishing it outward. They are asked as one question because they
1987
+ * license one thing.
1988
+ *
1989
+ * AND EACH ARM IS AT MOST AS WIDE AS THE LICENCE IT RE-PROVES, which is what makes this a
1990
+ * containment rather than a second door. The callee arm is narrower for free — a `call`
1991
+ * operand is the address reaching a callee, which is what the scan approximated. The publish
1992
+ * arm is `publishedOutward` and not `published` for the same reason spelled out there: read
1993
+ * off the wider set, this re-proof accepts a halfword store and a store back into the
1994
+ * object's own bytes, both of which the licence refuses by name — so a frame the pre-lift
1995
+ * scan would never have licensed passes the check that exists to re-prove the licence.
1996
+ *
1997
+ * THE CONTAINMENT IS STRICT AND THE SLACK IS ON THIS SIDE. `taint` is whole-function where the
1998
+ * pre-lift walk is block-local, so a base captured in an earlier block is refused HERE and
1999
+ * accepted THERE. That is the only direction the two can differ in without a wrong answer
2000
+ * reaching a caller, and it is structural rather than lucky: this set is built from a superset
2001
+ * of the facts the walk has. `packages/core/test/thumb-frontend.test.ts` carries the input.
2002
+ *
2003
+ * NOT A STRUCT-RETURN TEMP, which `hiddenReturnPointerStands` asks. */
2004
+ function recheckWholeFramePremise({
2005
+ uses,
2006
+ returnsWithoutHiddenPointer,
2007
+ fail,
2008
+ }: {
2009
+ uses: FrameUses;
2010
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
2011
+ fail: Refuse;
2012
+ }): void {
2013
+ if (!uses.passedToCallee.has(0) && !uses.publishedOutward.has(0)) {
2014
+ fail(
2015
+ 'the one-word-frame proof licensed this lift on the frame base escaping, and in the ' +
2016
+ 'lifted function no call takes it and no word store publishes it outside this frame — ' +
2017
+ 'so nothing rules out an outgoing stack argument at [sp,#0]',
2018
+ );
2019
+ }
2020
+ const writtenHere = uses.accesses.get(0)?.some((a) => !a.isLoad) === true;
2021
+ const whyItStands = writtenHere ? null : hiddenReturnPointerStands(0, uses.arg0Callees, returnsWithoutHiddenPointer);
2022
+ if (whyItStands !== null) {
2023
+ fail(`the one-word frame is never written here, and ${whyItStands}`);
2024
+ }
2025
+ }
2026
+
2027
+ /** THE ESCAPE EVENT'S PREMISE, RE-ASKED OF THE IR — `recheckWholeFramePremise`'s first half at
2028
+ * any frame size. The outgoing-argument analysis lets a frame address leaving this function end
2029
+ * the pending stores from its offset up (`StackArgsEscape`), reading the escape off what a
2030
+ * REGISTER holds at a `bl` or a word `str`. Where its verdict leans on that (it would refuse
2031
+ * without the escapes), each offset it read must reach a call operand or a word store outside the
2032
+ * frame in the lifted function too. It catches an arity that shrinks after the event was read
2033
+ * without passing the lowering's `CallSite.leftOut` question. Same two arms, same width as the
2034
+ * licence they re-prove. */
2035
+ function recheckEscapesReliedOn(offsets: ReadonlySet<number>, uses: FrameUses | undefined, fail: Refuse): void {
2036
+ for (const off of offsets) {
2037
+ if (uses === undefined || (!uses.passedToCallee.has(off) && !uses.publishedOutward.has(off))) {
2038
+ fail(
2039
+ `the outgoing-argument analysis set aside the stores from [sp,#${off}] up on that address escaping, and in the ` +
2040
+ 'lifted function no call takes it and no word store publishes it outside this frame — so nothing rules out ' +
2041
+ 'an outgoing stack argument there',
2042
+ );
2043
+ }
2044
+ }
2045
+ }
2046
+
2047
+ /** NOT A STRUCT-RETURN TEMP. A one-word frame rules out agbcc's block-copy bases (each needs
2048
+ * two words) but NOT the hidden return pointer of a <=4-byte non-integer-like struct, which is
2049
+ * exactly one word: `struct S4 { char a,b,c,d; }; struct S4 s = mk(x);` compiles to `add
2050
+ * sp,#-4 / mov r0,sp / bl mk / ldr r0,[sp]`, instruction for instruction an out-parameter
2051
+ * call. Left alone that lifted as `mk(&sp0, a0)` — a call the real prototype rejects.
2052
+ *
2053
+ * TWO facts rule it out and either will do, because a return temp is storage the CALLEE owns
2054
+ * outright: it is written only by the callee, and the callee RETURNS the struct THROUGH IT.
2055
+ * So a store of our own says the object is one this function fills; and a callee whose RETURN
2056
+ * is known to need no hidden pointer says the same by the ABI — a function that returns
2057
+ * nothing, or returns in a register, has no such pointer to be given, whatever sits in r0.
2058
+ *
2059
+ * AND THE QUESTION IS PER-CALL, which is what bounds how far it has to be asked: the pointer
2060
+ * is argument 0, always (compiled — `struct S4 mk3(int,int,int)` puts sp in r0 and shifts all
2061
+ * three real arguments up), so an address NO call takes at argument 0 cannot be one, whatever
2062
+ * else the function does with it. That is the whole of this function and it is why the
2063
+ * block-copy idiom `memcpy(dst, buf, sizeof buf)` — buffer at argument 1 — needs no declaration
2064
+ * at all. Per-call and not per-object: an address handed over at argument 1 somewhere leaves the
2065
+ * call that takes it at argument 0 exactly as ambiguous as before, so position acquits a call
2066
+ * rather than an object.
2067
+ *
2068
+ * THE THIRD IS A DECLARATION, NOT AN INFERENCE, and so is its converse: a callee declared to
2069
+ * return a struct through memory, whose call the frontend stamps `sret`, makes the object that
2070
+ * call's return storage (`returnTemps`). Those two are the only answers here read off something
2071
+ * other than the instruction stream. The frontend's call declarations answer the third
2072
+ * (`FrameObjectAudit.returnsWithoutHiddenPointer`, which aggregate.ts's
2073
+ * `returnsWithoutHiddenPointer` decides), from the project's own `returnsVoid`, from a struct its
2074
+ * declaration returns that the target hands back in a register, or from the `returns` of a
2075
+ * signature the C standard fixes — the same table whose `params` decide a call's arity. It is
2076
+ * asked of EVERY callee that took the address at argument 0, because the object gets one
2077
+ * decision: one callee about whose return nothing is known leaves the ambiguity standing and the
2078
+ * refusal fires.
2079
+ *
2080
+ * AN ARITY CANNOT ANSWER IT, which is worth saying because the count is right there and looks
2081
+ * like evidence: a hidden pointer does set one argument register more than the callee
2082
+ * declares, but the register count is what the machine WROTE, and a register already holding
2083
+ * this function's own incoming parameter is written by nobody. Compiled: `void f(const void
2084
+ * *a, const void *b){ struct Blob64 s = makeblob(b); }` emits `add sp,#-0x40 / mov r0,sp /
2085
+ * bl makeblob` — one written register against one declared parameter — so counting registers
2086
+ * reads a real struct return as an out-parameter and declares the callee's own storage as a
2087
+ * local. The question is about the RETURN and only a statement about the return decides it.
2088
+ *
2089
+ * WHAT IT COSTS WHEN THE DECLARATION IS WRONG, measured rather than compared. On the `sret`
2090
+ * shape above, with `mk` (which really returns `struct S4`) declared `params: 1,
2091
+ * returnsVoid: true`, the lift succeeds and emits `s32 sret(s32 a0) { s32 sp0; mk(&sp0);
2092
+ * return (u8)sp0; }` — a compiling, plausible, WRONG program with the real argument dropped,
2093
+ * where a loud decline stood. Not a smaller cost than a wrong ARITY, either: the same entry
2094
+ * supplies both facts, so a wrong `returnsVoid` drops the argument too, and the frame re-model
2095
+ * is the silent half. The trade is accepted because there IS no other discriminator: compiled
2096
+ * through
2097
+ * the benchmark's own agbcc command, the hidden struct return and the out-parameter emit the
2098
+ * same instructions in the same order, the slot is read back at a scalar width in both, and
2099
+ * in both the value read back is what the function returns — so an asm-side corroboration
2100
+ * would be a rule with no discriminating input. The mitigation is that under-declaring is the
2101
+ * safe direction (a callee whose return nothing describes still declines) and that `FnProto`
2102
+ * says so at the field.
2103
+ *
2104
+ * The residual cost is stated rather than hidden: an OUTPUT-only parameter taken at argument
2105
+ * 0 of a callee the project has NOT declared is still byte-for-byte a struct return, and
2106
+ * still declines with it.
2107
+ *
2108
+ * ONE SOURCE FOR THE DECISION AND ITS REASON, because every arm of this audit asks it and a
2109
+ * predicate beside a message is two things that can disagree. Returns why the pointer is not
2110
+ * ruled out — the caller frames it for its own arm — or null. */
2111
+ function hiddenReturnPointerStands(
2112
+ off: number,
2113
+ arg0Callees: FrameUses['arg0Callees'],
2114
+ returnsWithoutHiddenPointer: (callee: string) => boolean,
2115
+ ): string | null {
2116
+ const cs = arg0Callees.get(off);
2117
+ if (cs === undefined || cs.size === 0) {
2118
+ return null;
2119
+ }
2120
+ const unknown = [...cs].filter((c) => c === null || !returnsWithoutHiddenPointer(c));
2121
+ if (unknown.length === 0) {
2122
+ return null;
2123
+ }
2124
+ return (
2125
+ `\`${unknown.map((c) => c ?? '?').join('`, `')}\` takes it at argument 0 and nothing says ` +
2126
+ 'what that callee returns — a struct returned through a hidden pointer is handed this same frame'
2127
+ );
2128
+ }
2129
+
2130
+ // TWO MODELS FOR ONE BYTE is a silent disagreement: the slot model keeps an SSA slot in a
2131
+ // register, so a store through an object over the same bytes would never be seen there.
2132
+ function overlaps(a: number, aw: number, b: number, bw: number): boolean {
2133
+ return a < b + bw && b < a + aw;
2134
+ }
2135
+
2136
+ function failIfSlotKeysIt(
2137
+ off: number,
2138
+ width: number,
2139
+ {
2140
+ usedSlotOffsets,
2141
+ objects,
2142
+ foldedHere,
2143
+ fail,
2144
+ }: {
2145
+ usedSlotOffsets: ReadonlySet<number>;
2146
+ objects: ReadonlyMap<number, readonly Op[]>;
2147
+ foldedHere: ReadonlySet<Op>;
2148
+ fail: Refuse;
2149
+ },
2150
+ ): void {
2151
+ for (const slot of usedSlotOffsets) {
2152
+ if (overlaps(off, width, slot, 4)) {
2153
+ fail(
2154
+ objects.get(off)?.some((op) => foldedHere.has(op))
2155
+ ? `the capture moved by a constant to [sp,#${off}) is a move the pre-lift walk does not follow, ` +
2156
+ `so the slot model keys [sp,#${slot}] too — one byte, two models`
2157
+ : `the object at [sp,#${off}) overlaps the SSA slot at [sp,#${slot}] — one byte, two models`,
2158
+ );
2159
+ }
2160
+ }
2161
+ }
2162
+
2163
+ /** ONE OBJECT IN MEMORY, the answer a device read or a callee write nothing bounds is given
2164
+ * instead of a refusal (`oneObject`, requested by the relift test where the escapes are judged).
2165
+ * Every byte of `[from, to)` is declared one `u8` array whose address the device or the callee
2166
+ * holds (`writerNotKept` says which writers it can hold) — or one per part, where the accesses
2167
+ * prove the bytes are several objects no use reads across (`provenParts`) — and every access
2168
+ * inside it is a cast-spelled access to that array (an element of a part, by its index:
2169
+ * structure.ts `frameArrayElement`), or a view of the union it is declared as where two widths
2170
+ * meet at one byte — so each store the machine made there is a store the
2171
+ * recompile makes too. That is what agbcc does for an object whose address escaped: it keeps
2172
+ * every store to it, in order (flow.c deletes a memory store only when an identical later
2173
+ * store in the same block overwrites it). And it is right whichever the source had there: a
2174
+ * member of the escaped object, which the device may read, or a spill, which no one reads —
2175
+ * the asm spells both as a store and a reload, and keeping a spill in memory changes no value. */
2176
+ function keepAsOneObject({
2177
+ oneObject: { from, to },
2178
+ irBlocks,
2179
+ objects,
2180
+ uses,
2181
+ usedSlotOffsets,
2182
+ returnsWithoutHiddenPointer,
2183
+ arrayOrder,
2184
+ arrayAlignment,
2185
+ fail,
2186
+ }: {
2187
+ oneObject: FrameRange;
2188
+ irBlocks: Block[];
2189
+ objects: ReadonlyMap<number, readonly Op[]>;
2190
+ uses: FrameUses;
2191
+ usedSlotOffsets: ReadonlySet<number>;
2192
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
2193
+ arrayOrder: TargetDescription['compilerBehaviors']['addressTakenArrayOrder'];
2194
+ arrayAlignment: TargetDescription['compilerBehaviors']['addressTakenArrayAlignment'];
2195
+ fail: Refuse;
2196
+ }): void {
2197
+ const kept = `the bytes [sp,#${from}) to [sp,#${to}) kept as one object`;
2198
+ for (const off of objects.keys()) {
2199
+ if (off < from || off >= to) {
2200
+ fail(`the object at [sp,#${off}) lies outside ${kept}`);
2201
+ }
2202
+ }
2203
+ for (const slot of usedSlotOffsets) {
2204
+ if (overlaps(slot, 4, from, to - from)) {
2205
+ fail(`the SSA slot at [sp,#${slot}] lies inside ${kept} — one byte, two models`);
2206
+ }
2207
+ }
2208
+ const writer = writerNotKept(uses, returnsWithoutHiddenPointer);
2209
+ if (writer !== null) {
2210
+ fail(`${kept} cannot hold every writer — ${writer}`);
2211
+ }
2212
+ // A runtime index names no byte, so the rule below is asked of it at EVERY byte it may reach:
2213
+ // all of them. Two indexed widths refuse here; an index beside a member of another width
2214
+ // refuses after the members are checked against each other, so a member pair keeps naming its
2215
+ // own byte (`indexedWidth` says why one width is enough).
2216
+ const index = indexedWidth([...uses.indexed.values()].flat());
2217
+ if (index.refusal !== null) {
2218
+ fail(`a runtime index into ${kept} ${index.refusal}`);
2219
+ }
2220
+ // ONE TYPE PER BYTE, OR ONE UNION. agbcc at -O2 turns on type-based alias analysis
2221
+ // (toplev.c:3616), and compiled, a `u32` read through a cast is served the earlier `u32` store
2222
+ // straight past a `u16` store to the same bytes — a stale value. So a byte two accesses of
2223
+ // different widths reach cannot be spelled through casts. A byte access is exempt, since
2224
+ // character types alias everything, and so is a signedness difference, since the signed and
2225
+ // unsigned types of one width share an alias set (c-common.c:1962-1974). The first audit asks
2226
+ // for this answer rather than refusing two widths — an object read at both, or a `strh` member
2227
+ // through the captured address over a word the slot model had — so this is where they are judged.
2228
+ //
2229
+ // What two widths at one byte CAN be spelled as is a UNION of per-width views, every access made
2230
+ // directly through it (`sp0.half[1]`): c_get_alias_set gives any access through a member of a
2231
+ // union alias set 0 (c-common.c, the COMPONENT_REF walk), which conflicts with every set, so no
2232
+ // read moves past a store to the same bytes — compiled, a `u16` read, a `u32` store and the same
2233
+ // read again reloads through the union and returns the stale value through casts. So the object
2234
+ // is stamped with the views its accesses use (`unionViews`) and declared as that union
2235
+ // (structure.ts `laddrType`), where every access to it is a fixed offset on the object itself,
2236
+ // aligned to its own width — the cases `unionRefusal` names refuse as before.
2237
+ const widthAt = new Map<number, number>();
2238
+ let twoWidths: { b: number; had: number; width: number } | undefined;
2239
+ for (const m of uses.members) {
2240
+ if (m.at < from || m.at + m.width > to) {
2241
+ fail(`an access of ${m.width} bytes at [sp,#${m.at}) reaches past ${kept}`);
2242
+ }
2243
+ if (m.width === 1) {
2244
+ continue;
2245
+ }
2246
+ for (let b = m.at; b < m.at + m.width; b++) {
2247
+ const had = widthAt.get(b);
2248
+ if (had !== undefined && had !== m.width) {
2249
+ twoWidths ??= { b, had, width: m.width };
2250
+ }
2251
+ widthAt.set(b, m.width);
2252
+ }
2253
+ }
2254
+ if (twoWidths !== undefined) {
2255
+ // any runtime index, a byte one too: `indexedWidth` leaves width 1 out because a char access
2256
+ // agrees with every width, but a union view is still spelled for fixed offsets alone
2257
+ const why = unionRefusal({ from, to, uses, indexed: uses.indexed.size > 0 });
2258
+ if (why !== null) {
2259
+ fail(
2260
+ `the byte at [sp,#${twoWidths.b}] of ${kept} is accessed ${twoWidths.had} and ${twoWidths.width} bytes ` +
2261
+ `wide, ${STALE_ACROSS_TYPES}, and ${why}`,
2262
+ );
2263
+ }
2264
+ }
2265
+ for (const [b, had] of widthAt) {
2266
+ if (index.width !== null && had !== index.width) {
2267
+ fail(
2268
+ `the byte at [sp,#${b}] of ${kept} is accessed ${had} bytes wide and by a runtime index ${index.width} ` +
2269
+ `bytes wide, ${STALE_ACROSS_TYPES}`,
2270
+ );
2271
+ }
2272
+ }
2273
+ // Rewritten onto one `laddr` at `from`: an access through a member at `k` becomes an access
2274
+ // at `k - from` off the object, and a member address used any other way becomes the object's
2275
+ // address moved by that constant.
2276
+ //
2277
+ // `volatile` goes on each ACCESS where the address was stored somewhere a writer may hold it
2278
+ // (`publishedToWriter`): unqualified, a spin on a byte a holder of the address sets compiles to
2279
+ // a loop that never reads it again. Never on the array: every member is spelled through a cast,
2280
+ // whose pointee drops the array's qualifier, so it would reach no access and only make agbcc
2281
+ // warn `discards qualifiers` wherever the bare array is named. A reader alone — a DMA source, a
2282
+ // transfer's fill source (`filledFrom`) — changes no byte a re-read returns. KNOWN GAP: a
2283
+ // callee that keeps the address it was handed, for an interrupt to write later, is a holder
2284
+ // nothing here sees, so a spin on such a byte compiles unqualified; the per-object stamp has the
2285
+ // same gap (`stampObjects`: not on an ordinary `&local` argument).
2286
+ const reread = uses.publishedToWriter.size > 0;
2287
+ // …and declared as SEVERAL objects where the accesses prove the bytes are several
2288
+ // (`provenParts`), each rewritten on its own: an access through a member at `k` lands on the
2289
+ // part that holds `k`, at `k` less the part's start. Only on a compiler that places address-
2290
+ // taken arrays in a known order against their declarations (`addressTakenArrayOrder`): the
2291
+ // parts are declared in that order, or the recompile swaps their addresses. And at a known
2292
+ // alignment (`addressTakenArrayAlignment`), which says where a part may start.
2293
+ const parts =
2294
+ twoWidths === undefined && arrayOrder !== undefined && arrayAlignment !== undefined
2295
+ ? provenParts({ from, to }, objects, uses, arrayAlignment)
2296
+ : [{ from, to }];
2297
+ // …stamped `part` where there are several: storage a copy proved one object of the source's, so
2298
+ // its bytes are that object's elements rather than the one object's mix of members and spills,
2299
+ // and the emitter may name them as elements (structure.ts `frameArrayElement`)
2300
+ const laddrs = parts.map(({ from: lo, to: hi }) =>
2301
+ mkOp('laddr', {
2302
+ results: [mkValue(T.unk(32))],
2303
+ attrs: { off: lo, width: 1, signed: false, count: hi - lo, ...(parts.length > 1 ? { part: true } : {}) },
2304
+ }),
2305
+ );
2306
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2307
+ const object = laddrs[0];
2308
+ const base = object.results[0];
2309
+ const memberAt = new Map<Value, number>();
2310
+ for (const op of [...objects.values()].flat()) {
2311
+ memberAt.set(op.results[0], op.attrs.off as number);
2312
+ }
2313
+ const baseAt = (at: number) => laddrs[partOf(at)].results[0];
2314
+ const startAt = (at: number) => parts[partOf(at)].from;
2315
+ // The qualifier goes on every access the classification judged one (`accessOps`) — through a
2316
+ // member, a runtime index into one, or a phi that carries one (`p = c ? buf : gOther; while
2317
+ // (*p == 0);`). Only an access through a member is rebased: a phi's incoming member is
2318
+ // rewritten below, where it is an edge argument.
2319
+ const stillUsed = new Set<Value>();
2320
+ for (const blk of irBlocks) {
2321
+ for (const op of blk.ops) {
2322
+ const at = memberAt.get(op.operands[0]);
2323
+ const access = op.opcode === 'load' || op.opcode === 'store';
2324
+ if (reread && uses.accessOps.has(op)) {
2325
+ op.attrs = { ...op.attrs, volatile: true };
2326
+ }
2327
+ if (access && at !== undefined) {
2328
+ op.operands = [baseAt(at), ...op.operands.slice(1)];
2329
+ op.attrs = { ...op.attrs, off: (op.attrs.off as number) + at - startAt(at) };
2330
+ }
2331
+ op.operands.forEach((v) => memberAt.has(v) && stillUsed.add(v));
2332
+ (op.successors ?? []).forEach((sx) => sx.args.forEach((v) => memberAt.has(v) && stillUsed.add(v)));
2333
+ }
2334
+ }
2335
+ const atBase = (v: Value): Value => {
2336
+ const at = memberAt.get(v);
2337
+ return at !== undefined && at === startAt(at) ? baseAt(at) : v;
2338
+ };
2339
+ for (const blk of irBlocks) {
2340
+ blk.ops = blk.ops.flatMap((op) => {
2341
+ const at = op.opcode === 'laddr' ? memberAt.get(op.results[0]) : undefined;
2342
+ if (at === undefined) {
2343
+ op.operands = op.operands.map(atBase);
2344
+ (op.successors ?? []).forEach((sx) => (sx.args = sx.args.map(atBase)));
2345
+ return [op];
2346
+ }
2347
+ if (!stillUsed.has(op.results[0]) || at === startAt(at)) {
2348
+ return [];
2349
+ }
2350
+ const by = mkValue(T.unk(32));
2351
+ return [
2352
+ mkOp('const', { results: [by], attrs: { value: at - startAt(at) } }),
2353
+ mkOp('add', { operands: [baseAt(at), by], results: op.results }),
2354
+ ];
2355
+ });
2356
+ }
2357
+ if (twoWidths !== undefined) {
2358
+ // …every access is now a fixed offset on the object itself, which is what a view spells; one
2359
+ // that is not was carried there by a phi, as a pointer, and a pointer to the union's bytes is
2360
+ // read through a cast again
2361
+ for (const op of uses.accessOps.keys()) {
2362
+ if (op.operands[0] !== base) {
2363
+ fail(
2364
+ `the bytes [sp,#${from}) to [sp,#${to}) are accessed at two widths, which only a union of views ` +
2365
+ 'spells without a stale read — and an access reaches them through a pointer a phi carries, which ' +
2366
+ 'no view of the union names',
2367
+ );
2368
+ }
2369
+ }
2370
+ object.attrs = { ...object.attrs, views: unionViews(irBlocks, base) };
2371
+ }
2372
+ irBlocks[0].ops.unshift(...(arrayOrder === 'descending' ? [...laddrs].reverse() : laddrs));
2373
+ }
2374
+
2375
+ /** THE PARTS THE ACCESSES PROVE `[from, to)` IS MADE OF, lowest first — or the whole range, one
2376
+ * part, wherever they do not. A part is a maximal run of bytes that overlapping uses chain
2377
+ * together — a fixed-offset access, a struct passed by value, and an escape a standard block copy
2378
+ * with a literal count bounds (`escapeExtents`) each name the bytes they touch — and a run that
2379
+ * merely abuts the one below it starts a new part only where such a copy starts — and so must
2380
+ * the lowest part, or nothing is split. Two brace-initialised arrays side by side are agbcc's
2381
+ * `memcpy(a, .LC0, 16); memcpy(b, .LC2, 16)`, and declared as one `u8[32]` neither image can be
2382
+ * folded into its own local (l3/constinit.ts).
2383
+ *
2384
+ * ONE PART, never a refusal, whenever a byte could be read through two parts: a runtime index
2385
+ * (it names no byte, so it may reach every one), an escape nothing bounds (a callee or a holder
2386
+ * that may reach any byte from the address it was given), a byte no use names (nothing says
2387
+ * which side it belongs to), or a use through a member that lands outside the part its own
2388
+ * address lies in (`&a[16]` read at [-1]: one pointer walking two parts is one object). The
2389
+ * union views of two widths at one byte are not split either (the caller asks only without
2390
+ * them). An access straddling two runs chains them into one part by construction. */
2391
+ function provenParts(
2392
+ { from, to }: FrameRange,
2393
+ objects: ReadonlyMap<number, readonly Op[]>,
2394
+ uses: FrameUses,
2395
+ alignment: number,
2396
+ ): FrameRange[] {
2397
+ const whole = [{ from, to }];
2398
+ if (uses.indexed.size > 0) {
2399
+ return whole;
2400
+ }
2401
+ const touched: { owner: number; lo: number; hi: number }[] = [];
2402
+ for (const [op, { off }] of uses.accessOps) {
2403
+ const at = off + (op.attrs.off as number);
2404
+ touched.push({ owner: off, lo: at, hi: at + (op.attrs.width as number) });
2405
+ }
2406
+ for (const [off, reads] of uses.byValueReads) {
2407
+ reads.forEach(({ type }) =>
2408
+ touched.push({ owner: off, lo: off, hi: off + (type.kind === 'struct' ? (type.size ?? Infinity) : Infinity) }),
2409
+ );
2410
+ }
2411
+ for (const [off, extents] of uses.escapeExtents) {
2412
+ for (const e of extents) {
2413
+ if (e === null) {
2414
+ return whole;
2415
+ }
2416
+ touched.push({ owner: off, ...e });
2417
+ }
2418
+ }
2419
+ // …and a boundary only where a bounded copy starts: an address handed to `memcpy` is where
2420
+ // the source put an object it fills. A run starting anywhere else is bytes reached from below,
2421
+ // which says nothing of where one object ends — the one-object relift reads every slot through
2422
+ // an address of its own, so a struct spilled word by word and read through its base shows a
2423
+ // run per word, and is one object at every word. The LOWEST part is held to the same test
2424
+ // (below): where it is only stores, a copy above it is as likely `memcpy(&buf[4], s, 12)` into
2425
+ // the middle of one array as the start of a second one, and declared as two the recompile
2426
+ // reads `buf[6]` through a base it holds where the target re-formed sp+6. Only a copy's
2427
+ // DESTINATION counts: a copy reading OUT of the frame says where the source took bytes from,
2428
+ // which is as likely `memcpy(g, &w[2], 8)` from the middle of one array as a second object.
2429
+ const starts = new Set([...uses.escapeExtents.values()].flat().flatMap((e) => (e?.into ? [e.lo] : [])));
2430
+ const parts: { from: number; to: number }[] = [];
2431
+ for (const { lo, hi } of [...touched].sort((a, b) => a.lo - b.lo)) {
2432
+ const last = parts.at(-1);
2433
+ if (last !== undefined && (lo < last.to || (lo === last.to && !starts.has(lo)))) {
2434
+ last.to = Math.max(last.to, hi);
2435
+ } else {
2436
+ parts.push({ from: lo, to: hi });
2437
+ }
2438
+ }
2439
+ const tiles =
2440
+ parts.length > 1 &&
2441
+ parts[0].from === from &&
2442
+ parts.at(-1)!.to === to &&
2443
+ parts.every((p, i) => i === 0 || parts[i - 1].to === p.from);
2444
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2445
+ if (
2446
+ !tiles ||
2447
+ [...objects.keys()].some((off) => partOf(off) < 0) ||
2448
+ touched.some(({ owner, lo }) => partOf(owner) !== partOf(lo))
2449
+ ) {
2450
+ return whole;
2451
+ }
2452
+ // …and a part starts only where the compiler places a local array of its own: at a multiple of
2453
+ // its `alignment` (agbcc pads `u8 a[18]` to 20, so a split there declares `b` at sp+20 where the
2454
+ // target had it at sp+18; gcc 2.7.2 on MIPS starts every one at a multiple of 8, so a part at
2455
+ // sp+20 is placed at sp+24 and the frame grows), and never as ONE byte, which declared alone is
2456
+ // a `u8` scalar whose address nothing takes — kept in a register, it drops the frame byte and
2457
+ // the store the target made. Such a part joins the one below it, and the lowest one byte joins
2458
+ // the one above: fewer parts only ever read as the one object does, so this costs a split,
2459
+ // never a value.
2460
+ const merged: { from: number; to: number }[] = [];
2461
+ for (const p of parts) {
2462
+ const last = merged.at(-1);
2463
+ if (last !== undefined && (p.from % alignment !== 0 || p.to - p.from === 1 || last.to - last.from === 1)) {
2464
+ last.to = p.to;
2465
+ } else {
2466
+ merged.push({ ...p });
2467
+ }
2468
+ }
2469
+ return merged.length > 1 && merged.every((p) => starts.has(p.from)) ? merged : whole;
2470
+ }
2471
+
2472
+ /** Why the one object's bytes, accessed at two widths, cannot be spelled as a union of per-width
2473
+ * views, or null. A view names an ELEMENT, so every access must sit at a multiple of its own width
2474
+ * from the object's start and every view must tile the object; and it names it at a constant
2475
+ * index, so a runtime index into the object — whose element width a view could carry, but whose
2476
+ * spelling nothing here builds — keeps the refusal, and so does a qualifier on the accesses
2477
+ * (`publishedToWriter`), which a member access of an unqualified union cannot carry. */
2478
+ function unionRefusal({
2479
+ from,
2480
+ to,
2481
+ uses,
2482
+ indexed,
2483
+ }: {
2484
+ from: number;
2485
+ to: number;
2486
+ uses: FrameUses;
2487
+ indexed: boolean;
2488
+ }): string | null {
2489
+ if (indexed) {
2490
+ return 'a runtime index reaches them too, which no view of a union is spelled for';
2491
+ }
2492
+ if (uses.publishedToWriter.size > 0) {
2493
+ return 'the address is stored where a writer may hold it, and a view of a union carries no per-access qualifier';
2494
+ }
2495
+ for (const m of uses.members) {
2496
+ if ((m.at - from) % m.width !== 0) {
2497
+ return `the ${m.width}-byte access at [sp,#${m.at}) is not aligned to its width, so no view of a union names it`;
2498
+ }
2499
+ if ((to - from) % m.width !== 0) {
2500
+ return `the ${to - from} bytes are not a whole number of ${m.width}-byte elements, so no view of a union tiles them`;
2501
+ }
2502
+ }
2503
+ return null;
2504
+ }
2505
+
2506
+ /** The views a union object's accesses use, as `laddr`'s `views` attr: one entry per view, its
2507
+ * element width in bytes, NEGATIVE for a signed narrow view — which views, by the rule
2508
+ * raise/structs.ts declares a struct member's union by (ir/types.ts `unionViewSigns`). A word's
2509
+ * one view is `s32` whichever sign it carries, so it is entered unsigned. Widest first, unsigned
2510
+ * before signed. */
2511
+ function unionViews(irBlocks: readonly Block[], base: Value): number[] {
2512
+ const byWidth = new Map<number, { isLoad: boolean; signed: boolean; stamped: boolean }[]>();
2513
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
2514
+ if ((op.opcode === 'load' || op.opcode === 'store') && op.operands[0] === base) {
2515
+ const w = op.attrs.width as number;
2516
+ (byWidth.get(w) ?? byWidth.set(w, []).get(w)!).push({
2517
+ isLoad: op.opcode === 'load',
2518
+ signed: (op.attrs.signed as boolean) ?? false,
2519
+ stamped: op.opcode === 'store' && op.attrs.signed !== undefined,
2520
+ });
2521
+ }
2522
+ }
2523
+ return [...byWidth]
2524
+ .sort(([a], [b]) => b - a)
2525
+ .flatMap(([w, accesses]) => unionViewSigns(w, accesses).map((sg) => (sg && w !== 4 ? -w : w)));
2526
+ }
2527
+
2528
+ /** One object's shape: `count` elements of `width` bytes. */
2529
+ interface ObjectShape {
2530
+ readonly width: number;
2531
+ readonly count: number;
2532
+ }
2533
+
2534
+ /** The per-object model's reading of every object. */
2535
+ interface ObjectShapes {
2536
+ readonly extent: ReadonlyMap<number, ObjectShape>;
2537
+ /** the declared struct each return temp is, by offset */
2538
+ readonly aggregateAt: ReadonlyMap<number, IrType>;
2539
+ /** each object over a slot the slot model keys, as `[off, width]`, refused last (`failIfSlotKeysIt`) */
2540
+ readonly overSlot: readonly (readonly [number, number])[];
2541
+ }
2542
+
2543
+ const span = (o: ObjectShape) => o.width * o.count;
2544
+
2545
+ function slotKeys(usedSlotOffsets: ReadonlySet<number>, off: number, width: number): boolean {
2546
+ return [...usedSlotOffsets].some((slot) => overlaps(off, width, slot, 4));
2547
+ }
2548
+
2549
+ /** The SHAPE of each object — `count` elements of `width` bytes, spanning `width * count` —
2550
+ * and then that its bytes belong to nothing else.
2551
+ *
2552
+ * AN OBJECT OVER A SLOT IS REFUSED LAST, after the one-object answer is asked: that answer
2553
+ * routes every word of the local area through `laddr`, so no slot is left for the object to
2554
+ * overlap, and refusing first would decline exactly the frames it exists for — an object at
2555
+ * [sp,#0] whose first member is a word stored `str rN, [sp]`, or a by-value struct parameter
2556
+ * spilled word by word and read through `mov rD, sp` where nothing escapes. The other per-object
2557
+ * refusals here go through `shapeRefused`, for the same reason: where nothing escapes, each of
2558
+ * them asks for that answer too (`chooseFrameModel`). */
2559
+ function objectShapes({
2560
+ objects,
2561
+ uses,
2562
+ owned,
2563
+ declared,
2564
+ usedSlotOffsets,
2565
+ returnsWithoutHiddenPointer,
2566
+ model,
2567
+ fail,
2568
+ }: {
2569
+ objects: ReadonlyMap<number, readonly Op[]>;
2570
+ uses: FrameUses;
2571
+ owned: FrameRange;
2572
+ declared: FrameRange;
2573
+ usedSlotOffsets: ReadonlySet<number>;
2574
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
2575
+ model: ModelChoice;
2576
+ fail: Refuse;
2577
+ }): ObjectShapes {
2578
+ const extent = new Map<number, ObjectShape>();
2579
+ const aggregateAt = new Map<number, IrType>();
2580
+ const overSlot: [number, number][] = [];
2581
+ for (const [off, acc] of uses.accesses) {
2582
+ const byIndex = uses.indexed.get(off) ?? [];
2583
+ const temps = uses.returnTemps.get(off);
2584
+ const passed = uses.byValueReads.get(off);
2585
+ if (passed !== undefined) {
2586
+ // A struct passed BY VALUE (`foldByValueArgs`): this function writes and reads its members,
2587
+ // and the call reads it whole. A struct-return temp passed on, an index, or any other use of
2588
+ // the address is not modelled.
2589
+ const type = passed[0].type;
2590
+ const at = `the object at [sp,#${off}) is passed by value as ${structSpelling(type)}`;
2591
+ if (temps !== undefined) {
2592
+ fail(`${at}, and is also the storage a call returns a struct into — not modelled`);
2593
+ }
2594
+ if (passed.some((p) => !typeEquals(p.type, type))) {
2595
+ fail(`${at} to calls declared to take different types`);
2596
+ }
2597
+ if (byIndex.length > 0) {
2598
+ fail(`${at}, and this function also indexes it — only its members' accesses are modelled`);
2599
+ }
2600
+ if (uses.useCount.get(off) !== acc.length + passed.length) {
2601
+ fail(`${at}, and its address is also used another way — only its members' accesses are modelled`);
2602
+ }
2603
+ const size = type.kind === 'struct' ? type.size! : 0;
2604
+ const past = uses.fixed.find(
2605
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
2606
+ );
2607
+ if (past !== undefined) {
2608
+ fail(`${at}, and this function accesses [sp,#${past.at}) of width ${past.width}, past the struct`);
2609
+ }
2610
+ extent.set(off, { width: 1, count: size });
2611
+ aggregateAt.set(off, type);
2612
+ continue;
2613
+ }
2614
+ if (temps !== undefined) {
2615
+ // The callee writes the whole struct and this function names it as the call's destination,
2616
+ // and READS it: each load is of bytes inside the struct, which the struct's declaration
2617
+ // types (raise/aggmembers.ts reads a word's members, structure.ts spells a member access).
2618
+ // A write, an index, or any other use of the address is not modelled.
2619
+ const callee = temps[0].attrs.target as string;
2620
+ const type = temps[0].results[0].type;
2621
+ const spelling = type.kind === 'struct' ? (type.declared ?? `struct ${type.name}`) : typeToString(type);
2622
+ const at = `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}`;
2623
+ if (temps.some((t) => !typeEquals(t.results[0].type, type))) {
2624
+ fail(`the object at [sp,#${off}) is the struct-return storage of calls declared to return different types`);
2625
+ }
2626
+ if (acc.some((a) => !a.isLoad) || byIndex.length > 0) {
2627
+ fail(
2628
+ `${at}, and this function also ${byIndex.length > 0 ? 'indexes' : 'writes'} it — only a read of a ` +
2629
+ 'returned struct is modelled',
2630
+ );
2631
+ }
2632
+ if (uses.useCount.get(off) !== temps.length + acc.length) {
2633
+ fail(`${at}, and its address is also used another way — only the call it is returned by is modelled`);
2634
+ }
2635
+ const size = returnedSize(temps[0]);
2636
+ const past = uses.fixed.find(
2637
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
2638
+ );
2639
+ if (past !== undefined) {
2640
+ fail(`${at}, and this function reads [sp,#${past.at}) of width ${past.width}, past the struct`);
2641
+ }
2642
+ extent.set(off, { width: 1, count: returnedSize(temps[0]) });
2643
+ aggregateAt.set(off, type);
2644
+ continue;
2645
+ }
2646
+ if (acc.length === 0) {
2647
+ // An object with no access of its own has no declared type and no extent, and the two
2648
+ // ways it gets there are two different gaps. Its bytes may already be keyed by the slot
2649
+ // model, which one byte is enough to decide; otherwise nothing in-function pins it at
2650
+ // all, and a guessed declaration is the plausible-but-wrong class.
2651
+ if (slotKeys(usedSlotOffsets, off, 1)) {
2652
+ overSlot.push([off, 1]);
2653
+ continue;
2654
+ }
2655
+ const why = notTheWholeArea(off, byIndex.length > 0, {
2656
+ objects,
2657
+ uses,
2658
+ declared,
2659
+ returnsWithoutHiddenPointer,
2660
+ });
2661
+ if (why !== null) {
2662
+ model.shapeRefused(
2663
+ (byIndex.length > 0
2664
+ ? 'the captured address is addressed only through a runtime index'
2665
+ : 'the captured address is never dereferenced in this function') +
2666
+ `, so nothing pins the local object type — and ${why}`,
2667
+ );
2668
+ // …asked for the one object instead, which sizes no object of its own
2669
+ continue;
2670
+ }
2671
+ // An indexed access reads THROUGH A CAST on the storage declared below, at its own width
2672
+ // and signedness, as an access to the one object does (`keepAsOneObject`), so the accesses
2673
+ // wider than a byte must be of one width, one alias set (`indexedWidth`).
2674
+ const index = indexedWidth(byIndex);
2675
+ if (index.refusal !== null) {
2676
+ model.shapeRefused(`a runtime index into the object at [sp,#${off}) ${index.refusal}`);
2677
+ }
2678
+ // STORAGE, NOT A TYPE. The reservation says how many bytes the frame holds and the
2679
+ // conjuncts above say they are all this object's, so the declaration commits to an EXTENT
2680
+ // and to nothing else: `u8 name[n]` over the declared range, unsigned bytes because no
2681
+ // access named an element type and inventing one is the guess this refuses everywhere else.
2682
+ // An indexed access names only its own width, which its cast spells where it reads.
2683
+ //
2684
+ // THE EXTENT IS A ROUNDED ONE, stated because it is not a defect. agbcc reserves the
2685
+ // local area in whole words, so a source object of 13, 14, 15 or 16 bytes all reserve
2686
+ // sixteen — compiled and diffed at the row's own flags, `u8 x[0xD]` and `u8 x[0x10]`
2687
+ // reach the same object where `u8 x[0xC]` and `u8 x[0x11]` do not. What is declared is
2688
+ // the RESERVATION, which is the thing the asm carries; every source extent inside one
2689
+ // word of it emits the same object, so no member of that class is more right than this.
2690
+ //
2691
+ // NO COMPILER TERM, unlike `capturedObjectIsTheWholeFrame`, whose one-word reading is a
2692
+ // fact about what agbcc puts in a four-byte frame. This argument needs only that the
2693
+ // reservation ROUNDS UP to some granularity, which is a property of every stack ABI, and
2694
+ // it declares the reservation rather than a guess inside it — so a coarser rounding makes
2695
+ // the declared extent coarser too, never wrong about the bytes the machine reserved.
2696
+ extent.set(off, { width: 1, count: declared.to - declared.from });
2697
+ continue;
2698
+ }
2699
+ if (byIndex.length > 0) {
2700
+ model.shapeRefused(
2701
+ `a runtime index into the object at [sp,#${off}), which an access of its own types as one ` +
2702
+ 'scalar — only the untyped storage of the whole declared area is indexed',
2703
+ );
2704
+ }
2705
+ const widths = new Set(acc.map((a) => a.width));
2706
+ if (widths.size > 1) {
2707
+ model.shapeRefused(`the accesses through the captured address disagree on width (${[...widths].join(' vs ')})`);
2708
+ }
2709
+ // …and on SIGNEDNESS, over the loads, for the same reason: one declared type extends one
2710
+ // way, so an object read by both `ldrsb` and `ldrb` has no faithful declaration —
2711
+ // `sp4 - sp4` would fold to 0 where the machine computes sext(b) - zext(b). Loads only: a
2712
+ // store extends nothing, and `strb` beside `ldrsb` is not a disagreement.
2713
+ const signs = new Set(acc.filter((a) => a.isLoad).map((a) => a.signed));
2714
+ if (signs.size > 1) {
2715
+ model.shapeRefused(
2716
+ 'the loads through the captured address disagree on signedness — one declared type extends one way',
2717
+ );
2718
+ }
2719
+ // …and a scalar this function only READS is the callee's to fill, which is what a struct
2720
+ // return's hidden temp is: agbcc spells `s = mk(x)` above an outgoing block as `add r0, sp,
2721
+ // #0x4 / bl mk / ldr r0, [sp, #0x4]`, instruction for instruction an out-parameter call. The
2722
+ // question the whole-frame and untyped arms ask, asked of every object.
2723
+ const whyItStands = acc.some((a) => !a.isLoad)
2724
+ ? null
2725
+ : hiddenReturnPointerStands(off, uses.arg0Callees, returnsWithoutHiddenPointer);
2726
+ if (whyItStands !== null) {
2727
+ fail(`the object at [sp,#${off}) is never written here, and ${whyItStands}`);
2728
+ }
2729
+ extent.set(off, { width: acc[0].width, count: 1 });
2730
+ }
2731
+ // Each object must own its bytes outright: inside the reserved local area, clear of every SSA
2732
+ // slot (`overSlot`, refused last), and clear of every other object.
2733
+ const objs = [...extent].sort((x, y) => x[0] - y[0]);
2734
+ for (const [off, obj] of objs) {
2735
+ if (off < owned.from || off + span(obj) > owned.to) {
2736
+ fail(`the object at [sp,#${off}) of width ${span(obj)} lies outside the reserved local area`);
2737
+ }
2738
+ if (slotKeys(usedSlotOffsets, off, span(obj))) {
2739
+ overSlot.push([off, span(obj)]);
2740
+ }
2741
+ }
2742
+ for (let i = 1; i < objs.length; i++) {
2743
+ const [off, obj] = objs[i];
2744
+ const [prev, prevObj] = objs[i - 1];
2745
+ if (overlaps(prev, span(prevObj), off, span(obj))) {
2746
+ model.shapeRefused(`the objects at [sp,#${prev}) and [sp,#${off}) overlap — one byte, two models`);
2747
+ }
2748
+ }
2749
+ return { extent, aggregateAt, overSlot };
2750
+ }
2751
+
2752
+ /** THE FRAME RESERVATION IS AN EXTENT, when the declared local area is provably one object's alone.
2753
+ * `add sp, sp, #-0x10` reserves sixteen bytes; if exactly one address-taken object sits at the
2754
+ * bottom of the DECLARED part of them and the slot model keys none of that part (`overSlot`, which
2755
+ * `objectShapes` asks of every extent), then there is nothing else those bytes can be — a
2756
+ * compiler does not reserve frame for nothing. That is the frame-accounting equation the escape
2757
+ * rules (`FRAME_ESCAPE_GATES`) already solve word by word, asked in the other direction: they
2758
+ * check that the objects and slots TILE the reserved area, this reads the declared range off as
2759
+ * the one object's size.
2760
+ *
2761
+ * THE DECLARED RANGE, NOT THE OWNED ONE. Below `declared.from` lies the outgoing stack-argument
2762
+ * block `analyzeOutgoingArgs` licensed — arguments 5+ of this function's calls, which C stages
2763
+ * and never declares — so those bytes are not this object's, and the slots keyed there are those
2764
+ * arguments. The escape gates read the frame the same way: `reaches-a-slot` looks no lower than
2765
+ * `declared.from`, and `reaches-an-unaccounted-word` counts the argument words as accounted.
2766
+ * agbcc lays its locals out like this (ACCUMULATE_OUTGOING_ARGS 1, STARTING_FRAME_OFFSET 0,
2767
+ * thumb.h): `struct S t; f(&t, b, c, d, e, g, h, i)` with an 8-byte `S` reserves 0x18, stages
2768
+ * the four argument words at [sp,#0..#0xc] and puts `t` at [sp,#0x10]. The block is LICENSED by
2769
+ * construction: one not stored on every path is `analyzeOutgoingArgs`'s own blocker, which turns
2770
+ * the slot model off, and every `laddr` mint is behind `slotsOk`.
2771
+ *
2772
+ * Returns why it does not apply, or null. Every clause refuses in its own words: the reason an
2773
+ * acceptance did not fire is as much an attribution as the reason a lift declined, and one
2774
+ * sentence covering all of them is how several gaps come to look like one.
2775
+ *
2776
+ * FOUR CLAUSES BOUND THIS PATH — a second object, an object that does not start at the bottom of
2777
+ * the declared area, an address that reaches memory rather than a callee, and the callee's
2778
+ * declared return — and each has a test that fails without it. A fifth return, the precautionary
2779
+ * one marked where it sits, has none.
2780
+ *
2781
+ * The last two are about an ESCAPE (`wholeAreaEscapeRefusal`), and they are asked only of an
2782
+ * object that escapes or that nothing in this function addresses. An object this function indexes
2783
+ * and never lets go of is written and read by its own indexed accesses alone, so the reservation
2784
+ * is all there is to size it by: `u8 a[8]; for (j = 0; j < 8; j++) a[j] = tbl[j]; return a[i];`. */
2785
+ function notTheWholeArea(
2786
+ off: number,
2787
+ indexedHere: boolean,
2788
+ {
2789
+ objects,
2790
+ uses,
2791
+ declared,
2792
+ returnsWithoutHiddenPointer,
2793
+ }: {
2794
+ objects: ReadonlyMap<number, readonly Op[]>;
2795
+ uses: FrameUses;
2796
+ declared: FrameRange;
2797
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
2798
+ },
2799
+ ): string | null {
2800
+ if (objects.size !== 1) {
2801
+ return 'another address-taken object shares the frame, so the reservation is not this one alone';
2802
+ }
2803
+ if (off !== declared.from || declared.to <= declared.from) {
2804
+ return 'the object does not start at the bottom of the declared area, so something below it is unaccounted for';
2805
+ }
2806
+ // PRECAUTIONARY for an object nothing indexes: an address that neither accesses nor escapes
2807
+ // already declines where the audit classifies its uses ("flows into `ret`"), so it never arrives
2808
+ // here unescaped. Kept because storage declared with no writer is a SILENT wrong answer.
2809
+ if (!uses.escaped.has(off)) {
2810
+ return indexedHere
2811
+ ? null
2812
+ : 'the address never leaves this function, so there is no writer of the storage to size it for';
2813
+ }
2814
+ return wholeAreaEscapeRefusal(off, uses, returnsWithoutHiddenPointer);
2815
+ }
2816
+
2817
+ /** Why the escape of the address at `off` is not one the WHOLE-AREA argument holds, or null. That
2818
+ * argument — the object is every byte of the declared area, so whatever a holder of the address
2819
+ * does through it lands on bytes one declaration owns — is made twice: for one untyped object
2820
+ * (`notTheWholeArea`) and for the one object several captures are members of (`writerNotKept`).
2821
+ * It needs a callee to have been handed the address as an argument, the one holder this frontend
2822
+ * can name; an address that only reached memory has none. A store of the address beside that
2823
+ * call is held with it, whether to an ordinary global or into a device's source register — and
2824
+ * where the store hands the address to something that may write through it (`publishedToWriter`),
2825
+ * only when that callee may write through it too: a bounded block transfer reading its source
2826
+ * names no writer.
2827
+ *
2828
+ * The frame's SIZE changes nothing about whose storage it is, so the hidden-pointer question is
2829
+ * the one the one-word arm asks, asked here of the same callees. */
2830
+ function wholeAreaEscapeRefusal(
2831
+ off: number,
2832
+ uses: FrameUses,
2833
+ returnsWithoutHiddenPointer: (callee: string) => boolean,
2834
+ ): string | null {
2835
+ if (!uses.passedToCallee.has(off)) {
2836
+ return 'the address is published rather than passed as an argument, and nothing declares what reads it';
2837
+ }
2838
+ if (uses.publishedToWriter.has(off) && !uses.passedToWriter.has(off)) {
2839
+ return (
2840
+ 'the address is published, and the only callee handed it is a block transfer that reads through it, ' +
2841
+ 'so nothing declares what reads it'
2842
+ );
2843
+ }
2844
+ return hiddenReturnPointerStands(off, uses.arg0Callees, returnsWithoutHiddenPointer);
2845
+ }
2846
+
2847
+ /** Every escaped object as the rules an escape retracts read it (`FRAME_ESCAPE_GATES`), computed
2848
+ * ONCE, with whether the escape may write and how it left, so a new bound — a callee's declared
2849
+ * extent — has one place to go. */
2850
+ function frameEscapes({
2851
+ irBlocks,
2852
+ uses,
2853
+ windows,
2854
+ shapes: { extent },
2855
+ owned,
2856
+ declared,
2857
+ usedSlotOffsets,
2858
+ }: {
2859
+ irBlocks: readonly Block[];
2860
+ uses: FrameUses;
2861
+ windows: ReadonlyMap<number, ReadWindow>;
2862
+ shapes: ObjectShapes;
2863
+ owned: FrameRange;
2864
+ declared: FrameRange;
2865
+ usedSlotOffsets: ReadonlySet<number>;
2866
+ }): FrameEscape[] {
2867
+ const undefSlots = irBlocks
2868
+ .flatMap((blk) => blk.ops.filter((op) => op.opcode === 'undef'))
2869
+ .map((op) => slotKeyOffset(op.attrs.key as string))
2870
+ .filter((slot) => slot !== null)
2871
+ .sort((x, y) => x - y);
2872
+ const accountedWords = new Set<number>();
2873
+ for (const [off, obj] of extent) {
2874
+ for (let w = off - (off % 4); w < off + span(obj); w += 4) {
2875
+ accountedWords.add(w);
2876
+ }
2877
+ }
2878
+ for (const slot of usedSlotOffsets) {
2879
+ accountedWords.add(slot - (slot % 4));
2880
+ }
2881
+ const unaccountedIn = (lo: number, hi: number): number | undefined => {
2882
+ for (let w = owned.from; w < owned.to; w += 4) {
2883
+ if (!accountedWords.has(w) && w < hi && w + 4 > lo) {
2884
+ return w;
2885
+ }
2886
+ }
2887
+ return undefined;
2888
+ };
2889
+ const escapes: FrameEscape[] = [];
2890
+ for (const off of uses.escaped) {
2891
+ const { lo, hi, why } = windows.get(off)!;
2892
+ const writes = mayWrite(uses, off);
2893
+ const objectReached = [...extent].find(([o, obj]) => o !== off && o < off + hi && off + lo < o + span(obj));
2894
+ let slotReached: FrameEscape['slotReached'];
2895
+ for (const slot of usedSlotOffsets) {
2896
+ if (slot > off && slot < off + hi) {
2897
+ slotReached = { slot, above: true };
2898
+ break;
2899
+ }
2900
+ if (slot < off && slot >= declared.from && slot + 4 > off + lo) {
2901
+ slotReached = { slot, above: false };
2902
+ break;
2903
+ }
2904
+ }
2905
+ escapes.push({
2906
+ off,
2907
+ lo,
2908
+ hi,
2909
+ writes,
2910
+ how: uses.passedToCallee.has(off)
2911
+ ? 'is passed to a callee'
2912
+ : writes
2913
+ ? 'is stored to memory'
2914
+ : `is handed to a device ${why}`,
2915
+ objectReached: objectReached?.[0],
2916
+ slotReached,
2917
+ undefReached: undefSlots.find((slot) => slot < off + hi && slot + 4 > off + lo),
2918
+ unaccountedWord: unaccountedIn(off + lo, off + hi),
2919
+ });
2920
+ }
2921
+ return escapes;
2922
+ }
2923
+
2924
+ function escapeRefusal(id: string, e: FrameEscape): string {
2925
+ const at = `the captured address at [sp,#${e.off}) ${e.how}`;
2926
+ switch (id) {
2927
+ case 'reaches-another-object':
2928
+ return e.lo === -Infinity && e.hi === Infinity
2929
+ ? 'the captured address escapes, so something outside this function reaches the whole frame — including another object'
2930
+ : `${at}, which may read the object at [sp,#${e.objectReached})`;
2931
+ case 'writer-over-undef':
2932
+ return e.lo === -Infinity && e.hi === Infinity
2933
+ ? 'the captured address escapes, so a callee may write any frame offset and an unstored slot is not provably uninitialised'
2934
+ : `${at}, which may write the slot at [sp,#${e.undefReached}] — an unstored slot it reaches is not provably uninitialised`;
2935
+ case 'reaches-a-slot':
2936
+ return !e.slotReached!.above
2937
+ ? `${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`
2938
+ : e.writes
2939
+ ? `${at}, which may write the slot at [sp,#${e.slotReached!.slot}] — this function's own store there would be forwarded past the write`
2940
+ : `${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`;
2941
+ default:
2942
+ return (
2943
+ `the word at [sp,#${e.unaccountedWord}] is neither an object this lift models nor a slot it keys, ` +
2944
+ `and the captured address reaches something that may ${e.writes ? 'write' : 'read'} it — nothing accounts for the ` +
2945
+ "rest of the frame, so nothing bounds the captured object's extent"
2946
+ );
2947
+ }
2948
+ }
2949
+
2950
+ /** Proven. Stamp the MACHINE FACTS the audit established — width and signedness are what the
2951
+ * accesses used, so the declaration downstream is a fact, not a guess. The C-level NAME is
2952
+ * deliberately NOT chosen here: identifiers live in the structurer's namespace (params,
2953
+ * locals, globals, the symbol map), which the frontend cannot see — a frontend-chosen `sp0`
2954
+ * silently shadowed a project global of the same name.
2955
+ * `volatile` iff the address is PUBLISHED — written to memory, rather than handed to a
2956
+ * callee. That is the DMA idiom this rule was written for and it IS the source's own
2957
+ * spelling there: klonoa's `DMA_FILL` writes `vu##bit tmp` outright, sa3's does under
2958
+ * `PLATFORM_GBA`, pokeemerald's inside `DMA_FILL_UNCHECKED`, and the address goes to a device
2959
+ * register through a store. Reproducing that source means reproducing the qualifier.
2960
+ *
2961
+ * …and iff it is the FIXED source of a block transfer (`filledFrom`), the same idiom through a
2962
+ * BIOS call: every fill macro writes `vu##bit tmp = value; CpuSet((void *)&tmp, …)` (sa3's
2963
+ * cpuset_macros.h, pokeemerald's macro.h). Compiled, the plain spelling is not the same
2964
+ * program: a `u16` parameter stored to a plain `u16 tmp` loses its `lsl`/`lsr` pair, and the
2965
+ * pool loads around the call reorder.
2966
+ *
2967
+ * NOT on an ordinary `&local` ARGUMENT, where no source in the corpus writes one and the
2968
+ * qualifier is not free. `void f(u32 i){ s32 w; w = gEnts[i].h; use(&w); four(w,w,w,w); }`
2969
+ * compiles to one `ldr` reloaded into four registers by copies; the structurer emits one C
2970
+ * read per USE rather than per machine load, so `volatile` forbids the CSE and makes it four
2971
+ * `ldr`s — a byte-exact candidate turned into a four-instruction nonmatch (compiled, agbcc
2972
+ * 2.9-arm-000512, `-O2 -mthumb-interwork -Wimplicit -fhex-asm -fprologue-bugfix`). It is free
2973
+ * only where the object is read at most once, which is all the rows that first shipped it
2974
+ * did. agbcc also warns `discards qualifiers` at every such call.
2975
+ *
2976
+ * NOT because gcc would otherwise delete the store. That claim was here for several releases
2977
+ * and does not reproduce: taking `&tmp` makes the local addressable, so gcc-2.9 keeps the
2978
+ * store with or without the qualifier, measured on store-then-escape, publish-then-fill, and
2979
+ * a loop that stores and escapes each iteration. What the qualifier does change is register
2980
+ * ALLOCATION — the same function compiled `vu16` and `u16` is 98 instructions either way and
2981
+ * differs in three register assignments — which is why it still has to be right. asmlift's
2982
+ * OWN dead-store pass keys on address-taken, not on the qualifier (l3/dce.ts), so dropping the
2983
+ * qualifier here cannot cost a store.
2984
+ *
2985
+ * An object whose address never leaves the function needs no volatile and must not pay it.
2986
+ *
2987
+ * AN UNTYPED OBJECT REACHES THIS RULE TOO, where the address is both published and handed to
2988
+ * a callee whose return is declared. It cannot pay the price above — that price is a read the
2989
+ * compiler may no longer fold, and an object with no access in this function has none —
2990
+ * compiled at the corpus's flags, the qualified and plain spellings are byte-identical and
2991
+ * differ in two `discards qualifiers` warnings. So the rule is the same rule, and the reason
2992
+ * it is free here is not the reason it is free on a scalar read once. */
2993
+ function stampObjects(
2994
+ objects: ReadonlyMap<number, readonly Op[]>,
2995
+ { extent, aggregateAt }: ObjectShapes,
2996
+ uses: FrameUses,
2997
+ ): void {
2998
+ for (const [off, ops] of objects) {
2999
+ const { width, count } = extent.get(off)!;
3000
+ const aggregate = aggregateAt.get(off);
3001
+ // a declared struct's storage is bytes, whatever signedness its members are read at
3002
+ const signed = aggregate === undefined && uses.accesses.get(off)!.some((a) => a.signed);
3003
+ for (const op of ops) {
3004
+ const qualified = uses.published.has(off) || uses.filledFrom.has(off);
3005
+ op.attrs = {
3006
+ ...op.attrs,
3007
+ width,
3008
+ signed,
3009
+ count,
3010
+ ...(qualified ? { volatile: true } : {}),
3011
+ ...(aggregate !== undefined ? { aggregate: true } : {}),
3012
+ };
3013
+ // a return temp's address points at the struct the call returns
3014
+ if (aggregate !== undefined) {
3015
+ op.results[0].type = T.ptr(aggregate);
3016
+ }
3017
+ }
3018
+ }
3019
+ // The declaration carries the qualifier to an access spelled through the object's own name, and
3020
+ // not to one through a phi that carries its address: that phi is a pointer local typed from the
3021
+ // access, so `p = c ? &b : gOther; while (*p == 0);` would compile to a loop that never reads
3022
+ // `b` again. Such an access carries the qualifier itself.
3023
+ for (const [op, { off, through }] of uses.accessOps) {
3024
+ const own = objects.get(off)!.some((o) => o.results[0] === through);
3025
+ if (!own && (uses.published.has(off) || uses.filledFrom.has(off))) {
3026
+ op.attrs = { ...op.attrs, volatile: true };
3027
+ }
3028
+ }
3029
+ }
3030
+
3031
+ function gcd(a: number, b: number): number {
3032
+ return b === 0 ? Math.abs(a) : gcd(b, a % b);
3033
+ }
3034
+
3035
+ /** The size of the struct a call stamped `sret` returns through its argument 0 (frontend/call-plan.ts
3036
+ * types the call's result as that struct, for every frontend whose lowering passes the pointer). */
3037
+ function returnedSize(call: Op): number {
3038
+ const t = call.results[0].type;
3039
+ if (t.kind !== 'struct' || t.size === undefined) {
3040
+ throw new Error(`a call stamped sret returns ${typeToString(t)}, not a laid-out struct`);
3041
+ }
3042
+ return t.size;
3043
+ }
3044
+
3045
+ /** The block parameters every incoming value of which is a local's address or another such
3046
+ * parameter: the greatest set closed under that, so a loop-carried address is one too. Each maps to
3047
+ * the frame offsets its incoming addresses name, through such parameters. The entry block's are the
3048
+ * function's parameters, and are left out. */
3049
+ function mergedFrameAddresses(irBlocks: readonly Block[]): Map<Value, ReadonlySet<number>> {
3050
+ const laddrOff = new Map<Value, number>();
3051
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3052
+ if (op.opcode === 'laddr') {
3053
+ laddrOff.set(op.results[0], op.attrs.off as number);
3054
+ }
3055
+ }
3056
+ const incoming = new Map<Value, Value[]>();
3057
+ for (const b of irBlocks.slice(1)) {
3058
+ for (const p of b.params) {
3059
+ incoming.set(p, []);
3060
+ }
3061
+ }
3062
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3063
+ for (const succ of op.successors) {
3064
+ succ.block.params.forEach((p, i) => incoming.get(p)?.push(succ.args[i]));
3065
+ }
3066
+ }
3067
+ const merged = new Set([...incoming].filter(([, vs]) => vs.length > 0).map(([p]) => p));
3068
+ for (let changed = true; changed;) {
3069
+ changed = false;
3070
+ for (const p of merged) {
3071
+ if (incoming.get(p)!.some((v) => !laddrOff.has(v) && !merged.has(v))) {
3072
+ merged.delete(p);
3073
+ changed = true;
3074
+ }
3075
+ }
3076
+ }
3077
+ const out = new Map<Value, ReadonlySet<number>>();
3078
+ for (const p of merged) {
3079
+ const offs = new Set<number>();
3080
+ const seen = new Set<Value>([p]);
3081
+ const work = [p];
3082
+ while (work.length > 0) {
3083
+ for (const v of incoming.get(work.pop()!)!) {
3084
+ if (laddrOff.has(v)) {
3085
+ offs.add(laddrOff.get(v)!);
3086
+ } else if (!seen.has(v)) {
3087
+ seen.add(v);
3088
+ work.push(v);
3089
+ }
3090
+ }
3091
+ }
3092
+ out.set(p, offs);
3093
+ }
3094
+ return out;
3095
+ }
3096
+
3097
+ /** A STRUCT RETURNED THROUGH A MERGE OF ONE LOCAL'S ADDRESS IS THAT LOCAL. gcc rotates a loop that
3098
+ * calls a struct-returning function, `while (lo > 0) { qr = ldiv(lo, 10); … }`, so `addiu a0,sp,16`
3099
+ * runs once ahead of the loop and once in the delay slot of its back branch, and hoists it into
3100
+ * both arms of `if (x) a = f(x); else a = f(x + 1);` ahead of one shared `jal`. The call then
3101
+ * reads a block parameter, but every path hands it the same frame address, so the call is handed
3102
+ * that address: an `laddr` of it, minted ahead of the call, which the frame-object audit types as
3103
+ * the struct (`declaredStructs`) and `refuseSretOutsideFrame` accepts. A merge of two offsets is
3104
+ * two objects, and is left for the audit to refuse. Run before the audit, on every frontend whose call
3105
+ * lowering passes the hidden pointer. */
3106
+ export function nameMergedSretObjects(irBlocks: Block[]): void {
3107
+ const merged = mergedFrameAddresses(irBlocks);
3108
+ for (const b of irBlocks) {
3109
+ b.ops = b.ops.flatMap((op) => {
3110
+ const offs = op.opcode === 'call' && op.attrs.sret === true ? merged.get(op.operands[0]) : undefined;
3111
+ if (offs === undefined || offs.size !== 1) {
3112
+ return [op];
3113
+ }
3114
+ const at = mkValue(T.unk(32));
3115
+ op.operands = [at, ...op.operands.slice(1)];
3116
+ return [mkOp('laddr', { results: [at], attrs: { off: [...offs][0] } }), op];
3117
+ });
3118
+ }
3119
+ }
3120
+
3121
+ /** A STRUCT RETURNED THROUGH MEMORY IS SPELLED `local = f(..)`, which hands the callee that local's
3122
+ * address: the hidden pointer must be a frame object the audit accepted. A global or a pointer
3123
+ * handed over directly — `gS = f(..)` (agbcc, ido, gcc), IDO's `*p = f(..)`, and the caller's own
3124
+ * hidden pointer forwarded by `return f(..)` — is another spelling, which nothing here states. A
3125
+ * merge of frame addresses never reaches here: one that names one address on every path was handed
3126
+ * that address's `laddr` before the audit (`nameMergedSretObjects`), and a merge of two names no one
3127
+ * storage, which the audit refuses first (`addressFlow`: `a phi merges the frame objects`). Asked
3128
+ * of every frontend whose call lowering passes the hidden pointer
3129
+ * (`CallLowering.memoryReturn`). */
3130
+ export function refuseSretOutsideFrame(name: string, irBlocks: readonly Block[], target: TargetDescription): void {
3131
+ const laddrs = new Set(
3132
+ irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'laddr').map((op) => op.results[0])),
3133
+ );
3134
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3135
+ if (op.opcode === 'call' && op.attrs.sret === true && !laddrs.has(op.operands[0])) {
3136
+ throw new FrontendUnsupportedError(
3137
+ `cannot lift '${name}': \`${op.attrs.target as string}\` returns struct ${typeToString(op.results[0].type)} through the pointer in ${target.argRegs[0]}, ` +
3138
+ 'and that pointer is not the address of a local of this frame — a struct returned into a global or ' +
3139
+ 'through a pointer is not modelled',
3140
+ );
3141
+ }
3142
+ }
3143
+ }
3144
+
3145
+ /** THE CALLER-SIDE SEAM. A frontend calls the audit THROUGH this record rather than through the
3146
+ * `auditFrameObjects` binding, so a process outside core can put a wrapped `gates` table in front of a real
3147
+ * lift (`pnpm bench gates --pass frame-objects`): a module-namespace binding is read-only and
3148
+ * cannot be swapped (`apps/benchmark/src/run/gate-census.ts`, WHAT PUTS A PASS IN THE REGISTRY). */
3149
+ export const FRAME_OBJECT_AUDIT: { run: (audit: FrameObjectAudit) => FrameObjectVerdict } = {
3150
+ run: (audit) => auditFrameObjects(audit),
3151
+ };
3152
+
3153
+ /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
3154
+ * every source path under `./*`, so a plain `export` here would put the audit's stages into the
3155
+ * package's public API, where no consumer should be able to depend on them. The name says what it
3156
+ * is; nothing in `src` reads it. */
3157
+ export const __testing = {
3158
+ frameRefusal,
3159
+ foldMovedCaptures,
3160
+ frameAddressFacts,
3161
+ splitAddressingCopies,
3162
+ frameObjects,
3163
+ addressFlow,
3164
+ classifyFrameUses,
3165
+ opPositions,
3166
+ escapeWindows,
3167
+ readWindow,
3168
+ chooseFrameModel,
3169
+ objectShapes,
3170
+ frameEscapes,
3171
+ };