@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
@@ -239,7 +239,7 @@
239
239
  //
240
240
  // This does NOT recover the boolean-VALUE form `return a && b` — that is shortcircuit.ts's job
241
241
  // (the `logic_and`/`logic_or` connective plus agbcc's `(-b|b)>>31` = `b!=0` normalisation).
242
- import { Block, Fn, Op, Value, defOpMap, isBodyless, mkOp, predecessors, terminator } from '../ir/core';
242
+ import { Block, Fn, Op, Value, defOpMap, fallThroughOf, isBodyless, mkOp, predecessors, terminator } from '../ir/core';
243
243
  import { NEGATED_ICMP } from '../ir/opcodes';
244
244
  import { simplifyTrivialPhis } from '../ir/simplify';
245
245
  import { type Gate, firstRejection } from '../l3/gates';
@@ -645,9 +645,13 @@ export function sinkReturns(
645
645
  continue;
646
646
  }
647
647
  for (const p of brPreds) {
648
- const args = p.ops[p.ops.length - 1].successors[0].args;
649
- const sunk = ret.operands.map((o) => args[m.params.indexOf(o)]);
650
- p.ops[p.ops.length - 1] = mkOp('ret', { operands: sunk });
648
+ const t = p.ops[p.ops.length - 1];
649
+ const sunk = ret.operands.map((o) => t.successors[0].args[m.params.indexOf(o)]);
650
+ // The `br` being replaced carries whether the machine BRANCHED to this epilogue or fell into
651
+ // it, which is what `structure/retspell.ts` reads. Sinking puts the return ON that edge, so
652
+ // the edge's fact travels with it; ask the new return block's in-edges instead and they answer
653
+ // how control reached the statements above the return, not how it reached the epilogue.
654
+ p.ops[p.ops.length - 1] = mkOp('ret', { operands: sunk, attrs: fallThroughOf(t) });
651
655
  changed = true;
652
656
  }
653
657
  // If no predecessor still branches to m (all were unconditional), it is unreachable — drop it.
@@ -39,7 +39,16 @@
39
39
  // NO GATE: which copy stays shared is the follow's question, and whether a sunk function is worth
40
40
  // a candidate is rank.ts's, asked with the follow's own predicate (`hasDivergentSharedRet`) rather
41
41
  // than a copy of it here.
42
- import { type Block, type Fn, type Value, mkOp, predecessors, reachableBlocks, terminator } from '../ir/core';
42
+ import {
43
+ type Block,
44
+ type Fn,
45
+ type Value,
46
+ fallThroughOf,
47
+ mkOp,
48
+ predecessors,
49
+ reachableBlocks,
50
+ terminator,
51
+ } from '../ir/core';
43
52
  import { simplifyTrivialPhis } from '../ir/simplify';
44
53
 
