@asmlift/core 0.1.0 → 0.3.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.
@@ -37,11 +37,19 @@
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';
40
+ import { type IrType, T, scalarTypeForAccess, typeEquals } from '../ir/types';
41
41
  import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren } 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
+ declaredFields,
50
+ isArrayField,
51
+ pointeeFields,
52
+ } from '../symbols';
45
53
  import { analyze } from './analysis';
46
54
  import { makeLoopHazards, updateWriteSet } from './hazards';
47
55
  import { analyzeLoops, dominators } from './loops';
@@ -59,44 +67,267 @@ import { makeSwitchRecovery } from './switch-recover';
59
67
  // TODAY — carrying the struct name (resolved against SFn.structs) is the same move as width and
60
68
  // the named follow-up; until then no backend pays a tax for the tree cast (Pascal loud-fails
61
69
  // `field` regardless, C++ falls through its leaf hook to the shared C spelling).
70
+ // `&gSym`, possibly wearing the value-context integer cast the additive lowering adds
71
+ // (`(u32)&gSym` — see lowerDef's addr-intify): both spell the same link-time constant, so the
72
+ // fold rules match through the cast and every access that CAN spell a named element still does.
73
+ // WIDTH 32 ONLY — a NARROWING cast (`(u8)&gSym`, from a zext/sext lowering) is a different
74
+ // VALUE (`addr & 0xFF`), and folding through it would read the named global at a wrong address
75
+ // (the adversarial round's probe: `*(u8*)(u8)&gSym` must keep its truncation, never become
76
+ // `*(u8*)&gSym` — let alone a confidently-named `gSym.field`).
77
+ function addrIn(e: Expr): Extract<Expr, { k: 'addr' }> | null {
78
+ if (e.k === 'addr') {
79
+ return e;
80
+ }
81
+ if (e.k === 'cast' && e.to.kind === 'int' && e.to.width === 32 && e.e.k === 'addr') {
82
+ return e.e;
83
+ }
84
+ return null;
85
+ }
86
+
62
87
  // If `e` is a global address `&gSym` (optionally `+ index`), return the global name and the
63
88
  // element index (byte residual divided by the access width). `&gSym` alone → idx const 0;
64
89
  // `&gSym + i` → idx `i / width` (exact division only — a non-multiple residual is a mid-element
65
90
  // access this whole-global spelling can't express, so it declines to null and the caller casts).
66
91
  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 } };
92
+ const top = addrIn(e);
93
+ if (top) {
94
+ return { name: top.name, idx: { k: 'const', value: 0 } };
69
95
  }
