@asmlift/core 0.7.0 → 0.8.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 (44) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/pascal.ts +2 -2
  4. package/src/codegen-flags.ts +640 -0
  5. package/src/frontend/disasm.ts +141 -11
  6. package/src/frontend/high-half.ts +149 -0
  7. package/src/frontend/mips.ts +458 -209
  8. package/src/frontend/ppc.ts +332 -67
  9. package/src/frontend/reloc-symbol.ts +109 -0
  10. package/src/frontend/splat.ts +56 -18
  11. package/src/frontend/ssa.ts +126 -29
  12. package/src/frontend/stackargs.ts +420 -0
  13. package/src/frontend/thumb.ts +207 -230
  14. package/src/ir/core.ts +62 -3
  15. package/src/ir/opcodes.ts +9 -0
  16. package/src/ir/parse.ts +7 -1
  17. package/src/l3/advance.ts +2 -2
  18. package/src/l3/argbase.ts +2 -2
  19. package/src/l3/argcopy.ts +269 -0
  20. package/src/l3/ast.ts +45 -1
  21. package/src/l3/basecse.ts +2 -2
  22. package/src/l3/coalesce.ts +109 -52
  23. package/src/l3/scopebase.ts +4 -4
  24. package/src/l3/tailret.ts +70 -0
  25. package/src/l3/unmerge.ts +2 -2
  26. package/src/l3/unreduce.ts +2 -1
  27. package/src/mangle.ts +49 -0
  28. package/src/pattern/engine.ts +128 -13
  29. package/src/pipeline.ts +22 -11
  30. package/src/raise/extscale.ts +5 -2
  31. package/src/raise/paramwidth.ts +111 -3
  32. package/src/raise/pre-recovery.ts +11 -1
  33. package/src/raise/retsink.ts +8 -4
  34. package/src/raise/tailsink.ts +17 -2
  35. package/src/rank-declare.ts +17 -9
  36. package/src/rank.ts +45 -19
  37. package/src/structure/retspell.ts +95 -0
  38. package/src/structure/structure.ts +12 -3
  39. package/src/structure/switch-recover.ts +1 -1
  40. package/src/target.ts +224 -14
  41. package/src/trace.ts +27 -18
  42. package/src/variation-definitions.ts +52 -2
  43. package/src/variation-gates.ts +3 -0
  44. package/src/variation-tokens.ts +1 -0
