@asmlift/core 0.8.0 → 0.9.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 (105) hide show
  1. package/README.md +34 -30
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +248 -0
  4. package/src/backend/c.ts +5 -2
  5. package/src/backend/cfamily.ts +236 -37
  6. package/src/backend/cpp.ts +114 -9
  7. package/src/backend/pascal.ts +39 -0
  8. package/src/cdecl/constant.ts +59 -0
  9. package/src/cdecl/lex.ts +209 -0
  10. package/src/cdecl/parse.ts +975 -0
  11. package/src/cdecl/spell.ts +69 -0
  12. package/src/codegen-flags.ts +38 -6
  13. package/src/compiler-diagnostics.ts +194 -0
  14. package/src/contracts.ts +615 -79
  15. package/src/declare.ts +75 -5
  16. package/src/frontend/asmdata.ts +19 -5
  17. package/src/frontend/call-plan.ts +686 -0
  18. package/src/frontend/device-pins.ts +277 -0
  19. package/src/frontend/errors.ts +5 -0
  20. package/src/frontend/flags-edge.ts +101 -0
  21. package/src/frontend/fpu.ts +167 -0
  22. package/src/frontend/frame-objects.ts +2131 -0
  23. package/src/frontend/high-half.ts +2 -1
  24. package/src/frontend/local-object.ts +490 -0
  25. package/src/frontend/mips.ts +363 -40
  26. package/src/frontend/opaque.ts +97 -18
  27. package/src/frontend/ppc.ts +520 -129
  28. package/src/frontend/reloc-symbol.ts +94 -13
  29. package/src/frontend/splat.ts +108 -5
  30. package/src/frontend/ssa.ts +669 -76
  31. package/src/frontend/stackargs.ts +120 -39
  32. package/src/frontend/thumb.ts +2764 -2018
  33. package/src/ir/alias.ts +29 -0
  34. package/src/ir/core.ts +101 -3
  35. package/src/ir/discipline.ts +248 -0
  36. package/src/ir/float-bits.ts +47 -0
  37. package/src/ir/opcodes.ts +84 -112
  38. package/src/ir/parse.ts +1 -0
  39. package/src/ir/types.ts +118 -2
  40. package/src/ir/verify.ts +150 -3
  41. package/src/l3/address.ts +5 -0
  42. package/src/l3/argcopy.ts +38 -6
  43. package/src/l3/ast.ts +186 -14
  44. package/src/l3/coalesce.ts +21 -13
  45. package/src/l3/dce.ts +32 -10
  46. package/src/l3/gates.ts +9 -3
  47. package/src/l3/hoist.ts +8 -7
  48. package/src/l3/initfirst.ts +27 -9
  49. package/src/l3/intcell.ts +97 -0
  50. package/src/l3/mentions.ts +10 -4
  51. package/src/l3/narrowdecl.ts +218 -0
  52. package/src/l3/nearbase.ts +10 -5
  53. package/src/l3/parkfirst.ts +8 -5
  54. package/src/l3/pollguard.ts +3 -2
  55. package/src/l3/ptrcell.ts +357 -0
  56. package/src/l3/regspell.ts +17 -3
  57. package/src/l3/reindex.ts +10 -7
  58. package/src/l3/symbol-refs.ts +104 -23
  59. package/src/l3/tailmerge.ts +6 -19
  60. package/src/l3/typing.ts +137 -25
  61. package/src/l3/unreduce.ts +4 -4
  62. package/src/l3/volstore.ts +96 -7
  63. package/src/mangle.ts +11 -1
  64. package/src/pattern/engine.ts +19 -16
  65. package/src/pipeline.ts +57 -12
  66. package/src/proto-context.ts +788 -0
  67. package/src/proto.ts +689 -50
  68. package/src/raise/arrays.ts +4 -3
  69. package/src/raise/declared-volatile.ts +131 -0
  70. package/src/raise/divpow2.ts +2 -2
  71. package/src/raise/errors.ts +17 -0
  72. package/src/raise/floathelpers.ts +243 -0
  73. package/src/raise/globalshape.ts +11 -5
  74. package/src/raise/gvn.ts +26 -5
  75. package/src/raise/narrow.ts +29 -2
  76. package/src/raise/narrowlocal.ts +11 -6
  77. package/src/raise/offsetnames.ts +510 -0
  78. package/src/raise/pairparams.ts +81 -0
  79. package/src/raise/paramwidth.ts +7 -0
  80. package/src/raise/pre-recovery.ts +78 -4
  81. package/src/raise/recover.ts +14 -10
  82. package/src/raise/retsink.ts +1 -1
  83. package/src/raise/shortcircuit.ts +41 -19
  84. package/src/raise/softdiv.ts +21 -23
  85. package/src/raise/struct-arrays.ts +5 -4
  86. package/src/raise/structs.ts +311 -98
  87. package/src/raise/truncload.ts +366 -0
  88. package/src/raise/widehelpers.ts +106 -0
  89. package/src/rank-declare.ts +30 -5
  90. package/src/rank-variations.ts +36 -8
  91. package/src/rank.ts +276 -80
  92. package/src/runtime-helpers.ts +287 -0
  93. package/src/stillborn.ts +289 -0
  94. package/src/structure/analysis.ts +1285 -448
  95. package/src/structure/hazards.ts +633 -58
  96. package/src/structure/local-statics.ts +381 -0
  97. package/src/structure/namecoalesce.ts +1 -1
  98. package/src/structure/redundant-test.ts +99 -0
  99. package/src/structure/structure.ts +1772 -395
  100. package/src/structure/switch-recover.ts +159 -78
  101. package/src/symbols.ts +38 -3
  102. package/src/target.ts +662 -59
  103. package/src/trace.ts +44 -24
  104. package/src/variation-definitions.ts +119 -1
  105. package/src/variation-tokens.ts +20 -6
