@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/l3/basecse.ts CHANGED
@@ -1,5 +1,6 @@
1
- // L3 pass: hoist a REUSED pointer base (a global address or a numeric pointer constant) into a
2
- // typed local pointer.
1
+ // L3 pass: hoist a leaf pointer base (a global address or a numeric pointer constant) into a
2
+ // typed local pointer. HOW MANY uses a base needs is the gate table's question, not the pass's:
3
+ // the default table wants 2+, `BASEFOLD_GATES` admits one under the conditions below.
3
4
  //
4
5
  // A base indexed at 2+ sites — `((u8 *)&gTable)[i+5]` and `[i+6]`, or the MMIO/RAM constant
5
6
  // `((s32 *)0x40000d4)[0]`, `[1]`, `[2]` — re-materialized the address (a fresh pool load) at each
@@ -8,21 +9,108 @@
8
9
  // base into a local pointer `T *p = (T *)base` and points each access at `p`, so the recompiled code
9
10
  // keeps the address in one register instead of reloading it.
10
11
  //
11
- // SCOPE / SOUNDNESS. Only an `index` node whose base is a bare `addr` (a global address) or a bare
12
- // `const` (a numeric pointer address) is eligible, and only when 2+ such nodes share the SAME
13
- // (base, width, signedness) an AGGREGATE base (F9 spells a SCALAR global as a bare `var`, which is
14
- // never an `index`-of-leaf, so scalar recovery is untouched). Non-leaf bases (a local, a
15
- // struct-element `p[a0]`, arithmetic) are excluded: agbcc may re-derive those, so hoisting them can
16
- // MISMATCH (empirically confirmed). The hoisted local carries the access's pointer type, so the
12
+ // WHICH bases. The pass hoists every key its gate list admits, which answers per FUNCTION a
13
+ // question the source answered per BASE one register file spelled as a pointer local beside
14
+ // scalar cells spelled as bare derefs. The `single-cell` gate is what makes the narrower answer
15
+ // reachable: under `LIVEBASE_BLOCK_GATES` a base every access of which is ONE fixed offset stays
16
+ // inline, and rank's LIVEBASE_HOISTS roster emits each table's hoist and every composition onto
17
+ // it as its own candidate family, for the differ to referee between them. The unit is
18
+ // the (base, width, signedness) KEY, not the base — a base read at two widths is two keys, and the
19
+ // gate can leave one of them inline while the other binds.
20
+ //
21
+ // COVERAGE: the roster (rank.ts) is SEVEN rows over FIVE gate tables — two PAIRS share a table and
22
+ // differ only in placement, `/basefold` with `/basefold/sinkinit` and `/orderbase` with
23
+ // `/orderbase-scoped` — and it is a set of hand-picked SUBSETS rather than a narrowness ranking;
24
+ // only `/livebase` ⊇ `/livebase-block` are ordered by inclusion. A table
25
+ // whose predicate cuts across the others therefore carves out a PARTIAL answer, which is what
26
+ // `UNFOLDED_GATES` does. Measured at ONE stated scope, `decompile()`'s default structuring,
27
+ // map-less, one tree per row over the 363 agbcc rows the artifact carried when the census ran
28
+ // (23 unstructurable):
29
+ // `admittedBases(sfn, UNFOLDED_GATES)` is a PROPER NONEMPTY SUBSET of
30
+ // `admittedBases(sfn, LIVEBASE_GATES)` on THIRTEEN of them — `kleod:ConfigureEntityBehavior` 3→2,
31
+ // `kleod:ProcessInputAndUpdateEntities` 7→5, `kleod:UpdateCameraScroll` 5→2,
32
+ // `sa3:Sio32MultiLoadIntr` 5→1 and `synthetic:unfoldpark` 3→2 among them — and on
33
+ // `synthetic:dmascope`, a function with THREE bases `single-cell` does not reject, exactly ONE
34
+ // binds. So "some of the several" is reachable wherever a PREDICATE separates the bases. What
35
+ // stays unreachable is an ARBITRARY subset: a table is a conjunction of gates and so denotes ONE
36
+ // predicate over `BaseKey`, which means the only subsets on offer are the ones some predicate
37
+ // carves, and choosing per key by score is the per-base policy assignment nobody has built.
38
+ // EVERY COUNT HERE IS SCOPED TO THAT ONE STRUCTURING and to the artifact's row count on the day —
39
+ // a wider census (every structuring `enumerateCandidates` builds) sees keys this one never mints,
40
+ // and the corpus grows. Re-run rather than read.
41
+ //
42
+ // WHAT THE ASM SUGGESTS, AND WHY IT IS A CANDIDATE. `single-use` refuses a base reached once, on
43
+ // the theory that one access re-materializes as cheaply as a named local. A surviving `[rN, #imm]`
44
+ // off a register holding a bare address is EVIDENCE against that theory, on a compiler that folds
45
+ // a constant SUBSCRIPT into the literal it materializes
46
+ // (TargetDescription.compilerBehaviors.foldsConstAddrOffset, where the compiled pair is).
47
+ // `BaseKey.unfoldedOffset` is that shape: the access's constant offset reached the MEMORY OPERAND
48
+ // rather than the materialized literal, which `l3/ast.ts`'s `index.operandOff` carries down from
49
+ // the lift because the fold at L3 makes the two indistinguishable.
50
+ // HOW STRONG THE EVIDENCE IS, compiled in both directions rather than reasoned about — and the
51
+ // answer differs between the two base kinds, so read the one you are looking at.
52
+ // For a NUMERIC base every inline shape tried spends the offset somewhere the operand does not
53
+ // see it: one read folds it into the literal (`.word 0x3001103` + `ldrb [r0]`); several reads at
54
+ // several offsets share ONE pool word and pay `sub`/`add` per access; a store pair agbcc CSEs
55
+ // (`*(u8 *)0x3001100 = v; *(u16 *)0x3001102 = v;` → one pool word plus `add r1, r1, #0x2`) spends
56
+ // it on an add, which the frontend folds back into the address; and a read whose bare address is
57
+ // ALSO used as a value takes a SECOND literal (`.word 0x3001100` + `.word 0x3001103`) rather than
58
+ // an operand offset.
59
+ // For a SYMBOL base that last shape is a COUNTEREXAMPLE, and it is the one thing that separates
60
+ // the two. `void live(void){ sink((int)(u8 *)&gS); sink(((u8 *)&gS)[3]); }` emits ONE `.word gS`
61
+ // and `ldrb r0, [r4, #0x3]` — agbcc CSEs the symbol reference where it re-materializes the integer
62
+ // — so the inline subscript spelling produces exactly the shape this rule reads as evidence
63
+ // against it. asmlift lifts that asm back to the correct `((u8 *)&gS)[3]` and then offers the
64
+ // spelling through a named base anyway. Measured reach: of the 21 keys the symbol half newly admits over
65
+ // the artifact's agbcc rows in both symbol-map configurations, 4 are on a base whose address the
66
+ // tree also uses as a value (2 distinct keys, on `kleod:ProcessInputAndUpdateEntities` and
67
+ // `pokeemerald:TrySetCantSelectMoveBattleScript`).
68
+ // So on the symbol half this is weaker than evidence-with-two-known-exceptions: it is a hint with
69
+ // a live counterexample, which is precisely why it is a ROSTER ADMISSION and not a gate relaxation
70
+ // — the inline spelling rides beside it in every case and `compareScored` orders by score, so the
71
+ // hint FIRING wrongly costs a candidate compile and never a match. Note which direction that
72
+ // covers: it does not say the flag is free to LOSE. A `/basefold*` candidate that is never
73
+ // enumerated takes whatever it would have won with it — deleting the HEAD roster row costs
74
+ // `synthetic:foldhead` its match — which is why `index.operandOff` is carried from the lift
75
+ // instead of re-derived, and why a committed pass that can drop it is worth a test
76
+ // (test/basecse.test.ts, the "`operandOff` is provenance" describe). WHAT EACH ROW IS WORTH is
77
+ // measured in rank.ts's note on `BASEFOLD_HOISTS`, not here, and the two rows are not worth
78
+ // the same thing. Promoting the hint to a default would need this paragraph to say something it
79
+ // does not.
80
+ //
81
+ // It is EVIDENCE and not proof, which is why `BASEFOLD_GATES` below backs a variation rather than a
82
+ // relaxation of the default table. agbcc folds a subscript but keeps an aggregate MEMBER offset in
83
+ // the memory operand: `((struct S *)0x3001100)->b` emits `.word 0x3001100` + `ldr [r0, #0x4]`,
84
+ // byte-identical to the spelling through a named base, and the same holds for a union member and for a
85
+ // store. So the shape has two sources and asmlift can spell only one of them; rank.ts offers both
86
+ // and the differ referees.
87
+ //
88
+ // A SYMBOL base reads the same rule, one exception weaker (above). agbcc folds a symbol's offset
89
+ // as it folds a numeric one — `((u8 *)&gSym)[3]` emits `.word gSym+0x3` + `ldrb [r1]` where
90
+ // `gSym.d` and `u8 *p = (u8 *)&gSym; p[3]` both emit `.word gSym` + `ldrb [r1, #0x3]` — and the
91
+ // relocation's addend arrives as an explicit `add` where the operand offset arrives as the load's
92
+ // own, so the two are one flag apart at the point the offsets fold together.
93
+ //
94
+ // SCOPE / SOUNDNESS. Only an `index` node whose base is a bare `addr` (a global address), a bare
95
+ // `const` (a numeric pointer address) or a REINTERPRET CAST of one of those (see `isHoistableBase`)
96
+ // is eligible, keyed by (base, width, signedness) — never an
97
+ // AGGREGATE base (F9 spells a SCALAR global as a bare `var`, which is never an `index`-of-leaf, so
98
+ // scalar recovery is untouched). Non-leaf bases (a local, a struct-element `p[a0]`,
99
+ // arithmetic) are excluded: agbcc may re-derive those, so hoisting them can
100
+ // MISMATCH (empirically confirmed) — the differ-refereed `/addr-home` variation
101
+ // (structure/analysis.ts homeSharedAddresses) serves the shared gaddr-free ARITHMETIC bases
102
+ // instead.
103
+ // The hoisted local carries the access's pointer type, so the
17
104
  // deref cast the C backend applied inline at each `index` now lands ONCE on the local's initializer
18
105
  // and the accesses stride correctly with no per-use cast. A wrong hoist (a base agbcc would actually
19
106
  // re-materialize) only changes recompiled bytes -> a LOST match under the zero-lost gate, never a
20
107
  // miscompile: the address value is identical, just held in a different place.
21
- import { type IrType, T, scalarTypeForAccess } from '../ir/types';
108
+ import { assertHoistsDominate } from '../contracts';
109
+ import { type IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
22
110
  import type { Expr, SFn, Stmt } from './ast';
23
- import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
24
- import { type Gate, firstRejection } from './gates';
25
- import { nameAllocator } from './hoist';
111
+ import { exprChildren, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
112
+ import { type Gate, ablateHeuristic, firstRejection } from './gates';
113
+ import { type BaseInit, type HoistPlacement, nameAllocator, placeBaseLocals } from './hoist';
26
114
 
27
115
  // A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
28
116
  // address). Both are relocation-invariant leaves whose value the compiler keeps in one register
@@ -31,18 +119,71 @@ import { nameAllocator } from './hoist';
31
119
  // argbase.ts take is the obvious consolidation and it is wrong twice over: this pass has no `lead`
32
120
  // handling, so a rank-aware `g[0][i]` comes out as `p[0][i]` through a scalar pointer, and it
33
121
  // undoes raise/gvn.ts's hoist on exactly the rows a symbol map serves (test/addr-placement.test.ts).
34
- type HoistableBase = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
35
- const isHoistableBase = (e: Expr): e is HoistableBase => e.k === 'addr' || e.k === 'const';
36
- const baseId = (b: HoistableBase): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
122
+ //
123
+ // …and a REINTERPRET CAST of one of those leaves, `((struct S *)&gSym)[i]`, which is the same leaf
124
+ // wearing the pointer type its element needs. The structurer emits it for an array of STRUCTS
125
+ // (structure.ts arrayAccess's `fieldOff` path), where the scalar `scalarTypeForAccess` this pass
126
+ // otherwise mints is meaningless — a 28-byte element has no `intType`. The cast is the base's
127
+ // SPELLING rather than a different base, so it hoists to `struct S *p = (struct S *)&gSym`, the
128
+ // accesses stride it exactly as a scalar key's do, and the KEY carries the cast's target type
129
+ // because two casts over one symbol stride differently and are two locals. Refused when the cast's
130
+ // target is not a pointer (nothing an index can stride) and when it is `volatile`: dropping that
131
+ // qualifier onto a non-volatile local makes every access through the local a plain one, a silent
132
+ // change of meaning rather than of bytes — volatility on a hoisted local is `l3/volatileptr.ts`'s
133
+ // question, asked of the local and not of the leaf.
134
+ type HoistableLeaf = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
135
+ type HoistableCast = Extract<Expr, { k: 'cast' }> & { e: HoistableLeaf };
136
+ type HoistableBase = HoistableLeaf | HoistableCast;
137
+ const isHoistableLeaf = (e: Expr): e is HoistableLeaf => e.k === 'addr' || e.k === 'const';
138
+ const isHoistableBase = (e: Expr): e is HoistableBase =>
139
+ isHoistableLeaf(e) || (e.k === 'cast' && e.to.kind === 'ptr' && e.volatile !== true && isHoistableLeaf(e.e));
140
+ /** A hoistable LEAF's identity, ignoring the access width: `a:<symbol>` or `c:<numeric address>`.
141
+ * Exported because `l3/homesplit.ts` identifies bases in this same vocabulary, and two producers
142
+ * of one string are two places for the spelling to drift. */
143
+ export const leafId = (b: HoistableLeaf): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
144
+ const baseId = (b: HoistableBase): string => (b.k === 'cast' ? `${leafId(b.e)} <${typeToString(b.to)}>` : leafId(b));
37
145
 
38
146
  /** The (base, access-shape) key an `index`-of-hoistable-base shares with its reuse siblings. */
39
147
  const keyOf = (base: HoistableBase, width: number, signed: boolean): string => `${baseId(base)} ${width} ${signed}`;
40
148
 
149
+ /** The key's own grammar, read back — `<leafId>[ <type>] <width> <signed>`.
150
+ *
151
+ * IT LIVES BESIDE `keyOf` BECAUSE THAT IS THE ONLY THING THAT MAKES IT SAFE. The key is a string
152
+ * and its readers are elsewhere — `l3/homesplit.ts` builds a candidate's VARIATION out of it, and a
153
+ * candidate's variations are its identity — so a second file knowing this grammar is a collision waiting
154
+ * for the next base kind (`homeSplitTag` states the one the cast form causes).
155
+ *
156
+ * The one space inside a cast's base id is this grammar's own separator, not the type's: every
157
+ * type this pass can put there spells without one (`u16*`, `Struct0`, `u8[4]`). A struct's name is
158
+ * DATA, though — synthetic today, DWARF later — so the parse below reads the type as everything
159
+ * between the separator and the closing `>` rather than as one word. */
160
+ export interface BaseKeyParts {
161
+ /** the hoistable leaf, `a:<symbol>` or `c:<numeric address>` */
162
+ readonly leaf: string;
163
+ /** the reinterpret cast's target type as `typeToString` spells it, or null for a bare leaf */
164
+ readonly castType: string | null;
165
+ readonly width: number;
166
+ readonly signed: boolean;
167
+ }
168
+ export function parseBaseKey(key: string): BaseKeyParts {
169
+ const parts = key.split(' ');
170
+ const signed = parts.pop() === 'true';
171
+ const width = Number(parts.pop());
172
+ const id = parts.join(' ');
173
+ const lt = id.indexOf(' <');
174
+ return {
175
+ leaf: lt === -1 ? id : id.slice(0, lt),
176
+ castType: lt === -1 ? null : id.slice(lt + 2, -1),
177
+ width,
178
+ signed,
179
+ };
180
+ }
181
+
41
182
  interface Collected {
42
183
  count: Map<string, number>;
43
184
  order: string[];
44
185
  meta: Map<string, { base: HoistableBase; width: number; signed: boolean }>;
45
- /** keys with ANY use inside a loop — disqualified (see the loop note in `hoistReusedGlobalBases`). */
186
+ /** keys with ANY use inside a loop — disqualified (see the loop note in `hoistBaseLocals`). */
46
187
  inLoop: Set<string>;
47
188
  /** per key, how many times each CONSTANT offset was accessed — the input to the
48
189
  * `repeated-const-offset` gate, which losing the ProcessHBlankWait match is what bought. A
@@ -50,9 +191,21 @@ interface Collected {
50
191
  * tallied); a repeat means a scalar re-access, and ONE is enough to disqualify the base even
51
192
  * when it also has distinct-offset uses. */
52
193
  constOffCount: Map<string, Map<number, number>>;
194
+ /** keys indexed by a NON-constant expression somewhere — an array walk, so the base reaches a
195
+ * BLOCK of cells however few constant offsets it also touches (see `single-cell`). */
196
+ varIndexed: Set<string>;
197
+ /** keys with an access whose constant offset arrived in the MEMORY OPERAND (l3/ast.ts
198
+ * `index.operandOff`) — the input to `unfoldedOffset`. */
199
+ operandOff: Set<string>;
200
+ /** keys EVERY access of which carries the order licence (l3/ast.ts `index.baseOrdered`) — the
201
+ * input to `orderLicensed`. Per key rather than per access because a home is one decision for
202
+ * the whole key; the licence is derived per SYMBOL upstream, so the two only disagree where a
203
+ * pass has rebuilt one node and dropped the stamp, and requiring all of them makes that a
204
+ * refusal rather than a half-homed base. */
205
+ ordered: Map<string, boolean>;
53
206
  }
54
207
 
55
- /** Every `index` node whose base is a hoistable leaf, tallied by key (for the 2+-reuse test) and in
208
+ /** Every `index` node whose base is a hoistable leaf, tallied by key (the gates' use count) and in
56
209
  * first-appearance order (so the hoisted assignments emit in the order the bases are first used,
57
210
  * matching the compiler's pool-load order). `loop` marks uses nested in a while/do-while/for. */
58
211
  function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
@@ -70,9 +223,17 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
70
223
  if (e.idx.k === 'const') {
71
224
  const m = c.constOffCount.get(k) ?? c.constOffCount.set(k, new Map()).get(k)!;
72
225
  m.set(e.idx.value, (m.get(e.idx.value) ?? 0) + 1);
226
+ } else {
227
+ c.varIndexed.add(k);
228
+ }
229
+ // `!== undefined`, never truthiness: the field carries a displacement, and a negative one
230
+ // is a real access (`lw v0, -8(a1)`).
231
+ if (e.operandOff !== undefined) {
232
+ c.operandOff.add(k);
73
233
  }
234
+ c.ordered.set(k, (c.ordered.get(k) ?? true) && e.baseOrdered === true);
74
235
  }
75
- for (const ch of exprChildrenOf(e)) {
236
+ for (const ch of exprChildren(e)) {
76
237
  visitExpr(ch, inLoop);
77
238
  }
78
239
  };
@@ -87,16 +248,6 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
87
248
  }
