@asmlift/core 0.5.0 → 0.7.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 (94) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +270 -171
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +243 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +78 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +345 -2
  16. package/src/ir/opcodes.ts +176 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/advance.ts +373 -0
  24. package/src/l3/argbase.ts +6 -5
  25. package/src/l3/ast.ts +510 -59
  26. package/src/l3/basecse.ts +686 -78
  27. package/src/l3/coalesce.ts +432 -46
  28. package/src/l3/dce.ts +31 -9
  29. package/src/l3/gates.ts +96 -1
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +176 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +114 -89
  42. package/src/l3/reindex.ts +722 -80
  43. package/src/l3/scopebase.ts +649 -220
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +16 -1
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +687 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +239 -16
  57. package/src/pipeline.ts +173 -60
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/const.ts +203 -3
  61. package/src/raise/divpow2.ts +4 -4
  62. package/src/raise/extscale.ts +342 -0
  63. package/src/raise/globalshape.ts +1058 -0
  64. package/src/raise/gvn.ts +33 -18
  65. package/src/raise/latch.ts +126 -0
  66. package/src/raise/magicdiv.ts +2 -2
  67. package/src/raise/memberarrays.ts +594 -0
  68. package/src/raise/narrow.ts +124 -0
  69. package/src/raise/narrowlocal.ts +572 -0
  70. package/src/raise/paramwidth.ts +201 -0
  71. package/src/raise/pre-recovery.ts +169 -21
  72. package/src/raise/recover.ts +56 -23
  73. package/src/raise/retsink.ts +585 -19
  74. package/src/raise/shortcircuit.ts +1050 -89
  75. package/src/raise/struct-arrays.ts +19 -2
  76. package/src/raise/structs.ts +34 -4
  77. package/src/raise/tailsink.ts +126 -0
  78. package/src/rank-declare.ts +256 -0
  79. package/src/rank-variations.ts +760 -0
  80. package/src/rank.ts +2122 -326
  81. package/src/structure/analysis.ts +1398 -150
  82. package/src/structure/bitfields.ts +432 -0
  83. package/src/structure/globalaccess.ts +300 -0
  84. package/src/structure/hazards.ts +411 -20
  85. package/src/structure/loops.ts +2 -49
  86. package/src/structure/namecoalesce.ts +454 -0
  87. package/src/structure/structure.ts +3979 -612
  88. package/src/structure/switch-recover.ts +710 -145
  89. package/src/symbols.ts +188 -6
  90. package/src/target.ts +495 -32
  91. package/src/trace.ts +112 -33
  92. package/src/variation-definitions.ts +1540 -0
  93. package/src/variation-gates.ts +89 -0
  94. package/src/variation-tokens.ts +355 -0
package/src/ir/verify.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // 1. every block ends in exactly one terminator (and it is the last op)
4
4
  // 2. operands well-formed: opcode registered, correct arity/attrs
5
5
  // 3. SSA: each value defined once; every use is defined; def dominates use
6
- import { Block, Fn, Value, predecessors } from './core';
6
+ // 4. side data: a fn that carries a write-order record carries one for EVERY block, with every
7
+ // ordinal inside that block's own write count (ir/core.ts `WriteOrder`)
8
+ import { Block, Fn, Value, dominators } from './core';
7
9
  import { opSig } from './opcodes';
8
10
 
9
11
  export class VerifyError extends Error {}
@@ -133,42 +135,8 @@ export function verify(fn: Fn): void {
133
135
  );
134
136
  }
135
137
 
136
- // --- dominance (iterative dominators over the CFG) ---
137
- const entry = fn.blocks[0];
138
- const preds = predecessors(fn);
139
- const dom = new Map<Block, Set<Block>>();
140
- const allBlocks = new Set(fn.blocks);
141
- for (const b of fn.blocks) {
142
- dom.set(b, b === entry ? new Set([entry]) : new Set(allBlocks));
143
- }
144
- let changed = true;
145
- while (changed) {
146
- changed = false;
147
- for (const b of fn.blocks) {
148
- if (b === entry) {
149
- continue;
150
- }
151
- let inter: Set<Block> | null = null;
152
- for (const p of preds.get(b)!) {
153
- const dp = dom.get(p)!;
154
- if (inter === null) {
155
- inter = new Set(dp);
156
- continue;
157
- }
158
- for (const x of inter) {
159
- if (!dp.has(x)) {
160
- inter.delete(x);
161
- }
162
- } // intersect in place (spec-safe delete-in-iter)
163
- }
164
- const next = new Set<Block>(inter ?? []);
165
- next.add(b);
166
- if (!setEq(next, dom.get(b)!)) {
167
- dom.set(b, next);
168
- changed = true;
169
- }
170
- }
171
- }
138
+ // --- dominance: def must dominate every use (ir/core.ts owns the analysis) ---
139
+ const dom = dominators(fn);
172
140
  const dominates = (a: Block, b: Block) => dom.get(b)!.has(a);
