@asmlift/core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +25 -24
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +149 -0
  4. package/src/backend/c.ts +2 -0
  5. package/src/backend/cfamily.ts +197 -61
  6. package/src/backend/cpp.ts +3 -1
  7. package/src/backend/lines.ts +10 -0
  8. package/src/backend/pascal.ts +47 -16
  9. package/src/cdecl/parse.ts +21 -0
  10. package/src/contracts.ts +246 -36
  11. package/src/declare.ts +69 -20
  12. package/src/frontend/arm-body.ts +155 -0
  13. package/src/frontend/asmdata.ts +9 -2
  14. package/src/frontend/call-plan.ts +1117 -61
  15. package/src/frontend/callee-body.ts +370 -0
  16. package/src/frontend/dead-reads.ts +628 -0
  17. package/src/frontend/device-pins.ts +33 -4
  18. package/src/frontend/emit.ts +4 -3
  19. package/src/frontend/fpu.ts +224 -17
  20. package/src/frontend/frame-objects.ts +1689 -206
  21. package/src/frontend/frontend.ts +84 -3
  22. package/src/frontend/high-half.ts +53 -4
  23. package/src/frontend/jump-table.ts +545 -0
  24. package/src/frontend/local-object.ts +158 -32
  25. package/src/frontend/mips.ts +2296 -217
  26. package/src/frontend/own-struct-return.ts +500 -0
  27. package/src/frontend/ppc.ts +2748 -245
  28. package/src/frontend/register-struct-call.ts +399 -0
  29. package/src/frontend/reloc-symbol.ts +22 -7
  30. package/src/frontend/ssa.ts +1721 -43
  31. package/src/frontend/stackargs.ts +199 -72
  32. package/src/frontend/thumb-entry.ts +340 -0
  33. package/src/frontend/thumb.ts +2400 -325
  34. package/src/ir/alias.ts +65 -2
  35. package/src/ir/bits.ts +1699 -4
  36. package/src/ir/core.ts +254 -9
  37. package/src/ir/discipline.ts +86 -21
  38. package/src/ir/entry.ts +157 -0
  39. package/src/{structure → ir}/loops.ts +6 -2
  40. package/src/ir/opcodes.ts +137 -9
  41. package/src/ir/parse.ts +1 -0
  42. package/src/ir/simplify.ts +61 -10
  43. package/src/ir/types.ts +86 -2
  44. package/src/ir/verify.ts +96 -11
  45. package/src/l3/argbase.ts +2 -0
  46. package/src/l3/ast.ts +582 -18
  47. package/src/l3/basecse.ts +68 -15
  48. package/src/l3/caseendgoto.ts +223 -0
  49. package/src/l3/coalesce.ts +42 -14
  50. package/src/l3/constinit.ts +410 -0
  51. package/src/l3/dce.ts +53 -9
  52. package/src/l3/initfirst.ts +42 -11
  53. package/src/l3/intcell.ts +2 -1
  54. package/src/l3/narrowdecl.ts +77 -36
  55. package/src/l3/pollguard.ts +8 -2
  56. package/src/l3/ptrcell.ts +33 -34
  57. package/src/l3/ptrfield.ts +123 -8
  58. package/src/l3/regspell.ts +11 -4
  59. package/src/l3/reindex.ts +295 -99
  60. package/src/l3/scopebase.ts +2 -0
  61. package/src/l3/storage.ts +98 -1
  62. package/src/l3/symbol-refs.ts +84 -6
  63. package/src/l3/tailmerge.ts +6 -2
  64. package/src/l3/typing.ts +108 -15
  65. package/src/l3/unreduce.ts +6 -5
  66. package/src/l3/volatileptr.ts +17 -54
  67. package/src/mangle.ts +30 -0
  68. package/src/pattern/engine.ts +361 -73
  69. package/src/pipeline.ts +1039 -38
  70. package/src/proto-context.ts +1039 -45
  71. package/src/proto.ts +555 -40
  72. package/src/raise/aggmembers.ts +290 -0
  73. package/src/raise/arrays.ts +5 -1
  74. package/src/raise/const.ts +25 -0
  75. package/src/raise/declared-volatile.ts +207 -31
  76. package/src/raise/divpow2.ts +192 -0
  77. package/src/raise/floathelpers.ts +254 -33
  78. package/src/raise/gvn.ts +3 -3
  79. package/src/raise/latch.ts +162 -6
  80. package/src/raise/memberarrays.ts +10 -4
  81. package/src/raise/offsetnames.ts +422 -36
  82. package/src/raise/pairparams.ts +33 -0
  83. package/src/raise/paramwidth.ts +196 -6
  84. package/src/raise/pre-recovery.ts +107 -21
  85. package/src/raise/pre-structure.ts +73 -0
  86. package/src/raise/recover.ts +12 -5
  87. package/src/raise/reroll.ts +674 -0
  88. package/src/raise/shortcircuit.ts +217 -34
  89. package/src/raise/softdiv.ts +1 -1
  90. package/src/raise/storesign.ts +153 -0
  91. package/src/raise/struct-arrays.ts +30 -11
  92. package/src/raise/structs.ts +396 -33
  93. package/src/raise/truncload.ts +4 -3
  94. package/src/raise/unpre.ts +594 -0
  95. package/src/raise/unthread.ts +400 -0
  96. package/src/rank-declare.ts +118 -12
  97. package/src/rank-variations.ts +186 -35
  98. package/src/rank.ts +536 -68
  99. package/src/runtime-helpers.ts +140 -22
  100. package/src/structure/analysis.ts +217 -8
  101. package/src/structure/arith-ops.ts +23 -0
  102. package/src/structure/cross-jumped-tail.ts +216 -0
  103. package/src/structure/globalaccess.ts +157 -10
  104. package/src/structure/hazards.ts +16 -64
  105. package/src/structure/local-statics.ts +145 -9
  106. package/src/structure/namecoalesce.ts +1 -1
  107. package/src/structure/pointer-spelling.ts +1106 -0
  108. package/src/structure/redundant-test.ts +1 -1
  109. package/src/structure/structure.ts +6192 -1213
  110. package/src/structure/switch-recover.ts +583 -120
  111. package/src/structure/unfolded-branch-constant.ts +90 -0
  112. package/src/symbols.ts +58 -15
  113. package/src/target.ts +1028 -54
  114. package/src/trace.ts +49 -17
  115. package/src/variation-definitions.ts +333 -20
  116. package/src/variation-gates.ts +4 -0
  117. package/src/variation-tokens.ts +68 -3
@@ -9,11 +9,19 @@
9
9
  * every ISA — the level-tower split for this frame model: the frontend supplies the PARTITION,
10
10
  * the shared pass applies the rule. Messages name the frame base `sp`.
11
11
  *
12
- * Thumb is the only caller today; the worked examples below are agbcc's, because agbcc is the
13
- * compiler every rule was measured against. */
14
- import { type Block, type Op, type Value, mkOp, mkValue } from '../ir/core';
15
- import { type IrType, T, typeEquals, typeToString } from '../ir/types';
16
- import type { Gate } from '../l3/gates';
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 { type StridedRange, 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, firstRejection } from '../l3/gates';
24
+ import { blockCopyRoles } from '../proto';
17
25
  import type { SymbolMap } from '../symbols';
18
26
  import { type TargetDescription, blockTransferRead, sourceControlRead, sourceReach } from '../target';
19
27
  import type { CallDeclarations } from './call-plan';
@@ -31,9 +39,25 @@ export interface FrameObjectAudit {
31
39
  * stack-argument block staged at its bottom, which is where an untyped object claims to start */
32
40
  declaredLocals: NonNullable<LiveInModel['declaredLocals']>;
33
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 };
34
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>;
35
54
  /** every capture the add arm re-minted at a constant offset from it */
36
55
  movedCaptures: ReadonlySet<Value>;
56
+ /** the `laddr`s the frontend minted for a DIRECT frame access (`off(sp)` inside its local area)
57
+ * rather than for a captured address: a word reached only through them is one its slot model
58
+ * lifts again outside a relift's bytes (`reliftRange`). Thumb, which lifts a direct word access
59
+ * as a slot, has none. */
60
+ directAccesses?: ReadonlySet<Value>;
37
61
  /** whether a callee's declaration rules out a struct returned through a hidden pointer at
38
62
  * argument 0 (aggregate.ts `returnsWithoutHiddenPointer`), asked of the frontend's call
39
63
  * declarations */