@@ -0,0 +1,510 @@
1
+ // asmlift — A NAME FOR AN ADDRESS THE MACHINE BUILT BY ARITHMETIC, read off the same symbol map
2
+ // the pool-word promotion reads.
3
+ //
4
+ // WHAT IS MISSING WITHOUT THIS. frontend/thumb.ts promotes a POOL-LOADED word to `gaddr sym` by
5
+ // asking the address-keyed map what lives at that address, and its own note records the boundary:
6
+ // "Only pool-loaded words promote (an address built by arithmetic never reaches here)". A
7
+ // compiler that has several neighbouring cells to touch does not load a pool word per cell — it
8
+ // loads one and walks:
9
+ //
10
+ // ldr r1, .L4 @ .word 0x40000ba -> gaddr REG_DMA0CNT_H
11
+ // strh r2, [r1]
12
+ // adds r1, #12
13
+ // strh r3, [r1] @ 0x40000c6 — REG_DMA1CNT_H, and the map says so
14
+ //
15
+ // so the map is never asked about the second cell, and the second store spells an offset walk off
16
+ // the first name. That is not merely a longer spelling. `structure.ts` classifies a symbol whose
17
+ // address is ever an `add`/`sub` operand as AGGREGATE, which drops it from `scalarGlobals`, and
18
+ // the bare-name spelling and the `volatile` qualifier both hang off that set — so one walked store
19
+ // de-names the symbol at EVERY site in the function, including the off-0 ones, and silently casts
20
+ // the `volatile` away. Observable in the object: with the cast form agbcc CSEs a read the target
21
+ // re-reads.
22
+ //
23
+ // It is also a REPRESENTATION gap and not merely a missed call. `SymbolInfo` carries no address
24
+ // and `structure()` receives the map NAME-keyed, so once the frontend has emitted `gaddr S`
25
+ // nothing downstream can compute `addr(S) + K` at all. That puts the pass anywhere BEFORE
26
+ // `structure()`, and it sits at the top of that window for one reason: `raise/arrays.ts`,
27
+ // `raise/struct-arrays.ts` and `raise/memberarrays.ts` match the same `add(gaddr, K)` shape this
28
+ // pass consumes, and `interior-offset` below cedes the in-object case back to them — so the
29
+ // question "is this address an object of its own?" is answered first and what it refuses is handed
30
+ // on untouched. The passes on the OTHER side of the question are `l3/nearbase.ts` and
31
+ // `l3/basecse.ts`: they BUILD the one-base-many-cells form this one un-spells — `nearbase`'s own
32
+ // example is `add #72` / `add #74` off one pool word — which is why the walk stays reachable from
33
+ // `/raw-globals`, where no map name exists to replace it.
34
+ //
35
+ // THE CORPUS DOES NOT REFEREE THE SEAT: run instead from `raiseRecovered`'s
36
+ // `beforeRecover` hook, after every recognizer, and `pnpm bench gates --pass offsetnames` reports
37
+ // the identical counts on all six rows that refuse or name anything, with
38
+ // `kleod:DeleteAllSaveData:agbcc` the same winner at the same 0/81. The argument is about which
39
+ // pass sees a shape first, not about a row.
40
+ //
41
+ // WHAT IT DOES. Over the lifted L1 fn: resolve every `add`/`sub` whose operand tree bottoms out
42
+ // at a `gaddr` plus constants, and where the map holds a symbol EXACTLY at the resulting address,
43
+ // rewrite that op into `gaddr` for it. No opcode, no representation and no level is introduced —
44
+ // `gaddr` and `add` both already exist, and everything downstream is the named-global machinery
45
+ // the pool path already feeds.
46
+ //
47
+ // NAMING IS NOT A FIDELITY LOSS HERE, and that is compiled rather than assumed: the same four
48
+ // `REG_DMA*CNT_H` stores spelled as four independent named volatile MMIO stores compile back to
49
+ // `ldr r1 / strh / adds r1,#12 / strh / …` — agbcc re-derives the walk itself from the four
50
+ // literal addresses. Whether the compiler will re-derive it for an `extern`-shaped name it has to
51
+ // relocate is a SCORE question the ranker referees, not a correctness one; the address this pass
52
+ // spells is the address the machine computed, by the map's own arithmetic.
53
+ //
54
+ // SO WHY A DEFAULT AND NOT A VARIATION. The usual test is whether the asm UNDERDETERMINES the
55
+ // source, and here it does: the `adds r1,#12` chain is what agbcc emits for the walk AND for four
56
+ // independent named stores. What settles it the other way is that the two spellings are not peers.
57
+ // The named one is address-identical by construction and restores the `volatile` the cast form
58
+ // erases (see the AGGREGATE note above); the walk is reachable from `/raw-globals`, which is the
59
+ // arm that drops every map name at once. The price is measured and real — a default rewrite
60
+ // removes the walk spelling from the fan at every admitted site, and `sub_0804E708`'s falls 8 -> 7
61
+ // against `origin/main` — and it buys 0 lost rows. A per-site variation would be the third answer,
62
+ // and nothing in the corpus asks for it yet.
63
+ //
64
+ // WHY THERE IS NO SYNTHETIC ROW, measured rather than conceded. A walk only happens where the
65
+ // compiler saw LITERAL addresses — spelled as `extern`s the pool words are relocations, agbcc
66
+ // cannot see two cells are adjacent, and it loads one word per cell — and a literal address is
67
+ // exactly what `/raw-globals` spells with no map at all. So a row built on the walk alone always
68
+ // carries a map-less candidate with the same bytes. Two reductions were built and both scored the
69
+ // broken candidate exactly as they scored the right one: three MMIO stores twelve bytes apart is
70
+ // 0/13 with this pass and 0/13 with it ablated, and so is the version that re-reads the walked-to
71
+ // cell, whose `/raw-globals` sibling wins at 0/13 either way. The capability is row-visible only
72
+ // where the function ALSO needs something the map provides, which is what
73
+ // `kleod:DeleteAllSaveData:agbcc` is — ablate the pass there and it goes MATCH -> 20/82 while its
74
+ // best `/raw-globals` candidate is 72/97. The unit tests below carry the rest.
75
+ //
76
+ // WHAT REFUSES is `OFFSET_NAME_GATES` below, and every rejection leaves the arithmetic exactly as
77
+ // the frontend emitted it. That is a spelling whose BYTES are right under any declaration, and it
78
+ // is not a neutral one: the `add` it leaves behind is what makes `structure.ts` classify the base
79
+ // AGGREGATE, so a refusal here still de-names the symbol at its off-0 sites and drops the
80
+ // `volatile` (`gVolA = 0;` becomes `*(u16 *)&gVolA = 0;` the moment one refused walk stands beside
81
+ // it). The per-SITE classification that would answer it lives at `scalarGlobals`, three levels
82
+ // away and booked in `docs/level-tower.md`; a refusal here is the safe answer to the question this
83
+ // pass asks and not a cost-free one.
84
+ //
85
+ // WHICH GATE BOUNDS THE CORPUS PATH — `pnpm bench gates --pass offsetnames`, plus one run per real
86
+ // project (`--only <project>:`), counting a refusal per EVALUATION rather than per site (a row
87
+ // lifts more than once, which that command's own footer states):
88
+ //
89
+ // interior-offset 25 pokeemerald 13, synthetic `sbscope` 6, marioparty3 4, kleod 2
90
+ // access-behind-merge 1 kleod `ButtonConfigurationScreenInit`
91
+ // base-unsized 1 snowboardkids2 `func_80014440_15040`'s `gDefaultFontPalette+2`
92
+ // everything else 0
93
+ //
94
+ // Two of the three heuristics decide the whole path — `no-symbol-at-offset` fires nowhere. Of the
95
+ // SOUND rules exactly one refuses anything here, once: `access-behind-merge`, on the single site
96
+ // in `ButtonConfigurationScreenInit` whose address is merged from two arms. That row scores
97
+ // 107/212 whether the site is named or refused, and its fan moves 440 -> 446 against `origin/main`
98
+ // rather than the other way, because what a refusal leaves is arithmetic and the respell
99
+ // variations have something to apply to again. The rest — a code address at either end, an address
100
+ // the map spells two ways at either end, a width the map does not state, a width that disagrees
101
+ // with the access, a store onto a `const` name — refuse NOTHING here and are pinned by
102
+ // `offset-names.test.ts` alone.
103
+ import { type Fn, type Op, type Value, defOpMap } from '../ir/core';
104
+ import { verify } from '../ir/verify';
105
+ import { type Gate, firstRejection } from '../l3/gates';
106
+ import { dce } from '../pattern/engine';
107
+ import { type SymbolInfo, type SymbolMap, accessSignedness, lookupSymbol } from '../symbols';
108
+ import { type StampBehaviors, stampDeclaredVolatileUnder } from './declared-volatile';
109
+
110
+ /** One `add`/`sub` whose address the gates judge. Built only for an op that already resolves to a
111
+ * named base plus a NON-ZERO constant — a zero offset names the base itself, which the pool path
112
+ * spelled already, so there is nothing to judge. */
113
+ export interface OffsetAddress {
114
+ /** the base symbol's own entry, read at the FIRST address the map carries the name at */
115
+ readonly base: SymbolInfo;
116
+ /** the map carries the base name at more than one address, so `target` stands on an arbitrary
117
+ * pick — the one thing `base-address-ambiguous` is there to refuse */
118
+ readonly ambiguous: boolean;
119
+ /** the base `gaddr` carries the frontend's `code` attr (a function address) */
120
+ readonly baseIsCode: boolean;
121
+ /** the constant the machine added to the base address (signed) */
122
+ readonly offset: number;
123
+ /** what the map holds EXACTLY at `addr(base) + offset` */
124
+ readonly target: SymbolInfo | null;
125
+ /** the map holds several entries at that address and they do not all spell the same thing, so
126
+ * `target` is an arbitrary pick — the mirror of `ambiguous`, on the other end of the walk */
127
+ readonly targetAmbiguous: boolean;
128
+ /** some use of this value is the base operand of a store */
129
+ readonly written: boolean;
130
+ /** some use of this value is a successor's block argument, which is where the two access sets
131
+ * below stop being a complete answer — see `access-behind-merge` */
132
+ readonly crossesMerge: boolean;
133
+ /** the width of every OFF-0 access through this address, and the load signedness of the narrow
134
+ * ones — the same facts `rank-declare.ts`'s `bareGlobalAccessFacts` reads for a bare name, taken
135
+ * at this one address. An access at a non-zero offset is spelled through a cast that carries its
136
+ * own width, so it states nothing about the name's declaration and is not collected. Empty where
137
+ * the value is only an address: the next link of the walk, a call argument. */
138
+ readonly accessWidths: ReadonlySet<number>;
139
+ readonly accessSigns: ReadonlySet<boolean>;
140
+ }
141
+
142
+ /** The access the walked-to name's own declaration produces: the width `gSym = v` writes, and the
143
+ * signedness `x = gSym` loads with. `null` where the map states no width — a name-only entry, or a
144
+ * struct whose interior the field spelling owns. A 4-byte unit has no signedness in the
145
+ * instruction, which is the normalization the access side makes too. */
146
+ function accessUnit(info: SymbolInfo): { width: number; signed: boolean } | null {
147
+ const [width, signed] =
148
+ info.shape === 'scalar'
149
+ ? [info.size, info.signed === true]
150
+ : info.shape === 'array'
151
+ ? [info.elemSize, info.elemSigned === true]
152
+ : info.shape === 'pointer'
153
+ ? [4, false]
154
+ : [undefined, false];
155
+ return width === undefined ? null : { width, signed: accessSignedness(width, signed) };
156
+ }
157
+
158
+ /** Everything the gates below read off a `SymbolInfo` beyond its identity: the name they spell,
159
+ * the kind `target-is-code` refuses, the `const` qualifier `const-target-store` refuses, and the
160
+ * access the declaration produces. Two entries at one address agreeing on all four make the pick
161
+ * between them immaterial. */
162
+ function sameAuthority(a: SymbolInfo, b: SymbolInfo): boolean {
163
+ const ua = accessUnit(a);
164
+ const ub = accessUnit(b);
165
+ return (
166
+ a.name === b.name &&
167
+ a.kind === b.kind &&
168
+ (a.const === true) === (b.const === true) &&
169
+ ua?.width === ub?.width &&
170
+ ua?.signed === ub?.signed
171
+ );
172
+ }
173
+
174
+ /** The refusals. FIRST rejection is what `offsetNameRefusals` reports, so the order is the
175
+ * attribution order: what the map can say about the BASE, then what sits at the offset, then the
176
+ * ACCESS made through it, and last the store. The rules that are not `sound` are about OWNERSHIP
177
+ * and ABSENCE rather than correctness — an interior address is the field and element machinery's
178
+ * to spell, and where no symbol sits there the arithmetic is simply the only name available.
179
+ *
180
+ * THE ADDRESS IS NOT THE WHOLE QUESTION, and `target-unsized` and `access-unlike-target` are what
181
+ * say so. Naming an address the map holds is address-exact by construction; what a bare name ALSO
182
+ * decides is the instruction, because `gSym = v` writes whatever `gSym` was declared as. A
183
+ * halfword store walked onto a word-wide neighbour compiles to `str` where the target says `strh`
184
+ * — the same four bytes of address, four bytes written instead of two — and a name the map has no
185
+ * width for reaches declaration synthesis with no width authority at all (rank-declare.ts's
186
+ * `bareGlobalAccessFacts` keys off the pool-loaded `gaddr` defs of a lift this pass has not run
187
+ * on), so it falls back to `extern u32`. Both refuse here, where the map's own width is in hand. */
188
+ export const OFFSET_NAME_GATES: readonly Gate<OffsetAddress>[] = [
189
+ {
190
+ id: 'base-address-ambiguous',
191
+ why: 'the map carries the base name at more than one address, so the offset names nothing definite',
192
+ sound: true,
193
+ guardedBy: 'offset-names.test.ts: without `base-address-ambiguous` a doubly-mapped name picks one of its addresses',
194
+ rejects: (c) => c.ambiguous,
195
+ },
196
+ {
197
+ id: 'base-is-code',
198
+ why: 'an offset into a function is not an object C can name',
199
+ sound: true,
200
+ guardedBy: 'offset-names.test.ts: without `base-is-code` an offset into a function becomes a data name',
201
+ rejects: (c) => c.baseIsCode || c.base.kind === 'code',
202
+ },
203
+ // NOT sound, for the same reason `interior-offset` is not: the address it refuses is one the map
204
+ // holds a name at exactly, so the spelling it costs is address-identical and the access rules
205
+ // below check its width. What it buys is OWNERSHIP — an unsized base may span the cell, and
206
+ // naming the cell by a neighbour then re-attributes an access to an object the base's own
207
+ // spelling covers.
208
+ {
209
+ id: 'base-unsized',
210
+ why: 'the base declares no size, so nothing here can tell its own interior from a neighbour',
211
+ sound: false,
212
+ rejects: (c) => c.base.size === undefined,
213
+ },
214
+ // The `why` claims ownership rather than a handover, because the handover is not what happens:
215
+ // `pokeemerald:TrySetCantSelectMoveBattleScript`'s interior sites emit
216
+ // `((struct Elem7 *)((u32)&gBattleBufferB + 2))[i].field_0` — the element and field machinery
217
+ // indexes off the base, and the interior offset itself stays raw arithmetic.
218
+ {
219
+ id: 'interior-offset',
220
+ why: 'the address lands inside the base object, so the name at it labels a part of something the base already spells',
221
+ sound: false,
222
+ rejects: (c) => c.offset > 0 && c.offset < (c.base.size ?? 0),
223
+ },
224
+ {
225
+ id: 'no-symbol-at-offset',
226
+ why: 'no symbol sits exactly at the computed address, so the arithmetic is the only honest spelling',
227
+ sound: false,
228
+ rejects: (c) => c.target === null,
229
+ },
230
+ // The mirror of `base-address-ambiguous`, and it is not the same question asked twice: that one
231
+ // is one NAME the map places at two addresses, this one is one ADDRESS the map gives several
232
+ // names — aliases, or typed views of one RAM region, which `SymbolMap`'s `SymbolInfo[]` exists
233
+ // to carry. `lookupSymbol` answers with the first of them, so the width the two access rules
234
+ // check and the `const` the store rule checks would otherwise be read off an arbitrary pick.
235
+ {
236
+ id: 'target-address-ambiguous',
237
+ why: 'the map gives the walked-to address several names that do not agree, so which one this spells is arbitrary',
238
+ sound: true,
239
+ guardedBy:
240
+ 'offset-names.test.ts: without `target-address-ambiguous` the walk spells one of two names at the address',
241
+ rejects: (c) => c.targetAmbiguous,
242
+ },
243
+ {
244
+ id: 'target-is-code',
245
+ why: 'a walked-to function address is a relocation this spelling cannot reproduce',
246
+ sound: true,
247
+ guardedBy: 'offset-names.test.ts: without `target-is-code` the walk names a function',
248
+ rejects: (c) => c.target?.kind === 'code',
249
+ },
250
+ // FIRST of the access rules, because it is the reason the ones below it can have nothing to
251
+ // judge. An address merged from two predecessors reaches its store through the successor's
252
+ // block parameter, and `offsetSites` counts accesses against the site's OWN value — so without
253
+ // this the rules below are asked about an empty set and pass, and the merge the structurer then
254
+ // collapses hands the bare name straight to the access they exist to check.
255
+ {
256
+ id: 'access-behind-merge',
257
+ why: 'a use of this address is a block argument, so the accesses made through it are not this census to count',
258
+ sound: true,
259
+ guardedBy: 'offset-names.test.ts: without `access-behind-merge` a word store behind a merge names a halfword cell',
260
+ rejects: (c) => c.crossesMerge,
261
+ },
262
+ {
263
+ id: 'target-unsized',
264
+ why: 'the map states no width for the walked-to name, so a declaration synthesized for it would guess one',
265
+ sound: true,
266
+ guardedBy:
267
+ 'offset-names.test.ts: without `target-unsized` a name-only neighbour is stored through at the guessed width',
268
+ rejects: (c) => c.target !== null && c.accessWidths.size > 0 && accessUnit(c.target) === null,
269
+ },
270
+ {
271
+ id: 'access-unlike-target',
272
+ why: 'the access through this address is not the one the walked-to name declares, so the bare spelling is a different instruction',
273
+ sound: true,
274
+ guardedBy:
275
+ 'offset-names.test.ts: without `access-unlike-target` a halfword store onto a word-wide name becomes `str`',
276
+ rejects: (c) => {
277
+ const unit = c.target === null ? null : accessUnit(c.target);
278
+ return (
279
+ unit !== null &&
280
+ ([...c.accessWidths].some((w) => w !== unit.width) || [...c.accessSigns].some((s) => s !== unit.signed))
281
+ );
282
+ },
283
+ },
284
+ // How hard the constraint violation fails is per compiler, so the rule states the constraint
285
+ // rather than a verdict: the same `gRomTable[i] = 1;` is `warning: assignment of read-only
286
+ // location` and exit 0 on agbcc, and `cfe: Error: Change value for constant variable.` and exit 1
287
+ // on IDO 7.1.
288
+ {
289
+ id: 'const-target-store',
290
+ why: 'a store through a const-declared name is a constraint violation, and these compilers range from a warning to a hard error',
291
+ sound: true,
292
+ guardedBy: 'offset-names.test.ts: without `const-target-store` the store walks onto a const-declared name',
293
+ rejects: (c) => c.written && c.target?.const === true,
294
+ },
295
+ ];
296
+
297
+ /** Every name the map carries, with the FIRST address it sits at and whether it sits at more than
298
+ * one. The reverse of the address-keyed map, which is the direction `addr(S) + K` needs and the
299
+ * only direction `SymbolMap` does not already have.
300
+ *
301
+ * `twice` is about two ADDRESSES, not two entries: `SymbolMap` holds a `SymbolInfo[]` per address
302
+ * precisely because one address legitimately carries several (aliases, typed views of one RAM
303
+ * region), and a name repeated inside one of those lists still says where it is. */
304
+ function addressesByName(symbols: SymbolMap): Map<string, { addr: number; info: SymbolInfo; twice: boolean }> {
305
+ const out = new Map<string, { addr: number; info: SymbolInfo; twice: boolean }>();
306
+ for (const [addr, infos] of symbols) {
307
+ for (const info of infos) {
308
+ const prev = out.get(info.name);
309
+ if (prev === undefined) {
310
+ out.set(info.name, { addr, info, twice: false });
311
+ } else if (prev.addr !== addr) {
312
+ out.set(info.name, { ...prev, twice: true });
313
+ }
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /** The named base an address value stands on, following `add`/`sub` chains through constants —
320
+ * the `adds r1,#12` walk is a chain of them, and each link is its own candidate. Stops at
321
+ * anything else, block parameters included (they have no def, so a loop-carried pointer
322
+ * induction never resolves). */
323
+ function namedBase(v: Value, defOf: Map<Value, Op>): { sym: string; code: boolean; offset: number } | null {
324
+ const d = defOf.get(v);
325
+ if (d === undefined) {
326
+ return null;
327
+ }
328
+ if (d.opcode === 'gaddr') {
329
+ return { sym: d.attrs.sym as string, code: d.attrs.code === true, offset: 0 };
330
+ }
331
+ if (d.opcode !== 'add' && d.opcode !== 'sub') {
332
+ return null;
333
+ }
334
+ const [a, b] = d.operands;
335
+ const ka = defOf.get(a)?.opcode === 'const' ? (defOf.get(a)!.attrs.value as number) : null;
336
+ const kb = defOf.get(b)?.opcode === 'const' ? (defOf.get(b)!.attrs.value as number) : null;
337
+ // `sub` is not commutative: only `base - K` is an offset off the base.
338
+ if (kb !== null) {
339
+ const base = namedBase(a, defOf);
340
+ return base && { ...base, offset: base.offset + (d.opcode === 'sub' ? -kb : kb) };
341
+ }
342
+ if (ka !== null && d.opcode === 'add') {
343
+ const base = namedBase(b, defOf);
344
+ return base && { ...base, offset: base.offset + ka };
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /** Every `add`/`sub` in `fn` that resolves to a named base plus a non-zero constant, with the
350
+ * context the gates judge. Shared by the rewrite and by `offsetNameRefusals` so the two cannot
351
+ * disagree about which sites exist. */
352
+ function offsetSites(fn: Fn, symbols: SymbolMap): { op: Op; sym: string; addr: OffsetAddress }[] {
353
+ const defOf = defOpMap(fn);
354
+ const byName = addressesByName(symbols);
355
+ const written = new Set<Value>();
356
+ // Through the address arithmetic, not just at it: `gRom[i] = v` stores through an `add` of the
357
+ // walked address and a scaled index, so the value the gates judge is one level up from the
358
+ // store's own base operand. Without the walk the const rule fires or not depending on whether an
359
+ // index happens to sit in between.
360
+ //
361
+ // A CONSTANT displacement ends the closure, because it is the link that names its own cell: on
362
+ // `&g + 12 + 12` the store goes through the second link, and descending past it would report
363
+ // the first as written too — a `const` two cells back would then refuse a site nothing stores
364
+ // through, and `offsetNameRefusals` would publish that as this site's reason.
365
+ const markWritten = (v: Value): void => {
366
+ if (written.has(v)) {
367
+ return;
368
+ }
369
+ written.add(v);
370
+ const d = defOf.get(v);
371
+ if (d?.opcode !== 'add' && d?.opcode !== 'sub') {
372
+ return;
373
+ }
374
+ if (d.operands.some((o) => defOf.get(o)?.opcode === 'const')) {
375
+ return;
376
+ }
377
+ d.operands.forEach(markWritten);
378
+ };
379
+ const access = new Map<Value, { widths: Set<number>; signs: Set<boolean> }>();
380
+ const accessOf = (v: Value) => access.get(v) ?? access.set(v, { widths: new Set(), signs: new Set() }).get(v)!;
381
+ const blockArgs = new Set<Value>();
382
+ for (const b of fn.blocks) {
383
+ for (const op of b.ops) {
384
+ for (const s of op.successors) {
385
+ s.args.forEach((a) => blockArgs.add(a));
386
+ }
387
+ if (op.opcode === 'store' || op.opcode === 'astore') {
388
+ markWritten(op.operands[0]);
389
+ }
390
+ if ((op.opcode === 'load' || op.opcode === 'store') && (op.attrs.off as number) === 0) {
391
+ const a = accessOf(op.operands[0]);
392
+ const w = op.attrs.width as number;
393
+ a.widths.add(w);
394
+ if (op.opcode === 'load') {
395
+ a.signs.add(accessSignedness(w, op.attrs.signed as boolean | undefined));
396
+ }
397
+ } else if (op.opcode === 'aload' || op.opcode === 'astore') {
398
+ const a = accessOf(op.operands[0]);
399
+ const w = op.attrs.elemSize as number;
400
+ a.widths.add(w);
401
+ if (op.opcode === 'aload') {
402
+ a.signs.add(accessSignedness(w, op.attrs.signed as boolean | undefined));
403
+ }
404
+ }
405
+ }
406
+ }
407
+ const out: { op: Op; sym: string; addr: OffsetAddress }[] = [];
408
+ for (const b of fn.blocks) {
409
+ for (const op of b.ops) {
410
+ if (op.opcode !== 'add' && op.opcode !== 'sub') {
411
+ continue;
412
+ }
413
+ const r = op.results[0];
414
+ if (r === undefined) {
415
+ continue;
416
+ }
417
+ const base = namedBase(r, defOf);
418
+ if (base === null || base.offset === 0) {
419
+ continue;
420
+ }
421
+ const at = byName.get(base.sym);
422
+ if (at === undefined) {
423
+ continue; // the base name reached the IR from somewhere other than this map
424
+ }
425
+ const entries = symbols.get(at.addr + base.offset) ?? [];
426
+ out.push({
427
+ op,
428
+ sym: base.sym,
429
+ addr: {
430
+ base: at.info,
431
+ ambiguous: at.twice,
432
+ baseIsCode: base.code,
433
+ offset: base.offset,
434
+ target: lookupSymbol(symbols, at.addr + base.offset),
435
+ targetAmbiguous: entries.length > 1 && entries.some((i) => !sameAuthority(i, entries[0])),
436
+ written: written.has(r),
437
+ crossesMerge: blockArgs.has(r),
438
+ accessWidths: access.get(r)?.widths ?? new Set(),
439
+ accessSigns: access.get(r)?.signs ?? new Set(),
440
+ },
441
+ });
442
+ }
443
+ }
444
+ return out;
445
+ }
446
+
447
+ /** Rewrite every admitted site into the `gaddr` the map names, in place. Returns THE NAMES it
448
+ * spelled, in block order — what a caller has to be told, because a name this pass produced does
449
+ * not rest on the map alone the way a pool-loaded one does: it rests on `addr(base) + K` landing
450
+ * on the map entry it names. `DecompileResult.walkedNames` and the trace's `stage:offsetnames`
451
+ * carry it out; nothing else can tell from a result that a walk was named. */
452
+ export function nameOffsetAddresses(
453
+ fn: Fn,
454
+ symbols: SymbolMap,
455
+ behaviors: StampBehaviors,
456
+ gates: readonly Gate<OffsetAddress>[] = OFFSET_NAME_GATES,
457
+ ): string[] {
458
+ // Every site is judged against the PRE-REWRITE defs, so a chain's later links resolve through
459
+ // the base they were lifted from and the answer does not depend on the order links are visited.
460
+ const admitted = offsetSites(fn, symbols).filter((s) => firstRejection(gates, s.addr) === null);
461
+ for (const s of admitted) {
462
+ s.op.opcode = 'gaddr';
463
+ s.op.operands = [];
464
+ s.op.attrs = { sym: s.addr.target!.name };
465
+ }
466
+ if (admitted.length > 0) {
467
+ // An access now names another object, so whether it reads one the map declares volatile is
468
+ // asked again before anything is deleted.
469
+ stampDeclaredVolatileUnder(fn, symbols, behaviors);
470
+ dce(fn); // the constants and the walked-off base the rewrite just orphaned
471
+ verify(fn);
472
+ }
473
+ return admitted.map((s) => s.addr.target!.name);
474
+ }
475
+
476
+ /** Which rule refused each offset site this function builds — one entry per SITE, in block order,
477
+ * as `[<base>+<offset>, reason]` with `null` where the site was named. The attribution
478
+ * `firstRejection` exists for. NOT on the shipped path: a caller instrumenting a refusal asks
479
+ * here instead of re-deriving the predicates.
480
+ *
481
+ * A LIST and not a map keyed by address: two `add` ops can resolve to one address — the arms of
482
+ * a merge do exactly that — and a map reports the pair as one entry, which under-reports the
483
+ * refusals the caller came to count. */
484
+ export function offsetNameRefusals(
485
+ fn: Fn,
486
+ symbols: SymbolMap,
487
+ gates: readonly Gate<OffsetAddress>[] = OFFSET_NAME_GATES,
488
+ ): readonly (readonly [string, string | null])[] {
489
+ return offsetSites(fn, symbols).map(
490
+ (s) =>
491
+ [`${s.sym}${s.addr.offset < 0 ? '-' : '+'}${Math.abs(s.addr.offset)}`, firstRejection(gates, s.addr)] as const,
492
+ );
493
+ }
494
+
495
+ /** THE CALLER-SIDE SEAM. The three drivers — pipeline's `runTower`, rank's `enumerateCandidates`,
496
+ * report's `traceTower` — call the pass THROUGH this record rather than through the binding above,
497
+ * which is what lets `pnpm bench gates --pass offsetnames` put a wrapped table in front of a real
498
+ * enumeration: a module-namespace binding is read-only and cannot be swapped, so a tabled pass
499
+ * whose only reachable name is its import is not censusable at all
500
+ * (`apps/benchmark/src/run/gate-census.ts`, WHAT PUTS A PASS IN THE REGISTRY).
501
+ *
502
+ * A record and not a pass LIST: the ordering question a list answers is already answered above,
503
+ * this pass having one seat. What a list also carries is the invariant that every driver picks a
504
+ * pass up, which three hand-written calls do not — so `offset-names.test.ts` supplies it by
505
+ * wrapping this record and asserting all three reach it. */
506
+ export const OFFSET_NAME_PASS: {
507
+ run: (fn: Fn, symbols: SymbolMap, behaviors: StampBehaviors) => string[];
508
+ } = {
509
+ run: (fn, symbols, behaviors) => nameOffsetAddresses(fn, symbols, behaviors),
510
+ };
@@ -0,0 +1,81 @@
1
+ // asmlift — two argument registers that are one 64-bit PARAMETER.
2
+ //
3
+ // The ABI hands a 64-bit argument over in a register pair, so the frontend reads it as two
4
+ // live-ins and builds a `concat` from them. That `concat` is the evidence, and this pass turns it
5
+ // into the signature: one parameter of width 64 in place of the two words.
6
+ //
7
+ // WHY THE EVIDENCE IS THE `concat` AND NOT THE ADJACENCY. Two adjacent argument registers are two
8
+ // arguments far more often than they are one, so a pass keyed on position would rewrite the
9
+ // signature of most functions in the corpus. A `concat` is built at an ATOMIC site only — an ABI
10
+ // pair, or a carry-chained add — so it is a claim the machine made rather than one this pass
11
+ // invents, and the pair of registers it names is the pair the machine used.
12
+ //
13
+ // AND WHY EACH HALF MUST BE USED NOWHERE ELSE. A parameter also read on its own is not half of a
14
+ // 64-bit value; it is a word this function uses as a word, which is what a 64-bit argument passed
15
+ // to a helper and separately tested would look like. Fusing it would delete a use.
16
+ import { Fn, Op, Value } from '../ir/core';
17
+
18
+ /** Count every operand and edge-argument occurrence of each value. Occurrences, not sites: a value
19
+ * passed to `f(x, x)` is used twice, and a rule that said "used once" about it would be wrong. */
20
+ function useCounts(fn: Fn): Map<Value, number> {
21
+ const n = new Map<Value, number>();
22
+ const bump = (v: Value) => n.set(v, (n.get(v) ?? 0) + 1);
23
+ for (const b of fn.blocks) {
24
+ for (const op of b.ops) {
25
+ op.operands.forEach(bump);
26
+ for (const s of op.successors) {
27
+ s.args.forEach(bump);
28
+ }
29
+ }
30
+ }
31
+ return n;
32
+ }
33
+
34
+ /** Whether `op` is the `concat` of two entry parameters that ARRIVED as one 64-bit argument: the two
35
+ * consecutive argument slots, in slot order, each read by nothing else (`uses` counts occurrences).
36
+ *
37
+ * SLOT ORDER, because a pair built in any other order is a different value. `s64 f(s64 a, s64 b){
38
+ * return rot32(a) * b; }` hands `__muldi3` a's words swapped (`add r1,r5; add r0,r4` from r0:r1),
39
+ * so its `concat(r1, r0)` is not `a`, and a fusion that took it would print `a * b`. Consecutive,
40
+ * because the frontends mint every slot below the highest one read (`mintArgSlotHoles`), so two
41
+ * neighbouring parameters are two neighbouring slots — including r3 and the first stack word,
42
+ * which is where agbcc splits a 64-bit argument. */
43
+ export function isArgumentPair(entry: readonly Value[], op: Op, uses: (v: Value) => number): boolean {
44
+ if (op.opcode !== 'concat') {
45
+ return false;
46
+ }
47
+ const [lo, hi] = op.operands;
48
+ const at = entry.indexOf(lo);
49
+ return at >= 0 && entry[at + 1] === hi && uses(lo) === 1 && uses(hi) === 1;
50
+ }
51
+
52
+ /** Fuse each entry-parameter pair that a `concat` names into one 64-bit parameter. Returns whether
53
+ * anything changed. */
54
+ export function fuseParamPairs(fn: Fn): boolean {
55
+ const entry = fn.blocks[0];
56
+ if (entry.params.length < 2) {
57
+ return false;
58
+ }
59
+ const uses = useCounts(fn);
60
+ let changed = false;
61
+ for (const b of fn.blocks) {
62
+ for (let i = 0; i < b.ops.length; i++) {
63
+ const op: Op = b.ops[i];
64
+ if (!isArgumentPair(entry.params, op, (v) => uses.get(v) ?? 0)) {
65
+ continue;
66
+ }
67
+ // The pair takes its first slot's position, which is its position in the ABI order the
68
+ // frontend already sorted these into — so the signature keeps the argument order the
69
+ // machine passed them in.
70
+ const whole = op.results[0];
71
+ entry.params.splice(entry.params.indexOf(op.operands[0]), 2, whole);
72
+ // The `concat`'s RESULT becomes the parameter — the same Value, so every existing use
73
+ // already points at it and nothing has to be rewritten. The op itself goes: a block
74
+ // parameter has no defining op, and leaving one would define the value twice.
75
+ b.ops.splice(i, 1);
76
+ i--;
77
+ changed = true;
78
+ }
79
+ }
80
+ return changed;
81
+ }
@@ -259,6 +259,13 @@ export function narrowEntryParams(
259
259
  // function nobody measured (parsed IR, a hand-built fn) is never narrowed on evidence never taken.
260
260
  const evidence = fn.paramEvidence;
261
261
  const entry = fn.blocks[0];
262
+ // PER PARAMETER, AND NOT `proto.ts` `declaredCallArgs`, WHICH ASKS A DIFFERENT QUESTION. That
263
+ // reader abstains for a whole list over one spelling it cannot size, because its caller is
264
+ // laying out argument registers and one unknown width displaces every later argument. Here every
265
+ // entry stands alone: `undefined` at position k is the `proto-width` gate ABSTAINING for
266
+ // parameter k, which is the right answer for a project typedef, and it says nothing about
267
+ // parameter k+1. A shared reader would make one unreadable spelling drop the narrowing of every
268
+ // other parameter.
262
269
  const declared = Array.isArray(self?.params) ? self.params.map(declaredWidth) : [];
263
270
  const entryIsJoin = fn.blocks.some((b) => successorsOf(b).includes(entry));
264
271
  const params = new Set(entry.params);