@asmlift/core 0.4.0 → 0.6.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 (87) 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 +238 -164
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +341 -22
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +24 -6
  10. package/src/frontend/opaque.ts +31 -18
  11. package/src/frontend/ppc.ts +54 -7
  12. package/src/frontend/ssa.ts +632 -13
  13. package/src/frontend/thumb.ts +2786 -286
  14. package/src/ir/alias.ts +129 -0
  15. package/src/ir/bits.ts +75 -0
  16. package/src/ir/core.ts +337 -2
  17. package/src/ir/opcodes.ts +156 -27
  18. package/src/ir/parse.ts +19 -2
  19. package/src/ir/print.ts +27 -2
  20. package/src/ir/simplify.ts +190 -3
  21. package/src/ir/struct-names.ts +42 -0
  22. package/src/ir/verify.ts +43 -49
  23. package/src/l3/address.ts +62 -0
  24. package/src/l3/argbase.ts +8 -2
  25. package/src/l3/ast.ts +464 -49
  26. package/src/l3/basecse.ts +709 -88
  27. package/src/l3/coalesce.ts +521 -66
  28. package/src/l3/dce.ts +54 -19
  29. package/src/l3/gates.ts +88 -0
  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 +113 -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 +110 -85
  42. package/src/l3/reindex.ts +715 -78
  43. package/src/l3/scopebase.ts +649 -219
  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 +23 -4
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +263 -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 +236 -13
  57. package/src/pipeline.ts +206 -49
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/divpow2.ts +4 -3
  61. package/src/raise/globalshape.ts +1038 -0
  62. package/src/raise/gvn.ts +44 -19
  63. package/src/raise/latch.ts +126 -0
  64. package/src/raise/memberarrays.ts +594 -0
  65. package/src/raise/narrow.ts +124 -0
  66. package/src/raise/narrowlocal.ts +556 -0
  67. package/src/raise/paramwidth.ts +179 -0
  68. package/src/raise/pre-recovery.ts +101 -16
  69. package/src/raise/recover.ts +56 -23
  70. package/src/raise/retsink.ts +215 -14
  71. package/src/raise/shortcircuit.ts +477 -79
  72. package/src/raise/struct-arrays.ts +21 -3
  73. package/src/raise/structs.ts +61 -3
  74. package/src/rank-axes.ts +630 -0
  75. package/src/rank-declare.ts +256 -0
  76. package/src/rank.ts +1726 -251
  77. package/src/structure/analysis.ts +1516 -220
  78. package/src/structure/bitfields.ts +332 -0
  79. package/src/structure/globalaccess.ts +274 -0
  80. package/src/structure/hazards.ts +411 -20
  81. package/src/structure/loops.ts +2 -49
  82. package/src/structure/namecoalesce.ts +435 -0
  83. package/src/structure/structure.ts +2850 -533
  84. package/src/structure/switch-recover.ts +688 -147
  85. package/src/symbols.ts +62 -1
  86. package/src/target.ts +367 -24
  87. package/src/trace.ts +111 -32
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,48 +9,203 @@
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_ADMISSIONS roster emits each table's hoist and every product of
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
+ // named-base respelling 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_ADMISSIONS`, 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 is a lever 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 named-base spelling, 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` axis
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 { 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';
25
114
 
26
115
  // A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
27
116
  // address). Both are relocation-invariant leaves whose value the compiler keeps in one register
28
117
  // when it indexes them at 2+ sites. Anything else (a local var, a struct-element `p[a0]`, arbitrary
29
- // arithmetic) is NOT — agbcc may re-derive it.
30
- type HoistableBase = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
31
- const isHoistableBase = (e: Expr): e is HoistableBase => e.k === 'addr' || e.k === 'const';
32
- const baseId = (b: HoistableBase): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
118
+ // arithmetic) is NOT — agbcc may re-derive it. Admitting the bare `var` that scopebase.ts and
119
+ // argbase.ts take is the obvious consolidation and it is wrong twice over: this pass has no `lead`
120
+ // handling, so a rank-aware `g[0][i]` comes out as `p[0][i]` through a scalar pointer, and it
121
+ // undoes raise/gvn.ts's hoist on exactly the rows a symbol map serves (test/addr-placement.test.ts).
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));
33
145
 
34
146
  /** The (base, access-shape) key an `index`-of-hoistable-base shares with its reuse siblings. */
35
147
  const keyOf = (base: HoistableBase, width: number, signed: boolean): string => `${baseId(base)} ${width} ${signed}`;
36
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 LABEL out of it, and a
153
+ * label is a candidate's 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
+
37
182
  interface Collected {
38
183
  count: Map<string, number>;
39
184
  order: string[];
40
185
  meta: Map<string, { base: HoistableBase; width: number; signed: boolean }>;
41
- /** 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`). */
42
187
  inLoop: Set<string>;
43
- /** per key, how many times each CONSTANT offset was accessed. A constant offset touched 2+ times
44
- * is a SCALAR access at one fixed location (a `*(T*)C |= x` MMIO read-modify-write, or repeated
45
- * `*p`), which the compiler re-materializes rather than register-holds hoisting it MISMATCHES
46
- * (it broke the ProcessHBlankWait match). A key with ANY repeated constant offset is therefore
47
- * disqualified, even if it ALSO has distinct-offset uses (a mixed scalar+array base). A genuine
48
- * reused array base touches each constant offset once, or uses a variable index (not tallied). */
188
+ /** per key, how many times each CONSTANT offset was accessed the input to the
189
+ * `repeated-const-offset` gate, which losing the ProcessHBlankWait match is what bought. A
190
+ * genuine reused array base touches each constant offset once, or indexes by a variable (not
191
+ * tallied); a repeat means a scalar re-access, and ONE is enough to disqualify the base even
192
+ * when it also has distinct-offset uses. */
49
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>;
50
206
  }