70
96
  if (e.k === 'bin' && e.op === '+') {
71
- for (const [addrSide, other] of [
97
+ for (const [side, other] of [
72
98
  [e.l, e.r],
73
99
  [e.r, e.l],
74
100
  ] 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
101
+ const addrSide = addrIn(side);
102
+ if (addrSide) {
103
+ const idx = elementIndex(other, width);
104
+ return idx ? { name: addrSide.name, idx } : null;
105
+ }
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+
111
+ // A BYTE residual read as an ELEMENT index of `elemSize`-wide elements, or null when it is not one
112
+ // the residual then addresses mid-element and no whole-element spelling can express it, so the
113
+ // caller falls through to the honest cast forms. THE one copy of the rule, indexing the
114
+ // `&gSym`-based array spelling: width 1 → the byte residual IS the index; wider → a constant
115
+ // residual must divide exactly, and a non-constant one must already be element-scaled
116
+ // (`i * elemSize` / `i << log2(elemSize)`), which is exactly what the asm's own index scaling
117
+ // produced.
118
+ function elementIndex(residual: Expr, elemSize: number): Expr | null {
119
+ if (elemSize === 1) {
120
+ return residual;
121
+ }
122
+ if (residual.k === 'const') {
123
+ return residual.value % elemSize === 0 ? { k: 'const', value: residual.value / elemSize } : null;
124
+ }
125
+ if (residual.k === 'bin' && (residual.op === '*' || residual.op === '<<')) {
126
+ const factor =
127
+ residual.op === '<<'
128
+ ? residual.r.k === 'const'
129
+ ? 1 << residual.r.value
130
+ : 0
131
+ : residual.r.k === 'const'
132
+ ? residual.r.value
133
+ : 0;
134
+ if (factor === elemSize) {
135
+ return residual.l;
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /** The symbol-map rendering context threaded into memAccess/arrayAccess: shape facts per
142
+ * global name, plus a callback registering a global's env type (so the bare `gSym[i]` spelling,
143
+ * which must pass the stride check uncast, does). Absent ⇒ today's spellings. */
144
+ interface SymRenderCtx {
145
+ info(name: string): SymbolInfo | undefined;
146
+ noteGlobal(name: string, type: IrType): void;
147
+ }
148
+
149
+ // ── interior spelling through a POINTER-shaped global ────────────────────────────────────────
150
+ // A pointer global's VALUE is the address of the object the project's header says it points at.
151
+ // With that pointee's layout in the map, an access at a known offset is a NAMED member of it —
152
+ // `gPtr->member` — which is the source spelling; without it the access is byte arithmetic on the
153
+ // loaded cell (`((u8 *)gPtr + i)[16]`), honest but opaque. The address computed is IDENTICAL
154
+ // either way: `->member` adds the member's DWARF offset, which is the same constant the arithmetic
155
+ // added, and an index into an array member scales by the member's own element size, which the
156
+ // rules below require to equal the access width. Everything not provably that — a partial overlap,
157
+ // a width mismatch, an unnamed offset, a member whose declared signedness differs from the
158
+ // access's (an s8 read is ldrb+lsl+asr where u8 is ldrb alone), a missing layout — falls THROUGH
159
+ // to the cast forms. A member is never guessed.
160
+
161
+ /** The global named by a pointer global's VALUE as the additive lowering spells it: the bare
162
+ * `gPtr`, or that value wearing the byte-pointer / u32 cast that lowering adds (cast-then-add,
163
+ * see the `needsIntSpelling` / pointer-global arithmetic rules below). Both denote the same
164
+ * address and add BYTES to it, so both fold here; a cast to any other pointer type is NOT looked
165
+ * through — a `(u16 *)` base would re-scale everything added after it. */
166
+ function ptrGlobalValueName(x: Expr): string | null {
167
+ if (x.k === 'var') {
168
+ return x.name;
169
+ }
170
+ if (x.k === 'cast' && x.e.k === 'var') {
171
+ const t = x.to;
172
+ const bytePtr = t.kind === 'ptr' && t.to.kind === 'int' && t.to.width === 8;
173
+ return bytePtr || (t.kind === 'int' && t.width === 32) ? x.e.name : null;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** A pointer global's value, the constant bytes added to it, and the at-most-one variable term. */
179
+ interface PtrGlobalBase {
180
+ name: string;
181
+ byte: number;
182
+ idx: Expr | null;
183
+ }
184
+
185
+ /** Decompose an access base into "the VALUE of a map-declared POINTER global + a constant byte
186
+ * offset + at most ONE variable term": `gPtr`, `gPtr + K`, `(u8 *)gPtr + i`, `(u8 *)gPtr + (i <<
187
+ * 2) + K`. Null for anything else — two variable terms, no such global, a non-`+` operator —
188
+ * because only a single residual can be read as one member's index. */
189
+ function ptrGlobalBase(e: Expr, isPtrGlobal: (n: string) => boolean): PtrGlobalBase | null {
190
+ let name: string | null = null;
191
+ let byte = 0;
192
+ let idx: Expr | null = null;
193
+ let ok = true;
194
+ const visit = (x: Expr): void => {
195
+ if (!ok) {
196
+ return;
197
+ }
198
+ if (x.k === 'bin' && x.op === '+') {
199
+ visit(x.l);
200
+ visit(x.r);
201
+ return;
202
+ }
203
+ const global = ptrGlobalValueName(x);
204
+ if (global !== null && name === null && isPtrGlobal(global)) {
205
+ name = global;
206
+ return;
207
+ }
208
+ if (x.k === 'const') {
209
+ byte += x.value;
210
+ return;
211
+ }
212
+ if (idx !== null) {
213
+ ok = false;
214
+ return;
215
+ }
216
+ idx = x;
217
+ };
218
+ visit(e);
219
+ return ok && name !== null ? { name, byte, idx } : null;
220
+ }
221
+
222
+ /** Does a member declared at signedness `declared` read as EXACTLY the type the cast spelling this
223
+ * replaces would have produced — `scalarTypeForAccess(width, signed)`? That is the whole
224
+ * byte-exactness argument for naming a member instead of casting: same address, same read width,
225
+ * same C type, so every operator downstream compiles identically. A 4-byte access renders s32
226
+ * whatever the load said (the ISA has one word load), so only a SIGNED member may take its place;
227
+ * narrower accesses carry their own signedness and must match it. An undeclared signedness (the
228
+ * member is not a base type — a nested struct, an enum, a pointer) is never assumed to match.
229
+ * Compared against THE one copy of that rule (ir/types.ts) rather than restating it. */
230
+ function spellsAccessType(declared: boolean | undefined, width: number, signed: boolean): boolean {
231
+ if (declared === undefined) {
232
+ return false;
233
+ }
234
+ return typeEquals(T.int(width * 8, declared), scalarTypeForAccess(width, signed));
235
+ }
236
+
237
+ /** May a member be NAMED by an access of this direction, given the qualifiers on its declaration?
238
+ * The named spelling REPLACES a cast through `(u8 *)`, which carries no qualifier at all, so a
239
+ * qualifier the name reintroduces changes what the compiler emits:
240
+ * • `volatile` makes the access observable — the load may no longer be folded or reordered,
241
+ * which is a different instruction sequence (measured: 6 insns where the cast form was 5);
242
+ * • `const` under a STORE is a hard error, where the cast form merely cast the qualifier away.
243
+ * Either way the honest spelling is the cast form, so the member simply is not nameable here. */
244
+ function memberQualsAllow(f: SymbolStructField, containerConst: boolean | undefined, isStore: boolean): boolean {
245
+ if (f.volatile) {
246
+ return false;
247
+ }
248
+ return !(isStore && (f.const || containerConst));
249
+ }
250
+
251
+ // WHY THERE IS NO INDEXED `gPtr->arr[i]` SPELLING.
252
+ //
253
+ // Naming a member is only allowed where it is byte-identical to the cast form it replaces, and
254
+ // for the INDEXED form that was measured to be false. Against agbcc, `gPtr->arr[i]` and
255
+ // `((u8 *)gPtr + i)[K]` differ at EVERY nonzero K and for every width and direction — agbcc does
256
+ // not reassociate `(base + K) + i` into `(base + i) + K`, so it materialises the offset instead of
257
+ // folding it into the load (`adds r1, #16`; +2 code bytes at width 1, +4 at widths 2 and 4). At
258
+ // K = 0 the two still differ for widths 2 and 4, where the commutative `adds` picks a different
259
+ // destination register. The single agbcc case that did measure identical — width 1 at K = 0 —
260
+ // survives only a BARE index in a function with ONE such access: `i & 255`, `i + 1`, `i >> 2` and
261
+ // a second access that lets the cast side CSE its base all break it. A spelling rule decides one
262
+ // expression at a time and cannot see the neighbouring access that changes the answer, so there is
263
+ // no local gate that makes this form safe. (Both MIPS targets accept it freely, but core is
264
+ // target-agnostic — it cannot condition on the compiler it is emitting for.)
265
+ //
266
+ // The CONSTANT-offset form below is the opposite case, and is emitted unconditionally: measured
267
+ // identical on all three targets for widths 1/2/4, loads and stores, at every offset tested up to
268
+ // 4096 — including offsets past Thumb's immediate range, and under multi-member, across-a-call
269
+ // and in-a-loop shapes. It is one load whose member offset becomes the same immediate the cast
270
+ // form used, and unlike the indexed form it composes.
271
+
272
+ /** The pointee a global's value may be spelled through: the members the declaration synthesis
273
+ * DECLARES. Null when nothing may be named through it — THE shared gate (symbols.ts
274
+ * pointeeFields), so core never names a member that synthesis would not declare, and never sees a
275
+ * member synthesis drops (a union alias behind the first view at that offset). A VOLATILE pointee
276
+ * declines outright: every named access through it would be a volatile access where the cast form
277
+ * it replaces was plain. */
278
+ function spellablePointee(
279
+ name: string,
280
+ sym: SymRenderCtx,
281
+ ): { fields: DeclaredField[]; const: boolean | undefined } | null {
282
+ const pointee = sym.info(name)?.pointee;
283
+ const fields = pointeeFields(pointee);
284
+ if (fields === null || pointee!.volatile) {
285
+ return null;
286
+ }
287
+ return { fields, const: pointee!.const };
288
+ }
289
+
290
+ /** `gPtr->member` for an access through a pointer global's value, or null when the offset is not
291
+ * provably ONE member's (see the block comment above). A VARIABLE index declines whatever it
292
+ * lands on — the indexed form is not byte-neutral and has no spelling here. */
293
+ function pointeeAccess(
294
+ pg: PtrGlobalBase,
295
+ off: number,
296
+ width: number,
297
+ signed: boolean,
298
+ isStore: boolean,
299
+ sym: SymRenderCtx,
300
+ ): Expr | null {
301
+ if (pg.idx !== null) {
302
+ return null;
303
+ }
304
+ const total = pg.byte + off;
305
+ // Constant offset: the member must match EXACTLY — offset, read width, and the SPELLED type
306
+ // (spellsAccessType). An ARRAY member is excluded whatever its size: `u8 x[1]` would match a
307
+ // byte access by (offset, size) and spell `->x`, which is not an lvalue of that width at all.
308
+ const p = spellablePointee(pg.name, sym);
309
+ const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m));
310
+ return p && f && spellsAccessType(f.signed, width, signed) && memberQualsAllow(f, p.const, isStore)
311
+ ? { k: 'field', base: { k: 'var', name: pg.name }, name: f.name }
312
+ : null;
313
+ }
314
+
315
+ // The (name, byte offset) of a global access with a CONSTANT total offset — `&gSym` → off,
316
+ // `&gSym + K` → K + off. The exact byte is what a struct-layout field lookup needs; a variable
317
+ // residual returns null (no field spelling — falls through to the index/cast forms).
318
+ function globalConstByte(baseExpr: Expr, off: number): { name: string; byte: number } | null {
319
+ const top = addrIn(baseExpr);
320
+ if (top) {
321
+ return { name: top.name, byte: off };
322
+ }
323
+ if (baseExpr.k === 'bin' && baseExpr.op === '+') {
324
+ for (const [a, b] of [
325
+ [baseExpr.l, baseExpr.r],
326
+ [baseExpr.r, baseExpr.l],
327
+ ] as const) {
328
+ const a2 = addrIn(a);
329
+ if (a2 && b.k === 'const') {
330
+ return { name: a2.name, byte: b.value + off };
100
331
  }
101
332
  }
102
333
  }
@@ -111,6 +342,8 @@ function memAccess(
111
342
  signed: boolean,
112
343
  ctype: (e: Expr) => IrType | undefined,
113
344
  scalarGlobals: Set<string>,
345
+ sym?: SymRenderCtx,
346
+ isStore = false,
114
347
  ): Expr {
115
348
  // A deref of a global's address collapses to the bare global: `*(&gSym)` at off 0 is `gSym`;
116
349
  // at off N the global is an array — `gSym[N/width]` (a C global name decays to a pointer, so
@@ -118,6 +351,35 @@ function memAccess(
118
351
  // `*(&gSym + i)` → `gSym[i + off/width]` (byte offset `i` peeled from the tree; for a u8 global
119
352
  // the residual IS the index). This is what makes an agbcc `.word gSym` pool access a named
120
353
  // global read/element rather than a phantom-pointer deref.
354
+ // Declaration-shape spellings (symbol map): a STRUCT global's constant-offset access is the
355
+ // named field (`gSym.field` — the source spelling a folded literal can never match); an ARRAY
356
+ // global indexes its BARE name (`gSym[i]`, see below). Exact field match only (offset AND
357
+ // width) — anything else falls through to the honest cast forms, never a guessed field.
358
+ if (sym) {
359
+ const gb = globalConstByte(baseExpr, off);
360
+ const si = gb ? sym.info(gb.name) : undefined;
361
+ if (gb && si?.shape === 'struct') {
362
+ // THE shared spellability predicate (symbols.ts), the same call declare.ts gates its struct
363
+ // declaration on: a layout it declines whole is a layout with no nameable members, and a
364
+ // union alias it drops for the first view at that offset is a name no declaration carries.
365
+ // An ARRAY member is excluded for the same reason as in pointeeAccess: `u8 x[1]` would match
366
+ // a byte access by (offset, size) and spell `.x`, which is not an lvalue of that width.
367
+ const fld = declaredFields(si.layout)?.find((f) => f.offset === gb.byte && f.size === width && !isArrayField(f));
368
+ if (fld && memberQualsAllow(fld, si.const, isStore)) {
369
+ return { k: 'field', base: { k: 'var', name: gb.name }, name: fld.name, dot: true };
370
+ }
371
+ }
372
+ // …and the same idea one indirection down: an access at a CONSTANT offset through a POINTER
373
+ // global's VALUE is a named member of what it points at (`gPtr->member`) when the map knows
374
+ // the pointee's layout — see pointeeAccess for the guards.
375
+ const pg = ptrGlobalBase(baseExpr, (n) => sym.info(n)?.shape === 'pointer');
376
+ if (pg) {
377
+ const spelled = pointeeAccess(pg, off, width, signed, isStore, sym);
378
+ if (spelled) {
379
+ return spelled;
380
+ }
381
+ }
382
+ }
121
383
  const g = globalOf(baseExpr, width);
122
384
  if (g) {
123
385
  const idxVal = g.idx;
@@ -136,6 +398,14 @@ function memAccess(
136
398
  : idxVal.k === 'const'
137
399
  ? { k: 'const', value: idxVal.value + off / width }
138
400
  : { k: 'bin', op: '+', l: idxVal, r: { k: 'const', value: off / width } };
401
+ // ARRAY-declared global (symbol map): index the bare name — `gSym[i]`, the spelling the
402
+ // dogfood proved agbcc needs for ROM tables — with the element type registered in the env
403
+ // so the stride check passes and no cast is added. Element-width match only.
404
+ const siArr = sym?.info(g.name);
405
+ if (siArr?.shape === 'array' && siArr.elemSize === width) {
406
+ sym!.noteGlobal(g.name, T.ptr(T.int(width * 8, siArr.elemSigned ?? false)));
407
+ return { k: 'index', base: { k: 'var', name: g.name }, idx, width, signed };
408
+ }
139
409
  return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
140
410
  }
141
411
  const bt = base.type;
@@ -169,11 +439,18 @@ function arrayAccess(
169
439
  elemSize: number,
170
440
  signed: boolean,
171
441
  ctype: (e: Expr) => IrType | undefined,
442
+ sym?: SymRenderCtx,
172
443
  ): Expr {
173
444
  // A variable-index access off a global's address indexes the ADDRESS `&gSym` (the cast form
174
445
  // `((T *)&gSym)[i]` — valid for a struct global too, unlike casting the bare value). A
175
446
  // struct-array-of-globals (fieldOff) through `&gSym` is out of scope — fall through.
176
447
  if (baseExpr.k === 'addr' && fieldOff === undefined) {
448
+ // ARRAY-declared global (symbol map): the bare-name spelling, same rule as memAccess.
449
+ const si = sym?.info(baseExpr.name);
450
+ if (si?.shape === 'array' && si.elemSize === elemSize) {
451
+ sym!.noteGlobal(baseExpr.name, T.ptr(T.int(elemSize * 8, si.elemSigned ?? false)));
452
+ return { k: 'index', base: { k: 'var', name: baseExpr.name }, idx: idxExpr, width: elemSize, signed };
453
+ }
177
454
  return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
178
455
  }
179
456
  const bt = base.type;
@@ -306,6 +583,11 @@ export interface StructureOptions {
306
583
  // "annotate" — a `marker` node that spells as the undefined ASMLIFT_ERROR(...) symbol (loud in
307
584
  // the ARTIFACT: the function emits complete, but cannot compile un-acknowledged).
308
585
  onGap?: 'strict' | 'annotate';
586
+ /** NAME-keyed project symbol facts (symbols.ts `symbolsByName`) — drives the byte-sensitive
587
+ * declaration-shape spellings: `shape:'array'` forces the aggregate classification and the
588
+ * bare `gSym[i]` form; `shape:'struct'`+layout spells interiors as `gSym.field`. Absent (or
589
+ * a symbol not in the map) ⇒ today's usage-inferred behavior, byte-identical. */
590
+ symbols?: Map<string, SymbolInfo>;
309
591
  }
310
592
 
311
593
  export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
@@ -316,6 +598,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
316
598
  orderArgCopiesByComputation = true,
317
599
  switchAllowsNeqCase = true,
318
600
  onGap = 'strict',
601
+ symbols,
319
602
  } = opts;
320
603
  const defs = defOpMap(fn);
321
604
  const preds = predecessorBlocks(fn);
@@ -347,14 +630,16 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
347
630
  if (s) {
348
631
  (offsets.get(s) ?? offsets.set(s, new Set()).get(s)!).add(op.attrs.off as number);
349
632
  }
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
- }
633
+ } else if (op.opcode === 'add' || op.opcode === 'sub') {
634
+ // ANY arithmetic on the symbol's address is interior addressing ⇒ aggregate — even when
635
+ // the sum only reaches memory through a copy/phi (a pointer-walk loop `p = &g + 2;
636
+ // do { *p++ }` never makes the add a DIRECT load/store base, which is all the old
637
+ // check saw; the symbol then classified scalar and emitted the bare `g = 0` spelling,
638
+ // which a project declaring `extern u16 g[]` rejects as an incomplete-type assignment).
639
+ for (const o of op.operands) {
640
+ const s2 = gaddrSym(o);
641
+ if (s2) {
642
+ bumpAgg(s2);
358
643
  }
359
644
  }
360
645
  } else if (op.opcode === 'aload' || op.opcode === 'astore') {
@@ -370,8 +655,38 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
370
655
  scalarGlobals.add(sym);
371
656
  }
372
657
  }
658
+ // Declaration-shape OVERRIDE (symbol map): a project-declared array/struct global is an
659
+ // AGGREGATE whatever the usage inference saw — a lone off-0 access to `extern u16 tbl[]`
660
+ // must still spell through the aggregate/array forms, never the bare scalar `tbl`.
661
+ if (symbols) {
662
+ for (const [n, si] of symbols) {
663
+ if (si.shape === 'array' || si.shape === 'struct') {
664
+ scalarGlobals.delete(n);
665
+ }
666
+ }
667
+ }
373
668
  }
374
669
 
670
+ // Symbol-map rendering context (memAccess/arrayAccess): shape lookups + the env registry for
671
+ // array-shaped globals actually referenced (they surface as SFn.globals — typed, undeclared).
672
+ const shapedGlobalTypes = new Map<string, IrType>();
673
+ const symCtx: SymRenderCtx | undefined = symbols
674
+ ? { info: (n) => symbols.get(n), noteGlobal: (n, t) => shapedGlobalTypes.set(n, t) }
675
+ : undefined;
676
+
677
+ /** A bare `gSym` naming a map-declared POINTER global — the VALUE of a pointer cell. Load,
678
+ * store and compare of that 4-byte cell are identical for any object-pointer type, so the
679
+ * declared pointee never matters to THEM; arithmetic on the loaded value is the opposite case,
680
+ * where the pointee's size scales what is added and every stride must therefore be made
681
+ * explicit (`(u8 *)gPtr + K`). `ctype` cannot see any of this: it types only params/locals, so
682
+ * a pointer global renders `undefined` there. */
683
+ const isPtrGlobal = (x: Expr): boolean => x.k === 'var' && symCtx?.info(x.name)?.shape === 'pointer';
684
+
685
+ /** Operands `-`/`~` cannot take as spelled: a rendered pointer, a bare `&gSym`, a pointer
686
+ * global's value. All three are ill-formed C under a unary arithmetic operator — the asm did
687
+ * 32-bit integer math on the address, so that is what gets spelled. */
688
+ const needsIntSpelling = (x: Expr): boolean => ctype(x)?.kind === 'ptr' || x.k === 'addr' || isPtrGlobal(x);
689
+
375
690
  // --- loop discovery (loops.ts): natural loops via dominator back-edges + the nesting forest ---
376
691
  const forest = analyzeLoops(fn, dom);
377
692
 
@@ -786,11 +1101,20 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
786
1101
  }
787
1102
  }
788
1103
 
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: '?' };
1104
+ // An unresolvable value: strict mode keeps the `"?"` sentinel AND records the reason — the
1105
+ // decline thrown below names the actual gaps ("unmodelled instruction 'adde'"), the same
1106
+ // reasons annotate mode's markers carry, instead of the anonymous `?` that assertResolved
1107
+ // would report at the boundary (assertResolved stays as the backstop for any other producer).
1108
+ // Annotate mode emits a marker (the undefined ASMLIFT_ERROR symbol loud in the ARTIFACT,
1109
+ // function still complete).
1110
+ const strictGaps: string[] = [];
1111
+ const mkGap = (reason: string, args: Expr[]): Expr => {
1112
+ if (onGap === 'annotate') {
1113
+ return { k: 'marker', reason, args };
1114
+ }
1115
+ strictGaps.push(reason);
1116
+ return { k: 'var', name: '?' };
1117
+ };
794
1118
 
795
1119
  // Lower ONE def's operation to an Expr, rendering operands through `e`. Shared between the
796
1120
  // inline-at-use path (exprWith) and the materialized-temp path (sideEffects), so both spell a
@@ -800,7 +1124,30 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
800
1124
  return { k: 'const', value: d.attrs.value as number };
801
1125
  }
802
1126
  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]) };
