@asmlift/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +154 -5
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +37 -5
  7. package/src/declare.ts +251 -0
  8. package/src/frontend/frontend.ts +12 -2
  9. package/src/frontend/mips.ts +24 -23
  10. package/src/frontend/opaque.ts +39 -2
  11. package/src/frontend/ssa.ts +32 -53
  12. package/src/frontend/thumb.ts +420 -32
  13. package/src/ir/opcodes.ts +44 -0
  14. package/src/ir/simplify.ts +72 -0
  15. package/src/l3/argbase.ts +216 -0
  16. package/src/l3/ast.ts +126 -6
  17. package/src/l3/basecse.ts +3 -40
  18. package/src/l3/coalesce.ts +146 -0
  19. package/src/l3/dce.ts +2 -23
  20. package/src/l3/hoist.ts +65 -0
  21. package/src/l3/reindex.ts +7 -0
  22. package/src/l3/scopebase.ts +436 -0
  23. package/src/l3/symbol-refs.ts +61 -0
  24. package/src/l3/tailmerge.ts +120 -0
  25. package/src/l3/typing.ts +4 -0
  26. package/src/macros.ts +335 -0
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +20 -6
  29. package/src/proto.ts +55 -0
  30. package/src/raise/divpow2.ts +226 -0
  31. package/src/raise/gvn.ts +141 -0
  32. package/src/raise/pre-recovery.ts +37 -3
  33. package/src/raise/recover.ts +24 -7
  34. package/src/raise/retsink.ts +36 -7
  35. package/src/raise/shortcircuit.ts +264 -22
  36. package/src/raise/structs.ts +12 -2
  37. package/src/rank.ts +370 -79
  38. package/src/structure/analysis.ts +42 -1
  39. package/src/structure/structure.ts +852 -67
  40. package/src/structure/switch-recover.ts +21 -3
  41. package/src/symbols.ts +541 -0
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +17 -2
@@ -0,0 +1,436 @@
1
+ // L3 re-spelling lever: hoist a reused global base into a pointer local at the INNERMOST scope
2
+ // that contains all of its uses.
3
+ //
4
+ // `l3/basecse.ts` already hoists a reused leaf base — but always to the FUNCTION TOP, and only for
5
+ // an `addr`/`const` base. Both limits are load-bearing here, and each costs a real row:
6
+ //
7
+ // PLACEMENT. A base used only inside one `if` arm, hoisted to the function top, is live across
8
+ // 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.
15
+ // basecse's header already names the gap — "a loop-body base is left
16
+ // inline for a future scope-aware hoist" — and this is that hoist.
17
+ //
18
+ // ELIGIBILITY. With a symbol map that states an array's RANK, the access renders as the bare
19
+ // `gSym[0][i]`, whose base node is a `var` naming the global, not an `addr`. basecse's
20
+ // `isHoistableBase` takes only `addr`/`const`, so the rank-aware spelling — the one a project with
21
+ // real headers actually gets — is invisible to it.
22
+ //
23
+ // WHY IT MATCHES, and it is not a readability preference: a store whose destination address the
24
+ // compiler materialized into a register before computing the source reads back as exactly this
25
+ // shape. The decomp author's alternative is a no-op read-modify-write (`g[0][K] += 0;`) purely to
26
+ // force that materialization; naming the base is the same codegen without the quirk.
27
+ //
28
+ // A LEVER, not a rewrite: emitted as an ADDITIONAL candidate (rank.ts `/scopebase`) with the
29
+ // differ refereeing, so the un-hoisted spelling is always still in the list and this can never cost
30
+ // a match.
31
+ //
32
+ // SEMANTICS ARE PRESERVED BY CONSTRUCTION. The hoisted value is a pure ADDRESS of a global — no
33
+ // load, nothing observable, nothing that can fault — so evaluating it earlier in a scope that
34
+ // DOMINATES every use is invisible. The rewritten accesses keep their own width/signedness, so
35
+ // every stride is unchanged. Domination is the load-bearing half: `collect` and `rewriteStmt` must
36
+ // walk the SAME tree, or an access the planner never placed gets repointed at a local whose
37
+ // assignment does not reach it — compiling C that reads an uninitialized pointer, which neither
38
+ // boundary contract catches (they check resolution and deref typing, not definite assignment).
39
+ //
40
+ // ORDERING: `hoistReusedGlobalBases` (basecse) runs unconditionally in `structureChecked`, BEFORE
41
+ // rank's levers see the tree. So this pass's `addr`/`const` input is only what basecse REFUSED —
42
+ // loop uses and repeated-constant-offset uses — which is why it carries basecse's const-offset gate
43
+ // rather than assuming those bases never arrive.
44
+ import { type IrType, T, scalarTypeForAccess } from '../ir/types';
45
+ import type { Expr, SFn, Stmt } from './ast';
46
+ import { mapExprChildren, stmtExprs } from './ast';
47
+ import { nameAllocator } from './hoist';
48
+
49
+ /** A base this lever may name: a leaf whose value is a fixed address.
50
+ *
51
+ * `var` is included ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal` alone
52
+ * (two call sites in structure.ts, both on the `bareArrayLead` path, which requires
53
+ * `shape === 'array'`) — so a `var` base here is always an ARRAY-declared global and `(T *)&gSym`
54
+ * is its start address under any declaration. The invariant is worth stating because it is what
55
+ * keeps a POINTER-shaped global out: for one of those, `(T *)&gPtr` names the pointer CELL rather
56
+ * than the object it points at, which would be silently the wrong address. A local `var` is
57
+ * excluded for the ordinary reason: it can be assigned between the hoist point and a use. */
58
+ type LeafBase = Extract<Expr, { k: 'addr' } | { k: 'const' } | { k: 'var' }>;
59
+ const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
60
+ e.k === 'addr' || e.k === 'const' || (e.k === 'var' && globals.has(e.name));
61
+
62
+ /** THE identity of a base — what makes two accesses "the same address".
63
+ *
64
+ * A global reaches L3 under two spellings, `addr g` and the bare `var g`, and they denote the same
65
+ * cell; keying on the NAME alone means a function that mixes them still sees one base. */
66
+ const baseId = (b: LeafBase): string => (b.k === 'const' ? `c:${b.value}` : `n:${b.name}`);
67
+
68
+ /** An access this lever may re-point, or null.
69
+ *
70
+ * REFUSES a non-zero `lead`. `lead` pins the leading subscripts of a multidimensional array, so
71
+ * `g[1][i]` is a whole ROW past `g[0][i]`. The hoisted local points at the START of the object, and
72
+ * the rewrite DROPS the lead — sound only when every leading subscript is 0. A non-zero lead would
73
+ * silently address the wrong row, which no contract checks: the tree stays well-typed and
74
+ * spellable, it just names different bytes. (Today `bareArrayLead` only ever emits zeros; this
75
+ * guard is what keeps that an implementation detail rather than a correctness dependency.) */
76
+ function eligible(e: Expr, globals: ReadonlySet<string>): Extract<Expr, { k: 'index' }> | null {
77
+ if (e.k !== 'index' || !isLeaf(e.base, globals)) {
78
+ return null;
79
+ }
80
+ return (e.lead ?? []).every((n) => n === 0) ? e : null;
81
+ }
82
+
83
+ /** The (base, access-shape) key an access shares with its reuse siblings. Width and signedness are
84
+ * part of it because the hoisted local carries the access's pointer type — two widths through one
85
+ * base are two different locals, exactly as in basecse. */
86
+ const keyOf = (n: Extract<Expr, { k: 'index' }>): string => `${baseId(n.base as LeafBase)} ${n.width} ${n.signed}`;
87
+
88
+ /** One use, located by its chain of enclosing statement LISTS (outermost first).
89
+ *
90
+ * `loop[i]` says whether `path[i]` is a LOOP BODY. Recorded here, at the only point the tree walk
91
+ * actually knows it, so the loop question below is a lookup rather than a second traversal that
92
+ * could disagree with this one. */
93
+ interface Site {
94
+ path: Stmt[][];
95
+ loop: boolean[];
96
+ /** `idx[i]` is the index, within `path[i]`, of the statement this use sits under. Used to place
97
+ * the hoist immediately before the FIRST statement that needs it rather than at the list head:
98
+ * a call between the assignment and the first use is exactly what forces the pointer into a
99
+ * CALLEE-SAVED register and adds the prologue push/pop the original avoided — the same failure,
100
+ * one level smaller, that this module exists to fix. argbase.ts places by the same rule. */
101
+ idx: number[];
102
+ /** the use runs EVERY ITERATION of a loop whose body is not on `path` — a loop's own condition,
103
+ * or a `for`'s increment. No scope reachable from `path` runs at that cadence, so a key with any
104
+ * such use is refused outright rather than hoisted to a point that runs once. */
105
+ perIteration: boolean;
106
+ }
107
+
108
+ /** Set when the tree holds a shape `collect` and `rewriteStmt` would disagree about — see the
109
+ * `for`-part note below. The pass then declines outright. */
110
+ let compound = false;
111
+
112
+ /** Walk every expression in the tree, recording each eligible access's key and its scope path. */
113
+ function collect(
114
+ body: Stmt[],
115
+ globals: ReadonlySet<string>,
116
+ out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }>,
117
+ path: Stmt[][],
118
+ loop: boolean[],
119
+ idxPath: number[],
120
+ ): void {
121
+ let at = 0;
122
+ const visit = (e: Expr, perIteration: boolean): void => {
123
+ const ix = eligible(e, globals);
124
+ if (ix) {
125
+ const k = keyOf(ix);
126
+ const rec = out.get(k) ?? { uses: [], sample: ix, constOff: new Map<number, number>() };
127
+ rec.uses.push({ path, loop, perIteration, idx: [...idxPath, at] });
128
+ if (ix.idx.k === 'const') {
129
+ rec.constOff.set(ix.idx.value, (rec.constOff.get(ix.idx.value) ?? 0) + 1);
130
+ }
131
+ out.set(k, rec);
132
+ }
133
+ mapExprChildren(e, (c) => {
134
+ visit(c, perIteration);
135
+ return c;
136
+ });
137
+ };
138
+ for (const [i, s] of body.entries()) {
139
+ at = i;
140
+ const isLoop = s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
141
+ // A loop's OWN condition runs every iteration — a base there is loop-invariant exactly as a
142
+ // body use is, and it lives at THIS list, which does not. basecse.ts and argbase.ts treat the
143
+ // CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
144
+ // (its `stmtChildren('for')` is `[init, inc, …body]`, recursed with `nested`), this pass counts
145
+ // 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.
147
+ stmtExprs(s).forEach((e) => visit(e, isLoop));
148
+ if (s.k === 'for') {
149
+ // `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
150
+ // reaches only its own expressions while `rewriteStmt` descends into any nested list — the
151
+ // round-1 walker asymmetry, one node kind deeper, and the fuzz reproduces it (a use inside
152
+ // `for (if (1) i = g[3]; …)` gets repointed at a local the `if` arm may never have assigned).
153
+ // No producer emits a compound part today (structure.ts and reindex.ts both emit `assign`), so
154
+ // rather than grow a second recursion this REFUSES the whole function — loud decline over a
155
+ // silently unreachable definition. Delete this when `stmtLists` makes collect/rewrite share
156
+ // one traversal.
157
+ if (childLists(s.init).length > 0 || childLists(s.inc).length > 0) {
158
+ compound = true;
159
+ }
160
+ // `init` and `inc` are STATEMENTS, so their expressions are reached by neither `stmtExprs`
161
+ // nor `childLists` — yet `rewriteStmt` rewrites them. Collect and rewrite MUST see the same
162
+ // tree: an access the planner never counted would still be repointed, at a local whose
163
+ // assignment need not dominate it (`for (i = p0[3]; …)` after an `if` arm that defines p0).
164
+ // `init` runs once, at this list's cadence; `inc` runs every iteration, like the condition.
165
+ stmtExprs(s.init).forEach((e) => visit(e, false));
166
+ stmtExprs(s.inc).forEach((e) => visit(e, true));
167
+ }
168
+ for (const child of childLists(s)) {
169
+ collect(child, globals, out, [...path, child], [...loop, isLoop], [...idxPath, i]);
170
+ }
171
+ }
172
+ }
173
+
174
+ /** The nested statement LISTS of a statement — the scopes a hoist could land in.
175
+ *
176
+ * Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
177
+ * are single statements, not lists, and a hoist has nowhere legal to go in either (before the loop
178
+ * changes when it runs, inside the body repeats it). A `for`'s body IS a list and is included. */
179
+ function childLists(s: Stmt): Stmt[][] {
180
+ switch (s.k) {
181
+ case 'if':
182
+ return [s.then, s.else];
183
+ case 'while':
184
+ case 'dowhile':
185
+ case 'for':
186
+ return [s.body];
187
+ case 'switch':
188
+ return [...s.cases.map((c) => c.body), ...(s.default ? [s.default] : [])];
189
+ // Exhaustive on purpose — no `default`. A future Stmt kind carrying a nested list must be a
190
+ // COMPILE error here, exactly as it is in `stmtChildren`: a silent `[]` would collect that
191
+ // kind's uses at the wrong scope while `rewriteStmt`, which IS exhaustive, still rewrote them.
192
+ case 'assign':
193
+ case 'store':
194
+ case 'exprstmt':
195
+ case 'return':
196
+ case 'break':
197
+ case 'continue':
198
+ return [];
199
+ }
200
+ }
201
+
202
+ /** The innermost statement list common to every use, or null when they span the function body.
203
+ *
204
+ * Null is NOT a decline any more: the caller falls through to `deepestCluster`. Kept as a distinct
205
+ * answer because "one scope holds everything" is the better shape when it exists — every use is
206
+ * named, not just a cluster. The consolidation this file still owes would make both of these one
207
+ * selector parameter over a single collected index. */
208
+ function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
209
+ const first = uses[0].path;
210
+ let depth = 0;
211
+ while (depth < first.length && uses.every((u) => u.path[depth] === first[depth])) {
212
+ depth++;
213
+ }
214
+ return depth === 0 ? null : { scope: first[depth - 1], depth };
215
+ }
216
+
217
+ /** The DEEPEST statement list holding 2+ uses, with just those uses — the fallback when no single
218
+ * scope holds them all.
219
+ *
220
+ * Ties are broken by first appearance, so emission stays deterministic. Returning a SUBSET is the
221
+ * whole point: the uses outside the cluster keep their original spelling, which is exactly the
222
+ * mixed form the compiler produces when it materializes an address in one arm and re-derives it
223
+ * elsewhere. */
224
+ function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site[] } | null {
225
+ const byList = new Map<Stmt[], { depth: number; uses: Site[] }>();
226
+ for (const u of all) {
227
+ u.path.forEach((list, i) => {
228
+ const e = byList.get(list) ?? { depth: i + 1, uses: [] };
229
+ e.uses.push(u);
230
+ byList.set(list, e);
231
+ });
232
+ }
233
+ let best: { scope: Stmt[]; depth: number; uses: Site[] } | null = null;
234
+ for (const [scope, e] of byList) {
235
+ if (e.uses.length >= 2 && (best === null || e.depth > best.depth)) {
236
+ best = { scope, depth: e.depth, uses: e.uses };
237
+ }
238
+ }
239
+ return best;
240
+ }
241
+
242
+ /** Does any use sit inside a LOOP nested below the chosen scope?
243
+ *
244
+ * OVER-REFUSES in two shapes, deliberately: a `do { … } while (g[1]) ;` body head and a
245
+ * `for (…; …; i = g[5])` body head both DO run at the flagged cadence, so a hoist there would be
246
+ * legal. Refusing them costs a missed spelling and nothing else (bench: 0 lost, 0 gained), and the
247
+ * precise rule needs the loop-DEPTH model an extraction would bring. Otherwise:
248
+ * the hoist would be loop-invariant code motion to a point the original never had — the
249
+ * register-pressure failure `basecse.ts`'s own `inLoop` gate refuses, and the reason that gate
250
+ * exists. When EVERY use is inside the loop, the common scope IS the loop body: the assignment
251
+ * then runs per iteration exactly as the inline spelling did, and there is nothing to refuse. */
252
+ function underNestedLoop(uses: Site[], depth: number): boolean {
253
+ return uses.some((u) => u.perIteration || u.loop.slice(depth).some(Boolean));
254
+ }
255
+
256
+ /**
257
+ * The `/scopebase` re-spelling, or null when nothing qualifies (the caller then adds no candidate
258
+ * rather than a duplicate of the primary).
259
+ */
260
+ export function hoistScopedBases(sfn: SFn): SFn | null {
261
+ compound = false;
262
+ // A name that is BOTH a declared global and a local/param is not safely a global here: `&g` would
263
+ // take the address of the LOCAL, silently a different object. Excluded rather than assumed apart.
264
+ const shadowed = new Set([...sfn.locals.map((l) => l.name), ...sfn.params.map((p) => p.name)]);
265
+ const globals = new Set((sfn.globals ?? []).map((g) => g.name).filter((n) => !shadowed.has(n)));
266
+ const found = new Map<
267
+ string,
268
+ { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }
269
+ >();
270
+ collect(sfn.body, globals, found, [], [], []);
271
+ if (compound) {
272
+ return null;
273
+ }
274
+
275
+ const fresh = nameAllocator(sfn);
276
+ // key → (scope list identity, local name)
277
+ const plan: { scope: Stmt[]; key: string; name: string; type: IrType; base: LeafBase; before: number }[] = [];
278
+ for (const [key, rec] of found) {
279
+ if (rec.uses.length < 2) {
280
+ continue; // one access re-materializes as cheaply as a named local
281
+ }
282
+ // A constant offset touched 2+ times is a SCALAR re-access at one fixed location (an MMIO
283
+ // read-modify-write, a repeated `*p`), which the compiler re-materializes rather than
284
+ // register-holds. basecse.ts learned this by LOSING the ProcessHBlankWait match to it. Inherited
285
+ // here rather than re-lost — but honestly: the evidence is a `const` MMIO address, and it
286
+ // applies cleanly only to the `addr`/`const` half of this pass's input, which is exactly what
287
+ // basecse refused and left behind. For the `var` (array-global) half basecse never ran, so this
288
+ // is an EXTRAPOLATION, not an inheritance. Conservative direction, so the cost is a missed
289
+ // hoist rather than a wrong one. It also SLIPS on a fixed offset not spelled as a literal —
290
+ // two identical `g[i]` accesses are not tallied — which basecse acknowledges in its own comment
291
+ // and which this pass is MORE exposed to, since it deliberately admits loop-body uses, exactly
292
+ // the input basecse's `inLoop` gate kept away from that hole.
293
+ if ([...rec.constOff.values()].some((n) => n >= 2)) {
294
+ continue;
295
+ }
296
+ let at = commonScope(rec.uses);
297
+ let uses = rec.uses;
298
+ if (!at) {
299
+ // The uses span the FUNCTION BODY, so no single scope holds them. Rather than decline, take a
300
+ // scope that holds two or more and name the base for THOSE only, leaving the rest as they
301
+ // were.
302
+ //
303
+ // The selection rule is DEEPEST, with no size term, and that is a real limitation rather than
304
+ // a model of the compiler: a scope with four uses enclosing a nested scope with two will name
305
+ // the TWO and leave the four re-deriving the address. Only ONE cluster is ever served, and
306
+ // when two siblings tie on depth the first-appearing wins — arbitrary, not principled.
307
+ // Largest-cluster-with-deepest-as-tie-break is the better rule; it is a behaviour change and
308
+ // belongs with the placement-selector consolidation, not bolted on here.
309
+ //
310
+ // NOTE this fires for an `addr`/`const` base too — nothing here tests the base kind. That is
311
+ // not a duplicate of basecse's hoist: basecse runs FIRST (see the ordering note in the file
312
+ // header), so any `addr`/`const` base reaching this pass is one basecse already REFUSED.
313
+ const cluster = deepestCluster(rec.uses);
314
+ if (!cluster) {
315
+ continue;
316
+ }
317
+ at = { scope: cluster.scope, depth: cluster.depth };
318
+ uses = cluster.uses;
319
+ }
320
+ if (underNestedLoop(uses, at.depth)) {
321
+ continue;
322
+ }
323
+ const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
324
+ // the earliest statement of the scope list that (transitively) holds a use
325
+ // `path` starts EMPTY, so `idx` carries one entry more than `path`: idx[j+1] is the index
326
+ // within path[j]. The scope is path[depth-1], so its index is idx[depth].
327
+ const before = Math.min(...uses.map((u) => u.idx[at.depth]));
328
+ plan.push({ scope: at.scope, key, name: fresh(), type, base: rec.sample.base as LeafBase, before });
329
+ }
330
+ if (plan.length === 0) {
331
+ return null;
332
+ }
333
+
334
+ // A plan entry may own only a SUBSET of its key's uses (see deepestCluster), so repointing is
335
+ // scoped: a key becomes active when the rewrite enters its scope and inactive on the way out.
336
+ // Repointing by key alone would rewrite uses the hoist does not dominate.
337
+ // SAFE ONLY because `plan` holds at most one entry per key, so `delete` on the way out cannot
338
+ // discard an outer binding. Serving a second cluster for one key — the obvious next step — makes
339
+ // that false, and an inner delete would silently unbind the outer one for the rest of its scope:
340
+ // a use of an unassigned pointer, the defect class this module has already shipped twice. Switch
341
+ // to save/restore (or pass the bindings as an argument) before serving more than one cluster.
342
+ const active = new Map<string, string>();
343
+ const point = (e: Expr): Expr => {
344
+ const ix = eligible(e, globals);
345
+ if (ix) {
346
+ const nm = active.get(keyOf(ix));
347
+ if (nm) {
348
+ // `lead` is DROPPED — the local already points at the object start, and `eligible` has
349
+ // established every leading subscript is 0.
350
+ const { lead: _drop, ...rest } = ix;
351
+ return { ...rest, base: { k: 'var', name: nm }, idx: point(ix.idx) };
352
+ }
353
+ }
354
+ return mapExprChildren(e, point);
355
+ };
356
+
357
+ // Rebuild the tree, inserting each hoist at the head of its own scope list. Statement lists are
358
+ // matched by IDENTITY against the ORIGINAL tree, so the rewrite walks the original and emits a
359
+ // fresh tree in one pass — a two-pass version would compare rebuilt lists that no longer match.
360
+ const rewriteList = (list: Stmt[]): Stmt[] => {
361
+ const here = plan.filter((p) => p.scope === list);
362
+ // SAVE/RESTORE, not set/delete. A plain delete on the way out is correct only while `plan`
363
+ // holds one entry per key; the moment a second cluster for one key is served, an inner exit
364
+ // would unbind an OUTER hoist for the rest of its scope — under-repointing silently. Restoring
365
+ // makes the nesting correct by construction instead of by an unguarded invariant.
366
+ const saved = here.map((p) => [p.key, active.get(p.key)] as const);
367
+ for (const p of here) {
368
+ active.set(p.key, p.name);
369
+ }
370
+ const rewritten = list.map(rewriteStmt);
371
+ for (const [key, prev] of saved) {
372
+ if (prev === undefined) {
373
+ active.delete(key);
374
+ } else {
375
+ active.set(key, prev);
376
+ }
377
+ }
378
+ // Insert each hoist immediately before the first statement that uses it. Descending by index so
379
+ // earlier insertions do not shift the positions later ones were computed against. NOTE that two
380
+ // hoists sharing a `before` come out REVERSED relative to `plan` order — the sort is stable and
381
+ // descending, so both splice at the same index and the later one ends up first. Deterministic
382
+ // and semantically irrelevant, but it is not first-appearance order, which this comment used to
383
+ // claim.
384
+ for (const p of [...here].sort((a, b) => b.before - a.before)) {
385
+ rewritten.splice(p.before, 0, {
386
+ k: 'assign',
387
+ name: p.name,
388
+ // The always-valid form: `(T *)&gSym` is byte-identical under ANY declaration of gSym, which
389
+ // is why it is also what `bareArrayLead` falls back to. A `const` base keeps its literal.
390
+ value: { k: 'cast', to: p.type, e: p.base.k === 'const' ? p.base : { k: 'addr', name: p.base.name } },
391
+ });
392
+ }
393
+ return rewritten;
394
+ };
395
+ const rewriteStmt = (s: Stmt): Stmt => {
396
+ switch (s.k) {
397
+ case 'assign':
398
+ return { ...s, value: point(s.value) };
399
+ case 'store':
400
+ return { ...s, lval: point(s.lval), value: point(s.value) };
401
+ case 'exprstmt':
402
+ return { ...s, value: point(s.value) };
403
+ case 'return':
404
+ return s.value === undefined ? s : { ...s, value: point(s.value) };
405
+ case 'if':
406
+ return { ...s, cond: point(s.cond), then: rewriteList(s.then), else: rewriteList(s.else) };
407
+ case 'while':
408
+ case 'dowhile':
409
+ return { ...s, cond: point(s.cond), body: rewriteList(s.body) };
410
+ case 'for':
411
+ return {
412
+ ...s,
413
+ init: rewriteStmt(s.init),
414
+ cond: point(s.cond),
415
+ inc: rewriteStmt(s.inc),
416
+ body: rewriteList(s.body),
417
+ };
418
+ case 'switch':
419
+ return {
420
+ ...s,
421
+ scrutinee: point(s.scrutinee),
422
+ cases: s.cases.map((c) => ({ ...c, body: rewriteList(c.body) })),
423
+ ...(s.default ? { default: rewriteList(s.default) } : {}),
424
+ };
425
+ case 'break':
426
+ case 'continue':
427
+ return s;
428
+ }
429
+ };
430
+
431
+ const body = rewriteList(sfn.body);
432
+ // Declared from `plan`, one per hoist — NOT accumulated inside `rewriteList`, which would emit a
433
+ // duplicate declaration (non-compiling C) if a `Stmt[]` were ever structurally shared by two tree
434
+ // positions.
435
+ return { ...sfn, body, locals: [...sfn.locals, ...plan.map((p) => ({ name: p.name, type: p.type }))] };
436
+ }
@@ -0,0 +1,61 @@
1
+ // asmlift — SELF-DECLARING CANDIDATES: the pure map-reference query
2
+ // (research/self-declaring-candidates-2026-07-26.md).
3
+ //
4
+ // `collectSymbolRefs` derives, from a FINAL structured tree, every map-derived symbol the body
5
+ // references in a VALUE context — the input to the scoring layer's declaration synthesis
6
+ // (@asmlift/cli declare.ts). It is a pure tree query with no pipeline state: the enumeration
7
+ // layer (rank.ts) calls it exactly once per candidate, on the tree the candidate's source was
8
+ // emitted from, at the moment the candidate is finalized. There is deliberately NO cached
9
+ // `symbolRefs` field on `SFn` — a carried field would oblige every future l3 pass to remember
10
+ // to recompute it (a dead-store DCE that drops a tree's only reference would otherwise leave a
11
+ // stale ref, transitively reintroducing the hazards the collector excludes). Deriving at the
12
+ // consumption point makes staleness impossible by construction.
13
+ import type { SymbolInfo } from '../symbols';
14
+ import { Expr, Stmt, exprChildren, stmtChildren, stmtExprs } from './ast';
15
+
16
+ /** One recorded map-symbol VALUE reference — a name the tree references plus its map facts. */
17
+ export interface SymbolRef {
18
+ name: string;
19
+ info: SymbolInfo;
20
+ /** NAME-ONLY symbols (no map shape): the bare off-0 access facts observed in the candidate's
21
+ * own IR — attached by the enumeration (rank.ts bareGlobalAccessFacts), consumed by the
22
+ * declaration synthesis (declare.ts) as the width/signedness authority for `extern T name;`. */
23
+ access?: { width: number; signed: boolean };
24
+ }
25
+
26
+ /** The map-derived symbols a structured body references in a VALUE context — the input to the
27
+ * scoring layer's declaration synthesis. A name counts when it appears as a `var`/`addr` leaf
28
+ * and the map knows it (bare `gSym`, `&gSym`, `(u32)Func`, a `field` base — all reduce to
29
+ * those leaves). A name that is ANY call's target is excluded entirely, even if also
30
+ * value-referenced: prototyping a called symbol `void F(void);` hard-errors under gcc-2.9
31
+ * when the call passes args, while leaving it undeclared keeps today's implicit-declaration
32
+ * behavior (the one honest option without arity knowledge). The function's OWN name
33
+ * (`selfName`) is excluded too — the candidate's definition IS its declaration, and a
34
+ * synthesized `void F(void);` above `s32 F(...)` is a conflicting-types hard error (a
35
+ * self-address reference resolves against the definition itself). */
36
+ export function collectSymbolRefs(body: Stmt[], symbols: Map<string, SymbolInfo>, selfName: string): SymbolRef[] {
37
+ const called = new Set<string>();
38
+ const valueRefs = new Set<string>();
39
+ const visitExpr = (e: Expr): void => {
40
+ if (e.k === 'call') {
41
+ called.add(e.fn);
42
+ } else if ((e.k === 'var' || e.k === 'addr') && symbols.has(e.name)) {
43
+ valueRefs.add(e.name);
44
+ }
45
+ exprChildren(e).forEach(visitExpr);
46
+ };
47
+ const visitStmt = (s: Stmt): void => {
48
+ // an `assign` carries its target as a NAME, not an Expr — a scalar global WRITE
49
+ // (`gSym = x;`) references the symbol every bit as much as a read does
50
+ if (s.k === 'assign' && symbols.has(s.name)) {
51
+ valueRefs.add(s.name);
52
+ }
53
+ stmtExprs(s).forEach(visitExpr);
54
+ stmtChildren(s).forEach(visitStmt);
55
+ };
56
+ body.forEach(visitStmt);
57
+ return [...valueRefs]
58
+ .filter((n) => !called.has(n) && n !== selfName)
59
+ .sort()
60
+ .map((n) => ({ name: n, info: symbols.get(n)! }));
61
+ }
@@ -0,0 +1,120 @@
1
+ // L3 structural simplification: a statement that ends EVERY arm of an `if` moves below the `if`.
2
+ //
3
+ // SSA destruction puts the same merge-variable write at the end of each arm, because each arm is
4
+ // where that edge's copy belongs:
5
+ //
6
+ // if (c) { v4 = 1; } else { g[594] = g[659]; v4 = 1; }
7
+ //
8
+ // The source wrote it once. Both arms execute it LAST on their own path, so hoisting it below the
9
+ // `if` runs it exactly once, on the same paths, in the same order relative to everything else —
10
+ // which is why this needs no liveness or dominance analysis and holds even for a side-effecting
11
+ // statement. It is the merge direction that is unconditionally sound: hoisting a common HEAD above
12
+ // the `if` would move it across the condition's own evaluation, which is not.
13
+ //
14
+ // Runs BEFORE `eliminateDeadStores`, whose empty-then peephole then flips the arm this empties:
15
+ //
16
+ // if (c) { } else { g[594] = g[659]; } v4 = 1; → if (!c) { g[594] = g[659]; } v4 = 1;
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.
22
+ //
23
+ // KNOWN INTERACTIONS, both byte-level rather than soundness. This pass is unconditional like
24
+ // `dce.ts` and `basecse.ts` rather than a differ-refereed lever, and the argument those files each
25
+ // state for themselves applies here too and was missing: a wrong merge changes recompiled bytes and
26
+ // surfaces as a LOST match under the zero-lost gate, never as wrong C.
27
+ //
28
+ // - it DEFEATS basecse's scalar-fixed-offset gate. That gate counts repeated constant offsets
29
+ // function-wide and refuses to hoist them; it was bought by losing the ProcessHBlankWait match.
30
+ // Deleting an arm's duplicate drops the count 2→1, so a base that gate would have refused is now
31
+ // hoisted. Systematic, not incidental.
32
+ // - it does NOT reach a fixpoint with `eliminateDeadStores`. A DIFFERING DEAD statement at the end
33
+ // of the arms hides the common tail, and DCE only removes it afterwards, so the shape this pass
34
+ // exists for is missed. The fix is a fixpoint of the pair, not one extra call — a lone second
35
+ // pass leaves an empty `if` behind.
36
+ //
37
+ // SCOPE. Only `assign`/`store`/`exprstmt` merge, compared structurally through `exprEquals`.
38
+ // Control flow (`break`/`continue`/`return`) is excluded: moving one out of an arm changes which
39
+ // statements the arm can still reach. Nested `if`/loop/`switch` statements are excluded because
40
+ // comparing them needs a full `Stmt` congruence, and there is no second inhabitant for one — the
41
+ // `Expr`-level comparison is the part that already exists, is tested, and is all this needs.
42
+ //
43
+ // An `ASMLIFT_ERROR` marker ending both arms merges like anything else. The gap stays loud (the
44
+ // artifact still refuses to compile) but `collectMarkers` then reports it once rather than twice,
45
+ // which is accurate — it is one gap that ran on both paths.
46
+ import type { SFn, Stmt } from './ast';
47
+ import { exprEquals } from './ast';
48
+
49
+ /** Statements this pass may move. Deliberately narrow — see SCOPE. */
50
+ type Mergeable = Extract<Stmt, { k: 'assign' } | { k: 'store' } | { k: 'exprstmt' }>;
51
+ const isMergeable = (s: Stmt): s is Mergeable => s.k === 'assign' || s.k === 'store' || s.k === 'exprstmt';
52
+
53
+ /** Do these two statements write the same thing from the same expression? */
54
+ function sameStmt(a: Stmt, b: Stmt): boolean {
55
+ if (!isMergeable(a) || !isMergeable(b) || a.k !== b.k) {
56
+ return false;
57
+ }
58
+ if (a.k === 'assign' && b.k === 'assign') {
59
+ return a.name === b.name && exprEquals(a.value, b.value);
60
+ }
61
+ if (a.k === 'store' && b.k === 'store') {
62
+ return exprEquals(a.lval, b.lval) && exprEquals(a.value, b.value);
63
+ }
64
+ const av = (a as Extract<Stmt, { k: 'exprstmt' }>).value;
65
+ const bv = (b as Extract<Stmt, { k: 'exprstmt' }>).value;
66
+ return exprEquals(av, bv);
67
+ }
68
+
69
+ /** Rewrite one statement, then the list it lives in. */
70
+ function rewrite(s: Stmt): Stmt[] {
71
+ const list = (xs: Stmt[]): Stmt[] => xs.flatMap(rewrite);
72
+ switch (s.k) {
73
+ case 'if': {
74
+ const then = list(s.then);
75
+ const els = list(s.else);
76
+ // Peel from the END of both arms while they agree. The length test is a precondition of the
77
+ // NEXT peel, not a floor: `if (c) { a } else { a }` peels until BOTH arms are empty, which is
78
+ // fine — `eliminateDeadStores` then drops the `if` and keeps its condition as an `exprstmt`
79
+ // only when the condition itself has a side effect. Note what that means for a condition that
80
+ // is a memory LOAD: a compare-and-branch present in the asm disappears from the emitted C,
81
+ // and the surviving bare `*(u16 *)&gReg;` is something the compiler may elide — so a read the
82
+ // original performed unconditionally becomes one it may not. Byte-level, and the differ sees
83
+ // it, but it is the one shape where a zero-arm merge is qualitatively unlike a partial one.
84
+ const tail: Stmt[] = [];
85
+ while (then.length > 0 && els.length > 0 && sameStmt(then[then.length - 1], els[els.length - 1])) {
86
+ tail.unshift(then[then.length - 1]);
87
+ then.pop();
88
+ els.pop();
89
+ }
90
+ return [{ ...s, then, else: els }, ...tail];
91
+ }
92
+ case 'while':
93
+ case 'dowhile':
94
+ return [{ ...s, body: list(s.body) }];
95
+ case 'for':
96
+ return [{ ...s, body: list(s.body) }];
97
+ case 'switch':
98
+ // Case bodies are NOT merged: a case that falls through to the next has no "end" of its own,
99
+ // so peeling its last statement would move code across a fall-through boundary.
100
+ return [
101
+ {
102
+ ...s,
103
+ cases: s.cases.map((c) => ({ ...c, body: list(c.body) })),
104
+ ...(s.default ? { default: list(s.default) } : {}),
105
+ },
106
+ ];
107
+ case 'assign':
108
+ case 'store':
109
+ case 'exprstmt':
110
+ case 'return':
111
+ case 'break':
112
+ case 'continue':
113
+ return [s];
114
+ }
115
+ }
116
+
117
+ /** Move every statement that ends all arms of an `if` below that `if`. */
118
+ export function mergeCommonTails(sfn: SFn): SFn {
119
+ return { ...sfn, body: sfn.body.flatMap(rewrite) };
120
+ }
package/src/l3/typing.ts CHANGED
@@ -27,6 +27,10 @@ export type VarTypes = (name: string) => IrType | undefined;
27
27
 
28
28
  export function declaredTypes(fn: SFn): VarTypes {
29
29
  const m = new Map<string, IrType>();
30
+ // shape-known project globals first, so a (theoretical) local of the same name wins
31
+ for (const g of fn.globals ?? []) {
32
+ m.set(g.name, g.type);
33
+ }
30
34
  for (const p of fn.params) {
31
35
  m.set(p.name, p.type);
32
36
  }