@asmlift/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ // A pass's admission rules as DATA, so "does every sound gate have a test that fails without it?"
2
+ // is a query instead of an audit.
3
+ //
4
+ // Because the table is a value, a test can drop one entry and re-run the pass: the real predicate,
5
+ // on real input, with no test-only branch in the shipped path. That makes `sound` cost something to
6
+ // declare — see `gateTableDefects` and the contract test that pairs with it.
7
+ //
8
+ // `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
9
+ // which has room; duplicating it here is how a table stops paying for itself.
10
+ export interface Gate<Ctx> {
11
+ /** stable, kebab-case; appears in test names and in the contract report */
12
+ readonly id: string;
13
+ /** one line: the reason the rule exists */
14
+ readonly why: string;
15
+ /** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
16
+ * heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
17
+ readonly sound: boolean;
18
+ /** the test that fails when this gate is removed — required for a sound gate */
19
+ readonly guardedBy?: string;
20
+ /** true ⇒ REJECT this candidate */
21
+ readonly rejects: (c: Ctx) => boolean;
22
+ }
23
+
24
+ /** The id of the first gate that rejects `c`, or null when every gate admits it. FIRST, not all:
25
+ * one decisive rule is what makes a refusal attributable, and it keeps the cost the same as the
26
+ * `||` chain this replaces — evaluation still short-circuits. */
27
+ export function firstRejection<Ctx>(gates: readonly Gate<Ctx>[], c: Ctx): string | null {
28
+ for (const g of gates) {
29
+ if (g.rejects(c)) {
30
+ return g.id;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** A gate table with one entry removed — the ablation, as a value. Throws on an unknown id: a
37
+ * typo'd ablation that silently tests nothing is the failure this file exists to prevent. */
38
+ export function without<Ctx>(gates: readonly Gate<Ctx>[], id: string): readonly Gate<Ctx>[] {
39
+ if (!gates.some((g) => g.id === id)) {
40
+ throw new Error(`no gate '${id}' to ablate (have: ${gates.map((g) => g.id).join(', ')})`);
41
+ }
42
+ return gates.filter((g) => g.id !== id);
43
+ }
44
+
45
+ /** Structural defects in a gate table — the part checkable without running the pass. Returns
46
+ * findings rather than throwing, so core stays free of a test-framework import. */
47
+ export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
48
+ const out: string[] = [];
49
+ const seen = new Set<string>();
50
+ for (const g of gates) {
51
+ if (seen.has(g.id)) {
52
+ out.push(`duplicate gate id '${g.id}'`);
53
+ }
54
+ seen.add(g.id);
55
+ if (!/^[a-z][a-z0-9-]*$/.test(g.id)) {
56
+ out.push(`gate id '${g.id}' is not kebab-case`);
57
+ }
58
+ if (g.why.trim().length < 12) {
59
+ out.push(`gate '${g.id}' has no usable \`why\``);
60
+ }
61
+ // the one rule that costs something to declare
62
+ if (g.sound && !g.guardedBy?.trim()) {
63
+ out.push(`gate '${g.id}' is marked sound but names no guard`);
64
+ }
65
+ }
66
+ return out;
67
+ }
@@ -1,17 +1,20 @@
1
1
  // L3 re-spelling lever: hoist a reused global base into a pointer local at the INNERMOST scope
2
2
  // that contains all of its uses.
3
3
  //
4
+ // The lever earns its place: returning `null` from `hoistScopedBases` costs
5
+ // kleod:UpdateHUDCounterDisplay its match, so the benchmark's zero-lost gate guards this file.
6
+ //
4
7
  // `l3/basecse.ts` already hoists a reused leaf base — but always to the FUNCTION TOP, and only for
5
8
  // an `addr`/`const` base. Both limits are load-bearing here, and each costs a real row:
6
9
  //
7
10
  // PLACEMENT. A base used only inside one `if` arm, hoisted to the function top, is live across
8
11
  // everything before that arm — a live range the original never had, which is the register-pressure
9
- // failure basecse's own loop gate exists for. Measured on kleod:UpdateHUDCounterDisplay by
10
- // hand-editing the REFERENCE source: naming the `gBgTilemapBufs` store base inside the arm that
11
- // uses it is byte-exact, and moving that same declaration to the function top costs 24. That is
12
- // the reason the lever is scope-aware; it is NOT a claim about what the lever achieves. On that
13
- // row it now declines outright (a later pass retired the phi it keyed on, so the base's uses span
14
- // the function body), and the cluster fallback below is what recovers it.
12
+ // failure basecse's own loop gate exists for. That argument is why the lever is scope-aware; it is
13
+ // NOT a claim about what the lever achieves, and no committed measurement separates the two
14
+ // placements (the one that did edited a reference source by hand and cannot be re-run). On
15
+ // kleod:UpdateHUDCounterDisplay the primary path declines outright (a later pass retired the phi
16
+ // it keyed on, so the base's uses span the function body), and the cluster fallback below is what
17
+ // recovers it.
15
18
  // basecse's header already names the gap — "a loop-body base is left
16
19
  // inline for a future scope-aware hoist" — and this is that hoist.
17
20
  //
@@ -143,7 +146,8 @@ function collect(
143
146
  // CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
144
147
  // (its `stmtChildren('for')` is `[init, inc, …body]`, recursed with `nested`), this pass counts
145
148
  // it at the enclosing cadence, which is the truthful reading — it runs once. Recorded because
146
- // the divergence is real and an extraction has to pick one.
149
+ // the divergence is real and an extraction has to pick one; both readings are pinned in
150
+ // test/addr-placement.test.ts so the pick is deliberate rather than whichever survives.
147
151
  stmtExprs(s).forEach((e) => visit(e, isLoop));
148
152
  if (s.k === 'for') {
149
153
  // `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
@@ -15,10 +15,14 @@
15
15
  //
16
16
  // if (c) { } else { g[594] = g[659]; } v4 = 1; → if (!c) { g[594] = g[659]; } v4 = 1;
17
17
  //
18
- // Measured on kleod:UpdateHUDCounterDisplay: 60 33 (visible in the committed results.json). The
19
- // placement is not a matter of taste peeling the same statements to ABOVE the `if` instead scores
20
- // 48 but note that above-the-`if` is a THIRD option, unsound for its own reason (it crosses the
21
- // condition as well as both arms); the soundness argument here covers only below-vs-in-arms.
18
+ // THE BENCHMARK DOES NOT GUARD THIS PASS. It fires on two of its 743 rows and both match with the
19
+ // merge and without it, so no score moves if this file breaks and none moves if the pipeline
20
+ // simply stops calling it. `test/tailmerge.test.ts` covers that gap explicitly, end to end, on one
21
+ // of those two functions; the rest of that file calls this pass directly and cannot see an unwiring.
22
+ //
23
+ // Placement is not a matter of taste: peeling the same statements ABOVE the `if` is a THIRD option,
24
+ // unsound for its own reason (it crosses the condition as well as both arms). The soundness argument
25
+ // above covers only below-vs-in-arms.
22
26
  //
23
27
  // KNOWN INTERACTIONS, both byte-level rather than soundness. This pass is unconditional like
24
28
  // `dce.ts` and `basecse.ts` rather than a differ-refereed lever, and the argument those files each
package/src/pipeline.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  // asmlift — the library entry point. `decompile(name, asm, target)` runs the raising tower and
2
2
  // returns structured results: the source, the per-level IR dumps, and diagnostics.
3
3
  import { cBackend } from './backend/c';
4
- import { ContractError, assertDerefsTyped, assertResolved, assertTypesRecovered } from './contracts';
4
+ import {
5
+ ContractError,
6
+ assertDerefsTyped,
7
+ assertEffectsPreserved,
8
+ assertResolved,
9
+ assertTypesRecovered,
10
+ } from './contracts';
5
11
  import type { AsmData } from './frontend/asmdata';
6
12
  import { FrontendUnsupportedError } from './frontend/errors';
7
13
  import { frontendFor } from './frontend/registry';
8
- import type { Fn } from './ir/core';
14
+ import { type Block, type Fn, successorsOf } from './ir/core';
9
15
  import { print } from './ir/print';
10
16
  import { T } from './ir/types';
11
17
  import { VerifyError, verify } from './ir/verify';
12
- import { Expr, LanguageBackend, SFn, Stmt, exprChildren, stmtChildren, stmtExprs } from './l3/ast';
18
+ import { Expr, LanguageBackend, SFn, Stmt, exprChildren, gapReasonFor, stmtChildren, stmtExprs } from './l3/ast';
13
19
  import { hoistReusedGlobalBases } from './l3/basecse';
14
20
  import { eliminateDeadStores } from './l3/dce';
15
21
  import { mergeCommonTails } from './l3/tailmerge';
@@ -189,9 +195,55 @@ export function raiseRecovered(fn: Fn, target: TargetDescription, hooks: RaiseHo
189
195
  }
190
196
  }
191
197
 
198
+ /** Run `body`; if it declines, name the unmodelled instructions the function carries.
199
+ *
200
+ * An `opaque` degrades its own value AND makes its block impure, so a shape recognizer refuses:
201
+ * `headerPure` rejects a header holding one, and the loop declines with "unrecovered back-edge …".
202
+ * True and useless — the shape is fine, an instruction is missing — and the benchmark classifies
203
+ * declines by that text, so the round is filed as a loop-capability gap and the improvement loop
204
+ * builds the wrong thing.
205
+ *
206
+ * Only ADDS attribution: never converts a decline into a success, never fires without an
207
+ * unmodelled instruction, reachable blocks only (one in dead code did not cause the refusal). */
208
+ function attributeOpaques<T>(fn: Fn, body: () => T): T {
209
+ try {
210
+ return body();
211
+ } catch (e) {
212
+ // Attribution is a nicety, so it must not be able to throw: a crash here would replace a
213
+ // DESIGNED loud failure with an incidental one, which contract-invariant.test.ts rejects by name.
214
+ if (!(e instanceof StructureError) || !fn.blocks[0]) {
215
+ throw e;
216
+ }
217
+ const seen = new Set<Block>([fn.blocks[0]]);
218
+ for (const stack = [fn.blocks[0]]; stack.length;) {
219
+ for (const s of successorsOf(stack.pop()!)) {
220
+ if (!seen.has(s)) {
221
+ seen.add(s);
222
+ stack.push(s);
223
+ }
224
+ }
225
+ }
226
+ const names = new Set<string>();
227
+ for (const b of seen) {
228
+ for (const op of b.ops) {
229
+ if (op.opcode === 'opaque') {
230
+ names.add(typeof op.attrs.mnemonic === 'string' ? op.attrs.mnemonic : '?');
231
+ }
232
+ }
233
+ }
234
+ if (!names.size || /unmodelled instruction/.test(e.message)) {
235
+ throw e;
236
+ }
237
+ // Through `gapReasonFor`, so the classifier sees its canonical text — a hand-written variant
238
+ // misses the mnemonic-anchored classes and every attributed decline lands in the generic bucket.
239
+ const list = [...names].sort().map(gapReasonFor).join(', ');
240
+ throw new StructureError(`${e.message} — and the function carries ${list}, which is the more likely cause`);
241
+ }
242
+ }
243
+
192
244
  /** Stage 4 — structure + its boundary contracts, always as a pair. */
193
245
  export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]): SFn {
194
- const raw = structure(fn, opts);
246
+ const raw = attributeOpaques(fn, () => structure(fn, opts));
195
247
  // BOTH boundary contracts run on the pre-DCE tree: the readability pass must never be able to
196
248
  // hide a structuring defect by dropping the dead statement that carries it. assertResolved
197
249
  // catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
@@ -199,6 +251,7 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
199
251
  // removes statements/flips branches over an already-validated tree.
200
252
  assertResolved(raw);
201
253
  assertDerefsTyped(raw);
254
+ assertEffectsPreserved(fn, raw);
202
255
  // Then the readability/quality rewrites: merge a statement common to every arm of an if,
203
256
  // drop dead stores (whose empty-then peephole flips the arm the merge empties), then hoist a
204
257
  // reused aggregate-global
@@ -206,6 +259,9 @@ export function structureChecked(fn: Fn, opts: Parameters<typeof structure>[1]):
206
259
  // local's initializer, so re-validate deref typing on the rewritten tree.
207
260
  const sfn = hoistReusedGlobalBases(eliminateDeadStores(mergeCommonTails(raw)));
208
261
  assertDerefsTyped(sfn);
262
+ // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
263
+ // merges arms or drops statements must not be able to lose or duplicate a call.
264
+ assertEffectsPreserved(fn, sfn);
209
265
  return sfn;
210
266
  }
211
267
 
@@ -103,7 +103,8 @@ export function recognizeDivPow2(fn: Fn): boolean {
103
103
  for (const bias of preds.get(m)!) {
104
104
  // The BIAS arm: sole predecessor is the head, and it does nothing but bias (and possibly
105
105
  // shift). The whole block is DELETED, not hoisted, so anything else in it would be silently
106
- // dropped — a store, a call or a live opaque there would simply stop happening.
106
+ // dropped — a store, a call or an opaque there would simply stop happening (an opaque
107
+ // whether or not its result is read: liveness says nothing about what the instruction did).
107
108
  const bt = term(bias);
108
109
  if (bt.opcode !== 'br' || bt.successors[0]?.block !== m || bias === fn.blocks[0]) {
109
110
  continue;
package/src/raise/gvn.ts CHANGED
@@ -39,6 +39,9 @@
39
39
  // analysis.ts, whose materialize-into-a-local rule covers `const`, `call` and the memory reads, NOT
40
40
  // address ops), so the address is re-spelled at each access exactly as the original source did.
41
41
  // Hoisting therefore does not create the long live range that hoisting a LOADED value would.
42
+ // That is a promise ANOTHER module keeps, so `test/addr-placement.test.ts` holds it to it: let
43
+ // analysis.ts materialize an address op and the entry hoist becomes a function-top local — the one
44
+ // this pass exists to delete, reintroduced one level up.
42
45
  //
43
46
  // SCOPE, deliberately narrow: `code: true` symbols (a promoted function pointer, spelled `(u32)Name`
44
47
  // rather than `&Name`) are numbered separately from data ones, because the attr is part of what the
@@ -47,15 +50,22 @@
47
50
  // THE WIN IS CONTINGENT ON THE SYMBOL MAP, which is worth knowing before relying on it. With a map
48
51
  // supplying an array's rank the accesses render as `gSym[0][i]`, a `var` base that
49
52
  // `l3/basecse.ts`'s `isHoistableBase` cannot see, so nothing re-creates the local this pass
50
- // deleted. WITHOUT a map (verified by running the row map-less) the same accesses spell as
51
- // `addr`, basecse sees the reuse, and it hoists a function-top `p0 = (u16 *)&gBgTilemapBufs` —
52
- // the same local, one level up. Three modules now answer "is this
53
- // address a local?" with independent policies (here: never; basecse: when reused 2+ times;
54
- // l3/scopebase.ts: at the innermost scope), and reconciling them is recorded debt.
53
+ // deleted. WITHOUT one the same accesses spell as `addr`, basecse sees the reuse, and it hoists a
54
+ // function-top `p0 = (u16 *)&gBgTilemapBufs` — the same local, one level up. Both arms are pinned
55
+ // in `test/addr-placement.test.ts`; before that they rested on a run nobody could repeat.
56
+ //
57
+ // FOUR modules now answer "is this address a local?" with independent policies — here: never;
58
+ // basecse: at the function top, when reused 2+ times; l3/scopebase.ts: at the innermost scope
59
+ // holding the uses; l3/argbase.ts: immediately before a call whose arguments share it. Reconciling
60
+ // them is recorded debt, and the same test pins the two places they actively disagree, because a
61
+ // consolidation has to PICK rather than discover them: a `for`'s init (basecse reads it at loop
62
+ // cadence and refuses, scopebase at the enclosing one and hoists) and a global name shadowed by a
63
+ // local (scopebase must refuse — it re-spells the base as `&g` — while argbase may fire, because it
64
+ // keeps the base expression verbatim).
55
65
  import { Block, Fn, Op, Value, mkOp, replaceAllUsesWith } from '../ir/core';
56
66
 
57
67
  /** Ops whose result depends on `attrs` alone — no operands, no memory, no control flow. */
58
- const NUMBERABLE = new Set(['gaddr']);
68
+ const NUMBERABLE = new Set(['gaddr', 'laddr']); // laddr: same argument — operand-free, pure, attr-keyed
59
69
 
60
70
  /** The value-number key: the opcode plus every attribute, in a stable order. */
61
71
  function keyOf(op: Op): string {
@@ -47,8 +47,10 @@ export const PRE_RECOVERY_PASSES: PreRecoveryPass[] = [
47
47
  // Numbering alone is not enough and not safe to ship alone: collapsing the duplicates leaves a
48
48
  // block param whose edges now all carry one value, and the structurer still destroys THAT into
49
49
  // a local (it only reuses a name a carrier already has, and an inlined `gaddr` has none).
50
- // Measured: numbering without the phi cleanup made kleod:UpdateHUDCounterDisplay WORSE. So the
51
- // pair is the atomic unit, expressed as a body rather than a sum of two unrelated counts.
50
+ // Numbering alone costs kleod:UpdateHUDCounterDisplay its match, so the pair is the atomic
51
+ // unit, expressed as a body rather than a sum of two unrelated counts. It is NOT monotone,
52
+ // which is worth knowing before tuning either half: dropping the cleanup IMPROVES
53
+ // kleod:ConfigureEntityBehavior and kleod:CountCollectedGems, neither of them near matching.
52
54
  run: (fn) => {
53
55
  const n = numberPureValues(fn);
54
56
  return n + simplifyTrivialPhis(fn);
@@ -68,10 +68,11 @@ export function sinkReturns(fn: Fn): boolean {
68
68
  // i.e. BEFORE this one, so on `ifand`/`and3` shape (b) is the only one that ever fires.
69
69
  //
70
70
  // The connective ALONE is not enough, and the extra requirement is BOTH arms being real
71
- // blocks (≥2 `br` preds). `return a || b` (synthetic:lor) also ends up as a `cond_br` on a
72
- // `logic_or`, but it is a value-merge: one edge runs from the head STRAIGHT into the merge,
73
- // so the merge has a single `br` pred. Sinking it replaces the merge variable that
74
- // byte-matches measured, it cost that row its match. A two-armed diamond is what
71
+ // blocks (≥2 `br` preds). `return a || b` (synthetic:lor:agbcc) also ends up as a
72
+ // `cond_br` on a `logic_or`, but it is a value-merge: one edge runs from the head STRAIGHT
73
+ // into the merge, so the merge has a single `br` pred. Sinking it replaces the merge
74
+ // variable that byte-matches: dropping the `brPreds.length >= 2` half of this gate costs
75
+ // that row its match. A two-armed diamond is what
75
76
  // distinguishes `if (a && b) return X; return Y;` from every value-merge.
76
77
  //
77
78
  // A simple single-condition select is excluded by both arms of the gate for the same reason:
@@ -319,11 +319,9 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
319
319
  // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
320
  // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
321
  // short circuit.
322
- // HOIST_UNSAFE_OPS, not EFFECTFUL_OPS: a live `opaque` is an instruction asmlift could not
323
- // model, and moving it out of the arm that guards it is the reordering this refuses. Loud
324
- // either way today (a decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under
325
- // `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
326
- // an observed bug.
322
+ // HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
323
+ // out of the arm that guards it is the reordering this refuses. Loud either way today — a
324
+ // decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
327
325
  const body = g.ops.slice(0, -1);
328
326
  if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
329
327
  continue;
@@ -87,7 +87,8 @@ function withPadding(dataFields: StructField[], stride: number): StructField[] {
87
87
  * rematerializes the same element address (several `add(base, i*stride)` ops for one logical
88
88
  * array), and recovering them one-by-one would let the first claim the base and force its
89
89
  * twins to decline — a mixed spelling that is worse than either pure form (found live on
90
- * pokeemerald:GetGender, whose address is materialized twice). A base whose element pointers
90
+ * pokeemerald:GetGenderFromSpeciesAndPersonality, whose address is materialized twice). A base
91
+ * whose element pointers
91
92
  * disagree on stride declines entirely: two strides over one base is a reinterpreted view or
92
93
  * a 2D layout, genuinely ambiguous — decline over guess. */
93
94
  export function recognizeStructArrays(fn: Fn): number {
@@ -178,6 +178,17 @@ export function recognizeStructs(fn: Fn): number {
178
178
  }
179
179
  }
180
180
 
181
+ // Which values are the address of a NAMED global (`gaddr`)? Consulted only when synthesis
182
+ // DECLINES: see the catch below.
183
+ const namedGlobal = new Set<Value>();
184
+ for (const b of fn.blocks) {
185
+ for (const op of b.ops as Op[]) {
186
+ if (op.opcode === 'gaddr') {
187
+ namedGlobal.add(op.results[0]);
188
+ }
189
+ }
190
+ }
191
+
181
192
  let count = 0;
182
193
  for (const base of order) {
183
194
  if (arrayBases.has(base)) {
@@ -186,11 +197,28 @@ export function recognizeStructs(fn: Fn): number {
186
197
  if (base.type.kind !== 'unknown') {
187
198
  continue;
188
199
  } // already typed (not a bare recovery target)
200
+
189
201
  const accesses = accessesOf.get(base)!;
190
202
  if (isArray(accesses)) {
191
203
  continue;
192
204
  } // uniform stride / single aligned access → array
193
- base.type = T.ptr(buildStruct(`Struct${count}`, accesses));
205
+ try {
206
+ base.type = T.ptr(buildStruct(`Struct${count}`, accesses));
207
+ } catch (e) {
208
+ // A NAMED global whose accesses synthesis cannot reconcile is not a reason to decline the
209
+ // function: its declaration belongs to the project's own headers, and its constant-offset
210
+ // accesses render at L3 through the symbol context (member spelling when the map knows the
211
+ // layout, the honest cast spelling when it does not). The inhabitant is agbcc FUSING two
212
+ // adjacent u8 compares into one ldrh — `s.level == 8 && s.world == 6` reads offset 12 at
213
+ // widths 1 AND 2, which is not a union, just two spellings of declared bytes. An ANONYMOUS
214
+ // base (a loaded pointer, a parameter) has no other source of truth, so for it the decline
215
+ // stands exactly as before — this catch narrows nothing for the shapes that already worked,
216
+ // because a base synthesis succeeds on takes the same path it always took.
217
+ if (e instanceof RaiseUnsupportedError && namedGlobal.has(base)) {
218
+ continue;
219
+ }
220
+ throw e;
221
+ }
194
222
  count++;
195
223
  }
196
224
  return count;
package/src/rank.ts CHANGED
@@ -12,6 +12,7 @@ import { cBackend } from './backend/c';
12
12
  import { assertDerefsTyped, assertResolved } from './contracts';
13
13
  import type { AsmData } from './frontend/asmdata';
14
14
  import { frontendFor } from './frontend/registry';
15
+ import { globalCellOf } from './ir/alias';
15
16
  import { Fn, type Value, defOpMap } from './ir/core';
16
17
  import { T } from './ir/types';
17
18
  import { verify } from './ir/verify';
@@ -223,7 +224,7 @@ export function enumerateCandidates(
223
224
  [...opts.symbols.values()].some((infos) =>
224
225
  infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
225
226
  );
226
- const senseCands = mapHasBitfields
227
+ const bitfieldCands = mapHasBitfields
227
228
  ? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
228
229
  : baseSense;
229
230
  // Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
@@ -239,6 +240,28 @@ export function enumerateCandidates(
239
240
  // Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
240
241
  // from the probe: widths/offsets are lift-time facts, identical across every candidate.
241
242
  const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
243
+ // `/reread-globals` — the VALUE-HOME axis (structure/analysis.ts AnalyzeOptions). Whether the
244
+ // source read a global once into a variable or re-read it at each use is not derivable from asm:
245
+ // the compiler CSEs the second spelling back into one load, and the round-5 dogfood watched agbcc
246
+ // land on both sides inside a single function (its highest-cost defect, 25 of 27 points on one
247
+ // klonoa function and 35/50 both ways on another). So both spellings are emitted and the differ
248
+ // referees — the same footing as signedness and branch sense, and never a default: the cached
249
+ // spelling stays the primary, so this can only ever ADD a winner.
250
+ //
251
+ // Gated on the function having a load that resolves to a named global at all — the only thing the
252
+ // axis can change. The dedup below collapses the pair wherever it changed nothing.
253
+ const probeDefs = defOpMap(probe);
254
+ const readsANamedGlobal = probe.blocks.some((b) =>
255
+ b.ops.some(
256
+ (op) => op.opcode === 'load' && globalCellOf(probeDefs, op.operands[0], op.attrs.off as number) !== null,
257
+ ),
258
+ );
259
+ const senseCands = readsANamedGlobal
260
+ ? [
261
+ ...bitfieldCands.map((s) => ({ ...s, reread: false })),
262
+ ...bitfieldCands.map((s) => ({ ...s, suffix: `${s.suffix}/reread-globals`, reread: true })),
263
+ ]
264
+ : bitfieldCands.map((s) => ({ ...s, reread: false }));
242
265
 
243
266
  const seen = new Set<string>();
244
267
  const out: Candidate[] = [];
@@ -273,9 +296,10 @@ export function enumerateCandidates(
273
296
  preserveDivergentBranchSense: s.sense,
274
297
  anchorConstCopies: s.anchor,
275
298
  spellBitfieldMembers: s.bitfields,
299
+ rereadGlobals: s.reread,
276
300
  });
277
301
  } catch (e) {
278
- if (!s.anchor && s.bitfields) {
302
+ if (!s.anchor && s.bitfields && !s.reread) {
279
303
  throw e; // the base axes keep their behavior: a structuring failure aborts the row
280
304
  }
281
305
  // an anchored variant that fails structuring or its contracts is a dropped lever, never