@asmlift/core 0.3.0 → 0.5.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 (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
@@ -1,4 +1,16 @@
1
- // asmlift — boolean-value short-circuit recovery (F-CFG; successor-aware, agbcc-class).
1
+ // asmlift — short-circuit connective recovery (F-CFG; successor-aware, agbcc-class).
2
+ //
3
+ // A `&&`/`||` reaches the IR in two shapes, and this module recovers BOTH into the SAME pair of
4
+ // opcodes (`logic_and`/`logic_or`) the backend already prints as `&&`/`||`:
5
+ //
6
+ // - the VALUE form (`return a && b`) — a diamond whose merge phi is the boolean. That is
7
+ // `recognizeShortCircuit`, described below.
8
+ // - the CONTROL-FLOW form (`if (a || b) X else Y`) — no value at all, just two `cond_br` blocks
9
+ // that share a target. That is `recognizeBranchShortCircuit`, at the bottom of this file.
10
+ //
11
+ // They are one concept and stay in one file, but they are separate passes because their inputs do
12
+ // not overlap: the value form needs the second block to end in `br` carrying a phi argument, the
13
+ // branch form needs it to end in `cond_br`.
2
14
  //
3
15
  // `return a && b` compiles (agbcc) to a value-producing diamond: `if (a==0) result=0; else result=(b!=0)`,
4
16
  // where the merge block returns the phi. The structurer lowers that as `if (a==0){v0=0}else{v0=(-b|b)>>31}
@@ -27,26 +39,10 @@
27
39
  // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
28
40
  // negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
29
41
  import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
30
- import type { Opcode } from '../ir/opcodes';
31
- import { EFFECTFUL_OPS } from '../ir/opcodes';
42
+ import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
32
43
  import { T } from '../ir/types';
33
44
 
34
- const NEGATE_ICMP: Record<string, Opcode> = {
35
- icmp_eq: 'icmp_ne',
36
- icmp_ne: 'icmp_eq',
37
- icmp_slt: 'icmp_sge',
38
- icmp_sge: 'icmp_slt',
39
- icmp_sgt: 'icmp_sle',
40
- icmp_sle: 'icmp_sgt',
41
- icmp_ult: 'icmp_uge',
42
- icmp_uge: 'icmp_ult',
43
- icmp_ugt: 'icmp_ule',
44
- icmp_ule: 'icmp_ugt',
45
- };
46
- const BOOL_OPS = new Set([...Object.keys(NEGATE_ICMP), 'logic_and', 'logic_or']);
47
- // Ops with an observable side effect — unsafe to HOIST out of a short-circuit's conditional arm
48
- // (they would run unconditionally). Derived from the ONE effect table in ir/opcodes.ts.
49
- const SIDE_EFFECT = EFFECTFUL_OPS;
45
+ const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
50
46
 
51
47
  /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
52
48
  // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
@@ -124,6 +120,16 @@ export function recognizeShortCircuit(fn: Fn): boolean {
124
120
  if (bp.length !== 1) {
125
121
  continue;
126
122
  }
123
+ // The ENTRY block is never a feeder, for the same reason it is never ^g in the branch form
124
+ // below: `predecessors()` walks successor edges only, so an entry block that is also a loop
125
+ // header shows one predecessor while actually running BEFORE it on the first iteration.
126
+ // Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
127
+ // assertResolved and assertDerefsTyped all pass. PRE-EXISTING (this fold predates the branch
128
+ // form and `main` miscompiles the same MIPS input); fixed here because the branch form's
129
+ // note used to assert this one was safe.
130
+ if (bfeed === fn.blocks[0]) {
131
+ continue;
132
+ }
127
133
  const h = bp[0];
128
134
  const ht = term(h);
129
135
  if (ht.opcode !== 'cond_br') {
@@ -157,7 +163,7 @@ export function recognizeShortCircuit(fn: Fn): boolean {
157
163
  }
158
164
  const cond = ht.operands[0];
159
165
  const condDef = defs.get(cond);
160
- if (!condDef || !NEGATE_ICMP[condDef.opcode]) {
166
+ if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
161
167
  continue;
162
168
  } // head condition must be a negatable icmp
163
169
 
@@ -171,14 +177,14 @@ export function recognizeShortCircuit(fn: Fn): boolean {
171
177
  // expression, where C's own short-circuit re-guards them. Any side effect ⇒ DECLINE the fold — the
172
178
  // merge-variable spelling the fall-through leaves is correct (the side effect stays in B's block),
173
179
  // just possibly non-matching.
174
- if (bfeed.ops.slice(0, -1).some((op) => SIDE_EFFECT.has(op.opcode))) {
180
+ if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
175
181
  continue;
176
182
  }
177
183
  bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
178
184
  let condSide = cond;
179
185
  if (wantNeg) {
180
186
  condSide = mkValue(T.unk(32));
181
- before(mkOp(NEGATE_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
187
+ before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
182
188
  }
183
189
  // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
184
190
  let res = condSide;
@@ -205,3 +211,237 @@ export function recognizeShortCircuit(fn: Fn): boolean {
205
211
  }
206
212
  return changed;
207
213
  }
214
+
215
+ // ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
216
+ //
217
+ // `if (a || b) X else Y` produces no value: it is two `cond_br` blocks that SHARE a target.
218
+ //
219
+ // ^h: cond_br c1, ^X, ^g <- `a`
220
+ // ^g: … ; cond_br c2, ^Y, ^X <- `b` (sole predecessor ^h)
221
+ //
222
+ // Nothing in the tower recognizes that today, so the structurer reaches ^X from two arms and
223
+ // TAIL-DUPLICATES it — `if (a) X else { if (b') Y else X }`. The duplicate is correct C but it is
224
+ // not the C the compiler compiled, and the duplicated tail costs every byte it contains.
225
+ //
226
+ // The fold rewrites ^h's terminator to one `cond_br` over a connective and drops ^g. Which
227
+ // connective, and which successor slot, follows from WHICH of ^h's edges leads to ^g:
228
+ //
229
+ // ^g is ^h's FALL → ^g runs iff !c1, so the SHARED block is taken iff `c1 || cShared`
230
+ // ⇒ cond_br(logic_or(c1, cShared))[shared, other]
231
+ // ^g is ^h's TAKEN → ^g runs iff c1, so the OTHER block is taken iff `c1 && cOther`
232
+ // ⇒ cond_br(logic_and(c1, cOther))[other, shared]
233
+ //
234
+ // where `cShared`/`cOther` is ^g's own condition ORIENTED at that block — ^g's `cond_br` operand
235
+ // when it already branches there, otherwise its negation (so ^g's condition must be a negatable
236
+ // icmp, exactly as in the value form). Both spellings keep ^h's original successor ORDER for the
237
+ // edge that did not change, so the branch sense the frontend read out of the asm is preserved.
238
+ //
239
+ // REFUSALS (each one a real way this could be wrong, not a hypothetical):
240
+ //
241
+ // - ^g is the ENTRY block. `predecessors()` walks successor edges only — it does not model the
242
+ // implicit edge into `fn.blocks[0]` — so an entry block that is ALSO a loop header (its one
243
+ // real predecessor being its own latch) passes the sole-predecessor test below while the whole
244
+ // soundness argument fails for it: on the first iteration ^g runs BEFORE ^h, so hoisting ^g's
245
+ // body into ^h reorders it, and deleting ^g moves the entry to another block entirely. That
246
+ // turns an entry-guarded `while` into a `do…while` whose body runs once unconditionally —
247
+ // silent wrong code, caught by no contract (verify, assertResolved and assertDerefsTyped all
248
+ // pass). MIPS and PPC reach this: only thumb.ts inserts a synthetic preheader that would give
249
+ // the header a second predecessor. `retsink.ts` guards the same way (`fn.blocks[0] !== m`).
250
+ // - ^g has a predecessor other than ^h — folding would delete a block still reachable elsewhere.
251
+ // - ^g has block params — ^h's edge binds them, and dropping the edge drops the binding.
252
+ // - ^h and ^g both test the SAME value against CONSTANTS — that is a comparison-tree `switch`,
253
+ // not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
254
+ // `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
255
+ // disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
256
+ // of nested `if`s. The switch is the better recovery and it is the more specific one, so it
257
+ // wins the shape. Cost: a genuine source-level `x == 1 || x == 2` that is NOT part of a wider
258
+ // tree also declines — the same conservative trade loops.ts makes when it refuses to infer a
259
+ // header from `cond_br` shape.
260
+ // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
261
+ // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
262
+ // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
263
+ // MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
264
+ // conditional computation into an unconditional one. Single-use-and-local is precisely the
265
+ // shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
266
+ // re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
267
+ // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
268
+ // so it can only carry one argument list; picking either would silently drop the other path's
269
+ // phi input.
270
+ // - ^g's two successors are the same block, or ^g's condition is not a negatable icmp when the
271
+ // orientation needs negating.
272
+ //
273
+ // Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
274
+ // miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
275
+ // more condition block.
276
+ export function recognizeBranchShortCircuit(fn: Fn): boolean {
277
+ let changed = false;
278
+ const term = (b: Block) => b.ops[b.ops.length - 1];
279
+ let progress = true;
280
+ while (progress) {
281
+ progress = false;
282
+ const defs = defOpMap(fn);
283
+ const preds = predecessors(fn);
284
+ outer: for (const h of fn.blocks) {
285
+ const ht = term(h);
286
+ if (ht.opcode !== 'cond_br') {
287
+ continue;
288
+ }
289
+ const [taken, fall] = ht.successors;
290
+ // Try ^g = the fall edge, then ^g = the taken edge. `gIsFall` picks the connective.
291
+ for (const gIsFall of [true, false]) {
292
+ const gEdge = gIsFall ? fall : taken;
293
+ const sharedFromH = gIsFall ? taken : fall;
294
+ const g = gEdge.block;
295
+ if (g === h || g === sharedFromH.block || g.params.length > 0) {
296
+ continue;
297
+ }
298
+ // The ENTRY block is never ^g — see the REFUSALS note. `predecessors()` cannot see the
299
+ // implicit entry edge, so this is the only thing standing between an entry-block loop
300
+ // header and a silently reordered function body.
301
+ if (g === fn.blocks[0]) {
302
+ continue;
303
+ }
304
+ if ((preds.get(g) ?? []).length !== 1) {
305
+ continue;
306
+ }
307
+ const gt = term(g);
308
+ if (gt.opcode !== 'cond_br') {
309
+ continue;
310
+ }
311
+ const [gTaken, gFall] = gt.successors;
312
+ if (gTaken.block === gFall.block) {
313
+ continue;
314
+ }
315
+ // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold.
316
+ if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
317
+ continue;
318
+ }
319
+ // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
+ // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
+ // short circuit.
322
+ // HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
323
+ // out of the arm that guards it is the reordering this refuses. Loud either way today — a
324
+ // decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
325
+ const body = g.ops.slice(0, -1);
326
+ if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
327
+ continue;
328
+ }
329
+ if (!definedValuesStayLocal(fn, g)) {
330
+ continue;
331
+ }
332
+ // Which of ^g's edges rejoins ^h's other successor? That is the shared block.
333
+ const sharedEdge =
334
+ gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
335
+ if (!sharedEdge) {
336
+ continue;
337
+ }
338
+ const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
339
+ if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
340
+ continue;
341
+ }
342
+ // The second operand, oriented at the block whose slot it decides: `logic_or` asks "does ^g
343
+ // reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
344
+ const wantEdge = gIsFall ? sharedEdge : otherEdge;
345
+ const c2 = gt.operands[0];
346
+ const c2Def = defs.get(c2);
347
+ let second = c2;
348
+ const negated: Op[] = [];
349
+ if (wantEdge !== gTaken) {
350
+ if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
351
+ continue;
352
+ }
353
+ second = mkValue(T.unk(32));
354
+ negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
355
+ }
356
+ const res = mkValue(T.unk(32));
357
+ const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
358
+ operands: [ht.operands[0], second],
359
+ results: [res],
360
+ });
361
+ // ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
362
+ // (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
363
+ h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
364
+ ...mkOp('cond_br', { operands: [res] }),
365
+ successors: gIsFall
366
+ ? [
367
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
368
+ { block: otherEdge.block, args: [...otherEdge.args] },
369
+ ]
370
+ : [
371
+ { block: otherEdge.block, args: [...otherEdge.args] },
372
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
373
+ ],
374
+ });
375
+ fn.blocks = fn.blocks.filter((x) => x !== g);
376
+ changed = true;
377
+ progress = true;
378
+ break outer; // defs/preds are stale after the mutation — recompute on the next round
379
+ }
380
+ }
381
+ }
382
+ return changed;
383
+ }
384
+
385
+ /** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
386
+ * comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
387
+ * only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
388
+ * range check) is a genuine connective this fold should still take. */
389
+ function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
390
+ const eqTest = (v: Value): { scrutinee: Value } | null => {
391
+ const d = defs.get(v);
392
+ if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
393
+ return null;
394
+ }
395
+ const [x, y] = d.operands;
396
+ const xc = defs.get(x)?.opcode === 'const';
397
+ const yc = defs.get(y)?.opcode === 'const';
398
+ // exactly one side constant — `x == y` between two variables is no switch test
399
+ return xc === yc ? null : { scrutinee: xc ? y : x };
400
+ };
401
+ const a = eqTest(c1);
402
+ const b = eqTest(c2);
403
+ return a !== null && b !== null && a.scrutinee === b.scrutinee;
404
+ }
405
+
406
+ /** True when every value `g` defines is read at most once, and any read is inside `g`.
407
+ *
408
+ * The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
409
+ * ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
410
+ * is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
411
+ * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
412
+ * escape, and only this check stops it.
413
+ *
414
+ * An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
415
+ * itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
416
+ * WRONG — the entry block dominates every block whatever M's predecessor count — and it was wrong
417
+ * about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
418
+ * form was missing entirely. Both now have it. When changing either fold, check the other. */
419
+ function definedValuesStayLocal(fn: Fn, g: Block): boolean {
420
+ const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
421
+ if (defined.size === 0) {
422
+ return true;
423
+ }
424
+ const uses = new Map<Value, number>();
425
+ for (const b of fn.blocks) {
426
+ for (const op of b.ops) {
427
+ for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
428
+ if (!defined.has(v)) {
429
+ continue;
430
+ }
431
+ if (b !== g) {
432
+ return false; // escapes ^g — the structurer would render it before the `if`
433
+ }
434
+ uses.set(v, (uses.get(v) ?? 0) + 1);
435
+ }
436
+ }
437
+ }
438
+ // ZERO uses is fine — a dead op renders nothing at all, so it cannot escape the short circuit.
439
+ // TWO or more is not: analysis.ts materializes a multi-consumer value into a local, which is a
440
+ // statement, and a statement lands before the `if`.
441
+ return [...defined].every((v) => (uses.get(v) ?? 0) <= 1);
442
+ }
443
+
444
+ /** Two successor argument lists that a fold may collapse into one: same values, same order. */
445
+ function sameArgs(a: Value[], b: Value[]): boolean {
446
+ return a.length === b.length && a.every((v, i) => v === b[i]);
447
+ }
@@ -87,7 +87,8 @@ function withPadding(dataFields: StructField[], stride: number): StructField[] {
87
87
  * rematerializes the same element address (several `add(base, i*stride)` ops for one logical
88
88
  * array), and recovering them one-by-one would let the first claim the base and force its
89
89
  * twins to decline — a mixed spelling that is worse than either pure form (found live on
90
- * pokeemerald:GetGender, whose address is materialized twice). A base whose element pointers
90
+ * pokeemerald:GetGenderFromSpeciesAndPersonality, whose address is materialized twice). A base
91
+ * whose element pointers
91
92
  * disagree on stride declines entirely: two strides over one base is a reinterpreted view or