1127
+ // A bare global address `&gSym` as a COMPARISON operand is the same unspelled escape as the
1128
+ // arithmetic case below (see intifyAddr): its C type comes from the PROJECT's own
1129
+ // declaration, unknowable here. Worse, the compare's SIGNEDNESS lives in the operand types
1130
+ // (CMP_TO_BIN maps icmp_ult and icmp_slt to the same '<'), so leaving `&gSym` untyped lets
1131
+ // the project's declaration pick the compare the compiler emits — silently byte-inexact
1132
+ // whenever it disagrees with the asm. The honest spelling is integer math on the address
1133
+ // with the cast AGREEING with the opcode's signedness: unsigned compares (and the
1134
+ // sign-agnostic ==/!=) spell `(u32)&gSym`, signed compares `(s32)&gSym` — exactly the
1135
+ // compare the asm did. The deref folds never see a compare operand, so no named spelling is
1136
+ // lost; a NARROWING cast (`(u8)&gSym`) is not a bare `addr` and keeps its truncation.
1137
+ // SCOPE (adversarial review): this closes the hole for BARE addr operands only. An
1138
+ // addr-carrying arithmetic tree (`(u32)&gSym + 4`, spelled by intifyAddr below) under an
1139
+ // icmp_s* still compares unsigned in C (u32 wins the usual-arithmetic-conversions) — the
1140
+ // same pre-existing wrongness the old ptr-vs-int spelling had, surfacing as a scoring
1141
+ // nonmatch, never a silent regression of a formerly-correct compare. Rare shape; an outer
1142
+ // signed cast on addr-carrying trees is the follow-up if it ever costs a row.
1143
+ const t = /^icmp_s/.test(d.opcode) ? T.s(32) : T.u(32);
1144
+ const intifyAddrCmp = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: t, e: x } : x);
1145
+ return {
1146
+ k: 'bin',
1147
+ op: CMP_TO_BIN[d.opcode],
1148
+ l: intifyAddrCmp(e(d.operands[0])),
1149
+ r: intifyAddrCmp(e(d.operands[1])),
1150
+ };
804
1151
  }
