@asmlift/core 0.2.0 → 0.4.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 +154 -5
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +37 -5
  7. package/src/declare.ts +251 -0
  8. package/src/frontend/frontend.ts +12 -2
  9. package/src/frontend/mips.ts +24 -23
  10. package/src/frontend/opaque.ts +39 -2
  11. package/src/frontend/ssa.ts +32 -53
  12. package/src/frontend/thumb.ts +420 -32
  13. package/src/ir/opcodes.ts +44 -0
  14. package/src/ir/simplify.ts +72 -0
  15. package/src/l3/argbase.ts +216 -0
  16. package/src/l3/ast.ts +126 -6
  17. package/src/l3/basecse.ts +3 -40
  18. package/src/l3/coalesce.ts +146 -0
  19. package/src/l3/dce.ts +2 -23
  20. package/src/l3/hoist.ts +65 -0
  21. package/src/l3/reindex.ts +7 -0
  22. package/src/l3/scopebase.ts +436 -0
  23. package/src/l3/symbol-refs.ts +61 -0
  24. package/src/l3/tailmerge.ts +120 -0
  25. package/src/l3/typing.ts +4 -0
  26. package/src/macros.ts +335 -0
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +20 -6
  29. package/src/proto.ts +55 -0
  30. package/src/raise/divpow2.ts +226 -0
  31. package/src/raise/gvn.ts +141 -0
  32. package/src/raise/pre-recovery.ts +37 -3
  33. package/src/raise/recover.ts +24 -7
  34. package/src/raise/retsink.ts +36 -7
  35. package/src/raise/shortcircuit.ts +264 -22
  36. package/src/raise/structs.ts +12 -2
  37. package/src/rank.ts +370 -79
  38. package/src/structure/analysis.ts +42 -1
  39. package/src/structure/structure.ts +852 -67
  40. package/src/structure/switch-recover.ts +21 -3
  41. package/src/symbols.ts +541 -0
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +17 -2
@@ -37,11 +37,23 @@
37
37
  // whose exit copies would clobber, switch fall-through, and mixed-entry self-loops (a guarded
38
38
  // header also entered by a plain br).
39
39
  import { Block, Fn, Op, Value, defOpMap, successorsOf } from '../ir/core';
40
- import { type IrType, T } from '../ir/types';
41
- import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren } from '../l3/ast';
40
+ import { type IrType, T, scalarTypeForAccess, typeEquals } from '../ir/types';
41
+ import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren, negateCond } from '../l3/ast';
42
42
  import { exprCType, ptrElemBytes } from '../l3/typing';
43
43
  import { returnType } from '../raise/recover';
44
44
  import { collectStructs } from '../raise/structs';
45
+ import {
46
+ type DeclaredField,
47
+ type SymbolInfo,
48
+ type SymbolStructField,
49
+ arrayInnerExtents,
50
+ declaredFields,
51
+ isArrayField,
52
+ isBitfieldField,
53
+ isScalarCellSize,
54
+ pointeeFields,
55
+ scalarCellType,
56
+ } from '../symbols';
45
57
  import { analyze } from './analysis';
46
58
  import { makeLoopHazards, updateWriteSet } from './hazards';
47
59
  import { analyzeLoops, dominators } from './loops';
@@ -59,44 +71,295 @@ import { makeSwitchRecovery } from './switch-recover';
59
71
  // TODAY — carrying the struct name (resolved against SFn.structs) is the same move as width and
60
72
  // the named follow-up; until then no backend pays a tax for the tree cast (Pascal loud-fails
61
73
  // `field` regardless, C++ falls through its leaf hook to the shared C spelling).
74
+ // `&gSym`, possibly wearing the value-context integer cast the additive lowering adds
75
+ // (`(u32)&gSym` — see lowerDef's addr-intify): both spell the same link-time constant, so the
76
+ // fold rules match through the cast and every access that CAN spell a named element still does.
77
+ // WIDTH 32 ONLY — a NARROWING cast (`(u8)&gSym`, from a zext/sext lowering) is a different
78
+ // VALUE (`addr & 0xFF`), and folding through it would read the named global at a wrong address
79
+ // (the adversarial round's probe: `*(u8*)(u8)&gSym` must keep its truncation, never become
80
+ // `*(u8*)&gSym` — let alone a confidently-named `gSym.field`).
81
+ function addrIn(e: Expr): Extract<Expr, { k: 'addr' }> | null {
82
+ if (e.k === 'addr') {
83
+ return e;
84
+ }
85
+ if (e.k === 'cast' && e.to.kind === 'int' && e.to.width === 32 && e.e.k === 'addr') {
86
+ return e.e;
87
+ }
88
+ return null;
89
+ }
90
+
62
91
  // If `e` is a global address `&gSym` (optionally `+ index`), return the global name and the
63
92
  // element index (byte residual divided by the access width). `&gSym` alone → idx const 0;
64
93
  // `&gSym + i` → idx `i / width` (exact division only — a non-multiple residual is a mid-element
65
94
  // access this whole-global spelling can't express, so it declines to null and the caller casts).