45
54
  /** One edge that supplies the tail its arguments. `resolve` sends a value the tail reads to the
@@ -110,7 +119,13 @@ export function sinkStoreTails(fn: Fn): boolean {
110
119
  const body = tail.ops.slice(0, -1);
111
120
  for (const src of sources) {
112
121
  const copies = body.map((o) => mkOp('store', { operands: o.operands.map(src.resolve), attrs: { ...o.attrs } }));
113
- src.from.ops.splice(src.from.ops.length - 1, 1, ...copies, mkOp('ret'));
122
+ // Carry the replaced edge's fall-through fact onto the duplicated `ret`, as `raise/retsink.ts`
123
+ // does. Only for a source that branches straight to the tail: seen THROUGH a forwarder the path
124
+ // is several edges and no one of them answers on its own, so the copy is left unmarked and
125
+ // read as a branch — the side that can never delete a return the object needs.
126
+ const t = src.from.ops[src.from.ops.length - 1];
127
+ const attrs = t.successors[0]?.block === tail ? fallThroughOf(t) : {};
128
+ src.from.ops.splice(src.from.ops.length - 1, 1, ...copies, mkOp('ret', { attrs }));
114
129
  }
115
130
  // By REACHABILITY, not predecessor count: the tail (unless a conditional edge keeps it) and
116
131
  // every forwarder on the way are left unreachable, however long the chain, and a forwarder's
@@ -118,18 +118,16 @@ const DECL_RESERVED = new Set<string>([
118
118
 
119
119
  /** The emitter's own NAME GRAMMAR for storage it invents: parameters `a0, a1, …` (structure.ts
120
120
  * names them positionally, so no rename can move one) and coalesced/temp locals `v0…`/`t0…`
121
- * (structure.ts's `localNames` accepts exactly `/^[vt]\d+$/`). A pool or map symbol with one of
122
- * these names cannot be declared beside the C that spells it — see the refusal in `refsOf`, which
123
- * is the one that kills the spelling rather than the line.
121
+ * (structure.ts's `localNames` accepts exactly `/^[vt]\d+$/`). A global the target names with one
122
+ * of these names cannot be declared beside the C that spells it — see the refusal in `refsOf`,
123
+ * which is the one that kills the spelling rather than the line.
124
124
  *
125
125
  * Checked as a grammar IN ADDITION to the tree's own bound names, because the collision that
126
126
  * matters is the one the tree cannot show: `localNames` DROPS a local whose name a written
127
127
  * global already claims, so where the global is stored `tree.locals` is silent about it. The
128
- * price is refusing a real global that happens to be named `v3` in a function that never mints
129
- * one measured at zero: over the benchmark corpus, in each row's own symbol world, no candidate
130
- * references such a name, and no vendored symbol map on that sweep's checkouts contained one. The
131
- * map's own name total is deliberately not quoted — it is a property of the checkouts the sweep
132
- * ran over rather than of this repo, so nothing here can re-derive it. */
128
+ * price is refusing a global the target names `v3` in a function that never mints one. A map
129
+ * global the target does NOT name pays nothing: Pikmin's map holds `v0` and `v1`, the only
130
+ * grammar names in any vendored map, and no Pikmin row references either. */
133
131
  const EMITTER_NAME = /^[avt]\d+$/;
134
132
 
135
133
  /** Why a name the candidate's tree references got NO declaration. Reported rather than silently
@@ -201,10 +199,13 @@ export function makeRefCollector(ctx: {
201
199
  accessFacts: ReadonlyMap<string, { width: number; signed: boolean }>;
202
200
  /** the project map alone — a name it does NOT know makes the ref a `synthesized` hypothesis */
203
201
  mapSymbols: ReadonlyMap<string, SymbolInfo> | undefined;
202
+ /** the globals the TARGET names — the pool/reloc names and the shapes the asm evidences, before
203
+ * the map is unioned in */
204
+ targetNames: ReadonlySet<string>;
204
205
  /** reports a refusal at most once per (name, reason); the caller owns the dedup */
205
206
  refuse: (name: string, reason: RefusedDeclarationReason) => void;