92
93
  * a 2D layout, genuinely ambiguous — decline over guess. */
93
94
  export function recognizeStructArrays(fn: Fn): number {
@@ -178,6 +178,17 @@ export function recognizeStructs(fn: Fn): number {
178
178
  }
179
179
  }
180
180
 
181
+ // Which values are the address of a NAMED global (`gaddr`)? Consulted only when synthesis
182
+ // DECLINES: see the catch below.
183
+ const namedGlobal = new Set<Value>();
184
+ for (const b of fn.blocks) {
185
+ for (const op of b.ops as Op[]) {
186
+ if (op.opcode === 'gaddr') {
187
+ namedGlobal.add(op.results[0]);
188
+ }
189
+ }
190
+ }
191
+
181
192
  let count = 0;
182
193
  for (const base of order) {
183
194
  if (arrayBases.has(base)) {
@@ -186,18 +197,45 @@ export function recognizeStructs(fn: Fn): number {
186
197
  if (base.type.kind !== 'unknown') {
187
198
  continue;
188
199
  } // already typed (not a bare recovery target)
200
+
189
201
  const accesses = accessesOf.get(base)!;
190
202
  if (isArray(accesses)) {
191
203
  continue;
192
204
  } // uniform stride / single aligned access → array
193
- base.type = T.ptr(buildStruct(`Struct${count}`, accesses));
205
+ try {
206
+ base.type = T.ptr(buildStruct(`Struct${count}`, accesses));
207
+ } catch (e) {
208
+ // A NAMED global whose accesses synthesis cannot reconcile is not a reason to decline the
209
+ // function: its declaration belongs to the project's own headers, and its constant-offset
210
+ // accesses render at L3 through the symbol context (member spelling when the map knows the
211
+ // layout, the honest cast spelling when it does not). The inhabitant is agbcc FUSING two
212
+ // adjacent u8 compares into one ldrh — `s.level == 8 && s.world == 6` reads offset 12 at
213
+ // widths 1 AND 2, which is not a union, just two spellings of declared bytes. An ANONYMOUS
214
+ // base (a loaded pointer, a parameter) has no other source of truth, so for it the decline
215
+ // stands exactly as before — this catch narrows nothing for the shapes that already worked,
216
+ // because a base synthesis succeeds on takes the same path it always took.
217
+ if (e instanceof RaiseUnsupportedError && namedGlobal.has(base)) {
218
+ continue;
219
+ }
220
+ throw e;
221
+ }
194
222
  count++;
195
223
  }
196
224
  return count;
197
225
  }
198
226
 
199
- /** The distinct struct types this function references (unwrapping struct pointers on every value),
200
- * deduped by name and sorted, for the backend to declare above the function. */
227
+ /** The distinct struct types this function's L2 GRAPH mentions (unwrapping struct pointers on every
228
+ * value), deduped by name and sorted, for the backend to declare above the function.
229
+ *
230
+ * "Mentions", not "references": this walks `fn.blocks` at the moment structuring runs, and the
231
+ * result is CACHED on the SFn (`structure.ts`) and carried by every later `{...sfn}` pass, so a
232
+ * struct whose last use a subsequent L3 pass removed would still be declared. Sibling `locals` is
233
+ * reference-pruned after dead-store elimination (`l3/dce.ts`) for exactly that reason; this list is
234
+ * not. No pass drops such a use today — the IR-level DCE runs long before recognition, and l3/dce's
235
+ * `mustKeep` never drops a `field`/`index` — so the staleness is LATENT, not live, which is why it
236
+ * is recorded here rather than fixed speculatively. The fix, if a pass ever makes it reachable, is
237
+ * the one l3/symbol-refs.ts already applies to symbol references: derive at the consumption point
238
+ * (`backend/cfamily.ts` structDecls) instead of caching. */
201
239
  export function collectStructs(fn: Fn): StructType[] {
202
240
  const seen = new Map<string, StructType>();
203
241
  const consider = (t: IrType) => {