@asmlift/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +1 -0
  5. package/src/backend/cfamily.ts +165 -50
  6. package/src/backend/cpp.ts +2 -1
  7. package/src/backend/pascal.ts +31 -12
  8. package/src/contracts.ts +173 -16
  9. package/src/declare.ts +69 -20
  10. package/src/frontend/asmdata.ts +7 -1
  11. package/src/frontend/call-plan.ts +964 -60
  12. package/src/frontend/dead-reads.ts +628 -0
  13. package/src/frontend/device-pins.ts +23 -4
  14. package/src/frontend/emit.ts +4 -3
  15. package/src/frontend/fpu.ts +224 -17
  16. package/src/frontend/frame-objects.ts +1232 -192
  17. package/src/frontend/frontend.ts +64 -3
  18. package/src/frontend/high-half.ts +53 -4
  19. package/src/frontend/jump-table.ts +530 -0
  20. package/src/frontend/local-object.ts +158 -32
  21. package/src/frontend/mips.ts +2076 -215
  22. package/src/frontend/own-struct-return.ts +500 -0
  23. package/src/frontend/ppc.ts +2711 -232
  24. package/src/frontend/register-struct-call.ts +484 -0
  25. package/src/frontend/reloc-symbol.ts +22 -7
  26. package/src/frontend/ssa.ts +1460 -44
  27. package/src/frontend/stackargs.ts +106 -57
  28. package/src/frontend/thumb.ts +2044 -318
  29. package/src/ir/alias.ts +42 -0
  30. package/src/ir/bits.ts +681 -1
  31. package/src/ir/core.ts +100 -5
  32. package/src/ir/discipline.ts +86 -21
  33. package/src/ir/entry.ts +159 -0
  34. package/src/{structure → ir}/loops.ts +6 -2
  35. package/src/ir/opcodes.ts +109 -9
  36. package/src/ir/types.ts +86 -2
  37. package/src/ir/verify.ts +80 -11
  38. package/src/l3/ast.ts +290 -18
  39. package/src/l3/basecse.ts +33 -5
  40. package/src/l3/constinit.ts +382 -0
  41. package/src/l3/dce.ts +2 -2
  42. package/src/l3/initfirst.ts +42 -11
  43. package/src/l3/intcell.ts +2 -1
  44. package/src/l3/narrowdecl.ts +77 -36
  45. package/src/l3/pollguard.ts +8 -2
  46. package/src/l3/ptrcell.ts +31 -34
  47. package/src/l3/ptrfield.ts +123 -8
  48. package/src/l3/regspell.ts +9 -4
  49. package/src/l3/reindex.ts +283 -96
  50. package/src/l3/storage.ts +98 -1
  51. package/src/l3/symbol-refs.ts +84 -6
  52. package/src/l3/tailmerge.ts +4 -2
  53. package/src/l3/typing.ts +108 -15
  54. package/src/l3/unreduce.ts +6 -5
  55. package/src/l3/volatileptr.ts +17 -54
  56. package/src/mangle.ts +30 -0
  57. package/src/pattern/engine.ts +361 -73
  58. package/src/pipeline.ts +541 -24
  59. package/src/proto-context.ts +905 -45
  60. package/src/proto.ts +555 -40
  61. package/src/raise/aggmembers.ts +290 -0
  62. package/src/raise/arrays.ts +5 -1
  63. package/src/raise/declared-volatile.ts +207 -31
  64. package/src/raise/floathelpers.ts +254 -33
  65. package/src/raise/gvn.ts +3 -3
  66. package/src/raise/latch.ts +162 -6
  67. package/src/raise/memberarrays.ts +10 -4
  68. package/src/raise/offsetnames.ts +10 -26
  69. package/src/raise/pairparams.ts +33 -0
  70. package/src/raise/paramwidth.ts +196 -6
  71. package/src/raise/pre-recovery.ts +102 -19
  72. package/src/raise/pre-structure.ts +54 -0
  73. package/src/raise/recover.ts +12 -5
  74. package/src/raise/reroll.ts +694 -0
  75. package/src/raise/shortcircuit.ts +26 -12
  76. package/src/raise/softdiv.ts +1 -1
  77. package/src/raise/storesign.ts +153 -0
  78. package/src/raise/struct-arrays.ts +30 -11
  79. package/src/raise/structs.ts +396 -33
  80. package/src/raise/truncload.ts +4 -3
  81. package/src/raise/unthread.ts +400 -0
  82. package/src/rank-declare.ts +118 -12
  83. package/src/rank-variations.ts +89 -31
  84. package/src/rank.ts +307 -54
  85. package/src/runtime-helpers.ts +140 -22
  86. package/src/structure/analysis.ts +165 -2
  87. package/src/structure/arith-ops.ts +23 -0
  88. package/src/structure/globalaccess.ts +111 -9
  89. package/src/structure/hazards.ts +16 -64
  90. package/src/structure/local-statics.ts +145 -9
  91. package/src/structure/namecoalesce.ts +1 -1
  92. package/src/structure/pointer-spelling.ts +1106 -0
  93. package/src/structure/redundant-test.ts +1 -1
  94. package/src/structure/structure.ts +2180 -1054
  95. package/src/structure/switch-recover.ts +244 -100
  96. package/src/symbols.ts +6 -0
  97. package/src/target.ts +765 -52
  98. package/src/trace.ts +26 -8
  99. package/src/variation-definitions.ts +126 -14
  100. package/src/variation-gates.ts +2 -0
  101. package/src/variation-tokens.ts +5 -1
@@ -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';
12
+ * Three frontends call it: Thumb; MIPS, which lowers `addiu rD,sp,k` and `addu rD,sp,rX` to an
13
+ * `laddr` and hands over the O32 local area as both ranges (`frontend/mips.ts` `measureO32Frame`);
14
+ * and PPC, which lowers `addi rD,r1,k` and every r1 access inside mwcc's local area the same way
15
+ * (`frontend/ppc.ts` `measurePpcFrame`).
16
+ * The worked examples below are agbcc's, because agbcc is the compiler every rule was first
17
+ * measured against; IDO, gcc 2.7.2 and mwcc reload past every store of another type (strict
18
+ * aliasing does not reorder there), so the cast and union refusals below are only more cautious on
19
+ * MIPS and PPC. */
20
+ import { linearFactor } from '../ir/bits';
21
+ import { type Block, type Op, type Value, byValueOperands, calleeName, mkOp, mkValue, successorsOf } from '../ir/core';
22
+ import { type IrType, T, structSpelling, typeEquals, typeToString, unionViewSigns } from '../ir/types';
16
23
  import type { Gate } 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,7 +39,18 @@ 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>;
37
56
  /** whether a callee's declaration rules out a struct returned through a hidden pointer at
@@ -54,12 +73,61 @@ export interface FrameRange {
54
73
  readonly to: number;
55
74
  }
56
75
 
76
+ /** THE LOCAL AREA ABOVE A FRAME'S OUTGOING AREA, `[from, to)` in post-push stack-pointer offsets:
77
+ * from where the outgoing words end (`outgoingEnd`, rounded up to `align`) to the lowest register
78
+ * save at or above that, or the frame's top. The rule O32's `above-outgoing-area` compilers
79
+ * (frontend/mips.ts `o32LocalArea`) and mwcc (frontend/ppc.ts, `compilerBehaviors.linkageLocalArea`)
80
+ * both keep; each frontend measures the numbers it is applied to. Null where nothing is left. */
81
+ export function areaAboveOutgoing({
82
+ frameSize,
83
+ outgoingEnd,
84
+ saves,
85
+ align,
86
+ }: {
87
+ frameSize: number;
88
+ outgoingEnd: number;
89
+ saves: readonly number[];
90
+ align: number;
91
+ }): FrameRange | null {
92
+ const from = Math.ceil(outgoingEnd / align) * align;
93
+ const area = { from, to: Math.min(frameSize, ...saves.filter((o) => o >= from)) };
94
+ return area.from < area.to ? area : null;
95
+ }
96
+
97
+ /** Whether a `width`-byte frame access at post-push offset `at` lands on a byte of `area`. A
98
+ * frontend that routes accesses into the area by their post-push offset asks it of every access
99
+ * made at ANOTHER depth, which that routing would misname, and draws no area where one does. */
100
+ export const landsOnArea = (area: FrameRange, at: number, width: number): boolean =>
101
+ at < area.to && at + width > area.from;
102
+
57
103
  /** What an audit answers instead of a refusal when a device may read the frame, or a callee write
58
104
  * it, without bound: lift again with `oneObject` set to these bytes. */
59
105
  export interface FrameObjectRelift {
60
106
  readonly oneObject: FrameRange;
61
107
  }
62
108
 
109
+ /** THE RELIFT PROTOCOL, AT MOST TWICE, the one copy every frontend that runs the audit calls: lift,
110
+ * and where the audit answers with bytes to keep as one object (`FrameObjectRelift`), lift once
111
+ * more with them routed through an `laddr`. The second audit judges them as one object and refuses
112
+ * rather than asking again, so a second request is an internal error, not a third lift. */
113
+ export function liftWithRelift<F>(
114
+ name: string,
115
+ liftOnce: (oneObject: FrameRange | undefined) => F | FrameObjectRelift,
116
+ ): F {
117
+ const first = liftOnce(undefined);
118
+ if (!isRelift(first)) {
119
+ return first;
120
+ }
121
+ const again = liftOnce(first.oneObject);
122
+ if (isRelift(again)) {
123
+ throw new Error(`internal: the frame-object audit of '${name}' asked for a second relift`);
124
+ }
125
+ return again;
126
+ }
127
+
128
+ const isRelift = <F>(x: F | FrameObjectRelift): x is FrameObjectRelift =>
129
+ typeof x === 'object' && x !== null && 'oneObject' in x;
130
+
63
131
  /** What an audit answers: lift again (`FrameObjectRelift`), the device pin the accepted function
64
132
  * owes (`DevicePins`, applied by `pinDeviceAccesses`), or nothing, when no frame address was
65
133
  * taken. */