805
1152
  if (ARITH_TO_BIN[d.opcode]) {
806
1153
  let l = e(d.operands[0]);
@@ -844,6 +1191,50 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
844
1191
  } else if (op === '-' && ctype(l)?.kind !== 'ptr' && ctype(r)?.kind === 'ptr') {
845
1192
  r = intify(r); // int - ptr is not C
846
1193
  }
1194
+ // A bare global address `&gSym` under ANY of these operators is never emitted as-is: its C
1195
+ // type comes from the PROJECT's own declaration (unknowable here — exprCType types `addr`
1196
+ // undefined, so the ptr-keyed intify above never fires on it), which makes `&gSym + K`
1197
+ // byte-INEXACT (C scales K by sizeof(gSym)) and `&gSym & K` ill-formed. The honest spelling
1198
+ // is integer math on the address — `(u32)&gSym + K`, exactly the arithmetic the asm did.
1199
+ // The deref folds (globalOf / globalConstByte, via addrIn) look through this cast, so every
1200
+ // access that CAN spell a named element/field still does; only a genuine value-context
1201
+ // escape (a call argument, a stored address, a compare) keeps it — previously such an
1202
+ // escape tripped assertDerefsTyped's interior-pointer rule and declined the whole function.
1203
+ const intifyAddr = (x: Expr): Expr => (x.k === 'addr' ? { k: 'cast', to: T.u(32), e: x } : x);
1204
+ l = intifyAddr(l);
1205
+ r = intifyAddr(r);
1206
+ // The SAME hazard one level down, for a POINTER-shaped global's VALUE (`gPtr`, isPtrGlobal):
1207
+ // C scales `gPtr + K` by sizeof(*gPtr) — 1 under the map's synthesized `void *`, but
1208
+ // whatever the PROJECT's header declares (a 0x5C-byte struct, say) in the world a user
1209
+ // actually recompiles in. The asm added BYTES, so the honest spelling makes the stride
1210
+ // explicit: CAST-THEN-ADD, `(u8 *)gPtr + K`, the same address in EVERY world. Add-then-cast
1211
+ // (`(u8 *)(gPtr + K)`, what the backend's deref legalization would otherwise produce) is
1212
+ // byte-correct in exactly one of them — a silent wrongness, the class this project refuses.
1213
+ // NOT foldable into the deref index either: `((u8 *)gPtr)[K + off]` re-scales K by the
1214
+ // ACCESS width, a different address whenever that width is not 1.
1215
+ // Under the non-additive operators C rejects a pointer outright, so there the honest
1216
+ // spelling is integer math on the cell — exactly intifyAddr's `(u32)&gSym` rule.
1217
+ const bytePtr = (x: Expr): Expr => ({ k: 'cast', to: T.ptr(T.u(8)), e: x });
1218
+ const intifyPtrGlobal = (x: Expr): Expr => ({ k: 'cast', to: T.u(32), e: x });
1219
+ if (op === '+' || op === '-') {
1220
+ // `ptr ± int` and `ptr - ptr` are byte arithmetic once both sides are byte pointers;
1221
+ // `ptr + ptr` and `int - ptr` are not C at all, so the second pointer goes integer.
1222
+ const bothPtr = isPtrGlobal(l) && isPtrGlobal(r);
1223
+ if (isPtrGlobal(l)) {
1224
+ l = bytePtr(l);
1225
+ }
1226
+ if (isPtrGlobal(r)) {
1227
+ r = bothPtr && op === '-' ? bytePtr(r) : op === '+' && !bothPtr ? bytePtr(r) : intifyPtrGlobal(r);
1228
+ }
1229
+ } else if (op !== '&&' && op !== '||') {
1230
+ // (`&&`/`||` take a pointer operand legally — a truth test, no arithmetic.)
1231
+ l = isPtrGlobal(l) ? intifyPtrGlobal(l) : l;
1232
+ r = isPtrGlobal(r) ? intifyPtrGlobal(r) : r;
1233
+ }
1234
+ // SCOPE: this and intifyAddr cover the ARITHMETIC escapes. A pointer global under a
1235
+ // COMPARISON (`gPtr < K` — C compares unsigned whatever the asm's icmp_s* said) is the same
1236
+ // class as intifyAddrCmp's `addr` rule and is deliberately left alone here: it is valid C
1237
+ // today, so closing it would churn spellings for a signedness case no row exercises.
847
1238
  return { k: 'bin', op, l, r };
