@asmlift/core 0.7.0 → 0.8.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 (44) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/pascal.ts +2 -2
  4. package/src/codegen-flags.ts +640 -0
  5. package/src/frontend/disasm.ts +141 -11
  6. package/src/frontend/high-half.ts +149 -0
  7. package/src/frontend/mips.ts +458 -209
  8. package/src/frontend/ppc.ts +332 -67
  9. package/src/frontend/reloc-symbol.ts +109 -0
  10. package/src/frontend/splat.ts +56 -18
  11. package/src/frontend/ssa.ts +126 -29
  12. package/src/frontend/stackargs.ts +420 -0
  13. package/src/frontend/thumb.ts +207 -230
  14. package/src/ir/core.ts +62 -3
  15. package/src/ir/opcodes.ts +9 -0
  16. package/src/ir/parse.ts +7 -1
  17. package/src/l3/advance.ts +2 -2
  18. package/src/l3/argbase.ts +2 -2
  19. package/src/l3/argcopy.ts +269 -0
  20. package/src/l3/ast.ts +45 -1
  21. package/src/l3/basecse.ts +2 -2
  22. package/src/l3/coalesce.ts +109 -52
  23. package/src/l3/scopebase.ts +4 -4
  24. package/src/l3/tailret.ts +70 -0
  25. package/src/l3/unmerge.ts +2 -2
  26. package/src/l3/unreduce.ts +2 -1
  27. package/src/mangle.ts +49 -0
  28. package/src/pattern/engine.ts +128 -13
  29. package/src/pipeline.ts +22 -11
  30. package/src/raise/extscale.ts +5 -2
  31. package/src/raise/paramwidth.ts +111 -3
  32. package/src/raise/pre-recovery.ts +11 -1
  33. package/src/raise/retsink.ts +8 -4
  34. package/src/raise/tailsink.ts +17 -2
  35. package/src/rank-declare.ts +17 -9
  36. package/src/rank.ts +45 -19
  37. package/src/structure/retspell.ts +95 -0
  38. package/src/structure/structure.ts +12 -3
  39. package/src/structure/switch-recover.ts +1 -1
  40. package/src/target.ts +224 -14
  41. package/src/trace.ts +27 -18
  42. package/src/variation-definitions.ts +52 -2
  43. package/src/variation-gates.ts +3 -0
  44. package/src/variation-tokens.ts +1 -0