88
249
  }
89
250
 
90
- // local re-export to avoid importing exprChildren twice (mapExprChildren covers rewrite).
91
- function exprChildrenOf(e: Expr): Expr[] {
92
- const out: Expr[] = [];
93
- mapExprChildren(e, (c) => {
94
- out.push(c);
95
- return c;
96
- });
97
- return out;
98
- }
99
-
100
251
  /** Rewrite every `index`-of-hoistable-base whose key is hoisted so its base becomes the hoist local. */
101
252
  function rewrite(e: Expr, localFor: Map<string, string>): Expr {
102
253
  if (e.k === 'index' && isHoistableBase(e.base)) {
@@ -108,37 +259,6 @@ function rewrite(e: Expr, localFor: Map<string, string>): Expr {
108
259
  return mapExprChildren(e, (c) => rewrite(c, localFor));
109
260
  }
110
261
 
111
- function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
112
- const mapS = (x: Stmt): Stmt => rewriteStmt(x, localFor);
113
- switch (s.k) {
114
- case 'assign':
115
- return { ...s, value: rewrite(s.value, localFor) };
116
- case 'store':
117
- return { ...s, lval: rewrite(s.lval, localFor), value: rewrite(s.value, localFor) };
118
- case 'exprstmt':
119
- return { ...s, value: rewrite(s.value, localFor) };
120
- case 'return':
121
- return s.value ? { ...s, value: rewrite(s.value, localFor) } : s;
122
- case 'if':
123
- return { ...s, cond: rewrite(s.cond, localFor), then: s.then.map(mapS), else: s.else.map(mapS) };
124
- case 'while':
125
- case 'dowhile':
126
- return { ...s, cond: rewrite(s.cond, localFor), body: s.body.map(mapS) };
127
- case 'for':
128
- return { ...s, init: mapS(s.init), cond: rewrite(s.cond, localFor), inc: mapS(s.inc), body: s.body.map(mapS) };
129
- case 'switch':
130
- return {
131
- ...s,
132
- scrutinee: rewrite(s.scrutinee, localFor),
133
- cases: s.cases.map((c) => ({ ...c, body: c.body.map(mapS) })),
134
- default: s.default?.map(mapS),
135
- };
136
- case 'break':
137
- case 'continue':
138
- return s;
139
- }
140
- }
141
-
142
262
  /** One base under consideration, keyed as `(base, width, signedness)`. */
143
263
  export interface BaseKey {
144
264
  key: string;
@@ -146,6 +266,100 @@ export interface BaseKey {
146
266
  inLoop: boolean;
147
267
  /** some CONSTANT offset through this base is touched 2+ times */
148
268
  repeatedConstOffset: boolean;
269
+ /** every access is the SAME fixed offset — one scalar cell rather than a block of them */
270
+ singleCell: boolean;
271
+ /** A base whose constant offset arrived in the MEMORY OPERAND (l3/ast.ts `index.operandOff`).
272
+ * On a compiler that folds a constant subscript into the literal it materializes, an offset
273
+ * that reached the instruction instead survived because something OTHER than a subscript put it
274
+ * there — a named base, or an aggregate member (see the header). TWO gate tables read it, both
275
+ * on roster rows rank.ts offers only where the target declares the fold, and they ask OPPOSITE
276
+ * questions: `BASEFOLD_GATES` EXEMPTS `single-use` on it, `UNFOLDED_GATES` REQUIRES it. Neither
277
+ * subtracts the other, so ablating one prices one.
278
+ *
279
+ * Where the fold leaves no evidence to read is decided UPSTREAM and once, in
280
+ * `structure/structure.ts` (`const fromOperand = off !== 0 ? { operandOff: off } : {}`). THREE
281
+ * ways to get an absence:
282
+ * • an offset the address expression carried (a relocation addend, a folded `add`);
283
+ * • an offset of 0;
284
+ * • an offset the INSTRUCTION COULD NOT ENCODE — not upstream's doing, and the one to watch,
285
+ * because the flag then reads the same for BOTH spellings and the gate is blind rather than
286
+ * unpersuaded. Thumb's load displacement is a scaled imm5 — 31 bytes for `ldrb`, 62 for
287
+ * `ldrh`, 124 for `ldr` — and past it agbcc materializes the pointer local with an explicit
288
+ * `add`: `u16 *p = (u16 *)&gBgInfo; p[36]` compiles to `.word gBgInfo` +
289
+ * `add r4, r4, #0x48` + `ldrh r0, [r4]` (no operand offset), against the inline
290
+ * `((u16 *)&gBgInfo)[36]`'s `.word gBgInfo+0x48` + `ldrh r0, [r4]` (also none). At an
291
+ * in-range subscript the same pair is `.word gBgInfo` + `ldrh [r4, #0xa]` against
292
+ * `.word gBgInfo+0xa` + `ldrh [r4]`, so the field discriminates there and only there.
293
+ *
294
+ * A GROUP OF TWO OR MORE ACCESSES PAST THE RANGE INVERTS THE SENSE, which is worse than
295
+ * blind: the fold SHIFTS the literal and the displacements resume relative to the shifted
296
+ * base, so it is the INLINE spelling that sets the flag. It bites only where a table is
297
+ * CONSULTED — a key `BASECSE_GATES` rejects, i.e. one reached inside a loop, since the
298
+ * default hoist binds anything else first; on a straight-line pair `admittedBases` is empty
299
+ * for every table here and for an EMPTY gate list, so that shape says nothing either way.
300
+ * In a loop over `((u16 *)&gBgInfo)[36]` and `[37]` the inline spelling emits
301
+ * `.word gBgInfo+0x48` + `ldrh [r5]` + `ldrh [r5, #0x2]` while the pointer-local twin emits
302
+ * `.word gBgInfo` + two `add`s and no operand offset at all; lifted, `UNFOLDED_GATES`
303
+ * admits `a:gBgInfo 2 false` on the INLINE one (2 of its 8 candidates carry `/unfolded`)
304
+ * and NOTHING on the local one. So on such a group `folded-offset` admits the key the
305
+ * reference spelled inline and refuses the key it parked — backwards, costing fan and a
306
+ * tie-break and never meaning, since the inline spelling is enumerated beside it. The two
307
+ * spellings still differ in the asm — a bare `.word` plus an `add` against a `.word` with
308
+ * the addend baked in — but not through THIS field, and no field carries it.
309
+ *
310
+ * HOW BIG THE INVERTED CLASS IS, at ONE stated scope — `decompile()`'s default structuring,
311
+ * map-less, one tree per row over the 363 agbcc rows the artifact carried when the census
312
+ * ran (23 unstructurable):
313
+ * `folded-offset` ADMITS 43 keys and ZERO of them have every nonzero displacement past the
314
+ * range for their width, so the inverted class has no corpus inhabitant at this scope; it
315
+ * REJECTS 40, of which 36 have no nonzero constant displacement at all (the two documented
316
+ * causes) and FOUR are past the range — `ConfigureEntityBehavior` gBgInfo 72,
317
+ * `EntityItemDrop` gEntity 504/506, `ProcessInputAndUpdateEntities` gUnk_03005220 76,
318
+ * `TransformSingleEntityToScreen` gUnk_03003430 64/66. A WIDER census — every structuring
319
+ * `enumerateCandidates` builds — mints keys this one never sees and reads higher on both
320
+ * sides, so quote the scope with the number and re-run rather than compare across scopes.
321
+ * Reaching the rejected four needs a NEW piece of evidence recorded upstream, not a relaxed
322
+ * gate here: widening `folded-offset` to fire on absence would make it fire exactly where
323
+ * its premise is false.
324
+ * Nothing is subtracted again here. A base of 0 is NOT a second refusal, tempting as it
325
+ * looks: on MIPS `((s8 *)0)[16]` really is one `lb $v0, 16($zero)` with nowhere else for the
326
+ * offset to be, but this rule runs only where `foldsConstAddrOffset` is declared, and agbcc
327
+ * materializes a zero base like any other — `mov r0, #0x10` + `ldrb [r0, #0]` inline against
328
+ * `s8 *p = (s8 *)0; p[16]`'s `mov r0, #0x0` + `ldrb [r0, #0x10]`, the same discriminating pair
329
+ * as at 0x3001100. Refusing it costs nothing here either way (no agbcc tree in the corpus
330
+ * reaches a base-0 access with an operand offset, in either symbol-map configuration), which is
331
+ * exactly why an unmeasured clause could sit in it. */
332
+ unfoldedOffset: boolean;
333
+ /** The base is a REINTERPRET CAST of a leaf (`(struct S *)&gSym`) rather than the leaf itself —
334
+ * the array-of-struct element shape. Its own field because it is about what the base IS, not
335
+ * about how often it is reached, and because every shipped table refuses it EXCEPT
336
+ * `ORDERBASE_GATES`, which ablates `cast-base`: the default spelling of a struct element is the
337
+ * inline cast, and homing it is a candidate `/orderbase` offers where the assembly licenses it. */
338
+ castBase: boolean;
339
+ /** No access through this base scaled the index before the base was materialized, and at least
340
+ * one materialized the base first (l3/ast.ts `index.baseOrdered`, from raise/globalshape.ts).
341
+ *
342
+ * NOT "every access", whatever `Collected.ordered`'s `&&` looks like it enforces. The licence
343
+ * admits an access that carries no order fact at all — a scaling in another block is not
344
+ * comparable, so it answers `undefined` rather than `false` — and 4 of the corpus's licensed
345
+ * symbols have one on both symbol-map arms (`kleod:EntityDeathAnimation`'s `gEntityArray` is 11
346
+ * of 28 accesses). Per SYMBOL is the right grain here and not a shortcut:
347
+ * agbcc CSEs the pool word, so one `ldr` is shared by every access of the name and there is one
348
+ * order fact to have. WITHIN ONE STRUCTURING the `&&` therefore decides nothing:
349
+ * `structure/structure.ts`'s `stampOrderedBases` is a post-pass over the finished body that
350
+ * stamps per SYMBOL, so the accesses of one key leave it agreeing. What the `&&` guards is a
351
+ * LATER pass that rebuilt one node and dropped the stamp — a tree whose stamps are MIXED — and
352
+ * what it does there is refuse the key whole rather than half-home it, which no assembly can
353
+ * reach and test/basecse.test.ts pins on a hand-built tree ('a key whose accesses DISAGREE on
354
+ * the stamp is refused whole, never half-homed').
355
+ *
356
+ * On agbcc a base-first order is what a declared array produces (`build_array_ref`'s fork) and
357
+ * what a pointer local's own initializer STATEMENT produces (see raise/globalshape.ts's header
358
+ * for the compile that separates the two), while the inline cast produces the other order — so
359
+ * it is evidence a home is what the source wrote. Read only by `ORDERBASE_GATES` (rank.ts);
360
+ * false for every base a compiler that has not opted in produced, which is what keeps the variation
361
+ * off those targets. */
362
+ orderLicensed: boolean;
149
363
  }
150
364
 
151
365
  /** The admission rules. NONE is sound, and that is a property of the pass rather than an oversight:
@@ -156,12 +370,25 @@ export interface BaseKey {
156
370
  * in a register across the loop too — but hoisting to the FUNCTION TOP forces a callee-saved
157
371
  * register, which can add the prologue push/pop the original avoided. `l3/scopebase.ts` is the
158
372
  * scope-aware hoist that serves those instead. */
373
+ const reachedOnce = (c: BaseKey): boolean => c.uses < 2;
374
+
159
375
  export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
376
+ {
377
+ // FIRST, so a cast base attributes here rather than to whichever use-count rule it also trips.
378
+ // Every table derived from this one inherits it EXCEPT `ORDERBASE_GATES`, which ablates it — so
379
+ // a cast base is collectable under every table and bound by exactly one roster family.
380
+ // Censused at `decompile()`'s default structuring, map-less, one tree per row over the
381
+ // artifact's 404 agbcc rows: `ORDERBASE_GATES` admits 12 keys on 10 rows, 11 of them cast keys.
382
+ id: 'cast-base',
383
+ why: 'a struct element’s reinterpret cast is the inline spelling unless the assembly says the base was held in a local',
384
+ sound: false,
385
+ rejects: (c) => c.castBase,
386
+ },
160
387
  {
161
388
  id: 'single-use',
162
- why: 'one access re-materializes as cheaply as a named local',
389
+ why: 'a base accessed once is as cheap to load again as to hold in a named local',
163
390
  sound: false,
164
- rejects: (c) => c.uses < 2,
391
+ rejects: reachedOnce,
165
392
  },
166
393
  {
167
394
  id: 'loop',
@@ -170,45 +397,426 @@ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
170
397
  rejects: (c) => c.inLoop,
171
398
  },
172
399
  {
400
+ // A KNOWN COUNTEREXAMPLE, recorded here rather than fixed. `LIVEBASE_GATES` already names it —
401
+ // an MMIO wait or poll stores and re-reads ONE fixed offset through ONE register the whole
402
+ // time — so this gate rejects a base the target demonstrably held in a register, and the row
403
+ // it costs is carried by `/livebase`, which is this table minus the placement gates.
404
+ //
405
+ // NOT FIXED BY EXCLUDING A VALUELESS READ FROM THE CENSUS: that hides a real access to make
406
+ // the gate right for the wrong reason — the premise is falsified by the function's assembly,
407
+ // not by how the access is spelled. NOT FIXED BY EXEMPTING DEVICE-REGISTER BASES EITHER: that
408
+ // is the sound rule, and its blast radius is every MMIO row in the corpus against a gate this
409
+ // comment records was bought with a real match. It wants its own round and its own zero-flip
410
+ // gate.
411
+ //
412
+ // THE STRUCTURER'S DEAD-READ SPELLING (structure.ts `unreadResult`) INTERACTS, but not on the
413
+ // map-fed default: that spelling requires a qualifier to reach the access, a CAST spelling
414
+ // (`((s32 *)&REG_DMA3SAD)[2]`) carries none, and a map-fed DMA tree spells it exactly that
415
+ // way — so no statement is emitted there, offset 8 is touched once, and the base local
416
+ // survives. The inhabitant is the `/raw-globals` subtree, where the read IS spelled and this
417
+ // gate DOES demote; that subtree's own winner is a `/livebase` candidate, so nothing on the
418
+ // ranked path loses by it. Stated because the demotion is invisible from either file alone.
173
419
  id: 'repeated-const-offset',
174
- why: 'a fixed offset touched twice is a scalar RMW, which the compiler re-materializes',
420
+ why: 'a fixed offset read and then written is one scalar update, and the compiler loads its address again for it',
175
421
  sound: false,
176
422
  rejects: (c) => c.repeatedConstOffset,
177
423
  },
178
424
  ];
179
425
 
180
- export function hoistReusedGlobalBases(sfn: SFn): SFn {
181
- const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
426
+ /** `/basefold`'s admission (rank.ts): the default rules with `single-use` EXEMPTING a base whose
427
+ * offset survived into the memory operand (`unfoldedOffset`, see the header). A separate table
428
+ * rather than a relaxed `single-use`, because the evidence is not proof — an inline
429
+ * aggregate-member access emits the same bytes — so the spelling it generates belongs beside the
430
+ * inline one with the differ between them, never committed on the single-shot path where nothing
431
+ * referees.
432
+ *
433
+ * HOW TO PRICE THE TWO RULES. `without(BASECSE_GATES, 'single-use')` ablates the use-count rule
434
+ * alone. The EXEMPTION has no ablation of its own: ablation removes a whole gate, and this table's
435
+ * gate carries the rule AND its exemption in one `rejects`, so
436
+ * `without(BASEFOLD_GATES, 'single-use-unfolded')` is `without(BASECSE_GATES, 'single-use')` gate
437
+ * object for gate object — the naive full ablation, which costs a real match. What the exemption
438
+ * added is the two tables' admitted-set DIFF, `admittedBases(sfn, BASEFOLD_GATES)` minus
439
+ * `admittedBases(sfn, BASECSE_GATES)`.
440
+ *
441
+ * rank.ts offers the row only where the target declares
442
+ * `compilerBehaviors.foldsConstAddrOffset` — MIPS and PPC put the addend in the instruction by
443
+ * construction (`lui`/`%lo`, `lis`/`ori`), so a surviving offset carries no information there.
444
+ * It offers it at BOTH placements (l3/hoist.ts): a base reached once is loaded where it is used,
445
+ * so where the init sits is the question the differ has to settle, not whether it exists. */
446
+ export const BASEFOLD_GATES: readonly Gate<BaseKey>[] = [
447
+ {
448
+ id: 'single-use-unfolded',
449
+ why: 'a base accessed once is as cheap to load again as to hold in a named local, unless its offset survived the fold',
450
+ sound: false,
451
+ rejects: (c) => reachedOnce(c) && !c.unfoldedOffset,
452
+ },
453
+ ...ablateHeuristic(BASECSE_GATES, 'single-use'),
454
+ ];
455
+
456
+ /** The `/livebase` variation's admission (rank.ts): the default rules with both PLACEMENT heuristics
457
+ * ablated, keeping only `single-use`. `loop` and `repeated-const-offset` predict which spelling
458
+ * the compiler chose, and both predictions have a counterexample — an MMIO poll (`p[2] = go;
459
+ * while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register the whole
460
+ * time. Neither gate is `sound`, so ablating them can only change which spelling wins, never
461
+ * what a candidate means; the differ referees. */
462
+ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
463
+ ablateHeuristic(BASECSE_GATES, 'loop'),
464
+ 'repeated-const-offset',
465
+ );
466
+
467
+ /** `/livebase-block`'s admission (rank.ts): `/livebase` plus `single-cell`. The two tables differ
468
+ * by exactly one gate, so `without(LIVEBASE_BLOCK_GATES, 'single-cell')` is `/livebase`'s own
469
+ * admission and this selectivity rule prices by ablation like every other.
470
+ *
471
+ * `single-cell` GENERATES a narrower candidate; it does not classify, and taking it for a compiler
472
+ * fact is the way to misuse it. Its counterexample is in this corpus: `synthetic:sizebound`'s
473
+ * `*(u16 *)0x03001048` is reached at one fixed offset only — this rule rejects it — and binding it
474
+ * is what the differ picks (16 against 36 on the same shape). The rule is legitimate anyway
475
+ * because it never SUBTRACTS a candidate: `/livebase` rides beside it, and the differ referees.
476
+ * Promote it into `BASECSE_GATES` or prune with it and that row pays.
477
+ *
478
+ * WHAT IT IS WORTH, since a generator's price is what its own family WINS and not what its
479
+ * refusal explains. Ablated — this table made equal to `LIVEBASE_GATES`, at which point
480
+ * rank.ts's `sameBases` shadow declines the whole `/livebase-block` family — THREE matches go:
481
+ * `synthetic:dmaflat` MATCH → diff:3, `synthetic:dmapoll` MATCH → diff:12 and
482
+ * `synthetic:mixpoll` MATCH → diff:2.
483
+ *
484
+ * A PRICE IS A CLAIM ABOUT THE WHOLE ROSTER, not about one table, and nothing in the gate order
485
+ * can see it go stale: `bench regression` and `bench diff` measure OUTCOMES, and a rule nobody
486
+ * ablated has no outcome. `synthetic:foldpark` is the standing example — `/unfolded` binds that
487
+ * row's bases set-for-set at `first-use`, so with THIS gate ablated alone the row is still MATCH
488
+ * and brackets nothing; ablate both and it goes diff:6. Re-run every number in this paragraph
489
+ * whenever an admission is added to or removed from the roster.
490
+ * HOW: prefer the edit-free form — import this array and `splice` the gate out of it before the
491
+ * first `enumerateCandidates` call, since the roster holds a reference to this very object. The
492
+ * env-read recipe on BASEFOLD_HOISTS edits files instead, and a tap reverted underneath a
493
+ * running process reports ZEROES rather than crashing, which reads exactly like "the rule never
494
+ * fires"; if you use it, hash the tree either side of the window and quote both hashes.
495
+ *
496
+ * A CENSUS OVER WINNERS' VARIATIONS CANNOT STAND IN FOR THAT — "only a row whose winner carries
497
+ * `/livebase-block` can move" is unsound for the reason rank.ts's `seen` dedup spells out. This
498
+ * table's own census over winners' variations reads 5 rows and read 7 before `/unfolded` shipped, and the
499
+ * two that left differ: `synthetic:foldpark` by RENAME (byte-identical source, MATCH either
500
+ * side), `synthetic:unfoldpark` because its winning SPELLING changed, 402 bytes at diff:9 to 397
501
+ * at MATCH.
502
+ *
503
+ * WHICH ROWS THE ABLATION REACHES, since "found by running it" is only an instruction until
504
+ * someone runs it. Enumeration only, no compiles, both arms — `single-cell` spliced out of this
505
+ * array in process, prototypes only, map-less, over all artifact rows; run twice with the working
506
+ * tree hashed either side and byte-identical both times. THE ARTIFACT HELD 951 ROWS THAT DAY (358
507
+ * agbcc, 593 not) and has grown since: this paragraph is at the earlier scope and is not re-run
508
+ * against the later one, so its per-row fans still stand and its two TOTALS do not.
509
+ * THIRTEEN rows change their distinct-source set, corpus fan 48995 → 42701, and ZERO non-agbcc
510
+ * rows are reached — the arm easiest to skip, because this table sits on the UNCONDITIONAL half of
511
+ * the roster and is offered to ido/kmc/mwcc/gcc272 too. READ THAT POPULATION HONESTLY, since a denominator is a rig
512
+ * artifact until it is broken out: of the 593 non-agbcc rows, 400 enumerate in both arms and NONE
513
+ * of them moves; the other 193 decline at the lift or structure seam and have no fan on either
514
+ * side, so they are vacuous rather than evidence. The claim is over the 400.
515
+ * Seven of the thirteen are the rows this note already names (the three matches above, plus the
516
+ * four re-run below); the other six are `kleod:ConfigureEntityBehavior` (fan 1248 → 864),
517
+ * `kleod:ProcessInputAndUpdateEntities` (23040 → 19200), `kleod:SetupBG3WindowOverlay`
518
+ * (696 → 640), `kleod:UpdateCameraScroll` (15936 → 14272), `kleod:UpdateWorldMapNodeAnim`
519
+ * (216 → 192) and `synthetic:livepark` (32 → 24, a MATCH row). `ConfigureEntityBehavior` and
520
+ * `livepark` keep their published winning source in the ablated fan, so neither outcome nor score
521
+ * can move on them. THE OTHER FOUR ARE UNPRICED, not free: they are real-tier rows whose
522
+ * published spelling is map-FUL, and a map-less enumeration does not contain it in EITHER arm, so
523
+ * this rig cannot say. Do not quote them as unmoved.
524
+ *
525
+ * The rows it is quoted against, all re-run at this commit: `synthetic:sizebound` — the
526
+ * counterexample row above, which the narrow family still helps — goes 8 → 10;
527
+ * `sa3:Sio32MultiLoadIntr`, the one REAL-tier row involved and the reason this gate is not a
528
+ * synthetic-only concern, is 69 either way; and `synthetic:foldpark` and `synthetic:unfoldpark`
529
+ * are MATCH either way. A round promoting this rule into `BASECSE_GATES` prices it against all of
530
+ * them, re-run rather than re-quoted.
531
+ *
532
+ * Why the ACCESS SHAPE and not the address: an MMIO register file and the IWRAM halfword beside
533
+ * it are both numeric constants in the same range. And why the rule is not in `BASECSE_GATES`: it
534
+ * would reject nothing there, being a strict refinement of `repeated-const-offset` — past
535
+ * `single-use`, a base with no variable index and one distinct offset touched it twice. */
536
+ export const LIVEBASE_BLOCK_GATES: readonly Gate<BaseKey>[] = [
537
+ ...LIVEBASE_GATES,
538
+ {
539
+ id: 'single-cell',
540
+ why: 'a base reached at one fixed offset reads as a scalar, which the source more often spells inline',
541
+ sound: false,
542
+ rejects: (c) => c.singleCell,
543
+ },
544
+ ];
545
+
546
+ /** `/unfolded`'s admission (rank.ts): `/livebase` plus `folded-offset`, which REQUIRES the fold
547
+ * evidence `BASEFOLD_GATES` exempts on. The two tables read one field and ask opposite questions
548
+ * of it — that one exempts `single-use` for a key reached ONCE whose offset survived, this one
549
+ * keeps `single-use` and demands the same survival of a key reached TWICE — so neither is the
550
+ * other relaxed.
551
+ *
552
+ * WHY IT IS A SELECTION AND NOT A WIDENING. `/livebase` and `/livebase-block` ARE one chain:
553
+ * every key the narrow table binds the wide one binds too, so between them a function has only
554
+ * "all the reused bases" and "those minus the scalar cells". A source that parked ONE numeric
555
+ * base and left a second one inline is at neither end of that chain, and `singleCell` cannot
556
+ * separate the two — both are reached at fixed offsets. `unfoldedOffset` can: on a compiler that
557
+ * folds a constant subscript into the literal it materializes, the offset that reached the
558
+ * instruction is the one a POINTER LOCAL strode, and the address that reached the pool with the
559
+ * offset already in it is the one the source spelled inline.
560
+ *
561
+ * BUT THIS TABLE IS NOT ITSELF ON THAT CHAIN, and reading it as "the middle one" is wrong in
562
+ * both directions. `singleCell` and `unfoldedOffset` are independent fields, so this table and
563
+ * `LIVEBASE_BLOCK_GATES` are lattice-INCOMPARABLE: a block walk with no constant displacement
564
+ * (`varIndexed`, so not `singleCell`; offset 0, so no `unfoldedOffset`) is bound by that table
565
+ * and refused by this one, and a scalar cell at a surviving offset is the reverse. Censused at
566
+ * `decompile()`'s default structuring, map-less, over the 363 agbcc rows the artifact carried
567
+ * when the census ran (23 unstructurable): 8 keys on 7 functions go to `/livebase-block` alone and 5 keys on 5 functions
568
+ * to this table alone, and `kleod:ProcessInputAndUpdateEntities` crosses BOTH ways on one tree.
569
+ * That is why `sameBases` compares sets and never subsets (see COVERAGE in the header).
570
+ *
571
+ * The evidence is not proof and the gate never treats it as such — what it produces is a
572
+ * candidate beside the other admissions, refereed by the differ. Two counterexamples, both
573
+ * compiled rather than argued:
574
+ * • an aggregate member emits the same operand offset as a strided pointer local;
575
+ * • so does a SYMBOL BASE whose address the tree also uses as a value. agbcc CSEs the symbol
576
+ * reference where it re-materializes the integer, so `sink((int)(u8 *)&gS); sink(((u8 *)&gS)
577
+ * [3]); sink(((u8 *)&gS)[4]);` and its pointer-local twin emit BYTE-IDENTICAL Thumb — one
578
+ * `.word gS`, `add r0, r4, #0`, `ldrb [r4, #0x3]`, `ldrb [r4, #0x4]`. On such a key the
579
+ * field is not weak evidence, it is none. At the scope above, FOUR of this table's 43
580
+ * admissions sit on one: `ProcessInputAndUpdateEntities` gBgDataPtrs / gCallbackQueue /
581
+ * gUnk_03004C20 and `sa3:sub_802DFC8` gStageData. It costs fan and a tie-break, never
582
+ * meaning, because the inline spelling rides beside it.
583
+ * Absence is not evidence either way — see the header for the THREE ways to get one, including
584
+ * the displacement the instruction could not encode, where this gate is blind rather than
585
+ * unpersuaded — so `folded-offset` refuses on MISSING EVIDENCE and never on a claim that the
586
+ * offset was folded.
587
+ *
588
+ * This table is its own gate object, so `without(UNFOLDED_GATES, 'folded-offset')` is
589
+ * `LIVEBASE_GATES` and the rule prices by ablation — the handle `BASEFOLD_GATES`' exemption
590
+ * cannot have (see the note above it). */
591
+ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
592
+ ...LIVEBASE_GATES,
593
+ {
594
+ id: 'folded-offset',
595
+ why: 'no offset survived into the operand, so nothing says a pointer local strode this base',
596
+ sound: false,
597
+ rejects: (c) => !c.unfoldedOffset,
598
+ },
599
+ ];
600
+
601
+ /** `/orderbase`'s admission (rank.ts): the two rules ABOUT THE BASE ablated — `cast-base` and
602
+ * `single-use` — and one rule demanding the ORDER LICENCE in their place.
603
+ *
604
+ * THE EVIDENCE IS THE ASSEMBLY, and it is the only thing here that is. Every other table on this
605
+ * roster predicts which spelling the source wrote from the SHAPE of the accesses — how many, at
606
+ * what offsets, inside a loop or not — and each prediction has a counterexample this file names.
607
+ * This one reads the instruction ORDER (raise/globalshape.ts's header carries the compiles behind
608
+ * it, and why a declared array and a pointer LOCAL reach the same order by different routes).
609
+ * Compiled through the benchmark's own agbcc command, against a base-first object:
610
+ *
611
+ * extern u16 gTbl[]; gTbl[i] 0 ┐ the same object
612
+ * u16 *p = (u16 *)&gTbl; p[i] 0 ┘
613
+ * ((u16 *)&gTbl)[i] 2 a different one
614
+ *
615
+ * A DERIVED DECLARATION IS NOT THE SAME AS A BARE SPELLING, which is the thing to know before
616
+ * reading the population: where `raise/globalshape.ts` shapes a name the structurer usually spells
617
+ * it bare and no key exists here at all — but a shape is ONE element type for the whole name, so an
618
+ * access that strides something else keeps its cast and its key. `kleod:SetupBG3WindowOverlay`'s
619
+ * `gBgInfo` (a row retired 2026-09-13) derived `elemSize 4` and still reached this table at stride
620
+ * 28, in both arms.
621
+ *
622
+ * What this table admits, censused over the artifact's 370 agbcc rows: map-less 8 rows / 10 keys,
623
+ * map-ful 10 rows / 12 keys. TWO shapes, and the arms differ:
624
+ *
625
+ * • the STRUCT ELEMENT — no `intType`, members read at a displacement — 9 keys map-less, 10
626
+ * map-ful, and the only shape `cast-base`'s ablation reaches. The map-ful extra is
627
+ * `kleod:StreamCmd_SetBGScroll`'s `gBgInfo`, a pool word the map-less lift leaves NUMERIC: the
628
+ * map is what makes it a named global, not anything the licence read from the map.
629
+ * • a PLAIN SCALAR LEAF with no cast anywhere, which only `single-use`'s ablation admits — and a
630
+ * reader deciding whether `single-use` can be put back needs it named. Both inhabitants reach
631
+ * this table for a reason that is NOT an interior read. `kleod:UpdateCameraScroll`'s
632
+ * `gSineTable` (both arms) is refused a declaration on `interior-or-non-access`'s NON-ACCESS
633
+ * half: one clean load, and the same element address feeding three other `add`s.
634
+ * `pokeemerald:Sin2`'s `gSineDegreeTable` (map-ful only) is refused nothing — it DERIVES
635
+ * `elemSize 2` unsigned, and map-less that is what it is spelled as, so there is no key. The
636
+ * symbol map declares the same element SIGNED, map-first wins, and an unsigned load through a
637
+ * signed declaration cannot be spelled bare, so the cast comes back and with it the key.
638
+ *
639
+ * WHY `single-use` GOES. The rule's theory is that one access re-materializes as cheaply as a
640
+ * named local, which is a guess about the source in the absence of evidence; here there is
641
+ * evidence, and `synthetic:bgarr` is a one-access function whose target loads the pool word first.
642
+ * The licence is its OWN gate rather than an exemption folded into a relaxed `single-use`, so
643
+ * `without(ORDERBASE_GATES, 'order-licensed')` prices it — the handle `BASEFOLD_GATES`' fused
644
+ * exemption cannot have.
645
+ *
646
+ * `loop` and `repeated-const-offset` STAY. Neither is about the base's identity and both are fan
647
+ * control; ablating them is `/livebase`'s variation, already on the roster, and a row that wants the
648
+ * pairing is one hoist. THE PRICE OF THAT IS A HOLE, and it is named rather than left for a
649
+ * reader to find: a licensed base with a use inside a loop is admitted by NO table on the roster —
650
+ * this one refuses it on `loop`, and every table that ablates `loop` refuses it on `cast-base` or
651
+ * `single-use` — which is the "a base set that is no row's stays unreachable" debt
652
+ * docs/level-tower.md books against a roster of hand-picked subsets. Measured over the artifact's
653
+ * 370 agbcc rows in both symbol-map arms, `admittedBases(sfn, without(ORDERBASE_GATES, 'loop'))`
654
+ * minus everything any shipped table admits is 0 rows, so the hole is structural and unpopulated.
655
+ * Folding the licence into `cast-base` and `single-use` as an EXEMPTION would close it and reach
656
+ * every table, at the cost of the ablation handle below — the trade `BASEFOLD_GATES`' fused
657
+ * exemption already made once, and not one to make for a class with no inhabitant. rank.ts offers this row only where
658
+ * `compilerBehaviors.arrayShapeFromStride` — the same opt-in the licence itself carries, because
659
+ * the fork is agbcc's and no other compiler has been shown to make it.
660
+ *
661
+ * ITS REACH IS A FUNCTION OF THE SYMBOL MAP, and unevenly so — a sweep is evidence about the arm
662
+ * and the subtree it ran over, so both are stated. Over every cleanly-lifting function of each
663
+ * project's WHOLE `asm` tree, `asm/matchings` included:
664
+ * klonoa binds 18 of 284 map-ful and ZERO map-less, sa3 30 of 1709 map-ful and 29 map-less.
665
+ * The klonoa collapse is THIS GATE and not a missing base: ablating `order-licensed` admits 118
666
+ * map-less klonoa functions where the full table admits none — so the bases are collected and it
667
+ * is the ORDER EVIDENCE that map-less klonoa does not carry. Why the evidence depends on the map is not
668
+ * attributed here; instrument `collect`'s `ordered` before assuming. What follows for a reader
669
+ * either way: a klonoa row carrying no symbol map exercises nothing in this table, and its whole
670
+ * family — the `scope` placement included — is carried on that project by the map-ful arm alone. */
671
+ export const ORDERBASE_GATES: readonly Gate<BaseKey>[] = [
672
+ ...ablateHeuristic(ablateHeuristic(BASECSE_GATES, 'cast-base'), 'single-use'),
673
+ {
674
+ id: 'order-licensed',
675
+ why: 'nothing in the assembly says this base was held in a local: the index was scaled first, or the order says nothing',
676
+ sound: false,
677
+ rejects: (c) => !c.orderLicensed,
678
+ },
679
+ ];
680
+
681
+ /** The census without the rewrite, so a caller choosing between admissions can compare what two
682
+ * tables would bind for one tree walk each. */
683
+ export function admittedBases(sfn: SFn, gates: readonly Gate<BaseKey>[]): readonly string[] {
684
+ return admit(sfn, gates).keys;
685
+ }
686
+
687
+ /** What each key NAMES, for a caller that has to translate a key into another pass's vocabulary —
688
+ * `l3/scopebase.ts` spells an `addr` base's identity differently, so a string compare across the
689
+ * two would silently never match. Every key the tree holds, admitted or not. */
690
+ export function baseSites(sfn: SFn): ReadonlyMap<string, { base: HoistableBase; width: number; signed: boolean }> {
691
+ return collectFrom(sfn).meta;
692
+ }
693
+
694
+ /** The whole census for one tree — every field seeded empty and one `collect` walk over the body.
695
+ * ONE constructor, because `baseSites` and `admit` must judge the same census: a field added to
696
+ * `Collected` and seeded in only one of two copies reads as an empty tally rather than as a type
697
+ * error. */
698
+ function collectFrom(sfn: SFn): Collected {
699
+ const c: Collected = {
700
+ count: new Map(),
701
+ order: [],
702
+ meta: new Map(),
703
+ inLoop: new Set(),
704
+ constOffCount: new Map(),
705
+ varIndexed: new Set(),
706
+ operandOff: new Set(),
707
+ ordered: new Map(),
708
+ };
182
709
  collect(sfn.body, c, false);
183
- const { count, order, meta } = c;
184
- const hoisted = order.filter(
185
- (k) =>
186
- firstRejection(BASECSE_GATES, {
710
+ return c;
711
+ }
712
+
713
+ /** The keys `gates` admits, in first-use order, with the census they were judged from. */
714
+ function admit(sfn: SFn, gates: readonly Gate<BaseKey>[]): { c: Collected; keys: string[] } {
715
+ const c = collectFrom(sfn);
716
+ const keys = c.order.filter((k) => {
717
+ const offsets = c.constOffCount.get(k);
718
+ return (
719
+ firstRejection(gates, {
187
720
  key: k,
188
- uses: count.get(k) ?? 0,
721
+ uses: c.count.get(k) ?? 0,
189
722
  inLoop: c.inLoop.has(k),
190
- repeatedConstOffset: [...(c.constOffCount.get(k)?.values() ?? [])].some((n) => n >= 2),
191
- }) === null,
192
- );
723
+ repeatedConstOffset: [...(offsets?.values() ?? [])].some((n) => n >= 2),
724
+ singleCell: !c.varIndexed.has(k) && (offsets?.size ?? 0) <= 1,
725
+ unfoldedOffset: c.operandOff.has(k),
726
+ castBase: c.meta.get(k)!.base.k === 'cast',
727
+ orderLicensed: c.ordered.get(k) === true,
728
+ }) === null
729
+ );
730
+ });
731
+ return { c, keys };
732
+ }
733
+
734
+ /** THE TWO FLAT PLACEMENTS ALWAYS ANSWER. Both put the run in the top-level statement list, so
735
+ * there is always a tree to return and the caller has no decline to handle — which is why
736
+ * `pipeline.ts` can commit one of them.
737
+ *
738
+ * `scope` DECLINES, and the overload is how a caller is told: `null` means the placement had
739
+ * nothing to say about this function, because no init landed inside a nested list. That tree is
740
+ * byte-for-byte the `first-use` spelling (l3/hoist.ts's `nested`), and the roster withholds the
741
+ * `first-use` row for this table deliberately (rank.ts, ORDERBASE_HOISTS) — so returning it
742
+ * ships the withheld candidate under the scoped row's name.
743
+ *
744
+ * IT WITHDRAWS A SPELLING RATHER THAN COLLAPSING A DUPLICATE, which is what the decline costs.
745
+ * `ORDERBASE_HOISTS` holds exactly two rows, `head` and `scope`, so nothing is ever enumerated
746
+ * at `first-use` for this table and the refused tree has no twin to fold into — its shape and
747
+ * `/volatile` compositions go with it. Over each project's whole `asm` tree, map-ful: of the 48
748
+ * functions `ORDERBASE_GATES` admits, 7 place an init inside a nested list and 41 do not, and for
749
+ * 29 of the 41 the refused spelling is one the `head` row does not already produce. Instrumented
750
+ * on two of those, both map-ful — the `kleod:StreamCmd_SetBGScroll` row (fan 11), and
751
+ * `sub_808A4EC` in the sa3 checkout, which is no row here (fan 40) — where the `head` source is
752
+ * among the candidates `enumerateCandidates` returns and the withheld `first-use` source is not. */
753
+ export function hoistBaseLocals(sfn: SFn, gates?: readonly Gate<BaseKey>[], placement?: 'head' | 'first-use'): SFn;
754
+ export function hoistBaseLocals(sfn: SFn, gates: readonly Gate<BaseKey>[], placement: HoistPlacement): SFn | null;
755
+ export function hoistBaseLocals(
756
+ sfn: SFn,
757
+ gates: readonly Gate<BaseKey>[] = BASECSE_GATES,
758
+ placement: HoistPlacement = 'head',
759
+ ): SFn | null {
760
+ const { c, keys: hoisted } = admit(sfn, gates);
761
+ const { meta } = c;
193
762
  if (hoisted.length === 0) {
194
- return sfn;
763
+ return placement === 'scope' ? null : sfn;
195
764
  }
196
765
 
197
766
  const fresh = nameAllocator(sfn);
198
767
 
199
768
  const localFor = new Map<string, string>();
200
769
  const newLocals: { name: string; type: IrType }[] = [];
201
- const hoistStmts: Stmt[] = [];
770
+ const hoistStmts: BaseInit[] = [];
202
771
  for (const k of hoisted) {
203
772
  const m = meta.get(k)!;
204
- const ptrType = T.ptr(scalarTypeForAccess(m.width, m.signed));
773
+ // A CAST base already wears the pointer type its element needs, so the local takes that type
774
+ // and the cast the structurer wrote becomes the initializer unchanged.
775
+ const ptrType = m.base.k === 'cast' ? m.base.to : T.ptr(scalarTypeForAccess(m.width, m.signed));
205
776
  const nm = fresh();
206
777
  localFor.set(k, nm);
207
778
  newLocals.push({ name: nm, type: ptrType });
208
779
  // `p = (T *)base` — the cast makes the local the access's pointer type so each `p[i]` strides it.
209
- hoistStmts.push({ k: 'assign', name: nm, value: { k: 'cast', to: ptrType, e: m.base } });
780
+ hoistStmts.push({
781
+ k: 'assign',
782
+ name: nm,
783
+ value: m.base.k === 'cast' ? m.base : { k: 'cast', to: ptrType, e: m.base },
784
+ });
210
785
  }
211
786
 
212
- const body = [...hoistStmts, ...sfn.body.map((s) => rewriteStmt(s, localFor))];
213
- return { ...sfn, body, locals: [...sfn.locals, ...newLocals] };
787
+ const rewritten = sfn.body.map((s) => mapStmtExprs(s, (e) => rewrite(e, localFor)));
788
+ // The new inits join the tree's LEADING run of base inits rather than being prepended above it:
789
+ // when rank's /livebase re-runs this pass the head already carries the default run's, and
790
+ // blindly prepending would spell the new base's pool load above locals the compiler loads first.
791
+ // That is why `HoistPlacement` has no `prepend` — the hazard is typed out rather than warned
792
+ // about. `placement` then answers where the whole run goes (l3/hoist.ts, the mechanism
793
+ // sinkinit.ts's policy shares). Under either value it is ordered by first use, which is
794
+ // pool-load order (see `collect`) — deliberately reaching the single default run too (a head of
795
+ // user pointer inits before a firing hoist), where it repairs the same invariant.
796
+ const locals = [...sfn.locals, ...newLocals];
797
+ // The shell carries the minted DECLARATIONS and the REWRITTEN statements together: first-use
798
+ // would not know the new names without the first, and would query the pre-rewrite accesses
799
+ // without the second.
800
+ const { body, moved, nested } = placeBaseLocals({ ...sfn, locals, body: rewritten }, hoistStmts, placement);
801
+ // DEGENERATE SCOPE IS A DECLINE, not a candidate: nothing reached a nested list, so this is the
802
+ // `first-use` tree, which this function's header prices.
803
+ if (placement === 'scope' && nested.length === 0) {
804
+ return null;
805
+ }
806
+ const out = { ...sfn, body, locals };
807
+ // The two FLAT placements can only put the run in the top-level list, above every use of it by
808
+ // construction. `scope` puts an init inside a nested list, which is where a placing variation can ship
809
+ // the one failure the byte differ rewards — a read of a local whose assignment does not reach it —
810
+ // so the tree it emits is checked rather than argued (contracts.ts).
811
+ //
812
+ // THE POPULATION IS THE MOTION, and `moved` is what the placer says it moved rather than what this
813
+ // function minted. The leading run this pass inherits is the DEFAULT hoist's, committed by
814
+ // `structureChecked` before rank's variations see the tree (pipeline.ts), and `scope` moves those
815
+ // inits too — so `newLocals` names less than half of what has to be judged. Real inhabitants, in the
816
+ // CHECKOUTS rather than in a benchmark row — `DecompressAndLoadLevel` in klonoa and `sub_8052474`
817
+ // in sa3, both map-ful — each sink one inherited `p0` beside the minted `p1`.
818
+ if (placement === 'scope') {
819
+ assertHoistsDominate(out, new Set([...newLocals.map((l) => l.name), ...moved]));
820
+ }
821
+ return out;
214
822
  }