848
1239
  }
849
1240
  // `-`/`~` on a pointer rendering is equally not C — same honest integer cast as above.
@@ -879,11 +1270,11 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
879
1270
  }
880
1271
  if (d.opcode === 'neg') {
881
1272
  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 };
1273
+ return { k: 'un', op: '-', e: needsIntSpelling(x) ? { k: 'cast', to: T.s(32), e: x } : x };
883
1274
  }
884
1275
  if (d.opcode === 'not') {
885
1276
  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 };
1277
+ return { k: 'un', op: '~', e: needsIntSpelling(x) ? { k: 'cast', to: T.s(32), e: x } : x };
887
1278
  }
888
1279
  // Width-narrowing casts: `zext`/`sext` widen a `width`-bit value back to 32 → C `(u8)e`/`(s8)e`.
889
1280
  if (d.opcode === 'zext') {
@@ -896,6 +1287,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
896
1287
  return { k: 'call', fn: d.attrs.target as string, args: d.operands.map(e) };
897
1288
  }
898
1289
  if (d.opcode === 'gaddr') {
1290
+ // A promoted CODE symbol (frontend `code: true`) is a function pointer stored as an
1291
+ // integer: spelled `(u32)Name` — the source idiom — never `&Name` (defect G of the
1292
+ // dogfood report; the & form compiles but is a different, non-matching spelling).
1293
+ if (d.attrs.code === true) {
1294
+ return { k: 'cast', to: T.int(32, false), e: { k: 'var', name: d.attrs.sym as string } };
1295
+ }
899
1296
  return { k: 'addr', name: d.attrs.sym as string };
900
1297
  }