66
95
  function globalOf(e: Expr, width: number): { name: string; idx: Expr } | null {
67
- if (e.k === 'addr') {
68
- return { name: e.name, idx: { k: 'const', value: 0 } };
96
+ const top = addrIn(e);
97
+ if (top) {
98
+ return { name: top.name, idx: { k: 'const', value: 0 } };
69
99
  }
70
100
  if (e.k === 'bin' && e.op === '+') {
71
- for (const [addrSide, other] of [
101
+ for (const [side, other] of [
72
102
  [e.l, e.r],
73
103
  [e.r, e.l],
74
104
  ] as const) {
75
- if (addrSide.k === 'addr') {
76
- // width 1 → the byte residual IS the index; width>1 → a constant residual divides, a
77
- // non-constant residual must already be element-scaled (`i * width`) to divide exactly.
78
- if (width === 1) {
79
- return { name: addrSide.name, idx: other };
80
- }
81
- if (other.k === 'const') {
82
- return other.value % width === 0
83
- ? { name: addrSide.name, idx: { k: 'const', value: other.value / width } }
84
- : null;
85
- }
86
- if (other.k === 'bin' && (other.op === '*' || other.op === '<<')) {
87
- const factor =
88
- other.op === '<<'
89
- ? other.r.k === 'const'
90
- ? 1 << other.r.value
91
- : 0
92
- : other.r.k === 'const'
93
- ? other.r.value
94
- : 0;
95
- if (factor === width) {
96
- return { name: addrSide.name, idx: other.l };
97
- }
98
- }
99
- return null; // a non-element-aligned residual decline the global-array spelling
105
+ const addrSide = addrIn(side);
106
+ if (addrSide) {
107
+ const idx = elementIndex(other, width);
108
+ return idx ? { name: addrSide.name, idx } : null;
109
+ }
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ // THE one gate on the BARE-NAME array-global spelling (`gSym[i]` rather than `((T *)&gSym)[i]`),
116
+ // shared by the constant-offset and variable-index access paths so the two cannot disagree.
117
+ // Returns the `index` node's `lead` fragment when the bare form is spellable, or null to fall
118
+ // through to the always-valid `&gSym` cast form.
119
+ //
120
+ // Two facts are required, not one. The element WIDTH must match, as it always has. And the RANK
121
+ // must be SPELLABLE, because one subscript reaches an element only on a rank-1 array: on `u16
122
+ // g[4][0x400]`, `g[i]` is a ROW. Against the project's own header that is usually a type error,
123
+ // but where the row address flows into an integer context it is merely a warning and the emitted C
124
+ // then addresses a different object than the asm did — silently.
125
+ //
126
+ // A rank > 1 pins the leading dimensions at 0 and puts the whole flat element index in the last
127
+ // subscript (`g[0][i]`) — the same address arithmetic, and the idiom decomp sources themselves use
128
+ // when the split is not observable in the asm either (`gBgTilemapBufs[0][…]` in kleod,
129
+ // `gNatureStatTable[nature][…]` in pokeemerald). A rank the map states but cannot spell (an unknown
130
+ // inner extent) gets no bare form at all; `((T *)&gSym)[i]` is byte-identical and valid under ANY
131
+ // declaration, which is why it is the safe fallback. See symbols.ts arrayInnerExtents for why an
132
+ // ABSENT rank is read as 1 rather than as unknown.
133
+ function bareArrayLead(si: SymbolInfo, width: number): { lead?: number[] } | null {
134
+ if (si.shape !== 'array' || si.elemSize !== width) {
135
+ return null;
136
+ }
137
+ const inner = arrayInnerExtents(si);
138
+ return inner === null ? null : inner.length === 0 ? {} : { lead: new Array<number>(inner.length).fill(0) };
139
+ }
140
+
141
+ // A BYTE residual read as an ELEMENT index of `elemSize`-wide elements, or null when it is not one
142
+ // — the residual then addresses mid-element and no whole-element spelling can express it, so the
143
+ // caller falls through to the honest cast forms. THE one copy of the rule, indexing the
144
+ // `&gSym`-based array spelling: width 1 → the byte residual IS the index; wider → a constant
145
+ // residual must divide exactly, and a non-constant one must already be element-scaled
146
+ // (`i * elemSize` / `i << log2(elemSize)`), which is exactly what the asm's own index scaling
147
+ // produced.
148
+ function elementIndex(residual: Expr, elemSize: number): Expr | null {
149
+ if (elemSize === 1) {
150
+ return residual;
151
+ }
152
+ if (residual.k === 'const') {
153
+ return residual.value % elemSize === 0 ? { k: 'const', value: residual.value / elemSize } : null;
154
+ }
155
+ if (residual.k === 'bin' && (residual.op === '*' || residual.op === '<<')) {
156
+ const factor =
157
+ residual.op === '<<'
158
+ ? residual.r.k === 'const'
159
+ ? 1 << residual.r.value
160
+ : 0
161
+ : residual.r.k === 'const'
162
+ ? residual.r.value
163
+ : 0;
164
+ if (factor === elemSize) {
165
+ return residual.l;
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /** The symbol-map rendering context threaded into memAccess/arrayAccess: shape facts per
172
+ * global name, plus a callback registering a global's env type (so the bare `gSym[i]` spelling,
173
+ * which must pass the stride check uncast, does). Absent ⇒ today's spellings. */
174
+ interface SymRenderCtx {
175
+ info(name: string): SymbolInfo | undefined;
176
+ noteGlobal(name: string, type: IrType): void;
177
+ }
178
+
179
+ // ── interior spelling through a POINTER-shaped global ────────────────────────────────────────
180
+ // A pointer global's VALUE is the address of the object the project's header says it points at.
181
+ // With that pointee's layout in the map, an access at a known offset is a NAMED member of it —
182
+ // `gPtr->member` — which is the source spelling; without it the access is byte arithmetic on the
183
+ // loaded cell (`((u8 *)gPtr + i)[16]`), honest but opaque. The address computed is IDENTICAL
184
+ // either way: `->member` adds the member's DWARF offset, which is the same constant the arithmetic
185
+ // added, and an index into an array member scales by the member's own element size, which the
186
+ // rules below require to equal the access width. Everything not provably that — a partial overlap,
187
+ // a width mismatch, an unnamed offset, a member whose declared signedness differs from the
188
+ // access's (an s8 read is ldrb+lsl+asr where u8 is ldrb alone), a missing layout — falls THROUGH
189
+ // to the cast forms. A member is never guessed.
190
+
191
+ /** The global named by a pointer global's VALUE as the additive lowering spells it: the bare
192
+ * `gPtr`, or that value wearing the byte-pointer / u32 cast that lowering adds (cast-then-add,
193
+ * see the `needsIntSpelling` / pointer-global arithmetic rules below). Both denote the same
194
+ * address and add BYTES to it, so both fold here; a cast to any other pointer type is NOT looked
195
+ * through — a `(u16 *)` base would re-scale everything added after it. */
196
+ function ptrGlobalValueName(x: Expr): string | null {
197
+ if (x.k === 'var') {
198
+ return x.name;
199
+ }
200
+ if (x.k === 'cast' && x.e.k === 'var') {
201
+ const t = x.to;
202
+ const bytePtr = t.kind === 'ptr' && t.to.kind === 'int' && t.to.width === 8;
203
+ return bytePtr || (t.kind === 'int' && t.width === 32) ? x.e.name : null;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /** A pointer global's value, the constant bytes added to it, and the at-most-one variable term. */
209
+ interface PtrGlobalBase {
210
+ name: string;
211
+ byte: number;
212
+ idx: Expr | null;
213
+ }
214
+
215
+ /** Decompose an access base into "the VALUE of a map-declared POINTER global + a constant byte
216
+ * offset + at most ONE variable term": `gPtr`, `gPtr + K`, `(u8 *)gPtr + i`, `(u8 *)gPtr + (i <<
217
+ * 2) + K`. Null for anything else — two variable terms, no such global, a non-`+` operator —
218
+ * because only a single residual can be read as one member's index. */
219
+ function ptrGlobalBase(e: Expr, isPtrGlobal: (n: string) => boolean): PtrGlobalBase | null {
220
+ let name: string | null = null;
221
+ let byte = 0;
222
+ let idx: Expr | null = null;
223
+ let ok = true;
224
+ const visit = (x: Expr): void => {
225
+ if (!ok) {
226
+ return;
227
+ }
228
+ if (x.k === 'bin' && x.op === '+') {
229
+ visit(x.l);
230
+ visit(x.r);
231
+ return;
232
+ }
233
+ const global = ptrGlobalValueName(x);
234
+ if (global !== null && name === null && isPtrGlobal(global)) {
235
+ name = global;
236
+ return;
237
+ }
238
+ if (x.k === 'const') {
239
+ byte += x.value;
240
+ return;
241
+ }
242
+ if (idx !== null) {
243
+ ok = false;
244
+ return;
245
+ }
246
+ idx = x;
247
+ };
248
+ visit(e);
249
+ return ok && name !== null ? { name, byte, idx } : null;
250
+ }
251
+
252
+ /** Does a member declared at signedness `declared` read as EXACTLY the type the cast spelling this
253
+ * replaces would have produced — `scalarTypeForAccess(width, signed)`? That is the whole
254
+ * byte-exactness argument for naming a member instead of casting: same address, same read width,
255
+ * same C type, so every operator downstream compiles identically. A 4-byte access renders s32
256
+ * whatever the load said (the ISA has one word load), so only a SIGNED member may take its place;
257
+ * narrower accesses carry their own signedness and must match it. An undeclared signedness (the
258
+ * member is not a base type — a nested struct, an enum, a pointer) is never assumed to match.
259
+ * Compared against THE one copy of that rule (ir/types.ts) rather than restating it. */
260
+ function spellsAccessType(declared: boolean | undefined, width: number, signed: boolean): boolean {
261
+ if (declared === undefined) {
262
+ return false;
263
+ }
264
+ return typeEquals(T.int(width * 8, declared), scalarTypeForAccess(width, signed));
265
+ }
266
+
267
+ /** May a member be NAMED by an access of this direction, given the qualifiers on its declaration?
268
+ * The named spelling REPLACES a cast through `(u8 *)`, which carries no qualifier at all, so a
269
+ * qualifier the name reintroduces changes what the compiler emits:
270
+ * • `volatile` makes the access observable — the load may no longer be folded or reordered,
271
+ * which is a different instruction sequence (measured: 6 insns where the cast form was 5);
272
+ * • `const` under a STORE is a hard error, where the cast form merely cast the qualifier away.
273
+ * Either way the honest spelling is the cast form, so the member simply is not nameable here. */
274
+ function memberQualsAllow(f: SymbolStructField, containerConst: boolean | undefined, isStore: boolean): boolean {
275
+ if (f.volatile) {
276
+ return false;
277
+ }
278
+ return !(isStore && (f.const || containerConst));
279
+ }
280
+
281
+ // WHY THERE IS NO INDEXED `gPtr->arr[i]` SPELLING.
282
+ //
283
+ // Naming a member is only allowed where it is byte-identical to the cast form it replaces, and
284
+ // for the INDEXED form that was measured to be false. Against agbcc, `gPtr->arr[i]` and
285
+ // `((u8 *)gPtr + i)[K]` differ at EVERY nonzero K and for every width and direction — agbcc does
286
+ // not reassociate `(base + K) + i` into `(base + i) + K`, so it materialises the offset instead of
287
+ // folding it into the load (`adds r1, #16`; +2 code bytes at width 1, +4 at widths 2 and 4). At
288
+ // K = 0 the two still differ for widths 2 and 4, where the commutative `adds` picks a different
289
+ // destination register. The single agbcc case that did measure identical — width 1 at K = 0 —
290
+ // survives only a BARE index in a function with ONE such access: `i & 255`, `i + 1`, `i >> 2` and
291
+ // a second access that lets the cast side CSE its base all break it. A spelling rule decides one
292
+ // expression at a time and cannot see the neighbouring access that changes the answer, so there is
293
+ // no local gate that makes this form safe. (Both MIPS targets accept it freely, but core is
294
+ // target-agnostic — it cannot condition on the compiler it is emitting for.)
295
+ //
296
+ // The CONSTANT-offset form below is the opposite case, and is emitted unconditionally: measured
297
+ // identical on all three targets for widths 1/2/4, loads and stores, at every offset tested up to
298
+ // 4096 — including offsets past Thumb's immediate range, and under multi-member, across-a-call
299
+ // and in-a-loop shapes. It is one load whose member offset becomes the same immediate the cast
300
+ // form used, and unlike the indexed form it composes.
301
+
302
+ /** The pointee a global's value may be spelled through: the members the declaration synthesis
303
+ * DECLARES. Null when nothing may be named through it — THE shared gate (symbols.ts
304
+ * pointeeFields), so core never names a member that synthesis would not declare, and never sees a
305
+ * member synthesis drops (a union alias behind the first view at that offset). A VOLATILE pointee
306
+ * declines outright: every named access through it would be a volatile access where the cast form
307
+ * it replaces was plain. */
308
+ function spellablePointee(
309
+ name: string,
310
+ sym: SymRenderCtx,
311
+ ): { fields: DeclaredField[]; const: boolean | undefined } | null {
312
+ const pointee = sym.info(name)?.pointee;
313
+ const fields = pointeeFields(pointee);
314
+ if (fields === null || pointee!.volatile) {
315
+ return null;
316
+ }
317
+ return { fields, const: pointee!.const };
318
+ }
319
+
320
+ /** `gPtr->member` for an access through a pointer global's value, or null when the offset is not
321
+ * provably ONE member's (see the block comment above). A VARIABLE index declines whatever it
322
+ * lands on — the indexed form is not byte-neutral and has no spelling here. */
323
+ function pointeeAccess(
324
+ pg: PtrGlobalBase,
325
+ off: number,
326
+ width: number,
327
+ signed: boolean,
328
+ isStore: boolean,
329
+ sym: SymRenderCtx,
330
+ ): Expr | null {
331
+ if (pg.idx !== null) {
332
+ return null;
333
+ }
334
+ const total = pg.byte + off;
335
+ // Constant offset: the member must match EXACTLY — offset, read width, and the SPELLED type
336
+ // (spellsAccessType). An ARRAY member is excluded whatever its size: `u8 x[1]` would match a
337
+ // byte access by (offset, size) and spell `->x`, which is not an lvalue of that width at all.
338
+ // A BITFIELD member likewise: its `size` is the byte span its bits touch, so a 7-bit field
339
+ // would match a plain u16 read and spell a 7-bit lvalue for a 16-bit access.
340
+ const p = spellablePointee(pg.name, sym);
341
+ const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m) && !isBitfieldField(m));
342
+ return p && f && spellsAccessType(f.signed, width, signed) && memberQualsAllow(f, p.const, isStore)
343
+ ? { k: 'field', base: { k: 'var', name: pg.name }, name: f.name }
344
+ : null;
345
+ }
346
+
347
+ // The (name, byte offset) of a global access with a CONSTANT total offset — `&gSym` → off,
348
+ // `&gSym + K` → K + off. The exact byte is what a struct-layout field lookup needs; a variable
349
+ // residual returns null (no field spelling — falls through to the index/cast forms).
350
+ function globalConstByte(baseExpr: Expr, off: number): { name: string; byte: number } | null {
351
+ const top = addrIn(baseExpr);
352
+ if (top) {
353
+ return { name: top.name, byte: off };
354
+ }
355
+ if (baseExpr.k === 'bin' && baseExpr.op === '+') {
356
+ for (const [a, b] of [
357
+ [baseExpr.l, baseExpr.r],
358
+ [baseExpr.r, baseExpr.l],
359
+ ] as const) {
360
+ const a2 = addrIn(a);
361
+ if (a2 && b.k === 'const') {
362
+ return { name: a2.name, byte: b.value + off };
100
363
  }
101
364
  }
102
365
  }
@@ -111,6 +374,8 @@ function memAccess(
111
374
  signed: boolean,
112
375
  ctype: (e: Expr) => IrType | undefined,
113
376
  scalarGlobals: Set<string>,
377
+ sym?: SymRenderCtx,
378
+ isStore = false,
114
379
  ): Expr {
115
380
  // A deref of a global's address collapses to the bare global: `*(&gSym)` at off 0 is `gSym`;
116
381
  // at off N the global is an array — `gSym[N/width]` (a C global name decays to a pointer, so
@@ -118,6 +383,39 @@ function memAccess(
118
383
  // `*(&gSym + i)` → `gSym[i + off/width]` (byte offset `i` peeled from the tree; for a u8 global
119
384
  // the residual IS the index). This is what makes an agbcc `.word gSym` pool access a named
120
385
  // global read/element rather than a phantom-pointer deref.
386
+ // Declaration-shape spellings (symbol map): a STRUCT global's constant-offset access is the
387
+ // named field (`gSym.field` — the source spelling a folded literal can never match); an ARRAY
388
+ // global indexes its BARE name (`gSym[i]`, see below). Exact field match only (offset AND
389
+ // width) — anything else falls through to the honest cast forms, never a guessed field.
390
+ if (sym) {
391
+ const gb = globalConstByte(baseExpr, off);
392
+ const si = gb ? sym.info(gb.name) : undefined;
393
+ if (gb && si?.shape === 'struct') {
394
+ // THE shared spellability predicate (symbols.ts), the same call declare.ts gates its struct
395
+ // declaration on: a layout it declines whole is a layout with no nameable members, and a
396
+ // union alias it drops for the first view at that offset is a name no declaration carries.
397
+ // An ARRAY member is excluded for the same reason as in pointeeAccess: `u8 x[1]` would match
398
+ // a byte access by (offset, size) and spell `.x`, which is not an lvalue of that width. A
399
+ // BITFIELD member likewise — a plain read of its bytes is not a read of its bits (the named
400
+ // bitfield spelling has its own recognizer, on the extract shape: see lowerDef).
401
+ const fld = declaredFields(si.layout)?.find(
402
+ (f) => f.offset === gb.byte && f.size === width && !isArrayField(f) && !isBitfieldField(f),
403
+ );
404
+ if (fld && memberQualsAllow(fld, si.const, isStore)) {
405
+ return { k: 'field', base: { k: 'var', name: gb.name }, name: fld.name, dot: true };
406
+ }
407
+ }
408
+ // …and the same idea one indirection down: an access at a CONSTANT offset through a POINTER
409
+ // global's VALUE is a named member of what it points at (`gPtr->member`) when the map knows
410
+ // the pointee's layout — see pointeeAccess for the guards.
411
+ const pg = ptrGlobalBase(baseExpr, (n) => sym.info(n)?.shape === 'pointer');
412
+ if (pg) {
413
+ const spelled = pointeeAccess(pg, off, width, signed, isStore, sym);
414
+ if (spelled) {
415
+ return spelled;
416
+ }
417
+ }
418
+ }
121
419
  const g = globalOf(baseExpr, width);
122
420
  if (g) {
123
421
  const idxVal = g.idx;
@@ -136,6 +434,15 @@ function memAccess(
136
434
  : idxVal.k === 'const'
137
435
  ? { k: 'const', value: idxVal.value + off / width }
138
436
  : { k: 'bin', op: '+', l: idxVal, r: { k: 'const', value: off / width } };
437
+ // ARRAY-declared global (symbol map): index the bare name — `gSym[i]`, the spelling the
438
+ // dogfood proved agbcc needs for ROM tables — with the element type registered in the env
439
+ // so the stride check passes and no cast is added. Element-width match only.
440
+ const siArr = sym?.info(g.name);
441
+ const lead = siArr === undefined ? null : bareArrayLead(siArr, width);
442
+ if (lead !== null) {
443
+ sym!.noteGlobal(g.name, T.ptr(T.int(width * 8, siArr!.elemSigned ?? false)));
444
+ return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed, ...lead };
445
+ }
139
446
  return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
140
447
  }
141
448
  const bt = base.type;
@@ -169,11 +476,19 @@ function arrayAccess(
169
476
  elemSize: number,
170
477
  signed: boolean,
171
478
  ctype: (e: Expr) => IrType | undefined,
479
+ sym?: SymRenderCtx,
172
480
  ): Expr {
173
481
  // A variable-index access off a global's address indexes the ADDRESS `&gSym` (the cast form
174
482
  // `((T *)&gSym)[i]` — valid for a struct global too, unlike casting the bare value). A
175
483
  // struct-array-of-globals (fieldOff) through `&gSym` is out of scope — fall through.
176
484
  if (baseExpr.k === 'addr' && fieldOff === undefined) {
485
+ // ARRAY-declared global (symbol map): the bare-name spelling, same rule as memAccess.
486
+ const si = sym?.info(baseExpr.name);
487
+ const lead = si === undefined ? null : bareArrayLead(si, elemSize);
488
+ if (lead !== null) {
489
+ sym!.noteGlobal(baseExpr.name, T.ptr(T.int(elemSize * 8, si!.elemSigned ?? false)));
490
+ return { k: 'index', base: { k: 'var', name: baseExpr.name }, idx: idxExpr, width: elemSize, signed, ...lead };
491
+ }
177
492
  return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
178
493
  }
179
494
  const bt = base.type;
@@ -243,12 +558,11 @@ const ARITH_TO_BIN: Record<string, BinOp> = {
243
558
  and: '&',
244
559
  xor: '^',
245
560
  shl: '<<',
246
- shr_u: '>>',
561
+ shr_u: '>>>', // the LOGICAL right shift; the C backend spells it `>>` over an unsigned operand
247
562
  shr_s: '>>',
248
563
  logic_and: '&&',
249
564
  logic_or: '||', // short-circuit connectives (raise/shortcircuit.ts)
250
565
  };
251
- const NEGATE: Record<string, BinOp> = { '<': '>=', '>=': '<', '>': '<=', '<=': '>', '==': '!=', '!=': '==' };
252
566
 
253
567
  // Recovered info for a self-loop header: its exit block and the per-parameter back-edge
254
568
  // arg it feeds (the value on the header→header edge). The back-edge arg is the "next"
@@ -300,12 +614,32 @@ export interface StructureOptions {
300
614
  // body). GCC freely uses `!=`; IDO prefers `==`/`<`. A per-compiler DATA lever, not an `arch ==`
301
615
  // branch — default true (permissive; the decline path keeps it sound either way).
302
616
  switchAllowsNeqCase?: boolean;
617
+ // Anchor a constant merge copy at its const op's ORIGINAL position instead of at the CFG edge:
618
+ // `movs r9, #0` at entry ahead of a single-armed overwrite emits as a pre-initialization above
619
+ // the `if`, not as its else-arm. A differ-refereed candidate axis (rank.ts `/defsite`), never a
620
+ // default — see the refusal conditions where it is computed.
621
+ anchorConstCopies?: boolean;
622
+ // HARDWARE fact from TargetDescription.capabilities.endianness, threaded by structureOptionsFor:
623
+ // the bitfield extract recognizer solves an LSB-first equation, so it only runs on little-endian
624
+ // data. The provider already refuses to EMIT bitfield facts for a big-endian ELF; this is the
625
+ // same boundary enforced on core's side, against a hand-built map that never went through it.
626
+ littleEndian?: boolean;
627
+ // Spell `(x << a) >> b` extracts of a struct global as the map's named bitfield member. On by
628
+ // default; rank.ts enumerates the OFF spelling as the `/no-bitfield` axis, because the named
629
+ // read recompiles at the DECLARATION's access width — where that diverges from the asm's load
630
+ // width the honest shift spelling is the one that matches, and the differ referees.
631
+ spellBitfieldMembers?: boolean;
303
632
  // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
304
633
  // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
305
634
  // the PROCESS);
306
635
  // "annotate" — a `marker` node that spells as the undefined ASMLIFT_ERROR(...) symbol (loud in
307
636
  // the ARTIFACT: the function emits complete, but cannot compile un-acknowledged).
308
637
  onGap?: 'strict' | 'annotate';
638
+ /** NAME-keyed project symbol facts (symbols.ts `symbolsByName`) — drives the byte-sensitive
639
+ * declaration-shape spellings: `shape:'array'` forces the aggregate classification and the
640
+ * bare `gSym[i]` form; `shape:'struct'`+layout spells interiors as `gSym.field`. Absent (or
641
+ * a symbol not in the map) ⇒ today's usage-inferred behavior, byte-identical. */
642
+ symbols?: Map<string, SymbolInfo>;
309
643
  }
310
644
 
311
645
  export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
@@ -315,7 +649,11 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
315
649
  preserveDivergentBranchSense = true,
316
650
  orderArgCopiesByComputation = true,
317
651
  switchAllowsNeqCase = true,
652
+ anchorConstCopies = false,
653
+ littleEndian = true,
654
+ spellBitfieldMembers = true,
318
655
  onGap = 'strict',
656
+ symbols,
319
657
  } = opts;
320
658
  const defs = defOpMap(fn);
321
659
  const preds = predecessorBlocks(fn);
@@ -323,7 +661,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
323
661
  const dom = dominators(fn);
324
662
 
325
663
  // ── analysis phase (structure/analysis.ts): use registry, liveness, materialization ──
326
- const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom } = analyze(fn, returnsVoid);
664
+ const { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween } = analyze(
665
+ fn,
666
+ returnsVoid,
667
+ );
327
668
 
328
669
  // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
329
670
  // global → the bare name `gSym` (byte-exact, matches the source). A symbol accessed at any
@@ -347,14 +688,16 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
347
688
  if (s) {
348
689
  (offsets.get(s) ?? offsets.set(s, new Set()).get(s)!).add(op.attrs.off as number);
349
690
  }
350
- // a `+`-tree base holding a gaddr (global array element) is aggregate
351
- const d = defs.get(op.operands[0]);
352
- if (d?.opcode === 'add') {
353
- for (const o of d.operands) {
354
- const s2 = gaddrSym(o);
355
- if (s2) {
356
- bumpAgg(s2);
357
- }
691
+ } else if (op.opcode === 'add' || op.opcode === 'sub') {
692
+ // ANY arithmetic on the symbol's address is interior addressing ⇒ aggregate — even when
693
+ // the sum only reaches memory through a copy/phi (a pointer-walk loop `p = &g + 2;
694
+ // do { *p++ }` never makes the add a DIRECT load/store base, which is all the old
695
+ // check saw; the symbol then classified scalar and emitted the bare `g = 0` spelling,
696
+ // which a project declaring `extern u16 g[]` rejects as an incomplete-type assignment).
697
+ for (const o of op.operands) {
698
+ const s2 = gaddrSym(o);
699
+ if (s2) {
700
+ bumpAgg(s2);
358
701
  }
359
702
  }
360
703
  } else if (op.opcode === 'aload' || op.opcode === 'astore') {
@@ -370,8 +713,38 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
370
713
  scalarGlobals.add(sym);
371
714
  }
372
715
  }
716
+ // Declaration-shape OVERRIDE (symbol map): a project-declared array/struct global is an
717
+ // AGGREGATE whatever the usage inference saw — a lone off-0 access to `extern u16 tbl[]`
718
+ // must still spell through the aggregate/array forms, never the bare scalar `tbl`.
719
+ if (symbols) {
720
+ for (const [n, si] of symbols) {
721
+ if (si.shape === 'array' || si.shape === 'struct') {
722
+ scalarGlobals.delete(n);
723
+ }
724
+ }
725
+ }
373
726
  }
374
727
 
728
+ // Symbol-map rendering context (memAccess/arrayAccess): shape lookups + the env registry for
729
+ // array-shaped globals actually referenced (they surface as SFn.globals — typed, undeclared).
730
+ const shapedGlobalTypes = new Map<string, IrType>();
731
+ const symCtx: SymRenderCtx | undefined = symbols
732
+ ? { info: (n) => symbols.get(n), noteGlobal: (n, t) => shapedGlobalTypes.set(n, t) }
733
+ : undefined;
734
+
735
+ /** A bare `gSym` naming a map-declared POINTER global — the VALUE of a pointer cell. Load,
736
+ * store and compare of that 4-byte cell are identical for any object-pointer type, so the
737
+ * declared pointee never matters to THEM; arithmetic on the loaded value is the opposite case,
738
+ * where the pointee's size scales what is added and every stride must therefore be made
739
+ * explicit (`(u8 *)gPtr + K`). `ctype` cannot see any of this: it types only params/locals, so
740
+ * a pointer global renders `undefined` there. */
741
+ const isPtrGlobal = (x: Expr): boolean => x.k === 'var' && symCtx?.info(x.name)?.shape === 'pointer';
742
+
743
+ /** Operands `-`/`~` cannot take as spelled: a rendered pointer, a bare `&gSym`, a pointer
744
+ * global's value. All three are ill-formed C under a unary arithmetic operator — the asm did
745
+ * 32-bit integer math on the address, so that is what gets spelled. */
746
+ const needsIntSpelling = (x: Expr): boolean => ctype(x)?.kind === 'ptr' || x.k === 'addr' || isPtrGlobal(x);
747
+
375
748
  // --- loop discovery (loops.ts): natural loops via dominator back-edges + the nesting forest ---
376
749
  const forest = analyzeLoops(fn, dom);
377
750
 
@@ -580,6 +953,50 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
580
953
  // The C static type of a rendered expression, over the declared variable types — what decides
581
954
  // whether a memory access's base may be dereferenced as spelled (memAccess/arrayAccess).
582
955
  const ctype = (e0: Expr): IrType | undefined => exprCType(e0, (n) => varType.get(n));
956
+
957
+ /** `&gSym` assigned to a `T *` local: the address of an AGGREGATE is not a pointer to its
958
+ * element. `&gArr` is `T (*)[n]`, `&gStruct` is `struct S *`, and neither is assignable to
959
+ * `T *` — yet the IR's `gaddr` value legitimately has type `T *`, because that is what the asm
960
+ * loaded. The bare spelling therefore states a type the project's own header contradicts.
961
+ *
962
+ * It survived because agbcc only WARNS ("assignment from incompatible pointer type") and
963
+ * computes the right address anyway. That leniency is not something to rely on: the Klonoa
964
+ * project's own build template treats these as fatal, so the row's emitted C does not build
965
+ * where its author would put it. The cast is the always-valid spelling — the same fallback
966
+ * `bareArrayLead` documents for the indexed form — and it is byte-identical (measured on
967
+ * kleod:UpdateHUDCounterDisplay: 81 with and without).
968
+ *
969
+ * The test is whether `&gSym`'s rendered type PROVABLY equals the destination's, not whether the
970
+ * symbol looks like an aggregate. A shape enumeration got this wrong three ways, each a real
971
+ * miss: `shape:'pointer'` declares a pointer cell (`void *gSym`, or `struct Tag *gSym` when the
972
+ * pointee has a declarable layout), so `&gSym` is a pointer-to-pointer either way; a `shape:'scalar'`
973
+ * whose width differs from the destination's pointee gives `s32 *` for a `u16 *` slot; and a
974
+ * NAME-ONLY symbol is synthesized as `extern u32 gSym;` (declare.ts), which is `u32 *` — not the
975
+ * `T *` the older comment here claimed. So the default is to CAST, and the cast is omitted only
976
+ * where the declared cell type is known and matches exactly. Byte-identical either way, so the
977
+ * cost of casting one time too many is a redundant `(T *)`, never a wrong address. */
978
+ const castAggregateAddr = (name: string, value: Expr): Expr => {
979
+ const t = varType.get(name);
980
+ if (t?.kind !== 'ptr' || value.k !== 'addr') {
981
+ return value;
982
+ }
983
+ // The only provably-redundant case: a NON-VOLATILE scalar cell whose DECLARED type is the
984
+ // destination's pointee, where `&gSym` already denotes exactly `T *`.
985
+ //
986
+ // `scalarCellType` and not `scalarTypeForAccess`: the latter answers what an ACCESS of that
987
+ // width reads and collapses every 4-byte access to `s32`, so it called a `u32` cell equal to an
988
+ // `s32 *` destination and let the incompatible assignment through. And a `volatile` cell makes
989
+ // `&gSym` a `volatile T *`, so omitting the cast would DISCARD the qualifier — the same class of
990
+ // fatal-under-a-strict-build defect this rule exists to remove.
991
+ const si = symCtx?.info(value.name);
992
+ if (si?.shape === 'scalar' && !si.volatile && isScalarCellSize(si.size)) {
993
+ if (typeEquals(scalarCellType(si.size, si.signed), t.to)) {
994
+ return value;
995
+ }
996
+ }
997
+ return { k: 'cast', to: t, e: value };
998
+ };
999
+
583
1000
  let fresh = 0;
584
1001
  // Materialized defs are named FIRST: the temp is the register the compiler held the
585
1002
  // value in, so downstream coalescing (loop inits, merge params) may adopt it — subject to the
@@ -786,11 +1203,270 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
786
1203
  }
787
1204
  }
788
1205
 
789
- // An unresolvable value: strict mode keeps the `"?"` sentinel (assertResolved trips at the
790
- // boundary loud in the PROCESS); annotate mode emits a marker (the undefined ASMLIFT_ERROR
791
- // symbol loud in the ARTIFACT, function still complete).
792
- const mkGap = (reason: string, args: Expr[]): Expr =>
793
- onGap === 'annotate' ? { k: 'marker', reason, args } : { k: 'var', name: '?' };
1206
+ // ── def-site anchoring of constant merge copies (anchorConstCopies) ──────────────────────────
1207
+ // An edge copy `v = K` places the constant where the EDGE is, but the asm often materialized K
1208
+ // earlier: `movs r9, #0` at entry ahead of a single-armed overwrite, `movs r5, #1` at the top
1209
+ // of an arm ahead of a nested if. Anchoring the copy at the const op's own program position
1210
+ // reproduces that placement the write is emitted as a statement there (sideEffects reads
1211
+ // `anchoredAt`) and the edge copies it replaces are suppressed (argAssignsFor reads
1212
+ // `suppressedArgs`). Where the surviving arm then empties, mkIf's empty-then peephole yields
1213
+ // the single-armed positive `if` the source wrote.
1214
+ //
1215
+ // REFUSAL CONDITIONS — each keeps the edge placement, never producing a different write:
1216
+ // - the arg is not an UNNAMED `const` op (only a rematerializable constant carries
1217
+ // unambiguous placement evidence; a named value's position is its materialized def's);
1218
+ // - the merge is a loop header (loop copies have their own placement discipline);
1219
+ // - the const's block does not dominate every edge source passing it (the anchored write
1220
+ // must precede the edge on every path);
1221
+ // - the const's block or any edge source sits inside ANY loop. Block-level dominance does
1222
+ // not give per-ITERATION precedence — a path may pass the def in iteration 1 and take the
1223
+ // suppressed edge in iteration 2 with the variable overwritten in between, the /preinit
1224
+ // sticky-arm failure class (PR #13) — so in-loop shapes are declined outright;
1225
+ // - the merge variable names any OTHER SSA value (a shared name has readers and writers
1226
+ // between the def site and the edge that edge placement respects and anchoring would not);
1227
+ // - another anchored const of the same variable lies on a path from this one to this one's
1228
+ // edge (the later write would clobber this arg's value; both stay at their edges instead).
1229
+ const anchoredAt = new Map<Op, { name: string; arg: Value }[]>();
1230
+ const suppressedArgs = new Map<object, Set<number>>();
1231
+ if (anchorConstCopies) {
1232
+ const nameCount = new Map<string, number>();
1233
+ for (const n of varName.values()) {
1234
+ nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
1235
+ }
1236
+ const inLoop = (b: Block): boolean => {
1237
+ for (const nl of forest.byHeader.values()) {
1238
+ if (nl.body.has(b)) {
1239
+ return true;
1240
+ }
1241
+ }
1242
+ return false;
1243
+ };
1244
+ // conservative "a write in `a` may execute between one in `b` and `b`'s terminator": same
1245
+ // block counts (op order refined by the caller where it matters), else CFG reachability
1246
+ const mayFollow = (a: Block, b: Block): boolean => a === b || reachFrom(a).has(b);
1247
+ for (const M of fn.blocks) {
1248
+ if (M === entry || M.params.length === 0 || forest.byHeader.has(M)) {
1249
+ continue;
1250
+ }
1251
+ M.params.forEach((p, i) => {
1252
+ const name = varName.get(p)!;
1253
+ if (nameCount.get(name) !== 1) {
1254
+ return;
1255
+ }
1256
+ // every in-edge record into M, grouped by the SSA value it passes for param i
1257
+ const groups = new Map<Value, { rec: { block: Block; args: Value[] }; src: Block }[]>();
1258
+ for (const pr of new Set(preds.get(M) ?? [])) {
1259
+ for (const s of pr.ops[pr.ops.length - 1].successors) {
1260
+ if (s.block === M) {
1261
+ const g = groups.get(s.args[i]);
1262
+ if (g) {
1263
+ g.push({ rec: s, src: pr });
1264
+ } else {
1265
+ groups.set(s.args[i], [{ rec: s, src: pr }]);
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+ const candidates: { arg: Value; def: Op; defBlock: Block; edges: { rec: object; src: Block }[] }[] = [];
1271
+ for (const [arg, edges] of groups) {
1272
+ const def = defs.get(arg);
1273
+ if (!def || def.opcode !== 'const' || varName.has(arg)) {
1274
+ continue;
1275
+ }
1276
+ const defBlock = opBlock.get(def)!;
1277
+ if (inLoop(defBlock) || edges.some(({ src }) => inLoop(src))) {
1278
+ continue;
1279
+ }
1280
+ if (edges.some(({ src }) => !dom.get(src)!.has(defBlock))) {
1281
+ continue;
1282
+ }
1283
+ candidates.push({ arg, def, defBlock, edges });
1284
+ }
1285
+ // pairwise clobber check: candidate `c` is unsafe when another candidate's write can lie
1286
+ // between c's def and one of c's edges (def_c → def_o → edge_c); both then keep their edges
1287
+ const safe = candidates.filter((c) =>
1288
+ candidates.every((o) => {
1289
+ if (o === c) {
1290
+ return true;
1291
+ }
1292
+ const oAfterC =
1293
+ c.defBlock === o.defBlock ? opIndex.get(o.def)! > opIndex.get(c.def)! : mayFollow(c.defBlock, o.defBlock);
1294
+ return !(oAfterC && c.edges.some(({ src }) => mayFollow(o.defBlock, src)));
1295
+ }),
1296
+ );
1297
+ for (const c of safe) {
1298
+ const at = anchoredAt.get(c.def);
1299
+ if (at) {
1300
+ at.push({ name, arg: c.arg });
1301
+ } else {
1302
+ anchoredAt.set(c.def, [{ name, arg: c.arg }]);
1303
+ }
1304
+ for (const { rec } of c.edges) {
1305
+ const sup = suppressedArgs.get(rec);
1306
+ if (sup) {
1307
+ sup.add(i);
1308
+ } else {
1309
+ suppressedArgs.set(rec, new Set([i]));
1310
+ }
1311
+ }
1312
+ }
1313
+ });
1314
+ }
1315
+ }
1316
+
1317
+ // ── BITFIELD member reads (symbol map) ──────────────────────────────────────────────────────
1318
+ // The `(x << a) >> b` extract of a struct global's loaded bytes IS a bitfield access when the
1319
+ // map declares a bitfield at exactly those bits: spelled `gSym.field`, the source form, whose
1320
+ // declared `u32 field : n` then makes C's own integer promotion reproduce the signedness every
1321
+ // downstream operator compiled with (a 7-bit unsigned field promotes to signed int — sdiv
1322
+ // renders `/` and recompiles to __divsi3, where the raw-shift spelling stays u32).
1323
+ //
1324
+ // Semantically EXACT, never approximate: the window must lie inside the loaded bytes (so the
1325
+ // load's extension bits cannot reach it), the field's position, width and signedness must all
1326
+ // match the extract (a logical shift is an unsigned read, an arithmetic one a signed read —
1327
+ // a signless field never matches), and the member must be nameable at all (memberQualsAllow;
1328
+ // the map only carries bitfield facts for little-endian ELFs — see SymbolStructField). Any
1329
+ // mismatch keeps the honest shift spelling.
1330
+ //
1331
+ // Precomputed over the ops (not folded during rendering) for the load's sake: a load whose
1332
+ // EVERY use is a spelled extract chain must not also emit its materialized `v = *(u16 *)&g;`
1333
+ // temp — the compiler CSEs the repeated member reads back to one load, but the leftover temp
1334
+ // would be a second one. A VOLATILE container refuses the whole fold: N member reads are N
1335
+ // volatile accesses where the asm did one load. (Byte-level residual, differ-refereed: a load
1336
+ // only PARTIALLY absorbed — one extract spelled, another use kept — emits both the temp and
1337
+ // the named reads, one load more than the asm; semantics hold, the score decides.)
1338
+ //
1339
+ // ORDERING GATE (adversarial round, CRITICAL 1 — twice): the named spelling replaces a
1340
+ // REGISTER value — the bits captured at the load's program position — with a fresh memory
1341
+ // read at each render position. Every other memory read in this file goes through the
1342
+ // materialization model (analysis.ts) for exactly that hazard, so the fold clears the SAME
1343
+ // bar with the SAME machinery: `emitPos` resolves where each extract actually renders
1344
+ // (transitively through its inlining consumers — an unresolvable position refuses), and
1345
+ // `memWriteBetween` walks every def-avoiding load→render path for a call, an opaque, or a
1346
+ // store not provably to a DIFFERENT named global. Path-based on purpose: the second audit
1347
+ // pass broke the first fix's linear-position scan with a block laid out AFTER the render in
1348
+ // address order but executing between load and render on the taken path — fn.blocks order is
1349
+ // address order, not topological order.
1350
+ const bitfieldSpelling = new Map<Op, { global: string; field: string }>();
1351
+ const absorbedLoads = new Set<Op>();
1352
+ if (symCtx && littleEndian && spellBitfieldMembers) {
1353
+ // the (name, byte) of a load's address when it resolves through defs alone — `gaddr` or
1354
+ // `add(gaddr, const)`; anything else (a materialized base, a variable index) declines
1355
+ const loadTargets = new Map<Op, { name: string; byte: number }>();
1356
+ const addrOf = (v: Value, off: number): { name: string; byte: number } | null => {
1357
+ const d0 = defs.get(v);
1358
+ if (d0?.opcode === 'gaddr') {
1359
+ return { name: d0.attrs.sym as string, byte: off };
1360
+ }
1361
+ if (d0?.opcode === 'add' && d0.operands.length === 2) {
1362
+ for (const [x, y] of [
1363
+ [d0.operands[0], d0.operands[1]],
1364
+ [d0.operands[1], d0.operands[0]],
1365
+ ] as const) {
1366
+ const g0 = defs.get(x);
1367
+ const c0 = defs.get(y);
1368
+ if (g0?.opcode === 'gaddr' && c0?.opcode === 'const') {
1369
+ return { name: g0.attrs.sym as string, byte: (c0.attrs.value as number) + off };
1370
+ }
1371
+ }
1372
+ }
1373
+ return null;
1374
+ };
1375
+ // A write for the fold's purposes: calls and opaques always; a store/astore unless its base
1376
+ // resolves to a global PROVABLY different from the folded one. (Name comparison suffices:
1377
+ // the pool promotion picks one canonical name per address, so one cell cannot appear under
1378
+ // two names within a function.)
1379
+ const mayWrite =
1380
+ (sym: string) =>
1381
+ (x: Op): boolean => {
1382
+ if (x.opcode === 'call' || x.opcode === 'opaque') {
1383
+ return true;
1384
+ }
1385
+ if (x.opcode !== 'store' && x.opcode !== 'astore') {
1386
+ return false;
1387
+ }
1388
+ const t = addrOf(x.operands[0], 0);
1389
+ return !(t && t.name !== sym);
1390
+ };
1391
+ for (const blk of fn.blocks) {
1392
+ for (const op of blk.ops) {
1393
+ if ((op.opcode !== 'shr_u' && op.opcode !== 'shr_s') || op.operands.length !== 1) {
1394
+ continue;
1395
+ }
1396
+ const b = op.attrs.imm as number | undefined;
1397
+ const inner = defs.get(op.operands[0]);
1398
+ if (typeof b !== 'number' || b <= 0 || b >= 32 || inner?.opcode !== 'shl' || inner.operands.length !== 1) {
1399
+ continue;
1400
+ }
1401
+ const a = inner.attrs.imm as number | undefined;
1402
+ if (typeof a !== 'number' || a < 0 || b < a) {
1403
+ continue;
1404
+ }
1405
+ const w = 32 - b; // extract width
1406
+ const lo = b - a; // low bit within the loaded value
1407
+ const load = defs.get(inner.operands[0]);
1408
+ if (load?.opcode !== 'load' || lo + w > (load.attrs.width as number) * 8) {
1409
+ continue;
1410
+ }
1411
+ // a materialized shl would still emit its `v = x << a` temp reading the load — the fold
1412
+ // would then ADD member reads on top of it; rare, refuse
1413
+ if (materialize.has(inner)) {
1414
+ continue;
1415
+ }
1416
+ const gb = addrOf(load.operands[0], load.attrs.off as number);
1417
+ const si = gb ? symCtx.info(gb.name) : undefined;
1418
+ if (!gb || si?.shape !== 'struct' || si.volatile) {
1419
+ continue;
1420
+ }
1421
+ // where does the member read RENDER? at the extract's own position when materialized,
1422
+ // else wherever each of its consumers ultimately renders (emitPos, transitively —
1423
+ // unresolvable refuses); every load→render path must be write-free
1424
+ const renders = materialize.has(op)
1425
+ ? [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }]
1426
+ : [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))].map((c) => emitPos(c));
1427
+ const writes = mayWrite(gb.name);
1428
+ if (renders.some((r) => r === null) || renders.some((r) => memWriteBetween(load, r!, writes))) {
1429
+ continue;
1430
+ }
1431
+ const signedRead = op.opcode === 'shr_s';
1432
+ const fld = declaredFields(si.layout)?.find(
1433
+ (f) => f.bitWidth === w && f.offset * 8 + f.bitOffset! === gb.byte * 8 + lo && f.signed === signedRead,
1434
+ );
1435
+ if (fld && memberQualsAllow(fld, si.const, false)) {
1436
+ bitfieldSpelling.set(op, { global: gb.name, field: fld.name });
1437
+ loadTargets.set(load, gb);
1438
+ }
1439
+ }
1440
+ }
1441
+ // a load is ABSORBED when every use is an shl whose every use is a spelled extract
1442
+ for (const load of loadTargets.keys()) {
1443
+ const shls = useSitesOf.get(load.results[0]) ?? [];
1444
+ const absorbed =
1445
+ shls.length > 0 &&
1446
+ shls.every(
1447
+ (u) =>
1448
+ u.op.opcode === 'shl' && (useSitesOf.get(u.op.results[0]) ?? []).every((v) => bitfieldSpelling.has(v.op)),
1449
+ );
1450
+ if (absorbed) {
1451
+ absorbedLoads.add(load);
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ // An unresolvable value: strict mode keeps the `"?"` sentinel AND records the reason — the
1457
+ // decline thrown below names the actual gaps ("unmodelled instruction 'adde'"), the same
1458
+ // reasons annotate mode's markers carry, instead of the anonymous `?` that assertResolved
1459
+ // would report at the boundary (assertResolved stays as the backstop for any other producer).
1460
+ // Annotate mode emits a marker (the undefined ASMLIFT_ERROR symbol — loud in the ARTIFACT,
1461
+ // function still complete).
1462
+ const strictGaps: string[] = [];
1463
+ const mkGap = (reason: string, args: Expr[]): Expr => {
1464
+ if (onGap === 'annotate') {
1465
+ return { k: 'marker', reason, args };
1466
+ }
1467
+ strictGaps.push(reason);
1468
+ return { k: 'var', name: '?' };
1469
+ };
794
1470
 
795
1471
  // Lower ONE def's operation to an Expr, rendering operands through `e`. Shared between the
796
1472
  // inline-at-use path (exprWith) and the materialized-temp path (sideEffects), so both spell a
@@ -799,8 +1475,37 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
799
1475
  if (d.opcode === 'const') {
800
1476
  return { k: 'const', value: d.attrs.value as number };
801
1477
  }
1478
+ // a bitfield extract recognized over the ops (see the precompute above): the member read,
1479
+ // not the shift pair
1480
+ const bf = bitfieldSpelling.get(d);
1481
+ if (bf) {
1482
+ return { k: 'field', base: { k: 'var', name: bf.global }, name: bf.field, dot: true };
1483
+ }
802
1484
  if (CMP_TO_BIN[d.opcode]) {
803
- return { k: 'bin', op: CMP_TO_BIN[d.opcode], l: e(d.operands[0]), r: e(d.operands[1]) };
1485
+ // A bare global address `&gSym` as a COMPARISON operand is the same unspelled escape as the
1486
+ // arithmetic case below (see intifyAddr): its C type comes from the PROJECT's own
1487
+ // declaration, unknowable here. Worse, the compare's SIGNEDNESS lives in the operand types
1488
+ // (CMP_TO_BIN maps icmp_ult and icmp_slt to the same '<'), so leaving `&gSym` untyped lets
1489
+ // the project's declaration pick the compare the compiler emits — silently byte-inexact
1490
+ // whenever it disagrees with the asm. The honest spelling is integer math on the address
1491
+ // with the cast AGREEING with the opcode's signedness: unsigned compares (and the
1492
+ // sign-agnostic ==/!=) spell `(u32)&gSym`, signed compares `(s32)&gSym` — exactly the
1493
+ // compare the asm did. The deref folds never see a compare operand, so no named spelling is
1494
+ // lost; a NARROWING cast (`(u8)&gSym`) is not a bare `addr` and keeps its truncation.
1495
+ // SCOPE (adversarial review): this closes the hole for BARE addr operands only. An
1496
+ // addr-carrying arithmetic tree (`(u32)&gSym + 4`, spelled by intifyAddr below) under an
1497
+ // icmp_s* still compares unsigned in C (u32 wins the usual-arithmetic-conversions) — the
1498
+ // same pre-existing wrongness the old ptr-vs-int spelling had, surfacing as a scoring
1499
+ // nonmatch, never a silent regression of a formerly-correct compare. Rare shape; an outer
1500
+ // signed cast on addr-carrying trees is the follow-up if it ever costs a row.
1501
+ const t = /^icmp_s/.test(d.opcode) ? T.s(32) : T.u(32);
1502
+ const intifyAddrCmp = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: t, e: x } : x);
1503
+ return {
1504
+ k: 'bin',
1505
+ op: CMP_TO_BIN[d.opcode],
1506
+ l: intifyAddrCmp(e(d.operands[0])),
1507
+ r: intifyAddrCmp(e(d.operands[1])),
1508
+ };
804
1509
  }
805
1510
  if (ARITH_TO_BIN[d.opcode]) {
806
1511
  let l = e(d.operands[0]);
@@ -844,6 +1549,53 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
844
1549
  } else if (op === '-' && ctype(l)?.kind !== 'ptr' && ctype(r)?.kind === 'ptr') {
845
1550
  r = intify(r); // int - ptr is not C
846
1551
  }
1552
+ // A bare global address `&gSym` under ANY of these operators is never emitted as-is: its C
1553
+ // type comes from the PROJECT's own declaration (unknowable here — exprCType types `addr`
1554
+ // undefined, so the ptr-keyed intify above never fires on it), which makes `&gSym + K`
1555
+ // byte-INEXACT (C scales K by sizeof(gSym)) and `&gSym & K` ill-formed. The honest spelling
1556
+ // is integer math on the address — `(u32)&gSym + K`, exactly the arithmetic the asm did.
1557
+ // The deref folds (globalOf / globalConstByte, via addrIn) look through this cast, so every
1558
+ // access that CAN spell a named element/field still does; only a genuine value-context
1559
+ // escape (a call argument, a stored address, a compare) keeps it — previously such an
1560
+ // escape tripped assertDerefsTyped's interior-pointer rule and declined the whole function.
1561
+ const intifyAddr = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: T.u(32), e: x } : x);
1562
+ l = intifyAddr(l);
1563
+ r = intifyAddr(r);
1564
+ // The SAME hazard one level down, for a POINTER-shaped global's VALUE (`gPtr`, isPtrGlobal):
1565
+ // C scales `gPtr + K` by sizeof(*gPtr) — 1 under the map's synthesized `void *`, but
1566
+ // whatever the PROJECT's header declares (a 0x5C-byte struct, say) in the world a user
1567
+ // actually recompiles in. The asm added BYTES, so the honest spelling makes the stride
1568
+ // explicit: CAST-THEN-ADD, `(u8 *)gPtr + K`, the same address in EVERY world. Add-then-cast
1569
+ // (`(u8 *)(gPtr + K)`, what the backend's deref legalization would otherwise produce) is
1570
+ // byte-correct in exactly one of them — a silent wrongness, the class this project refuses.
1571
+ // NOT foldable into the deref index either: `((u8 *)gPtr)[K + off]` re-scales K by the
1572
+ // ACCESS width, a different address whenever that width is not 1.
1573
+ // Under the non-additive operators C rejects a pointer outright, so there the honest
1574
+ // spelling is integer math on the cell — exactly intifyAddr's `(u32)&gSym` rule.
1575
+ const bytePtr = (x: Expr): Expr => ({ k: 'cast', to: T.ptr(T.u(8)), e: x });
1576
+ const intifyPtrGlobal = (x: Expr): Expr => ({ k: 'cast', to: T.u(32), e: x });
1577
+ if (op === '+' || op === '-') {
1578
+ // `ptr ± int` and `ptr - ptr` are byte arithmetic once both sides are byte pointers;
1579
+ // `ptr + ptr` and `int - ptr` are not C at all, so the second pointer goes integer.
1580
+ const bothPtr = isPtrGlobal(l) && isPtrGlobal(r);
1581
+ if (isPtrGlobal(l)) {
1582
+ l = bytePtr(l);
1583
+ }
1584
+ if (isPtrGlobal(r)) {
1585
+ r = bothPtr && op === '-' ? bytePtr(r) : op === '+' && !bothPtr ? bytePtr(r) : intifyPtrGlobal(r);
1586
+ }
1587
+ } else if (op !== '&&' && op !== '||') {
1588
+ // (`&&`/`||` take a pointer operand legally — a truth test, no arithmetic.)
1589
+ l = isPtrGlobal(l) ? intifyPtrGlobal(l) : l;
1590
+ r = isPtrGlobal(r) ? intifyPtrGlobal(r) : r;
1591
+ }
1592
+ // (The two right shifts stay DISTINCT ops here — `>>>` logical, `>>` arithmetic. Which token
1593
+ // a language spells each with, and what cast pins the choice, is a BACKEND decision; see
1594
+ // l3/ast.ts BinOp and backend/cfamily.ts's shift rule.)
1595
+ // SCOPE: this and intifyAddr cover the ARITHMETIC escapes. A pointer global under a
1596
+ // COMPARISON (`gPtr < K` — C compares unsigned whatever the asm's icmp_s* said) is the same
1597
+ // class as intifyAddrCmp's `addr` rule and is deliberately left alone here: it is valid C
1598
+ // today, so closing it would churn spellings for a signedness case no row exercises.
847
1599
  return { k: 'bin', op, l, r };
848
1600
  }
849
1601
  // `-`/`~` on a pointer rendering is equally not C — same honest integer cast as above.
@@ -851,8 +1603,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
851
1603
  // The C rotate idiom — `x >> n | x << (32 - n)` (mirrored for rotl). Byte-exact round-trip
852
1604
  // on agbcc (thumb ror) and mwcc (rotlw/rotlwi), verified against both toolchains before the
853
1605
  // ops landed. `x` and `n` render twice — both pure by construction (SSA values; the rotate's
854
- // operands are register reads), and recovery seeds the rotated value unsigned so `>>`
855
- // spells the logical shift the idiom requires.
1606
+ // operands are register reads). The right half is the LOGICAL shift `>>>` the idiom is
1607
+ // wrong with an arithmetic one — stated on the node rather than left to the rotated value's
1608
+ // recovered unsignedness, which is a property of recovery rather than of the idiom.
856
1609
  //
857
1610
  // (The PPC mirror fold — `rotl(x, 32 - m)` ⇒ rotr(x, m) — lives in the PATTERN layer,
858
1611
  // engine.ts ROTL_MIRROR: it is a compiler-spelling idiom, mwcc-gated there, not a
@@ -869,7 +1622,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
869
1622
  n.k === 'const'
870
1623
  ? { k: 'const', value: 32 - n.value }
871
1624
  : { k: 'bin', op: '-', l: { k: 'const', value: 32 }, r: n };
872
- const [near, far] = dir === 'rotr' ? (['>>', '<<'] as const) : (['<<', '>>'] as const);
1625
+ const [near, far] = dir === 'rotr' ? (['>>>', '<<'] as const) : (['<<', '>>>'] as const);
873
1626
  return {
874
1627
  k: 'bin',
875
1628
  op: '|',
@@ -879,11 +1632,11 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
879
1632
  }
880
1633
  if (d.opcode === 'neg') {
881
1634
  const x = e(d.operands[0]);
882
- return { k: 'un', op: '-', e: ctype(x)?.kind === 'ptr' ? { k: 'cast', to: T.s(32), e: x } : x };
1635
+ return { k: 'un', op: '-', e: needsIntSpelling(x) ? { k: 'cast', to: T.s(32), e: x } : x };
883
1636
  }
884
1637
  if (d.opcode === 'not') {
885
1638
  const x = e(d.operands[0]);
886
- return { k: 'un', op: '~', e: ctype(x)?.kind === 'ptr' ? { k: 'cast', to: T.s(32), e: x } : x };
1639
+ return { k: 'un', op: '~', e: needsIntSpelling(x) ? { k: 'cast', to: T.s(32), e: x } : x };
887
1640
  }
888
1641
  // Width-narrowing casts: `zext`/`sext` widen a `width`-bit value back to 32 → C `(u8)e`/`(s8)e`.
889
1642
  if (d.opcode === 'zext') {
@@ -896,6 +1649,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
896
1649
  return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
897
1650
  }
898
1651
  if (d.opcode === 'gaddr') {
1652
+ // A promoted CODE symbol (frontend `code: true`) is a function pointer stored as an
1653
+ // integer: spelled `(u32)Name` — the source idiom — never `&Name` (defect G of the
1654
+ // dogfood report; the & form compiles but is a different, non-matching spelling).
1655
+ if (d.attrs.code === true) {
1656
+ return { k: 'cast', to: T.int(32, false), e: { k: 'var', name: d.attrs.sym as string } };
1657
+ }
899
1658
  return { k: 'addr', name: d.attrs.sym as string };
900
1659
  }
901
1660
  if (d.opcode === 'load') {
@@ -907,6 +1666,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
907
1666
  (d.attrs.signed as boolean) ?? false,
908
1667
  ctype,
909
1668
  scalarGlobals,
1669
+ symCtx,
910
1670
  );
911
1671
  }
912
1672
  // aload carries a runtime index operand (variable-index array access) — `base[index]`, or
@@ -920,6 +1680,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
920
1680
  d.attrs.elemSize as number,
921
1681
  (d.attrs.signed as boolean) ?? false,
922
1682
  ctype,
1683
+ symCtx,
923
1684
  );
924
1685
  }
925
1686
  return d.opcode === 'opaque'
@@ -983,13 +1744,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
983
1744
  const target = succ.block;
984
1745
  const argExpr = sub ? exprWith(sub) : expr;
985
1746
  const copies: { name: string; value: Expr; arg: Value }[] = [];
1747
+ const suppressed = suppressedArgs.get(succ);
986
1748
  target.params.forEach((p, i) => {
1749
+ if (suppressed?.has(i)) {
1750
+ return;
1751
+ } // anchored at its const's def site — the write already ran before this edge
987
1752
  const name = varName.get(p)!;
988
1753
  const arg = succ.args[i];
989
1754
  if ((sub?.get(arg) ?? varName.get(arg)) === name) {
990
1755
  return;
991
1756
  } // identity copy — coalesced away
992
- copies.push({ name, value: argExpr(arg), arg });
1757
+ copies.push({ name, value: castAggregateAddr(name, argExpr(arg)), arg });
993
1758
  });
994
1759
  // Emit in the order the args are COMPUTED in `pred` — a compiler that lays the defining ops
995
1760
  // (and thus the copies that read them) out in that order matches with no spurious arg-swap.
@@ -1035,6 +1800,8 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1035
1800
  width === 4,
1036
1801
  ctype,
1037
1802
  scalarGlobals,
1803
+ symCtx,
1804
+ true, // an lvalue: a member whose declaration is const cannot be NAMED as the target
1038
1805
  );
1039
1806
  if (lval0.k === 'var') {
1040
1807
  globalNames.add(lval0.name);
@@ -1056,13 +1823,21 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1056
1823
  elemSize,
1057
1824
  elemSize === 4,
1058
1825
  ctype,
1826
+ symCtx,
1059
1827
  ),
1060
1828
  value: expr(op.operands[2]),
1061
1829
  });
1062
1830
  } else if (op.opcode === 'call' && op.results.length && !useSitesOf.has(op.results[0])) {
1063
1831
  out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1064
- } else if (materialize.has(op)) {
1065
- out.push({ k: 'assign', name: varName.get(op.results[0])!, value: lowerDef(op, expr) });
1832
+ } else if (materialize.has(op) && !absorbedLoads.has(op)) {
1833
+ // (an absorbed load's every consumer spells a named bitfield read — emitting its temp
1834
+ // here would recompile to a second load the asm does not have)
1835
+ const nm = varName.get(op.results[0])!;
1836
+ out.push({ k: 'assign', name: nm, value: castAggregateAddr(nm, lowerDef(op, expr)) });
1837
+ }
1838
+ // a merge copy anchored at this const's original position (anchorConstCopies, above)
1839
+ for (const a of anchoredAt.get(op) ?? []) {
1840
+ out.push({ k: 'assign', name: a.name, value: expr(a.arg) });
1066
1841
  }
1067
1842
  }
1068
1843
  return out;
@@ -1122,6 +1897,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1122
1897
  isNamed: (v) => varName.has(v),
1123
1898
  isCmpOpcode: (opcode) => !!CMP_TO_BIN[opcode],
1124
1899
  switchAllowsNeqCase,
1900
+ emitsAnchoredWrite: (blk) => blk.ops.some((o) => anchoredAt.has(o)),
1125
1901
  expr: (v) => expr(v),
1126
1902
  structureRegion: (b, stop) => structureRegion(b, stop),
1127
1903
  });
@@ -1323,7 +2099,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1323
2099
  out.push(...updateCopies); // the loop update, RAW (i++, p>>=1, …)
1324
2100
  let leaveCond = exprWith(sub)(term.operands[0]);
1325
2101
  if (contIsTaken) {
1326
- leaveCond = negate(leaveCond);
2102
+ leaveCond = negateCond(leaveCond);
1327
2103
  } // continue is `taken` → leave when NOT it
1328
2104
  const exitArm = isBreak
1329
2105
  ? [...argAssigns(b, loopCtx.exit, sub), { k: 'break' } as Stmt] // break to the loop exit
@@ -1357,7 +2133,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1357
2133
  // IDO/MIPS; agbcc/GCC canonicalise either way, so it is safe there too. A compiler that
1358
2134
  // inverts branch canonicalization sets preserveDivergentBranchSense false and falls through
1359
2135
  // to the positive form below.
1360
- out.push({ k: 'if', cond: negate(cond), then: elseS, else: thenS });
2136
+ out.push({ k: 'if', cond: negateCond(cond), then: elseS, else: thenS });
1361
2137
  return out;
1362
2138
  }
1363
2139
  out.push(mkIf(cond, thenS, elseS));
@@ -1386,7 +2162,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1386
2162
  const term = li.header.ops[li.header.ops.length - 1];
1387
2163
  let cond = exprWith(loopSub(li))(term.operands[0]);
1388
2164
  if (term.successors[0].block !== li.header) {
1389
- cond = negate(cond);
2165
+ cond = negateCond(cond);
1390
2166
  } // loop-continue must be `taken`
1391
2167
  const body = [...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
1392
2168
  return { k: 'while', cond, body };
@@ -1400,7 +2176,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1400
2176
  const term = wl.header.ops[wl.header.ops.length - 1];
1401
2177
  let cond = expr(term.operands[0]);
1402
2178
  if (term.successors[1].block === wl.bodyEntry) {
1403
- cond = negate(cond);
2179
+ cond = negateCond(cond);
1404
2180
  }
1405
2181
  // The header→bodyEntry edge may carry non-identity phi args (a value the header COMPUTED and passes
1406
2182
  // into the body). Those copies must open the body — dropping them reads an uninitialised local.
@@ -1462,7 +2238,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1462
2238
  const body = [...inner, ...sideEffects(dw.latch), ...updates];
1463
2239
  let cond = exprWith(sub)(lterm.operands[0]);
1464
2240
  if (lterm.successors[1].block === dw.header) {
1465
- cond = negate(cond);
2241
+ cond = negateCond(cond);
1466
2242
  } // continue edge must be `taken`
1467
2243
  const out: Stmt[] = [{ k: 'dowhile', cond, body }];
1468
2244
  // The exit region reads latch back-edge values under `sub` (post-loop they live in the loop vars).
@@ -1471,6 +2247,14 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1471
2247
  };
1472
2248
 
1473
2249
  const body = recognizeForLoops(structureRegion(entry, null));
2250
+ // Strict-mode gaps decline HERE, naming the reasons — the same text annotate's markers
2251
+ // carry, so the two mode surfaces report the same decline (the reproduction scripts run
2252
+ // strict; the benchmark rows store annotate markers — fidelity holds them against each
2253
+ // other). Without this, the `?` sentinels reach assertResolved and decline anonymously.
2254
+ if (strictGaps.length > 0) {
2255
+ const reasons = [...new Set(strictGaps)].join('; ');
2256
+ throw new StructureError(`${strictGaps.length} unresolvable value(s) in '${fn.name}' — ${reasons}`);
2257
+ }
1474
2258
  // v* = coalesced/materialized locals; t* = sequentialize's swap-cycle temps (varType-only —
1475
2259
  // they have no Value, so they are collected from varType, not varName).
1476
2260
  const localNames = [...new Set([...varName.values(), ...[...varType.keys()].filter((n) => /^t\d+$/.test(n))])].filter(
@@ -1481,6 +2265,13 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1481
2265
  name: fn.name,
1482
2266
  params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
1483
2267
  locals: localNames.map((n) => ({ name: n, type: varType.get(n)! })),
2268
+ ...(shapedGlobalTypes.size
2269
+ ? {
2270
+ globals: [...shapedGlobalTypes]
2271
+ .map(([name, type]) => ({ name, type }))
2272
+ .sort((a, b) => a.name.localeCompare(b.name)),
2273
+ }
2274
+ : {}),
1484
2275
  retType: returnsVoid ? T.void() : returnType(fn),
1485
2276
  body,
1486
2277
  ...(structs.length ? { structs } : {}),
@@ -1630,16 +2421,10 @@ function substVar(e: Expr, from: string, to: string): Expr {
1630
2421
  // empty-then peephole: `if (c) {} else { S }` → `if (!c) { S }`
1631
2422
  function mkIf(cond: Expr, thenS: Stmt[], elseS: Stmt[]): Stmt {
1632
2423
  if (thenS.length === 0 && elseS.length > 0) {
1633
- return { k: 'if', cond: negate(cond), then: elseS, else: [] };
2424
+ return { k: 'if', cond: negateCond(cond), then: elseS, else: [] };
1634
2425
  }
1635
2426
  return { k: 'if', cond, then: thenS, else: elseS };
1636
2427
  }
1637
- function negate(e: Expr): Expr {
1638
- if (e.k === 'bin' && NEGATE[e.op]) {
1639
- return { ...e, op: NEGATE[e.op] };
1640
- }
1641
- return { k: 'un', op: '!', e };
1642
- }
1643
2428
 
1644
2429
  // --- CFG utilities ---
1645
2430
  function predecessorBlocks(fn: Fn): Map<Block, Block[]> {