173
141
 
174
142
  for (const b of fn.blocks) {
@@ -193,6 +161,44 @@ export function verify(fn: Fn): void {
193
161
  ),
194
162
  );
195
163
  }
164
+
165
+ checkWriteOrder(fn);
166
+ }
167
+
168
+ /** THE WRITE-ORDER RECORD'S CROSS-PASS OBLIGATION, checked rather than trusted (ir/core.ts
169
+ * `WriteOrder`, `foldWriteOrder`), because the consequence of missing it is silent: an unmeasured
170
+ * block's edge copies sort with no records at all, changing the order the structurer emits them in
171
+ * and, on a cycle, which register it spills. No decline, no marker.
172
+ *
173
+ * WHAT IT CATCHES: a pass that MINTS a block into a measured fn, or drops a block's entry, leaving
174
+ * a hole indistinguishable from a fn nobody measured. Also an ordinal outside its block's own
175
+ * write count — a `foldWriteOrder` that moved records without growing the count. WHAT IT CANNOT:
176
+ * ops moved from one MEASURED block into another with the fold forgotten, since both blocks still
177
+ * have entries and no snapshot of the IR shows the move. That half stays a review obligation until
178
+ * the record hangs on `Successor` itself.
179
+ *
180
+ * A fn is "measured" iff it has any entry at all; one with none is parsed or hand-built IR, whose
181
+ * edges take the def-position proxy by design. */
182
+ function checkWriteOrder(fn: Fn): void {
183
+ const order = fn.writeOrder;
184
+ if (order === undefined || order.writes.size === 0) {
185
+ return;
186
+ }
187
+ for (const b of fn.blocks) {
188
+ const writes = order.writes.get(b);
189
+ if (writes === undefined) {
190
+ throw new VerifyError(
191
+ `fn '${fn.name}': block ^bb${fn.blocks.indexOf(b)} carries no write-order entry, but the fn is measured`,
192
+ );
193
+ }
194
+ for (const at of order.lastWrite.get(b)?.values() ?? []) {
195
+ if (at < 0 || at >= writes) {
196
+ throw new VerifyError(
197
+ `fn '${fn.name}': write-order ordinal ${at} on block ^bb${fn.blocks.indexOf(b)} is outside its ${writes} writes`,
198
+ );
199
+ }
200
+ }
201
+ }
196
202
  }
197
203
 
