@asmlift/core 0.3.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.
@@ -38,7 +38,7 @@
38
38
  // header also entered by a plain br).
39
39
  import { Block, Fn, Op, Value, defOpMap, successorsOf } from '../ir/core';
40
40
  import { type IrType, T, scalarTypeForAccess, typeEquals } from '../ir/types';
41
- import { BinOp, Expr, SFn, Stmt, SwitchCase, exprChildren, mapExprChildren } from '../l3/ast';
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';
@@ -46,9 +46,13 @@ import {
46
46
  type DeclaredField,
47
47
  type SymbolInfo,
48
48
  type SymbolStructField,
49
+ arrayInnerExtents,
49
50
  declaredFields,
50
51
  isArrayField,
52
+ isBitfieldField,
53
+ isScalarCellSize,
51
54
  pointeeFields,
55
+ scalarCellType,
52
56
  } from '../symbols';
53
57
  import { analyze } from './analysis';
54
58
  import { makeLoopHazards, updateWriteSet } from './hazards';
@@ -108,6 +112,32 @@ function globalOf(e: Expr, width: number): { name: string; idx: Expr } | null {
108
112
  return null;
109
113
  }
110
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
+
111
141
  // A BYTE residual read as an ELEMENT index of `elemSize`-wide elements, or null when it is not one
112
142
  // — the residual then addresses mid-element and no whole-element spelling can express it, so the
113
143
  // caller falls through to the honest cast forms. THE one copy of the rule, indexing the
@@ -305,8 +335,10 @@ function pointeeAccess(
305
335
  // Constant offset: the member must match EXACTLY — offset, read width, and the SPELLED type
306
336
  // (spellsAccessType). An ARRAY member is excluded whatever its size: `u8 x[1]` would match a
307
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.
308
340
  const p = spellablePointee(pg.name, sym);
309
- const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m));
341
+ const f = p?.fields.find((m) => m.offset === total && m.size === width && !isArrayField(m) && !isBitfieldField(m));
310
342
  return p && f && spellsAccessType(f.signed, width, signed) && memberQualsAllow(f, p.const, isStore)
311
343
  ? { k: 'field', base: { k: 'var', name: pg.name }, name: f.name }
312
344
  : null;
@@ -363,8 +395,12 @@ function memAccess(
363
395
  // declaration on: a layout it declines whole is a layout with no nameable members, and a
364
396
  // union alias it drops for the first view at that offset is a name no declaration carries.
365
397
  // 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));
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
+ );
368
404
  if (fld && memberQualsAllow(fld, si.const, isStore)) {
369
405
  return { k: 'field', base: { k: 'var', name: gb.name }, name: fld.name, dot: true };
370
406
  }
@@ -402,9 +438,10 @@ function memAccess(
402
438
  // dogfood proved agbcc needs for ROM tables — with the element type registered in the env
403
439
  // so the stride check passes and no cast is added. Element-width match only.
404
440
  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 };
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 };
408
445
  }
409
446
  return { k: 'index', base: { k: 'addr', name: g.name }, idx, width, signed };
410
447
  }
@@ -447,9 +484,10 @@ function arrayAccess(
447
484
  if (baseExpr.k === 'addr' && fieldOff === undefined) {
448
485
  // ARRAY-declared global (symbol map): the bare-name spelling, same rule as memAccess.
449
486
  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 };
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 };
453
491
  }
454
492
  return { k: 'index', base: baseExpr, idx: idxExpr, width: elemSize, signed };
455
493
  }
@@ -520,12 +558,11 @@ const ARITH_TO_BIN: Record<string, BinOp> = {
520
558
  and: '&',
521
559
  xor: '^',
522
560
  shl: '<<',
523
- shr_u: '>>',
561
+ shr_u: '>>>', // the LOGICAL right shift; the C backend spells it `>>` over an unsigned operand
524
562
  shr_s: '>>',
525
563
  logic_and: '&&',
526
564
  logic_or: '||', // short-circuit connectives (raise/shortcircuit.ts)
527
565
  };
528
- const NEGATE: Record<string, BinOp> = { '<': '>=', '>=': '<', '>': '<=', '<=': '>', '==': '!=', '!=': '==' };
529
566
 
530
567
  // Recovered info for a self-loop header: its exit block and the per-parameter back-edge
531
568
  // arg it feeds (the value on the header→header edge). The back-edge arg is the "next"
@@ -577,6 +614,21 @@ export interface StructureOptions {
577
614
  // body). GCC freely uses `!=`; IDO prefers `==`/`<`. A per-compiler DATA lever, not an `arch ==`
578
615
  // branch — default true (permissive; the decline path keeps it sound either way).
579
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;
580
632
  // How an unresolvable VALUE degrades (a live `opaque`, an unlowered transient op, a dropped def):
581
633
  // "strict" (default) — the `"?"` sentinel, tripping assertResolved at the boundary (loud in
582
634
  // the PROCESS);
@@ -597,6 +649,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
597
649
  preserveDivergentBranchSense = true,
598
650
  orderArgCopiesByComputation = true,
599
651
  switchAllowsNeqCase = true,
652
+ anchorConstCopies = false,
653
+ littleEndian = true,
654
+ spellBitfieldMembers = true,
600
655
  onGap = 'strict',
601
656
  symbols,
602
657
  } = opts;
