@asmlift/core 0.9.0 → 0.11.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 (117) hide show
  1. package/README.md +25 -24
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +149 -0
  4. package/src/backend/c.ts +2 -0
  5. package/src/backend/cfamily.ts +197 -61
  6. package/src/backend/cpp.ts +3 -1
  7. package/src/backend/lines.ts +10 -0
  8. package/src/backend/pascal.ts +47 -16
  9. package/src/cdecl/parse.ts +21 -0
  10. package/src/contracts.ts +246 -36
  11. package/src/declare.ts +69 -20
  12. package/src/frontend/arm-body.ts +155 -0
  13. package/src/frontend/asmdata.ts +9 -2
  14. package/src/frontend/call-plan.ts +1117 -61
  15. package/src/frontend/callee-body.ts +370 -0
  16. package/src/frontend/dead-reads.ts +628 -0
  17. package/src/frontend/device-pins.ts +33 -4
  18. package/src/frontend/emit.ts +4 -3
  19. package/src/frontend/fpu.ts +224 -17
  20. package/src/frontend/frame-objects.ts +1689 -206
  21. package/src/frontend/frontend.ts +84 -3
  22. package/src/frontend/high-half.ts +53 -4
  23. package/src/frontend/jump-table.ts +545 -0
  24. package/src/frontend/local-object.ts +158 -32
  25. package/src/frontend/mips.ts +2296 -217
  26. package/src/frontend/own-struct-return.ts +500 -0
  27. package/src/frontend/ppc.ts +2748 -245
  28. package/src/frontend/register-struct-call.ts +399 -0
  29. package/src/frontend/reloc-symbol.ts +22 -7
  30. package/src/frontend/ssa.ts +1721 -43
  31. package/src/frontend/stackargs.ts +199 -72
  32. package/src/frontend/thumb-entry.ts +340 -0
  33. package/src/frontend/thumb.ts +2400 -325
  34. package/src/ir/alias.ts +65 -2
  35. package/src/ir/bits.ts +1699 -4
  36. package/src/ir/core.ts +254 -9
  37. package/src/ir/discipline.ts +86 -21
  38. package/src/ir/entry.ts +157 -0
  39. package/src/{structure → ir}/loops.ts +6 -2
  40. package/src/ir/opcodes.ts +137 -9
  41. package/src/ir/parse.ts +1 -0
  42. package/src/ir/simplify.ts +61 -10
  43. package/src/ir/types.ts +86 -2
  44. package/src/ir/verify.ts +96 -11
  45. package/src/l3/argbase.ts +2 -0
  46. package/src/l3/ast.ts +582 -18
  47. package/src/l3/basecse.ts +68 -15
  48. package/src/l3/caseendgoto.ts +223 -0
  49. package/src/l3/coalesce.ts +42 -14
  50. package/src/l3/constinit.ts +410 -0
  51. package/src/l3/dce.ts +53 -9
  52. package/src/l3/initfirst.ts +42 -11
  53. package/src/l3/intcell.ts +2 -1
  54. package/src/l3/narrowdecl.ts +77 -36
  55. package/src/l3/pollguard.ts +8 -2
  56. package/src/l3/ptrcell.ts +33 -34
  57. package/src/l3/ptrfield.ts +123 -8
  58. package/src/l3/regspell.ts +11 -4
  59. package/src/l3/reindex.ts +295 -99
  60. package/src/l3/scopebase.ts +2 -0
  61. package/src/l3/storage.ts +98 -1
  62. package/src/l3/symbol-refs.ts +84 -6
  63. package/src/l3/tailmerge.ts +6 -2
  64. package/src/l3/typing.ts +108 -15
  65. package/src/l3/unreduce.ts +6 -5
  66. package/src/l3/volatileptr.ts +17 -54
  67. package/src/mangle.ts +30 -0
  68. package/src/pattern/engine.ts +361 -73
  69. package/src/pipeline.ts +1039 -38
  70. package/src/proto-context.ts +1039 -45
  71. package/src/proto.ts +555 -40
  72. package/src/raise/aggmembers.ts +290 -0
  73. package/src/raise/arrays.ts +5 -1
  74. package/src/raise/const.ts +25 -0
  75. package/src/raise/declared-volatile.ts +207 -31
  76. package/src/raise/divpow2.ts +192 -0
  77. package/src/raise/floathelpers.ts +254 -33
  78. package/src/raise/gvn.ts +3 -3
  79. package/src/raise/latch.ts +162 -6
  80. package/src/raise/memberarrays.ts +10 -4
  81. package/src/raise/offsetnames.ts +422 -36
  82. package/src/raise/pairparams.ts +33 -0
  83. package/src/raise/paramwidth.ts +196 -6
  84. package/src/raise/pre-recovery.ts +107 -21
  85. package/src/raise/pre-structure.ts +73 -0
  86. package/src/raise/recover.ts +12 -5
  87. package/src/raise/reroll.ts +674 -0
  88. package/src/raise/shortcircuit.ts +217 -34
  89. package/src/raise/softdiv.ts +1 -1
  90. package/src/raise/storesign.ts +153 -0
  91. package/src/raise/struct-arrays.ts +30 -11
  92. package/src/raise/structs.ts +396 -33
  93. package/src/raise/truncload.ts +4 -3
  94. package/src/raise/unpre.ts +594 -0
  95. package/src/raise/unthread.ts +400 -0
  96. package/src/rank-declare.ts +118 -12
  97. package/src/rank-variations.ts +186 -35
  98. package/src/rank.ts +536 -68
  99. package/src/runtime-helpers.ts +140 -22
  100. package/src/structure/analysis.ts +217 -8
  101. package/src/structure/arith-ops.ts +23 -0
  102. package/src/structure/cross-jumped-tail.ts +216 -0
  103. package/src/structure/globalaccess.ts +157 -10
  104. package/src/structure/hazards.ts +16 -64
  105. package/src/structure/local-statics.ts +145 -9
  106. package/src/structure/namecoalesce.ts +1 -1
  107. package/src/structure/pointer-spelling.ts +1106 -0
  108. package/src/structure/redundant-test.ts +1 -1
  109. package/src/structure/structure.ts +6192 -1213
  110. package/src/structure/switch-recover.ts +583 -120
  111. package/src/structure/unfolded-branch-constant.ts +90 -0
  112. package/src/symbols.ts +58 -15
  113. package/src/target.ts +1028 -54
  114. package/src/trace.ts +49 -17
  115. package/src/variation-definitions.ts +333 -20
  116. package/src/variation-gates.ts +4 -0
  117. package/src/variation-tokens.ts +68 -3
package/src/pipeline.ts CHANGED
@@ -1,22 +1,36 @@
1
1
  // asmlift — the library entry point. `decompile(name, asm, target)` runs the raising tower and
2
2
  // returns structured results: the source, the per-level IR dumps, and diagnostics.
3
+ import { aggregateSize, ownArgumentWordsOn, returnedAggregate, returnsInMemory } from './aggregate';
3
4
  import { cBackend } from './backend/c';
4
5
  import {
5
6
  ContractError,
6
7
  assertDerefsTyped,
7
8
  assertEffectsPreserved,
9
+ assertFrameUnionsViewed,
8
10
  assertLocalsWritten,
9
11
  assertPostIncrUnshared,
10
12
  assertResolved,
11
13
  assertTypesRecovered,
12
14
  } from './contracts';
13
- import type { AsmData } from './frontend/asmdata';
15
+ import type { AsmData, Reloc } from './frontend/asmdata';
16
+ import { returnedStruct, structLocalType } from './frontend/call-plan';
17
+ import {
18
+ type CalleeBodies,
19
+ type CalleeBodyBound,
20
+ type CalleeStackWords,
21
+ calleeBodyReads,
22
+ } from './frontend/callee-body';
14
23
  import { FrontendUnsupportedError } from './frontend/errors';
24
+ import type { LiftOptions } from './frontend/frontend';
25
+ import { liftOwnRegisterStruct, liftOwnStructReturn } from './frontend/own-struct-return';
26
+ import { liftRegisterStructCalls } from './frontend/register-struct-call';
15
27
  import { frontendFor } from './frontend/registry';
28
+ import { ABSORBED_KEYS, HOMED_UNDEF } from './frontend/ssa';
16
29
  import { type Fn, reachableBlocks } from './ir/core';
30
+ import { deadOps } from './ir/discipline';
17
31
  import { print } from './ir/print';
18
- import { firstTrivialPhi } from './ir/simplify';
19
- import { T } from './ir/types';
32
+ import { firstTrivialPhi, pruneDeadParams } from './ir/simplify';
33
+ import { type IrType, T, intWidth } from './ir/types';
20
34
  import { VerifyError, verify } from './ir/verify';
21
35
  import { LanguageBackend, SFn, gapReasonFor, walkExprs } from './l3/ast';
22
36
  import { BASECSE_GATES, hoistBaseLocals } from './l3/basecse';
@@ -24,16 +38,32 @@ import { eliminateDeadStores } from './l3/dce';
24
38
  import { mergeCommonTails } from './l3/tailmerge';
25
39
  import { dropUnspelledReturns } from './l3/tailret';
26
40
  import { DEFAULT_IDIOM_PATTERNS, RewritePattern, applyPattern, dce, patternApplies } from './pattern/engine';
27
- import { type FnProto, type Prototypes, declaresVoidReturn, prototypesFromSymbols } from './proto';
28
- import { stampDeclaredVolatileUnder } from './raise/declared-volatile';
41
+ import {
42
+ type FnProto,
43
+ type Prototypes,
44
+ declaresAggregateReturn,
45
+ declaresVoidReturn,
46
+ ownReturnWidth,
47
+ prototypesFromSymbols,
48
+ } from './proto';
49
+ import { type ContextGlobals, splitOwnContext } from './proto-context';
50
+ import { type StampBehaviors, stampDeclaredVolatileUnder } from './raise/declared-volatile';
29
51
  import { RaiseUnsupportedError } from './raise/errors';