198
204
  /** Run a check body; a VerifyError it throws is re-thrown with the op's location appended —
@@ -207,15 +213,3 @@ function locate(body: () => void, where: () => string): void {
207
213
  throw e;
208
214
  }
209
215
  }
210
-
211
- function setEq(a: Set<Block>, b: Set<Block>): boolean {
212
- if (a.size !== b.size) {
213
- return false;
214
- }
215
- for (const x of a) {
216
- if (!b.has(x)) {
217
- return false;
218
- }
219
- }
220
- return true;
221
- }
@@ -0,0 +1,62 @@
1
+ // asmlift — THE THREE READINGS OF A CONSTANT ADDRESS, in one place because they are three and not
2
+ // one, and because the same four lines had been re-typed into five files at three strengths.
3
+ //
4
+ // A raw address reaches L3 as a cast chain over a `const`, and "which numeric cell is this" has
5
+ // three different right answers depending on what the caller intends to DO with the answer. The
6
+ // readings are deliberately not merged; what this module supplies is names for them, so a pass
7
+ // declares which one it means instead of restating four lines and drifting.
8
+ //
9
+ // • baseConst — a deref BASE, through SCALAR pointer casts only. A cast to a STRUCT pointer is
10
+ // the dot-form's base and is refused, because a respell variation that re-spells THROUGH it collapses the
11
+ // stride (`((struct S *)K)[i].f` is not `((u8 *)K)[…]`). This is the reading a respell variation that
12
+ // REWRITES the base needs: l3/nearbase.ts's clusters, l3/volstore.ts's qualifier.
13
+ // • addrConst — the address an expression IS, through ANY pointer cast. Wider, and safe
14
+ // because nothing re-spells through it: l3/volatileptr.ts counts volatility claims with it.
15
+ // • rootConst — the constant an ACCESS CHAIN is rooted at, through any cast and any number of
16
+ // subscripts and field selections. Wide enough to place an object whose element is not known,
17
+ // which is what a read with a runtime subscript needs (l3/unreduce.ts's aliasing gate).
18
+ //
19
+ // And `cellAddress`, which is not a fourth reading of a base but the WHOLE address an `index`
20
+ // denotes — base plus subscript × width — or null when any part of it is not constant.
21
+ //
22
+ // A CAUTION THE HISTORY EARNED: `rootConst` and `cellAddress` disagree, and a predicate that uses
23
+ // one on its write side and the other on its read side is not the same predicate on both. A read
24
+ // rooted at 0x03FFFFF0 whose element is `[8]` denotes 0x04000010 — a device register the root
25
+ // reports as EWRAM. Ask for both where both can be had.
26
+ import type { Expr } from './ast';
27
+
28
+ /** the numeric address behind a deref base, through SCALAR pointer casts only */
29
+ export const baseConst = (e: Expr): number | null =>
30
+ e.k === 'const'
31
+ ? e.value
32
+ : e.k === 'cast' && !(e.to.kind === 'ptr' && e.to.to.kind === 'struct')
33
+ ? baseConst(e.e)
34
+ : null;
35
+
36
+ /** the numeric address an expression IS, through any number of pointer casts */
37
+ export const addrConst = (e: Expr): number | null =>
38
+ e.k === 'const' ? e.value : e.k === 'cast' && e.to.kind === 'ptr' ? addrConst(e.e) : null;
39
+
40
+ /** the constant an ACCESS CHAIN is rooted at, through any cast, subscript and field selection */
41
+ export const rootConst = (e: Expr): number | null =>
42
+ e.k === 'const'
43
+ ? e.value
44
+ : e.k === 'cast'
45
+ ? rootConst(e.e)
46
+ : e.k === 'index' || e.k === 'field'
47
+ ? rootConst(e.base)
48
+ : null;
49
+
50
+ /** The WHOLE address an `index` access denotes, or null when any part of it is not constant. A
51
+ * `field` never resolves here: its offset is the struct's, which this module has no layout for. */
52
+ export function cellAddress(e: Expr): number | null {
53
+ if (e.k !== 'index' || e.lead !== undefined || e.idx.k !== 'const') {
54
+ return null; // a `lead` is a multidimensional global's leading dimension, not a numeric address
55
+ }
56
+ const base = baseConst(e.base);
57
+ return base === null ? null : base + e.idx.value * e.width;
58
+ }
59
+
60
+ /** is `a` inside the half-open range `w`? A null address, or no declared range, is never inside. */
61
+ export const inRange = (a: number | null, w?: readonly [number, number]): boolean =>
62
+ w !== undefined && a !== null && a >= w[0] && a < w[1];
@@ -0,0 +1,373 @@
1
+ // L3 respell variation: a pointer local the source ADVANCED between two accesses, rather than two
2
+ // addresses the compiler derived from one.
3
+ //
4
+ // `ldr r3,=X; strh [r3]; adds r3,#2; strh [r3]` — the machine held an address in a register, used
5
+ // it, moved it, used it again. `raise/const.ts` folds the lift's `add(const X, const 2)` into the
6
+ // literal `X + 2`, because on Thumb that pair is also how a compiler materialises a 32-bit literal
7
+ // it cannot encode in one instruction, and it records the distinction it is erasing as
8
+ // `index.baseAdvanced` (l3/ast.ts's third evidence field). This pass reads it:
9
+ //
10
+ // *(u16 *)0x04000048 = a; *(u16 *)0x0400004A = b;
11
+ // → u16 *p = (u16 *)0x04000048; *p = a; p = p + 1; *p = b;
12
+ //
13
+ // WHY IT IS A CANDIDATE. Against the INDEXED spelling of the same minted local the advance buys
14
+ // nothing: agbcc folds `p = p + 1; *p` straight back into `strh [r3, #2]`, byte for byte the
15
+ // subscript's own object. What makes it visible is the CONJUNCTION with a `volatile` pointee,
16
+ // which bars that fold and leaves the `add` the target records. Both halves are compiled against
17
+ // `kleod:StreamCmd_SetWindowRegs`'s target object; the four corners are in test/advance.test.ts's
18
+ // header. So this pass emits a spelling and `compareScored` referees; nothing here claims the
19
+ // source wrote it.
20
+ //
21
+ // SOUNDNESS IS ADDRESS EQUALITY. `p` is freshly minted and assigned by nothing else, so at each
22
+ // member's access it holds `A0 + Σ steps so far` — that member's own absolute address — PROVIDED
23
+ // every advance sits between the accesses it separates on every path, and PROVIDED every node this
24
+ // pass re-spells as `*p` is one of those accesses. A top-level statement list has no back edge and
25
+ // runs its statements in order at most once each, so placing each advance at the top level
26
+ // immediately above its member's statement, with the members at STRICTLY INCREASING top-level
27
+ // indices, carries the first half.
28
+ //
29
+ // THE SECOND HALF IS `rewrite`, AND IT MATCHES BY ADDRESS, NOT BY IDENTITY (`:rewrite` below): it
30
+ // replaces EVERY `index` node whose `cellAddress` is a chain member's, wherever it sits. So a
31
+ // second access at a member's address — a twin at the top level, or one inside an arm or a loop
32
+ // body — is re-spelled `*p` at a point where `p` does not hold that address. The two rules that
33
+ // refuse those shapes (`member-second-site`, `member-nested-site`, and their head twins) are
34
+ // therefore SOUND, not narrowing, and the shape is pinned by test/advance.test.ts's `an access at
35
+ // a chain address inside a loop is not re-spelled`.
36
+ //
37
+ // SCOPE (decline over approximate) is `ADVANCE_HEAD_GATES` and `ADVANCE_MEMBER_GATES` below — as
38
+ // tables rather than an `||` chain, so `sound` costs a `guardedBy` and every rule is ablated
39
+ // against the real pass by test/advance.test.ts's battery, which records WHAT THE ABLATED PASS
40
+ // EMITS and checks `sound` against it. NOT by `bench gates`: `pnpm bench gates --pass advance`
41
+ // answers `no censusable pass "advance"`, and structurally must, because this pass is reached
42
+ // through a static import binding in rank.ts rather than through a mutable caller-side record
43
+ // (run/gate-census.ts's header, which measures the `TypeError` a module-namespace write raises).
44
+ // The firing census below was therefore taken by hand, with the recipe it states.
45
+ //
46
+ // FIVE OF THE TWELVE ARE NARROWING rather than soundness and each says which it is. Three are
47
+ // judgements about what the asm shows (`head-already-advanced`, `member-negative-step`,
48
+ // `member-no-evidence` — the last is what makes this a reading rather than a guess); one,
49
+ // `member-signedness`, buys the minted local ONE pointee type where the backend would otherwise
50
+ // spell a correct reinterpret cast; one, `member-element-grid`, is a LOUD refusal — ablated it
51
+ // emits `p0 = p0 + 0.5;`, which is not C. Dropping `member-no-evidence` alone leaves the emitted
52
+ // step `undefined / width` = `NaN`, refused downstream only by the two arithmetic rules'
53
+ // comparisons against it (`NaN % w !== 0`, `NaN !== addr`); the battery's `noncompile` verdicts
54
+ // for both are what hold that.
55
+ //
56
+ // HOW OFTEN EACH FIRES, over the whole corpus — `bench sweep --fan`, both arms, 2,126 records,
57
+ // instrumented on `firstRejection` (2026-09-12), which is HAND INSTRUMENTATION and reproduced by
58
+ // wrapping both tables in `tallying()` (l3/gates.ts) at this pass's one call site in rank.ts,
59
+ // passing `.gates` to `advancedBases`, and printing `.refusals()` when the sweep ends. The numbers
60
+ // count CALLS, and enumeration calls this pass about eleven times per record, once per structure setting's
61
+ // tree:
62
+ // 23,322 calls · 112 found a chain · 23,210 declined
63
+ // head-second-site 872 · member-no-evidence 664 · head-nested-site 256 · head-already-advanced 144
64
+ // every other member rule: 0
65
+ // So the eight remaining member rules are pinned by the battery and by NOTHING IN THE CORPUS —
66
+ // where the corpus refuses a chain, it refuses it at the head. Chain lengths found: 96 of two
67
+ // members and 16 of four, no others.
68
+ //
69
+ // WHAT THIS PASS DOES NOT DO, both measured rather than assumed:
70
+ // • A function with TWO disjoint chains gets one candidate, spelling the FIRST BY POSITION — not
71
+ // the longest, and the second chain is unreachable by any variation. ZERO of the 112 chain-bearing
72
+ // calls above held a second chain sharing no address with the first (the instrument kept
73
+ // scanning), so the second local this would need has no inhabitant to price it.
74
+ // • The init is `prepend`ed and there is no sunk twin; see the note at `placeBaseLocals` below.
75
+ // • IT IS MAP-LESS ONLY. Every member is reached through `cellAddress`, which answers null once
76
+ // a symbol map promotes the pool word to `&REG_WININ` — so with a map this pass enumerates
77
+ // nothing, and every `/advance` candidate the corpus carries is a `/raw-globals` one. That is
78
+ // what caps the variation at the six rows `bench sweep --fan --base origin/main` names
79
+ // (apps/benchmark/dataset/synthetic.ts, at `volwalk`), and it is the question to ask of it the
80
+ // day the symbol-map direction lands: this capability survives only if `cellAddress` learns
81
+ // the promoted form.
82
+ import { type IrType, scalarTypeForAccess } from '../ir/types';
83
+ import { cellAddress } from './address';
84
+ import { type Expr, type SFn, type Stmt, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
85
+ import { type Gate, firstRejection } from './gates';
86
+ import type { BaseInit } from './hoist';
87
+ import { nameAllocator, placeBaseLocals } from './hoist';
88
+
89
+ /** One const-addressed access, with the top-level statement it was reached at. */
90
+ export interface Site {
91
+ stmt: number;
92
+ addr: number;
93
+ width: number;
94
+ signed: boolean;
95
+ advanced?: number;
96
+ }
97
+
98
+ /** One candidate member, judged against the chain so far. `twin`/`nested` are the two ways some
99
+ * OTHER node in the tree names this site's address — the facts `rewrite`'s by-address match makes
100
+ * load-bearing. */
101
+ export interface MemberCtx {
102
+ prev: Site;
103
+ site: Site;
104
+ twin: boolean;
105
+ nested: boolean;
106
+ }
107
+ export type HeadCtx = Omit<MemberCtx, 'prev'>;
108
+
109
+ /** The head's own admission. The two address rules are the same PREDICATE as the member table's
110
+ * and deliberately not the same rule objects (see gates.ts on why a second consumer owns its
111
+ * own): a head that is re-spelled at a second site is wrong for the same reason a member is. */
112
+ export const ADVANCE_HEAD_GATES: readonly Gate<HeadCtx>[] = [
113
+ {
114
+ id: 'head-second-site',
115
+ why: 'the rewrite finds accesses by address, so another access to the same address would read `p` before it is set',
116
+ sound: true,
117
+ guardedBy: 'advance.test.ts: a chain address reached at a second site declines',
118
+ rejects: (c) => c.twin,
119
+ },
120
+ {
121
+ id: 'head-nested-site',
122
+ why: 'the same address inside an arm or a loop body is re-spelled at a point `p` may not hold',
123
+ sound: true,
124
+ guardedBy: 'advance.test.ts: an access at a chain address inside a loop is not re-spelled',
125
+ rejects: (c) => c.nested,
126
+ },
127
+ {
128
+ id: 'head-already-advanced',
129
+ why: 'an access the machine reached by stepping from an earlier one continues that chain, and starting a chain there would load an address the machine never loaded',
130
+ sound: false,
131
+ guardedBy: 'advance.test.ts: a chain may not START at an advanced site',
132
+ rejects: (c) => c.site.advanced !== undefined,
133
+ },
134
+ ];
135
+
136
+ /** Each successor, against the member before it. FIRST rejection wins, so a refusal is
137
+ * attributable to one rule. */
138
+ export const ADVANCE_MEMBER_GATES: readonly Gate<MemberCtx>[] = [
139
+ {
140
+ id: 'member-no-evidence',
141
+ why: 'where the lift recorded no step, the pair is the compiler deriving two addresses from one literal',
142
+ sound: false,
143
+ guardedBy: 'advance.test.ts: the same pair with no evidence declines',
144
+ rejects: (c) => c.site.advanced === undefined,
145
+ },
146
+ {
147
+ id: 'member-second-site',
148
+ why: 'the rewrite finds accesses by address, so another access to the same address would read `p` at the wrong value',
149
+ sound: true,
150
+ guardedBy: 'advance.test.ts: a chain address reached at a second site declines',
151
+ rejects: (c) => c.twin,
152
+ },
153
+ {
154
+ id: 'member-nested-site',
155
+ why: 'the same address inside an arm or a loop body is re-spelled at a point `p` may not hold',
156
+ sound: true,
157
+ guardedBy: 'advance.test.ts: an access at a chain address inside a loop is not re-spelled',
158
+ rejects: (c) => c.nested,
159
+ },
160
+ {
161
+ id: 'member-statement-order',
162
+ why: 'the advance must sit between the two accesses it separates, so the indices must increase',
163
+ sound: true,
164
+ guardedBy: 'advance.test.ts: two accesses in ONE statement are not a chain',
165
+ rejects: (c) => c.site.stmt <= c.prev.stmt,
166
+ },
167
+ {
168
+ id: 'member-width',
169
+ why: 'the new pointer has one pointee width, and `*p` at another width names other bytes',
170
+ sound: true,
171
+ guardedBy: 'advance.test.ts: members of different widths decline',
172
+ rejects: (c) => c.site.width !== c.prev.width,
173
+ },
174
+ {
175
+ id: 'member-signedness',
176
+ why: 'the new pointer has one pointee type, and a second access of another type would need a cast the source did not write',
177
+ sound: false,
178
+ guardedBy: 'advance.test.ts: members of different signedness decline',
179
+ rejects: (c) => c.site.signed !== c.prev.signed,
180
+ },
181
+ {
182
+ id: 'member-element-grid',
183
+ why: 'a step that is not a whole number of elements would be written `p = p + step / width` with a fraction, which is not C',
184
+ sound: false,
185
+ guardedBy: 'advance.test.ts: a step off the element grid declines',
186
+ rejects: (c) => c.site.advanced! % c.prev.width !== 0,
187
+ },
188
+ {
189
+ id: 'member-step-lands',
190
+ why: 'a step that does not land on this access is evidence about some other pair of addresses',
191
+ sound: true,
192
+ guardedBy: 'advance.test.ts: a step that does not land on the next access declines',
193
+ rejects: (c) => c.prev.addr + c.site.advanced! !== c.site.addr,
194
+ },
195
+ {
196
+ id: 'member-negative-step',
197
+ why: 'a backward step (`p = p + -1`) is correct C, but this variation writes only forward steps',
198
+ sound: false,
199
+ guardedBy: 'advance.test.ts: a NEGATIVE step declines',
200
+ rejects: (c) => c.site.advanced! <= 0,
201
+ },
202
+ ];
203
+
204
+ /** Every const-addressed `index` node in the body, split into the ones reached EXACTLY ONCE per
205
+ * execution of a top-level statement — the only places an advance statement can be put — and the
206
+ * ADDRESSES of every other one, which the chain rule refuses outright.
207
+ *
208
+ * A loop's OWN expression joins its body on the second side: a `while` condition runs once per
209
+ * iteration, so an advance above the loop and an access in its test are not the same count. A
210
+ * top-level `if`'s condition stays on the first side, because the `if` statement itself runs once
211
+ * whatever its arms do. */
212
+ function collectSites(body: readonly Stmt[]): { sites: Site[]; nestedAddrs: Set<number> } {
213
+ const sites: Site[] = [];
214
+ const nestedAddrs = new Set<number>();
215
+ const visit = (e: Expr, stmt: number, nested: boolean): void => {
216
+ if (e.k === 'index') {
217
+ const addr = cellAddress(e);
218
+ if (addr !== null) {
219
+ if (nested) {
220
+ nestedAddrs.add(addr);
221
+ } else {
222
+ sites.push({ stmt, addr, width: e.width, signed: e.signed, advanced: e.baseAdvanced });
223
+ }
224
+ }
225
+ }
226
+ mapExprChildren(e, (c) => {
227
+ visit(c, stmt, nested);
228
+ return c;
229
+ });
230
+ };
231
+ const walk = (stmts: readonly Stmt[], stmt: number, nested: boolean): void => {
232
+ for (const s of stmts) {
233
+ const repeats = s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
234
+ for (const e of stmtExprs(s)) {
235
+ visit(e, stmt, nested || repeats);
236
+ }
237
+ walk(stmtChildren(s), stmt, true);
238
+ }
239
+ };
240
+ body.forEach((s, i) => walk([s], i, false));
241
+ return { sites, nestedAddrs };
242
+ }
243
+
244
+ /** The one chain this pass spells, or null.
245
+ *
246
+ * A NON-MEMBER SITE BETWEEN TWO MEMBERS DOES NOT END THE CHAIN. `p` is freshly minted, so an
247
+ * access that does not touch it cannot move it — and the clientele is MMIO setup code, where one
248
+ * `REG_BLDCNT = y;` between two window writes is the ordinary case. So the head is chosen by the
249
+ * head table rather than by position, and the walk scans every later site rather than stopping at
250
+ * the first one a gate refuses. Ambiguity is resolved greedily in statement order: where two later
251
+ * sites would both extend the chain, the earlier one does.
252
+ *
253
+ * THE TOLERANCE HAS NO CORPUS INHABITANT. MMIO writes with an unrelated const-addressed access
254
+ * between two members appear nowhere in the corpus, so this is a rule the file can state
255
+ * truthfully rather than reach a sweep can show. */
256
+ function chainOf(sites: readonly Site[], nestedAddrs: ReadonlySet<number>, gates: AdvanceGates): Site[] | null {
257
+ const head = gates.head ?? ADVANCE_HEAD_GATES;
258
+ const member = gates.member ?? ADVANCE_MEMBER_GATES;
259
+ const occurrences = new Map<number, number>();
260
+ for (const s of sites) {
261
+ occurrences.set(s.addr, (occurrences.get(s.addr) ?? 0) + 1);
262
+ }
263
+ const ctx = (site: Site): HeadCtx => ({
264
+ site,
265
+ twin: (occurrences.get(site.addr) ?? 0) > 1,
266
+ nested: nestedAddrs.has(site.addr),
267
+ });
268
+ for (let i = 0; i < sites.length; i++) {
269
+ if (firstRejection(head, ctx(sites[i])) !== null) {
270
+ continue;
271
+ }
272
+ const chain = [sites[i]];
273
+ for (let j = i + 1; j < sites.length; j++) {
274
+ if (firstRejection(member, { prev: chain[chain.length - 1], ...ctx(sites[j]) }) === null) {
275
+ chain.push(sites[j]);
276
+ }
277
+ }
278
+ if (chain.length >= 2) {
279
+ return chain;
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /** The two tables, ablatable — `gates.ts`'s reason: a test drops one entry and re-runs the REAL
286
+ * predicate on real input, with no test-only branch in the shipped path. Nothing in `src/` passes
287
+ * this; a shipped ablation of a `sound: true` rule emits wrong addresses, which is what
288
+ * `ablateHeuristic` refuses. */
289
+ export interface AdvanceGates {
290
+ head?: readonly Gate<HeadCtx>[];
291
+ member?: readonly Gate<MemberCtx>[];
292
+ }
293
+
294
+ /** Re-spell one advanced chain as a pointer local moved in place, or decline (null). */
295
+ export function advancedBases(sfn: SFn, gates: AdvanceGates = {}): SFn | null {
296
+ const { sites, nestedAddrs } = collectSites(sfn.body);
297
+ const chain = chainOf(sites, nestedAddrs, gates);
298
+ if (chain === null) {
299
+ return null;
300
+ }
301
+ const name = nameAllocator(sfn)();
302
+ const elem: IrType = scalarTypeForAccess(chain[0].width, chain[0].signed);
303
+ const ptr: IrType = { kind: 'ptr', to: elem };
304
+ const members = new Set(chain.map((m) => m.addr));
305
+ // The access itself: every chain member reads `*p`, because `p` has been advanced to exactly its
306
+ // address. The evidence fields go with the old base — they described how the ADDRESS was
307
+ // computed, and this spelling is the answer to that question rather than another instance of it.
308
+ //
309
+ // BY ADDRESS, NOT BY IDENTITY, and the header's soundness argument turns on it: a node this
310
+ // finds at a member's address that is NOT the member — a twin, or one inside an arm or a loop —
311
+ // is re-spelled too, which is why the gates that refuse those shapes are `sound: true`.
312
+ const rewrite = (e: Expr): Expr => {
313
+ const m = mapExprChildren(e, rewrite);
314
+ const addr = m.k === 'index' ? cellAddress(m) : null;
315
+ if (m.k === 'index' && addr !== null && members.has(addr)) {
316
+ return { k: 'index', base: { k: 'var', name }, idx: { k: 'const', value: 0 }, width: m.width, signed: m.signed };
317
+ }
318
+ return m;
319
+ };
320
+ // The emitted distance is the GATED quantity — the step `member-step-lands` tied to this pair of
321
+ // addresses and `member-element-grid` divided — rather than the address difference, which is the
322
+ // same number only because those two rules hold. Deriving it separately is how a later ablation
323
+ // of one of them emits a fractional advance nothing checked.
324
+ //
325
+ // WHICH MAKES THE ARITHMETIC HERE TOTAL ONLY BECAUSE OF THE TABLE, and both ways out are LOUD
326
+ // rather than silent — measured, and pinned by the battery's `noncompile` verdicts rather than
327
+ // guarded here: with `member-element-grid` dropped this emits `p0 = p0 + 0.5;`, and with
328
+ // `member-no-evidence` dropped `advanced` is `undefined` and this emits `p0 = p0 + NaN;`.
329
+ // Neither is C, so a candidate carrying one is dropped at compile with its message rather than
330
+ // scored — which is why the two rules are `sound: false` and why no `Number.isInteger` refusal
331
+ // stands here: adding one would turn those two ablations into a DECLINE and delete the evidence
332
+ // the battery reads. `member-no-evidence` is ablatable by `ablateHeuristic`, so a round that
333
+ // ships that ablation as a ranked candidate ships noncompiling sources; that is its price.
334
+ const advanceAt = new Map<number, number>();
335
+ for (let i = 1; i < chain.length; i++) {
336
+ advanceAt.set(chain[i].stmt, chain[i].advanced! / chain[i].width);
337
+ }
338
+ const body: Stmt[] = [];
339
+ sfn.body.forEach((s, i) => {
340
+ const step = advanceAt.get(i);
341
+ if (step !== undefined) {
342
+ body.push({
343
+ k: 'assign',
344
+ name,
345
+ value: { k: 'bin', op: '+', l: { k: 'var', name }, r: { k: 'const', value: step } },
346
+ });
347
+ }
348
+ body.push(mapStmtExprs(s, rewrite));
349
+ });
350
+ const init: BaseInit = {
351
+ k: 'assign',
352
+ name,
353
+ value: { k: 'cast', to: ptr, e: { k: 'const', value: chain[0].addr } },
354
+ };
355
+ const locals = [...sfn.locals, { name, type: ptr as SFn['locals'][number]['type'] }];
356
+ // `prepend` for `l3/nearbase.ts`'s reason and a second one this pass owns: the init MATERIALISES
357
+ // the register the chain advances, and the target's own instruction order is what says where the
358
+ // pool word was loaded. Putting it in first-use order instead moves it below whatever else the
359
+ // function loads first, which on `kleod:StreamCmd_SetWindowRegs` swaps the two pool words and
360
+ // costs the match; test/advance.test.ts's `the base init leads` pins the emitted order.
361
+ //
362
+ // AND NO `/advance/sinkinit` ALTERNATIVE, unlike `/nearbase`, which ships one for exactly this decision —
363
+ // not because the decision is better determined here (the generator cannot see the target either
364
+ // way) but because the twin CANNOT EXIST. `sinkInitsToFirstUse` sinks an init only when
365
+ // `localMentions` counts ONE assignment to its local ("or the move would cross the other write",
366
+ // l3/hoist.ts), and an advance IS a second assignment to this one — so the sink declines on every
367
+ // tree this pass produces, by construction rather than by row: the sink returns null on the
368
+ // advanced tree, and registering `/advance/sinkinit` adds no candidate to
369
+ // `kleod:StreamCmd_SetWindowRegs:agbcc`'s fan. The `prepend` decision above is therefore the only
370
+ // placement this variation HAS, which is a stronger reason to record the compile behind it.
371
+ const { body: placed } = placeBaseLocals({ ...sfn, locals, body }, [init], 'prepend');
372
+ return { ...sfn, locals, body: placed };
373
+ }
package/src/l3/argbase.ts CHANGED
@@ -1,4 +1,4 @@
1
- // L3 re-spelling lever: materialize the deref BASES of a call's arguments into locals, before the
1
+ // L3 respell variation: materialize the deref BASES of a call's arguments into locals, before the
2
2
  // call.
3
3
  //
4
4
  // When a call's arguments are each a deref through a different fixed address, the compiler loads
@@ -16,9 +16,9 @@
16
16
  // order names the bases first (`vu8 *p = &REG_VCOUNT_L; u8 *e = gEntityArray; f(*p, e[8])`), which
17
17
  // is what a decomp author writes and what this pass reproduces.
18
18
  //
19
- // A LEVER, not a rewrite: it is emitted as an ADDITIONAL candidate (rank.ts `/argbase`) and the
19
+ // A VARIATION, not a committed rewrite: it is emitted as an ADDITIONAL candidate (rank.ts `/argbase`) and the
20
20
  // differ referees, so the inline spelling is always still there to win. That is what bounds the
21
- // risk — a lever that replaced the primary could lose a match, this one cannot.
21
+ // risk — a rewrite that replaced the default could lose a match, this one cannot.
22
22
  //
23
23
  // SEMANTICS ARE PRESERVED BY CONSTRUCTION, which matters because on a NONMATCH row the
24
24
  // best-scoring candidate is what the user is shown. Only a PURE leaf base is eligible — a global's
@@ -29,7 +29,7 @@
29
29
  // KNOWN LIMITATION: the hoisted local is a plain `T *` — `IrType` models no cv-qualifier at all,
30
30
  // so naming a VOLATILE cell through it drops the qualifier that macros.ts goes out of its way to
31
31
  // carry. Pre-existing and not introduced here (every pointer local in the tower has it), but the
32
- // two features meet on exactly the MMIO shape this lever targets, so it is written down rather
32
+ // two features meet on exactly the MMIO shape this variation targets, so it is written down rather
33
33
  // than left to be rediscovered.
34
34
  //
35
35
  // GATE: at least TWO arguments of the same call must qualify, with DISTINCT bases. The reordering
@@ -41,6 +41,7 @@ import { type IrType, T, scalarTypeForAccess } from '../ir/types';
41
41
  import type { Expr, SFn, Stmt } from './ast';
42
42
  import { mapExprChildren, stmtExprs } from './ast';
43
43
  import { nameAllocator } from './hoist';
44
+ import { declaredGlobals } from './storage';
44
45
 
45
46
  /** A base this pass may evaluate early: pure, and not something a store can change under us.
46
47
  *
@@ -103,7 +104,7 @@ function baseKey(n: Extract<Expr, { k: 'index' }>): string {
103
104
  * candidate at all rather than a duplicate of the primary).
104
105
  */
105
106
  export function materializeArgBases(sfn: SFn): SFn | null {
106
- const globals = new Set((sfn.globals ?? []).map((g) => g.name));
107
+ const globals = declaredGlobals(sfn);
107
108
  const fresh = nameAllocator(sfn);
108
109
  const newLocals: { name: string; type: IrType }[] = [];
109
110
  let fired = false;