@asmlift/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
package/src/rank.ts CHANGED
@@ -12,12 +12,16 @@ 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';
19
+ import { materializeArgBases } from './l3/argbase';
18
20
  import type { LanguageBackend, SFn } from './l3/ast';
21
+ import { coalesceCandidates } from './l3/coalesce';
19
22
  import { registerishSpellings } from './l3/regspell';
20
23
  import { reindexWalks } from './l3/reindex';
24
+ import { hoistScopedBases } from './l3/scopebase';
21
25
  import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
22
26
  import { RewritePattern } from './pattern/engine';
23
27
  import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
@@ -117,12 +121,30 @@ export interface EnumerateOptions {
117
121
  asmData?: AsmData;
118
122
  /** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
119
123
  symbols?: SymbolMap;
124
+ /** Called when a re-spelling lever THROWS or fails a boundary contract, so the failure is visible
125
+ * instead of the candidate silently not existing. Enumeration continues either way — the primary
126
+ * spelling is unaffected — but a lever that never fires because it always throws is a defect, and
127
+ * without this it looks identical to a lever that correctly declined. */
128
+ onLeverError?: (label: string, error: string) => void;
120
129
  }
121
130
 
122
- /** One distinct candidate spelling (a signedness × branch-sense lever combination), emitted to source. */
131
+ /** One distinct candidate spelling a point in the axis cross (signedness × branch sense ×
132
+ * def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
133
+ * emitted to source. */
123
134
  export interface Candidate {
124
135
  label: string;
125
136
  source: string;
137
+ /** Which PREFERENCE GROUP this spelling belongs to — the symbol-variant index (0 = the map's own
138
+ * named spellings, 1 = their `/raw-globals` siblings). Enumeration emits the groups in
139
+ * preference order, and a lower group WINS a score tie: when both compile to the same bytes the
140
+ * reader should get `gCounter.field`, not a byte offset off a hoisted `(u8 *)` base.
141
+ *
142
+ * Carried structurally rather than left to enumeration order because the readability tie-break
143
+ * (compareScored) must compare only spellings that are genuinely alternatives of the same
144
+ * thing. Ranking a named spelling against a raw-address one on cast count is not a readability
145
+ * comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
146
+ * construction, trading named struct fields for anonymous byte offsets. */
147
+ group: number;
126
148
  /** the map-derived VALUE references this candidate's tree contains — what the scoring
127
149
  * layer's declaration synthesis renders. DERIVED, never carried: computed once from the
128
150
  * exact tree this candidate's source was emitted from, at the moment the candidate is
@@ -179,10 +201,32 @@ export function enumerateCandidates(
179
201
  // and let the differ referee. The default sense is always among them, so this never scores
180
202
  // worse; it only wins where the flip matches.
181
203
  const defSense = baseOpts.preserveDivergentBranchSense ?? true;
182
- const senseCands = [
183
- { suffix: '', sense: defSense },
184
- { suffix: '/flip-branch', sense: !defSense },
204
+ // `/defsite` def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
205
+ // structuring axis on the same footing as branch sense: where the asm materialized a merge
206
+ // constant is placement evidence, but whether the SOURCE spelled it there is genuinely
207
+ // ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
208
+ // (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
209
+ // below collapses every variant the anchoring left unchanged.
210
+ const baseSense = [
211
+ { suffix: '', sense: defSense, anchor: false, bitfields: true },
212
+ { suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
213
+ { suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
214
+ { suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
185
215
  ];
216
+ // `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
217
+ // The named read recompiles at the DECLARATION's access width; where that diverges from the
218
+ // asm's load width, the shifts are the spelling that matches — so both are emitted and the
219
+ // differ referees. Enumerated only when the map carries any bitfield member at all (checked
220
+ // below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
221
+ // every variant where no fold fired.
222
+ const mapHasBitfields =
223
+ opts.symbols !== undefined &&
224
+ [...opts.symbols.values()].some((infos) =>
225
+ infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
226
+ );
227
+ const bitfieldCands = mapHasBitfields
228
+ ? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
229
+ : baseSense;
186
230
  // Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
187
231
  // so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
188
232
  // compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
@@ -196,6 +240,28 @@ export function enumerateCandidates(
196
240
  // Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
197
241
  // from the probe: widths/offsets are lift-time facts, identical across every candidate.
198
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 }));
199
265
 
200
266
  const seen = new Set<string>();
201
267
  const out: Candidate[] = [];
@@ -211,7 +277,7 @@ export function enumerateCandidates(
211
277
  { suffix: '/raw-globals', symbols: undefined },
212
278
  ]
213
279
  : [{ suffix: '' }];
214
- for (const sv of symbolVariants) {
280
+ for (const [svIndex, sv] of symbolVariants.entries()) {
215
281
  const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
216
282
  for (const cand of SIGN_CANDS) {
217
283
  const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
@@ -223,7 +289,24 @@ export function enumerateCandidates(
223
289
  for (const s of senseCands) {
224
290
  // structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
225
291
  // senses structure the same recovered function without re-lifting.
226
- const sfn = structureChecked(fn, { ...svOpts, preserveDivergentBranchSense: s.sense });
292
+ let sfn: SFn;
293
+ try {
294
+ sfn = structureChecked(fn, {
295
+ ...svOpts,
296
+ preserveDivergentBranchSense: s.sense,
297
+ anchorConstCopies: s.anchor,
298
+ spellBitfieldMembers: s.bitfields,
299
+ rereadGlobals: s.reread,
300
+ });
301
+ } catch (e) {
302
+ if (!s.anchor && s.bitfields && !s.reread) {
303
+ throw e; // the base axes keep their behavior: a structuring failure aborts the row
304
+ }
305
+ // an anchored variant that fails structuring or its contracts is a dropped lever, never
306
+ // an aborted enumeration — same rule as respell below
307
+ opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
308
+ continue;
309
+ }
227
310
  // The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
228
311
  // signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
229
312
  // genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
@@ -266,24 +349,72 @@ export function enumerateCandidates(
266
349
  // to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
267
350
  // defect class this project exists to avoid. Hence each lever's decline-over-approximate
268
351
  // gates, adversarially audited.
269
- const respell = (suffix: string, alt: SFn): void => {
352
+ // Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
353
+ // from the pass itself — rather than from the contracts or the backend — would escape and
354
+ // abort the whole enumeration for this row, primary included: the one way a lever can cost
355
+ // a match. Making that structural rather than per-call-site means no lever can opt out.
356
+ const respell = (suffix: string, make: () => SFn | null | undefined): void => {
270
357
  try {
358
+ const alt = make();
359
+ if (!alt) {
360
+ return; // the lever declined to fire — no candidate, not a duplicate of the primary
361
+ }
271
362
  assertResolved(alt);
272
363
  assertDerefsTyped(alt);
273
364
  spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
274
- } catch {
275
- // contract-failing or unspellable re-spelling: drop it, keep the primary
365
+ } catch (e) {
366
+ // A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
367
+ // REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
368
+ // so without this a lever that fails here vanishes with no trace — indistinguishable
369
+ // from one that correctly declined, which is exactly the hidden failure
370
+ // DroppedCandidate exists to surface.
371
+ opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
276
372
  }
277
373
  };
278
- const indexed = reindexWalks(sfn);
279
- if (indexed) {
280
- respell('/indexed', indexed);
281
- }
374
+ // `/argbase` name a call's argument bases before the call (l3/argbase.ts). A lever on the
375
+ // same footing as the others: the primary inline spelling stays in the list, so the differ
376
+ // referees and this can never cost a match.
377
+ respell('/argbase', () => materializeArgBases(sfn));
378
+ // `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
379
+ // (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
380
+ // carries: this one fires exactly where that placement would extend a live range the
381
+ // original never had.
382
+ // `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
383
+ // derivable from the tree — on the row this was built for the two legal merges score 18 and
384
+ // 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
385
+ // discards the winner. Every variant is emitted and the differ referees, exactly as
386
+ // `/regcopy` does for its allocator-ambiguous tail choice.
387
+ //
388
+ // POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
389
+ // levers do not compose. These are not a second lever composed onto the first: coalescing is
390
+ // enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
391
+ // just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
392
+ //
393
+ // EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
394
+ // runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
395
+ // the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
396
+ respell('/scopebase', () => hoistScopedBases(sfn));
397
+ const enumerate = (label: string, from: () => SFn | null | undefined): void => {
398
+ let variants: { merged: string; sfn: SFn }[] = [];
399
+ try {
400
+ const base = from();
401
+ variants = base ? coalesceCandidates(base) : [];
402
+ } catch (e) {
403
+ opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
404
+ return;
405
+ }
406
+ for (const c of variants) {
407
+ respell(`${label}-${c.merged}`, () => c.sfn);
408
+ }
409
+ };
410
+ enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
411
+ enumerate('/coalesce', () => sfn);
412
+ respell('/indexed', () => reindexWalks(sfn));
282
413
  // the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
283
414
  // the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
284
415
  // ambiguous, so both are ranked)
285
416
  const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
286
- registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
417
+ registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
287
418
  for (const sp of spellings) {
288
419
  const source = sp.source;
289
420
  // Collapse a spelling that produced identical source (a function with no divergent `if`
@@ -298,6 +429,7 @@ export function enumerateCandidates(
298
429
  out.push({
299
430
  label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
300
431
  source,
432
+ group: svIndex,
301
433
  ...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
302
434
  });
303
435
  }
@@ -332,15 +464,59 @@ export function rankBy<S extends { score: number }>(
332
464
  if (results.length === 0) {
333
465
  throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
334
466
  }
335
- // Score first; ENUMERATION ORDER breaks a tie. That order is meaningful, not incidental:
336
- // enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
337
- // when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
338
- // than a bare address. Spelled as an explicit comparator because relying on Array#sort's
339
- // stability would make the preference an accident of two unrelated decisions.
340
- results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
467
+ results.sort(compareScored);
341
468
  return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
342
469
  }
343
470
 
471
+ /** THE candidate ordering — score, then preference group, then readability, then enumeration
472
+ * order. Exported because there are TWO drivers over the same enumeration (this module's sync
473
+ * `rankBy` for the Node/objdiff scorer, and the webapp's async await-loop for the wasm one), and
474
+ * a per-driver copy would let the same input produce two different winners.
475
+ *
476
+ * SCORE dominates absolutely: the differ is the fitness function, and a tie means the axis that
477
+ * separates these two spellings did not change the bytes — so everything below only chooses what
478
+ * the READER sees, and can never cost a match.
479
+ *
480
+ * GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
481
+ *
482
+ * CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
483
+ * the C backend has to cast a shift operand back to the signedness the machine op needs, so
484
+ * pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
485
+ * for the same bytes as `s32 f(s32 a0) { return a0 >> a1; }`. Before the backend synthesized that
486
+ * cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
487
+ * silently install the noisier spelling.
488
+ *
489
+ * ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
490
+ * result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
491
+ * would make each preference an accident of two unrelated decisions. */
492
+ export function compareScored<S extends { score: number }>(
493
+ a: Candidate & { score: S; order: number },
494
+ b: Candidate & { score: S; order: number },
495
+ ): number {
496
+ return (
497
+ a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
498
+ );
499
+ }
500
+
501
+ /** Scalar casts in a candidate's rendered source — the readability tie-break above.
502
+ *
503
+ * A TEXT count over the emitted string, matching how the benchmark's own readability metric
504
+ * measures the same thing (apps/benchmark/src/eval/quality.ts) — the two must agree about what
505
+ * "cast noise" means, or ranking optimizes for something the report then scores differently.
506
+ *
507
+ * It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
508
+ * — those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
509
+ * ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
510
+ * arithmetic on a link-time address, which decomp projects write themselves. Counting it would
511
+ * penalize precisely the named spelling this ranking is supposed to prefer.
512
+ *
513
+ * Deterministic, and total on any string. */
514
+ function castCount(source: string): number {
515
+ const all = source.match(/\((?:u|s)(?:8|16|32)\)/g)?.length ?? 0;
516
+ const addr = source.match(/\((?:u|s)32\)\s*&/g)?.length ?? 0;
517
+ return all - addr;
518
+ }
519
+
344
520
  /** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
345
521
  function firstLine(e: unknown): string {
346
522
  return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
@@ -5,6 +5,7 @@
5
5
  // interference check in structure.ts;
6
6
  // • the effect-ordering model — which call/load defs must MATERIALIZE as named temps at
7
7
  // their own program position instead of inlining at their use.
8
+ import { globalCellOf, mayWriteGlobal } from '../ir/alias';
8
9
  import { Block, Fn, Op, Value, successorsOf } from '../ir/core';
9
10
 
10
11
  export interface UseSite {
@@ -24,9 +25,42 @@ export interface StructureAnalysis {
24
25
  materialize: Set<Op>;
25
26
  /** cached forward reachability (successors-transitive, excluding the start block itself) */
26
27
  reachFrom: (b: Block) => Set<Block>;
28
+ /** where a value's expression ultimately renders — the anchored consumer it inlines into,
29
+ * transitively; null = several places / unresolvable (callers treat conservatively) */
30
+ emitPos: (op: Op) => { blk: Block; idx: number } | null;
31
+ /** may an op `isWrite` accepts execute between `def` and a statement at `render`, on any
32
+ * def-avoiding path — the fold-ordering gate (see the closure's comment) */
33
+ memWriteBetween: (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean) => boolean;
27
34
  }
