@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
@@ -21,7 +21,7 @@
21
21
  import { Block, Fn, Op, Successor, Value, mkOp, mkValue } from '../ir/core';
22
22
  import type { Opcode } from '../ir/opcodes';
23
23
  import { T } from '../ir/types';
24
- import { type Prototypes, protoArity } from '../proto';
24
+ import { type FnProto, type Prototypes, declaredWidth, protoArity } from '../proto';
25
25
  import { RUNTIME_HELPERS } from '../raise/softdiv';
26
26
  import { type SymbolMap, lookupInterior, lookupSymbol } from '../symbols';
27
27
  import type { TargetDescription } from '../target';
@@ -32,6 +32,7 @@ import { assertInputFormat } from './format';
32
32
  import type { Frontend } from './frontend';
33
33
  import { opaqueDest } from './opaque';
34
34
  import { abiSortEntryParams, fallbackArgc, makeSsaBuilder, slotKeyOffset, stackSlotKey } from './ssa';
35
+ import { type OutgoingArgs, type StackArgsEvent, analyzeOutgoingArgs } from './stackargs';
35
36
 
36
37
  interface Instr {
37
38
  /** the CANONICAL spelling — legacy names are normalised (see LEGACY_MNEMONICS) so that every
@@ -755,6 +756,28 @@ interface Resolved {
755
756
  witness: LayoutFact[];
756
757
  }
757
758
 
759
+ /** A GNU-as listing without its debug and stabs sections: every line from a `.section .debug_*` (or a
760
+ * `.stab*` section) up to the next section switch is dropped, and every other line is kept verbatim,
761
+ * so a listing with no debug section comes back unchanged. What agbcc `-g` adds to a `.s` beside
762
+ * the marker labels in its code (`corpus/agbcc-debug{,-g}.s`). */
763
+ export function withoutDebugSections(asm: string): string {
764
+ let inDebugSection = false;
765
+ const kept: string[] = [];
766
+ for (const rawLine of asm.split('\n')) {
767
+ const sectionSwitch = rawLine
768
+ .split('@')[0]
769
+ .trim()
770
+ .match(/^\.(?:section\s+([^\s,]+)|text\b|data\b|bss\b)/);
771
+ if (sectionSwitch) {
772
+ inDebugSection = sectionSwitch[1] !== undefined && /^\.(debug|stab)/.test(sectionSwitch[1]);
773
+ }
774
+ if (!inDebugSection) {
775
+ kept.push(rawLine);
776
+ }
777
+ }
778
+ return kept.join('\n');
779
+ }
780
+
758
781
  /** The canonical serialisation a witness is compared BY, and the prose it is reported AS. Keeping
759
782
  * them apart is the point: rewording `sayFact` changes a message, never a decision. */
760
783
  const factKey = (f: LayoutFact) => JSON.stringify(f);
@@ -806,13 +829,36 @@ function decode(
806
829
  let dataLabel: string | null = null;
807
830
  let pendingFn = false;
808
831
  let pendingArm = false;
809
- for (const rawLine of asm.split('\n')) {
810
- let rest = rawLine.split('@')[0].trim();
811
- if (!rest) {
812
- continue;
832
+ // DEBUG OUTPUT IS NEITHER CODE NOR DATA THE CODE READS. agbcc `-g` leaves `.text` byte-identical
833
+ // (`corpus/agbcc-debug{,-g}.s`), so the lift reads exactly the function it reads without `-g`, and
834
+ // two things are dropped:
835
+ // • every debug or stabs section (`withoutDebugSections`): the debug rows, their labels, and the
836
+ // data a trailing `.Letext0` label would otherwise head;
837
+ // • the marker labels `-g` plants in the code stream (`.LFB1`, `.LM3`, `.LBB2`, `.LBE2`, `.LFE1`,
838
+ // `.Letext0`) that no code line names. A label starts a block, and a block split at a
839
+ // lexical-scope marker restructures the loop around it: kept, it lifts the pair's `gcd` loop as
840
+ // an `if` around a `do` instead of a `while`.
841
+ const codeLines: string[] = [];
842
+ for (const rawLine of withoutDebugSections(asm).split('\n')) {
843
+ const line = rawLine.split('@')[0].trim();
844
+ if (line) {
845
+ codeLines.push(line);
846
+ }
847
+ }
848
+ const DEBUG_MARKER = /(?<![\w.$])\.L(?:FB|FE|M|BB|BE|etext)\d+\b/g;
849
+ const namedByCode = new Set<string>();
850
+ for (const line of codeLines) {
851
+ for (const m of line.replace(/^[A-Za-z_.$][\w.$]*:\s*/, '').matchAll(DEBUG_MARKER)) {
852
+ namedByCode.add(m[0]);
813
853
  }
854
+ }
855
+ const isDebugMarker = (label: string) => /^\.L(?:FB|FE|M|BB|BE|etext)\d+$/.test(label) && !namedByCode.has(label);
856
+ for (let rest of codeLines) {
814
857
  // A label may share the line with what follows it (pret pools: `_08x: .4byte 0x…`) — peel it.
815
858
  const lm = rest.match(/^([A-Za-z_.$][\w.$]*):\s*(.*)$/);
859
+ if (lm && isDebugMarker(lm[1]) && lm[2] === '') {
860
+ continue;
861
+ }
816
862
  if (lm) {
817
863
  const lab = lm[1];
818
864
  if (pendingFn || pendingArm) {
@@ -2808,16 +2854,16 @@ export function lift(
2808
2854
  // local the compiler was entitled to put in place with no prologue at all.
2809
2855
  const ssa = makeSsaBuilder(name, asmBlocks.length, preds, () => ({
2810
2856
  ownedLocals: { from: 0, to: localArea },
2811
- // THE SAME RANGE, AND NOT THE SAME CLAIM. `ownedLocals` answers "is a def-less read here an
2812
- // uninitialised local?"; `declaredLocals` answers "is a spill here a DECLARATION RANK?", which
2813
- // `ir/core.ts` `SlotHomes` and `l3/slotorder.ts` consume. Under agbcc's
2814
- // ACCUMULATE_OUTGOING_ARGS the outgoing stack-argument area sits at the BOTTOM of `localArea`
2815
- // (see the decline below), so this range would be WRONG for the second question on any
2816
- // function that has one and it is written as the same range only because `prefixStored`
2817
- // declines every such function before it gets here. Lifting that decline obliges narrowing
2818
- // THIS range above the argument block; the two fields exist apart so that obligation is
2819
- // visible where it is created rather than inferred from a guard in another module.
2820
- declaredLocals: { from: 0, to: localArea },
2857
+ // NOT THE SAME RANGE, AND NOT THE SAME CLAIM. `ownedLocals` answers "is a def-less read here
2858
+ // an uninitialised local?" and the outgoing stack-argument area IS owned, so it starts at 0.
2859
+ // `declaredLocals` answers "is a spill here a DECLARATION RANK?", which `ir/core.ts`
2860
+ // `SlotHomes` and `l3/slotorder.ts` consume, and an argument slot's offset is an ABI POSITION,
2861
+ // not an `expand_decl` rank. Under agbcc's ACCUMULATE_OUTGOING_ARGS that area sits at the
2862
+ // BOTTOM of `localArea`, so the declared range starts where the largest licensed block ends
2863
+ // (`frontend/stackargs.ts`). A refusal there licenses nothing and reports `area` 0, so the two
2864
+ // ranges coincide exactly when no argument word was proved, and the narrowing can only ever
2865
+ // skip offsets a callee's declaration and this function's own stores agreed on.
2866
+ declaredLocals: { from: outgoingArgs.area, to: localArea },
2821
2867
  ...(target.nonArgRegs
2822
2868
  ? {
2823
2869
  uninitRegs: target.nonArgRegs.filter((r) => scratchRegs.has(r) || savedRegs.has(r)),
@@ -2982,7 +3028,7 @@ export function lift(
2982
3028
  // which the sp declines append, so a refused function names the capability actually missing
2983
3029
  // instead of the generic "local stack frames". The gap histogram is the improvement loop's
2984
3030
  // work-list; a misattributed refusal sends that loop to build the wrong thing.
2985
- const slotModelBlocker = (): string | null => {
3031
+ const slotModelBlocker = (outgoing: OutgoingArgs<Instr>): string | null => {
2986
3032
  for (const ab of asmBlocks) {
2987
3033
  for (const ins of ab.instrs) {
2988
3034
  const acc = spMemAccess(ins);
@@ -3042,213 +3088,12 @@ export function lift(
3042
3088
  }
3043
3089
  }
3044
3090
  }
3045
- // OUTGOING ARGUMENTS. agbcc reserves the BOTTOM of the frame for arguments 5+ of the calls this
3046
- // function makes: `add sp,sp,#-8` `str r2,[sp]` / `str r3,[sp,#4]` `bl callee`. Those
3047
- // offsets are inside the frame and nothing this function does ever reloads them, so modelling
3048
- // them as locals makes them dead defs and DCE deletes them — the arguments vanish from the call
3049
- // with no diagnostic. Ground truth: sa3's CreateEntity_Platform_0_0 (platform.c:734) forwards
3050
- // SIX arguments and came out as `CreateEntity_Platform(0, 0, a0, (u16)a1)`. "Inside my frame"
3051
- // does not mean "private": the outgoing area belongs to the callee, which may even assign to a
3052
- // stack parameter.
3053
- //
3054
- // How big is that area? A declared arity bounds it from BELOW — `4 * max(0, arity - 4)` — and
3055
- // that is all the facts available here can support. It is used in exactly one direction: to
3056
- // REFUSE. A callee declared with five parameters proves this frame has an outgoing area, and
3057
- // consuming those stores as call operands is the dual capability, unbuilt, so the model
3058
- // declines. A callee declared with four proves NOTHING, because a declaration is a lower bound
3059
- // on the words a call actually pushes:
3060
- //
3061
- // * a parameter may occupy more than one word (`double`, `long long`, a struct by value),
3062
- // * a variadic callee's list is a prefix — `sprintf` truthfully declares two and is handed six,
3063
- // * a large struct return adds a hidden pointer argument that appears in no parameter list.
3064
- //
3065
- // None of those is recorded by `FnProto` or `SymbolSignature`, so no arity here can license an
3066
- // ACCEPTANCE. An earlier cut treated `arity <= 4` as proof of an empty area and had all three
3067
- // holes: supplying a TRUE fact (`{ sprintf: { params: 2 } }`) turned a correct decline into
3068
- // `return sprintf(a0, a1)` with both stack arguments deleted, where supplying nothing declined.
3069
- // A fact must only ever move a function toward refusal — never toward an acceptance the facts
3070
- // do not entail. Refusing on a lower bound is monotone in exactly that way: a true arity larger
3071
- // than declared can only make the area bigger, and the answer is already "decline".
3072
- //
3073
- // Measured, this costs nothing it was buying: forcing the old acceptance path off changed 0
3074
- // lift/decline verdicts across 2686 corpus functions (sa3's vendored map carries no signatures
3075
- // at all), so the path that carried those holes was never load-bearing. That sweep was run over
3076
- // the sa3/klonoa CHECKOUTS, which this repo does not vendor — it is not the benchmark's 404
3077
- // agbcc rows, and it has not been re-run since it was taken.
3078
- for (const ab of asmBlocks) {
3079
- for (const ins of ab.instrs) {
3080
- if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3081
- continue;
3082
- }
3083
- const c = ins.ops[0] ?? '';
3084
- const arity = protoArity(prototypes[c]) ?? protoArity(RUNTIME_HELPERS[c]);
3085
- if (arity !== undefined && arity > target.argRegs.length) {
3086
- return `callee \`${c}\` is declared with ${arity} arguments, so this frame has an outgoing stack-argument area — consuming stack call arguments is not implemented`;
3087
- }
3088
- }
3089
- }
3090
- // Nothing above could prove the area empty, so fall back to reading the CODE. Two conditions,
3091
- // covering different escapes:
3092
- // (a) every slot store must be reloaded somewhere reachable. An outgoing argument is read by
3093
- // the CALLEE, never by the caller, so a store never read back is the signature of one.
3094
- // (b) no slot store may reach a `bl` unread ALONG A PATH.
3095
- //
3096
- // Neither is sound alone and the pair is not either, so keep two things straight. (a)'s real
3097
- // theorem is not "the callee reads it, the caller does not" — it is that agbcc's
3098
- // ACCUMULATE_OUTGOING_ARGS puts the outgoing area at the BOTTOM of localArea, disjoint from the
3099
- // locals, so no local load can land on an argument offset. That disjointness is what a
3100
- // tail-merged call site breaks, and agbcc DOES tail-merge: `Task_BonusFlower_Spawn` (sa3
3101
- // bonus_game_enemies) stores argument 5 in both predecessors with the `bl` in the join.
3102
- //
3103
- // A SECOND THING NOW DEPENDS ON THIS DECLINE, and it is not in this file. `SlotHomes`
3104
- // (ir/core.ts) reads a `[sp,#k]` spill as a DECLARATION RANK and `l3/slotorder.ts` orders the
3105
- // declaration list by it. The partition that admits an offset is `LiveInModel.declaredLocals`,
3106
- // which this frontend sets to `[0, localArea)` — a range that CONTAINS the outgoing area. The
3107
- // only thing keeping an argument slot from becoming a declaration rank is that this decline
3108
- // removes such functions from the population first. So lifting or narrowing this guard is not
3109
- // a local change: it must come with a narrowed `declaredLocals`, or the ordering starts
3110
- // ranking argument positions with no diagnostic. The class is populated — of 2,001 lifted real
3111
- // agbcc functions, 12 carry an L1 slot home AND call, and 11 of those home offset 0. "Real
3112
- // agbcc functions" there means a CHECKOUT sweep this repo does not vendor, not benchmark rows
3113
- // (the benchmark has 126 real agbcc rows in total), and it has not been re-run since.
3114
- //
3115
- // (b) is a forward may-analysis over the CFG, and it has been wrong twice in the other
3116
- // direction. Scanning per block let a LABEL decide accept versus refuse; scanning the flat
3117
- // listing let BLOCK ORDER decide, because a load in one arm of a branch cleared a store that
3118
- // reaches the call through the other arm — swap the arms in the listing, same CFG and same
3119
- // semantics, and the verdict flipped. Only the path-sensitive form is stable under layout.
3120
- //
3121
- // Only for a function that CALLS. With no call there is no outgoing area to mistake a local
3122
- // for, and a never-reloaded store there is an ordinary dead local — which PR #30 modelled and
3123
- // which must keep working.
3124
- //
3125
- // …and not when the WHOLE FRAME is an object whose address a callee holds
3126
- // (`capturedObjectIsTheWholeFrame` — a one-word frame, passed by a bare `mov rD, sp`). Both
3127
- // conditions hunt for an argument block; every argument block starts at [sp,#0] (that is what
3128
- // `prefixStored` encodes); and a one-word frame that is entirely an addressable local has no
3129
- // room for one. So there is no argument block to find and both conditions can only fire as
3130
- // FALSE ALARMS — which is what they did: the three address-taken rows in the synthetic tier
3131
- // declined at (a) on a store that is never reloaded for the ordinary reason, that the CALLEE
3132
- // reads it through the pointer.
3133
- //
3134
- // This is the only acceptance in this function, so keep straight what licenses it. NOT arity: a
3135
- // callee declared with four arguments proves nothing (see above), and the layout gate is
3136
- // deliberately independent of the declarations. The arity refusal still runs FIRST and still
3137
- // wins — in a one-word frame a declared fifth argument and an addressable local at offset 0 are
3138
- // contradictory claims about the same word, so the honest answer there is the decline, not a
3139
- // guess about which one to believe.
3140
- if (
3141
- !capturedObjectIsTheWholeFrame &&
3142
- asmBlocks.some((ab) => ab.instrs.some((i) => i.mnemonic === 'bl' || i.mnemonic === 'blx'))
3143
- ) {
3144
- const slotAcc = (ins: Instr) => {
3145
- const a = spMemAccess(ins);
3146
- return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off + 4 <= localArea
3147
- ? a.off
3148
- : null;
3149
- };
3150
- const isStore = (ins: Instr) => /^str/.test(ins.mnemonic);
3151
- // Entry-REACHABLE blocks only: a reload in dead code is not evidence that live code reads the
3152
- // slot back, and counting it lets an argument store satisfy (a) on the strength of an
3153
- // instruction that never executes.
3154
- const live = entryReachable;
3155
- const reloaded = new Set<number>();
3156
- for (const b of live) {
3157
- for (const ins of asmBlocks[b].instrs) {
3158
- const off = slotAcc(ins);
3159
- if (off !== null && !isStore(ins)) {
3160
- reloaded.add(off);
3161
- }
3162
- }
3163
- }
3164
- // CONTIGUITY. AAPCS lays the outgoing stack arguments at [sp,#0] upward, one word each, so an
3165
- // argument block is CONTIGUOUS FROM ZERO: a store at [sp,#4] can be argument 6 of a call only
3166
- // if argument 5 at [sp,#0] is also supplied on a path to that same call. A pending store
3167
- // whose lower slots are nowhere supplied is therefore provably not an argument block, and
3168
- // refusing it is a false alarm — the exact false alarm that blocked the commonest real shape,
3169
- // a value spilled at [sp,#4] and kept live across calls (kleod's ProcessInputAndUpdateEntities
3170
- // stores its `sp4` local and calls m4aSongNumStart 80 lines later, with offset 0 never stored
3171
- // in the whole function).
3172
- //
3173
- // The calibration in this: a conforming caller stores EVERY argument slot of a call it makes,
3174
- // so "slot 0 unsupplied" rules out "slot 4 is an argument". Hand-written asm could skip
3175
- // storing an argument the callee never reads; agbcc cannot (no interprocedural dead-argument
3176
- // elimination). That is the same producer assumption the reload conditions above already
3177
- // make, stated once here.
3178
- const prefixStored = (k: number, st: Set<number>): boolean => {
3179
- for (let j = 0; j < k; j += 4) {
3180
- if (!st.has(j)) {
3181
- return false;
3182
- }
3183
- }
3184
- return true;
3185
- };
3186
- // (a), contiguity-filtered: a store never reloaded ANYWHERE is an argument's signature only
3187
- // if its lower slots are supplied somewhere too; otherwise it is an ordinary dead local.
3188
- const storedAnywhere = new Set<number>();
3189
- for (const b of live) {
3190
- for (const ins of asmBlocks[b].instrs) {
3191
- const off = slotAcc(ins);
3192
- if (off !== null && isStore(ins)) {
3193
- storedAnywhere.add(off);
3194
- }
3195
- }
3196
- }
3197
- for (const off of storedAnywhere) {
3198
- if (!reloaded.has(off) && prefixStored(off, storedAnywhere)) {
3199
- return `the store to [sp,#${off}] is never reloaded and its lower slots are supplied — it may be an outgoing stack argument of one of this function's calls`; // (a)
3200
- }
3201
- }
3202
- // (b): `pendingOut[b]` = offsets stored and not yet reloaded on SOME path through b;
3203
- // `storedOut[b]` = offsets stored on SOME path through b (a reload does not remove the value
3204
- // from memory, so it does not remove the offset from this set — the callee would still read
3205
- // what the store put there).
3206
- const pendingOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
3207
- const storedOut: Array<Set<number>> = asmBlocks.map(() => new Set<number>());
3208
- for (let changed = true; changed;) {
3209
- changed = false;
3210
- for (let b = 0; b < asmBlocks.length; b++) {
3211
- if (!live.has(b)) {
3212
- continue;
3213
- }
3214
- const pend = new Set<number>();
3215
- const st = new Set<number>();
3216
- for (const q of preds[b]) {
3217
- for (const off of pendingOut[q]) {
3218
- pend.add(off);
3219
- }
3220
- for (const off of storedOut[q]) {
3221
- st.add(off);
3222
- }
3223
- }
3224
- for (const ins of asmBlocks[b].instrs) {
3225
- const off = slotAcc(ins);
3226
- if (off !== null) {
3227
- if (isStore(ins)) {
3228
- pend.add(off);
3229
- st.add(off);
3230
- } else {
3231
- pend.delete(off);
3232
- }
3233
- } else if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
3234
- for (const k of pend) {
3235
- if (prefixStored(k, st)) {
3236
- // (b) — a plausible argument block reaches this call unread
3237
- return `the store to [sp,#${k}] reaches \`bl ${ins.ops[0] ?? '?'}\` unread with its lower slots supplied — it may be that call's outgoing stack argument`;
3238
- }
3239
- }
3240
- }
3241
- }
3242
- const grow = (out: Array<Set<number>>, cur: Set<number>): void => {
3243
- if (cur.size !== out[b].size || [...cur].some((o) => !out[b].has(o))) {
3244
- out[b] = cur;
3245
- changed = true;
3246
- }
3247
- };
3248
- grow(pendingOut, pend);
3249
- grow(storedOut, st);
3250
- }
3251
- }
3091
+ // OUTGOING ARGUMENTS one analysis, and its refusals are this guard's. `analyzeOutgoingArgs`
3092
+ // decides per call whether the words staged at the bottom of the frame are a callee's
3093
+ // arguments; everything it cannot license refuses here, so a `[sp,#k]` access in such a
3094
+ // function still declines loud.
3095
+ if (outgoing.blocker !== null) {
3096
+ return outgoing.blocker;
3252
3097
  }
3253
3098
  // A `pop`/`ldm` off sp READS frame memory, and push/pop are transparent to dataflow, so a pop
3254
3099
  // taken while the local area is still reserved reads a slot this model has retargeted into SSA
@@ -3469,7 +3314,123 @@ export function lift(
3469
3314
  // widened to `localArea >= 4` (measured). No answer to the frame size moves it.
3470
3315
  const capturedObjectIsTheWholeFrame = target.compiler === 'agbcc' && frameBasePassedToCallee && localArea === 4;
3471
3316
 
3472
- const slotsOffReason = slotModelBlocker();
3317
+ // THE OUTGOING STACK-ARGUMENT AREA. `frontend/stackargs.ts` holds the licence and every refusal;
3318
+ // what belongs HERE is the decoding it deliberately does not do — which accesses are whole frame
3319
+ // slots, which instructions are calls, and what each callee's DECLARATION asks for. `blx rN`
3320
+ // names a REGISTER in the operand slot, so it matches no prototype and its block is null, which
3321
+ // is correct: nothing here knows what an indirect call takes.
3322
+ //
3323
+ // A WHOLE WORD OF THE RESERVED LOCAL AREA is the same bytes `isOwnFrameWordSlot` models, minus
3324
+ // `slotsOk` — which is the answer this analysis is being asked to help compute.
3325
+ const slotAcc = (ins: Instr): number | null => {
3326
+ const a = spMemAccess(ins);
3327
+ return a && !a.regOff && a.width === 4 && a.off % 4 === 0 && a.off >= 0 && a.off + 4 <= localArea ? a.off : null;
3328
+ };
3329
+ // WHAT ONE CALLEE'S DECLARATION SAYS — the ONE place that reads it. The analysis below and the
3330
+ // `bl` lowering both come through here, so the arity that LICENSED a block and the arity that
3331
+ // CONSUMES it cannot drift apart; a disagreement between two spellings of this lookup would read
3332
+ // `r4` as argument 5 or throw a slot-model error naming the wrong thing.
3333
+ //
3334
+ // WHERE THE BLOCK IS is `compilerBehaviors.stagesOutgoingArgsInFrame`, not an assumption: the
3335
+ // area sits at the bottom of the frame this function reserved because agbcc's thumb.h defines
3336
+ // ACCUMULATE_OUTGOING_ARGS. A compiler that does not claim it stages nothing here, and every
3337
+ // call keeps the refusal it had before the licence existed.
3338
+ //
3339
+ // THE BLOCK IS WORDS AND THE ARITY IS PARAMETERS, which are the same number only while every
3340
+ // parameter occupies exactly one word: AAPCS lays arguments 5..n at [sp,#0] upward, one word
3341
+ // each, and the lowering maps parameter k to word k - |argRegs|. A `double`, a `long long` or a
3342
+ // by-value struct breaks both halves at once — it adds words AND moves every later argument's
3343
+ // home — so the premise is checked here rather than assumed from a distant module.
3344
+ //
3345
+ // WHAT THE CHECK CAN SEE. Only the TYPED form of `params` carries spellings, and `declaredWidth`
3346
+ // answers for every type asmlift can spell; a width it cannot read is the only evidence that a
3347
+ // parameter may be wider than a word, so such a declaration sizes no block and refuses
3348
+ // (`unsizableDeclaration`). That is not merely a message: with a wide parameter the two witnesses
3349
+ // can AGREE by coincidence — `void fd(s32, s32, s32, s32, double)` staged as two words matches a
3350
+ // six-parameter list whose fifth entry is `double`, and consuming it would hand the callee six
3351
+ // arguments. The COUNT form (`{ params: 5 }`) carries no spellings at all: it is the user's word
3352
+ // for how many WORDS the call takes, and a count that lies is garbage in — `validatePrototypes`
3353
+ // can no more check it than it can check `returnsVoid`.
3354
+ //
3355
+ // The machine-derived side upholds the premise at its source: `prototypesFromSymbols` drops a
3356
+ // whole entry rather than spell a parameter that is not 1, 2 or 4 bytes (test/proto.test.ts).
3357
+ const wideParam = (p: FnProto | undefined): string | null =>
3358
+ (Array.isArray(p?.params) ? p.params : []).find((t) => {
3359
+ const w = declaredWidth(t);
3360
+ return w === undefined || w > 32;
3361
+ }) ?? null;
3362
+ // `block` is null for the two cases that license nothing: an arity that fits in registers (there
3363
+ // IS no outgoing block) and one this frontend cannot lay out (`wide` names the parameter, and
3364
+ // `unsizableDeclaration` below turns it into the refusal). The analysis is told null for both —
3365
+ // it may license neither — and only the second is an error to report.
3366
+ const declaredCall = (
3367
+ callee: string,
3368
+ ): { arity: number; block: readonly number[] | null; wide: string | null } | null => {
3369
+ const own = prototypes[callee];
3370
+ const proto = protoArity(own) !== undefined ? own : RUNTIME_HELPERS[callee];
3371
+ const arity = protoArity(proto);
3372
+ if (arity === undefined) {
3373
+ return null;
3374
+ }
3375
+ const words = arity - target.argRegs.length;
3376
+ if (words <= 0 || target.compilerBehaviors.stagesOutgoingArgsInFrame !== true) {
3377
+ // No outgoing block exists to lay out: it all fits in registers, or this compiler does not
3378
+ // claim to stage arguments inside the caller's own frame at all.
3379
+ return { arity, block: null, wide: null };
3380
+ }
3381
+ const wide = wideParam(proto);
3382
+ return {
3383
+ arity,
3384
+ block: wide === null ? Array.from({ length: words }, (_, i) => 4 * i) : null,
3385
+ wide,
3386
+ };
3387
+ };
3388
+ const outgoingArgs = analyzeOutgoingArgs<Instr>({
3389
+ blocks: asmBlocks.map((ab) => ({
3390
+ events: ab.instrs.flatMap((ins): StackArgsEvent<Instr>[] => {
3391
+ const off = slotAcc(ins);
3392
+ if (off !== null) {
3393
+ return [{ kind: /^str/.test(ins.mnemonic) ? 'store' : 'load', off }];
3394
+ }
3395
+ if (ins.mnemonic === 'bl' || ins.mnemonic === 'blx') {
3396
+ const callee = ins.ops[0] ?? '?';
3397
+ return [{ kind: 'call', call: ins, callee, declared: declaredCall(callee)?.block ?? null }];
3398
+ }
3399
+ return [];
3400
+ }),
3401
+ })),
3402
+ preds,
3403
+ live: entryReachable,
3404
+ localArea,
3405
+ argRegs: target.argRegs.length,
3406
+ capturedWholeFrame: capturedObjectIsTheWholeFrame,
3407
+ });
3408
+
3409
+ // A DECLARATION THIS FRONTEND CANNOT LAY OUT refuses for the whole function, ahead of every other
3410
+ // slot-model refusal, because it is the most specific thing that was seen. The analysis is never
3411
+ // told a block for such a call, so it can license nothing either way; what this adds is the
3412
+ // message, which names the parameter rather than a staged word that happened to disagree.
3413
+ const unsizableDeclaration = ((): string | null => {
3414
+ for (const ab of asmBlocks) {
3415
+ for (const ins of ab.instrs) {
3416
+ if (ins.mnemonic !== 'bl' && ins.mnemonic !== 'blx') {
3417
+ continue;
3418
+ }
3419
+ const callee = ins.ops[0] ?? '?';
3420
+ const declared = declaredCall(callee);
3421
+ if (declared !== null && declared.wide !== null) {
3422
+ return (
3423
+ `callee \`${callee}\` is declared with ${declared.arity} arguments and its parameter type \`${declared.wide}\` ` +
3424
+ 'is one asmlift cannot size — a parameter wider than one word moves every later argument home, ' +
3425
+ "so this frame's outgoing stack-argument block cannot be laid out"
3426
+ );
3427
+ }
3428
+ }
3429
+ }
3430
+ return null;
3431
+ })();
3432
+
3433
+ const slotsOffReason = unsizableDeclaration ?? slotModelBlocker(outgoingArgs);
3473
3434
  const slotsOk = slotsOffReason === null;
3474
3435
  // Every offset the body actually keys as an SSA slot — the frame-object audit checks the
3475
3436
  // address-taken object cannot overlap one (two models for one byte is a silent disagreement).
@@ -4156,11 +4117,27 @@ export function lift(
4156
4117
  const targetSym = a;
4157
4118
  // Caller-supplied prototype wins; otherwise a known runtime helper (`__divsi3` &c.)
4158
4119
  // supplies its arity so its arguments are recovered; only then fall back to guessing.
4159
- const declared = protoArity(prototypes[targetSym]) ?? protoArity(RUNTIME_HELPERS[targetSym]);
4160
- const argc = declared ?? fallbackArgcHere(bi);
4120
+ const declared = declaredCall(targetSym);
4121
+ const argc = declared?.arity ?? fallbackArgcHere(bi);
4122
+ // ARGUMENTS BEYOND THE REGISTERS come out of this frame's outgoing area, at [sp,#0]
4123
+ // upward — the block `analyzeOutgoingArgs` licensed for THIS call, and only that block.
4124
+ // `fallbackArgcHere` never exceeds `argRegs.length`, so an unlicensed stack argument can
4125
+ // only come from a declaration, and the analysis has already refused that function;
4126
+ // reaching here with no block means the slot model is off for another reason, and the
4127
+ // decline names it rather than reading `r4` as if it were argument 5.
4128
+ const stackArgs = slotsOk ? outgoingArgs.blocks.get(ins) : undefined;
4161
4129
  const args: Value[] = [];
4162
4130
  for (let k = 0; k < argc; k++) {
4163
- args.push(readVar(`r${k}`, bi));
4131
+ if (k < target.argRegs.length) {
4132
+ args.push(readVar(`r${k}`, bi));
4133
+ continue;
4134
+ }
4135
+ const off = stackArgs?.[k - target.argRegs.length];
4136
+ if (off === undefined) {
4137
+ throw spAsDataError();
4138
+ }
4139
+ usedSlotOffsets.add(off);
4140
+ args.push(readVar(slotKey(off), bi));
4164
4141
  }
4165
4142
  const res = mkValue(T.unk(32));
4166
4143
  const callOp = mkOp('call', { operands: args, results: [res], attrs: { target: targetSym } });
@@ -4168,7 +4145,7 @@ export function lift(
4168
4145
  // A GUESSED arity is revisited in `finish()`: only once the whole function is lifted is it
4169
4146
  // known whether every path to here passes through another call, which would have clobbered
4170
4147
  // the argument registers this guess just read.
4171
- if (declared === undefined) {
4148
+ if (declared === null) {
4172
4149
  ssa.recordGuessedCall(callOp, bi, target);
4173
4150
  }
4174
4151
  writeData('r0', bi, res); // the callee defines r0 …
@@ -4195,7 +4172,7 @@ export function lift(
4195
4172
  } else if (!last) {
4196
4173
  // an EMPTY block is only ever the synthetic entry preheader (decoded blocks are non-empty):
4197
4174
  // fall through to the real entry, whose loop-header phis take their entry operand from here.
4198
- irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
4175
+ irb.ops.push(mkOp('br', { attrs: { fallthrough: true }, successors: [succ(fallLabel(bi))] }));
4199
4176
  } else if (kind === 'return') {
4200
4177
  // bx lr / pop {…,pc} / mov pc,lr
4201
4178
  //
@@ -4227,7 +4204,7 @@ export function lift(
4227
4204
  irb.ops.push(mkOp('cond_br', { operands: [cond], successors: [succ(last.ops[0]), succ(fallLabel(bi))] }));
4228
4205
  } else {
4229
4206
  // fallthrough (last instruction is a call / data op, no control transfer)
4230
- irb.ops.push(mkOp('br', { successors: [succ(fallLabel(bi))] }));
4207
+ irb.ops.push(mkOp('br', { attrs: { fallthrough: true }, successors: [succ(fallLabel(bi))] }));
4231
4208
  }
4232
4209
  };
4233
4210
 
package/src/ir/core.ts CHANGED
@@ -53,8 +53,55 @@ export interface Fn {
53
53
  * so at its own definition), and a fact the structurer reads but the score probe's clone drops
54
54
  * makes that probe's delta a fact about a program asmlift does not emit. */
55
55
  slotHomes: SlotHomes | undefined;
56
+ /** L1 SIDE DATA (see {@link ParamEvidence}); set by the SSA builder, `undefined` on parsed IR.
57
+ * REQUIRED-but-possibly-undefined for exactly the reason `writeOrder` and `slotHomes` are. */
58
+ paramEvidence: ParamEvidence | undefined;
56
59
  }
57
60
 
61
+ /** What the machine's own object shows about each ENTRY PARAMETER, beyond the value graph — two
62
+ * observations the lift destroys, recorded so raise/paramwidth.ts can read them. Every entry
63
+ * parameter of a lifted function has an entry; a `Fn` with no map is one nobody measured.
64
+ *
65
+ * BOTH ARE OBSERVATIONS, NOT VERDICTS. What a compiler's object shows for a narrow DECLARED
66
+ * parameter is a fact about a TARGET, and this builder holds none; `target.ts`
67
+ * `compilerBehaviors.narrowParamWitness` decides what the pair means, and the disassemblies for
68
+ * each value live there.
69
+ *
70
+ * `deadHome` — THE STORE DOES NOT REACH THE IR. Both slot-modelling frontends spell a word
71
+ * sp-relative store as a write to the SSA key `sp@k` instead of a `store` op (`stackSlotKey`), so
72
+ * a slot nothing reloads has no reader, no op and no value: it simply is not there by L1. A
73
+ * parameter stored to two slots, one of them reloaded, still counts — the dead store happened —
74
+ * and the reader's own gates decide what a parameter with that much traffic may become.
75
+ *
76
+ * It asks NO FRAME PARTITION, and that is the difference from {@link SlotHomes}. A slot home is a
77
+ * DECLARATION RANK, so `sp@40` had to be classified as this function's local before it could be
78
+ * stamped, and MIPS declares no partition and so stamps none. "Stored here and never read back"
79
+ * needs no such classification: it is a statement about the store, true at any offset.
80
+ *
81
+ * `selfRedefined` — THE REGISTER IDENTITY DOES NOT REACH THE IR EITHER. SSA renames, so nothing
82
+ * downstream can tell a value the machine put back in the argument's OWN register from one it put
83
+ * in a scratch. This records the first: the first write to the parameter's register, in the entry
84
+ * block, is a value that parameter itself feeds. First and not any — a later write is a reuse of a
85
+ * register the parameter is done with.
86
+ *
87
+ * ENTRY PARAMETERS ONLY, for both. A dead spill of an ordinary value is a dead spill, and an
88
+ * ordinary register's self-update is arithmetic; it is the incoming ARGUMENT that carries a
89
+ * declaration.
90
+ *
91
+ * AND SO NEITHER FOLLOWS `replaceAllUsesWith`, where a {@link SlotHomes} entry does. A frame
92
+ * coordinate belongs to whichever value the structurer will name, so it travels with the uses;
93
+ * these are facts about the ARGUMENT REGISTER the machine was handed, and no other value can come
94
+ * to have been that argument. The score probe's clone re-keys the map (`cli/src/report.ts`)
95
+ * because a clone mints new `Value` objects for the same parameters; nothing else moves it. */
96
+ export interface ParamObservation {
97
+ /** the machine stored this parameter to a stack slot no load reads back (the ABI argument home) */
98
+ deadHome: boolean;
99
+ /** the first entry-block write to this parameter's own register is a value the parameter feeds */
100
+ selfRedefined: boolean;
101
+ }
102
+
103
+ export type ParamEvidence = ReadonlyMap<Value, ParamObservation>;
104
+
58
105
  /** Which `[sp,#k]` the machine homed a value at — the frame coordinate, carried L1 → L3.
59
106
  *
60
107
  * The coordinate exists only in the frontends: they record a word sp-relative slot's value in SSA
@@ -69,9 +116,10 @@ export interface Fn {
69
116
  *
70
117
  * DECLARES, NOT OWNS, AND THE DIFFERENCE IS AN AGBCC FACT WITH A LIVE DEPENDENCY: `ownedLocals`,
71
118
  * the partition a def-less READ asks, admits agbcc's outgoing stack-argument area, and an offset
72
- * there is an ABI position rather than an `expand_decl` rank. Under Thumb the two ranges are
73
- * written equal, and a decline rather than a proof is what makes that safe — see `declaredLocals`
74
- * (frontend/ssa.ts) for the dependency and what lifting it obliges.
119
+ * there is an ABI position rather than an `expand_decl` rank. Under Thumb the declared range
120
+ * therefore starts above that area, where the largest outgoing argument block the frontend could
121
+ * LICENSE ends — see `declaredLocals` (frontend/ssa.ts) for what the licence proves and for the
122
+ * frames it refuses outright.
75
123
  *
76
124
  * ONE consumer reads it for its content — the structurer, which turns it into
77
125
  * `SFn.locals[i].slots`; everything else only carries it (`replaceAllUsesWith`, the report's
@@ -194,6 +242,17 @@ export function terminator(b: Block): Op | undefined {
194
242
  return b.ops[b.ops.length - 1];
195
243
  }
196
244
 
245
+ /** The layout fall-through stamp alone (`opcodes.ts` declares it on `br` and on `ret`). A pass that
246
+ * moves a `ret` ONTO an edge takes the edge's own stamp with it and nothing else: the rest of a
247
+ * terminator's attrs describe the branch, not the arrival.
248
+ *
249
+ * A GUARD rather than a fix — `fallthrough` is the only attr anything sets on a `br`, so copying
250
+ * the whole bag would behave identically — spelled so that the next attr a frontend invents cannot
251
+ * ride onto an edge it says nothing about. */
252
+ export function fallThroughOf(term: Op): Op['attrs'] {
253
+ return term.attrs.fallthrough === true ? { fallthrough: true } : {};
254
+ }
255
+
197
256
  /** The successor blocks of `b`, read off its terminator. */
198
257
  export function successorsOf(b: Block): Block[] {
199
258
  return terminator(b)?.successors.map((s) => s.block) ?? [];
package/src/ir/opcodes.ts CHANGED
@@ -177,7 +177,16 @@ export const OPCODES = {
177
177
  // no more reapable than a dead `call`.
178
178
  opaque: { operands: 'variadic', results: 1, effects: true },
179
179
  // --- terminators ---
180
+ // `fallthrough: true` — OPTIONAL, the same fact as on `br`: a `ret` SUNK onto one edge
181
+ // (`raise/retsink.ts`, `raise/tailsink.ts`) replaces that edge's `br` and takes its stamp, so
182
+ // `structure/retspell.ts` can still read how the machine arrived. A `ret` a frontend built carries
183
+ // nothing, and is read by its block's in-edges instead.
180
184
  ret: { operands: 'variadic', results: 0, terminator: true, successors: 0 },
185
+ // `fallthrough: true` — OPTIONAL, set by a frontend when the block carried NO control-transfer
186
+ // instruction and this `br` stands for the machine simply running into the next block. Its
187
+ // absence therefore means a real branch instruction. The one reader is `structure/retspell.ts`,
188
+ // which needs to tell a `b <label>` (a transfer the source asked for) from layout; a pass that
189
+ // builds a fresh `br` leaves it off and is read as a branch, which is the conservative answer.
181
190
  br: { operands: 0, results: 0, terminator: true, successors: 1 },
182
191
  cond_br: { operands: 1, results: 0, terminator: true, successors: 2 },
183
192
  // Many-way switch dispatch (Regime B, jump table). The single operand is the scrutinee;
package/src/ir/parse.ts CHANGED
@@ -121,7 +121,13 @@ export function parse(text: string): Fn {
121
121
 
122
122
  // No write order: the text form is the value graph, and the record is a measurement of the
123
123
  // MACHINE. A parsed fn's edges are UNMEASURED, never written-nowhere (ir/core.ts `WriteOrder`).
124
- return { name, blocks: rawBlocks.map((r) => r.block), writeOrder: undefined, slotHomes: undefined };
124
+ return {
125
+ name,
126
+ blocks: rawBlocks.map((r) => r.block),
127
+ writeOrder: undefined,
128
+ slotHomes: undefined,
129
+ paramEvidence: undefined,
130
+ };
125
131
  }
126
132
 
127
133
  /** Drop the two annotations `print(fn, { writeOrder: true })` appends, and NOTHING else.