@@ -0,0 +1,109 @@
1
+ // asmlift — the symbol-naming policy for relocations, as data.
2
+ //
3
+ // A relocation hands the frontend a LINKER's name, and a linker's namespace is strictly larger than
4
+ // C's: it holds anonymous constant pools, section-relative labels, C++ vtables and mangled
5
+ // function-scope statics, none of which any C or C++ source can spell. Recovering an address is
6
+ // only half the job — the other half is deciding, per KIND of name, whether the recovered global
7
+ // can be written down at all.
8
+ //
9
+ // The failure this exists to prevent is the one m2c shipped as `unksp0` for years: a gap rendered
10
+ // as an ordinary-looking identifier. `__vt__6System` is the sharpest case — `extern u32
11
+ // __vt__6System;` COMPILES, so nothing downstream would ever complain; the candidate would simply
12
+ // be wrong in a way that reads as right. So the decision is made here, once, by the shape of the
13
+ // name, and every refusing kind gets its own sentence naming what was seen.
14
+ //
15
+ // Consumed by frontend/ppc.ts, which refuses before it recovers. A kind is listed only when it
16
+ // behaves differently: the decomp projects' generated labels (`lbl_1_bss_2464`, `fn_1_458`) are
17
+ // ordinary identifiers that the project's own headers declare and its own sources spell, so they
18
+ // are `plain` and get no entry of their own.
19
+ //
20
+ // THE SCOPE OF THIS POLICY IS THE NAME, AND ONLY ON A DATA RELOCATION.
21
+ // • Not the TYPE. A name this passes is still rendered at the width the asm implies, which is a
22
+ // different seam and fails loud in the compiler (docs/symbol-naming-policy.md).
23
+ // • Not the LINKAGE. A file-scope `static` and an `extern` of the same name compile to the same
24
+ // object under mwcc — same bytes, same named relocation — so the minted `extern` asserts
25
+ // nothing the reference did not already assert (measured; see the doc).
26
+ // • Not a `bl` CALLEE. Those reach the emitter through the call path and are NOT classified here:
27
+ // a C++ row's candidate is compiled inside an `extern \"C\"` block, where a mangled name written
28
+ // verbatim denotes exactly that symbol (apps/benchmark/src/compile/real.ts `candidateLinkage`),
29
+ // so a callee needs no policy the way a global that must be DECLARED does.
30
+ //
31
+ // The evidence, the counts and the corpus behind every rule are in docs/symbol-naming-policy.md.
32
+
33
+ /** What sort of name a relocation carries. Everything but `plain` is unspellable in C. */
34
+ export type RelocSymbolKind =
35
+ 'plain' | 'anon-pool' | 'section-local' | 'local-static' | 'cpp-vtable' | 'cpp-mangled' | 'not-an-identifier';
36
+
37
+ /** Classify a relocation's symbol by its spelling. Order matters: the shapes that ARE valid C
38
+ * identifiers (`__vt__…`, a mangled class-scoped name) or contain characters a C identifier may
39
+ * not (`@`, `.`, `$`) are each recognised before the general identifier test, so the catch-all
40
+ * below can be exactly "a name of no kind this policy knows, and not an identifier either". */
41
+ export function classifyRelocSymbol(sym: string): RelocSymbolKind {
42
+ if (sym.startsWith('@')) {
43
+ return 'anon-pool'; // `@193` — mwcc's anonymous string/constant pool entries
44
+ }
45
+ if (sym.startsWith('.')) {
46
+ return 'section-local'; // `...bss.0`, `.rodata` — an offset into a section
47
+ }
48
+ if (sym.includes('$')) {
49
+ return 'local-static'; // `sprHideTbl$797` — a function-scope static plus mwcc's TU-wide counter
50
+ }
51
+ if (sym.startsWith('__vt__')) {
52
+ return 'cpp-vtable'; // `__vt__6System` — a compiler-emitted virtual table
53
+ }
54
+ // mwcc mangles a class-scoped name as `<name>__<length><Class>` (`statbuff__9CmdStream`) or,
55
+ // for a nested scope, `<name>__Q<depth><…>` (`__ct__Q26Action5ChildFv`). The marker is the `__`
56
+ // followed by that LENGTH or `Q<depth>`, never a double underscore on its own: a rule that fired
57
+ // on `__` would refuse ordinary C globals like `g_my__table` and `__initialised`.
58
+ //
59
+ // THE MARKER IS THE SCOPE, NOT THE MANGLING, and the difference is deliberate. A free function's
60
+ // parameter mangle (`makeObjectBoss__Fv`, `ARAMFinish__FUl` — 89 of the 24,236 distinct symbols a
61
+ // data relocation names across the three checkouts) is `plain`: what this kind refuses is the
62
+ // missing DECLARATION, and a free function has no class scope for the symbol map to suppress one
63
+ // through. 0 of the 4,663 lifted functions in the same sweep emit such a name, so the line is
64
+ // documented rather than moved; docs/symbol-naming-policy.md names the two measurements that
65
+ // would have to come first.
66
+ if (/__(?:\d|Q\d)/.test(sym)) {
67
+ return 'cpp-mangled';
68
+ }
69
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(sym) ? 'plain' : 'not-an-identifier';
70
+ }
71
+
72
+ /** Why a symbol of this kind cannot be written into a candidate, as the tail of a refusal sentence
73
+ * — or null for the one kind that can. The caller prefixes the function, mnemonic and address, so
74
+ * a reader of the artifact learns the kind without re-deriving it from the name. */
75
+ export function unspellableReason(sym: string): string | null {
76
+ switch (classifyRelocSymbol(sym)) {
77
+ case 'plain':
78
+ return null;
79
+ case 'anon-pool':
80
+ return (
81
+ `names an anonymous constant pool entry ('${sym}') — the compiler generated that name for a ` +
82
+ `literal it has no declaration for, so no C source can refer to it`
83
+ );
84
+ case 'section-local':
85
+ return (
86
+ `names a section-relative label ('${sym}') — it denotes an offset into a section, not an ` +
87
+ `object, so there is nothing to declare`
88
+ );
89
+ case 'local-static':
90
+ return (
91
+ `names a function-scope static ('${sym}') — the suffix is a translation-unit-wide counter ` +
92
+ `the compiler assigned, which no source can spell`
93
+ );
94
+ case 'cpp-vtable':
95
+ return (
96
+ `names a C++ virtual table ('${sym}') — the compiler emits it from a class definition, so ` +
97
+ `no source spells it (and declaring it anyway would compile, which is why this refuses)`
98
+ );
99
+ case 'cpp-mangled':
100
+ return (
101
+ `names a C++ class-scoped symbol ('${sym}') — a reference spelled this way reaches exactly ` +
102
+ `that symbol, but nothing here can DECLARE it: the row's own unit declares the member under ` +
103
+ `its class scope, which this frontend does not decode, and the candidate would name an ` +
104
+ `identifier no declaration introduces`
105
+ );
106
+ case 'not-an-identifier':
107
+ return `names '${sym}', which is not a C identifier`;
108
+ }
109
+ }
@@ -15,11 +15,16 @@
15
15
  // • constant immediate EXPRESSIONS (`(0x660104 >> 16)`, `(x & 0xFFFF)`) — the assembler's hi/lo
