@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,366 @@
1
+ // asmlift — A NARROW READ OF A WIDER FIELD, which the struct recognizers see as a second field at
2
+ // the same offset.
3
+ //
4
+ // WHAT IS MISSING WITHOUT THIS. A compiler asked for the low byte of a halfword member emits a byte
5
+ // load at the halfword's own address:
6
+ //
7
+ // BlendPalette(..., task->data[6], ...) // data[] is s16, the parameter is u8
8
+ // ldrh r0, [r4, #0xc] @ the member, read whole
9
+ // ldrb r2, [r4, #0xc] @ the same member, narrowed for the call
10
+ //
11
+ // The pass that groups a base's constant-offset accesses reads that as ONE OFFSET AT TWO WIDTHS:
12
+ // `raise/structs.ts` declares the pair a union member (on a parameter or a loaded pointer). That
13
+ // reading is right about the SHAPE and wrong about the CAUSE — there is no second object here, only
14
+ // a cast — so the function would carry a union its source never declared. (`raise/memberarrays.ts`'s
15
+ // same-sounding `member-conflict` judges VARIABLE-INDEX accesses, `aload`/`astore`, and a base with
16
+ // any constant-offset load is already outside that pass by `direct-access`. It is a different shape
17
+ // and this fold does not reach it.)
18
+ //
19
+ // THE ASM DOES NOT SAY WHICH IT IS, and that is the point: a union, a cast, a bitfield and two
20
+ // adjacent bytes the compiler fused all produce the same two instructions. What this pass claims is
21
+ // narrower than any of those readings. `(u8)x` and a byte load of `x`'s low-order byte denote THE
22
+ // SAME BYTES AND THE SAME VALUE, so rewriting one into the other invents no type and loses no
23
+ // fidelity; what the struct recognizers then do with a base carrying one width instead of two is
24
+ // their existing job, refereed by the differ as before.
25
+ //
26
+ // MEASURED, not argued, on the row that motivated it
27
+ // (`pokeemerald:AnimTask_FlashHealthboxOnLevelUp_Step:agbcc`): the reference source declares ONE
28
+ // `s16` member at offset 12 and passes it to a `u8` parameter, and agbcc compiles that to the
29
+ // `ldrh`/`ldrb` pair above — `.text` sha256 `e0524eb341119a21`, 204 bytes, byte-identical to the
30
+ // target object. So the single-field layout this pass makes reachable is the layout the ROM was
31
+ // built from.
32
+ //
33
+ // WHAT IT DOES. Over the L1 fn, for every base with constant-offset accesses: a LOAD whose byte
34
+ // range is the LOW-ORDER END of a strictly wider access's range at the same base becomes a
35
+ // `zext`/`sext` — matching the narrow load's own signedness — of a fresh load at the wider range.
36
+ // No opcode, no representation, no level: `zext`/`sext` with a `width` attr already exist
37
+ // (`ir/opcodes.ts`), they already print as a C cast, and the backend already recompiles that cast
38
+ // to the narrow instruction on the compilers that emit it.
39
+ //
40
+ // THE FOLD WIDENS A READ, which is the one thing worth arguing about, and the two rules that bound
41
+ // it are `fixed-cell` and `covering-dominates` below. Neither is a restatement of what
42
+ // raise/structs.ts already claims, and the difference is what makes them rules rather than
43
+ // decoration: that pass declares a SINGLE LAYOUT covering every offset the function touches, which
44
+ // is a claim the compiler reads and no instruction executes, while this one emits a DEREFERENCE.
45
+ // Widening a read where the covering access is on the other arm of a branch would have the emitted
46
+ // program load bytes that no execution reaching that point ever loaded — a compile-time claim
47
+ // escalated into a runtime one. Widening a read of a cell at a constant address would change the
48
+ // event a device sees. So the range this fold widens to must be a range some access at the same
49
+ // base read ON THIS PATH, at a cell whose declaration cannot make its access width observable.
50
+ //
51
+ // LOW-ORDER END IS AN ENDIANNESS QUESTION, read off `capabilities.endianness` rather than assumed:
52
+ // the low bytes of a big-endian field sit at its TOP address (`lw 20(a0)` covering `lhu 22(a0)`),
53
+ // the low bytes of a little-endian one at its base. Both directions have a corpus inhabitant and
54
+ // the benchmark's `unitrunc` row carries both.
55
+ //
56
+ // WHICH GATE BOUNDS THE CORPUS PATH — `pnpm bench gates --pass truncload [--toolchain <id>]`, and
57
+ // the answer is the SOUND ones, per population. Over the synthetic tier on agbcc (337 rows) the
58
+ // census reads `covering-store 12, covering-dominates 4, fixed-cell 2`; on ido7.1 (179 rows) and
59
+ // gcc2.7.2kmc (183) `covering-store 12, high-order-read 4, fixed-cell 2`, the big-endian rows
60
+ // reaching the skew rule because `utag`'s union members are read at the field's TOP; on
61
+ // mwcc_242_81 (189) `covering-store 12, fixed-cell 2`. The union rows (`uhalf`, `ureread`, `utag`)
62
+ // are most of it, and they are counted whole only because raise/structs.ts
63
+ // recovers them rather than declining part-way through the enumeration — the next paragraph.
64
+ //
65
+ // READ THE `did not lift` LINE WITH THE COUNTS, because the census runs the whole RANKED
66
+ // enumeration per row and a row that THROWS part-way through it contributes a TRUNCATED count
67
+ // rather than none. `synthetic:unidev` is the worked example: while `raise/structs.ts` declined it
68
+ // the agbcc census read `fixed-cell 1` with 13 rows unlifted, and once that pass stopped declining
69
+ // a literal-address base it read `fixed-cell 2` with 12 — the same refusals, all of them now
70
+ // reached. `--only synthetic:unidev:agbcc` shows the whole delta on that one row.
71
+ //
72
+ // `high-order-read` is STARVED on agbcc — not because nothing
73
+ // skews there, but because `synthetic:uhalf`'s skewed read sits on a base whose widest access is
74
+ // the `u->w = v` STORE, so the earlier rule answers first. Real rows show two of them alone:
75
+ // `--only sa3:sub_804DC38:agbcc` reads `high-order-read 4`, an `ldrb` at byte 5 of a word at 4,
76
+ // and `--only kleod:WorldMapScreenUnlockNewWorld:agbcc` reads `fixed-cell 90` — a named global's
77
+ // cells, which is the same shape a named hardware register has. Two rules are UNINHABITED and say
78
+ // so rather than being left to look load-bearing: `unresolved-base` and `cast-width` are refusals
79
+ // no corpus row reaches, and both are pinned by a hand-written shape in the unit tests instead.
80
+ //
81
+ // A THIRD SHAPE IS ADMITTED AND SAYS SO RATHER THAN BEING CLAIMED SOUND: a cell reached at a
82
+ // RUNTIME index from a literal or a named base — `((vu16 *)0x4000000)[i]`, the GBA palette and DMA
83
+ // loops. `fixed-cell` does not fire there, because no constant address exists to read, and nothing
84
+ // else can: the IR for that loop and the IR for an element of an ordinary global array are the
85
+ // SAME IR, so refusing it would refuse every indexed access into a named object — including the
86
+ // map-less spelling of the row this pass was written for. Where the access is qualified — the lift
87
+ // pinned it as a device register's, or the map declares its object volatile — `qualified-access`
88
+ // refuses it; any other one is a residue, it is unpriced, and the place it would be settled is the
89
+ // symbol map, not this pass.
90
+ //
91
+ // A NARROW STORE IS NOT A CANDIDATE AT ALL — the refusal is in the candidate builder below rather
92
+ // than in the gate table, and this is the table's named residue. Widening a write clobbers the
93
+ // bytes past it, and no cast spelling expresses a partial write: `(u8)p->f = v` is not C. A base
94
+ // whose conflict is a narrow STORE therefore keeps both widths — two views of a union member where
95
+ // raise/structs.ts synthesizes the layout, two casts at a declared address.
96
+ //
97
+ // THE SECOND RESIDUE. A union read through members of three widths on three arms of a `switch`
98
+ // (`synthetic:utag`) is refused by `covering-dominates`, and the overlap it leaves is declared as a
99
+ // union member by raise/structs.ts — which is what that source wrote. Widening its reads DOES
100
+ // compile byte-exactly on agbcc, which is exactly why the rule has to be a rule: the differ scores
101
+ // the sound and the unsound spelling the same, so nothing downstream would ever refuse it.
102
+ import { constAddressOf, globalCellOf } from '../ir/alias';
103
+ import { type Block, type Fn, type Op, type Value, defOpMap, dominators, mkOp, mkValue } from '../ir/core';
104
+ import { qualified } from '../ir/discipline';
105
+ import { CAST_WIDTHS } from '../ir/opcodes';
106
+ import { T } from '../ir/types';
107
+ import { type Gate, firstRejection } from '../l3/gates';
108
+ import { type Access, constOffsetAccesses } from './structs';
109
+
110
+ /** Ops through which a base address is still the SAME address question — the arithmetic a lift
111
+ * produces between a root and a load's operand. Anything else (a `load`, a `call`, a `laddr`) is
112
+ * a value the function obtained, which ENDS the walk: a pointer it was handed is an object the
113
+ * source declared, and there is nothing further to resolve. */
114
+ const ADDRESS_ARITHMETIC: ReadonlySet<string> = new Set(['add', 'sub', 'or', 'and', 'xor', 'shl', 'shr_u', 'shr_s']);
115
+
116
+ /** Does this base's origin RESOLVE — does the walk above reach a root it can name on every branch?
117
+ * An entry parameter, a literal, a named global and a loaded pointer all resolve; a block param
118
+ * joining two edges and an `undef` do not.
119
+ *
120
+ * It answers ONE question and `fixed-cell` answers the other: whether the cell is at a constant
121
+ * address is asked of the resolved base, and a base that never resolved cannot be asked at all.
122
+ * WHY THE WALK IS HERE rather than composed out of ir/alias.ts's `constAddressOf` /
123
+ * `globalBaseOf`: both of those return null for "not one of these" AND for "cannot tell", so a
124
+ * caller composing them cannot separate a parameter from a join — and the join is the case that
125
+ * has to refuse. */
126
+ function baseResolves(defs: Map<Value, Op>, entryParams: ReadonlySet<Value>, v: Value): boolean {
127
+ const d = defs.get(v);
128
+ if (!d) {
129
+ return entryParams.has(v);
130
+ }
131
+ if (d.opcode === 'undef') {
132
+ return false;
133
+ }
134
+ return !ADDRESS_ARITHMETIC.has(d.opcode) || d.operands.every((o) => baseResolves(defs, entryParams, o));
135
+ }
136
+
137
+ /** What the gates below judge: one narrow LOAD and the access at its base that covers it. */
138
+ export interface TruncatedLoad {
139
+ /** the narrow load reads a cell at a COMPILE-TIME CONSTANT address */
140
+ fixedCell: boolean;
141
+ /** the narrow load or its cover is a qualified access: pinned by the lift, or of an object the
142
+ * map declares volatile */
143
+ qualified: boolean;
144
+ /** the base's origin resolves — it is not a join the walk could not finish */
145
+ resolvedBase: boolean;
146
+ /** the covering access is a LOAD, so its signedness is read rather than invented */
147
+ coveringLoad: boolean;
148
+ /** the narrow bytes are the low-order end of the covering range on THIS target's endianness */
149
+ lowOrderEnd: boolean;
150
+ /** the covering access runs, on EVERY path that reaches the narrow load, before it */
151
+ coveringDominates: boolean;
152
+ /** the width in BITS the cast would carry */
153
+ castWidth: number;
154
+ }
155
+
156
+ export const TRUNC_LOAD_GATES: readonly Gate<TruncatedLoad>[] = [
157
+ {
158
+ // SOUND, and it is the ACCESS WIDTH that is at stake rather than the value: a hardware register
159
+ // answers a byte read and a halfword read differently, so reading two bytes where the machine
160
+ // read one is an event the device sees. A memory-mapped register is a FIXED cell, and both
161
+ // spellings a source has for one land here: the bare cast of a number (`*(vu16 *)0x4000004`)
162
+ // through `constAddressOf`, and the named register (`REG_DISPSTAT`) through `globalCellOf`,
163
+ // whose `volatile` is a declaration in the project's headers that no raise pass can read.
164
+ // The two helpers are ir/alias.ts's, and `constAddressOf` is the address structure.ts's
165
+ // `volatileQualifiable` asks about — so a load this rule and `qualified-access` admit is one that
166
+ // predicate answers no to, by construction rather than by measurement.
167
+ //
168
+ // NOT `capabilities.deviceRegisters`. That window is documented as a question about SPELLING
169
+ // and may be approximate (target.ts), so a soundness rule may not rest on it; asking whether
170
+ // the address is constant at all is wider and needs nothing approximate.
171
+ id: 'fixed-cell',
172
+ why: 'a cell at a constant address can be a hardware register, where the access WIDTH is itself observable',
173
+ sound: true,
174
+ guardedBy: 'truncload.test.ts: a cell at a constant address is not narrowed',
175
+ rejects: (c) => c.fixedCell,
176
+ },
177
+ {
178
+ // SOUND, and the residue of the rule above rather than a rule of its own: a base that joins two
179
+ // edges has no definition to walk, so `fixed-cell` cannot ask its question and the honest
180
+ // answer is that this pass does not know what the base is. An `undef` base is the same shape —
181
+ // storage nothing was entitled to write.
182
+ id: 'unresolved-base',
183
+ why: 'a base whose origin does not resolve cannot be shown not to be a device cell',
184
+ sound: true,
185
+ guardedBy: 'truncload.test.ts: a base joined on two edges is not narrowed',
186
+ rejects: (c) => !c.resolvedBase,
187
+ },
188
+ {
189
+ // SOUND, and `fixed-cell`'s question answered by the passes that knew it: the device pin
190
+ // (frontend/device-pins.ts) marks a register's access whatever its address is, including the
191
+ // runtime-indexed channel `(vu32 *)(0x40000B0 + ch * 12)` no constant address reaches, and the
192
+ // `declared` stamp (raise/declared-volatile.ts) marks a read of a volatile object through a
193
+ // runtime index into it. Widened, the narrow access becomes a cast of a fresh load that carries
194
+ // no placement, at a width the machine never used on the object.
195
+ id: 'qualified-access',
196
+ why: 'a qualified access is made at its own width, and the fold would also drop its placement',
197
+ sound: true,
198
+ guardedBy: 'truncload.test.ts: a pinned narrow read through a runtime-indexed base keeps its width and its pin',
199
+ rejects: (c) => c.qualified,
200
+ },
201
+ {
202
+ // The sibling narrowing passes' rule, under the sibling name (raise/paramwidth.ts,
203
+ // raise/narrowlocal.ts): a width `CAST_WIDTHS` does not carry has no C cast to spell it, so the
204
+ // fold would mint a `zext` the backend can only gap on. No corpus row reaches it today — every
205
+ // frontend load is 1, 2 or 4 bytes — and it costs a line rather than an argument about which
206
+ // widths a frontend may acquire.
207
+ id: 'cast-width',
208
+ why: 'only 8 and 16 are widths a `zext`/`sext` — and so a C declaration — carries',
209
+ sound: true,
210
+ guardedBy: 'truncload.test.ts: a width no C type spells is refused',
211
+ rejects: (c) => !CAST_WIDTHS.has(c.castWidth),
212
+ },
213
+ {
214
+ // A covering STORE fixes the bytes but not the field's signedness, and the fold has to put one
215
+ // on the load it synthesizes. Not sound — the bytes are the same either way, so the worst case
216
+ // is a field declared `u16` where the source wrote `s16` — but inventing a fact the asm did not
217
+ // carry is not how a default spelling is chosen, and the differ never sees this one because the
218
+ // synthesized load is not enumerated.
219
+ id: 'covering-store',
220
+ why: 'a covering store carries no signedness, so the field type would be invented rather than read',
221
+ sound: false,
222
+ rejects: (c) => !c.coveringLoad,
223
+ },
224
+ {
225
+ // SOUND: a read of bytes ABOVE the field's low-order end is `(u16)(x >> 16)`, not `(u16)x`.
226
+ // The cast spelling would read the field's other half — a different value from the one the
227
+ // machine loaded, silently. The shifted form is expressible, but it is not what the narrow
228
+ // instruction spells and nothing would recompile to it, so the honest answer is to leave the
229
+ // base declining.
230
+ id: 'high-order-read',
231
+ why: 'bytes above the low-order end of a field are a shift, and a plain cast would read the wrong ones',
232
+ sound: true,
233
+ guardedBy: 'truncload.test.ts: a read above the low-order end is not narrowed',
234
+ rejects: (c) => !c.lowOrderEnd,
235
+ },
236
+ {
237
+ // SOUND, and the rule that separates this fold from the layout claim it feeds. Widening the
238
+ // read puts a WIDER ACCESS at the point where the machine executed a narrower one, so the bytes
239
+ // past the narrow range have to be bytes this execution already read — not merely bytes the
240
+ // FUNCTION reads somewhere. raise/structs.ts declares one layout per base across the whole
241
+ // function, which is a claim about the object's TYPE and costs no instruction; this fold emits
242
+ // a dereference, so the same function-wide reasoning would have the program read three bytes it
243
+ // never read on the path it is on. Dominance is what makes the two claims different sizes.
244
+ id: 'covering-dominates',
245
+ why: 'the bytes past the narrow read must be bytes this execution already read, not bytes some other path reads',
246
+ sound: true,
247
+ guardedBy: 'truncload.test.ts: a cover on the other arm of a branch is not narrowed',
248
+ rejects: (c) => !c.coveringDominates,
249
+ },
250
+ ];
251
+
252
+ /** One admitted-or-refused fold: the narrow load, the access it truncates, and what the gates see. */
253
+ export interface TruncLoadCandidate {
254
+ narrow: Access;
255
+ covering: Access;
256
+ c: TruncatedLoad;
257
+ }
258
+
259
+ /** Every narrow load an access at the same base covers, in program order.
260
+ *
261
+ * WHICH COVER, when several cover the same narrow read: the pick is a TOTAL ORDER over facts, not
262
+ * a reduction over the order the accesses happen to be emitted in. A cover the sound rules can
263
+ * admit outranks one they cannot — `{w4@8, w4@6, w1@8}` folds whichever of the two wide reads was
264
+ * emitted first otherwise, so an identical access set would admit or refuse on instruction
265
+ * scheduling alone. After that the WIDEST wins, because a base read at 4, 2 and 1 bytes from one
266
+ * offset collapses to ONE field in a single pass that way where the closest cover would need a
267
+ * fixpoint; then a load over a store; then the earlier access. A cover is never itself a
268
+ * candidate, since a candidate is strictly narrower than its own cover. */
269
+ export function truncatedLoadCandidates(fn: Fn, littleEndian: boolean): TruncLoadCandidate[] {
270
+ const { accessesOf } = constOffsetAccesses(fn);
271
+ const defs = defOpMap(fn);
272
+ const entryParams = new Set(fn.blocks[0].params);
273
+ const dom = dominators(fn);
274
+ const blockDominates = (a: number, b: number): boolean => dom.get(fn.blocks[b] as Block)!.has(fn.blocks[a] as Block);
275
+ const runsBefore = (a: Access, b: Access): boolean =>
276
+ a.block === b.block ? a.at < b.at : blockDominates(a.block, b.block);
277
+ const isLowOrderEnd = (narrow: Access, cover: Access): boolean =>
278
+ littleEndian ? narrow.off === cover.off : narrow.off + narrow.width === cover.off + cover.width;
279
+
280
+ const out: TruncLoadCandidate[] = [];
281
+ for (const b of fn.blocks) {
282
+ for (const op of b.ops as Op[]) {
283
+ if (op.opcode !== 'load') {
284
+ continue;
285
+ }
286
+ const base = op.operands[0];
287
+ const all = accessesOf.get(base)!;
288
+ const narrow = all.find((a) => a.op === op)!;
289
+ const covers = all.filter(
290
+ (a) => a.width > narrow.width && a.off <= narrow.off && a.off + a.width >= narrow.off + narrow.width,
291
+ );
292
+ if (covers.length === 0) {
293
+ continue;
294
+ }
295
+ const preference = (a: Access): number[] => [
296
+ isLowOrderEnd(narrow, a) ? 1 : 0,
297
+ runsBefore(a, narrow) ? 1 : 0,
298
+ a.width,
299
+ a.isLoad ? 1 : 0,
300
+ ];
301
+ const covering = covers.reduce((best, a) => {
302
+ const [x, y] = [preference(a), preference(best)];
303
+ const i = x.findIndex((v, k) => v !== y[k]);
304
+ return i >= 0 && x[i] > y[i] ? a : best;
305
+ });
306
+ out.push({
307
+ narrow,
308
+ covering,
309
+ c: {
310
+ fixedCell: constAddressOf(defs, base, narrow.off) !== null || globalCellOf(defs, base, narrow.off) !== null,
311
+ qualified: [op, covering.op].some(qualified),
312
+ resolvedBase: baseResolves(defs, entryParams, base),
313
+ coveringLoad: covering.isLoad,
314
+ lowOrderEnd: isLowOrderEnd(narrow, covering),
315
+ coveringDominates: runsBefore(covering, narrow),
316
+ castWidth: narrow.width * 8,
317
+ },
318
+ });
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+
324
+ /** Rewrite every admitted narrow load into a cast of a wider one. Returns the number of rewrites.
325
+ *
326
+ * `gates` is a REQUIRED argument, not an option with a default: the table is how the refusals are
327
+ * ablated, and a second caller that could omit it would switch the channel off silently. */
328
+ export function foldTruncatedLoads(fn: Fn, littleEndian: boolean, gates: readonly Gate<TruncatedLoad>[]): number {
329
+ const admitted = truncatedLoadCandidates(fn, littleEndian).filter((k) => firstRejection(gates, k.c) === null);
330
+ if (admitted.length === 0) {
331
+ return 0;
332
+ }
333
+ const byBlock = new Map<number, TruncLoadCandidate[]>();
334
+ for (const k of admitted) {
335
+ const list = byBlock.get(k.narrow.block);
336
+ if (list) {
337
+ list.push(k);
338
+ } else {
339
+ byBlock.set(k.narrow.block, [k]);
340
+ }
341
+ }
342
+ for (const [block, ks] of byBlock) {
343
+ const b = fn.blocks[block];
344
+ const inserted = new Map<number, Op>();
345
+ for (const k of ks) {
346
+ const wide = mkOp('load', {
347
+ operands: [k.narrow.op.operands[0]],
348
+ results: [mkValue(T.unk(32))],
349
+ attrs: { off: k.covering.off, width: k.covering.width, signed: k.covering.signed },
350
+ });
351
+ inserted.set(k.narrow.at, wide);
352
+ // REWRITTEN IN PLACE, so the result `Value` identity survives and every existing use — and
353
+ // any slot home stamped on it (`ir/core.ts` SlotHomes) — keeps reading the same value. The
354
+ // operand it drops is the base, which the fresh load above still reads, so nothing is
355
+ // orphaned and the pass declares `dce: false`.
356
+ k.narrow.op.opcode = k.narrow.signed ? 'sext' : 'zext';
357
+ k.narrow.op.operands = [wide.results[0]];
358
+ k.narrow.op.attrs = { width: k.narrow.width * 8 };
359
+ }
360
+ b.ops = b.ops.flatMap((op, at) => {
361
+ const wide = inserted.get(at);
362
+ return wide ? [wide, op] : [op];
363
+ });
364
+ }
365
+ return admitted.length;
366
+ }
@@ -0,0 +1,106 @@
1
+ // asmlift — 64-bit runtime-helper lowering (L1 recognition).
2
+ //
3
+ // The peer of `raise/softdiv.ts`, one width up, and NOT gated on a hardware capability. A soft
4
+ // division is a division the ISA has no instruction for, so a target with a divider never emits
5
+ // one; no ISA this repo targets has 64-bit integer arithmetic at all, so `bl __muldi3` is software
6
+ // on every one of them whatever `hwDivide` says.
7
+ //
8
+ // By the time this runs the frontend has already read the call's arguments as 64-bit values and
9
+ // split its result back into the register pair (`frontend/thumb.ts`, at the `bl`), so the rewrite
10
+ // itself is the same one-line splice `softdiv` does: the helper's op over the same operands,
11
+ // reusing the SAME result value so every existing use already points at it.
12
+ //
13
+ // The signedness of the OPERATION comes from the helper name where the name splits it
14
+ // (`__divdi3`/`__udivdi3`) and from the operands where it does not — agbcc's `__muldi3` serves
15
+ // both spellings, so nothing here may read a signedness off it. See the table's own note.
16
+ import { Fn, mkOp } from '../ir/core';
17
+ import { arrivesAsDeclared, helperOp, isFloatHelper, isWideHelper, lookupHelper } from '../runtime-helpers';
18
+ import type { TargetDescription } from '../target';
19
+
20
+ /** Rewrite each recognised 64-bit helper call to the op it computes, in place. Returns whether
21
+ * anything changed. Runs BEFORE type recovery, so the operands get their signedness there. */
22
+ export function recognizeWideHelpers(fn: Fn, target: TargetDescription): boolean {
23
+ const table = target.runtimeHelpers ?? {};
24
+ let changed = false;
25
+ for (const b of fn.blocks) {
26
+ for (let i = 0; i < b.ops.length; i++) {
27
+ const op = b.ops[i];
28
+ if (op.opcode !== 'call') {
29
+ continue;
30
+ }
31
+ const helper = lookupHelper(table, String(op.attrs.target));
32
+ if (!helper?.op || !isWideHelper(helper) || isFloatHelper(helper)) {
33
+ continue;
34
+ }
35
+ // ITS C PARAMETERS AT THEIR STATED WIDTHS, not its argument registers and not their count:
36
+ // the frontend has paired the registers up, so a `__ashrdi3` that occupied three of them
37
+ // arrives here as a 64-bit value and a word. That pair construction is the evidence the fold
38
+ // rests on, and `arrivesAsDeclared` is the precondition for reading it — an operand count
39
+ // says nothing about what the operands hold, and a call carrying the right count of the
40
+ // wrong things folds into an operation over one half of each value.
41
+ //
42
+ // WHAT DECLINES HERE DOES NOT PASS THROUGH: `refuseUnmodelledHelpers` below gaps every
43
+ // surviving call to a name this table carries, so a shape this cannot fold gets the loud
44
+ // answer rather than a plausible one.
45
+ if (
46
+ !arrivesAsDeclared(
47
+ helper,
48
+ op.operands.map((o) => o.type),
49
+ op.results.map((r) => r.type),
50
+ )
51
+ ) {
52
+ continue;
53
+ }
54
+ b.ops.splice(i, 1, helperOp(helper.op, String(op.attrs.target), op.operands, op.results[0]));
55
+ changed = true;
56
+ }
57
+ }
58
+ return changed;
59
+ }
60
+
61
+ /** Turn every surviving call to one of the target's runtime helpers into a gap. Returns whether
62
+ * anything changed. Runs after the helper recognisers, so what it sees is what they declined.
63
+ *
64
+ * RE-EMITTING A COMPILER'S OWN RUNTIME CALL IS NOT A RECOVERY, and it is worse than a plain
65
+ * miss: it MATCHES. Hand `mwcceppc` the source `return __div2i(a, b);` and it emits the `bl
66
+ * __div2i` the row was lifted from, byte for byte — so the differ scores asmlift's failure to
67
+ * model 64-bit division exactly as it would score modelling it. A row that cannot tell the two
68
+ * apart is measuring nothing, and four cells of the synthetic 64-bit family were banking that.
69
+ *
70
+ * ONLY THE NAMES THE TARGET CARRIES, and a name outside the table stays an ordinary callee —
71
+ * which is right, because a project's own `__`-prefixed function is not this compiler's runtime
72
+ * and asmlift cannot tell them apart by spelling. A target with NO table therefore refuses
73
+ * nothing and spells every helper call it makes, which is the configuration this refusal exists
74
+ * to remove (`TargetDescription.runtimeHelpers`).
75
+ *
76
+ * An `opaque` rather than a throw, so the gap behaves like every other one: strict mode declines
77
+ * naming it, annotate mode marks it and leaves the rest of the function standing. */
78
+ export function refuseUnmodelledHelpers(fn: Fn, target: TargetDescription): boolean {
79
+ const table = target.runtimeHelpers;
80
+ if (!table) {
81
+ return false;
82
+ }
83
+ let changed = false;
84
+ for (const b of fn.blocks) {
85
+ for (let i = 0; i < b.ops.length; i++) {
86
+ const op = b.ops[i];
87
+ // Membership, through the table's one reader: `in` would also answer for `toString` and every
88
+ // other name on `Object.prototype`, and this is the refusal, so the fabricated reason would be
89
+ // the whole of what a caller saw.
90
+ if (op.opcode !== 'call' || lookupHelper(table, String(op.attrs.target)) === undefined) {
91
+ continue;
92
+ }
93
+ b.ops.splice(
94
+ i,
95
+ 1,
96
+ mkOp('opaque', {
97
+ operands: [...op.operands],
98
+ results: [...op.results],
99
+ attrs: { helper: op.attrs.target },
100
+ }),
101
+ );
102
+ changed = true;
103
+ }
104
+ }
105
+ return changed;
106
+ }
@@ -11,8 +11,9 @@
11
11
  import { type Fn, type Value, defOpMap } from './ir/core';
12
12
  import type { SFn } from './l3/ast';
13
13
  import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
14
- import type { SymbolInfo } from './symbols';
15
- import { C_TYPEDEFS } from './target';
14
+ import type { Prototypes } from './proto';
15
+ import { type SymbolInfo, accessSignedness } from './symbols';
16
+ import { C_TYPEDEFS, type TargetDescription } from './target';
16
17
 
17
18
  /** Bare-global ACCESS FACTS for name-only map symbols — the width/signedness authority the
18
19
  * declaration synthesis (declare.ts) uses when the map has no shape. The map knows only the
@@ -42,7 +43,7 @@ export function bareGlobalAccessFacts(fn: Fn): Map<string, { width: number; sign
42
43
  } else {
43
44
  a.widths.add(op.attrs.width as number);
44
45
  if (op.opcode === 'load') {
45
- a.signs.add(((op.attrs.signed as boolean) ?? false) && (op.attrs.width as number) < 4);
46
+ a.signs.add(accessSignedness(op.attrs.width as number, op.attrs.signed as boolean | undefined));
46
47
  }
47
48
  }
48
49
  }
@@ -202,15 +203,34 @@ export function makeRefCollector(ctx: {
202
203
  /** the globals the TARGET names — the pool/reloc names and the shapes the asm evidences, before
203
204
  * the map is unioned in */
204
205
  targetNames: ReadonlySet<string>;
206
+ /** the project's prototype table — what lets a CALL TARGET be declared rather than refused */
207
+ prototypes: Prototypes;
208
+ /** the target the lift ran for, whose ABI says which declared types a printed prototype carries */
209
+ target: Pick<TargetDescription, 'doubleArgWords'>;
205
210
  /** reports a refusal at most once per (name, reason); the caller owns the dedup */
206
211
  refuse: (name: string, reason: RefusedDeclarationReason) => void;
207
212
  }): (tree: SFn) => { symbolRefs?: SymbolRef[] } {
208
- const { declSymbols, accessFacts, mapSymbols, targetNames, refuse } = ctx;
213
+ const { declSymbols, accessFacts, mapSymbols, targetNames, prototypes, target, refuse } = ctx;
209
214
  return (tree: SFn): { symbolRefs?: SymbolRef[] } => {
210
215
  // The names THIS tree binds. Computed per tree because the emitter mints local names per
211
216
  // spelling — but the test below is NOT `bound` alone, and the difference is a wrong answer.
212
217
  const bound = new Set<string>([...tree.params.map((p) => p.name), ...tree.locals.map((l) => l.name)]);
213
- const refs = collectSymbolRefs(tree.body, declSymbols, tree.name, refuse).flatMap((r) => {
218
+ // A function-scope static is declared in the body beside the locals, so it shares their
219
+ // namespace: one the emitter's own names can reach dies with the spelling, as below.
220
+ const statics = new Set((tree.statics ?? []).map((st) => st.name));
221
+ for (const st of statics) {
222
+ if (bound.has(st) || EMITTER_NAME.test(st)) {
223
+ refuse(st, 'emitter-name');
224
+ throw new Error(
225
+ `cannot spell '${tree.name}': the target names a function-scope static '${st}', which is a ` +
226
+ `name the emitted C uses for its own locals and parameters — no declaration can bind it`,
227
+ );
228
+ }
229
+ }
230
+ const refs = collectSymbolRefs(tree, declSymbols, prototypes, target, refuse).flatMap((r) => {
231
+ if (statics.has(r.name)) {
232
+ return []; // defined in the body — the map's global of the same name is another object
233
+ }
214
234
  // THE ONE REFUSAL THAT IS NOT A REFUSAL — a name the emitted C uses for its OWN storage
215
235
  // kills the SPELLING, because no declaration makes that candidate right and no declaration
216
236
  // makes it fail either. Two shapes, and the second is why the test is the emitter's whole
@@ -257,6 +277,11 @@ export function makeRefCollector(ctx: {
257
277
  // A ref no MAP accounts for is a hypothesis read out of the target asm, and it is marked
258
278
  // as one all the way to the consumer (SymbolRef.synthesized).
259
279
  const synthesized = mapSymbols?.has(r.name) ? {} : { synthesized: true as const };
280
+ // A global the asm reads narrower than a word holds no pointer, whatever it meets bare.
281
+ if (r.holdsPointer && access !== undefined && access.width !== 4) {
282
+ const { holdsPointer: _, ...integer } = r;
283
+ return [{ ...integer, access, ...synthesized }];
284
+ }
260
285
  return [{ ...r, ...(access ? { access } : {}), ...synthesized }];
261
286
  });
262
287
  return refs.length ? { symbolRefs: refs } : {};
@@ -27,6 +27,7 @@ import {
27
27
  import type { Gate } from './l3/gates';
28
28
  import type { HoistPlacement } from './l3/hoist';
29
29
  import { initFirstGuards } from './l3/initfirst';
30
+ import { narrowDeclarations, narrowReadDeclarations } from './l3/narrowdecl';
30
31
  import { pollGuards, pollReads } from './l3/pollguard';
31
32
  import { unmergeJoins } from './l3/unmerge';
32
33
  // TYPE-ONLY, and deliberately: the structure-variation table types its `options` as
@@ -35,6 +36,7 @@ import { unmergeJoins } from './l3/unmerge';
35
36
  import type { structureChecked } from './pipeline';
36
37
  import {
37
38
  hasDerivedReadHome,
39
+ hasEscapingExtension,
38
40
  hasHomeableSharedAddress,
39
41
  hasLoopSharedPureValue,
40
42
  hasMergeFeedHome,
@@ -53,11 +55,12 @@ import type { VariationName } from './variation-tokens';
53
55
  * that setting's own lifted fn (a map-lifted shared lift spells const bases as gaddr, which would
54
56
  * blind the /raw-globals siblings — the /addr-home lesson). `strip` opts the variation into the
55
57
  * dropped-sibling closure: a candidate that applies it is skipped when its default sibling (the
56
- * same setting without it) failed the boundary contracts. Two variations are EXEMPT from structure()'s assertDefaultAccepts invariant:
57
- * `/reread-globals` only relaxes inlining barriers and `/uns-cmp` only changes spelling and
58
- * declarations — neither adds materialization or merging, so neither can unlock a function the
59
- * default declines (reread also skips the strip closure). Both exemptions are stated here
60
- * rather than left implicit in a missing `||` arm or trigger term. */
58
+ * same setting without it) failed the boundary contracts. Which of these entries `structure()`'s
59
+ * `assertDefaultAccepts` guard does NOT reset — the ones that cannot unlock a function the default
60
+ * declines, and so carry a written argument in place of the guard — is stated ONCE, beside that
61
+ * guard in structure.ts, and derived from the guard's own reset list in
62
+ * test/variation-offers.test.ts — never restated here, where a second copy drifts from the guard
63
+ * without anything going red. */
61
64
  export interface StructureVariation {
62
65
  flag:
63
66
  | 'reread'
@@ -67,6 +70,7 @@ export interface StructureVariation {
67
70
  | 'exprHome'
68
71
  | 'derivedHome'
69
72
  | 'mergeHome'
73
+ | 'escapeHome'
70
74
  | 'unsCmp'
71
75
  | 'freshMerge'
72
76
  | 'copyDefPos'
@@ -203,6 +207,24 @@ export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
203
207
  perLiftGate: hasMergeFeedHome,
204
208
  strip: true,
205
209
  },
210
+ // `/escape-home` — the escaping-extension-home variation (structure/analysis.ts AnalyzeOptions
211
+ // homeEscapingExtensions): a `zext`/`sext` with 2+ consumers, none of them in its own block,
212
+ // materializes into a local — the register the asm narrowed into once and every later block read
213
+ // — where the default re-evaluates the truncation at each consumer (`(u16)v` per use). The
214
+ // MIRROR of `/expr-home`, which wants a def outside a loop and a consumer inside; neither scope
215
+ // reaches the other's shape. Gated per symbol-map setting like its four siblings.
216
+ //
217
+ // A VARIATION, not a default, and `pokeemerald:AcroBikeHandleInputTurning:agbcc` is the row that
218
+ // decides it: it is a MATCH inside the scope, so forced on the spelling would be REPLACED across
219
+ // the fan rather than added to it. As a variation `compareScored` rides the un-homed sibling
220
+ // beside it and the match cannot be lost — the same argument `/merge-home` records above.
221
+ {
222
+ flag: 'escapeHome',
223
+ name: 'escape-home',
224
+ options: (on) => ({ homeEscapingExtensions: on }),
225
+ perLiftGate: hasEscapingExtension,
226
+ strip: true,
227
+ },
206
228
  // `/uns-cmp` — spell unsigned compares unsigned (structure.ts unsignedCompareSpelling): an
207
229
  // icmp_u* operand takes a (u32) cast where the rendered operands do not guarantee the
208
230
  // unsignedness, and a mixed-claimant declaration reconciles to u32 when nothing under the
@@ -352,15 +374,21 @@ export const STRUCTURE_VARIATIONS: readonly StructureVariation[] = [
352
374
  ];
353
375
 
354
376
  /** The STACKED variations (sanctioned in the POLICY note at rank.ts's respell site): each entry is
355
- * a statement-order/shape respell variation orthogonal to every other respell variation, derived
356
- * onto every source. Each fires alone, plus
377
+ * a statement-order/shape or declaration-width respell variation orthogonal to every respell
378
+ * variation outside this table, derived onto every source. Two members need not commute
379
+ * (`/narrow-decl` and `/narrow-read` compete for one cast), and table order then decides which one
380
+ * takes it in the all-together candidate. Each fires alone, plus
357
381
  * all of them together in table order — not the full subset lattice; the pairs question is
358
382
  * settled by applyStacked' skip-on-decline below, and a row demanding a true EXCLUSION pair —
359
- * all three fire, the match needs exactly two — is what would earn the lattice. */
383
+ * every member fires, the match needs a strict subset of them — is what would earn the lattice.
384
+ * So a member appended here also changes the all-together candidate wherever it fires beside two
385
+ * or more of the others. */
360
386
  export const STACKED_VARIATIONS: { name: VariationName; apply: (sfn: SFn) => SFn | null }[] = [
361
387
  { name: 'initfirst', apply: initFirstGuards },
362
388
  { name: 'pollguard', apply: pollGuards },
363
389
  { name: 'pollread', apply: pollReads },
390
+ { name: 'narrow-decl', apply: narrowDeclarations },
391
+ { name: 'narrow-read', apply: narrowReadDeclarations },
364
392
  ];
365
393
 
366
394
  /** The PRE-RESPELL variations (sanctioned in the POLICY note at rank.ts's respell site): a tree