30
52
  import { assumedShapes, inferGlobalArrays, orderLicensedGlobals } from './raise/globalshape';
31
- import { foldEmptyLatches } from './raise/latch';
32
- import { OFFSET_NAME_PASS } from './raise/offsetnames';
53
+ import { OFFSET_NAME_PASS, type OffsetNameBehaviors } from './raise/offsetnames';
33
54
  import { type PreRecoveryOptions, type PreRecoveryPass, runPreRecovery } from './raise/pre-recovery';
55
+ import { PRE_STRUCTURE_FOLDS, type PreStructureFold } from './raise/pre-structure';
34
56
  import { recoverTypes } from './raise/recover';
35
57
  import { sinkReturns } from './raise/retsink';
36
- import { StructureError, structure } from './structure/structure';
58
+ import { crossJumpedTail } from './structure/cross-jumped-tail';
59
+ import {
60
+ DefaultAcceptsMemo,
61
+ DefaultAcceptsMemoMisuse,
62
+ type JoinPastReturnsSite,
63
+ StructureError,
64
+ structure,
65
+ } from './structure/structure';
66
+ import { unfoldedBranchConstant } from './structure/unfolded-branch-constant';
37
67
  import { type SymbolInfo, type SymbolMap, symbolsByName } from './symbols';
38
68
  import { type TargetDescription, structureOptionsFor } from './target';
39
69
 
@@ -73,8 +103,33 @@ export interface DecompileOptions {
73
103
  * declaration shapes (DWARF types-sidecar). Drives the Thumb numeric-pool promotion and the
74
104
  * byte-sensitive global spellings. Absent ⇒ byte-identical to today. */
75
105
  symbols?: SymbolMap;
106
+ /** what the project's declaration context states about its globals (proto-context.ts
107
+ * `globalsFromContext`): no aggregate is spelled as a bare scalar name, and every access to a
108
+ * volatile object the map does not lay out, or through a pointer to one, is stamped
109
+ * (raise/declared-volatile.ts), so the cast it is read through carries the qualifier. */
110
+ contextGlobals?: ContextGlobals;
111
+ /** the declaration context's own entry for `name` (proto-context.ts `ownContextEntry`), which
112
+ * bounds the argument registers SOFTLY where `prototypes` states no entry for it, and whose
113
+ * struct return, where it declares one, is lifted where the machine proves it (`liftStamped`);
114
+ * absent ⇒ nothing bounds them softly. A `prototypes` entry for `name` marked
115
+ * `fromContext` is read as this, and taken out of the table (proto-context.ts
116
+ * `splitOwnContext`). */
117
+ ownContextProto?: FnProto;
118
+ /** OPTIONAL bodies of callees the input does not define, found elsewhere by the driver (the CLI
119
+ * reads them off `tools.asmlift.elf`): each answers what its callee can read of its arguments
120
+ * where the input holds no label of that name (frontend/callee-body.ts). Absent ⇒ only the
121
+ * input's own bodies answer. */
122
+ calleeBodies?: CalleeBodies;
76
123
  /** gap policy — see `OnGap`. Default "strict". */
77
124
  onGap?: OnGap;
125
+ /** force the return-tail copies (`StructureOptions.copyReturnTails`, rank.ts's `/ret-copy`).
126
+ * Absent ⇒ the shorter of the copies and the join past early returns, ties to the join
127
+ * (`structureReturnTailReading`). */
128
+ copyReturnTails?: boolean;
129
+ /** force the join past early returns wherever the structuring takes it, however long it spells
130
+ * the function (rank.ts's `/ret-join` where the copies are the shorter). Absent ⇒ the shorter,
131
+ * as above. Ignored under `copyReturnTails`. */
132
+ joinPastReturns?: boolean;
78
133
  }
79
134
 
80
135
  export interface DecompileResult {
@@ -120,6 +175,23 @@ export interface DecompileResult {
120
175
  * `stage:offsetnames`), and it is also the only thing in a result that says a walk spelling
121
176
  * stopped being enumerable here. Empty on every run with no symbol map. */
122
177
  walkedNames: string[];
178
+ /** WHERE THE LIFT WENT PAST ITS CONTEXT DECLARATION (`liftStamped`'s `[own-arity]` notes): each
179
+ * names the declaration and either the argument registers the body reads past it, kept as
180
+ * parameters, or the refusal that made the lift drop the bound. A note is no gap, but the
181
+ * parameter list printed contradicts the header, which every compiler here rejects as
182
+ * conflicting types. Empty where no context entry was handed over, or where the lift stayed
183
+ * within it. */
184
+ ownArityNotes: string[];
185
+ /** WHICH BODIES HANDED IN (`DecompileOptions.calleeBodies`) THE LIFT ASKED, and what each answered
186
+ * (frontend/callee-body.ts): the argument words that callee's body reads at most. A call asks only
187
+ * where a gate questions a register the caller did not set up for it, and only of a callee whose
188
+ * entry states no parameter list (frontend/call-plan.ts `bodyWords`), so a body handed in that no
189
+ * call asked, or that answered nothing, is not here. Empty where no bodies were handed in. */
190
+ calleeBodiesAsked: Record<string, number>;
191
+ /** WHICH CALLEES NOTHING DECLARES THE LIFT SIZED OFF THEIR OWN BODIES, with the stack words each was
192
+ * laid out at past the argument registers (frontend/callee-body.ts `CalleeStackWords`): a call to
193
+ * one takes every argument register and those words, no guess. Empty where none was. */
194
+ calleeStackSized: Record<string, number>;
123
195
  }
124
196
 