@@ -0,0 +1,420 @@
1
+ /** THE OUTGOING STACK-ARGUMENT ANALYSIS, over digested slot events rather than instructions.
2
+ *
3
+ * NOTHING HERE DECODES. The frontend hands over, per basic block, the sequence of whole-word
4
+ * frame-slot accesses and calls it found, each call already carrying the block its callee's
5
+ * DECLARATION asks for; every mnemonic, addressing mode and prototype question is answered before
6
+ * the input is built. That split is what makes the fixpoint below testable on its own — it is the
7
+ * part most likely to be wrong, and a table of events is a fixture, where a table of Thumb is a
8
+ * second decoder.
9
+ *
10
+ * The call token `C` is opaque: the analysis only ever uses it as a map key, so the frontend can
11
+ * key by its own instruction object and a test by a string.
12
+ */
13
+
14
+ /** One event the analysis reads, in block order. A load and a store are the only frame accesses
15
+ * that matter — a sub-word or register-offset access is not a slot, and one anywhere turns the
16
+ * whole word-slot model off at the same gate this analysis's `blocker` feeds, so no access that
17
+ * could alias a licensed word is ever silently missing from the events. */
18
+ export type StackArgsEvent<C> = StackArgsSlot | StackArgsCall<C>;
19
+
20
+ /** A whole-word access to a reserved frame slot at `off`, which must lie inside `[0, localArea)`. */
21
+ export interface StackArgsSlot {
22
+ readonly kind: 'store' | 'load';
23
+ readonly off: number;
24
+ }
25
+
26
+ /** A call, and what its callee's DECLARATION says it takes on the stack. `declared` is the block
27
+ * `[0, 4*(n - argRegs))` as an ascending offset list, or null when nothing declares this call —
28
+ * an indirect call, a callee with no prototype, or one whose arity fits in registers. A null
29
+ * declaration can only ever lead to a refusal: the code alone cannot say where a block ENDS. */
30
+ export interface StackArgsCall<C> {
31
+ readonly kind: 'call';
32
+ /** The frontend's own handle for this call — the key of `OutgoingArgs.blocks`. */
33
+ readonly call: C;
34
+ /** The callee's name, for the refusal messages. `?` where the frontend has none. */
35
+ readonly callee: string;
36
+ readonly declared: readonly number[] | null;
37
+ }
38
+
39
+ /** One basic block's events. Where control goes afterwards is read out of `preds`, so a block is
40
+ * nothing but its slot-level events. */
41
+ export interface StackArgsBlock<C> {
42
+ readonly events: readonly StackArgsEvent<C>[];
43
+ }
44
+
45
+ export interface StackArgsInput<C> {
46
+ /** Every block, live or not, indexed as `preds` and `live` index them. */
47
+ readonly blocks: readonly StackArgsBlock<C>[];
48
+ readonly preds: readonly (readonly number[])[];
49
+ /** The entry-reachable blocks. Dead blocks are still passed so a call in one is still SEEN —
50
+ * it stages nothing, so it refuses, which is the verdict that does not depend on deciding
51
+ * whether the block runs. */
52
+ readonly live: ReadonlySet<number>;
53
+ /** The frame this function reserved for itself, in bytes. 0 disables every slot. */
54
+ readonly localArea: number;
55
+ /** How many arguments the ABI passes in registers — where the stack block starts counting. */
56
+ readonly argRegs: number;
57
+ /** The frontend's verdict that the whole frame is one addressable object handed to a callee. */
58
+ readonly capturedWholeFrame: boolean;
59
+ }
60
+
61
+ /** What this function's calls do with the BOTTOM of its frame — the outgoing stack-argument area
62
+ * agbcc's ACCUMULATE_OUTGOING_ARGS reserves there for arguments 5+ of the calls it makes. */
63
+ export interface OutgoingArgs<C> {
64
+ /** Why no call here may be consumed, or null. The frontend returns it as its slot-model blocker,
65
+ * so a function whose area cannot be licensed declines at its first `[sp,#k]` access instead of
66
+ * lifting. */
67
+ blocker: string | null;
68
+ /** Per call, the frame offsets that call's stack arguments occupy — ascending and contiguous
69
+ * from zero. A call absent from the map passes everything in registers. */
70
+ blocks: ReadonlyMap<C, readonly number[]>;
71
+ /** The largest licensed block's extent. `[0, area)` is storage this function owns and does NOT
72
+ * declare, so it is where `LiveInModel.declaredLocals` starts. */
73
+ area: number;
74
+ }
75
+
76
+ const isCallEvent = <C>(ev: StackArgsEvent<C>): ev is StackArgsCall<C> => ev.kind === 'call';
77
+
78
+ // THE OUTGOING STACK-ARGUMENT AREA, AND WHO MAY CONSUME IT.
79
+ //
80
+ // agbcc's ACCUMULATE_OUTGOING_ARGS reserves the BOTTOM of the frame for arguments 5+ of the calls
81
+ // this function makes: `add sp,sp,#-8` … `str r2,[sp]` / `str r3,[sp,#4]` … `bl callee`. Those
82
+ // offsets are inside the frame and nothing this function does ever reloads them. "Inside my
83
+ // frame" does not mean "private": the area belongs to the CALLEE, which may even assign to a
84
+ // stack parameter. Model those words as locals and they are dead defs that DCE deletes — the
85
+ // arguments vanish from the call with no diagnostic. Ground truth: sa3's
86
+ // CreateEntity_Platform_0_0 (platform.c:734) forwards SIX arguments and came out as
87
+ // `CreateEntity_Platform(0, 0, a0, (u16)a1)`.
88
+ //
89
+ // TWO INDEPENDENT WITNESSES MUST AGREE, and that agreement is the whole licence:
90
+ // * the DECLARATION says how many words a call takes. AAPCS lays arguments 5..n at [sp,#0]
91
+ // upward, one word each, so the block is `[0, 4*(n - |argRegs|))`, contiguous from zero.
92
+ // * the CODE says which words are staged for it — the offsets stored and not yet reloaded when
93
+ // the `bl` executes.
94
+ // Equal ⇒ consume. Anything else ⇒ decline, naming what was seen.
95
+ //
96
+ // WHY NEITHER WITNESS IS ENOUGH ALONE. A declared parameter list is a LOWER bound on the words a
97
+ // call pushes:
98
+ //
99
+ // * a parameter may occupy more than one word (`double`, `long long`, a struct by value),
100
+ // * a variadic callee's list is a prefix — `sprintf` truthfully declares two and is handed six,
101
+ // * a large struct return adds a hidden pointer argument that appears in no parameter list.
102
+ //
103
+ // None of those is recorded by `FnProto` or `SymbolSignature`, so an ARITY-ONLY acceptance had
104
+ // all three holes: supplying a TRUE fact (`{ sprintf: { params: 2 } }`) turned a correct decline
105
+ // into `return sprintf(a0, a1)` with both stack arguments deleted. Under the rule here the four
106
+ // words `sprintf` is really handed are four offsets reaching the call that its declaration does
107
+ // not account for, the witnesses disagree, and the answer is the decline again. And the CODE
108
+ // alone cannot say where a block ENDS — a store never reloaded is an argument's signature, but
109
+ // so is a dead local, which is why reading the code alone could only ever refuse (conditions (a)
110
+ // and (b) below, kept for every call no declaration covers).
111
+ //
112
+ // THE TWO SIDES ARE CHECKED AGAINST DIFFERENT SETS, and the asymmetry is the point.
113
+ // * NOTHING EXTRA is checked against the MAY set (stored and unreloaded on SOME path): the
114
+ // weakest thing that could still be a word this call takes must be inside the block.
115
+ // * NOTHING MISSING is checked against the MUST set (on EVERY path): a slot the callee reads
116
+ // must have been written on every path that reaches the call, or the argument is whatever
117
+ // the frame happened to hold. m2c renders that case as `ErrorExpr("Unable to find stack arg
118
+ // 0x0 in block")`; here it is a decline, and for the same reason — it is a GAP, and a gap
119
+ // must never render as a plausible value.
120
+ //
121
+ // WHAT "NOTHING EXTRA" COSTS, because the reach is narrower than the disappearance of the old
122
+ // decline suggests. A genuine SPILL that is live across a licensed call sits in the may set and is
123
+ // not in the declared block, so the call refuses — and that is agbcc's commonest frame with an
124
+ // outgoing area. Tolerating it means arguing that a pending word which is RELOADED later is a
125
+ // local rather than argument n+1, which needs a gate and a row that gate protects; none exists.
126
+ // The cost is in attribution, not correctness: the decline such a function gets names a STORE
127
+ // ("[sp,#k] also reaches the call unread") rather than the capability, so a gap histogram groups
128
+ // this class under that message and not under anything about stack arguments.
129
+ // The must set is an intersection over predecessors, which is exactly what a TAIL-MERGED call
130
+ // site needs: agbcc does tail-merge (`Task_BonusFlower_Spawn`, sa3 bonus_game_enemies, stores
131
+ // argument 5 in both predecessors with the `bl` in the join), and a one-armed store — the same
132
+ // shape with one predecessor not storing — is missing on a path and refuses.
133
+ //
134
+ // PATH-SENSITIVE, because the weaker forms have been wrong twice in the other direction:
135
+ // scanning per block let a LABEL decide accept versus refuse; scanning the flat listing let
136
+ // BLOCK ORDER decide, because a load in one arm of a branch cleared a store that reaches the
137
+ // call through the other arm — swap the arms, same CFG and same semantics, and the verdict
138
+ // flipped.
139
+ //
140
+ // A LICENSED CALL CONSUMES ITS BLOCK, which is what lets a function make several calls: the
141
+ // callee reads those words, so they stop being pending after it, exactly as a reload would end
142
+ // them. The deletion is driven by the DECLARATION alone, never by the licence, so the fixpoint
143
+ // cannot depend on its own outcome.
144
+ export function analyzeOutgoingArgs<C>({
145
+ blocks: asmBlocks,
146
+ preds,
147
+ live,
148
+ localArea,
149
+ argRegs,
150
+ capturedWholeFrame,
151
+ }: StackArgsInput<C>): OutgoingArgs<C> {
152
+ const refuse = (blocker: string): OutgoingArgs<C> => ({ blocker, blocks: new Map(), area: 0 });
153
+ // EVERY block, not the entry-reachable ones: a call in dead code stages nothing, so the
154
+ // dataflow below never finds its block and it refuses — which is the verdict it had before
155
+ // consumption existed. An unreachable `bl` is not evidence about the frame either way, and
156
+ // the loud answer is the one that does not depend on deciding which. The message a reader gets
157
+ // is the dataflow fact ("[sp,#0] is not stored on every path to the call"), not "this block is
158
+ // dead", and deliberately so: which blocks run is the question being refused, not an answer.
159
+ const calls = asmBlocks.flatMap((ab) => ab.events.filter(isCallEvent));
160
+ // ONLY FOR A FUNCTION THAT CALLS. With no call there is no outgoing area to mistake a local
161
+ // for, and a never-reloaded store is then an ordinary dead local — which PR #30 modelled and
162
+ // which must keep working.
163
+ if (calls.length === 0) {
164
+ return { blocker: null, blocks: new Map(), area: 0 };
165
+ }
166
+ const say = (offs: readonly number[]) => offs.map((o) => `[sp,#${o}]`).join(', ');
167
+ const arityOf = (offs: readonly number[]) => argRegs + offs.length;
168
+
169
+ // THE ONE-WORD CAPTURED FRAME. `capturedWholeFrame` says the whole frame is an
170
+ // object whose address a callee holds; a declared fifth argument says [sp,#0] is a DIFFERENT
171
+ // callee's argument slot. Two contradictory claims about the same word, and nothing here can
172
+ // decide which to believe, so the honest answer is the decline it has always been.
173
+ if (capturedWholeFrame) {
174
+ for (const ev of calls) {
175
+ const offs = ev.declared;
176
+ if (offs !== null) {
177
+ return refuse(
178
+ `callee \`${ev.callee}\` is declared with ${arityOf(offs)} arguments, so [sp,#0] is its outgoing stack argument — ` +
179
+ 'but this one-word frame is an object whose address is passed to a callee, and the two name the same word',
180
+ );
181
+ }
182
+ }
183
+ // Conditions (a) and (b) hunt for an argument block; every block starts at [sp,#0]; and a
184
+ // one-word frame that is entirely an addressable local has no room for one. So here they can
185
+ // only fire as FALSE ALARMS — which is what they did, declining the three address-taken
186
+ // synthetic rows on a store never reloaded for the ordinary reason, that the CALLEE reads it
187
+ // through the pointer.
188
+ return { blocker: null, blocks: new Map(), area: 0 };
189
+ }
190
+
191
+ const everySlot: number[] = [];
192
+ for (let o = 0; o + 4 <= localArea; o += 4) {
193
+ everySlot.push(o);
194
+ }
195
+
196
+ // The forward dataflow, three sets per block. `may`/`must` are the pending stores (stored and
197
+ // not yet reloaded) on SOME / EVERY path; `stored` is every offset written on some path, which
198
+ // a reload does NOT remove — the callee would still read what the store put there, and it is
199
+ // what the contiguity filter asks. `must` is a meet-over-all-paths intersection, so it starts
200
+ // at every slot and shrinks (the entry block starts EMPTY: control arrives there from outside
201
+ // the function, storing nothing, whatever back edge also targets it).
202
+ //
203
+ // AND YES, `must` DUPLICATES THE SSA BUILDER'S REACHING-DEF QUERY, which is the same
204
+ // meet-over-all-paths question and already answers it for the `ldr` arm. The duplication is
205
+ // FORCED, not an oversight: the frontend must know `slotsOk` — whose answer is this analysis's
206
+ // `blocker` — BEFORE it fills a single block, and the SSA builder has no defs until the fill
207
+ // runs. Folding this into an `ssa.hasReachingDef` call makes the frontend ask a question whose
208
+ // answer depends on the question, so the next reader who spots the redundancy should stop here.
209
+ const mayOut = asmBlocks.map(() => new Set<number>());
210
+ const mustOut = asmBlocks.map(() => new Set(everySlot));
211
+ const storedOut = asmBlocks.map(() => new Set<number>());
212
+ const mayAt = new Map<StackArgsCall<C>, Set<number>>();
213
+ const mustAt = new Map<StackArgsCall<C>, Set<number>>();
214
+ const storedAt = new Map<StackArgsCall<C>, Set<number>>();
215
+ for (let changed = true; changed;) {
216
+ changed = false;
217
+ for (let b = 0; b < asmBlocks.length; b++) {
218
+ if (!live.has(b)) {
219
+ continue;
220
+ }
221
+ const livePreds = preds[b].filter((q) => live.has(q));
222
+ const may = new Set<number>();
223
+ const stored = new Set<number>();
224
+ for (const q of livePreds) {
225
+ for (const off of mayOut[q]) {
226
+ may.add(off);
227
+ }
228
+ for (const off of storedOut[q]) {
229
+ stored.add(off);
230
+ }
231
+ }
232
+ const must = new Set(
233
+ b === 0 || livePreds.length === 0 ? [] : everySlot.filter((o) => livePreds.every((q) => mustOut[q].has(o))),
234
+ );
235
+ for (const ev of asmBlocks[b].events) {
236
+ if (isCallEvent(ev)) {
237
+ mayAt.set(ev, new Set(may));
238
+ mustAt.set(ev, new Set(must));
239
+ storedAt.set(ev, new Set(stored));
240
+ for (const o of ev.declared ?? []) {
241
+ may.delete(o);
242
+ must.delete(o);
243
+ }
244
+ } else if (ev.kind === 'store') {
245
+ may.add(ev.off);
246
+ must.add(ev.off);
247
+ stored.add(ev.off);
248
+ } else {
249
+ may.delete(ev.off);
250
+ must.delete(ev.off);
251
+ }
252
+ }
253
+ const grow = (out: Array<Set<number>>, cur: Set<number>): void => {
254
+ if (cur.size !== out[b].size || [...cur].some((o) => !out[b].has(o))) {
255
+ out[b] = cur;
256
+ changed = true;
257
+ }
258
+ };
259
+ grow(mayOut, may);
260
+ grow(mustOut, must);
261
+ grow(storedOut, stored);
262
+ }
263
+ }
264
+
265
+ // CONTIGUITY. An argument block is contiguous from zero, so a store at [sp,#4] can be argument
266
+ // 6 of a call only if argument 5 at [sp,#0] is supplied on a path to that same call. A pending
267
+ // store whose lower slots are nowhere supplied is provably not an argument block, and refusing
268
+ // it is a false alarm — the exact false alarm that blocked the commonest real shape, a value
269
+ // spilled at [sp,#4] and kept live across calls (kleod's ProcessInputAndUpdateEntities stores
270
+ // its `sp4` local and calls m4aSongNumStart 80 lines later, with offset 0 never stored in the
271
+ // whole function). The calibration: a conforming caller stores EVERY argument slot of a call it
272
+ // makes, so "slot 0 unsupplied" rules out "slot 4 is an argument". Hand-written asm could skip
273
+ // storing an argument the callee never reads; agbcc cannot (no interprocedural dead-argument
274
+ // elimination). That is the producer assumption both code-reading conditions make.
275
+ const prefixStored = (k: number, st: ReadonlySet<number>): boolean => {
276
+ for (let j = 0; j < k; j += 4) {
277
+ if (!st.has(j)) {
278
+ return false;
279
+ }
280
+ }
281
+ return true;
282
+ };
283
+ const asc = (s: Iterable<number>) => [...s].sort((x, y) => x - y);
284
+
285
+ // THE LICENCE, call by call. Every declared block must match what the code staged for it,
286
+ // exactly — and every refusal after this one then runs knowing which words are spoken for.
287
+ const blocks = new Map<C, readonly number[]>();
288
+ let area = 0;
289
+ for (const ev of calls) {
290
+ const offs = ev.declared;
291
+ if (offs === null) {
292
+ continue;
293
+ }
294
+ const may = mayAt.get(ev) ?? new Set<number>();
295
+ const must = mustAt.get(ev) ?? new Set<number>();
296
+ const missing = offs.filter((o) => !must.has(o));
297
+ const extra = asc(may).filter((o) => !offs.includes(o));
298
+ if (missing.length > 0 || extra.length > 0) {
299
+ return refuse(
300
+ `callee \`${ev.callee}\` is declared with ${arityOf(offs)} arguments, so its outgoing stack-argument block is ${say(offs)} — but ` +
301
+ (missing.length > 0
302
+ ? `${say(missing)} is not stored on every path to the call`
303
+ : `${say(extra)} also reaches the call unread, so the declaration does not account for every word staged here`),
304
+ );
305
+ }
306
+ blocks.set(ev.call, offs);
307
+ area = Math.max(area, 4 * offs.length);
308
+ }
309
+
310
+ const licensed = new Set<number>();
311
+ for (const offs of blocks.values()) {
312
+ for (const o of offs) {
313
+ licensed.add(o);
314
+ }
315
+ }
316
+ // The two whole-function facts the next two refusals read: every offset live code stores, and
317
+ // every offset live code loads back. A reload in dead code is not evidence that anything reads
318
+ // the slot back, so it does not count.
319
+ const reloaded = new Set<number>();
320
+ const storedAnywhere = new Set<number>();
321
+ for (const b of live) {
322
+ for (const ev of asmBlocks[b].events) {
323
+ if (ev.kind !== 'call') {
324
+ (ev.kind === 'store' ? storedAnywhere : reloaded).add(ev.off);
325
+ }
326
+ }
327
+ }
328
+ // A LICENSED WORD THIS FUNCTION ALSO LOADS. The area belongs to the CALLEE — which may assign
329
+ // to a stack parameter — so after the `bl` the word holds whatever the callee left, and an
330
+ // `ldr` off that offset reads a GAP. The dataflow above cannot catch it: the call consumes the
331
+ // offset, so the load meets an empty pending set and clears nothing. Left alone, the ordinary
332
+ // `ldr` arm answers it from the staging store's reaching def and renders the value the CALLER
333
+ // passed in — a plausible identifier standing in for an unknown, which is the `unksp0` failure
334
+ // mode this whole analysis exists to avoid. It is the same contradiction
335
+ // `capturedWholeFrame` refuses: one word carrying two incompatible claims, with
336
+ // nothing here able to decide between them. A load BEFORE the staging store is the same verdict
337
+ // for the same reason — under ACCUMULATE_OUTGOING_ARGS the locals sit ABOVE the area, so a
338
+ // caller-side load of an argument offset contradicts the layout the licence rests on.
339
+ for (const off of asc(licensed)) {
340
+ if (reloaded.has(off)) {
341
+ return refuse(
342
+ `[sp,#${off}] is an outgoing stack-argument slot of one of this function's calls, but this function also LOADS it — ` +
343
+ 'the callee owns that word across the call, so nothing here can say what the load reads',
344
+ );
345
+ }
346
+ }
347
+ // (a) — a store never reloaded ANYWHERE, with its lower slots supplied, is an argument's
348
+ // signature: an outgoing argument is read by the CALLEE, never by the caller. Its real theorem
349
+ // is the layout one (the area sits at the BOTTOM of localArea, disjoint from the locals, so no
350
+ // local load can land on an argument offset), which is why it is a whole-function question.
351
+ // A LICENSED offset is excluded: its never being reloaded is explained by the call that takes it.
352
+ for (const off of asc(storedAnywhere)) {
353
+ if (!licensed.has(off) && !reloaded.has(off) && prefixStored(off, storedAnywhere)) {
354
+ return refuse(
355
+ `the store to [sp,#${off}] is never reloaded and its lower slots are supplied — it may be an outgoing stack argument of one of this function's calls`,
356
+ );
357
+ }
358
+ }
359
+ // (b) — no slot store may reach a `bl` unread ALONG A PATH. For a call the licence covered,
360
+ // the equality above already answered this; what is left are the calls no declaration sizes,
361
+ // where a plausible argument block reaching one unread is an argument this analysis cannot
362
+ // size, and the answer is the decline.
363
+ //
364
+ // THE OFFSET THIS NAMES IS THE LOWEST PENDING ONE, because `may` is reported through `asc`. The
365
+ // verdict does not depend on it — any one of them refuses — but the message is what a gap
366
+ // histogram keys on, and scanning a Set in insertion order named whichever offset the code stored
367
+ // FIRST instead (pokeemerald's `PickLotteryCornerTicket` stores [sp,#4] before [sp,#0]).
368
+ for (const ev of calls) {
369
+ if (ev.declared !== null) {
370
+ continue;
371
+ }
372
+ const may = mayAt.get(ev) ?? new Set<number>();
373
+ const stored = storedAt.get(ev) ?? new Set<number>();
374
+ for (const k of asc(may)) {
375
+ if (prefixStored(k, stored)) {
376
+ return refuse(
377
+ `the store to [sp,#${k}] reaches \`bl ${ev.callee}\` unread with its lower slots supplied — it may be that call's outgoing stack argument`,
378
+ );
379
+ }
380
+ }
381
+ }
382
+ // NOTHING LEFT OVER. The exclusion above is per OFFSET, so it would also excuse a store to a
383
+ // licensed offset that no call ever reads — a write into the argument area that is still pending
384
+ // where the function ENDS. That is not an argument and not a local anyone reloads, so nothing
385
+ // here can say what it is: decline rather than let it drop as a dead def.
386
+ //
387
+ // "WHERE THE FUNCTION ENDS" IS A LIVE BLOCK WITH NO LIVE SUCCESSOR, read off `preds`, not a
388
+ // terminator the caller classified. Under Thumb the two coincide — a computed PC write has no
389
+ // static successor and the frontend throws on one long before here — but asking the CFG costs
390
+ // nothing and removes a fact the caller could get wrong.
391
+ //
392
+ // WHAT IT STILL DOES NOT REACH, stated because the escape is real: a store into the licensed area
393
+ // on a path that never ends. Every block of an infinite loop has a live successor, so the word
394
+ // stays pending forever and nothing here refuses it. Measured rather than assumed — a `str` into
395
+ // the area after a licensed `bl`, falling into `.L1: b .L1`, passes this analysis and then
396
+ // declines at L2: "unrecovered back-edge into block #1 (loop-recovery declined this shape)". So
397
+ // the loud answer is preserved by a DIFFERENT family's refusal, not by this one. Closing it needs
398
+ // a backward "can this word still be consumed?" pass, which no row in the corpus asks for.
399
+ const hasLiveSucc = asmBlocks.map(() => false);
400
+ for (let b = 0; b < asmBlocks.length; b++) {
401
+ if (live.has(b)) {
402
+ for (const q of preds[b]) {
403
+ hasLiveSucc[q] = true;
404
+ }
405
+ }
406
+ }
407
+ for (let b = 0; b < asmBlocks.length; b++) {
408
+ if (!live.has(b) || hasLiveSucc[b]) {
409
+ continue;
410
+ }
411
+ for (const off of asc(mayOut[b])) {
412
+ if (licensed.has(off)) {
413
+ return refuse(
414
+ `the store to [sp,#${off}] is inside the outgoing stack-argument area but is still staged where this function ends — no call it makes accounts for it`,
415
+ );
416
+ }
417
+ }
418
+ }
419
+ return { blocker: null, blocks, area };
420
+ }