@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
package/src/ir/opcodes.ts CHANGED
@@ -130,11 +130,55 @@ export function opSig(opcode: string): OpSig | undefined {
130
130
  return (OPCODES as Record<string, OpSig | undefined>)[opcode];
131
131
  }
132
132
 
133
+ /** The comparison whose result is the logical NEGATION of each `icmp_*` — `!(a < b)` is `a >= b`.
134
+ *
135
+ * Unlike EFFECTFUL_OPS/HOIST_UNSAFE_OPS below, this is AUTHORED data seated beside the registry,
136
+ * not a view derived from it: nothing in `OPCODES` states which comparison opposes which. What is
137
+ * derived is its SYMMETRY — the five involutive pairs are expanded both ways, so `neg(neg(c)) === c`
138
+ * holds by construction (a hand-written map is one typo away from breaking it, and the symptom is a
139
+ * plainly inverted condition in the emitted C). Completeness against the icmp family is the part
140
+ * construction cannot give, so a test asserts it (test/pattern.test.ts) — an eleventh comparison
141
+ * added to `OPCODES` would otherwise degrade three consumers three different ways.
142
+ *
143
+ * It lives here for the reason HOIST_UNSAFE_OPS does: every consumer that has to say "the opposite
144
+ * of this compare" reads THIS one — the MIPS frontend's `slt …; beqz` branch-when-false fold, the
145
+ * short-circuit recognizer's diamond negation, and the idiom layer's `cmp ^ 1` fold — so they
146
+ * cannot drift apart the way inline copies did. Two adjacent facts worth knowing: raise/
147
+ * shortcircuit.ts derives its `BOOL_OPS` from these keys (asserting negatable-icmp == boolean-op,
148
+ * true today), and l3/ast.ts `NEGATE_REL` is the SAME relation over the neutral L3 operator
149
+ * vocabulary — deliberately separate, because signedness lives in the operand types there, so the
150
+ * two tables are not candidates for further consolidation. */
151
+ const ICMP_NEGATION_PAIRS: readonly (readonly [Opcode, Opcode])[] = [
152
+ ['icmp_eq', 'icmp_ne'],
153
+ ['icmp_slt', 'icmp_sge'],
154
+ ['icmp_sgt', 'icmp_sle'],
155
+ ['icmp_ult', 'icmp_uge'],
156
+ ['icmp_ugt', 'icmp_ule'],
157
+ ];
158
+ export const NEGATED_ICMP: Readonly<Record<string, Opcode>> = Object.fromEntries(
159
+ ICMP_NEGATION_PAIRS.flatMap(([a, b]) => [
160
+ [a, b],
161
+ [b, a],
162
+ ]),
163
+ );
164
+
133
165
  /** Ops with an observable side effect — the derived view raise/shortcircuit.ts consumes. */
134
166
  export const EFFECTFUL_OPS: ReadonlySet<string> = new Set(
135
167
  (Object.keys(OPCODES) as Opcode[]).filter((k) => (OPCODES[k] as OpSig).effects),
136
168
  );
137
169
 
170
+ /** Ops that may not be REORDERED across other code — `EFFECTFUL_OPS` plus `opaque`.
171
+ *
172
+ * `effects` is overloaded on two axes, and `opaque` is exactly the op that separates them: a dead
173
+ * `opaque` MUST stay deletable (`isDceSafe` below says so deliberately — giving it `effects: true`
174
+ * would strand dead opaques after every pattern rewrite, and they would surface as ASMLIFT_ERROR
175
+ * gaps in functions that emit cleanly today), while a LIVE one is an instruction asmlift could not
176
+ * model and must not be moved past anything. So "deletable when dead" and "movable when live" are
177
+ * different questions and get different views, both derived here rather than re-spelled per
178
+ * consumer — structure/analysis.ts and structure/structure.ts each carry their own inline copy of
179
+ * this membership, which is how the two models drifted apart in the first place. */
180
+ export const HOIST_UNSAFE_OPS: ReadonlySet<string> = new Set([...EFFECTFUL_OPS, 'opaque']);
181
+
138
182
  /** May a dead result of this opcode be deleted? Registered, no observable effects, not control
139
183
  * flow. Deliberately includes `opaque` — a dead opaque vanishing is designed behavior. */