125
197
  export function decompile(
@@ -153,9 +225,24 @@ function runTower(
153
225
  const backend = opts.backend ?? cBackend;
154
226
  // The project's own DWARF signatures fill in what the caller did not state — in practice the
155
227
  // CALLEES (a function still in assembly has none), which is what makes this transferable.
156
- const prototypes = prototypesFromSymbols(opts.symbols, opts.prototypes ?? {});
228
+ const own = splitOwnContext(opts.prototypes, name, opts.ownContextProto);
229
+ const prototypes = prototypesFromSymbols(opts.symbols, own.prototypes ?? {});
157
230
  // (1) lift: ISA frontend (resolved by target) → L1 with block-argument SSA
158
- const fn = liftStamped(name, asm, target, prototypes, opts.asmData, opts.symbols);
231
+ const ownArityNotes: string[] = [];
232
+ const bodiesAsked = new Map<string, number | undefined>();
233
+ const stacksSized = new Map<string, number>();
234
+ const fn = liftStamped(
235
+ name,
236
+ asm,
237
+ target,
238
+ prototypes,
239
+ opts.asmData,
240
+ opts.symbols,
241
+ opts.contextGlobals,
242
+ { calleeBodies: opts.calleeBodies, bodiesAsked, stacksSized },
243
+ own.ownContextProto,
244
+ ownArityNotes,
245
+ );
159
246
  // Every dump carries the write-order record (ir/print.ts `PrintOptions`): it decides the
160
247
  // edge-copy order and the raising folds mutate it, so two dumps compare whole program states.
161
248
  const raw = print(fn, { writeOrder: true });
@@ -178,7 +265,15 @@ function runTower(
178
265
  // `inferredSymbols`, and the ORDER licence only enables a cast spelling that is byte-correct
179
266
  // under any declaration. Measured: with this call moved ABOVE both readings, the three corpus
180
267
  // rows the pass fires on keep their fan size, their winner and their score exactly.
181
- const walkedNames = opts.symbols ? OFFSET_NAME_PASS.run(fn, opts.symbols, target.compilerBehaviors) : [];
268
+ //
269
+ // The pass is also where the lift's names become final, so it DECLINES the function — whether or
270
+ // not it named anything — when two accesses reach one cell under two spellings, or one spelling
271
+ // reaches two of the cells the map places a name at (raise/offsetnames.ts `cellSpelledTwice`,
272
+ // RaiseUnsupportedError). That check is no gate: `bench gates --pass offsetnames` tables the walk
273
+ // refusals only, and a decline from it reads as this stage's.
274
+ const walkedNames = opts.symbols
275
+ ? OFFSET_NAME_PASS.run(fn, opts.symbols, offsetNameBehaviorsFor(target, opts.contextGlobals))
276
+ : [];
182
277
 
183
278
  // (2) idiom fold: apply serializable patterns on the IR (the AI-improvement surface),
184
279
  // gated generically by the Target's capabilities (not an `arch ==` branch).
@@ -193,17 +288,34 @@ function runTower(
193
288
  // (4) structure: IR → neutral AST; boundary contract: no unresolved value leaked (strict), or
194
289
  // every unresolved value spelled as a loud ASMLIFT_ERROR marker (annotate).
195
290
  const mapSymbols = opts.symbols ? symbolsByName(opts.symbols) : undefined;
196
- const sfn = structureChecked(fn, {
197
- ...structureOptionsFor(target, declaresVoidReturn(prototypes[name]), prototypes),
198
- // What the EMITTED LANGUAGE can say is a structuring input wherever two recoveries of one
199
- // shape are behaviourally identical and only one of them is printable (switch fall-through
200
- // vs plain if-nesting): recovery must not mint a tree this backend would refuse.
201
- spellSwitchFallthrough: backend.spellsSwitchFallthrough,
202
- onGap,
203
- ...(mapSymbols ? { symbols: mapSymbols } : {}),
204
- ...(inferredSymbols.size ? { inferredSymbols } : {}),
205
- ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
206
- });
291
+ const { sfn } = structureReturnTailReading(
292
+ fn,
293
+ {
294
+ ...structureOptionsFor(target, declaresVoidReturn(prototypes[name]), prototypes),
295
+ // What the EMITTED LANGUAGE can say is a structuring input wherever two recoveries of one
296
+ // shape are behaviourally identical and only one of them is printable (switch fall-through
297
+ // vs plain if-nesting): recovery must not mint a tree this backend would refuse.
298
+ spellSwitchFallthrough: backend.spellsSwitchFallthrough,
299
+ spellSwitchBreak: backend.spellsSwitchBreak,
300
+ spellEarlyReturns: backend.spellsEarlyReturn,
301
+ ...(opts.copyReturnTails ? { copyReturnTails: true } : {}),
302
+ ...(opts.joinPastReturns ? { joinPastReturns: true } : {}),
303
+ onGap,
304
+ ...(mapSymbols ? { symbols: mapSymbols } : {}),
305
+ ...(opts.contextGlobals ? { contextGlobals: opts.contextGlobals } : {}),
306
+ ...(inferredSymbols.size ? { inferredSymbols } : {}),
307
+ ...(orderLicensed.size ? { orderLicensedGlobals: orderLicensed } : {}),
308
+ },
309
+ (t) => backend.emit(t),
310
+ {},
311
+ // ONE default-accepts memo for this lift, as rank.ts keeps one per lift: the copies the measured
312
+ // default structures first carry `copyReturnTails`, so every run of their ladder asks
313
+ // `assertDefaultAccepts` to structure the default first, and without a memo each rerun pays that
314
+ // whole structuring again. `structure()` does not mutate `fn` (structure-purity.test.ts), so the
315
+ // memo's answer is the one each rerun would compute. Measured on Klonoa's sub_0800D188 (1,783
316
+ // blocks): every one of its copies' reruns structured the whole join ladder again.
317
+ new DefaultAcceptsMemo(fn),
318
+ );
207
319
 
208
320
  // (5) lower + print: neutral AST → target language
209
321
  const source = backend.emit(sfn);
@@ -219,13 +331,139 @@ function runTower(
219
331
  // does not have (raise/globalshape.ts `assumedShapes`).
220
332
  assumedSymbols: assumedShapes(inferredSymbols, sfn, mapSymbols),
221
333
  walkedNames,
334
+ ownArityNotes,
335
+ calleeBodiesAsked: answeredBodies(bodiesAsked, opts.calleeBodies),
336
+ calleeStackSized: Object.fromEntries(stacksSized),
222
337
  };
223
338
  }
224
339
 
340
+ /** The bodies handed in that a lift asked and that answered (`DecompileResult.calleeBodiesAsked`). */
341
+ function answeredBodies(
342
+ asked: ReadonlyMap<string, number | undefined>,
343
+ bodies: CalleeBodies | undefined,
344
+ ): Record<string, number> {
345
+ const out: Record<string, number> = {};
346
+ for (const [callee, words] of asked) {
347
+ if (words !== undefined && bodies !== undefined && Object.hasOwn(bodies, callee)) {
348
+ out[callee] = words;
349
+ }
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /** The `calleeBodiesAsked` and `calleeStackSized` (`DecompileResult`) of one lift under `opts`, for a
355
+ * caller whose result carries none — the ranked path, whose every candidate lifts as this one does.
356
+ * Empty where the lift fails: the run reports that failure itself. */
357
+ export function calleeBodyNotesOf(
358
+ name: string,
359
+ asm: string,
360
+ target: TargetDescription,
361
+ opts: Pick<
362
+ DecompileOptions,
363
+ 'prototypes' | 'asmData' | 'symbols' | 'contextGlobals' | 'ownContextProto' | 'calleeBodies'
364
+ >,
365
+ ): Pick<DecompileResult, 'calleeBodiesAsked' | 'calleeStackSized'> {
366
+ const own = splitOwnContext(opts.prototypes, name, opts.ownContextProto);
367
+ const asked = new Map<string, number | undefined>();
368
+ const stacksSized = new Map<string, number>();
369
+ try {
370
+ liftStamped(
371
+ name,
372
+ asm,
373
+ target,
374
+ prototypesFromSymbols(opts.symbols, own.prototypes ?? {}),
375
+ opts.asmData,
376
+ opts.symbols,
377
+ opts.contextGlobals,
378
+ { calleeBodies: opts.calleeBodies, bodiesAsked: asked, stacksSized },
379
+ own.ownContextProto,
380
+ );
381
+ } catch {
382
+ return { calleeBodiesAsked: {}, calleeStackSized: {} };
383
+ }
384
+ return {
385
+ calleeBodiesAsked: answeredBodies(asked, opts.calleeBodies),
386
+ calleeStackSized: Object.fromEntries(stacksSized),
387
+ };
388
+ }
389
+
390
+ /** The `[own-arity]` notes (`DecompileResult.ownArityNotes`) of one lift under `opts`, for a caller
391
+ * whose result carries none — the ranked path, whose every candidate lifts as this one does. Empty
392
+ * where the lift fails: the run reports that failure itself. */
393
+ export function ownArityNotesOf(
394
+ name: string,
395
+ asm: string,
396
+ target: TargetDescription,
397
+ opts: Pick<
398
+ DecompileOptions,
399
+ 'prototypes' | 'asmData' | 'symbols' | 'contextGlobals' | 'ownContextProto' | 'calleeBodies'
400
+ >,
401
+ ): string[] {
402
+ const own = splitOwnContext(opts.prototypes, name, opts.ownContextProto);
403
+ if (own.ownContextProto === undefined) {
404
+ return [];
405
+ }
406
+ const notes: string[] = [];
407
+ try {
408
+ liftStamped(
409
+ name,
410
+ asm,
411
+ target,
412
+ prototypesFromSymbols(opts.symbols, own.prototypes ?? {}),
413
+ opts.asmData,
414
+ opts.symbols,
415
+ opts.contextGlobals,
416
+ { calleeBodies: opts.calleeBodies },
417
+ own.ownContextProto,
418
+ notes,
419
+ );
420
+ } catch {
421
+ return [];
422
+ }
423
+ return notes;
424
+ }
425
+
426
+ /** What the declared-volatile stamp reads besides the IR and the map: the compiler's fact and the
427
+ * declaration context's globals. Every stamp a driver puts reads the same two. */
428
+ export function stampBehaviorsFor(
429
+ target: TargetDescription,
430
+ contextGlobals: ContextGlobals | undefined,
431
+ ): StampBehaviors {
432
+ return {
433
+ volatileReadsExtendInRegister: target.compilerBehaviors.volatileReadsExtendInRegister,
434
+ contextGlobals,
435
+ };
436
+ }
437
+
438
+ /** What the offset-name pass reads besides the IR and the map: the stamp's behaviours (it re-stamps
439
+ * after naming) and whether the compiler reloads across two spellings of one cell (its one-spelling
440
+ * check, raise/offsetnames.ts `cellSpelledTwice`). */
441
+ export function offsetNameBehaviorsFor(
442
+ target: TargetDescription,
443
+ contextGlobals: ContextGlobals | undefined,
444
+ ): OffsetNameBehaviors {
445
+ return {
446
+ ...stampBehaviorsFor(target, contextGlobals),
447
+ reloadsAcrossSpellings: target.compilerBehaviors.reloadsAcrossSpellings,
448
+ };
449
+ }
450
+
451
+ /** What `liftStamped` is handed besides the frontend's own options: the bodies of callees found
452
+ * outside the input, which only the callee-body readers read (`bodyReadsIn`), and a record of each
453
+ * callee whose bound a call asked, with its answer (`DecompileResult.calleeBodiesAsked`). */
454
+ export type StampedLiftOptions = LiftOptions & {
455
+ calleeBodies?: CalleeBodies;
456
+ bodiesAsked?: Map<string, number | undefined>;
457
+ /** each callee a call was sized at off its body, with its stack words (`DecompileResult.calleeStackSized`) */
458
+ stacksSized?: Map<string, number>;
459
+ };
460
+
225
461
  /** Stage 1 — lift: the target's ISA frontend → L1, verified, with every read of an object
226
- * `symbols` declares volatile stamped `declared` (raise/declared-volatile.ts) before any pass asks
227
- * whether it may be deleted. Every entry path lifts through here, each lift under the map its
228
- * structuring will be handed. */
462
+ * `symbols` or `contextGlobals` declares volatile stamped `declared` (raise/declared-volatile.ts)
463
+ * before any pass asks whether it may be deleted. Every entry path lifts through here, each lift
464
+ * under the map its structuring will be handed, and each bounded by the context's own entry
465
+ * (`ownContext`) the same way (`liftWithinContextBound`); `notes` collects that bound's
466
+ * `[own-arity]` notes. */
229
467
  export function liftStamped(
230
468
  name: string,
231
469
  asm: string,
@@ -233,13 +471,496 @@ export function liftStamped(
233
471
  prototypes: Prototypes,
234
472
  asmData: AsmData | undefined,
235
473
  symbols: SymbolMap | undefined,
474
+ contextGlobals: ContextGlobals | undefined,
475
+ stampedOptions?: StampedLiftOptions,
476
+ ownContext?: FnProto,
477
+ notes?: string[],
236
478
  ): Fn {
237
- const fn = frontendFor(target).lift(name, asm, target, prototypes, asmData, symbols);
479
+ const { calleeBodies, bodiesAsked, stacksSized, ...liftOptions } = stampedOptions ?? {};
480
+ // Its callers split the table already; split again here, where every lift passes, so that no
481
+ // frontend is handed a marked context entry to read as a stated, hard bound.
482
+ const split = splitOwnContext(prototypes, name, ownContext);
483
+ const table = split.prototypes ?? {};
484
+ const own = Object.hasOwn(table, name) ? table[name] : undefined;
485
+ const ownReturn = ownStructReturnThroughMemory(name, target, own, split.ownContextProto, notes);
486
+ // a refusal about the return's width that the declared struct explains waits for the proof
487
+ let deferred: ((unproved: string) => never) | undefined;
488
+ const bytes = ownReturn?.register === true ? undefined : ownReturn?.type.size;
489
+ // what each callee whose body is in this input, or handed in, reads of its arguments, lifted from
490
+ // that body as a call to it is first lowered (frontend/callee-body.ts); the callee's own lift reads
491
+ // no bodies, and, off the same lift, what each one loads of its incoming stack words
492
+ const read =
493
+ liftOptions.calleeBodyBound === undefined || liftOptions.calleeStackWords === undefined
494
+ ? bodyReadsIn(name, asm, target, prototypes, ownContext, table, asmData, symbols, contextGlobals, calleeBodies)
495
+ : undefined;
496
+ const bound = liftOptions.calleeBodyBound ?? read!.bound;
497
+ const calleeBodyBound: CalleeBodyBound =
498
+ bodiesAsked === undefined
499
+ ? bound
500
+ : (callee) => {
501
+ const words = bound(callee);
502
+ bodiesAsked.set(callee, words);
503
+ return words;
504
+ };
505
+ const options = {
506
+ ...liftOptions,
507
+ calleeBodyBound,
508
+ calleeStackWords: liftOptions.calleeStackWords ?? read!.stackWords,
509
+ ...(stacksSized === undefined
510
+ ? {}
511
+ : { stackSized: (callee: string, words: number) => stacksSized.set(callee, words) }),
512
+ };
513
+ const fn = liftWithinContextBound(
514
+ name,
515
+ asm,
516
+ target,
517
+ table,
518
+ asmData,
519
+ symbols,
520
+ contextGlobals,
521
+ bytes === undefined
522
+ ? options
523
+ : { ...options, ownMemoryReturn: { bytes, defer: (refuse) => void (deferred ??= refuse) } },
524
+ split.ownContextProto,
525
+ notes,
526
+ );
527
+ // a struct a callee returns in a register is the local the call assigns, or the function's own
528
+ // return where every return hands that struct back whole
529
+ const calls = liftRegisterStructCalls(
530
+ fn,
531
+ table,
532
+ target,
533
+ own ?? split.ownContextProto,
534
+ ownReturn?.register === true ? ownReturn.type : undefined,
535
+ );
238
536
  verify(fn);
239
- stampDeclaredVolatileUnder(fn, symbols, target.compilerBehaviors);
537
+ if (ownReturn !== undefined && !calls.handedBack) {
538
+ returnOwnStruct(fn, name, target, ownReturn, notes, deferred, calls.readsCallStruct);
539
+ verify(fn);
540
+ }
541
+ refuseNarrowReturnsOfWideDeclaration(fn, name, own);
542
+ stampDeclaredVolatileUnder(fn, symbols, stampBehaviorsFor(target, contextGlobals));
240
543
  return fn;
241
544
  }
242
545
 
546
+ /** Both readers of a callee's body (frontend/callee-body.ts `calleeBodyReads`). */
547
+ type BodyReaders = { bound: CalleeBodyBound; stackWords: CalleeStackWords };
548
+
549
+ /** The readers already built, per caller table as handed to `liftStamped`, each with what else it was
550
+ * read under. A ranked run lifts one function a dozen times under one table, and each callee body
551
+ * is a lift of the whole input, so reading it once per lift would double the lift time of a large
552
+ * unit. Keyed by identity: a table rebuilt reads its bounds again, which costs and never lies. */
553
+ const readsRead = new WeakMap<
554
+ Prototypes,
555
+ Array<{
556
+ key: readonly unknown[];
557
+ reads: BodyReaders;
558
+ }>
559
+ >();
560
+
561
+ /** Each callee's body readers (frontend/callee-body.ts) as a lift of `name` under `table` reads them: every
562
+ * callee lifted from the same input, or from its body in `calleeBodies`, under the same table, map
563
+ * and context, reading no bodies. A body from outside the input is lifted with none of the input's
564
+ * side table, which describes the input's own object, and with only the relocations it carries
565
+ * (`CalleeBody.relocs`). Kept across lifts of the same function under the same caller table,
566
+ * map, data, context and bodies (`readsRead`). */
567
+ function bodyReadsIn(
568
+ name: string,
569
+ asm: string,
570
+ target: TargetDescription,
571
+ prototypes: Prototypes,
572
+ ownContext: FnProto | undefined,
573
+ table: Prototypes,
574
+ asmData: AsmData | undefined,
575
+ symbols: SymbolMap | undefined,
576
+ contextGlobals: ContextGlobals | undefined,
577
+ calleeBodies: CalleeBodies | undefined,
578
+ ): BodyReaders {
579
+ const key = [name, asm, target, ownContext, asmData, symbols, contextGlobals, calleeBodies];
580
+ const read = readsRead.get(prototypes) ?? [];
581
+ readsRead.set(prototypes, read);
582
+ const hit = read.find((r) => r.key.every((k, i) => k === key[i]));
583
+ if (hit !== undefined) {
584
+ return hit.reads;
585
+ }
586
+ const reads = calleeBodyReads(
587
+ name,
588
+ asm,
589
+ target,
590
+ table,
591
+ (callee, calleeTable, entryReadCalls, text, relocs) =>
592
+ liftStamped(
593
+ callee,
594
+ text,
595
+ target,
596
+ calleeTable,
597
+ text === asm ? asmData : relocs === undefined ? undefined : relocsOnly(relocs, target),
598
+ symbols,
599
+ contextGlobals,
600
+ { calleeBodyBound: () => undefined, calleeStackWords: () => undefined, entryReadCalls },
601
+ ),
602
+ calleeBodies,
603
+ frontendFor(target).entryStackWords,
604
+ );
605
+ // a handful per table: the symbol settings a ranked run tries, never a growing list
606
+ read.unshift({ key, reads });
607
+ read.length = Math.min(read.length, 8);
608
+ return reads;
609
+ }
610
+
611
+ /** A side table holding a body's own relocations and nothing of the input's object: no section
612
+ * bytes, so no jump table reads, and no symbols. */
613
+ const relocsOnly = (relocs: readonly Reloc[], target: TargetDescription): AsmData => ({
614
+ sections: new Map(),
615
+ relocs: [...relocs],
616
+ symbols: new Map(),
617
+ symbolCount: 0,
618
+ bigEndian: target.capabilities.endianness === 'big',
619
+ });
620
+
621
+ /** THE CONTEXT'S OWN ENTRY BOUNDS THE ARGUMENT REGISTERS, SOFTLY: an argument register past the
622
+ * words it fills (`ownArgumentWordsOn`, the layout a stated entry gets) is lifted as an uninitialised
623
+ * local's home, and where the finished lift still READS one, the function is lifted again with
624
+ * the bound raised past it, so that register stays the parameter a context-less lift makes it,
625
+ * with an `[own-arity]` note naming the declaration and the register.
626
+ *
627
+ * WHY SOFT, where a stated entry is hard. A header entry for a function still in assembly was
628
+ * written with no compiler checking it against a definition, and the two directions of its error
629
+ * cost differently. Too long, it only keeps a parameter. Too short, a hard bound turns a real
630
+ * argument into `uninit_<reg>`, which compiles silently: sbk2's task callbacks are declared
631
+ * `void f(void);` ahead of a definition that reads `a0`, 407 of 525 such headers were short when
632
+ * their functions were decompiled, and a hard bound lifted 16 of 33 sampled ones as `s32
633
+ * uninit_a0; … memcpy(uninit_a0 + 28, …)`. Kept as a parameter instead, the lift is the right
634
+ * function and contradicts the header, which every compiler here rejects as conflicting types —
635
+ * loud — and the note says which register. What the bound buys is the other direction: a
636
+ * register nothing in the lift reads is no parameter (sa3's `void Task_802F698(void)` lifted
637
+ * `(s32 a0, s32 a1, s32 a2, s32 a3)`).
638
+ *
639
+ * "READS" MEANS THE FINISHED LIFT, not the SSA builder. A register is read as the argument of a
640
+ * call whose arity was guessed, and the guess then trims it (`k4(a, b, 2, 0); zq(a); sl();` reads
641
+ * a2/a3 as `zq`'s and `sl`'s possible arguments); so the test is that an undefined value of a
642
+ * dropped register survives into the lift — an `undef` keyed by it, or by a join that took it,
643
+ * that `dce` would not delete, or one the machine homes to a slot nothing reads
644
+ * (`readDroppedArgRegs`). A read the raising tower would delete later still counts: that only
645
+ * keeps the parameter.
646
+ *
647
+ * A LIFT THAT REFUSES WITHIN THE BOUND IS LIFTED WITHOUT IT, with a note, so the bound turns no
648
+ * lift into a decline. The one refusal it adds is a stated entry's (frontend/ssa.ts
649
+ * `LiveInModel.ownArgWords`): an incoming stack argument past it, which no local can be, and which,
650
+ * named by position past the registers the bound dropped, would be one slot low. A stated entry for
651
+ * `name` — `prototypes`, which includes what the symbol map derives — wins, and the context's is
652
+ * not read. */
653
+ function liftWithinContextBound(
654
+ name: string,
655
+ asm: string,
656
+ target: TargetDescription,
657
+ prototypes: Prototypes,
658
+ asmData: AsmData | undefined,
659
+ symbols: SymbolMap | undefined,
660
+ contextGlobals: ContextGlobals | undefined,
661
+ liftOptions: LiftOptions | undefined,
662
+ ownContext: FnProto | undefined,
663
+ notes: string[] | undefined,
664
+ ): Fn {
665
+ const frontend = frontendFor(target);
666
+ // THE CONTEXT'S OWN ENTRY RIDES ON EVERY LIFT IT MAY BIND, the stated entry's absence permitting.
667
+ // A lift bounded by exactly its words, or one it fills every register of, may take it as proof
668
+ // (`LiftOptions.contextOwnProto.proves`). Any other lift may only refuse on it: the unbounded
669
+ // relift below, and a bound raised past a register the body reads. Otherwise a decided arity gap
670
+ // (frontend/call-plan.ts `decideGap`) that the entry refuses would come back from the relift as a
671
+ // lift without it. With `int pr(void)` in the context, `logv(s, 1)` would lift `logv(a0, 1)`, the
672
+ // same as with no declaration.
673
+ const context = (proves: boolean): LiftOptions =>
674
+ ownContext === undefined || Object.hasOwn(prototypes, name)
675
+ ? {}
676
+ : { contextOwnProto: { proto: ownContext, proves } };
677
+ const unbounded = () =>
678
+ forgetAbsorbedKeys(
679
+ frontend.lift(name, asm, target, prototypes, asmData, symbols, contextGlobals, {
680
+ ...liftOptions,
681
+ ...context(false),
682
+ }),
683
+ );
684
+ let words =
685
+ ownContext === undefined || Object.hasOwn(prototypes, name)
686
+ ? undefined
687
+ : ownArgumentWordsOn(name, ownContext, target);
688
+ if (words === undefined || words >= target.argRegs.length) {
689
+ return words === undefined
690
+ ? unbounded()
691
+ : forgetAbsorbedKeys(
692
+ frontend.lift(name, asm, target, prototypes, asmData, symbols, contextGlobals, {
693
+ ...liftOptions,
694
+ ...context(true),
695
+ }),
696
+ );
697
+ }
698
+ const declared = spelledDeclaration(name, ownContext!);
699
+ const declaredWords = words;
700
+ for (;;) {
701
+ let fn: Fn;
702
+ try {
703
+ fn = frontend.lift(name, asm, target, prototypes, asmData, symbols, contextGlobals, {
704
+ ...liftOptions,
705
+ contextOwnWords: words,
706
+ ...context(words === declaredWords),
707
+ });
708
+ } catch (e) {
709
+ if (!(e instanceof FrontendUnsupportedError)) {
710
+ throw e;
711
+ }
712
+ const lifted = unbounded();
713
+ notes?.push(
714
+ `${name}: lifted without its context declaration \`${declared}\`, which it could not be lifted within ` +
715
+ `(${e.message.split('\n')[0]})`,
716
+ );
717
+ return lifted;
718
+ }
719
+ const read = readDroppedArgRegs(fn, target.argRegs.slice(words));
720
+ if (read.length === 0) {
721
+ return forgetAbsorbedKeys(fn);
722
+ }
723
+ notes?.push(
724
+ `${name}: its context declaration \`${declared}\` fills ${words} argument word${words === 1 ? '' : 's'}, ` +
725
+ `and the body reads ${read.join(', ')} past ${words === 1 ? 'it' : 'them'} — kept as ` +
726
+ `${read.length === 1 ? 'a parameter' : 'parameters'}, which the header contradicts`,
727
+ );
728
+ words = Math.max(...read.map((r) => target.argRegs.indexOf(r))) + 1;
729
+ if (words >= target.argRegs.length) {
730
+ return unbounded();
731
+ }
732
+ }
733
+ }
734
+
735
+ /** The dropped argument registers whose undefined value the lift still reads: an `undef` keyed by
736
+ * one, or that absorbed one when its join collapsed (frontend/ssa.ts `ABSORBED_KEYS`), which is
737
+ * not dead (`deadOps`, the definition `dce` deletes by) or which the machine stored to a slot
738
+ * nothing reads back (`HOMED_UNDEF`). That store reaches no op, and it is still a read: IDO -g3 and
739
+ * -O0 home every argument register the definition declares, so a header naming fewer contradicts
740
+ * the asm, and the main lift's `(a0, a1)` is the one that recompiles to the same words. */
741
+ function readDroppedArgRegs(fn: Fn, dropped: readonly string[]): string[] {
742
+ const dead = deadOps(fn.blocks);
743
+ const read = new Set<string>();
744
+ for (const b of fn.blocks) {
745
+ for (const op of b.ops) {
746
+ if (op.opcode === 'undef' && (!dead.has(op) || op.attrs[HOMED_UNDEF] === true)) {
747
+ for (const k of [String(op.attrs.key), ...String(op.attrs[ABSORBED_KEYS] ?? '').split(',')]) {
748
+ read.add(k);
749
+ }
750
+ }
751
+ }
752
+ }
753
+ return dropped.filter((r) => read.has(r));
754
+ }
755
+
756
+ /** `fn` with no `undef` carrying `ABSORBED_KEYS` or `HOMED_UNDEF`, which nothing past the own-entry
757
+ * bound reads. */
758
+ function forgetAbsorbedKeys(fn: Fn): Fn {
759
+ for (const b of fn.blocks) {
760
+ for (const op of b.ops) {
761
+ if (op.opcode === 'undef') {
762
+ delete op.attrs[ABSORBED_KEYS];
763
+ delete op.attrs[HOMED_UNDEF];
764
+ }
765
+ }
766
+ }
767
+ return fn;
768
+ }
769
+
770
+ /** A declaration as a note spells it: `int f(short, int)`, `void f(void)`, `int f(int, ...)`, and
771
+ * `int f()` for one that states no parameters. */
772
+ function spelledDeclaration(name: string, p: FnProto): string {
773
+ const ret = p.returns ?? (p.returnsVoid === true ? 'void' : 'int');
774
+ const params =
775
+ p.variadicParams !== undefined
776
+ ? [...p.variadicParams, '...'].join(', ')
777
+ : Array.isArray(p.params)
778
+ ? p.params.length > 0
779
+ ? p.params.join(', ')
780
+ : 'void'
781
+ : '';
782
+ return `${ret} ${name}(${params})`;
783
+ }
784
+
785
+ /** A FUNCTION THAT ITSELF RETURNS A STRUCT BY VALUE, as its declaration says: a stated entry for
786
+ * `name` (`own`), or else the context's own entry (`ownContext`, proto-context.ts
787
+ * `ownContextEntry`). Where its target's rule (`returnsInMemory`) returns the struct through
788
+ * memory, its caller hands it the storage in the first argument register, so its declared
789
+ * parameters sit one register up, and the lift is the pointer function `s32 *f(s32 *a0, s32 a1)`
790
+ * until `returnOwnStruct` proves that pointer is the storage and lifts the struct return the
791
+ * declaration names. This answers what that proof is handed — the struct laid out, and which
792
+ * declaration said so — and refuses, before any frontend runs, where no lift can be the declared
793
+ * function:
794
+ * - a struct the rule hands back in two registers (mwcc's svr4 eight-byte struct in r3:r4): the
795
+ * lift returns one word, and would drop the second register — on both declarations, since a
796
+ * context's header that says so is the only thing that says the second register is live;
797
+ * - a stated declaration whose struct the rule cannot place, because the target states no rule
798
+ * or nothing lays the struct out, or that names no type a local can be declared with. A context
799
+ * entry that cannot be placed keeps the pointer lift, with a note in `notes`: what it costs is
800
+ * a signature the header contradicts, which the compiler rejects.
801
+ * A struct the rule hands back in one register (agbcc's and mwcc's one-word struct in r0/r3) is
802
+ * that register's word: the plan says so (`register`), and a struct that is all one word-wide
803
+ * integer member is spelled as that member (frontend/own-struct-return.ts
804
+ * `liftOwnRegisterStruct`), where any other one stays the word the lift returns, with a note in
805
+ * `notes` saying why (`returnOwnStruct`). */
806
+ function ownStructReturnThroughMemory(
807
+ name: string,
808
+ target: TargetDescription,
809
+ own: FnProto | undefined,
810
+ ownContext: FnProto | undefined,
811
+ notes: string[] | undefined,
812
+ ): OwnStructReturn | undefined {
813
+ const stated = own !== undefined;
814
+ const declared = stated ? own : ownContext;
815
+ if (!declaresAggregateReturn(declared)) {
816
+ return undefined;
817
+ }
818
+ const spelled = declared!.returns ?? 'a struct or union';
819
+ const read = returnedStruct(declared!, target);
820
+ if (read === 'register') {
821
+ // A struct wider than a word that comes back in registers is a pair the lift returns one word
822
+ // of, dropping the second register; one of a word or less is that word
823
+ const size = aggregateSize(returnedAggregate(declared!), target)?.size;
824
+ if (size !== undefined && size <= 4) {
825
+ const type = structLocalType(declared!, target);
826
+ return 'refused' in type
827
+ ? undefined
828
+ : { type, stated, declaration: spelledDeclaration(name, declared!), register: true };
829
+ }
830
+ if (size !== undefined) {
831
+ const pair =
832
+ `this target hands its ${size} bytes back in two registers — a function that itself returns a struct ` +
833
+ 'in a register pair is not modelled';
834
+ if (stated) {
835
+ throw new FrontendUnsupportedError(
836
+ `cannot lift '${name}': it is declared to return ${spelled} by value, and ${pair}`,
837
+ );
838
+ }
839
+ throw new FrontendUnsupportedError(
840
+ `cannot lift '${name}': its context declaration says it returns ${spelled} by value, and ${pair} ` +
841
+ `(\`${spelledDeclaration(name, declared!)}\`)`,
842
+ );
843
+ }
844
+ return undefined;
845
+ }
846
+ if ('refused' in read) {
847
+ const how =
848
+ returnsInMemory(returnedAggregate(declared!), target) === true
849
+ ? `this target returns that through a hidden pointer in ${target.argRegs[0]}, and ${read.refused}`
850
+ : target.compilerBehaviors.aggregateReturn === undefined
851
+ ? `nothing here says whether this target returns it through a hidden pointer in ${target.argRegs[0]}`
852
+ : `nothing here lays it out, and this target's rule needs its layout to say whether it comes back through a ` +
853
+ `hidden pointer in ${target.argRegs[0]}`;
854
+ if (stated) {
855
+ throw new FrontendUnsupportedError(
856
+ `cannot lift '${name}': it is declared to return ${spelled} by value, and ${how}`,
857
+ );
858
+ }
859
+ notes?.push(
860
+ `[own-return] ${name}: its context declaration \`${spelledDeclaration(name, declared!)}\` returns a struct by value, ` +
861
+ `and ${how} — lifted as the machine code's function, which the header contradicts`,
862
+ );
863
+ return undefined;
864
+ }
865
+ return { type: read.type, stated, declaration: spelledDeclaration(name, declared!) };
866
+ }
867
+
868
+ /** The struct a function's own declaration returns by value, and which declaration said so. */
869
+ interface OwnStructReturn {
870
+ type: Extract<IrType, { kind: 'struct' }>;
871
+ stated: boolean;
872
+ declaration: string;
873
+ /** the target hands it back in the return register, not through a hidden pointer */
874
+ register?: true;
875
+ }
876
+
877
+ /** Lifts `fn`'s struct return (frontend/own-struct-return.ts `liftOwnStructReturn`), where the
878
+ * lift proves the hidden pointer is the struct's storage. Where it does not, a STATED declaration
879
+ * refuses — the pointer function contradicts it — and a context's keeps the pointer lift, as the
880
+ * machine code's function, with a note in `notes`: the context's own entry is a header nothing
881
+ * checked against a definition (`liftWithinContextBound` says why that makes it soft). Either way, a
882
+ * refusal the frontend `deferred` because this struct would explain it (agbcc's 8-byte epilogue,
883
+ * `LiftOptions.ownMemoryReturn`) is thrown where the proof fails, since nothing else explains it, and
884
+ * says why the proof failed. */
885
+ function returnOwnStruct(
886
+ fn: Fn,
887
+ name: string,
888
+ target: TargetDescription,
889
+ own: OwnStructReturn,
890
+ notes: string[] | undefined,
891
+ deferred: ((unproved: string) => never) | undefined,
892
+ readsCallStruct: boolean,
893
+ ): void {
894
+ if (own.register) {
895
+ // a word the struct may not be all of stays the lift's own return, as the machine has it, and
896
+ // the note says why the declared struct is not what it returns
897
+ const kept = liftOwnRegisterStruct(fn, own.type);
898
+ // … save in a function that reads a struct a call returned (frontend/register-struct-call.ts):
899
+ // the word it returns may be computed from that struct — through a frame slot as well as an
900
+ // op — and the word return would print it as the scalar the declaration contradicts
901
+ if (kept !== undefined && readsCallStruct) {
902
+ throw new FrontendUnsupportedError(
903
+ `cannot lift '${name}': it is declared to return ${own.type.declared ?? 'a struct'} by value, which comes back in ` +
904
+ `${target.returnReg} — ${kept}, and it reads a struct a call returned there, which what it returns may be computed from`,
905
+ );
906
+ }
907
+ if (kept !== undefined) {
908
+ notes?.push(
909
+ `[own-return] ${name}: its ${own.stated ? '' : 'context '}declaration \`${own.declaration}\` returns ` +
910
+ `${own.type.declared ?? 'a struct'} by value in ${target.returnReg}, and ${kept} — lifted as the word ` +
911
+ 'the machine code returns, which the header contradicts',
912
+ );
913
+ }
914
+ return;
915
+ }
916
+ const why = liftOwnStructReturn(fn, own.type, target);
917
+ if (why === undefined) {
918
+ return;
919
+ }
920
+ // a refusal the frontend deferred because this struct would explain it stands where it does not
921
+ deferred?.(why);
922
+ const declared = `${own.type.declared ?? 'a struct'} by value, which this target returns through a hidden pointer in ${target.argRegs[0]}`;
923
+ if (own.stated) {
924
+ throw new FrontendUnsupportedError(
925
+ `cannot lift '${name}': it is declared to return ${own.type.declared ?? 'a struct'} by value, which this target returns ` +
926
+ `through a hidden pointer in ${target.argRegs[0]}, and the lift cannot show that pointer is only its storage: ${why}`,
927
+ );
928
+ }
929
+ notes?.push(
930
+ `[own-return] ${name}: its context declaration \`${own.declaration}\` returns ${declared}, and the lift cannot show ` +
931
+ `that pointer is only its storage (${why}) — lifted as the machine code's pointer function, which the header contradicts`,
932
+ );
933
+ }
934
+
935
+ /** A FUNCTION DECLARED TO RETURN AN INTEGER WIDER THAN A REGISTER, whose lift returns one word or
936
+ * none, refuses — on every frontend, since this reads only the lifted `ret`s.
937
+ *
938
+ * Only the declaration can decide this. A `long long` comes back in a register pair (Thumb r0:r1,
939
+ * O32 v0:v1, PowerPC r3:r4), and where the body never reads the pair's second register back, the
940
+ * asm of `long long f(long long *p, long long *q) { long long x = *p; *q = x; return x; }` is
941
+ * byte-identical to that of the `s32` function storing the same two words (kmc-gcc and gcc 2.7.2
942
+ * compile both to one object). A frontend that builds the pair returns one 64-bit value and
943
+ * passes; one that lowered a word would print `s32 f(...)` against the project's own `long long
944
+ * f(...)` — a candidate the compiler rejects as conflicting types. The frontends' asm rules
945
+ * (Thumb's `refuseWordReturns`, O32's `v1MayHoldLowWord`) stay as the backstop for a function
946
+ * nothing declares, and that byte-identical shape is their blind spot. */
947
+ function refuseNarrowReturnsOfWideDeclaration(fn: Fn, name: string, own: FnProto | undefined): void {
948
+ const width = ownReturnWidth(own);
949
+ if (width === undefined || width <= 32) {
950
+ return;
951
+ }
952
+ for (const blk of fn.blocks) {
953
+ for (const op of blk.ops) {
954
+ if (op.opcode === 'ret' && !op.operands.some((v) => (intWidth(v.type) ?? 0) > 32)) {
955
+ throw new FrontendUnsupportedError(
956
+ `cannot lift '${name}': its declaration returns a ${width}-bit integer, which comes back as the halves of a ` +
957
+ '64-bit pair, and this lowering returns one word',
958
+ );
959
+ }
960
+ }
961
+ }
962
+ }
963
+
243
964
  // ── the shared raising tower ────────────────────────────────────────────────────────────────
244
965
  // decompile(), decompileTraced (trace.ts), rank.ts's decompileRanked and the cli's
245
966
  // decompileWithReport + its score probe (report.ts) all raise a lifted fn through the SAME stage
@@ -272,19 +993,20 @@ export interface RaiseHooks {
272
993
  afterRecover?: () => void;
273
994
  /** after return-sinking, only when it changed the fn (fires after its verify) */
274
995
  afterRetsink?: () => void;
275
- /** after empty-latch folding, only when it removed a block (fires after its verify) */
276
- afterLatchFold?: () => void;
996
+ /** after each pre-structure fold (raise/pre-structure.ts) that changed the fn (fires after its verify) */
997
+ afterFold?: (fold: PreStructureFold) => void;
277
998
  }
278
999
 
279
1000
  /** Stages 2.35–3.5 — pre-recovery recognizers (the shared ordered list in raise/pre-recovery.ts)
280
1001
  * → type recovery (boundary contract: no `unknown` survives) → return-sinking (tail-duplicate a
281
- * return-only merge so short-circuits emit early returns) → empty-latch folding (splice out a
282
- * back-edge block SSA construction emptied). `verify` after every pass that changed the IR.
1002
+ * return-only merge so short-circuits emit early returns) → the pre-structure folds
1003
+ * (raise/pre-structure.ts: empty latches, exit trampolines, peeled loop tests). `verify` after
1004
+ * every pass that changed the IR.
283
1005
  *
284
- * The two CFG passes look ordered and are not: running latch folding FIRST instead changes
285
- * nothing across a 3337-function agbcc corpus. Worth saying, because folding an empty block ahead
1006
+ * Return-sinking and latch folding look ordered and are not: running latch folding FIRST instead
1007
+ * changes nothing across a 3337-function agbcc corpus. Worth saying, because folding an empty block ahead
286
1008
  * of return-sinking does take away `br` predecessors it needs — the dominance gate is what makes
287
- * that unreachable, since the blocks retsink wants are never back-edge sources. It goes last
1009
+ * that unreachable, since the blocks retsink wants are never back-edge sources. The folds go last
288
1010
  * because that is where the CFG stops moving.
289
1011
  *
290
1012
  * The tail is three separate parameters rather than an options bag on purpose: this is the
@@ -314,6 +1036,9 @@ export function raiseRecovered(
314
1036
  pre,
315
1037
  );
316
1038
  hooks.beforeRecover?.();
1039
+ if (declaresVoidReturn(self)) {
1040
+ dropVoidReturnOperands(fn);
1041
+ }
317
1042
  recoverTypes(fn);
318
1043
  verify(fn);
319
1044
  assertTypesRecovered(fn);
@@ -330,9 +1055,11 @@ export function raiseRecovered(
330
1055
  verify(fn);
331
1056
  hooks.afterRetsink?.();
332
1057
  }
333
- if (foldEmptyLatches(fn)) {
334
- verify(fn);
335
- hooks.afterLatchFold?.();
1058
+ for (const fold of PRE_STRUCTURE_FOLDS) {
1059
+ if (fold.run(fn) > 0) {
1060
+ verify(fn);
1061
+ hooks.afterFold?.(fold);
1062
+ }
336
1063
  }
337
1064
  // THE BOUNDARY POSTCONDITION. Above this line passes move the CFG; below it nothing does, and the
338
1065
  // structurer reads a block parameter as a JOIN — a name it must give a local of its own. A param
@@ -352,6 +1079,31 @@ export function raiseRecovered(
352
1079
  }
353
1080
  }
354
1081
 
1082
+ /** A FUNCTION ITS DECLARATION SAYS RETURNS NOTHING RETURNS NOTHING: each `ret` drops the return
1083
+ * register the frontend hands it (frontend/ssa.ts returns that register at every `ret`, whatever
1084
+ * the declared type), and the joins only that operand read go with it. Before type recovery, since
1085
+ * a join read by nothing else is no value of the program, and recovery would otherwise unify what
1086
+ * two paths leave in that register: mwcc's `if (*p == 7) { gHit += 1; break; }` leaves r3 holding
1087
+ * gHit's value on one exit and the walked pointer on the other, and the one join typed both as
1088
+ * `u8 *` (`gHit = (void *)(v0 + 1)`, which mwcc rejects). The structurer already reads no `ret`
1089
+ * operand under `returnsVoid`, so this changes no `return` it writes. */
1090
+ function dropVoidReturnOperands(fn: Fn): void {
1091
+ let dropped = false;
1092
+ for (const blk of fn.blocks) {
1093
+ for (const op of blk.ops) {
1094
+ if (op.opcode === 'ret' && op.operands.length > 0) {
1095
+ op.operands = [];
1096
+ dropped = true;
1097
+ }
1098
+ }
1099
+ }
1100
+ if (dropped) {
1101
+ pruneDeadParams(fn);
1102
+ dce(fn);
1103
+ verify(fn);
1104
+ }
1105
+ }
1106
+
355
1107
  /** Run `body`; if it declines, name the unmodelled instructions the function carries.
356
1108
  *
357
1109
  * An `opaque` degrades its own value AND makes its block impure, so a shape recognizer refuses:
@@ -413,8 +1165,9 @@ export function structureChecked(
413
1165
  fn: Fn,
414
1166
  opts: Parameters<typeof structure>[1],
415
1167
  hooks?: Parameters<typeof structure>[2],
1168
+ memo?: Parameters<typeof structure>[3],
416
1169
  ): SFn {
417
- const raw = attributeOpaques(fn, () => structure(fn, opts, hooks));
1170
+ const raw = attributeOpaques(fn, () => structure(fn, opts, hooks, memo));
418
1171
  // The boundary contracts run on the pre-DCE tree: the readability pass must never be able to
419
1172
  // hide a structuring defect by dropping the dead statement that carries it. assertResolved
420
1173
  // catches an unresolved `?` value; assertDerefsTyped catches an ill-typed deref (e.g. a pointer
@@ -430,6 +1183,7 @@ export function structureChecked(
430
1183
  // re-validate deref typing on the rewritten tree.
431
1184
  const sfn = readabilityRewrites(raw);
432
1185
  assertDerefsTyped(sfn);
1186
+ assertFrameUnionsViewed(sfn);
433
1187
  assertPostIncrUnshared(sfn);
434
1188
  // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
435
1189
  // merges arms or drops statements must not be able to lose or duplicate a call.
@@ -437,6 +1191,250 @@ export function structureChecked(
437
1191
  return sfn;
438
1192
  }
439
1193
 
1194
+ /** Non-blank lines in an emitted source: what the return-tail reading below is bounded by, and
1195
+ * rank.ts's compactness tie-break, counted exactly as the report counts `lines`
1196
+ * (apps/benchmark/src/eval/quality.ts). Deterministic, and total on any string. */
1197
+ export function sourceLineCount(source: string): number {
1198
+ return source.split('\n').filter((l) => l.trim().length > 0).length;
1199
+ }
1200
+
1201
+ /** The reading a {@link structureReturnTailReading} did not publish, for rank.ts's alternative
1202
+ * candidate: its tree and which reading it is, or why the copies have none. */
1203
+ export type UnpublishedReading =
1204
+ { readonly reading: 'copies' | 'join'; readonly sfn: SFn } | { readonly reading: 'copies'; readonly error: unknown };
1205
+
1206
+ export interface ReturnTailReading {
1207
+ /** the tree this structuring publishes */
1208
+ readonly sfn: SFn;
1209
+ /** the other reading of the same structuring, where the published one was a choice between two:
1210
+ * absent where no divergent branch took the join past early returns, or where a reading was
1211
+ * forced */
1212
+ readonly unpublished?: UnpublishedReading;
1213
+ }
1214
+
1215
+ /** Stage 4, THE RETURN-TAIL READING BOUNDED BY SIZE AND READ OFF THE LAYOUT. On a target that
1216
+ * declares the join past early returns its default (target.ts `joinPastReturnsByDefault`:
1217
+ * mwcc_242_81 and mwcc_233_163n), `structureChecked`, and where that structuring took the join
1218
+ * somewhere, the same structuring again reading the return-tail copies (`copyReturnTails`). The copies are
1219
+ * published where they spell the function SHORTER, or where the target's asm layout shows that the
1220
+ * compiler cross-jumped a tail the source wrote into every arm, or its cse left a return unfolded
1221
+ * (below). Every other target publishes the copies (THE MEASURED DEFAULT).
1222
+ *
1223
+ * WHY A BOUND. The join writes the code below a divergent branch once where the copies write it
1224
+ * into every arm; but the join copies a return tail into each early return in its turn, so neither
1225
+ * reading is the shorter in general (structure.ts `structureWithinBudget`). Where the join is the
1226
+ * longer, it is the reading that duplicates code. Measured with the real compilers over the 620
1227
+ * real functions the unbounded join re-spells (2026-11-05): the 180 it makes longer score further
1228
+ * from their bytes than the copies on 89 and closer on 10 (MATCH 23 → 16).
1229
+ *
1230
+ * THE UNIT IS THE WHOLE FUNCTION'S EMITTED SOURCE, in non-blank lines (`sourceLineCount`), and a
1231
+ * tie goes to the join.
1232
+ * - Whole function, not per branch: these are the two whole-function readings the ranked fan
1233
+ * holds (rank.ts's default and its `/ret-copy` or `/ret-join` sibling), so the published tree
1234
+ * is always one of them. A per-branch unit differs only where the join fires at two or more
1235
+ * branches (196 of those 620), and its gain there is not measured. Of the 13 that MATCH only
1236
+ * with the copies (all sa3 agbcc) this bound publishes the copies on 9; on the other 4 the
1237
+ * join is the shorter, and 3 of them take it at a single branch, so a per-branch unit could
1238
+ * recover at most 1. The layout recovers all 4 (THE LAYOUT, below).
1239
+ * - Emitted, not the structured tree: `readabilityRewrites` runs after `structure()` and can fold
1240
+ * copied tails back together (`mergeCommonTails`), so a size read inside `structure()` is not
1241
+ * the size published.
1242
+ * - Lines, not statements: on those 620 the final tree's statement count disagrees with the line
1243
+ * class on 49, and it ties the mwcc jump-table window (cli
1244
+ * test/matching/jump-table-window.test.ts) at 17 statements where the copies are 30 lines and
1245
+ * byte-exact, the join 32.
1246
+ * - Lines alone, not rank.ts `compareScored`'s goto and cast counts ahead of them: on those 620
1247
+ * neither reading spells a `goto`, and casts-first parts from lines on 17, where lines scores
1248
+ * the closer on 5 and casts on 3, with no MATCH between them. The ranked fan holds both
1249
+ * readings, so where the default is ranked `compareScored` still orders them.
1250
+ *
1251
+ * THE LAYOUT. A compiler that cross-jumps return tails (target.ts `crossJumpsReturnTails`) compiles
1252
+ * the copies into the CFG the join reads, with one copy of the tail. So the join is the shorter
1253
+ * reading of those copies, and the bound alone publishes it. What the copies leave instead is a
1254
+ * mark in the layout at a site the join took: the surviving tail ends in a jump over an arm laid
1255
+ * out after it, identical arms stayed separate blocks, or a call at the join was cut from the
1256
+ * argument setup each arm kept (structure/cross-jumped-tail.ts). Where
1257
+ * any site the published join run took shows a mark, the copies are published whatever their size.
1258
+ * - Read only where the target declares the merge. ido7.1 lays a tail out mid-construct with no
1259
+ * cross-jump, and there the same layout fact is not evidence of the copies.
1260
+ * - Whole function, as the bound is: the two readings are still rank.ts's default and its
1261
+ * `/ret-copy` or `/ret-join` sibling, so the ranked fan holds the same two trees. A mark at one
1262
+ * site re-spells every join the function took, marked or not (14 of the 116 agbcc functions
1263
+ * with a mark over those 620 have unmarked sites too); a per-site reading would add trees to
1264
+ * the fan, and is not built.
1265
+ * - MEASURED over those 620 functions, each reading really compiled: the bound with the layout
1266
+ * gives MATCH 44 (copies everywhere 35, the bound alone 40), and loses none of the copies'
1267
+ * matches. The bound alone loses 4, all sa3 agbcc: three where the tail is left mid-construct,
1268
+ * and one where identical case bodies stayed apart. The first two marks were chosen on those
1269
+ * 620; the third, and the split tail of the first, on held-out generated sources written with
1270
+ * the copies, where the bound with the first two still lost matches the copies make (agbcc 7,
1271
+ * gcc2.7.2kmc 9, gcc2.7.2 2 of 160; with all three, 2, 3, 1). Their numbers are in
1272
+ * structure/cross-jumped-tail.ts. So, held out, this reading still loses some of the copies'
1273
+ * matches; the 620 and the full bench lose none. Those 620 MATCH figures are of the bound with
1274
+ * the marks on every compiler; agbcc, ido7.1 and the two GCC 2.7.2 lanes publish the copies
1275
+ * (THE MEASURED DEFAULT), so the marks are read by the tests and by no default (no compiler
1276
+ * that declares `crossJumpsReturnTails` declares the join its default), and the join's gains
1277
+ * there are given up (agbcc 2 of the 620, ido7.1 7: the field's note).
1278
+ * - A mark is evidence, not proof: jump2 cross-jumps shared arm code that is not a return tail
1279
+ * too, and leaves the same marks under a join-written source (structure/cross-jumped-tail.ts).
1280
+ * So a mark moves which verified reading is the default, never what is lifted.
1281
+ *
1282
+ * THE UNFOLDED RETURN. Where the target's cse folds a branch-proven constant only into the branch's
1283
+ * fall-through arm (target.ts `foldsBranchConstantOnTakenEdge` false: gcc 2.x at -O1), an early
1284
+ * return of the very value its branch tested equal to a constant, left unfolded in the object, was
1285
+ * the jump-target (`else`) arm in the source, and the join cannot spell it: `if (x == 0) return x;`
1286
+ * compiles to `return 0` (structure/unfolded-branch-constant.ts). Where any site shows one, the
1287
+ * copies are published whatever their size, as for a layout mark. MEASURED on Mario Party 3's
1288
+ * `func_8010BD28_472F98_mgmode` (gcc2.7.2 -O1), the one held-out real function the join lost a
1289
+ * match on there (2026-11-06): the copies match, the join is 1 line off. Like the layout marks it
1290
+ * is read by no default now: gcc2.7.2 publishes the copies (THE MEASURED DEFAULT).
1291
+ *
1292
+ * THE MEASURED DEFAULT. A target that does not declare `joinPastReturnsByDefault` (agbcc, ido7.1,
1293
+ * gcc2.7.2kmc, gcc2.7.2, mwcc_247_107, and any description that leaves it out) publishes the copies
1294
+ * whatever the bound and the marks would say, and keeps the join as the unpublished reading, so
1295
+ * the ranked fan holds both as before (`copiesByDefault`). The join is declared only where the
1296
+ * held-out real functions and the generated ones written with the copies show the bound with every
1297
+ * mark losing none of the copies' matches; on agbcc and ido7.1 (held out) and on gcc2.7.2kmc and
1298
+ * gcc2.7.2 (generated) it loses some no mark read from the target separates, and mwcc_247_107 is
1299
+ * unmeasured (the field's note has the counts and the gains given up).
1300
+ *
1301
+ * REFUSALS. Never a lift turned into a decline:
1302
+ * - a join that declines or crosses the copy budget already falls to the copies inside
1303
+ * `structure()` (`structureWithinBudget`), and that tree is published as it was;
1304
+ * - outside the measured default, the copies are structured only once the join has lifted, and
1305
+ * where they decline, fail a boundary contract, or the backend cannot emit one of the two, the
1306
+ * join stands;
1307
+ * - under the measured default the copies come first, and where they refuse the join stands, so
1308
+ * a function the old default declined can lift there (`copiesByDefault`);
1309
+ * - a forced reading (`copyReturnTails`, `joinPastReturns`), or a language with no early `return`
1310
+ * (`spellEarlyReturns: false`), is structured once, as asked. rank.ts forces the join this way
1311
+ * for a setting whose stripped sibling dropped its copies.
1312
+ *
1313
+ * HOOKS. The caller's hooks fire for both structurings, so an observer reads the union of what the
1314
+ * two runs saw. That is what keeps rank.ts's off-sibling witness sound: a setting is a tree dedup
1315
+ * of its sibling only where neither reading of the sibling fired the witness. A
1316
+ * `DefaultAcceptsMemoMisuse` is a defect and is rethrown, never an unpublished reading. */
1317
+ export function structureReturnTailReading(
1318
+ fn: Fn,
1319
+ opts: Parameters<typeof structure>[1] & {
1320
+ joinPastReturns?: boolean;
1321
+ crossJumpsReturnTails?: boolean;
1322
+ foldsBranchConstantOnTakenEdge?: boolean;
1323
+ joinPastReturnsByDefault?: boolean;
1324
+ },
1325
+ emit: (sfn: SFn) => string,
1326
+ hooks: Parameters<typeof structure>[2] = {},
1327
+ memo?: Parameters<typeof structure>[3],
1328
+ ): ReturnTailReading {
1329
+ // `crossJumpsReturnTails` rides along into `structure()`, which never reads it, as every compiler
1330
+ // behaviour `structureOptionsFor` spreads does: stripped here, it would key this structuring's
1331
+ // default-accepts memo apart from rank.ts's other passes over the same setting.
1332
+ const { joinPastReturns, ...structOpts } = opts;
1333
+ if (structOpts.copyReturnTails === true || structOpts.spellEarlyReturns === false) {
1334
+ return { sfn: structureChecked(fn, structOpts, hooks, memo) };
1335
+ }
1336
+ const sites: JoinPastReturnsSite[] = [];
1337
+ const structureJoin = () =>
1338
+ structureChecked(
1339
+ fn,
1340
+ structOpts,
1341
+ {
1342
+ ...hooks,
1343
+ onJoinPastReturns: (site) => {
1344
+ sites.push(site);
1345
+ hooks.onJoinPastReturns?.(site);
1346
+ },
1347
+ },
1348
+ memo,
1349
+ );
1350
+ if (opts.joinPastReturnsByDefault !== true && joinPastReturns !== true) {
1351
+ return copiesByDefault(fn, structOpts, structureJoin, sites, hooks, memo);
1352
+ }
1353
+ const join = structureJoin();
1354
+ if (sites.length === 0 || joinPastReturns === true) {
1355
+ return { sfn: join };
1356
+ }
1357
+ let copies: SFn;
1358
+ try {
1359
+ copies = structureChecked(fn, { ...structOpts, copyReturnTails: true }, hooks, memo);
1360
+ } catch (error) {
1361
+ if (error instanceof DefaultAcceptsMemoMisuse) {
1362
+ throw error;
1363
+ }
1364
+ return { sfn: join, unpublished: { reading: 'copies', error } };
1365
+ }
1366
+ // Read only where the compiler is one that merges return tails: elsewhere the same layout is no
1367
+ // evidence of the copies (ido7.1 lays a tail out mid-construct with no cross-jump at all).
1368
+ const crossJumped = opts.crossJumpsReturnTails === true && sites.some((site) => crossJumpedTail(fn, site));
1369
+ // Read only where the target's cse leaves the taken edge unfolded (gcc 2.x at -O1): elsewhere a
1370
+ // return of the tested value says nothing about which arm the source wrote it in.
1371
+ const unfolded =
1372
+ opts.foldsBranchConstantOnTakenEdge === false && sites.some((site) => unfoldedBranchConstant(fn, site));
1373
+ let publishCopies = false;
1374
+ try {
1375
+ const [copyLines, joinLines] = [sourceLineCount(emit(copies)), sourceLineCount(emit(join))];
1376
+ publishCopies = crossJumped || unfolded || copyLines < joinLines;
1377
+ } catch {
1378
+ // a backend that cannot spell one of the two leaves the join standing; the caller's own emit of
1379
+ // the join reports its refusal where it has one
1380
+ }
1381
+ return publishCopies
1382
+ ? { sfn: copies, unpublished: { reading: 'join', sfn: join } }
1383
+ : { sfn: join, unpublished: { reading: 'copies', sfn: copies } };
1384
+ }
1385
+
1386
+ /** THE MEASURED DEFAULT (no `joinPastReturnsByDefault`): the copies, structured FIRST and
1387
+ * published wherever they lift, as the default before the join past early returns was one. The join
1388
+ * is structured after them for the ranked fan, and its refusal costs only that reading.
1389
+ *
1390
+ * Where the copies REFUSE, the join is published: never a lift turned into a decline. The copies
1391
+ * here (`copyReturnTails`) are not that old default's structuring: its ladder's other rungs live in
1392
+ * the join's reading (structure.ts THE COPIES, where the join refuses), and a function only they
1393
+ * lift is one the copies alone decline (`synthetic:sw_gotocase2` on
1394
+ * ido7.1, a case that leaves its switch for a loop exit past the switch's end). So this default
1395
+ * also LIFTS some functions the old one declined, where only the join structures them (pokeemerald
1396
+ * `Task_ShowContestEntryMonPic`, agbcc, a jump table whose case arms the copies cannot linearize):
1397
+ * a decline turned into a lift, which the held-out sweep counts as moved, not as level. A refusal a
1398
+ * function's first structuring alone can see (a volatile read stamped late under a `&&`/`||`) is
1399
+ * remembered on the function (structure.ts `STAMPED_LATE`), so the join run after the copies
1400
+ * declines it as they did. Where both refuse, the copies' refusal is the one reported. */
1401
+ function copiesByDefault(
1402
+ fn: Fn,
1403
+ structOpts: Parameters<typeof structure>[1],
1404
+ structureJoin: () => SFn,
1405
+ sites: readonly JoinPastReturnsSite[],
1406
+ hooks: Parameters<typeof structure>[2],
1407
+ memo: Parameters<typeof structure>[3],
1408
+ ): ReturnTailReading {
1409
+ const refusal = (error: unknown) => {
1410
+ if (error instanceof DefaultAcceptsMemoMisuse) {
1411
+ throw error;
1412
+ }
1413
+ return error;
1414
+ };
1415
+ let copies: SFn | undefined;
1416
+ let copiesError: unknown;
1417
+ try {
1418
+ copies = structureChecked(fn, { ...structOpts, copyReturnTails: true }, hooks, memo);
1419
+ } catch (error) {
1420
+ copiesError = refusal(error);
1421
+ }
1422
+ let join: SFn;
1423
+ try {
1424
+ join = structureJoin();
1425
+ } catch (error) {
1426
+ refusal(error);
1427
+ if (copies === undefined) {
1428
+ throw copiesError;
1429
+ }
1430
+ return { sfn: copies };
1431
+ }
1432
+ if (copies === undefined) {
1433
+ return sites.length === 0 ? { sfn: join } : { sfn: join, unpublished: { reading: 'copies', error: copiesError } };
1434
+ }
1435
+ return sites.length === 0 ? { sfn: copies } : { sfn: copies, unpublished: { reading: 'join', sfn: join } };
1436
+ }
1437
+
440
1438
  // ── annotate-mode support ─────────────────────────────────────────────────────────────────
441
1439
 
442
1440
  /** Classify a caught failure by which stage's designed signal it is. `instanceof`, not name
@@ -492,6 +1490,9 @@ export function stubResult(name: string, asm: string, backend: LanguageBackend,
492
1490
  // A stub spells no global, so it assumes nothing about one and walked to none.
493
1491
  assumedSymbols: [],
494
1492
  walkedNames: [],
1493
+ ownArityNotes: [],
1494
+ calleeBodiesAsked: {},
1495
+ calleeStackSized: {},
495
1496
  };
496
1497
  }
497
1498