51
207
 
52
- /** 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
53
209
  * first-appearance order (so the hoisted assignments emit in the order the bases are first used,
54
210
  * matching the compiler's pool-load order). `loop` marks uses nested in a while/do-while/for. */
55
211
  function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
@@ -67,9 +223,17 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
67
223
  if (e.idx.k === 'const') {
68
224
  const m = c.constOffCount.get(k) ?? c.constOffCount.set(k, new Map()).get(k)!;
69
225
  m.set(e.idx.value, (m.get(e.idx.value) ?? 0) + 1);
226
+ } else {
227
+ c.varIndexed.add(k);
70
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);
233
+ }
234
+ c.ordered.set(k, (c.ordered.get(k) ?? true) && e.baseOrdered === true);
71
235
  }
72
- for (const ch of exprChildrenOf(e)) {
236
+ for (const ch of exprChildren(e)) {
73
237
  visitExpr(ch, inLoop);
74
238
  }
75
239
  };
@@ -84,16 +248,6 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
84
248
  }
85
249
  }
86
250
 
87
- // local re-export to avoid importing exprChildren twice (mapExprChildren covers rewrite).
88
- function exprChildrenOf(e: Expr): Expr[] {
89
- const out: Expr[] = [];
90
- mapExprChildren(e, (c) => {
91
- out.push(c);
92
- return c;
93
- });
94
- return out;
95
- }
96
-
97
251
  /** Rewrite every `index`-of-hoistable-base whose key is hoisted so its base becomes the hoist local. */