@@ -247,13 +315,20 @@ export const FRAME_ESCAPE_GATES: readonly Gate<FrameEscape>[] = [
247
315
  * anywhere as a VALUE — into an MMIO register (the DMA-fill idiom), a call, a phi — and it is
248
316
  * judged in one of two models, chosen before any shape is:
249
317
  * - 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;
318
+ * width and one agreed extension, or a read or write through a runtime index into storage
319
+ * nothing else types — bytes, or ONE wider width, read through a cast on the bytes
320
+ * (`indexedWidth`) — and its bytes must belong to nothing else in the frame;
252
321
  * - 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.
322
+ * that answer can hold (`writerNotKept`), or where no address escapes at all and the
323
+ * per-object model refuses (`oneObject`, which this answers and the frontend lifts again
324
+ * with): the local area is one `u8` array in memory, every fixed-offset access in it is a
325
+ * member at its own offset, a runtime index may reach it at a byte or at ONE wider
326
+ * width (`indexedWidth`), and two widths at one byte are the views of one union
327
+ * (`unionViews`), or refuse where no view names them (`unionRefusal`).
328
+ * In either model, a store through a capture that reaches a call unread on every path, beneath
329
+ * every word a load proves a local's, declines: it may be that call's stack argument
330
+ * (`storedNeverRead`). Which load proves it is the compiler's premise: one after a call under
331
+ * `localsAboveOutgoingArea`, and any load from the offset up under `outgoingAreaNeverLoadedFrom`.
257
332
  * Any use the audit cannot vouch for declines the whole function loudly. Nothing here guesses: a
258
333
  * scalar's declared type is exactly the access type the machine used, and an object NO access
259
334
  * reaches is sized by the frame reservation and left untyped.
@@ -279,7 +354,9 @@ export function auditFrameObjects({
279
354
  ownedLocals: owned,
280
355
  declaredLocals: declared,
281
356
  usedSlotOffsets,
357
+ loadedWords,
282
358
  capturedObjectIsTheWholeFrame,
359
+ escapesReliedOn = new Set(),
283
360
  movedCaptures,
284
361
  returnsWithoutHiddenPointer,
285
362
  symbols,
@@ -290,24 +367,48 @@ export function auditFrameObjects({
290
367
  const { laddrs: captures, foldedHere } = foldMovedCaptures(irBlocks, movedCaptures);
291
368
  // …and the audit runs for a licensed acceptance with no object at all, so the premise re-check
292
369
  // is total rather than resting on "the capture always survives into the IR".
293
- if (captures.length === 0 && !capturedObjectIsTheWholeFrame) {
370
+ if (captures.length === 0 && !capturedObjectIsTheWholeFrame && escapesReliedOn.size === 0) {
294
371
  return undefined;
295
372
  }
296
373
  const fail = frameRefusal(name);
374
+ if (captures.length === 0 && escapesReliedOn.size > 0) {
375
+ recheckEscapesReliedOn(escapesReliedOn, undefined, fail);
376
+ }
297
377
  const facts = frameAddressFacts(irBlocks, symbols, target);
298
- const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere);
378
+ const { laddrs, splitRefusal } = splitAddressingCopies(irBlocks, captures, foldedHere, declaredStructs(irBlocks));
299
379
  const objects = frameObjects(laddrs, owned, fail);
300
380
  const flow = addressFlow(irBlocks, objects, facts, fail);
301
- const uses = classifyFrameUses({ irBlocks, objects, flow, facts, splitRefusal, oneObject, target, fail });
381
+ const uses = classifyFrameUses({
382
+ irBlocks,
383
+ objects,
384
+ flow,
385
+ facts,
386
+ splitRefusal,
387
+ oneObject,
388
+ target,
389
+ fail,
390
+ });
302
391
  const windows = escapeWindows(irBlocks, uses, flow, facts, target);
303
- const model = chooseFrameModel(oneObject, uses, windows, returnsWithoutHiddenPointer, fail);
392
+ const model = chooseFrameModel({ oneObject, uses, windows, returnsWithoutHiddenPointer, fail });
304
393
  if (capturedObjectIsTheWholeFrame) {
305
394
  recheckWholeFramePremise({ uses, returnsWithoutHiddenPointer, fail });
306
395
  }
396
+ recheckEscapesReliedOn(escapesReliedOn, uses, fail);
397
+ storedNeverRead(irBlocks, uses, loadedWords, target, fail);
307
398
  // The device pin's trigger: the `readOnlyAddressSinks` registers a frame address was stored to.
308
399
  const sinks = [...new Set([...uses.sourceStores.values()].flat().map((s) => s.sink))];
309
400
  if (oneObject !== undefined) {
310
- keepAsOneObject({ oneObject, irBlocks, objects, uses, usedSlotOffsets, returnsWithoutHiddenPointer, fail });
401
+ keepAsOneObject({
402
+ oneObject,
403
+ irBlocks,
404
+ objects,
405
+ uses,
406
+ usedSlotOffsets,
407
+ returnsWithoutHiddenPointer,
408
+ arrayOrder: target.compilerBehaviors.addressTakenArrayOrder,
409
+ arrayAlignment: target.compilerBehaviors.addressTakenArrayAlignment,
410
+ fail,
411
+ });
311
412
  return { policy: 'one-object', sinks };
312
413
  }
313
414
  const shapes = objectShapes({
@@ -321,17 +422,19 @@ export function auditFrameObjects({
321
422
  fail,
322
423
  });
323
424
  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
425
+ // THE ONE-OBJECT ANSWER, where it is on offer (`chooseFrameModel`: one escape reaches without
426
+ // bound and every writer is one it can hold, or no address escapes at all) and the per-object
427
+ // model does not describe the frame: it refused a shape, an object sits over a slot, or the
428
+ // unbounded reach meets another object, a slot or a word nothing accounts for. Then what the
429
+ // device may read or the callee may write — or, with no escape, only this function's own
430
+ // accesses — is kept rather than refused: lift again with the local area as one object in memory
329
431
  // (`keepAsOneObject`). Below the local area are the outgoing arguments and above it the saved
330
432
  // registers, neither of them an object. Where the per-object model does describe the frame —
331
433
  // one object and nothing else in reach — it stands, and the object keeps its own type.
434
+ // An escape `misses` bounded is asked as the unbounded read it was without that proof
435
+ // (`ModelChoice.unboundedAt`): the proof may add a per-object lift, never take the one-object one.
332
436
  const answered = (e: FrameEscape): boolean =>
333
- e.lo === -Infinity &&
334
- e.hi === Infinity &&
437
+ model.unboundedAt.has(e.off) &&
335
438
  (e.objectReached !== undefined || e.slotReached !== undefined || e.unaccountedWord !== undefined);
336
439
  if (model.onOffer && (model.refused || shapes.overSlot.length > 0 || escapes.some(answered))) {
337
440
  return { oneObject: { from: declared.from, to: declared.to } };
@@ -361,6 +464,42 @@ function frameRefusal(name: string): Refuse {
361
464
  };
362
465
  }
363
466
 
467
+ /** THE FRAME BYTES A DECLARATION TYPES AS ONE STRUCT: a call's return storage (`sret`) and a
468
+ * struct passed by value (`byValue`, call-plan.ts `foldByValueArgs`), where the operand is an
469
+ * `laddr`. */
470
+ function declaredStructs(irBlocks: readonly Block[]): FrameRange[] {
471
+ const laddrOff = new Map<Value, number>();
472
+ for (const b of irBlocks) {
473
+ for (const op of b.ops) {
474
+ if (op.opcode === 'laddr') {
475
+ laddrOff.set(op.results[0], op.attrs.off as number);
476
+ }
477
+ }
478
+ }
479
+ const out: FrameRange[] = [];
480
+ for (const b of irBlocks) {
481
+ for (const op of b.ops) {
482
+ if (op.opcode !== 'call') {
483
+ continue;
484
+ }
485
+ const t = op.results[0]?.type;
486
+ const from = laddrOff.get(op.operands[0]);
487
+ if (op.attrs.sret === true && from !== undefined && t?.kind === 'struct' && t.size !== undefined) {
488
+ out.push({ from, to: from + t.size });
489
+ }
490
+ for (const { at } of byValueOperands(op)) {
491
+ const v = op.operands[at];
492
+ const off = laddrOff.get(v);
493
+ const t = v?.type.kind === 'ptr' ? v.type.to : undefined;
494
+ if (off !== undefined && t?.kind === 'struct' && t.size !== undefined) {
495
+ out.push({ from: off, to: off + t.size });
496
+ }
497
+ }
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+
364
503
  /** A CAPTURE MOVED BY A CONSTANT IS THE CAPTURE AT THE SUM, and here the constant is exact: an
365
504
  * `add` of a `laddr` and a `const` — a register the lift could not see through, `mov r0, sp /
366
505
  * movs r2, #0 / ldrsh r1, [r0, r2]`, or a move the pre-lift walk does not follow — is re-minted
@@ -369,6 +508,22 @@ function frameRefusal(name: string): Refuse {
369
508
  * lands on a keyed slot is refused as the move, the capability that is missing
370
509
  * (`failIfSlotKeysIt`).
371
510
  *
511
+ * AN INDEX INTO A CAPTURE, MOVED BY A NONZERO CONSTANT, IS THE SAME INDEX INTO THE CAPTURE AT THE
512
+ * SUM. agbcc spells `a[i]` on a local at [sp,#k) as the frame base plus the index, moved after:
513
+ * `mov r0, sp / add r0, r0, r4 / add r0, #0x4` or `add r0, r0, sp / add r0, #0x4` — the bytes
514
+ * `add r0, sp, #0x4 / add r0, r0, r4` reach, so `add(add(laddr@a, i), k)` is re-minted as
515
+ * `add(laddr@(a+k), i)`, and the inner sum and capture are dropped when nothing else reads them.
516
+ * The object at [sp,#a) is often the outgoing-argument word (a 5-argument call beside
517
+ * the array), which the index never touches. Zero is left alone: `add(sum, 0)` is agbcc's
518
+ * `mov r1, #0x0 / ldrsb r0, [r0, r1]`, which `indexedAccess` judges where the register put it.
519
+ * The rebased object is held to every rule an object at a+k is held to, so a displaced FIELD of a
520
+ * struct element — no object of its own at a+k, the struct's other bytes at a — lands as a second
521
+ * object beside the first and is refused or lifted as one object by the same rules. A load or
522
+ * store's own nonzero displacement on the sum is the same move: `ldrb r0, [r0, #0x1]` (the field
523
+ * agbcc reads unsigned) and `mov r1, #0x1 / ldrsb r0, [r0, r1]` (signed) reach the same byte, so
524
+ * the access is re-pointed at `add(laddr@(a+d), i)` with displacement 0, and the verdict does not
525
+ * depend on where the literal sits.
526
+ *
372
527
  * Returns every `laddr` that survives, in block order. */
373
528
  function foldMovedCaptures(
374
529
  irBlocks: Block[],
@@ -387,23 +542,77 @@ function foldMovedCaptures(
387
542
  }
388
543
  }
389
544
  }
545
+ // the `add` of a capture and a runtime value that defines each value, for the index rebase
546
+ const indexOf = new Map<Value, { base: Op; index: Value; sum: Op }>();
547
+ const rebased = new Set<Op>();
390
548
  for (let changed = true; changed;) {
391
549
  changed = false;
392
550
  for (const blk of irBlocks) {
393
- blk.ops = blk.ops.map((op) => {
551
+ blk.ops = blk.ops.flatMap((op) => {
394
552
  const [x, y] = op.operands;
553
+ const sum0 = (op.opcode === 'load' || op.opcode === 'store') && x !== undefined ? indexOf.get(x) : undefined;
554
+ const disp = (op.attrs.off as number | undefined) ?? 0;
555
+ if (sum0 !== undefined && disp !== 0) {
556
+ const object = mkOp('laddr', {
557
+ results: [mkValue(sum0.base.results[0].type)],
558
+ attrs: { off: ((sum0.base.attrs.off as number) + disp) | 0 },
559
+ });
560
+ const moves = mkOp('add', {
561
+ results: [mkValue(x.type)],
562
+ operands: [object.results[0], sum0.index],
563
+ attrs: sum0.sum.attrs,
564
+ });
565
+ laddrOf.set(object.results[0], object);
566
+ indexOf.set(moves.results[0], { base: object, index: sum0.index, sum: moves });
567
+ moved.add(sum0.base.results[0]);
568
+ foldedHere.add(object);
569
+ rebased.add(sum0.sum);
570
+ op.operands[0] = moves.results[0];
571
+ op.attrs = { ...op.attrs, off: 0 };
572
+ changed = true;
573
+ return [object, moves, op];
574
+ }
575
+ if (op.opcode !== 'add' || op.operands.length !== 2) {
576
+ return [op];
577
+ }
395
578
  const base = laddrOf.get(x) ?? laddrOf.get(y);
396
579
  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;
580
+ if (base !== undefined && by !== undefined) {
581
+ const off = ((base.attrs.off as number) + by) | 0;
582
+ const object = mkOp('laddr', { results: op.results, attrs: { off } });
583
+ laddrOf.set(op.results[0], object);
584
+ moved.add(base.results[0]);
585
+ foldedHere.add(object);
586
+ changed = true;
587
+ return [object];
588
+ }
589
+ const index = base === undefined ? undefined : laddrOf.has(x) ? y : x;
590
+ if (base !== undefined && index !== undefined && !laddrOf.has(index)) {
591
+ // a sum first seen here takes another pass: a use laid out in an EARLIER block was
592
+ // visited before the sum was known, and is moved only on the next one
593
+ if (!indexOf.has(op.results[0])) {
594
+ indexOf.set(op.results[0], { base, index, sum: op });
595
+ changed = true;
596
+ }
597
+ return [op];
598
+ }
599
+ const sum = indexOf.get(x) ?? indexOf.get(y);
600
+ const k = constOf.get(indexOf.has(x) ? y : x);
601
+ if (sum === undefined || k === undefined || k === 0) {
602
+ return [op];
399
603
  }
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]);
604
+ const object = mkOp('laddr', {
605
+ results: [mkValue(sum.base.results[0].type)],
606
+ attrs: { off: ((sum.base.attrs.off as number) + k) | 0 },
607
+ });
608
+ const moves = mkOp('add', { results: op.results, operands: [object.results[0], sum.index], attrs: op.attrs });
609
+ laddrOf.set(object.results[0], object);
610
+ indexOf.set(op.results[0], { base: object, index: sum.index, sum: moves });
611
+ moved.add(sum.base.results[0]);
404
612
  foldedHere.add(object);
613
+ rebased.add(sum.sum);
405
614
  changed = true;
406
- return object;
615
+ return [object, moves];
407
616
  });
408
617
  }
409
618
  }
@@ -412,13 +621,24 @@ function foldMovedCaptures(
412
621
  // the write-back of `stmia r1!, {r2, r3, r4}` through a capture is `add r1, r1, #12`, a pointer
413
622
  // to the end of what was copied that the machine never uses. Only those: an unused `mov rD, sp`
414
623
  // 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)));
624
+ const readNow = (): Set<Value> => {
625
+ const read = new Set<Value>();
626
+ for (const blk of irBlocks) {
627
+ for (const op of blk.ops) {
628
+ op.operands.forEach((v) => read.add(v));
629
+ (op.successors ?? []).forEach((s) => s.args.forEach((v) => read.add(v)));
630
+ }
631
+ }
632
+ return read;
633
+ };
634
+ // …and an index sum a rebase moved off, once nothing reads it, goes the same way first
635
+ if (rebased.size > 0) {
636
+ const before = readNow();
637
+ for (const blk of irBlocks) {
638
+ blk.ops = blk.ops.filter((op) => !rebased.has(op) || before.has(op.results[0]));
420
639
  }
421
640
  }
641
+ const read = readNow();
422
642
  const laddrs: Op[] = [];
423
643
  for (const blk of irBlocks) {
424
644
  blk.ops = blk.ops.filter(
@@ -474,7 +694,7 @@ function frameAddressFacts(
474
694
  }
475
695
  for (const [lit, index] of [d.operands, [...d.operands].reverse()]) {
476
696
  const from = literalAddrOf(lit);
477
- const stride = from === undefined ? 0 : factorOf(addresses, index);
697
+ const stride = from === undefined ? 0 : linearFactor({ defs: defOf, constOf: addresses.constOf }, index);
478
698
  if (from !== undefined && readOnlySinks.has(from + off) && stride > 0 && stride % sinkSpacing === 0) {
479
699
  return [...readOnlySinks].filter((s) => (s - (from + off)) % stride === 0);
480
700
  }
@@ -484,47 +704,6 @@ function frameAddressFacts(
484
704
  return { ...addresses, readsThrough };
485
705
  }
486
706
 
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
707
  /** A FRAME BASE ADDRESSED THROUGH IS NOT A CAPTURE. Thumb-1 gives `ldr`/`str` an `[sp,#imm]`
529
708
  * encoding and gives the sub-word forms none, so a byte or halfword spill can only be spelled
530
709
  * by copying sp into a register and addressing through the copy:
@@ -556,6 +735,7 @@ function splitAddressingCopies(
556
735
  irBlocks: Block[],
557
736
  laddrs: Op[],
558
737
  foldedHere: Set<Op>,
738
+ structs: readonly FrameRange[] = [],
559
739
  ): { laddrs: Op[]; splitRefusal: Map<Value, string> } {
560
740
  const splitRefusal = new Map<Value, string>();
561
741
  const uses = new Map<Value, { op: Op; idx: number; blk: Block }[]>();
@@ -593,13 +773,42 @@ function splitAddressingCopies(
593
773
  if (at.length === 0 || accesses.length !== at.length) {
594
774
  continue;
595
775
  }
776
+ // AN ADDRESS STRICTLY INSIDE A DECLARED STRUCT, ONLY ACCESSED, IS A MEMBER ACCESS OF THAT
777
+ // STRUCT. A word store into a struct passed by value goes straight to `[sp,#k]` — `x.c = w` is
778
+ // `str r1, [sp, #0x4]` over `struct F {u16 a,b; u32 c; s8 e,f;}` at [sp,#0) — and the frontend
779
+ // mints that access its own `laddr` at k. The declaration says those bytes are the struct's,
780
+ // so the address is re-rooted on the struct's base with the displacement on the access, and
781
+ // the audit judges one object rather than two that overlap. Any use that is not an access —
782
+ // `&x.c` handed on — is an interior pointer, which is not this shape and is left to refuse.
783
+ const off = capture.attrs.off as number;
784
+ const holders = structs.filter((r) => r.from < off && off < r.to);
785
+ const holder = holders[0];
786
+ const inHolder = (u: { op: Op }): boolean =>
787
+ off + (u.op.attrs.off as number) >= holder!.from &&
788
+ off + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= holder!.to;
789
+ if (
790
+ holder !== undefined &&
791
+ holders.every((r) => r.from === holder.from && r.to === holder.to) &&
792
+ accesses.every(inHolder)
793
+ ) {
794
+ for (const u of accesses) {
795
+ u.op.attrs = { ...u.op.attrs, off: off - holder.from + (u.op.attrs.off as number) };
796
+ }
797
+ capture.attrs = { ...capture.attrs, off: holder.from };
798
+ continue;
799
+ }
596
800
  if (!subWord) {
597
801
  splitRefusal.set(capture.results[0], 'a WORD access through the copy, and `ldr`/`str` have an `[sp,#imm]` form');
598
802
  continue;
599
803
  }
600
804
  // 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)) {
805
+ // the frame base itself, which is what the DMA-fill idiom captures — or every access lands in
806
+ // the declared struct the capture is the start of (`declaredStructs`), whose members they are.
807
+ const base = capture.attrs.off as number;
808
+ const into = (r: FrameRange): boolean =>
809
+ base === r.from &&
810
+ accesses.every((u) => base + (u.op.attrs.off as number) + (u.op.attrs.width as number) <= r.to);
811
+ if (accesses.every((u) => u.op.attrs.off === 0) || structs.some(into)) {
603
812
  continue;
604
813
  }
605
814
  for (const u of accesses) {
@@ -644,8 +853,16 @@ function frameObjects(laddrs: readonly Op[], owned: FrameRange, fail: Refuse): M
644
853
  // saved register's — `g(&e)` for a fifth parameter `e` is `add r0, sp, #0x8` over a one-word
645
854
  // local area — and no other rule of the audit is about that gap. The TOP itself is also C's
646
855
  // one-past-the-end pointer, `g(buf, buf + 8)` over an 8-byte array, and the refusal names both
647
- // readings.
856
+ // readings. BELOW IT is the outgoing block a call's stack words are staged in, and on O32 also
857
+ // the saves IDO keeps under its locals: an address there is no local's either. Every object's
858
+ // extent is held to the area again below; this says why, for the frontend that supplied it.
648
859
  for (const off of objects.keys()) {
860
+ if (off < owned.from) {
861
+ fail(
862
+ `the captured address at [sp,#${off}) is below the reserved local area, which starts at [sp,#${owned.from}) — ` +
863
+ 'an outgoing stack argument word or a saved register, whose address is not modelled',
864
+ );
865
+ }
649
866
  if (off === owned.to) {
650
867
  fail(
651
868
  `the captured address at [sp,#${off}) is the top of the reserved local area of ${owned.to - owned.from} bytes — ` +
@@ -786,10 +1003,28 @@ interface FrameUses {
786
1003
  readonly publishedToWriter: ReadonlySet<number>;
787
1004
  readonly arg0Callees: ReadonlyMap<number, ReadonlySet<string | null>>;
788
1005
  readonly returnTemps: ReadonlyMap<number, readonly Op[]>;
1006
+ /** the calls each object is passed to BY VALUE (`foldByValueArgs`), with the struct each passes */
1007
+ readonly byValueReads: ReadonlyMap<number, readonly { readonly op: Op; readonly type: IrType }[]>;
789
1008
  readonly useCount: ReadonlyMap<number, number>;
790
1009
  readonly indexed: ReadonlyMap<number, readonly { readonly width: number; readonly signed: boolean }[]>;
791
1010
  readonly members: readonly { readonly at: number; readonly width: number }[];
792
1011
  readonly memberRefusal: string | undefined;
1012
+ /** every escape of each object's address, as the frame bytes it may reach: `[lo, hi)` where a
1013
+ * literal-count block copy proves them (`boundedCopy`), `null` where nothing does, and `into`
1014
+ * where the copy WRITES them (the address is its destination, not its source). Only the split
1015
+ * of the one object reads this (`provenParts`); the escape windows keep the copy as an
1016
+ * unbounded writer, which is what puts that one object on offer at all (`chooseFrameModel`). */
1017
+ readonly escapeExtents: ReadonlyMap<
1018
+ number,
1019
+ readonly ({ readonly lo: number; readonly hi: number; readonly into: boolean } | null)[]
1020
+ >;
1021
+ readonly fixed: readonly {
1022
+ readonly op: Op;
1023
+ readonly at: number;
1024
+ readonly width: number;
1025
+ readonly isLoad: boolean;
1026
+ readonly frameOnly: boolean;
1027
+ }[];
793
1028
  }
794
1029
 
795
1030
  /** Did the address at `off` reach something that may write the frame back: a callee that is not a
@@ -885,6 +1120,9 @@ function classifyFrameUses({
885
1120
  // is the callee's return storage, a local of the declared type. `useCount` counts every use of
886
1121
  // each object, so a return temp this function does anything else with is told apart.
887
1122
  const returnTemps = new Map<number, Op[]>();
1123
+ // …and a call's operand its lowering passes BY VALUE (`foldByValueArgs`) is no escape: the
1124
+ // callee receives a copy of the whole struct, so it is a read of the object's bytes at the call
1125
+ const byValueReads = new Map<number, { op: Op; type: IrType }[]>();
888
1126
  const useCount = new Map<number, number>();
889
1127
  // The accesses through a runtime-indexed address, per object — kept apart from `accesses`,
890
1128
  // which types the object at its own offset: `a[i]` says what one ELEMENT is, not what sits at
@@ -897,17 +1135,37 @@ function classifyFrameUses({
897
1135
  // access at [+k] through a capture is a member of the one object rather than a second object
898
1136
  const members: { at: number; width: number }[] = [];
899
1137
  // …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;
1138
+ // chosen (`chooseFrameModel`) — save one of a call's return storage or of a struct passed by
1139
+ // value, whose members are the struct's declared ones (`objectShapes`)
1140
+ const memberRefusals: [number, string][] = [];
1141
+ const escapeExtents = new Map<number, ({ lo: number; hi: number; into: boolean } | null)[]>();
1142
+ // …and every value something reads, since a copy whose returned destination is read hands the
1143
+ // address on to a use nothing here follows
1144
+ const readValues = new Set<Value>(
1145
+ irBlocks.flatMap((b) => b.ops.flatMap((op) => [...op.operands, ...(op.successors ?? []).flatMap((sx) => sx.args)])),
1146
+ );
1147
+ // …and in either model, every fixed-offset access as the op that makes it, the frame bytes it
1148
+ // touches, whether it reads them, and whether its address is this frame's on every path — what
1149
+ // `storedNeverRead` asks of a frame that makes a call
1150
+ const fixed: { op: Op; at: number; width: number; isLoad: boolean; frameOnly: boolean }[] = [];
902
1151
  const scalar = (kind: string, op: Op, off: number, v: Value): void => {
1152
+ fixed.push({
1153
+ op,
1154
+ at: off + (op.attrs.off as number),
1155
+ width: op.attrs.width as number,
1156
+ isLoad: kind === 'load',
1157
+ frameOnly: frameOnEveryPath.has(v),
1158
+ });
903
1159
  if (oneObject !== undefined) {
904
1160
  members.push({ at: off + (op.attrs.off as number), width: op.attrs.width as number });
905
1161
  return;
906
1162
  }
907
1163
  if ((op.attrs.off as number) !== 0) {
908
- memberRefusal ??=
1164
+ memberRefusals.push([
1165
+ off,
909
1166
  `a ${kind} at [+${op.attrs.off}] through the captured address — ` +
910
- (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled');
1167
+ (splitRefusal.get(v) ?? 'only a scalar at the captured address is modelled'),
1168
+ ]);
911
1169
  }
912
1170
  };
913
1171
  for (const blk of irBlocks) {
@@ -934,8 +1192,22 @@ function classifyFrameUses({
934
1192
  accesses.get(off)!.push({ width: op.attrs.width as number, signed: false, isLoad: false });
935
1193
  continue;
936
1194
  }
1195
+ if (byValueOperands(op).some((b) => b.at === idx)) {
1196
+ // the fold types the operand a pointer to the struct passed (call-plan.ts `foldByValueArgs`)
1197
+ const type = v.type.kind === 'ptr' ? v.type.to : undefined;
1198
+ if (type?.kind !== 'struct' || type.size === undefined) {
1199
+ throw new Error(`internal: a by-value operand of a call to '${calleeName(op)}' has no declared struct`);
1200
+ }
1201
+ (byValueReads.get(off) ?? byValueReads.set(off, []).get(off)!).push({ op, type });
1202
+ fixed.push({ op, at: off, width: type.size, isLoad: true, frameOnly: frameOnEveryPath.has(v) });
1203
+ continue;
1204
+ }
937
1205
  if ((op.opcode === 'store' && idx === 1) || op.opcode === 'call') {
938
1206
  escaped.add(off); // the address ESCAPES as a value — the point of the capability
1207
+ const copied = op.opcode === 'call' ? boundedCopy(op, idx, facts, readValues) : undefined;
1208
+ (escapeExtents.get(off) ?? escapeExtents.set(off, []).get(off)!).push(
1209
+ copied === undefined ? null : { lo: off + copied.lo, hi: off + copied.hi, into: copied.into },
1210
+ );
939
1211
  const handedTo = op.opcode === 'store' ? facts.readsThrough(op) : [];
940
1212
  const read = op.opcode === 'call' ? transferRead(op, idx, off, target, facts) : undefined;
941
1213
  if (read !== undefined) {
@@ -984,7 +1256,7 @@ function classifyFrameUses({
984
1256
  // judged with the rest (`objectShapes`).
985
1257
  const other = op.opcode === 'add' && op.operands.length === 2 ? op.operands[1 - idx] : undefined;
986
1258
  if (other !== undefined && taint.get(other) === undefined && defOf.get(other)?.opcode !== 'const') {
987
- indexedAccess(irBlocks, { indexed, accessOps }, { off, through: v }, op.results[0], fail);
1259
+ indexedAccess(irBlocks, { indexed, accessOps }, { off, through: v }, op.results[0], facts.constOf, fail);
988
1260
  continue;
989
1261
  }
990
1262
  // A CONSTANT move left after the fold (`foldMovedCaptures`) moves a capture a phi carried.
@@ -1022,13 +1294,39 @@ function classifyFrameUses({
1022
1294
  publishedToWriter,
1023
1295
  arg0Callees,
1024
1296
  returnTemps,
1297
+ byValueReads,
1025
1298
  useCount,
1026
1299
  indexed,
1027
1300
  members,
1028
- memberRefusal,
1301
+ memberRefusal: memberRefusals.find(([off]) => !returnTemps.has(off) && !byValueReads.has(off))?.[1],
1302
+ escapeExtents,
1303
+ fixed,
1029
1304
  };
1030
1305
  }
1031
1306
 
1307
+ // The bytes, relative to the address at operand `idx`, that a call of a standard block copy with
1308
+ // a literal, positive count reaches through it, and whether it writes them (`idx` is its
1309
+ // destination) — or `undefined` for any other callee, operand or count, which this does not
1310
+ // bound, and for a copy whose result (`memcpy` returns its destination) anything in `read` uses.
1311
+ function boundedCopy(
1312
+ op: Op,
1313
+ idx: number,
1314
+ facts: LiteralAddresses,
1315
+ read: ReadonlySet<Value>,
1316
+ ): { lo: number; hi: number; into: boolean } | undefined {
1317
+ const roles = blockCopyRoles(op.attrs.target);
1318
+ if (
1319
+ roles === undefined ||
1320
+ (idx !== roles.dst && idx !== roles.src) ||
1321
+ op.operands.length <= roles.n ||
1322
+ op.results.some((r) => read.has(r))
1323
+ ) {
1324
+ return undefined;
1325
+ }
1326
+ const n = facts.constOf(op.operands[roles.n]);
1327
+ return n !== undefined && Number.isInteger(n) && n > 0 ? { lo: 0, hi: n, into: idx === roles.dst } : undefined;
1328
+ }
1329
+
1032
1330
  // The bytes a block transfer that took the address at `idx` reads, when `idx` is its source and
1033
1331
  // its control word is a literal.
1034
1332
  function transferRead(
@@ -1051,6 +1349,28 @@ function transferRead(
1051
1349
  }
1052
1350
 
1053
1351
  // Records every access through `sum`, a runtime index into the object at `off`.
1352
+ //
1353
+ // A SUM MOVED BY A LITERAL IS THE SAME INDEX, DISPLACED. Thumb has no `ldrsb`/`ldrsh` with an
1354
+ // immediate, so agbcc loads the whole displacement into a register (thumb.md:411-505, `mov %0,%2 ;
1355
+ // ldrs[bh] %0,[%1,%0]`) and the frontend lowers that register-offset load as `add` + load@0:
1356
+ // `add r0, r1, r4 / mov r1, #0x0 / ldrsb r1, [r0, r1]` is `a[i]` on an `s8 a[n]` local. So the
1357
+ // uses of an `add` of the sum and a `const` are judged at that displacement. One `add` deep: agbcc
1358
+ // emits one `mov` per load, and no other compiler emits a literal index register. Only
1359
+ // displacement 0 is the indexed element. A nonzero `const` on a sum whose base is a capture never
1360
+ // arrives here: `foldMovedCaptures` has already moved the index to the object at the sum, which is
1361
+ // what agbcc's `mov r0, sp / add r0, r0, r4 / add r0, #0x4` is (an array above the outgoing word),
1362
+ // and what `mov r1, #0x3 / ldrsb r1, [r0, r1]` and a load's own displacement, `ldrb r0, [r0,
1363
+ // #0x1]` (a field of an element struct, or `a[i + 1]`), reach. What is left nonzero — a constant
1364
+ // computed rather than loaded as a `const`, a literal or a displacement on an index into a capture
1365
+ // a phi carries, or a displacement on top of the zero literal, which no compiler emits — refuses
1366
+ // naming the displacement.
1367
+ //
1368
+ // The element is judged here and left where the register put it, so it is spelled with the
1369
+ // literal: `*(s16 *)((u32)sp0 + (a0 << 1) + 0)`. Re-pointing it at the sum spells
1370
+ // `((s16 *)sp0)[a0]`, an index through a cast on the `u8` storage (`objectShapes`), which agbcc
1371
+ // compiles to `add r4, r4, sp` and the source's `mov r1, sp / add r0, r1, r4` does not: the `u16`
1372
+ // twin `((u16 *)sp0)[a0]` misses the same way. Left raw, the access compiles to the source's
1373
+ // bytes with or without the `+ 0`.
1054
1374
  function indexedAccess(
1055
1375
  irBlocks: readonly Block[],
1056
1376
  {
@@ -1059,28 +1379,85 @@ function indexedAccess(
1059
1379
  }: { indexed: Map<number, { width: number; signed: boolean }[]>; accessOps: Map<Op, FrameAccessOp> },
1060
1380
  object: FrameAccessOp,
1061
1381
  sum: Value,
1382
+ constOf: (v: Value) => number | undefined,
1062
1383
  fail: Refuse,
1063
1384
  ): void {
1064
1385
  const { off } = object;
1065
1386
  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
- );
1387
+ const judge = (at: Value, by: number): void => {
1388
+ for (const blk of irBlocks) {
1389
+ for (const op of blk.ops) {
1390
+ const roles = [
1391
+ ...op.operands.flatMap((v, i) => (v === at ? [i] : [])),
1392
+ ...(op.successors ?? []).flatMap((s) => (s.args.includes(at) ? [-1] : [])),
1393
+ ];
1394
+ for (const i of roles) {
1395
+ const k =
1396
+ at === sum && op.opcode === 'add' && op.operands.length === 2 && i >= 0
1397
+ ? constOf(op.operands[1 - i])
1398
+ : undefined;
1399
+ if (k !== undefined) {
1400
+ judge(op.results[0], k);
1401
+ continue;
1402
+ }
1403
+ const access = (op.opcode === 'load' || op.opcode === 'store') && i === 0;
1404
+ if (!access) {
1405
+ fail(
1406
+ `a runtime index into the object at [sp,#${off}) flows into \`${i === -1 ? 'a phi' : op.opcode}\` — ` +
1407
+ 'only a load or store at the indexed address is modelled',
1408
+ );
1409
+ }
1410
+ const at0 = (by + (op.attrs.off as number)) | 0;
1411
+ if (at0 !== 0) {
1412
+ fail(
1413
+ `a runtime index into the object at [sp,#${off}) is accessed at displacement ${at0} from the indexed element — ` +
1414
+ 'only a load or store at the indexed address is modelled',
1415
+ );
1416
+ }
1417
+ got.push({ width: op.attrs.width as number, signed: op.opcode === 'load' && op.attrs.signed === true });
1418
+ accessOps.set(op, object);
1078
1419
  }
1079
- got.push({ width: op.attrs.width as number, signed: op.opcode === 'load' && op.attrs.signed === true });
1080
- accessOps.set(op, object);
1081
1420
  }
1082
1421
  }
1083
- }
1422
+ };
1423
+ judge(sum, 0);
1424
+ }
1425
+
1426
+ /** Why two access types on one byte refuse, as every refusal of it words it. THE RULE IS AGBCC'S
1427
+ * AND IS HELD ON EVERY TARGET: agbcc at -O2 serves a read of one type past a store of the other
1428
+ * (`indexedWidth` has the compiled cases), while IDO 7.1, KMC gcc and gcc 2.7.2 reload past every such
1429
+ * store, so on MIPS it is only more cautious than it has to be. The text names no compiler of the
1430
+ * input, since the audit is handed none. */
1431
+ const STALE_ACROSS_TYPES =
1432
+ 'and type-based alias analysis can let a read of one type pass a store of the other (agbcc at -O2 does, ' +
1433
+ 'and the audit refuses it on every target) — a cast spelling could read stale';
1434
+
1435
+ /** The one width wider than a byte that runtime-indexed accesses take, or why there is not one.
1436
+ *
1437
+ * A runtime index names no byte, so its access type must agree with every access it may reach,
1438
+ * and "agree" is what stops agbcc reordering two of them. At -O2 it runs type-based alias
1439
+ * analysis (toplev.c:3616), and `c_get_alias_set` (c-common.c:1892-2005) gives an integer type
1440
+ * and its unsigned twin one set, a character type set 0 (conflicting with every set), a
1441
+ * member access its member's type's set, and any other type — a pointer included — a new set.
1442
+ * Compiled at the corpus's flags, `k(sp0); ((int *)sp0)[i] = 1; ((T *)sp0)[j] = 0; return
1443
+ * ((int *)sp0)[i];` reloads the word for T = int, unsigned int and unsigned char, and returns the
1444
+ * stale 1 for T = unsigned short, void * and long.
1445
+ *
1446
+ * So ONE WIDTH IS ONE SET only while every access of that width READS AT one integer type or its
1447
+ * twin: a cast on the storage to `s32`/`u32` (`int`/`unsigned int` in every agbcc project), or a
1448
+ * member of an element struct (raise/struct-arrays) whose FIELD is such an integer — never the pointer an element
1449
+ * may hold. `/ptr-field`'s `over-local-storage` gate keeps such a field an integer, and contracts.ts
1450
+ * `assertLocalStorageStaysInteger` holds every respelled tree to it. The storage stays
1451
+ * `u8 name[n]` whatever the width. */
1452
+ function indexedWidth(accesses: readonly { readonly width: number }[]): {
1453
+ width: number | null;
1454
+ refusal: string | null;
1455
+ } {
1456
+ const widths = [...new Set(accesses.map((x) => x.width))].filter((w) => w !== 1);
1457
+ return {
1458
+ width: widths[0] ?? null,
1459
+ refusal: widths.length > 1 ? `accesses ${widths.join(' and ')} bytes wide, ${STALE_ACROSS_TYPES}` : null,
1460
+ };
1084
1461
  }
1085
1462
 
1086
1463
  /** The bytes an escape may reach, `[lo, hi)` from the object's own offset, and how it reads them
@@ -1089,6 +1466,10 @@ interface ReadWindow {
1089
1466
  readonly lo: number;
1090
1467
  readonly hi: number;
1091
1468
  readonly why: string;
1469
+ /** bounded only because a store through a computed pointer provably misses the control
1470
+ * halfword (`misses`): without that proof the read is unbounded, and the one-object answer
1471
+ * that held it then stays on offer (`chooseFrameModel`) */
1472
+ readonly sparedByMiss?: true;
1092
1473
  }
1093
1474
 
1094
1475
  /** Each op's block and index, over the IR as the classification judged it. */
@@ -1123,8 +1504,9 @@ const unbounded = (why: string): ReadWindow => ({ lo: -Infinity, hi: Infinity, w
1123
1504
  * the control stores that bound it are the ones reachable in between. Each has to be a literal
1124
1505
  * the target decodes. A store through a pointer this cannot resolve — one that is this frame's
1125
1506
  * 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
1507
+ * the read unbounded; so does a transfer never armed here at all. A store whose every address
1508
+ * misses that halfword (`misses`) is not such a store. An unbounded device read says which of
1509
+ * those it met (`why`): each is a different capability to build. A block transfer
1128
1510
  * that took the address as its source adds the bytes its control word reads (`calleeReads`).
1129
1511
  *
1130
1512
  * A NAMED SYMBOL PLUS A CONSTANT IS RESOLVED WHERE THE SYMBOL MAP PLACES THE NAME, and then
@@ -1154,9 +1536,11 @@ function readWindow(
1154
1536
  target: TargetDescription;
1155
1537
  },
1156
1538
  ): ReadWindow {
1157
- // a callee handed only its own return storage writes the struct it returns and nothing else
1539
+ // a callee handed only its own return storage writes the struct it returns and nothing else —
1540
+ // and this function's own reads of it hand the address to nobody
1158
1541
  const temps = uses.returnTemps.get(off);
1159
- if (temps !== undefined && uses.useCount.get(off) === temps.length) {
1542
+ const reads = uses.accesses.get(off)?.filter((a) => a.isLoad).length ?? 0;
1543
+ if (temps !== undefined && uses.useCount.get(off) === temps.length + reads) {
1160
1544
  return { lo: 0, hi: returnedSize(temps[0]), why: 'that returns its struct into it' };
1161
1545
  }
1162
1546
  const control = target.capabilities.readSourceControl;
@@ -1170,6 +1554,7 @@ function readWindow(
1170
1554
  }
1171
1555
  const little = target.capabilities.endianness === 'little';
1172
1556
  const halves: number[] = [];
1557
+ let spared = false;
1173
1558
  for (const { op: handed, sink } of stores) {
1174
1559
  const cnt = sink + control.offset;
1175
1560
  let armed = false;
@@ -1185,7 +1570,10 @@ function readWindow(
1185
1570
  const base = facts.literalAddrOf(op.operands[0]);
1186
1571
  if (base === undefined) {
1187
1572
  if (!flow.frameOnEveryPath.has(op.operands[0])) {
1188
- return unbounded('a later store through an unresolved pointer may re-arm');
1573
+ if (!misses(op, facts, cnt)) {
1574
+ return unbounded('a later store through an unresolved pointer may re-arm');
1575
+ }
1576
+ spared = true;
1189
1577
  }
1190
1578
  continue;
1191
1579
  }
@@ -1226,7 +1614,35 @@ function readWindow(
1226
1614
  lo = Math.min(lo, reach.lo);
1227
1615
  hi = Math.max(hi, reach.hi);
1228
1616
  }
1229
- return { lo, hi, why: 'that reads through it' };
1617
+ return { lo, hi, why: 'that reads through it', ...(spared ? { sparedByMiss: true as const } : {}) };
1618
+ }
1619
+
1620
+ /** A store through a computed pointer that provably misses the control halfword at `cnt`: no
1621
+ * address its base may hold (`addressRangeOf`, a strided interval), displaced by the store's own
1622
+ * offset, starts a store whose bytes overlap `[cnt, cnt + 2)`. agbcc's `gEnts[i].f = 1` after a
1623
+ * transfer, `i` a `u8`, writes one byte at `gEnts + 4 + 8 * [0, 255]`, and no `i` makes that the
1624
+ * channel's control register — so it cannot re-arm the channel, and the window stays where the
1625
+ * literal stores put it. The stride decides it where the interval alone cannot: placed at
1626
+ * 0x04000000 that span covers 0x040000DE, and still no `i` lands on it. Not "the transfer is
1627
+ * over": an immediate transfer is, but a later store that sets the enable bit again reloads the
1628
+ * source register and reads the frame anew (GBATEK, DMA control bit 15), which is what this
1629
+ * guards against. A store this cannot bound on every value stays an unresolved pointer. */
1630
+ function misses(op: Op, facts: FrameAddressFacts, cnt: number): boolean {
1631
+ const r = facts.addressRangeOf(op.operands[0]);
1632
+ if (r === undefined) {
1633
+ return false;
1634
+ }
1635
+ const off = op.attrs.off as number;
1636
+ const width = op.attrs.width as number;
1637
+ const lo = r.lo + off;
1638
+ const hi = r.hi + off;
1639
+ if (lo < 0 || hi + width > 2 ** 32) {
1640
+ return false;
1641
+ }
1642
+ // the first start at or above the lowest one that overlaps, against the highest that does
1643
+ const from = cnt - width + 1;
1644
+ const first = from <= lo || r.stride === 0 ? lo : lo + Math.ceil((from - lo) / r.stride) * r.stride;
1645
+ return first > Math.min(hi, cnt + 1) || first < from;
1230
1646
  }
1231
1647
 
1232
1648
  /** Which model the audit judges the frame in. `refused` is the one mutable fact the audit
@@ -1234,6 +1650,9 @@ function readWindow(
1234
1650
  * one-object answer is on offer, and read by the relift test. */
1235
1651
  interface ModelChoice {
1236
1652
  readonly onOffer: boolean;
1653
+ /** the escapes whose read is unbounded, or was before `misses` bounded it (`sparedByMiss`) — the
1654
+ * one fact both the offer and the relift test (`answered`) read, so the miss proof moves neither */
1655
+ readonly unboundedAt: ReadonlySet<number>;
1237
1656
  refused: boolean;
1238
1657
  /** refuses `why` where it stands, or, with the one-object answer on offer, asks for it instead */
1239
1658
  shapeRefused(why: string): void;
@@ -1264,27 +1683,56 @@ function writerNotKept(uses: FrameUses, returnsWithoutHiddenPointer: (callee: st
1264
1683
  return null;
1265
1684
  }
1266
1685
 
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);
1686
+ /** THE MODEL IS CHOSEN BEFORE ANY SHAPE IS JUDGED. The one-object answer is on offer in two
1687
+ * frames. Where one escape reaches without bound and that answer can hold every writer
1688
+ * (`writerNotKept`) — two out-parameters handed to one callee as readily as a device read. And
1689
+ * where NO ADDRESS ESCAPES AT ALL: then there is no writer and no reader but this function's own
1690
+ * accesses, so keeping every word of the declared area in memory keeps every store and every load
1691
+ * the machine made there, and a spill kept in memory changes no value. That second frame is the
1692
+ * one a by-value struct parameter leaves: agbcc spills its registers word by word (`str r1, [sp]`,
1693
+ * the slot model's) and, Thumb having no sp-relative halfword or byte load, reads its members
1694
+ * through `mov rD, sp` (the object's) — `int f(struct Q { u8 a[9]; } p){ return p.a[1] + p.a[8]; }`,
1695
+ * or a member read signed and unsigned, or the struct copied out a word at a time. The shapes the
1696
+ * per-object model refuses are ones that answer can hold: a member at [+k] through a capture, two
1697
+ * widths or two signednesses at one address, a runtime index, overlapping objects, a second
1698
+ * object beside an untyped one, an object over a slot. So there each of those refusals asks for
1699
+ * that answer instead of declining (`shapeRefused`), and the second audit judges the bytes as one
1700
+ * object by its own rules — two types at one byte are a union there, or refuse. Where the answer
1701
+ * is not on offer, each refuses where it stands.
1702
+ *
1703
+ * A frame nothing escapes from can still hold a CALL'S STACK ARGUMENT: agbcc builds one it cannot
1704
+ * place with `str rX, [sp,#k]` through `mov rD, sp` — a by-value struct block-copied with
1705
+ * `ldmia`/`stmia`, a compound literal stored a byte or a halfword at a time — and the address
1706
+ * escapes nowhere. Kept here as a local, in either model, those stores outlive the call that took
1707
+ * them. That is `storedNeverRead`'s to refuse, the stage after this one, and it asks it of every
1708
+ * call, whatever a prototype says. */
1709
+ function chooseFrameModel({
1710
+ oneObject,
1711
+ uses,
1712
+ windows,
1713
+ returnsWithoutHiddenPointer,
1714
+ fail,
1715
+ }: {
1716
+ oneObject: FrameRange | undefined;
1717
+ uses: FrameUses;
1718
+ windows: ReadonlyMap<number, ReadWindow>;
1719
+ returnsWithoutHiddenPointer: (callee: string) => boolean;
1720
+ fail: Refuse;
1721
+ }): ModelChoice {
1722
+ const unboundedAt = new Set(
1723
+ [...windows]
1724
+ .filter(([, w]) => (w.lo === -Infinity && w.hi === Infinity) || w.sparedByMiss === true)
1725
+ .map(([off]) => off),
1726
+ );
1727
+ const unbounded = oneObject === undefined && unboundedAt.size > 0;
1284
1728
  const writer = unbounded ? writerNotKept(uses, returnsWithoutHiddenPointer) : null;
1285
- const onOffer = unbounded && writer === null;
1729
+ const escapeFree = oneObject === undefined && uses.escaped.size === 0;
1730
+ // A struct passed by value is ONE object of its declared type, which the call names whole; the
1731
+ // one object is bytes, and a call handed them would pass a pointer.
1732
+ const onOffer = uses.byValueReads.size === 0 && ((unbounded && writer === null) || escapeFree);
1286
1733
  const model: ModelChoice = {
1287
1734
  onOffer,
1735
+ unboundedAt,
1288
1736
  refused: false,
1289
1737
  shapeRefused: (why) => {
1290
1738
  if (!onOffer) {
@@ -1302,6 +1750,226 @@ function chooseFrameModel(
1302
1750
  return model;
1303
1751
  }
1304
1752
 
1753
+ /** A FRAME STORE THAT REACHES A CALL UNREAD, BENEATH EVERY WORD PROVEN A LOCAL, MAY BE THAT
1754
+ * CALL'S STACK ARGUMENT — refuse. agbcc builds an argument it cannot place with `str rX, [sp,#k]`
1755
+ * IN PLACE through a capture: a by-value struct is `mov rD, sp` + `stmia`, a compound literal is
1756
+ * `strb`/`strh` through `mov rD, sp` moved by `add rD, #k`. The stack-argument analysis
1757
+ * (stackargs.ts) sees `[sp,#k]` slot stores alone, so it never weighs these, and no prototype
1758
+ * bounds them: a callee no prototype states counts argument registers alone, and a stated list is
1759
+ * a LOWER bound on the words a call takes — a variadic callee's list is a prefix, and DWARF's
1760
+ * `symbolPrototype` drops the `...`. Whatever model then holds the bytes, a local per capture or
1761
+ * the one object, keeps the stores and the call loses its stack words:
1762
+ * `th(1, 2, 3, 4, (struct S){{x, y}})` lifts as `th(1, 2, 3, 4)`. So this asks EVERY call.
1763
+ *
1764
+ * A store is asked when it reaches a call PENDING — on some path from it to the call nothing loads
1765
+ * a byte it wrote, and on EVERY path from the entry some byte of it is written by such a store and
1766
+ * not loaded since — since a word the callee takes is one the caller wrote for it on every path
1767
+ * to the call and did not read back. A store through a pointer that is the
1768
+ * frame's on only some paths (`*(p ? p : &local) = x`) is not asked: it is not a store the
1769
+ * outgoing block alone can own. Nor is a store a runtime index or an escaped address reaches from
1770
+ * an object at or below it: whoever that hands the bytes to may read them.
1771
+ *
1772
+ * AND ONLY A STORE BENEATH THE LOADED-WORD BOUND. Under `localsAboveOutgoingArea` a compiler keeps
1773
+ * one outgoing area at the frame bottom, below every local, and never reads it back after a call;
1774
+ * so a frame byte loaded after a call before anything re-stores it held a value across that call,
1775
+ * its word is a local's, and so is every word above it. Under the strong form,
1776
+ * `outgoingAreaNeverLoadedFrom`, any load in a word at or above that offset bounds it: from 0 on
1777
+ * agbcc, past the a0..a3 home on O32 (whose compilers load a by-value aggregate's home words back)
1778
+ * and from the parameter area on mwcc (`target.ts` says why). The frontend measures that bound
1779
+ * once, off its instructions, and hands it here (`loadedWords`) — the same number its own
1780
+ * stack-argument refusals take (stackargs' `loadedWordBound` on Thumb).
1781
+ * Every compiled outgoing-argument shape sits beneath any such bound, and a local the source reads
1782
+ * after a call does not: `g(); b[0] = x; b[1] = y; return b[0];` reaches no call, `b[0] = x; g();
1783
+ * return b[0];` is above the bound. What this still refuses that is honest is a local the source
1784
+ * stores, never reads back, and calls past — a dead local and an outgoing argument are the same
1785
+ * machine code, and the lift cannot tell which. The after-call liveness weighs this audit's fixed
1786
+ * accesses alone, so it sits at or above the bound the frontend's slot loads would give, which
1787
+ * only asks more; the strong form adds the frontend's bound (`loadedWords`). Without either
1788
+ * premise the bound proves nothing, and every pending store is asked. */
1789
+ function storedNeverRead(
1790
+ irBlocks: readonly Block[],
1791
+ uses: FrameUses,
1792
+ loadedWords: FrameObjectAudit['loadedWords'],
1793
+ target: TargetDescription,
1794
+ fail: Refuse,
1795
+ ): void {
1796
+ const calls = irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'call'));
1797
+ if (calls.length === 0) {
1798
+ return;
1799
+ }
1800
+ const byOp = new Map(uses.fixed.map((a) => [a.op, a]));
1801
+ const bytes = (a: { at: number; width: number }): number[] => [...Array(a.width).keys()].map((i) => a.at + i);
1802
+ const index = new Map(irBlocks.map((b, i) => [b, i]));
1803
+ const succs = irBlocks.map((b) => successorsOf(b).map((s) => index.get(s)!));
1804
+ const live = new Set(irBlocks.length > 0 ? [0] : []);
1805
+ for (const stack = [...live]; stack.length;) {
1806
+ for (const s of succs[stack.pop()!]) {
1807
+ if (!live.has(s)) {
1808
+ live.add(s);
1809
+ stack.push(s);
1810
+ }
1811
+ }
1812
+ }
1813
+
1814
+ // THE SURVIVOR BOUND: a backward liveness over the frame bytes this function reads at fixed
1815
+ // offsets through an address that is the frame's on every path. A load makes its bytes live, a
1816
+ // store that writes them kills them, and a call is transparent but marks every byte live across
1817
+ // it. A load whose address may be elsewhere proves nothing and a store that may write elsewhere
1818
+ // kills nothing; nor does a block the entry cannot reach.
1819
+ let bound = Infinity;
1820
+ if (target.compilerBehaviors.localsAboveOutgoingArea === true) {
1821
+ const liveIn = irBlocks.map(() => new Set<number>());
1822
+ for (let changed = true; changed;) {
1823
+ changed = false;
1824
+ for (let b = irBlocks.length - 1; b >= 0; b--) {
1825
+ if (!live.has(b)) {
1826
+ continue;
1827
+ }
1828
+ const cur = new Set(succs[b].flatMap((s) => [...liveIn[s]]));
1829
+ for (const op of [...irBlocks[b].ops].reverse()) {
1830
+ const a = byOp.get(op);
1831
+ if (op.opcode === 'call') {
1832
+ for (const o of cur) {
1833
+ bound = Math.min(bound, o - (o % 4));
1834
+ }
1835
+ } else if (a?.frameOnly) {
1836
+ for (const o of bytes(a)) {
1837
+ if (a.isLoad) {
1838
+ cur.add(o);
1839
+ } else {
1840
+ cur.delete(o);
1841
+ }
1842
+ }
1843
+ }
1844
+ }
1845
+ if ([...cur].some((o) => !liveIn[b].has(o))) {
1846
+ liveIn[b] = cur;
1847
+ changed = true;
1848
+ }
1849
+ }
1850
+ }
1851
+ }
1852
+ // …and under the STRONG form (`outgoingAreaNeverLoadedFrom`) the caller loads no word of the
1853
+ // area from that offset up before a call either, so ANY frame byte loaded there bounds it. The
1854
+ // frontend measured that bound off its instructions (`loadedWords`): the slot reloads the IR
1855
+ // does not show, and the prologue's and epilogue's own loads — without them
1856
+ // `mov r3, sp; strh r0, [r3]; … bl sink; ldr r0, [sp]` refuses the `strh` on a word the code
1857
+ // itself proves a local's. What only this audit sees is a load through a captured address, in a
1858
+ // block the entry reaches, and it bounds the same way. A load in a word below the offset proves
1859
+ // nothing: O32 compilers load the a1..a3 home words they stage a by-value aggregate in.
1860
+ if (loadedWords !== undefined) {
1861
+ bound = Math.min(bound, loadedWords.bound);
1862
+ for (const b of live) {
1863
+ for (const op of irBlocks[b].ops) {
1864
+ const a = byOp.get(op);
1865
+ const word = a === undefined ? -1 : a.at - (a.at % 4);
1866
+ if (a?.isLoad && a.frameOnly && word >= loadedWords.from) {
1867
+ bound = Math.min(bound, word);
1868
+ }
1869
+ }
1870
+ }
1871
+ }
1872
+ const from = loadedWords?.from;
1873
+
1874
+ // THE PENDING STORES, two forward dataflows from the entry over the blocks it reaches. The MAY
1875
+ // set is the stores this asks that some path brings to a point unread: a load of any byte a
1876
+ // store wrote ends it — an argument is a word the caller writes and never reads, so a store
1877
+ // with any byte read is a local's — as a frame store covering every byte it wrote does. The
1878
+ // MUST set is the bytes every path brings to a point written by a store this asks and not
1879
+ // loaded since: a load ends the bytes it reads, a frame store the bytes it writes, and a store
1880
+ // that may write elsewhere ends nothing. An argument is staged on EVERY path to its call — a
1881
+ // tail-merged call is staged in each predecessor — so a byte some path reaches the call without
1882
+ // is not this call's to take: a loop's store that the first trip calls past is a local's.
1883
+ const reachedFrom = Math.min(...uses.escaped, ...uses.indexed.keys());
1884
+ type Fixed = FrameUses['fixed'][number];
1885
+ const asked = (a: Fixed): boolean => !a.isLoad && a.frameOnly && a.at + a.width <= reachedFrom && a.at < bound;
1886
+ interface Pending {
1887
+ may: Set<Fixed>;
1888
+ must: Set<number>;
1889
+ }
1890
+ const step = (op: Op, cur: Pending): void => {
1891
+ const a = byOp.get(op);
1892
+ if (a === undefined) {
1893
+ return;
1894
+ }
1895
+ const touched = new Set(bytes(a));
1896
+ for (const p of [...cur.may]) {
1897
+ const mine = bytes(p);
1898
+ if (a.isLoad ? mine.some((o) => touched.has(o)) : a.frameOnly && mine.every((o) => touched.has(o))) {
1899
+ cur.may.delete(p);
1900
+ }
1901
+ }
1902
+ if (a.isLoad || a.frameOnly) {
1903
+ for (const o of touched) {
1904
+ cur.must.delete(o);
1905
+ }
1906
+ }
1907
+ if (asked(a)) {
1908
+ cur.may.add(a);
1909
+ for (const o of touched) {
1910
+ cur.must.add(o);
1911
+ }
1912
+ }
1913
+ };
1914
+ const into: (Pending | undefined)[] = irBlocks.map((_, b) =>
1915
+ b === 0 ? { may: new Set(), must: new Set() } : undefined,
1916
+ );
1917
+ for (let changed = true; changed;) {
1918
+ changed = false;
1919
+ for (let b = 0; b < irBlocks.length; b++) {
1920
+ const at = into[b];
1921
+ if (!live.has(b) || at === undefined) {
1922
+ continue;
1923
+ }
1924
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
1925
+ irBlocks[b].ops.forEach((op) => step(op, cur));
1926
+ for (const s of succs[b]) {
1927
+ const old = into[s];
1928
+ if (s === 0) {
1929
+ continue;
1930
+ }
1931
+ if (old === undefined) {
1932
+ into[s] = { may: new Set(cur.may), must: new Set(cur.must) };
1933
+ changed = true;
1934
+ continue;
1935
+ }
1936
+ const may = new Set([...old.may, ...cur.may]);
1937
+ const must = new Set([...old.must].filter((o) => cur.must.has(o)));
1938
+ if (may.size !== old.may.size || must.size !== old.must.size) {
1939
+ into[s] = { may, must };
1940
+ changed = true;
1941
+ }
1942
+ }
1943
+ }
1944
+ }
1945
+ for (let b = 0; b < irBlocks.length; b++) {
1946
+ const at = into[b];
1947
+ if (!live.has(b) || at === undefined) {
1948
+ continue;
1949
+ }
1950
+ const cur: Pending = { may: new Set(at.may), must: new Set(at.must) };
1951
+ for (const op of irBlocks[b].ops) {
1952
+ // a struct a call is passed by value is read by that call (`byValueReads`), not handed over
1953
+ if (op.opcode === 'call' && byOp.has(op)) {
1954
+ step(op, cur);
1955
+ }
1956
+ if (op.opcode === 'call') {
1957
+ const store = [...cur.may].filter((p) => bytes(p).some((o) => cur.must.has(o))).sort((x, y) => x.at - y.at)[0];
1958
+ if (store !== undefined) {
1959
+ const callee = calleeName(op);
1960
+ fail(
1961
+ `the ${store.width}-byte store at [sp,#${store.at}) through a captured address reaches the call to ` +
1962
+ `${callee === undefined ? 'an indirect callee' : `\`${callee}\``} unread on every path, and no ` +
1963
+ `${from === undefined ? 'load after a call' : from === 0 ? 'load at or beneath it' : `load at or beneath it, from [sp,#${from}) up,`} proves it a local's — it may be ` +
1964
+ "that call's stack argument, which the lift would drop",
1965
+ );
1966
+ }
1967
+ }
1968
+ step(op, cur);
1969
+ }
1970
+ }
1971
+ }
1972
+
1305
1973
  /** THE ACCEPTANCE'S PREMISE, RE-ASKED OF THE IR. `capturedObjectIsTheWholeFrame` is a reading
1306
1974
  * of the TEXT and it is the one thing in this file that switches a refusal OFF, so the two
1307
1975
  * facts it claims are re-proven here, where they are exact, rather than left to the
@@ -1356,6 +2024,26 @@ function recheckWholeFramePremise({
1356
2024
  }
1357
2025
  }
1358
2026
 
2027
+ /** THE ESCAPE EVENT'S PREMISE, RE-ASKED OF THE IR — `recheckWholeFramePremise`'s first half at
2028
+ * any frame size. The outgoing-argument analysis lets a frame address leaving this function end
2029
+ * the pending stores from its offset up (`StackArgsEscape`), reading the escape off what a
2030
+ * REGISTER holds at a `bl` or a word `str`. Where its verdict leans on that (it would refuse
2031
+ * without the escapes), each offset it read must reach a call operand or a word store outside the
2032
+ * frame in the lifted function too. It catches an arity that shrinks after the event was read
2033
+ * without passing the lowering's `CallSite.leftOut` question. Same two arms, same width as the
2034
+ * licence they re-prove. */
2035
+ function recheckEscapesReliedOn(offsets: ReadonlySet<number>, uses: FrameUses | undefined, fail: Refuse): void {
2036
+ for (const off of offsets) {
2037
+ if (uses === undefined || (!uses.passedToCallee.has(off) && !uses.publishedOutward.has(off))) {
2038
+ fail(
2039
+ `the outgoing-argument analysis set aside the stores from [sp,#${off}] up on that address escaping, and in the ` +
2040
+ 'lifted function no call takes it and no word store publishes it outside this frame — so nothing rules out ' +
2041
+ 'an outgoing stack argument there',
2042
+ );
2043
+ }
2044
+ }
2045
+ }
2046
+
1359
2047
  /** NOT A STRUCT-RETURN TEMP. A one-word frame rules out agbcc's block-copy bases (each needs
1360
2048
  * two words) but NOT the hidden return pointer of a <=4-byte non-integer-like struct, which is
1361
2049
  * exactly one word: `struct S4 { char a,b,c,d; }; struct S4 s = mk(x);` compiles to `add
@@ -1475,8 +2163,11 @@ function failIfSlotKeysIt(
1475
2163
  /** ONE OBJECT IN MEMORY, the answer a device read or a callee write nothing bounds is given
1476
2164
  * instead of a refusal (`oneObject`, requested by the relift test where the escapes are judged).
1477
2165
  * 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
2166
+ * holds (`writerNotKept` says which writers it can hold) — or one per part, where the accesses
2167
+ * prove the bytes are several objects no use reads across (`provenParts`) — and every access
2168
+ * inside it is a cast-spelled access to that array (an element of a part, by its index:
2169
+ * structure.ts `frameArrayElement`), or a view of the union it is declared as where two widths
2170
+ * meet at one byte — so each store the machine made there is a store the
1480
2171
  * recompile makes too. That is what agbcc does for an object whose address escaped: it keeps
1481
2172
  * every store to it, in order (flow.c deletes a memory store only when an identical later
1482
2173
  * store in the same block overwrites it). And it is right whichever the source had there: a
@@ -1489,6 +2180,8 @@ function keepAsOneObject({
1489
2180
  uses,
1490
2181
  usedSlotOffsets,
1491
2182
  returnsWithoutHiddenPointer,
2183
+ arrayOrder,
2184
+ arrayAlignment,
1492
2185
  fail,
1493
2186
  }: {
1494
2187
  oneObject: FrameRange;
@@ -1497,6 +2190,8 @@ function keepAsOneObject({
1497
2190
  uses: FrameUses;
1498
2191
  usedSlotOffsets: ReadonlySet<number>;
1499
2192
  returnsWithoutHiddenPointer: (callee: string) => boolean;
2193
+ arrayOrder: TargetDescription['compilerBehaviors']['addressTakenArrayOrder'];
2194
+ arrayAlignment: TargetDescription['compilerBehaviors']['addressTakenArrayAlignment'];
1500
2195
  fail: Refuse;
1501
2196
  }): void {
1502
2197
  const kept = `the bytes [sp,#${from}) to [sp,#${to}) kept as one object`;
@@ -1514,22 +2209,33 @@ function keepAsOneObject({
1514
2209
  if (writer !== null) {
1515
2210
  fail(`${kept} cannot hold every writer — ${writer}`);
1516
2211
  }
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.
2212
+ // A runtime index names no byte, so the rule below is asked of it at EVERY byte it may reach:
2213
+ // all of them. Two indexed widths refuse here; an index beside a member of another width
2214
+ // refuses after the members are checked against each other, so a member pair keeps naming its
2215
+ // own byte (`indexedWidth` says why one width is enough).
2216
+ const index = indexedWidth([...uses.indexed.values()].flat());
2217
+ if (index.refusal !== null) {
2218
+ fail(`a runtime index into ${kept} ${index.refusal}`);
2219
+ }
2220
+ // ONE TYPE PER BYTE, OR ONE UNION. agbcc at -O2 turns on type-based alias analysis
2221
+ // (toplev.c:3616), and compiled, a `u32` read through a cast is served the earlier `u32` store
2222
+ // straight past a `u16` store to the same bytes — a stale value. So a byte two accesses of
2223
+ // different widths reach cannot be spelled through casts. A byte access is exempt, since
2224
+ // character types alias everything, and so is a signedness difference, since the signed and
2225
+ // unsigned types of one width share an alias set (c-common.c:1962-1974). The first audit asks
2226
+ // for this answer rather than refusing two widths — an object read at both, or a `strh` member
2227
+ // through the captured address over a word the slot model had — so this is where they are judged.
2228
+ //
2229
+ // What two widths at one byte CAN be spelled as is a UNION of per-width views, every access made
2230
+ // directly through it (`sp0.half[1]`): c_get_alias_set gives any access through a member of a
2231
+ // union alias set 0 (c-common.c, the COMPONENT_REF walk), which conflicts with every set, so no
2232
+ // read moves past a store to the same bytes — compiled, a `u16` read, a `u32` store and the same
2233
+ // read again reloads through the union and returns the stale value through casts. So the object
2234
+ // is stamped with the views its accesses use (`unionViews`) and declared as that union
2235
+ // (structure.ts `laddrType`), where every access to it is a fixed offset on the object itself,
2236
+ // aligned to its own width — the cases `unionRefusal` names refuse as before.
1532
2237
  const widthAt = new Map<number, number>();
2238
+ let twoWidths: { b: number; had: number; width: number } | undefined;
1533
2239
  for (const m of uses.members) {
1534
2240
  if (m.at < from || m.at + m.width > to) {
1535
2241
  fail(`an access of ${m.width} bytes at [sp,#${m.at}) reaches past ${kept}`);
@@ -1540,14 +2246,30 @@ function keepAsOneObject({
1540
2246
  for (let b = m.at; b < m.at + m.width; b++) {
1541
2247
  const had = widthAt.get(b);
1542
2248
  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
- );
2249
+ twoWidths ??= { b, had, width: m.width };
1547
2250
  }
1548
2251
  widthAt.set(b, m.width);
1549
2252
  }
1550
2253
  }
2254
+ if (twoWidths !== undefined) {
2255
+ // any runtime index, a byte one too: `indexedWidth` leaves width 1 out because a char access
2256
+ // agrees with every width, but a union view is still spelled for fixed offsets alone
2257
+ const why = unionRefusal({ from, to, uses, indexed: uses.indexed.size > 0 });
2258
+ if (why !== null) {
2259
+ fail(
2260
+ `the byte at [sp,#${twoWidths.b}] of ${kept} is accessed ${twoWidths.had} and ${twoWidths.width} bytes ` +
2261
+ `wide, ${STALE_ACROSS_TYPES}, and ${why}`,
2262
+ );
2263
+ }
2264
+ }
2265
+ for (const [b, had] of widthAt) {
2266
+ if (index.width !== null && had !== index.width) {
2267
+ fail(
2268
+ `the byte at [sp,#${b}] of ${kept} is accessed ${had} bytes wide and by a runtime index ${index.width} ` +
2269
+ `bytes wide, ${STALE_ACROSS_TYPES}`,
2270
+ );
2271
+ }
2272
+ }
1551
2273
  // Rewritten onto one `laddr` at `from`: an access through a member at `k` becomes an access
1552
2274
  // at `k - from` off the object, and a member address used any other way becomes the object's
1553
2275
  // address moved by that constant.
@@ -1562,15 +2284,34 @@ function keepAsOneObject({
1562
2284
  // nothing here sees, so a spin on such a byte compiles unqualified; the per-object stamp has the
1563
2285
  // same gap (`stampObjects`: not on an ordinary `&local` argument).
1564
2286
  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
- });
2287
+ // …and declared as SEVERAL objects where the accesses prove the bytes are several
2288
+ // (`provenParts`), each rewritten on its own: an access through a member at `k` lands on the
2289
+ // part that holds `k`, at `k` less the part's start. Only on a compiler that places address-
2290
+ // taken arrays in a known order against their declarations (`addressTakenArrayOrder`): the
2291
+ // parts are declared in that order, or the recompile swaps their addresses. And at a known
2292
+ // alignment (`addressTakenArrayAlignment`), which says where a part may start.
2293
+ const parts =
2294
+ twoWidths === undefined && arrayOrder !== undefined && arrayAlignment !== undefined
2295
+ ? provenParts({ from, to }, objects, uses, arrayAlignment)
2296
+ : [{ from, to }];
2297
+ // …stamped `part` where there are several: storage a copy proved one object of the source's, so
2298
+ // its bytes are that object's elements rather than the one object's mix of members and spills,
2299
+ // and the emitter may name them as elements (structure.ts `frameArrayElement`)
2300
+ const laddrs = parts.map(({ from: lo, to: hi }) =>
2301
+ mkOp('laddr', {
2302
+ results: [mkValue(T.unk(32))],
2303
+ attrs: { off: lo, width: 1, signed: false, count: hi - lo, ...(parts.length > 1 ? { part: true } : {}) },
2304
+ }),
2305
+ );
2306
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2307
+ const object = laddrs[0];
1569
2308
  const base = object.results[0];
1570
2309
  const memberAt = new Map<Value, number>();
1571
2310
  for (const op of [...objects.values()].flat()) {
1572
2311
  memberAt.set(op.results[0], op.attrs.off as number);
1573
2312
  }
2313
+ const baseAt = (at: number) => laddrs[partOf(at)].results[0];
2314
+ const startAt = (at: number) => parts[partOf(at)].from;
1574
2315
  // The qualifier goes on every access the classification judged one (`accessOps`) — through a
1575
2316
  // member, a runtime index into one, or a phi that carries one (`p = c ? buf : gOther; while
1576
2317
  // (*p == 0);`). Only an access through a member is rebased: a phi's incoming member is
@@ -1584,14 +2325,17 @@ function keepAsOneObject({
1584
2325
  op.attrs = { ...op.attrs, volatile: true };
1585
2326
  }
1586
2327
  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 };
2328
+ op.operands = [baseAt(at), ...op.operands.slice(1)];
2329
+ op.attrs = { ...op.attrs, off: (op.attrs.off as number) + at - startAt(at) };
1589
2330
  }
1590
2331
  op.operands.forEach((v) => memberAt.has(v) && stillUsed.add(v));
1591
2332
  (op.successors ?? []).forEach((sx) => sx.args.forEach((v) => memberAt.has(v) && stillUsed.add(v)));
1592
2333
  }
1593
2334
  }
1594
- const atBase = (v: Value): Value => (memberAt.get(v) === from ? base : v);
2335
+ const atBase = (v: Value): Value => {
2336
+ const at = memberAt.get(v);
2337
+ return at !== undefined && at === startAt(at) ? baseAt(at) : v;
2338
+ };
1595
2339
  for (const blk of irBlocks) {
1596
2340
  blk.ops = blk.ops.flatMap((op) => {
1597
2341
  const at = op.opcode === 'laddr' ? memberAt.get(op.results[0]) : undefined;
@@ -1600,17 +2344,185 @@ function keepAsOneObject({
1600
2344
  (op.successors ?? []).forEach((sx) => (sx.args = sx.args.map(atBase)));
1601
2345
  return [op];
1602
2346
  }
1603
- if (!stillUsed.has(op.results[0]) || at === from) {
2347
+ if (!stillUsed.has(op.results[0]) || at === startAt(at)) {
1604
2348
  return [];
1605
2349
  }
1606
2350
  const by = mkValue(T.unk(32));
1607
2351
  return [
1608
- mkOp('const', { results: [by], attrs: { value: at - from } }),
1609
- mkOp('add', { operands: [base, by], results: op.results }),
2352
+ mkOp('const', { results: [by], attrs: { value: at - startAt(at) } }),
2353
+ mkOp('add', { operands: [baseAt(at), by], results: op.results }),
1610
2354
  ];
1611
2355
  });
1612
2356
  }
1613
- irBlocks[0].ops.unshift(object);
2357
+ if (twoWidths !== undefined) {
2358
+ // …every access is now a fixed offset on the object itself, which is what a view spells; one
2359
+ // that is not was carried there by a phi, as a pointer, and a pointer to the union's bytes is
2360
+ // read through a cast again
2361
+ for (const op of uses.accessOps.keys()) {
2362
+ if (op.operands[0] !== base) {
2363
+ fail(
2364
+ `the bytes [sp,#${from}) to [sp,#${to}) are accessed at two widths, which only a union of views ` +
2365
+ 'spells without a stale read — and an access reaches them through a pointer a phi carries, which ' +
2366
+ 'no view of the union names',
2367
+ );
2368
+ }
2369
+ }
2370
+ object.attrs = { ...object.attrs, views: unionViews(irBlocks, base) };
2371
+ }
2372
+ irBlocks[0].ops.unshift(...(arrayOrder === 'descending' ? [...laddrs].reverse() : laddrs));
2373
+ }
2374
+
2375
+ /** THE PARTS THE ACCESSES PROVE `[from, to)` IS MADE OF, lowest first — or the whole range, one
2376
+ * part, wherever they do not. A part is a maximal run of bytes that overlapping uses chain
2377
+ * together — a fixed-offset access, a struct passed by value, and an escape a standard block copy
2378
+ * with a literal count bounds (`escapeExtents`) each name the bytes they touch — and a run that
2379
+ * merely abuts the one below it starts a new part only where such a copy starts — and so must
2380
+ * the lowest part, or nothing is split. Two brace-initialised arrays side by side are agbcc's
2381
+ * `memcpy(a, .LC0, 16); memcpy(b, .LC2, 16)`, and declared as one `u8[32]` neither image can be
2382
+ * folded into its own local (l3/constinit.ts).
2383
+ *
2384
+ * ONE PART, never a refusal, whenever a byte could be read through two parts: a runtime index
2385
+ * (it names no byte, so it may reach every one), an escape nothing bounds (a callee or a holder
2386
+ * that may reach any byte from the address it was given), a byte no use names (nothing says
2387
+ * which side it belongs to), or a use through a member that lands outside the part its own
2388
+ * address lies in (`&a[16]` read at [-1]: one pointer walking two parts is one object). The
2389
+ * union views of two widths at one byte are not split either (the caller asks only without
2390
+ * them). An access straddling two runs chains them into one part by construction. */
2391
+ function provenParts(
2392
+ { from, to }: FrameRange,
2393
+ objects: ReadonlyMap<number, readonly Op[]>,
2394
+ uses: FrameUses,
2395
+ alignment: number,
2396
+ ): FrameRange[] {
2397
+ const whole = [{ from, to }];
2398
+ if (uses.indexed.size > 0) {
2399
+ return whole;
2400
+ }
2401
+ const touched: { owner: number; lo: number; hi: number }[] = [];
2402
+ for (const [op, { off }] of uses.accessOps) {
2403
+ const at = off + (op.attrs.off as number);
2404
+ touched.push({ owner: off, lo: at, hi: at + (op.attrs.width as number) });
2405
+ }
2406
+ for (const [off, reads] of uses.byValueReads) {
2407
+ reads.forEach(({ type }) =>
2408
+ touched.push({ owner: off, lo: off, hi: off + (type.kind === 'struct' ? (type.size ?? Infinity) : Infinity) }),
2409
+ );
2410
+ }
2411
+ for (const [off, extents] of uses.escapeExtents) {
2412
+ for (const e of extents) {
2413
+ if (e === null) {
2414
+ return whole;
2415
+ }
2416
+ touched.push({ owner: off, ...e });
2417
+ }
2418
+ }
2419
+ // …and a boundary only where a bounded copy starts: an address handed to `memcpy` is where
2420
+ // the source put an object it fills. A run starting anywhere else is bytes reached from below,
2421
+ // which says nothing of where one object ends — the one-object relift reads every slot through
2422
+ // an address of its own, so a struct spilled word by word and read through its base shows a
2423
+ // run per word, and is one object at every word. The LOWEST part is held to the same test
2424
+ // (below): where it is only stores, a copy above it is as likely `memcpy(&buf[4], s, 12)` into
2425
+ // the middle of one array as the start of a second one, and declared as two the recompile
2426
+ // reads `buf[6]` through a base it holds where the target re-formed sp+6. Only a copy's
2427
+ // DESTINATION counts: a copy reading OUT of the frame says where the source took bytes from,
2428
+ // which is as likely `memcpy(g, &w[2], 8)` from the middle of one array as a second object.
2429
+ const starts = new Set([...uses.escapeExtents.values()].flat().flatMap((e) => (e?.into ? [e.lo] : [])));
2430
+ const parts: { from: number; to: number }[] = [];
2431
+ for (const { lo, hi } of [...touched].sort((a, b) => a.lo - b.lo)) {
2432
+ const last = parts.at(-1);
2433
+ if (last !== undefined && (lo < last.to || (lo === last.to && !starts.has(lo)))) {
2434
+ last.to = Math.max(last.to, hi);
2435
+ } else {
2436
+ parts.push({ from: lo, to: hi });
2437
+ }
2438
+ }
2439
+ const tiles =
2440
+ parts.length > 1 &&
2441
+ parts[0].from === from &&
2442
+ parts.at(-1)!.to === to &&
2443
+ parts.every((p, i) => i === 0 || parts[i - 1].to === p.from);
2444
+ const partOf = (at: number) => parts.findIndex((p) => at >= p.from && at < p.to);
2445
+ if (
2446
+ !tiles ||
2447
+ [...objects.keys()].some((off) => partOf(off) < 0) ||
2448
+ touched.some(({ owner, lo }) => partOf(owner) !== partOf(lo))
2449
+ ) {
2450
+ return whole;
2451
+ }
2452
+ // …and a part starts only where the compiler places a local array of its own: at a multiple of
2453
+ // its `alignment` (agbcc pads `u8 a[18]` to 20, so a split there declares `b` at sp+20 where the
2454
+ // target had it at sp+18; gcc 2.7.2 on MIPS starts every one at a multiple of 8, so a part at
2455
+ // sp+20 is placed at sp+24 and the frame grows), and never as ONE byte, which declared alone is
2456
+ // a `u8` scalar whose address nothing takes — kept in a register, it drops the frame byte and
2457
+ // the store the target made. Such a part joins the one below it, and the lowest one byte joins
2458
+ // the one above: fewer parts only ever read as the one object does, so this costs a split,
2459
+ // never a value.
2460
+ const merged: { from: number; to: number }[] = [];
2461
+ for (const p of parts) {
2462
+ const last = merged.at(-1);
2463
+ if (last !== undefined && (p.from % alignment !== 0 || p.to - p.from === 1 || last.to - last.from === 1)) {
2464
+ last.to = p.to;
2465
+ } else {
2466
+ merged.push({ ...p });
2467
+ }
2468
+ }
2469
+ return merged.length > 1 && merged.every((p) => starts.has(p.from)) ? merged : whole;
2470
+ }
2471
+
2472
+ /** Why the one object's bytes, accessed at two widths, cannot be spelled as a union of per-width
2473
+ * views, or null. A view names an ELEMENT, so every access must sit at a multiple of its own width
2474
+ * from the object's start and every view must tile the object; and it names it at a constant
2475
+ * index, so a runtime index into the object — whose element width a view could carry, but whose
2476
+ * spelling nothing here builds — keeps the refusal, and so does a qualifier on the accesses
2477
+ * (`publishedToWriter`), which a member access of an unqualified union cannot carry. */
2478
+ function unionRefusal({
2479
+ from,
2480
+ to,
2481
+ uses,
2482
+ indexed,
2483
+ }: {
2484
+ from: number;
2485
+ to: number;
2486
+ uses: FrameUses;
2487
+ indexed: boolean;
2488
+ }): string | null {
2489
+ if (indexed) {
2490
+ return 'a runtime index reaches them too, which no view of a union is spelled for';
2491
+ }
2492
+ if (uses.publishedToWriter.size > 0) {
2493
+ return 'the address is stored where a writer may hold it, and a view of a union carries no per-access qualifier';
2494
+ }
2495
+ for (const m of uses.members) {
2496
+ if ((m.at - from) % m.width !== 0) {
2497
+ return `the ${m.width}-byte access at [sp,#${m.at}) is not aligned to its width, so no view of a union names it`;
2498
+ }
2499
+ if ((to - from) % m.width !== 0) {
2500
+ return `the ${to - from} bytes are not a whole number of ${m.width}-byte elements, so no view of a union tiles them`;
2501
+ }
2502
+ }
2503
+ return null;
2504
+ }
2505
+
2506
+ /** The views a union object's accesses use, as `laddr`'s `views` attr: one entry per view, its
2507
+ * element width in bytes, NEGATIVE for a signed narrow view — which views, by the rule
2508
+ * raise/structs.ts declares a struct member's union by (ir/types.ts `unionViewSigns`). A word's
2509
+ * one view is `s32` whichever sign it carries, so it is entered unsigned. Widest first, unsigned
2510
+ * before signed. */
2511
+ function unionViews(irBlocks: readonly Block[], base: Value): number[] {
2512
+ const byWidth = new Map<number, { isLoad: boolean; signed: boolean; stamped: boolean }[]>();
2513
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
2514
+ if ((op.opcode === 'load' || op.opcode === 'store') && op.operands[0] === base) {
2515
+ const w = op.attrs.width as number;
2516
+ (byWidth.get(w) ?? byWidth.set(w, []).get(w)!).push({
2517
+ isLoad: op.opcode === 'load',
2518
+ signed: (op.attrs.signed as boolean) ?? false,
2519
+ stamped: op.opcode === 'store' && op.attrs.signed !== undefined,
2520
+ });
2521
+ }
2522
+ }
2523
+ return [...byWidth]
2524
+ .sort(([a], [b]) => b - a)
2525
+ .flatMap(([w, accesses]) => unionViewSigns(w, accesses).map((sg) => (sg && w !== 4 ? -w : w)));
1614
2526
  }
1615
2527
 
1616
2528
  /** One object's shape: `count` elements of `width` bytes. */
@@ -1640,8 +2552,10 @@ function slotKeys(usedSlotOffsets: ReadonlySet<number>, off: number, width: numb
1640
2552
  * AN OBJECT OVER A SLOT IS REFUSED LAST, after the one-object answer is asked: that answer
1641
2553
  * routes every word of the local area through `laddr`, so no slot is left for the object to
1642
2554
  * 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. */
2555
+ * [sp,#0] whose first member is a word stored `str rN, [sp]`, or a by-value struct parameter
2556
+ * spilled word by word and read through `mov rD, sp` where nothing escapes. The other per-object
2557
+ * refusals here go through `shapeRefused`, for the same reason: where nothing escapes, each of
2558
+ * them asks for that answer too (`chooseFrameModel`). */
1645
2559
  function objectShapes({
1646
2560
  objects,
1647
2561
  uses,
@@ -1667,30 +2581,63 @@ function objectShapes({
1667
2581
  for (const [off, acc] of uses.accesses) {
1668
2582
  const byIndex = uses.indexed.get(off) ?? [];
1669
2583
  const temps = uses.returnTemps.get(off);
2584
+ const passed = uses.byValueReads.get(off);
2585
+ if (passed !== undefined) {
2586
+ // A struct passed BY VALUE (`foldByValueArgs`): this function writes and reads its members,
2587
+ // and the call reads it whole. A struct-return temp passed on, an index, or any other use of
2588
+ // the address is not modelled.
2589
+ const type = passed[0].type;
2590
+ const at = `the object at [sp,#${off}) is passed by value as ${structSpelling(type)}`;
2591
+ if (temps !== undefined) {
2592
+ fail(`${at}, and is also the storage a call returns a struct into — not modelled`);
2593
+ }
2594
+ if (passed.some((p) => !typeEquals(p.type, type))) {
2595
+ fail(`${at} to calls declared to take different types`);
2596
+ }
2597
+ if (byIndex.length > 0) {
2598
+ fail(`${at}, and this function also indexes it — only its members' accesses are modelled`);
2599
+ }
2600
+ if (uses.useCount.get(off) !== acc.length + passed.length) {
2601
+ fail(`${at}, and its address is also used another way — only its members' accesses are modelled`);
2602
+ }
2603
+ const size = type.kind === 'struct' ? type.size! : 0;
2604
+ const past = uses.fixed.find(
2605
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
2606
+ );
2607
+ if (past !== undefined) {
2608
+ fail(`${at}, and this function accesses [sp,#${past.at}) of width ${past.width}, past the struct`);
2609
+ }
2610
+ extent.set(off, { width: 1, count: size });
2611
+ aggregateAt.set(off, type);
2612
+ continue;
2613
+ }
1670
2614
  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.
2615
+ // The callee writes the whole struct and this function names it as the call's destination,
2616
+ // and READS it: each load is of bytes inside the struct, which the struct's declaration
2617
+ // types (raise/aggmembers.ts reads a word's members, structure.ts spells a member access).
2618
+ // A write, an index, or any other use of the address is not modelled.
1677
2619
  const callee = temps[0].attrs.target as string;
1678
2620
  const type = temps[0].results[0].type;
1679
2621
  const spelling = type.kind === 'struct' ? (type.declared ?? `struct ${type.name}`) : typeToString(type);
2622
+ const at = `the object at [sp,#${off}) is where \`${callee}\` returns ${spelling}`;
1680
2623
  if (temps.some((t) => !typeEquals(t.results[0].type, type))) {
1681
2624
  fail(`the object at [sp,#${off}) is the struct-return storage of calls declared to return different types`);
1682
2625
  }
1683
- if (acc.length > 0 || byIndex.length > 0) {
2626
+ if (acc.some((a) => !a.isLoad) || byIndex.length > 0) {
1684
2627
  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',
2628
+ `${at}, and this function also ${byIndex.length > 0 ? 'indexes' : 'writes'} it — only a read of a ` +
2629
+ 'returned struct is modelled',
1687
2630
  );
1688
2631
  }
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
- );
2632
+ if (uses.useCount.get(off) !== temps.length + acc.length) {
2633
+ fail(`${at}, and its address is also used another way — only the call it is returned by is modelled`);
2634
+ }
2635
+ const size = returnedSize(temps[0]);
2636
+ const past = uses.fixed.find(
2637
+ (f) => uses.accessOps.get(f.op)?.off === off && (f.at < off || f.at + f.width > off + size),
2638
+ );
2639
+ if (past !== undefined) {
2640
+ fail(`${at}, and this function reads [sp,#${past.at}) of width ${past.width}, past the struct`);
1694
2641
  }
1695
2642
  extent.set(off, { width: 1, count: returnedSize(temps[0]) });
1696
2643
  aggregateAt.set(off, type);
@@ -1721,26 +2668,18 @@ function objectShapes({
1721
2668
  // …asked for the one object instead, which sizes no object of its own
1722
2669
  continue;
1723
2670
  }
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
- }
2671
+ // An indexed access reads THROUGH A CAST on the storage declared below, at its own width
2672
+ // and signedness, as an access to the one object does (`keepAsOneObject`), so the accesses
2673
+ // wider than a byte must be of one width, one alias set (`indexedWidth`).
2674
+ const index = indexedWidth(byIndex);
2675
+ if (index.refusal !== null) {
2676
+ model.shapeRefused(`a runtime index into the object at [sp,#${off}) ${index.refusal}`);
1739
2677
  }
1740
2678
  // STORAGE, NOT A TYPE. The reservation says how many bytes the frame holds and the
1741
2679
  // conjuncts above say they are all this object's, so the declaration commits to an EXTENT
1742
2680
  // and to nothing else: `u8 name[n]` over the declared range, unsigned bytes because no
1743
2681
  // access named an element type and inventing one is the guess this refuses everywhere else.
2682
+ // An indexed access names only its own width, which its cast spells where it reads.
1744
2683
  //
1745
2684
  // THE EXTENT IS A ROUNDED ONE, stated because it is not a defect. agbcc reserves the
1746
2685
  // local area in whole words, so a source object of 13, 14, 15 or 16 bytes all reserve
@@ -2058,8 +2997,9 @@ function stampObjects(
2058
2997
  ): void {
2059
2998
  for (const [off, ops] of objects) {
2060
2999
  const { width, count } = extent.get(off)!;
2061
- const signed = uses.accesses.get(off)!.some((a) => a.signed);
2062
3000
  const aggregate = aggregateAt.get(off);
3001
+ // a declared struct's storage is bytes, whatever signedness its members are read at
3002
+ const signed = aggregate === undefined && uses.accesses.get(off)!.some((a) => a.signed);
2063
3003
  for (const op of ops) {
2064
3004
  const qualified = uses.published.has(off) || uses.filledFrom.has(off);
2065
3005
  op.attrs = {
@@ -2092,8 +3032,8 @@ function gcd(a: number, b: number): number {
2092
3032
  return b === 0 ? Math.abs(a) : gcd(b, a % b);
2093
3033
  }
2094
3034
 
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). */
3035
+ /** The size of the struct a call stamped `sret` returns through its argument 0 (frontend/call-plan.ts
3036
+ * types the call's result as that struct, for every frontend whose lowering passes the pointer). */
2097
3037
  function returnedSize(call: Op): number {
2098
3038
  const t = call.results[0].type;
2099
3039
  if (t.kind !== 'struct' || t.size === undefined) {
@@ -2102,8 +3042,108 @@ function returnedSize(call: Op): number {
2102
3042
  return t.size;
2103
3043
  }
2104
3044
 
3045
+ /** The block parameters every incoming value of which is a local's address or another such
3046
+ * parameter: the greatest set closed under that, so a loop-carried address is one too. Each maps to
3047
+ * the frame offsets its incoming addresses name, through such parameters. The entry block's are the
3048
+ * function's parameters, and are left out. */
3049
+ function mergedFrameAddresses(irBlocks: readonly Block[]): Map<Value, ReadonlySet<number>> {
3050
+ const laddrOff = new Map<Value, number>();
3051
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3052
+ if (op.opcode === 'laddr') {
3053
+ laddrOff.set(op.results[0], op.attrs.off as number);
3054
+ }
3055
+ }
3056
+ const incoming = new Map<Value, Value[]>();
3057
+ for (const b of irBlocks.slice(1)) {
3058
+ for (const p of b.params) {
3059
+ incoming.set(p, []);
3060
+ }
3061
+ }
3062
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3063
+ for (const succ of op.successors) {
3064
+ succ.block.params.forEach((p, i) => incoming.get(p)?.push(succ.args[i]));
3065
+ }
3066
+ }
3067
+ const merged = new Set([...incoming].filter(([, vs]) => vs.length > 0).map(([p]) => p));
3068
+ for (let changed = true; changed;) {
3069
+ changed = false;
3070
+ for (const p of merged) {
3071
+ if (incoming.get(p)!.some((v) => !laddrOff.has(v) && !merged.has(v))) {
3072
+ merged.delete(p);
3073
+ changed = true;
3074
+ }
3075
+ }
3076
+ }
3077
+ const out = new Map<Value, ReadonlySet<number>>();
3078
+ for (const p of merged) {
3079
+ const offs = new Set<number>();
3080
+ const seen = new Set<Value>([p]);
3081
+ const work = [p];
3082
+ while (work.length > 0) {
3083
+ for (const v of incoming.get(work.pop()!)!) {
3084
+ if (laddrOff.has(v)) {
3085
+ offs.add(laddrOff.get(v)!);
3086
+ } else if (!seen.has(v)) {
3087
+ seen.add(v);
3088
+ work.push(v);
3089
+ }
3090
+ }
3091
+ }
3092
+ out.set(p, offs);
3093
+ }
3094
+ return out;
3095
+ }
3096
+
3097
+ /** A STRUCT RETURNED THROUGH A MERGE OF ONE LOCAL'S ADDRESS IS THAT LOCAL. gcc rotates a loop that
3098
+ * calls a struct-returning function, `while (lo > 0) { qr = ldiv(lo, 10); … }`, so `addiu a0,sp,16`
3099
+ * runs once ahead of the loop and once in the delay slot of its back branch, and hoists it into
3100
+ * both arms of `if (x) a = f(x); else a = f(x + 1);` ahead of one shared `jal`. The call then
3101
+ * reads a block parameter, but every path hands it the same frame address, so the call is handed
3102
+ * that address: an `laddr` of it, minted ahead of the call, which the frame-object audit types as
3103
+ * the struct (`declaredStructs`) and `refuseSretOutsideFrame` accepts. A merge of two offsets is
3104
+ * two objects, and is left for the audit to refuse. Run before the audit, on every frontend whose call
3105
+ * lowering passes the hidden pointer. */
3106
+ export function nameMergedSretObjects(irBlocks: Block[]): void {
3107
+ const merged = mergedFrameAddresses(irBlocks);
3108
+ for (const b of irBlocks) {
3109
+ b.ops = b.ops.flatMap((op) => {
3110
+ const offs = op.opcode === 'call' && op.attrs.sret === true ? merged.get(op.operands[0]) : undefined;
3111
+ if (offs === undefined || offs.size !== 1) {
3112
+ return [op];
3113
+ }
3114
+ const at = mkValue(T.unk(32));
3115
+ op.operands = [at, ...op.operands.slice(1)];
3116
+ return [mkOp('laddr', { results: [at], attrs: { off: [...offs][0] } }), op];
3117
+ });
3118
+ }
3119
+ }
3120
+
3121
+ /** A STRUCT RETURNED THROUGH MEMORY IS SPELLED `local = f(..)`, which hands the callee that local's
3122
+ * address: the hidden pointer must be a frame object the audit accepted. A global or a pointer
3123
+ * handed over directly — `gS = f(..)` (agbcc, ido, gcc), IDO's `*p = f(..)`, and the caller's own
3124
+ * hidden pointer forwarded by `return f(..)` — is another spelling, which nothing here states. A
3125
+ * merge of frame addresses never reaches here: one that names one address on every path was handed
3126
+ * that address's `laddr` before the audit (`nameMergedSretObjects`), and a merge of two names no one
3127
+ * storage, which the audit refuses first (`addressFlow`: `a phi merges the frame objects`). Asked
3128
+ * of every frontend whose call lowering passes the hidden pointer
3129
+ * (`CallLowering.memoryReturn`). */
3130
+ export function refuseSretOutsideFrame(name: string, irBlocks: readonly Block[], target: TargetDescription): void {
3131
+ const laddrs = new Set(
3132
+ irBlocks.flatMap((b) => b.ops.filter((op) => op.opcode === 'laddr').map((op) => op.results[0])),
3133
+ );
3134
+ for (const op of irBlocks.flatMap((b) => b.ops)) {
3135
+ if (op.opcode === 'call' && op.attrs.sret === true && !laddrs.has(op.operands[0])) {
3136
+ throw new FrontendUnsupportedError(
3137
+ `cannot lift '${name}': \`${op.attrs.target as string}\` returns struct ${typeToString(op.results[0].type)} through the pointer in ${target.argRegs[0]}, ` +
3138
+ 'and that pointer is not the address of a local of this frame — a struct returned into a global or ' +
3139
+ 'through a pointer is not modelled',
3140
+ );
3141
+ }
3142
+ }
3143
+ }
3144
+
2105
3145
  /** 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
3146
+ * `auditFrameObjects` binding, so a process outside core can put a wrapped `gates` table in front of a real
2107
3147
  * lift (`pnpm bench gates --pass frame-objects`): a module-namespace binding is read-only and
2108
3148
  * cannot be swapped (`apps/benchmark/src/run/gate-census.ts`, WHAT PUTS A PASS IN THE REGISTRY). */
2109
3149
  export const FRAME_OBJECT_AUDIT: { run: (audit: FrameObjectAudit) => FrameObjectVerdict } = {