206
207
  }): (tree: SFn) => { symbolRefs?: SymbolRef[] } {
207
- const { declSymbols, accessFacts, mapSymbols, refuse } = ctx;
208
+ const { declSymbols, accessFacts, mapSymbols, targetNames, refuse } = ctx;
208
209
  return (tree: SFn): { symbolRefs?: SymbolRef[] } => {
209
210
  // The names THIS tree binds. Computed per tree because the emitter mints local names per
210
211
  // spelling — but the test below is NOT `bound` alone, and the difference is a wrong answer.
@@ -226,6 +227,13 @@ export function makeRefCollector(ctx: {
226
227
  // project trades nothing for — so the spelling dies here and `respellTree`'s catch reports it.
227
228
  // If every spelling of every tree dies, the row declines LOUDLY naming the collision.
228
229
  if (bound.has(r.name) || EMITTER_NAME.test(r.name)) {
230
+ // …but only a global the TARGET names can collide. A name that only the MAP supplies is a
231
+ // project global the function never touches, and the tree's `v0` is its own local: Pikmin
232
+ // holds `.sdata` statics named `v0` and `v1`, and refusing them killed every spelling of
233
+ // every function whose emitted C mints those locals.
234
+ if (!targetNames.has(r.name)) {
235
+ return [];
236
+ }
229
237
  refuse(r.name, 'emitter-name');
230
238
  throw new Error(
231
239
  `cannot spell '${tree.name}': the target names a global '${r.name}', which is a name the ` +
package/src/rank.ts CHANGED
@@ -34,6 +34,7 @@ import { T } from './ir/types';
34
34
  import { verify } from './ir/verify';
35
35
  import { advancedBases } from './l3/advance';
36
36
  import { materializeArgBases } from './l3/argbase';
37
+ import { argCopyCandidates } from './l3/argcopy';
37
38
  import type { LanguageBackend, SFn } from './l3/ast';
38
39
  import { type BaseKey, admittedBases, hoistBaseLocals } from './l3/basecse';
39
40
  import { armDisjointCandidates, coalesceCandidates } from './l3/coalesce';
@@ -846,14 +847,17 @@ export function enumerateCandidates(
846
847
  // spelling needs one, and the declaration a candidate spelling `gTbl[i]` cannot compile
847
848
  // without), and the project map, which knows more than either.
848
849
  const mapSymbols = baseOpts.symbols;
849
- const declSymbols = new Map<string, SymbolInfo>([
850
- ...bareGlobalSymbols(sharedLift),
851
- ...sharedLiftShapes,
852
- ...(mapSymbols ?? []),
853
- ]);
850
+ const targetSymbols = new Map<string, SymbolInfo>([...bareGlobalSymbols(sharedLift), ...sharedLiftShapes]);
851
+ const declSymbols = new Map<string, SymbolInfo>([...targetSymbols, ...(mapSymbols ?? [])]);
854
852
  // The four per-enumeration constants named at the seam rather than captured across 60 lines of
855
853
  // closure (rank-declare.ts states why they belong on one object).
856
- const refsOf = makeRefCollector({ declSymbols, accessFacts, mapSymbols, refuse });
854
+ const refsOf = makeRefCollector({
855
+ declSymbols,
856
+ accessFacts,
857
+ mapSymbols,
858
+ targetNames: new Set(targetSymbols.keys()),
859
+ refuse,
860
+ });
857
861
  // THE RESPELL SET, as a function whose PARAMETER LIST is the invariant the tree skip below
858
862
  // rests on: every source here is a pure function of the structured tree and this call's own
859
863
  // constants, so a tree an earlier structure setting already produced can only re-emit sources
@@ -1254,6 +1258,25 @@ export function enumerateCandidates(
1254
1258
  const v = regionVolatile();
1255
1259
  return v ? volStore(v) : null;
1256
1260
  });
1261
+ /** The results of a multi-result variation, under the same guard `respell` gives a
1262
+ * single-result one: a throw costs this variation and nothing else. Run at statement level
1263
+ * instead, a throwing source takes the WHOLE row with it — `reportThrow` never runs, so the
1264
+ * DEFAULT candidate is lost too and the row goes `noncompile`. A multi-result source is a
1265
+ * `for`-loop subject rather than a thunk, which is why the guard is a helper and not a try
1266
+ * inside `respell`. */
1267
+ const candidatesOf = (
1268
+ variations: readonly Variation[],
1269
+ from: () => SFn | null | undefined,
1270
+ resultsOf: (s: SFn) => { merged: string; sfn: SFn }[],
1271
+ ): { merged: string; sfn: SFn }[] => {
1272
+ try {
1273
+ const base = from();
1274
+ return base ? resultsOf(base) : [];
1275
+ } catch (e) {
1276
+ reportThrow([...preRespellVariations, ...variations], e);
1277
+ return [];
1278
+ }
1279
+ };
1257
1280
  /** One candidate per result of a multi-result variation, `name` applied to the result's own
1258
1281
  * subject after `prefix`. */
1259
1282
  const respellEach = (
@@ -1265,19 +1288,8 @@ export function enumerateCandidates(
1265
1288
  if (!offeredOn(target, [...prefix, name])) {
1266
1289
  return;
1267
1290
  }
1268
- let results: { variations: readonly Variation[]; sfn: SFn }[] = [];
1269
- try {
1270
- const base = from();
1271
- results = (base ? resultsOf(base) : []).map((c) => ({
1272
- variations: [...prefix, withSubject(name, c.merged)],
1273
- sfn: c.sfn,
1274
- }));
1275
- } catch (e) {
1276
- reportThrow([...preRespellVariations, ...prefix, name], e);
1277
- return;
1278
- }
1279
- for (const c of results) {
1280
- respell(c.variations, () => c.sfn);
1291
+ for (const c of candidatesOf([...prefix, name], from, resultsOf)) {
1292
+ respell([...prefix, withSubject(name, c.merged)], () => c.sfn);
1281
1293
  }
1282
1294
  };
1283
1295
  respellEach(['scopebase'], 'coalesce', () => hoistScopedBases(sfn));
@@ -1630,6 +1642,20 @@ export function enumerateCandidates(
1630
1642
  respellEach(variations, 'coalesce', hoist, armDisjointCandidates);
1631
1643
  respellEach([...variations, 'volatile'], 'coalesce', volatiles, armDisjointCandidates);
1632
1644
  }
1645
+ // `/argcopy` — a pointer parameter copied into a local for ONE region (l3/argcopy.ts): the
1646
+ // copy frees the parameter's incoming register for that region, and every legal region is its
1647
+ // own candidate.
1648
+ //
1649
+ // PAIRED WITH `/coalesce`, because a freed register is worth nothing until something takes it:
1650
+ // on pokeemerald:SetMauvilleOldManLanguage the copy frees r5 and the counter shared between two
1651
+ // switch arms is what moves into it, and neither spelling alone reaches the bytes.
1652
+ if (offeredOn(target, ['argcopy'])) {
1653
+ for (const c of candidatesOf(['argcopy'], () => sfn, argCopyCandidates)) {
1654
+ const copied = withSubject('argcopy', c.merged);
1655
+ respell([copied], () => c.sfn);
1656
+ respellEach([copied], 'coalesce', () => c.sfn);
1657
+ }
1658
+ }
1633
1659
  // `/parkfirst` — incoming-argument parks lead the entry prefix (l3/parkfirst.ts): the
1634
1660
  // park's `mov` lifts to pure SSA aliasing, so its position is unrecoverable and the
1635
1661
  // default order is emission's. Both orders are emitted; the differ referees.
@@ -0,0 +1,95 @@
1
+ // Which `return;` statements the SOURCE wrote, read off the assembly.
2
+ //
3
+ // A void `return;` is a control transfer to the epilogue, and a compiler spells one as an
4
+ // unconditional `b <epilogue>`. So where the epilogue is a block of its OWN — nothing in it but the
5
+ // `ret` — its in-edges are the ways the body ends, and only one kind of arrival is a statement:
6
+ //
7
+ // - an unconditional branch instruction into it → a `return;` the source wrote.
8
+ // - FALLING into it → the body simply running out, which spells
9
+ // nothing.
10
+ // - a conditional branch's own edge → nothing either. Unoptimised code tests into a
11
+ // BODY label and never into the epilogue, so a
12
+ // `bxx` landing there is jump-optimisation having
13
+ // rerouted a branch the source did not write — and
14
+ // by then both spellings are one object anyway.
15
+ //
16
+ // The statement being decided is the one at the END of the body, so a fall-through in-edge normally
17
+ // settles it: that edge is the body running out, and it wrote no `return;`. A branch in-edge
18
+ // alongside it is a `return;`, just not this one — it ends an arm, and `l3/tailret.ts` may delete a
19
+ // return only in TAIL position, so an arm the function continues past keeps its own. An arm that IS
20
+ // in tail position is one the compiler must branch over regardless (the block laid out before the
21
+ // epilogue is the one that falls in, and only one block can be), so its `return;` and its `}`
22
+ // compile to the same instruction. That is what makes the answer safe per BLOCK.
23
+ //
24
+ // ONE KIND OF BRANCH IN-EDGE OVERRULES THE FALLING ONE: a branch out of an EMPTY block. That arm
25
+ // holds nothing but the `return;`, so dropping it does not shorten a statement list, it flips the
26
+ // branch sense — and the two senses are two different objects. Measured on `if (*p & 1) return; …`
27
+ // at agbcc -O0: the sense that writes the `return;` is 40 bytes, `beq` over a `b <epilogue>`; the
28
+ // flipped one is 36, `bne` over the body with no `b` at all. So that `b` is in the object because
29
+ // the source wrote a `return;`, and the falling edge beside it licenses nothing.
30
+ //
31
+ // With no fall-through in-edge, a branch in-edge is the only evidence there is, and it says the
32
+ // source wrote a `return;` — keep it. With neither, nothing reached the epilogue by a written
33
+ // transfer at all and there is nothing to spell. NO in-edge at all says the same thing without
34
+ // needing the epilogue to be a block of its own: a body that never branched anywhere ended by
35
+ // running out. That is the single-block function, which on an OBJECT has no epilogue label to
36
+ // split it — the shape the `.s` path never shows.
37
+ //
38
+ // A `ret` SUNK onto one edge (`raise/retsink.ts`, `raise/tailsink.ts`) carries that edge's own
39
+ // fall-through fact and answers for itself, which beats anything its block's in-edges could say:
40
+ // those are about reaching the statements above the return, not about reaching the epilogue. An
41
+ // epilogue block that ALSO holds statements and did not come from sinking is not asked at all, for
42
+ // the same reason.
43
+ //
44
+ // NOT DECIDED HERE: a `while` whose only exit branches straight to the epilogue. That `b` is the
45
+ // loop's `}`, and the same instruction to the same address is what a trailing `return;` compiles to
46
+ // — the two objects differ only by an empty forwarder block between them, which is a fact about
47
+ // branch chains rather than about return spelling. It is kept, which is the side that can never
48
+ // delete a return the object needs.
49
+ //
50
+ // The in-edges the reading is about are the ways the body ENDS, so only LIVE ones count. The thumb
51
+ // frontend hands over unreachable blocks on purpose, and one laid out just before the epilogue would
52
+ // otherwise contribute a fall-through no execution takes — deleting the `return;` every real edge
53
+ // branched in with.
54
+ //
55
+ // This decides SPELLING only; `l3/tailret.ts` owns whether a marked return is safe to delete.
56
+ import type { Block, Fn } from '../ir/core';
57
+ import { predecessors, reachableBlocks } from '../ir/core';
58
+
59
+ /** The arrival an in-edge stands for: a `return;` the source wrote, or the body running out. */
60
+ const isWrittenBranch = (p: Block): boolean => {
61
+ const term = p.ops[p.ops.length - 1];
62
+ return term.opcode === 'br' && term.attrs.fallthrough !== true;
63
+ };
64
+ const isFallThrough = (p: Block): boolean => {
65
+ const term = p.ops[p.ops.length - 1];
66
+ return term.opcode === 'br' && term.attrs.fallthrough === true;
67
+ };
68
+ /** A branch out of an EMPTY block is an arm with nothing else in it: the `return;` it stands for is
69
+ * the only statement that arm would hold, so it has nowhere else to live and is never dropped. */
70
+ const isBareBranch = (p: Block): boolean => p.ops.length === 1 && isWrittenBranch(p);
71
+
72
+ /** The blocks whose `ret` the assembly shows no `return;` for. */
73
+ export function unspelledEpilogues(fn: Fn): Set<Block> {
74
+ const preds = predecessors(fn);
75
+ const live = reachableBlocks(fn);
76
+ const out = new Set<Block>();
77
+ for (const b of fn.blocks) {
78
+ const term = b.ops[b.ops.length - 1];
79
+ if (term?.opcode !== 'ret') {
80
+ continue;
81
+ }
82
+ const inEdges = (preds.get(b) ?? []).filter((p) => live.has(p));
83
+ // Nothing arrived here from anywhere: the body never left its one block, so it transferred
84
+ // control to no epilogue and spelled no `return;`.
85
+ if (inEdges.length === 0) {
86
+ out.add(b);
87
+ continue;
88
+ }
89
+ const fellIn = inEdges.some(isFallThrough) && !inEdges.some(isBareBranch);
90
+ if (term.attrs.fallthrough === true || (b.ops.length === 1 && (fellIn || !inEdges.some(isWrittenBranch)))) {
91
+ out.add(b);
92
+ }
93
+ }
94
+ return out;
95
+ }
@@ -93,6 +93,7 @@ import {
93
93
  import { makeLoopHazards, sunkCopyOverDroppedUndef, updateWriteSet } from './hazards';
94
94
  import { type NaturalLoop, analyzeLoops } from './loops';
95
95
  import { type NameMerge, coalesceNames } from './namecoalesce';
96
+ import { unspelledEpilogues } from './retspell';
96
97
  import { type ArmExit, makeSwitchRecovery } from './switch-recover';
97
98
 
98
99
  // Lower a constant-offset memory access to its lvalue/rvalue Expr. If the base was recovered as a
@@ -446,7 +447,7 @@ function spellablePointee(
446
447
  * materialised — and where the displacement carries it the cast spelling stands.
447
448
  *
448
449
  * THAT CHANNEL DOES NOT SAY THE SOURCE NAMED THE MEMBER: a HOISTED BASE LOCAL materialises the
449
- * same constant. Compiled at `TOOLCHAIN.agbccFlags` against `u8 unk8[6][8]`, all three of
450
+ * same constant. Compiled at agbcc's canonical flags against `u8 unk8[6][8]`, all three of
450
451
  * `gBlob->unk8[0][i]`, `u8 *p = (u8 *)gBlob->unk8; p[i]` and `u8 *p = (u8 *)gBlob + 8; p[i]` emit
451
452
  * the identical `add r1, #0x8` · `add r1, r1, r0` · `ldrb r0, [r1]`, while `*((u8 *)gBlob + 8 + i)`
452
453
  * and `((u8 *)gBlob + i)[8]` take the displacement — and the base-local form is a spelling three
@@ -1406,7 +1407,7 @@ export interface StructureOptions {
1406
1407
  // and a `switch` over the same values produce the SAME candidate and the differ never sees the
1407
1408
  // ladder — there is nothing in the fan for it to prefer.
1408
1409
  //
1409
- // The two spellings are different objects. Compiled at TOOLCHAIN.agbccFlags the same two-case
1410
+ // The two spellings are different objects. Compiled at agbcc's canonical flags the same two-case
1410
1411
  // body is 20 bytes either way (0x14, ten Thumb instructions — the pair is committed as
1411
1412
  // `corpus/agbcc-sw{frontload,ladder}.s`) and disagrees instruction for instruction: the `switch` emits
1412
1413
  // `cmp #0x1e; beq` then `cmp #0x64; bne` — both tests ahead of both bodies, and sorted ASCENDING,
@@ -4394,6 +4395,10 @@ export function structure(fn: Fn, opts: StructureOptions = {}, hooks: StructureH
4394
4395
  // Branch-sense sites, numbered as the walk below first reaches them (`branchSenseFlipSites`).
4395
4396
  const senseOrdinal = new Map<number, number>();
4396
4397
 
4398
+ // Which `return;` statements the SOURCE wrote — the reading, and why it is decidable, live in
4399
+ // `structure/retspell.ts`. `l3/tailret.ts` owns whether a marked return is safe to delete.
4400
+ const unspelledRets = unspelledEpilogues(fn);
4401
+
4397
4402
  const structureRegion = (b: Block, stop: Block | null): Stmt[] => {
4398
4403
  if (b === stop) {
4399
4404
  return [];
@@ -4456,7 +4461,11 @@ export function structure(fn: Fn, opts: StructureOptions = {}, hooks: StructureH
4456
4461
  const term = b.ops[b.ops.length - 1];
4457
4462
  if (term.opcode === 'ret') {
4458
4463
  // A void function's `bx lr` leaves whatever in r0; suppress that phantom return value.
4459
- out.push({ k: 'return', value: returnsVoid || !term.operands.length ? undefined : expr(term.operands[0]) });
4464
+ const value = returnsVoid || !term.operands.length ? undefined : expr(term.operands[0]);
4465
+ // The `value === undefined` half is a GUARD: `l3/tailret.ts` re-checks it before deleting
4466
+ // anything, so marking a value-carrying return would change no output. The mark claims the asm
4467
+ // shows no `return;` STATEMENT here, which is a claim about a VOID return only.
4468
+ out.push({ k: 'return', value, ...(value === undefined && unspelledRets.has(b) ? { unspelled: true } : {}) });
4460
4469
  return out;
4461
4470
  }
4462
4471
  if (term.opcode === 'br') {
@@ -900,7 +900,7 @@ export function makeSwitchRecovery(deps: SwitchRecoverDeps): SwitchRecovery {
900
900
  // WHY BLOCK IDENTITY IS THE KEY, and not a body-equality one like `sameBareExit` above. agbcc
901
901
  // MERGES two written-out copies into one block — target.ts's `switchArmsFollowLayout` note
902
902
  // says so from agbcc's own sources, SRCS compiling jump.c — and compiling both directions at
903
- // TOOLCHAIN.agbccFlags says WHERE the merged block lands: at the last copy's position. So
903
+ // agbcc's canonical flags says WHERE the merged block lands: at the last copy's position. So
904
904
  // `case 0: A break; case 1: … case 2: A break;` and the grouped arm placed THERE are one
905
905
  // object (.text md5 555abb1a), while the grouped arm placed at the first value is not
906
906
  // (fe4d7d35). That is why the grouped spelling round-trips rather than merely reading shorter: