@asmlift/core 0.7.0 → 0.8.1
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.
- package/README.md +48 -24
- package/package.json +1 -1
- package/src/backend/pascal.ts +2 -2
- package/src/codegen-flags.ts +640 -0
- package/src/frontend/disasm.ts +141 -11
- package/src/frontend/high-half.ts +149 -0
- package/src/frontend/mips.ts +458 -209
- package/src/frontend/ppc.ts +332 -67
- package/src/frontend/reloc-symbol.ts +109 -0
- package/src/frontend/splat.ts +56 -18
- package/src/frontend/ssa.ts +126 -29
- package/src/frontend/stackargs.ts +420 -0
- package/src/frontend/thumb.ts +207 -230
- package/src/ir/core.ts +62 -3
- package/src/ir/opcodes.ts +9 -0
- package/src/ir/parse.ts +7 -1
- package/src/l3/advance.ts +2 -2
- package/src/l3/argbase.ts +2 -2
- package/src/l3/argcopy.ts +269 -0
- package/src/l3/ast.ts +45 -1
- package/src/l3/basecse.ts +2 -2
- package/src/l3/coalesce.ts +109 -52
- package/src/l3/scopebase.ts +4 -4
- package/src/l3/tailret.ts +70 -0
- package/src/l3/unmerge.ts +2 -2
- package/src/l3/unreduce.ts +2 -1
- package/src/mangle.ts +49 -0
- package/src/pattern/engine.ts +128 -13
- package/src/pipeline.ts +22 -11
- package/src/raise/extscale.ts +5 -2
- package/src/raise/paramwidth.ts +111 -3
- package/src/raise/pre-recovery.ts +11 -1
- package/src/raise/retsink.ts +8 -4
- package/src/raise/tailsink.ts +17 -2
- package/src/rank-declare.ts +17 -9
- package/src/rank.ts +54 -20
- package/src/structure/retspell.ts +95 -0
- package/src/structure/structure.ts +12 -3
- package/src/structure/switch-recover.ts +1 -1
- package/src/target.ts +224 -14
- package/src/trace.ts +27 -18
- package/src/variation-definitions.ts +52 -2
- package/src/variation-gates.ts +3 -0
- package/src/variation-tokens.ts +1 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/** THE OUTGOING STACK-ARGUMENT ANALYSIS, over digested slot events rather than instructions.
|
|
2
|
+
*
|
|
3
|
+
* NOTHING HERE DECODES. The frontend hands over, per basic block, the sequence of whole-word
|
|
4
|
+
* frame-slot accesses and calls it found, each call already carrying the block its callee's
|
|
5
|
+
* DECLARATION asks for; every mnemonic, addressing mode and prototype question is answered before
|
|
6
|
+
* the input is built. That split is what makes the fixpoint below testable on its own — it is the
|
|
7
|
+
* part most likely to be wrong, and a table of events is a fixture, where a table of Thumb is a
|
|
8
|
+
* second decoder.
|
|
9
|
+
*
|
|
10
|
+
* The call token `C` is opaque: the analysis only ever uses it as a map key, so the frontend can
|
|
11
|
+
* key by its own instruction object and a test by a string.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One event the analysis reads, in block order. A load and a store are the only frame accesses
|
|
15
|
+
* that matter — a sub-word or register-offset access is not a slot, and one anywhere turns the
|
|
16
|
+
* whole word-slot model off at the same gate this analysis's `blocker` feeds, so no access that
|
|
17
|
+
* could alias a licensed word is ever silently missing from the events. */
|
|
18
|
+
export type StackArgsEvent<C> = StackArgsSlot | StackArgsCall<C>;
|
|
19
|
+
|
|
20
|
+
/** A whole-word access to a reserved frame slot at `off`, which must lie inside `[0, localArea)`. */
|
|
21
|
+
export interface StackArgsSlot {
|
|
22
|
+
readonly kind: 'store' | 'load';
|
|
23
|
+
readonly off: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A call, and what its callee's DECLARATION says it takes on the stack. `declared` is the block
|
|
27
|
+
* `[0, 4*(n - argRegs))` as an ascending offset list, or null when nothing declares this call —
|
|
28
|
+
* an indirect call, a callee with no prototype, or one whose arity fits in registers. A null
|
|
29
|
+
* declaration can only ever lead to a refusal: the code alone cannot say where a block ENDS. */
|
|
30
|
+
export interface StackArgsCall<C> {
|
|
31
|
+
readonly kind: 'call';
|
|
32
|
+
/** The frontend's own handle for this call — the key of `OutgoingArgs.blocks`. */
|
|
33
|
+
readonly call: C;
|
|
34
|
+
/** The callee's name, for the refusal messages. `?` where the frontend has none. */
|
|
35
|
+
readonly callee: string;
|
|
36
|
+
readonly declared: readonly number[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One basic block's events. Where control goes afterwards is read out of `preds`, so a block is
|
|
40
|
+
* nothing but its slot-level events. */
|
|
41
|
+
export interface StackArgsBlock<C> {
|
|
42
|
+
readonly events: readonly StackArgsEvent<C>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StackArgsInput<C> {
|
|
46
|
+
/** Every block, live or not, indexed as `preds` and `live` index them. */
|
|
47
|
+
readonly blocks: readonly StackArgsBlock<C>[];
|
|
48
|
+
readonly preds: readonly (readonly number[])[];
|
|
49
|
+
/** The entry-reachable blocks. Dead blocks are still passed so a call in one is still SEEN —
|
|
50
|
+
* it stages nothing, so it refuses, which is the verdict that does not depend on deciding
|
|
51
|
+
* whether the block runs. */
|
|
52
|
+
readonly live: ReadonlySet<number>;
|
|
53
|
+
/** The frame this function reserved for itself, in bytes. 0 disables every slot. */
|
|
54
|
+
readonly localArea: number;
|
|
55
|
+
/** How many arguments the ABI passes in registers — where the stack block starts counting. */
|
|
56
|
+
readonly argRegs: number;
|
|
57
|
+
/** The frontend's verdict that the whole frame is one addressable object handed to a callee. */
|
|
58
|
+
readonly capturedWholeFrame: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What this function's calls do with the BOTTOM of its frame — the outgoing stack-argument area
|
|
62
|
+
* agbcc's ACCUMULATE_OUTGOING_ARGS reserves there for arguments 5+ of the calls it makes. */
|
|
63
|
+
export interface OutgoingArgs<C> {
|
|
64
|
+
/** Why no call here may be consumed, or null. The frontend returns it as its slot-model blocker,
|
|
65
|
+
* so a function whose area cannot be licensed declines at its first `[sp,#k]` access instead of
|
|
66
|
+
* lifting. */
|
|
67
|
+
blocker: string | null;
|
|
68
|
+
/** Per call, the frame offsets that call's stack arguments occupy — ascending and contiguous
|
|
69
|
+
* from zero. A call absent from the map passes everything in registers. */
|
|
70
|
+
blocks: ReadonlyMap<C, readonly number[]>;
|
|
71
|
+
/** The largest licensed block's extent. `[0, area)` is storage this function owns and does NOT
|
|
72
|
+
* declare, so it is where `LiveInModel.declaredLocals` starts. */
|
|
73
|
+
area: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isCallEvent = <C>(ev: StackArgsEvent<C>): ev is StackArgsCall<C> => ev.kind === 'call';
|
|
77
|
+
|
|
78
|
+
// THE OUTGOING STACK-ARGUMENT AREA, AND WHO MAY CONSUME IT.
|
|
79
|
+
//
|
|
80
|
+
// agbcc's ACCUMULATE_OUTGOING_ARGS reserves the BOTTOM of the frame for arguments 5+ of the calls
|
|
81
|
+
// this function makes: `add sp,sp,#-8` … `str r2,[sp]` / `str r3,[sp,#4]` … `bl callee`. Those
|
|
82
|
+
// offsets are inside the frame and nothing this function does ever reloads them. "Inside my
|
|
83
|
+
// frame" does not mean "private": the area belongs to the CALLEE, which may even assign to a
|
|
84
|
+
// stack parameter. Model those words as locals and they are dead defs that DCE deletes — the
|
|
85
|
+
// arguments vanish from the call with no diagnostic. Ground truth: sa3's
|
|
86
|
+
// CreateEntity_Platform_0_0 (platform.c:734) forwards SIX arguments and came out as
|
|
87
|
+
// `CreateEntity_Platform(0, 0, a0, (u16)a1)`.
|
|
88
|
+
//
|
|
89
|
+
// TWO INDEPENDENT WITNESSES MUST AGREE, and that agreement is the whole licence:
|
|
90
|
+
// * the DECLARATION says how many words a call takes. AAPCS lays arguments 5..n at [sp,#0]
|
|
91
|
+
// upward, one word each, so the block is `[0, 4*(n - |argRegs|))`, contiguous from zero.
|
|
92
|
+
// * the CODE says which words are staged for it — the offsets stored and not yet reloaded when
|
|
93
|
+
// the `bl` executes.
|
|
94
|
+
// Equal ⇒ consume. Anything else ⇒ decline, naming what was seen.
|
|
95
|
+
//
|
|
96
|
+
// WHY NEITHER WITNESS IS ENOUGH ALONE. A declared parameter list is a LOWER bound on the words a
|
|
97
|
+
// call pushes:
|
|
98
|
+
//
|
|
99
|
+
// * a parameter may occupy more than one word (`double`, `long long`, a struct by value),
|
|
100
|
+
// * a variadic callee's list is a prefix — `sprintf` truthfully declares two and is handed six,
|
|
101
|
+
// * a large struct return adds a hidden pointer argument that appears in no parameter list.
|
|
102
|
+
//
|
|
103
|
+
// None of those is recorded by `FnProto` or `SymbolSignature`, so an ARITY-ONLY acceptance had
|
|
104
|
+
// all three holes: supplying a TRUE fact (`{ sprintf: { params: 2 } }`) turned a correct decline
|
|
105
|
+
// into `return sprintf(a0, a1)` with both stack arguments deleted. Under the rule here the four
|
|
106
|
+
// words `sprintf` is really handed are four offsets reaching the call that its declaration does
|
|
107
|
+
// not account for, the witnesses disagree, and the answer is the decline again. And the CODE
|
|
108
|
+
// alone cannot say where a block ENDS — a store never reloaded is an argument's signature, but
|
|
109
|
+
// so is a dead local, which is why reading the code alone could only ever refuse (conditions (a)
|
|
110
|
+
// and (b) below, kept for every call no declaration covers).
|
|
111
|
+
//
|
|
112
|
+
// THE TWO SIDES ARE CHECKED AGAINST DIFFERENT SETS, and the asymmetry is the point.
|
|
113
|
+
// * NOTHING EXTRA is checked against the MAY set (stored and unreloaded on SOME path): the
|
|
114
|
+
// weakest thing that could still be a word this call takes must be inside the block.
|
|
115
|
+
// * NOTHING MISSING is checked against the MUST set (on EVERY path): a slot the callee reads
|
|
116
|
+
// must have been written on every path that reaches the call, or the argument is whatever
|
|
117
|
+
// the frame happened to hold. m2c renders that case as `ErrorExpr("Unable to find stack arg
|
|
118
|
+
// 0x0 in block")`; here it is a decline, and for the same reason — it is a GAP, and a gap
|
|
119
|
+
// must never render as a plausible value.
|
|
120
|
+
//
|
|
121
|
+
// WHAT "NOTHING EXTRA" COSTS, because the reach is narrower than the disappearance of the old
|
|
122
|
+
// decline suggests. A genuine SPILL that is live across a licensed call sits in the may set and is
|
|
123
|
+
// not in the declared block, so the call refuses — and that is agbcc's commonest frame with an
|
|
124
|
+
// outgoing area. Tolerating it means arguing that a pending word which is RELOADED later is a
|
|
125
|
+
// local rather than argument n+1, which needs a gate and a row that gate protects; none exists.
|
|
126
|
+
// The cost is in attribution, not correctness: the decline such a function gets names a STORE
|
|
127
|
+
// ("[sp,#k] also reaches the call unread") rather than the capability, so a gap histogram groups
|
|
128
|
+
// this class under that message and not under anything about stack arguments.
|
|
129
|
+
// The must set is an intersection over predecessors, which is exactly what a TAIL-MERGED call
|
|
130
|
+
// site needs: agbcc does tail-merge (`Task_BonusFlower_Spawn`, sa3 bonus_game_enemies, stores
|
|
131
|
+
// argument 5 in both predecessors with the `bl` in the join), and a one-armed store — the same
|
|
132
|
+
// shape with one predecessor not storing — is missing on a path and refuses.
|
|
133
|
+
//
|
|
134
|
+
// PATH-SENSITIVE, because the weaker forms have been wrong twice in the other direction:
|
|
135
|
+
// scanning per block let a LABEL decide accept versus refuse; scanning the flat listing let
|
|
136
|
+
// BLOCK ORDER decide, because a load in one arm of a branch cleared a store that reaches the
|
|
137
|
+
// call through the other arm — swap the arms, same CFG and same semantics, and the verdict
|
|
138
|
+
// flipped.
|
|
139
|
+
//
|
|
140
|
+
// A LICENSED CALL CONSUMES ITS BLOCK, which is what lets a function make several calls: the
|
|
141
|
+
// callee reads those words, so they stop being pending after it, exactly as a reload would end
|
|
142
|
+
// them. The deletion is driven by the DECLARATION alone, never by the licence, so the fixpoint
|
|
143
|
+
// cannot depend on its own outcome.
|
|
144
|
+
export function analyzeOutgoingArgs<C>({
|
|
145
|
+
blocks: asmBlocks,
|
|
146
|
+
preds,
|
|
147
|
+
live,
|
|
148
|
+
localArea,
|
|
149
|
+
argRegs,
|
|
150
|
+
capturedWholeFrame,
|
|
151
|
+
}: StackArgsInput<C>): OutgoingArgs<C> {
|
|
152
|
+
const refuse = (blocker: string): OutgoingArgs<C> => ({ blocker, blocks: new Map(), area: 0 });
|
|
153
|
+
// EVERY block, not the entry-reachable ones: a call in dead code stages nothing, so the
|
|
154
|
+
// dataflow below never finds its block and it refuses — which is the verdict it had before
|
|
155
|
+
// consumption existed. An unreachable `bl` is not evidence about the frame either way, and
|
|
156
|
+
// the loud answer is the one that does not depend on deciding which. The message a reader gets
|
|
157
|
+
// is the dataflow fact ("[sp,#0] is not stored on every path to the call"), not "this block is
|
|
158
|
+
// dead", and deliberately so: which blocks run is the question being refused, not an answer.
|
|
159
|
+
const calls = asmBlocks.flatMap((ab) => ab.events.filter(isCallEvent));
|
|
160
|
+
// ONLY FOR A FUNCTION THAT CALLS. With no call there is no outgoing area to mistake a local
|
|
161
|
+
// for, and a never-reloaded store is then an ordinary dead local — which PR #30 modelled and
|
|
162
|
+
// which must keep working.
|
|
163
|
+
if (calls.length === 0) {
|
|
164
|
+
return { blocker: null, blocks: new Map(), area: 0 };
|
|
165
|
+
}
|
|
166
|
+
const say = (offs: readonly number[]) => offs.map((o) => `[sp,#${o}]`).join(', ');
|
|
167
|
+
const arityOf = (offs: readonly number[]) => argRegs + offs.length;
|
|
168
|
+
|
|
169
|
+
// THE ONE-WORD CAPTURED FRAME. `capturedWholeFrame` says the whole frame is an
|
|
170
|
+
// object whose address a callee holds; a declared fifth argument says [sp,#0] is a DIFFERENT
|
|
171
|
+
// callee's argument slot. Two contradictory claims about the same word, and nothing here can
|
|
172
|
+
// decide which to believe, so the honest answer is the decline it has always been.
|
|
173
|
+
if (capturedWholeFrame) {
|
|
174
|
+
for (const ev of calls) {
|
|
175
|
+
const offs = ev.declared;
|
|
176
|
+
if (offs !== null) {
|
|
177
|
+
return refuse(
|
|
178
|
+
`callee \`${ev.callee}\` is declared with ${arityOf(offs)} arguments, so [sp,#0] is its outgoing stack argument — ` +
|
|
179
|
+
'but this one-word frame is an object whose address is passed to a callee, and the two name the same word',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Conditions (a) and (b) hunt for an argument block; every block starts at [sp,#0]; and a
|
|
184
|
+
// one-word frame that is entirely an addressable local has no room for one. So here they can
|
|
185
|
+
// only fire as FALSE ALARMS — which is what they did, declining the three address-taken
|
|
186
|
+
// synthetic rows on a store never reloaded for the ordinary reason, that the CALLEE reads it
|
|
187
|
+
// through the pointer.
|
|
188
|
+
return { blocker: null, blocks: new Map(), area: 0 };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const everySlot: number[] = [];
|
|
192
|
+
for (let o = 0; o + 4 <= localArea; o += 4) {
|
|
193
|
+
everySlot.push(o);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The forward dataflow, three sets per block. `may`/`must` are the pending stores (stored and
|
|
197
|
+
// not yet reloaded) on SOME / EVERY path; `stored` is every offset written on some path, which
|
|
198
|
+
// a reload does NOT remove — the callee would still read what the store put there, and it is
|
|
199
|
+
// what the contiguity filter asks. `must` is a meet-over-all-paths intersection, so it starts
|
|
200
|
+
// at every slot and shrinks (the entry block starts EMPTY: control arrives there from outside
|
|
201
|
+
// the function, storing nothing, whatever back edge also targets it).
|
|
202
|
+
//
|
|
203
|
+
// AND YES, `must` DUPLICATES THE SSA BUILDER'S REACHING-DEF QUERY, which is the same
|
|
204
|
+
// meet-over-all-paths question and already answers it for the `ldr` arm. The duplication is
|
|
205
|
+
// FORCED, not an oversight: the frontend must know `slotsOk` — whose answer is this analysis's
|
|
206
|
+
// `blocker` — BEFORE it fills a single block, and the SSA builder has no defs until the fill
|
|
207
|
+
// runs. Folding this into an `ssa.hasReachingDef` call makes the frontend ask a question whose
|
|
208
|
+
// answer depends on the question, so the next reader who spots the redundancy should stop here.
|
|
209
|
+
const mayOut = asmBlocks.map(() => new Set<number>());
|
|
210
|
+
const mustOut = asmBlocks.map(() => new Set(everySlot));
|
|
211
|
+
const storedOut = asmBlocks.map(() => new Set<number>());
|
|
212
|
+
const mayAt = new Map<StackArgsCall<C>, Set<number>>();
|
|
213
|
+
const mustAt = new Map<StackArgsCall<C>, Set<number>>();
|
|
214
|
+
const storedAt = new Map<StackArgsCall<C>, Set<number>>();
|
|
215
|
+
for (let changed = true; changed;) {
|
|
216
|
+
changed = false;
|
|
217
|
+
for (let b = 0; b < asmBlocks.length; b++) {
|
|
218
|
+
if (!live.has(b)) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const livePreds = preds[b].filter((q) => live.has(q));
|
|
222
|
+
const may = new Set<number>();
|
|
223
|
+
const stored = new Set<number>();
|
|
224
|
+
for (const q of livePreds) {
|
|
225
|
+
for (const off of mayOut[q]) {
|
|
226
|
+
may.add(off);
|
|
227
|
+
}
|
|
228
|
+
for (const off of storedOut[q]) {
|
|
229
|
+
stored.add(off);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const must = new Set(
|
|
233
|
+
b === 0 || livePreds.length === 0 ? [] : everySlot.filter((o) => livePreds.every((q) => mustOut[q].has(o))),
|
|
234
|
+
);
|
|
235
|
+
for (const ev of asmBlocks[b].events) {
|
|
236
|
+
if (isCallEvent(ev)) {
|
|
237
|
+
mayAt.set(ev, new Set(may));
|
|
238
|
+
mustAt.set(ev, new Set(must));
|
|
239
|
+
storedAt.set(ev, new Set(stored));
|
|
240
|
+
for (const o of ev.declared ?? []) {
|
|
241
|
+
may.delete(o);
|
|
242
|
+
must.delete(o);
|
|
243
|
+
}
|
|
244
|
+
} else if (ev.kind === 'store') {
|
|
245
|
+
may.add(ev.off);
|
|
246
|
+
must.add(ev.off);
|
|
247
|
+
stored.add(ev.off);
|
|
248
|
+
} else {
|
|
249
|
+
may.delete(ev.off);
|
|
250
|
+
must.delete(ev.off);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const grow = (out: Array<Set<number>>, cur: Set<number>): void => {
|
|
254
|
+
if (cur.size !== out[b].size || [...cur].some((o) => !out[b].has(o))) {
|
|
255
|
+
out[b] = cur;
|
|
256
|
+
changed = true;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
grow(mayOut, may);
|
|
260
|
+
grow(mustOut, must);
|
|
261
|
+
grow(storedOut, stored);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// CONTIGUITY. An argument block is contiguous from zero, so a store at [sp,#4] can be argument
|
|
266
|
+
// 6 of a call only if argument 5 at [sp,#0] is supplied on a path to that same call. A pending
|
|
267
|
+
// store whose lower slots are nowhere supplied is provably not an argument block, and refusing
|
|
268
|
+
// it is a false alarm — the exact false alarm that blocked the commonest real shape, a value
|
|
269
|
+
// spilled at [sp,#4] and kept live across calls (kleod's ProcessInputAndUpdateEntities stores
|
|
270
|
+
// its `sp4` local and calls m4aSongNumStart 80 lines later, with offset 0 never stored in the
|
|
271
|
+
// whole function). The calibration: a conforming caller stores EVERY argument slot of a call it
|
|
272
|
+
// makes, so "slot 0 unsupplied" rules out "slot 4 is an argument". Hand-written asm could skip
|
|
273
|
+
// storing an argument the callee never reads; agbcc cannot (no interprocedural dead-argument
|
|
274
|
+
// elimination). That is the producer assumption both code-reading conditions make.
|
|
275
|
+
const prefixStored = (k: number, st: ReadonlySet<number>): boolean => {
|
|
276
|
+
for (let j = 0; j < k; j += 4) {
|
|
277
|
+
if (!st.has(j)) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
};
|
|
283
|
+
const asc = (s: Iterable<number>) => [...s].sort((x, y) => x - y);
|
|
284
|
+
|
|
285
|
+
// THE LICENCE, call by call. Every declared block must match what the code staged for it,
|
|
286
|
+
// exactly — and every refusal after this one then runs knowing which words are spoken for.
|
|
287
|
+
const blocks = new Map<C, readonly number[]>();
|
|
288
|
+
let area = 0;
|
|
289
|
+
for (const ev of calls) {
|
|
290
|
+
const offs = ev.declared;
|
|
291
|
+
if (offs === null) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const may = mayAt.get(ev) ?? new Set<number>();
|
|
295
|
+
const must = mustAt.get(ev) ?? new Set<number>();
|
|
296
|
+
const missing = offs.filter((o) => !must.has(o));
|
|
297
|
+
const extra = asc(may).filter((o) => !offs.includes(o));
|
|
298
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
299
|
+
return refuse(
|
|
300
|
+
`callee \`${ev.callee}\` is declared with ${arityOf(offs)} arguments, so its outgoing stack-argument block is ${say(offs)} — but ` +
|
|
301
|
+
(missing.length > 0
|
|
302
|
+
? `${say(missing)} is not stored on every path to the call`
|
|
303
|
+
: `${say(extra)} also reaches the call unread, so the declaration does not account for every word staged here`),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
blocks.set(ev.call, offs);
|
|
307
|
+
area = Math.max(area, 4 * offs.length);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const licensed = new Set<number>();
|
|
311
|
+
for (const offs of blocks.values()) {
|
|
312
|
+
for (const o of offs) {
|
|
313
|
+
licensed.add(o);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// The two whole-function facts the next two refusals read: every offset live code stores, and
|
|
317
|
+
// every offset live code loads back. A reload in dead code is not evidence that anything reads
|
|
318
|
+
// the slot back, so it does not count.
|
|
319
|
+
const reloaded = new Set<number>();
|
|
320
|
+
const storedAnywhere = new Set<number>();
|
|
321
|
+
for (const b of live) {
|
|
322
|
+
for (const ev of asmBlocks[b].events) {
|
|
323
|
+
if (ev.kind !== 'call') {
|
|
324
|
+
(ev.kind === 'store' ? storedAnywhere : reloaded).add(ev.off);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// A LICENSED WORD THIS FUNCTION ALSO LOADS. The area belongs to the CALLEE — which may assign
|
|
329
|
+
// to a stack parameter — so after the `bl` the word holds whatever the callee left, and an
|
|
330
|
+
// `ldr` off that offset reads a GAP. The dataflow above cannot catch it: the call consumes the
|
|
331
|
+
// offset, so the load meets an empty pending set and clears nothing. Left alone, the ordinary
|
|
332
|
+
// `ldr` arm answers it from the staging store's reaching def and renders the value the CALLER
|
|
333
|
+
// passed in — a plausible identifier standing in for an unknown, which is the `unksp0` failure
|
|
334
|
+
// mode this whole analysis exists to avoid. It is the same contradiction
|
|
335
|
+
// `capturedWholeFrame` refuses: one word carrying two incompatible claims, with
|
|
336
|
+
// nothing here able to decide between them. A load BEFORE the staging store is the same verdict
|
|
337
|
+
// for the same reason — under ACCUMULATE_OUTGOING_ARGS the locals sit ABOVE the area, so a
|
|
338
|
+
// caller-side load of an argument offset contradicts the layout the licence rests on.
|
|
339
|
+
for (const off of asc(licensed)) {
|
|
340
|
+
if (reloaded.has(off)) {
|
|
341
|
+
return refuse(
|
|
342
|
+
`[sp,#${off}] is an outgoing stack-argument slot of one of this function's calls, but this function also LOADS it — ` +
|
|
343
|
+
'the callee owns that word across the call, so nothing here can say what the load reads',
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// (a) — a store never reloaded ANYWHERE, with its lower slots supplied, is an argument's
|
|
348
|
+
// signature: an outgoing argument is read by the CALLEE, never by the caller. Its real theorem
|
|
349
|
+
// is the layout one (the area sits at the BOTTOM of localArea, disjoint from the locals, so no
|
|
350
|
+
// local load can land on an argument offset), which is why it is a whole-function question.
|
|
351
|
+
// A LICENSED offset is excluded: its never being reloaded is explained by the call that takes it.
|
|
352
|
+
for (const off of asc(storedAnywhere)) {
|
|
353
|
+
if (!licensed.has(off) && !reloaded.has(off) && prefixStored(off, storedAnywhere)) {
|
|
354
|
+
return refuse(
|
|
355
|
+
`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`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// (b) — no slot store may reach a `bl` unread ALONG A PATH. For a call the licence covered,
|
|
360
|
+
// the equality above already answered this; what is left are the calls no declaration sizes,
|
|
361
|
+
// where a plausible argument block reaching one unread is an argument this analysis cannot
|
|
362
|
+
// size, and the answer is the decline.
|
|
363
|
+
//
|
|
364
|
+
// THE OFFSET THIS NAMES IS THE LOWEST PENDING ONE, because `may` is reported through `asc`. The
|
|
365
|
+
// verdict does not depend on it — any one of them refuses — but the message is what a gap
|
|
366
|
+
// histogram keys on, and scanning a Set in insertion order named whichever offset the code stored
|
|
367
|
+
// FIRST instead (pokeemerald's `PickLotteryCornerTicket` stores [sp,#4] before [sp,#0]).
|
|
368
|
+
for (const ev of calls) {
|
|
369
|
+
if (ev.declared !== null) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const may = mayAt.get(ev) ?? new Set<number>();
|
|
373
|
+
const stored = storedAt.get(ev) ?? new Set<number>();
|
|
374
|
+
for (const k of asc(may)) {
|
|
375
|
+
if (prefixStored(k, stored)) {
|
|
376
|
+
return refuse(
|
|
377
|
+
`the store to [sp,#${k}] reaches \`bl ${ev.callee}\` unread with its lower slots supplied — it may be that call's outgoing stack argument`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// NOTHING LEFT OVER. The exclusion above is per OFFSET, so it would also excuse a store to a
|
|
383
|
+
// licensed offset that no call ever reads — a write into the argument area that is still pending
|
|
384
|
+
// where the function ENDS. That is not an argument and not a local anyone reloads, so nothing
|
|
385
|
+
// here can say what it is: decline rather than let it drop as a dead def.
|
|
386
|
+
//
|
|
387
|
+
// "WHERE THE FUNCTION ENDS" IS A LIVE BLOCK WITH NO LIVE SUCCESSOR, read off `preds`, not a
|
|
388
|
+
// terminator the caller classified. Under Thumb the two coincide — a computed PC write has no
|
|
389
|
+
// static successor and the frontend throws on one long before here — but asking the CFG costs
|
|
390
|
+
// nothing and removes a fact the caller could get wrong.
|
|
391
|
+
//
|
|
392
|
+
// WHAT IT STILL DOES NOT REACH, stated because the escape is real: a store into the licensed area
|
|
393
|
+
// on a path that never ends. Every block of an infinite loop has a live successor, so the word
|
|
394
|
+
// stays pending forever and nothing here refuses it. Measured rather than assumed — a `str` into
|
|
395
|
+
// the area after a licensed `bl`, falling into `.L1: b .L1`, passes this analysis and then
|
|
396
|
+
// declines at L2: "unrecovered back-edge into block #1 (loop-recovery declined this shape)". So
|
|
397
|
+
// the loud answer is preserved by a DIFFERENT family's refusal, not by this one. Closing it needs
|
|
398
|
+
// a backward "can this word still be consumed?" pass, which no row in the corpus asks for.
|
|
399
|
+
const hasLiveSucc = asmBlocks.map(() => false);
|
|
400
|
+
for (let b = 0; b < asmBlocks.length; b++) {
|
|
401
|
+
if (live.has(b)) {
|
|
402
|
+
for (const q of preds[b]) {
|
|
403
|
+
hasLiveSucc[q] = true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (let b = 0; b < asmBlocks.length; b++) {
|
|
408
|
+
if (!live.has(b) || hasLiveSucc[b]) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const off of asc(mayOut[b])) {
|
|
412
|
+
if (licensed.has(off)) {
|
|
413
|
+
return refuse(
|
|
414
|
+
`the store to [sp,#${off}] is inside the outgoing stack-argument area but is still staged where this function ends — no call it makes accounts for it`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { blocker: null, blocks, area };
|
|
420
|
+
}
|