16
16
  // split of a 32-bit literal, evaluated here to the plain number the decode switch parses.
17
17
  //
18
- // `%hi`/`%lo` operands (a global's address) are preserved verbatim so the MIPS frontend can fold
19
- // them into a `gaddr` (frontend/mips.ts). The other GOT/PIC relocations (`%gp_rel`, `%got`, …) are
20
- // declined LOUDsmall-data / position-independent access is not modelled. Preserving rather than
21
- // blindly evaluating is what keeps `parseImm('%hi(SYM)')` from silently becoming a NaN immediate.
22
- import type { DisasmInstr } from './disasm';
18
+ // `%hi`/`%lo` operands (a global's address) are turned into exactly what a relocatable object
19
+ // carries an `R_MIPS_HI16`/`R_MIPS_LO16` record on the instruction, plus the immediate the
20
+ // instruction really encodes so both MIPS dialects reach ONE fold (frontend/mips.ts,
21
+ // frontend/high-half.ts) and neither gets a pairing rule of its own. The encoding is the
22
+ // assembler's: `%hi(x)` is `((x + 0x8000) >> 16) & 0xffff`, ADJUSTED so the sign-extended low half
23
+ // cancels the carry, and `%lo(x)` is the sign-extended low 16 bits — which is what lets the fold
24
+ // recover `x` as `(hi << 16) + (s16)lo` for a positive or a negative offset alike.
25
+ // The other GOT/PIC relocations (`%gp_rel`, `%got`, …) are declined LOUD — small-data /
26
+ // position-independent access is not modelled.
27
+ import type { DisasmInstr, DisasmReloc } from './disasm';
23
28
  import { FrontendUnsupportedError } from './errors';
24
29
 
25
30
  // One instruction line: `/* ROM VRAM BYTES */ MNEMONIC OPS`. Group 1 is the VRAM address word.
@@ -29,9 +34,12 @@ const INSN_SIGNAL = /\/\*\s*[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s*\*\//;
29
34
  // A local-label DEFINITION on its own line (`.L800011C0_1DC0:`); the colon is required.
30
35
  const LABEL_DEF = /^(\.[\w.$]+):$/;
31
36
  // A GOT/PIC relocation operand this reader does not support (small-data / position-independent
32
- // access) — declined loud. `%hi`/`%lo` are NOT here: they name a global's address and are preserved
33
- // verbatim for the MIPS frontend to fold into a `gaddr` (see normalizeOperand / frontend/mips.ts).
37
+ // access) — declined loud. `%hi`/`%lo` are NOT here: they name a global's address and become
38
+ // relocation records for the MIPS frontend to fold (see normalizeOperand / frontend/mips.ts).
34
39
  const RELOC_OP = /%(gp_rel|gprel|got|call16|call_hi|call_lo|higher|highest|neg|tprel|dtprel)\b/i;
40
+ // Any `%hi`/`%lo` spelling at all, so a half `normalizeOperand`'s pattern cannot resolve is caught
41
+ // rather than falling through to the paths that read an operand as arithmetic.
42
+ const HILO_OP = /%(hi|lo)\s*\(/i;
35
43
  // Data directives whose bytes could encode an effect: skipping one inside a function slice would
36
44
  // silently delete it, so they decline (mirrors the Thumb frontend's in-code-data guard).
37
45
  const DATA_DIRECTIVE =
@@ -120,7 +128,17 @@ export function parseSplatMips(asm: string, name: string): DisasmInstr[] {
120
128
  `cannot lift '${name}': data directive '${mnemonic}' in the code stream — skipping it would silently delete its effect`,
121
129
  );
122
130
  }
123
- const ops = m[3].trim() ? splitOperands(m[3].trim()).map((o) => normalizeOperand(name, o)) : [];
131
+ const normalized = m[3].trim() ? splitOperands(m[3].trim()).map((o) => normalizeOperand(name, o)) : [];
132
+ const ops = normalized.map((n) => n.op);
133
+ // At most one relocation per instruction — the same invariant disasm.ts enforces on objdump
134
+ // output, and for the same reason: two would leave one symbol standing for the other's operand.
135
+ const relocs = normalized.map((n) => n.reloc).filter((r): r is DisasmReloc => r !== undefined);
136
+ if (relocs.length > 1) {
137
+ throw new FrontendUnsupportedError(
138
+ `cannot lift '${name}': two relocation operands on one instruction ('${mnemonic}' at ` +
139
+ `0x${addr.toString(16)}): '${relocs[0].sym}' and '${relocs[1].sym}'`,
140
+ );
141
+ }
124
142
  // addi/addiu SIGN-EXTEND their 16-bit immediate; Splat may spell the low half of a materialised
125
143
  // constant as an unsigned mask (`(0x8000ABCD & 0xFFFF)` = 0xABCD), so re-sign it here to match
126
144
  // the hardware — and the objdump path, which prints the already-signed value. Zero-extending ops
@@ -132,7 +150,7 @@ export function parseSplatMips(asm: string, name: string): DisasmInstr[] {
132
150
  labelAddr.set(l, addr);
133
151
  }
134
152
  pending = [];
135
- instrs.push({ addr, mnemonic, ops });
153
+ instrs.push({ addr, mnemonic, ops, reloc: relocs[0] });
136
154
  }
137
155
 
138
156
  // Resolve every branch/jump's target label to an address. A target that is not a local label of
@@ -194,31 +212,51 @@ function splitOperands(s: string): string[] {
194
212
 
195
213
  // Rewrite one Splat operand into the canonical objdump spelling the frontend consumes: strip the
196
214
  // `$` register sigil, fold a memory operand's displacement expression, evaluate a bare constant
197
- // expression, preserve a `%hi`/`%lo` global reference, and decline an unsupported PIC relocation.
198
- function normalizeOperand(name: string, op: string): string {
199
- // `%hi(SYM)` / `%lo(SYM + N)` / `%lo(SYM)(base)` — a global's address. Preserved verbatim (with a
200
- // de-sigiled base) for the MIPS frontend to fold into a `gaddr`; NOT declined like the PIC relocs.
201
- const hilo = op.match(/^(%(?:hi|lo)\([^)]*\))(?:\((\$?[A-Za-z]\w*)\))?$/);
215
+ // expression, split a `%hi`/`%lo` reference into an immediate plus its record, decline a PIC one.
216
+ function normalizeOperand(name: string, op: string): { op: string; reloc?: DisasmReloc } {
217
+ // `%hi(SYM)` / `%lo(SYM + N)` / `%lo(SYM)(base)` — a global's address. Becomes the relocation
218
+ // record an object file would carry plus the immediate the instruction really encodes, so the
219
+ // frontend folds this dialect through the same path as objdump; NOT declined like the PIC relocs.
220
+ const hilo = op.match(
221
+ /^%(hi|lo)\(\s*([A-Za-z_.$][\w.$]*)\s*(?:([+-])\s*(0x[0-9a-fA-F]+|\d+))?\s*\)(?:\((\$?[A-Za-z]\w*)\))?$/,
222
+ );
202
223
  if (hilo) {
203
- return hilo[2] ? `${hilo[1]}(${hilo[2].replace(/^\$/, '')})` : hilo[1];
224
+ const addend = hilo[4] ? evalConst(name, hilo[4]) * (hilo[3] === '-' ? -1 : 1) : 0;
225
+ const imm = hilo[1] === 'hi' ? ((addend + 0x8000) >> 16) & 0xffff : (addend << 16) >> 16;
226
+ const reloc: DisasmReloc = { type: hilo[1] === 'hi' ? 'R_MIPS_HI16' : 'R_MIPS_LO16', sym: hilo[2], addend: 0 };
227
+ return { op: hilo[5] ? `${imm}(${hilo[5].replace(/^\$/, '')})` : String(imm), reloc };
228
+ }
229
+ // A `%hi`/`%lo` the pattern above did NOT convert is still a relocation operand, and the paths
230
+ // below it read an operand as arithmetic: `%lo(0x800A1234)($v0)` matches the memory-operand shape
231
+ // and `evalConst` drops the tokens it does not know, so the displacement becomes the bare number
232
+ // and the access lifts as an index into the base register. A bare `%hi(…)` falls through to
233
+ // `plain` and the frontend refuses it one level down as a non-numeric immediate; refusing here
234
+ // says instead that what it saw was a relocation.
235
+ if (HILO_OP.test(op)) {
236
+ throw new FrontendUnsupportedError(
237
+ `cannot lift '${name}': relocation operand '${op}' — this reader resolves a '%hi'/'%lo' half ` +
238
+ `only against a symbol ('SYM' or 'SYM ± <integer>'), and will not treat one it cannot resolve ` +
239
+ `as arithmetic`,
240
+ );
204
241
  }
205
242
  if (RELOC_OP.test(op)) {
206
243
  throw new FrontendUnsupportedError(
207
244
  `cannot lift '${name}': relocation operand '${op}' (small-data / PIC data access) — not modelled`,
208
245
  );
209
246
  }
247
+ const plain = (v: string) => ({ op: v });
210
248
  // Memory operand `DISP(base)` — base is a register (letter-first), DISP a constant/expression.
211
249
  const mem = op.match(/^(.*)\((\$?[A-Za-z]\w*)\)$/);
212
250
  if (mem) {
213
251
  const disp = mem[1].trim();
214
252
  const off = disp === '' ? '0' : String(evalConst(name, disp));
215
- return `${off}(${mem[2].replace(/^\$/, '')})`;
253
+ return plain(`${off}(${mem[2].replace(/^\$/, '')})`);
216
254
  }
217
255
  // A bare constant expression (`(0x660104 >> 16)`) — the assembler's hi/lo literal split.
218
256
  if (op.startsWith('(')) {
219
- return String(evalConst(name, op));
257
+ return plain(String(evalConst(name, op)));
220
258
  }
221
- return op.replace(/^\$/, '');
259
+ return plain(op.replace(/^\$/, ''));
222
260
  }
223
261
 
224
262
  // Evaluate a constant integer expression (the assembler's hi/lo split: hex/dec literals with
@@ -15,7 +15,18 @@
15
15
  // computation via read/writeVar, push its terminator op last (successors referencing
16
16
  // `irBlocks`, args left empty — phi wiring appends them), then call `markFilled(b)`. When all
17
17
  // blocks are filled, call `finish()` to remove trivial phis.
18
- import { Block, Fn, Op, type SlotHomes, Value, type WriteOrder, mkOp, mkValue } from '../ir/core';
18
+ import {
19
+ Block,
20
+ Fn,
21
+ Op,
22
+ type ParamObservation,
23
+ type SlotHomes,
24
+ Value,
25
+ type WriteOrder,
26
+ defOpMap,
27
+ mkOp,
28
+ mkValue,
29
+ } from '../ir/core';
19
30
  import { pruneDeadParams, simplifyTrivialPhis } from '../ir/simplify';
20
31
  import { T } from '../ir/types';
21
32
  import { FrontendUnsupportedError } from './errors';
@@ -45,8 +56,13 @@ export interface SsaBuilder {
45
56
  * elsewhere a parameter is a phi whose position is aligned with its predecessors' terminator
46
57
  * args, and appending an unpaired one would corrupt that. */
47
58
  ensureParam(key: string, b: number): void;
48
- /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic). */
49
- hasReachingDef(reg: string, b: number, seen?: Set<number>): boolean;
59
+ /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic).
60
+ *
61
+ * `accept` says what counts as a definition. A frontend that defines a register with something
62
+ * that is NOT a value — PowerPC's `@ha` high half — passes a predicate rejecting it, because
63
+ * "a def reaches here" and "a value reaches here" are the same question only when every def is
64
+ * a value. */
65
+ hasReachingDef(reg: string, b: number, accept?: (v: Value) => boolean): boolean;
50
66
  /** Record that block `b` makes a call HERE: the ABI's caller-saved registers stop being ones the
51
67
  * caller set up. Call it AFTER `recordGuessedCall` for the same instruction, and after writing
52
68
  * the call's own result — the result is the CALLEE's, so it must not count as caller-side
@@ -92,21 +108,22 @@ export interface LiveInModel {
92
108
  * is right for that question — but its offset is an ABI position, not an `expand_decl` rank,
93
109
  * and ranking a declaration list by it would be wrong with no diagnostic.
94
110
  *
95
- * TODAY THE TWO RANGES COINCIDE UNDER THUMB, AND THAT IS DELEGATED, NOT PROVED. What keeps
96
- * argument slots out of `SlotHomes` is `prefixStored` (frontend/thumb.ts): a function whose
97
- * frame has an outgoing area DECLINES before it reaches here, so every function that does
98
- * reach here has none. That guard's own comment says "Neither is sound alone and the pair is
99
- * not either", and lifting it is a named next step so this field exists to make the
100
- * dependency TYPED and LOCAL rather than implicit and cross-module. Whoever lifts that decline
101
- * must narrow this range above the argument block; leaving it equal to `ownedLocals` would
102
- * start minting declaration ranks out of argument slots silently.
111
+ * THE TWO RANGES DIFFER UNDER THUMB, AND A PROOF IS WHAT SEPARATES THEM. The frontend passes
112
+ * `{ from: area, to: localArea }`, where `area` is the largest outgoing block
113
+ * `analyzeOutgoingArgs` (frontend/stackargs.ts) LICENSED the extent over which a callee's declared
114
+ * parameter count and this function's own staging stores agree word for word. That licence, not
115
+ * a decline, is what keeps argument slots out of `SlotHomes`: a frame whose outgoing area cannot
116
+ * be licensed still declines in the frontend and never reaches here, and a frame with no call
117
+ * taking stack arguments has `area` 0, so the ranges coincide exactly when there is provably
118
+ * nothing to skip. The dependency is TYPED and LOCAL for that reason — the offsets a frontend
119
+ * must not report as declarations are stated here rather than implied across modules.
103
120
  *
104
121
  * The class is populated, not hypothetical. Over a sweep of every sa3 and klonoa listing, of
105
122
  * 2,001 lifted real agbcc functions 27 carry any L1 slot home, 12 of those also CALL, and 11 of
106
- * those carry a home at offset 0 — the exact offset `prefixStored` encodes as where an argument
107
- * block starts (`PackSaveSector` homes [0,4,…,72], `modf` [0,4,…,36], `RenderDialogSprites`
108
- * [0,4,…,36], and eight more). None reaches the ordering today, for an unrelated reason
109
- * (`l3/slotorder.ts`'s REACH note), so nothing downstream is guarding this.
123
+ * those carry a home at offset 0 — the exact offset an outgoing argument block starts at
124
+ * (`PackSaveSector` homes [0,4,…,72], `modf` [0,4,…,36], `RenderDialogSprites` [0,4,…,36], and
125
+ * eight more). None reaches the ordering today, for an unrelated reason (`l3/slotorder.ts`'s
126
+ * REACH note), so nothing downstream is guarding this.
110
127
  *
111
128
  * ABSENT ⇒ NO STAMP. MIPS and PPC declare no frame partition at all, so they stamp nothing,
112
129
  * which is the refusing direction. */