901
1298
  if (d.opcode === 'load') {
@@ -907,6 +1304,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
907
1304
  (d.attrs.signed as boolean) ?? false,
908
1305
  ctype,
909
1306
  scalarGlobals,
1307
+ symCtx,
910
1308
  );
911
1309
  }
912
1310
  // aload carries a runtime index operand (variable-index array access) — `base[index]`, or
@@ -920,6 +1318,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
920
1318
  d.attrs.elemSize as number,
921
1319
  (d.attrs.signed as boolean) ?? false,
922
1320
  ctype,
1321
+ symCtx,
923
1322
  );
924
1323
  }
925
1324
  return d.opcode === 'opaque'
@@ -1035,6 +1434,8 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1035
1434
  width === 4,
1036
1435
  ctype,
1037
1436
  scalarGlobals,
1437
+ symCtx,
1438
+ true, // an lvalue: a member whose declaration is const cannot be NAMED as the target
1038
1439
  );
1039
1440
  if (lval0.k === 'var') {
1040
1441
  globalNames.add(lval0.name);
@@ -1056,6 +1457,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1056
1457
  elemSize,
1057
1458
  elemSize === 4,
1058
1459
  ctype,
1460
+ symCtx,
1059
1461
  ),
1060
1462
  value: expr(op.operands[2]),