@@ -43,6 +67,17 @@ export interface FrameObjectAudit {
43
67
  /** the retraction rules an escape is judged by; `FRAME_ESCAPE_GATES` when absent, and a
44
68
  * census or an ablation hands in its own (`FRAME_OBJECT_AUDIT`) */
45
69
  gates?: readonly Gate<FrameEscape>[];
70
+ /** the rules that keep a frame's end word in the one object (`reliftRange`);
71
+ * `RELIFT_HAND_BACK_GATES` when absent, and a census or an ablation hands in its own */
72
+ reliftGates?: readonly Gate<HandBack>[];
73
+ /** THE ARGUMENT HOMES THE FRONTEND KEPT FROM THE SLOT MODEL FOR THE ONE-OBJECT ANSWER ALONE: frame
74
+ * words an incoming argument register is stored to on entry, whose narrow reloads the frontend
75
+ * left as captures rather than reading them as the word narrowed, because a by-value struct
76
+ * parameter's home recompiles as that one object (Thumb `argumentHomes`). Where that answer is
77
+ * withheld and the audit refuses, it asks for a relift with them released
78
+ * (`FrameObjectRelift.homesReleased`) rather than refusing, and a relift that declines reports
79
+ * this refusal first. Absent or empty where none was kept. */
80
+ releasableHomes?: ReadonlySet<number>;
46
81
  /** the bytes a first audit asked to have kept as ONE object in memory (`FrameObjectRelift`):
47
82
  * the frontend routes every access inside them through an `laddr`, and the audit declares
48
83
  * them one object rather than judging each offset on its own */
@@ -54,12 +89,113 @@ export interface FrameRange {
54
89
  readonly to: number;
55
90
  }
56
91
 
57
- /** What an audit answers instead of a refusal when a device may read the frame, or a callee write
58
- * it, without bound: lift again with `oneObject` set to these bytes. */
92
+ /** THE LOCAL AREA ABOVE A FRAME'S OUTGOING AREA, `[from, to)` in post-push stack-pointer offsets:
93
+ * from where the outgoing words end (`outgoingEnd`, rounded up to `align`) to the lowest register
94
+ * save at or above that, or the frame's top. The rule O32's `above-outgoing-area` compilers
95
+ * (frontend/mips.ts `o32LocalArea`) and mwcc (frontend/ppc.ts, `compilerBehaviors.linkageLocalArea`)
96
+ * both keep; each frontend measures the numbers it is applied to. Null where nothing is left. */
97
+ export function areaAboveOutgoing({
98
+ frameSize,
99
+ outgoingEnd,
100
+ saves,
101
+ align,
102
+ }: {
103
+ frameSize: number;
104
+ outgoingEnd: number;
105
+ saves: readonly number[];
106
+ align: number;
107
+ }): FrameRange | null {
108
+ const from = Math.ceil(outgoingEnd / align) * align;
109
+ const area = { from, to: Math.min(frameSize, ...saves.filter((o) => o >= from)) };
110
+ return area.from < area.to ? area : null;
111
+ }
112
+
113
+ /** Whether a `width`-byte frame access at post-push offset `at` lands on a byte of `area`. A
114
+ * frontend that routes accesses into the area by their post-push offset asks it of every access
115
+ * made at ANOTHER depth, which that routing would misname, and draws no area where one does. */
116
+ export const landsOnArea = (area: FrameRange, at: number, width: number): boolean =>
117
+ at < area.to && at + width > area.from;
118
+
119
+ /** Whether a `width`-byte access at `at` inside a frontend's local area is MEMORY in a relift that
120
+ * keeps `kept` as one object: inside it, yes; wholly outside, no — a word the first audit handed
121
+ * back to the slot model (`reliftRange`); across its edge, refused, half memory and half slot. */
122
+ export function keptInRelift(kept: FrameRange, at: number, width: number, what: () => string, name: string): boolean {
123
+ if (at >= kept.from && at + width <= kept.to) {
124
+ return true;
125
+ }
126
+ if (at >= kept.to || at + width <= kept.from) {
127
+ return false;
128
+ }
129
+ throw new FrontendUnsupportedError(
130
+ `cannot lift '${name}': address-taken stack local — ${what()} reaches across an edge of the bytes ` +
131
+ `[sp,#${kept.from}) to [sp,#${kept.to}) kept as one object — part memory, part slot`,
132
+ );
133
+ }
134
+
135
+ /** What an audit answers instead of a refusal. Where a device may read the frame, or a callee write
136
+ * it, without bound: lift again with `oneObject` set to these bytes. Where the one-object answer is
137
+ * withheld and the audit refuses a frame with an argument home the frontend kept out of the slot
138
+ * model for that answer alone (`FrameObjectAudit.releasableHomes`): lift again with `oneObject`
139
+ * unset and `homesReleased`, so the frontend reads those homes as the slots they are. */
59
140
  export interface FrameObjectRelift {
60
- readonly oneObject: FrameRange;
141
+ readonly oneObject: FrameRange | undefined;
142
+ /** with `homesReleased`, the first audit's refusal, which a relift that declines again reports
143
+ * first: the frame as the first lift read it, followed by the second lift's own refusal where it
144
+ * differs (`bothRefusals`) */
145
+ readonly homesReleased?: { readonly refusal: FrontendUnsupportedError };
146
+ }
147
+
148
+ /** THE RELIFT PROTOCOL, AT MOST TWICE, the one copy every frontend that runs the audit calls: lift,
149
+ * and where the audit answers with a relift (`FrameObjectRelift`), lift once more as it asks —
150
+ * bytes to keep as one object routed through an `laddr`, or argument homes released to the slot
151
+ * model. The second audit judges the frame as asked and refuses rather than asking again, so a
152
+ * second request is an internal error, not a third lift. A second lift with homes released that
153
+ * declines reports the first audit's refusal (`homesReleased.refusal`), then its own where that
154
+ * differs. A frontend that hands the audit no `releasableHomes` is never asked to release one. */
155
+ export function liftWithRelift<F>(
156
+ name: string,
157
+ liftOnce: (oneObject: FrameRange | undefined, homesReleased: boolean) => F | FrameObjectRelift,
158
+ ): F {
159
+ const first = liftOnce(undefined, false);
160
+ if (!isRelift(first)) {
161
+ return first;
162
+ }
163
+ const released = first.homesReleased;
164
+ let again: F | FrameObjectRelift;
165
+ try {
166
+ again = liftOnce(first.oneObject, released !== undefined);
167
+ } catch (e) {
168
+ if (released !== undefined && e instanceof FrontendUnsupportedError) {
169
+ throw bothRefusals(name, released.refusal, e);
170
+ }
171
+ throw e;
172
+ }
173
+ if (isRelift(again)) {
174
+ throw new Error(`internal: the frame-object audit of '${name}' asked for a second relift`);
175
+ }
176
+ return again;
61
177
  }
62
178
 
179
+ /** A released relift's decline, worded as both lifts read the frame: the first audit's refusal of
180
+ * the frame with the homes held, then the second's with them released, which may name another
181
+ * obstacle (an escape the first refusal stopped short of). The first leads, so a message that
182
+ * only the first lift's refusal explains reads as it would with no release; the second is
183
+ * appended only where it says something else. */
184
+ function bothRefusals(
185
+ name: string,
186
+ first: FrontendUnsupportedError,
187
+ second: FrontendUnsupportedError,
188
+ ): FrontendUnsupportedError {
189
+ const lead = `cannot lift '${name}': `;
190
+ const reason = second.message.startsWith(lead) ? second.message.slice(lead.length) : second.message;
191
+ return first.message.endsWith(reason)
192
+ ? first
193
+ : new FrontendUnsupportedError(`${first.message}; with the argument home released: ${reason}`);
194
+ }
195
+
196
+ const isRelift = <F>(x: F | FrameObjectRelift): x is FrameObjectRelift =>
197
+ typeof x === 'object' && x !== null && 'oneObject' in x;
198
+
63
199
  /** What an audit answers: lift again (`FrameObjectRelift`), the device pin the accepted function
64
200
  * owes (`DevicePins`, applied by `pinDeviceAccesses`), or nothing, when no frame address was
65
201
  * taken. */
@@ -247,13 +383,20 @@ export const FRAME_ESCAPE_GATES: readonly Gate<FrameEscape>[] = [
247
383
  * anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — and it is
248
384
  * judged in one of two models, chosen before any shape is:
249
385
  * - PER OBJECT, the default: every MEMORY access through it must be at offset 0, with one agreed
250
- * width and one agreed extension, or a byte read or written through a runtime index into
251
- * storage nothing else types, and its bytes must belong to nothing else in the frame;
386
+ * width and one agreed extension, or a read or write through a runtime index into storage
387
+ * nothing else types — bytes, or ONE wider width, read through a cast on the bytes
388
+ * (`indexedWidth`) — and its bytes must belong to nothing else in the frame;
252
389
  * - ONE OBJECT, where a device reads or a callee writes without bound and every writer is one
253
- * that answer can hold (`writerNotKept`; `oneObject`, which this answers and the frontend
254
- * lifts again with): the local area is one `u8` array in memory, every fixed-offset access in
255
- * it is a member at its own offset, a runtime index may reach a byte of it, and two widths at
256
- * one byte refuse.
390
+ * that answer can hold (`writerNotKept`), or where no address escapes at all and the
391
+ * per-object model refuses (`oneObject`, which this answers and the frontend lifts again
392
+ * with): the local area is one `u8` array in memory, every fixed-offset access in it is a
393
+ * member at its own offset, a runtime index may reach it at a byte or at ONE wider
394
+ * width (`indexedWidth`), and two widths at one byte are the views of one union
395
+ * (`unionViews`), or refuse where no view names them (`unionRefusal`).
396
+ * In either model, a store through a capture that reaches a call unread on every path, beneath
397
+ * every word a load proves a local's, declines: it may be that call's stack argument
398
+ * (`storedNeverRead`). Which load proves it is the compiler's premise: one after a call under
399
+ * `localsAboveOutgoingArea`, and any load from the offset up under `outgoingAreaNeverLoadedFrom`.
257
400
  * Any use the audit cannot vouch for declines the whole function loudly. Nothing here guesses: a
258
401
  * scalar's declared type is exactly the access type the machine used, and an object NO access
259
402
  * reaches is sized by the frame reservation and left untyped.
@@ -273,41 +416,92 @@ export const FRAME_ESCAPE_GATES: readonly Gate<FrameEscape>[] = [
273
416
  * the fold, the split, the one-object answer and the stamp; `foldedHere`, which the split adds to
274
417
  * and the slot-overlap refusal reads; and `ModelChoice.refused`, which the model choice and the
275
418
  * shape stage set and the relift test reads. */
276
- export function auditFrameObjects({
419
+ export function auditFrameObjects(audit: FrameObjectAudit): FrameObjectVerdict {
420
+ // AN ARGUMENT HOME HELD FOR AN ANSWER THAT DID NOT COME. The frontend keeps a by-value struct
421
+ // parameter's home out of the slot model (`releasableHomes`) so the one-object answer can take it
422
+ // whole. Where this audit declines instead, that answer withheld (an address escapes to a writer
423
+ // it cannot hold, such as a struct-return temp), the decline becomes a relift with the homes
424
+ // released. The hold is for byte-exactness, not soundness: a home's narrow reload read as the
425
+ // word narrowed is the value the entry stored, and the second audit judges the frame whole, so
426
+ // the release is value-sound whichever refusal fired. Asked of the first lift alone: a relift
427
+ // that keeps one object has already had the answer the homes were held for.
428
+ const { oneObject, releasableHomes } = audit;
429
+ if (oneObject !== undefined || releasableHomes === undefined || releasableHomes.size === 0) {
430
+ return auditFrame(audit);
431
+ }
432
+ try {
433
+ return auditFrame(audit);
434
+ } catch (e) {
435
+ if (e instanceof FrontendUnsupportedError) {
436
+ return { oneObject: undefined, homesReleased: { refusal: e } };
437
+ }
438
+ throw e;
439
+ }
440
+ }
441
+
442
+ function auditFrame({
277
443
  name,
278
444
  irBlocks,
279
445
  ownedLocals: owned,
280
446
  declaredLocals: declared,
281
447
  usedSlotOffsets,
448
+ loadedWords,
282
449
  capturedObjectIsTheWholeFrame,
450
+ escapesReliedOn = new Set(),
283
451
  movedCaptures,
452
+ directAccesses = new Set(),
284
453
  returnsWithoutHiddenPointer,
285
454
  symbols,
286
455
  target,
287
456
  gates = FRAME_ESCAPE_GATES,
457
+ reliftGates = RELIFT_HAND_BACK_GATES,
288
458
  oneObject,
289
459
  }: FrameObjectAudit): FrameObjectVerdict {
290
460
  const { laddrs: captures, foldedHere } = foldMovedCaptures(irBlocks, movedCaptures);
291
461
  // …and the audit runs for a licensed acceptance with no object at all, so the premise re-check
292
462
  // is total rather than resting on "the capture always survives into the IR".
293
- if (captures.length === 0 && !capturedObjectIsTheWholeFrame) {
463
+ if (captures.length === 0 && !capturedObjectIsTheWholeFrame && escapesReliedOn.size === 0) {
294
464
  return undefined;
295
465
  }
296
466
  const fail = frameRefusal(name);
467
+ if (captures.length === 0 && escapesReliedOn.size > 0) {
468
+ recheckEscapesReliedOn(escapesReliedOn, undefined, fail);
469
+ }
297
470
  const facts = frameAddressFacts(irBlocks, symbols, target);
298
- const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere);
471
+ const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere, declaredStructs(irBlocks));
299
472
  const objects = frameObjects(laddrs, owned, fail);
300
473
  const flow = addressFlow(irBlocks, objects, facts, fail);
301
- const uses = classifyFrameUses({ irBlocks, objects, flow, facts, splitRefusal, oneObject, target, fail });
474
+ const uses = classifyFrameUses({
475
+ irBlocks,
476
+ objects,
477
+ flow,
478
+ facts,
479
+ splitRefusal,
480
+ oneObject,
481
+ target,
482
+ fail,
483
+ });
302
484
  const windows = escapeWindows(irBlocks, uses, flow, facts, target);
303
- const model = chooseFrameModel(oneObject, uses, windows, returnsWithoutHiddenPointer, fail);
485
+ const model = chooseFrameModel({ oneObject, uses, windows, returnsWithoutHiddenPointer, fail });
304
486
  if (capturedObjectIsTheWholeFrame) {
305
487
  recheckWholeFramePremise({ uses, returnsWithoutHiddenPointer, fail });
306
488
  }
489
+ recheckEscapesReliedOn(escapesReliedOn, uses, fail);
490
+ storedNeverRead(irBlocks, uses, loadedWords, target, fail);
307
491
  // The device pin's trigger: the `readOnlyAddressSinks` registers a frame address was stored to.
308
492
  const sinks = [...new Set([...uses.sourceStores.values()].flat().map((s) => s.sink))];
309
493
  if (oneObject !== undefined) {
310
- keepAsOneObject({ oneObject, irBlocks, objects, uses, usedSlotOffsets, returnsWithoutHiddenPointer, fail });
494
+ keepAsOneObject({
495
+ oneObject,
496
+ irBlocks,
497
+ objects,
498
+ uses,
499
+ usedSlotOffsets,
500
+ returnsWithoutHiddenPointer,
501
+ arrayOrder: target.compilerBehaviors.addressTakenArrayOrder,
502
+ arrayAlignment: target.compilerBehaviors.addressTakenArrayAlignment,
503
+ fail,
504
+ });
311
505
  return { policy: 'one-object', sinks };
312
506
  }
313
507
  const shapes = objectShapes({
@@ -321,20 +515,32 @@ export function auditFrameObjects({
321
515
  fail,
322
516
  });
323
517
  const escapes = frameEscapes({ irBlocks, uses, windows, shapes, owned, declared, usedSlotOffsets });
324
- // THE ONE-OBJECT ANSWER, where it is on offer (one escape reaches without bound, and every
325
- // writer is one it can hold) and the per-object model does not describe the frame: it refused a
326
- // shape, an object sits over a slot, or the unbounded reach meets another object, a slot or a
327
- // word nothing accounts for. Then what the device may read or the callee may write is kept
328
- // rather than refused: lift again with the local area as one object in memory
518
+ // THE ONE-OBJECT ANSWER, where it is on offer (`chooseFrameModel`: one escape reaches without
519
+ // bound and every writer is one it can hold, or no address escapes at all) and the per-object
520
+ // model does not describe the frame: it refused a shape, an object sits over a slot, or the
521
+ // unbounded reach meets another object, a slot or a word nothing accounts for. Then what the
522
+ // device may read or the callee may write — or, with no escape, only this function's own
523
+ // accesses — is kept rather than refused: lift again with the local area as one object in memory
329
524
  // (`keepAsOneObject`). Below the local area are the outgoing arguments and above it the saved
330
525
  // registers, neither of them an object. Where the per-object model does describe the frame —
331
526
  // one object and nothing else in reach — it stands, and the object keeps its own type.
527
+ // An escape `misses` bounded is asked as the unbounded read it was without that proof
528
+ // (`ModelChoice.unboundedAt`): the proof may add a per-object lift, never take the one-object one.
332
529
  const answered = (e: FrameEscape): boolean =>
333
- e.lo === -Infinity &&
334
- e.hi === Infinity &&
530
+ model.unboundedAt.has(e.off) &&
335
531
  (e.objectReached !== undefined || e.slotReached !== undefined || e.unaccountedWord !== undefined);
336
532
  if (model.onOffer && (model.refused || shapes.overSlot.length > 0 || escapes.some(answered))) {
337
- return { oneObject: { from: declared.from, to: declared.to } };
533
+ return {
534
+ oneObject: reliftRange({
535
+ declared,
536
+ objects,
537
+ uses,
538
+ usedSlotOffsets,
539
+ directAccesses,
540
+ splits: splitsOneObject(target),
541
+ gates: reliftGates,
542
+ }),
543
+ };
338
544
  }
339
545
  const failWithheld: Refuse = (why) => fail(model.withheld(why));
340
546
  for (const [off, width] of shapes.overSlot) {
@@ -352,6 +558,147 @@ export function auditFrameObjects({
352
558
  return { policy: 'per-object', sinks };
353
559
  }
354
560
 
561
+ /** THE BYTES THE ONE-OBJECT RELIFT KEEPS: the declared local area, less the words at either end
562
+ * that are a scalar's and nothing else's — a word the slot model keys and no access names, or one
563
+ * the frontend reached only by direct whole-word accesses (`directAccesses`), which escapes
564
+ * nowhere — and, past such a word, words nothing touches at all. The frontend lifts a word
565
+ * outside the range with its slot model (Thumb `isFrameObjectAccess`, MIPS `keptInRelift`; PPC
566
+ * is never handed a narrowed range, `no-split-follows`), so the one object is the bytes its
567
+ * escapes and accesses reach, and `provenParts` can split it where they tile: agbcc's reload puts
568
+ * spill slots ABOVE the arrays (the Thumb frame grows up, thumb.h:569/573, reload1.c:2274),
569
+ * ido7.1 puts them below, and gcc 2.7.2 leaves a pad word between (each array starts at a
570
+ * multiple of 8); kept in the one object, such a word joins the part beside it.
571
+ *
572
+ * A word is handed back only where no rule of `RELIFT_HAND_BACK_GATES` keeps it: a reach through
573
+ * the object, or no split to follow. What makes a word a scalar's (`scalarWord`) is not tabled,
574
+ * and its clauses are not each measured for soundness: a word it gets wrong meets the second
575
+ * audit's refusals where an object or an access lands outside the kept bytes (`keepAsOneObject`).
576
+ *
577
+ * The copies do NOT tell a spill from a struct member only stored (`struct { u8 a[16], b[16];
578
+ * u32 y; }` with `s.y = x` and nothing reading it): that member is handed back too, and its store
579
+ * drops. No value changes, since nothing reads the word through the object (an index or an
580
+ * escape reaching it keeps it); what is lost is one store the target made. */
581
+ function reliftRange({
582
+ declared,
583
+ objects,
584
+ uses,
585
+ usedSlotOffsets,
586
+ directAccesses,
587
+ splits,
588
+ gates,
589
+ }: {
590
+ declared: FrameRange;
591
+ objects: ReadonlyMap<number, readonly Op[]>;
592
+ uses: FrameUses;
593
+ usedSlotOffsets: ReadonlySet<number>;
594
+ directAccesses: ReadonlySet<Value>;
595
+ splits: boolean;
596
+ gates: readonly Gate<HandBack>[];
597
+ }): FrameRange {
598
+ const whole = { from: declared.from, to: declared.to };
599
+ const reaches = frameReaches(uses);
600
+ const fills = copyStarts(reaches);
601
+ const kept = (word: number): boolean => firstRejection(gates, { word, reaches, fills, splits }) !== null;
602
+ const scalarWord = (w: number, pastScalar: boolean): boolean => {
603
+ const touching = uses.fixed.filter((f) => overlaps(f.at, f.width, w, 4));
604
+ const owners = [...objects.keys()].filter((o) => overlaps(o, 1, w, 4));
605
+ if ([...usedSlotOffsets].some((s) => s !== w && overlaps(s, 4, w, 4))) {
606
+ return false;
607
+ }
608
+ if (usedSlotOffsets.has(w) || touching.length === 0) {
609
+ // …and past one, a word nothing touches: no value lives in it either way
610
+ return owners.length === 0 && touching.length === 0 && (usedSlotOffsets.has(w) || pastScalar);
611
+ }
612
+ return (
613
+ owners.length === 1 &&
614
+ owners[0] === w &&
615
+ !uses.escaped.has(w) &&
616
+ uses.useCount.get(w) === (uses.accesses.get(w)?.length ?? 0) &&
617
+ touching.every((f) => {
618
+ const by = uses.accessOps.get(f.op);
619
+ return f.at === w && f.width === 4 && by?.off === w && directAccesses.has(by.through);
620
+ })
621
+ );
622
+ };
623
+ const handedBack = (w: number, pastScalar: boolean): boolean => !kept(w) && scalarWord(w, pastScalar);
624
+ let { from, to } = whole;
625
+ while (to - from > 4 && handedBack(to - 4, to < whole.to)) {
626
+ to -= 4;
627
+ }
628
+ while (to - from > 4 && handedBack(from, from > whole.from)) {
629
+ from += 4;
630
+ }
631
+ return { from, to };
632
+ }
633
+
634
+ /** Whether `keepAsOneObject` may split the one object on this target at all: where it places
635
+ * address-taken arrays in a known order and at a known alignment. */
636
+ function splitsOneObject(target: TargetDescription): boolean {
637
+ return (
638
+ target.compilerBehaviors.addressTakenArrayOrder !== undefined &&
639
+ target.compilerBehaviors.addressTakenArrayAlignment !== undefined
640
+ );
641
+ }
642
+
643
+ /** What `RELIFT_HAND_BACK_GATES` asks of one end word of the local area: the word, and every
644
+ * escape and runtime index of the frame as the bytes each may reach (`frameReaches`). */
645
+ export interface HandBack {
646
+ readonly word: number;
647
+ /** every escape and runtime index, with the bytes each may reach */
648
+ readonly reaches: readonly OwnedReach[];
649
+ /** where a bounded copy writes into the frame */
650
+ readonly fills: ReadonlySet<number>;
651
+ /** the target may split the one object (`splitsOneObject`) */
652
+ readonly splits: boolean;
653
+ }
654
+
655
+ /** WHAT KEEPS AN END WORD IN THE ONE OBJECT (`reliftRange`): a reach through the object that may
656
+ * land on it, or no split to gain by handing it back. A word handed back is a local whose address
657
+ * nothing takes, so the recompile keeps it in a register or drops its store, and whatever reached
658
+ * it through the object in the source reads or writes another byte. */
659
+ export const RELIFT_HAND_BACK_GATES: readonly Gate<HandBack>[] = [
660
+ // `struct S { u8 a[16], b[16]; u32 y; } s; … s.y = x; h(s.a, 16); g(s.b); return s.y + s.a[3];`
661
+ // on agbcc: `g` may write `s.y` through the address it was handed, and the target reads it back
662
+ {
663
+ id: 'escape-without-bound',
664
+ why: 'a callee handed an address nothing bounds may reach any byte of the frame from it',
665
+ sound: true,
666
+ guardedBy: 'frame-audit-stages.test.ts: keeps every word where an escape no literal count bounds may reach it',
667
+ rejects: (c) => c.reaches.some((r) => r.by === 'escape' && r.reach === null),
668
+ },
669
+ // `memcpy(s + 4, src, 8)` over a slot keyed at 8 writes the word the slot model would keep
670
+ {
671
+ id: 'copy-reaches-word',
672
+ why: 'a block copy into or out of the frame names the bytes it moves, and this word is one of them',
673
+ sound: true,
674
+ guardedBy: 'frame-audit-stages.test.ts: keeps a word a bounded copy reaches',
675
+ rejects: (c) => c.reaches.some((r) => r.by === 'escape' && r.reach !== null && mayTouch(r.reach, c.word, 4)),
676
+ },
677
+ // An index nothing bounds reaches every byte of the object it is based in, so no split follows
678
+ // it (`provenParts` keeps the one object whole), and a word handed back would only leave the
679
+ // bytes the index may reach. `*(u32 *)(s + 32) = x; return s[16 + i];` on agbcc is spelled
680
+ // exactly as a spill slot beside an index is. An index whose range is proven (its `reach`:
681
+ // `s[16 + (i & 7) * 2]`) reaches only the bytes it names, so a word outside every such reach is
682
+ // read through no index, and handing it back loses nothing an index could read.
683
+ {
684
+ id: 'runtime-index',
685
+ why: 'a runtime index may reach this word: nothing bounds it, or its bounded reach covers it',
686
+ sound: true,
687
+ guardedBy: 'thumb-frontend.test.ts: a word a runtime index may reach is not handed back to the slot model',
688
+ rejects: (c) => c.reaches.some((r) => r.by === 'index' && mayTouch(r.reach, c.word, 4)),
689
+ },
690
+ // Handing back buys a split and nothing else: two or more copies with a literal count must fill
691
+ // the area, on a target that splits at all (PPC_MWCC places address-taken arrays in no order
692
+ // `target.ts` records). Elsewhere it would only cost a stored-only member's store.
693
+ {
694
+ id: 'no-split-follows',
695
+ why: 'a word is handed back only so that the copies can split the one object',
696
+ sound: false,
697
+ guardedBy: 'frame-audit-stages.test.ts: keeps every word where no split could follow',
698
+ rejects: (c) => c.fills.size < 2 || !c.splits,
699
+ },
700
+ ];
701
+
355
702
  /** Refuses the whole function, naming the address-taken local as what it cannot lift. */
356
703
  type Refuse = (why: string) => never;
357
704
 
@@ -361,6 +708,42 @@ function frameRefusal(name: string): Refuse {
361
708
  };
362
709
  }
363
710
 
711
+ /** THE FRAME BYTES A DECLARATION TYPES AS ONE STRUCT: a call's return storage (`sret`) and a
712
+ * struct passed by value (`byValue`, call-plan.ts `foldByValueArgs`), where the operand is an
713
+ * `laddr`. */
714
+ function declaredStructs(irBlocks: readonly Block[]): FrameRange[] {
715
+ const laddrOff = new Map<Value, number>();
716
+ for (const b of irBlocks) {
717
+ for (const op of b.ops) {
718
+ if (op.opcode === 'laddr') {
719
+ laddrOff.set(op.results[0], op.attrs.off as number);
720
+ }
721
+ }
722
+ }
723
+ const out: FrameRange[] = [];
724
+ for (const b of irBlocks) {
725
+ for (const op of b.ops) {
726
+ if (op.opcode !== 'call') {
727
+ continue;
728
+ }
729
+ const t = op.results[0]?.type;
730
+ const from = laddrOff.get(op.operands[0]);
731
+ if (op.attrs.sret === true && from !== undefined && t?.kind === 'struct' && t.size !== undefined) {
732
+ out.push({ from, to: from + t.size });
733
+ }
734
+ for (const { at } of byValueOperands(op)) {
735
+ const v = op.operands[at];
736
+ const off = laddrOff.get(v);
737
+ const t = v?.type.kind === 'ptr' ? v.type.to : undefined;
738
+ if (off !== undefined && t?.kind === 'struct' && t.size !== undefined) {
739
+ out.push({ from: off, to: off + t.size });
740
+ }
741
+ }
742
+ }
743
+ }
744
+ return out;
745
+ }
746
+
364
747
  /** A CAPTURE MOVED BY A CONSTANT IS THE CAPTURE AT THE SUM, and here the constant is exact: an
365
748
  * `add` of a `laddr` and a `const` — a register the lift could not see through, `mov r0, sp /
366
749
  * movs r2, #0 / ldrsh r1, [r0, r2]`, or a move the pre-lift walk does not follow — is re-minted
@@ -369,6 +752,22 @@ function frameRefusal(name: string): Refuse {
369
752
  * lands on a keyed slot is refused as the move, the capability that is missing
370
753
  * (`failIfSlotKeysIt`).
371
754
  *
755
+ * AN INDEX INTO A CAPTURE, MOVED BY A NONZERO CONSTANT, IS THE SAME INDEX INTO THE CAPTURE AT THE
756
+ * SUM. agbcc spells `a[i]` on a local at [sp,#k) as the frame base plus the index, moved after:
757
+ * `mov r0, sp / add r0, r0, r4 / add r0, #0x4` or `add r0, r0, sp / add r0, #0x4` — the bytes
758
+ * `add r0, sp, #0x4 / add r0, r0, r4` reach, so `add(add(laddr@a, i), k)` is re-minted as
759
+ * `add(laddr@(a+k), i)`, and the inner sum and capture are dropped when nothing else reads them.
760
+ * The object at [sp,#a) is often the outgoing-argument word (a 5-argument call beside
761
+ * the array), which the index never touches. Zero is left alone: `add(sum, 0)` is agbcc's
762
+ * `mov r1, #0x0 / ldrsb r0, [r0, r1]`, which `indexedAccess` judges where the register put it.
763
+ * The rebased object is held to every rule an object at a+k is held to, so a displaced FIELD of a
764
+ * struct element — no object of its own at a+k, the struct's other bytes at a — lands as a second
765
+ * object beside the first and is refused or lifted as one object by the same rules. A load or
766
+ * store's own nonzero displacement on the sum is the same move: `ldrb r0, [r0, #0x1]` (the field
767
+ * agbcc reads unsigned) and `mov r1, #0x1 / ldrsb r0, [r0, r1]` (signed) reach the same byte, so
768
+ * the access is re-pointed at `add(laddr@(a+d), i)` with displacement 0, and the verdict does not
769
+ * depend on where the literal sits.
770
+ *
372
771
  * Returns every `laddr` that survives, in block order. */
373
772
  function foldMovedCaptures(
374
773
  irBlocks: Block[],
@@ -387,23 +786,77 @@ function foldMovedCaptures(
387
786
  }
388
787
  }
389
788
  }
789
+ // the `add` of a capture and a runtime value that defines each value, for the index rebase
790
+ const indexOf = new Map<Value, { base: Op; index: Value; sum: Op }>();
791
+ const rebased = new Set<Op>();
390
792
  for (let changed = true; changed;) {
391
793
  changed = false;
392
794
  for (const blk of irBlocks) {
393
- blk.ops = blk.ops.map((op) => {
795
+ blk.ops = blk.ops.flatMap((op) => {
394
796
  const [x, y] = op.operands;
797
+ const sum0 = (op.opcode === 'load' || op.opcode === 'store') && x !== undefined ? indexOf.get(x) : undefined;
798
+ const disp = (op.attrs.off as number | undefined) ?? 0;
799
+ if (sum0 !== undefined && disp !== 0) {
800
+ const object = mkOp('laddr', {
801
+ results: [mkValue(sum0.base.results[0].type)],
802
+ attrs: { off: ((sum0.base.attrs.off as number) + disp) | 0 },
803
+ });
804
+ const moves = mkOp('add', {
805
+ results: [mkValue(x.type)],
806
+ operands: [object.results[0], sum0.index],
807
+ attrs: sum0.sum.attrs,
808
+ });
809
+ laddrOf.set(object.results[0], object);
810
+ indexOf.set(moves.results[0], { base: object, index: sum0.index, sum: moves });
811
+ moved.add(sum0.base.results[0]);
812
+ foldedHere.add(object);
813
+ rebased.add(sum0.sum);
814
+ op.operands[0] = moves.results[0];
815
+ op.attrs = { ...op.attrs, off: 0 };
816
+ changed = true;
817
+ return [object, moves, op];
818
+ }
819
+ if (op.opcode !== 'add' || op.operands.length !== 2) {
820
+ return [op];
821
+ }
395
822
  const base = laddrOf.get(x) ?? laddrOf.get(y);
396
823
  const by = constOf.get(laddrOf.has(x) ? y : x);
397
- if (op.opcode !== 'add' || op.operands.length !== 2 || base === undefined || by === undefined) {
398
- return op;
824
+ if (base !== undefined && by !== undefined) {
825
+ const off = ((base.attrs.off as number) + by) | 0;
826
+ const object = mkOp('laddr', { results: op.results, attrs: { off } });
827
+ laddrOf.set(op.results[0], object);
828
+ moved.add(base.results[0]);
829
+ foldedHere.add(object);
830
+ changed = true;
831
+ return [object];
832
+ }
833
+ const index = base === undefined ? undefined : laddrOf.has(x) ? y : x;
834
+ if (base !== undefined && index !== undefined && !laddrOf.has(index)) {
835
+ // a sum first seen here takes another pass: a use laid out in an EARLIER block was
836
+ // visited before the sum was known, and is moved only on the next one
837
+ if (!indexOf.has(op.results[0])) {
838
+ indexOf.set(op.results[0], { base, index, sum: op });
839
+ changed = true;
840
+ }
841
+ return [op];
399
842
  }
400
- const off = ((base.attrs.off as number) + by) | 0;
401
- const object = mkOp('laddr', { results: op.results, attrs: { off } });
402
- laddrOf.set(op.results[0], object);
403
- moved.add(base.results[0]);
843
+ const sum = indexOf.get(x) ?? indexOf.get(y);
844
+ const k = constOf.get(indexOf.has(x) ? y : x);
845
+ if (sum === undefined || k === undefined || k === 0) {
846
+ return [op];
847
+ }
848
+ const object = mkOp('laddr', {
849
+ results: [mkValue(sum.base.results[0].type)],
850
+ attrs: { off: ((sum.base.attrs.off as number) + k) | 0 },
851
+ });
852
+ const moves = mkOp('add', { results: op.results, operands: [object.results[0], sum.index], attrs: op.attrs });
853
+ laddrOf.set(object.results[0], object);
854
+ indexOf.set(op.results[0], { base: object, index: sum.index, sum: moves });
855
+ moved.add(sum.base.results[0]);
404
856
  foldedHere.add(object);
857
+ rebased.add(sum.sum);
405
858
  changed = true;
406
- return object;
859
+ return [object, moves];
407
860
  });
408
861
  }
409
862
  }
@@ -412,13 +865,24 @@ function foldMovedCaptures(
412
865
  // the write-back of `stmia r1!, {r2, r3, r4}` through a capture is `add r1, r1, #12`, a pointer
413
866
  // to the end of what was copied that the machine never uses. Only those: an unused `mov rD, sp`
414
867
  // of the machine's own is still a capture, and is judged.
415
- const read = new Set<Value>();
416
- for (const blk of irBlocks) {
417
- for (const op of blk.ops) {
418
- op.operands.forEach((v) => read.add(v));
419
- (op.successors ?? []).forEach((s) => s.args.forEach((v) => read.add(v)));
868
+ const readNow = (): Set<Value> => {
869
+ const read = new Set<Value>();
870
+ for (const blk of irBlocks) {
871
+ for (const op of blk.ops) {
872
+ op.operands.forEach((v) => read.add(v));
873
+ (op.successors ?? []).forEach((s) => s.args.forEach((v) => read.add(v)));
874
+ }
875
+ }
876
+ return read;
877
+ };
878
+ // …and an index sum a rebase moved off, once nothing reads it, goes the same way first
879
+ if (rebased.size > 0) {
880
+ const before = readNow();
881
+ for (const blk of irBlocks) {
882
+ blk.ops = blk.ops.filter((op) => !rebased.has(op) || before.has(op.results[0]));
420
883
  }
421
884
  }
885
+ const read = readNow();
422
886
  const laddrs: Op[] = [];
423
887
  for (const blk of irBlocks) {
424
888
  blk.ops = blk.ops.filter(
@@ -474,7 +938,7 @@ function frameAddressFacts(
474
938
  }
475
939
  for (const [lit, index] of [d.operands, [...d.operands].reverse()]) {
476
940
  const from = literalAddrOf(lit);
477
- const stride = from === undefined ? 0 : factorOf(addresses, index);
941
+ const stride = from === undefined ? 0 : linearFactor({ defs: defOf, constOf: addresses.constOf }, index);
478
942
  if (from !== undefined && readOnlySinks.has(from + off) && stride > 0 && stride % sinkSpacing === 0) {
479
943
  return [...readOnlySinks].filter((s) => (s - (from + off)) % stride === 0);
480
944
  }
@@ -484,47 +948,6 @@ function frameAddressFacts(
484
948
  return { ...addresses, readsThrough };
485
949
  }
486
950
 
487
- // `v` as a sum of runtime values times constants, plus a constant (the `1` key): agbcc's `ch *
488
- // 12` is `((ch << 1) + ch) << 2`, which is 12 times `ch` only once the two `ch` terms are added.
489
- // Undefined past what this can read.
490
- function linearOf(facts: LiteralAddresses, v: Value, depth = 0): Map<Value | 1, number> | undefined {
491
- const c = facts.constOf(v);
492
- if (c !== undefined) {
493
- return new Map([[1, c]]);
494
- }
495
- const d = facts.defOf.get(v);
496
- if (d === undefined || depth > 8) {
497
- return new Map([[v, 1]]);
498
- }
499
- const scaled = (x: Value, by: number) => {
500
- const l = linearOf(facts, x, depth + 1);
501
- return l && new Map([...l].map(([t, k]) => [t, k * by] as const));
502
- };
503
- const k = d.operands.length === 1 ? (d.attrs.imm as number | undefined) : facts.constOf(d.operands[1]);
504
- if (d.opcode === 'shl' && k !== undefined && k >= 0 && k < 31) {
505
- return scaled(d.operands[0], 2 ** k);
506
- }
507
- if (d.opcode === 'mul' && d.operands.length === 2 && k !== undefined) {
508
- return scaled(d.operands[0], k);
509
- }
510
- if ((d.opcode === 'add' || d.opcode === 'sub') && d.operands.length === 2) {
511
- const x = linearOf(facts, d.operands[0], depth + 1);
512
- const y = scaled(d.operands[1], d.opcode === 'add' ? 1 : -1);
513
- if (x === undefined || y === undefined) {
514
- return undefined;
515
- }
516
- y.forEach((n, t) => x.set(t, (x.get(t) ?? 0) + n));
517
- return x;
518
- }
519
- return new Map([[v, 1]]);
520
- }
521
-
522
- // A number every value of `v` is a multiple of, or 0 when this cannot name one.
523
- function factorOf(facts: LiteralAddresses, v: Value): number {
524
- const l = linearOf(facts, v);
525
- return l === undefined ? 0 : [...l.values()].reduce(gcd, 0);
526
- }
527
-
528
951
  /** A FRAME BASE ADDRESSED THROUGH IS NOT A CAPTURE. Thumb-1 gives `ldr`/`str` an `[sp,#imm]`
529
952
  * encoding and gives the sub-word forms none, so a byte or halfword spill can only be spelled
530
953
  * by copying sp into a register and addressing through the copy:
@@ -556,6 +979,7 @@ function splitAddressingCopies(
556
979
  irBlocks: Block[],
557
980
  laddrs: Op[],
558
981
  foldedHere: Set<Op>,
982
+ structs: readonly FrameRange[] = [],
559
983
  ): { laddrs: Op[]; splitRefusal: Map<Value, string> } {
560
984
  const splitRefusal = new Map<Value, string>();
561
985
  const uses = new Map<Value, { op: Op; idx: number; blk: Block }[]>();
@@ -593,13 +1017,42 @@ function splitAddressingCopies(
593
1017
  if (at.length === 0 || accesses.length !== at.length) {
594
1018
  continue;
595
1019
  }
1020
+ // AN ADDRESS STRICTLY INSIDE A DECLARED STRUCT, ONLY ACCESSED, IS A MEMBER ACCESS OF THAT
1021
+ // STRUCT. A word store into a struct passed by value goes straight to `[sp,#k]` — `x.c = w` is
1022
+ // `str r1, [sp, #0x4]` over `struct F {u16 a,b; u32 c; s8 e,f;}` at [sp,#0) — and the frontend
1023
+ // mints that access its own `laddr` at k. The declaration says those bytes are the struct's,
1024
+ // so the address is re-rooted on the struct's base with the displacement on the access, and
1025
+ // the audit judges one object rather than two that overlap. Any use that is not an access —
1026
+ // `&x.c` handed on — is an interior pointer, which is not this shape and is left to refuse.
1027
+ const off = capture.attrs.off as number;
1028
+ const holders = structs.filter((r) => r.from < off && off < r.to);
1029
+ const holder = holders[0];
1030
+ const inHolder = (u: { op: Op }): boolean =>
1031
+ off + (u.op.attrs.off as number) >= holder!.from &&
1032
+ off + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= holder!.to;
1033
+ if (
1034
+ holder !== undefined &&
1035
+ holders.every((r) => r.from === holder.from && r.to === holder.to) &&
1036
+ accesses.every(inHolder)
1037
+ ) {
1038
+ for (const u of accesses) {
1039
+ u.op.attrs = { ...u.op.attrs, off: off - holder.from + (u.op.attrs.off as number) };
1040
+ }
1041
+ capture.attrs = { ...capture.attrs, off: holder.from };
1042
+ continue;
1043
+ }
596
1044
  if (!subWord) {
597
1045
  splitRefusal.set(capture.results[0], 'a WORD access through the copy, and `ldr`/`str` have an `[sp,#imm]` form');
598
1046
  continue;
599
1047
  }
600
1048
  // Nothing to split when the capture already names ONE object: every access at offset 0 is
601
- // the frame base itself, which is what the DMA-fill idiom captures.
602
- if (accesses.every((u) => u.op.attrs.off === 0)) {
1049
+ // the frame base itself, which is what the DMA-fill idiom captures — or every access lands in
1050
+ // the declared struct the capture is the start of (`declaredStructs`), whose members they are.
1051
+ const base = capture.attrs.off as number;
1052
+ const into = (r: FrameRange): boolean =>
1053
+ base === r.from &&
1054
+ accesses.every((u) => base + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= r.to);
1055
+ if (accesses.every((u) => u.op.attrs.off === 0) || structs.some(into)) {
603
1056
  continue;
604
1057
  }
605
1058
  for (const u of accesses) {
@@ -644,8 +1097,16 @@ function frameObjects(laddrs: readonly Op[], owned: FrameRange, fail: Refuse): M
644
1097
  // saved register's — `g(&e)` for a fifth parameter `e` is `add r0, sp, #0x8` over a one-word
645
1098
  // local area — and no other rule of the audit is about that gap. The TOP itself is also C's
646
1099
  // one-past-the-end pointer, `g(buf, buf + 8)` over an 8-byte array, and the refusal names both
647
- // readings.
1100
+ // readings. BELOW IT is the outgoing block a call's stack words are staged in, and on O32 also
1101
+ // the saves IDO keeps under its locals: an address there is no local's either. Every object's
1102
+ // extent is held to the area again below; this says why, for the frontend that supplied it.
648
1103
  for (const off of objects.keys()) {
1104
+ if (off < owned.from) {
1105
+ fail(
1106
+ `the captured address at [sp,#${off}) is below the reserved local area, which starts at [sp,#${owned.from}) — ` +
1107
+ 'an outgoing stack argument word or a saved register, whose address is not modelled',
1108
+ );
1109
+ }
649
1110
  if (off === owned.to) {
650
1111
  fail(
651
1112
  `the captured address at [sp,#${off}) is the top of the reserved local area of ${owned.to - owned.from} bytes — ` +
@@ -764,10 +1225,70 @@ interface FrameAccess {
764
1225
  }
765
1226
 
766
1227
  /** A load or store judged an access to the object at `off`, and the address value it reaches the
767
- * object through: a capture, or a phi that carries one — for a runtime index, the indexed one. */
1228
+ * object through: a capture, or a phi that carries one — for a runtime index, the indexed one,
1229
+ * and then `index` is the access as `FrameUses.indexed` records it. Without `index` the access
1230
+ * is at its own displacement from `through`. */
768
1231
  interface FrameAccessOp {
769
1232
  readonly off: number;
770
1233
  readonly through: Value;
1234
+ readonly index?: IndexedAccess;
1235
+ }
1236
+
1237
+ /** THE FRAME BYTES `[lo, hi)` ONE USE MAY TOUCH, or `null` where nothing bounds it, which is every
1238
+ * byte: a runtime index's reach (`FrameUses.indexed`) and an escape's extent
1239
+ * (`FrameUses.escapeExtents`) are each one. */
1240
+ type FrameReach = { readonly lo: number; readonly hi: number } | null;
1241
+
1242
+ /** Whether a use reaching `r` may touch any of the `width` bytes at `lo`: every byte where
1243
+ * nothing bounds it. */
1244
+ function mayTouch(r: FrameReach, lo: number, width: number): boolean {
1245
+ return r === null || overlaps(lo, width, r.lo, r.hi - r.lo);
1246
+ }
1247
+
1248
+ /** Whether two reaches may share a byte. */
1249
+ function mayShare(a: FrameReach, b: FrameReach): boolean {
1250
+ return b === null || mayTouch(a, b.lo, b.hi - b.lo);
1251
+ }
1252
+
1253
+ /** One reach through the frame, with the object whose address it starts from (`owner`), what
1254
+ * makes it (`by`: an escape of that address, or a runtime index into it), and `into` where it is
1255
+ * a bounded block copy that WRITES the bytes (the address is its destination). */
1256
+ interface OwnedReach {
1257
+ readonly owner: number;
1258
+ readonly by: 'escape' | 'index';
1259
+ readonly reach: FrameReach;
1260
+ readonly into: boolean;
1261
+ }
1262
+
1263
+ /** EVERY REACH THROUGH THE FRAME, the one list the split of the one object (`provenParts`) and
1264
+ * the words handed back to the slot model (`reliftRange`) read: each escape's extent, and each
1265
+ * runtime-indexed access's reach. */
1266
+ function frameReaches(uses: FrameUses): OwnedReach[] {
1267
+ return [
1268
+ ...[...uses.escapeExtents].flatMap(([owner, extents]) =>
1269
+ extents.map((e): OwnedReach => ({
1270
+ owner,
1271
+ by: 'escape',
1272
+ reach: e === null ? null : { lo: e.lo, hi: e.hi },
1273
+ into: e?.into === true,
1274
+ })),
1275
+ ),
1276
+ ...[...uses.indexed].flatMap(([owner, accesses]) =>
1277
+ accesses.map((x): OwnedReach => ({ owner, by: 'index', reach: x.reach, into: false })),
1278
+ ),
1279
+ ];
1280
+ }
1281
+
1282
+ /** Where a bounded block copy writes into the frame: the start of each copy's destination. */
1283
+ function copyStarts(reaches: readonly OwnedReach[]): Set<number> {
1284
+ return new Set(reaches.flatMap((r) => (r.into && r.reach !== null ? [r.reach.lo] : [])));
1285
+ }
1286
+
1287
+ /** One access through a runtime index: its width and signedness, and the bytes it may touch. */
1288
+ interface IndexedAccess {
1289
+ readonly width: number;
1290
+ readonly signed: boolean;
1291
+ readonly reach: FrameReach;
771
1292
  }
772
1293
 
773
1294
  /** Every use of every frame object, by offset — what each later stage reads. Each field is
@@ -786,10 +1307,30 @@ interface FrameUses {
786
1307
  readonly publishedToWriter: ReadonlySet<number>;
787
1308
  readonly arg0Callees: ReadonlyMap<number, ReadonlySet<string | null>>;
788
1309
  readonly returnTemps: ReadonlyMap<number, readonly Op[]>;
1310
+ /** the calls each object is passed to BY VALUE (`foldByValueArgs`), with the struct each passes */
1311
+ readonly byValueReads: ReadonlyMap<number, readonly { readonly op: Op; readonly type: IrType }[]>;
789
1312
  readonly useCount: ReadonlyMap<number, number>;
790
- readonly indexed: ReadonlyMap<number, readonly { readonly width: number; readonly signed: boolean }[]>;
1313
+ /** every runtime-indexed access of each object, with the frame bytes `[lo, hi)` it may touch
1314
+ * (`reach`, `indexedAccess`), or `null` where nothing bounds the index */
1315
+ readonly indexed: ReadonlyMap<number, readonly IndexedAccess[]>;
791
1316
  readonly members: readonly { readonly at: number; readonly width: number }[];
792
1317
  readonly memberRefusal: string | undefined;
1318
+ /** every escape of each object's address, as the frame bytes it may reach: `[lo, hi)` where a
1319
+ * literal-count block copy proves them (`boundedCopy`), `null` where nothing does, and `into`
1320
+ * where the copy WRITES them (the address is its destination, not its source). Only the split
1321
+ * of the one object reads this (`provenParts`); the escape windows keep the copy as an
1322
+ * unbounded writer, which is what puts that one object on offer at all (`chooseFrameModel`). */
1323
+ readonly escapeExtents: ReadonlyMap<
1324
+ number,
1325
+ readonly ({ readonly lo: number; readonly hi: number; readonly into: boolean } | null)[]
1326
+ >;
1327
+ readonly fixed: readonly {
1328
+ readonly op: Op;
1329
+ readonly at: number;
1330
+ readonly width: number;
1331
+ readonly isLoad: boolean;
1332
+ readonly frameOnly: boolean;
1333
+ }[];
793
1334
  }
794
1335
 
795
1336
  /** Did the address at `off` reach something that may write the frame back: a callee that is not a
@@ -885,11 +1426,14 @@ function classifyFrameUses({
885
1426
  // is the callee's return storage, a local of the declared type. `useCount` counts every use of
886
1427
  // each object, so a return temp this function does anything else with is told apart.
887
1428
  const returnTemps = new Map<number, Op[]>();
1429
+ // …and a call's operand its lowering passes BY VALUE (`foldByValueArgs`) is no escape: the
1430
+ // callee receives a copy of the whole struct, so it is a read of the object's bytes at the call
1431
+ const byValueReads = new Map<number, { op: Op; type: IrType }[]>();
888
1432
  const useCount = new Map<number, number>();
889
1433
  // The accesses through a runtime-indexed address, per object — kept apart from `accesses`,
890
1434
  // which types the object at its own offset: `a[i]` says what one ELEMENT is, not what sits at
891
1435
  // `a`.
892
- const indexed = new Map<number, { width: number; signed: boolean }[]>();
1436
+ const indexed = new Map<number, IndexedAccess[]>();
893
1437
  for (const off of objects.keys()) {
894
1438
  accesses.set(off, []);
895
1439
  }
@@ -897,17 +1441,37 @@ function classifyFrameUses({
897
1441
  // access at [+k] through a capture is a member of the one object rather than a second object
898
1442
  const members: { at: number; width: number }[] = [];
899
1443
  // …and without it, the first such access, which the per-object model refuses once the model is
900
- // chosen (`chooseFrameModel`)
901
- let memberRefusal: string | undefined;
1444
+ // chosen (`chooseFrameModel`) — save one of a call's return storage or of a struct passed by
1445
+ // value, whose members are the struct's declared ones (`objectShapes`)
1446
+ const memberRefusals: [number, string][] = [];
1447
+ const escapeExtents = new Map<number, ({ lo: number; hi: number; into: boolean } | null)[]>();
1448
+ // …and every value something reads, since a copy whose returned destination is read hands the
1449
+ // address on to a use nothing here follows
1450
+ const readValues = new Set<Value>(
1451
+ irBlocks.flatMap((b) => b.ops.flatMap((op) => [...op.operands, ...(op.successors ?? []).flatMap((sx) => sx.args)])),
1452
+ );
1453
+ // …and in either model, every fixed-offset access as the op that makes it, the frame bytes it
1454
+ // touches, whether it reads them, and whether its address is this frame's on every path — what
1455
+ // `storedNeverRead` asks of a frame that makes a call
1456
+ const fixed: { op: Op; at: number; width: number; isLoad: boolean; frameOnly: boolean }[] = [];
902
1457
  const scalar = (kind: string, op: Op, off: number, v: Value): void => {
1458
+ fixed.push({
1459
+ op,
1460
+ at: off + (op.attrs.off as number),
1461
+ width: op.attrs.width as number,
1462
+ isLoad: kind === 'load',
1463
+ frameOnly: frameOnEveryPath.has(v),
1464
+ });
903
1465
  if (oneObject !== undefined) {
904
1466
  members.push({ at: off + (op.attrs.off as number), width: op.attrs.width as number });
905
1467
  return;
906
1468
  }
907
1469
  if ((op.attrs.off as number) !== 0) {
908
- memberRefusal ??=
1470
+ memberRefusals.push([
1471
+ off,
909
1472
  `a ${kind} at [+${op.attrs.off}] through the captured address — ` +
910
- (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled');
1473
+ (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled'),
1474
+ ]);
911
1475
  }
912
1476
  };
913
1477
  for (const blk of irBlocks) {
@@ -934,8 +1498,22 @@ function classifyFrameUses({
934
1498
  accesses.get(off)!.push({ width: op.attrs.width as number, signed: false, isLoad: false });
935
1499
  continue;
936
1500
  }
1501
+ if (byValueOperands(op).some((b) => b.at === idx)) {
1502
+ // the fold types the operand a pointer to the struct passed (call-plan.ts `foldByValueArgs`)
1503
+ const type = v.type.kind === 'ptr' ? v.type.to : undefined;
1504
+ if (type?.kind !== 'struct' || type.size === undefined) {
1505
+ throw new Error(`internal: a by-value operand of a call to '${calleeName(op)}' has no declared struct`);
1506
+ }
1507
+ (byValueReads.get(off) ?? byValueReads.set(off, []).get(off)!).push({ op, type });
1508
+ fixed.push({ op, at: off, width: type.size, isLoad: true, frameOnly: frameOnEveryPath.has(v) });
1509
+ continue;
1510
+ }
937
1511
  if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
938
1512
  escaped.add(off); // the address ESCAPES as a value — the point of the capability
1513
+ const copied = op.opcode === 'call' ? boundedCopy(op, idx, facts, readValues) : undefined;
1514
+ (escapeExtents.get(off) ?? escapeExtents.set(off, []).get(off)!).push(
1515
+ copied === undefined ? null : { lo: off + copied.lo, hi: off + copied.hi, into: copied.into },
1516
+ );
939
1517
  const handedTo = op.opcode === 'store' ? facts.readsThrough(op) : [];
940
1518
  const read = op.opcode === 'call' ? transferRead(op, idx, off, target, facts) : undefined;
941
1519
  if (read !== undefined) {
@@ -984,7 +1562,14 @@ function classifyFrameUses({
984
1562
  // judged with the rest (`objectShapes`).
985
1563
  const other = op.opcode === 'add' && op.operands.length === 2 ? op.operands[1 - idx] : undefined;
986
1564
  if (other !== undefined && taint.get(other) === undefined && defOf.get(other)?.opcode !== 'const') {
987
- indexedAccess(irBlocks, { indexed, accessOps }, { off, through: v }, op.results[0], fail);
1565
+ indexedAccess(
1566
+ irBlocks,
1567
+ { indexed, accessOps },
1568
+ { off, through: v },
1569
+ { sum: op.results[0], range: facts.addressRangeOf(other, blk, 'narrowest') },
1570
+ facts.constOf,
1571
+ fail,
1572
+ );
988
1573
  continue;
989
1574
  }
990
1575
  // A CONSTANT move left after the fold (`foldMovedCaptures`) moves a capture a phi carried.
@@ -1022,13 +1607,39 @@ function classifyFrameUses({
1022
1607
  publishedToWriter,
1023
1608
  arg0Callees,
1024
1609
  returnTemps,
1610
+ byValueReads,
1025
1611
  useCount,
1026
1612
  indexed,
1027
1613
  members,
1028
- memberRefusal,
1614
+ memberRefusal: memberRefusals.find(([off]) => !returnTemps.has(off) && !byValueReads.has(off))?.[1],
1615
+ escapeExtents,
1616
+ fixed,
1029
1617
  };
1030
1618
  }
1031
1619
 
1620
+ // The bytes, relative to the address at operand `idx`, that a call of a standard block copy with
1621
+ // a literal, positive count reaches through it, and whether it writes them (`idx` is its
1622
+ // destination) — or `undefined` for any other callee, operand or count, which this does not
1623
+ // bound, and for a copy whose result (`memcpy` returns its destination) anything in `read` uses.
1624
+ function boundedCopy(
1625
+ op: Op,
1626
+ idx: number,
1627
+ facts: LiteralAddresses,
1628
+ read: ReadonlySet<Value>,
1629
+ ): { lo: number; hi: number; into: boolean } | undefined {
1630
+ const roles = blockCopyRoles(op.attrs.target);
1631
+ if (
1632
+ roles === undefined ||
1633
+ (idx !== roles.dst && idx !== roles.src) ||
1634
+ op.operands.length <= roles.n ||
1635
+ op.results.some((r) => read.has(r))
1636
+ ) {
1637
+ return undefined;
1638
+ }
1639
+ const n = facts.constOf(op.operands[roles.n]);
1640
+ return n !== undefined && Number.isInteger(n) && n > 0 ? { lo: 0, hi: n, into: idx === roles.dst } : undefined;
1641
+ }
1642
+
1032
1643
  // The bytes a block transfer that took the address at `idx` reads, when `idx` is its source and
1033
1644
  // its control word is a literal.
1034
1645
  function transferRead(
@@ -1051,36 +1662,128 @@ function transferRead(
1051
1662
  }
1052
1663
 
1053
1664
  // Records every access through `sum`, a runtime index into the object at `off`.
1665
+ //
1666
+ // A SUM MOVED BY A LITERAL IS THE SAME INDEX, DISPLACED. Thumb has no `ldrsb`/`ldrsh` with an
1667
+ // immediate, so agbcc loads the whole displacement into a register (thumb.md:411-505, `mov %0,%2 ;
1668
+ // ldrs[bh] %0,[%1,%0]`) and the frontend lowers that register-offset load as `add` + load@0:
1669
+ // `add r0, r1, r4 / mov r1, #0x0 / ldrsb r1, [r0, r1]` is `a[i]` on an `s8 a[n]` local. So the
1670
+ // uses of an `add` of the sum and a `const` are judged at that displacement. One `add` deep: agbcc
1671
+ // emits one `mov` per load, and no other compiler emits a literal index register. Only
1672
+ // displacement 0 is the indexed element. A nonzero `const` on a sum whose base is a capture never
1673
+ // arrives here: `foldMovedCaptures` has already moved the index to the object at the sum, which is
1674
+ // what agbcc's `mov r0, sp / add r0, r0, r4 / add r0, #0x4` is (an array above the outgoing word),
1675
+ // and what `mov r1, #0x3 / ldrsb r1, [r0, r1]` and a load's own displacement, `ldrb r0, [r0,
1676
+ // #0x1]` (a field of an element struct, or `a[i + 1]`), reach. What is left nonzero — a constant
1677
+ // computed rather than loaded as a `const`, a literal or a displacement on an index into a capture
1678
+ // a phi carries, or a displacement on top of the zero literal, which no compiler emits — refuses
1679
+ // naming the displacement.
1680
+ //
1681
+ // The element is judged here and left where the register put it, so it is spelled with the
1682
+ // literal: `*(s16 *)((u32)sp0 + (a0 << 1) + 0)`. Re-pointing it at the sum spells
1683
+ // `((s16 *)sp0)[a0]`, an index through a cast on the `u8` storage (`objectShapes`), which agbcc
1684
+ // compiles to `add r4, r4, sp` and the source's `mov r1, sp / add r0, r1, r4` does not: the `u16`
1685
+ // twin `((u16 *)sp0)[a0]` misses the same way. Left raw, the access compiles to the source's
1686
+ // bytes with or without the `+ 0`.
1687
+ //
1688
+ // WHAT IT MAY REACH is the object's offset plus every value the index may hold (`range`, ir/bits.ts
1689
+ // `stridedRange`, asked at the sum's block): a mask, a zero-fill shift, an unsigned narrow load or a
1690
+ // `zext` — by its width, or by its operand's range where that is narrower (`(u8)k` of a counter in
1691
+ // [0, 7]); a compare on the way to the access (`if (i < 8) a[i]`); a loop counter its latch bounds,
1692
+ // by induction where the latch bounds one side; and linear forms of them. The range is widened by
1693
+ // the access's width — the stride is dropped, so the bytes are a superset of the ones reached.
1694
+ // Every element is at displacement 0 from the sum (above), so the address is exactly the capture
1695
+ // plus the index. An index nothing bounds, or one whose sum could wrap past the top of the address
1696
+ // space back below the object, reaches every byte (`null`).
1054
1697
  function indexedAccess(
1055
1698
  irBlocks: readonly Block[],
1056
- {
1057
- indexed,
1058
- accessOps,
1059
- }: { indexed: Map<number, { width: number; signed: boolean }[]>; accessOps: Map<Op, FrameAccessOp> },
1699
+ { indexed, accessOps }: { indexed: Map<number, IndexedAccess[]>; accessOps: Map<Op, FrameAccessOp> },
1060
1700
  object: FrameAccessOp,
1061
- sum: Value,
1701
+ { sum, range }: { sum: Value; range: StridedRange | undefined },
1702
+ constOf: (v: Value) => number | undefined,
1062
1703
  fail: Refuse,
1063
1704
  ): void {
1064
1705
  const { off } = object;
1065
1706
  const got = indexed.get(off) ?? indexed.set(off, []).get(off)!;
1066
- for (const blk of irBlocks) {
1067
- for (const op of blk.ops) {
1068
- const roles = [
1069
- ...op.operands.flatMap((v, i) => (v === sum ? [i] : [])),
1070
- ...(op.successors ?? []).flatMap((s) => (s.args.includes(sum) ? [-1] : [])),
1071
- ];
1072
- for (const i of roles) {
1073
- if ((op.opcode !== 'load' && op.opcode !== 'store') || i !== 0 || (op.attrs.off as number) !== 0) {
1074
- fail(
1075
- `a runtime index into the object at [sp,#${off}) flows into \`${i === -1 ? 'a phi' : op.opcode}\` — ` +
1076
- 'only a load or store at the indexed address is modelled',
1077
- );
1707
+ const reach = (width: number): FrameReach => {
1708
+ const starts = range === undefined ? null : displaced(range, off, width);
1709
+ return starts === null ? null : { lo: starts.lo, hi: starts.hi + width };
1710
+ };
1711
+ const judge = (at: Value, by: number): void => {
1712
+ for (const blk of irBlocks) {
1713
+ for (const op of blk.ops) {
1714
+ const roles = [
1715
+ ...op.operands.flatMap((v, i) => (v === at ? [i] : [])),
1716
+ ...(op.successors ?? []).flatMap((s) => (s.args.includes(at) ? [-1] : [])),
1717
+ ];
1718
+ for (const i of roles) {
1719
+ const k =
1720
+ at === sum && op.opcode === 'add' && op.operands.length === 2 && i >= 0
1721
+ ? constOf(op.operands[1 - i])
1722
+ : undefined;
1723
+ if (k !== undefined) {
1724
+ judge(op.results[0], k);
1725
+ continue;
1726
+ }
1727
+ const access = (op.opcode === 'load' || op.opcode === 'store') && i === 0;
1728
+ if (!access) {
1729
+ fail(
1730
+ `a runtime index into the object at [sp,#${off}) flows into \`${i === -1 ? 'a phi' : op.opcode}\` — ` +
1731
+ 'only a load or store at the indexed address is modelled',
1732
+ );
1733
+ }
1734
+ const at0 = (by + (op.attrs.off as number)) | 0;
1735
+ if (at0 !== 0) {
1736
+ fail(
1737
+ `a runtime index into the object at [sp,#${off}) is accessed at displacement ${at0} from the indexed element — ` +
1738
+ 'only a load or store at the indexed address is modelled',
1739
+ );
1740
+ }
1741
+ const width = op.attrs.width as number;
1742
+ const index = { width, signed: op.opcode === 'load' && op.attrs.signed === true, reach: reach(width) };
1743
+ got.push(index);
1744
+ accessOps.set(op, { ...object, index });
1078
1745
  }
1079
- got.push({ width: op.attrs.width as number, signed: op.opcode === 'load' && op.attrs.signed === true });
1080
- accessOps.set(op, object);
1081
1746
  }
1082
1747
  }
1083
- }
1748
+ };
1749
+ judge(sum, 0);
1750
+ }
1751
+
1752
+ /** Why two access types on one byte refuse, as every refusal of it words it. THE RULE IS AGBCC'S
1753
+ * AND IS HELD ON EVERY TARGET: agbcc at -O2 serves a read of one type past a store of the other
1754
+ * (`indexedWidth` has the compiled cases), while IDO 7.1, KMC gcc and gcc 2.7.2 reload past every such
1755
+ * store, so on MIPS it is only more cautious than it has to be. The text names no compiler of the
1756
+ * input, since the audit is handed none. */
1757
+ const STALE_ACROSS_TYPES =
1758
+ 'and type-based alias analysis can let a read of one type pass a store of the other (agbcc at -O2 does, ' +
1759
+ 'and the audit refuses it on every target) — a cast spelling could read stale';
1760
+
1761
+ /** The one width wider than a byte that runtime-indexed accesses take, or why there is not one.
1762
+ *
1763
+ * A runtime index names no byte, so its access type must agree with every access it may reach,
1764
+ * and "agree" is what stops agbcc reordering two of them. At -O2 it runs type-based alias
1765
+ * analysis (toplev.c:3616), and `c_get_alias_set` (c-common.c:1892-2005) gives an integer type
1766
+ * and its unsigned twin one set, a character type set 0 (conflicting with every set), a
1767
+ * member access its member's type's set, and any other type — a pointer included — a new set.
1768
+ * Compiled at the corpus's flags, `k(sp0); ((int *)sp0)[i] = 1; ((T *)sp0)[j] = 0; return
1769
+ * ((int *)sp0)[i];` reloads the word for T = int, unsigned int and unsigned char, and returns the
1770
+ * stale 1 for T = unsigned short, void * and long.
1771
+ *
1772
+ * So ONE WIDTH IS ONE SET only while every access of that width READS AT one integer type or its
1773
+ * twin: a cast on the storage to `s32`/`u32` (`int`/`unsigned int` in every agbcc project), or a
1774
+ * member of an element struct (raise/struct-arrays) whose FIELD is such an integer — never the pointer an element
1775
+ * may hold. `/ptr-field`'s `over-local-storage` gate keeps such a field an integer, and contracts.ts
1776
+ * `assertLocalStorageStaysInteger` holds every respelled tree to it. The storage stays
1777
+ * `u8 name[n]` whatever the width. */
1778
+ function indexedWidth(accesses: readonly { readonly width: number }[]): {
1779
+ width: number | null;
1780
+ refusal: string | null;
1781
+ } {
1782
+ const widths = [...new Set(accesses.map((x) => x.width))].filter((w) => w !== 1);
1783
+ return {
1784
+ width: widths[0] ?? null,
1785
+ refusal: widths.length > 1 ? `accesses ${widths.join(' and ')} bytes wide, ${STALE_ACROSS_TYPES}` : null,
1786
+ };
1084
1787
  }
1085
1788
 
1086
1789
  /** The bytes an escape may reach, `[lo, hi)` from the object's own offset, and how it reads them
@@ -1089,6 +1792,10 @@ interface ReadWindow {
1089
1792
  readonly lo: number;
1090
1793
  readonly hi: number;
1091
1794
  readonly why: string;
1795
+ /** bounded only because a store through a computed pointer provably misses the control
1796
+ * halfword (`misses`): without that proof the read is unbounded, and the one-object answer
1797
+ * that held it then stays on offer (`chooseFrameModel`) */
1798
+ readonly sparedByMiss?: true;
1092
1799
  }
1093
1800
 
1094
1801
  /** Each op's block and index, over the IR as the classification judged it. */
@@ -1123,8 +1830,9 @@ const unbounded = (why: string): ReadWindow => ({ lo: -Infinity, hi: Infinity, w
1123
1830
  * the control stores that bound it are the ones reachable in between. Each has to be a literal
1124
1831
  * the target decodes. A store through a pointer this cannot resolve — one that is this frame's
1125
1832
  * on only some paths included (`frameOnEveryPath`) — may BE the control halfword, so it leaves
1126
- * the read unbounded — as does a transfer never armed here at all. An unbounded device read
1127
- * says which of those it met (`why`): each is a different capability to build. A block transfer
1833
+ * the read unbounded; so does a transfer never armed here at all. A store whose every address
1834
+ * misses that halfword (`misses`) is not such a store. An unbounded device read says which of
1835
+ * those it met (`why`): each is a different capability to build. A block transfer
1128
1836
  * that took the address as its source adds the bytes its control word reads (`calleeReads`).
1129
1837
  *
1130
1838
  * A NAMED SYMBOL PLUS A CONSTANT IS RESOLVED WHERE THE SYMBOL MAP PLACES THE NAME, and then
@@ -1154,9 +1862,11 @@ function readWindow(
1154
1862
  target: TargetDescription;
1155
1863
  },
1156
1864
  ): ReadWindow {
1157
- // a callee handed only its own return storage writes the struct it returns and nothing else
1865
+ // a callee handed only its own return storage writes the struct it returns and nothing else —
1866
+ // and this function's own reads of it hand the address to nobody
1158
1867
  const temps = uses.returnTemps.get(off);
1159
- if (temps !== undefined && uses.useCount.get(off) === temps.length) {
1868
+ const reads = uses.accesses.get(off)?.filter((a) => a.isLoad).length ?? 0;
1869
+ if (temps !== undefined && uses.useCount.get(off) === temps.length + reads) {
1160
1870
  return { lo: 0, hi: returnedSize(temps[0]), why: 'that returns its struct into it' };
1161
1871
  }
1162
1872
  const control = target.capabilities.readSourceControl;
@@ -1170,6 +1880,7 @@ function readWindow(
1170
1880
  }
1171
1881
  const little = target.capabilities.endianness === 'little';
1172
1882
  const halves: number[] = [];
1883
+ let spared = false;
1173
1884
  for (const { op: handed, sink } of stores) {
1174
1885
  const cnt = sink + control.offset;
1175
1886
  let armed = false;
@@ -1185,7 +1896,10 @@ function readWindow(
1185
1896
  const base = facts.literalAddrOf(op.operands[0]);
1186
1897
  if (base === undefined) {
1187
1898
  if (!flow.frameOnEveryPath.has(op.operands[0])) {
1188
- return unbounded('a later store through an unresolved pointer may re-arm');
1899
+ if (!misses(op, facts, cnt, blk)) {
1900
+ return unbounded('a later store through an unresolved pointer may re-arm');
1901
+ }
1902
+ spared = true;
1189
1903
  }
1190
1904
  continue;
1191
1905
  }
@@ -1226,7 +1940,44 @@ function readWindow(
1226
1940
  lo = Math.min(lo, reach.lo);
1227
1941
  hi = Math.max(hi, reach.hi);
1228
1942
  }
1229
- return { lo, hi, why: 'that reads through it' };
1943
+ return { lo, hi, why: 'that reads through it', ...(spared ? { sparedByMiss: true as const } : {}) };
1944
+ }
1945
+
1946
+ /** The addresses a strided range moved by `off` may hold, where an access `width` bytes wide at
1947
+ * any of them stays inside one address space — or null where a start could fall below 0 or an
1948
+ * access wrap past 2^32, so no interval names the bytes it touches. What a runtime index into the
1949
+ * frame may reach (`indexedAccess`) and where a computed store may land (`misses`) both read it. */
1950
+ function displaced(r: StridedRange, off: number, width: number): StridedRange | null {
1951
+ const lo = r.lo + off;
1952
+ const hi = r.hi + off;
1953
+ return lo < 0 || hi + width > 2 ** 32 ? null : { lo, hi, stride: r.stride };
1954
+ }
1955
+
1956
+ /** A store through a computed pointer that provably misses the control halfword at `cnt`: no
1957
+ * address its base may hold (`addressRangeOf`, a strided interval), displaced by the store's own
1958
+ * offset, starts a store whose bytes overlap `[cnt, cnt + 2)`. agbcc's `gEnts[i].f = 1` after a
1959
+ * transfer, `i` a `u8`, writes one byte at `gEnts + 4 + 8 * [0, 255]`, and no `i` makes that the
1960
+ * channel's control register — so it cannot re-arm the channel, and the window stays where the
1961
+ * literal stores put it. The stride decides it where the interval alone cannot: placed at
1962
+ * 0x04000000 that span covers 0x040000DE, and still no `i` lands on it. Not "the transfer is
1963
+ * over": an immediate transfer is, but a later store that sets the enable bit again reloads the
1964
+ * source register and reads the frame anew (GBATEK, DMA control bit 15), which is what this
1965
+ * guards against. A store this cannot bound on every value stays an unresolved pointer. */
1966
+ function misses(op: Op, facts: FrameAddressFacts, cnt: number, blk: Block): boolean {
1967
+ const r = facts.addressRangeOf(op.operands[0], blk);
1968
+ if (r === undefined) {
1969
+ return false;
1970
+ }
1971
+ const width = op.attrs.width as number;
1972
+ const starts = displaced(r, op.attrs.off as number, width);
1973
+ if (starts === null) {
1974
+ return false;
1975
+ }
1976
+ const { lo, hi } = starts;
1977
+ // the first start at or above the lowest one that overlaps, against the highest that does
1978
+ const from = cnt - width + 1;
1979
+ const first = from <= lo || r.stride === 0 ? lo : lo + Math.ceil((from - lo) / r.stride) * r.stride;
1980
+ return first > Math.min(hi, cnt + 1) || first < from;
1230
1981
  }
1231
1982
 
1232
1983
  /** Which model the audit judges the frame in. `refused` is the one mutable fact the audit
@@ -1234,6 +1985,9 @@ function readWindow(
1234
1985
  * one-object answer is on offer, and read by the relift test. */
1235
1986
  interface ModelChoice {
1236
1987
  readonly onOffer: boolean;
1988
+ /** the escapes whose read is unbounded, or was before `misses` bounded it (`sparedByMiss`) — the
1989
+ * one fact both the offer and the relift test (`answered`) read, so the miss proof moves neither */
1990
+ readonly unboundedAt: ReadonlySet<number>;
1237
1991
  refused: boolean;
1238
1992
  /** refuses `why` where it stands, or, with the one-object answer on offer, asks for it instead */
1239
1993
  shapeRefused(why: string): void;
@@ -1264,27 +2018,56 @@ function writerNotKept(uses: FrameUses, returnsWithoutHiddenPointer: (callee: st
1264
2018
  return null;
1265
2019
  }
1266
2020
 
1267
- /** THE MODEL IS CHOSEN BEFORE ANY SHAPE IS JUDGED. Where one escape reaches without bound and the
1268
- * one-object answer can hold every writer (`writerNotKept`), that answer is on offer — two
1269
- * out-parameters handed to one callee as readily as a device read — and the shapes the
1270
- * per-object model refuses are ones it can hold: a member at [+k] through a capture, two widths
1271
- * or two signednesses at one address, a runtime index, overlapping objects, a second object
1272
- * beside an untyped one, an object over a slot. So there each of those refusals asks for that
1273
- * answer instead of declining (`shapeRefused`), and the second audit judges the bytes as one
1274
- * object by its own rules — two types at one byte still refuse there. Where the answer is not on offer, each refuses where it stands. */
1275
- function chooseFrameModel(
1276
- oneObject: FrameRange | undefined,
1277
- uses: FrameUses,
1278
- windows: ReadonlyMap<number, ReadWindow>,
1279
- returnsWithoutHiddenPointer: (callee: string) => boolean,
1280
- fail: Refuse,
1281
- ): ModelChoice {
1282
- const unbounded =
1283
- oneObject === undefined && [...windows.values()].some((w) => w.lo === -Infinity && w.hi === Infinity);
2021
+ /** THE MODEL IS CHOSEN BEFORE ANY SHAPE IS JUDGED. The one-object answer is on offer in two
2022
+ * frames. Where one escape reaches without bound and that answer can hold every writer
2023
+ * (`writerNotKept`) — two out-parameters handed to one callee as readily as a device read. And
2024
+ * where NO ADDRESS ESCAPES AT ALL: then there is no writer and no reader but this function's own
2025
+ * accesses, so keeping every word of the declared area in memory keeps every store and every load
2026
+ * the machine made there, and a spill kept in memory changes no value. That second frame is the
2027
+ * one a by-value struct parameter leaves: agbcc spills its registers word by word (`str r1, [sp]`,
2028
+ * the slot model's) and, Thumb having no sp-relative halfword or byte load, reads its members
2029
+ * through `mov rD, sp` (the object's) — `int f(struct Q { u8 a[9]; } p){ return p.a[1] + p.a[8]; }`,
2030
+ * or a member read signed and unsigned, or the struct copied out a word at a time. The shapes the
2031
+ * per-object model refuses are ones that answer can hold: a member at [+k] through a capture, two
2032
+ * widths or two signednesses at one address, a runtime index, overlapping objects, a second
2033
+ * object beside an untyped one, an object over a slot. So there each of those refusals asks for
2034
+ * that answer instead of declining (`shapeRefused`), and the second audit judges the bytes as one
2035
+ * object by its own rules — two types at one byte are a union there, or refuse. Where the answer
2036
+ * is not on offer, each refuses where it stands.
2037
+ *
2038
+ * A frame nothing escapes from can still hold a CALL'S STACK ARGUMENT: agbcc builds one it cannot
2039
+ * place with `str rX, [sp,#k]` through `mov rD, sp` — a by-value struct block-copied with
2040
+ * `ldmia`/`stmia`, a compound literal stored a byte or a halfword at a time — and the address
2041
+ * escapes nowhere. Kept here as a local, in either model, those stores outlive the call that took
2042
+ * them. That is `storedNeverRead`'s to refuse, the stage after this one, and it asks it of every
2043
+ * call, whatever a prototype says. */
2044
+ function chooseFrameModel({
2045
+ oneObject,
2046
+ uses,
2047
+ windows,
2048
+ returnsWithoutHiddenPointer,
2049
+ fail,
2050
+ }: {
2051
+ oneObject: FrameRange | undefined;
2052
+ uses: FrameUses;
2053
+ windows: ReadonlyMap<number, ReadWindow>;
2054
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
2055
+ fail: Refuse;
2056
+ }): ModelChoice {
2057
+ const unboundedAt = new Set(
2058
+ [...windows]
2059
+ .filter(([, w]) => (w.lo === -Infinity && w.hi === Infinity) || w.sparedByMiss === true)
2060
+ .map(([off]) => off),
2061
+ );
2062
+ const unbounded = oneObject === undefined && unboundedAt.size > 0;
1284
2063
  const writer = unbounded ? writerNotKept(uses, returnsWithoutHiddenPointer) : null;
1285
- const onOffer = unbounded && writer === null;
2064
+ const escapeFree = oneObject === undefined && uses.escaped.size === 0;
2065
+ // A struct passed by value is ONE object of its declared type, which the call names whole; the
2066
+ // one object is bytes, and a call handed them would pass a pointer.
2067
+ const onOffer = uses.byValueReads.size === 0 && ((unbounded && writer === null) || escapeFree);
1286
2068
  const model: ModelChoice = {
1287
2069
  onOffer,
2070
+ unboundedAt,
1288
2071
  refused: false,
1289
2072
  shapeRefused: (why) => {
1290
2073
  if (!onOffer) {
@@ -1302,6 +2085,226 @@ function chooseFrameModel(
1302
2085
  return model;
1303
2086
  }
1304
2087
 
2088
+ /** A FRAME STORE THAT REACHES A CALL UNREAD, BENEATH EVERY WORD PROVEN A LOCAL, MAY BE THAT
2089
+ * CALL'S STACK ARGUMENT — refuse. agbcc builds an argument it cannot place with `str rX, [sp,#k]`
2090
+ * IN PLACE through a capture: a by-value struct is `mov rD, sp` + `stmia`, a compound literal is
2091
+ * `strb`/`strh` through `mov rD, sp` moved by `add rD, #k`. The stack-argument analysis
2092
+ * (stackargs.ts) sees `[sp,#k]` slot stores alone, so it never weighs these, and no prototype
2093
+ * bounds them: a callee no prototype states counts argument registers alone, and a stated list is
2094
+ * a LOWER bound on the words a call takes — a variadic callee's list is a prefix, and DWARF's
2095
+ * `symbolPrototype` drops the `...`. Whatever model then holds the bytes, a local per capture or
2096
+ * the one object, keeps the stores and the call loses its stack words:
2097
+ * `th(1, 2, 3, 4, (struct S){{x, y}})` lifts as `th(1, 2, 3, 4)`. So this asks EVERY call.
2098
+ *
2099
+ * A store is asked when it reaches a call PENDING — on some path from it to the call nothing loads
2100
+ * a byte it wrote, and on EVERY path from the entry some byte of it is written by such a store and
2101
+ * not loaded since — since a word the callee takes is one the caller wrote for it on every path
2102
+ * to the call and did not read back. A store through a pointer that is the
2103
+ * frame's on only some paths (`*(p ? p : &local) = x`) is not asked: it is not a store the
2104
+ * outgoing block alone can own. Nor is a store a runtime index or an escaped address reaches from
2105
+ * an object at or below it: whoever that hands the bytes to may read them.
2106
+ *
2107
+ * AND ONLY A STORE BENEATH THE LOADED-WORD BOUND. Under `localsAboveOutgoingArea` a compiler keeps
2108
+ * one outgoing area at the frame bottom, below every local, and never reads it back after a call;
2109
+ * so a frame byte loaded after a call before anything re-stores it held a value across that call,
2110
+ * its word is a local's, and so is every word above it. Under the strong form,
2111
+ * `outgoingAreaNeverLoadedFrom`, any load in a word at or above that offset bounds it: from 0 on
2112
+ * agbcc, past the a0..a3 home on O32 (whose compilers load a by-value aggregate's home words back)
2113
+ * and from the parameter area on mwcc (`target.ts` says why). The frontend measures that bound
2114
+ * once, off its instructions, and hands it here (`loadedWords`) — the same number its own
2115
+ * stack-argument refusals take (stackargs' `loadedWordBound` on Thumb).
2116
+ * Every compiled outgoing-argument shape sits beneath any such bound, and a local the source reads
2117
+ * after a call does not: `g(); b[0] = x; b[1] = y; return b[0];` reaches no call, `b[0] = x; g();
2118
+ * return b[0];` is above the bound. What this still refuses that is honest is a local the source
2119
+ * stores, never reads back, and calls past — a dead local and an outgoing argument are the same
2120
+ * machine code, and the lift cannot tell which. The after-call liveness weighs this audit's fixed
2121
+ * accesses alone, so it sits at or above the bound the frontend's slot loads would give, which
2122
+ * only asks more; the strong form adds the frontend's bound (`loadedWords`). Without either
2123
+ * premise the bound proves nothing, and every pending store is asked. */
2124
+ function storedNeverRead(
2125
+ irBlocks: readonly Block[],
2126
+ uses: FrameUses,
2127
+ loadedWords: FrameObjectAudit['loadedWords'],
2128
+ target: TargetDescription,
2129
+ fail: Refuse,
2130
+ ): void {
2131
+ const calls = irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'call'));
2132
+ if (calls.length === 0) {
2133
+ return;
2134
+ }
2135
+ const byOp = new Map(uses.fixed.map((a) => [a.op, a]));
2136
+ const bytes = (a: { at: number; width: number }): number[] => [...Array(a.width).keys()].map((i) => a.at + i);
2137
+ const index = new Map(irBlocks.map((b, i) => [b, i]));
2138
+ const succs = irBlocks.map((b) => successorsOf(b).map((s) => index.get(s)!));
2139
+ const live = new Set(irBlocks.length > 0 ? [0] : []);
2140
+ for (const stack = [...live]; stack.length;) {
2141
+ for (const s of succs[stack.pop()!]) {
2142
+ if (!live.has(s)) {
2143
+ live.add(s);
2144
+ stack.push(s);
2145
+ }
2146
+ }
2147
+ }
2148
+
2149
+ // THE SURVIVOR BOUND: a backward liveness over the frame bytes this function reads at fixed
2150
+ // offsets through an address that is the frame's on every path. A load makes its bytes live, a
2151
+ // store that writes them kills them, and a call is transparent but marks every byte live across
2152
+ // it. A load whose address may be elsewhere proves nothing and a store that may write elsewhere
2153
+ // kills nothing; nor does a block the entry cannot reach.
2154
+ let bound = Infinity;
2155
+ if (target.compilerBehaviors.localsAboveOutgoingArea === true) {
2156
+ const liveIn = irBlocks.map(() => new Set<number>());
2157
+ for (let changed = true; changed;) {
2158
+ changed = false;
2159
+ for (let b = irBlocks.length - 1; b >= 0; b--) {
2160
+ if (!live.has(b)) {
2161
+ continue;
2162
+ }
2163
+ const cur = new Set(succs[b].flatMap((s) => [...liveIn[s]]));
2164
+ for (const op of [...irBlocks[b].ops].reverse()) {
2165
+ const a = byOp.get(op);
2166
+ if (op.opcode === 'call') {
2167
+ for (const o of cur) {
2168
+ bound = Math.min(bound, o - (o % 4));
2169
+ }
2170
+ } else if (a?.frameOnly) {
2171
+ for (const o of bytes(a)) {
2172
+ if (a.isLoad) {
2173
+ cur.add(o);
2174
+ } else {
2175
+ cur.delete(o);
2176
+ }
2177
+ }
2178
+ }
2179
+ }
2180
+ if ([...cur].some((o) => !liveIn[b].has(o))) {
2181
+ liveIn[b] = cur;
2182
+ changed = true;
2183
+ }
2184
+ }
2185
+ }
2186
+ }
2187
+ // …and under the STRONG form (`outgoingAreaNeverLoadedFrom`) the caller loads no word of the
2188
+ // area from that offset up before a call either, so ANY frame byte loaded there bounds it. The
2189
+ // frontend measured that bound off its instructions (`loadedWords`): the slot reloads the IR
2190
+ // does not show, and the prologue's and epilogue's own loads — without them
2191
+ // `mov r3, sp; strh r0, [r3]; … bl sink; ldr r0, [sp]` refuses the `strh` on a word the code
2192
+ // itself proves a local's. What only this audit sees is a load through a captured address, in a
2193
+ // block the entry reaches, and it bounds the same way. A load in a word below the offset proves
2194
+ // nothing: O32 compilers load the a1..a3 home words they stage a by-value aggregate in.
2195
+ if (loadedWords !== undefined) {
2196
+ bound = Math.min(bound, loadedWords.bound);
2197
+ for (const b of live) {
2198
+ for (const op of irBlocks[b].ops) {
2199
+ const a = byOp.get(op);
2200
+ const word = a === undefined ? -1 : a.at - (a.at % 4);
2201
+ if (a?.isLoad && a.frameOnly && word >= loadedWords.from) {
2202
+ bound = Math.min(bound, word);
2203
+ }
2204
+ }
2205
+ }
2206
+ }
2207
+ const from = loadedWords?.from;
2208
+
2209
+ // THE PENDING STORES, two forward dataflows from the entry over the blocks it reaches. The MAY
2210
+ // set is the stores this asks that some path brings to a point unread: a load of any byte a
2211
+ // store wrote ends it — an argument is a word the caller writes and never reads, so a store
2212
+ // with any byte read is a local's — as a frame store covering every byte it wrote does. The
2213
+ // MUST set is the bytes every path brings to a point written by a store this asks and not
2214
+ // loaded since: a load ends the bytes it reads, a frame store the bytes it writes, and a store
2215
+ // that may write elsewhere ends nothing. An argument is staged on EVERY path to its call — a
2216
+ // tail-merged call is staged in each predecessor — so a byte some path reaches the call without
2217
+ // is not this call's to take: a loop's store that the first trip calls past is a local's.
2218
+ const reachedFrom = Math.min(...uses.escaped, ...uses.indexed.keys());
2219
+ type Fixed = FrameUses['fixed'][number];
2220
+ const asked = (a: Fixed): boolean => !a.isLoad && a.frameOnly && a.at + a.width <= reachedFrom && a.at < bound;
2221
+ interface Pending {
2222
+ may: Set<Fixed>;
2223
+ must: Set<number>;
2224
+ }
2225
+ const step = (op: Op, cur: Pending): void => {
2226
+ const a = byOp.get(op);
2227
+ if (a === undefined) {
2228
+ return;
2229
+ }
2230
+ const touched = new Set(bytes(a));
2231
+ for (const p of [...cur.may]) {
2232
+ const mine = bytes(p);
2233
+ if (a.isLoad ? mine.some((o) => touched.has(o)) : a.frameOnly && mine.every((o) => touched.has(o))) {
2234
+ cur.may.delete(p);
2235
+ }
2236
+ }
2237
+ if (a.isLoad || a.frameOnly) {
2238
+ for (const o of touched) {
2239
+ cur.must.delete(o);
2240
+ }
2241
+ }
2242
+ if (asked(a)) {
2243
+ cur.may.add(a);
2244
+ for (const o of touched) {
2245
+ cur.must.add(o);
2246
+ }
2247
+ }
2248
+ };
2249
+ const into: (Pending | undefined)[] = irBlocks.map((_, b) =>
2250
+ b === 0 ? { may: new Set(), must: new Set() } : undefined,
2251
+ );
2252
+ for (let changed = true; changed;) {
2253
+ changed = false;
2254
+ for (let b = 0; b < irBlocks.length; b++) {
2255
+ const at = into[b];
2256
+ if (!live.has(b) || at === undefined) {
2257
+ continue;
2258
+ }
2259
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
2260
+ irBlocks[b].ops.forEach((op) => step(op, cur));
2261
+ for (const s of succs[b]) {
2262
+ const old = into[s];
2263
+ if (s === 0) {
2264
+ continue;
2265
+ }
2266
+ if (old === undefined) {
2267
+ into[s] = { may: new Set(cur.may), must: new Set(cur.must) };
2268
+ changed = true;
2269
+ continue;
2270
+ }
2271
+ const may = new Set([...old.may, ...cur.may]);
2272
+ const must = new Set([...old.must].filter((o) => cur.must.has(o)));
2273
+ if (may.size !== old.may.size || must.size !== old.must.size) {
2274
+ into[s] = { may, must };
2275
+ changed = true;
2276
+ }
2277
+ }
2278
+ }
2279
+ }
2280
+ for (let b = 0; b < irBlocks.length; b++) {
2281
+ const at = into[b];
2282
+ if (!live.has(b) || at === undefined) {
2283
+ continue;
2284
+ }
2285
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
2286
+ for (const op of irBlocks[b].ops) {
2287
+ // a struct a call is passed by value is read by that call (`byValueReads`), not handed over
2288
+ if (op.opcode === 'call' && byOp.has(op)) {
2289
+ step(op, cur);
2290
+ }
2291
+ if (op.opcode === 'call') {
2292
+ const store = [...cur.may].filter((p) => bytes(p).some((o) => cur.must.has(o))).sort((x, y) => x.at - y.at)[0];
2293
+ if (store !== undefined) {
2294
+ const callee = calleeName(op);
2295
+ fail(
2296
+ `the ${store.width}-byte store at [sp,#${store.at}) through a captured address reaches the call to ` +
2297
+ `${callee === undefined ? 'an indirect callee' : `\`${callee}\``} unread on every path, and no ` +
2298
+ `${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 ` +
2299
+ "that call's stack argument, which the lift would drop",
2300
+ );
2301
+ }
2302
+ }
2303
+ step(op, cur);
2304
+ }
2305
+ }
2306
+ }
2307
+
1305
2308
  /** THE ACCEPTANCE'S PREMISE, RE-ASKED OF THE IR. `capturedObjectIsTheWholeFrame` is a reading
1306
2309
  * of the TEXT and it is the one thing in this file that switches a refusal OFF, so the two
1307
2310
  * facts it claims are re-proven here, where they are exact, rather than left to the
@@ -1356,6 +2359,26 @@ function recheckWholeFramePremise({
1356
2359
  }
1357
2360
  }
1358
2361
 
2362
+ /** THE ESCAPE EVENT'S PREMISE, RE-ASKED OF THE IR — `recheckWholeFramePremise`'s first half at
2363
+ * any frame size. The outgoing-argument analysis lets a frame address leaving this function end
2364
+ * the pending stores from its offset up (`StackArgsEscape`), reading the escape off what a
2365
+ * REGISTER holds at a `bl` or a word `str`. Where its verdict leans on that (it would refuse
2366
+ * without the escapes), each offset it read must reach a call operand or a word store outside the
2367
+ * frame in the lifted function too. It catches an arity that shrinks after the event was read
2368
+ * without passing the lowering's `CallSite.leftOut` question. Same two arms, same width as the
2369
+ * licence they re-prove. */
2370
+ function recheckEscapesReliedOn(offsets: ReadonlySet<number>, uses: FrameUses | undefined, fail: Refuse): void {
2371
+ for (const off of offsets) {
2372
+ if (uses === undefined || (!uses.passedToCallee.has(off) && !uses.publishedOutward.has(off))) {
2373
+ fail(
2374
+ `the outgoing-argument analysis set aside the stores from [sp,#${off}] up on that address escaping, and in the ` +
2375
+ 'lifted function no call takes it and no word store publishes it outside this frame — so nothing rules out ' +
2376
+ 'an outgoing stack argument there',
2377
+ );
2378
+ }
2379
+ }
2380
+ }
2381
+
1359
2382
  /** NOT A STRUCT-RETURN TEMP. A one-word frame rules out agbcc's block-copy bases (each needs
1360
2383
  * two words) but NOT the hidden return pointer of a <=4-byte non-integer-like struct, which is
1361
2384
  * exactly one word: `struct S4 { char a,b,c,d; }; struct S4 s = mk(x);` compiles to `add
@@ -1475,8 +2498,11 @@ function failIfSlotKeysIt(
1475
2498
  /** ONE OBJECT IN MEMORY, the answer a device read or a callee write nothing bounds is given
1476
2499
  * instead of a refusal (`oneObject`, requested by the relift test where the escapes are judged).
1477
2500
  * Every byte of `[from, to)` is declared one `u8` array whose address the device or the callee
1478
- * holds (`writerNotKept` says which writers it can hold), and every access inside it is a
1479
- * cast-spelled access to that array — so each store the machine made there is a store the
2501
+ * holds (`writerNotKept` says which writers it can hold) — or one per part, where the accesses
2502
+ * prove the bytes are several objects no use reads across (`provenParts`) — and every access
2503
+ * inside it is a cast-spelled access to that array (an element of a part, by its index:
2504
+ * structure.ts `framePartDecls`), or a view of the union it is declared as where two widths
2505
+ * meet at one byte — so each store the machine made there is a store the
1480
2506
  * recompile makes too. That is what agbcc does for an object whose address escaped: it keeps
1481
2507
  * every store to it, in order (flow.c deletes a memory store only when an identical later
1482
2508
  * store in the same block overwrites it). And it is right whichever the source had there: a
@@ -1489,6 +2515,8 @@ function keepAsOneObject({
1489
2515
  uses,
1490
2516
  usedSlotOffsets,
1491
2517
  returnsWithoutHiddenPointer,
2518
+ arrayOrder,
2519
+ arrayAlignment,
1492
2520
  fail,
1493
2521
  }: {
1494
2522
  oneObject: FrameRange;
@@ -1497,6 +2525,8 @@ function keepAsOneObject({
1497
2525
  uses: FrameUses;
1498
2526
  usedSlotOffsets: ReadonlySet<number>;
1499
2527
  returnsWithoutHiddenPointer: (callee: string) => boolean;
2528
+ arrayOrder: TargetDescription['compilerBehaviors']['addressTakenArrayOrder'];
2529
+ arrayAlignment: TargetDescription['compilerBehaviors']['addressTakenArrayAlignment'];
1500
2530
  fail: Refuse;
1501
2531
  }): void {
1502
2532
  const kept = `the bytes [sp,#${from}) to [sp,#${to}) kept as one object`;
@@ -1514,22 +2544,41 @@ function keepAsOneObject({
1514
2544
  if (writer !== null) {
1515
2545
  fail(`${kept} cannot hold every writer — ${writer}`);
1516
2546
  }
1517
- // A runtime index names no byte, so no access type can be checked against the others at the
1518
- // bytes it reaches — except a byte access, which needs none: character types alias every type.
1519
- if ([...uses.indexed.values()].some((xs) => xs.some((x) => x.width !== 1))) {
1520
- fail(
1521
- `a runtime index into ${kept} accesses more than a byte, and names no byte whose type it can be checked against`,
1522
- );
1523
- }
1524
- // ONE TYPE PER BYTE. agbcc at -O2 turns on type-based alias analysis (toplev.c:3616), and
1525
- // compiled, a `u32` read through a cast is served the earlier `u32` store straight past a
1526
- // `u16` store to the same bytes — a stale value. So a byte two accesses of different widths
1527
- // reach cannot be spelled through casts. A byte access is exempt, since character types
1528
- // alias everything, and so is a signedness difference, since the signed and unsigned types
1529
- // of one width share an alias set (c-common.c:1962-1974). The first audit asks for this
1530
- // answer rather than refusing two widths — an object read at both, or a `strh` member through
1531
- // the captured address over a word the slot model had — so this is where they refuse.
2547
+ // A runtime index names no byte, so the rule below is asked of it at EVERY byte it may reach:
2548
+ // all of them where nothing bounds it, the bytes its `reach` names where one does — a byte no
2549
+ // index reaches is read at one type whatever the index's is, and accesses that share no byte
2550
+ // cannot serve each other a stale value. Two indexed widths that may share a byte refuse here;
2551
+ // an index beside a member of another width refuses after the members are checked against each
2552
+ // other, so a member pair keeps naming its own byte (`indexedWidth` says why one width is
2553
+ // enough).
2554
+ const indexes = [...uses.indexed.values()].flat();
2555
+ indexes.forEach((x, i) => {
2556
+ for (const y of indexes.slice(i + 1)) {
2557
+ const { refusal } = indexedWidth([x, y]);
2558
+ if (refusal !== null && mayShare(x.reach, y.reach)) {
2559
+ fail(`a runtime index into ${kept} ${refusal}`);
2560
+ }
2561
+ }
2562
+ });
2563
+ // ONE TYPE PER BYTE, OR ONE UNION. agbcc at -O2 turns on type-based alias analysis
2564
+ // (toplev.c:3616), and compiled, a `u32` read through a cast is served the earlier `u32` store
2565
+ // straight past a `u16` store to the same bytes — a stale value. So a byte two accesses of
2566
+ // different widths reach cannot be spelled through casts. A byte access is exempt, since
2567
+ // character types alias everything, and so is a signedness difference, since the signed and
2568
+ // unsigned types of one width share an alias set (c-common.c:1962-1974). The first audit asks
2569
+ // for this answer rather than refusing two widths — an object read at both, or a `strh` member
2570
+ // through the captured address over a word the slot model had — so this is where they are judged.
2571
+ //
2572
+ // What two widths at one byte CAN be spelled as is a UNION of per-width views, every access made
2573
+ // directly through it (`sp0.half[1]`): c_get_alias_set gives any access through a member of a
2574
+ // union alias set 0 (c-common.c, the COMPONENT_REF walk), which conflicts with every set, so no
2575
+ // read moves past a store to the same bytes — compiled, a `u16` read, a `u32` store and the same
2576
+ // read again reloads through the union and returns the stale value through casts. So the object
2577
+ // is stamped with the views its accesses use (`unionViews`) and declared as that union
2578
+ // (structure.ts `laddrType`), where every access to it is a fixed offset on the object itself,
2579
+ // aligned to its own width — the cases `unionRefusal` names refuse as before.
1532
2580
  const widthAt = new Map<number, number>();
2581
+ let twoWidths: { b: number; had: number; width: number } | undefined;
1533
2582
  for (const m of uses.members) {
1534
2583
  if (m.at < from || m.at + m.width > to) {
1535
2584
  fail(`an access of ${m.width} bytes at [sp,#${m.at}) reaches past ${kept}`);
@@ -1540,14 +2589,32 @@ function keepAsOneObject({
1540
2589
  for (let b = m.at; b < m.at + m.width; b++) {
1541
2590
  const had = widthAt.get(b);
1542
2591
  if (had !== undefined && had !== m.width) {
1543
- fail(
1544
- `the byte at [sp,#${b}] of ${kept} is accessed ${had} and ${m.width} bytes wide, and agbcc at -O2 ` +
1545
- 'lets a read of one type pass a store of the other (strict aliasing) — a cast spelling would read stale',
1546
- );
2592
+ twoWidths ??= { b, had, width: m.width };
1547
2593
  }
1548
2594
  widthAt.set(b, m.width);
1549
2595
  }
1550
2596
  }
2597
+ if (twoWidths !== undefined) {
2598
+ // any runtime index, a byte one too, and wherever its reach lies: `indexedWidth` leaves width
2599
+ // 1 out because a char access agrees with every width, but a union view is spelled for fixed
2600
+ // offsets on the object alone, and an indexed access is not one (the rewrite below refuses it)
2601
+ const why = unionRefusal({ from, to, uses, indexed: uses.indexed.size > 0 });
2602
+ if (why !== null) {
2603
+ fail(
2604
+ `the byte at [sp,#${twoWidths.b}] of ${kept} is accessed ${twoWidths.had} and ${twoWidths.width} bytes ` +
2605
+ `wide, ${STALE_ACROSS_TYPES}, and ${why}`,
2606
+ );
2607
+ }
2608
+ }
2609
+ for (const [b, had] of widthAt) {
2610
+ const by = indexes.find((x) => x.width !== 1 && x.width !== had && mayTouch(x.reach, b, 1));
2611
+ if (by !== undefined) {
2612
+ fail(
2613
+ `the byte at [sp,#${b}] of ${kept} is accessed ${had} bytes wide and by a runtime index ${by.width} ` +
2614
+ `bytes wide, ${STALE_ACROSS_TYPES}`,
2615
+ );
2616
+ }
2617
+ }
1551
2618
  // Rewritten onto one `laddr` at `from`: an access through a member at `k` becomes an access
1552
2619
  // at `k - from` off the object, and a member address used any other way becomes the object's
1553
2620
  // address moved by that constant.
@@ -1562,15 +2629,35 @@ function keepAsOneObject({
1562
2629
  // nothing here sees, so a spin on such a byte compiles unqualified; the per-object stamp has the
1563
2630
  // same gap (`stampObjects`: not on an ordinary `&local` argument).
1564
2631
  const reread = uses.publishedToWriter.size > 0;
1565
- const object = mkOp('laddr', {
1566
- results: [mkValue(T.unk(32))],
1567
- attrs: { off: from, width: 1, signed: false, count: to - from },
1568
- });
2632
+ // …and declared as SEVERAL objects where the accesses prove the bytes are several
2633
+ // (`provenParts`), each rewritten on its own: an access through a member at `k` lands on the
2634
+ // part that holds `k`, at `k` less the part's start. Only on a compiler that places address-
2635
+ // taken arrays in a known order against their declarations (`addressTakenArrayOrder`): the
2636
+ // parts are declared in that order, or the recompile swaps their addresses. And at a known
2637
+ // alignment (`addressTakenArrayAlignment`), which says where a part may start.
2638
+ const parts =
2639
+ twoWidths === undefined && arrayOrder !== undefined && arrayAlignment !== undefined
2640
+ ? provenParts({ from, to }, objects, uses, arrayAlignment)
2641
+ : [{ from, to }];
2642
+ // …stamped `part` where there are several: storage a copy proved one object of the source's, so
2643
+ // its bytes are that object's elements rather than the one object's mix of members and spills,
2644
+ // and the emitter may name them as elements (structure.ts `framePartDecls`), of the type their
2645
+ // accesses agree on (`stampPartElements`, below)
2646
+ const laddrs = parts.map(({ from: lo, to: hi }) =>
2647
+ mkOp('laddr', {
2648
+ results: [mkValue(T.unk(32))],
2649
+ attrs: { off: lo, width: 1, signed: false, count: hi - lo, ...(parts.length > 1 ? { part: true } : {}) },
2650
+ }),
2651
+ );
2652
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2653
+ const object = laddrs[0];
1569
2654
  const base = object.results[0];
1570
2655
  const memberAt = new Map<Value, number>();
1571
2656
  for (const op of [...objects.values()].flat()) {
1572
2657
  memberAt.set(op.results[0], op.attrs.off as number);
1573
2658
  }
2659
+ const baseAt = (at: number) => laddrs[partOf(at)].results[0];
2660
+ const startAt = (at: number) => parts[partOf(at)].from;
1574
2661
  // The qualifier goes on every access the classification judged one (`accessOps`) — through a
1575
2662
  // member, a runtime index into one, or a phi that carries one (`p = c ? buf : gOther; while
1576
2663
  // (*p == 0);`). Only an access through a member is rebased: a phi's incoming member is
@@ -1584,14 +2671,17 @@ function keepAsOneObject({
1584
2671
  op.attrs = { ...op.attrs, volatile: true };
1585
2672
  }
1586
2673
  if (access && at !== undefined) {
1587
- op.operands = [base, ...op.operands.slice(1)];
1588
- op.attrs = { ...op.attrs, off: (op.attrs.off as number) + at - from };
2674
+ op.operands = [baseAt(at), ...op.operands.slice(1)];
2675
+ op.attrs = { ...op.attrs, off: (op.attrs.off as number) + at - startAt(at) };
1589
2676
  }
1590
2677
  op.operands.forEach((v) => memberAt.has(v) && stillUsed.add(v));
1591
2678
  (op.successors ?? []).forEach((sx) => sx.args.forEach((v) => memberAt.has(v) && stillUsed.add(v)));
1592
2679
  }
1593
2680
  }
1594
- const atBase = (v: Value): Value => (memberAt.get(v) === from ? base : v);
2681
+ const atBase = (v: Value): Value => {
2682
+ const at = memberAt.get(v);
2683
+ return at !== undefined && at === startAt(at) ? baseAt(at) : v;
2684
+ };
1595
2685
  for (const blk of irBlocks) {
1596
2686
  blk.ops = blk.ops.flatMap((op) => {
1597
2687
  const at = op.opcode === 'laddr' ? memberAt.get(op.results[0]) : undefined;
@@ -1600,17 +2690,282 @@ function keepAsOneObject({
1600
2690
  (op.successors ?? []).forEach((sx) => (sx.args = sx.args.map(atBase)));
1601
2691
  return [op];
1602
2692
  }
1603
- if (!stillUsed.has(op.results[0]) || at === from) {
2693
+ if (!stillUsed.has(op.results[0]) || at === startAt(at)) {
1604
2694
  return [];
1605
2695
  }
1606
2696
  const by = mkValue(T.unk(32));
1607
2697
  return [
1608
- mkOp('const', { results: [by], attrs: { value: at - from } }),
1609
- mkOp('add', { operands: [base, by], results: op.results }),
2698
+ mkOp('const', { results: [by], attrs: { value: at - startAt(at) } }),
2699
+ mkOp('add', { operands: [baseAt(at), by], results: op.results }),
1610
2700
  ];
1611
2701
  });
1612
2702
  }
1613
- irBlocks[0].ops.unshift(object);
2703
+ if (twoWidths !== undefined) {
2704
+ // …every access is now a fixed offset on the object itself, which is what a view spells; one
2705
+ // that is not was carried there by a phi, as a pointer, and a pointer to the union's bytes is
2706
+ // read through a cast again
2707
+ for (const op of uses.accessOps.keys()) {
2708
+ if (op.operands[0] !== base) {
2709
+ fail(
2710
+ `the bytes [sp,#${from}) to [sp,#${to}) are accessed at two widths, which only a union of views ` +
2711
+ 'spells without a stale read — and an access reaches them through a pointer a phi carries, which ' +
2712
+ 'no view of the union names',
2713
+ );
2714
+ }
2715
+ }
2716
+ object.attrs = { ...object.attrs, views: unionViews(irBlocks, base) };
2717
+ }
2718
+ if (laddrs.length > 1) {
2719
+ stampPartElements(irBlocks, laddrs, uses.accessOps);
2720
+ }
2721
+ irBlocks[0].ops.unshift(...(arrayOrder === 'descending' ? [...laddrs].reverse() : laddrs));
2722
+ }
2723
+
2724
+ /** THE PARTS THE ACCESSES PROVE `[from, to)` IS MADE OF, lowest first — or the whole range, one
2725
+ * part, wherever they do not. A part is a maximal run of bytes that overlapping uses chain
2726
+ * together — a fixed-offset access, a struct passed by value, and an escape a standard block copy
2727
+ * with a literal count bounds (`escapeExtents`) each name the bytes they touch — and a run that
2728
+ * merely abuts the one below it starts a new part only where such a copy starts — and so must
2729
+ * the lowest part, or nothing is split. Two brace-initialised arrays side by side are agbcc's
2730
+ * `memcpy(a, .LC0, 16); memcpy(b, .LC2, 16)`, and declared as one `u8[32]` neither image can be
2731
+ * folded into its own local (l3/constinit.ts).
2732
+ *
2733
+ * ONE PART, never a refusal, whenever a byte could be read through two parts: a runtime index
2734
+ * nothing bounds (it names no byte, so it may reach every one) or one whose `reach` (the range
2735
+ * ir/bits.ts proves of the index: `a[(i & 7) * 2]`) crosses a part, an escape nothing bounds (a
2736
+ * callee or a holder that may reach any byte from the address it was given), a byte no use names
2737
+ * (nothing says which side it belongs to), or a use through a member that lands outside the part
2738
+ * its own address lies in (`&a[16]` read at [-1]: one pointer walking two parts is one object). The
2739
+ * union views of two widths at one byte are not split either (the caller asks only without
2740
+ * them). An access straddling two runs chains them into one part by construction. */
2741
+ function provenParts(
2742
+ { from, to }: FrameRange,
2743
+ objects: ReadonlyMap<number, readonly Op[]>,
2744
+ uses: FrameUses,
2745
+ alignment: number,
2746
+ ): FrameRange[] {
2747
+ const whole = [{ from, to }];
2748
+ const touched: { owner: number; lo: number; hi: number }[] = [];
2749
+ for (const [op, { off, index }] of uses.accessOps) {
2750
+ // …an access through a runtime index is not at its own displacement: it is its reach (below)
2751
+ if (index !== undefined) {
2752
+ continue;
2753
+ }
2754
+ const at = off + (op.attrs.off as number);
2755
+ touched.push({ owner: off, lo: at, hi: at + (op.attrs.width as number) });
2756
+ }
2757
+ // An escape and a runtime index each touch every byte they may reach (`frameReaches`), so each
2758
+ // chains the runs it spans as one access as wide would — a reach across a copy's start makes
2759
+ // the two copies one part — and a reach that starts in a part other than its object's fails the
2760
+ // owner test below
2761
+ const reaches = frameReaches(uses);
2762
+ for (const { owner, reach } of reaches) {
2763
+ if (reach === null) {
2764
+ return whole;
2765
+ }
2766
+ touched.push({ owner, ...reach });
2767
+ }
2768
+ for (const [off, reads] of uses.byValueReads) {
2769
+ reads.forEach(({ type }) =>
2770
+ touched.push({ owner: off, lo: off, hi: off + (type.kind === 'struct' ? (type.size ?? Infinity) : Infinity) }),
2771
+ );
2772
+ }
2773
+ // …and a boundary only where a bounded copy starts: an address handed to `memcpy` is where
2774
+ // the source put an object it fills. A run starting anywhere else is bytes reached from below,
2775
+ // which says nothing of where one object ends — the one-object relift reads every slot through
2776
+ // an address of its own, so a struct spilled word by word and read through its base shows a
2777
+ // run per word, and is one object at every word. The LOWEST part is held to the same test
2778
+ // (below): where it is only stores, a copy above it is as likely `memcpy(&buf[4], s, 12)` into
2779
+ // the middle of one array as the start of a second one, and declared as two the recompile
2780
+ // reads `buf[6]` through a base it holds where the target re-formed sp+6. Only a copy's
2781
+ // DESTINATION counts: a copy reading OUT of the frame says where the source took bytes from,
2782
+ // which is as likely `memcpy(g, &w[2], 8)` from the middle of one array as a second object.
2783
+ const starts = copyStarts(reaches);
2784
+ const parts: { from: number; to: number }[] = [];
2785
+ for (const { lo, hi } of [...touched].sort((a, b) => a.lo - b.lo)) {
2786
+ const last = parts.at(-1);
2787
+ if (last !== undefined && (lo < last.to || (lo === last.to && !starts.has(lo)))) {
2788
+ last.to = Math.max(last.to, hi);
2789
+ } else {
2790
+ parts.push({ from: lo, to: hi });
2791
+ }
2792
+ }
2793
+ const tiles =
2794
+ parts.length > 1 &&
2795
+ parts[0].from === from &&
2796
+ parts.at(-1)!.to === to &&
2797
+ parts.every((p, i) => i === 0 || parts[i - 1].to === p.from);
2798
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2799
+ if (
2800
+ !tiles ||
2801
+ [...objects.keys()].some((off) => partOf(off) < 0) ||
2802
+ touched.some(({ owner, lo }) => partOf(owner) !== partOf(lo))
2803
+ ) {
2804
+ return whole;
2805
+ }
2806
+ // …and a part starts only where the compiler places a local array of its own: at a multiple of
2807
+ // its `alignment` (agbcc pads `u8 a[18]` to 20, so a split there declares `b` at sp+20 where the
2808
+ // target had it at sp+18; gcc 2.7.2 on MIPS starts every one at a multiple of 8, so a part at
2809
+ // sp+20 is placed at sp+24 and the frame grows), and never as ONE byte, which declared alone is
2810
+ // a `u8` scalar whose address nothing takes — kept in a register, it drops the frame byte and
2811
+ // the store the target made. Such a part joins the one below it, and the lowest one byte joins
2812
+ // the one above: fewer parts only ever read as the one object does, so this costs a split,
2813
+ // never a value.
2814
+ const merged: { from: number; to: number }[] = [];
2815
+ for (const p of parts) {
2816
+ const last = merged.at(-1);
2817
+ if (last !== undefined && (p.from % alignment !== 0 || p.to - p.from === 1 || last.to - last.from === 1)) {
2818
+ last.to = p.to;
2819
+ } else {
2820
+ merged.push({ ...p });
2821
+ }
2822
+ }
2823
+ return merged.length > 1 && merged.every((p) => starts.has(p.from)) ? merged : whole;
2824
+ }
2825
+
2826
+ /** Why the one object's bytes, accessed at two widths, cannot be spelled as a union of per-width
2827
+ * views, or null. A view names an ELEMENT, so every access must sit at a multiple of its own width
2828
+ * from the object's start and every view must tile the object; and it names it at a constant
2829
+ * index, so a runtime index into the object — whose element width a view could carry, but whose
2830
+ * spelling nothing here builds — keeps the refusal, and so does a qualifier on the accesses
2831
+ * (`publishedToWriter`), which a member access of an unqualified union cannot carry. */
2832
+ function unionRefusal({
2833
+ from,
2834
+ to,
2835
+ uses,
2836
+ indexed,
2837
+ }: {
2838
+ from: number;
2839
+ to: number;
2840
+ uses: FrameUses;
2841
+ indexed: boolean;
2842
+ }): string | null {
2843
+ if (indexed) {
2844
+ return 'a runtime index reaches them too, which no view of a union is spelled for';
2845
+ }
2846
+ if (uses.publishedToWriter.size > 0) {
2847
+ return 'the address is stored where a writer may hold it, and a view of a union carries no per-access qualifier';
2848
+ }
2849
+ for (const m of uses.members) {
2850
+ if ((m.at - from) % m.width !== 0) {
2851
+ return `the ${m.width}-byte access at [sp,#${m.at}) is not aligned to its width, so no view of a union names it`;
2852
+ }
2853
+ if ((to - from) % m.width !== 0) {
2854
+ return `the ${to - from} bytes are not a whole number of ${m.width}-byte elements, so no view of a union tiles them`;
2855
+ }
2856
+ }
2857
+ return null;
2858
+ }
2859
+
2860
+ /** The views a union object's accesses use, as `laddr`'s `views` attr: one entry per view, its
2861
+ * element width in bytes, NEGATIVE for a signed narrow view — which views, by the rule
2862
+ * raise/structs.ts declares a struct member's union by (ir/types.ts `unionViewSigns`). A word's
2863
+ * one view is `s32` whichever sign it carries, so it is entered unsigned. Widest first, unsigned
2864
+ * before signed. */
2865
+ function unionViews(irBlocks: readonly Block[], base: Value): number[] {
2866
+ const byWidth = new Map<number, { isLoad: boolean; signed: boolean; stamped: boolean }[]>();
2867
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
2868
+ if ((op.opcode === 'load' || op.opcode === 'store') && op.operands[0] === base) {
2869
+ const w = op.attrs.width as number;
2870
+ (byWidth.get(w) ?? byWidth.set(w, []).get(w)!).push({
2871
+ isLoad: op.opcode === 'load',
2872
+ signed: (op.attrs.signed as boolean) ?? false,
2873
+ stamped: op.opcode === 'store' && op.attrs.signed !== undefined,
2874
+ });
2875
+ }
2876
+ }
2877
+ return [...byWidth]
2878
+ .sort(([a], [b]) => b - a)
2879
+ .flatMap(([w, accesses]) => unionViewSigns(w, accesses).map((sg) => (sg && w !== 4 ? -w : w)));
2880
+ }
2881
+
2882
+ /** THE ELEMENT EACH PROVEN PART IS DECLARED WITH, from the census of its own accesses — the way
2883
+ * `unionViews` types a union's views from theirs — stamped `elem` on the part's `laddr` (a width in
2884
+ * bytes, negative for a signed narrow one; absent is the storage default, `u8`).
2885
+ *
2886
+ * A part is an object of the source's (`provenParts`), and its bare element `sp64[i]` is not the
2887
+ * same compile as the cast `((u16 *)sp64)[i]` beside a second array whose base agbcc holds (see
2888
+ * structure.ts `framePartDecls`) — but the bare spelling exists only for an access of the declared
2889
+ * element's width and extension, so a part of `u16` or `s8` elements declared `u8` reaches no
2890
+ * access bare. Every access agreeing on one element is the evidence a declaration of it needs. An access that disagrees with the declaration keeps its cast, which is
2891
+ * one value under any declaration (structure.ts `bareArrayElement`), so the stamp never changes
2892
+ * what an access reads; nor what it aliases, since a bare element of a `u16` array is a `u16`
2893
+ * lvalue, the type the cast it replaces names.
2894
+ *
2895
+ * - One width over every access (a block copy or any other escape has none), aligned to it — the
2896
+ * part's start, its size and every fixed offset — else the storage default.
2897
+ * - Its extension from the LOADS: one extension is that one, both is no element (no evidence picks
2898
+ * between them). An unsigned byte read whose every reader sign-extends it is either: agbcc has
2899
+ * no `ldrsb` with an immediate offset and mwcc no signed byte load at all, so `s8 a[64]`'s `a[3]`
2900
+ * is that read (structure.ts `declaredExtendedRead` spells it bare once the part says `s8`). With
2901
+ * no load committing, such a read makes the element signed, and else a store raise/storesign.ts
2902
+ * stamped signed does, as `unionViewSigns` reads a view's.
2903
+ * - A word is one element whatever its loads say (`scalarTypeForAccess`).
2904
+ * An access whose part is not known — one through a pointer a phi carries, an index nothing bounds
2905
+ * — leaves every part at the default: its width is not counted, so no census is whole. */
2906
+ function stampPartElements(
2907
+ irBlocks: readonly Block[],
2908
+ laddrs: readonly Op[],
2909
+ accessOps: ReadonlyMap<Op, FrameAccessOp>,
2910
+ ): void {
2911
+ const bases = laddrs.map((d) => d.results[0]);
2912
+ const start = laddrs.map((d) => d.attrs.off as number);
2913
+ const end = laddrs.map((d) => (d.attrs.off as number) + (d.attrs.count as number));
2914
+ const users = new Map<Value, Op[]>();
2915
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
2916
+ op.operands.forEach((v) => (users.get(v) ?? users.set(v, []).get(v)!).push(op));
2917
+ }
2918
+ type Access = { width: number; at: number | null; load: boolean; signed: boolean | null; stamped: boolean };
2919
+ const census = laddrs.map((): Access[] => []);
2920
+ for (const [op, { index }] of accessOps) {
2921
+ const fixed = bases.indexOf(op.operands[0]);
2922
+ const reach = index?.reach ?? null;
2923
+ const part =
2924
+ fixed >= 0 ? fixed : reach === null ? -1 : start.findIndex((lo, i) => reach.lo >= lo && reach.hi <= end[i]);
2925
+ if (part < 0) {
2926
+ return;
2927
+ }
2928
+ const width = op.attrs.width as number;
2929
+ const load = op.opcode === 'load';
2930
+ // …a sign extension as the frontend lifts it: mwcc's `extsb`, or the `lsl #24; asr #24` pair
2931
+ // (the idiom stage folds it to a `sext` later, pattern/engine.ts `sextPat`)
2932
+ const extends8 = (u: Op): boolean =>
2933
+ (u.opcode === 'sext' && u.attrs.width === 8) ||
2934
+ (u.opcode === 'shl' &&
2935
+ u.attrs.imm === 24 &&
2936
+ (users.get(u.results[0]) ?? []).length > 0 &&
2937
+ users.get(u.results[0])!.every((s) => s.opcode === 'shr_s' && s.attrs.imm === 24));
2938
+ const readers = load ? (users.get(op.results[0]) ?? []) : [];
2939
+ const either = width === 1 && op.attrs.signed !== true && readers.length > 0 && readers.every(extends8);
2940
+ census[part].push({
2941
+ width,
2942
+ at: fixed >= 0 ? (op.attrs.off as number) : null,
2943
+ load,
2944
+ signed: !load || either ? null : op.attrs.signed === true,
2945
+ stamped: !load && op.attrs.signed === true,
2946
+ });
2947
+ }
2948
+ laddrs.forEach((d, i) => {
2949
+ const accesses = census[i];
2950
+ const widths = new Set(accesses.map((a) => a.width));
2951
+ if (widths.size !== 1) {
2952
+ return;
2953
+ }
2954
+ const [w] = widths;
2955
+ const count = d.attrs.count as number;
2956
+ if (start[i] % w !== 0 || count % w !== 0 || accesses.some((a) => a.at !== null && a.at % w !== 0)) {
2957
+ return;
2958
+ }
2959
+ const signs = new Set(accesses.flatMap((a) => (a.signed === null ? [] : [a.signed])));
2960
+ if (signs.size > 1) {
2961
+ return;
2962
+ }
2963
+ const signed = signs.size === 1 ? [...signs][0] : accesses.some((a) => a.load || a.stamped);
2964
+ const elem = signed && w !== 4 ? -w : w;
2965
+ if (elem !== 1) {
2966
+ d.attrs = { ...d.attrs, elem };
2967
+ }
2968
+ });
1614
2969
  }
1615
2970
 
1616
2971
  /** One object's shape: `count` elements of `width` bytes. */
@@ -1640,8 +2995,10 @@ function slotKeys(usedSlotOffsets: ReadonlySet<number>, off: number, width: numb
1640
2995
  * AN OBJECT OVER A SLOT IS REFUSED LAST, after the one-object answer is asked: that answer
1641
2996
  * routes every word of the local area through `laddr`, so no slot is left for the object to
1642
2997
  * overlap, and refusing first would decline exactly the frames it exists for — an object at
1643
- * [sp,#0] whose first member is a word stored `str rN, [sp]`. The other per-object refusals here
1644
- * go through `shapeRefused`, for the same reason. */
2998
+ * [sp,#0] whose first member is a word stored `str rN, [sp]`, or a by-value struct parameter
2999
+ * spilled word by word and read through `mov rD, sp` where nothing escapes. The other per-object
3000
+ * refusals here go through `shapeRefused`, for the same reason: where nothing escapes, each of
3001
+ * them asks for that answer too (`chooseFrameModel`). */
1645
3002
  function objectShapes({
1646
3003
  objects,
1647
3004
  uses,
@@ -1667,30 +3024,63 @@ function objectShapes({
1667
3024
  for (const [off, acc] of uses.accesses) {
1668
3025
  const byIndex = uses.indexed.get(off) ?? [];
1669
3026
  const temps = uses.returnTemps.get(off);
3027
+ const passed = uses.byValueReads.get(off);
3028
+ if (passed !== undefined) {
3029
+ // A struct passed BY VALUE (`foldByValueArgs`): this function writes and reads its members,
3030
+ // and the call reads it whole. A struct-return temp passed on, an index, or any other use of
3031
+ // the address is not modelled.
3032
+ const type = passed[0].type;
3033
+ const at = `the object at [sp,#${off}) is passed by value as ${structSpelling(type)}`;
3034
+ if (temps !== undefined) {
3035
+ fail(`${at}, and is also the storage a call returns a struct into — not modelled`);
3036
+ }
3037
+ if (passed.some((p) => !typeEquals(p.type, type))) {
3038
+ fail(`${at} to calls declared to take different types`);
3039
+ }
3040
+ if (byIndex.length > 0) {
3041
+ fail(`${at}, and this function also indexes it — only its members' accesses are modelled`);
3042
+ }
3043
+ if (uses.useCount.get(off) !== acc.length + passed.length) {
3044
+ fail(`${at}, and its address is also used another way — only its members' accesses are modelled`);
3045
+ }
3046
+ const size = type.kind === 'struct' ? type.size! : 0;
3047
+ const past = uses.fixed.find(
3048
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
3049
+ );
3050
+ if (past !== undefined) {
3051
+ fail(`${at}, and this function accesses [sp,#${past.at}) of width ${past.width}, past the struct`);
3052
+ }
3053
+ extent.set(off, { width: 1, count: size });
3054
+ aggregateAt.set(off, type);
3055
+ continue;
3056
+ }
1670
3057
  if (temps !== undefined) {
1671
- // The callee writes the whole struct and this function names it once, as the call's
1672
- // destination. A read of a member, or any other use of the address, is not modelled —
1673
- // and most such reads never arrive here: the Thumb slot model refuses a word of the temp
1674
- // read at a constant offset first ("stack pointer used as data"), a copy of the whole
1675
- // struct into a global (`gS8 = mk8(x)`, which agbcc stages through the temp) included. So
1676
- // modelling member reads starts with the slot model handing this audit a read return temp.
3058
+ // The callee writes the whole struct and this function names it as the call's destination,
3059
+ // and READS it: each load is of bytes inside the struct, which the struct's declaration
3060
+ // types (raise/aggmembers.ts reads a word's members, structure.ts spells a member access).
3061
+ // A write, an index, or any other use of the address is not modelled.
1677
3062
  const callee = temps[0].attrs.target as string;
1678
3063
  const type = temps[0].results[0].type;
1679
3064
  const spelling = type.kind === 'struct' ? (type.declared ?? `struct ${type.name}`) : typeToString(type);
3065
+ const at = `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}`;
1680
3066
  if (temps.some((t) => !typeEquals(t.results[0].type, type))) {
1681
3067
  fail(`the object at [sp,#${off}) is the struct-return storage of calls declared to return different types`);
1682
3068
  }
1683
- if (acc.length > 0 || byIndex.length > 0) {
3069
+ if (acc.some((a) => !a.isLoad) || byIndex.length > 0) {
1684
3070
  fail(
1685
- `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}, and this function also reads or ` +
1686
- 'writes it — a member of a returned struct is not modelled',
3071
+ `${at}, and this function also ${byIndex.length > 0 ? 'indexes' : 'writes'} it — only a read of a ` +
3072
+ 'returned struct is modelled',
1687
3073
  );
1688
3074
  }
1689
- if (uses.useCount.get(off) !== temps.length) {
1690
- fail(
1691
- `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}, and its address is also used ` +
1692
- 'another way — only the call it is returned by is modelled',
1693
- );
3075
+ if (uses.useCount.get(off) !== temps.length + acc.length) {
3076
+ fail(`${at}, and its address is also used another way — only the call it is returned by is modelled`);
3077
+ }
3078
+ const size = returnedSize(temps[0]);
3079
+ const past = uses.fixed.find(
3080
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
3081
+ );
3082
+ if (past !== undefined) {
3083
+ fail(`${at}, and this function reads [sp,#${past.at}) of width ${past.width}, past the struct`);
1694
3084
  }
1695
3085
  extent.set(off, { width: 1, count: returnedSize(temps[0]) });
1696
3086
  aggregateAt.set(off, type);
@@ -1721,26 +3111,18 @@ function objectShapes({
1721
3111
  // …asked for the one object instead, which sizes no object of its own
1722
3112
  continue;
1723
3113
  }
1724
- // An indexed access reads ONE ELEMENT of the storage declared below, so it must be one:
1725
- // an unsigned byte. A wider element is a different array over the same bytes, and a
1726
- // sign-extending read is not what `u8` spells.
1727
- for (const a of byIndex) {
1728
- if (a.width !== 1) {
1729
- model.shapeRefused(
1730
- `a runtime index into the object at [sp,#${off}) accesses ${a.width} bytes, and the storage ` +
1731
- 'nothing else types is declared as bytes — only a byte element is modelled',
1732
- );
1733
- }
1734
- if (a.signed) {
1735
- model.shapeRefused(
1736
- `a runtime index into the object at [sp,#${off}) sign-extends, and the storage is declared unsigned`,
1737
- );
1738
- }
3114
+ // An indexed access reads THROUGH A CAST on the storage declared below, at its own width
3115
+ // and signedness, as an access to the one object does (`keepAsOneObject`), so the accesses
3116
+ // wider than a byte must be of one width, one alias set (`indexedWidth`).
3117
+ const index = indexedWidth(byIndex);
3118
+ if (index.refusal !== null) {
3119
+ model.shapeRefused(`a runtime index into the object at [sp,#${off}) ${index.refusal}`);
1739
3120
  }
1740
3121
  // STORAGE, NOT A TYPE. The reservation says how many bytes the frame holds and the
1741
3122
  // conjuncts above say they are all this object's, so the declaration commits to an EXTENT
1742
3123
  // and to nothing else: `u8 name[n]` over the declared range, unsigned bytes because no
1743
3124
  // access named an element type and inventing one is the guess this refuses everywhere else.
3125
+ // An indexed access names only its own width, which its cast spells where it reads.
1744
3126
  //
1745
3127
  // THE EXTENT IS A ROUNDED ONE, stated because it is not a defect. agbcc reserves the
1746
3128
  // local area in whole words, so a source object of 13, 14, 15 or 16 bytes all reserve
@@ -2058,8 +3440,9 @@ function stampObjects(
2058
3440
  ): void {
2059
3441
  for (const [off, ops] of objects) {
2060
3442
  const { width, count } = extent.get(off)!;
2061
- const signed = uses.accesses.get(off)!.some((a) => a.signed);
2062
3443
  const aggregate = aggregateAt.get(off);
3444
+ // a declared struct's storage is bytes, whatever signedness its members are read at
3445
+ const signed = aggregate === undefined && uses.accesses.get(off)!.some((a) => a.signed);
2063
3446
  for (const op of ops) {
2064
3447
  const qualified = uses.published.has(off) || uses.filledFrom.has(off);
2065
3448
  op.attrs = {
@@ -2092,8 +3475,8 @@ function gcd(a: number, b: number): number {
2092
3475
  return b === 0 ? Math.abs(a) : gcd(b, a % b);
2093
3476
  }
2094
3477
 
2095
- /** The size of the struct a call stamped `sret` returns through its argument 0 (frontend/thumb.ts
2096
- * types the call's result as that struct). */
3478
+ /** The size of the struct a call stamped `sret` returns through its argument 0 (frontend/call-plan.ts
3479
+ * types the call's result as that struct, for every frontend whose lowering passes the pointer). */
2097
3480
  function returnedSize(call: Op): number {
2098
3481
  const t = call.results[0].type;
2099
3482
  if (t.kind !== 'struct' || t.size === undefined) {
@@ -2102,8 +3485,108 @@ function returnedSize(call: Op): number {
2102
3485
  return t.size;
2103
3486
  }
2104
3487
 
3488
+ /** The block parameters every incoming value of which is a local's address or another such
3489
+ * parameter: the greatest set closed under that, so a loop-carried address is one too. Each maps to
3490
+ * the frame offsets its incoming addresses name, through such parameters. The entry block's are the
3491
+ * function's parameters, and are left out. */
3492
+ function mergedFrameAddresses(irBlocks: readonly Block[]): Map<Value, ReadonlySet<number>> {
3493
+ const laddrOff = new Map<Value, number>();
3494
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3495
+ if (op.opcode === 'laddr') {
3496
+ laddrOff.set(op.results[0], op.attrs.off as number);
3497
+ }
3498
+ }
3499
+ const incoming = new Map<Value, Value[]>();
3500
+ for (const b of irBlocks.slice(1)) {
3501
+ for (const p of b.params) {
3502
+ incoming.set(p, []);
3503
+ }
3504
+ }
3505
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3506
+ for (const succ of op.successors) {
3507
+ succ.block.params.forEach((p, i) => incoming.get(p)?.push(succ.args[i]));
3508
+ }
3509
+ }
3510
+ const merged = new Set([...incoming].filter(([, vs]) => vs.length > 0).map(([p]) => p));
3511
+ for (let changed = true; changed;) {
3512
+ changed = false;
3513
+ for (const p of merged) {
3514
+ if (incoming.get(p)!.some((v) => !laddrOff.has(v) && !merged.has(v))) {
3515
+ merged.delete(p);
3516
+ changed = true;
3517
+ }
3518
+ }
3519
+ }
3520
+ const out = new Map<Value, ReadonlySet<number>>();
3521
+ for (const p of merged) {
3522
+ const offs = new Set<number>();
3523
+ const seen = new Set<Value>([p]);
3524
+ const work = [p];
3525
+ while (work.length > 0) {
3526
+ for (const v of incoming.get(work.pop()!)!) {
3527
+ if (laddrOff.has(v)) {
3528
+ offs.add(laddrOff.get(v)!);
3529
+ } else if (!seen.has(v)) {
3530
+ seen.add(v);
3531
+ work.push(v);
3532
+ }
3533
+ }
3534
+ }
3535
+ out.set(p, offs);
3536
+ }
3537
+ return out;
3538
+ }
3539
+
3540
+ /** A STRUCT RETURNED THROUGH A MERGE OF ONE LOCAL'S ADDRESS IS THAT LOCAL. gcc rotates a loop that
3541
+ * calls a struct-returning function, `while (lo > 0) { qr = ldiv(lo, 10); … }`, so `addiu a0,sp,16`
3542
+ * runs once ahead of the loop and once in the delay slot of its back branch, and hoists it into
3543
+ * both arms of `if (x) a = f(x); else a = f(x + 1);` ahead of one shared `jal`. The call then
3544
+ * reads a block parameter, but every path hands it the same frame address, so the call is handed
3545
+ * that address: an `laddr` of it, minted ahead of the call, which the frame-object audit types as
3546
+ * the struct (`declaredStructs`) and `refuseSretOutsideFrame` accepts. A merge of two offsets is
3547
+ * two objects, and is left for the audit to refuse. Run before the audit, on every frontend whose call
3548
+ * lowering passes the hidden pointer. */
3549
+ export function nameMergedSretObjects(irBlocks: Block[]): void {
3550
+ const merged = mergedFrameAddresses(irBlocks);
3551
+ for (const b of irBlocks) {
3552
+ b.ops = b.ops.flatMap((op) => {
3553
+ const offs = op.opcode === 'call' && op.attrs.sret === true ? merged.get(op.operands[0]) : undefined;
3554
+ if (offs === undefined || offs.size !== 1) {
3555
+ return [op];
3556
+ }
3557
+ const at = mkValue(T.unk(32));
3558
+ op.operands = [at, ...op.operands.slice(1)];
3559
+ return [mkOp('laddr', { results: [at], attrs: { off: [...offs][0] } }), op];
3560
+ });
3561
+ }
3562
+ }
3563
+
3564
+ /** A STRUCT RETURNED THROUGH MEMORY IS SPELLED `local = f(..)`, which hands the callee that local's
3565
+ * address: the hidden pointer must be a frame object the audit accepted. A global or a pointer
3566
+ * handed over directly — `gS = f(..)` (agbcc, ido, gcc), IDO's `*p = f(..)`, and the caller's own
3567
+ * hidden pointer forwarded by `return f(..)` — is another spelling, which nothing here states. A
3568
+ * merge of frame addresses never reaches here: one that names one address on every path was handed
3569
+ * that address's `laddr` before the audit (`nameMergedSretObjects`), and a merge of two names no one
3570
+ * storage, which the audit refuses first (`addressFlow`: `a phi merges the frame objects`). Asked
3571
+ * of every frontend whose call lowering passes the hidden pointer
3572
+ * (`CallLowering.memoryReturn`). */
3573
+ export function refuseSretOutsideFrame(name: string, irBlocks: readonly Block[], target: TargetDescription): void {
3574
+ const laddrs = new Set(
3575
+ irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'laddr').map((op) => op.results[0])),
3576
+ );
3577
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3578
+ if (op.opcode === 'call' && op.attrs.sret === true && !laddrs.has(op.operands[0])) {
3579
+ throw new FrontendUnsupportedError(
3580
+ `cannot lift '${name}': \`${op.attrs.target as string}\` returns struct ${typeToString(op.results[0].type)} through the pointer in ${target.argRegs[0]}, ` +
3581
+ 'and that pointer is not the address of a local of this frame — a struct returned into a global or ' +
3582
+ 'through a pointer is not modelled',
3583
+ );
3584
+ }
3585
+ }
3586
+ }
3587
+
2105
3588
  /** THE CALLER-SIDE SEAM. A frontend calls the audit THROUGH this record rather than through the
2106
- * binding above, so a process outside core can put a wrapped `gates` table in front of a real
3589
+ * `auditFrameObjects` binding, so a process outside core can put a wrapped `gates` table in front of a real
2107
3590
  * lift (`pnpm bench gates --pass frame-objects`): a module-namespace binding is read-only and
2108
3591
  * cannot be swapped (`apps/benchmark/src/run/gate-census.ts`, WHAT PUTS A PASS IN THE REGISTRY). */
2109
3592
  export const FRAME_OBJECT_AUDIT: { run: (audit: FrameObjectAudit) => FrameObjectVerdict } = {