98
252
  function rewrite(e: Expr, localFor: Map<string, string>): Expr {
99
253
  if (e.k === 'index' && isHoistableBase(e.base)) {
@@ -105,77 +259,544 @@ function rewrite(e: Expr, localFor: Map<string, string>): Expr {
105
259
  return mapExprChildren(e, (c) => rewrite(c, localFor));
106
260
  }
107
261
 
108
- function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
109
- const mapS = (x: Stmt): Stmt => rewriteStmt(x, localFor);
110
- switch (s.k) {
111
- case 'assign':
112
- return { ...s, value: rewrite(s.value, localFor) };
113
- case 'store':
114
- return { ...s, lval: rewrite(s.lval, localFor), value: rewrite(s.value, localFor) };
115
- case 'exprstmt':
116
- return { ...s, value: rewrite(s.value, localFor) };
117
- case 'return':
118
- return s.value ? { ...s, value: rewrite(s.value, localFor) } : s;
119
- case 'if':
120
- return { ...s, cond: rewrite(s.cond, localFor), then: s.then.map(mapS), else: s.else.map(mapS) };
121
- case 'while':
122
- case 'dowhile':
123
- return { ...s, cond: rewrite(s.cond, localFor), body: s.body.map(mapS) };
124
- case 'for':
125
- return { ...s, init: mapS(s.init), cond: rewrite(s.cond, localFor), inc: mapS(s.inc), body: s.body.map(mapS) };
126
- case 'switch':
127
- return {
128
- ...s,
129
- scrutinee: rewrite(s.scrutinee, localFor),
130
- cases: s.cases.map((c) => ({ ...c, body: c.body.map(mapS) })),
131
- default: s.default?.map(mapS),
132
- };
133
- case 'break':
134
- case 'continue':
135
- return s;
136
- }
262
+ /** One base under consideration, keyed as `(base, width, signedness)`. */
263
+ export interface BaseKey {
264
+ key: string;
265
+ uses: number;
266
+ inLoop: boolean;
267
+ /** some CONSTANT offset through this base is touched 2+ times */
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 axis
361
+ * off those targets. */
362
+ orderLicensed: boolean;
137
363
  }
138
364
 
139
- export function hoistReusedGlobalBases(sfn: SFn): SFn {
140
- const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
141
- collect(sfn.body, c, false);
142
- // A repeated CONSTANT offset means a scalar re-access at a fixed location (MMIO RMW / repeated
143
- // `*p`) the compiler re-materializes disqualify the whole base, even mixed with array uses.
144
- const hasRepeatedConstOffset = (k: string): boolean => {
145
- for (const n of c.constOffCount.get(k)?.values() ?? []) {
146
- if (n >= 2) {
147
- return true;
148
- }
149
- }
150
- return false;
365
+ /** The admission rules. NONE is sound, and that is a property of the pass rather than an oversight:
366
+ * a wrong hoist emits the same address held in a different place, so it costs bytes and a match,
367
+ * never meaning. The zero-lost benchmark gate is what referees them.
368
+ *
369
+ * The `loop` rule is the subtle one. A loop-body base is loop-invariant, so the compiler keeps it
370
+ * in a register across the loop too — but hoisting to the FUNCTION TOP forces a callee-saved
371
+ * register, which can add the prologue push/pop the original avoided. `l3/scopebase.ts` is the
372
+ * scope-aware hoist that serves those instead. */
373
+ const reachedOnce = (c: BaseKey): boolean => c.uses < 2;
374
+
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 had a home',
384
+ sound: false,
385
+ rejects: (c) => c.castBase,
386
+ },
387
+ {
388
+ id: 'single-use',
389
+ why: 'one access re-materializes as cheaply as a named local',
390
+ sound: false,
391
+ rejects: reachedOnce,
392
+ },
393
+ {
394
+ id: 'loop',
395
+ why: 'a function-top hoist of a loop base forces a callee-saved register the original avoided',
396
+ sound: false,
397
+ rejects: (c) => c.inLoop,
398
+ },
399
+ {
400
+ id: 'repeated-const-offset',
401
+ why: 'a fixed offset touched twice is a scalar RMW, which the compiler re-materializes',
402
+ sound: false,
403
+ rejects: (c) => c.repeatedConstOffset,
404
+ },
405
+ ];
406
+
407
+ /** `/basefold`'s admission (rank.ts): the default rules with `single-use` EXEMPTING a base whose
408
+ * offset survived into the memory operand (`unfoldedOffset`, see the header). A separate table
409
+ * rather than a relaxed `single-use`, because the evidence is not proof — an inline
410
+ * aggregate-member access emits the same bytes — so the spelling it generates belongs beside the
411
+ * inline one with the differ between them, never committed on the single-shot path where nothing
412
+ * referees.
413
+ *
414
+ * HOW TO PRICE THE TWO RULES. `without(BASECSE_GATES, 'single-use')` ablates the use-count rule
415
+ * alone. The EXEMPTION has no ablation of its own: ablation removes a whole gate, and this table's
416
+ * gate carries the rule AND its exemption in one `rejects`, so
417
+ * `without(BASEFOLD_GATES, 'single-use-unfolded')` is `without(BASECSE_GATES, 'single-use')` gate
418
+ * object for gate object — the naive full ablation, which costs a real match. What the exemption
419
+ * added is the two tables' admitted-set DIFF, `admittedBases(sfn, BASEFOLD_GATES)` minus
420
+ * `admittedBases(sfn, BASECSE_GATES)`.
421
+ *
422
+ * rank.ts offers the row only where the target declares
423
+ * `compilerBehaviors.foldsConstAddrOffset` — MIPS and PPC put the addend in the instruction by
424
+ * construction (`lui`/`%lo`, `lis`/`ori`), so a surviving offset carries no information there.
425
+ * It offers it at BOTH placements (l3/hoist.ts): a base reached once is loaded where it is used,
426
+ * so where the init sits is the question the differ has to settle, not whether it exists. */
427
+ export const BASEFOLD_GATES: readonly Gate<BaseKey>[] = [
428
+ {
429
+ id: 'single-use-unfolded',
430
+ why: 'one access re-materializes as cheaply as a named local, unless its offset survived the fold',
431
+ sound: false,
432
+ rejects: (c) => reachedOnce(c) && !c.unfoldedOffset,
433
+ },
434
+ ...ablateHeuristic(BASECSE_GATES, 'single-use'),
435
+ ];
436
+
437
+ /** The `/livebase` lever's admission (rank.ts): the default rules with both PLACEMENT heuristics
438
+ * ablated, keeping only `single-use`. `loop` and `repeated-const-offset` predict which spelling
439
+ * the compiler chose, and both predictions have a counterexample — an MMIO poll (`p[2] = go;
440
+ * while (p[2] & BUSY) {}`) stores and re-reads a fixed offset through ONE register the whole
441
+ * time. Neither gate is `sound`, so ablating them can only change which spelling wins, never
442
+ * what a candidate means; the differ referees. */
443
+ export const LIVEBASE_GATES: readonly Gate<BaseKey>[] = ablateHeuristic(
444
+ ablateHeuristic(BASECSE_GATES, 'loop'),
445
+ 'repeated-const-offset',
446
+ );
447
+
448
+ /** `/livebase-block`'s admission (rank.ts): `/livebase` plus `single-cell`. The two tables differ
449
+ * by exactly one gate, so `without(LIVEBASE_BLOCK_GATES, 'single-cell')` is `/livebase`'s own
450
+ * admission and this selectivity axis prices by ablation like every other.
451
+ *
452
+ * `single-cell` GENERATES a narrower candidate; it does not classify, and taking it for a compiler
453
+ * fact is the way to misuse it. Its counterexample is in this corpus: `synthetic:sizebound`'s
454
+ * `*(u16 *)0x03001048` is reached at one fixed offset only — this rule rejects it — and binding it
455
+ * is what the differ picks (16 against 36 on the same shape). The rule is legitimate anyway
456
+ * because it never SUBTRACTS a candidate: `/livebase` rides beside it, and the differ referees.
457
+ * Promote it into `BASECSE_GATES` or prune with it and that row pays.
458
+ *
459
+ * WHAT IT IS WORTH, since a generator's price is what its own family WINS and not what its
460
+ * refusal explains. Ablated — this table made equal to `LIVEBASE_GATES`, at which point
461
+ * rank.ts's `sameBases` shadow declines the whole `/livebase-block` family — THREE matches go:
462
+ * `synthetic:dmaflat` MATCH → diff:3, `synthetic:dmapoll` MATCH → diff:12 and
463
+ * `synthetic:mixpoll` MATCH → diff:2.
464
+ *
465
+ * A PRICE IS A CLAIM ABOUT THE WHOLE ROSTER, not about one table, and nothing in the gate order
466
+ * can see it go stale: `bench regression` and `bench diff` measure OUTCOMES, and a rule nobody
467
+ * ablated has no outcome. `synthetic:foldpark` is the standing example — `/unfolded` binds that
468
+ * row's bases set-for-set at `first-use`, so with THIS gate ablated alone the row is still MATCH
469
+ * and brackets nothing; ablate both and it goes diff:6. Re-run every number in this paragraph
470
+ * whenever an admission is added to or removed from the roster.
471
+ * HOW: prefer the edit-free form — import this array and `splice` the gate out of it before the
472
+ * first `enumerateCandidates` call, since the roster holds a reference to this very object. The
473
+ * env-read recipe on BASEFOLD_ADMISSIONS edits files instead, and a tap reverted underneath a
474
+ * running process reports ZEROES rather than crashing, which reads exactly like "the rule never
475
+ * fires"; if you use it, hash the tree either side of the window and quote both hashes.
476
+ *
477
+ * A CENSUS OVER WINNING LABELS CANNOT STAND IN FOR THAT — "only a row whose winner carries
478
+ * `/livebase-block` can move" is unsound for the reason rank.ts's `seen` dedup spells out. This
479
+ * table's own winning-label census reads 5 rows and read 7 before `/unfolded` shipped, and the
480
+ * two that left differ: `synthetic:foldpark` by RENAME (byte-identical source, MATCH either
481
+ * side), `synthetic:unfoldpark` because its winning SPELLING changed, 402 bytes at diff:9 to 397
482
+ * at MATCH.
483
+ *
484
+ * WHICH ROWS THE ABLATION REACHES, since "found by running it" is only an instruction until
485
+ * someone runs it. Enumeration only, no compiles, both arms — `single-cell` spliced out of this
486
+ * array in process, prototypes only, map-less, over all artifact rows; run twice with the working
487
+ * tree hashed either side and byte-identical both times. THE ARTIFACT HELD 951 ROWS THAT DAY (358
488
+ * agbcc, 593 not) and has grown since: this paragraph is at the earlier scope and is not re-run
489
+ * against the later one, so its per-row fans still stand and its two TOTALS do not.
490
+ * THIRTEEN rows change their distinct-source set, corpus fan 48995 → 42701, and ZERO non-agbcc
491
+ * rows are reached — the arm easiest to skip, because this table sits on the UNCONDITIONAL half of
492
+ * the roster and is offered to ido/kmc/mwcc/gcc272 too. READ THAT POPULATION HONESTLY, since a denominator is a rig
493
+ * artifact until it is broken out: of the 593 non-agbcc rows, 400 enumerate in both arms and NONE
494
+ * of them moves; the other 193 decline at the lift or structure seam and have no fan on either
495
+ * side, so they are vacuous rather than evidence. The claim is over the 400.
496
+ * Seven of the thirteen are the rows this note already names (the three matches above, plus the
497
+ * four re-run below); the other six are `kleod:ConfigureEntityBehavior` (fan 1248 → 864),
498
+ * `kleod:ProcessInputAndUpdateEntities` (23040 → 19200), `kleod:SetupBG3WindowOverlay`
499
+ * (696 → 640), `kleod:UpdateCameraScroll` (15936 → 14272), `kleod:UpdateWorldMapNodeAnim`
500
+ * (216 → 192) and `synthetic:livepark` (32 → 24, a MATCH row). `ConfigureEntityBehavior` and
501
+ * `livepark` keep their published winning source in the ablated fan, so neither outcome nor score
502
+ * can move on them. THE OTHER FOUR ARE UNPRICED, not free: they are real-tier rows whose
503
+ * published spelling is map-FUL, and a map-less enumeration does not contain it in EITHER arm, so
504
+ * this rig cannot say. Do not quote them as unmoved.
505
+ *
506
+ * The rows it is quoted against, all re-run at this commit: `synthetic:sizebound` — the
507
+ * counterexample row above, which the narrow family still helps — goes 8 → 10;
508
+ * `sa3:Sio32MultiLoadIntr`, the one REAL-tier row involved and the reason this gate is not a
509
+ * synthetic-only concern, is 69 either way; and `synthetic:foldpark` and `synthetic:unfoldpark`
510
+ * are MATCH either way. A round promoting this rule into `BASECSE_GATES` prices it against all of
511
+ * them, re-run rather than re-quoted.
512
+ *
513
+ * Why the ACCESS SHAPE and not the address: an MMIO register file and the IWRAM halfword beside
514
+ * it are both numeric constants in the same range. And why the rule is not in `BASECSE_GATES`: it
515
+ * would reject nothing there, being a strict refinement of `repeated-const-offset` — past
516
+ * `single-use`, a base with no variable index and one distinct offset touched it twice. */
517
+ export const LIVEBASE_BLOCK_GATES: readonly Gate<BaseKey>[] = [
518
+ ...LIVEBASE_GATES,
519
+ {
520
+ id: 'single-cell',
521
+ why: 'a base reached at one fixed offset reads as a scalar, which the source more often spells inline',
522
+ sound: false,
523
+ rejects: (c) => c.singleCell,
524
+ },
525
+ ];
526
+
527
+ /** `/unfolded`'s admission (rank.ts): `/livebase` plus `folded-offset`, which REQUIRES the fold
528
+ * evidence `BASEFOLD_GATES` exempts on. The two tables read one field and ask opposite questions
529
+ * of it — that one exempts `single-use` for a key reached ONCE whose offset survived, this one
530
+ * keeps `single-use` and demands the same survival of a key reached TWICE — so neither is the
531
+ * other relaxed.
532
+ *
533
+ * WHY IT IS A SELECTION AND NOT A WIDENING. `/livebase` and `/livebase-block` ARE one chain:
534
+ * every key the narrow table binds the wide one binds too, so between them a function has only
535
+ * "all the reused bases" and "those minus the scalar cells". A source that parked ONE numeric
536
+ * base and left a second one inline is at neither end of that chain, and `singleCell` cannot
537
+ * separate the two — both are reached at fixed offsets. `unfoldedOffset` can: on a compiler that
538
+ * folds a constant subscript into the literal it materializes, the offset that reached the
539
+ * instruction is the one a POINTER LOCAL strode, and the address that reached the pool with the
540
+ * offset already in it is the one the source spelled inline.
541
+ *
542
+ * BUT THIS TABLE IS NOT ITSELF ON THAT CHAIN, and reading it as "the middle one" is wrong in
543
+ * both directions. `singleCell` and `unfoldedOffset` are independent fields, so this table and
544
+ * `LIVEBASE_BLOCK_GATES` are lattice-INCOMPARABLE: a block walk with no constant displacement
545
+ * (`varIndexed`, so not `singleCell`; offset 0, so no `unfoldedOffset`) is bound by that table
546
+ * and refused by this one, and a scalar cell at a surviving offset is the reverse. Censused at
547
+ * `decompile()`'s default structuring, map-less, over the 363 agbcc rows the artifact carried
548
+ * when the census ran (23 unstructurable): 8 keys on 7 functions go to `/livebase-block` alone and 5 keys on 5 functions
549
+ * to this table alone, and `kleod:ProcessInputAndUpdateEntities` crosses BOTH ways on one tree.
550
+ * That is why `sameBases` compares sets and never subsets (see COVERAGE in the header).
551
+ *
552
+ * The evidence is not proof and the gate never treats it as such — what it produces is a
553
+ * candidate beside the other admissions, refereed by the differ. Two counterexamples, both
554
+ * compiled rather than argued:
555
+ * • an aggregate member emits the same operand offset as a strided pointer local;
556
+ * • so does a SYMBOL BASE whose address the tree also uses as a value. agbcc CSEs the symbol
557
+ * reference where it re-materializes the integer, so `sink((int)(u8 *)&gS); sink(((u8 *)&gS)
558
+ * [3]); sink(((u8 *)&gS)[4]);` and its pointer-local twin emit BYTE-IDENTICAL Thumb — one
559
+ * `.word gS`, `add r0, r4, #0`, `ldrb [r4, #0x3]`, `ldrb [r4, #0x4]`. On such a key the
560
+ * field is not weak evidence, it is none. At the scope above, FOUR of this table's 43
561
+ * admissions sit on one: `ProcessInputAndUpdateEntities` gBgDataPtrs / gCallbackQueue /
562
+ * gUnk_03004C20 and `sa3:sub_802DFC8` gStageData. It costs fan and a tie-break, never
563
+ * meaning, because the inline spelling rides beside it.
564
+ * Absence is not evidence either way — see the header for the THREE ways to get one, including
565
+ * the displacement the instruction could not encode, where this gate is blind rather than
566
+ * unpersuaded — so `folded-offset` refuses on MISSING EVIDENCE and never on a claim that the
567
+ * offset was folded.
568
+ *
569
+ * This table is its own gate object, so `without(UNFOLDED_GATES, 'folded-offset')` is
570
+ * `LIVEBASE_GATES` and the rule prices by ablation — the handle `BASEFOLD_GATES`' exemption
571
+ * cannot have (see the note above it). */
572
+ export const UNFOLDED_GATES: readonly Gate<BaseKey>[] = [
573
+ ...LIVEBASE_GATES,
574
+ {
575
+ id: 'folded-offset',
576
+ why: 'no offset survived into the operand, so nothing says a pointer local strode this base',
577
+ sound: false,
578
+ rejects: (c) => !c.unfoldedOffset,
579
+ },
580
+ ];
581
+
582
+ /** `/orderbase`'s admission (rank.ts): the two rules ABOUT THE BASE ablated — `cast-base` and
583
+ * `single-use` — and one rule demanding the ORDER LICENCE in their place.
584
+ *
585
+ * THE EVIDENCE IS THE ASSEMBLY, and it is the only thing here that is. Every other table on this
586
+ * roster predicts which spelling the source wrote from the SHAPE of the accesses — how many, at
587
+ * what offsets, inside a loop or not — and each prediction has a counterexample this file names.
588
+ * This one reads the instruction ORDER (raise/globalshape.ts's header carries the compiles behind
589
+ * it, and why a declared array and a pointer LOCAL reach the same order by different routes).
590
+ * Compiled through the benchmark's own agbcc command, against a base-first object:
591
+ *
592
+ * extern u16 gTbl[]; gTbl[i] 0 ┐ the same object
593
+ * u16 *p = (u16 *)&gTbl; p[i] 0 ┘
594
+ * ((u16 *)&gTbl)[i] 2 a different one
595
+ *
596
+ * A DERIVED DECLARATION IS NOT THE SAME AS A BARE SPELLING, which is the thing to know before
597
+ * reading the population: where `raise/globalshape.ts` shapes a name the structurer usually spells
598
+ * it bare and no key exists here at all — but a shape is ONE element type for the whole name, so an
599
+ * access that strides something else keeps its cast and its key. `kleod:SetupBG3WindowOverlay`'s
600
+ * `gBgInfo` derives `elemSize 4` and still reaches this table at stride 28, in both arms.
601
+ *
602
+ * What this table admits, censused over the artifact's 370 agbcc rows: map-less 8 rows / 10 keys,
603
+ * map-ful 10 rows / 12 keys. TWO shapes, and the arms differ:
604
+ *
605
+ * • the STRUCT ELEMENT — no `intType`, members read at a displacement — 9 keys map-less, 10
606
+ * map-ful, and the only shape `cast-base`'s ablation reaches. The map-ful extra is
607
+ * `kleod:StreamCmd_SetBGScroll`'s `gBgInfo`, a pool word the map-less lift leaves NUMERIC: the
608
+ * map is what makes it a named global, not anything the licence read from the map.
609
+ * • a PLAIN SCALAR LEAF with no cast anywhere, which only `single-use`'s ablation admits — and a
610
+ * reader deciding whether `single-use` can be put back needs it named. Both inhabitants reach
611
+ * this table for a reason that is NOT an interior read. `kleod:UpdateCameraScroll`'s
612
+ * `gSineTable` (both arms) is refused a declaration on `interior-or-non-access`'s NON-ACCESS
613
+ * half: one clean load, and the same element address feeding three other `add`s.
614
+ * `pokeemerald:Sin2`'s `gSineDegreeTable` (map-ful only) is refused nothing — it DERIVES
615
+ * `elemSize 2` unsigned, and map-less that is what it is spelled as, so there is no key. The
616
+ * symbol map declares the same element SIGNED, map-first wins, and an unsigned load through a
617
+ * signed declaration cannot be spelled bare, so the cast comes back and with it the key.
618
+ *
619
+ * WHY `single-use` GOES. The rule's theory is that one access re-materializes as cheaply as a
620
+ * named local, which is a guess about the source in the absence of evidence; here there is
621
+ * evidence, and `synthetic:bgarr` is a one-access function whose target loads the pool word first.
622
+ * The licence is its OWN gate rather than an exemption folded into a relaxed `single-use`, so
623
+ * `without(ORDERBASE_GATES, 'order-licensed')` prices it — the handle `BASEFOLD_GATES`' fused
624
+ * exemption cannot have.
625
+ *
626
+ * `loop` and `repeated-const-offset` STAY. Neither is about the base's identity and both are fan
627
+ * control; ablating them is `/livebase`'s axis, already on the roster, and a row that wants the
628
+ * product is one roster line. THE PRICE OF THAT IS A HOLE, and it is named rather than left for a
629
+ * reader to find: a licensed base with a use inside a loop is admitted by NO table on the roster —
630
+ * this one refuses it on `loop`, and every table that ablates `loop` refuses it on `cast-base` or
631
+ * `single-use` — which is the "a base set that is no row's stays unreachable" debt
632
+ * docs/level-tower.md books against a roster of hand-picked subsets. Measured over the artifact's
633
+ * 370 agbcc rows in both symbol-map arms, `admittedBases(sfn, without(ORDERBASE_GATES, 'loop'))`
634
+ * minus everything any shipped table admits is 0 rows, so the hole is structural and unpopulated.
635
+ * Folding the licence into `cast-base` and `single-use` as an EXEMPTION would close it and reach
636
+ * every table, at the cost of the ablation handle below — the trade `BASEFOLD_GATES`' fused
637
+ * exemption already made once, and not one to make for a class with no inhabitant. rank.ts offers this row only where
638
+ * `compilerBehaviors.arrayShapeFromStride` — the same opt-in the licence itself carries, because
639
+ * the fork is agbcc's and no other compiler has been shown to make it.
640
+ *
641
+ * ITS REACH IS A FUNCTION OF THE SYMBOL MAP, and unevenly so — a sweep is evidence about the arm
642
+ * and the subtree it ran over, so both are stated. Over every cleanly-lifting function of each
643
+ * project's WHOLE `asm` tree, `asm/matchings` included:
644
+ * klonoa binds 18 of 284 map-ful and ZERO map-less, sa3 30 of 1709 map-ful and 29 map-less.
645
+ * The klonoa collapse is THIS GATE and not a missing base: ablating `order-licensed` admits 118
646
+ * map-less klonoa functions where the full table admits none — so the bases are collected and it
647
+ * is the ORDER EVIDENCE that map-less klonoa does not carry. Why the evidence depends on the map is not
648
+ * attributed here; instrument `collect`'s `ordered` before assuming. What follows for a reader
649
+ * either way: a klonoa row carrying no symbol map exercises nothing in this table, and its whole
650
+ * family — the `scope` placement included — is carried on that project by the map-ful arm alone. */
651
+ export const ORDERBASE_GATES: readonly Gate<BaseKey>[] = [
652
+ ...ablateHeuristic(ablateHeuristic(BASECSE_GATES, 'cast-base'), 'single-use'),
653
+ {
654
+ id: 'order-licensed',
655
+ why: 'nothing in the assembly says this base had a home: the index was scaled first, or the order says nothing',
656
+ sound: false,
657
+ rejects: (c) => !c.orderLicensed,
658
+ },
659
+ ];
660
+
661
+ /** The census without the rewrite, so a caller choosing between admissions can compare what two
662
+ * tables would bind for one tree walk each. */
663
+ export function admittedBases(sfn: SFn, gates: readonly Gate<BaseKey>[]): readonly string[] {
664
+ return admit(sfn, gates).keys;
665
+ }
666
+
667
+ /** What each key NAMES, for a caller that has to translate a key into another pass's vocabulary —
668
+ * `l3/scopebase.ts` spells an `addr` base's identity differently, so a string compare across the
669
+ * two would silently never match. Every key the tree holds, admitted or not. */
670
+ export function baseSites(sfn: SFn): ReadonlyMap<string, { base: HoistableBase; width: number; signed: boolean }> {
671
+ return collectFrom(sfn).meta;
672
+ }
673
+
674
+ /** The whole census for one tree — every field seeded empty and one `collect` walk over the body.
675
+ * ONE constructor, because `baseSites` and `admit` must judge the same census: a field added to
676
+ * `Collected` and seeded in only one of two copies reads as an empty tally rather than as a type
677
+ * error. */
678
+ function collectFrom(sfn: SFn): Collected {
679
+ const c: Collected = {
680
+ count: new Map(),
681
+ order: [],
682
+ meta: new Map(),
683
+ inLoop: new Set(),
684
+ constOffCount: new Map(),
685
+ varIndexed: new Set(),
686
+ operandOff: new Set(),
687
+ ordered: new Map(),
151
688
  };
689
+ collect(sfn.body, c, false);
690
+ return c;
691
+ }
152
692
 
153
- // Reuse 2+ and NOT used inside a loop. A loop-body base is loop-invariant, so the compiler ALSO
154
- // keeps it in a register across the loop but hoisting it to the function top forces a
155
- // callee-saved register that can add prologue push/pop the original avoided, worsening the match
156
- // (register-pressure matching, not a correctness issue). Straight-line / branch reuse is the safe
157
- // win; a loop-body base is left inline for a future scope-aware hoist.
158
- const { count, order, meta } = c;
159
- const hoisted = order.filter((k) => (count.get(k) ?? 0) >= 2 && !c.inLoop.has(k) && !hasRepeatedConstOffset(k));
693
+ /** The keys `gates` admits, in first-use order, with the census they were judged from. */
694
+ function admit(sfn: SFn, gates: readonly Gate<BaseKey>[]): { c: Collected; keys: string[] } {
695
+ const c = collectFrom(sfn);
696
+ const keys = c.order.filter((k) => {
697
+ const offsets = c.constOffCount.get(k);
698
+ return (
699
+ firstRejection(gates, {
700
+ key: k,
701
+ uses: c.count.get(k) ?? 0,
702
+ inLoop: c.inLoop.has(k),
703
+ repeatedConstOffset: [...(offsets?.values() ?? [])].some((n) => n >= 2),
704
+ singleCell: !c.varIndexed.has(k) && (offsets?.size ?? 0) <= 1,
705
+ unfoldedOffset: c.operandOff.has(k),
706
+ castBase: c.meta.get(k)!.base.k === 'cast',
707
+ orderLicensed: c.ordered.get(k) === true,
708
+ }) === null
709
+ );
710
+ });
711
+ return { c, keys };
712
+ }
713
+
714
+ /** THE TWO FLAT PLACEMENTS ALWAYS ANSWER. Both put the run in the top-level statement list, so
715
+ * there is always a tree to return and the caller has no decline to handle — which is why
716
+ * `pipeline.ts` can commit one of them.
717
+ *
718
+ * `scope` DECLINES, and the overload is how a caller is told: `null` means the placement had
719
+ * nothing to say about this function, because no init landed inside a nested list. That tree is
720
+ * byte-for-byte the `first-use` spelling (l3/hoist.ts's `nested`), and the roster withholds the
721
+ * `first-use` row for this table deliberately (rank.ts, ORDERBASE_ADMISSIONS) — so returning it
722
+ * ships the withheld candidate under the scoped row's name.
723
+ *
724
+ * IT WITHDRAWS A SPELLING RATHER THAN COLLAPSING A DUPLICATE, which is what the decline costs.
725
+ * `ORDERBASE_ADMISSIONS` holds exactly two rows, `head` and `scope`, so nothing is ever enumerated
726
+ * at `first-use` for this table and the refused tree has no twin to fold into — its shape and
727
+ * `/volatile` products go with it. Over each project's whole `asm` tree, map-ful: of the 48
728
+ * functions `ORDERBASE_GATES` admits, 7 place an init inside a nested list and 41 do not, and for
729
+ * 29 of the 41 the refused spelling is one the `head` row does not already produce. Instrumented
730
+ * on two of those, both map-ful — the `kleod:StreamCmd_SetBGScroll` row (fan 11), and
731
+ * `sub_808A4EC` in the sa3 checkout, which is no row here (fan 40) — where the `head` source is
732
+ * among the candidates `enumerateCandidates` returns and the withheld `first-use` source is not. */
733
+ export function hoistBaseLocals(sfn: SFn, gates?: readonly Gate<BaseKey>[], placement?: 'head' | 'first-use'): SFn;
734
+ export function hoistBaseLocals(sfn: SFn, gates: readonly Gate<BaseKey>[], placement: HoistPlacement): SFn | null;
735
+ export function hoistBaseLocals(
736
+ sfn: SFn,
737
+ gates: readonly Gate<BaseKey>[] = BASECSE_GATES,
738
+ placement: HoistPlacement = 'head',
739
+ ): SFn | null {
740
+ const { c, keys: hoisted } = admit(sfn, gates);
741
+ const { meta } = c;
160
742
  if (hoisted.length === 0) {
161
- return sfn;
743
+ return placement === 'scope' ? null : sfn;
162
744
  }
163
745
 
164
746
  const fresh = nameAllocator(sfn);
165
747
 
166
748
  const localFor = new Map<string, string>();
167
749
  const newLocals: { name: string; type: IrType }[] = [];
168
- const hoistStmts: Stmt[] = [];
750
+ const hoistStmts: BaseInit[] = [];
169
751
  for (const k of hoisted) {
170
752
  const m = meta.get(k)!;
171
- const ptrType = T.ptr(scalarTypeForAccess(m.width, m.signed));
753
+ // A CAST base already wears the pointer type its element needs, so the local takes that type
754
+ // and the cast the structurer wrote becomes the initializer unchanged.
755
+ const ptrType = m.base.k === 'cast' ? m.base.to : T.ptr(scalarTypeForAccess(m.width, m.signed));
172
756
  const nm = fresh();
173
757
  localFor.set(k, nm);
174
758
  newLocals.push({ name: nm, type: ptrType });
175
759
  // `p = (T *)base` — the cast makes the local the access's pointer type so each `p[i]` strides it.
176
- hoistStmts.push({ k: 'assign', name: nm, value: { k: 'cast', to: ptrType, e: m.base } });
760
+ hoistStmts.push({
761
+ k: 'assign',
762
+ name: nm,
763
+ value: m.base.k === 'cast' ? m.base : { k: 'cast', to: ptrType, e: m.base },
764
+ });
177
765
  }
178
766
 
179
- const body = [...hoistStmts, ...sfn.body.map((s) => rewriteStmt(s, localFor))];
180
- return { ...sfn, body, locals: [...sfn.locals, ...newLocals] };
767
+ const rewritten = sfn.body.map((s) => mapStmtExprs(s, (e) => rewrite(e, localFor)));
768
+ // The new inits join the tree's LEADING run of base inits rather than being prepended above it:
769
+ // when rank's /livebase re-runs this pass the head already carries the default run's, and
770
+ // blindly prepending would spell the new base's pool load above locals the compiler loads first.
771
+ // That is why `HoistPlacement` has no `prepend` — the hazard is typed out rather than warned
772
+ // about. `placement` then answers where the whole run goes (l3/hoist.ts, the mechanism
773
+ // sinkinit.ts's policy shares). Under either value it is ordered by first use, which is
774
+ // pool-load order (see `collect`) — deliberately reaching the single default run too (a head of
775
+ // user pointer inits before a firing hoist), where it repairs the same invariant.
776
+ const locals = [...sfn.locals, ...newLocals];
777
+ // The shell carries the minted DECLARATIONS and the REWRITTEN statements together: first-use
778
+ // would not know the new names without the first, and would query the pre-rewrite accesses
779
+ // without the second.
780
+ const { body, moved, nested } = placeBaseLocals({ ...sfn, locals, body: rewritten }, hoistStmts, placement);
781
+ // DEGENERATE SCOPE IS A DECLINE, not a candidate: nothing reached a nested list, so this is the
782
+ // `first-use` tree, which this function's header prices.
783
+ if (placement === 'scope' && nested.length === 0) {
784
+ return null;
785
+ }
786
+ const out = { ...sfn, body, locals };
787
+ // The two FLAT placements can only put the run in the top-level list, above every use of it by
788
+ // construction. `scope` puts an init inside a nested list, which is where a placing lever can ship
789
+ // the one failure the byte differ rewards — a read of a local whose assignment does not reach it —
790
+ // so the tree it emits is checked rather than argued (contracts.ts).
791
+ //
792
+ // THE POPULATION IS THE MOTION, and `moved` is what the placer says it moved rather than what this
793
+ // function minted. The leading run this pass inherits is the DEFAULT hoist's, committed by
794
+ // `structureChecked` before rank's levers see the tree (pipeline.ts), and `scope` moves those
795
+ // inits too — so `newLocals` names less than half of what has to be judged. Real inhabitants, in the
796
+ // CHECKOUTS rather than in a benchmark row — `DecompressAndLoadLevel` in klonoa and `sub_8052474`
797
+ // in sa3, both map-ful — each sink one inherited `p0` beside the minted `p1`.
798
+ if (placement === 'scope') {
799
+ assertHoistsDominate(out, new Set([...newLocals.map((l) => l.name), ...moved]));
800
+ }
801
+ return out;
181
802
  }