@@ -196,7 +213,7 @@ export function makeSsaBuilder(
196
213
  const inRange = (off: number, r?: { from: number; to: number }) => r !== undefined && off >= r.from && off < r.to;
197
214
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
198
215
  // `writeOrder` and `slotHomes` are filled in below, where the builder's counters live.
199
- const fn: Fn = { name, blocks: irBlocks, writeOrder: undefined, slotHomes: undefined };
216
+ const fn: Fn = { name, blocks: irBlocks, writeOrder: undefined, slotHomes: undefined, paramEvidence: undefined };
200
217
 
201
218
  const defs: Array<Map<string, Value>> = irBlocks.map(() => new Map());
202
219
  const sealed: boolean[] = irBlocks.map(() => false);
@@ -262,9 +279,9 @@ export function makeSsaBuilder(
262
279
  //
263
280
  // AND IT ASKS `declaredLocals`, NOT `ownedLocals`, which is a different question with a
264
281
  // different answer under agbcc — the outgoing stack-argument area is storage the function owns
265
- // and does not declare. The two ranges are equal under Thumb today only because `prefixStored`
266
- // declines every function with an outgoing area; see `declaredLocals`' own doc for the
267
- // measurement and for what lifting that decline obliges.
282
+ // and does not declare. Under Thumb the declared range therefore starts where the largest
283
+ // LICENSED outgoing block ends, and the two coincide only when that block is empty; see
284
+ // `declaredLocals`' own doc for what earns the narrowing and for the frames still refused.
268
285
  if (!inRange(off, model().declaredLocals)) {
269
286
  return;
270
287
  }
@@ -301,6 +318,44 @@ export function makeSsaBuilder(
301
318
  prev.add(off);
302
319
  }
303
320
  };
321
+ // PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`), measured HERE for the same reason the clobber
322
+ // set, the write order and the slot homes are: a slot write is a `writeVar` and a slot read is a
323
+ // `readVar` in BOTH slot-modelling frontends, so one rule covers them and no frontend can forget
324
+ // to route a store past a wrapper. The two directions are NOT symmetric: raise/paramwidth.ts reads
325
+ // an absent observation as proof the declaration was wide, so a missed one costs a narrowing while
326
+ // a spurious one retypes a parameter the machine never declared narrow.
327
+ //
328
+ // RAW, in two ways that matter. Both halves record VALUES rather than verdicts, because the entry
329
+ // parameters are not final until `pruneDeadParams` has run in `finish()`, which is where the map
330
+ // is sealed. And the slot half asks no frame partition, unlike `noteSlotHome` directly above: see
331
+ // `ParamEvidence` for why "stored and never read back" needs none.
332
+ //
333
+ // A READ IS A `readVar`, AND `hasReachingDef` IS NOT ONE. The guard in `frontend/mips.ts`'s
334
+ // `emitLoad` asks whether a slot was ever stored before it reads it; asking is not reading, and
335
+ // the `readVar` on the line after it is.
336
+ const slotWrites = new Map<string, Set<Value>>();
337
+ const slotReads = new Set<string>();
338
+ const noteSlotTraffic = (key: string, v: Value | null) => {
339
+ if (slotKeyOffset(key) === null) {
340
+ return;
341
+ }
342
+ if (v === null) {
343
+ slotReads.add(key);
344
+ return;
345
+ }
346
+ const at = slotWrites.get(key);
347
+ if (at === undefined) {
348
+ slotWrites.set(key, new Set([v]));
349
+ } else {
350
+ at.add(v);
351
+ }
352
+ };
353
+ // THE FIRST entry-block write to each REGISTER, for `selfRedefined`. First and not any: a later
354
+ // write is the allocator reusing a register the argument is done with, which says nothing about
355
+ // the argument. Entry block only, because a widening the machine performs on the argument's own
356
+ // register is prologue work — a write in a later block has body code before it.
357
+ const firstEntryWrite = new Map<string, Value>();
358
+
304
359
  const forgetOrder = (p: Value) => {
305
360
  for (const m of writeOrder.lastWrite.values()) {
306
361
  m.delete(p);
@@ -309,11 +364,18 @@ export function makeSsaBuilder(
309
364
 
310
365
  const writeVar = (reg: string, b: number, v: Value) => {
311
366
  noteSlotHome(reg, v);
367
+ noteSlotTraffic(reg, v);
368
+ if (b === 0 && !firstEntryWrite.has(reg)) {
369
+ firstEntryWrite.set(reg, v);
370
+ }
312
371
  writtenSinceCall[b].add(reg);
313
372
  defs[b].set(reg, v);
314
373
  lastWriteAt[b].set(reg, writeCount[b]++);
315
374
  };
316
- const readVar = (reg: string, b: number): Value => defs[b].get(reg) ?? readRecursive(reg, b);
375
+ const readVar = (reg: string, b: number): Value => {
376
+ noteSlotTraffic(reg, null);
377
+ return defs[b].get(reg) ?? readRecursive(reg, b);
378
+ };
317
379
 
318
380
  const newPhi = (reg: string, b: number): Value => {
319
381
  const phi = mkValue(T.unk(32));
@@ -454,15 +516,21 @@ export function makeSsaBuilder(
454
516
  obligedParams[b].set(key, p);
455
517
  };
456
518
 
457
- const hasReachingDef = (reg: string, b: number, seen = new Set<number>()): boolean => {
458
- if (defs[b].has(reg)) {
459
- return true;
460
- }
461
- if (seen.has(b)) {
462
- return false;
463
- }
464
- seen.add(b);
465
- return preds[b].length > 0 && preds[b].some((p) => hasReachingDef(reg, p, seen));
519
+ const hasReachingDef = (reg: string, b: number, accept: (v: Value) => boolean = () => true): boolean => {
520
+ const walk = (at: number, seen: Set<number>): boolean => {
521
+ const own = defs[at].get(reg);
522
+ // A def `accept` rejects does not fall through to the predecessors: it is still a def, and
523
+ // nothing older than it reaches past it.
524
+ if (own !== undefined) {
525
+ return accept(own);
526
+ }
527
+ if (seen.has(at)) {
528
+ return false;
529
+ }
530
+ seen.add(at);
531
+ return preds[at].length > 0 && preds[at].some((p) => walk(p, seen));
532
+ };
533
+ return walk(b, new Set<number>());
466
534
  };
467
535
 
468
536
  return {
@@ -532,6 +600,35 @@ export function makeSsaBuilder(
532
600
  phiKey.delete(p);
533
601
  forgetOrder(p);
534
602
  });
603
+ // SEAL THE PARAMETER EVIDENCE (ir/core.ts `ParamEvidence`). Here and not at the store or the
604
+ // write, because `pruneDeadParams` above is the last thing that can retire an entry
605
+ // parameter, and an observation about a value no longer in the signature is one the reader
606
+ // would never find. Every surviving entry parameter gets an entry — EMPTY-but-present on a
607
+ // function that shows neither, because this builder measured it and found nothing.
608
+ const evidence = new Map<Value, ParamObservation>();
609
+ const deadHomed = new Set<Value>();
610
+ for (const [key, stored] of slotWrites) {
611
+ if (slotReads.has(key)) {
612
+ continue; // the slot is read back: the store is live and says nothing about a declaration
613
+ }
614
+ for (const v of stored) {
615
+ deadHomed.add(v);
616
+ }
617
+ }
618
+ const defs0 = defOpMap(fn);
619
+ for (const p of irBlocks[0].params) {
620
+ const reg = paramReg.get(p);
621
+ const first = reg === undefined ? undefined : firstEntryWrite.get(reg);
622
+ // The redefining op must READ the parameter — that is what makes the write the argument's
623
+ // own value moving, rather than an unrelated value landing in a register it had finished
624
+ // with. Direct, not transitive: a chain through body code is body code.
625
+ const redef = first === undefined ? undefined : defs0.get(first);
626
+ evidence.set(p, {
627
+ deadHome: deadHomed.has(p),
628
+ selfRedefined: redef !== undefined && redef.operands.includes(p),
629
+ });
630
+ }
631
+ fn.paramEvidence = evidence;
535
632
  // A STACK SLOT MAY NEVER LEAVE AS AN ENTRY PARAMETER. A slot is memory the function itself
536
633
  // allocated, so its value can only come from a store the function made; arriving as a live-in
537
634
  // instead means it was read on a path that never stored it, and the signature has grown an