28
35
 
29
- export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
36
+ export interface AnalyzeOptions {
37
+ /** the fn's def map (`defOpMap`) — the structurer already holds one, so it is passed rather than
38
+ * rebuilt. Absent ⇒ the global-aware alias rule below cannot resolve anything and every write
39
+ * bars, exactly as before it existed. */
40
+ defs?: Map<Value, Op>;
41
+ /** THE value-home axis (rank.ts `/reread-globals`). A read of a named global is barred from
42
+ * rendering at its use by any write in between — even a store to an unrelated global, which
43
+ * cannot possibly change what it sees. That over-conservatism is what invents the locals the
44
+ * round-5 dogfood measured as its highest-cost defect ("hoists what agbcc re-reads"):
45
+ *
46
+ * gA = v; gB = v; with `s32 v = gValue;` where the source said `gA = gValue; gB = gValue;`
47
+ *
48
+ * With this on, the barrier scan for a load whose address resolves to a named global uses THE
49
+ * shared disjointness query (ir/alias.ts) instead of "any write at all". Materializing is always
50
+ * sound, so today's spelling is never wrong — only sometimes not the one the compiler was given.
51
+ * Which side matches is genuinely per-function (the same dogfood watched agbcc go both ways
52
+ * inside ONE function), so this is a differ-refereed candidate axis, never a default. */
53
+ rereadGlobals?: boolean;
54
+ /** "does the project declare this global volatile?" — a read of a volatile object may NOT be
55
+ * duplicated or moved, so the axis above refuses on one. Answers false for a symbol the map
56
+ * does not carry (and for no map at all), which is the same posture the multi-render rule has
57
+ * always had: without a declaration nothing here can know, and the differ referees the extra
58
+ * load. Where the map DOES know, the axis is silent about it rather than wrong. */
59
+ volatileGlobal?: (name: string) => boolean;
60
+ }
61
+
62
+ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {}): StructureAnalysis {
63
+ const { defs, rereadGlobals = false, volatileGlobal } = opts;
30
64
  // ── use registry ────────────────────────────────────────────────────────────────────────
31
65
  // Every use of a value, POSITIONED: the consuming op and its block/index. Successor args are
32
66
  // uses AT the terminator (they render in argAssigns at block end). A void function's `ret`
@@ -198,29 +232,116 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
198
232
  // terminator, materialized def) it inlines into, transitively through single-use pure ops.
199
233
  // null = renders in several places / unresolvable (treated conservatively by the caller).
200
234
  const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
235
+ /** an op that renders AT ITS OWN position: a statement, a terminator, a materialized or dead def */
236
+ const anchored = (op: Op): boolean =>
237
+ op.successors.length > 0 ||
238
+ op.opcode === 'ret' ||
239
+ op.opcode === 'store' ||
240
+ op.opcode === 'astore' ||
241
+ materialize.has(op) ||
242
+ !op.results.length ||
243
+ !useSitesOf.has(op.results[0]);
244
+ const consumersOf = (op: Op): Op[] => [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
201
245
  const emitPos = (op: Op): { blk: Block; idx: number } | null => {
202
246
  if (emitPosCache.has(op)) {
203
247
  return emitPosCache.get(op)!;
204
248
  }
205
- const own = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
206
249
  let res: { blk: Block; idx: number } | null;
207
- if (
208
- op.successors.length ||
209
- op.opcode === 'ret' ||
210
- op.opcode === 'store' ||
211
- op.opcode === 'astore' ||
212
- materialize.has(op) ||
213
- !op.results.length ||
214
- !useSitesOf.has(op.results[0])
215
- ) {
216
- res = own; // statements, terminators, materialized/dead defs
250
+ if (anchored(op)) {
251
+ res = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
217
252
  } else {
218
- const consumers = [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
253
+ const consumers = consumersOf(op);
219
254
  res = consumers.length === 1 ? emitPos(consumers[0]) : null;
220
255
  }
221
256
  emitPosCache.set(op, res);
222
257
  return res;
223
258
  };
259
+ // EVERY position a value's expression renders at — `emitPos` generalized to the whole set (it
260
+ // answers one place or gives up), by following ALL consumers transitively. That matters for
261
+ // the value-home axis: a pure expression with two consumers (`gOut = e; return e;`) has no single
262
+ // emit position, so `emitPos` answers null and every memory read feeding it is forced into a
263
+ // local — even when re-reading at both places is provably equivalent. Null only for a genuine
264
+ // cycle (defensive: SSA use-def is acyclic through ops), which the caller treats as unresolvable.
265
+ //
266
+ // NEVER for a call: two render positions mean two executions, so a call whose consumer renders in
267
+ // several places must keep answering null and materialize.
268
+ const emitPosSetCache = new Map<Op, { blk: Block; idx: number }[] | null>();
269
+ const emitPositions = (op: Op, visiting: Set<Op> = new Set()): { blk: Block; idx: number }[] | null => {
270
+ const hit = emitPosSetCache.get(op);
271
+ if (hit !== undefined) {
272
+ return hit;
273
+ }
274
+ if (visiting.has(op)) {
275
+ return null;
276
+ }
277
+ let res: { blk: Block; idx: number }[] | null;
278
+ if (anchored(op)) {
279
+ res = [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }];
280
+ } else {
281
+ visiting.add(op);
282
+ const seenPos = new Set<string>();
283
+ const acc: { blk: Block; idx: number }[] = [];
284
+ res = acc;
285
+ for (const c of consumersOf(op)) {
286
+ const sub = emitPositions(c, visiting);
287
+ if (!sub) {
288
+ res = null;
289
+ break;
290
+ }
291
+ for (const p of sub) {
292
+ const key = `${blockPos.get(p.blk)}:${p.idx}`;
293
+ if (!seenPos.has(key)) {
294
+ seenPos.add(key);
295
+ acc.push(p);
296
+ }
297
+ }
298
+ }
299
+ visiting.delete(op);
300
+ }
301
+ emitPosSetCache.set(op, res);
302
+ return res;
303
+ };
304
+ // THE def→render path discipline — one implementation, three callers (the two materialization
305
+ // rules below and structure.ts's bitfield fold, which imports it). May an op `isWrite` accepts
306
+ // execute between `def` and a statement at `render`, on any def-avoiding path? The def block's
307
+ // tail, the render block's head, and every between-block on a path; a path re-crossing the def
308
+ // is the NEXT dynamic instance and does not count. Path-based on purpose: `fn.blocks` is ADDRESS
309
+ // order, so a linear-position scan misses a block laid out after the render that executes
310
+ // between def and render on the taken path (an audit round broke exactly that way).
311
+ const memWriteBetween = (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean): boolean => {
312
+ const b = opBlock.get(def)!;
313
+ const oi = opIndex.get(def)!;
314
+ const wDirty = (list: Op[], from: number, to: number): boolean => {
315
+ for (let k = from; k < to; k++) {
316
+ if (isWrite(list[k])) {
317
+ return true;
318
+ }
319
+ }
320
+ return false;
321
+ };
322
+ // Same block: the only def-avoiding path is the straight line between the two indices
323
+ // (leaving and re-entering the block re-crosses the def). A render BEFORE the def cannot
324
+ // happen — within a block, uses follow defs — and falls through to the path walk, whose
325
+ // answer is the conservative one.
326
+ if (render.blk === b && oi < render.idx) {
327
+ return wDirty(b.ops, oi + 1, render.idx);
328
+ }
329
+ if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
330
+ return true;
331
+ }
332
+ for (const x of reachAvoiding(b, b)) {
333
+ if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
334
+ continue; // acyclic render block: head checked
335
+ }
336
+ if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
337
+ continue; // not on a def→render path
338
+ }
339
+ if (wDirty(x.ops, 0, x.ops.length)) {
340
+ return true;
341
+ }
342
+ }
343
+ return false;
344
+ };
224
345
  // Decide in REVERSE program order so a consumer's own materialization is settled before any