@@ -606,7 +661,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
606
661
  const dom = dominators(fn);
607
662
 
608
663
  // ── analysis phase (structure/analysis.ts): use registry, liveness, materialization ──
609
- 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
+ );
610
668
 
611
669
  // SCALAR-vs-AGGREGATE globals: a `gaddr` symbol accessed EXCLUSIVELY at offset 0 is a scalar
612
670
  // global → the bare name `gSym` (byte-exact, matches the source). A symbol accessed at any
@@ -895,6 +953,50 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
895
953
  // The C static type of a rendered expression, over the declared variable types — what decides
896
954
  // whether a memory access's base may be dereferenced as spelled (memAccess/arrayAccess).
897
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
+
898
1000
  let fresh = 0;
899
1001
  // Materialized defs are named FIRST: the temp is the register the compiler held the
900
1002
  // value in, so downstream coalescing (loop inits, merge params) may adopt it — subject to the
@@ -1101,6 +1203,256 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1101
1203
  }
1102
1204
  }
1103
1205
 
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
+
1104
1456
  // An unresolvable value: strict mode keeps the `"?"` sentinel AND records the reason — the
1105
1457
  // decline thrown below names the actual gaps ("unmodelled instruction 'adde'"), the same
1106
1458
  // reasons annotate mode's markers carry, instead of the anonymous `?` that assertResolved
@@ -1123,6 +1475,12 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1123
1475
  if (d.opcode === 'const') {
1124
1476
  return { k: 'const', value: d.attrs.value as number };
1125
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
+ }
1126
1484
  if (CMP_TO_BIN[d.opcode]) {
1127
1485
  // A bare global address `&gSym` as a COMPARISON operand is the same unspelled escape as the
1128
1486
  // arithmetic case below (see intifyAddr): its C type comes from the PROJECT's own
@@ -1231,6 +1589,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1231
1589
  l = isPtrGlobal(l) ? intifyPtrGlobal(l) : l;
1232
1590
  r = isPtrGlobal(r) ? intifyPtrGlobal(r) : r;
1233
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.)
1234
1595
  // SCOPE: this and intifyAddr cover the ARITHMETIC escapes. A pointer global under a
1235
1596
  // COMPARISON (`gPtr < K` — C compares unsigned whatever the asm's icmp_s* said) is the same
1236
1597
  // class as intifyAddrCmp's `addr` rule and is deliberately left alone here: it is valid C
@@ -1242,8 +1603,9 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1242
1603
  // The C rotate idiom — `x >> n | x << (32 - n)` (mirrored for rotl). Byte-exact round-trip
1243
1604
  // on agbcc (thumb ror) and mwcc (rotlw/rotlwi), verified against both toolchains before the
1244
1605
  // ops landed. `x` and `n` render twice — both pure by construction (SSA values; the rotate's
1245
- // operands are register reads), and recovery seeds the rotated value unsigned so `>>`
1246
- // 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.
1247
1609
  //
1248
1610
  // (The PPC mirror fold — `rotl(x, 32 - m)` ⇒ rotr(x, m) — lives in the PATTERN layer,
1249
1611
  // engine.ts ROTL_MIRROR: it is a compiler-spelling idiom, mwcc-gated there, not a
@@ -1260,7 +1622,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1260
1622
  n.k === 'const'
1261
1623
  ? { k: 'const', value: 32 - n.value }
1262
1624
  : { k: 'bin', op: '-', l: { k: 'const', value: 32 }, r: n };
1263
- const [near, far] = dir === 'rotr' ? (['>>', '<<'] as const) : (['<<', '>>'] as const);
1625
+ const [near, far] = dir === 'rotr' ? (['>>>', '<<'] as const) : (['<<', '>>>'] as const);
1264
1626
  return {
1265
1627
  k: 'bin',
1266
1628
  op: '|',
@@ -1382,13 +1744,17 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1382
1744
  const target = succ.block;
1383
1745
  const argExpr = sub ? exprWith(sub) : expr;
1384
1746
  const copies: { name: string; value: Expr; arg: Value }[] = [];
1747
+ const suppressed = suppressedArgs.get(succ);
1385
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
1386
1752
  const name = varName.get(p)!;
1387
1753
  const arg = succ.args[i];
1388
1754
  if ((sub?.get(arg) ?? varName.get(arg)) === name) {
1389
1755
  return;
1390
1756
  } // identity copy — coalesced away
1391
- copies.push({ name, value: argExpr(arg), arg });
1757
+ copies.push({ name, value: castAggregateAddr(name, argExpr(arg)), arg });
1392
1758
  });
1393
1759
  // Emit in the order the args are COMPUTED in `pred` — a compiler that lays the defining ops
1394
1760
  // (and thus the copies that read them) out in that order matches with no spurious arg-swap.