140
184
  export function isDceSafe(opcode: string): boolean {
@@ -0,0 +1,72 @@
1
+ // asmlift — SSA cleanups that belong to the substrate, not to any one pass.
2
+ //
3
+ // Peer of `pattern/engine.ts`'s `dce`: general, opcode-agnostic, and callable by anything that has
4
+ // just changed the CFG.
5
+ import { Block, Fn, Successor, Value, replaceAllUsesWith } from './core';
6
+
7
+ /**
8
+ * Remove block params that are really TRIVIAL PHIS — those whose incoming args, across every
9
+ * predecessor edge and ignoring a self-reference from a back edge, are all one value. Such a param
10
+ * carries no join information, so it is replaced by that value and the arg dropped from each
11
+ * predecessor's terminator. Returns how many were removed.
12
+ *
13
+ * Iterated to a fixpoint: removing one phi can make the next trivial.
14
+ *
15
+ * DOMINANCE is free. If every predecessor passes `v`, then `v` is defined before each of those
16
+ * terminators and every path into the block goes through one of them — so `v` dominates the block
17
+ * and every use the param had. The self-reference waiver preserves that: a class where every edge
18
+ * passes the param itself has no first dynamic entry, so the first real entry always arrives on a
19
+ * `v`-passing edge.
20
+ *
21
+ * THE ENTRY BLOCK IS NEVER TOUCHED. Its params are the function's own parameters. Braun's
22
+ * construction in `frontend/ssa.ts` used to rely on entry having no in-edges to get this for free —
23
+ * which stops being true for an entry block that is ALSO a loop header, a shape this codebase does
24
+ * have (`raise/shortcircuit.ts` and `raise/retsink.ts` both guard it explicitly, the former after a
25
+ * reproduced silent miscompile). The guard is stated rather than inherited from an accident.
26
+ *
27
+ * `onRemoved` lets a caller drop its own bookkeeping for the retired param (the frontend's phi-block
28
+ * map); it is called once per removal, before the param is spliced out.
29
+ */
30
+ export function simplifyTrivialPhis(fn: Fn, onRemoved?: (param: Value) => void): number {
31
+ const edgesTo = (b: Block): Successor[] => {
32
+ const out: Successor[] = [];
33
+ for (const pb of fn.blocks) {
34
+ for (const op of pb.ops) {
35
+ for (const s of op.successors) {
36
+ if (s.block === b) {
37
+ out.push(s);
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return out;
43
+ };
44
+ let removed = 0;
45
+ for (;;) {
46
+ let changed = false;
47
+ for (const b of fn.blocks) {
48
+ if (b === fn.blocks[0]) {
49
+ continue;
50
+ }
51
+ const incoming = edgesTo(b);
52
+ for (let i = b.params.length - 1; i >= 0; i--) {
53
+ const param = b.params[i];
54
+ const distinct = [...new Set(incoming.map((s) => s.args[i]).filter((v) => v !== param))];
55
+ if (distinct.length !== 1) {
56
+ continue; // a genuine join, or unreachable (no in-edges at all)
57
+ }
58
+ replaceAllUsesWith(fn, param, distinct[0]);
59
+ onRemoved?.(param);
60
+ b.params.splice(i, 1);
61
+ for (const s of incoming) {
62
+ s.args.splice(i, 1);
63
+ }
64
+ removed++;
65
+ changed = true;
66
+ }
67
+ }
68
+ if (!changed) {
69
+ return removed;
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,216 @@
1
+ // L3 re-spelling lever: materialize the deref BASES of a call's arguments into locals, before the
2
+ // call.
3
+ //
4
+ // When a call's arguments are each a deref through a different fixed address, the compiler loads
5
+ // BOTH addresses before dereferencing either — it needs two registers live across the argument
6
+ // setup, so it emits the two pool loads first:
7
+ //
8
+ // ldr r0, .L4 <- both addresses
9
+ // ldr r1, .L4+0x4
10
+ // ldrb r0, [r0] <- then both loads
11
+ // ldrb r1, [r1, #0x8]
12
+ //
13
+ // Spelling the derefs INLINE in the argument list (`f(*(u8 *)0x4000006, gEntityArray[8])`) makes
14
+ // agbcc finish argument 0 before starting argument 1 — `ldr; ldrb; ldr; ldrb` — which is the same
15
+ // four instructions in a different order, and a nonmatch. The source that produces the target's
16
+ // order names the bases first (`vu8 *p = &REG_VCOUNT_L; u8 *e = gEntityArray; f(*p, e[8])`), which
17
+ // is what a decomp author writes and what this pass reproduces.
18
+ //
19
+ // A LEVER, not a rewrite: it is emitted as an ADDITIONAL candidate (rank.ts `/argbase`) and the
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.
22
+ //
23
+ // SEMANTICS ARE PRESERVED BY CONSTRUCTION, which matters because on a NONMATCH row the
24
+ // best-scoring candidate is what the user is shown. Only a PURE leaf base is eligible — a global's
25
+ // address (`addr`), a numeric pointer (`const`), or the bare name of a declared global — so
26
+ // evaluating it earlier can be neither observable nor faulting. A local variable is excluded: it
27
+ // may be assigned between the hoist point and the call, which would change what is dereferenced.
28
+ //
29
+ // KNOWN LIMITATION: the hoisted local is a plain `T *` — `IrType` models no cv-qualifier at all,
30
+ // so naming a VOLATILE cell through it drops the qualifier that macros.ts goes out of its way to
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
33
+ // than left to be rediscovered.
34
+ //
35
+ // GATE: at least TWO arguments of the same call must qualify, with DISTINCT bases. The reordering
36
+ // this reproduces only exists when two addresses compete for registers during argument setup — with
37
+ // ONE base there is nothing to interleave, so the compiler emits the same sequence either way and
38
+ // naming it is pure churn. (Evidence: on kleod:UpdateFadeEffect, hoisting only the first base
39
+ // leaves the diff at 2; both together take it to 0.)
40
+ import { type IrType, T, scalarTypeForAccess } from '../ir/types';
41
+ import type { Expr, SFn, Stmt } from './ast';
42
+ import { mapExprChildren, stmtExprs } from './ast';
43
+ import { nameAllocator } from './hoist';
44
+
45
+ /** A base this pass may evaluate early: pure, and not something a store can change under us. */
46
+ function eligibleBase(base: Expr, globals: ReadonlySet<string>): boolean {
47
+ return base.k === 'addr' || base.k === 'const' || (base.k === 'var' && globals.has(base.name));
48
+ }
49
+
50
+ /** THE identity of an eligible base — what makes two accesses "the same address".
51
+ *
52
+ * A global reaches this pass under TWO spellings: `addr g` (its address) and, when the symbol map
53
+ * types it as an array of the access width, the bare `var g`. They denote the same cell, so a
54
+ * structural comparison would count them as two addresses and defeat the gate on
55
+ * `f(*(u8 *)&g, g[4])` — the exact churn the gate exists to reject, reached by a different route.
56
+ * Named bases therefore key on the NAME alone, whichever node kind carries it. */
57
+ function baseIdentity(base: Expr): string {
58
+ return base.k === 'const' ? `c:${base.value}` : `n:${(base as Extract<Expr, { k: 'var' | 'addr' }>).name}`;
59
+ }
60
+
61
+ /** The `index` nodes directly under a call's arguments whose base is eligible — the candidates for
62
+ * materialization. Only the argument's OWN top-level deref counts: a base buried inside arbitrary
63
+ * argument arithmetic is not what the compiler is loading up front. */
64
+ function argBases(call: Extract<Expr, { k: 'call' }>, globals: ReadonlySet<string>): Extract<Expr, { k: 'index' }>[] {
65
+ const out: Extract<Expr, { k: 'index' }>[] = [];
66
+ for (const a of call.args) {
67
+ if (a.k === 'index' && !a.lead?.length && eligibleBase(a.base, globals)) {
68
+ out.push(a);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Distinct bases, in first-appearance order — the compiler loads each ADDRESS once.
75
+ *
76
+ * Keyed on the base alone, deliberately, even though the naming below keys on width too: the gate
77
+ * counts how many addresses compete for registers during argument setup, and two accesses of the
78
+ * same address at different widths are still ONE address. Counting them separately would pass the
79
+ * gate on `callee(*(u8 *)&g, *(u16 *)&g)`, where nothing reorders and there is nothing to fix. */
80
+ function distinctBases(nodes: Extract<Expr, { k: 'index' }>[]): Extract<Expr, { k: 'index' }>[] {
81
+ const out: Extract<Expr, { k: 'index' }>[] = [];
82
+ for (const n of nodes) {
83
+ if (!out.some((o) => baseIdentity(o.base) === baseIdentity(n.base))) {
84
+ out.push(n);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** The (base, width, signedness) key an `index` shares with every other access through the same
91
+ * base — so ALL of a base's uses in one call rewrite to the same local, not just the first. */
92
+ function baseKey(n: Extract<Expr, { k: 'index' }>): string {
93
+ return `${baseIdentity(n.base)} ${n.width} ${n.signed}`;
94
+ }
95
+
96
+ /**
97
+ * The `/argbase` re-spelling, or null when no statement qualifies (the caller then adds no
98
+ * candidate at all rather than a duplicate of the primary).
99
+ */
100
+ export function materializeArgBases(sfn: SFn): SFn | null {
101
+ const globals = new Set((sfn.globals ?? []).map((g) => g.name));
102
+ const fresh = nameAllocator(sfn);
103
+ const newLocals: { name: string; type: IrType }[] = [];
104
+ let fired = false;
105
+
106
+ // Rewrite ONE statement into the list that replaces it: the naming assignments, then the
107
+ // statement with its qualifying bases pointed at them. The naming goes immediately BEFORE the
108
+ // statement holding the call, not at the function top — the compiler loads these addresses where
109
+ // it needs them, and hoisting further extends live ranges the original never had (the
110
+ // register-pressure failure basecse.ts's loop gate exists for).
111
+ //
112
+ // NOTE the shape: recursion happens per FIELD, through an exhaustive switch, and the `pre`
113
+ // insertion happens INSIDE it. Rebuilding a statement from a flattened `stmtChildren` list cannot
114
+ // work — inserting statements shifts the boundary the rebuild would have to split at, and
115
+ // `stmtChildren('for')` is `[init, inc, ...body]`, which is not a body. Both mistakes produce
116
+ // COMPILING but wrong C (a call migrating across an if/else boundary; a `for` init duplicated
117
+ // into its body), which no boundary contract checks: they check resolution and spellability, not
118
+ // statement placement.
119
+ const rewrite = (s: Stmt): Stmt[] => {
120
+ const pre: Stmt[] = [];
121
+ const localFor = new Map<string, string>();
122
+ // Only the statement's OWN expressions can carry a call this pass names bases for; nested
123
+ // statement lists get their own `pre`, in their own scope, via the recursion below.
124
+ //
125
+ // A LOOP's own expression is its CONDITION, which runs every iteration — but `pre` lands
126
+ // BEFORE the loop, which would make this a loop-invariant hoist to a point the original never
127
+ // had. That is the register-pressure failure basecse.ts's `inLoop` gate exists to refuse, and
128
+ // it would contradict this pass's own placement rule two comments down. So a loop's condition
129
+ // is left alone; only its body (via the recursion) is eligible.
130
+ const ownExprs = s.k === 'while' || s.k === 'dowhile' || s.k === 'for' ? [] : stmtExprs(s);
131
+ for (const e of ownExprs) {
132
+ const scan = (x: Expr): void => {
133
+ if (x.k === 'call') {
134
+ const bases = distinctBases(argBases(x, globals));
135
+ if (bases.length >= 2) {
136
+ for (const b of bases) {
137
+ const key = baseKey(b);
138
+ if (localFor.has(key)) {
139
+ continue;
140
+ }
141
+ const ptrType = T.ptr(scalarTypeForAccess(b.width, b.signed));
142
+ const nm = fresh();
143
+ localFor.set(key, nm);
144
+ newLocals.push({ name: nm, type: ptrType });
145
+ pre.push({ k: 'assign', name: nm, value: { k: 'cast', to: ptrType, e: b.base } });
146
+ }
147
+ fired = true;
148
+ }
149
+ }
150
+ mapExprChildren(x, (c) => {
151
+ scan(c);
152
+ return c;
153
+ });
154
+ };
155
+ scan(e);
156
+ }
157
+ const point = (e: Expr): Expr => {
158
+ if (e.k === 'index' && !e.lead?.length && eligibleBase(e.base, globals)) {
159
+ const nm = localFor.get(baseKey(e));
160
+ if (nm) {
161
+ return { ...e, base: { k: 'var', name: nm }, idx: point(e.idx) };
162
+ }
163
+ }
164
+ return mapExprChildren(e, point);
165
+ };
166
+ const kids = (list: Stmt[]): Stmt[] => list.flatMap(rewrite);
167
+ let out: Stmt;
168
+ switch (s.k) {
169
+ case 'assign':
170
+ out = { ...s, value: point(s.value) };
171
+ break;
172
+ case 'store':
173
+ out = { ...s, lval: point(s.lval), value: point(s.value) };
174
+ break;
175
+ case 'exprstmt':
176
+ out = { ...s, value: point(s.value) };
177
+ break;
178
+ case 'return':
179
+ out = s.value === undefined ? s : { ...s, value: point(s.value) };
180
+ break;
181
+ case 'if':
182
+ out = { ...s, cond: point(s.cond), then: kids(s.then), else: kids(s.else) };
183
+ break;
184
+ case 'while':
185
+ case 'dowhile':
186
+ out = { ...s, cond: point(s.cond), body: kids(s.body) };
187
+ break;
188
+ case 'for': {
189
+ // `init`/`inc` are single statements. A `pre` produced inside either has nowhere legal to
190
+ // go (before the loop changes when it runs; inside the body repeats it), so this pass
191
+ // declines to fire there and leaves them alone.
192
+ out = { ...s, cond: point(s.cond), body: kids(s.body) };
193
+ break;
194
+ }
195
+ case 'switch':
196
+ out = {
197
+ ...s,
198
+ scrutinee: point(s.scrutinee),
199
+ cases: s.cases.map((c) => ({ ...c, body: kids(c.body) })),
200
+ ...(s.default ? { default: kids(s.default) } : {}),
201
+ };
202
+ break;
203
+ case 'break':
204
+ case 'continue':
205
+ out = s;
206
+ break;
207
+ }
208
+ if (pre.length === 0) {
209
+ return [out];
210
+ }
211
+ return [...pre, out];
212
+ };
213
+
214
+ const body = sfn.body.flatMap(rewrite);
215
+ return fired ? { ...sfn, body, locals: [...sfn.locals, ...newLocals] } : null;
216
+ }
package/src/l3/ast.ts CHANGED
@@ -38,12 +38,18 @@ export type Expr =
38
38
  // Variable-index `a[i]` is recovered at the IR level (`aload`/`astore` carry elemSize;
39
39
  // raise/arrays.ts) but still LOWERS to this one C-shaped `index` node, so it stays C-only
40
40
  // (a Pascal array-access spelling is future work). Treat `index` with idx ≠ 0 as C-shaped.
41
- | { k: 'index'; base: Expr; idx: Expr; width: number; signed: boolean }
41
+ // `lead` prefixes CONSTANT subscripts before `idx` `g[0][i]` rather than `g[i]`. It exists for
42
+ // exactly one inhabitant: the bare-name spelling of a MULTIDIMENSIONAL array global, where one
43
+ // subscript reaches a row and the element needs the leading dimensions pinned first. The node
44
+ // still denotes ONE `width`-byte element, so its type, its legalization and its stride contract
45
+ // are unchanged — this is a spelling of the same address, not a new kind of access. Absent for
46
+ // every rank-1 access, which is why it is optional rather than an empty array.
47
+ | { k: 'index'; base: Expr; idx: Expr; width: number; signed: boolean; lead?: number[] }
42
48
  // A named struct-field access `base->name` (raise/structs.ts recovered `base` as a struct
43
49
  // pointer, so the byte offset resolves to a named field instead of a scaled array index).
44
50
  // Unlike `index`, this carries the field NAME (which encodes the byte offset, `field_<off>`),
45
51
  // not a width-scaled number — the byte-offset-carrying member access cpp.ts's sub-word guard needs.
46
- | { k: 'field'; base: Expr; name: string }
52
+ | { k: 'field'; base: Expr; name: string; dot?: true }
47
53
  // A GAP MARKER — the annotate-mode (`onGap: "annotate"`) spelling of a value asmlift could not
48
54
  // faithfully lift (an unmodelled instruction's `opaque` result, an unlowered transient op, a
49
55
  // dropped def). Every backend spells it as a call to the UNDEFINED symbol `ASMLIFT_ERROR("reason",
@@ -53,8 +59,45 @@ export type Expr =
53
59
  // default) never produces this node; it keeps the `"?"` sentinel → ContractError behavior.
54
60
  | { k: 'marker'; reason: string; args: Expr[] };
55
61
 
62
+ // `>>` is the ARITHMETIC right shift and `>>>` the LOGICAL one. C spells both `>>` and picks from
63
+ // the left operand's type, so the C backend synthesizes the cast that pins the choice — exactly as
64
+ // it already synthesizes scalar deref casts from an `index` node's width. A backend with no
65
+ // spelling for one of them (IDO Pascal) declines LOUDLY on the operation itself, rather than on
66
+ // whatever artifact another language's spelling happened to leave in the tree.
67
+ //
68
+ // WHY THIS ONE SPLIT AND NOT THE OTHERS. "The machine distinguishes them" is NOT the rule — the
69
+ // machine distinguishes `divu`/`div` and `sltu`/`slt` too, and ARITH_TO_BIN deliberately collapses
70
+ // `udiv`→`/`, `umod`→`%`, `icmp_u*`→`<` etc., noting that "unsignedness is in the operand types".
71
+ // Taking the machine as the rule would license four more splits with no inhabitant, which is what
72
+ // "earn the level" forbids. The rule is the repo's own: the shift split because a real,
73
+ // byte-load-bearing divergence HAD inhabitants (~20 rows, 5 projects, 4 compilers) and no other
74
+ // channel could carry it — the operand type could not, since a promoted narrow value is signed
75
+ // whatever it was loaded as.
76
+ //
77
+ // The collapsed operators lean on exactly that channel, so they carry the same latent hazard:
78
+ // `*(u16 *)p / 3` renders as a signed division of a promoted `int` where the asm did `divu`. It is
79
+ // tolerated because no row has produced such a divergence. When one does, the fix is this same
80
+ // split — not a per-site patch.
56
81
  export type BinOp =
57
- '+' | '-' | '*' | '/' | '%' | '<' | '<=' | '>' | '>=' | '==' | '!=' | '&' | '|' | '^' | '<<' | '>>' | '&&' | '||';
82
+ | '+'
83
+ | '-'
84
+ | '*'
85
+ | '/'
86
+ | '%'
87
+ | '<'
88
+ | '<='
89
+ | '>'
90
+ | '>='
91
+ | '=='
92
+ | '!='
93
+ | '&'
94
+ | '|'
95
+ | '^'
96
+ | '<<'
97
+ | '>>'
98
+ | '>>>'
99
+ | '&&'
100
+ | '||';
58
101
 
59
102
  export type Stmt =
60
103
  | { k: 'assign'; name: string; value: Expr }
@@ -111,6 +154,10 @@ export interface SFn {
111
154
  name: string;
112
155
  params: { name: string; type: IrType }[];
113
156
  locals: { name: string; type: IrType }[]; // recovered locals, declared at function top
157
+ /** project globals referenced with a known declaration shape (symbol map) — typed for the
158
+ * legalization env (exprCType) but NEVER declared by a backend: the project's own headers
159
+ * declare them, exactly like every other global name asmlift emits. */
160
+ globals?: { name: string; type: IrType }[];
114
161
  retType: IrType;
115
162
  body: Stmt[];
116
163
  /** Struct types this function's fields reference, declared above it by the backend. Empty
@@ -148,7 +195,9 @@ export function dotBase(f: Extract<Expr, { k: 'field' }>): Extract<Expr, { k: 'i
148
195
 
149
196
  /** Boolean projection of `dotBase` for conditions that need no narrowing. */
150
197
  export function fieldSpellsDot(f: Extract<Expr, { k: 'field' }>): boolean {
151
- return dotBase(f) !== undefined;
198
+ // dot also spells a STRUCT-VALUE global's field (`gSym.field`, the symbol-map layout path)
199
+ // marked explicitly by the structurer via `dot: true` since the base is a `var`, not an index.
200
+ return dotBase(f) !== undefined || f.dot === true;
152
201
  }
153
202
 
154
203
  /** Structural equality of two expression trees. THE one copy of Expr deep-equal (like
@@ -183,11 +232,27 @@ export function exprEquals(a: Expr, b: Expr): boolean {
183
232
  }
184
233
  case 'index': {
185
234
  const bb = b as typeof a;
186
- return a.width === bb.width && a.signed === bb.signed && exprEquals(a.base, bb.base) && exprEquals(a.idx, bb.idx);
235
+ // `lead` is part of the ADDRESS (`g[0][i]` and `g[1][i]` are different elements), so it
236
+ // must be compared — an omission here would let CSE/dedup collapse two distinct accesses.
237
+ const lead = a.lead ?? [];
238
+ const bLead = bb.lead ?? [];
239
+ return (
240
+ a.width === bb.width &&
241
+ a.signed === bb.signed &&
242
+ lead.length === bLead.length &&
243
+ lead.every((v, i) => v === bLead[i]) &&
244
+ exprEquals(a.base, bb.base) &&
245
+ exprEquals(a.idx, bb.idx)
246
+ );
187
247
  }
188
248
  case 'field': {
189
249
  const bb = b as typeof a;
190
- return a.name === bb.name && exprEquals(a.base, bb.base);
250
+ // `dot` is part of the SPELLING, and for the same reason `lead` is compared above: a CSE or
251
+ // dedup that treats these as equal keeps one node and discards the other, silently respelling
252
+ // `p->field_4` as `p.field_4` (or the reverse). Both compile only for the base type each
253
+ // belongs to, so collapsing them is how a valid access becomes an invalid one — or worse, a
254
+ // valid one against a different object.
255
+ return a.name === bb.name && (a.dot ?? false) === (bb.dot ?? false) && exprEquals(a.base, bb.base);
191
256
  }
192
257
  case 'marker': {
193
258
  const bb = b as typeof a;
@@ -299,3 +364,58 @@ export function stmtChildren(s: Stmt): Stmt[] {
299
364
  return [...s.cases.flatMap((c) => c.body), ...(s.default ?? [])];
300
365
  }
301
366
  }
367
+
368
+ // THE negation of a CONDITION — the one implementation, shared by every L3 pass that flips one.
369
+ //
370
+ // There were two, and they drifted: structure.ts's empty-then peephole learned to distribute over
371
+ // the short-circuit connectives while l3/dce.ts's copy kept wrapping in `!`, and because
372
+ // `eliminateDeadStores` runs AFTER structuring it re-introduced the very spelling the other one had
373
+ // just removed. That is the l3/hoist.ts failure mode verbatim — a copied helper silently losing the
374
+ // newer rule — so this lives with the AST vocabulary and the passes call it.
375
+ //
376
+ // Three rules, in order:
377
+ // 1. a relational operator flips directly (`!=` → `==`, `<` → `>=`, …), exact over C's total
378
+ // integer order;
379
+ // 2. DE MORGAN — `!(a && b)` becomes `!a || !b`. Sound including EVALUATION ORDER: `a && b` runs
380
+ // `b` only when `a` holds, and `!a || !b` runs `!b` only when `!a` is false, i.e. when `a`
381
+ // holds. Same operands, same inputs — which is what makes it safe over a `b` that loads. It
382
+ // matters because a source `&&` and its dual `||` compile to the SAME branch graph, so the
383
+ // recognizers in raise/shortcircuit.ts can only pick whichever the asm's branch senses spell;
384
+ // distributing is what lets the `/flip-branch` candidate reach the other one;
385
+ // 3. `!!x` collapses to `x`, reachable only from a double flip that rule 2 now produces.
386
+ //
387
+ // CONTEXT REQUIREMENT, and it is the reason this is `negateCond` and not `negate`: rule 3 is valid
388
+ // only in a TRUTH-VALUE context, where `x` and `!!x` are interchangeable. `!!5` is 1 and `5` is 5,
389
+ // so this must never be used to negate a general integer expression — only an `if`/loop test or an
390
+ // operand of one of the connectives above.
391
+ //
392
+ // SCOPE of rule 2: it only gives the differ a second spelling where a candidate lever already flips
393
+ // the condition, and `preserveDivergentBranchSense` covers divergent `if`s ONLY. A connective that
394
+ // ended up as a LOOP test therefore has no dual candidate at all — the differ never sees the other
395
+ // form, so on such a row this rule changes how the code READS and nothing else. Widening the
396
+ // branch-sense lever to loop tests is what would make it a matching lever there, and that is a
397
+ // separate change.
398
+ const NEGATE_REL: Partial<Record<BinOp, BinOp>> = {
399
+ '<': '>=',
400
+ '>=': '<',
401
+ '>': '<=',
402
+ '<=': '>',
403
+ '==': '!=',
404
+ '!=': '==',
405
+ };
406
+
407
+ export function negateCond(e: Expr): Expr {
408
+ if (e.k === 'bin') {
409
+ const flipped = NEGATE_REL[e.op];
410
+ if (flipped) {
411
+ return { ...e, op: flipped };
412
+ }
413
+ if (e.op === '&&' || e.op === '||') {
414
+ return { ...e, op: e.op === '&&' ? '||' : '&&', l: negateCond(e.l), r: negateCond(e.r) };
415
+ }
416
+ }
417
+ if (e.k === 'un' && e.op === '!') {
418
+ return e.e;
419
+ }
420
+ return { k: 'un', op: '!', e };
421
+ }
package/src/l3/basecse.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
22
22
  import type { Expr, SFn, Stmt } from './ast';
23
23
  import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
24
+ import { nameAllocator } from './hoist';
24
25
 
25
26
  // A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
26
27
  // address). Both are relocation-invariant leaves whose value the compiler keeps in one register
@@ -135,17 +136,6 @@ function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
135
136
  }
136
137
  }
137
138
 
138
- /** A name not already used by a param/local/global in `sfn`, of the form `p<n>`. */
139
- function freshName(taken: Set<string>): string {
140
- let n = 0;
141
- while (taken.has(`p${n}`)) {
142
- n++;
143
- }
144
- const nm = `p${n}`;
145
- taken.add(nm);
146
- return nm;
147
- }
148
-
149
139
  export function hoistReusedGlobalBases(sfn: SFn): SFn {
150
140
  const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
151
141
  collect(sfn.body, c, false);
@@ -171,9 +161,7 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
171
161
  return sfn;
172
162
  }
173
163
 
174
- const taken = new Set<string>([...sfn.params.map((p) => p.name), ...sfn.locals.map((l) => l.name)]);
175
- // globals are referenced by name; a hoist name must not shadow one that appears in the body.
176
- collectNames(sfn.body, taken);
164
+ const fresh = nameAllocator(sfn);
177
165
 
178
166
  const localFor = new Map<string, string>();
179
167
  const newLocals: { name: string; type: IrType }[] = [];
@@ -181,7 +169,7 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
181
169
  for (const k of hoisted) {
182
170
  const m = meta.get(k)!;
183
171
  const ptrType = T.ptr(scalarTypeForAccess(m.width, m.signed));
184
- const nm = freshName(taken);
172
+ const nm = fresh();
185
173
  localFor.set(k, nm);
186
174
  newLocals.push({ name: nm, type: ptrType });
187
175
  // `p = (T *)base` — the cast makes the local the access's pointer type so each `p[i]` strides it.
@@ -191,28 +179,3 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
191
179
  const body = [...hoistStmts, ...sfn.body.map((s) => rewriteStmt(s, localFor))];
192
180
  return { ...sfn, body, locals: [...sfn.locals, ...newLocals] };
193
181
  }
194
-
195
- /** Every `var`/`addr`/called-function name mentioned anywhere in `stmts` (so a hoist name collides
196
- * with none — a global via `addr`, a local via `var`, OR a callee via `call.fn`). */
197
- function collectNames(stmts: Stmt[], out: Set<string>): void {
198
- const walk = (e: Expr): void => {
199
- if (e.k === 'var' || e.k === 'addr') {
200
- out.add(e.name);
201
- }
202
- if (e.k === 'call') {
203
- out.add(e.fn); // a hoist local must not shadow a called function symbol
204
- }
205
- for (const c of exprChildrenOf(e)) {
206
- walk(c);
207
- }
208
- };
209
- for (const s of stmts) {
210
- if (s.k === 'assign') {
211
- out.add(s.name);
212
- }
213
- for (const e of stmtExprs(s)) {
214
- walk(e);
215
- }
216
- collectNames(stmtChildren(s), out);
217
- }
218
- }