225
346
  // producer asks for its emit position (SSA: uses follow defs in dominance/layout order) — and
226
347
  // iterate to a fixpoint for IR whose block layout does not follow dominance (hand-built IR):
@@ -229,6 +350,7 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
229
350
  for (let sizeBefore = -1; sizeBefore !== materialize.size;) {
230
351
  sizeBefore = materialize.size;
231
352
  emitPosCache.clear();
353
+ emitPosSetCache.clear(); // both render-position caches read `materialize`, which just grew
232
354
  for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
233
355
  const b = fn.blocks[bi];
234
356
  for (let oi = b.ops.length - 1; oi >= 0; oi--) {
@@ -268,6 +390,14 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
268
390
  if (!r || !useSitesOf.has(r)) {
269
391
  continue;
270
392
  } // dead call → exprstmt (unchanged)
393
+ // Under the value-home axis: which named global cell this op reads, if any. A constant-
394
+ // offset `load` only — an `aload`'s runtime index names no single cell, and a call reads
395
+ // everything. Null ⇒ every write bars, exactly as before.
396
+ const cell =
397
+ rereadGlobals && defs && op.opcode === 'load'
398
+ ? globalCellOf(defs, op.operands[0], op.attrs.off as number)
399
+ : null;
400
+ const barsThisRead = cell && defs && !volatileGlobal?.(cell.name) ? mayWriteGlobal(defs, cell.name) : null;
271
401
  const sites = useSitesOf.get(r)!;
272
402
  const consumers = [...new Set(sites.map((s) => s.op))];
273
403
  const isCall = op.opcode === 'call';
@@ -280,50 +410,32 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
280
410
  // per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
281
411
  // so it is sound iff every render still sees the def-time memory: NO write anywhere
282
412
  // between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
283
- if (!isCall && consumers.length > 1) {
413
+ //
414
+ // WHERE it renders. Without the axis: one position per consumer, and a consumer with no
415
+ // single position (its own value renders in several places) refuses. With the axis a load
416
+ // resolves the whole SET instead — the second half of the value-home defect, where the
417
+ // local is invented not by a barrier but because the pure expression downstream is itself
418
+ // duplicated (`gOut = (gValue << 1) + gValue; return (gValue << 1) + gValue;`). Never for a
419
+ // call: several positions there mean several executions.
420
+ const poss =
421
+ rereadGlobals && !isCall
422
+ ? emitPositions(op)
423
+ : consumers.length > 1
424
+ ? consumers.map((c) => emitPos(c))
425
+ : [emitPos(consumers[0])];
426
+ if (!poss || poss.some((p) => p === null)) {
427
+ materialize.add(op);
428
+ continue;
429
+ }
430
+ if (poss.length > 1) {
284
431
  const MW = new Set(['store', 'astore', 'call', 'opaque']);
285
- const wDirty = (list: Op[], from: number, to: number) => {
286
- for (let k = from; k < to; k++) {
287
- if (MW.has(list[k].opcode)) {
288
- return true;
289
- }
290
- }
291
- return false;
292
- };
293
- const defToRenderDirty = (q: { blk: Block; idx: number }): boolean => {
294
- // Same block: the only def-avoiding path is the straight line between the two indices
295
- // (leaving and re-entering the block re-crosses the def).
296
- if (q.blk === b && oi < q.idx) {
297
- return wDirty(b.ops, oi + 1, q.idx);
298
- }
299
- if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(q.blk.ops, 0, q.idx)) {
300
- return true;
301
- }
302
- const between = reachAvoiding(b, b);
303
- for (const x of between) {
304
- if (x === q.blk && !reachAvoiding(q.blk, b).has(q.blk)) {
305
- continue;
306
- } // acyclic render blk: head checked
307
- if (x !== q.blk && !reachAvoiding(x, b).has(q.blk)) {
308
- continue;
309
- } // not on a def→render path
310
- if (wDirty(x.ops, 0, x.ops.length)) {
311
- return true;
312
- }
313
- }
314
- return false;
315
- };
316
- const poss = consumers.map((c) => emitPos(c));
317
- if (poss.some((p) => p === null) || poss.some((p) => defToRenderDirty(p!))) {
432
+ const isWrite = barsThisRead ?? ((x: Op) => MW.has(x.opcode));
433
+ if (poss.some((p) => memWriteBetween(op, p!, isWrite))) {
318
434
  materialize.add(op);
319
435
  }
320
436
  continue;
321
437
  }
322
- const pos = emitPos(consumers[0]);
323
- if (!pos) {
324
- materialize.add(op);
325
- continue;
326
- }
438
+ const pos = poss[0]!;
327
439
  // A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
328
440
  // stores/opaque always; a call/load that is dead (statement), materialized (statement), or
329
441
  // inlined into a DIFFERENT statement. A sibling effect inlined into the SAME statement is
@@ -331,6 +443,11 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
331
443
  // exactly as it originally chose to. Loads never bar a load (reads don't conflict).
332
444
  const samePos = (q: { blk: Block; idx: number } | null) => q !== null && q.blk === pos.blk && q.idx === pos.idx;
333
445
  const isBarrier = (x: Op): boolean => {
446
+ // Value-home axis: a store/astore this read is PROVABLY disjoint from (a different named
447
+ // global) does not sequence against it, so the read may still render at its use.
448
+ if (barsThisRead && (x.opcode === 'store' || x.opcode === 'astore') && !barsThisRead(x)) {
449
+ return false;
450
+ }
334
451
  if (x.opcode === 'store') {
335
452
  // A store to a PROVABLY-DISJOINT slot of the same base never aliases the load: same
336
453
  // base SSA value, both constant offset+width, ranges non-overlapping (the everyday
@@ -362,49 +479,18 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
362
479
  }
363
480
  return false;
364
481
  };
365
- const gapDirty = (list: Op[], from: number, to: number) => {
366
- for (let k = from; k < to; k++) {
367
- if (isBarrier(list[k])) {
368
- return true;
369
- }
370
- }
371
- return false;
372
- };
373
- if (pos.blk === b) {
374
- if (gapDirty(b.ops, oi + 1, pos.idx)) {
375
- materialize.add(op);
376
- }
377
- continue;
378
- }
379
- // Cross-block: a call's execution would become path-dependent — always materialize. A
380
- // load may inline only if NO write exists on any DEF-AVOIDING def→render path (a path
381
- // re-crossing the def is the next dynamic instance): the def block's tail, the render
382
- // block's head, and every block between; a render block cyclic WITHOUT passing the def
383
- // (an inner loop around the render) is checked in full.
384
- if (isCall) {
482
+ // A CROSS-BLOCK call's execution would become path-dependent always materialize. Within
483
+ // its own block a call is judged like everything else, by the barrier scan below.
484
+ if (isCall && pos.blk !== b) {
385
485
  materialize.add(op);
386
486
  continue;
387
487
  }
388
- let dirty = gapDirty(b.ops, oi + 1, b.ops.length) || gapDirty(pos.blk.ops, 0, pos.idx);
389
- if (!dirty) {
390
- for (const x of reachAvoiding(b, b)) {
391
- if (x === pos.blk && !reachAvoiding(pos.blk, b).has(pos.blk)) {
392
- continue;
393
- } // acyclic render block: head checked
394
- if (x !== pos.blk && !reachAvoiding(x, b).has(pos.blk)) {
395
- continue;
396
- } // not on a def→render path
397
- if (gapDirty(x.ops, 0, x.ops.length)) {
398
- dirty = true;
399
- break;
400
- }
401
- }
402
- }
403
- if (dirty) {
488
+ // Otherwise: inline only if no barrier stands on any def-avoiding def→render path.
489
+ if (memWriteBetween(op, pos, isBarrier)) {
404
490
  materialize.add(op);
405
491
  }
406
492
  }
407
493
  }
408
494
  }
409
- return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom };
495
+ return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween };
410
496
  }