@@ -1463,8 +1829,15 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1463
1829
  });
1464
1830
  } else if (op.opcode === 'call' && op.results.length && !useSitesOf.has(op.results[0])) {
1465
1831
  out.push({ k: 'exprstmt', value: expr(op.results[0]) });
1466
- } else if (materialize.has(op)) {
1467
- 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) });
1468
1841
  }
1469
1842
  }
1470
1843
  return out;
@@ -1524,6 +1897,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1524
1897
  isNamed: (v) => varName.has(v),
1525
1898
  isCmpOpcode: (opcode) => !!CMP_TO_BIN[opcode],
1526
1899
  switchAllowsNeqCase,
1900
+ emitsAnchoredWrite: (blk) => blk.ops.some((o) => anchoredAt.has(o)),
1527
1901
  expr: (v) => expr(v),
1528
1902
  structureRegion: (b, stop) => structureRegion(b, stop),
1529
1903
  });
@@ -1725,7 +2099,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1725
2099
  out.push(...updateCopies); // the loop update, RAW (i++, p>>=1, …)
1726
2100
  let leaveCond = exprWith(sub)(term.operands[0]);
1727
2101
  if (contIsTaken) {
1728
- leaveCond = negate(leaveCond);
2102
+ leaveCond = negateCond(leaveCond);
1729
2103
  } // continue is `taken` → leave when NOT it
1730
2104
  const exitArm = isBreak
1731
2105
  ? [...argAssigns(b, loopCtx.exit, sub), { k: 'break' } as Stmt] // break to the loop exit
@@ -1759,7 +2133,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1759
2133
  // IDO/MIPS; agbcc/GCC canonicalise either way, so it is safe there too. A compiler that
1760
2134
  // inverts branch canonicalization sets preserveDivergentBranchSense false and falls through
1761
2135
  // to the positive form below.
1762
- out.push({ k: 'if', cond: negate(cond), then: elseS, else: thenS });
2136
+ out.push({ k: 'if', cond: negateCond(cond), then: elseS, else: thenS });
1763
2137
  return out;
1764
2138
  }
1765
2139
  out.push(mkIf(cond, thenS, elseS));
@@ -1788,7 +2162,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1788
2162
  const term = li.header.ops[li.header.ops.length - 1];
1789
2163
  let cond = exprWith(loopSub(li))(term.operands[0]);
1790
2164
  if (term.successors[0].block !== li.header) {
1791
- cond = negate(cond);
2165
+ cond = negateCond(cond);
1792
2166
  } // loop-continue must be `taken`
1793
2167
  const body = [...sideEffects(li.header), ...(updates ?? argAssigns(li.header, li.header))];
1794
2168
  return { k: 'while', cond, body };
@@ -1802,7 +2176,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1802
2176
  const term = wl.header.ops[wl.header.ops.length - 1];
1803
2177
  let cond = expr(term.operands[0]);
1804
2178
  if (term.successors[1].block === wl.bodyEntry) {
1805
- cond = negate(cond);
2179
+ cond = negateCond(cond);
1806
2180
  }
1807
2181
  // The header→bodyEntry edge may carry non-identity phi args (a value the header COMPUTED and passes
1808
2182
  // into the body). Those copies must open the body — dropping them reads an uninitialised local.
@@ -1864,7 +2238,7 @@ export function structure(fn: Fn, opts: StructureOptions = {}): SFn {
1864
2238
  const body = [...inner, ...sideEffects(dw.latch), ...updates];
1865
2239
  let cond = exprWith(sub)(lterm.operands[0]);
1866
2240
  if (lterm.successors[1].block === dw.header) {
1867
- cond = negate(cond);
2241
+ cond = negateCond(cond);
1868
2242
  } // continue edge must be `taken`
1869
2243
  const out: Stmt[] = [{ k: 'dowhile', cond, body }];
1870
2244
  // The exit region reads latch back-edge values under `sub` (post-loop they live in the loop vars).
@@ -2047,16 +2421,10 @@ function substVar(e: Expr, from: string, to: string): Expr {
2047
2421
  // empty-then peephole: `if (c) {} else { S }` → `if (!c) { S }`
2048
2422
  function mkIf(cond: Expr, thenS: Stmt[], elseS: Stmt[]): Stmt {
2049
2423
  if (thenS.length === 0 && elseS.length > 0) {
2050
- return { k: 'if', cond: negate(cond), then: elseS, else: [] };
2424
+ return { k: 'if', cond: negateCond(cond), then: elseS, else: [] };
2051
2425
  }
2052
2426
  return { k: 'if', cond, then: thenS, else: elseS };
2053
2427
  }
2054
- function negate(e: Expr): Expr {
2055
- if (e.k === 'bin' && NEGATE[e.op]) {
2056
- return { ...e, op: NEGATE[e.op] };
2057
- }
2058
- return { k: 'un', op: '!', e };
2059
- }
2060
2428
 
2061
2429
  // --- CFG utilities ---
2062
2430
  function predecessorBlocks(fn: Fn): Map<Block, Block[]> {