1061
1463
  });
@@ -1471,6 +1873,14 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1471
1873
  };
1472
1874
 
1473
1875
  const body = recognizeForLoops(structureRegion(entry, null));
1876
+ // Strict-mode gaps decline HERE, naming the reasons — the same text annotate's markers
1877
+ // carry, so the two mode surfaces report the same decline (the reproduction scripts run
1878
+ // strict; the benchmark rows store annotate markers — fidelity holds them against each
1879
+ // other). Without this, the `?` sentinels reach assertResolved and decline anonymously.
1880
+ if (strictGaps.length > 0) {
1881
+ const reasons = [...new Set(strictGaps)].join('; ');
1882
+ throw new StructureError(`${strictGaps.length} unresolvable value(s) in '${fn.name}' — ${reasons}`);
1883
+ }
1474
1884
  // v* = coalesced/materialized locals; t* = sequentialize's swap-cycle temps (varType-only —
1475
1885
  // they have no Value, so they are collected from varType, not varName).
1476
1886
  const localNames = [...new Set([...varName.values(), ...[...varType.keys()].filter((n) => /^t\d+$/.test(n))])].filter(
@@ -1481,6 +1891,13 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1481
1891
  name: fn.name,
1482
1892
  params: entry.params.map((p, i) => ({ name: `a${i}`, type: p.type })),
1483
1893
  locals: localNames.map((n) => ({ name: n, type: varType.get(n)! })),
1894
+ ...(shapedGlobalTypes.size
1895
+ ? {
1896
+ globals: [...shapedGlobalTypes]
1897
+ .map(([name, type]) => ({ name, type }))
1898
+ .sort((a, b) => a.name.localeCompare(b.name)),
1899
+ }
1900
+ : {}),
1484
1901
  retType: returnsVoid ? T.void() : returnType(fn),
1485
1902
  body,
1486
1903
  ...(structs.length ? { structs } : {}),